Explorar o código

修复 Bot 模式身份保存并统一账号显示

LSamien hai 1 mes
pai
achega
2489506a0e

+ 1 - 1
README.md

@@ -1,6 +1,6 @@
 # 夭夭 Web
 
-当前发布版本:**v0.3.15**,配套 iOS **1.3(176)**。详见 [发布说明](docs/releases/v0.3.15.md)。
+当前发布版本:**v0.3.16**,配套 iOS **1.3(176)**。详见 [发布说明](docs/releases/v0.3.16.md)。
 
 夭夭 Web 为网页和 iOS 提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务;无需安装 Yaoyao 插件。
 

+ 10 - 0
docs/releases/v0.3.16.md

@@ -0,0 +1,10 @@
+# v0.3.16
+
+修复 Bot 模式设置中的身份链路,并明确区分账号与 Agent。
+
+- Bot 模式加载与聊天模式相同的 Profile 名称和头像,避免回退显示 `default / default`。
+- Bot 模式接通 Agent 身份保存事件、忙碌状态、错误信息和保存后刷新;造型、颜色、表情及名称可持久保存。
+- 设置中心的账号页显示登录账号;Agent 身份和模型页显示当前正在设置的 Profile。
+- 登录账号暂未提供自定义头像字段,使用统一的绿色圆形默认头像;各模式显示一致。
+
+配套 iOS 继续为 1.3(176),无需插件升级。

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.15",
+  "version": "0.3.16",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "hermes-yaoyao",
-      "version": "0.3.15",
+      "version": "0.3.16",
       "license": "BSL-1.1",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.15",
+  "version": "0.3.16",
   "private": true,
   "license": "BSL-1.1",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.3.15",
-  "webVersion": "0.3.15",
-  "gitTag": "v0.3.15"
+  "releaseVersion": "0.3.16",
+  "webVersion": "0.3.16",
+  "gitTag": "v0.3.16"
 }

+ 9 - 2
src/client/components/app/SettingsCenterDialog.vue

@@ -132,6 +132,7 @@ const activeTitle = computed(() => ({
   'system-update': '更新与回滚',
 })[activePage.value])
 const accountName = computed(() => props.pairingUserName || props.userName || '当前账号')
+const showAgentSelector = computed(() => activePage.value.startsWith('agent-'))
 const activeScope = computed(() => {
   if (activePage.value.startsWith('agent-')) return `正在设置:${profileTitle(props.activeProfile)} / ${props.activeProfile?.name || '未选择'}`
   if (activePage.value.startsWith('account-')) return `当前账号:${accountName.value}${props.isAdmin ? ' · 管理员' : ''}`
@@ -272,7 +273,7 @@ function requestModeSwitch() {
                 <button class="settings-center__close" type="button" aria-label="关闭设置中心" :disabled="updateLocked" @click="requestClose"><AppIcon name="close" :size="18" /></button>
               </header>
               <div class="settings-sidebar__scroll">
-                <div class="settings-agent-selector">
+                <div v-if="showAgentSelector" class="settings-agent-selector">
                   <button ref="settingsAgentTrigger" type="button" aria-haspopup="listbox" :aria-expanded="profileMenuOpen" @click="toggleAgentMenu">
                     <AgentAvatar :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="34" />
                     <span><strong>{{ profileTitle(activeProfile) }}</strong><small>{{ activeProfile?.name || '未选择 Agent' }}</small></span>
@@ -286,6 +287,10 @@ function requestModeSwitch() {
                     </button>
                   </div>
                 </div>
+                <div v-else class="settings-account-summary" aria-label="当前账号">
+                  <AgentAvatar :name="accountName" :size="34" />
+                  <span><strong>{{ accountName }}</strong><small>当前账号</small></span>
+                </div>
 
                 <nav>
                   <section><button type="button" :disabled="updateLocked" @click="requestModeSwitch"><AppIcon :name="botMode ? 'chat' : 'users'" :size="18" /><span>{{ botMode ? '进入聊天模式' : '进入 Bot 模式' }}</span></button></section>
@@ -392,6 +397,8 @@ function requestModeSwitch() {
 .settings-sidebar__header { display: none; }.settings-sidebar__header h2 { margin: 0; font-size: 14px; letter-spacing: -.01em; }
 .settings-sidebar__scroll { min-height: 0; }
 .settings-agent-selector { position: sticky; z-index: 3; top: -12px; margin: -12px 0 8px; padding: 12px 0 8px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--surface-soft) 42%, var(--surface-raised)); }
+.settings-account-summary { position: sticky; z-index: 3; top: -12px; display: grid; min-height: 42px; grid-template-columns: 34px minmax(0, 1fr); align-items: center; gap: 8px; margin: -12px 0 8px; padding: 12px 4px 8px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--surface-soft) 42%, var(--surface-raised)); }
+.settings-account-summary span { display: grid; min-width: 0; gap: 2px; }.settings-account-summary strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }.settings-account-summary small { color: var(--text-muted); font-size: 11px; }
 .settings-agent-selector > button { display: grid; width: 100%; min-height: 42px; grid-template-columns: 34px minmax(0, 1fr) 16px; align-items: center; gap: 8px; padding: 2px 4px; border: 0; border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }
 .settings-agent-selector > button:hover { background: var(--surface-hover); }
 .settings-agent-selector span,.settings-agent-menu span { display: grid; min-width: 0; gap: 2px; }
