Răsfoiți Sursa

feat: 机器人头像改为 Keyline 图标选择(10 组 80 个图标 + 8 种颜色)

- 头像格式 kl:<图标>:<颜色>;旧的 icon:<codicon>:<颜色> 与 emoji 头像仍可显示
- 图标数据由 scripts/gen-keyline-avatars.py 从 @iconify-json/keyline-icons 1.2.18 抽取(MIT),不新增依赖
- chat-ui 新增 BotAvatar 组件与头像工具函数,TeamChat / Playground / Bot Key 页面统一使用
- 新建/编辑机器人时用弹出面板选头像,图标区域可滚动,已被其他机器人使用的图标变暗

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 săptămână în urmă
părinte
comite
600635eb96

+ 2 - 1
apps/admin/src/components/BotKeysPanel.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 // Bot Key(ADR-0024):只能调用 /bot/v1,按机器人授权;与 Agent Key 互不通用
 import { computed, onMounted, reactive, ref } from 'vue'
+import { BotAvatar } from '@agent-server/chat-ui'
 import type { BotDto, BotKeyDto } from '@agent-server/shared'
 import { admin, errorMessage } from '../lib/api'
 import { formatTime, toast } from '../lib/ui'
@@ -129,7 +130,7 @@ defineExpose({ openKey: () => openKey() })
           <div class="flex flex-col gap-1 max-h-72 overflow-y-auto">
             <label v-for="b in bots" :key="b.id" class="flex items-center gap-2 px-2 h-8 rounded-md hover:bg-hover cursor-pointer">
               <input type="checkbox" :checked="editor.allowed.includes(b.id)" @change="toggleAllowed(b.id)">
-              <span>{{ b.avatar }} {{ b.name }}</span><span class="hint">{{ KIND[b.kind] }}</span>
+              <BotAvatar :value="b.avatar" :size="18" /><span>{{ b.name }}</span><span class="hint">{{ KIND[b.kind] }}</span>
             </label>
           </div>
           <div class="hint mt-1">平台调用 <code class="font-mono">GET /bot/v1/bots</code> 只会看到勾选的机器人,建工作组时也只能使用它们。</div>

+ 11 - 7
apps/admin/src/views/BotPlaygroundView.vue

@@ -2,7 +2,7 @@
 // Bot Playground(docs/design/13-bot-teams.md §12.2):以后台身份调用 /bot/v1,建工作组、下命令、查看每个机器人的执行过程
 import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
-import { AgentChat, TeamChat, basename, fileIcon, formatSize, useChatSession, useTeamSession, type ChatSession, type TeamSession } from '@agent-server/chat-ui'
+import { AgentChat, BotAvatar, TeamChat, avatarText, basename, fileIcon, formatSize, useChatSession, useTeamSession, type ChatSession, type TeamSession } from '@agent-server/chat-ui'
 import type { BotDto, FileDto, TeamDto, TeamStatus } from '@agent-server/shared'
 import FilePreview from '../components/FilePreview.vue'
 import SidePane from '../components/SidePane.vue'
