You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

678 lines
31 KiB

<template>
<div>
<!-- 顶部 -->
<div class="flex items-center justify-between mb-5 flex-wrap gap-3">
<div>
<h2 class="text-lg font-semibold text-slate-800">Agent 配置</h2>
<p class="text-xs text-slate-400 mt-1">
把已配置的第三方服务编排成可下发客户端的智能体。四种类型按需引用不同服务;通话翻译规则只能引用「翻译」与「端到端翻译」两类。
</p>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-slate-500">作用域</span>
<select v-model="scopeApp" class="select select-bordered select-sm" @change="loadAll">
<option value="">全局默认</option>
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option>
</select>
<button class="btn btn-primary btn-sm text-white" @click="startAdd">+ 新建 Agent</button>
</div>
</div>
<!-- 按类型分组 -->
<div v-if="!agents.length" class="text-center py-20 text-slate-300">
<div class="text-5xl mb-3">🤖</div>
<p class="text-sm">当前作用域下还没有 Agent</p>
<p class="text-xs mt-1">先在「第三方服务配置」里配好服务,再来把它们组装成 Agent</p>
</div>
<div v-for="g in grouped" :key="g.type" class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="type-tag" :style="{ background: g.bg, color: g.color }">{{ g.icon }} {{ g.label }}</span>
<span class="text-xs text-slate-400">{{ g.hint }}</span>
<span class="text-xs text-slate-300">·</span>
<span class="text-xs text-slate-400">{{ g.items.length }} 个</span>
</div>
<div class="agent-grid">
<div v-for="a in g.items" :key="a.id" class="agent-card" @click="startEdit(a)">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="font-semibold text-sm text-slate-800 truncate">{{ a.name || a.id }}</div>
<div class="text-[11px] text-slate-400 mt-0.5 truncate">{{ a.id }}</div>
</div>
<div class="flex items-center gap-1 flex-shrink-0">
<span v-if="a.enable" class="badge badge-success badge-xs">启用</span>
<span v-else class="badge badge-ghost badge-xs">停用</span>
<button class="btn btn-ghost btn-xs text-error" @click.stop="delAgent(a)">删除</button>
</div>
</div>
<p v-if="a.description" class="text-xs text-slate-500 mt-2 line-clamp-2">{{ a.description }}</p>
<div class="flex flex-wrap items-center gap-1.5 mt-3">
<span v-for="s in pipelineOf(a)" :key="s.slot" class="svc-pill" :style="{ background: s.color }">
{{ s.slot }}: {{ svcName(s.id) }}
</span>
</div>
<div v-if="a.support_langs" class="text-[11px] text-slate-400 mt-2 truncate">
支持语言:{{ a.support_langs }}
</div>
</div>
</div>
</div>
<!-- 编辑弹窗 -->
<div v-show="modalOpen" class="ag-overlay">
<div class="ag-box">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<h3 class="text-base font-semibold text-slate-800">{{ isEditing ? '编辑 Agent' : '新建 Agent' }}</h3>
<span class="scope-chip">{{ scopeApp || '全局默认' }}</span>
</div>
<button class="btn btn-ghost btn-xs" @click="closeModal">✕ 关闭</button>
</div>
<!-- 类型:新建时可选,编辑时锁定(换类型等于换一套服务槽位) -->
<section class="ed-section">
<div class="ed-section-title">类型</div>
<div class="type-row">
<button
v-for="t in AGENT_TYPES"
:key="t.value"
type="button"
class="ttab"
:class="{ on: form.type === t.value, locked: isEditing && form.type !== t.value }"
:disabled="isEditing"
@click="form.type = t.value"
>
<span class="ttab-title">{{ t.icon }} {{ t.label }}</span>
<span class="ttab-desc">{{ t.hint }}</span>
</button>
</div>
<p v-if="isEditing" class="ed-tip">类型创建后不可更改;需要换类型请新建一个 Agent。</p>
</section>
<!-- 基本信息 -->
<section class="ed-section">
<div class="ed-section-title">基本信息</div>
<div class="grid grid-cols-2 gap-3">
<div>
<span class="ed-label">Agent ID <i class="req">*</i></span>
<input
v-model="form.id"
:disabled="isEditing"
class="input input-bordered input-sm w-full"
placeholder="如 trans-cn-en(作用域内唯一,创建后不可改)"
/>
</div>
<div>
<span class="ed-label">名称 <i class="req">*</i></span>
<input v-model="form.name" class="input input-bordered input-sm w-full" placeholder="展示名,如 中英实时翻译" />
</div>
</div>
<div class="mt-3">
<span class="ed-label">描述</span>
<input v-model="form.description" class="input input-bordered input-sm w-full" placeholder="这个 Agent 用在什么场景" />
</div>
<div class="grid grid-cols-3 gap-3 mt-3">
<div>
<span class="ed-label">头像 URL</span>
<input v-model="form.avatar_url" class="input input-bordered input-sm w-full" placeholder="可留空" />
</div>
<div>
<span class="ed-label">排序(越大越靠前)</span>
<input v-model.number="form.sort" type="number" class="input input-bordered input-sm w-full" />
</div>
<div>
<span class="ed-label">音色性别偏好</span>
<select v-model.number="form.gender" class="select select-bordered select-sm w-full">
<option :value="0">不限</option>
<option :value="1">男</option>
<option :value="2">女</option>
</select>
</div>
</div>
</section>
<!-- 服务编排:槽位随类型变化 -->
<section class="ed-section">
<div class="ed-section-title">服务编排</div>
<div class="slot-row">
<template v-for="(s, i) in slotsOfType(form.type)" :key="s.key">
<div class="slot-item">
<span class="stage-tag" :style="{ background: s.color }">{{ s.slot }}</span>
<span class="ed-label inline">{{ s.label }}</span>
<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 v-for="v in svcsByCats(s.cats)" :key="v.id" :value="v.id">{{ v.name }}{{ isVisionSvc(v) ? ' 🖼' : '' }}</option>
</select>
</div>
<span v-if="i < slotsOfType(form.type).length - 1" class="slot-arrow">→</span>
</template>
</div>
</section>
<!-- 语言与音色 -->
<section class="ed-section">
<div class="ed-section-title">语言与音色</div>
<div class="grid grid-cols-2 gap-3">
<div>
<span class="ed-label">支持语言(多选)</span>
<LangSelect v-model="form.support_langs" multiple />
</div>
<div>
<span class="ed-label">默认语言</span>
<LangSelect v-model="form.default_lang" :options="supportedLangOptions" />
</div>
</div>
<div class="mt-3">
<div class="flex items-center justify-between mb-1">
<span class="ed-label mb-0">音色(按语言+性别配,Agent 切语言时自动换音色)</span>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs" :disabled="!voiceTableSupported" @click="fillVoicesBySupportLangs">按支持语言补全</button>
<button class="btn btn-ghost btn-xs" @click="addVoice">+ 添加一行</button>
</div>
</div>
<p class="ed-tip mb-1">
<template v-if="!voiceSvcId">先在上方选好语音服务,这里才能列出可用音色。</template>
<template v-else-if="voiceTableSupported">
候选来自内置音色表({{ voiceProvider }});同一语言+性别有多个时默认第一个,可在下拉里换。没配到的语言走服务商默认音色。
</template>
<template v-else>「{{ voiceProvider || '该服务商' }}」不在内置音色表里,请手填服务商的音色 id。</template>
</p>
<div v-if="!form.voice_map.length" class="text-xs text-slate-300 py-2">未设置,全部走服务商默认音色</div>
<div v-for="(v, i) in form.voice_map" :key="i" class="voice-row">
<select v-model="v.language" class="select select-bordered select-xs" @change="onVoiceRowChange(v)">
<option value="">(语言)</option>
<option v-for="o in supportedLangOptions" :key="o.code" :value="o.code">{{ o.code }} · {{ o.name }}</option>
</select>
<select v-model.number="v.gender" class="select select-bordered select-xs" @change="onVoiceRowChange(v)">
<option :value="0">不限</option>
<option :value="1">男</option>
<option :value="2">女</option>
</select>
<select
v-if="voiceTableSupported && voiceOptions(v.language, v.gender).length"
v-model="v.voice"
class="select select-bordered select-xs flex-1"
>
<option v-for="(o, oi) in voiceOptions(v.language, v.gender)" :key="o.voice_id" :value="o.voice_id">
{{ o.name }} · {{ o.voice_id }}{{ oi === 0 ? '(默认)' : '' }}
</option>
</select>
<input v-else v-model="v.voice" class="input input-bordered input-xs flex-1" placeholder="音色 id,如 zh-CN-XiaoxiaoNeural" />
<button class="btn btn-ghost btn-xs text-error" @click="form.voice_map.splice(i, 1)">删除</button>
</div>
</div>
</section>
<!-- 对话设定:只有对话类 agent 需要 -->
<section v-if="isChatType" class="ed-section">
<div class="ed-section-title">对话设定</div>
<div>
<span class="ed-label">系统提示词</span>
<textarea v-model="form.system_prompt" class="textarea textarea-bordered w-full text-xs" rows="3" placeholder="定义这个智能体的人设与行为" />
</div>
<div class="mt-3">
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="form.greeting_enabled" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span>开场白</span>
</label>
<input
v-if="form.greeting_enabled"
v-model="form.greeting_text"
class="input input-bordered input-sm w-full mt-2"
placeholder="接通后先说的一句话"
/>
</div>
</section>
<!-- MCP 工具:agent 能调用哪些 MCP 服务 -->
<section class="ed-section">
<div class="ed-section-title">MCP 服务(可选,给 Agent 挂工具)</div>
<p v-if="!mcpSvcs.length" class="ed-tip">还没有启用的 MCP 服务,先去「第三方服务配置 → MCP 服务」添加。</p>
<div v-else class="mcp-grid">
<label
v-for="m in mcpSvcs"
:key="m.id"
class="mcp-item"
:class="{ on: form.mcp_svc_ids.includes(m.id) }"
>
<input
type="checkbox"
class="checkbox checkbox-xs checkbox-primary"
:checked="form.mcp_svc_ids.includes(m.id)"
@change="toggleMcp(m.id)"
/>
<span class="min-w-0">
<span class="block truncate">{{ m.name }}</span>
<span class="block truncate text-[10px] text-slate-400 font-mono">{{ m.id }}</span>
</span>
</label>
</div>
</section>
<!-- 变量:提示词/开场白里的占位符 -->
<section class="ed-section">
<div class="flex items-center justify-between mb-1">
<div class="ed-section-title mb-0">变量(提示词/开场白里用 &#123;&#123;key&#125;&#125; 占位)</div>
<button class="btn btn-ghost btn-xs" @click="addVariable">+ 添加变量</button>
</div>
<div v-if="!form.variables.length" class="text-xs text-slate-300 py-2">未定义变量</div>
<div v-for="(v, i) in form.variables" :key="i" class="var-row">
<input v-model="v.key" class="input input-bordered input-xs" placeholder="key" />
<input v-model="v.label" class="input input-bordered input-xs" placeholder="展示名" />
<input v-model="v.def_value" class="input input-bordered input-xs flex-1" placeholder="默认值(客户端没传时用它)" />
<button class="btn btn-ghost btn-xs text-error" @click="form.variables.splice(i, 1)">删除</button>
</div>
</section>
<div class="ed-footer">
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="form.enable" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span>启用</span>
</label>
<div class="flex gap-2">
<button class="btn btn-ghost btn-sm" @click="closeModal">取消</button>
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="save">保存</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { LANG_OPTIONS } from '~/composables/useLangOptions'
import LangSelect from '~/components/LangSelect.vue'
definePageMeta({ title: 'Agent 配置' })
// ===== 类型 =====
interface SvcConfig {
id: string
name: string
provider?: string
enable: boolean
categories: string
}
interface AgentVoice {
language: string
gender: number
voice: string
}
interface Agent {
app_name: string
id: string
name: string
type: string
description: string
avatar_url: string
enable: boolean
stt_svc_id: string
llm_svc_id: string
tts_svc_id: string
mt_svc_id: string
sts_svc_id: string
ast_svc_id: string
system_prompt: string
gender: number
greeting_enabled: boolean
greeting_text: string
support_langs: string
default_lang: string
voice_map: AgentVoice[]
mcp_svc_ids: string[]
variables: AgentVariable[]
sort: number
}
interface AgentVariable {
key: string
label: string
def_value: string
description: string
}
// 音色表项(后端 comm/ttsvoice.go 常量表,经 api_getttsvoices 下发)
interface TTSVoice {
provider: string
language: string
gender: number
voice_id: string
name: string
}
// ===== 常量 =====
// 服务类别(与后端 svcCatXxx / serviceconfig.vue 的 SCAT 一致)
const CAT_STT = 1
const CAT_TTS = 2
const CAT_MT = 3
const CAT_AST = 4
const CAT_LLM = 5
const CAT_STS = 7
const CAT_LLM_VISION = 9 // 多媒体大模型:能力是文本模型的超集,LLM 槽位一并可选
const CAT_MCP = 10 // MCP 服务:与其他第三方服务同表,agent 可绑定若干个当工具
// Agent 类型(值与后端 comm.AgentType* 常量严格一致)
// 只保留两种对话型:agent 用来配「对话对象」,翻译走通话翻译规则那套按语言选路,不做成 agent。
const AGENT_TYPES = [
{ value: 'chat', label: 'chat-agent 对话', icon: '💬', hint: 'STT → LLM → TTS 三段拼接', color: '#b45309', bg: '#fef9c3' },
{ value: 'sts-chat', label: 'sts-chat-agent 端到端对话', icon: '⚡', hint: '单个 STS 服务直接完成语音对话', color: '#be185d', bg: '#fce7f3' },
]
// 已下线的类型:新建选不到,但库里可能有历史行,列表得能显示出来(否则数据像凭空消失)。
const LEGACY_TYPES: Record<string, { label: string; icon: string; color: string; bg: string }> = {
translate: { label: '翻译(已下线)', icon: '🧩', color: '#64748b', bg: '#f1f5f9' },
'ast-translate': { label: '端到端翻译(已下线)', icon: '🚀', color: '#64748b', bg: '#f1f5f9' },
}
// 每种类型的服务槽位:key 对应 AgentConfig 字段名,cats 用于筛选可选服务(命中任一即可选)
interface Slot { key: string; slot: string; label: string; cats: number[]; color: string }
const SLOTS: Record<string, Slot[]> = {
chat: [
{ key: 'stt_svc_id', slot: 'STT', label: '语音识别', cats: [CAT_STT], color: '#1664ff' },
{ key: 'llm_svc_id', slot: 'LLM', label: '大语言模型', cats: [CAT_LLM, CAT_LLM_VISION], color: '#b45309' },
{ key: 'tts_svc_id', slot: 'TTS', label: '语音合成', cats: [CAT_TTS], color: '#7c3aed' },
],
translate: [
{ key: 'stt_svc_id', slot: 'STT', label: '语音识别', cats: [CAT_STT], color: '#1664ff' },
{ key: 'mt_svc_id', slot: 'MT', label: '机器翻译', cats: [CAT_MT], color: '#059669' },
{ key: 'tts_svc_id', slot: 'TTS', label: '语音合成', cats: [CAT_TTS], color: '#7c3aed' },
],
'sts-chat': [{ key: 'sts_svc_id', slot: 'STS', label: '端到端语音对话', cats: [CAT_STS], color: '#be185d' }],
'ast-translate': [{ key: 'ast_svc_id', slot: 'AST', label: '端到端语音翻译', cats: [CAT_AST], color: '#f97316' }],
}
function slotsOfType(t: string): Slot[] {
return SLOTS[t] ?? []
}
const { webApi, consoleApi } = useApi()
const { success, error } = useToast()
// ===== 状态 =====
const scopeApp = ref('')
const scopeApps = ref<string[]>([])
const agents = ref<Agent[]>([])
const allSvcs = ref<SvcConfig[]>([])
const ttsVoices = ref<TTSVoice[]>([])
const modalOpen = ref(false)
const isEditing = ref(false)
const submitting = ref(false)
function emptyForm(): Agent {
return {
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,
}
}
const form = reactive<Agent>(emptyForm())
const isChatType = computed(() => form.type === 'chat' || form.type === 'sts-chat')
// 默认语言只在已选的支持语言里挑;没选支持语言时放开全集,避免无从选择
const supportedLangOptions = computed(() => {
const codes = (form.support_langs || '').split(',').map((x) => x.trim()).filter(Boolean)
if (!codes.length) return LANG_OPTIONS
return LANG_OPTIONS.filter((o) => codes.includes(o.code))
})
const bySort = (a: Agent, b: Agent) => (b.sort || 0) - (a.sort || 0) || (a.name || '').localeCompare(b.name || '')
const grouped = computed(() => {
const groups = AGENT_TYPES.map((t) => ({
...t,
type: t.value,
items: agents.value.filter((a) => a.type === t.value).sort(bySort),
})).filter((g) => g.items.length)
// 库里可能还有已下线类型的历史 agent:单独成组展示,可以看、可以停用/删除,就是不能新建。
for (const [type, meta] of Object.entries(LEGACY_TYPES)) {
const items = agents.value.filter((a) => a.type === type).sort(bySort)
if (items.length) groups.push({ value: type, type, hint: '已下线的类型,仅供查看与清理', ...meta, items })
}
return groups
})
// ===== 加载 =====
async function loadScopeApps() {
try {
const list = await consoleApi<Array<{ name: string }>>('products/list', {})
scopeApps.value = (list ?? []).map((p: { name: string }) => p.name).filter(Boolean)
} catch {
scopeApps.value = []
}
}
async function loadSvcs() {
try {
const d = await webApi<{ items: SvcConfig[] }>('getsvcconfigs', { app_name: scopeApp.value })
allSvcs.value = d?.items ?? []
} catch {
allSvcs.value = []
}
}
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 ?? [],
}))
} catch (e: any) {
error(e?.message ?? '加载失败')
agents.value = []
}
}
// 音色表来自后端常量(comm/ttsvoice.go),不是后台数据——这里只读一次用于渲染候选。
async function loadVoices() {
try {
const d = await webApi<{ voices: TTSVoice[] }>('getttsvoices', {})
ttsVoices.value = d?.voices ?? []
} catch {
ttsVoices.value = []
}
}
async function loadAll() {
await Promise.all([loadSvcs(), loadAgents()])
}
// ===== 音色 =====
// 语音输出用的是哪个服务:对话类是 TTS,端到端对话由 STS 服务自己合成。
const voiceSvcId = computed(() => (form.type === 'sts-chat' ? form.sts_svc_id : form.tts_svc_id))
const voiceProvider = computed(() => allSvcs.value.find((s) => s.id === voiceSvcId.value)?.provider ?? '')
// 该服务商是否在内置音色表里:不在就退回手填(火山/OpenAI 等音色表没覆盖,不能把选择框变成死路)
const voiceTableSupported = computed(() => !!voiceProvider.value && ttsVoices.value.some((v) => v.provider === voiceProvider.value))
// 某(语言,性别)下的候选音色,顺序即优先级,第一个是默认——与后端 comm.PickVoice 口径一致
function voiceOptions(language: string, gender: number): TTSVoice[] {
const provider = voiceProvider.value
if (!provider || !language) return []
const base = (language.split('-')[0] || '').toLowerCase()
const hit = ttsVoices.value.filter((v) => {
if (v.provider !== provider) return false
if (gender && v.gender !== gender) return false
return v.language.toLowerCase() === language.toLowerCase()
})
if (hit.length) return hit
// 语言退化到主语言子标签(zh → zh-CN),绝不跨语言兜底
return ttsVoices.value.filter((v) => {
if (v.provider !== provider) return false
if (gender && v.gender !== gender) return false
return (v.language.split('-')[0] || '').toLowerCase() === base
})
}
function defaultVoiceOf(language: string, gender: number): string {
return voiceOptions(language, gender)[0]?.voice_id ?? ''
}
// 语言/性别变了就把音色重挑一个默认值(原值若仍在候选里则保留,避免覆盖运营的选择)
function onVoiceRowChange(v: AgentVoice) {
const opts = voiceOptions(v.language, v.gender)
if (!opts.length) return
if (!v.voice || !opts.some((o) => o.voice_id === v.voice)) v.voice = opts[0].voice_id
}
// 按「支持语言 × agent 性别」一键补全:每种语言取候选里的第一个,已配过的不动
function fillVoicesBySupportLangs() {
const codes = (form.support_langs || '').split(',').map((x) => x.trim()).filter(Boolean)
if (!codes.length) { error('请先选择支持语言'); return }
let added = 0
for (const code of codes) {
if (form.voice_map.some((v) => v.language === code && v.gender === form.gender)) continue
const voice = defaultVoiceOf(code, form.gender)
if (!voice) continue
form.voice_map.push({ language: code, gender: form.gender, voice })
added++
}
added ? success(`已补全 ${added} 种语言的默认音色`) : success('已是最新,无需补全')
}
// ===== MCP 绑定 =====
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)
}
// ===== 变量 =====
function addVariable() {
form.variables.push({ key: '', label: '', def_value: '', description: '' })
}
// ===== 服务 =====
function svcCats(s: SvcConfig): number[] {
return (s.categories || '').split(',').map(Number)
}
// 槽位可选服务 = 已启用 且 类别命中该槽位允许的任一类别
function svcsByCats(cats: number[]): SvcConfig[] {
return allSvcs.value.filter((s) => s.enable && svcCats(s).some((c) => cats.includes(c)))
}
// 是否多媒体大模型(下拉里加 🖼 标记,与「会议记录服务」页一致)
function isVisionSvc(s: SvcConfig): boolean {
return svcCats(s).includes(CAT_LLM_VISION)
}
function svcName(id?: string): string {
if (!id) return '未配置'
const s = allSvcs.value.find((x) => x.id === id)
return s ? s.name : id
}
// 卡片上展示这个 agent 的服务管线
function pipelineOf(a: Agent) {
return slotsOfType(a.type).map((s) => ({ slot: s.slot, color: s.color, id: (a as any)[s.key] as string }))
}
// ===== 编辑 =====
function startAdd() {
Object.assign(form, emptyForm())
isEditing.value = false
modalOpen.value = true
}
function startEdit(a: Agent) {
Object.assign(form, emptyForm(), a, {
voice_map: (a.voice_map ?? []).map((v) => ({ ...v })),
mcp_svc_ids: [...(a.mcp_svc_ids ?? [])],
variables: (a.variables ?? []).map((v) => ({ ...v })),
})
isEditing.value = true
modalOpen.value = true
}
function closeModal() {
modalOpen.value = false
}
function addVoice() {
const lang = form.default_lang || ''
form.voice_map.push({ language: lang, gender: form.gender, voice: defaultVoiceOf(lang, form.gender) })
}
async function save() {
if (!form.id.trim() || !form.name.trim()) {
error('Agent ID 和 名称 必填')
return
}
// 服务槽位缺一不可——后端也会校验,这里先挡一道,省一次往返
for (const s of slotsOfType(form.type)) {
if (!((form as any)[s.key] || '').trim()) {
error(`${s.slot} ${s.label} 必选`)
return
}
}
submitting.value = true
try {
const payload: Agent = {
...JSON.parse(JSON.stringify(form)),
app_name: scopeApp.value,
id: form.id.trim(),
name: form.name.trim(),
// 只提交当前类型用得到的服务引用,避免换过类型后残留脏字段
stt_svc_id: '', llm_svc_id: '', tts_svc_id: '', mt_svc_id: '', sts_svc_id: '', ast_svc_id: '',
}
for (const s of slotsOfType(form.type)) {
;(payload as any)[s.key] = (form as any)[s.key]
}
payload.voice_map = form.voice_map.filter((v) => v.language && v.voice)
payload.mcp_svc_ids = form.mcp_svc_ids.filter(Boolean)
payload.variables = form.variables.filter((v) => (v.key || '').trim())
await webApi(isEditing.value ? 'updateagent' : 'addagent', payload)
success('保存成功')
closeModal()
await loadAgents()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
function delAgent(a: Agent) {
if (!window.confirm(`确定删除 Agent「${a.name || a.id}」吗?引用它的通话翻译规则会失效。`)) return
webApi('delagent', { app_name: scopeApp.value, id: a.id })
.then(() => { success('已删除'); loadAgents() })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
onMounted(async () => {
await loadScopeApps()
await Promise.all([loadAll(), loadVoices()])
})
</script>
<style scoped>
.type-tag { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 8px; }
.agent-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
.agent-card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; padding: 14px 16px; cursor: pointer; transition: all .15s; }
.agent-card:hover { border-color: #c7d2fe; box-shadow: 0 4px 14px rgba(99, 102, 241, .08); }
.svc-pill { display: inline-flex; align-items: center; font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 7px; color: #fff; }
/* 弹窗 */
.ag-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, .45); z-index: 100; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(2px); padding: 24px; }
.ag-box { background: #fff; border-radius: 18px; padding: 24px; width: 780px; max-width: 96vw; max-height: 90vh; overflow-y: auto; box-shadow: 0 24px 80px rgba(0, 0, 0, .18); }
.scope-chip { font-size: 11px; font-weight: 600; padding: 2px 9px; border-radius: 6px; background: #eef2ff; color: #4338ca; }
.ed-section { padding: 14px 0; border-top: 1px dashed #eef2f7; }
.ed-section:first-of-type { padding-top: 0; border-top: none; }
.ed-section-title { font-size: 11px; font-weight: 700; letter-spacing: .04em; color: #94a3b8; margin-bottom: 10px; }
.ed-label { display: block; font-size: 11px; color: #64748b; margin-bottom: 4px; }
.ed-label.inline { display: inline-block; margin-left: 6px; }
.ed-tip { font-size: 11px; color: #94a3b8; margin-top: 8px; }
.req { color: #dc2626; font-style: normal; }
.type-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.ttab { display: flex; flex-direction: column; gap: 3px; padding: 11px 12px; border-radius: 10px; border: 1.5px solid #e2e8f0; background: #fff; cursor: pointer; transition: all .15s; text-align: left; }
.ttab:hover:not(:disabled) { border-color: #c7d2fe; background: #f8fafc; }
.ttab.on { border-color: #6366f1; background: linear-gradient(135deg, #eef2ff, #f5f3ff); }
.ttab.locked { opacity: .45; cursor: not-allowed; }
.ttab-title { font-weight: 700; font-size: 12px; color: #1e293b; }
.ttab-desc { font-size: 10px; color: #94a3b8; }
.slot-row { display: flex; align-items: flex-end; gap: 10px; }
.slot-item { flex: 1; min-width: 0; }
.slot-arrow { color: #cbd5e1; padding-bottom: 8px; flex-shrink: 0; }
.stage-tag { display: inline-block; font-size: 10px; font-weight: 700; padding: 1px 7px; border-radius: 5px; color: #fff; vertical-align: middle; }
.voice-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.var-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.mcp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; }
.mcp-item { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 7px 10px; border: 1.5px solid #e2e8f0; border-radius: 9px; font-size: 12.5px; color: #475569; cursor: pointer; transition: all .15s; }
.mcp-item:hover { border-color: #c7d2fe; background: #f8fafc; }
.mcp-item.on { border-color: #6366f1; background: #eef2ff; color: #4338ca; }
.ed-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 14px; border-top: 1px solid #eef2f7; }
@media (max-width: 860px) {
.type-row { grid-template-columns: repeat(2, 1fr); }
.slot-row { flex-direction: column; align-items: stretch; }
.slot-arrow { display: none; }
}
</style>