You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
334 lines
17 KiB
334 lines
17 KiB
package console
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"yunyan/comm"
|
|
"yunyan/lego/sys/log"
|
|
"yunyan/lego/sys/postgres"
|
|
"yunyan/pb"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// ============================ 第三方服务商模板域 (svctemplate) ============================
|
|
//
|
|
// 数据驱动模板:管理员在「第三方服务配置」新增服务时选一个模板,自动带出字段(可再增删)。
|
|
// 模板存 console 主库(postgres.GetSys()),启动时幂等 seed 内置常见服务商(见 seedSvcTemplates)。
|
|
// 模板字段只作 schema 预设,不存真实密钥、不加密。
|
|
|
|
// ============================== 模板 CRUD ==============================
|
|
|
|
// getSvcTemplates 列出全部服务商模板;category>0 时按类别过滤,按 (category, sort, id) 排序返回。
|
|
func (this *serverComp) getSvcTemplates(c *gin.Context) {
|
|
var req struct {
|
|
Category int32 `json:"category"`
|
|
}
|
|
_ = c.ShouldBindJSON(&req)
|
|
items := make([]*ThirdSvcTemplate, 0)
|
|
var err error
|
|
if req.Category > 0 {
|
|
err = postgres.Find(comm.TableThirdSvcTemplate, &items, "category=?", req.Category)
|
|
} else {
|
|
err = postgres.Find(comm.TableThirdSvcTemplate, &items, "")
|
|
}
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
if items[i].Category != items[j].Category {
|
|
return items[i].Category < items[j].Category
|
|
}
|
|
if items[i].Sort != items[j].Sort {
|
|
return items[i].Sort < items[j].Sort
|
|
}
|
|
return items[i].Id < items[j].Id
|
|
})
|
|
writeOK(c, gin.H{"items": items})
|
|
}
|
|
|
|
// addSvcTemplate 新增自定义服务商模板(Builtin=false)。
|
|
func (this *serverComp) addSvcTemplate(c *gin.Context) {
|
|
var m ThirdSvcTemplate
|
|
if err := c.ShouldBindJSON(&m); err != nil {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
|
|
return
|
|
}
|
|
if strings.TrimSpace(m.Id) == "" || strings.TrimSpace(m.Name) == "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "模板ID 和 名称 必填")
|
|
return
|
|
}
|
|
exist := &ThirdSvcTemplate{}
|
|
if err := postgres.FindOne(comm.TableThirdSvcTemplate, exist, "id=?", m.Id); err == nil {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "模板ID 已存在: "+m.Id)
|
|
return
|
|
}
|
|
m.Builtin = false
|
|
now := time.Now().UnixMilli()
|
|
m.Createtime = now
|
|
m.Updatetime = now
|
|
if err := postgres.Insert(comm.TableThirdSvcTemplate, &m); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
writeOK(c, &m)
|
|
}
|
|
|
|
// updateSvcTemplate 更新服务商模板(按 id)。保留旧记录的 Builtin/Createtime。
|
|
func (this *serverComp) updateSvcTemplate(c *gin.Context) {
|
|
var m ThirdSvcTemplate
|
|
if err := c.ShouldBindJSON(&m); err != nil {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
|
|
return
|
|
}
|
|
if strings.TrimSpace(m.Id) == "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "模板ID 必填")
|
|
return
|
|
}
|
|
old := &ThirdSvcTemplate{}
|
|
if err := postgres.FindOne(comm.TableThirdSvcTemplate, old, "id=?", m.Id); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, "模板不存在: "+m.Id)
|
|
return
|
|
}
|
|
m.Builtin = old.Builtin
|
|
m.Createtime = old.Createtime
|
|
m.Updatetime = time.Now().UnixMilli()
|
|
if err := postgres.Save(comm.TableThirdSvcTemplate, &m); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
writeOK(c, &m)
|
|
}
|
|
|
|
// delSvcTemplate 删除服务商模板(按 id)。删除 builtin 允许,但下次启动会被 seed 重新补回。
|
|
func (this *serverComp) delSvcTemplate(c *gin.Context) {
|
|
var req struct {
|
|
Id string `json:"id"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Id) == "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填")
|
|
return
|
|
}
|
|
if err := postgres.Delete(comm.TableThirdSvcTemplate, "id=?", req.Id); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
writeOK(c, gin.H{"id": req.Id})
|
|
}
|
|
|
|
// ============================== 内置模板 seed ==============================
|
|
|
|
// 服务类别常量(与 admin 前端 SCAT / serviceconfig.vue 一致)。
|
|
// 一个服务实例的 categories 是逗号分隔多值,可同属多类(如 GPT-4o 同属「文本大模型」+「多媒体大模型」)。
|
|
const (
|
|
svcCatSTT int32 = 1 // 语音识别(实时/短语音,通话翻译在用)
|
|
svcCatTTS int32 = 2 // 语音合成
|
|
svcCatMT int32 = 3 // 机器翻译
|
|
svcCatAST int32 = 4 // 端到端语音翻译
|
|
svcCatLLM int32 = 5 // 文本大模型
|
|
svcCatStorage int32 = 6 // 对象存储
|
|
svcCatSTS int32 = 7 // 端到端语音对话
|
|
svcCatASRFile int32 = 8 // 录音文件识别(会议记录离线转写,接口/凭据区别于实时 STT)
|
|
svcCatLLMVision int32 = 9 // 多媒体大模型(能识别图像,用于带图的会议总结)
|
|
svcCatMCP int32 = 10 // MCP 服务(模型上下文协议;url/type/tools 三字段,按区域 fork,全应用共享)
|
|
)
|
|
|
|
// sf 构造一个模板字段(sort 由调用顺序在 tpl 内自动填)。
|
|
func sf(key, desc string, encrypted bool) SvcField {
|
|
return SvcField{Key: key, Description: desc, Encrypted: encrypted}
|
|
}
|
|
|
|
// sfd 构造一个带默认值的模板字段(如回调相对路径——由代码路由决定,运营通常不用改)。
|
|
func sfd(key, desc string, encrypted bool, defValue string) SvcField {
|
|
return SvcField{Key: key, Description: desc, Encrypted: encrypted, DefValue: defValue}
|
|
}
|
|
|
|
// tpl 构造一个内置模板(Builtin=true,字段 sort 按传入顺序自动编号)。
|
|
func tpl(id, provider, name string, category, order int32, fields ...SvcField) ThirdSvcTemplate {
|
|
for i := range fields {
|
|
fields[i].Sort = int32(i)
|
|
}
|
|
return ThirdSvcTemplate{
|
|
Id: id, Provider: provider, Name: name,
|
|
Category: category, Sort: order, Fields: fields, Builtin: true,
|
|
}
|
|
}
|
|
|
|
// audioOutputCats 需要输出音频、须配置「支持语言 + 默认语言」的服务类别(TTS/AST/STS)。
|
|
var audioOutputCats = map[int32]bool{svcCatTTS: true, svcCatAST: true, svcCatSTS: true}
|
|
|
|
// asrLangCats 需要「支持语言(多选)」但不需要默认语言的识别类:录音文件识别。
|
|
// 语义是「该服务能识别哪些语种」,会议记录编排按它做选路过滤(源语言不支持则跳到下一个)。
|
|
var asrLangCats = map[int32]bool{svcCatASRFile: true}
|
|
|
|
// 录音文件识别全语种集(本系统 BCP-47 全集,与 admin useLangOptions.ts 一致)。
|
|
// 字节大模型 / Google / Azure 三家在此全集内均全量支持。
|
|
const asrLangsAll = "zh-CN,zh-TW,zh-HK,yue-HK,en-US,en-GB,ja-JP,ko-KR,id-ID,es-ES,es-MX,pt-BR,pt-PT,de-DE,fr-FR,it-IT,nl-NL,pl-PL,ru-RU,uk-UA,tr-TR,vi-VN,th-TH,ms-MY,fil-PH,ar-SA"
|
|
|
|
// asrFileDefaultLangs 各录音识别服务商官方支持语种默认值(2026-07 核对),seed 时预填 languages 字段,运营可再增删。
|
|
// 阿里 paraformer-v2 窄(官方仅中含粤/英/日/韩/德/法/俄);其余三家本全集内全支持。
|
|
var asrFileDefaultLangs = map[string]string{
|
|
"bytedance": asrLangsAll,
|
|
"google": asrLangsAll,
|
|
"azure": asrLangsAll,
|
|
"alibaba": "zh-CN,zh-TW,zh-HK,yue-HK,en-US,en-GB,ja-JP,ko-KR,de-DE,fr-FR,ru-RU", // paraformer-v2
|
|
}
|
|
|
|
// builtinSvcTemplates 内置服务商模板清单(参考 polychat-agent 的服务商与字段归纳,可后续增改)。
|
|
func builtinSvcTemplates() []ThirdSvcTemplate {
|
|
list := []ThirdSvcTemplate{
|
|
// STT 语音识别
|
|
tpl("stt_azure", "azure", "微软 Azure STT", svcCatSTT, 0,
|
|
sf("subscription_key", "订阅密钥", true), sf("region", "区域/终结点,如 eastus", false), sf("language", "语言代码,如 zh-CN", false)),
|
|
tpl("stt_openai", "openai", "OpenAI Whisper", svcCatSTT, 1,
|
|
sf("api_key", "API Key", true), sf("model", "模型,如 whisper-1", false), sf("language", "语言代码", false)),
|
|
tpl("stt_volcengine", "volcengine", "火山引擎 STT", svcCatSTT, 2,
|
|
sf("appid", "应用 AppID", false), sf("token", "访问 Token", true), sf("cluster", "集群标识", false)),
|
|
tpl("stt_alibaba", "alibaba", "阿里云 STT", svcCatSTT, 3,
|
|
sf("access_key_id", "AccessKey ID", true), sf("access_key_secret", "AccessKey Secret", true), sf("app_key", "项目 AppKey", false)),
|
|
|
|
// TTS 语音合成
|
|
tpl("tts_azure", "azure", "微软 Azure TTS", svcCatTTS, 0,
|
|
sf("subscription_key", "订阅密钥", true), sf("region", "区域/终结点,如 eastus", false), sf("voice_name", "音色,如 zh-CN-XiaoxiaoNeural", false), sf("rate", "语速,-50%~+50%", false), sf("pitch", "音调,-50Hz~+50Hz", false)),
|
|
tpl("tts_openai", "openai", "OpenAI TTS", svcCatTTS, 1,
|
|
sf("api_key", "API Key", true), sf("model", "模型,如 tts-1", false), sf("voice", "音色,如 alloy", false)),
|
|
tpl("tts_volcengine", "volcengine", "火山引擎 TTS", svcCatTTS, 2,
|
|
sf("appid", "应用 AppID", false), sf("token", "访问 Token", true), sf("cluster", "集群标识", false)),
|
|
tpl("tts_alibaba", "alibaba", "阿里云 TTS", svcCatTTS, 3,
|
|
sf("access_key_id", "AccessKey ID", true), sf("access_key_secret", "AccessKey Secret", true)),
|
|
tpl("tts_elevenlabs", "elevenlabs", "ElevenLabs TTS", svcCatTTS, 4,
|
|
sf("api_key", "API Key", true), sf("voice_id", "音色 ID", false)),
|
|
|
|
// MT 机器翻译
|
|
tpl("mt_azure", "azure", "微软 Azure 翻译", svcCatMT, 0,
|
|
sf("subscription_key", "订阅密钥", true), sf("region", "区域,如 eastasia", false)),
|
|
// ⚠️ 字段须对齐运行时 sys 客户端:Google 翻译走服务账号 JSON(sys/google/translate),
|
|
// 火山翻译走 AK/SK(sys/bytedance/translate)——不是 api_key。
|
|
tpl("mt_google", "google", "Google 翻译", svcCatMT, 1,
|
|
sf("json", "服务账号 JSON(内容或路径)", true)),
|
|
tpl("mt_doubao", "doubao", "豆包(火山)翻译", svcCatMT, 2,
|
|
sf("access_key", "火山 AccessKey", true), sf("secret_key", "火山 SecretKey", true), sf("region", "区域(可选,默认 cn-north-1)", false)),
|
|
tpl("mt_alibaba", "alibaba", "阿里云 翻译", svcCatMT, 3,
|
|
sf("access_key_id", "AccessKey ID", true), sf("access_key_secret", "AccessKey Secret", true)),
|
|
|
|
// AST 端到端语音翻译
|
|
tpl("ast_azure", "azure", "微软 Azure AST", svcCatAST, 0,
|
|
sf("subscription_key", "订阅密钥", true), sf("region", "区域,如 eastus", false)),
|
|
tpl("ast_volcengine", "volcengine", "火山引擎 AST", svcCatAST, 1,
|
|
sf("appid", "应用 AppID", false), sf("token", "访问 Token", true), sf("cluster", "集群标识", false)),
|
|
tpl("ast_alibaba", "alibaba", "阿里云 AST", svcCatAST, 2,
|
|
sf("access_key_id", "AccessKey ID", true), sf("access_key_secret", "AccessKey Secret", true)),
|
|
|
|
// LLM 文本大模型
|
|
tpl("llm_openai", "openai", "OpenAI", svcCatLLM, 0,
|
|
sf("api_key", "API Key", true), sf("model", "模型,如 gpt-4o", false), sf("base_url", "接口地址(可选)", false), sf("temperature", "生成温度 0~2", false), sf("max_tokens", "最大输出 Token", false)),
|
|
tpl("llm_doubao", "doubao", "豆包(火山引擎)", svcCatLLM, 1,
|
|
sf("api_key", "API Key", true), sf("endpoint_id", "推理接入点 ID", false), sf("base_url", "接口地址", false)),
|
|
tpl("llm_anthropic", "anthropic", "Anthropic Claude", svcCatLLM, 2,
|
|
sf("api_key", "API Key", true), sf("model", "模型,如 claude-sonnet-4", false)),
|
|
tpl("llm_deepseek", "deepseek", "DeepSeek", svcCatLLM, 3,
|
|
sf("api_key", "API Key", true), sf("model", "模型,如 deepseek-chat", false), sf("base_url", "接口地址", false)),
|
|
tpl("llm_qwen", "qwen", "通义千问(阿里)", svcCatLLM, 4,
|
|
sf("api_key", "API Key", true), sf("model", "模型,如 qwen-max", false)),
|
|
|
|
// 录音文件识别(会议记录离线转写;字段对齐 modules/echomeet 各 provider 的运行时凭据)
|
|
//
|
|
// ⚠️ 回调只存**相对路径**,不存完整 URL:一个服务(尤其全局默认的)会被所有应用共用,
|
|
// 而每个应用/部署的对外地址不同。完整回调地址由运行时拼:<该应用的对外地址> + callback_path。
|
|
// 默认值取自 echomeet 的反射路由(home 服务前缀 /api/home/ + <module>_<method小写>),
|
|
// 改 echomeet 回调方法名或网关前缀时要同步这里。Google/Azure 走轮询、无回调,故无此字段。
|
|
tpl("asrfile_bytedance", "bytedance", "字节跳动 录音识别大模型", svcCatASRFile, 0,
|
|
sf("appid", "应用 AppID", false), sf("token", "访问 Token", true),
|
|
sfd("callback_path", "转写回调路径(相对),运行时与应用对外地址拼成完整回调", false, "/api/home/echomeet_backcall")),
|
|
tpl("asrfile_alibaba", "alibaba", "阿里云 DashScope 录音识别", svcCatASRFile, 1,
|
|
sf("api_key", "DashScope ApiKey", true),
|
|
sfd("callback_path", "转写回调路径(相对),运行时与应用对外地址拼成完整回调", false, "/api/home/echomeet_alibackcall")),
|
|
tpl("asrfile_google", "google", "Google Cloud Speech", svcCatASRFile, 2,
|
|
sf("json", "服务账号 JSON(内容或路径)", true)),
|
|
tpl("asrfile_azure", "azure", "微软 Azure 语音", svcCatASRFile, 3,
|
|
sf("subscription_key", "订阅密钥", true), sf("region", "区域,如 eastasia", false)),
|
|
|
|
// 多媒体大模型(能识别图像,用于带图会议总结;一个全能模型服务可同时勾选「文本大模型」)
|
|
tpl("llmv_openai", "openai", "OpenAI(GPT-4o 视觉)", svcCatLLMVision, 0,
|
|
sf("api_key", "API Key", true), sf("model", "视觉模型,如 gpt-4o", false), sf("base_url", "接口地址(可选)", false)),
|
|
tpl("llmv_qwen", "qwen", "通义千问 VL(阿里)", svcCatLLMVision, 1,
|
|
sf("api_key", "API Key", true), sf("model", "视觉模型,如 qwen-vl-max", false), sf("base_url", "接口地址(可选)", false)),
|
|
tpl("llmv_gemini", "google", "Gemini(Google)", svcCatLLMVision, 2,
|
|
sf("api_key", "API Key", true), sf("model", "视觉模型,如 gemini-2.5-flash", false), sf("endpoint", "自定义 Endpoint(可选)", false)),
|
|
tpl("llmv_doubao", "doubao", "豆包视觉(火山引擎)", svcCatLLMVision, 3,
|
|
sf("api_key", "API Key", true), sf("endpoint_id", "推理接入点 ID", false), sf("base_url", "接口地址", false)),
|
|
|
|
// STS 端到端语音对话
|
|
tpl("sts_volcengine", "volcengine", "火山引擎 STS", svcCatSTS, 0,
|
|
sf("appid", "应用 AppID", false), sf("token", "访问 Token", true), sf("cluster", "集群标识", false)),
|
|
tpl("sts_doubao", "doubao", "豆包(火山)STS", svcCatSTS, 1,
|
|
sf("appid", "应用 AppID", false), sf("token", "访问 Token", true), sf("cluster", "集群标识", false)),
|
|
tpl("sts_azure", "azure", "微软 Azure STS", svcCatSTS, 2,
|
|
sf("subscription_key", "订阅密钥", true), sf("region", "区域,如 eastus", false)),
|
|
tpl("sts_google", "google", "Google STS", svcCatSTS, 3,
|
|
sf("api_key", "API Key", true)),
|
|
|
|
// 存储 对象存储
|
|
tpl("storage_aliyun_oss", "aliyun_oss", "阿里云 OSS", svcCatStorage, 0,
|
|
sf("access_key_id", "AccessKey ID", true), sf("access_key_secret", "AccessKey Secret", true), sf("bucket", "存储桶", false), sf("endpoint", "地域节点 Endpoint", false)),
|
|
tpl("storage_tencent_cos", "tencent_cos", "腾讯云 COS", svcCatStorage, 1,
|
|
sf("secret_id", "SecretId", true), sf("secret_key", "SecretKey", true), sf("bucket", "存储桶", false), sf("region", "地域,如 ap-guangzhou", false)),
|
|
|
|
// MCP 服务(模型上下文协议)。字段均非加密:url 服务地址,type 传输类型(0=HTTP/1=SSE),tools 启用工具白名单。
|
|
// 各区域的实际 url/tools 通过「区域覆盖分叉」维护,运行时按客户端区域解析。
|
|
tpl("mcp_default", "custom", "MCP 服务", svcCatMCP, 0,
|
|
sf("url", "服务地址,如 https://...", false), sfd("type", "传输类型:0=HTTP,1=SSE", false, "0"), sf("tools", "启用工具(逗号分隔,空=全部)", false)),
|
|
}
|
|
// 前置语言字段(languages 存逗号分隔 BCP-47 如 zh-CN,en-US;default_lang 存单个码):
|
|
// - 音频输出类(TTS/AST/STS):支持语言 + 默认语言;
|
|
// - 录音文件识别(ASRFile):只需「支持语言」,用于会议记录选路过滤,无默认语言概念。
|
|
// 不绑定单一语言,音色/语速等仍由各服务商自有字段决定,运行时按请求语言选择。
|
|
for i := range list {
|
|
cat := list[i].Category
|
|
var langFields []SvcField
|
|
switch {
|
|
case audioOutputCats[cat]:
|
|
langFields = []SvcField{
|
|
{Key: "languages", Description: "支持的语言(可多选)", Type: "langs"},
|
|
{Key: "default_lang", Description: "默认语言", Type: "lang"},
|
|
}
|
|
case asrLangCats[cat]:
|
|
langFields = []SvcField{
|
|
{Key: "languages", Description: "支持识别的语言(可多选,选路按此过滤)", Type: "langs", DefValue: asrFileDefaultLangs[list[i].Provider]},
|
|
}
|
|
default:
|
|
continue
|
|
}
|
|
list[i].Fields = append(langFields, list[i].Fields...)
|
|
for j := range list[i].Fields {
|
|
list[i].Fields[j].Sort = int32(j)
|
|
}
|
|
}
|
|
return list
|
|
}
|
|
|
|
// seedSvcTemplates 幂等 seed 内置模板:仅当同 id 不存在时插入,不覆盖用户已改动的记录。
|
|
// 在 ensureConfigTables 之后调用(启动一次)。
|
|
func seedSvcTemplates() {
|
|
now := time.Now().UnixMilli()
|
|
inserted := 0
|
|
for _, t := range builtinSvcTemplates() {
|
|
exist := &ThirdSvcTemplate{}
|
|
if err := postgres.FindOne(comm.TableThirdSvcTemplate, exist, "id=?", t.Id); err == nil {
|
|
continue // 已存在,跳过(保留用户改动)
|
|
}
|
|
t.Createtime = now
|
|
t.Updatetime = now
|
|
if err := postgres.Insert(comm.TableThirdSvcTemplate, &t); err != nil {
|
|
log.Warn("console: 内置服务商模板 seed 失败", log.Field{Key: "id", Value: t.Id}, log.Field{Key: "err", Value: err.Error()})
|
|
continue
|
|
}
|
|
inserted++
|
|
}
|
|
if inserted > 0 {
|
|
log.Info("console: 内置服务商模板 seed 完成", log.Field{Key: "inserted", Value: inserted})
|
|
}
|
|
}
|
|
|