|
|
@@ -1,7 +1,8 @@
|
|
|
<script setup lang="ts">
|
|
|
-import { computed, onMounted, reactive, ref } from 'vue'
|
|
|
+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'
|
|
|
@@ -20,9 +21,46 @@ async function load() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// ---- 头像: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: '🤖', kind: 'worker', description: '', profileId: '', enabled: true },
|
|
|
+ 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)
|
|
|
|
|
|
@@ -30,7 +68,8 @@ 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: '🤖', kind: 'worker', description: '', profileId: profiles.value.find(p => p.isDefault)?.id ?? profiles.value[0]?.id ?? '', enabled: true }
|
|
|
+ : { 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
|
|
|
}
|
|
|
|
|
|
@@ -82,7 +121,7 @@ onMounted(load)
|
|
|
<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 }">
|
|
|
- <div class="w-10 h-10 flex-none flex items-center justify-center rounded-full bg-code text-[20px]">{{ b.avatar }}</div>
|
|
|
+ <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>
|
|
|
@@ -111,10 +150,41 @@ onMounted(load)
|
|
|
<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">
|
|
|
- <div class="w-20">
|
|
|
- <label class="label" for="bot-avatar">头像</label>
|
|
|
- <input id="bot-avatar" v-model="editor.form.avatar" class="field text-center" maxlength="8">
|
|
|
+ <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>
|
|
|
@@ -148,3 +218,73 @@ onMounted(load)
|
|
|
</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>
|