diff --git a/apps/admin/app/components/McpToolPicker.vue b/apps/admin/app/components/McpToolPicker.vue new file mode 100644 index 00000000..bcca13e3 --- /dev/null +++ b/apps/admin/app/components/McpToolPicker.vue @@ -0,0 +1,156 @@ + + + + + + diff --git a/apps/admin/app/pages/agents.vue b/apps/admin/app/pages/agents.vue index ee5bf918..5e033799 100644 --- a/apps/admin/app/pages/agents.vue +++ b/apps/admin/app/pages/agents.vue @@ -92,7 +92,11 @@
+

⚠ {{ slotCredWarn }}

候选 = 本应用已启用的服务 + 「全局兜底」的服务(标注了的那些,来自全局默认作用域)。 引用全局服务是允许的:应用没单独配时本来就走它。其它应用的服务不会出现在这里。 @@ -397,27 +402,36 @@

MCP 服务绑定(给 Agent 挂工具)

当前作用域还没有已启用的 MCP 服务,先去「第三方服务配置 → MCP 服务」添加。

@@ -449,6 +463,7 @@ import { useApi } from '~/composables/useApi' import { useToast } from '~/composables/useToast' import { LANG_OPTIONS } from '~/composables/useLangOptions' import LangSelect from '~/components/LangSelect.vue' +import McpToolPicker from '~/components/McpToolPicker.vue' definePageMeta({ title: 'Agent 配置' }) @@ -494,6 +509,7 @@ interface Agent { default_lang: string voice_map: AgentVoice[] mcp_svc_ids: string[] + mcp_tools: Record variables: AgentVariable[] sort: number } @@ -574,6 +590,9 @@ const scopeApps = ref([]) const agents = ref([]) const allSvcs = ref([]) // 当前作用域(应用/全局)自己的第三方服务 const globalSvcs = ref([]) // 全局默认作用域的服务:应用作用域下作为兜底候选 +// svc_id → 一个值都没有的凭据字段(后端 getsvcconfigs 算好:基础行与所有区域分叉都空才算缺)。 +// 引用了这种服务的 agent,配置下发到客户端就是一串空字符串,运行时必然鉴权失败。 +const credMissing = ref>({}) const ttsVoices = ref([]) const keyword = ref('') const filterType = ref('') @@ -588,7 +607,7 @@ function emptyForm(): Agent { app_name: '', id: '', name: '', type: 'chat', description: '', avatar_url: '', enable: true, stt_svc_id: '', llm_svc_id: '', tts_svc_id: '', mt_svc_id: '', sts_svc_id: '', ast_svc_id: '', system_prompt: '', gender: 0, greeting_enabled: false, greeting_text: '', - support_langs: '', default_lang: '', voice_map: [], mcp_svc_ids: [], variables: [], sort: 0, + support_langs: '', default_lang: '', voice_map: [], mcp_svc_ids: [], mcp_tools: {}, variables: [], sort: 0, } } const form = reactive(emptyForm()) @@ -625,6 +644,15 @@ const grouped = computed(() => { } return groups }) +// 卡片上 MCP 数字的悬浮说明:哪些服务、各自收了几个工具 +function mcpStatTitle(a: Agent): string { + const ids = a.mcp_svc_ids ?? [] + if (!ids.length) return '未绑定 MCP 服务' + return ids.map((id) => { + const n = (a.mcp_tools ?? {})[id]?.length ?? 0 + return `${svcName(id)}:${n ? n + ' 个工具' : '全部工具'}` + }).join('\n') +} function langCount(a: Agent): number { return (a.support_langs || '').split(',').map((x) => x.trim()).filter(Boolean).length } @@ -657,18 +685,22 @@ async function loadScopeApps() { // 只是不能引用「别的应用」的服务——那些本就不在这两次查询的结果里。 async function loadSvcs() { const app = scopeApp.value + type SvcResp = { items: SvcConfig[]; cred_missing?: Record } const [own, global] = await Promise.all([ - webApi<{ items: SvcConfig[] }>('getsvcconfigs', { app_name: app }).catch(() => null), - app ? webApi<{ items: SvcConfig[] }>('getsvcconfigs', { app_name: '' }).catch(() => null) : Promise.resolve(null), + webApi('getsvcconfigs', { app_name: app }).catch(() => null), + app ? webApi('getsvcconfigs', { app_name: '' }).catch(() => null) : Promise.resolve(null), ]) allSvcs.value = own?.items ?? [] globalSvcs.value = global?.items ?? [] + // 应用内同 id 的服务覆盖全局,缺凭据判定也按这个顺序:先铺全局,再让应用作用域的结论盖上去 + credMissing.value = { ...(global?.cred_missing ?? {}), ...(own?.cred_missing ?? {}) } } async function loadAgents() { try { const d = await webApi<{ items: Agent[] }>('getagents', { app_name: scopeApp.value }) agents.value = (d?.items ?? []).map((a) => ({ - ...a, voice_map: a.voice_map ?? [], mcp_svc_ids: a.mcp_svc_ids ?? [], variables: a.variables ?? [], + ...a, voice_map: a.voice_map ?? [], mcp_svc_ids: a.mcp_svc_ids ?? [], + mcp_tools: a.mcp_tools ?? {}, variables: a.variables ?? [], })) } catch (e: any) { error(e?.message ?? '加载失败') @@ -748,7 +780,20 @@ function addVoice() { const mcpSvcs = computed(() => svcsByCats([CAT_MCP])) function toggleMcp(id: string) { const i = form.mcp_svc_ids.indexOf(id) - i >= 0 ? form.mcp_svc_ids.splice(i, 1) : form.mcp_svc_ids.push(id) + if (i >= 0) { + form.mcp_svc_ids.splice(i, 1) + delete form.mcp_tools[id] // 解绑就把工具白名单一起丢掉,免得重新绑上时冒出上次的残留 + } else { + form.mcp_svc_ids.push(id) + } +} +// 某个 MCP 服务在本 agent 下允许的工具;空数组=不设白名单(该服务的工具全放开) +function mcpToolsOf(id: string): string[] { + return form.mcp_tools[id] ?? [] +} +function setMcpTools(id: string, tools: string[]) { + if (tools.length) form.mcp_tools[id] = tools + else delete form.mcp_tools[id] } // ===== 变量 ===== @@ -794,6 +839,10 @@ function svcsByCats(cats: number[]): SvcConfig[] { function isVisionSvc(s: SvcConfig): boolean { return svcCats(s).includes(CAT_LLM_VISION) } +// 该服务的凭据是不是一个都没配(配了才有的下拉/胶囊红标) +function credMiss(id?: string): string[] { + return (id && credMissing.value[id]) || [] +} function svcName(id?: string): string { if (!id) return '未配置' const s = svcPool.value.find((x) => x.id === id) @@ -838,6 +887,18 @@ function tabBadge(key: string): string { if (key === 'svc') return form.voice_map.length ? String(form.voice_map.length) : '' return '' } +// 服务编排页签的凭据提示:选中的服务里有「凭据一个都没配」的,直接点名—— +// 这种配置存得下、下发得出去,但客户端拿到的是空字符串,跑起来必然鉴权失败。 +const slotCredWarn = computed(() => { + const bad = slotsOfType(form.type) + .map((s) => ({ slot: s.slot, id: ((form as any)[s.key] || '').trim() })) + .filter((x) => credMiss(x.id).length) + if (!bad.length) return '' + return bad + .map((x) => `${x.slot} 选的「${svcName(x.id)}」还没配凭据(${credMiss(x.id).join('、')})`) + .join(';') + ' —— 去「第三方服务配置」补齐,否则客户端拿到的是空值' +}) + // 保存前的缺项提示:直接显示在底部,省得点了保存才知道哪没填 const missingHint = computed(() => { if (!form.name.trim()) return '还差:名称' @@ -849,6 +910,7 @@ function openModal(a: Agent | null, editing: boolean) { Object.assign(form, emptyForm(), a ?? {}, { voice_map: (a?.voice_map ?? []).map((v) => ({ ...v })), mcp_svc_ids: [...(a?.mcp_svc_ids ?? [])], + mcp_tools: JSON.parse(JSON.stringify(a?.mcp_tools ?? {})), variables: (a?.variables ?? []).map((v) => ({ ...v })), }) isEditing.value = editing @@ -892,6 +954,9 @@ function buildPayload(a: Agent): Agent { } payload.voice_map = (a.voice_map ?? []).filter((v) => v.language && v.voice) payload.mcp_svc_ids = (a.mcp_svc_ids ?? []).filter(Boolean) + payload.mcp_tools = Object.fromEntries( + payload.mcp_svc_ids.filter((id) => (a.mcp_tools ?? {})[id]?.length).map((id) => [id, a.mcp_tools[id]]), + ) payload.variables = (a.variables ?? []).filter((v) => (v.key || '').trim()) return payload } @@ -1017,6 +1082,10 @@ onMounted(async () => { } .svc-pill b { font-size: 9.5px; font-weight: 700; letter-spacing: .03em; color: var(--accent); } .svc-pill.miss { color: #dc2626; background: #fef2f2; border-color: #fecaca; } +/* 服务选了,但它的凭据一个都没配——下发到客户端就是空字符串,跟没选一样跑不起来 */ +.svc-pill.nocred { color: #b45309; background: #fffbeb; border-color: #fde68a; } +.svc-pill.nocred b { color: #b45309; } +.ed-warn { font-size: 11.5px; color: #b45309; background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 6px 9px; margin-top: 8px; line-height: 1.6; } .pipe-arrow { color: #cbd5e1; font-size: 13px; } .card-stats { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; font-size: 10.5px; color: #94a3b8; } @@ -1090,6 +1159,14 @@ onMounted(async () => { .var-head, .var-row { display: grid; grid-template-columns: 1.1fr 1fr 1.2fr 1.2fr 52px; gap: 8px; align-items: center; } .var-head span { font-size: 10px; color: #cbd5e1; letter-spacing: .03em; } +.mcp-list { display: flex; flex-direction: column; gap: 10px; } +.mcp-card { border: 1.5px solid #e2e8f0; border-radius: 12px; overflow: hidden; transition: all .15s; } +.mcp-card:hover { border-color: #c7d2fe; } +.mcp-card.on { border-color: #6366f1; } +.mcp-card-head { display: flex; align-items: center; gap: 9px; padding: 10px 12px; font-size: 12.5px; color: #475569; cursor: pointer; } +.mcp-card.on .mcp-card-head { background: #eef2ff; color: #4338ca; } +.mcp-count { font-size: 10.5px; color: #94a3b8; flex-shrink: 0; } +.mcp-card-body { padding: 0 12px 12px; } .mcp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 8px; } .mcp-item { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 9px 11px; border: 1.5px solid #e2e8f0; border-radius: 10px; font-size: 12.5px; color: #475569; cursor: pointer; transition: all .15s; } .mcp-item:hover { border-color: #c7d2fe; background: #f8fafc; } diff --git a/apps/admin/app/pages/serviceconfig.vue b/apps/admin/app/pages/serviceconfig.vue index b46b116b..c9bc4cfa 100644 --- a/apps/admin/app/pages/serviceconfig.vue +++ b/apps/admin/app/pages/serviceconfig.vue @@ -58,6 +58,11 @@ {{ s.provider }} {{ (s.fields || []).length }} 字段 🔒{{ encCount(s) }} + ⚠ 缺凭据
{{ regionChip(s).text }} @@ -148,7 +153,10 @@ 音色本身是代码里的常量表(后端 comm/ttsvoice.go),实际用哪个在 Agent 里按语言+性别选,同组第一个是默认。

