Browse Source

上传代码

main
liwei1dao 2 months ago
parent
commit
af05ff12ce
  1. 156
      apps/admin/app/components/McpToolPicker.vue
  2. 133
      apps/admin/app/pages/agents.vue
  3. 160
      apps/admin/app/pages/serviceconfig.vue
  4. 14
      apps/proto/user/user_msg_v3.proto
  5. 110
      apps/services/cmd/syncgoods/main.go
  6. 5
      apps/services/comm/agentcfg.go
  7. 17
      apps/services/modules/console/api_agent.go
  8. 58
      apps/services/modules/console/api_agent_test.go
  9. 1
      apps/services/modules/console/api_config.go
  10. 161
      apps/services/modules/console/api_mcptools.go
  11. 106
      apps/services/modules/console/api_svcconfig.go
  12. 71
      apps/services/modules/console/api_svcconfig_test.go
  13. 8
      apps/services/modules/console/api_svctemplate.go
  14. 50
      apps/services/modules/console/api_svctemplate_test.go
  15. 4
      apps/services/modules/console/server.go
  16. 37
      apps/services/modules/user/api_v3_getagents.go
  17. 15
      apps/services/modules/user/api_v3_getappconfig.go
  18. 49
      apps/services/modules/user/module_test.go
  19. 122
      apps/services/pb/user_msg_v3.pb.go
  20. 27
      apps/services/swagger/docs.go
  21. 27
      apps/services/swagger/swagger.json
  22. 23
      apps/services/swagger/swagger.yaml

156
apps/admin/app/components/McpToolPicker.vue

