Explorar o código

发布 v0.3.5:修复执行状态核对、群头像与句中提及

LSamien hai 1 mes
pai
achega
8c21fc4564

+ 1 - 1
README.md

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

+ 17 - 0
docs/releases/v0.3.5.md

@@ -0,0 +1,17 @@
+# v0.3.5
+
+修复连接中断后执行状态长期停留在“待确认”、默认头像在单聊与群聊中不一致,以及中文句中的 @ 提及未触发回复的问题。
+
+- 对待确认的执行自动重连并核对原会话历史;不会重复提交提示词,也不会将后续请求的回复当成本轮结果。
+- 上游明确拒绝提交时显示执行失败并解除会话占用,保留异常原因以便定位。
+- 同一个 Agent 的默认头像按 Agent ID 统一生成,单聊列表、会话标题与群成员组合保持一致;已设置的头像继续使用原值。
+- 用户和 Agent 都支持在中文句中直接 @ 成员,例如“请@瑶儿继续”;按最长成员名称匹配,排除邮件、链接、代码和引用中的提及。
+- 保持原有聊天页面布局,群成员仍然固定。
+
+配套 iOS 1.3(170)。本版本只更新 Web 服务,无需 Yaoyao 插件。
+
+## 验证
+
+- 发布清单、类型检查与生产构建通过;85 个测试文件、465 项 Web 测试通过。
+- 断线恢复不重复提交、延迟历史核对、后续回复隔离和明确拒绝的回归测试通过。
+- 浏览器流程通过:不同成员的群头像与单聊头像一致,并实际触发中文句中 @ 指定成员回复。

+ 2 - 2
package-lock.json

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

+ 1 - 1
package.json

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

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.3.4",
-  "webVersion": "0.3.4",
-  "gitTag": "v0.3.4"
+  "releaseVersion": "0.3.5",
+  "webVersion": "0.3.5",
+  "gitTag": "v0.3.5"
 }

+ 4 - 4
src/client/components/composer/ComposerShell.vue

@@ -258,8 +258,8 @@ function updateMenu() {
     return
   }
   if (props.mode === 'group' && props.mentionOptions.length) {
-    const match = before.match(/(?:^|\s)@([^\s@]*)$/u)
-    if (match && !/https?:\/\/\S*$/u.test(before) && !/\S+@\S*$/u.test(before)) {
+    const match = before.match(/(?<![A-Za-z0-9_.%+/@-])@([^\s@]*)$/u)
+    if (match && !/https?:\/\/\S*$/u.test(before)) {
       menuKind.value = 'mention'
       menuQuery.value = match[1] ?? ''
       menuIndex.value = 0
@@ -281,8 +281,8 @@ function selectOption(option: ComposerOption) {
     text.value = replacement + after
     nextTick(() => el.setSelectionRange(replacement.length, replacement.length))
   } else {
-    const match = before.match(/(?:^|\s)@([^\s@]*)$/u)
-    const start = match ? cursor - match[0].trimStart().length : cursor
+    const match = before.match(/(?<![A-Za-z0-9_.%+/@-])@([^\s@]*)$/u)
+    const start = match ? cursor - match[0].length : cursor
     const prefix = text.value.slice(0, start)
     const replacement = `@${option.label} `
     text.value = prefix + replacement + after

+ 4 - 4
src/server/workspaceRoutes.ts

@@ -72,7 +72,7 @@ export function workspaceRouter(
     ctx.body = await nodes.sources(owner(ctx))
   })
   router.get('/api/app/agents', (ctx) => {
-    ctx.body = { agents: store.list<WorkspaceAgent>(owner(ctx), 'agent') }
+    ctx.body = { agents: store.list<WorkspaceAgent>(owner(ctx), 'agent').map(agent => store.agentSummary(agent)) }
   })
   router.post('/api/app/agents', async (ctx) => {
     const user = owner(ctx),
@@ -80,11 +80,11 @@ export function workspaceRouter(
       sources = await nodes.sources(user)
     if (!sources.sources.some((s) => s.nodeId === input.nodeId && s.profile === input.profile))
       throw new HttpError(409, '基础 Agent 当前不可用', 'source_unavailable')
-    ctx.body = { agent: store.createAgent(user, input) }
+    ctx.body = { agent: store.agentSummary(store.createAgent(user, input)) }
     ctx.status = 201
   })
   router.patch('/api/app/agents/:id', (ctx) => {
-    ctx.body = { agent: store.updateAgent(owner(ctx), ctx.params.id, body(ctx)) }
+    ctx.body = { agent: store.agentSummary(store.updateAgent(owner(ctx), ctx.params.id, body(ctx))) }
   })
   router.get('/api/app/conversations', (ctx) => {
     const user = owner(ctx)
@@ -116,7 +116,7 @@ export function workspaceRouter(
     const user = owner(ctx),
       conversation = store.require<WorkspaceConversation>(user, 'conversation', ctx.params.id)
     ctx.body = {
-      conversation,
+      conversation: store.conversationSummary(user, conversation),
       messages: store.messages(user, conversation.id),
       run: conversation.activeRunId
         ? store.require<WorkspaceRun>(user, 'run', conversation.activeRunId)

+ 59 - 25
src/server/workspaceRuntime.ts

@@ -52,23 +52,29 @@ export const sendInput = z
   .refine((b) => b.content.trim() || b.fileIds.length, '请输入消息或添加附件')
 export function mentionedAgents(text: string, agents: Array<Pick<Agent, 'id' | 'name'>>): string[] {
   const plain = text
-    .replace(/```[\s\S]*?```/g, '')
+    .replace(/<quoted_message\b[^>]*>[\s\S]*?<\/quoted_message>/gi, '')
+    .replace(/```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)/g, '')
     .replace(/`[^`]*`/g, '')
-    .split('\n')
-    .filter((l) => !l.trimStart().startsWith('>'))
-    .join('\n')
-  if (/(?:^|\s)@all(?=$|[\s,。,::!!??])/i.test(plain)) return agents.map((a) => a.id)
-  return agents
-    .filter((a) => {
-      const escaped = a.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
-      return new RegExp(`(?:^|\\s)@${escaped}(?=$|[\\s,。,::!!??])`, 'u').test(plain)
-    })
-    .map((a) => a.id)
+    .replace(/https?:\/\/\S+/g, '')
+    .replace(/[\p{L}\p{N}._%+-]+@[\p{L}\p{N}.-]+\.[\p{L}]{2,}/gu, '')
+    .split('\n').filter(line => !line.trimStart().startsWith('>')).join('\n')
+  const names = [...agents].sort((a, b) => b.name.length - a.name.length)
+  const alternatives = [...names.map(a => a.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 'all', '所有人']
+  const pattern = new RegExp(`(?<![A-Za-z0-9_.%+/@-])@(${alternatives.join('|')})(?![A-Za-z0-9_])`, 'giu')
+  const result = new Set<string>()
+  for (const match of plain.matchAll(pattern)) {
+    const name = match[1]!
+    if (/^(all|所有人)$/i.test(name)) { for (const a of agents) result.add(a.id) }
+    else { const a = agents.find(a => a.name.toLocaleLowerCase() === name.toLocaleLowerCase()); if (a) result.add(a.id) }
+  }
+  return [...result]
 }
 export class WorkspaceRuntime {
   private live = new Map<string, LiveTurn>()
   private executing = new Set<string>()
   private closing = false
+  private recoveryTimers = new Map<string, ReturnType<typeof setTimeout>>()
+  private recoveryAttempts = new Map<string, number>()
   onMessage: (owner: string, message: Message) => Promise<void> = async () => {}
   onNotify: (
     owner: string,
@@ -110,7 +116,7 @@ export class WorkspaceRuntime {
     for (const owner of this.store.owners())
       for (const run of this.store.list<Run>(owner, 'run')) {
         if (run.status === 'queued') void this.execute(owner, run.id)
-        else if (run.status === 'uncertain') void this.reconcile(owner, run.id).catch(() => {})
+        else if (run.status === 'uncertain') this.scheduleReconcile(owner, run.id)
       }
   }
   send(owner: string, conversationId: string, input: unknown): Run {
@@ -258,6 +264,7 @@ export class WorkspaceRuntime {
       }
     } finally {
       this.executing.delete(id)
+      this.scheduleReconcile(owner, id)
     }
   }
   private advance(owner: string, runId: string, agentId: string, message: Message): void {
@@ -348,7 +355,7 @@ export class WorkspaceRuntime {
           current.status === 'interrupted' ? 'interrupted' : submitted ? 'uncertain' : 'failed'
         if (submitted && current.status !== 'interrupted') {
           current.status = 'uncertain'
-          current.error = '执行状态待确认,未自动重发'
+          current.error = `执行状态待确认:${error.message.slice(0, 500)}(正在自动核对,不会重复提交)`
           this.store.saveRun(owner, current)
         }
         this.store.saveMessage(owner, resultMessage)
@@ -538,13 +545,10 @@ export class WorkspaceRuntime {
           const index = history.findLastIndex(
             (m: any) => m.role === 'user' && String(m.content ?? m.text).includes(marker),
           )
-          const answer =
-            index >= 0
-              ? history
-                  .slice(index + 1)
-                  .filter((m: any) => m.role === 'assistant' && (m.content || m.text))
-                  .at(-1)
-              : undefined
+          const after = index >= 0 ? history.slice(index + 1) : []
+          const nextUser = after.findIndex((m: any) => m.role === 'user')
+          const answer = (nextUser >= 0 ? after.slice(0, nextUser) : after)
+            .filter((m: any) => m.role === 'assistant' && (m.content || m.text)).at(-1)
           if (!answer) throw new Error('无法确认本轮是否执行完成,请检查后停止此轮')
           resultMessage.content = String(answer.content ?? answer.text)
           finish()
@@ -591,10 +595,16 @@ export class WorkspaceRuntime {
                 .join('\n\n')
             : trigger.content
         submitted = true
-        await gateway.rpc('prompt.submit', {
-          session_id: runtimeId,
-          text: `${rules}\n\n${text}\n${attachmentRefs.join('\n')}`,
-        })
+        try {
+          await gateway.rpc('prompt.submit', {
+            session_id: runtimeId,
+            text: `${rules}\n\n${text}\n${attachmentRefs.join('\n')}`,
+          })
+        } catch (error) {
+          // A JSON-RPC error is a definitive rejection, not a lost receipt.
+          if (error instanceof HttpError && error.code === 'gateway_rejected') submitted = false
+          throw error
+        }
       }
       return await completion
     } catch (error) {
@@ -672,10 +682,30 @@ export class WorkspaceRuntime {
       this.store.event(owner, 'interaction.changed', i, i.conversationId)
     }
   }
+  private scheduleReconcile(owner: string, id: string): void {
+    if (this.closing || !this.userActive(owner) || this.recoveryTimers.has(id)) return
+    const run = this.store.get<Run>(owner, 'run', id)
+    if (run?.status !== 'uncertain' || !run.currentMessageId || !run.queue.length) {
+      this.recoveryAttempts.delete(id)
+      return
+    }
+    const attempt = this.recoveryAttempts.get(id) ?? 0
+    this.recoveryAttempts.set(id, attempt + 1)
+    const timer = setTimeout(() => {
+      this.recoveryTimers.delete(id)
+      if (this.executing.has(id)) { this.scheduleReconcile(owner, id); return }
+      void this.reconcile(owner, id).catch(() => {})
+    }, Math.min(30_000, 500 * 2 ** Math.min(attempt, 6)))
+    timer.unref()
+    this.recoveryTimers.set(id, timer)
+  }
   async reconcile(owner: string, id: string): Promise<void> {
-    if (this.executing.has(id) || this.closing) return
+    if (this.executing.has(id) || this.closing || !this.userActive(owner)) return
     const run = this.store.require<Run>(owner, 'run', id)
     if (run.status !== 'uncertain' || !run.currentMessageId || !run.queue.length) return
+    const timer = this.recoveryTimers.get(id)
+    if (timer) clearTimeout(timer)
+    this.recoveryTimers.delete(id)
     this.executing.add(id)
     try {
       const agent = this.store.require<Agent>(owner, 'agent', run.queue[0]!),
@@ -684,11 +714,15 @@ export class WorkspaceRuntime {
       this.advance(owner, id, agent.id, message)
     } finally {
       this.executing.delete(id)
+      this.scheduleReconcile(owner, id)
     }
     await this.execute(owner, id)
   }
   close(): void {
     this.closing = true
+    for (const timer of this.recoveryTimers.values()) clearTimeout(timer)
+    this.recoveryTimers.clear()
+    this.recoveryAttempts.clear()
     for (const t of this.live.values()) {
       t.gateway.close()
       t.done(new Error('服务关闭'))

+ 6 - 1
src/server/workspaceStore.ts

@@ -6,7 +6,7 @@ import { EventEmitter } from 'node:events'
 import { z } from 'zod'
 import { HttpError } from './errors.js'
 import { notificationPlainText } from './notificationText.js'
-import { decodeAgentMascotAvatar, isAgentImageAvatar } from '../shared/agentIdentity.js'
+import { decodeAgentMascotAvatar, isAgentImageAvatar, defaultAgentIdentity, encodeAgentAvatar } from '../shared/agentIdentity.js'
 import type {
   WorkspaceAgent as Agent,
   WorkspaceConversation as Conversation,
@@ -342,9 +342,14 @@ export class WorkspaceStore {
       this.event(owner, 'conversation.changed', c, c.id)
     })
   }
+  agentSummary(agent: Agent): Agent {
+    return { ...agent, avatar: agent.avatar || encodeAgentAvatar(defaultAgentIdentity(agent.id, agent.name)) }
+  }
   conversationSummary(owner: string, conversation: Conversation): Conversation {
+    const member = conversation.kind === 'direct' ? this.get<Agent>(owner, 'agent', conversation.memberIds[0] ?? '') : undefined
     return {
       ...conversation,
+      ...(member ? { avatar: this.agentSummary(member).avatar } : {}),
       lastMessageAt: conversation.lastMessageAt
         ?? (conversation.lastSeq > 0 ? this.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, 1).at(-1)?.createdAt : undefined)
         ?? conversation.createdAt,

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

@@ -13,6 +13,8 @@ async function createAgent(page: Page, name: string) {
   await dialog
     .getByRole('textbox', { name: '角色提示词与规则', exact: true })
     .fill(`你是${name},每次回复给出验收证据。`)
+  await dialog.getByRole('button', { name: `使用颜色 ${name === '产品经理' ? '#e78531' : '#0ea5c6'}`, exact: true }).click()
+  await dialog.locator('.mascot-grid--shape button').filter({ hasText: name === '产品经理' ? '圆形' : '小三角' }).first().click()
   await dialog.getByRole('button', { name: '保存', exact: true }).click()
   await expect(page.getByRole('heading', { name, exact: true })).toBeVisible()
 }
@@ -51,6 +53,17 @@ test('created roles and immutable teams share a durable chat list without plugin
   await dialog.getByRole('button', { name: '保存', exact: true }).click()
   await expect(page.getByRole('heading', { name: '产品开发团队', exact: true })).toBeVisible()
   await expect(page.locator('.desktop-sidebar .sidebar-item')).toHaveCount(3)
+  // Every cluster portrait must be the same portrait shown in that member's direct chat.
+  const groupRow = page.locator('.desktop-sidebar .sidebar-item').filter({ hasText: '产品开发团队' })
+  await expect(groupRow.locator('.team-avatar__member')).toHaveCount(2)
+  for (const name of ['产品经理', '开发工程师']) {
+    const portrait = `.agent-avatar[aria-label="${name} 的头像"]`
+    const direct = page.locator('.desktop-sidebar .sidebar-item').filter({ hasText: name }).filter({ hasNot: page.locator('.team-avatar') }).locator(portrait)
+    const member = groupRow.locator(portrait)
+    await expect(member.locator('stop[offset="0.5"]')).toHaveAttribute('stop-color', await direct.locator('stop[offset="0.5"]').getAttribute('stop-color') || '')
+    expect(await member.locator('.agent-avatar__body > :first-child').evaluate(el => el.tagName))
+      .toBe(await direct.locator('.agent-avatar__body > :first-child').evaluate(el => el.tagName))
+  }
   await expect(page.locator('.desktop-sidebar')).not.toContainText('话题')
   await expect(page.locator('.desktop-sidebar .sidebar-account-switcher')).not.toContainText(
     '通用助手',
@@ -59,9 +72,10 @@ test('created roles and immutable teams share a durable chat list without plugin
   await expect(dialog.getByRole('checkbox', { name: '产品经理', exact: true })).toBeDisabled()
   await expect(dialog.getByRole('checkbox', { name: '开发工程师', exact: true })).toBeDisabled()
   await dialog.getByRole('button', { name: '取消', exact: true }).click()
-  await page.getByPlaceholder('输入消息,Enter 发送,Shift + Enter 换行').fill('@开发工程师 给出实现结果')
+  await page.getByPlaceholder('输入消息,Enter 发送,Shift + Enter 换行').fill('请@开发工程师给出实现结果')
   await page.getByRole('button', { name: '发送消息', exact: true }).click()
   await expect(page.locator('.message--assistant')).toHaveCount(2)
+  await expect(page.locator('.message--assistant').first()).toContainText('我是开发工程师')
   await expect(page.locator('.run-status')).toHaveCount(0)
   const second = await context.newPage()
   await second.goto(page.url())

+ 54 - 1
tests/server/workspace.test.ts

@@ -24,6 +24,7 @@ let home: string,
 let requests: Array<{ method: string; params: Record<string, any> }>,
   reply: (socket: WebSocket, params: Record<string, any>) => void
 let nodes: WorkspaceNodes
+let rejectPrompt: string | undefined
 let recoveryHistory: Array<{ role: string; content: string }>
 const owner = 'first-user',
   other = 'second-user'
@@ -33,6 +34,7 @@ beforeEach(async () => {
   uploads = new UploadStore(home)
   requests = []
   recoveryHistory = []
+  rejectPrompt = undefined
   server = new WebSocketServer({ port: 0, host: '127.0.0.1' })
   await new Promise<void>((resolve) => server.once('listening', resolve))
   const address = server.address() as { port: number }
@@ -85,6 +87,7 @@ beforeEach(async () => {
           info: { profile_name: frame.params.profile },
         })
       else if (frame.method === 'prompt.submit') {
+        if (rejectPrompt) { socket.send(JSON.stringify({id:frame.id,error:{code:4006,message:rejectPrompt}})); return }
         respond({ status: 'streaming' })
         reply(socket, frame.params)
       } else if (frame.method === 'session.usage') respond({ context_used: 400, context_max: 1000 })
@@ -349,7 +352,7 @@ describe('Web-owned workspace', () => {
       })
     let turns = 0
     reply = (socket, p) => {
-      const text = turns++ === 0 ? '请协作 @开发者' : '任务完成'
+      const text = turns++ === 0 ? '我已整理需求,现在请@开发者继续实现。' : '任务完成'
       setTimeout(
         () =>
           socket.send(
@@ -471,3 +474,53 @@ it('stores the expanded avatar shapes for cross-client role settings', () => {
     expect(store.updateAgent(owner,agent.id,{avatar}).avatar).toBe(avatar)
   }
 })
+
+
+it('automatically reconciles lost completion without submitting the prompt again', async () => {
+  const a=agent('自动恢复'),c=direct(a.id)
+  reply=(socket,p)=>{recoveryHistory=[{role:'user',content:p.text},{role:'assistant',content:'已完成一次'}];socket.terminate()}
+  const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'执行一次'})
+  await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('complete'),{timeout:5000})
+  expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1)
+  expect(store.messages(owner,c.id).at(-1)).toMatchObject({content:'已完成一次',status:'complete'})
+})
+it('reports a rejected prompt as failed instead of leaving it uncertain', async () => {
+  rejectPrompt='本轮请求被明确拒绝'
+  const a=agent('拒绝测试'),c=direct(a.id)
+  const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'执行'})
+  await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('failed'))
+  expect(store.require<WorkspaceConversation>(owner,'conversation',c.id).activeRunId).toBeUndefined()
+  expect(store.messages(owner,c.id).some(m=>m.status==='uncertain')).toBe(false)
+})
+it('keeps an unconfirmed turn separate from a later reply and retries history without resubmitting', async () => {
+  const a = agent('恢复边界'), c = direct(a.id)
+  let prompt = ''
+  reply = (socket, p) => {
+    prompt = p.text
+    recoveryHistory = [{ role: 'user', content: prompt }, { role: 'user', content: '另一个请求' }, { role: 'assistant', content: '另一轮回复' }]
+    socket.terminate()
+  }
+  const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '只执行一次' })
+  await vi.waitFor(() => expect(requests.filter(r => r.method === 'session.resume').length).toBeGreaterThanOrEqual(1), { timeout: 3000 })
+  expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('uncertain')
+  expect(store.messages(owner, c.id).at(-1)?.content).not.toBe('另一轮回复')
+  recoveryHistory = [{ role: 'user', content: prompt }, { role: 'assistant', content: '本轮结果' }]
+  await vi.waitFor(() => expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('complete'), { timeout: 4000 })
+  expect(requests.filter(r => r.method === 'prompt.submit')).toHaveLength(1)
+  expect(store.messages(owner, c.id).at(-1)).toMatchObject({ content: '本轮结果', status: 'complete' })
+})
+it('uses one canonical default avatar for the agent and its direct conversation',()=>{
+  const a=agent('默认头像'),c=direct(a.id)
+  expect(a.avatar).toBe('')
+  expect(store.agentSummary(a).avatar).toBe(store.conversationSummary(owner,c).avatar)
+  const changed=store.updateAgent(owner,a.id,{name:'换个名称'})
+  expect(store.agentSummary(changed).avatar).toBe(store.agentSummary(a).avatar)
+})
+it('recognizes mentions inside Chinese sentences and ignores quoted code and emails',()=>{
+  const a=agent('瑶儿'),b=agent('竹儿'),c=agent('瑶儿助手'),d=agent('Ann')
+  const roster=[a,b,c,d]
+  expect(mentionedAgents('先整理,再请@瑶儿帮忙,最后由(@竹儿)复核。',roster)).toEqual([a.id,b.id])
+  expect(mentionedAgents('请**@瑶儿助手**继续',roster)).toEqual([c.id])
+  expect(mentionedAgents('mail@Ann.com https://site/@竹儿 `@瑶儿` <quoted_message>@瑶儿</quoted_message>',roster)).toEqual([])
+  expect(mentionedAgents('@Anna',roster)).toEqual([])
+})