-
配置字段(全区域共用)
+
+ 配置字段(全区域共用) + +
✦ MCP 服务与其他第三方服务同表存储(svc_config):这里填的 url/type/tools 为全区域默认; 只在某些区域生效的地址,切到对应区域做分叉即可,卡片上会显示已分叉的区域数。 @@ -190,6 +198,19 @@
+ + +
+
+ 工具白名单 + 勾选结果写入上面的 tools 字段(逗号分隔,空=全部) +
+ +
@@ -341,6 +362,19 @@ + + +
+
+ 工具白名单 + 勾选结果写入上面的 tools 字段(逗号分隔,空=全部) +
+ +
@@ -407,6 +441,20 @@ + +
+
+ 工具白名单({{ curRegionLabel }}) + 写入本区域 tools 覆盖值;全清空=留空=继承默认 +
+ +
+
{{ curRegionLabel }} 专属字段
@@ -671,7 +719,7 @@ interface RegionExtraField { } const { webApi, consoleApi } = useApi() -const { success, error } = useToast() +const { success, error, warn } = useToast() // ===== 作用域:全局默认('') 或 某应用(app_name) ===== // 第三方服务/区域覆盖均按应用分离:应用有自己的就用应用的、没有回退全局。此选择器切换正在管理的作用域。 @@ -694,6 +742,81 @@ function scatInfo(v: number) { return SCAT.find((s) => s.v === v) || { short: '?', color: '#999', bg: '#eee', v: 0, label: '' } } // 当前编辑的是否 MCP 服务:新增向导看 editType,默认/区域编辑看当前服务 categories。 +// ── 按模板补齐字段 ── +// 内置模板后来新增的字段(如 llm_qwen 的 base_url)只会补进模板表,已经建好的服务实例不会自动长出来, +// 于是客户端拿到的这条服务就缺字段。这里按模板把缺的字段补进当前编辑中的字段表: +// 只加不改——已有字段的值/描述/加密标记一律保留,补进来的带模板默认值,确认后照常点保存生效。 +const fillFromTemplateTip = '内置模板新增的字段不会自动补到已建好的服务上,点这里按模板补齐(只加不改,需保存)' + +// 服务实例与模板的对应:优先按同 id(向导建的服务 id 就取自模板 id),否则按「服务商 + 类别」找。 +function matchTemplate(): SvcTemplate | undefined { + const byId = templates.value.find((t) => t.id === editForm.id) + if (byId) return byId + const cat = editType.value || parseCats(curSvc.value?.categories || '')[0] + return templates.value.find((t) => t.provider === editForm.provider && t.category === cat) +} + +function fillFieldsFromTemplate() { + const tpl = matchTemplate() + if (!tpl) { + warn('没找到对应的服务商模板(可到「服务商模板」里补一个,或手动 + 添加字段)') + return + } + const have = new Set(editFields.value.map((f) => f.key)) + const added: string[] = [] + for (const f of tpl.fields || []) { + if (have.has(f.key)) continue + editFields.value.push({ ...f, sort: editFields.value.length, _show: false }) + added.push(f.key) + } + if (!added.length) { + success(`字段已与模板「${tpl.name}」一致,无需补齐`) + return + } + success(`已按模板「${tpl.name}」补上 ${added.join('、')},确认无误后点保存`) +} + +// ── MCP 工具白名单 ── +// tools 字段存的是逗号分隔字符串,McpToolPicker 按数组来,这里做两个方向的转换。 +// url/type 取当前正在编辑的那份值:默认视图看 def_value,区域分叉看「覆盖值 || 继承的默认值」。 +function pickField(list: T[], key: string): T | undefined { + return list.find((f) => f.key === key) +} +function csvToArr(v?: string): string[] { + return (v || '').split(',').map((x) => x.trim()).filter(Boolean) +} + +const mcpEditUrl = computed(() => (pickField(editFields.value, 'url')?.def_value || '').trim()) +const mcpEditType = computed(() => (pickField(editFields.value, 'type')?.def_value || '0').trim()) +const mcpEditTools = computed({ + get: () => csvToArr(pickField(editFields.value, 'tools')?.def_value), + set: (v) => { + const f = pickField(editFields.value, 'tools') + if (f) f.def_value = v.join(',') + // 模板里本来就带 tools 字段;万一被删了就补一条回去,否则勾选结果无处可存 + else editFields.value.push({ key: 'tools', description: '启用工具(逗号分隔,空=全部)', encrypted: false, def_value: v.join(','), sort: editFields.value.length } as SvcField) + }, +}) + +const mcpRgnUrl = computed(() => { + const f = pickField(overrideFields.value, 'url') + return ((f?._input || f?.value) || '').trim() +}) +const mcpRgnType = computed(() => { + const f = pickField(overrideFields.value, 'type') + return ((f?._input || f?.value) || '0').trim() +}) +const mcpRgnTools = computed({ + get: () => { + const f = pickField(overrideFields.value, 'tools') + return csvToArr(f?._input || f?.value) + }, + set: (v) => { + const f = pickField(overrideFields.value, 'tools') + if (f) f._input = v.join(',') + }, +}) + function isMcpType(): boolean { if (editType.value === SCAT_MCP) return true return !!curSvc.value && parseCats(curSvc.value.categories).includes(SCAT_MCP) @@ -791,6 +914,9 @@ const allSvcs = ref([]) // svc_id → 已建区域分叉的区域枚举(后端 getsvcconfigs 一并返回)。卡片据此显示区域标识: // MCP 这类值全部落在区域分叉上的服务,不显示的话在默认视图里看着像「什么都没配」。 const svcRegions = ref>({}) +// svc_id → 一个值都没有的凭据字段(后端 getsvcconfigs 一并算好:基础行与所有区域分叉都空才算缺)。 +// 前端自己判不了——加密字段返回的是脱敏值,且凭据可能只配在某个区域分叉上。 +const credMissing = ref>({}) const curSvc = ref(null) const curRegion = ref(0) const svcModalVisible = ref(false) @@ -854,14 +980,16 @@ const templatesByCat = computed(() => { // ===== 加载服务列表 ===== async function loadSvcs() { try { - const data = await webApi<{ items: ThirdSvcConfig[]; regions?: Record }>('getsvcconfigs', { app_name: scopeApp.value }) + const data = await webApi<{ items: ThirdSvcConfig[]; regions?: Record; cred_missing?: Record }>('getsvcconfigs', { app_name: scopeApp.value }) allSvcs.value = data?.items ?? [] svcRegions.value = data?.regions ?? {} + credMissing.value = data?.cred_missing ?? {} backendReady.value = true } catch { backendReady.value = false allSvcs.value = [] svcRegions.value = {} + credMissing.value = {} return } if (curSvc.value) { @@ -1048,12 +1176,27 @@ function collectOverrideData() { return { overrides, disabled_keys, extra_fields } } +// 保存前的空字段检查:凭据为空由后端硬拦(启用中的服务存不下去),这里管的是其余字段—— +// base_url / languages / region 这类留空往往是有意的(走默认/不限),拦死会误伤,所以只列出来让人确认一次。 +// 之前 stt_azure 就是三个字段全空却一路存了下去,客户端拿到空串才发现。 +function confirmEmptyFields(): boolean { + const empty = editFields.value + .filter((f) => (f.key || '').trim() && !(f.def_value || '').trim()) + .map((f) => `${f.key}${f.description ? `(${f.description})` : ''}`) + if (!empty.length) return true + return window.confirm( + `以下字段没有填值:\n\n${empty.join('\n')}\n\n` + + '这些值会原样下发给客户端(空字符串)。确认它们本来就该留空吗?', + ) +} + // ===== 保存 ===== async function performSave(isNew: boolean): Promise { const isNew2 = isNew || !curSvc.value if (curRegion.value === 0) { const data = collectDefaultData(isNew2) if (!data.id || !data.name) { error('服务ID 和名称 必填'); return false } + if (!confirmEmptyFields()) return false const method = isNew2 ? 'addsvcconfig' : 'updatesvcconfig' try { const res = await webApi(method, data) @@ -1375,6 +1518,17 @@ onMounted(() => { .meta-row > .lock-badge { flex-shrink: 0; } +/* 凭据一个都没配的服务:卡片上直接标红,别等客户端拿到空字符串才发现 */ +.meta-row > .cred-badge { + flex-shrink: 0; + font-size: 10px; + font-weight: 700; + padding: 1px 6px; + border-radius: 5px; + background: #fef2f2; + color: #b91c1c; + border: 1px solid #fecaca; +} /* 标签下行:区域标识独占一行,长文案(如「本区域已分叉」)不会被上行挤掉 */ .region-row { display: flex; diff --git a/apps/proto/user/user_msg_v3.proto b/apps/proto/user/user_msg_v3.proto index 61434021..e69bbee4 100644 --- a/apps/proto/user/user_msg_v3.proto +++ b/apps/proto/user/user_msg_v3.proto @@ -8,6 +8,9 @@ import "db.proto"; // - 不再单独下发 mcps:MCP 已并入第三方服务配置(svc_config 的 MCP 类别),在 thirdsvcs 里, // 按 categories 含 "10" 识别;url/type/tools 在 fields 里。 // - 新增 meet:会议记录支持的语言与可选总结大模型,来自后台「会议记录服务」编排。 +// - agents 换成 AgentItemV3(与 user_getagents_v3 同一结构):带 type(chat|sts-chat) 与服务编排 +// (stt/llm/tts/sts_svc_id)、MCP 绑定、变量、已补全的音色表。v2 下发的旧 DBAgent 结构没有这些, +// 客户端拿到 agent 却不知道该走哪条链路、调哪个服务。 // 整体响应经网关 AES-CBC 加密(响应头 X-Encrypted:1),故 fields 里的凭据不再单独加密。 message UserGetAppConfigV3Req{ @@ -32,7 +35,7 @@ message MeetCapabilitiesItem{ message UserGetAppConfigV3Resp{ map env = 1; //@go_tags(`json:"env"`) 应用环境变量(app_config + 本区域 global_config) - repeated DBAgent agents = 2; //@go_tags(`json:"agents"`) 智能体列表 + repeated AgentItemV3 agents = 2; //@go_tags(`json:"agents"`) 智能体列表(结构同 user_getagents_v3,含 type 与服务编排) repeated DBProduct products = 3; //@go_tags(`json:"products"`) 产品列表 repeated ThirdSvcItem thirdsvcs = 4; //@go_tags(`json:"thirdsvcs"`) 第三方服务(含 MCP 类,按用户区域合并、凭据已解密为明文) MeetCapabilitiesItem meet = 5; //@go_tags(`json:"meet"`) 会议记录能力(语言 + 总结大模型) @@ -53,6 +56,14 @@ message AgentVariableItem{ string description = 4; //@go_tags(`json:"description"`) 备注 } +//agent 的 MCP 绑定项:绑了哪个 MCP 服务 + 这个 agent 允许它调用的工具。 +//同一个 agent 挂多个 MCP 时工具会互相干扰(同名工具、用不上的工具挤占模型注意力), +//故除服务自身的 tools 白名单外,再按 agent 收一次口。 +message AgentMcpBindingItem{ + string svc_id = 1; //@go_tags(`json:"svc_id"`) MCP 服务 id(详情在 getappconfig_v3 的 thirdsvcs 里) + repeated string tools = 2; //@go_tags(`json:"tools"`) 允许调用的工具名;空=该服务的工具全放开(仍受服务自身 tools 白名单约束) +} + message AgentItemV3{ string id = 1; //@go_tags(`json:"id"`) agent id string name = 2; //@go_tags(`json:"name"`) 名称 @@ -73,6 +84,7 @@ message AgentItemV3{ repeated string mcp_svc_ids = 17; //@go_tags(`json:"mcp_svc_ids"`) 绑定的 MCP 服务 id(详情在 getappconfig_v3 的 thirdsvcs 里) repeated AgentVariableItem variables = 18; //@go_tags(`json:"variables"`) 变量定义 int32 sort = 19; //@go_tags(`json:"sort"`) 排序 + repeated AgentMcpBindingItem mcps = 20; //@go_tags(`json:"mcps"`) MCP 绑定(含每个服务允许的工具);mcp_svc_ids 是它的 id 平铺,保留给老客户端 } message UserGetAgentsV3Resp{ diff --git a/apps/services/cmd/syncgoods/main.go b/apps/services/cmd/syncgoods/main.go new file mode 100644 index 00000000..75098530 --- /dev/null +++ b/apps/services/cmd/syncgoods/main.go @@ -0,0 +1,110 @@ +// 一次性工具:把某个应用业务库 goods 表导出的 JSON 同步进 console 主库的 app_goods 表。 +// 用法: go run ./cmd/syncgoods -dsn -file goods.json -app Voitrans-Test [-apply] +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "sort" + "time" + + "yunyan/comm" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type srcGoods struct { + Id string `json:"id"` + Enable int `json:"enable"` + Name string `json:"name"` + Localname string `json:"localname"` + Price int32 `json:"price"` + Usagetype int32 `json:"usagetype"` + Ainum int64 `json:"ainum"` + Meetnum int64 `json:"meetnum"` + Tradenum int64 `json:"tradenum"` + Viptime int32 `json:"viptime"` +} + +func main() { + dsn := flag.String("dsn", "", "console 主库 postgres dsn") + file := flag.String("file", "", "源 goods JSON 文件") + app := flag.String("app", "", "目标应用名 app_goods.app_name") + remark := flag.String("remark", "", "备注") + apply := flag.Bool("apply", false, "真正写入(默认只做 dry-run 打印)") + flag.Parse() + if *dsn == "" || *file == "" || *app == "" { + fmt.Println("缺少参数 -dsn/-file/-app") + os.Exit(1) + } + + buf, err := os.ReadFile(*file) + if err != nil { + panic(err) + } + var src []*srcGoods + if err = json.Unmarshal(buf, &src); err != nil { + panic(err) + } + sort.Slice(src, func(i, j int) bool { return src[i].Id < src[j].Id }) + + db, err := gorm.Open(postgres.Open(*dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Error)}) + if err != nil { + panic(err) + } + if err = db.Table(comm.TableAppGoods).AutoMigrate(&comm.AppGoods{}); err != nil { + panic(err) + } + fmt.Printf("表 %s 就绪,源记录 %d 条,目标应用 %s,apply=%v\n", comm.TableAppGoods, len(src), *app, *apply) + + now := time.Now().Unix() + var ins, upd int + for i, g := range src { + row := &comm.AppGoods{ + AppName: *app, + GoodsId: g.Id, + Enable: g.Enable != 0, + Name: g.Name, + Price: g.Price, + Usagetype: g.Usagetype, + Ainum: g.Ainum, + Meetnum: g.Meetnum, + Tradenum: g.Tradenum, + Viptime: g.Viptime, + Localname: g.Localname, + Sort: int32((i + 1) * 10), + Remark: *remark, + Createtime: now, + Updatetime: now, + } + old := &comm.AppGoods{} + err := db.Table(comm.TableAppGoods).Where("app_name=? AND goods_id=?", *app, g.Id).Take(old).Error + switch { + case err == gorm.ErrRecordNotFound: + ins++ + fmt.Printf(" + %-22s %-16s price=%d type=%d enable=%v\n", g.Id, g.Name, g.Price, g.Usagetype, row.Enable) + if *apply { + if err = db.Table(comm.TableAppGoods).Create(row).Error; err != nil { + panic(err) + } + } + case err != nil: + panic(err) + default: + upd++ + row.Id = old.Id + row.Createtime = old.Createtime + fmt.Printf(" ~ %-22s %-16s (覆盖已有 id=%d)\n", g.Id, g.Name, old.Id) + if *apply { + if err = db.Table(comm.TableAppGoods).Where("id=?", old.Id).Updates(row).Error; err != nil { + panic(err) + } + } + } + } + fmt.Printf("完成:新增 %d,更新 %d%s\n", ins, upd, map[bool]string{false: "(dry-run,未写库)", true: ""}[*apply]) +} diff --git a/apps/services/comm/agentcfg.go b/apps/services/comm/agentcfg.go index 03b188ce..30b85a6b 100644 --- a/apps/services/comm/agentcfg.go +++ b/apps/services/comm/agentcfg.go @@ -78,6 +78,11 @@ type AgentConfig struct { // McpSvcIds 绑定的 MCP 服务(引用 ThirdSvcConfig.Id,类别须含 SvcCatMCP)。 // 空=不给该 agent 挂工具。MCP 服务本身的 url/工具白名单在第三方服务配置里,这里只存引用。 McpSvcIds []string `gorm:"column:mcp_svc_ids;type:jsonb;serializer:json" json:"mcp_svc_ids"` + // McpTools 每个已绑定 MCP 服务「这个 agent 能用哪些工具」:key=MCP 服务 id,value=工具名列表。 + // 缺 key 或列表为空 = 该服务的工具全放开(与服务侧 tools 白名单再取交集)。 + // 一个 agent 挂多个 MCP 时,工具名会互相干扰(同名工具、用不上的工具挤占模型注意力), + // 所以按 agent 再收一次口,而不是只有服务级一份白名单。 + McpTools map[string][]string `gorm:"column:mcp_tools;type:jsonb;serializer:json" json:"mcp_tools"` // Variables 提示词/开场白里的变量占位({{key}}),见 AgentVariable。 Variables []AgentVariable `gorm:"column:variables;type:jsonb;serializer:json" json:"variables"` Sort int32 `gorm:"column:sort" json:"sort"` diff --git a/apps/services/modules/console/api_agent.go b/apps/services/modules/console/api_agent.go index 817c2bbf..b1b930a0 100644 --- a/apps/services/modules/console/api_agent.go +++ b/apps/services/modules/console/api_agent.go @@ -143,6 +143,23 @@ func normalizeAgent(a *AgentConfig) { } } a.McpSvcIds = ids + // 工具白名单只保留仍绑着的服务:取消绑定后再重新勾上,不该冒出上次的残留选择。 + // 某服务的列表为空就整条删掉——空列表与「没这个 key」同义(全放开),留着只会让下发数据变脏。 + tools := make(map[string][]string, len(a.McpTools)) + for _, id := range ids { + names := make([]string, 0, len(a.McpTools[id])) + dup := map[string]bool{} + for _, n := range a.McpTools[id] { + if n = strings.TrimSpace(n); n != "" && !dup[n] { + dup[n] = true + names = append(names, n) + } + } + if len(names) > 0 { + tools[id] = names + } + } + a.McpTools = tools vars := make([]comm.AgentVariable, 0, len(a.Variables)) for _, v := range a.Variables { if v.Key = strings.TrimSpace(v.Key); v.Key != "" { diff --git a/apps/services/modules/console/api_agent_test.go b/apps/services/modules/console/api_agent_test.go new file mode 100644 index 00000000..01386d78 --- /dev/null +++ b/apps/services/modules/console/api_agent_test.go @@ -0,0 +1,58 @@ +package console + +import ( + "testing" + + "yunyan/comm" +) + +// normalizeAgent 是落库前最后一道清洗:解绑过的 MCP 服务不能把工具白名单留在库里, +// 否则重新勾上该服务时会冒出上一轮的选择,运营看不见、客户端却会照着执行。 +func TestNormalizeAgentMcpTools(t *testing.T) { + a := &AgentConfig{ + McpSvcIds: []string{" mcp_map ", "mcp_music", "mcp_map", ""}, + McpTools: map[string][]string{ + "mcp_map": {"geocode", " geocode ", "", "route"}, + "mcp_music": {}, // 空列表 = 全放开,不该留在库里 + "mcp_gone": {"search"}, // 已解绑的服务,残留必须清掉 + }, + } + normalizeAgent(a) + + if len(a.McpSvcIds) != 2 || a.McpSvcIds[0] != "mcp_map" || a.McpSvcIds[1] != "mcp_music" { + t.Fatalf("MCP 引用清洗错:%v", a.McpSvcIds) + } + if _, ok := a.McpTools["mcp_gone"]; ok { + t.Fatalf("解绑服务的工具白名单没清掉:%v", a.McpTools) + } + if _, ok := a.McpTools["mcp_music"]; ok { + t.Fatalf("空白名单应整条删掉(空=全放开):%v", a.McpTools) + } + got := a.McpTools["mcp_map"] + if len(got) != 2 || got[0] != "geocode" || got[1] != "route" { + t.Fatalf("工具名去空白/去重错:%v", got) + } +} + +// 没配过工具白名单的老 agent(mcp_tools 为 nil)不能因为清洗炸掉,也不该凭空长出条目。 +func TestNormalizeAgentMcpToolsNil(t *testing.T) { + a := &AgentConfig{McpSvcIds: []string{"mcp_map"}} + normalizeAgent(a) + if a.McpTools == nil || len(a.McpTools) != 0 { + t.Fatalf("nil 白名单应清洗成空 map:%v", a.McpTools) + } +} + +// id 前缀按类型走,肉眼能看出这是哪类 agent;未知类型兜底成 agent。 +func TestAgentIdPrefix(t *testing.T) { + for typ, want := range map[string]string{ + comm.AgentTypeChat: "chat", + comm.AgentTypeStsChat: "sts", + "": "agent", + "whatever": "agent", + } { + if got := agentIdPrefix(typ); got != want { + t.Fatalf("type=%q 前缀应为 %s,实际 %s", typ, want, got) + } + } +} diff --git a/apps/services/modules/console/api_config.go b/apps/services/modules/console/api_config.go index 668e9a3c..5588d243 100644 --- a/apps/services/modules/console/api_config.go +++ b/apps/services/modules/console/api_config.go @@ -96,6 +96,7 @@ func ensureConfigTables() error { for _, s := range []string{ "ALTER TABLE " + comm.TableAgentConfig + " ADD COLUMN IF NOT EXISTS mcp_svc_ids jsonb", "ALTER TABLE " + comm.TableAgentConfig + " ADD COLUMN IF NOT EXISTS variables jsonb", + "ALTER TABLE " + comm.TableAgentConfig + " ADD COLUMN IF NOT EXISTS mcp_tools jsonb", } { if res := postgres.Exec(s); res.Error != nil { return res.Error diff --git a/apps/services/modules/console/api_mcptools.go b/apps/services/modules/console/api_mcptools.go new file mode 100644 index 00000000..8d030593 --- /dev/null +++ b/apps/services/modules/console/api_mcptools.go @@ -0,0 +1,161 @@ +package console + +// ============================ MCP 工具发现 (mcptools) ============================ +// +// 后台配 MCP 服务时,「这个服务有哪些工具」只有服务端自己知道。以前运营得去问对接方、 +// 再手抄工具名填进 tools 白名单,抄错了要到线上调用失败才发现。这里直接连上去问: +// 走标准 MCP 握手(initialize → tools/list),把工具名与说明列出来给人勾。 +// +// 两种入参(二选一): +// {url, type} —— 新建/编辑服务时还没落库,按当前填的地址现探 +// {app_name, id, region} —— 已落库的服务(agent 绑定 MCP 时用,只有 svc id); +// 基础行没配 url 时自动找一个配了 url 的区域分叉来探 +// +// 传输类型 type:0=HTTP(Streamable HTTP),1=SSE,与 comm.McpBaseFields / DBMcpServer.Stype 一致。 + +import ( + "context" + "strconv" + "strings" + "time" + + "yunyan/comm" + "yunyan/lego/sys/postgres" + "yunyan/pb" + + "github.com/gin-gonic/gin" + mcpclient "github.com/mark3labs/mcp-go/client" + mcpproto "github.com/mark3labs/mcp-go/mcp" +) + +// mcpToolsTimeout 单次探测的总超时:握手 + 拉列表。后台点一下要等的时间,别设太长。 +const mcpToolsTimeout = 15 * time.Second + +// mcpToolItem 下发给后台前端的工具项。 +type mcpToolItem struct { + Name string `json:"name"` + Description string `json:"description"` +} + +// getMcpTools 连上 MCP 服务列出它暴露的全部工具。 +func (this *serverComp) getMcpTools(c *gin.Context) { + var req struct { + Url string `json:"url"` + Type string `json:"type"` // "0"=HTTP "1"=SSE + AppName string `json:"app_name"` + Id string `json:"id"` + Region int32 `json:"region"` + } + _ = c.ShouldBindJSON(&req) + + url := strings.TrimSpace(req.Url) + stype := strings.TrimSpace(req.Type) + from := "" // 说明 url 是从哪解析来的,前端提示用 + if url == "" { + if strings.TrimSpace(req.Id) == "" { + writeErr(c, pb.ErrorCode_ReqParameterError, "请先填写 MCP 服务地址(url),或指定已保存的服务 id") + return + } + var msg string + if url, stype, from, msg = this.resolveMcpEndpoint(req.AppName, req.Id, req.Region); msg != "" { + writeErr(c, pb.ErrorCode_ReqParameterError, msg) + return + } + } + + tools, err := fetchMcpTools(url, stype == "1") + if err != nil { + writeErr(c, pb.ErrorCode_SystemError, "连接 MCP 服务失败: "+err.Error()) + return + } + writeOK(c, gin.H{"tools": tools, "count": len(tools), "url": url, "from": from}) +} + +// resolveMcpEndpoint 解析已落库 MCP 服务的 url/type:先按应用作用域找服务(未命中回退全局), +// 再取指定区域的分叉;基础行与该区域都没有 url 时,退而找任意一个配了 url 的区域分叉 +// ——MCP 的地址常常只配在区域分叉上(老 mcp 表一行绑一个区域,迁移后就是这形态)。 +// 返回 (url, type, 来源说明, 错误说明);错误说明非空即失败。 +func (this *serverComp) resolveMcpEndpoint(appName, id string, region int32) (string, string, string, string) { + svc := &ThirdSvcConfig{} + err := postgres.FindOne(comm.TableSvcConfig, svc, "app_name=? AND id=?", appName, id) + if err != nil && appName != "" { + err = postgres.FindOne(comm.TableSvcConfig, svc, "app_name=? AND id=?", "", id) + } + if err != nil { + return "", "", "", "MCP 服务不存在: " + id + } + if !comm.CategoriesHasMCP(svc.Categories) { + return "", "", "", "该服务不是 MCP 类型: " + id + } + + ovrs := make([]*SvcRegionOverride, 0) + _ = postgres.Find(comm.TableSvcRegionOverride, &ovrs, "app_name=? AND svc_id=?", svc.AppName, svc.Id) + pick := func(r int32) *SvcRegionOverride { + for _, o := range ovrs { + if o.Region == r { + return o + } + } + return nil + } + // ① 指定区域(含 0=全区域默认)的解析结果 + if m := comm.ResolveMcpServer(svc, pick(region), region); m != nil { + return m.Url, strconv.Itoa(int(m.Stype)), regionSourceLabel(region), "" + } + // ② 该区域没 url:找第一个配了 url 的分叉,探它总比直接报「没配」有用 + for _, o := range ovrs { + if m := comm.ResolveMcpServer(svc, o, o.Region); m != nil { + return m.Url, strconv.Itoa(int(m.Stype)), regionSourceLabel(o.Region), "" + } + } + return "", "", "", "MCP 服务「" + svc.Name + "」还没配 url(基础行与各区域分叉都没有)" +} + +// regionSourceLabel 给前端说明这次探的是哪一份配置。 +func regionSourceLabel(region int32) string { + if region <= 0 { + return "全区域默认" + } + return "区域 " + strconv.Itoa(int(region)) + " 分叉" +} + +// fetchMcpTools 走标准 MCP 协议握手并列出工具。 +// 用官方客户端而不是手拼 JSON-RPC:session id、SSE 事件流、initialized 通知这些细节由它兜着, +// 否则 SSE 类服务根本拿不到 tools/list 的响应(inspect 那边只做连通性探测就是这个原因)。 +func fetchMcpTools(url string, sse bool) ([]mcpToolItem, error) { + ctx, cancel := context.WithTimeout(context.Background(), mcpToolsTimeout) + defer cancel() + + var ( + cli *mcpclient.Client + err error + ) + if sse { + cli, err = mcpclient.NewSSEMCPClient(url) + } else { + cli, err = mcpclient.NewStreamableHttpClient(url) + } + if err != nil { + return nil, err + } + defer cli.Close() + + if err = cli.Start(ctx); err != nil { + return nil, err + } + initReq := mcpproto.InitializeRequest{} + initReq.Params.ProtocolVersion = mcpproto.LATEST_PROTOCOL_VERSION + initReq.Params.ClientInfo = mcpproto.Implementation{Name: "starpivot-console", Version: "1.0"} + if _, err = cli.Initialize(ctx, initReq); err != nil { + return nil, err + } + res, err := cli.ListTools(ctx, mcpproto.ListToolsRequest{}) + if err != nil { + return nil, err + } + out := make([]mcpToolItem, 0, len(res.Tools)) + for _, t := range res.Tools { + out = append(out, mcpToolItem{Name: t.Name, Description: strings.TrimSpace(t.Description)}) + } + return out, nil +} diff --git a/apps/services/modules/console/api_svcconfig.go b/apps/services/modules/console/api_svcconfig.go index 31b9eb51..85af1b74 100644 --- a/apps/services/modules/console/api_svcconfig.go +++ b/apps/services/modules/console/api_svcconfig.go @@ -94,10 +94,54 @@ func (this *serverComp) getSvcConfigs(c *gin.Context) { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } + // 凭据缺失清单要在打码前算:maskSvcFields 只把**非空**密文替换成 [encrypted],空值原样是 ""。 + missing := svcCredMissing(req.AppName, items) for _, item := range items { maskSvcFields(item.Fields) } - writeOK(c, gin.H{"items": items, "regions": svcRegionMap(req.AppName)}) + writeOK(c, gin.H{"items": items, "regions": svcRegionMap(req.AppName), "cred_missing": missing}) +} + +// svcCredMissing 算出每个服务「一个值都没有的凭据字段」,供后台把这类服务标出来。 +// 判定口径与巡检 checkSvcConfig 一致(凭据=加密字段,空即运行时必然鉴权失败), +// 但要把区域分叉算进去:凭据只配在某个区域分叉上是合法形态(老 mcp 表迁过来的服务就这样), +// 基础行空不代表没配——只有基础行与所有分叉都没给值,才算真的缺。 +// 返回 {svcId: [缺的字段名]},不缺的服务不出现在 map 里。 +func svcCredMissing(appName string, items []*ThirdSvcConfig) map[string][]string { + ovrs := make([]*SvcRegionOverride, 0) + _ = postgres.Find(comm.TableSvcRegionOverride, &ovrs, "app_name=?", appName) + bySvc := make(map[string][]*SvcRegionOverride, len(ovrs)) + for _, o := range ovrs { + bySvc[o.SvcId] = append(bySvc[o.SvcId], o) + } + return svcCredMissingWith(items, bySvc) +} + +// svcCredMissingWith 是上面的纯逻辑部分(区域分叉按 svc_id 归好传进来),便于单测。 +func svcCredMissingWith(items []*ThirdSvcConfig, bySvc map[string][]*SvcRegionOverride) map[string][]string { + out := make(map[string][]string, 4) + for _, s := range items { + miss := make([]string, 0, 2) + for _, f := range s.Fields { + if !f.Encrypted || strings.TrimSpace(f.DefValue) != "" { + continue + } + filled := false + for _, o := range bySvc[s.Id] { + if strings.TrimSpace(o.Overrides[f.Key]) != "" { + filled = true + break + } + } + if !filled { + miss = append(miss, f.Key) + } + } + if len(miss) > 0 { + out[s.Id] = miss + } + } + return out } // svcRegionMap 汇总某作用域下每个服务已建分叉的区域列表。空壳分叉(既没覆盖值、也没区域专属 @@ -121,6 +165,58 @@ func svcRegionMap(appName string) map[string][]int32 { } // addSvcConfig 新增依赖服务。encrypted=true 的字段值加密存储。 +// checkSvcRequiredFields 保存服务前的必填校验:只拦「一定会出问题」的空字段,口径同巡检 checkSvcConfig。 +// +// 凭据字段(encrypted) 为空 → 运行时必然鉴权失败; +// MCP 服务 url 为空 → 根本连不上。 +// +// 三条豁免: +// 1. 停用中的服务不校验——还没配好的先停用保存是正常流程,停用的服务不下发也不会被编排选中; +// 2. 值配在区域分叉里的不算空——凭据按区域分别给是合法形态(老 mcp 表迁过来的服务就这样); +// 3. 非加密字段一律不拦——base_url/language/tools 这类留空往往是有意的(走默认/全部),拦了只会误伤。 +// +// 传入的 fields 必须是**加密合并后**的值(update 时 "" / "[encrypted]" 已还原成旧密文), +// 否则「保留原值」的提交会被误判成空。返回错误说明(空串=通过)。 +func checkSvcRequiredFields(m *ThirdSvcConfig) string { + if !m.Enable { + return "" // 停用的服务先不查库,草稿态直接放行 + } + ovrs := make([]*SvcRegionOverride, 0) + _ = postgres.Find(comm.TableSvcRegionOverride, &ovrs, "app_name=? AND svc_id=?", m.AppName, m.Id) + return checkSvcRequiredFieldsWith(m, ovrs) +} + +// checkSvcRequiredFieldsWith 是上面的纯逻辑部分(区域分叉由调用方读好传进来),便于单测。 +func checkSvcRequiredFieldsWith(m *ThirdSvcConfig, ovrs []*SvcRegionOverride) string { + if !m.Enable { + return "" + } + filledInSomeRegion := func(key string) bool { + for _, o := range ovrs { + if strings.TrimSpace(o.Overrides[key]) != "" { + return true + } + } + return false + } + empty := make([]string, 0, 2) + for _, f := range m.Fields { + if !f.Encrypted || strings.TrimSpace(f.DefValue) != "" || filledInSomeRegion(f.Key) { + continue + } + empty = append(empty, f.Key) + } + if len(empty) > 0 { + sort.Strings(empty) + return "凭据字段不能为空:" + strings.Join(empty, "、") + + "。若该字段按区域分别配置,请先在区域分叉里填值;还没拿到凭据的服务,请先「停用」再保存。" + } + if comm.CategoriesHasMCP(m.Categories) && strings.TrimSpace(svcFieldValue(m, "url")) == "" && !filledInSomeRegion("url") { + return "MCP 服务必须填写 url(或在某个区域分叉里填),否则客户端连不上。" + } + return "" +} + func (this *serverComp) addSvcConfig(c *gin.Context) { var m ThirdSvcConfig if err := c.ShouldBindJSON(&m); err != nil { @@ -142,6 +238,10 @@ func (this *serverComp) addSvcConfig(c *gin.Context) { writeErr(c, pb.ErrorCode_SystemError, "字段加密失败: "+err.Error()) return } + if msg := checkSvcRequiredFields(&m); msg != "" { + writeErr(c, pb.ErrorCode_ReqParameterError, msg) + return + } m.RowId = 0 // 代理主键由库自增回填 now := time.Now().UnixMilli() m.Createtime = now @@ -180,6 +280,10 @@ func (this *serverComp) updateSvcConfig(c *gin.Context) { writeErr(c, pb.ErrorCode_SystemError, "字段加密失败: "+err.Error()) return } + if msg := checkSvcRequiredFields(&m); msg != "" { + writeErr(c, pb.ErrorCode_ReqParameterError, msg) + return + } m.RowId = old.RowId // 按代理主键更新本行 m.Createtime = old.Createtime m.Updatetime = time.Now().UnixMilli() diff --git a/apps/services/modules/console/api_svcconfig_test.go b/apps/services/modules/console/api_svcconfig_test.go new file mode 100644 index 00000000..27c45284 --- /dev/null +++ b/apps/services/modules/console/api_svcconfig_test.go @@ -0,0 +1,71 @@ +package console + +import "testing" + +// 保存服务时的必填校验:启用中的服务不许留空凭据——之前 stt_azure 三个字段全空也存下去了, +// 客户端拿到一串空字符串才暴露。停用/区域分叉两条豁免不能误伤。 +func TestCheckSvcRequiredFields(t *testing.T) { + enc := func(key, val string) SvcField { + return SvcField{Key: key, Encrypted: true, DefValue: val} + } + plain := func(key, val string) SvcField { + return SvcField{Key: key, DefValue: val} + } + + t.Run("启用+凭据为空 → 拦下", func(t *testing.T) { + m := &ThirdSvcConfig{Id: "stt_azure", Enable: true, Categories: "1", + Fields: []SvcField{enc("subscription_key", ""), plain("region", "")}} + if msg := checkSvcRequiredFieldsWith(m, nil); msg == "" { + t.Fatal("凭据为空必须拦下") + } + }) + + t.Run("停用的服务放行", func(t *testing.T) { + m := &ThirdSvcConfig{Id: "stt_azure", Categories: "1", Fields: []SvcField{enc("subscription_key", "")}} + if msg := checkSvcRequiredFieldsWith(m, nil); msg != "" { + t.Fatalf("停用的服务是草稿态,应放行:%s", msg) + } + }) + + t.Run("非加密字段留空不拦", func(t *testing.T) { + m := &ThirdSvcConfig{Id: "llm_qwen", Enable: true, Categories: "5", + Fields: []SvcField{enc("api_key", "sk-x"), plain("base_url", "")}} + if msg := checkSvcRequiredFieldsWith(m, nil); msg != "" { + t.Fatalf("base_url 这类可选字段不该拦:%s", msg) + } + }) + + t.Run("MCP 缺 url → 拦下", func(t *testing.T) { + m := &ThirdSvcConfig{Id: "mcp_x", Enable: true, Categories: "10", Fields: []SvcField{plain("url", "")}} + if msg := checkSvcRequiredFieldsWith(m, nil); msg == "" { + t.Fatal("MCP 没有 url 客户端连不上,必须拦下") + } + }) + + t.Run("凭据齐全放行", func(t *testing.T) { + m := &ThirdSvcConfig{Id: "tts_azure", Enable: true, Categories: "2", + Fields: []SvcField{enc("subscription_key", "cipher"), plain("region", "southeastasia")}} + if msg := checkSvcRequiredFieldsWith(m, nil); msg != "" { + t.Fatalf("配齐了不该拦:%s", msg) + } + }) +} + +// 列表接口给前端的「缺凭据」清单:只认「基础行与所有区域分叉都没值」,别把按区域配的服务误标。 +func TestSvcCredMissing(t *testing.T) { + items := []*ThirdSvcConfig{ + {Id: "a", Fields: []SvcField{{Key: "k1", Encrypted: true, DefValue: ""}, {Key: "k2", Encrypted: true, DefValue: "cipher"}}}, + {Id: "b", Fields: []SvcField{{Key: "k", Encrypted: true, DefValue: "cipher"}}}, + {Id: "c", Fields: []SvcField{{Key: "plain", DefValue: ""}}}, + } + got := svcCredMissingWith(items, nil) // 没有任何区域覆盖 + if len(got["a"]) != 1 || got["a"][0] != "k1" { + t.Fatalf("a 应只缺 k1:%v", got["a"]) + } + if _, ok := got["b"]; ok { + t.Fatalf("b 凭据齐全不该出现:%v", got) + } + if _, ok := got["c"]; ok { + t.Fatalf("c 只有非加密字段,不算缺凭据:%v", got) + } +} diff --git a/apps/services/modules/console/api_svctemplate.go b/apps/services/modules/console/api_svctemplate.go index cca8d549..d52bf82e 100644 --- a/apps/services/modules/console/api_svctemplate.go +++ b/apps/services/modules/console/api_svctemplate.go @@ -238,11 +238,15 @@ func builtinSvcTemplates() []ThirdSvcTemplate { 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)), + sf("api_key", "API Key", true), sf("model", "模型,如 claude-sonnet-4", false), + sfd("base_url", "接口地址", false, "https://api.anthropic.com/v1")), tpl("llm_deepseek", "deepseek", "DeepSeek", svcCatLLM, 3, sf("api_key", "API Key", true), sf("model", "模型,如 deepseek-chat", false), sf("base_url", "接口地址", false)), + // base_url 带默认值:客户端拿到配置就得能直接发请求,缺地址等于这条服务下发了个半成品 + // (同厂商的 llmv_qwen 一直有 base_url,llm_qwen 漏了,客户端只能自己写死 dashscope 地址)。 tpl("llm_qwen", "qwen", "通义千问(阿里)", svcCatLLM, 4, - sf("api_key", "API Key", true), sf("model", "模型,如 qwen-max", false)), + sf("api_key", "API Key", true), sf("model", "模型,如 qwen-max", false), + sfd("base_url", "接口地址", false, "https://dashscope.aliyuncs.com/compatible-mode/v1")), // 录音文件识别(会议记录离线转写;字段对齐 modules/echomeet 各 provider 的运行时凭据) // diff --git a/apps/services/modules/console/api_svctemplate_test.go b/apps/services/modules/console/api_svctemplate_test.go index b390e357..2d54c63a 100644 --- a/apps/services/modules/console/api_svctemplate_test.go +++ b/apps/services/modules/console/api_svctemplate_test.go @@ -145,3 +145,53 @@ func TestCategoriesHas(t *testing.T) { } } } + +// 大模型类服务必须给出接口地址:客户端拿配置就得能直接发请求,缺地址等于下发了个半成品。 +// llm_qwen 就漏过一次(同厂商的 llmv_qwen 一直有 base_url),客户端只能自己写死 dashscope 地址。 +// 新增大模型模板时这条会先炸,而不是等客户端反馈「阿里的 LLM 没有 base_url」。 +func TestLLMTemplatesCarryEndpoint(t *testing.T) { + // 地址字段的几种写法:base_url(OpenAI 兼容) / endpoint(Google 自定义端点) + addrKeys := map[string]bool{"base_url": true, "endpoint": true} + for _, tpl := range builtinSvcTemplates() { + if tpl.Category != svcCatLLM && tpl.Category != svcCatLLMVision { + continue + } + ok := false + for _, f := range tpl.Fields { + if addrKeys[f.Key] { + ok = true + break + } + } + if !ok { + t.Errorf("大模型模板 %s(%s) 没有接口地址字段(base_url/endpoint),客户端不知道往哪发请求", tpl.Id, tpl.Name) + } + } +} + +// 带默认值的地址字段不能写错——默认值是直接下发给客户端的,写错比不写更难查。 +func TestQwenTemplateBaseURL(t *testing.T) { + want := map[string]string{ + "llm_qwen": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "llmv_qwen": "", // 视觉模板的 base_url 是可选项,不设默认值,只要求字段在 + } + for _, tpl := range builtinSvcTemplates() { + exp, care := want[tpl.Id] + if !care { + continue + } + var got string + found := false + for _, f := range tpl.Fields { + if f.Key == "base_url" { + got, found = f.DefValue, true + } + } + if !found { + t.Fatalf("%s 缺 base_url 字段", tpl.Id) + } + if exp != "" && got != exp { + t.Fatalf("%s 的 base_url 默认值应为 %s,实际 %q", tpl.Id, exp, got) + } + } +} diff --git a/apps/services/modules/console/server.go b/apps/services/modules/console/server.go index f7092e9b..1bdcc2a9 100644 --- a/apps/services/modules/console/server.go +++ b/apps/services/modules/console/server.go @@ -409,6 +409,10 @@ func (this *serverComp) handleWeb(c *gin.Context) { case "api_delagent": this.delAgent(c) return + // MCP 工具发现:连上 MCP 服务列出它暴露的工具(服务配置页填完 url 点一下、agent 绑定时勾工具都用它)。 + case "api_getmcptools": + this.getMcpTools(c) + return // TTS 音色表(代码常量,见 comm/ttsvoice.go):后台 agent 配音色时用它渲染候选,不落库。 case "api_getttsvoices": this.getTTSVoices(c) diff --git a/apps/services/modules/user/api_v3_getagents.go b/apps/services/modules/user/api_v3_getagents.go index 220c79cb..02260247 100644 --- a/apps/services/modules/user/api_v3_getagents.go +++ b/apps/services/modules/user/api_v3_getagents.go @@ -23,12 +23,24 @@ import ( // 注册在 apiV3Comp(Version=v3)上,路由 = user_getagents_v3;已在 EncryptMsgs 中声明 // (系统提示词/编排关系不明文出网关)。v2 的 user_getagents_v2 保留不动。 func (this *apiV3Comp) GetAgents(session comm.IUserSession, req *pb.UserGetAgentsV3Req) (resp *pb.UserGetAgentsV3Resp, errdata *pb.ErrorData) { + items, err := this.agentItemsV3() + if err != nil { + errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()} + return + } + resp = &pb.UserGetAgentsV3Resp{Agents: items} + return +} + +// agentItemsV3 组装下发客户端的 agent 列表。user_getagents_v3 与 user_getappconfig_v3 共用这一份: +// 两个接口下发的必须是同一套结构与筛选口径,否则客户端从 appconfig 里拿到的 agent 会缺 type +// 与服务编排(历史上 appconfig 下发的是已废弃的旧 agent 表,客户端不知道该走 chat 还是 sts-chat)。 +func (this *apiV3Comp) agentItemsV3() ([]*pb.AgentItemV3, error) { app := comm.AppName() rows := make([]*comm.AgentConfig, 0) if err := postgres.Find(comm.TableAgentConfig, &rows, "(app_name=? OR app_name='')", app); err != nil && err != postgres.ErrNoDocuments { - errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()} - return + return nil, err } // 同 id 应用行覆盖全局行;仅启用的(应用行停用则回退全局行,与第三方服务下发同语义)。 @@ -53,7 +65,7 @@ func (this *apiV3Comp) GetAgents(session comm.IUserSession, req *pb.UserGetAgent }) providerOf := this.svcProviders(app) - resp = &pb.UserGetAgentsV3Resp{Agents: make([]*pb.AgentItemV3, 0, len(list))} + items := make([]*pb.AgentItemV3, 0, len(list)) for _, a := range list { item := &pb.AgentItemV3{ Id: a.Id, @@ -72,6 +84,7 @@ func (this *apiV3Comp) GetAgents(session comm.IUserSession, req *pb.UserGetAgent SupportLangs: a.SupportLangs, DefaultLang: a.DefaultLang, McpSvcIds: append([]string{}, a.McpSvcIds...), + Mcps: agentMcpBindings(a), Sort: a.Sort, VoiceMap: resolveAgentVoices(a, providerOf[voiceSvcIdOf(a)]), Variables: make([]*pb.AgentVariableItem, 0, len(a.Variables)), @@ -81,9 +94,23 @@ func (this *apiV3Comp) GetAgents(session comm.IUserSession, req *pb.UserGetAgent Key: v.Key, Label: v.Label, DefValue: v.DefValue, Description: v.Description, }) } - resp.Agents = append(resp.Agents, item) + items = append(items, item) } - return + return items, nil +} + +// agentMcpBindings 把 agent 的 MCP 绑定展开成「服务 + 该 agent 允许的工具」。 +// 工具白名单为空 = 这个服务的工具全放开;最终还要与服务自身 tools 白名单 +// (在 thirdsvcs 的 fields.tools 里,按区域解析)取交集,那步由客户端做—— +// 服务侧白名单是按区域分叉的,agent 下发时没有区域上下文。 +func agentMcpBindings(a *comm.AgentConfig) []*pb.AgentMcpBindingItem { + out := make([]*pb.AgentMcpBindingItem, 0, len(a.McpSvcIds)) + for _, id := range a.McpSvcIds { + item := &pb.AgentMcpBindingItem{SvcId: id, Tools: make([]string, 0, 4)} + item.Tools = append(item.Tools, a.McpTools[id]...) + out = append(out, item) + } + return out } // svcProviders 取本应用作用域下 svc_id → provider 的映射(应用行覆盖全局行)。 diff --git a/apps/services/modules/user/api_v3_getappconfig.go b/apps/services/modules/user/api_v3_getappconfig.go index a407d710..ea8af89e 100644 --- a/apps/services/modules/user/api_v3_getappconfig.go +++ b/apps/services/modules/user/api_v3_getappconfig.go @@ -11,7 +11,9 @@ import ( // @Summary 获取应用配置(v3) // @Description 一次性下发客户端启动所需的全部配置:环境变量 + 智能体 + 产品 + 第三方服务(含 MCP) + 会议记录能力。 // 与 v2 的差别:不再单独下发 mcps(MCP 已并入第三方服务配置,在 thirdsvcs 里按 categories 含 "10" 识别); -// 新增 meet(会议记录支持的语言与可选总结大模型,来自后台「会议记录服务」编排)。v1/v2 保持原样不受影响。 +// 新增 meet(会议记录支持的语言与可选总结大模型,来自后台「会议记录服务」编排); +// agents 换成与 user_getagents_v3 相同的结构(含 type=chat|sts-chat 与 stt/llm/tts/sts 服务编排、 +// MCP 绑定、变量、已补全的音色表),不再下发旧 agent 表。v1/v2 保持原样不受影响。 // @Tags User // @Accept json // @Produce json @@ -36,9 +38,18 @@ func (this *apiV3Comp) GetAppConfig(session comm.IUserSession, req *pb.UserGetAp ) // 环境变量:应用自有配置(app_config) 打底,本区域的 global_config 覆盖(口径同 v2)。 - config, globalconfig, agents, _ := this.module.configmodel.getdb() + // 注意 getdb 的第三个返回值是**旧 agent 表**,v3 已不下发它(见下方 agentItemsV3)。 + config, globalconfig, _, _ := this.module.configmodel.getdb() env := mergeAppEnv(config, globalconfig, region) + // 智能体:与 user_getagents_v3 同一份数据与结构(含 type、服务编排、MCP、变量、已补全音色), + // 客户端只调 appconfig 也能直接把 agent 跑起来。 + agents, err := this.agentItemsV3() + if err != nil { + errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()} + return + } + products, err := this.module.cache.GetProducts() if err != nil && err != mysql.ErrNoDocuments { errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()} diff --git a/apps/services/modules/user/module_test.go b/apps/services/modules/user/module_test.go index 6c8b29fb..a5ebb519 100644 --- a/apps/services/modules/user/module_test.go +++ b/apps/services/modules/user/module_test.go @@ -181,7 +181,7 @@ func TestV3MeetCapabilitiesEmptySnapshot(t *testing.T) { func TestV3AppConfigRespContract(t *testing.T) { resp := &pb.UserGetAppConfigV3Resp{ Env: map[string]string{}, - Agents: []*pb.DBAgent{}, + Agents: []*pb.AgentItemV3{}, // v3 下发的是带 type/服务编排的新结构,不是旧 agent 表 Products: []*pb.DBProduct{}, Thirdsvcs: []*pb.ThirdSvcItem{}, Meet: newV3Comp(nil).meetCapabilities(), @@ -204,6 +204,36 @@ func TestV3AppConfigRespContract(t *testing.T) { } } +// appconfig 里的 agent 必须是「能直接跑起来」的那一份:带 type(chat|sts-chat) 与服务编排。 +// 之前这里下发的是旧 agent 表(DBAgent),客户端拿到 agent 却不知道走哪条链路、调哪个服务。 +func TestV3AppConfigAgentCarriesTypeAndServices(t *testing.T) { + resp := &pb.UserGetAppConfigV3Resp{Agents: []*pb.AgentItemV3{{ + Id: "chat-abc", Name: "口语搭子", Type: comm.AgentTypeChat, + SttSvcId: "svc_stt", LlmSvcId: "svc_llm", TtsSvcId: "svc_tts", + }}} + buf, err := json.Marshal(resp) + if err != nil { + t.Fatal(err) + } + var got struct { + Agents []map[string]any `json:"agents"` + } + if err = json.Unmarshal(buf, &got); err != nil { + t.Fatal(err) + } + if len(got.Agents) != 1 { + t.Fatalf("agents 解析失败:%s", buf) + } + for _, k := range []string{"type", "stt_svc_id", "llm_svc_id", "tts_svc_id", "sts_svc_id", "mcp_svc_ids", "variables", "voice_map"} { + if _, ok := got.Agents[0][k]; !ok { + t.Fatalf("appconfig 的 agent 缺字段 %s:%s", k, buf) + } + } + if got.Agents[0]["type"] != comm.AgentTypeChat { + t.Fatalf("agent type 没下发对:%s", buf) + } +} + // 客户端靠 categories 含 "10" 从 thirdsvcs 里挑出 MCP 服务——v3 去掉 mcps 后这是唯一识别方式。 func TestV3McpIdentifiedByCategories(t *testing.T) { svcs := []*pb.ThirdSvcItem{ @@ -635,8 +665,23 @@ func TestApiProbeAppConfigV3(t *testing.T) { t.Logf(" svc[%s] name=%s provider=%s categories=%s fields=%d mcp=%v", s.Id, s.Name, s.Provider, s.Categories, len(s.Fields), comm.CategoriesHasMCP(s.Categories)) } + // agent 要能「拿了就跑」:type 指明走 chat 还是 sts-chat,对应槽位的服务 id 得齐。 for _, a := range d.Agents { - t.Logf(" agent id=%s name=%s", a.Id, a.Name) + t.Logf(" agent id=%s name=%s type=%s stt=%s llm=%s tts=%s sts=%s mcp=%v vars=%d voices=%d", + a.Id, a.Name, a.Type, a.SttSvcId, a.LlmSvcId, a.TtsSvcId, a.StsSvcId, + a.McpSvcIds, len(a.Variables), len(a.VoiceMap)) + switch a.Type { + case comm.AgentTypeChat: + if a.SttSvcId == "" || a.LlmSvcId == "" || a.TtsSvcId == "" { + t.Fatalf("chat agent %s 缺服务编排:stt=%q llm=%q tts=%q", a.Id, a.SttSvcId, a.LlmSvcId, a.TtsSvcId) + } + case comm.AgentTypeStsChat: + if a.StsSvcId == "" { + t.Fatalf("sts-chat agent %s 缺 sts_svc_id", a.Id) + } + default: + t.Fatalf("agent %s 的 type 为 %q:客户端不知道该走哪条链路(下发的应是 chat|sts-chat)", a.Id, a.Type) + } } // meet 是 v3 新增的:为空可以(后台没编排会议记录),为 null 不行——客户端会崩。 diff --git a/apps/services/pb/user_msg_v3.pb.go b/apps/services/pb/user_msg_v3.pb.go index f15edcb9..22187a1e 100644 --- a/apps/services/pb/user_msg_v3.pb.go +++ b/apps/services/pb/user_msg_v3.pb.go @@ -206,7 +206,7 @@ func (x *MeetCapabilitiesItem) GetSummaryServices() []*MeetSummaryServiceItem { type UserGetAppConfigV3Resp struct { state protoimpl.MessageState `protogen:"open.v1"` Env map[string]string `protobuf:"bytes,1,rep,name=env,proto3" json:"env" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` //@go_tags(`json:"env"`) 应用环境变量(app_config + 本区域 global_config) - Agents []*DBAgent `protobuf:"bytes,2,rep,name=agents,proto3" json:"agents"` //@go_tags(`json:"agents"`) 智能体列表 + Agents []*AgentItemV3 `protobuf:"bytes,2,rep,name=agents,proto3" json:"agents"` //@go_tags(`json:"agents"`) 智能体列表(结构同 user_getagents_v3,含 type 与服务编排) Products []*DBProduct `protobuf:"bytes,3,rep,name=products,proto3" json:"products"` //@go_tags(`json:"products"`) 产品列表 Thirdsvcs []*ThirdSvcItem `protobuf:"bytes,4,rep,name=thirdsvcs,proto3" json:"thirdsvcs"` //@go_tags(`json:"thirdsvcs"`) 第三方服务(含 MCP 类,按用户区域合并、凭据已解密为明文) Meet *MeetCapabilitiesItem `protobuf:"bytes,5,opt,name=meet,proto3" json:"meet"` //@go_tags(`json:"meet"`) 会议记录能力(语言 + 总结大模型) @@ -251,7 +251,7 @@ func (x *UserGetAppConfigV3Resp) GetEnv() map[string]string { return nil } -func (x *UserGetAppConfigV3Resp) GetAgents() []*DBAgent { +func (x *UserGetAppConfigV3Resp) GetAgents() []*AgentItemV3 { if x != nil { return x.Agents } @@ -388,6 +388,61 @@ func (x *AgentVariableItem) GetDescription() string { return "" } +// agent 的 MCP 绑定项:绑了哪个 MCP 服务 + 这个 agent 允许它调用的工具。 +// 同一个 agent 挂多个 MCP 时工具会互相干扰(同名工具、用不上的工具挤占模型注意力), +// 故除服务自身的 tools 白名单外,再按 agent 收一次口。 +type AgentMcpBindingItem struct { + state protoimpl.MessageState `protogen:"open.v1"` + SvcId string `protobuf:"bytes,1,opt,name=svc_id,json=svcId,proto3" json:"svc_id"` //@go_tags(`json:"svc_id"`) MCP 服务 id(详情在 getappconfig_v3 的 thirdsvcs 里) + Tools []string `protobuf:"bytes,2,rep,name=tools,proto3" json:"tools"` //@go_tags(`json:"tools"`) 允许调用的工具名;空=该服务的工具全放开(仍受服务自身 tools 白名单约束) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentMcpBindingItem) Reset() { + *x = AgentMcpBindingItem{} + mi := &file_user_user_msg_v3_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentMcpBindingItem) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentMcpBindingItem) ProtoMessage() {} + +func (x *AgentMcpBindingItem) ProtoReflect() protoreflect.Message { + mi := &file_user_user_msg_v3_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentMcpBindingItem.ProtoReflect.Descriptor instead. +func (*AgentMcpBindingItem) Descriptor() ([]byte, []int) { + return file_user_user_msg_v3_proto_rawDescGZIP(), []int{6} +} + +func (x *AgentMcpBindingItem) GetSvcId() string { + if x != nil { + return x.SvcId + } + return "" +} + +func (x *AgentMcpBindingItem) GetTools() []string { + if x != nil { + return x.Tools + } + return nil +} + type AgentItemV3 struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id"` //@go_tags(`json:"id"`) agent id @@ -409,13 +464,14 @@ type AgentItemV3 struct { McpSvcIds []string `protobuf:"bytes,17,rep,name=mcp_svc_ids,json=mcpSvcIds,proto3" json:"mcp_svc_ids"` //@go_tags(`json:"mcp_svc_ids"`) 绑定的 MCP 服务 id(详情在 getappconfig_v3 的 thirdsvcs 里) Variables []*AgentVariableItem `protobuf:"bytes,18,rep,name=variables,proto3" json:"variables"` //@go_tags(`json:"variables"`) 变量定义 Sort int32 `protobuf:"varint,19,opt,name=sort,proto3" json:"sort"` //@go_tags(`json:"sort"`) 排序 + Mcps []*AgentMcpBindingItem `protobuf:"bytes,20,rep,name=mcps,proto3" json:"mcps"` //@go_tags(`json:"mcps"`) MCP 绑定(含每个服务允许的工具);mcp_svc_ids 是它的 id 平铺,保留给老客户端 unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AgentItemV3) Reset() { *x = AgentItemV3{} - mi := &file_user_user_msg_v3_proto_msgTypes[6] + mi := &file_user_user_msg_v3_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -427,7 +483,7 @@ func (x *AgentItemV3) String() string { func (*AgentItemV3) ProtoMessage() {} func (x *AgentItemV3) ProtoReflect() protoreflect.Message { - mi := &file_user_user_msg_v3_proto_msgTypes[6] + mi := &file_user_user_msg_v3_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -440,7 +496,7 @@ func (x *AgentItemV3) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentItemV3.ProtoReflect.Descriptor instead. func (*AgentItemV3) Descriptor() ([]byte, []int) { - return file_user_user_msg_v3_proto_rawDescGZIP(), []int{6} + return file_user_user_msg_v3_proto_rawDescGZIP(), []int{7} } func (x *AgentItemV3) GetId() string { @@ -576,6 +632,13 @@ func (x *AgentItemV3) GetSort() int32 { return 0 } +func (x *AgentItemV3) GetMcps() []*AgentMcpBindingItem { + if x != nil { + return x.Mcps + } + return nil +} + type UserGetAgentsV3Resp struct { state protoimpl.MessageState `protogen:"open.v1"` Agents []*AgentItemV3 `protobuf:"bytes,1,rep,name=agents,proto3" json:"agents"` //@go_tags(`json:"agents"`) agent 列表 @@ -585,7 +648,7 @@ type UserGetAgentsV3Resp struct { func (x *UserGetAgentsV3Resp) Reset() { *x = UserGetAgentsV3Resp{} - mi := &file_user_user_msg_v3_proto_msgTypes[7] + mi := &file_user_user_msg_v3_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -597,7 +660,7 @@ func (x *UserGetAgentsV3Resp) String() string { func (*UserGetAgentsV3Resp) ProtoMessage() {} func (x *UserGetAgentsV3Resp) ProtoReflect() protoreflect.Message { - mi := &file_user_user_msg_v3_proto_msgTypes[7] + mi := &file_user_user_msg_v3_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -610,7 +673,7 @@ func (x *UserGetAgentsV3Resp) ProtoReflect() protoreflect.Message { // Deprecated: Use UserGetAgentsV3Resp.ProtoReflect.Descriptor instead. func (*UserGetAgentsV3Resp) Descriptor() ([]byte, []int) { - return file_user_user_msg_v3_proto_rawDescGZIP(), []int{7} + return file_user_user_msg_v3_proto_rawDescGZIP(), []int{8} } func (x *UserGetAgentsV3Resp) GetAgents() []*AgentItemV3 { @@ -637,10 +700,10 @@ const file_user_user_msg_v3_proto_rawDesc = "" + "\x13translate_languages\x18\x03 \x03(\tR\x12translateLanguages\x124\n" + "\x16translate_any_language\x18\x04 \x01(\bR\x14translateAnyLanguage\x122\n" + "\x15summary_client_choice\x18\x05 \x01(\bR\x13summaryClientChoice\x12B\n" + - "\x10summary_services\x18\x06 \x03(\v2\x17.MeetSummaryServiceItemR\x0fsummaryServices\"\xa6\x02\n" + + "\x10summary_services\x18\x06 \x03(\v2\x17.MeetSummaryServiceItemR\x0fsummaryServices\"\xaa\x02\n" + "\x16UserGetAppConfigV3Resp\x122\n" + - "\x03env\x18\x01 \x03(\v2 .UserGetAppConfigV3Resp.EnvEntryR\x03env\x12 \n" + - "\x06agents\x18\x02 \x03(\v2\b.DBAgentR\x06agents\x12&\n" + + "\x03env\x18\x01 \x03(\v2 .UserGetAppConfigV3Resp.EnvEntryR\x03env\x12$\n" + + "\x06agents\x18\x02 \x03(\v2\f.AgentItemV3R\x06agents\x12&\n" + "\bproducts\x18\x03 \x03(\v2\n" + ".DBProductR\bproducts\x12+\n" + "\tthirdsvcs\x18\x04 \x03(\v2\r.ThirdSvcItemR\tthirdsvcs\x12)\n" + @@ -653,7 +716,10 @@ const file_user_user_msg_v3_proto_rawDesc = "" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05label\x18\x02 \x01(\tR\x05label\x12\x1b\n" + "\tdef_value\x18\x03 \x01(\tR\bdefValue\x12 \n" + - "\vdescription\x18\x04 \x01(\tR\vdescription\"\xe7\x04\n" + + "\vdescription\x18\x04 \x01(\tR\vdescription\"B\n" + + "\x13AgentMcpBindingItem\x12\x15\n" + + "\x06svc_id\x18\x01 \x01(\tR\x05svcId\x12\x14\n" + + "\x05tools\x18\x02 \x03(\tR\x05tools\"\x91\x05\n" + "\vAgentItemV3\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x12\n" + @@ -679,7 +745,8 @@ const file_user_user_msg_v3_proto_rawDesc = "" + "\tvoice_map\x18\x10 \x03(\v2\x0f.AgentVoiceItemR\bvoiceMap\x12\x1e\n" + "\vmcp_svc_ids\x18\x11 \x03(\tR\tmcpSvcIds\x120\n" + "\tvariables\x18\x12 \x03(\v2\x12.AgentVariableItemR\tvariables\x12\x12\n" + - "\x04sort\x18\x13 \x01(\x05R\x04sort\";\n" + + "\x04sort\x18\x13 \x01(\x05R\x04sort\x12(\n" + + "\x04mcps\x18\x14 \x03(\v2\x14.AgentMcpBindingItemR\x04mcps\";\n" + "\x13UserGetAgentsV3Resp\x12$\n" + "\x06agents\x18\x01 \x03(\v2\f.AgentItemV3R\x06agentsB\x06Z\x04.;pbb\x06proto3" @@ -695,7 +762,7 @@ func file_user_user_msg_v3_proto_rawDescGZIP() []byte { return file_user_user_msg_v3_proto_rawDescData } -var file_user_user_msg_v3_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_user_user_msg_v3_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_user_user_msg_v3_proto_goTypes = []any{ (*UserGetAppConfigV3Req)(nil), // 0: UserGetAppConfigV3Req (*MeetSummaryServiceItem)(nil), // 1: MeetSummaryServiceItem @@ -703,29 +770,30 @@ var file_user_user_msg_v3_proto_goTypes = []any{ (*UserGetAppConfigV3Resp)(nil), // 3: UserGetAppConfigV3Resp (*UserGetAgentsV3Req)(nil), // 4: UserGetAgentsV3Req (*AgentVariableItem)(nil), // 5: AgentVariableItem - (*AgentItemV3)(nil), // 6: AgentItemV3 - (*UserGetAgentsV3Resp)(nil), // 7: UserGetAgentsV3Resp - nil, // 8: UserGetAppConfigV3Resp.EnvEntry - (*DBAgent)(nil), // 9: DBAgent + (*AgentMcpBindingItem)(nil), // 6: AgentMcpBindingItem + (*AgentItemV3)(nil), // 7: AgentItemV3 + (*UserGetAgentsV3Resp)(nil), // 8: UserGetAgentsV3Resp + nil, // 9: UserGetAppConfigV3Resp.EnvEntry (*DBProduct)(nil), // 10: DBProduct (*ThirdSvcItem)(nil), // 11: ThirdSvcItem (*AgentVoiceItem)(nil), // 12: AgentVoiceItem } var file_user_user_msg_v3_proto_depIdxs = []int32{ 1, // 0: MeetCapabilitiesItem.summary_services:type_name -> MeetSummaryServiceItem - 8, // 1: UserGetAppConfigV3Resp.env:type_name -> UserGetAppConfigV3Resp.EnvEntry - 9, // 2: UserGetAppConfigV3Resp.agents:type_name -> DBAgent + 9, // 1: UserGetAppConfigV3Resp.env:type_name -> UserGetAppConfigV3Resp.EnvEntry + 7, // 2: UserGetAppConfigV3Resp.agents:type_name -> AgentItemV3 10, // 3: UserGetAppConfigV3Resp.products:type_name -> DBProduct 11, // 4: UserGetAppConfigV3Resp.thirdsvcs:type_name -> ThirdSvcItem 2, // 5: UserGetAppConfigV3Resp.meet:type_name -> MeetCapabilitiesItem 12, // 6: AgentItemV3.voice_map:type_name -> AgentVoiceItem 5, // 7: AgentItemV3.variables:type_name -> AgentVariableItem - 6, // 8: UserGetAgentsV3Resp.agents:type_name -> AgentItemV3 - 9, // [9:9] is the sub-list for method output_type - 9, // [9:9] is the sub-list for method input_type - 9, // [9:9] is the sub-list for extension type_name - 9, // [9:9] is the sub-list for extension extendee - 0, // [0:9] is the sub-list for field type_name + 6, // 8: AgentItemV3.mcps:type_name -> AgentMcpBindingItem + 7, // 9: UserGetAgentsV3Resp.agents:type_name -> AgentItemV3 + 10, // [10:10] is the sub-list for method output_type + 10, // [10:10] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_user_user_msg_v3_proto_init() } @@ -741,7 +809,7 @@ func file_user_user_msg_v3_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_user_user_msg_v3_proto_rawDesc), len(file_user_user_msg_v3_proto_rawDesc)), NumEnums: 0, - NumMessages: 9, + NumMessages: 10, NumExtensions: 0, NumServices: 0, }, diff --git a/apps/services/swagger/docs.go b/apps/services/swagger/docs.go index 6e8dc66e..dd55cfab 100644 --- a/apps/services/swagger/docs.go +++ b/apps/services/swagger/docs.go @@ -3728,6 +3728,13 @@ const docTemplate = `{ "type": "string" } }, + "mcps": { + "description": "@go_tags(` + "`" + `json:\"mcps\"` + "`" + `) MCP 绑定(含每个服务允许的工具);mcp_svc_ids 是它的 id 平铺,保留给老客户端", + "type": "array", + "items": { + "$ref": "#/definitions/pb.AgentMcpBindingItem" + } + }, "name": { "description": "@go_tags(` + "`" + `json:\"name\"` + "`" + `) 名称", "type": "string" @@ -3776,6 +3783,22 @@ const docTemplate = `{ } } }, + "pb.AgentMcpBindingItem": { + "type": "object", + "properties": { + "svc_id": { + "description": "@go_tags(` + "`" + `json:\"svc_id\"` + "`" + `) MCP 服务 id(详情在 getappconfig_v3 的 thirdsvcs 里)", + "type": "string" + }, + "tools": { + "description": "@go_tags(` + "`" + `json:\"tools\"` + "`" + `) 允许调用的工具名;空=该服务的工具全放开(仍受服务自身 tools 白名单约束)", + "type": "array", + "items": { + "type": "string" + } + } + } + }, "pb.AgentSource": { "description": "智能体来源", "type": "integer", @@ -7593,10 +7616,10 @@ const docTemplate = `{ "type": "object", "properties": { "agents": { - "description": "@go_tags(` + "`" + `json:\"agents\"` + "`" + `) 智能体列表", + "description": "@go_tags(` + "`" + `json:\"agents\"` + "`" + `) 智能体列表(结构同 user_getagents_v3,含 type 与服务编排)", "type": "array", "items": { - "$ref": "#/definitions/pb.DBAgent" + "$ref": "#/definitions/pb.AgentItemV3" } }, "env": { diff --git a/apps/services/swagger/swagger.json b/apps/services/swagger/swagger.json index 687c6312..82f892e3 100644 --- a/apps/services/swagger/swagger.json +++ b/apps/services/swagger/swagger.json @@ -3720,6 +3720,13 @@ "type": "string" } }, + "mcps": { + "description": "@go_tags(`json:\"mcps\"`) MCP 绑定(含每个服务允许的工具);mcp_svc_ids 是它的 id 平铺,保留给老客户端", + "type": "array", + "items": { + "$ref": "#/definitions/pb.AgentMcpBindingItem" + } + }, "name": { "description": "@go_tags(`json:\"name\"`) 名称", "type": "string" @@ -3768,6 +3775,22 @@ } } }, + "pb.AgentMcpBindingItem": { + "type": "object", + "properties": { + "svc_id": { + "description": "@go_tags(`json:\"svc_id\"`) MCP 服务 id(详情在 getappconfig_v3 的 thirdsvcs 里)", + "type": "string" + }, + "tools": { + "description": "@go_tags(`json:\"tools\"`) 允许调用的工具名;空=该服务的工具全放开(仍受服务自身 tools 白名单约束)", + "type": "array", + "items": { + "type": "string" + } + } + } + }, "pb.AgentSource": { "description": "智能体来源", "type": "integer", @@ -7585,10 +7608,10 @@ "type": "object", "properties": { "agents": { - "description": "@go_tags(`json:\"agents\"`) 智能体列表", + "description": "@go_tags(`json:\"agents\"`) 智能体列表(结构同 user_getagents_v3,含 type 与服务编排)", "type": "array", "items": { - "$ref": "#/definitions/pb.DBAgent" + "$ref": "#/definitions/pb.AgentItemV3" } }, "env": { diff --git a/apps/services/swagger/swagger.yaml b/apps/services/swagger/swagger.yaml index d45de266..04043c1a 100644 --- a/apps/services/swagger/swagger.yaml +++ b/apps/services/swagger/swagger.yaml @@ -187,6 +187,12 @@ definitions: items: type: string type: array + mcps: + description: '@go_tags(`json:"mcps"`) MCP 绑定(含每个服务允许的工具);mcp_svc_ids 是它的 + id 平铺,保留给老客户端' + items: + $ref: '#/definitions/pb.AgentMcpBindingItem' + type: array name: description: '@go_tags(`json:"name"`) 名称' type: string @@ -222,6 +228,19 @@ definitions: $ref: '#/definitions/pb.AgentVoiceItem' type: array type: object + pb.AgentMcpBindingItem: + properties: + svc_id: + description: '@go_tags(`json:"svc_id"`) MCP 服务 id(详情在 getappconfig_v3 的 thirdsvcs + 里)' + type: string + tools: + description: '@go_tags(`json:"tools"`) 允许调用的工具名;空=该服务的工具全放开(仍受服务自身 + tools 白名单约束)' + items: + type: string + type: array + type: object pb.AgentSource: description: 智能体来源 enum: @@ -3055,9 +3074,9 @@ definitions: pb.UserGetAppConfigV3Resp: properties: agents: - description: '@go_tags(`json:"agents"`) 智能体列表' + description: '@go_tags(`json:"agents"`) 智能体列表(结构同 user_getagents_v3,含 type 与服务编排)' items: - $ref: '#/definitions/pb.DBAgent' + $ref: '#/definitions/pb.AgentItemV3' type: array env: additionalProperties: