| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294 |
- <script setup lang="ts">
- import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
- import { useRouter } from 'vue-router'
- import type { BotDto, BotInput, ProfileDto } from '@agent-server/shared'
- import { AVATAR_COLORS, AVATAR_GROUPS, AVATAR_ICONS, BotAvatar, KEYLINE_AVATAR_SVGS, avatarValue, parseAvatar } from '@agent-server/chat-ui'
- import PageHeader from '../components/PageHeader.vue'
- import { admin, botClient, errorMessage } from '../lib/api'
- import { PERMISSION_LABELS, toast } from '../lib/ui'
- const router = useRouter()
- const bots = ref<BotDto[]>([])
- const profiles = ref<ProfileDto[]>([])
- const profileOf = (id: string) => profiles.value.find(p => p.id === id)
- const KIND = { lead: '组长', worker: '组员' } as const
- async function load() {
- try {
- ;[bots.value, profiles.value] = await Promise.all([admin.bots(), admin.profiles()])
- } catch (err) {
- toast(errorMessage(err), 'error')
- }
- }
- // ---- 头像:Keyline 图标 + 颜色(`kl:<图标>:<颜色>`;旧的 codicon / emoji 头像仍可显示) ----
- const COLOR_NAMES = Object.keys(AVATAR_COLORS)
- const current = computed(() => {
- const m = /^(?:kl|icon):([a-z0-9-]+):([a-z]+)$/.exec(editor.form.avatar)
- return m ? { icon: m[1]!, color: m[2]! } : { icon: '', color: 'blue' }
- })
- /** 其他机器人已经在用的图标 → 机器人名称(仍可选择,只做提示) */
- const usedIcons = computed(() => {
- const map = new Map<string, string>()
- for (const b of bots.value) {
- const a = parseAvatar(b.avatar)
- if (b.id !== editor.id && a.kind === 'svg') map.set(a.icon, b.name)
- }
- return map
- })
- /** 新建时默认选一个还没被使用的图标(组长优先用"大脑"),颜色轮换 */
- function freeAvatar(kind: BotDto['kind']) {
- const used = new Set(usedIcons.value.keys())
- const pool = kind === 'lead' ? ['brain-sparkles', ...AVATAR_ICONS] : AVATAR_ICONS
- return avatarValue(pool.find(i => !used.has(i)) ?? 'bot', COLOR_NAMES[bots.value.length % COLOR_NAMES.length]!)
- }
- const avatarOpen = ref(false)
- const iconScroll = ref<HTMLElement>()
- // 打开面板时把当前图标滚到可见位置
- watch(avatarOpen, async open => {
- if (!open) return
- await nextTick()
- iconScroll.value?.querySelector('.cell.on')?.scrollIntoView({ block: 'nearest' })
- })
- function pickIcon(icon: string) {
- editor.form.avatar = avatarValue(icon, current.value.color)
- avatarOpen.value = false
- }
- function pickColor(color: string) {
- editor.form.avatar = avatarValue(current.value.icon && KEYLINE_AVATAR_SVGS[current.value.icon] ? current.value.icon : 'bot', color)
- }
- // ---- 机器人 ----
- const editor = reactive<{ open: boolean; id: string | null; form: BotInput; saving: boolean }>({
- open: false, id: null, saving: false, form: { name: '', avatar: 'kl:bot:blue', kind: 'worker', description: '', profileId: '', enabled: true },
- })
- const formValid = computed(() => editor.form.name.trim() && !/[\s@]/.test(editor.form.name) && editor.form.avatar.trim() && editor.form.profileId)
- function openBot(b?: BotDto) {
- editor.id = b?.id ?? null
- editor.form = b
- ? { name: b.name, avatar: b.avatar, kind: b.kind, description: b.description, profileId: b.profileId, enabled: b.enabled }
- : { name: '', avatar: freeAvatar('worker'), kind: 'worker', description: '', profileId: profiles.value.find(p => p.isDefault)?.id ?? profiles.value[0]?.id ?? '', enabled: true }
- avatarOpen.value = false
- editor.open = true
- }
- async function saveBot() {
- if (!formValid.value) return
- editor.saving = true
- try {
- const input = { ...editor.form, name: editor.form.name.trim() }
- if (editor.id) await admin.updateBot(editor.id, input)
- else await admin.createBot(input)
- editor.open = false
- toast('已保存', 'success')
- await load()
- } catch (err) {
- toast(errorMessage(err), 'error')
- } finally {
- editor.saving = false
- }
- }
- async function removeBot(b: BotDto) {
- if (!confirm(`删除机器人「${b.name}」?`)) return
- await admin.deleteBot(b.id).catch(err => toast(errorMessage(err), 'error'))
- await load()
- }
- /**
- * 在 Bot Playground 中测试:组员新建只有它的工作组;
- * 组长新建"组长 + 全部可用组员",组员都设为替身,不花组员的成本就能反复测试组长的拆分与汇总
- */
- async function tryBot(b: BotDto) {
- try {
- const workers = b.kind === 'lead' ? bots.value.filter(x => x.kind === 'worker' && x.enabled).map(x => x.id) : [b.id]
- const t = await botClient.teams.create({
- title: `测试 · ${b.name}`, human: { id: 'admin', name: '管理员' },
- ...(b.kind === 'lead' ? { leadBotId: b.id, workerBotIds: workers, debug: { stubBotIds: workers } } : { workerBotIds: workers }),
- })
- await router.push(`/bot-playground/${t.id}`)
- } catch (err) {
- toast(errorMessage(err), 'error')
- }
- }
- onMounted(load)
- </script>
- <template>
- <div class="h-full flex flex-col">
- <PageHeader title="机器人" subtitle="不同岗位的机器人:身份 + 关联的 Profile(技能、工具、权限);业务平台通过 Bot API 使用(Bot Key 在 API Keys 页面签发)">
- <RouterLink to="/api-keys?kind=bot" class="btn-ghost no-underline"><span class="codicon codicon-key" />Bot Keys</RouterLink>
- <button type="button" class="btn-primary" :disabled="!profiles.length" @click="openBot()"><span class="codicon codicon-add" />新建机器人</button>
- </PageHeader>
- <div class="flex-1 overflow-y-auto p-6 flex flex-col gap-3 max-w-240">
- <div v-if="!bots.length" class="card p-4 hint">还没有机器人。每个机器人关联一个 Profile:技能、MCP 服务、资料库和权限都由 Profile 决定;机器人本身只是身份和岗位描述。</div>
- <div v-for="b in bots" :key="b.id" class="card p-4 flex gap-3" :class="{ 'opacity-60': !b.enabled }">
- <BotAvatar :value="b.avatar" :size="40" />
- <div class="flex-1 min-w-0 flex flex-col gap-1">
- <div class="flex items-center gap-2">
- <span class="font-600">{{ b.name }}</span>
- <span class="badge bg-code" :class="b.kind === 'lead' ? 'text-accent' : ''">{{ KIND[b.kind] }}</span>
- <span v-if="!b.enabled" class="badge bg-code text-fg-muted">已停用</span>
- </div>
- <div class="hint whitespace-pre-wrap">{{ b.description || '(没有岗位描述)' }}</div>
- <div class="text-[12px] text-fg-muted">
- <template v-if="profileOf(b.profileId)">
- Profile:<RouterLink :to="`/profiles/${b.profileId}`" class="text-link">{{ profileOf(b.profileId)!.name }}</RouterLink>
- · {{ profileOf(b.profileId)!.provider }} / {{ profileOf(b.profileId)!.model }} · {{ PERMISSION_LABELS[profileOf(b.profileId)!.permissionMode] }}
- </template>
- <span v-else class="text-error">Profile 已删除,请重新选择</span>
- </div>
- </div>
- <div class="flex-none flex items-start gap-1">
- <button type="button" class="btn-ghost" :disabled="!b.enabled" @click="tryBot(b)"><span class="codicon codicon-play" />测试</button>
- <button type="button" class="btn-ghost" @click="openBot(b)">编辑</button>
- <button type="button" class="icon-btn" title="删除" @click="removeBot(b)"><span class="codicon codicon-trash" /></button>
- </div>
- </div>
- </div>
- <!-- 机器人编辑 -->
- <div v-if="editor.open" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="editor.open = false">
- <form class="w-[560px] card bg-input-alt p-5 flex flex-col gap-3 shadow-xl" @submit.prevent="saveBot">
- <div class="font-600 text-[14px]">{{ editor.id ? `编辑「${editor.form.name}」` : '新建机器人' }}</div>
- <div class="flex gap-3 items-end">
- <!-- 头像:点击后弹出选择面板,选中即关闭 -->
- <div class="relative flex-none">
- <button
- type="button" class="avatar-btn" :class="{ open: avatarOpen }" title="选择头像" aria-label="选择头像"
- @click="avatarOpen = !avatarOpen"
- >
- <BotAvatar :value="editor.form.avatar" :size="48" /><span class="codicon codicon-edit" />
- </button>
- <div v-if="avatarOpen" class="fixed inset-0 z-10" @click="avatarOpen = false" />
- <div v-if="avatarOpen" class="avatar-panel">
- <div class="flex items-center gap-1.5 pb-2 mb-2 border-b border-b-solid border-border">
- <span class="hint mr-1">颜色</span>
- <button
- v-for="c in COLOR_NAMES" :key="c" type="button" class="swatch" :class="{ on: current.color === c }"
- :style="{ background: AVATAR_COLORS[c] }" :aria-label="`颜色 ${c}`" @click="pickColor(c)"
- />
- </div>
- <div ref="iconScroll" class="icon-scroll flex flex-col gap-1">
- <div v-for="g in AVATAR_GROUPS" :key="g.label" class="flex items-center gap-1">
- <span class="w-9 flex-none hint text-[11px]">{{ g.label }}</span>
- <div class="flex-1 grid grid-cols-8 gap-1">
- <button
- v-for="icon in g.icons" :key="icon" type="button" class="cell"
- :class="{ used: usedIcons.has(icon) && current.icon !== icon, on: current.icon === icon }"
- :style="current.icon === icon ? { background: AVATAR_COLORS[current.color], color: '#fff' } : {}"
- :title="usedIcons.has(icon) ? `已被「${usedIcons.get(icon)}」使用` : icon" :aria-label="`图标 ${icon}`" @click="pickIcon(icon)"
- >
- <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" v-html="KEYLINE_AVATAR_SVGS[icon]" />
- </button>
- </div>
- </div>
- </div>
- <div class="hint mt-2">变暗的图标已被其他机器人使用,仍可选择</div>
- </div>
- </div>
- <div class="flex-1">
- <label class="label" for="bot-name">名称(不能有空格和 @)</label>
- <input id="bot-name" v-model="editor.form.name" class="field" placeholder="例如:数据分析员" maxlength="30" autofocus>
- </div>
- <div class="w-28">
- <label class="label" for="bot-kind">类型</label>
- <select id="bot-kind" v-model="editor.form.kind" class="field">
- <option value="worker">组员</option>
- <option value="lead">组长</option>
- </select>
- </div>
- </div>
- <div>
- <label class="label" for="bot-desc">岗位描述</label>
- <textarea id="bot-desc" v-model="editor.form.description" class="field-area h-28" placeholder="擅长什么、负责什么、不负责什么。会写进其他机器人的提示词,组长据此分派任务。" />
- </div>
- <div>
- <label class="label" for="bot-profile">Profile</label>
- <select id="bot-profile" v-model="editor.form.profileId" class="field">
- <option v-for="p in profiles" :key="p.id" :value="p.id">{{ p.name }}({{ p.provider }} / {{ p.model }} · {{ PERMISSION_LABELS[p.permissionMode] }})</option>
- </select>
- <div class="hint mt-1">技能、MCP 服务、资料库、权限模式都由 Profile 决定。工作组中的机器人尽量用"自动执行"或"只读",避免频繁审批。</div>
- </div>
- <label class="flex items-center gap-2 cursor-pointer"><input v-model="editor.form.enabled" type="checkbox">启用</label>
- <div class="flex justify-end gap-2">
- <button type="button" class="btn-secondary" @click="editor.open = false">取消</button>
- <button type="submit" class="btn-primary" :disabled="!formValid || editor.saving">保存</button>
- </div>
- </form>
- </div>
- </div>
- </template>
- <style scoped>
- .avatar-btn {
- position: relative;
- display: flex;
- padding: 0;
- border: 2px solid transparent;
- border-radius: 50%;
- background: transparent;
- cursor: pointer;
- }
- .avatar-btn:hover, .avatar-btn.open { border-color: var(--app-input-active-border); }
- .avatar-btn > .codicon {
- position: absolute;
- right: -4px;
- bottom: -4px;
- width: 18px;
- height: 18px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 50%;
- background: var(--app-input-secondary-background);
- border: 1px solid var(--app-input-border);
- font-size: 11px;
- color: var(--app-secondary-foreground);
- }
- .avatar-panel {
- position: absolute;
- z-index: 20;
- top: calc(100% + 6px);
- left: 0;
- width: 420px;
- padding: 10px;
- border: 1px solid var(--app-input-border);
- border-radius: 8px;
- background: var(--app-input-secondary-background);
- box-shadow: var(--app-shadow);
- }
- /* 图标区域约显示 5 行,其余滚动查看 */
- .icon-scroll {
- max-height: 196px;
- overflow-y: auto;
- padding-right: 4px;
- }
- .swatch {
- width: 20px;
- height: 20px;
- padding: 0;
- border: 2px solid transparent;
- border-radius: 50%;
- cursor: pointer;
- outline: 1px solid transparent;
- }
- .swatch.on { border-color: var(--app-input-secondary-background); outline-color: var(--app-primary-foreground); }
- .cell {
- height: 36px;
- display: flex;
- align-items: center;
- justify-content: center;
- border: none;
- border-radius: 6px;
- background: transparent;
- color: var(--app-primary-foreground);
- cursor: pointer;
- }
- .cell:hover { background: var(--app-list-hover-background); }
- .cell.used { opacity: 0.35; }
- .cell.used:hover { opacity: 0.7; }
- </style>
|