|
| 1 | +## MODIFIED Requirements |
| 2 | + |
| 3 | +### Requirement: Chat 主区域与 Proposal SidePanel 共享 UIMessage 列表组件 |
| 4 | + |
| 5 | +系统 SHALL 将 `UIMessageList` 组件通过 `type: "chat" | "side"` prop 标识使用场景,并新增可选 `agentId?: string` prop 用于在 `type="chat"` 时解析 assistant 头像。`ChatContainer.vue` 与 `ProposalApplySidePanel.vue` 的消息列表部分 SHALL 都通过该组件渲染,不再各自编写 `v-for message / v-for part` 的渲染逻辑。 |
| 6 | + |
| 7 | +共享组件的必要 props: |
| 8 | + |
| 9 | +- `messages: UIMessage<MessageMeta>[]` |
| 10 | +- `status: ChatStatus` |
| 11 | +- `type: "chat" | "side"` |
| 12 | +- `agentId?: string`(可选,仅在 `type="chat"` 时用于解析 assistant 头像) |
| 13 | + |
| 14 | +组件内部 SHALL 使用 `ai` 包的 `isReasoningUIPart` / `isTextUIPart` / `isToolUIPart` 派发到对应子组件:`UChatMessages` 承载消息容器、`UChatTool` 承载工具调用;assistant text part 与 reasoning part 中的 markdown 文本 SHALL 统一交由项目内统一的 markdown 渲染组件渲染(输入语义为 `content: string` 与 `isStreaming: boolean`),保持与当前 chat 主区域一致的渲染通路。该 markdown 渲染组件的具体实现细节由代码层决定,spec 不绑定具体组件名或第三方库。 |
| 15 | + |
| 16 | +`message.role === 'user'` 分支 SHALL 在 text part 之外,通过 `isUserImagePart(part)` 与 `isUserFilePart(part)`(`src/renderer/src/utils/chat-message-parts.ts` 提供)派发: |
| 17 | + |
| 18 | +- `isUserImagePart(part)` 命中 → 渲染图片缩略图卡片(`<img>` 的 `src` SHALL 从 `part.url` 解析;当 `part.url` 为 `file://` URI 时,组件 SHALL 通过 `chatApi.readAttachmentDataUrl(part.url, part.mediaType)` 获取 data URL,并使用返回的 `dataUrl` 作为 `src`;沿用 `AttachmentCard.vue` 图片分支样式) |
| 19 | +- `isUserFilePart(part)` 命中 → 渲染文件名片(图标 + `part.filename` + 扩展标签,沿用 `AttachmentCard.vue` 文件分支样式) |
| 20 | +- `isTextUIPart(part)` 命中 → 普通 user text 渲染(含 system-reminder 跳过逻辑);当该 text part 的渲染内容高度超过固定最大高度时,系统 SHALL 默认折叠该 text part,并提供展开/收起控制 |
| 21 | + |
| 22 | +普通 user text part 的折叠行为 SHALL 满足: |
| 23 | + |
| 24 | +- 每个可见 text part 独立维护展开状态,同一条 user message 内多个 text part 不得互相影响。 |
| 25 | +- 默认状态为折叠;折叠态 SHALL 设置固定最大高度并隐藏溢出内容。 |
| 26 | +- 仅当 text part 内容实际超过折叠最大高度时,才显示展开/收起控制。 |
| 27 | +- 点击展开控制后,当前 text part SHALL 完整显示;点击收起控制后,当前 text part SHALL 回到折叠态。 |
| 28 | +- 展开状态 SHALL 只存在于当前渲染实例内,不写入 `UIMessage`、session metadata 或持久化存储。 |
| 29 | + |
| 30 | +当 `type="chat"` 且 `agentId` 提供时,assistant 头像 SHALL 显示该 agent 对应的 ACP agent icon(来自 `useAcpAgentsStore.icons`)。若 `agentId` 未提供或对应 icon 不存在,则不显示头像(保持与 `type="side"` 一致的行为)。 |
| 31 | + |
| 32 | +渲染端 SHALL 使用 `UIMessage.id` 作为 `v-for :key`;该 id 在流式活跃期间为渲染进程生成的临时 id,在 resume 后为磁盘加载的 id,系统 SHALL NOT 做跨进程 id 匹配。 |
| 33 | + |
| 34 | +在 `message.role === 'user'` 的 text part 渲染分支中,系统 SHALL 通过 `isSystemReminderPart(part)` 工具函数识别 system-reminder 内容并**跳过渲染**。识别规则:`part.type === "text"` 且 `part.text` 经过 trim 后以 `<system-reminder>` 开头、以 `</system-reminder>` 结尾。该工具函数位于 `src/renderer/src/utils/system-reminder.ts`,`UIMessageList.vue` 直接调用。类型 `system-reminder` 的 part 仅在磁盘与 `UIMessage.parts` 数据中保留,UI 不展示,也不显示折叠控制。 |
| 35 | + |
| 36 | +#### Scenario: Chat 主区域使用共享组件渲染消息列表并显示 agent 头像 |
| 37 | + |
| 38 | +- **WHEN** 用户打开 chat 页面 |
| 39 | +- **THEN** `ChatContainer.vue` 通过 `<UIMessageList :messages :status type="chat" :agentId />` 渲染 `activeSession.messages` |
| 40 | +- **AND** assistant 消息的头像显示当前 session 对应 ACP agent 的 icon |
| 41 | +- **AND** 渲染结果与当前 chat 消息表现一致(text / tool / reasoning 分派保持现状) |
| 42 | + |
| 43 | +#### Scenario: Proposal SidePanel 使用共享组件保持现有行为 |
| 44 | + |
| 45 | +- **WHEN** 用户打开 proposal 详情页,SidePanel 展开 |
| 46 | +- **THEN** `ProposalApplySidePanel.vue` 通过 `<UIMessageList :messages :status type="side" />` 渲染 `messages` |
| 47 | +- **AND** SidePanel 外壳保持现状 |
| 48 | +- **AND** 消息列表渲染通路与 chat 一致 |
| 49 | + |
| 50 | +#### Scenario: user 消息含图片 part |
| 51 | + |
| 52 | +- **WHEN** user 消息的 `parts` 含 `{ type: "file", mediaType: "image/png", url: "file:///tmp/截图 1.png", filename }` |
| 53 | +- **THEN** `UIMessageList` 渲染图片缩略图卡片 |
| 54 | +- **AND** `<img>` 的 `src` 是 `chatApi.readAttachmentDataUrl` 返回的 data URL,而不是未处理的 `file:///tmp/截图 1.png` |
| 55 | +- **AND** assistant 消息不渲染任何 file part |
| 56 | + |
| 57 | +#### Scenario: user 消息含文件 part |
| 58 | + |
| 59 | +- **WHEN** user 消息的 `parts` 含 `{ type: "file", mediaType: "application/pdf", url, filename }` |
| 60 | +- **THEN** `UIMessageList` 渲染文件名片,包含 PDF 图标、文件名、扩展标签 |
| 61 | + |
| 62 | +#### Scenario: user 消息中的 system-reminder part 不在 UI 展示 |
| 63 | + |
| 64 | +- **WHEN** user 消息的 `parts` 首位为 system-reminder text part(`part.text` 经 trim 后以 `<system-reminder>` 开头并以 `</system-reminder>` 结尾) |
| 65 | +- **THEN** `UIMessageList.vue` 的 `message.role === 'user'` 分支跳过该 part 的渲染 |
| 66 | +- **AND** 同条 user 消息的其余 part 正常渲染 |
| 67 | +- **AND** 数据层 `message.parts` 不做修改 |
| 68 | + |
| 69 | +#### Scenario: user 消息仅含 system-reminder 时不输出可见文本 |
| 70 | + |
| 71 | +- **WHEN** user 消息的 `parts` 全部为 system-reminder text part |
| 72 | +- **THEN** 该消息气泡不渲染任何 text 内容 |
| 73 | +- **AND** 不抛错、不影响其他消息渲染 |
| 74 | + |
| 75 | +#### Scenario: user 长文本默认折叠并可展开 |
| 76 | + |
| 77 | +- **WHEN** user 消息包含一个普通 text part,且渲染后内容高度超过折叠最大高度 |
| 78 | +- **THEN** 该 text part 默认以固定最大高度显示并隐藏溢出内容 |
| 79 | +- **AND** 该 text part 显示展开控制 |
| 80 | +- **WHEN** 用户点击展开控制 |
| 81 | +- **THEN** 该 text part 完整显示 |
| 82 | +- **AND** 控制状态变为收起 |
| 83 | + |
| 84 | +#### Scenario: user 短文本不显示折叠控制 |
| 85 | + |
| 86 | +- **WHEN** user 消息包含一个普通 text part,且渲染后内容高度未超过折叠最大高度 |
| 87 | +- **THEN** 该 text part 按现有气泡样式完整显示 |
| 88 | +- **AND** 不显示展开/收起控制 |
| 89 | + |
| 90 | +#### Scenario: 同一 user 消息多个 text part 独立折叠 |
| 91 | + |
| 92 | +- **WHEN** user 消息包含两个超过折叠最大高度的普通 text part |
| 93 | +- **AND** 用户只展开第一个 text part |
| 94 | +- **THEN** 第一个 text part 完整显示 |
| 95 | +- **AND** 第二个 text part 仍保持折叠态 |
| 96 | + |
| 97 | +### Requirement: 附件用户消息渲染图片缩略图与文件名片 |
| 98 | + |
| 99 | +系统 SHALL 在 `src/renderer/src/utils/chat-message-parts.ts` 暴露: |
| 100 | + |
| 101 | +```ts |
| 102 | +isUserImagePart(part: UIMessage["parts"][number]): boolean |
| 103 | +isUserFilePart(part: UIMessage["parts"][number]): boolean |
| 104 | +``` |
| 105 | + |
| 106 | +判定规则: |
| 107 | + |
| 108 | +- `isUserImagePart`:`part.type === "file" && typeof part.mediaType === "string" && part.mediaType.startsWith("image/")` |
| 109 | +- `isUserFilePart`:`part.type === "file" && typeof part.mediaType === "string" && !part.mediaType.startsWith("image/")` |
| 110 | + |
| 111 | +`UIMessageList` 在 `message.role === 'user'` 分支 SHALL 通过这两个 helper 派发: |
| 112 | + |
| 113 | +- `isUserImagePart(part)` → 渲染缩略图卡片(`<img>` 的 `src` SHALL 从 `part.url` 解析;当 `part.url` 为 `file://` URI 时,组件 SHALL 通过 `chatApi.readAttachmentDataUrl(part.url, part.mediaType)` 获取 data URL,并使用返回的 `dataUrl` 作为 `src`;沿用 `AttachmentCard.vue` 风格的图片预览样式) |
| 114 | +- `isUserFilePart(part)` → 渲染文件名片(图标 + 文件名 `part.filename` + 扩展标签,沿用 `AttachmentCard.vue` 文件分支样式) |
| 115 | +- `isTextUIPart(part)` 与 `isSystemReminderPart(part)` 分支保持现状;普通 user text part 的长文本折叠行为由“Chat 主区域与 Proposal SidePanel 共享 UIMessage 列表组件” requirement 定义 |
| 116 | + |
| 117 | +assistant 分支 SHALL NOT 调这两个 helper(assistant 当前不渲染 file part)。 |
| 118 | + |
| 119 | +#### Scenario: user 消息含图片 part 渲染缩略图 |
| 120 | + |
| 121 | +- **WHEN** 历史 session 加载后,某条 user 消息 `parts` 含 `{ type: "file", mediaType: "image/png", url: "file:///abs/截图 1.png", filename: "截图 1.png" }` |
| 122 | +- **THEN** `UIMessageList` 渲染该 part 为图片缩略图 |
| 123 | +- **AND** `<img>` 的 `src` 是 `chatApi.readAttachmentDataUrl` 返回的 data URL |
| 124 | +- **AND** 数据层 `part.url` 仍保持为 `file:///abs/截图 1.png` |
| 125 | + |
| 126 | +#### Scenario: user 消息含非 file URL 图片 part 渲染缩略图 |
| 127 | + |
| 128 | +- **WHEN** 某条 user 消息 `parts` 含 `{ type: "file", mediaType: "image/png", url: "data:image/png;base64,abc", filename: "x.png" }` |
| 129 | +- **THEN** `UIMessageList` 渲染该 part 为图片缩略图 |
| 130 | +- **AND** `<img>` 的 `src` 为原始 `data:image/png;base64,abc` |
| 131 | + |
| 132 | +#### Scenario: user 消息含文件 part 渲染名片 |
| 133 | + |
| 134 | +- **WHEN** user 消息 `parts` 含 `{ type: "file", mediaType: "application/pdf", url: "file:///abs/doc.pdf", filename: "doc.pdf" }` |
| 135 | +- **THEN** 渲染包含文件图标、文件名 "doc.pdf"、扩展标签 "PDF" 的卡片 |
| 136 | +- **AND** 不展开 PDF 内容预览 |
0 commit comments