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.
1185 lines
59 KiB
1185 lines
59 KiB
<template>
|
|
<div>
|
|
<!-- 顶部 -->
|
|
<div class="flex items-start justify-between mb-4 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">
|
|
把已配置的第三方服务编排成可下发客户端的智能体。Agent 按应用隔离:只在下方选中的应用内生效,「全局默认」是所有应用的兜底。
|
|
</p>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm text-white" @click="startAdd">+ 新建 Agent</button>
|
|
</div>
|
|
|
|
<!-- 工具条:作用域 + 过滤 -->
|
|
<div class="toolbar">
|
|
<div class="tb-item">
|
|
<span class="tb-label">应用</span>
|
|
<select v-model="scopeApp" class="select select-bordered select-sm min-w-44" @change="onScopeChange">
|
|
<option value="">全局默认(所有应用共用)</option>
|
|
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="tb-sep"></div>
|
|
<input v-model="keyword" class="input input-bordered input-sm w-52" placeholder="按名称 / ID / 描述搜索" />
|
|
<select v-model="filterType" class="select select-bordered select-sm">
|
|
<option value="">全部类型</option>
|
|
<option v-for="t in AGENT_TYPES" :key="t.value" :value="t.value">{{ t.icon }} {{ t.label }}</option>
|
|
</select>
|
|
<select v-model="filterStatus" class="select select-bordered select-sm">
|
|
<option value="">全部状态</option>
|
|
<option value="on">仅启用</option>
|
|
<option value="off">仅停用</option>
|
|
</select>
|
|
<span class="tb-count">{{ filtered.length }} / {{ agents.length }} 个</span>
|
|
</div>
|
|
|
|
<!-- 作用域提示 -->
|
|
<div class="scope-bar">
|
|
<span class="scope-chip">{{ scopeApp || '全局默认' }}</span>
|
|
<span v-if="scopeApp">当前只显示并只会创建「{{ scopeApp }}」的 Agent;其它应用看不到它们。</span>
|
|
<span v-else>当前是全局默认作用域:这里的 Agent 会被所有未单独配置的应用继承。要给某个应用单独配,请先在左侧选择应用。</span>
|
|
</div>
|
|
|
|
<!-- 空态 -->
|
|
<div v-if="!agents.length" class="empty-box">
|
|
<div class="text-5xl mb-3">🤖</div>
|
|
<p class="text-sm">「{{ scopeApp || '全局默认' }}」下还没有 Agent</p>
|
|
<p class="text-xs mt-1 text-slate-400">先在「第三方服务配置」里配好服务,再来把它们组装成 Agent</p>
|
|
<button class="btn btn-primary btn-sm text-white mt-4" @click="startAdd">+ 新建 Agent</button>
|
|
</div>
|
|
<div v-else-if="!filtered.length" class="empty-box">
|
|
<div class="text-4xl mb-3">🔍</div>
|
|
<p class="text-sm">没有符合筛选条件的 Agent</p>
|
|
</div>
|
|
|
|
<!-- 按类型分组 -->
|
|
<div v-for="g in grouped" :key="g.type" class="mb-7">
|
|
<div class="group-head">
|
|
<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="group-line"></span>
|
|
<span class="text-xs text-slate-400 flex-shrink-0">{{ g.items.length }} 个</span>
|
|
</div>
|
|
<div class="agent-grid">
|
|
<article
|
|
v-for="a in g.items"
|
|
:key="a.id"
|
|
class="agent-card"
|
|
:class="{ off: !a.enable }"
|
|
:style="{ '--accent': g.color, '--accent-bg': g.bg }"
|
|
@click="startEdit(a)"
|
|
>
|
|
<span class="card-accent"></span>
|
|
|
|
<header class="card-head">
|
|
<div class="avatar-box">
|
|
<img v-if="a.avatar_url" :src="a.avatar_url" alt="" @error="a.avatar_url = ''" />
|
|
<span v-else>{{ g.icon }}</span>
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-1.5 min-w-0">
|
|
<h3 class="card-name truncate">{{ a.name || a.id }}</h3>
|
|
<span class="state-dot" :class="{ on: a.enable }" :title="a.enable ? '已启用' : '已停用'"></span>
|
|
</div>
|
|
<button class="card-id" :title="'点击复制 ' + a.id" @click.stop="copyId(a.id)">
|
|
{{ a.id }} <span class="copy-ico">⧉</span>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<p class="card-desc" :class="{ muted: !a.description }">{{ a.description || '暂无描述' }}</p>
|
|
|
|
<div class="pipeline">
|
|
<template v-for="(s, i) in pipelineOf(a)" :key="s.slot">
|
|
<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) }}
|
|
</span>
|
|
<span v-if="i < pipelineOf(a).length - 1" class="pipe-arrow">›</span>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="card-stats">
|
|
<span :title="a.support_langs || '未设置支持语言'">🌐 {{ langCount(a) || '—' }} 语言</span>
|
|
<span>🎙 {{ (a.voice_map || []).length || '—' }} 音色</span>
|
|
<span :title="mcpStatTitle(a)">🧩 {{ (a.mcp_svc_ids || []).length || '—' }} MCP</span>
|
|
<span>🔤 {{ (a.variables || []).length || '—' }} 变量</span>
|
|
</div>
|
|
|
|
<footer class="card-foot">
|
|
<label class="foot-toggle" :title="a.enable ? '点击停用' : '点击启用'" @click.stop>
|
|
<input type="checkbox" class="toggle toggle-xs toggle-primary" :checked="a.enable" @change="toggleEnable(a)" />
|
|
<span>{{ a.enable ? '启用中' : '已停用' }}</span>
|
|
</label>
|
|
<div class="foot-acts">
|
|
<button class="btn btn-ghost btn-xs" @click.stop="startEdit(a)">编辑</button>
|
|
<button class="btn btn-ghost btn-xs" @click.stop="duplicate(a)">复制</button>
|
|
<button class="btn btn-ghost btn-xs text-error" @click.stop="delAgent(a)">删除</button>
|
|
</div>
|
|
</footer>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 编辑弹窗:编辑内容多,点空白误关一次等于白填一屏:只能走「取消」/「✕」显式关闭 -->
|
|
<div v-if="modalOpen" class="ag-overlay">
|
|
<div class="ag-box">
|
|
<!-- 头部 -->
|
|
<header class="ag-head">
|
|
<div class="ah-avatar" @click="pickAvatar">
|
|
<img v-if="form.avatar_url" :src="form.avatar_url" alt="" @error="form.avatar_url = ''" />
|
|
<span v-else>{{ typeMeta(form.type).icon }}</span>
|
|
<span class="ah-avatar-mask">{{ avatarUploading ? '···' : '换头像' }}</span>
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<h3 class="text-base font-semibold text-slate-800 truncate">
|
|
{{ isEditing ? (form.name || '编辑 Agent') : '新建 Agent' }}
|
|
</h3>
|
|
<span class="scope-chip">{{ scopeApp || '全局默认' }}</span>
|
|
</div>
|
|
<div class="ah-id">
|
|
<span class="ah-id-label">Agent ID</span>
|
|
<template v-if="form.id">
|
|
<code>{{ form.id }}</code>
|
|
<button class="btn btn-ghost btn-xs" @click="copyId(form.id)">复制</button>
|
|
</template>
|
|
<em v-else>保存后由后台自动生成</em>
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-ghost btn-sm" @click="closeModal">✕</button>
|
|
</header>
|
|
|
|
<!-- Tab 页签 -->
|
|
<nav class="ag-tabs">
|
|
<button
|
|
v-for="t in TABS"
|
|
:key="t.key"
|
|
type="button"
|
|
class="ag-tab"
|
|
:class="{ on: tab === t.key }"
|
|
@click="tab = t.key"
|
|
>
|
|
<span>{{ t.icon }} {{ t.label }}</span>
|
|
<span v-if="tabBadge(t.key)" class="tab-badge">{{ tabBadge(t.key) }}</span>
|
|
</button>
|
|
</nav>
|
|
|
|
<!-- 内容 -->
|
|
<div class="ag-body">
|
|
<!-- ========== 基础信息 ========== -->
|
|
<section v-show="tab === 'basic'">
|
|
<div class="ed-block">
|
|
<div class="ed-block-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">类型创建后不可更改;需要换类型请新建(可用列表卡片上的「复制」快速搬配置)。</p>
|
|
</div>
|
|
|
|
<div class="ed-block">
|
|
<div class="ed-block-title">基本信息</div>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<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>
|
|
<span class="ed-label">所属应用</span>
|
|
<input :value="scopeApp || '全局默认(所有应用共用)'" disabled class="input input-bordered input-sm w-full" />
|
|
</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-2 gap-3 mt-3">
|
|
<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>
|
|
</div>
|
|
|
|
<div class="ed-block">
|
|
<div class="ed-block-title">头像</div>
|
|
<div class="avatar-editor">
|
|
<div class="ae-preview">
|
|
<img v-if="form.avatar_url" :src="form.avatar_url" alt="" @error="form.avatar_url = ''" />
|
|
<span v-else>{{ typeMeta(form.type).icon }}</span>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2">
|
|
<button class="btn btn-outline btn-primary btn-sm" :disabled="avatarUploading" @click="pickAvatar">
|
|
<span v-if="avatarUploading" class="loading loading-spinner loading-xs"></span>
|
|
{{ avatarUploading ? '上传中…' : '上传图片' }}
|
|
</button>
|
|
<button v-if="form.avatar_url" class="btn btn-ghost btn-sm" @click="form.avatar_url = ''">移除</button>
|
|
</div>
|
|
<p class="ed-tip">支持 jpg / png / webp,建议 256×256 以内的方图,不超过 2MB。上传走「第三方服务配置 → 存储 → 阿里云 OSS」。</p>
|
|
<div class="mt-2">
|
|
<span class="ed-label">或直接填图片 URL</span>
|
|
<input v-model="form.avatar_url" class="input input-bordered input-sm w-full font-mono text-xs" placeholder="https://..." />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<input ref="avatarInput" type="file" accept="image/*" class="hidden" @change="onAvatarChange" />
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ========== 服务编排 ========== -->
|
|
<section v-show="tab === 'svc'">
|
|
<div class="ed-block">
|
|
<div class="ed-block-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) ? ' 🖼' : '' }}{{ v.global ? '(全局兜底)' : '' }}{{ credMiss(v.id).length ? ' ⚠ 未配置凭据' : '' }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<span v-if="i < slotsOfType(form.type).length - 1" class="slot-arrow">→</span>
|
|
</template>
|
|
</div>
|
|
<p v-if="slotCredWarn" class="ed-warn">⚠ {{ slotCredWarn }}</p>
|
|
<p class="ed-tip">
|
|
候选 = 本应用已启用的服务 + 「全局兜底」的服务(标注了的那些,来自全局默认作用域)。
|
|
引用全局服务是允许的:应用没单独配时本来就走它。其它应用的服务不会出现在这里。
|
|
</p>
|
|
</div>
|
|
|
|
<div class="ed-block">
|
|
<div class="ed-block-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>
|
|
|
|
<div class="ed-block">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="ed-block-title mb-0">音色(按语言+性别配,Agent 切语言时自动换音色)</div>
|
|
<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-2">
|
|
<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="ed-empty">未设置,全部走服务商默认音色</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>
|
|
|
|
<!-- ========== 提示词 ========== -->
|
|
<section v-show="tab === 'prompt'">
|
|
<div class="ed-block">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="ed-block-title mb-0">系统提示词</div>
|
|
<span class="text-[11px] text-slate-400">{{ (form.system_prompt || '').length }} 字</span>
|
|
</div>
|
|
<textarea
|
|
ref="promptRef"
|
|
v-model="form.system_prompt"
|
|
class="textarea textarea-bordered w-full text-xs leading-relaxed"
|
|
rows="12"
|
|
placeholder="定义这个智能体的人设、语气与行为边界。例:你是一位耐心的英语口语老师,说话简洁,每次回复不超过三句话……"
|
|
/>
|
|
<div v-if="form.variables.length" class="var-insert">
|
|
<span class="text-[11px] text-slate-400">插入变量:</span>
|
|
<button v-for="v in form.variables" :key="v.key" class="var-chip" :disabled="!v.key" @click="insertVar(v.key)">
|
|
{{{{ v.key || '未命名' }}}}
|
|
</button>
|
|
</div>
|
|
<p v-else class="ed-tip">想在提示词里插入昵称、设备名之类的动态内容?先去「变量」页签定义,再回来点一下插入。</p>
|
|
</div>
|
|
|
|
<div class="ed-block">
|
|
<div class="ed-block-title">开场白</div>
|
|
<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>接通后先由 Agent 说一句</span>
|
|
</label>
|
|
<textarea
|
|
v-if="form.greeting_enabled"
|
|
v-model="form.greeting_text"
|
|
class="textarea textarea-bordered w-full text-xs mt-2"
|
|
rows="3"
|
|
placeholder="嗨,我是你的英语口语搭子,今天想聊点什么?"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ========== 变量 ========== -->
|
|
<section v-show="tab === 'vars'">
|
|
<div class="ed-block">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="ed-block-title mb-0">变量</div>
|
|
<button class="btn btn-ghost btn-xs" @click="addVariable">+ 添加变量</button>
|
|
</div>
|
|
<p class="ed-tip mb-3">
|
|
在提示词/开场白里用 {{key}} 占位,客户端按运行时上下文(用户昵称、设备名等)替换;没有对应上下文时用这里的默认值。
|
|
</p>
|
|
<div v-if="!form.variables.length" class="ed-empty">还没有定义变量</div>
|
|
<div v-else class="var-table">
|
|
<div class="var-head">
|
|
<span>变量名 key</span><span>展示名</span><span>默认值</span><span>备注</span><span></span>
|
|
</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 font-mono" placeholder="nickname" />
|
|
<input v-model="v.label" class="input input-bordered input-xs" placeholder="用户昵称" />
|
|
<input v-model="v.def_value" class="input input-bordered input-xs" placeholder="朋友" />
|
|
<input v-model="v.description" class="input input-bordered input-xs" placeholder="备注(可空)" />
|
|
<button class="btn btn-ghost btn-xs text-error" @click="form.variables.splice(i, 1)">删除</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ========== MCP 服务绑定 ========== -->
|
|
<section v-show="tab === 'mcp'">
|
|
<div class="ed-block">
|
|
<div class="ed-block-title">MCP 服务绑定(给 Agent 挂工具)</div>
|
|
<p v-if="!mcpSvcs.length" class="ed-tip">当前作用域还没有已启用的 MCP 服务,先去「第三方服务配置 → MCP 服务」添加。</p>
|
|
<template v-else>
|
|
<p class="ed-tip mb-3">
|
|
勾选服务后,再点「获取工具列表」把它暴露的工具拉下来,按需勾选这个 Agent 能用哪些。
|
|
挂多个 MCP 时尤其要收口:用不上的工具会挤占模型注意力、同名工具还会互相干扰。不勾具体工具 = 该服务的工具全放开。
|
|
</p>
|
|
<div class="mcp-list">
|
|
<div v-for="m in mcpSvcs" :key="m.id" class="mcp-card" :class="{ on: form.mcp_svc_ids.includes(m.id) }">
|
|
<label class="mcp-card-head">
|
|
<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 flex-1">
|
|
<span class="block truncate font-medium">
|
|
{{ 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 class="mcp-count">{{ mcpToolsOf(m.id).length ? `已选 ${mcpToolsOf(m.id).length} 个工具` : '全部工具' }}</span>
|
|
</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>
|
|
</template>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<!-- 底部 -->
|
|
<footer class="ed-footer">
|
|
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
|
<input v-model="form.enable" type="checkbox" class="toggle toggle-sm toggle-primary" />
|
|
<span>{{ form.enable ? '启用(客户端可见)' : '停用' }}</span>
|
|
</label>
|
|
<div class="flex items-center gap-2">
|
|
<span v-if="missingHint" class="text-[11px] text-amber-600">{{ missingHint }}</span>
|
|
<button class="btn btn-ghost btn-sm" @click="closeModal">取消</button>
|
|
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="save">
|
|
<span v-if="submitting" class="loading loading-spinner loading-xs"></span>保存
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</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'
|
|
import McpToolPicker from '~/components/McpToolPicker.vue'
|
|
|
|
definePageMeta({ title: 'Agent 配置' })
|
|
|
|
// ===== 类型 =====
|
|
interface SvcConfig {
|
|
id: string
|
|
name: string
|
|
provider?: string
|
|
enable: boolean
|
|
categories: string
|
|
global?: boolean // 前端标记:这条来自全局默认作用域(应用作用域下的兜底),非后端字段
|
|
}
|
|
interface AgentVoice {
|
|
language: string
|
|
gender: number
|
|
voice: string
|
|
}
|
|
interface AgentVariable {
|
|
key: string
|
|
label: string
|
|
def_value: string
|
|
description: 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[]
|
|
mcp_tools: Record<string, string[]>
|
|
variables: AgentVariable[]
|
|
sort: number
|
|
}
|
|
// 音色表项(后端 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。
|
|
interface TypeMeta { value: string; label: string; icon: string; hint: string; color: string; bg: string }
|
|
const AGENT_TYPES: TypeMeta[] = [
|
|
{ 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' },
|
|
}
|
|
function typeMeta(t: string): { label: string; icon: string; color: string; bg: string } {
|
|
return AGENT_TYPES.find((x) => x.value === t) ?? LEGACY_TYPES[t] ?? { label: t, icon: '🤖', color: '#64748b', bg: '#f1f5f9' }
|
|
}
|
|
|
|
// 编辑弹窗页签:一屏塞不下这么多配置,按用途分页。
|
|
const TABS = [
|
|
{ key: 'basic', label: '基础信息', icon: '📋' },
|
|
{ key: 'svc', label: '服务编排', icon: '🔌' },
|
|
{ key: 'prompt', label: '提示词', icon: '📝' },
|
|
{ key: 'vars', label: '变量', icon: '🔤' },
|
|
{ key: 'mcp', label: 'MCP 服务', icon: '🧩' },
|
|
]
|
|
|
|
// 每种类型的服务槽位: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] ?? []
|
|
}
|
|
|
|
// 作用域记忆:换页面/刷新后仍停在上次那个应用,避免误在「全局默认」下建应用专属 Agent。
|
|
const SCOPE_STORE_KEY = 'agents:scope-app'
|
|
|
|
const { webApi, consoleApi, uploadFile } = useApi()
|
|
const { success, error } = useToast()
|
|
|
|
// ===== 状态 =====
|
|
const scopeApp = ref('')
|
|
const scopeApps = ref<string[]>([])
|
|
const agents = ref<Agent[]>([])
|
|
const allSvcs = ref<SvcConfig[]>([]) // 当前作用域(应用/全局)自己的第三方服务
|
|
const globalSvcs = ref<SvcConfig[]>([]) // 全局默认作用域的服务:应用作用域下作为兜底候选
|
|
// svc_id → 一个值都没有的凭据字段(后端 getsvcconfigs 算好:基础行与所有区域分叉都空才算缺)。
|
|
// 引用了这种服务的 agent,配置下发到客户端就是一串空字符串,运行时必然鉴权失败。
|
|
const credMissing = ref<Record<string, string[]>>({})
|
|
const ttsVoices = ref<TTSVoice[]>([])
|
|
const keyword = ref('')
|
|
const filterType = ref('')
|
|
const filterStatus = ref('')
|
|
const modalOpen = ref(false)
|
|
const isEditing = ref(false)
|
|
const submitting = ref(false)
|
|
const tab = ref('basic')
|
|
|
|
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: [], mcp_tools: {}, variables: [], sort: 0,
|
|
}
|
|
}
|
|
const form = reactive<Agent>(emptyForm())
|
|
|
|
// 默认语言只在已选的支持语言里挑;没选支持语言时放开全集,避免无从选择
|
|
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 filtered = computed(() => {
|
|
const kw = keyword.value.trim().toLowerCase()
|
|
return agents.value.filter((a) => {
|
|
if (filterType.value && a.type !== filterType.value) return false
|
|
if (filterStatus.value === 'on' && !a.enable) return false
|
|
if (filterStatus.value === 'off' && a.enable) return false
|
|
if (!kw) return true
|
|
return ((a.name || '') + (a.id || '') + (a.description || '')).toLowerCase().includes(kw)
|
|
})
|
|
})
|
|
const grouped = computed(() => {
|
|
const groups = AGENT_TYPES.map((t) => ({
|
|
...t,
|
|
type: t.value,
|
|
items: filtered.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 = filtered.value.filter((a) => a.type === type).sort(bySort)
|
|
if (items.length) groups.push({ value: type, type, hint: '已下线的类型,仅供查看与清理', ...meta, items })
|
|
}
|
|
return groups
|
|
})
|
|
// 卡片上 MCP 数字的悬浮说明:哪些服务、各自收了几个工具
|
|
function mcpStatTitle(a: Agent): string {
|
|
const ids = a.mcp_svc_ids ?? []
|
|
if (!ids.length) return '未绑定 MCP 服务'
|
|
return ids.map((id) => {
|
|
const n = (a.mcp_tools ?? {})[id]?.length ?? 0
|
|
return `${svcName(id)}:${n ? n + ' 个工具' : '全部工具'}`
|
|
}).join('\n')
|
|
}
|
|
function langCount(a: Agent): number {
|
|
return (a.support_langs || '').split(',').map((x) => x.trim()).filter(Boolean).length
|
|
}
|
|
|
|
// ===== 加载 =====
|
|
// 应用名来源:应用中心的应用表 + 部署注册表(兜底老数据);非超管/管理员无权读这两张表,改走 api_getmyapps。
|
|
async function loadScopeApps() {
|
|
const names = new Set<string>()
|
|
const [products, deploys] = await Promise.all([
|
|
consoleApi<Array<{ name: string }>>('products/list', {}).catch(() => [] as Array<{ name: string }>),
|
|
consoleApi<Array<{ app_name?: string; name: string }>>('apps/list', {}).catch(() => [] as Array<{ app_name?: string; name: string }>),
|
|
])
|
|
;(products ?? []).forEach((p) => p.name && names.add(p.name))
|
|
;(deploys ?? []).forEach((d) => (d.app_name || d.name) && names.add(d.app_name || d.name))
|
|
if (!names.size) {
|
|
try {
|
|
const d = await webApi<{ apps: Array<{ name: string; app_name?: string }> }>('getmyapps', {})
|
|
;(d?.apps ?? []).forEach((a) => {
|
|
const n = a.app_name || a.name
|
|
if (n) names.add(n)
|
|
})
|
|
} catch {
|
|
// 拿不到就只剩「全局默认」,不阻断页面
|
|
}
|
|
}
|
|
scopeApps.value = [...names].sort()
|
|
}
|
|
// 选了应用时要连全局默认的服务一起拉:应用级 agent 可以引用全局兜底服务
|
|
// (后端 validateAgentSvcRefs 就是「应用作用域优先、未命中回退全局」),
|
|
// 只是不能引用「别的应用」的服务——那些本就不在这两次查询的结果里。
|
|
async function loadSvcs() {
|
|
const app = scopeApp.value
|
|
type SvcResp = { items: SvcConfig[]; cred_missing?: Record<string, string[]> }
|
|
const [own, global] = await Promise.all([
|
|
webApi<SvcResp>('getsvcconfigs', { app_name: app }).catch(() => null),
|
|
app ? webApi<SvcResp>('getsvcconfigs', { app_name: '' }).catch(() => null) : Promise.resolve(null),
|
|
])
|
|
allSvcs.value = own?.items ?? []
|
|
globalSvcs.value = global?.items ?? []
|
|
// 应用内同 id 的服务覆盖全局,缺凭据判定也按这个顺序:先铺全局,再让应用作用域的结论盖上去
|
|
credMissing.value = { ...(global?.cred_missing ?? {}), ...(own?.cred_missing ?? {}) }
|
|
}
|
|
async function loadAgents() {
|
|
try {
|
|
const d = await webApi<{ items: Agent[] }>('getagents', { app_name: scopeApp.value })
|
|
agents.value = (d?.items ?? []).map((a) => ({
|
|
...a, voice_map: a.voice_map ?? [], mcp_svc_ids: a.mcp_svc_ids ?? [],
|
|
mcp_tools: a.mcp_tools ?? {}, 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()])
|
|
}
|
|
function onScopeChange() {
|
|
try { localStorage.setItem(SCOPE_STORE_KEY, scopeApp.value) } catch { /* 隐私模式下不可用,忽略 */ }
|
|
loadAll()
|
|
}
|
|
|
|
// ===== 音色 =====
|
|
// 语音输出用的是哪个服务:对话类是 TTS,端到端对话由 STS 服务自己合成。
|
|
const voiceSvcId = computed(() => (form.type === 'sts-chat' ? form.sts_svc_id : form.tts_svc_id))
|
|
const voiceProvider = computed(() => svcPool.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('已是最新,无需补全')
|
|
}
|
|
function addVoice() {
|
|
const lang = form.default_lang || ''
|
|
form.voice_map.push({ language: lang, gender: form.gender, voice: defaultVoiceOf(lang, form.gender) })
|
|
}
|
|
|
|
// ===== MCP 绑定 =====
|
|
const mcpSvcs = computed(() => svcsByCats([CAT_MCP]))
|
|
function toggleMcp(id: string) {
|
|
const i = form.mcp_svc_ids.indexOf(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]
|
|
}
|
|
|
|
// ===== 变量 =====
|
|
const promptRef = ref<HTMLTextAreaElement | null>(null)
|
|
function addVariable() {
|
|
form.variables.push({ key: '', label: '', def_value: '', description: '' })
|
|
}
|
|
// 把 {{key}} 插到系统提示词的光标处(没聚焦过就追加到末尾)
|
|
function insertVar(key: string) {
|
|
if (!key) return
|
|
const token = '{{' + key + '}}'
|
|
const el = promptRef.value
|
|
const text = form.system_prompt || ''
|
|
if (!el || el.selectionStart == null) {
|
|
form.system_prompt = text + token
|
|
return
|
|
}
|
|
const start = el.selectionStart
|
|
const end = el.selectionEnd ?? start
|
|
form.system_prompt = text.slice(0, start) + token + text.slice(end)
|
|
requestAnimationFrame(() => {
|
|
el.focus()
|
|
el.setSelectionRange(start + token.length, start + token.length)
|
|
})
|
|
}
|
|
|
|
// ===== 服务 =====
|
|
// 可引用的服务池 = 本作用域的服务 + 全局默认的服务(同 id 以本应用的为准,与后端解析顺序一致)。
|
|
// 全局那部分在下拉里标「全局」,让运营清楚这条引用走的是兜底配置。
|
|
const svcPool = computed<SvcConfig[]>(() => {
|
|
if (!scopeApp.value) return allSvcs.value
|
|
const own = new Set(allSvcs.value.map((s) => s.id))
|
|
return [...allSvcs.value, ...globalSvcs.value.filter((s) => !own.has(s.id)).map((s) => ({ ...s, global: true }))]
|
|
})
|
|
function svcCats(s: SvcConfig): number[] {
|
|
return (s.categories || '').split(',').map(Number)
|
|
}
|
|
// 槽位可选服务 = 已启用 且 类别命中该槽位允许的任一类别;本应用的排前面,全局兜底排后面
|
|
function svcsByCats(cats: number[]): SvcConfig[] {
|
|
return svcPool.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 credMiss(id?: string): string[] {
|
|
return (id && credMissing.value[id]) || []
|
|
}
|
|
function svcName(id?: string): string {
|
|
if (!id) return '未配置'
|
|
const s = svcPool.value.find((x) => x.id === id)
|
|
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 }))
|
|
}
|
|
|
|
// ===== 头像上传 =====
|
|
const avatarInput = ref<HTMLInputElement | null>(null)
|
|
const avatarUploading = ref(false)
|
|
function pickAvatar() {
|
|
if (!avatarUploading.value) avatarInput.value?.click()
|
|
}
|
|
async function onAvatarChange(e: Event) {
|
|
const input = e.target as HTMLInputElement
|
|
const file = input.files?.[0]
|
|
input.value = '' // 允许连续选同一个文件重传
|
|
if (!file) return
|
|
if (!file.type.startsWith('image/')) { error('请选择图片文件'); return }
|
|
if (file.size > 2 * 1024 * 1024) { error('图片不能超过 2MB'); return }
|
|
avatarUploading.value = true
|
|
try {
|
|
// 文件名换成随机短名:同名头像会覆盖 OSS 上的旧对象,改名可避免不同 Agent 互相踩。
|
|
const ext = (file.name.split('.').pop() || 'png').toLowerCase().replace(/[^a-z0-9]/g, '') || 'png'
|
|
const uniq = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}.${ext}`
|
|
const url = await uploadFile(new File([file], uniq, { type: file.type }), `agent/avatar/${scopeApp.value || 'global'}/`)
|
|
form.avatar_url = url
|
|
success('头像已上传')
|
|
} catch (e: any) {
|
|
error(e?.message ?? '上传失败')
|
|
} finally {
|
|
avatarUploading.value = false
|
|
}
|
|
}
|
|
// ===== 编辑 =====
|
|
function tabBadge(key: string): string {
|
|
if (key === 'vars') return form.variables.length ? String(form.variables.length) : ''
|
|
if (key === 'mcp') return form.mcp_svc_ids.length ? String(form.mcp_svc_ids.length) : ''
|
|
if (key === 'svc') return form.voice_map.length ? String(form.voice_map.length) : ''
|
|
return ''
|
|
}
|
|
// 服务编排页签的凭据提示:选中的服务里有「凭据一个都没配」的,直接点名——
|
|
// 这种配置存得下、下发得出去,但客户端拿到的是空字符串,跑起来必然鉴权失败。
|
|
const slotCredWarn = computed(() => {
|
|
const bad = slotsOfType(form.type)
|
|
.map((s) => ({ slot: s.slot, id: ((form as any)[s.key] || '').trim() }))
|
|
.filter((x) => credMiss(x.id).length)
|
|
if (!bad.length) return ''
|
|
return bad
|
|
.map((x) => `${x.slot} 选的「${svcName(x.id)}」还没配凭据(${credMiss(x.id).join('、')})`)
|
|
.join(';') + ' —— 去「第三方服务配置」补齐,否则客户端拿到的是空值'
|
|
})
|
|
|
|
// 保存前的缺项提示:直接显示在底部,省得点了保存才知道哪没填
|
|
const missingHint = computed(() => {
|
|
if (!form.name.trim()) return '还差:名称'
|
|
const miss = slotsOfType(form.type).filter((s) => !((form as any)[s.key] || '').trim())
|
|
return miss.length ? `还差:${miss.map((s) => s.slot).join(' / ')} 服务` : ''
|
|
})
|
|
|
|
function openModal(a: Agent | null, editing: boolean) {
|
|
Object.assign(form, emptyForm(), a ?? {}, {
|
|
voice_map: (a?.voice_map ?? []).map((v) => ({ ...v })),
|
|
mcp_svc_ids: [...(a?.mcp_svc_ids ?? [])],
|
|
mcp_tools: JSON.parse(JSON.stringify(a?.mcp_tools ?? {})),
|
|
variables: (a?.variables ?? []).map((v) => ({ ...v })),
|
|
})
|
|
isEditing.value = editing
|
|
tab.value = 'basic'
|
|
modalOpen.value = true
|
|
}
|
|
function startAdd() {
|
|
openModal(null, false)
|
|
}
|
|
function startEdit(a: Agent) {
|
|
openModal(a, true)
|
|
}
|
|
// 复制为新 Agent:配置照搬,id 清空(后台重新生成),名称加「副本」以示区分。
|
|
function duplicate(a: Agent) {
|
|
openModal({ ...a, name: `${a.name || a.id} 副本` }, false)
|
|
form.id = ''
|
|
}
|
|
function closeModal() {
|
|
modalOpen.value = false
|
|
}
|
|
|
|
async function copyId(id: string) {
|
|
try {
|
|
await navigator.clipboard.writeText(id)
|
|
success('已复制 ' + id)
|
|
} catch {
|
|
error('复制失败,请手动选取')
|
|
}
|
|
}
|
|
|
|
// 保存用的完整载荷:只带当前类型用得到的服务引用,避免换过类型后残留脏字段。
|
|
function buildPayload(a: Agent): Agent {
|
|
const payload: Agent = {
|
|
...JSON.parse(JSON.stringify(a)),
|
|
app_name: scopeApp.value,
|
|
name: (a.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(a.type)) {
|
|
;(payload as any)[s.key] = (a as any)[s.key]
|
|
}
|
|
payload.voice_map = (a.voice_map ?? []).filter((v) => v.language && v.voice)
|
|
payload.mcp_svc_ids = (a.mcp_svc_ids ?? []).filter(Boolean)
|
|
payload.mcp_tools = Object.fromEntries(
|
|
payload.mcp_svc_ids.filter((id) => (a.mcp_tools ?? {})[id]?.length).map((id) => [id, a.mcp_tools[id]]),
|
|
)
|
|
payload.variables = (a.variables ?? []).filter((v) => (v.key || '').trim())
|
|
return payload
|
|
}
|
|
|
|
async function save() {
|
|
if (!form.name.trim()) {
|
|
error('名称必填')
|
|
tab.value = 'basic'
|
|
return
|
|
}
|
|
// 服务槽位缺一不可——后端也会校验,这里先挡一道,省一次往返
|
|
for (const s of slotsOfType(form.type)) {
|
|
if (!((form as any)[s.key] || '').trim()) {
|
|
error(`${s.slot} ${s.label} 必选`)
|
|
tab.value = 'svc'
|
|
return
|
|
}
|
|
}
|
|
submitting.value = true
|
|
try {
|
|
const payload = buildPayload(form)
|
|
if (!isEditing.value) payload.id = '' // 新建不传 id:由后台生成,避免人工起名撞车
|
|
const saved = await webApi<Agent>(isEditing.value ? 'updateagent' : 'addagent', payload)
|
|
success(isEditing.value ? '保存成功' : `已创建,Agent ID:${saved?.id ?? ''}`)
|
|
closeModal()
|
|
await loadAgents()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '保存失败')
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
// 卡片上的启停开关:整行回存(后端 updateagent 是全量覆盖),失败则回滚 UI。
|
|
async function toggleEnable(a: Agent) {
|
|
const next = !a.enable
|
|
a.enable = next
|
|
try {
|
|
await webApi('updateagent', buildPayload({ ...a, enable: next }))
|
|
success(next ? '已启用' : '已停用')
|
|
} catch (e: any) {
|
|
a.enable = !next
|
|
error(e?.message ?? '操作失败')
|
|
}
|
|
}
|
|
|
|
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()
|
|
// 作用域优先取上次选的;没有记录时默认落到第一个应用,而不是「全局默认」——
|
|
// 绝大多数新建都属于某个应用,默认全局容易建错地方。
|
|
let saved: string | null = null
|
|
try { saved = localStorage.getItem(SCOPE_STORE_KEY) } catch { saved = null }
|
|
if (saved !== null && (saved === '' || scopeApps.value.includes(saved))) scopeApp.value = saved
|
|
else scopeApp.value = scopeApps.value[0] ?? ''
|
|
await Promise.all([loadAll(), loadVoices()])
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* ===== 工具条 ===== */
|
|
.toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; background: #fff; border: 1px solid #eef2f7; border-radius: 12px; padding: 10px 14px; }
|
|
.tb-item { display: flex; align-items: center; gap: 8px; }
|
|
.tb-label { font-size: 12px; color: #64748b; }
|
|
.tb-sep { width: 1px; height: 20px; background: #eef2f7; }
|
|
.tb-count { margin-left: auto; font-size: 12px; color: #94a3b8; }
|
|
|
|
.scope-bar { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: #94a3b8; margin: 10px 2px 16px; }
|
|
.scope-chip { font-size: 11px; font-weight: 600; padding: 2px 9px; border-radius: 6px; background: #eef2ff; color: #4338ca; flex-shrink: 0; }
|
|
|
|
.empty-box { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; text-align: center; color: #cbd5e1; padding: 56px 20px; }
|
|
|
|
/* ===== 分组标题 ===== */
|
|
.group-head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
|
.group-line { flex: 1; height: 1px; background: #eef2f7; }
|
|
.type-tag { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 8px; flex-shrink: 0; }
|
|
|
|
/* ===== 卡片 ===== */
|
|
.agent-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
|
|
.agent-card {
|
|
position: relative; overflow: hidden; background: #fff; border: 1px solid #eef2f7; border-radius: 16px;
|
|
padding: 16px 16px 10px; cursor: pointer; transition: border-color .15s, box-shadow .15s, transform .15s;
|
|
}
|
|
.agent-card:hover { border-color: color-mix(in srgb, var(--accent) 40%, #fff); box-shadow: 0 8px 24px rgba(15, 23, 42, .07); transform: translateY(-2px); }
|
|
.agent-card.off { background: #fcfcfd; }
|
|
.agent-card.off .card-name, .agent-card.off .card-desc { color: #94a3b8; }
|
|
.card-accent { position: absolute; inset: 0 0 auto 0; height: 3px; background: var(--accent); opacity: .85; }
|
|
.agent-card.off .card-accent { opacity: .25; }
|
|
|
|
.card-head { display: flex; align-items: flex-start; gap: 11px; }
|
|
.avatar-box {
|
|
width: 42px; height: 42px; border-radius: 12px; flex-shrink: 0; overflow: hidden;
|
|
display: flex; align-items: center; justify-content: center; font-size: 20px;
|
|
background: var(--accent-bg); border: 1px solid color-mix(in srgb, var(--accent) 18%, #fff);
|
|
}
|
|
.avatar-box img { width: 100%; height: 100%; object-fit: cover; }
|
|
.card-name { font-size: 14px; font-weight: 650; color: #1e293b; }
|
|
.state-dot { width: 7px; height: 7px; border-radius: 50%; background: #cbd5e1; flex-shrink: 0; }
|
|
.state-dot.on { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, .15); }
|
|
.card-id {
|
|
display: inline-flex; align-items: center; gap: 4px; margin-top: 2px; max-width: 100%;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10.5px; color: #94a3b8;
|
|
background: none; border: none; padding: 0; cursor: copy;
|
|
}
|
|
.card-id:hover { color: #6366f1; }
|
|
.copy-ico { opacity: 0; transition: opacity .15s; }
|
|
.card-id:hover .copy-ico { opacity: 1; }
|
|
|
|
.card-desc { font-size: 12px; color: #64748b; margin-top: 10px; line-height: 1.5; min-height: 36px; display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
.card-desc.muted { color: #cbd5e1; }
|
|
|
|
.pipeline { display: flex; align-items: center; gap: 4px; margin-top: 10px; flex-wrap: wrap; }
|
|
.svc-pill {
|
|
display: inline-flex; align-items: center; gap: 4px; max-width: 100%; font-size: 10.5px; color: #475569;
|
|
background: #f8fafc; border: 1px solid #eef2f7; padding: 2px 8px; border-radius: 7px;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.svc-pill b { font-size: 9.5px; font-weight: 700; letter-spacing: .03em; color: var(--accent); }
|
|
.svc-pill.miss { color: #dc2626; background: #fef2f2; border-color: #fecaca; }
|
|
/* 服务选了,但它的凭据一个都没配——下发到客户端就是空字符串,跟没选一样跑不起来 */
|
|
.svc-pill.nocred { color: #b45309; background: #fffbeb; border-color: #fde68a; }
|
|
.svc-pill.nocred b { color: #b45309; }
|
|
.ed-warn { font-size: 11.5px; color: #b45309; background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 6px 9px; margin-top: 8px; line-height: 1.6; }
|
|
.pipe-arrow { color: #cbd5e1; font-size: 13px; }
|
|
|
|
.card-stats { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; font-size: 10.5px; color: #94a3b8; }
|
|
|
|
.card-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 10px; padding-top: 8px; border-top: 1px dashed #f1f5f9; }
|
|
.foot-toggle { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #94a3b8; cursor: pointer; }
|
|
.foot-acts { display: flex; gap: 2px; opacity: .35; transition: opacity .15s; }
|
|
.agent-card:hover .foot-acts { opacity: 1; }
|
|
|
|
/* ===== 弹窗 ===== */
|
|
.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 { display: flex; flex-direction: column; background: #fff; border-radius: 18px; width: 820px; max-width: 96vw; max-height: 90vh; overflow: hidden; box-shadow: 0 24px 80px rgba(0, 0, 0, .18); }
|
|
|
|
.ag-head { display: flex; align-items: center; gap: 14px; padding: 18px 22px 14px; border-bottom: 1px solid #f1f5f9; }
|
|
.ah-avatar {
|
|
position: relative; width: 52px; height: 52px; border-radius: 14px; flex-shrink: 0; overflow: hidden; cursor: pointer;
|
|
display: flex; align-items: center; justify-content: center; font-size: 24px; background: #f1f5f9; border: 1px solid #e2e8f0;
|
|
}
|
|
.ah-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
|
.ah-avatar-mask { position: absolute; inset: auto 0 0 0; background: rgba(15, 23, 42, .62); color: #fff; font-size: 9px; text-align: center; padding: 2px 0; opacity: 0; transition: opacity .15s; }
|
|
.ah-avatar:hover .ah-avatar-mask { opacity: 1; }
|
|
.ah-id { display: flex; align-items: center; gap: 6px; margin-top: 3px; font-size: 11px; color: #94a3b8; }
|
|
.ah-id-label { font-size: 10px; letter-spacing: .04em; color: #cbd5e1; }
|
|
.ah-id code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; color: #475569; background: #f8fafc; border: 1px solid #eef2f7; border-radius: 5px; padding: 1px 6px; }
|
|
.ah-id em { font-style: normal; color: #cbd5e1; }
|
|
|
|
.ag-tabs { display: flex; gap: 2px; padding: 0 18px; border-bottom: 1px solid #f1f5f9; overflow-x: auto; }
|
|
.ag-tab {
|
|
display: flex; align-items: center; gap: 5px; padding: 10px 12px; font-size: 12.5px; font-weight: 600; color: #94a3b8;
|
|
background: none; border: none; border-bottom: 2px solid transparent; cursor: pointer; white-space: nowrap; transition: all .15s;
|
|
}
|
|
.ag-tab:hover { color: #64748b; }
|
|
.ag-tab.on { color: #4338ca; border-bottom-color: #6366f1; }
|
|
.tab-badge { font-size: 9.5px; font-weight: 700; min-width: 16px; padding: 0 5px; line-height: 15px; border-radius: 8px; background: #eef2ff; color: #6366f1; }
|
|
|
|
.ag-body { flex: 1; overflow-y: auto; padding: 4px 22px 18px; }
|
|
.ed-block { padding: 16px 0; border-top: 1px dashed #f1f5f9; }
|
|
.ed-block:first-child { border-top: none; }
|
|
.ed-block-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; line-height: 1.6; }
|
|
.ed-empty { font-size: 12px; color: #cbd5e1; padding: 14px; text-align: center; background: #fafbfc; border: 1px dashed #e2e8f0; border-radius: 10px; }
|
|
.req { color: #dc2626; font-style: normal; }
|
|
|
|
.type-row { display: grid; grid-template-columns: repeat(2, 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; }
|
|
|
|
.avatar-editor { display: flex; align-items: flex-start; gap: 16px; }
|
|
.ae-preview { width: 76px; height: 76px; border-radius: 16px; flex-shrink: 0; overflow: hidden; display: flex; align-items: center; justify-content: center; font-size: 30px; background: #f8fafc; border: 1px dashed #e2e8f0; }
|
|
.ae-preview img { width: 100%; height: 100%; object-fit: cover; }
|
|
|
|
.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-insert { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
|
.var-chip { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10.5px; color: #4338ca; background: #eef2ff; border: 1px solid #e0e7ff; border-radius: 6px; padding: 2px 7px; cursor: pointer; transition: all .15s; }
|
|
.var-chip:hover:not(:disabled) { background: #e0e7ff; }
|
|
.var-chip:disabled { opacity: .4; cursor: not-allowed; }
|
|
|
|
.var-table { display: flex; flex-direction: column; gap: 6px; }
|
|
.var-head, .var-row { display: grid; grid-template-columns: 1.1fr 1fr 1.2fr 1.2fr 52px; gap: 8px; align-items: center; }
|
|
.var-head span { font-size: 10px; color: #cbd5e1; letter-spacing: .03em; }
|
|
|
|
.mcp-list { display: flex; flex-direction: column; gap: 10px; }
|
|
.mcp-card { border: 1.5px solid #e2e8f0; border-radius: 12px; overflow: hidden; transition: all .15s; }
|
|
.mcp-card:hover { border-color: #c7d2fe; }
|
|
.mcp-card.on { border-color: #6366f1; }
|
|
.mcp-card-head { display: flex; align-items: center; gap: 9px; padding: 10px 12px; font-size: 12.5px; color: #475569; cursor: pointer; }
|
|
.mcp-card.on .mcp-card-head { background: #eef2ff; color: #4338ca; }
|
|
.mcp-count { font-size: 10.5px; color: #94a3b8; flex-shrink: 0; }
|
|
.mcp-card-body { padding: 0 12px 12px; }
|
|
.mcp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 8px; }
|
|
.mcp-item { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 9px 11px; border: 1.5px solid #e2e8f0; border-radius: 10px; font-size: 12.5px; color: #475569; cursor: pointer; transition: all .15s; }
|
|
.mcp-item:hover { border-color: #c7d2fe; background: #f8fafc; }
|
|
.mcp-item.on { border-color: #6366f1; background: #eef2ff; color: #4338ca; }
|
|
.glb-tag { font-style: normal; font-size: 9px; font-weight: 700; margin-left: 5px; padding: 1px 5px; border-radius: 4px; background: #f1f5f9; color: #94a3b8; vertical-align: middle; }
|
|
|
|
.ed-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 22px; border-top: 1px solid #f1f5f9; background: #fcfcfd; }
|
|
|
|
@media (max-width: 860px) {
|
|
.type-row { grid-template-columns: 1fr; }
|
|
.slot-row { flex-direction: column; align-items: stretch; }
|
|
.slot-arrow { display: none; }
|
|
.var-head { display: none; }
|
|
.var-row { grid-template-columns: 1fr 1fr; }
|
|
}
|
|
</style>
|
|
|