@ -0,0 +1,156 @@
<!--
McpToolPicker:连上 MCP 服务把它暴露的工具列出来,勾选成白名单。
以前工具名靠人去问对接方再手抄进 tools 字段,抄错要等线上调用失败才发现;这里直接问服务端
(后端 api_getmcptools 走标准 MCP 握手 initialize → tools/list)。
两处在用:
- 第三方服务配置 → MCP 服务:白名单存服务自身的 tools 字段(逗号分隔),父组件负责 csv ⇄ 数组
- Agent 配置 → MCP 服务绑定:白名单按 agent 存(AgentConfig.mcp_tools[svcId])
空数组 = 全部工具(后续服务端新增的工具自动生效);非空 = 只给勾中的这些。
-->
<template>
<div class="mcp-tools">
<div class="mt-head">
<button class="btn btn-outline btn-primary btn-xs" :disabled="!canFetch || loading || disabled" @click="fetchTools">
<span v-if="loading" class="loading loading-spinner loading-xs"></span>
{{ loading ? '连接中…' : fetched ? '重新获取' : '获取工具列表' }}
</button>
<span v-if="!canFetch" class="mt-hint">{{ hint || '先填写 MCP 服务地址(url),才能获取工具列表' }}</span>
<span v-else-if="err" class="mt-err" :title="err">✕ {{ err }}</span>
<span v-else-if="fetched" class="mt-hint">
服务端共 {{ tools.length }} 个工具{{ from ? `(取自${from})` : '' }},已选 {{ useAll ? '全部' : selected.length }}
</span>
<span v-else-if="modelValue.length" class="mt-hint">当前白名单 {{ modelValue.length }} 个(未获取,显示的是已保存的名字)</span>
<span v-else class="mt-hint">未设置白名单 = 该服务的工具全部可用</span>
</div>
<!-- 没拉取过:把已保存的白名单原样列出来,别让人以为配置丢了 -->
<div v-if="!fetched && modelValue.length" class="mt-saved">
<span v-for="n in modelValue" :key="n" class="tool-chip saved">{{ n }}</span>
</div>
<template v-if="fetched">
<label class="mt-all">
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary" :checked="useAll" :disabled="disabled" @change="toggleAll" />
<span>使用全部工具</span>
<em>勾上则不写白名单,服务端以后新增的工具会自动对它生效</em>
</label>
<div v-if="!tools.length" class="mt-empty">这个 MCP 服务没有暴露任何工具</div>
<div v-else class="tool-list" :class="{ dim: useAll }">
<label v-for="t in tools" :key="t.name" class="tool-item" :class="{ on: !useAll && selected.includes(t.name) }">
<input
type="checkbox"
class="checkbox checkbox-xs checkbox-primary"
:checked="useAll || selected.includes(t.name)"
:disabled="useAll || disabled"
@change="toggle(t.name)"
/>
<span class="min-w-0">
<span class="tool-name">{{ t.name }}</span>
<span v-if="t.description" class="tool-desc">{{ t.description }}</span>
</span>
</label>
</div>
<!-- 白名单里有、服务端却没列出来的:多半是改过名或服务下线了,留着会静默失效 -->
<div v-if="unknown.length" class="mt-unknown">
<span>⚠ 白名单里这些工具服务端没有:</span>
<span v-for="n in unknown" :key="n" class="tool-chip stale">{{ n }}</span>
<button class="btn btn-ghost btn-xs" :disabled="disabled" @click="dropUnknown">清掉</button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useApi } from '~/composables/useApi'
interface McpTool { name: string; description: string }
const props = withDefaults(
defineProps<{
modelValue: string[] // 已选工具名;空数组=全部
fetchParams: Record<string, unknown> // 传给 api_getmcptools 的入参:{url,type} 或 {app_name,id,region}
canFetch?: boolean // 入参是否齐(父组件判断,比如 url 填了没)
hint?: string
disabled?: boolean
}>(),
{ canFetch: true, hint: '', disabled: false },
)
const emit = defineEmits<{ (e: 'update:modelValue', v: string[]): void }>()
const { webApi } = useApi()
const tools = ref<McpTool[]>([])
const loading = ref(false)
const fetched = ref(false)
const err = ref('')
const from = ref('')
const selected = computed(() => props.modelValue ?? [])
const useAll = computed(() => selected.value.length === 0)
// 已保存但服务端列表里没有的工具名
const unknown = computed(() => {
if (!fetched.value) return []
const have = new Set(tools.value.map((t) => t.name))
return selected.value.filter((n) => !have.has(n))
})
async function fetchTools() {
loading.value = true
err.value = ''
try {
const d = await webApi<{ tools: McpTool[]; from?: string }>('getmcptools', props.fetchParams)
tools.value = d?.tools ?? []
from.value = d?.from ?? ''
fetched.value = true
} catch (e: any) {
err.value = e?.message ?? '获取失败'
fetched.value = false
} finally {
loading.value = false
}
}
function toggle(name: string) {
const next = selected.value.includes(name) ? selected.value.filter((n) => n !== name) : [...selected.value, name]
// 勾满了等价于「全部」,直接写回空数组,免得以后服务端加了工具这个 agent 反而用不上
emit('update:modelValue', next.length === tools.value.length && tools.value.length > 0 ? [] : next)
}
// 从「全部」切到「按需勾选」时先给一份全选的具体名单,人再去掉不要的;反向则清空
function toggleAll() {
emit('update:modelValue', useAll.value ? tools.value.map((t) => t.name) : [])
}
function dropUnknown() {
const have = new Set(tools.value.map((t) => t.name))
emit('update:modelValue', selected.value.filter((n) => have.has(n)))
}
defineExpose({ fetchTools })
</script>
<style scoped>
.mcp-tools { border: 1px dashed #e2e8f0; border-radius: 10px; padding: 10px 12px; background: #fcfdfe; }
.mt-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.mt-hint { font-size: 11px; color: #94a3b8; }
.mt-err { font-size: 11px; color: #dc2626; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mt-saved { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
.tool-chip { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10.5px; padding: 2px 7px; border-radius: 6px; }
.tool-chip.saved { background: #f1f5f9; color: #64748b; }
.tool-chip.stale { background: #fef2f2; color: #b91c1c; }
.mt-all { display: flex; align-items: center; gap: 6px; margin-top: 10px; font-size: 12px; color: #475569; cursor: pointer; }
.mt-all em { font-style: normal; font-size: 10.5px; color: #94a3b8; }
.mt-empty { font-size: 11.5px; color: #cbd5e1; padding: 10px 0; }
.tool-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 6px; margin-top: 8px; }
.tool-list.dim { opacity: .45; }
.tool-item { display: flex; align-items: flex-start; gap: 7px; padding: 6px 9px; border: 1px solid #eef2f7; border-radius: 8px; cursor: pointer; transition: all .15s; }
.tool-item:hover { border-color: #c7d2fe; background: #f8fafc; }
.tool-item.on { border-color: #6366f1; background: #eef2ff; }
.tool-name { display: block; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; color: #1e293b; word-break: break-all; }
.tool-desc { display: block; font-size: 10.5px; color: #94a3b8; line-height: 1.45; margin-top: 1px; }
.mt-unknown { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 8px; font-size: 11px; color: #b91c1c; }
</style>

133
apps/admin/app/pages/agents.vue

@ -92,7 +92,11 @@
<div class="pipeline"> <div class="pipeline">
<template v-for="(s, i) in pipelineOf(a)" :key="s.slot"> <template v-for="(s, i) in pipelineOf(a)" :key="s.slot">
<span class="svc-pill" :class="{ miss: !s.id }" :title="svcName(s.id)"> <span
class="svc-pill"
:class="{ miss: !s.id, nocred: credMiss(s.id).length }"
:title="credMiss(s.id).length ? `${svcName(s.id)}:凭据未配置(${credMiss(s.id).join('、')}),客户端拿到的是空值` : svcName(s.id)"
>
<b>{{ s.slot }}</b>{{ svcName(s.id) }} <b>{{ s.slot }}</b>{{ svcName(s.id) }}
</span> </span>
<span v-if="i < pipelineOf(a).length - 1" class="pipe-arrow">›</span> <span v-if="i < pipelineOf(a).length - 1" class="pipe-arrow">›</span>
@ -102,7 +106,7 @@
<div class="card-stats"> <div class="card-stats">
<span :title="a.support_langs || '未设置支持语言'">🌐 {{ langCount(a) || '—' }} 语言</span> <span :title="a.support_langs || '未设置支持语言'">🌐 {{ langCount(a) || '—' }} 语言</span>
<span>🎙 {{ (a.voice_map || []).length || '—' }} 音色</span> <span>🎙 {{ (a.voice_map || []).length || '—' }} 音色</span>
<span>🧩 {{ (a.mcp_svc_ids || []).length || '—' }} MCP</span> <span :title="mcpStatTitle(a)">🧩 {{ (a.mcp_svc_ids || []).length || '—' }} MCP</span>
<span>🔤 {{ (a.variables || []).length || '—' }} 变量</span> <span>🔤 {{ (a.variables || []).length || '—' }} 变量</span>
</div> </div>
@ -258,13 +262,14 @@
<select v-model="(form as any)[s.key]" class="select select-bordered select-sm w-full"> <select v-model="(form as any)[s.key]" class="select select-bordered select-sm w-full">
<option value="" disabled>{{ svcsByCats(s.cats).length ? '请选择服务' : '(无可用服务,请先去第三方服务配置里添加)' }}</option> <option value="" disabled>{{ svcsByCats(s.cats).length ? '请选择服务' : '(无可用服务,请先去第三方服务配置里添加)' }}</option>
<option v-for="v in svcsByCats(s.cats)" :key="v.id" :value="v.id"> <option v-for="v in svcsByCats(s.cats)" :key="v.id" :value="v.id">
{{ v.name }}{{ isVisionSvc(v) ? ' 🖼' : '' }}{{ v.global ? '(全局兜底)' : '' }} {{ v.name }}{{ isVisionSvc(v) ? ' 🖼' : '' }}{{ v.global ? '(全局兜底)' : '' }}{{ credMiss(v.id).length ? ' ⚠ 未配置凭据' : '' }}
</option> </option>
</select> </select>
</div> </div>
<span v-if="i < slotsOfType(form.type).length - 1" class="slot-arrow">→</span> <span v-if="i < slotsOfType(form.type).length - 1" class="slot-arrow">→</span>
</template> </template>
</div> </div>
<p v-if="slotCredWarn" class="ed-warn">⚠ {{ slotCredWarn }}</p>
<p class="ed-tip"> <p class="ed-tip">
候选 = 本应用已启用的服务 + 「全局兜底」的服务(标注了的那些,来自全局默认作用域)。 候选 = 本应用已启用的服务 + 「全局兜底」的服务(标注了的那些,来自全局默认作用域)。
引用全局服务是允许的:应用没单独配时本来就走它。其它应用的服务不会出现在这里。 引用全局服务是允许的:应用没单独配时本来就走它。其它应用的服务不会出现在这里。
@ -397,27 +402,36 @@
<div class="ed-block-title">MCP 服务绑定(给 Agent 挂工具)</div> <div class="ed-block-title">MCP 服务绑定(给 Agent 挂工具)</div>
<p v-if="!mcpSvcs.length" class="ed-tip">当前作用域还没有已启用的 MCP 服务,先去「第三方服务配置 → MCP 服务」添加。</p> <p v-if="!mcpSvcs.length" class="ed-tip">当前作用域还没有已启用的 MCP 服务,先去「第三方服务配置 → MCP 服务」添加。</p>
<template v-else> <template v-else>
<p class="ed-tip mb-3">勾选后,客户端会把这些 MCP 服务的工具挂给该 Agent;服务地址与工具白名单在第三方服务配置里维护,这里只存引用。</p> <p class="ed-tip mb-3">
<div class="mcp-grid"> 勾选服务后,再点「获取工具列表」把它暴露的工具拉下来,按需勾选这个 Agent 能用哪些。
<label 挂多个 MCP 时尤其要收口:用不上的工具会挤占模型注意力、同名工具还会互相干扰。不勾具体工具 = 该服务的工具全放开。
v-for="m in mcpSvcs" </p>
:key="m.id" <div class="mcp-list">
class="mcp-item" <div v-for="m in mcpSvcs" :key="m.id" class="mcp-card" :class="{ on: form.mcp_svc_ids.includes(m.id) }">
:class="{ on: form.mcp_svc_ids.includes(m.id) }" <label class="mcp-card-head">
> <input
<input type="checkbox"
type="checkbox" class="checkbox checkbox-xs checkbox-primary"
class="checkbox checkbox-xs checkbox-primary" :checked="form.mcp_svc_ids.includes(m.id)"
:checked="form.mcp_svc_ids.includes(m.id)" @change="toggleMcp(m.id)"
@change="toggleMcp(m.id)" />
/> <span class="min-w-0 flex-1">
<span class="min-w-0"> <span class="block truncate font-medium">
<span class="block truncate font-medium"> {{ m.name }}<em v-if="m.global" class="glb-tag">全局</em>
{{ m.name }}<em v-if="m.global" class="glb-tag">全局</em> </span>
<span class="block truncate text-[10px] text-slate-400 font-mono">{{ m.id }}</span>
</span> </span>
<span class="block truncate text-[10px] text-slate-400 font-mono">{{ m.id }}</span> <span class="mcp-count">{{ mcpToolsOf(m.id).length ? `已选 ${mcpToolsOf(m.id).length} 个工具` : '全部工具' }}</span>
</span> </label>
</label> <div v-if="form.mcp_svc_ids.includes(m.id)" class="mcp-card-body">
<McpToolPicker
:model-value="mcpToolsOf(m.id)"
:fetch-params="{ app_name: scopeApp, id: m.id }"
hint="该服务还没配 url,去「第三方服务配置」补上才能列工具"
@update:model-value="(v: string[]) => setMcpTools(m.id, v)"
/>
</div>
</div>
</div> </div>
</template> </template>
</div> </div>
@ -449,6 +463,7 @@ import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { LANG_OPTIONS } from '~/composables/useLangOptions' import { LANG_OPTIONS } from '~/composables/useLangOptions'
import LangSelect from '~/components/LangSelect.vue' import LangSelect from '~/components/LangSelect.vue'
import McpToolPicker from '~/components/McpToolPicker.vue'
definePageMeta({ title: 'Agent 配置' }) definePageMeta({ title: 'Agent 配置' })
@ -494,6 +509,7 @@ interface Agent {
default_lang: string default_lang: string
voice_map: AgentVoice[] voice_map: AgentVoice[]
mcp_svc_ids: string[] mcp_svc_ids: string[]
mcp_tools: Record<string, string[]>
variables: AgentVariable[] variables: AgentVariable[]
sort: number sort: number
} }
@ -574,6 +590,9 @@ const scopeApps = ref<string[]>([])
const agents = ref<Agent[]>([]) const agents = ref<Agent[]>([])
const allSvcs = ref<SvcConfig[]>([]) // 当前作用域(应用/全局)自己的第三方服务 const allSvcs = ref<SvcConfig[]>([]) // 当前作用域(应用/全局)自己的第三方服务
const globalSvcs = ref<SvcConfig[]>([]) // 全局默认作用域的服务:应用作用域下作为兜底候选 const globalSvcs = ref<SvcConfig[]>([]) // 全局默认作用域的服务:应用作用域下作为兜底候选
// svc_id → 一个值都没有的凭据字段(后端 getsvcconfigs 算好:基础行与所有区域分叉都空才算缺)。
// 引用了这种服务的 agent,配置下发到客户端就是一串空字符串,运行时必然鉴权失败。
const credMissing = ref<Record<string, string[]>>({})
const ttsVoices = ref<TTSVoice[]>([]) const ttsVoices = ref<TTSVoice[]>([])
const keyword = ref('') const keyword = ref('')
const filterType = ref('') const filterType = ref('')
@ -588,7 +607,7 @@ function emptyForm(): Agent {
app_name: '', id: '', name: '', type: 'chat', description: '', avatar_url: '', enable: true, 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: '', 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: '', 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<Agent>(emptyForm()) const form = reactive<Agent>(emptyForm())
@ -625,6 +644,15 @@ const grouped = computed(() => {
} }
return groups 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 { function langCount(a: Agent): number {
return (a.support_langs || '').split(',').map((x) => x.trim()).filter(Boolean).length return (a.support_langs || '').split(',').map((x) => x.trim()).filter(Boolean).length
} }
@ -657,18 +685,22 @@ async function loadScopeApps() {
// 只是不能引用「别的应用」的服务——那些本就不在这两次查询的结果里。 // 只是不能引用「别的应用」的服务——那些本就不在这两次查询的结果里。
async function loadSvcs() { async function loadSvcs() {
const app = scopeApp.value const app = scopeApp.value
type SvcResp = { items: SvcConfig[]; cred_missing?: Record<string, string[]> }
const [own, global] = await Promise.all([ const [own, global] = await Promise.all([
webApi<{ items: SvcConfig[] }>('getsvcconfigs', { app_name: app }).catch(() => null), webApi<SvcResp>('getsvcconfigs', { app_name: app }).catch(() => null),
app ? webApi<{ items: SvcConfig[] }>('getsvcconfigs', { app_name: '' }).catch(() => null) : Promise.resolve(null), app ? webApi<SvcResp>('getsvcconfigs', { app_name: '' }).catch(() => null) : Promise.resolve(null),
]) ])
allSvcs.value = own?.items ?? [] allSvcs.value = own?.items ?? []
globalSvcs.value = global?.items ?? [] globalSvcs.value = global?.items ?? []
// 应用内同 id 的服务覆盖全局,缺凭据判定也按这个顺序:先铺全局,再让应用作用域的结论盖上去
credMissing.value = { ...(global?.cred_missing ?? {}), ...(own?.cred_missing ?? {}) }
} }
async function loadAgents() { async function loadAgents() {
try { try {
const d = await webApi<{ items: Agent[] }>('getagents', { app_name: scopeApp.value }) const d = await webApi<{ items: Agent[] }>('getagents', { app_name: scopeApp.value })
agents.value = (d?.items ?? []).map((a) => ({ 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) { } catch (e: any) {
error(e?.message ?? '加载失败') error(e?.message ?? '加载失败')
@ -748,7 +780,20 @@ function addVoice() {
const mcpSvcs = computed(() => svcsByCats([CAT_MCP])) const mcpSvcs = computed(() => svcsByCats([CAT_MCP]))
function toggleMcp(id: string) { function toggleMcp(id: string) {
const i = form.mcp_svc_ids.indexOf(id) 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 { function isVisionSvc(s: SvcConfig): boolean {
return svcCats(s).includes(CAT_LLM_VISION) return svcCats(s).includes(CAT_LLM_VISION)
} }
// 该服务的凭据是不是一个都没配(配了才有的下拉/胶囊红标)
function credMiss(id?: string): string[] {
return (id && credMissing.value[id]) || []
}
function svcName(id?: string): string { function svcName(id?: string): string {
if (!id) return '未配置' if (!id) return '未配置'
const s = svcPool.value.find((x) => x.id === id) 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) : '' if (key === 'svc') return form.voice_map.length ? String(form.voice_map.length) : ''
return '' 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(() => { const missingHint = computed(() => {
if (!form.name.trim()) return '还差:名称' if (!form.name.trim()) return '还差:名称'
@ -849,6 +910,7 @@ function openModal(a: Agent | null, editing: boolean) {
Object.assign(form, emptyForm(), a ?? {}, { Object.assign(form, emptyForm(), a ?? {}, {
voice_map: (a?.voice_map ?? []).map((v) => ({ ...v })), voice_map: (a?.voice_map ?? []).map((v) => ({ ...v })),
mcp_svc_ids: [...(a?.mcp_svc_ids ?? [])], mcp_svc_ids: [...(a?.mcp_svc_ids ?? [])],
mcp_tools: JSON.parse(JSON.stringify(a?.mcp_tools ?? {})),
variables: (a?.variables ?? []).map((v) => ({ ...v })), variables: (a?.variables ?? []).map((v) => ({ ...v })),
}) })
isEditing.value = editing 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.voice_map = (a.voice_map ?? []).filter((v) => v.language && v.voice)
payload.mcp_svc_ids = (a.mcp_svc_ids ?? []).filter(Boolean) 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()) payload.variables = (a.variables ?? []).filter((v) => (v.key || '').trim())
return payload 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 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.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; } .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; } .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, .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; } .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-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 { 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; } .mcp-item:hover { border-color: #c7d2fe; background: #f8fafc; }

160
apps/admin/app/pages/serviceconfig.vue

@ -58,6 +58,11 @@
<span v-if="s.provider" class="provider-chip" :title="s.provider">{{ s.provider }}</span> <span v-if="s.provider" class="provider-chip" :title="s.provider">{{ s.provider }}</span>
<span class="field-count">{{ (s.fields || []).length }} 字段</span> <span class="field-count">{{ (s.fields || []).length }} 字段</span>
<span v-if="encCount(s)" class="lock-badge">🔒{{ encCount(s) }}</span> <span v-if="encCount(s)" class="lock-badge">🔒{{ encCount(s) }}</span>
<span
v-if="credMissing[s.id]"
class="cred-badge"
:title="`凭据未配置:${credMissing[s.id].join('、')}(基础行与各区域分叉都没有值)。客户端会拿到空字符串,运行时必然鉴权失败。`"
>⚠ 缺凭据</span>
</div> </div>
<div class="region-row"> <div class="region-row">
<span class="region-chip" :class="regionChip(s).cls" :title="regionChip(s).title">{{ regionChip(s).text }}</span> <span class="region-chip" :class="regionChip(s).cls" :title="regionChip(s).title">{{ regionChip(s).text }}</span>
@ -148,7 +153,10 @@
音色本身是代码里的常量表(后端 comm/ttsvoice.go),实际用哪个在 Agent 里按语言+性别选,同组第一个是默认。 音色本身是代码里的常量表(后端 comm/ttsvoice.go),实际用哪个在 Agent 里按语言+性别选,同组第一个是默认。
</p> </p>
</div> </div>
<div class="divider text-xs text-slate-400 my-3">配置字段(全区域共用)</div> <div class="flex items-center justify-between mt-4 mb-2">
<span class="text-sm font-medium text-slate-700">配置字段(全区域共用)</span>
<button class="btn btn-ghost btn-xs" :title="fillFromTemplateTip" @click="fillFieldsFromTemplate">按模板补齐字段</button>
</div>
<div v-if="isMcpType()" class="rounded-lg px-3 py-2 mb-3 text-xs" style="background:#f0fdfa;border:1px solid #99f6e4;color:#0f766e"> <div v-if="isMcpType()" class="rounded-lg px-3 py-2 mb-3 text-xs" style="background:#f0fdfa;border:1px solid #99f6e4;color:#0f766e">
✦ MCP 服务与其他第三方服务同表存储(svc_config):这里填的 url/type/tools 为<b>全区域默认</b>; ✦ MCP 服务与其他第三方服务同表存储(svc_config):这里填的 url/type/tools 为<b>全区域默认</b>;
只在某些区域生效的地址,切到对应区域做分叉即可,卡片上会显示已分叉的区域数。 只在某些区域生效的地址,切到对应区域做分叉即可,卡片上会显示已分叉的区域数。
@ -190,6 +198,19 @@
</div> </div>
<button class="btn btn-ghost btn-sm mt-2 text-primary" @click="addField">+ 添加字段</button> <button class="btn btn-ghost btn-sm mt-2 text-primary" @click="addField">+ 添加字段</button>
</div> </div>
<!-- MCP 工具白名单:连服务端把工具列出来勾,别再手抄工具名 -->
<div v-if="isMcpType()" class="mt-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-slate-700">工具白名单</span>
<span class="text-[11px] text-slate-400">勾选结果写入上面的 tools 字段(逗号分隔,空=全部)</span>
</div>
<McpToolPicker
v-model="mcpEditTools"
:fetch-params="{ url: mcpEditUrl, type: mcpEditType }"
:can-fetch="!!mcpEditUrl"
/>
</div>
</template> </template>
<!-- 新增服务表单(类型优先向导:① 服务类型 → ② 服务商 → ③ 配置) --> <!-- 新增服务表单(类型优先向导:① 服务类型 → ② 服务商 → ③ 配置) -->
@ -341,6 +362,19 @@
</div> </div>
<button class="btn btn-ghost btn-sm mt-2 text-primary" @click="addField">+ 添加字段</button> <button class="btn btn-ghost btn-sm mt-2 text-primary" @click="addField">+ 添加字段</button>
</div> </div>
<!-- MCP 工具白名单:连服务端把工具列出来勾,别再手抄工具名 -->
<div v-if="isMcpType()" class="mt-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-slate-700">工具白名单</span>
<span class="text-[11px] text-slate-400">勾选结果写入上面的 tools 字段(逗号分隔,空=全部)</span>
</div>
<McpToolPicker
v-model="mcpEditTools"
:fetch-params="{ url: mcpEditUrl, type: mcpEditType }"
:can-fetch="!!mcpEditUrl"
/>
</div>
</template> </template>
</template> </template>
@ -407,6 +441,20 @@
</table> </table>
</div> </div>
<!-- MCP 工具白名单(本区域):探的是本区域生效的 url -->
<div v-if="isMcpType()" class="mt-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-slate-700">工具白名单({{ curRegionLabel }})</span>
<span class="text-[11px] text-slate-400">写入本区域 tools 覆盖值;全清空=留空=继承默认</span>
</div>
<McpToolPicker
v-model="mcpRgnTools"
:fetch-params="{ url: mcpRgnUrl, type: mcpRgnType }"
:can-fetch="!!mcpRgnUrl"
hint="本区域没有生效的 url(覆盖值与默认值都为空)"
/>
</div>
<!-- 区域专属字段 --> <!-- 区域专属字段 -->
<div class="divider text-xs text-slate-400 my-3">{{ curRegionLabel }} 专属字段</div> <div class="divider text-xs text-slate-400 my-3">{{ curRegionLabel }} 专属字段</div>
<div> <div>
@ -671,7 +719,7 @@ interface RegionExtraField {
} }
const { webApi, consoleApi } = useApi() const { webApi, consoleApi } = useApi()
const { success, error } = useToast() const { success, error, warn } = useToast()
// ===== 作用域:全局默认('') 或 某应用(app_name) ===== // ===== 作用域:全局默认('') 或 某应用(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: '' } return SCAT.find((s) => s.v === v) || { short: '?', color: '#999', bg: '#eee', v: 0, label: '' }
} }
// 当前编辑的是否 MCP 服务:新增向导看 editType,默认/区域编辑看当前服务 categories。 // 当前编辑的是否 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<T extends { key: string }>(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<string[]>({
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<string[]>({
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 { function isMcpType(): boolean {
if (editType.value === SCAT_MCP) return true if (editType.value === SCAT_MCP) return true
return !!curSvc.value && parseCats(curSvc.value.categories).includes(SCAT_MCP) return !!curSvc.value && parseCats(curSvc.value.categories).includes(SCAT_MCP)
@ -791,6 +914,9 @@ const allSvcs = ref<ThirdSvcConfig[]>([])
// svc_id → 已建区域分叉的区域枚举(后端 getsvcconfigs 一并返回)。卡片据此显示区域标识: // svc_id → 已建区域分叉的区域枚举(后端 getsvcconfigs 一并返回)。卡片据此显示区域标识:
// MCP 这类值全部落在区域分叉上的服务,不显示的话在默认视图里看着像「什么都没配」。 // MCP 这类值全部落在区域分叉上的服务,不显示的话在默认视图里看着像「什么都没配」。
const svcRegions = ref<Record<string, number[]>>({}) const svcRegions = ref<Record<string, number[]>>({})
// svc_id → 一个值都没有的凭据字段(后端 getsvcconfigs 一并算好:基础行与所有区域分叉都空才算缺)。
// 前端自己判不了——加密字段返回的是脱敏值,且凭据可能只配在某个区域分叉上。
const credMissing = ref<Record<string, string[]>>({})
const curSvc = ref<ThirdSvcConfig | null>(null) const curSvc = ref<ThirdSvcConfig | null>(null)
const curRegion = ref(0) const curRegion = ref(0)
const svcModalVisible = ref(false) const svcModalVisible = ref(false)
@ -854,14 +980,16 @@ const templatesByCat = computed(() => {
// ===== 加载服务列表 ===== // ===== 加载服务列表 =====
async function loadSvcs() { async function loadSvcs() {
try { try {
const data = await webApi<{ items: ThirdSvcConfig[]; regions?: Record<string, number[]> }>('getsvcconfigs', { app_name: scopeApp.value }) const data = await webApi<{ items: ThirdSvcConfig[]; regions?: Record<string, number[]>; cred_missing?: Record<string, string[]> }>('getsvcconfigs', { app_name: scopeApp.value })
allSvcs.value = data?.items ?? [] allSvcs.value = data?.items ?? []
svcRegions.value = data?.regions ?? {} svcRegions.value = data?.regions ?? {}
credMissing.value = data?.cred_missing ?? {}
backendReady.value = true backendReady.value = true
} catch { } catch {
backendReady.value = false backendReady.value = false
allSvcs.value = [] allSvcs.value = []
svcRegions.value = {} svcRegions.value = {}
credMissing.value = {}
return return
} }
if (curSvc.value) { if (curSvc.value) {
@ -1048,12 +1176,27 @@ function collectOverrideData() {
return { overrides, disabled_keys, extra_fields } 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<boolean> { async function performSave(isNew: boolean): Promise<boolean> {
const isNew2 = isNew || !curSvc.value const isNew2 = isNew || !curSvc.value
if (curRegion.value === 0) { if (curRegion.value === 0) {
const data = collectDefaultData(isNew2) const data = collectDefaultData(isNew2)
if (!data.id || !data.name) { error('服务ID 和名称 必填'); return false } if (!data.id || !data.name) { error('服务ID 和名称 必填'); return false }
if (!confirmEmptyFields()) return false
const method = isNew2 ? 'addsvcconfig' : 'updatesvcconfig' const method = isNew2 ? 'addsvcconfig' : 'updatesvcconfig'
try { try {
const res = await webApi<ThirdSvcConfig>(method, data) const res = await webApi<ThirdSvcConfig>(method, data)
@ -1375,6 +1518,17 @@ onMounted(() => {
.meta-row > .lock-badge { .meta-row > .lock-badge {
flex-shrink: 0; 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 { .region-row {
display: flex; display: flex;

14
apps/proto/user/user_msg_v3.proto

@ -8,6 +8,9 @@ import "db.proto";
// - 不再单独下发 mcps:MCP 已并入第三方服务配置(svc_config 的 MCP 类别),在 thirdsvcs 里, // - 不再单独下发 mcps:MCP 已并入第三方服务配置(svc_config 的 MCP 类别),在 thirdsvcs 里,
// 按 categories 含 "10" 识别;url/type/tools 在 fields 里。 // 按 categories 含 "10" 识别;url/type/tools 在 fields 里。
// - 新增 meet:会议记录支持的语言与可选总结大模型,来自后台「会议记录服务」编排。 // - 新增 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 里的凭据不再单独加密。 // 整体响应经网关 AES-CBC 加密(响应头 X-Encrypted:1),故 fields 里的凭据不再单独加密。
message UserGetAppConfigV3Req{ message UserGetAppConfigV3Req{
@ -32,7 +35,7 @@ message MeetCapabilitiesItem{
message UserGetAppConfigV3Resp{ message UserGetAppConfigV3Resp{
map<string,string> env = 1; //@go_tags(`json:"env"`) 应用环境变量(app_config + 本区域 global_config) map<string,string> 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 DBProduct products = 3; //@go_tags(`json:"products"`) 产品列表
repeated ThirdSvcItem thirdsvcs = 4; //@go_tags(`json:"thirdsvcs"`) 第三方服务(含 MCP 类,按用户区域合并、凭据已解密为明文) repeated ThirdSvcItem thirdsvcs = 4; //@go_tags(`json:"thirdsvcs"`) 第三方服务(含 MCP 类,按用户区域合并、凭据已解密为明文)
MeetCapabilitiesItem meet = 5; //@go_tags(`json:"meet"`) 会议记录能力(语言 + 总结大模型) MeetCapabilitiesItem meet = 5; //@go_tags(`json:"meet"`) 会议记录能力(语言 + 总结大模型)
@ -53,6 +56,14 @@ message AgentVariableItem{
string description = 4; //@go_tags(`json:"description"`) 备注 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{ message AgentItemV3{
string id = 1; //@go_tags(`json:"id"`) agent id string id = 1; //@go_tags(`json:"id"`) agent id
string name = 2; //@go_tags(`json:"name"`) 名称 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 string mcp_svc_ids = 17; //@go_tags(`json:"mcp_svc_ids"`) 绑定的 MCP 服务 id(详情在 getappconfig_v3 的 thirdsvcs 里)
repeated AgentVariableItem variables = 18; //@go_tags(`json:"variables"`) 变量定义 repeated AgentVariableItem variables = 18; //@go_tags(`json:"variables"`) 变量定义
int32 sort = 19; //@go_tags(`json:"sort"`) 排序 int32 sort = 19; //@go_tags(`json:"sort"`) 排序
repeated AgentMcpBindingItem mcps = 20; //@go_tags(`json:"mcps"`) MCP 绑定(含每个服务允许的工具);mcp_svc_ids 是它的 id 平铺,保留给老客户端
} }
message UserGetAgentsV3Resp{ message UserGetAgentsV3Resp{

110
apps/services/cmd/syncgoods/main.go

@ -0,0 +1,110 @@
// 一次性工具:把某个应用业务库 goods 表导出的 JSON 同步进 console 主库的 app_goods 表。
// 用法: go run ./cmd/syncgoods -dsn <postgres 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])
}

5
apps/services/comm/agentcfg.go

@ -78,6 +78,11 @@ type AgentConfig struct {
// McpSvcIds 绑定的 MCP 服务(引用 ThirdSvcConfig.Id,类别须含 SvcCatMCP)。 // McpSvcIds 绑定的 MCP 服务(引用 ThirdSvcConfig.Id,类别须含 SvcCatMCP)。
// 空=不给该 agent 挂工具。MCP 服务本身的 url/工具白名单在第三方服务配置里,这里只存引用。 // 空=不给该 agent 挂工具。MCP 服务本身的 url/工具白名单在第三方服务配置里,这里只存引用。
McpSvcIds []string `gorm:"column:mcp_svc_ids;type:jsonb;serializer:json" json:"mcp_svc_ids"` 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 提示词/开场白里的变量占位({{key}}),见 AgentVariable。
Variables []AgentVariable `gorm:"column:variables;type:jsonb;serializer:json" json:"variables"` Variables []AgentVariable `gorm:"column:variables;type:jsonb;serializer:json" json:"variables"`
Sort int32 `gorm:"column:sort" json:"sort"` Sort int32 `gorm:"column:sort" json:"sort"`

17
apps/services/modules/console/api_agent.go

@ -143,6 +143,23 @@ func normalizeAgent(a *AgentConfig) {
} }
} }
a.McpSvcIds = ids 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)) vars := make([]comm.AgentVariable, 0, len(a.Variables))
for _, v := range a.Variables { for _, v := range a.Variables {
if v.Key = strings.TrimSpace(v.Key); v.Key != "" { if v.Key = strings.TrimSpace(v.Key); v.Key != "" {

58
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)
}
}
}

1
apps/services/modules/console/api_config.go

@ -96,6 +96,7 @@ func ensureConfigTables() error {
for _, s := range []string{ 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 mcp_svc_ids jsonb",
"ALTER TABLE " + comm.TableAgentConfig + " ADD COLUMN IF NOT EXISTS variables 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 { if res := postgres.Exec(s); res.Error != nil {
return res.Error return res.Error

161
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
}

106
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()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return return
} }
// 凭据缺失清单要在打码前算:maskSvcFields 只把**非空**密文替换成 [encrypted],空值原样是 ""。
missing := svcCredMissing(req.AppName, items)
for _, item := range items { for _, item := range items {
maskSvcFields(item.Fields) 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 汇总某作用域下每个服务已建分叉的区域列表。空壳分叉(既没覆盖值、也没区域专属 // svcRegionMap 汇总某作用域下每个服务已建分叉的区域列表。空壳分叉(既没覆盖值、也没区域专属
@ -121,6 +165,58 @@ func svcRegionMap(appName string) map[string][]int32 {
} }
// addSvcConfig 新增依赖服务。encrypted=true 的字段值加密存储。 // 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) { func (this *serverComp) addSvcConfig(c *gin.Context) {
var m ThirdSvcConfig var m ThirdSvcConfig
if err := c.ShouldBindJSON(&m); err != nil { 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()) writeErr(c, pb.ErrorCode_SystemError, "字段加密失败: "+err.Error())
return return
} }
if msg := checkSvcRequiredFields(&m); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
m.RowId = 0 // 代理主键由库自增回填 m.RowId = 0 // 代理主键由库自增回填
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
m.Createtime = now m.Createtime = now
@ -180,6 +280,10 @@ func (this *serverComp) updateSvcConfig(c *gin.Context) {
writeErr(c, pb.ErrorCode_SystemError, "字段加密失败: "+err.Error()) writeErr(c, pb.ErrorCode_SystemError, "字段加密失败: "+err.Error())
return return
} }
if msg := checkSvcRequiredFields(&m); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
m.RowId = old.RowId // 按代理主键更新本行 m.RowId = old.RowId // 按代理主键更新本行
m.Createtime = old.Createtime m.Createtime = old.Createtime
m.Updatetime = time.Now().UnixMilli() m.Updatetime = time.Now().UnixMilli()

71
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)
}
}

8
apps/services/modules/console/api_svctemplate.go

@ -238,11 +238,15 @@ func builtinSvcTemplates() []ThirdSvcTemplate {
tpl("llm_doubao", "doubao", "豆包(火山引擎)", svcCatLLM, 1, tpl("llm_doubao", "doubao", "豆包(火山引擎)", svcCatLLM, 1,
sf("api_key", "API Key", true), sf("endpoint_id", "推理接入点 ID", false), sf("base_url", "接口地址", false)), sf("api_key", "API Key", true), sf("endpoint_id", "推理接入点 ID", false), sf("base_url", "接口地址", false)),
tpl("llm_anthropic", "anthropic", "Anthropic Claude", svcCatLLM, 2, 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, tpl("llm_deepseek", "deepseek", "DeepSeek", svcCatLLM, 3,
sf("api_key", "API Key", true), sf("model", "模型,如 deepseek-chat", false), sf("base_url", "接口地址", false)), 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, 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 的运行时凭据) // 录音文件识别(会议记录离线转写;字段对齐 modules/echomeet 各 provider 的运行时凭据)
// //

50
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)
}
}
}

4
apps/services/modules/console/server.go

@ -409,6 +409,10 @@ func (this *serverComp) handleWeb(c *gin.Context) {
case "api_delagent": case "api_delagent":
this.delAgent(c) this.delAgent(c)
return return
// MCP 工具发现:连上 MCP 服务列出它暴露的工具(服务配置页填完 url 点一下、agent 绑定时勾工具都用它)。
case "api_getmcptools":
this.getMcpTools(c)
return
// TTS 音色表(代码常量,见 comm/ttsvoice.go):后台 agent 配音色时用它渲染候选,不落库。 // TTS 音色表(代码常量,见 comm/ttsvoice.go):后台 agent 配音色时用它渲染候选,不落库。
case "api_getttsvoices": case "api_getttsvoices":
this.getTTSVoices(c) this.getTTSVoices(c)

37
apps/services/modules/user/api_v3_getagents.go

@ -23,12 +23,24 @@ import (
// 注册在 apiV3Comp(Version=v3)上,路由 = user_getagents_v3;已在 EncryptMsgs 中声明 // 注册在 apiV3Comp(Version=v3)上,路由 = user_getagents_v3;已在 EncryptMsgs 中声明
// (系统提示词/编排关系不明文出网关)。v2 的 user_getagents_v2 保留不动。 // (系统提示词/编排关系不明文出网关)。v2 的 user_getagents_v2 保留不动。
func (this *apiV3Comp) GetAgents(session comm.IUserSession, req *pb.UserGetAgentsV3Req) (resp *pb.UserGetAgentsV3Resp, errdata *pb.ErrorData) { 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() app := comm.AppName()
rows := make([]*comm.AgentConfig, 0) rows := make([]*comm.AgentConfig, 0)
if err := postgres.Find(comm.TableAgentConfig, &rows, "(app_name=? OR app_name='')", app); err != nil && err != postgres.ErrNoDocuments { 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 nil, err
return
} }
// 同 id 应用行覆盖全局行;仅启用的(应用行停用则回退全局行,与第三方服务下发同语义)。 // 同 id 应用行覆盖全局行;仅启用的(应用行停用则回退全局行,与第三方服务下发同语义)。
@ -53,7 +65,7 @@ func (this *apiV3Comp) GetAgents(session comm.IUserSession, req *pb.UserGetAgent
}) })
providerOf := this.svcProviders(app) 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 { for _, a := range list {
item := &pb.AgentItemV3{ item := &pb.AgentItemV3{
Id: a.Id, Id: a.Id,
@ -72,6 +84,7 @@ func (this *apiV3Comp) GetAgents(session comm.IUserSession, req *pb.UserGetAgent
SupportLangs: a.SupportLangs, SupportLangs: a.SupportLangs,
DefaultLang: a.DefaultLang, DefaultLang: a.DefaultLang,
McpSvcIds: append([]string{}, a.McpSvcIds...), McpSvcIds: append([]string{}, a.McpSvcIds...),
Mcps: agentMcpBindings(a),
Sort: a.Sort, Sort: a.Sort,
VoiceMap: resolveAgentVoices(a, providerOf[voiceSvcIdOf(a)]), VoiceMap: resolveAgentVoices(a, providerOf[voiceSvcIdOf(a)]),
Variables: make([]*pb.AgentVariableItem, 0, len(a.Variables)), 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, 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 的映射(应用行覆盖全局行)。 // svcProviders 取本应用作用域下 svc_id → provider 的映射(应用行覆盖全局行)。

15
apps/services/modules/user/api_v3_getappconfig.go

@ -11,7 +11,9 @@ import (
// @Summary 获取应用配置(v3) // @Summary 获取应用配置(v3)
// @Description 一次性下发客户端启动所需的全部配置:环境变量 + 智能体 + 产品 + 第三方服务(含 MCP) + 会议记录能力。 // @Description 一次性下发客户端启动所需的全部配置:环境变量 + 智能体 + 产品 + 第三方服务(含 MCP) + 会议记录能力。
// 与 v2 的差别:不再单独下发 mcps(MCP 已并入第三方服务配置,在 thirdsvcs 里按 categories 含 "10" 识别); // 与 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 // @Tags User
// @Accept json // @Accept json
// @Produce json // @Produce json
@ -36,9 +38,18 @@ func (this *apiV3Comp) GetAppConfig(session comm.IUserSession, req *pb.UserGetAp
) )
// 环境变量:应用自有配置(app_config) 打底,本区域的 global_config 覆盖(口径同 v2)。 // 环境变量:应用自有配置(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) 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() products, err := this.module.cache.GetProducts()
if err != nil && err != mysql.ErrNoDocuments { if err != nil && err != mysql.ErrNoDocuments {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()} errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}

49
apps/services/modules/user/module_test.go

@ -181,7 +181,7 @@ func TestV3MeetCapabilitiesEmptySnapshot(t *testing.T) {
func TestV3AppConfigRespContract(t *testing.T) { func TestV3AppConfigRespContract(t *testing.T) {
resp := &pb.UserGetAppConfigV3Resp{ resp := &pb.UserGetAppConfigV3Resp{
Env: map[string]string{}, Env: map[string]string{},
Agents: []*pb.DBAgent{}, Agents: []*pb.AgentItemV3{}, // v3 下发的是带 type/服务编排的新结构,不是旧 agent 表
Products: []*pb.DBProduct{}, Products: []*pb.DBProduct{},
Thirdsvcs: []*pb.ThirdSvcItem{}, Thirdsvcs: []*pb.ThirdSvcItem{},
Meet: newV3Comp(nil).meetCapabilities(), 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 后这是唯一识别方式。 // 客户端靠 categories 含 "10" 从 thirdsvcs 里挑出 MCP 服务——v3 去掉 mcps 后这是唯一识别方式。
func TestV3McpIdentifiedByCategories(t *testing.T) { func TestV3McpIdentifiedByCategories(t *testing.T) {
svcs := []*pb.ThirdSvcItem{ 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", 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)) 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 { 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 不行——客户端会崩。 // meet 是 v3 新增的:为空可以(后台没编排会议记录),为 null 不行——客户端会崩。

122
apps/services/pb/user_msg_v3.pb.go

@ -206,7 +206,7 @@ func (x *MeetCapabilitiesItem) GetSummaryServices() []*MeetSummaryServiceItem {
type UserGetAppConfigV3Resp struct { type UserGetAppConfigV3Resp struct {
state protoimpl.MessageState `protogen:"open.v1"` 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) 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"`) 产品列表 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 类,按用户区域合并、凭据已解密为明文) 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"`) 会议记录能力(语言 + 总结大模型) 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 return nil
} }
func (x *UserGetAppConfigV3Resp) GetAgents() []*DBAgent { func (x *UserGetAppConfigV3Resp) GetAgents() []*AgentItemV3 {
if x != nil { if x != nil {
return x.Agents return x.Agents
} }
@ -388,6 +388,61 @@ func (x *AgentVariableItem) GetDescription() string {
return "" 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 { type AgentItemV3 struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id"` //@go_tags(`json:"id"`) agent id 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 里) 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"`) 变量定义 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"`) 排序 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
func (x *AgentItemV3) Reset() { func (x *AgentItemV3) Reset() {
*x = AgentItemV3{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -427,7 +483,7 @@ func (x *AgentItemV3) String() string {
func (*AgentItemV3) ProtoMessage() {} func (*AgentItemV3) ProtoMessage() {}
func (x *AgentItemV3) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -440,7 +496,7 @@ func (x *AgentItemV3) ProtoReflect() protoreflect.Message {
// Deprecated: Use AgentItemV3.ProtoReflect.Descriptor instead. // Deprecated: Use AgentItemV3.ProtoReflect.Descriptor instead.
func (*AgentItemV3) Descriptor() ([]byte, []int) { 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 { func (x *AgentItemV3) GetId() string {
@ -576,6 +632,13 @@ func (x *AgentItemV3) GetSort() int32 {
return 0 return 0
} }
func (x *AgentItemV3) GetMcps() []*AgentMcpBindingItem {
if x != nil {
return x.Mcps
}
return nil
}
type UserGetAgentsV3Resp struct { type UserGetAgentsV3Resp struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Agents []*AgentItemV3 `protobuf:"bytes,1,rep,name=agents,proto3" json:"agents"` //@go_tags(`json:"agents"`) agent 列表 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() { func (x *UserGetAgentsV3Resp) Reset() {
*x = UserGetAgentsV3Resp{} *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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -597,7 +660,7 @@ func (x *UserGetAgentsV3Resp) String() string {
func (*UserGetAgentsV3Resp) ProtoMessage() {} func (*UserGetAgentsV3Resp) ProtoMessage() {}
func (x *UserGetAgentsV3Resp) ProtoReflect() protoreflect.Message { 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 { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -610,7 +673,7 @@ func (x *UserGetAgentsV3Resp) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserGetAgentsV3Resp.ProtoReflect.Descriptor instead. // Deprecated: Use UserGetAgentsV3Resp.ProtoReflect.Descriptor instead.
func (*UserGetAgentsV3Resp) Descriptor() ([]byte, []int) { 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 { 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" + "\x13translate_languages\x18\x03 \x03(\tR\x12translateLanguages\x124\n" +
"\x16translate_any_language\x18\x04 \x01(\bR\x14translateAnyLanguage\x122\n" + "\x16translate_any_language\x18\x04 \x01(\bR\x14translateAnyLanguage\x122\n" +
"\x15summary_client_choice\x18\x05 \x01(\bR\x13summaryClientChoice\x12B\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" + "\x16UserGetAppConfigV3Resp\x122\n" +
"\x03env\x18\x01 \x03(\v2 .UserGetAppConfigV3Resp.EnvEntryR\x03env\x12 \n" + "\x03env\x18\x01 \x03(\v2 .UserGetAppConfigV3Resp.EnvEntryR\x03env\x12$\n" +
"\x06agents\x18\x02 \x03(\v2\b.DBAgentR\x06agents\x12&\n" + "\x06agents\x18\x02 \x03(\v2\f.AgentItemV3R\x06agents\x12&\n" +
"\bproducts\x18\x03 \x03(\v2\n" + "\bproducts\x18\x03 \x03(\v2\n" +
".DBProductR\bproducts\x12+\n" + ".DBProductR\bproducts\x12+\n" +
"\tthirdsvcs\x18\x04 \x03(\v2\r.ThirdSvcItemR\tthirdsvcs\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" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05label\x18\x02 \x01(\tR\x05label\x12\x1b\n" + "\x05label\x18\x02 \x01(\tR\x05label\x12\x1b\n" +
"\tdef_value\x18\x03 \x01(\tR\bdefValue\x12 \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" + "\vAgentItemV3\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\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" + "\tvoice_map\x18\x10 \x03(\v2\x0f.AgentVoiceItemR\bvoiceMap\x12\x1e\n" +
"\vmcp_svc_ids\x18\x11 \x03(\tR\tmcpSvcIds\x120\n" + "\vmcp_svc_ids\x18\x11 \x03(\tR\tmcpSvcIds\x120\n" +
"\tvariables\x18\x12 \x03(\v2\x12.AgentVariableItemR\tvariables\x12\x12\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" + "\x13UserGetAgentsV3Resp\x12$\n" +
"\x06agents\x18\x01 \x03(\v2\f.AgentItemV3R\x06agentsB\x06Z\x04.;pbb\x06proto3" "\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 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{ var file_user_user_msg_v3_proto_goTypes = []any{
(*UserGetAppConfigV3Req)(nil), // 0: UserGetAppConfigV3Req (*UserGetAppConfigV3Req)(nil), // 0: UserGetAppConfigV3Req
(*MeetSummaryServiceItem)(nil), // 1: MeetSummaryServiceItem (*MeetSummaryServiceItem)(nil), // 1: MeetSummaryServiceItem
@ -703,29 +770,30 @@ var file_user_user_msg_v3_proto_goTypes = []any{
(*UserGetAppConfigV3Resp)(nil), // 3: UserGetAppConfigV3Resp (*UserGetAppConfigV3Resp)(nil), // 3: UserGetAppConfigV3Resp
(*UserGetAgentsV3Req)(nil), // 4: UserGetAgentsV3Req (*UserGetAgentsV3Req)(nil), // 4: UserGetAgentsV3Req
(*AgentVariableItem)(nil), // 5: AgentVariableItem (*AgentVariableItem)(nil), // 5: AgentVariableItem
(*AgentItemV3)(nil), // 6: AgentItemV3 (*AgentMcpBindingItem)(nil), // 6: AgentMcpBindingItem
(*UserGetAgentsV3Resp)(nil), // 7: UserGetAgentsV3Resp (*AgentItemV3)(nil), // 7: AgentItemV3
nil, // 8: UserGetAppConfigV3Resp.EnvEntry (*UserGetAgentsV3Resp)(nil), // 8: UserGetAgentsV3Resp
(*DBAgent)(nil), // 9: DBAgent nil, // 9: UserGetAppConfigV3Resp.EnvEntry
(*DBProduct)(nil), // 10: DBProduct (*DBProduct)(nil), // 10: DBProduct
(*ThirdSvcItem)(nil), // 11: ThirdSvcItem (*ThirdSvcItem)(nil), // 11: ThirdSvcItem
(*AgentVoiceItem)(nil), // 12: AgentVoiceItem (*AgentVoiceItem)(nil), // 12: AgentVoiceItem
} }
var file_user_user_msg_v3_proto_depIdxs = []int32{ var file_user_user_msg_v3_proto_depIdxs = []int32{
1, // 0: MeetCapabilitiesItem.summary_services:type_name -> MeetSummaryServiceItem 1, // 0: MeetCapabilitiesItem.summary_services:type_name -> MeetSummaryServiceItem
8, // 1: UserGetAppConfigV3Resp.env:type_name -> UserGetAppConfigV3Resp.EnvEntry 9, // 1: UserGetAppConfigV3Resp.env:type_name -> UserGetAppConfigV3Resp.EnvEntry
9, // 2: UserGetAppConfigV3Resp.agents:type_name -> DBAgent 7, // 2: UserGetAppConfigV3Resp.agents:type_name -> AgentItemV3
10, // 3: UserGetAppConfigV3Resp.products:type_name -> DBProduct 10, // 3: UserGetAppConfigV3Resp.products:type_name -> DBProduct
11, // 4: UserGetAppConfigV3Resp.thirdsvcs:type_name -> ThirdSvcItem 11, // 4: UserGetAppConfigV3Resp.thirdsvcs:type_name -> ThirdSvcItem
2, // 5: UserGetAppConfigV3Resp.meet:type_name -> MeetCapabilitiesItem 2, // 5: UserGetAppConfigV3Resp.meet:type_name -> MeetCapabilitiesItem
12, // 6: AgentItemV3.voice_map:type_name -> AgentVoiceItem 12, // 6: AgentItemV3.voice_map:type_name -> AgentVoiceItem
5, // 7: AgentItemV3.variables:type_name -> AgentVariableItem 5, // 7: AgentItemV3.variables:type_name -> AgentVariableItem
6, // 8: UserGetAgentsV3Resp.agents:type_name -> AgentItemV3 6, // 8: AgentItemV3.mcps:type_name -> AgentMcpBindingItem
9, // [9:9] is the sub-list for method output_type 7, // 9: UserGetAgentsV3Resp.agents:type_name -> AgentItemV3
9, // [9:9] is the sub-list for method input_type 10, // [10:10] is the sub-list for method output_type
9, // [9:9] is the sub-list for extension type_name 10, // [10:10] is the sub-list for method input_type
9, // [9:9] is the sub-list for extension extendee 10, // [10:10] is the sub-list for extension type_name
0, // [0:9] is the sub-list for field 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() } 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(), 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)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_user_user_msg_v3_proto_rawDesc), len(file_user_user_msg_v3_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 9, NumMessages: 10,
NumExtensions: 0, NumExtensions: 0,
NumServices: 0, NumServices: 0,
}, },

27
apps/services/swagger/docs.go

@ -3728,6 +3728,13 @@ const docTemplate = `{
"type": "string" "type": "string"
} }
}, },
"mcps": {
"description": "@go_tags(` + "`" + `json:\"mcps\"` + "`" + `) MCP 绑定(含每个服务允许的工具);mcp_svc_ids 是它的 id 平铺,保留给老客户端",
"type": "array",
"items": {
"$ref": "#/definitions/pb.AgentMcpBindingItem"
}
},
"name": { "name": {
"description": "@go_tags(` + "`" + `json:\"name\"` + "`" + `) 名称", "description": "@go_tags(` + "`" + `json:\"name\"` + "`" + `) 名称",
"type": "string" "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": { "pb.AgentSource": {
"description": "智能体来源", "description": "智能体来源",
"type": "integer", "type": "integer",
@ -7593,10 +7616,10 @@ const docTemplate = `{
"type": "object", "type": "object",
"properties": { "properties": {
"agents": { "agents": {
"description": "@go_tags(` + "`" + `json:\"agents\"` + "`" + `) 智能体列表", "description": "@go_tags(` + "`" + `json:\"agents\"` + "`" + `) 智能体列表(结构同 user_getagents_v3,含 type 与服务编排)",
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/pb.DBAgent" "$ref": "#/definitions/pb.AgentItemV3"
} }
}, },
"env": { "env": {

27
apps/services/swagger/swagger.json

@ -3720,6 +3720,13 @@
"type": "string" "type": "string"
} }
}, },
"mcps": {
"description": "@go_tags(`json:\"mcps\"`) MCP 绑定(含每个服务允许的工具);mcp_svc_ids 是它的 id 平铺,保留给老客户端",
"type": "array",
"items": {
"$ref": "#/definitions/pb.AgentMcpBindingItem"
}
},
"name": { "name": {
"description": "@go_tags(`json:\"name\"`) 名称", "description": "@go_tags(`json:\"name\"`) 名称",
"type": "string" "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": { "pb.AgentSource": {
"description": "智能体来源", "description": "智能体来源",
"type": "integer", "type": "integer",
@ -7585,10 +7608,10 @@
"type": "object", "type": "object",
"properties": { "properties": {
"agents": { "agents": {
"description": "@go_tags(`json:\"agents\"`) 智能体列表", "description": "@go_tags(`json:\"agents\"`) 智能体列表(结构同 user_getagents_v3,含 type 与服务编排)",
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/pb.DBAgent" "$ref": "#/definitions/pb.AgentItemV3"
} }
}, },
"env": { "env": {

23
apps/services/swagger/swagger.yaml

@ -187,6 +187,12 @@ definitions:
items: items:
type: string type: string
type: array type: array
mcps:
description: '@go_tags(`json:"mcps"`) MCP 绑定(含每个服务允许的工具);mcp_svc_ids 是它的
id 平铺,保留给老客户端'
items:
$ref: '#/definitions/pb.AgentMcpBindingItem'
type: array
name: name:
description: '@go_tags(`json:"name"`) 名称' description: '@go_tags(`json:"name"`) 名称'
type: string type: string
@ -222,6 +228,19 @@ definitions:
$ref: '#/definitions/pb.AgentVoiceItem' $ref: '#/definitions/pb.AgentVoiceItem'
type: array type: array
type: object 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: pb.AgentSource:
description: 智能体来源 description: 智能体来源
enum: enum:
@ -3055,9 +3074,9 @@ definitions:
pb.UserGetAppConfigV3Resp: pb.UserGetAppConfigV3Resp:
properties: properties:
agents: agents:
description: '@go_tags(`json:"agents"`) 智能体列表' description: '@go_tags(`json:"agents"`) 智能体列表(结构同 user_getagents_v3,含 type 与服务编排)'
items: items:
$ref: '#/definitions/pb.DBAgent' $ref: '#/definitions/pb.AgentItemV3'
type: array type: array
env: env:
additionalProperties: additionalProperties:

Loading…
Cancel
Save