Pārlūkot izejas kodu

调整 Bot 通信提示为小号居中身份行

Samien 1 mēnesi atpakaļ
vecāks
revīzija
f8fdad0ea9

+ 35 - 0
design-qa.md

@@ -105,3 +105,38 @@ final result: passed
 - `1440 × 1024` 浏览器检查确认三个成员均可辨识,短三角未被完全遮挡,控制台无 warning/error。
 
 final result: passed
+
+## Bot 通信提示验收 · 2026-09-14
+
+### 范围与视觉依据
+
+- 本次只调整 Bot 模式的通信展示,并移除 iOS 输入区左下角的“记忆与协作”;现有标题栏、普通消息、输入框和其他入口保持原样。
+- 用户确认的视觉:`docs/verification/2026-09-14-bot-communication/reference.png`,即第二稿去除横线、进一步缩小提示文字与头像后的版本。
+- 原图为 853 × 1844,逻辑参考约 390 × 844;iPhone 17 Pro 实测截图为 1206 × 2622,对应 402 × 874 @3x。比较提示组件时按逻辑尺寸归一,系统状态栏与既有标题栏不作为本次重绘目标。
+- iOS 浅色实现:`docs/verification/2026-09-14-bot-communication/ios-light.png`。
+- iOS 深色实现:`docs/verification/2026-09-14-bot-communication/ios-dark.png`。
+- 完整参考和两张原生截图已在同一比较输入中查看;重点核对两处提示行和输入区。没有把不同密度或既有标题栏差异误判为本次改动。
+
+### 五项视觉核对
+
+- 字体:提示为系统 caption(基准 12pt),小于消息正文;方向文字与名字同级,不额外加粗。Web 实测 12px,Android 12sp。
+- 间距:文字和头像间距 6,头像基准 16;整行居中,提示在通信正文上方。Web 390px 视口中两行中心均为 x=195;伪元素 before/after 均为 none。
+- 颜色:沿用各端语义次级文字色和主题气泡色;浅色、深色均能区分提示与正文,没有新增横线或卡片边框。
+- 资产:使用对方实际 Bot 头像,复用既有头像组件;不使用字母占位或额外装饰图标。截图中的研究员使用同一深色角色头像。
+- 文案:独立显示“发送给 / 消息来自 + 头像 + 名字”,正文显示实际通信内容。原始记录及旧客户端的兼容正文保留,新客户端通过 communication 展示信息渲染;普通消息不解析文案猜测方向。
+
+### 比较与修正记录
+
+1. Web 首次查看发现普通系统消息的样式仍给通信正文添加边框并居中(P2)。已增加仅针对通信消息的样式覆盖。
+2. 修正后在 Codex 内置浏览器的 390 × 844 视口重新查看:正文边框 0px、左对齐;只有提示行居中,头像 16px,控制台 error 为 0。
+3. iOS 使用原生界面测试分别启动浅色和深色,等待提示及原始用户问题可见后截图;两处提示均有独立可访问性标签,提示行高度不超过 24pt,输入区不存在“记忆与协作”按钮。
+4. iOS 深色自动截图出现普通用户气泡文字未绘制的现象,辅助功能就绪检查仍可通过;浅色截图完整。普通用户文本与颜色的渲染分支未改动,原因尚未确认。深色截图保留该现象,未标记为已修复,也不作为全页面视觉通过的证据。
+
+### 结果
+
+- 本次作用范围内没有剩余 P0、P1、P2 视觉差异。
+- 原生 iOS 的大标题、时间分隔与气泡宽度是产品原有布局,依本次修改范围保留。
+- Web 手动检查覆盖登录、历史消息、提示方向、实际正文、字号、头像尺寸、居中位置与控制台。
+- 自动回归覆盖实时事件与历史投影、原始数据不被重写、普通消息不误识别、三端类型兼容,以及 iOS 入口移除。
+
+final result: passed

+ 6 - 0
docs/bot-collaboration-memory.md

@@ -76,3 +76,9 @@ Bot 工具使用已有会话工具桥。记忆注入要求节点明确报告 `me
 写入使用 `requestId`,编辑使用 `expectedRevision`。旧版本修改返回冲突,客户端保留草稿。项目进入已有快照,`project.changed`、`memory.changed`、`memory.job.changed`、`collaboration.changed` 使用已有可续传事件同步。
 
 协作链保留原会话、话题、项目、来源和回复关系,最多 8 层、32 次自动投递,并抑制重复内容及纯确认。停止后迟到结果不能重新启动旧目标。提炼按用户轮次和 Bot 去重;有异步协作时等待结果返回原话题后合并提炼,不把同伴的私人聊天全文交给其他 Bot。失败最多自动尝试三次,不阻塞聊天。
+
+## 通信提示展示
+
+Bot 私信使用独立的 `communication` 展示信息:方向、对方身份与头像、实际正文。新客户端在消息正文上方居中显示“发送给 / 消息来自”,字号 12、头像 16、间距 6,不加横线或边框。原始审计记录与兼容正文不重写;没有真实投递记录的普通消息不会因为包含类似措辞而被识别为 Bot 通信。
+
+iOS 输入区不再提供“记忆与协作”文字入口,既有项目、记忆管理页面继续保留。

BIN
docs/verification/2026-09-14-bot-communication/ios-dark.png


BIN
docs/verification/2026-09-14-bot-communication/ios-light.png


BIN
docs/verification/2026-09-14-bot-communication/reference.png


+ 1 - 0
src/client/components/messages/types.ts

@@ -21,6 +21,7 @@ export type UiLocalFileLink = {
 }
 
 export type UiMessage = {
+  communication?: import('@shared/workspace').WorkspaceCommunication
   id: string
   role: 'user' | 'assistant' | 'system' | 'tool'
   author?: string

+ 17 - 5
src/client/components/workspace/WorkspaceMessageTimeline.vue

@@ -348,12 +348,18 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
           class="message"
           :class="[`message--${message.role}`, {
             'message--failed': message.status === 'failed',
+            'message--communication': !!message.communication,
             'message--tool-only': message.role === 'assistant' && !message.content && !message.reasoning && Boolean(message.tools?.length),
             'message--assistant-internal': message.role === 'assistant' && !message.content.trim(),
             'message--assistant-anonymous': message.role === 'assistant' && !showAssistantIdentity,
           }]"
         >
-          <AgentAvatar v-if="message.role !== 'user' && (message.role !== 'assistant' || showAssistantIdentity)" class="message__avatar" :name="message.author || message.profile || (message.role === 'assistant' ? '夭' : '系')" :avatar="message.profile ? agentAvatars[message.profile] || '' : ''" :size="27" :state="avatarState(message)" />
+          <AgentAvatar v-if="!message.communication && message.role !== 'user' && (message.role !== 'assistant' || showAssistantIdentity)" class="message__avatar" :name="message.author || message.profile || (message.role === 'assistant' ? '夭' : '系')" :avatar="message.profile ? agentAvatars[message.profile] || '' : ''" :size="27" :state="avatarState(message)" />
+          <div v-if="message.communication" class="communication-caption" role="note">
+            <span>{{ message.communication.direction === 'outgoing' ? '发送给' : '消息来自' }}</span>
+            <AgentAvatar :name="message.communication.peerName" :avatar="message.communication.peerAvatar" :size="16" :animated="false" aria-hidden="true" />
+            <span class="communication-caption__name">{{ message.communication.peerName }}</span>
+          </div>
           <div class="message__body">
             <template v-if="message.timelineKind === 'delegation-complete'">
               <details class="delegation-event">
@@ -380,7 +386,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               </details>
             </template>
             <template v-else>
-            <div v-if="message.role !== 'assistant' || showAssistantIdentity" class="message__meta">
+            <div v-if="!message.communication && (message.role !== 'assistant' || showAssistantIdentity)" class="message__meta">
               <strong><AppIcon v-if="message.isRemoteAgent" class="message__remote-agent" name="globe" :size="12" />{{ message.role === 'user' ? '你' : message.author || message.profile || (message.role === 'assistant' ? '机器人' : '系统') }}</strong>
               <span v-if="message.metadata" class="message__execution">{{ message.metadata }}</span>
               <time>{{ formatTime(message.createdAt) }}</time>
@@ -410,8 +416,8 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
                 :plain="message.role === 'user'"
                 :mention-names="mentionNames"
                 :outline-prefix="message.role === 'assistant' ? `outline-${message.id}` : ''"
-                :file-cards="message.role === 'user' || message.role === 'assistant'"
-                :process-content="message.role !== 'user' && message.role !== 'assistant'"
+                :file-cards="message.role === 'user' || message.role === 'assistant' || !!message.communication"
+                :process-content="!message.communication && message.role !== 'user' && message.role !== 'assistant'"
                 @rendered="onMarkdownRendered"
                 @file-link="(name, url) => emit('previewFile', { name, url })"
               />
@@ -428,7 +434,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               {{ deliveryLabel(message.status) }}
             </div>
 
-            <div v-if="message.role === 'assistant' && message.attachments?.length" class="message__attachments">
+            <div v-if="(message.role === 'assistant' || message.communication) && message.attachments?.length" class="message__attachments">
               <button v-for="attachment in message.attachments" :key="attachment.id" type="button" @click="emit('preview', attachment)">
                 <img v-if="attachment.kind === 'image' && attachment.url" :src="attachment.url" :alt="attachment.name" />
                 <span v-else><AppIcon :name="attachment.kind || 'file'" :size="17" /></span>
@@ -485,6 +491,12 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 </template>
 
 <style scoped>
+.message.message--communication { flex-direction: column; align-items: flex-start; gap: 12px; margin-top: 24px; }
+.communication-caption { display: flex; width: 100%; min-width: 0; justify-content: center; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 12px; font-weight: 400; line-height: 16px; }
+.communication-caption > span:first-child { flex-shrink: 0; white-space: nowrap; }
+.communication-caption__name { min-width: 0; max-width: 60%; overflow-wrap: anywhere; }
+.message.message--communication .message__body { width: fit-content; max-width: min(680px, 86%); margin: 0; padding: 12px 16px; border: 0; border-radius: 20px; background: var(--surface-soft); text-align: left; }
+
 .timeline-frame { position: relative; display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; }
 .timeline-header { display: flex; z-index: 6; min-height: 62px; align-items: center; gap: 13px; padding: 10px 18px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--canvas) 92%, transparent); backdrop-filter: blur(16px); }
 .timeline-header--transparent { position: absolute; top: 0; right: 0; left: 0; min-height: 0; justify-content: flex-end; padding: 11px 14px; border: 0; background: transparent; backdrop-filter: none; pointer-events: none; }

+ 2 - 1
src/client/components/workspace/viewModels.ts

@@ -384,8 +384,9 @@ export function artifactToUi(item: ConversationArtifact): UiLibraryItem {
 export function workspaceMessagesToUi(messages: import('@shared/workspace').WorkspaceMessage[]): UiMessage[] {
   return messages.filter(message => message.visible !== false).map(message => ({
     id: message.id, role: message.role, author: message.agentName,
+    communication: message.communication,
     profile: message.agentId, createdAt: message.createdAt,
-    content: (message.role === 'assistant' ? visibleMessageText(message.content) : message.content).replace(/(!?\[[^\]]*\])\(<?([^)>]+)>?\)/g, (whole, label: string, path: string) => {
+    content: (message.communication?.content ?? (message.role === 'assistant' ? visibleMessageText(message.content) : message.content)).replace(/(!?\[[^\]]*\])\(<?([^)>]+)>?\)/g, (whole, label: string, path: string) => {
       const file = message.attachments.find(file => file.sourcePath === path)
       return file ? `${label}(/api/app/files/${file.id}/${label.startsWith('!') ? 'preview' : 'download'})` : whole
     }),

+ 2 - 0
src/server/workspaceCollaboration.ts

@@ -101,6 +101,8 @@ export class WorkspaceCollaboration {
       const fingerprint = createHash('sha256').update(JSON.stringify([agent.id, target?.id ?? destination.id, content.normalize('NFC').replace(/\s+/g, ' '), [...input.fileIds].sort()])).digest('hex')
       if (chain.fingerprints.includes(fingerprint)) throw new HttpError(409, '本次协作已投递相同内容,请等待结果', 'peer_repeated')
       const now = Date.now(), peer: Peer = {
+        fromName: agent.name, fromAvatar: agent.avatar,
+        targetName: target?.name ?? destination.name, targetAvatar: target?.avatar ?? destination.avatar,
         id: input.requestId, chainId, fromAgentId: agent.id, toAgentId: target?.id, targetGroupId: input.groupId,
         originConversationId: conversation.id, originTaskId: work.conversationTaskId,
         originProjectId: root.projectId,

+ 17 - 0
src/server/workspaceStore.ts

@@ -931,6 +931,23 @@ export class WorkspaceStore {
       isVisibleMessageFile(file, messages.get(file.messageId ?? '') ?? this.nativeMessageForFile?.(owner, file)))
   }
   messageForDisplay(owner: string, message: Message): Message {
+    const peer = message.peerMessageId && message.role === 'system'
+      ? this.get<import('../shared/workspaceKnowledge.js').WorkspacePeerMessage>(owner, 'peer-message', message.peerMessageId) : undefined
+    const incoming = !!peer && !!message.runId && message.conversationId === peer.conversationId && message.conversationTaskId === peer.taskId
+    const outgoing = !!peer && !message.runId && message.conversationId === peer.originConversationId && message.conversationTaskId === peer.originTaskId
+    if (peer && (incoming || outgoing)) {
+      const peerId = incoming ? peer.fromAgentId : peer.toAgentId ?? peer.targetGroupId!
+      const peerKind = outgoing && peer.targetGroupId ? 'group' : 'agent'
+      const entity = peerKind === 'group' ? this.get<Conversation>(owner, 'conversation', peerId) : this.get<Agent>(owner, 'agent', peerId)
+      const peerName = entity?.name || (incoming ? peer.fromName ?? message.agentName : peer.targetName) || (peerKind === 'group' ? '群聊' : 'Bot')
+      const peerAvatar = entity?.avatar || (incoming ? peer.fromAvatar : peer.targetAvatar) || ''
+      const files = peer.fileIds.flatMap(id => {
+        const file = this.get<StoredWorkspaceFile>(owner, 'file', id) ?? message.attachments.find(file => file.id === id)
+        return file ? [{ id: file.id, name: file.name, mimeType: file.mimeType, size: file.size, sender: file.sender, createdAt: file.createdAt }] : []
+      })
+      return { ...message, reasoning: '', tools: [], attachments: message.visible === false ? [] : files,
+        communication: { direction: incoming ? 'incoming' : 'outgoing', peerId, peerName, peerAvatar, peerKind, content: peer.content } }
+    }
     const attachments = message.role === 'user' || message.role === 'assistant'
       ? message.attachments.filter(file => isVisibleMessageFile(
         this.get<StoredWorkspaceFile>(owner, 'file', file.id) ?? file, message))

+ 10 - 0
src/shared/workspace.ts

@@ -124,6 +124,7 @@ export interface WorkspaceFile {
   sender: 'user' | 'agent'
 }
 export interface WorkspaceMessage {
+  communication?: WorkspaceCommunication
   peerMessageId?: string
   revision?: number
   execution?:'profile'|'computer'
@@ -156,6 +157,15 @@ export interface WorkspaceMessagePatch {
   contentAppend: string
   reasoningAppend: string
 }
+/** Presentation-only provenance for a real Bot-to-Bot delivery. */
+export interface WorkspaceCommunication {
+  content?: string
+  direction: 'outgoing' | 'incoming'
+  peerId: string
+  peerName: string
+  peerAvatar: string
+  peerKind: 'agent' | 'group'
+}
 export interface WorkspaceRun {
   projectId?: string
   peerMessageId?: string

+ 4 - 0
src/shared/workspaceKnowledge.ts

@@ -52,6 +52,10 @@ export interface WorkspaceMemoryJob {
   error?: string
 }
 export interface WorkspacePeerMessage {
+  fromName?: string
+  fromAvatar?: string
+  targetName?: string
+  targetAvatar?: string
   id: string
   chainId: string
   fromAgentId: string

+ 7 - 0
tests/client/workspacePresentation.test.ts

@@ -14,6 +14,13 @@ describe('workspace chat uses the established presentation', () => {
     expect(message?.tools?.[0]).toMatchObject({name:'read_file',status:'success',output:'ok'})
     expect(message?.attachments?.[0]?.url).toBe('/api/app/files/f/download')
   })
+  it('keeps peer direction outside the body without presenting it as a user message', () => {
+    const communication = { direction: 'incoming' as const, peerId: 'b', peerName: '研究员', peerAvatar: '', peerKind: 'agent' as const, content: '当前为 v2' }
+    const [message] = workspaceMessagesToUi([{ id: 'peer', conversationId: 'c', seq: 1, role: 'system', peerMessageId: 'p', communication, content: '来自 Bot「研究员」的回复:当前为 v2', reasoning: '', tools: [], attachments: [], status: 'complete', createdAt: 1 }])
+    expect(message?.communication).toEqual(communication)
+    expect(message?.content).toBe('当前为 v2')
+    expect(message?.role).toBe('system')
+  })
 })
 
 import { mount } from '@vue/test-utils'

+ 17 - 0
tests/fixtures/workspace-server.ts

@@ -1,6 +1,7 @@
 import {nativeEnvironmentFixture} from './native-environment.js'
 import {FixtureGrokAuthProvider} from './grok-auth-provider.js'
 import { FixtureBotPlugins } from './bot-plugins.js'
+import { defaultAgentIdentity, encodeAgentAvatar } from '../../src/shared/agentIdentity.js'
 /** Isolated, deterministic Hermes fixture. Team-tool transport is simulated, with no live model. */
 import { createServer } from 'node:http'
 import { mkdtempSync, readFileSync, existsSync, writeFileSync } from 'node:fs'
@@ -380,6 +381,22 @@ const grokAuth=process.env.WORKSPACE_FIXTURE_GROK_AUTH==='1'?new FixtureGrokAuth
 const runtime = createApplication({ config, auth, grokFetch:grokAuth?.fetch, pluginFetch: pluginsFixture?.fetch }),
   node = createNodeServer(runtime)
 const closeNative=process.env.WORKSPACE_FIXTURE_NATIVE==='1'?nativeEnvironmentFixture(runtime,home,port):undefined
+if (process.env.WORKSPACE_FIXTURE_COMMUNICATION === '1') {
+  const owner = JSON.parse(readFileSync(usersPath, 'utf8')).users[0].id as string
+  const store = runtime.workspace
+  const a = store.createAgent(owner, { name: '竹儿', profile: 'default', avatar: encodeAgentAvatar({ ...defaultAgentIdentity('lead', '竹儿'), color: '#ff9500' }) })
+  const b = store.createAgent(owner, { name: '研究员', profile: 'default', avatar: encodeAgentAvatar({ ...defaultAgentIdentity('researcher', '研究员'), color: '#191a21' }) })
+  const direct = (id: string) => store.list<import('../../src/shared/workspace.js').WorkspaceConversation>(owner, 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === id)!
+  const c = direct(a.id), other = direct(b.id), now = Date.now()
+  const texts = ['请让研究员核对接口版本,再告诉我结论。', '我请研究员核对一下。', '请核对接口版本,确认旧客户端是否兼容。', '已核对:当前为 v2,旧客户端仍可使用。', '核对完成,当前接口兼容旧客户端。']
+  for (const [index, content] of texts.entries()) {
+    const peerMessageId = index === 2 || index === 3 ? randomUUID() : undefined
+    const runId = index === 3 ? randomUUID() : undefined
+    if (peerMessageId) store.put(owner, 'peer-message', peerMessageId, { id: peerMessageId, chainId: 'fixture', fromAgentId: index === 2 ? a.id : b.id, toAgentId: index === 2 ? b.id : a.id, originConversationId: index === 2 ? c.id : other.id, conversationId: index === 2 ? other.id : c.id, sourceRunId: randomUUID(), runId, content, fileIds: [], depth: 1, priority: false, status: 'complete', createdAt: now + index, updatedAt: now + index })
+    store.saveMessage(owner, { id: randomUUID(), conversationId: c.id, seq: 0, role: index === 0 ? 'user' : peerMessageId ? 'system' : 'assistant', agentId: index === 0 ? undefined : index === 3 ? b.id : a.id, agentName: index === 0 ? undefined : index === 3 ? b.name : a.name, content: index === 2 ? '已向 Bot「研究员」发送协作请求。结果将异步返回。' : index === 3 ? `来自 Bot「研究员」的回复:\n${content}` : content, reasoning: '', status: 'complete', attachments: [], tools: [], peerMessageId, runId, createdAt: now + index })
+  }
+  console.log(`Communication preview: http://127.0.0.1:${port}/conversations/${c.id}`)
+}
 runtime.app.use((ctx, next) => {
   if (ctx.path !== '/__test/workspace-transcript' || ctx.method !== 'POST') return next()
   const owner = auth.require(ctx).id

+ 24 - 0
tests/server/workspaceCollaboration.test.ts

@@ -34,6 +34,30 @@ beforeEach(() => {
 afterEach(() => { runtime.close(); uploads.close(); store.close(); rmSync(home, { recursive: true, force: true }) })
 const bot = (name: string) => store.createAgent(owner, { name, profile: 'default' })
 const direct = (id: string) => store.list<Conversation>(owner, 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === id)!
+it('projects real deliveries into compact direction metadata for live events and history without rewriting raw messages', () => {
+  vi.spyOn(runtime, 'wake').mockImplementation(() => {})
+  const a = bot('竹儿'), b = bot('研究员')
+  runtime.send(owner, direct(a.id).id, { requestId: randomUUID(), content: '请核对接口' })
+  const work = store.list<Work>(owner, 'turn')[0]!
+  work.status = 'running'; store.put(owner, 'turn', work.id, work)
+  const events: Message[] = []
+  const unsubscribe = store.observe((_owner, event) => { if (event.type === 'message.changed') events.push(event.data as Message) })
+  const input = { requestId: randomUUID(), agentId: b.id, content: '请核对接口版本,确认旧客户端是否兼容。', fileIds: [] }
+  const peer = runtime.collaboration.send(owner, work.id, input)
+  const deliveries = events.filter(m => m.peerMessageId === peer.id)
+  expect(deliveries.map(m => m.communication?.direction)).toEqual(['incoming', 'outgoing'])
+  expect(deliveries.every(m => m.role === 'system' && m.communication?.content === input.content)).toBe(true)
+  expect(deliveries[0]?.content).toContain('来自 Bot')
+  expect(deliveries[1]?.content).toContain('已向')
+  expect(deliveries[0]?.communication).toMatchObject({ peerId: a.id, peerName: '竹儿' })
+  expect(deliveries[1]?.communication).toMatchObject({ peerId: b.id, peerName: '研究员' })
+  const history = store.messages(owner, direct(a.id).id).find(m => m.peerMessageId === peer.id)!
+  expect(history.communication).toEqual(deliveries[1]?.communication)
+  expect(store.require<Message>(owner, 'message', history.id).content).toContain('已向')
+  const ordinary: Message = { ...history, id: randomUUID(), peerMessageId: undefined, communication: undefined, content: '已向 Bot 发送只是普通文本' }
+  expect(store.messageForDisplay(owner, ordinary).communication).toBeUndefined()
+  unsubscribe()
+})
 it('honors explicit round counts and finishes all eight speakers in twelve rounds', async () => {
   expect(discussionRounds('请讨论一轮')).toBe(1); expect(discussionRounds('请讨论三轮')).toBe(3); expect(discussionRounds('讨论一下')).toBe(2)
   const members = Array.from({ length: 8 }, (_, i) => bot(`成员${i}`))