@@ -460,6 +467,6 @@ function requestModeSwitch() {
   .settings-content--with-footer { grid-template-rows: auto minmax(0, 1fr) calc(68px + env(safe-area-inset-bottom)); }
   .settings-content__footer { padding: 0 20px env(safe-area-inset-bottom); }
   .settings-content__footer button { flex: 1; }
-  .settings-agent-selector { top: -18px; margin-top: -18px; padding-top: 18px; }
+  .settings-agent-selector,.settings-account-summary { top: -18px; margin-top: -18px; padding-top: 18px; }
 }
 </style>

+ 25 - 0
src/client/views/ConversationsView.vue

@@ -25,6 +25,7 @@ import { workspaceAvatarMembers, workspaceAvatarState, workspaceConversationItem
 import { useAuthStore } from '@/stores/auth'
 import { useThemeStore } from '@/stores/theme'
 import { apiRequest } from '@/api/client'
+import { updateProfileIdentity, type ProfileIdentityInput } from '@/api/profiles'
 import type { JsonValue } from '@shared/types'
 import type {
   WorkspaceAgent as Agent,
@@ -55,6 +56,9 @@ const text = ref(''),
   files = ref<WorkspaceFile[]>([]),
   mentions = ref<string[]>([]),
   older = ref(true)
+const identityBusy = ref(false),
+  identityError = ref(''),
+  identityResetVersion = ref(0)
 const searchItems = computed<SidebarItem[]>(() => [false, true].map(archived => {
   const items = conversations.value.filter(c => c.archived === archived).map(c => workspaceConversationItem(c, agents.value))
   return { id: archived ? 'archived' : 'unarchived', title: archived ? '已归档' : '未归档', children: items, emptyText: archived ? '没有已归档聊天' : '没有未归档聊天' }
@@ -158,6 +162,22 @@ const selected = computed(() => (typeof route.params.id === 'string' ? route.par
 const members = computed(() => agents.value.filter((a) => active.value?.memberIds.includes(a.id)))
 const isAgentDialog = computed(() => dialog.value === 'agent' || dialog.value === 'editAgent')
 const body = (v: unknown) => v as JsonValue
+async function saveProfileIdentity(input: ProfileIdentityInput) {
+  const profile = auth.activeProfile
+  if (!profile) return
+  identityBusy.value = true
+  identityError.value = ''
+  try {
+    await updateProfileIdentity(profile, input)
+    await auth.refreshProfiles()
+    await auth.refreshProfileAvatars()
+    identityResetVersion.value += 1
+  } catch (cause) {
+    identityError.value = cause instanceof Error ? cause.message : '保存 Agent 身份失败'
+  } finally {
+    identityBusy.value = false
+  }
+}
 async function refresh() {
   const [a, c] = await Promise.all([
     apiRequest<{ agents: Agent[] }>('/api/app/agents'),
@@ -482,6 +502,7 @@ function rendered(m: Message) {
 
 watch(selected, () => void load())
 onMounted(async () => {
+  void auth.refreshProfileAvatars().catch(() => undefined)
   try {
     await apiRequest('/api/app/capabilities')
     await refresh()
@@ -507,12 +528,16 @@ onBeforeUnmount(() => {
     :upstream-error="auth.upstreamError"
     :theme="theme.resolvedTheme"
     :theme-preference="theme.theme"
+    :identity-busy="identityBusy"
+    :identity-error="identityError"
+    :identity-reset-version="identityResetVersion"
     sidebar-title="聊天"
     sidebar-context-title="聊天列表"
     @logout="auth.logout"
     @select-profile="auth.selectProfile"
     @toggle-theme="theme.toggle"
     @set-theme="theme.setTheme"
+    @save-identity="saveProfileIdentity"
     @create-agent="openDialog('agent')"
     @create-group="openDialog('group')"
   >

+ 5 - 0
tests/client/settingsCenterDialog.test.ts

@@ -96,6 +96,11 @@ describe('Settings center dialog', () => {
     expect(modelPanel.attributes('data-profile')).toBe('ops:blue/team')
     expect(wrapper.get('.settings-content__header').text()).toContain('正在设置:运维 Agent / ops:blue/team')
 
+    await navigationButton(wrapper, '登录与安全').trigger('click')
+    expect(wrapper.find('.settings-agent-selector').exists()).toBe(false)
+    expect(wrapper.get('.settings-account-summary').text()).toContain('owner')
+    expect(wrapper.get('.settings-account-summary').text()).toContain('当前账号')
+
     const voiceButton = navigationButton(wrapper, '双流语音')
     expect(voiceButton.text()).toContain('全局')
     expect(voiceButton.element.closest('section')?.textContent).toContain('系统 · 仅管理员')

+ 16 - 1
tests/e2e/workspace-chat.spec.ts

@@ -42,7 +42,22 @@ test('created roles and editable teams share a durable chat list without plugin
   await expect(rail.locator('.sidebar-account-switcher__main')).toContainText('fixture')
   await rail.getByRole('button', { name: '设置与模式', exact: true }).click()
   await expect(page.getByRole('menu', { name: '设置与模式' }).getByRole('menuitem')).toHaveText(['进入设置', '进入聊天模式'])
-  await page.keyboard.press('Escape')
+  await page.getByRole('menuitem', { name: '进入设置', exact: true }).click()
+  const settings = page.getByRole('dialog', { name: '设置中心', exact: true })
+  await expect(settings.locator('.settings-account-summary')).toContainText('fixture')
+  await settings.getByRole('button', { name: '身份与头像', exact: true }).click()
+  await settings.getByRole('button', { name: '造型:星星', exact: true }).click()
+  await settings.getByRole('button', { name: '使用颜色 #1488ff', exact: true }).click()
+  await settings.locator('.mascot-grid--expression button').filter({ hasText: '开心' }).click()
+  await settings.getByRole('button', { name: '保存更改', exact: true }).click()
+  await expect(settings.getByRole('button', { name: '保存更改', exact: true })).toBeDisabled()
+  await settings.getByRole('button', { name: '关闭设置中心', exact: true }).click()
+  await rail.locator('.sidebar-account-switcher__main').click()
+  await settings.getByRole('button', { name: '身份与头像', exact: true }).click()
+  await expect(settings.getByRole('button', { name: '造型:星星', exact: true })).toHaveAttribute('aria-pressed', 'true')
+  await expect(settings.getByRole('button', { name: '使用颜色 #1488ff', exact: true })).toHaveAttribute('aria-pressed', 'true')
+  await expect(settings.locator('.mascot-grid--expression button').filter({ hasText: '开心' })).toHaveAttribute('aria-pressed', 'true')
+  await settings.getByRole('button', { name: '关闭设置中心', exact: true }).click()
   await expect(rail.getByRole('checkbox', { name: '显示已归档' })).toHaveCount(0)
   const plus = rail.getByRole('button', { name: '新建', exact: true })
   await expect(rail.locator('.sidebar-collapse')).toHaveCount(0)

+ 16 - 12
tests/fixtures/workspace-server.ts

@@ -24,6 +24,10 @@ const sessions = new Map<
 >()
 const calls: Array<{ method: string; params: Record<string, unknown> }> = []
 const heldReplies: Array<() => void> = []
+const profileState = new Map([
+  ['default', { name: 'default', display_name: '通用助手', is_default: true, gateway_running: true, ui_meta: {} as Record<string, unknown>, ui_meta_revisions: {} as Record<string, number> }],
+  ['server', { name: 'server', display_name: '开发助手', is_default: false, gateway_running: true, ui_meta: {} as Record<string, unknown>, ui_meta_revisions: {} as Record<string, number> }],
+])
 const upstream = createServer((req, res) => {
   const url = new URL(req.url || '/', `http://127.0.0.1:${upstreamPort}`)
   res.setHeader('content-type', 'application/json')
@@ -61,12 +65,7 @@ const upstream = createServer((req, res) => {
     return
   }
   if (url.pathname === '/api/profiles') {
-    send({
-      profiles: [
-        { name: 'default', display_name: '通用助手', is_default: true, gateway_running: true },
-        { name: 'server', display_name: '开发助手', is_default: false, gateway_running: true },
-      ],
-    })
+    send({ profiles: [...profileState.values()] })
     return
   }
   if (url.pathname === '/api/sessions') {
@@ -116,12 +115,17 @@ wss.on('connection', (socket) => {
         )
     }
     if (f.method === 'profiles.list') {
-      respond({
-        profiles: [
-          { name: 'default', display_name: '通用助手' },
-          { name: 'server', display_name: '开发助手', is_default: false, gateway_running: true },
-        ],
-      })
+      respond({ profiles: [...profileState.values()] })
+      return
+    }
+    if (f.method === 'profiles.configure') {
+      const profile = profileState.get(String(f.params.name))
+      if (!profile) { respond({ error: 'profile_not_found' }); return }
+      for (const [namespace, value] of Object.entries(f.params.ui_meta ?? {})) {
+        profile.ui_meta[namespace] = value
+        profile.ui_meta_revisions[namespace] = (profile.ui_meta_revisions[namespace] ?? 0) + 1
+      }
+      respond({ profile })
       return
     }
     if (f.method === 'session.create' || f.method === 'session.resume') {