@@ -195,7 +195,10 @@ onBeforeUnmount(() => {
       >
         <div class="flex-1 min-w-0">
           <div class="truncate" :class="{ 'text-fg-muted': x.status === 'closed' }">{{ x.title }}</div>
-          <div class="text-[11px] text-fg-muted truncate">{{ x.bots.map(b => b.avatar).join('') }} · {{ relativeTime(x.updatedAt) }}</div>
+          <div class="flex items-center gap-1 text-[11px] text-fg-muted truncate">
+            <BotAvatar v-for="b in x.bots" :key="b.id" :value="b.avatar" :size="14" />
+            <span class="ml-0.5">{{ relativeTime(x.updatedAt) }}</span>
+          </div>
         </div>
         <span v-if="x.bots.some(b => b.status !== 'idle')" class="w-2 h-2 rounded-full flex-none" :class="x.bots.some(b => b.status === 'waiting_input') ? 'bg-warning' : 'bg-accent'" />
         <span v-else-if="x.status !== 'active'" class="hint text-[11px]">{{ STATUS[x.status].text }}</span>
@@ -225,7 +228,7 @@ onBeforeUnmount(() => {
           <label class="label" for="team-lead">组长(可选)</label>
           <select id="team-lead" v-model="form.lead" class="field">
             <option value="">不要组长</option>
-            <option v-for="b in leads" :key="b.id" :value="b.id">{{ b.avatar }} {{ b.name }}</option>
+            <option v-for="b in leads" :key="b.id" :value="b.id">{{ avatarText(b.avatar) }} {{ b.name }}</option>
           </select>
         </div>
         <div>
@@ -234,7 +237,8 @@ onBeforeUnmount(() => {
           <div class="flex flex-col gap-0.5 max-h-60 overflow-y-auto">
             <label v-for="b in workers" :key="b.id" class="flex items-start gap-2 px-2 py-1 rounded-md hover:bg-hover cursor-pointer">
               <input v-model="form.workers" type="checkbox" :value="b.id" class="mt-0.5">
-              <span class="flex-1 min-w-0"><span>{{ b.avatar }} {{ b.name }}</span><span class="block hint truncate">{{ b.description }}</span></span>
+              <BotAvatar :value="b.avatar" :size="20" />
+              <span class="flex-1 min-w-0"><span>{{ b.name }}</span><span class="block hint truncate">{{ b.description }}</span></span>
             </label>
           </div>
         </div>
@@ -280,7 +284,7 @@ onBeforeUnmount(() => {
             <div class="flex items-center gap-2 px-1"><span class="codicon codicon-person" />{{ t.human.name }}<span class="hint">成员</span></div>
             <div v-for="b in t.bots" :key="b.id" class="card p-2.5 flex flex-col gap-1">
               <div class="flex items-center gap-2">
-                <span class="text-[16px]">{{ b.avatar }}</span>
+                <BotAvatar :value="b.avatar" :size="22" />
                 <span class="font-600">{{ b.name }}</span>
                 <span class="badge bg-code" :class="b.role === 'lead' ? 'text-accent' : ''">{{ b.role === 'lead' ? '组长' : '组员' }}</span>
                 <span class="flex-1" />
@@ -300,7 +304,7 @@ onBeforeUnmount(() => {
               @change="addBot"
             >
               <option value="" disabled>+ 添加机器人…</option>
-              <option v-for="b in addable" :key="b.id" :value="b.id">{{ b.avatar }} {{ b.name }}({{ b.kind === 'lead' ? '组长' : '组员' }})</option>
+              <option v-for="b in addable" :key="b.id" :value="b.id">{{ avatarText(b.avatar) }} {{ b.name }}({{ b.kind === 'lead' ? '组长' : '组员' }})</option>
             </select>
           </div>
 
@@ -309,7 +313,7 @@ onBeforeUnmount(() => {
             <div class="flex-none flex items-center gap-2 px-3 h-9 border-b border-b-solid border-border">
               <select class="field h-7" :value="proc.botId" aria-label="选择机器人" @change="pickBot(($event.target as HTMLSelectElement).value)">
                 <option value="" disabled>选择机器人…</option>
-                <option v-for="b in t.bots" :key="b.id" :value="b.id" :disabled="!b.sessionId">{{ b.avatar }} {{ b.name }}{{ b.sessionId ? '' : '(还没有工作过)' }}</option>
+                <option v-for="b in t.bots" :key="b.id" :value="b.id" :disabled="!b.sessionId">{{ avatarText(b.avatar) }} {{ b.name }}{{ b.sessionId ? '' : '(还没有工作过)' }}</option>
               </select>
             </div>
             <div ref="procEl" class="flex-1 min-h-0">

+ 148 - 8
apps/admin/src/views/BotsView.vue

@@ -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>

+ 2 - 1
apps/server/src/teams/bots.ts

@@ -13,7 +13,8 @@ type BotRow = typeof bots.$inferSelect
 
 const botSchema: z.ZodType<BotInput> = z.object({
   name: z.string().trim().min(1).max(30).refine(s => !/[\s@]/.test(s), '名称不能包含空格或 @'),
-  avatar: z.string().trim().min(1).max(8),
+  // `kl:<Keyline 图标>:<颜色>`(后台的图标头像;早先的 `icon:<codicon>:<颜色>` 仍可用),或一个 emoji
+  avatar: z.string().trim().min(1).max(48).refine(s => /^(kl|icon):[a-z0-9-]+:[a-z]+$/.test(s) || s.length <= 8, '头像格式不正确'),
   kind: z.enum(['lead', 'worker']),
   description: z.string().max(4000),
   profileId: z.string().min(1),

+ 11 - 1
apps/server/test/teams.test.ts

@@ -82,7 +82,7 @@ beforeAll(async () => {
   for (const [name, kind] of [['数据员', 'worker'], ['文档员', 'worker'], ['组长', 'lead'], ['外部员', 'worker']] as const) {
     const res = await server.inject({
       method: 'POST', url: '/admin/v1/bots', cookies: adminCookie,
-      payload: { name, avatar: '🤖', kind, description: `${name}的岗位`, profileId, enabled: true },
+      payload: { name, avatar: kind === 'lead' ? 'kl:brain:purple' : '🤖', kind, description: `${name}的岗位`, profileId, enabled: true },
     })
     expect(res.statusCode).toBe(201)
     ids[name] = res.json().id
@@ -282,6 +282,16 @@ describe('默认处理人与生命周期', () => {
     expect((await server.inject({ method: 'GET', url: `/bot/v1/teams/${t.id}`, headers: bk() })).statusCode).toBe(404)
   })
 
+  it('头像只接受图标格式或一个 emoji', async () => {
+    const profileId = app.profiles.getBuiltin().id
+    const bad = await server.inject({
+      method: 'POST', url: '/admin/v1/bots', cookies: adminCookie,
+      payload: { name: '坏头像', avatar: 'icon:Bad Name!', kind: 'worker', description: '', profileId, enabled: true },
+    })
+    expect(bad.statusCode).toBe(400)
+    expect(app.bots.get(ids['组长']!).avatar).toBe('kl:brain:purple')
+  })
+
   it('还在工作组中的机器人不能删除;用量记到 Bot Key 上', async () => {
     expect((await server.inject({ method: 'DELETE', url: `/admin/v1/bots/${ids['数据员']}`, cookies: adminCookie })).statusCode).toBe(400)
     const usage = app.usage.summary({ days: 1 })

+ 37 - 0
packages/chat-ui/src/avatar.ts

@@ -0,0 +1,37 @@
+// 机器人头像:`kl:<Keyline 图标>:<颜色>`(白色图标 + 彩色圆底);
+// 兼容早先的 `icon:<codicon 名称>:<颜色>` 与 emoji 文本
+import { KEYLINE_AVATAR_GROUPS, KEYLINE_AVATAR_SVGS } from './keyline-avatars'
+
+export const AVATAR_COLORS: Record<string, string> = {
+  blue: '#2f6fde', green: '#1f8a4c', orange: '#d9622b', purple: '#8250df',
+  red: '#cf3a3a', teal: '#0f8a85', pink: '#c7337d', slate: '#4b5563',
+}
+
+/** 头像选择面板中的图标分组 */
+export const AVATAR_GROUPS = KEYLINE_AVATAR_GROUPS
+export const AVATAR_ICONS = KEYLINE_AVATAR_GROUPS.flatMap(g => g.icons)
+
+export type ParsedAvatar =
+  | { kind: 'svg'; icon: string; svg: string; color: string }
+  | { kind: 'codicon'; icon: string; color: string }
+  | { kind: 'emoji'; emoji: string }
+
+export const avatarValue = (icon: string, color: string) => `kl:${icon}:${color}`
+
+export function parseAvatar(value: string): ParsedAvatar {
+  const m = /^(kl|icon):([a-z0-9-]+):([a-z]+)$/.exec(value)
+  if (m) {
+    const color = AVATAR_COLORS[m[3]!] ?? AVATAR_COLORS.slate!
+    const svg = m[1] === 'kl' ? KEYLINE_AVATAR_SVGS[m[2]!] : undefined
+    if (svg) return { kind: 'svg', icon: m[2]!, svg, color }
+    if (m[1] === 'icon') return { kind: 'codicon', icon: m[2]!, color }
+    return { kind: 'svg', icon: 'bot', svg: KEYLINE_AVATAR_SVGS.bot!, color }
+  }
+  return { kind: 'emoji', emoji: value || '🤖' }
+}
+
+/** 纯文本场景(如 <option>):emoji 原样返回,图标头像返回空字符串 */
+export function avatarText(value: string) {
+  const a = parseAvatar(value)
+  return a.kind === 'emoji' ? a.emoji : ''
+}

+ 38 - 0
packages/chat-ui/src/components/BotAvatar.vue

@@ -0,0 +1,38 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import { parseAvatar } from '../avatar'
+
+const props = withDefaults(defineProps<{ value: string; size?: number }>(), { size: 26 })
+const a = computed(() => parseAvatar(props.value))
+const box = computed(() => ({ width: `${props.size}px`, height: `${props.size}px` }))
+</script>
+
+<template>
+  <!-- SVG 内容来自随包附带的 Keyline 图标数据(keyline-avatars.ts),不是用户输入 -->
+  <span v-if="a.kind === 'svg'" class="bot-avatar" :style="{ ...box, background: a.color }" role="img" :aria-label="a.icon">
+    <svg viewBox="0 0 24 24" :width="Math.round(size * 0.6)" :height="Math.round(size * 0.6)" aria-hidden="true" v-html="a.svg" />
+  </span>
+  <span v-else-if="a.kind === 'codicon'" class="bot-avatar" :style="{ ...box, background: a.color }" role="img" :aria-label="a.icon">
+    <span class="codicon" :class="`codicon-${a.icon}`" :style="{ fontSize: `${Math.round(size * 0.55)}px` }" />
+  </span>
+  <span v-else class="bot-avatar emoji" :style="{ ...box, fontSize: `${Math.round(size * 0.58)}px` }">{{ a.emoji }}</span>
+</template>
+
+<style scoped>
+.bot-avatar {
+  flex: none;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 50%;
+  color: #fff;
+  line-height: 1;
+  vertical-align: middle;
+}
+.bot-avatar .codicon { color: #fff; }
+.bot-avatar.emoji {
+  background: var(--app-input-secondary-background);
+  border: 1px solid var(--app-input-border);
+  color: inherit;
+}
+</style>

+ 11 - 18
packages/chat-ui/src/components/TeamChat.vue

@@ -4,6 +4,7 @@ import type { BotClient } from '@agent-server/client'
 import type { FileDto, TeamBotDto, TeamMessage } from '@agent-server/shared'
 import { downloadFile, provideClient } from '../context'
 import type { TeamItem, TeamSession } from '../team-store'
+import BotAvatar from './BotAvatar.vue'
 import Composer from './Composer.vue'
 import FileCard from './FileCard.vue'
 import InteractionCard from './InteractionCard.vue'
@@ -86,7 +87,8 @@ function time(ts: string) {
 function senderOf(item: Extract<TeamItem, { kind: 'message' }>) {
   const s = item.message.sender
   if (s.kind === 'human') return { avatar: '', name: s.name }
-  if (s.kind === 'bot') return { avatar: s.avatar, name: s.name }
+  // 机器人用当前的头像与名称(修改后历史消息也跟着变);已离开工作组时用消息里记录的
+  if (s.kind === 'bot') return { avatar: botById(s.botId)?.avatar ?? s.avatar, name: botById(s.botId)?.name ?? s.name }
   return { avatar: '', name: '' }
 }
 
@@ -119,7 +121,7 @@ defineExpose({ focus: () => composer.value?.focus(), setText: (t: string) => com
 
             <!-- 机器人消息 -->
             <div v-else-if="item.kind === 'message' && item.message.sender.kind === 'bot'" class="row bot">
-              <div class="avatar">{{ senderOf(item).avatar }}</div>
+              <BotAvatar :value="senderOf(item).avatar" />
               <div class="body">
                 <div class="meta">
                   <span class="name">{{ senderOf(item).name }}</span><span class="time">{{ time(item.ts) }}</span>
@@ -150,7 +152,7 @@ defineExpose({ focus: () => composer.value?.focus(), setText: (t: string) => com
 
             <!-- 审批与提问 -->
             <div v-else class="row bot">
-              <div class="avatar">{{ botById(item.botId)?.avatar ?? '🤖' }}</div>
+              <BotAvatar :value="botById(item.botId)?.avatar ?? ''" />
               <div class="body">
                 <div class="meta"><span class="name">{{ botById(item.botId)?.name ?? '机器人' }}</span><span class="time">{{ time(item.ts) }}</span></div>
                 <InteractionCard :item="item" :readonly="readonly" :reply="readonly ? undefined : session.reply" />
@@ -161,7 +163,7 @@ defineExpose({ focus: () => composer.value?.focus(), setText: (t: string) => com
         </template>
 
         <div v-for="b in busy" :key="b.id" class="working">
-          <span class="codicon codicon-loading codicon-modifier-spin" />{{ b.avatar }} {{ b.name }} {{ STATUS_TEXT[b.status] }}…
+          <span class="codicon codicon-loading codicon-modifier-spin" /><BotAvatar :value="b.avatar" :size="16" />{{ b.name }} {{ STATUS_TEXT[b.status] }}…
           <button v-if="!readonly && b.status !== 'waiting_input'" type="button" class="link" title="中断这个机器人的本轮工作" @click="session.interrupt(b.id)">停止</button>
         </div>
       </div>
@@ -184,7 +186,7 @@ defineExpose({ focus: () => composer.value?.focus(), setText: (t: string) => com
             v-for="b in bots" :key="b.id" type="button" class="mention" :class="{ on: mentions.includes(b.id) }"
             :title="`@${b.name}${b.role === 'lead' ? '(组长)' : ''}:${b.description}`" @click="toggleMention(b.id)"
           >
-            {{ b.avatar }} {{ b.name }}
+            <BotAvatar :value="b.avatar" :size="16" />{{ b.name }}
           </button>
           <slot name="toolbar" />
         </template>
@@ -225,18 +227,6 @@ defineExpose({ focus: () => composer.value?.focus(), setText: (t: string) => com
 .to { color: var(--app-link-foreground); margin-right: 4px; }
 
 .bot { display: flex; gap: 8px; }
-.avatar {
-  flex: none;
-  width: 26px;
-  height: 26px;
-  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: 14px;
-}
 .body { flex: 1; min-width: 0; }
 
 .system { text-align: center; font-size: 12px; color: var(--app-secondary-foreground); }
@@ -262,7 +252,10 @@ defineExpose({ focus: () => composer.value?.focus(), setText: (t: string) => com
 .conn { margin-bottom: 6px; font-size: 12px; color: var(--app-warning); }
 
 .mention {
-  padding: 1px 8px;
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 1px 8px 1px 2px;
   border: 1px solid transparent;
   border-radius: 999px;
   background: color-mix(in srgb, var(--app-primary-foreground) 10%, transparent);

+ 3 - 0
packages/chat-ui/src/index.ts

@@ -2,6 +2,7 @@ import '@vscode/codicons/dist/codicon.css'
 import './style.css'
 
 export { default as AgentChat } from './components/AgentChat.vue'
+export { default as BotAvatar } from './components/BotAvatar.vue'
 export { default as Composer } from './components/Composer.vue'
 export { default as FileCard } from './components/FileCard.vue'
 export { default as InteractionCard } from './components/InteractionCard.vue'
@@ -12,4 +13,6 @@ export { default as ToolCall } from './components/ToolCall.vue'
 export { downloadFile, provideClient, useClient, useFileObjectUrl, type FileClient } from './context'
 export { applyEvent, createChatState, useChatSession, type ChatSession, type ChatState, type RunState, type TimelineItem } from './store'
 export { applyTeamEvent, useTeamSession, type TeamItem, type TeamSession, type TeamState } from './team-store'
+export { AVATAR_COLORS, AVATAR_GROUPS, AVATAR_ICONS, avatarText, avatarValue, parseAvatar, type ParsedAvatar } from './avatar'
+export { KEYLINE_AVATAR_SVGS } from './keyline-avatars'
 export { basename, fileIcon, formatSize, renderMarkdown, toolLabel } from './util'

Fișier diff suprimat deoarece este prea mare
+ 52 - 0
packages/chat-ui/src/keyline-avatars.ts


+ 60 - 0
scripts/gen-keyline-avatars.py

@@ -0,0 +1,60 @@
+# 生成 packages/chat-ui/src/keyline-avatars.ts(机器人头像图标)
+# 用法:npm pack @iconify-json/keyline-icons@1.2.18 && tar xzf iconify-json-keyline-icons-1.2.18.tgz
+#       python3 scripts/gen-keyline-avatars.py package/icons.json packages/chat-ui/src/keyline-avatars.ts
+import json, re, sys
+src, out = sys.argv[1], sys.argv[2]
+d = json.load(open(src))['icons']
+groups = [
+  ('助手', 'bot bot-simple bot-square bot-circle bot-message bot-heart bot-star bot-cloud'),
+  ('智能', 'bot-droplet brain brain-sparkles brain-circuit brain-cog cpu-sparkles message-sparkles wand-sparkles'),
+  ('人物', 'user user-cog user-sparkles user-shield user-search user-pen user-voice headset'),
+  ('数据', 'bar-chart chart-pie chart-area database table calculator coins scale'),
+  ('文档', 'file file-chart-column book-open notebook-pen pen clipboard-list sticky-note folder-open'),
+  ('技术', 'code terminal server cloud cpu bug wrench toolbox'),
+  ('运维', 'shield-check lock key-round settings plug briefcase factory truck'),
+  ('沟通', 'mail message megaphone bell calendar clock send inbox'),
+  ('目标', 'rocket lightbulb target trophy crown sparkles flame star'),
+  ('探索', 'globe compass map search flask-conical atom palette puzzle'),
+]
+icons = {}
+for _, names in groups:
+    for n in names.split():
+        body = d[n]['body']
+        # 只允许纯图形:没有脚本、事件属性、外部引用
+        assert not re.search(r'<script|\son\w+=|href|url\(|<foreignObject', body, re.I), n
+        assert set(re.findall(r'<(\w+)', body)) <= {'g', 'path', 'circle', 'rect', 'ellipse', 'line', 'polyline', 'polygon'}, (n, set(re.findall(r'<(\w+)', body)))
+        icons[n] = body
+license = '''MIT License
+
+Copyright (c) 2026 Keyline Icons
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.'''
+lines = ['// 机器人头像图标:Keyline Icons(https://keylineicons.com)的线条样式(stroke 2),24×24,取自 @iconify-json/keyline-icons 1.2.18。',
+         '// 由 scripts/gen-keyline-avatars.py 生成,只收录头像用到的图标;新增图标时重新生成,不要手工修改 SVG。',
+         '/*', *(' * ' + l if l else ' *' for l in license.splitlines()), ' */', '',
+         '/** 头像选择面板中的分组(顺序即显示顺序) */',
+         'export const KEYLINE_AVATAR_GROUPS: { label: string; icons: string[] }[] = [']
+for label, names in groups:
+    lines.append(f"  {{ label: '{label}', icons: [{', '.join(repr(n) for n in names.split())}] }},")
+lines += [']', '', '/** 图标名称 → SVG 内容(viewBox 0 0 24 24,颜色为 currentColor) */', 'export const KEYLINE_AVATAR_SVGS: Record<string, string> = {']
+for n, b in icons.items():
+    lines.append(f"  {json.dumps(n)}: {json.dumps(b, ensure_ascii=False)},")
+lines += ['}', '']
+open(out, 'w').write('\n'.join(lines).replace("['", "['").replace("', '", "', '"))
+print(len(icons), 'icons')

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff