Sfoglia il codice sorgente

WIP: 固定群聊 Agent 输入状态

[gstack-context]
Decisions: 使用 agent.status 具名展示输入状态;状态固定在 Composer 上方;群聊不再复用消息流里的通用正在思考
Remaining: 完整测试、重启 8800、真实浏览器验收
Tried: running 到 idle 的端到端状态用例先失败后通过
Skill: /investigate
[/gstack-context]
samien 2 mesi fa
parent
commit
cf8e682bec

+ 17 - 0
src/client/components/composer/ComposerShell.vue

@@ -24,6 +24,7 @@ const props = withDefaults(defineProps<{
   mentionOptions?: ComposerOption[]
   slashCommands?: ComposerOption[]
   attachmentsEnabled?: boolean
+  activityText?: string
 }>(), {
   mode: 'chat',
   draftKey: '',
@@ -45,6 +46,7 @@ const props = withDefaults(defineProps<{
   mentionOptions: () => [],
   slashCommands: () => [],
   attachmentsEnabled: true,
+  activityText: '',
 })
 
 const emit = defineEmits<{
@@ -392,6 +394,15 @@ defineExpose({
 
 <template>
   <div class="composer-area">
+    <div v-if="mode === 'group'" class="composer-activity-slot">
+      <Transition name="composer-activity">
+        <div v-if="activityText" class="composer-activity" role="status" aria-label="Agent 输入状态" aria-live="polite" aria-atomic="true">
+          <span class="composer-typing-dots" aria-hidden="true"><i /><i /><i /></span>
+          <strong>{{ activityText }}</strong>
+        </div>
+      </Transition>
+    </div>
+
     <div v-if="attachments.length" class="composer-attachments" aria-label="附件">
       <div v-for="attachment in attachments" :key="attachment.id" class="composer-attachment" :class="{ image: isImage(attachment.type) }">
         <img v-if="attachment.previewUrl" :src="attachment.previewUrl" :alt="attachment.name" />
@@ -543,6 +554,12 @@ defineExpose({
 
 <style scoped>
 .composer-area { position: relative; z-index: 12; flex: 0 0 auto; padding: 12px max(24px, calc((100% - 760px) / 2)) max(18px, env(safe-area-inset-bottom)); background: var(--conversation-canvas); }
+.composer-activity-slot { display: flex; width: 100%; max-width: 760px; min-height: 20px; margin: 0 auto 3px; align-items: center; }
+.composer-activity { display: inline-flex; min-width: 0; align-items: center; gap: 7px; color: var(--text-muted); font-size: 10px; }
+.composer-activity strong { overflow: hidden; color: var(--text-secondary); font-weight: 560; text-overflow: ellipsis; white-space: nowrap; }
+.composer-typing-dots { display: inline-flex; align-items: center; gap: 2px; }.composer-typing-dots i { width: 3px; height: 3px; border-radius: 50%; background: currentColor; animation: composer-typing 1.05s ease-in-out infinite; }.composer-typing-dots i:nth-child(2) { animation-delay: 140ms; }.composer-typing-dots i:nth-child(3) { animation-delay: 280ms; }
+@keyframes composer-typing { 0%, 65%, 100% { opacity: .3; transform: translateY(0); } 32% { opacity: 1; transform: translateY(-2px); } }
+.composer-activity-enter-active, .composer-activity-leave-active { transition: opacity 120ms ease, transform 120ms var(--ease-out); }.composer-activity-enter-from, .composer-activity-leave-to { opacity: 0; transform: translateY(3px); }
 .composer-shell { position: relative; display: flex; width: 100%; min-height: 72px; max-width: 760px; margin-inline: auto; flex-direction: column; align-items: stretch; gap: 8px; padding: 13px 14px 9px; border: 1px solid var(--input-border-color); border-radius: 18px; background: var(--chat-composer-bg); box-shadow: 0 8px 24px rgba(41,36,39,.06); cursor: text; transition: border-color 140ms ease, box-shadow 150ms ease, background-color 140ms ease; }
 .dark .composer-shell { box-shadow: 0 8px 28px rgba(0,0,0,.32); }
 .composer-shell:hover:not(:focus-within) { border-color: var(--input-border-hover-color); }

+ 10 - 1
src/client/views/GroupsView.vue

@@ -65,6 +65,15 @@ const mentionOptions = computed<ComposerOption[]>(() => [
   { id: 'all', label: '所有人', detail: '通知房间内全部 Agent' },
   ...groups.agents.map(agent => ({ id: agent.id, label: agent.displayName || agent.profile, detail: agent.profile, disabled: !agent.enabled })),
 ])
+const typingAgentNames = computed(() => [...new Set(groups.agents
+  .filter(agent => ['queued', 'running'].includes(agent.status))
+  .map(agent => agent.displayName || agent.profile))])
+const typingActivity = computed(() => {
+  const names = typingAgentNames.value
+  if (!names.length) return ''
+  if (names.length <= 3) return `${names.join('、')}正在输入…`
+  return `${names.slice(0, 2).join('、')}等 ${names.length} 个 Agent 正在输入…`
+})
 
 function restoreShowThinking(profile = auth.activeProfile?.name || 'default') {
   showThinking.value = readAgentShowThinking(auth.user?.id ?? 'local', profile)
@@ -235,7 +244,6 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         :connected="connected"
         :synced="!!groups.selectedRoom"
         :show-tools="showThinking"
-        :thinking="groups.isSending || groups.agents.some(agent => ['queued', 'running'].includes(agent.status))"
         :interaction="activeInteraction"
         :mention-names="mentionNames"
         empty-title="让多个 Agent 一起工作"
@@ -262,6 +270,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         :disabled="!groups.selectedRoom || groups.availability !== 'available'"
         :streaming="groups.agents.some(agent => ['queued', 'running'].includes(agent.status))"
         :sending="groups.isSending"
+        :activity-text="typingActivity"
         :tool-trace-visible="showThinking"
         :reference="reference"
         :mention-options="mentionOptions"

+ 15 - 0
tests/e2e/workspace.spec.ts

@@ -142,6 +142,21 @@ test('edits every protocol v3 Agent setting with one inspector close control', a
   expect((await restoreResponse).status()).toBe(200)
 })
 
+test('pins the named Agent typing status above the group composer', async ({ page }) => {
+  await page.getByRole('button', { name: '群聊' }).click()
+  const composer = page.locator('.composer-area')
+  await page.getByRole('textbox', { name: '发消息给群聊,输入 @ 提及 Agent' }).fill('@夭夭 检查输入状态')
+  await page.getByRole('button', { name: '发送消息' }).click()
+
+  const typing = composer.getByRole('status', { name: 'Agent 输入状态' })
+  await expect(typing).toHaveText('夭夭正在输入…')
+  await expect(page.locator('.message-stack .thinking-indicator')).toHaveCount(0)
+  const typingBox = await typing.boundingBox()
+  const shellBox = await composer.locator('.composer-shell').boundingBox()
+  expect(typingBox?.y).toBeLessThan(shellBox?.y ?? 0)
+  await expect(typing).toHaveCount(0, { timeout: 3_000 })
+})
+
 test('previews an octet-stream Markdown file from the file library', async ({ page }) => {
   await page.route('**/api/app/files**', async route => {
     const url = new URL(route.request().url())

+ 22 - 4
tests/fixtures/fake-hermes.mjs

@@ -1,11 +1,19 @@
 import { createServer } from 'node:http'
-import { WebSocketServer } from 'ws'
+import WebSocket, { WebSocketServer } from 'ws'
 
 const port = Number(process.env.FAKE_HERMES_PORT || 19119)
 const epoch = '11111111-1111-4111-8111-111111111111'
 const roomId = '22222222-2222-4222-8222-222222222222'
 const agentId = '33333333-3333-4333-8333-333333333333'
 const now = () => Date.now() / 1000
+let groupCursor = 0
+const groupClients = new Set()
+
+function broadcastGroup(event, payload) {
+  groupCursor += 1
+  const frame = JSON.stringify({ type: 'group.event', epoch, cursor: groupCursor, roomId, event, payload })
+  for (const client of groupClients) if (client.readyState === WebSocket.OPEN) client.send(frame)
+}
 
 function authenticated(request) {
   return String(request.headers.cookie || '').includes('fake_session=authenticated')
@@ -111,9 +119,9 @@ const server = createServer(async (request, response) => {
     return session ? json(response, 200, session) : json(response, 404, { detail: 'Not found' })
   }
   if (url.pathname === '/api/session-unread') return json(response, 200, { items: { 'session-demo': 0 } })
-  if (url.pathname === '/api/plugins/yaoyao/v1/capabilities') return json(response, 200, { protocolVersion: 3, journalEpoch: epoch, latestCursor: 0, limits: { maxAgentsPerRoom: 8, maxMessageBytes: 65536, maxMessagePageSize: 100, defaultMaxReplyRounds: 3, unlimitedReplyRoundsValue: -1, maxAgentDisplayNameLength: 100 }, eventTypes: ['message.upsert', 'agent.status'] })
+  if (url.pathname === '/api/plugins/yaoyao/v1/capabilities') return json(response, 200, { protocolVersion: 3, journalEpoch: epoch, latestCursor: groupCursor, limits: { maxAgentsPerRoom: 8, maxMessageBytes: 65536, maxMessagePageSize: 100, defaultMaxReplyRounds: 3, unlimitedReplyRoundsValue: -1, maxAgentDisplayNameLength: 100 }, eventTypes: ['message.upsert', 'agent.status'] })
   if (url.pathname === '/api/plugins/yaoyao/v1/rooms') return json(response, 200, { items: [room], nextCursor: null })
-  if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}`) return json(response, 200, { ...room, agents: [groupAgent], runs: [], pendingInteractions: [], latestCursor: 0 })
+  if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}`) return json(response, 200, { ...room, agents: [groupAgent], runs: [], pendingInteractions: [], latestCursor: groupCursor })
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/agents/${agentId}` && request.method === 'PATCH') {
     const input = await body(request)
     if (input.displayName === '所有人') return json(response, 409, { detail: '成员名称不能使用“所有人”' })
@@ -123,6 +131,14 @@ const server = createServer(async (request, response) => {
     groupAgent.updatedAt = now()
     return json(response, 200, groupAgent)
   }
+  if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/messages` && request.method === 'POST') {
+    const input = await body(request)
+    const sent = { ...groupMessage, seq: 3, id: input.clientMessageId, clientMessageId: input.clientMessageId, content: input.content, createdAt: now(), updatedAt: now() }
+    json(response, 200, { message: sent, runs: [{ id: '66666666-6666-4666-8666-666666666666', roomId, agentId, status: 'queued' }] })
+    setTimeout(() => broadcastGroup('agent.status', { roomId, agentId, status: 'running', runId: '66666666-6666-4666-8666-666666666666' }), 80)
+    setTimeout(() => broadcastGroup('agent.status', { roomId, agentId, status: 'idle', runId: null }), 1500)
+    return
+  }
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/messages`) return json(response, 200, { items: groupMessages })
   if (url.pathname === '/api/plugins/yaoyao/files') return json(response, 200, { items: [{ id: 1, path: '/tmp/demo-report.pdf', name: 'demo-report.pdf', extension: 'pdf', mimeType: 'application/pdf', size: 204800, modifiedAt: now(), exists: true, origins: [{ profile: 'yaoyao', sessionId: 'session-demo', sessionTitle: '夭夭 Web 验收会话', messageId: 'message-assistant', authorKind: 'assistant', authorName: '夭夭', observedAt: now() }] }], nextCursor: null, total: 1 })
   if (url.pathname === '/api/plugins/yaoyao/1/download') {
@@ -138,7 +154,9 @@ server.on('upgrade', (request, socket, head) => {
   if (url.searchParams.get('ticket') !== 'fake-ticket') return socket.destroy()
   sockets.handleUpgrade(request, socket, head, client => {
     if (url.pathname.endsWith('/events')) {
-      client.send(JSON.stringify({ type: 'group.ready', epoch, cursor: 0, heartbeatSeconds: 20 }))
+      groupClients.add(client)
+      client.once('close', () => groupClients.delete(client))
+      client.send(JSON.stringify({ type: 'group.ready', epoch, cursor: groupCursor, heartbeatSeconds: 20 }))
       return
     }
     client.send(JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'gateway.ready', payload: { capabilities: ['safe_interrupt', 'session_reasoning_config'] } } }))