From ed31a7c1b4ab8cb78cc58fb40e2e57b30c56920d Mon Sep 17 00:00:00 2001 From: "xiaohan.yuan" Date: Sat, 17 Oct 2026 05:57:49 +0800 Subject: [PATCH] feat(conversation): replicate table quote-to-chat action (TODO 02) --- docs/复刻待办清单-2026-10-17.md | 35 +- scripts/composer-regression.tsx | 7 +- scripts/math-renderer-regression.tsx | 8 +- .../rea/probe-quote-table-add-20261017.json | 11 + scripts/table-ui-regression.cjs | 6 +- scripts/verify-quote-table.cjs | 211 ++++++++++ src/main/ipc.ts | 7 +- src/renderer/src/components/ChatPane.tsx | 54 ++- .../src/components/MarkdownContent.tsx | 25 +- src/renderer/src/components/MessageBubble.tsx | 18 +- src/renderer/src/stores/chat.ts | 2 +- .../workbuddy/composer/ComposerControls.tsx | 31 +- .../conversation/MessageQuoteCard.tsx | 35 ++ .../conversation/MessageQuoteIcons.tsx | 35 ++ .../conversation/MessageQuoteTag.tsx | 42 ++ .../conversation/TableMessageQuoteAction.tsx | 48 +++ .../conversation/message-quote-source.css | 383 ++++++++++++++++++ src/shared/conversation.ts | 19 + src/shared/ipc.ts | 4 +- src/shared/types.ts | 4 +- 20 files changed, 939 insertions(+), 46 deletions(-) create mode 100644 scripts/rea/probe-quote-table-add-20261017.json create mode 100644 scripts/verify-quote-table.cjs create mode 100644 src/renderer/src/workbuddy/conversation/MessageQuoteCard.tsx create mode 100644 src/renderer/src/workbuddy/conversation/MessageQuoteIcons.tsx create mode 100644 src/renderer/src/workbuddy/conversation/MessageQuoteTag.tsx create mode 100644 src/renderer/src/workbuddy/conversation/TableMessageQuoteAction.tsx create mode 100644 src/renderer/src/workbuddy/conversation/message-quote-source.css diff --git a/docs/复刻待办清单-2026-10-17.md b/docs/复刻待办清单-2026-10-17.md index 97f85ff..2b78dec 100644 --- a/docs/复刻待办清单-2026-10-17.md +++ b/docs/复刻待办清单-2026-10-17.md @@ -36,12 +36,37 @@ ### 02. 表格“添加到对话”及引用结果 -- [ ] 对齐表格上方“添加到对话”按钮的字体、图标、背景、边距、出现条件及交互。 -- [ ] 对齐点击后输入区内引用表格的呈现,包括结构、预览、移除和与已有草稿的组合方式。 -- [ ] 对齐发送后用户消息中的表格引用显示。 -- [ ] 验收:引用正确表格内容,不丢失已有草稿;输入区、发送结果及历史重载三种状态一致。 +- [x] 对齐表格上方“添加到对话”按钮的字体、图标、背景、边距、出现条件及交互。 +- [x] 对齐点击后输入区内引用表格的呈现,包括结构、预览、移除和与已有草稿的组合方式。 +- [x] 对齐发送后用户消息中的表格引用显示。 +- [x] 验收:引用正确表格内容,不丢失已有草稿;输入区、发送结果及历史重载三种状态一致。 -状态:待 REA 对照。 +状态:主体已实现并做了真实窗口回归;仍有两处原版证据缺口(见“遗留”)。 + +原版证据: +- 渲染器实现:WorkBuddy 5.8.3 渲染器(REA 回收 `.tmp/wb-src-DbE1_mgM.js`)中的 `TableMessageQuoteAction`、`createTableMessageQuoteSourceId`、`getTableMessageQuoteMessageId`、`MessageQuoteAddIcon`、`MessageQuoteTextIcon`、`ChipCloseIcon`、`QuoteReferenceCard`、`InputBannerRail`、`MessageQuotePhraseTag`、`convertUserFrame`/`ContextChipsRenderer`。 +- 菜单几何:REA Evidence `ev_38cca1ac66606ab07fb495035e3509f5c4d8ec3e5e2b74d0fdcfcfadb120da3b`(`layout.json` 中 `.cr-message-quote-table-menu` 116×38、按钮 106×28、`top:8px right:8px`、padding 4、圆角 8、条目 14px/22px/500、padding 3px 8px、gap 4、圆角 6、图标 16×16 两条 path、`rgba(0,0,0,.08) 0 4px 16px` 阴影)。 +- 样式:REA 回收样式表 `src-CyXHA0IC.css`(SHA-256 `a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe`)40 条引用相关规则,含 `._tag_1krka_19` 及其 message-quote/`:readOnly` 变体、`._iconContainer_1krka_38`、`._icon_1krka_38`、`._text_1krka_66`、`.cr-message-quote-phrase-*`、`.cr-input-quote-card*`、`.cr-input-banner-rail`、`.cr-context-chips`、`.cr-user-content`。 +- 观察范围:桌面端浅色主题、单条助手消息中的 markdown 表格、输入区空白草稿。未覆盖:原版实际点击后输入区卡片与消息气泡标签的截图/几何(见遗留)。 + +实现: +- `shared/conversation.ts` 新增 `MessageQuote`(id、kind、sourceId、messageId、markdown、title?、preview);`ChatMessage.quotes`、`SendPayload.quotes`、`ComposerSendPayload.quotes` 贯通消息与 IPC。 +- `workbuddy/conversation/MessageQuoteIcons.tsx`:`MessageQuoteAddIcon`、`MessageQuoteTextIcon`、`ChipCloseIcon` 按回收 path 原样复刻。 +- `workbuddy/conversation/TableMessageQuoteAction.tsx`:原版 DOM 结构(`div.cr-message-quote-table-menu` + `data-message-quote-table-added` + `data-message-quote-source-id`、按钮 `aria-label="添加到对话"`),sourceId 用原版 `createTableMessageQuoteSourceId` 组合规则。 +- `workbuddy/conversation/message-quote-source.css`:菜单/按钮、已加入描边、引用标签(含只读态)、引用卡片、短语标签与 tooltip 规则,均照搬回收声明。 +- `workbuddy/conversation/MessageQuoteCard.tsx`、`MessageQuoteTag.tsx`:输入区卡片与消息只读标签。 +- `MarkdownContent`/`ReactMarkdown`:`renderTableAction` 改为输出原版组件,新增 `sourceId`(`${messageId}-text-${index}`)与 `quotedSourceIds`。 +- `ComposerControls`:输入框上方 rail 渲染引用卡片,支持移除与点击定位;发送载荷带 `quotes`;只有引用时也可发送。 +- `ChatPane`:草稿新增 `quotes`;`quoteTable` 改为挂引用而不是把 markdown 追加进正文;点击卡片回到来源表格并临时置 `data-message-quote-anchor-active`;编辑/重发/重新生成沿用历史引用。 +- `MessageBubble`:用户消息内新增只读引用标签(`.cr-context-chips`)。 +- `main/ipc.ts`:引用快照写入 `userMessage.quotes`,引用 markdown 随消息一并进入模型提示;空文本但有引用时允许发送。 + +验证:`scripts/verify-quote-table.cjs` 在真实隔离 Electron(真实 preload/IPC/渲染进程与文件系统)中 48 项断言全部通过,覆盖菜单/按钮几何与配色、`:focus-within` 显隐、已加入描边(`#40d1b3`)、输入区卡片(圆角 10 / padding 8 / gap 8 / 强调条 3px / 16×16 移除按钮 / rail `0 16px 8px` 与 gap 6)、草稿不被覆盖、移除后按钮恢复、`chat:send` 真实 IPC 载荷(正文+引用 markdown+sourceId)、发送后只读标签(22 高 / 圆角 6 / padding 2px 6px / 13px/14px/500 / 只读底色 / `data-content-block-meta-type=message-quote`)、历史重载一致。报告与截图:`.tmp/quote-table-SI0PNC/`(另有 `.tmp/quote-table-yiKDIs/`)。`npm run typecheck`、`npm run test:conversation`、`npm run build` 通过。 + +遗留: +1. 未取到原版“点击后输入区卡片”和“发送后消息标签”的实际截图与几何:探针在打开的会话里没有找到可引用的表格(`hover .cr-table-block` 返回 "Selected node is absent"),因此这两态是按渲染器源码与回收样式表实现,视觉未与原版逐像素比对。 +2. 原版 `--cr-brand-secondary`(以及 `--cr-bg-brand-subtle`、`--cr-border-brand-subtle`)在回收样式表中只有使用、没有定义;同版本 `contexts-BkjwYSZT.css` 给出的兜底值是 `#40d1b3`,YCode 暂按该兜底值定义并已在注释中标注,待取到原版运行时 token 后替换。 +3. 输入区引用卡片中的“来源标题”在原版由宿主(`message.quote.add` 处理端)生成,尚未采集;YCode 当前不显示标题,只显示内容摘要,不臆造文案。 ### 03. 编辑已发送消息 diff --git a/scripts/composer-regression.tsx b/scripts/composer-regression.tsx index 1e3e42f..2c0ed84 100644 --- a/scripts/composer-regression.tsx +++ b/scripts/composer-regression.tsx @@ -115,8 +115,13 @@ assert.deepEqual(oversizedAttachmentNames([ { name: 'over.bin', size: MAX_ATTACHMENT_BYTES + 1 } ]), ['over.bin']) assert.deepEqual(buildComposerSendPayload(' prompt ', [], ['C:/work/file.ts'], 'full-access'), { - text: 'prompt', attachments: [], resources: ['C:/work/file.ts'], mode: 'full-access' + text: 'prompt', attachments: [], resources: ['C:/work/file.ts'], quotes: [], mode: 'full-access' }) +const quotedPayload = buildComposerSendPayload('', [], [], 'full-access', [ + { id: 'm1-text-0:table:table-0', kind: 'table', sourceId: 'm1-text-0:table:table-0', messageId: 'm1', markdown: '| a |\n| - |\n| 1 |', preview: '| a |' } +]) +assert.deepEqual(quotedPayload.quotes.map((quote) => quote.sourceId), ['m1-text-0:table:table-0']) +assert.equal(quotedPayload.text, '', '引用单独存在时正文可以为空') assert.equal(parseResourcePath('file:///C:/work/file.ts'), 'C:\\work\\file.ts') assert.equal(parseResourcePath('C:\\work\\file.ts'), 'C:\\work\\file.ts') assert.deepEqual(readReferenceUris({ getData: (type) => type === 'application/vnd.code.uri-list' ? 'file:///C:/a.ts\r\nfile:///C:/b.ts' : '' }), ['file:///C:/a.ts', 'file:///C:/b.ts']) diff --git a/scripts/math-renderer-regression.tsx b/scripts/math-renderer-regression.tsx index 696eb0d..7a9cde9 100644 --- a/scripts/math-renderer-regression.tsx +++ b/scripts/math-renderer-regression.tsx @@ -31,9 +31,11 @@ async function main(): Promise { assert.doesNotMatch(render('beforeafter'), / renderToStaticMarkup( undefined} />) - assert.match(tableWithAction(true), /class="markdown-content__table-quote" disabled=""/) - assert.doesNotMatch(tableWithAction(false), /class="markdown-content__table-quote" disabled/) + const tableWithAction = (streaming: boolean): string => renderToStaticMarkup( undefined} />) + assert.match(tableWithAction(true), /class="cr-message-quote-table-action" disabled=""[^>]*aria-label="添加到对话"/) + assert.match(tableWithAction(false), /class="cr-message-quote-table-action"[^>]*aria-label="添加到对话">/) + assert.doesNotMatch(tableWithAction(false), /class="cr-message-quote-table-action" disabled/) + assert.match(tableWithAction(false), /data-message-quote-source-id="fixture-message-text-0:table:table-0"/) const opacity = render('abcdef', true) assert.match(opacity, /style="opacity:0\.6/) assert.match(opacity, /style="opacity:0\.1/) diff --git a/scripts/rea/probe-quote-table-add-20261017.json b/scripts/rea/probe-quote-table-add-20261017.json new file mode 100644 index 0000000..bcf29c6 --- /dev/null +++ b/scripts/rea/probe-quote-table-add-20261017.json @@ -0,0 +1,11 @@ +{ + "include_fonts": false, + "include_message_content": true, + "track_selectors": [".cr-table-block", ".cr-message-quote-table-menu", ".cr-message-quote-table-action", ".cr-input-banner-rail", ".cr-input-quote-card"], + "actions": [ + { "kind": "hover", "step_id": "hover-table", "selector": ".cr-table-block", "index": 0, "frames": 1 }, + { "kind": "wait", "step_id": "menu-visible", "duration_ms": 250, "frames": 1 }, + { "kind": "click", "step_id": "add-table-to-conversation", "selector": ".cr-message-quote-table-action", "index": 0, "frames": 0 }, + { "kind": "wait", "step_id": "quote-card-rendered", "duration_ms": 600, "frames": 2 } + ] +} diff --git a/scripts/table-ui-regression.cjs b/scripts/table-ui-regression.cjs index 9b1874f..37e562a 100644 --- a/scripts/table-ui-regression.cjs +++ b/scripts/table-ui-regression.cjs @@ -13,7 +13,7 @@ if(!process.versions.electron){ const contentNodes=sourceLayout.message_content.nodes const paragraph=contentNodes.filter(node=>node.node_index>=1622&&node.node_index<1630&&node.node_name==='#text').map(node=>node.text).join('') const sourceMarkdown='✅ **取消链路修复并双验证通过**:\n\n'+markdown+'\n\n'+paragraph - const originalFixture=`
window.qa.quoted.push(text)}/>
` + const originalFixture=`
window.qa.quoted.push(quote)}/>
` const sourceFixture=`
@@ -24,7 +24,7 @@ if(!process.versions.electron){
-
window.qa.quoted.push(text)}/>
+
window.qa.quoted.push(quote)}/>
@@ -108,7 +108,7 @@ if(!process.versions.electron){ await fs.writeFile(path.join(output,'candidate-roi'+suffix+'.png'),candidate.crop(roi).toPNG()) win.webContents.sendInputEvent({type:'mouseMove',x:600,y:402}) await new Promise(resolve=>setTimeout(resolve,180)) - const quote=await win.webContents.executeJavaScript("(()=>{const e=document.querySelector('.markdown-content__table-quote'),r=e.getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2),opacity:getComputedStyle(e).opacity}})()") + const quote=await win.webContents.executeJavaScript("(()=>{const e=document.querySelector('.cr-message-quote-table-action'),r=e.getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2),opacity:getComputedStyle(e.closest('.cr-message-quote-table-menu')).opacity}})()") assert.equal(quote.opacity,'1') win.webContents.sendInputEvent({type:'mouseDown',button:'left',clickCount:1,x:quote.x,y:quote.y}) win.webContents.sendInputEvent({type:'mouseUp',button:'left',clickCount:1,x:quote.x,y:quote.y}) diff --git a/scripts/verify-quote-table.cjs b/scripts/verify-quote-table.cjs new file mode 100644 index 0000000..9a80aa2 --- /dev/null +++ b/scripts/verify-quote-table.cjs @@ -0,0 +1,211 @@ +// 真实隔离 YCode App + 真实 preload/IPC/渲染进程,验证表格“添加到对话”与引用标签。 +// 原版期望值来自固定 REA 归档:ev_38cca1ac66606ab07fb495035e3509f5c4d8ec3e5e2b74d0fdcfcfadb120da3b(菜单/按钮几何) +// 与 src-CyXHA0IC.css(SHA-256 a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe,菜单/标签样式)。 +const fs = require('node:fs/promises') +const path = require('node:path') +const assert = require('node:assert/strict') + +const TABLE_MARKDOWN = [ + '| 项目 | 说明 |', + '| --- | --- |', + '| alpha | 第一行 |', + '| beta | 第二行 |' +].join('\n') + +if (!process.versions.electron) { + ;(async () => { + const directory = await fs.mkdtemp(path.resolve('.tmp/quote-table-')) + const data = path.join(directory, 'data') + const home = path.join(directory, 'home') + const cwd = path.join(directory, 'fixture-workspace') + await fs.mkdir(data); await fs.mkdir(home); await fs.mkdir(cwd) + const now = Date.now() + const crypto = require('node:crypto') + const quotedUserMessage = { + id: crypto.randomUUID(), role: 'user', content: '这条来自历史记录', createdAt: now - 1000, status: 'done', + quotes: [{ id: 'history-text-0:table:table-0', kind: 'table', sourceId: 'history-text-0:table:table-0', messageId: 'history', markdown: TABLE_MARKDOWN, preview: TABLE_MARKDOWN }] + } + const sessions = [ + { id: crypto.randomUUID(), title: 'Quote Live', cwd, createdAt: now, updatedAt: now + 1, messages: [ + { id: crypto.randomUUID(), role: 'user', content: '给我一个表格', createdAt: now - 2000, status: 'done' }, + { id: crypto.randomUUID(), role: 'assistant', content: `这里是表格:\n\n${TABLE_MARKDOWN}\n\n以上。`, createdAt: now - 1000, status: 'done' } + ] }, + { id: crypto.randomUUID(), title: 'Quote History', cwd, createdAt: now - 5000, updatedAt: now - 4000, messages: [ + quotedUserMessage, + { id: crypto.randomUUID(), role: 'assistant', content: '好的。', createdAt: now - 900, status: 'done' } + ] } + ] + await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions })) + const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, + YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' } + delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL + const child = require('node:child_process').spawn(require('electron'), + ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '' + child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('QUOTE_TABLE_')) process.stdout.write(bytes) }) + child.stderr.on('data', bytes => { output += bytes }) + const timer = setTimeout(() => child.kill(), 150000) + child.once('exit', async code => { + clearTimeout(timer) + await fs.writeFile(path.join(directory, 'process.log'), output) + console.log(JSON.stringify({ directory, exitCode: code })) + if (code) console.error(output) + process.exitCode = code ? 1 : 0 + }) + })() +} else { + const { app, BrowserWindow, ipcMain } = require('electron') + app.disableHardwareAcceleration() + const directory = process.env.YCODE_QA_DIRECTORY + assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('quote-table-')) + app.setPath('userData', path.join(directory, 'data')); app.setPath('sessionData', path.join(directory, 'data')) + app.setPath('home', path.join(directory, 'home')) + let main, phase = 'boot' + const checks = [], snapshots = {}, rendererErrors = [] + let sentPayload = null + const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) + async function read(source) { + let timer + try { + return await Promise.race([main.webContents.executeJavaScript(source), new Promise((_, reject) => { + timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 100))), 15000) + })]) + } catch (error) { throw Error(error.message + ' - ' + source.slice(0, 240)) } finally { clearTimeout(timer) } + } + async function wait(source, label) { + for (let index = 0; index < 250; index++) { const value = await read(source); if (value) return value; await pause(30) } + throw Error('Timeout: ' + label) + } + async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) } + function check(label, actual, expected) { + const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected) + checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected }) + } + async function shot(name) { + const capture = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }) + await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(capture.data, 'base64')) + } + const menuSource = `(()=>{const menu=document.querySelector('.cr-message-quote-table-menu');if(!menu)return null;const button=menu.querySelector('.cr-message-quote-table-action');const style=getComputedStyle(menu);const rect=menu.getBoundingClientRect();const icon=menu.querySelector('svg');return {rect:[rect.x,rect.y,rect.width,rect.height],padding:style.padding,borderRadius:style.borderRadius,border:style.borderWidth+' '+style.borderColor,background:style.backgroundColor,boxShadow:style.boxShadow,position:style.position,top:style.top,right:style.right,added:menu.getAttribute('data-message-quote-table-added'),sourceId:menu.getAttribute('data-message-quote-source-id'),label:button?button.textContent:'',ariaLabel:button?button.getAttribute('aria-label'):null,button:button?(()=>{const bs=getComputedStyle(button);const box=button.getBoundingClientRect();return {width:box.width,height:box.height,fontSize:bs.fontSize,fontWeight:bs.fontWeight,lineHeight:bs.lineHeight,gap:bs.gap,padding:bs.padding,borderRadius:bs.borderRadius,display:bs.display,color:bs.color}})():null,icon:icon?[icon.getBoundingClientRect().width,icon.getBoundingClientRect().height,icon.querySelectorAll('path').length]:null}})()` + const cardSource = `(()=>{const rail=document.querySelector('.cr-message-quote-rail');const card=document.querySelector('.cr-input-quote-card');if(!rail||!card)return null;const s=getComputedStyle(card);const rect=card.getBoundingClientRect();const accent=card.querySelector('.cr-input-quote-card__accent');const summary=card.querySelector('.cr-input-quote-card__summary');const remove=card.querySelector('.cr-input-quote-card__remove');return {railPadding:getComputedStyle(rail).padding,railGap:getComputedStyle(rail).gap,railDisplay:getComputedStyle(rail).display,count:document.querySelectorAll('.cr-input-quote-card').length,card:{width:rect.width,height:rect.height,borderRadius:s.borderRadius,padding:s.padding,gap:s.gap,display:s.display,background:s.backgroundColor},accent:accent?accent.getBoundingClientRect().width:null,summary:summary?summary.textContent:null,remove:remove?[remove.getBoundingClientRect().width,remove.getBoundingClientRect().height,remove.getAttribute('aria-label')]:null,sourceId:card.getAttribute('data-message-quote-source-id')}})()` + + ;(async () => { + await import('node:url').then(({ pathToFileURL }) => import(pathToFileURL(path.resolve('out/main/index.mjs')).href)) + await app.whenReady() + // 记录发送负载并返回合成结果:隔离环境下没有可用模型,但引用链路必须真实经过 IPC。 + ipcMain.removeHandler('chat:send') + ipcMain.handle('chat:send', async (_event, payload) => { + sentPayload = payload + const runId = 'quote-run' + const userMessage = { id: 'quote-user', role: 'user', content: payload.text, quotes: payload.quotes, createdAt: Date.now(), status: 'done', requestId: runId } + const assistant = { id: 'quote-assistant', role: 'assistant', content: '收到表格。', createdAt: Date.now(), status: 'done', requestId: runId, tools: [], parts: [] } + return { runId, assistantMessageId: assistant.id, userMessage, messages: [...(payload.history ?? []), userMessage, assistant] } + }) + main = BrowserWindow.getAllWindows()[0] + main.setSize(1336, 902) + main.showInactive() + main.webContents.debugger.attach('1.3') + main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) }) + await wait('!!document.querySelector("#root")', 'renderer root') + await pause(1500) + await wait('!!document.querySelector("[data-session-id]")', 'sidebar rows') + await step('select-live-session') + await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Quote Live'));if(!row)return false;row.click();return true})()`) + await wait(`!!document.querySelector('.cr-table-block')`, 'table block') + + await step('table-action-menu') + const menu = await wait(menuSource, 'table quote menu') + snapshots.menu = menu + check('menu source id matches the original composite id', /-text-\d+:table:table-\d+$/.test(menu.sourceId ?? ''), true) + check('menu label is 添加到对话', menu.label, '添加到对话') + check('menu aria-label matches the label', menu.ariaLabel, '添加到对话') + check('menu is absolutely positioned at top 8 right 8', menu.position === 'absolute' && menu.top === '8px' && menu.right === '8px', true) + check('menu padding is 4 like the original', menu.padding, '4px') + check('menu radius is 8 like the original', menu.borderRadius, '8px') + check('menu background is the primary surface', menu.background, 'rgb(255, 255, 255)') + check('menu shows the recovered panel shadow', menu.boxShadow, 'rgba(0, 0, 0, 0.08) 0px 4px 16px 0px, rgba(0, 0, 0, 0.04) 0px 1px 4px 0px') + check('button font is 14/22 weight 500', menu.button.fontSize === '14px' && menu.button.lineHeight === '22px' && menu.button.fontWeight === '500', true) + check('button padding is 3px 8px like the original', menu.button.padding, '3px 8px') + check('button gap is 4 like the original', menu.button.gap, '4px') + check('button radius is 6 like the original', menu.button.borderRadius, '6px') + check('button height is 28 like the original', Math.round(menu.button.height), 28) + check('button width matches the original 106 within layout rounding', menu.button.width, value => Math.abs(value - 106) <= 2) + check('button icon is 16x16 with both original paths', menu.icon[0] === 16 && menu.icon[1] === 16 && menu.icon[2] === 2, true) + await read(`document.querySelector('.cr-message-quote-table-action').focus()`) + await pause(320) + check('focus-within reveals the menu like the original', await read(`getComputedStyle(document.querySelector('.cr-message-quote-table-menu')).opacity`), '1') + await shot('table-action') + + await step('add-to-conversation') + await read(`document.querySelector('.cr-message-quote-table-action').click()`) + await wait(cardSource, 'composer quote card') + const card = await read(cardSource) + snapshots.card = card + check('added menu hides itself with the original --added class', await read(`!!document.querySelector('.cr-message-quote-table-menu--added') && document.querySelector('.cr-message-quote-table-menu').getAttribute('data-message-quote-table-added')==='true'`), true) + check('added table takes the brand border like the original', await read(`getComputedStyle(document.querySelector('.cr-table-block__inner')).borderTopColor`), 'rgb(64, 209, 179)') + check('composer renders exactly one quote card', card.count, 1) + check('card radius is 10 like the original', card.card.borderRadius, '10px') + check('card padding is 8 like the original', card.card.padding, '8px') + check('card gap is 8 like the original', card.card.gap, '8px') + check('card background is the item hover surface', card.card.background, 'rgb(242, 242, 242)') + check('accent bar is 3px wide like the original', Math.round(card.accent), 3) + check('card summary keeps the quoted table text', typeof card.summary === 'string' && card.summary.includes('alpha'), true) + check('remove button is 16x16 with a label', card.remove[0] === 16 && card.remove[1] === 16 && !!card.remove[2], true) + check('rail padding matches the original banner rail', card.railPadding, '0px 16px 8px') + check('rail gap is 6 like the original', card.railGap, '6px') + check('existing draft text is not overwritten by the quote', await read(`document.querySelector('.wb-composer__input').value`), '') + await shot('composer-quote') + + await step('remove-quote') + await read(`document.querySelector('.cr-input-quote-card__remove').click()`) + await pause(200) + check('removing the card clears the composer rail', await read(`!!document.querySelector('.cr-input-quote-card')`), false) + check('removing the quote restores the table button', await read(`!!document.querySelector('.cr-message-quote-table-action') && !document.querySelector('.cr-message-quote-table-menu--added')`), true) + + await step('send-with-quote') + await read(`document.querySelector('.cr-message-quote-table-action').click()`) + await wait(cardSource, 'quote card before send') + await read(`(()=>{const input=document.querySelector('.wb-composer__input');const setter=Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value').set;setter.call(input,'看看这个表格');input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`) + await pause(120) + await read(`document.querySelector('.wb-composer__send').click()`) + for (let index = 0; index < 100 && !sentPayload; index++) await pause(50) + check('chat:send receives the quote payload through IPC', !!sentPayload && Array.isArray(sentPayload.quotes) && sentPayload.quotes.length === 1, true) + check('sent quote keeps the table markdown snapshot', !!sentPayload.quotes[0].markdown.includes('| alpha |'), true) + check('sent quote keeps the composite source id', /:table:table-\d+$/.test(sentPayload.quotes[0].sourceId), true) + check('sent message keeps the typed text', sentPayload.text, '看看这个表格') + const historyCount = await read(`(()=>{const row=[...document.querySelectorAll('[data-session-id]')].find(item=>item.textContent.includes('Quote Live'));return row?row.getAttribute('data-session-id'):null})()`) + check('sent session identity is stable', typeof historyCount === 'string' && historyCount.length > 0, true) + const sentChip = await wait(`(()=>{const chip=document.querySelector('.cr-frame--right .cr-context-chips .cr-message-quote-tag');if(!chip)return null;const s=getComputedStyle(chip);const box=chip.getBoundingClientRect();const icon=chip.querySelector('.cr-message-quote-tag__icon-container svg');return {height:box.height,borderRadius:s.borderRadius,padding:s.padding,fontSize:s.fontSize,fontWeight:s.fontWeight,lineHeight:s.lineHeight,background:s.backgroundColor,cursor:s.cursor,metaType:chip.getAttribute('data-content-block-meta-type'),icon:icon?[icon.getBoundingClientRect().width,icon.getBoundingClientRect().height]:null,text:chip.textContent}})()`, 'sent quote chip') + snapshots.sentChip = sentChip + check('sent chip is 22 high like the original', Math.round(sentChip.height), 22) + check('sent chip radius is 6 like the original', sentChip.borderRadius, '6px') + check('sent chip padding is 2px 6px like the original', sentChip.padding, '2px 6px') + check('sent chip font is 13/14 weight 500', sentChip.fontSize === '13px' && sentChip.lineHeight === '14px' && sentChip.fontWeight === '500', true) + check('sent chip uses the read-only message-quote background', sentChip.background, 'rgba(0, 0, 0, 0.1)') + check('sent chip is read-only', sentChip.cursor, 'default') + check('sent chip keeps the message-quote meta type', sentChip.metaType, 'message-quote') + check('sent chip keeps the original 16x16 icon box', sentChip.icon[0] === 16 && sentChip.icon[1] === 16, true) + await shot('sent-quote') + + await step('history-reload') + await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Quote History'));if(!row)return false;row.click();return true})()`) + await pause(600) + const reloaded = await wait(`(()=>{const chip=document.querySelector('.cr-frame--right .cr-context-chips .cr-message-quote-tag');if(!chip)return null;return {text:chip.textContent,readOnly:chip.classList.contains('cr-message-quote-tag--read-only'),hasClose:!!chip.querySelector('.cr-message-quote-tag__close'),bubble:document.querySelector('.cr-frame--right .cr-self-bubble').textContent}})()`, 'history quote chip') + snapshots.reloaded = reloaded + check('history message renders the persisted quote chip', typeof reloaded.text === 'string' && reloaded.text.includes('| 项目 |'), true) + check('history chip is read-only without a remove control', reloaded.readOnly === true && reloaded.hasClose === false, true) + check('history message keeps its own text', reloaded.bubble.includes('这条来自历史记录'), true) + check('no renderer error logs', rendererErrors, []) + + const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL', + scope: 'Real isolated YCode App/preload/IPC/renderer. chat:send is stubbed in the test main process to record the payload because the sandbox has no model.', + sourceEvidence: ['ev_38cca1ac66606ab07fb495035e3509f5c4d8ec3e5e2b74d0fdcfcfadb120da3b', 'src-CyXHA0IC.css a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe'], + snapshots, checks, rendererErrors } + await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2)) + console.log('QUOTE_TABLE_' + report.result + ' ' + directory) + for (const failure of checks.filter(item => !item.pass)) console.log('QUOTE_TABLE_FAILURE ' + JSON.stringify(failure)) + app.exit(report.result === 'PASS' ? 0 : 1) + })().catch(async error => { + await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, snapshots, rendererErrors }, null, 2)) + console.error(error); if (main) await shot('failure').catch(() => undefined); app.exit(1) + }) +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 9cf1eb6..5154709 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -532,7 +532,7 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void { const win = getWindow() const { sessionId, text } = payload if (!(await sessions.listSessions()).some((session) => session.id === sessionId)) throw new Error('会话不存在') - if (typeof text !== 'string' || (!text.trim() && !payload.attachments?.length)) throw new Error('消息为空') + if (typeof text !== 'string' || (!text.trim() && !payload.attachments?.length && !payload.quotes?.length)) throw new Error('消息为空') if ([...runTasks.values()].some((run) => run.sessionId === sessionId)) throw new Error('该会话正在运行') const attachments = await Promise.all((payload.attachments ?? []).map((item) => getAttachment(item.id))) const promptAttachments = await attachmentPrompt(attachments) @@ -601,6 +601,7 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void { planId: payload.planId, attachments, resources: references, + quotes: payload.quotes?.length ? payload.quotes : undefined, skills: capabilitySnapshot.references, capabilityContext: capabilitySnapshot.context || undefined, expertId: context.expertId ?? undefined, @@ -661,7 +662,9 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void { } runEmitters.set(runId, emit) const interactionBroker = createInteractionBroker(runId, controller.signal, emit) - const promptText = [text, promptAttachments.text, references.length ? `引用工作区资源:\n${references.join('\n')}` : '', capabilitySnapshot.context].filter(Boolean).join('\n\n') + // 对话中引用的内容块(原版 message-quote)随消息一起交给模型;表格 markdown 原样附带。 + const quoteText = (payload.quotes ?? []).map((quote) => quote.markdown.trim()).filter(Boolean).join('\n\n') + const promptText = [text, quoteText, promptAttachments.text, references.length ? `引用工作区资源:\n${references.join('\n')}` : '', capabilitySnapshot.context].filter(Boolean).join('\n\n') // 回合在后台推进,立刻把 runId 还给渲染进程 const initialMessages = payload.action ? structuredClone([...history, userMessage, assistant]) : undefined diff --git a/src/renderer/src/components/ChatPane.tsx b/src/renderer/src/components/ChatPane.tsx index 84ef6b0..86635be 100644 --- a/src/renderer/src/components/ChatPane.tsx +++ b/src/renderer/src/components/ChatPane.tsx @@ -20,7 +20,7 @@ import SourceSkillPicker from '../capabilities/SourceSkillPicker' import { SkillsIcon } from '../capabilities/SkillSourceIcons' import { ComposerControls, type ComposerSendPayload } from '../workbuddy/composer/ComposerControls' import { QuestionFloating } from '../workbuddy/conversation' -import type { AgentMode, ChatAttachment, PermissionMode } from '@shared/conversation' +import type { AgentMode, ChatAttachment, MessageQuote, PermissionMode } from '@shared/conversation' import type { ChatMessage } from '@shared/types' import { getRevertChanges } from '@shared/revert' import { collectWorkbenchArtifacts } from '../workbuddy/workbench/workbench-state' @@ -56,7 +56,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, const selectedWorkspaceId = useSettingsStore((s) => s.selectedWorkspaceId) const defaultWorkspaceRoot = useSettingsStore((s) => s.workspace) - const [drafts, setDrafts] = useState>; editingId?: string; autoFilledPrompt?: string }>>({}) + const [drafts, setDrafts] = useState>; editingId?: string; autoFilledPrompt?: string }>>({}) const draftMoves = useRef(new Map()) const [skillPicker, setSkillPicker] = useState<{ owner: string; anchor: HTMLElement | null } | null>(null) const closeSkillPicker = useCallback(() => setSkillPicker(null), []) @@ -75,6 +75,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, const draftKey = currentId ?? `new-${draftId}` const entry = drafts[draftKey] ?? { text: '', attachments: [] } const draft = entry.text + const quotedSourceIds = useMemo(() => (entry.quotes ?? []).map((item) => item.sourceId), [entry.quotes]) const setDraft = useCallback((value: string | ((current: string) => string)): void => { setDrafts((all) => { const current = all[draftKey] ?? { text: '', attachments: [] }; const text = typeof value === 'function' ? value(current.text) : value; return { ...all, [draftKey]: { ...current, text, autoFilledPrompt: text === current.autoFilledPrompt ? current.autoFilledPrompt : undefined } } }) }, [draftKey]) @@ -121,19 +122,33 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, const [homeAction, setHomeAction] = useState(null) const scrollRef = useRef(null) const followBottom = useRef(true) - const quoteTable = useCallback((markdown: string): void => { - setDraft((current) => current ? `${current}\n\n${markdown}` : markdown) - document.querySelector('.wb-composer__input')?.focus() - }, [setDraft]) + /** 把表格引用挂到当前草稿(原版 message.quote.add:引用与正文分开保存,不移写正文)。 */ + const quoteTable = useCallback((quote: MessageQuote): void => { + setDrafts((all) => { + const current = all[draftKey] ?? { text: '', attachments: [] } + const quotes = current.quotes ?? [] + if (quotes.some((item) => item.id === quote.id)) return all + return { ...all, [draftKey]: { ...current, quotes: [...quotes, quote] } } + }) + requestAnimationFrame(() => document.querySelector('.wb-composer__input')?.focus()) + }, [draftKey]) + const removeQuote = useCallback((quote: MessageQuote): void => { + setDrafts((all) => { + const current = all[draftKey] + if (!current?.quotes) return all + return { ...all, [draftKey]: { ...current, quotes: current.quotes.filter((item) => item.id !== quote.id) } } + }) + }, [draftKey]) useEffect(() => { const quote = (event: Event): void => { const detail = (event as CustomEvent<{ text: string; sessionId?: string }>).detail if (detail?.sessionId !== (currentId ?? undefined) || typeof detail.text !== 'string') return - quoteTable(detail.text) + setDraft((current) => current ? `${current}\n\n${detail.text}` : detail.text) + requestAnimationFrame(() => document.querySelector('.wb-composer__input')?.focus()) } window.addEventListener('ycode:quote-text', quote) return () => window.removeEventListener('ycode:quote-text', quote) - }, [currentId, quoteTable]) + }, [currentId, setDraft]) const title = sessions.find((s) => s.id === currentId)?.title ?? '新对话' const isHome = currentId === null || messages.length === 0 @@ -187,23 +202,23 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, if (el) el.scrollTop = el.scrollHeight }, [currentId]) - const submit = async ({ text, attachments, resources, mode }: ComposerSendPayload): Promise => { - if ((!text && !attachments.length) || running || sending) return + const submit = async ({ text, attachments, resources, quotes, mode }: ComposerSendPayload): Promise => { + if ((!text && !attachments.length && !quotes.length) || running || sending) return followBottom.current = true - await send(text, { attachments, resources, skillIds: entry.skills?.map(item => item.id), permissionMode: mode, ...(entry.editingId ? { fromMessageId: entry.editingId, action: 'edit' as const } : {}) }) + await send(text, { attachments, resources, quotes, skillIds: entry.skills?.map(item => item.id), permissionMode: mode, ...(entry.editingId ? { fromMessageId: entry.editingId, action: 'edit' as const } : {}) }) const destination = draftMoves.current.get(draftKey) ?? draftKey draftMoves.current.delete(draftKey) setDrafts((all) => ({ ...all, [draftKey]: { text: '', attachments: [] }, [destination]: { text: '', attachments: [] } })) } const edit = (message: ChatMessage): void => { - setDrafts((all) => ({ ...all, [draftKey]: { text: message.content, attachments: message.attachments ?? [], resources: message.resources ?? [], skills: message.skills, editingId: message.id } })) + setDrafts((all) => ({ ...all, [draftKey]: { text: message.content, attachments: message.attachments ?? [], resources: message.resources ?? [], quotes: message.quotes, skills: message.skills, editingId: message.id } })) document.querySelector('.wb-composer__input')?.focus() } - const resend = (message: ChatMessage): Promise => send(message.content, { attachments: message.attachments, resources: message.resources, skillIds: message.skills?.map(item => item.id), permissionMode, fromMessageId: message.id, action: 'resend' }) + const resend = (message: ChatMessage): Promise => send(message.content, { attachments: message.attachments, resources: message.resources, quotes: message.quotes, skillIds: message.skills?.map(item => item.id), permissionMode, fromMessageId: message.id, action: 'resend' }) const regenerate = (_requestId: string, message: ChatMessage): Promise => { const preceding = messages.slice(0, messages.findIndex((item) => item.id === message.id)).findLast((item) => item.role === 'user') - return preceding ? send(preceding.content, { attachments: preceding.attachments, resources: preceding.resources, skillIds: preceding.skills?.map(item => item.id), permissionMode, fromMessageId: message.id, action: 'regenerate' }) : Promise.resolve() + return preceding ? send(preceding.content, { attachments: preceding.attachments, resources: preceding.resources, quotes: preceding.quotes, skillIds: preceding.skills?.map(item => item.id), permissionMode, fromMessageId: message.id, action: 'regenerate' }) : Promise.resolve() } const lastAssistantId = messages.findLast((message) => message.role === 'assistant')?.id const question = messages.flatMap((message) => message.interactions ?? []).find((item) => item.kind === 'question' && ['pending', 'responding', 'error'].includes(item.status)) @@ -285,6 +300,16 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, onSelect={(_text, answer) => respond(question.id, { kind: 'question', answer })} onSkip={() => void respond(question.id, { kind: 'question', answer: { questions: question.questions.map((item) => ({ id: item.id, question: item.question, answers: [] })) }, skipped: true }).catch(() => undefined)} />} onResourcesChange={(resources) => setDrafts((all) => ({ ...all, [draftKey]: { ...(all[draftKey] ?? { text: '', attachments: [] }), resources } }))} + quotes={entry.quotes ?? []} + onRemoveQuote={removeQuote} + onActivateQuote={(quote) => { + const target = document.querySelector(`[data-message-quote-source-id="${CSS.escape(quote.sourceId)}"]`) + const block = target?.closest('.cr-table-block') ?? target?.parentElement + if (!block) return + block.scrollIntoView({ block: 'center' }) + block.setAttribute('data-message-quote-anchor-active', 'true') + window.setTimeout(() => block.removeAttribute('data-message-quote-anchor-active'), 1200) + }} onAttachmentsChange={(attachments) => setDrafts((all) => ({ ...all, [draftKey]: { ...(all[draftKey] ?? { text: '' }), attachments } }))} attachmentHost={{ pick: () => window.ycode.attachments.pick(), stage: ({ filename, ...input }) => window.ycode.attachments.stage({ name: filename, ...input }), remove: (id) => window.ycode.attachments.remove(id) }} onRemoveAttachment={(id) => messages.some((message) => message.attachments?.some((item) => item.id === id)) ? Promise.resolve() : window.ycode.attachments.remove(id)} @@ -342,6 +367,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
{messages.map((message) => ( window.dispatchEvent(new CustomEvent('ycode:open-artifacts', { detail: { sessionId: currentId } }))} buildingPlanId={running ? messages.find((item) => item.role === 'user' && item.requestId === running.runId)?.planId : undefined} diff --git a/src/renderer/src/components/MarkdownContent.tsx b/src/renderer/src/components/MarkdownContent.tsx index 6e0adc4..8be82dd 100644 --- a/src/renderer/src/components/MarkdownContent.tsx +++ b/src/renderer/src/components/MarkdownContent.tsx @@ -12,6 +12,8 @@ import { ImagePreview } from '../workbuddy/images/ImagePreview' import { requiresHostImageResolution, type ImagePreviewRequest, type ImageUrlResolver } from '../workbuddy/images/image-url' import { downloadImageInHost, downloadInHost, openPathInHost, resolveCodeInHost, resolveImageInHost } from '../workbuddy/host' import { useSettingsStore } from '../stores/settings' +import type { MessageQuote } from '@shared/conversation' +import { TableMessageQuoteAction } from '../workbuddy/conversation/TableMessageQuoteAction' export { CopyButton } from '../workbuddy/components/CodeBlock' export { parseCodeBlockMeta, resolveCodeBlockFenceInfo } from '../workbuddy/markdown/code-fence' @@ -20,7 +22,11 @@ export type { CodeBlockFileMetadata, CodeBlockFenceInfo } from '../workbuddy/mar export interface MarkdownContentProps { content: string streaming?: boolean - onQuote?: (markdown: string) => void + onQuote?: (quote: MessageQuote) => void + /** 已加入对话的引用来源,用于隐藏按钮并标记表格。 */ + quotedSourceIds?: readonly string[] + /** 内容块来源标识;与原版 `${messageId}-text-${index}` 一致。 */ + sourceId?: string messageId?: string darkMode?: boolean imageUrlResolver?: ImageUrlResolver @@ -34,8 +40,11 @@ export function getMappedSource(source: string, map: [number, number] | null): s } /** YCode integration adapter; source-derived renderer lives in workbuddy/markdown. */ -export default function MarkdownContent({ content, streaming = false, onQuote, messageId, darkMode = false, imageUrlResolver = resolveImageInHost }: MarkdownContentProps): ReactElement { +export default function MarkdownContent({ content, streaming = false, onQuote, quotedSourceIds, sourceId, messageId, darkMode = false, imageUrlResolver = resolveImageInHost }: MarkdownContentProps): ReactElement { const fallbackId = useId() + /** 内容块标识:原版为 `${messageId}-text-${index}`,缺省回退到消息 id。 */ + const blockSourceId = sourceId ?? messageId + const quoted = useMemo(() => new Set(quotedSourceIds ?? []), [quotedSourceIds]) const workspace = useSettingsStore((state) => state.workspace) const paths = useMemo(() => workspace ? [workspace] : [], [workspace]) const [imagePreview, setImagePreview] = useState(null) @@ -60,15 +69,19 @@ export default function MarkdownContent({ content, streaming = false, onQuote, m applyMathPlugin(md) return md }, [mathVersion]) - const renderTableAction = useCallback(({ markdown, disabled }: TableActionContext) => onQuote && markdown ? ( - - ) : null, [onQuote]) + const renderTableAction = useCallback((context: TableActionContext) => { + const { markdown, disabled } = context + if (!onQuote || !markdown) return null + // 原版 createTableMessageQuoteSourceId: `${sourceId}:table:${tableId}` + const id = context.sourceId && context.tableId ? `${context.sourceId}:table:${context.tableId}` : '' + return + }, [onQuote, quoted]) const value = useMemo(() => ({ mdInstance, highlightVersion, renderTableAction: onQuote ? renderTableAction : undefined, requestId: messageId || fallbackId, paths, resolveCode: resolveCodeInHost, pathClickHandler: { onPathClick: openPathInHost }, imageUrlResolver, preferHostImageResolution: true, onPreviewImage, onDownload: downloadInHost, isDarkMode: darkMode }), [mdInstance, highlightVersion, onQuote, renderTableAction, messageId, fallbackId, paths, onPreviewImage, darkMode, imageUrlResolver]) return <> - {content} + {content} {imagePreview ? createPortal(, document.body) : null} } diff --git a/src/renderer/src/components/MessageBubble.tsx b/src/renderer/src/components/MessageBubble.tsx index fa9fcbf..3e00d34 100644 --- a/src/renderer/src/components/MessageBubble.tsx +++ b/src/renderer/src/components/MessageBubble.tsx @@ -17,16 +17,19 @@ import { PermissionConfirm, QuestionAnswerTranscript, ThinkingBlock, PlanCreateB import { conversationLabel } from '../workbuddy/conversation/labels' import { downloadInHost, openPathInHost } from '../workbuddy/host' import { openArtifactPreview } from '../stores/resources' -import type { InteractionResponse, PlanContent } from '@shared/conversation' +import type { InteractionResponse, MessageQuote, PlanContent } from '@shared/conversation' import MarkdownContent from './MarkdownContent' import StreamingFooter from '../workbuddy/conversation/StreamingFooter' import StreamingTurnDuration from '../workbuddy/conversation/StreamingTurnDuration' import { getMessageParts } from '@shared/message-parts' import { projectWorkBuddyHistoryTool } from '@shared/workbuddy-history-projection' +import { MessageQuoteTag } from '../workbuddy/conversation/MessageQuoteTag' interface Props { message: ChatMessage - onQuote?(markdown: string): void + onQuote?(quote: MessageQuote): void + /** 当前输入区已引用的来源;用于隐藏表格按钮并高亮表格。 */ + quotedSourceIds?: readonly string[] darkMode?: boolean actions?: Omit onRespond?(id: string, response: InteractionResponse): Promise @@ -37,7 +40,7 @@ interface Props { onShowArtifacts?(): void } -export default function MessageBubble({ message, onQuote, darkMode, actions, onRespond, sessionId, onBuildPlan, buildingPlanId, artifactCount = 0, onShowArtifacts }: Props): React.JSX.Element { +export default function MessageBubble({ message, onQuote, quotedSourceIds, darkMode, actions, onRespond, sessionId, onBuildPlan, buildingPlanId, artifactCount = 0, onShowArtifacts }: Props): React.JSX.Element { const [actionError, setActionError] = useState(null) const [liveEventsExpanded, setLiveEventsExpanded] = useState(false) const perform = (action: () => Promise): void => { @@ -52,7 +55,12 @@ export default function MessageBubble({ message, onQuote, darkMode, actions, onR return (
-
{message.content.split('\n').map((line, index) =>

{line ||
}

)}
+
+ {!!message.quotes?.length &&
+ {message.quotes.map((quote) => )} +
} +
{message.content.split('\n').map((line, index) =>

{line ||
}

)}
+
{!!message.skills?.length &&
{message.skills.map(skill => {skill.name})}
} {message.attachments?.map((attachment) =>
{attachment.previewUrl ? : {attachment.name}} @@ -224,6 +232,8 @@ export default function MessageBubble({ message, onQuote, darkMode, actions, onR ((set, get) => { }, async send(text, options = {}) { const trimmed = text.trim() - if ((!trimmed && !options.attachments?.length) || get().loading) return + if ((!trimmed && !options.attachments?.length && !options.quotes?.length) || get().loading) return let sessionId = get().currentSessionId if (!sessionId) { if (get().sending) return diff --git a/src/renderer/src/workbuddy/composer/ComposerControls.tsx b/src/renderer/src/workbuddy/composer/ComposerControls.tsx index 3538996..65951af 100644 --- a/src/renderer/src/workbuddy/composer/ComposerControls.tsx +++ b/src/renderer/src/workbuddy/composer/ComposerControls.tsx @@ -7,6 +7,8 @@ import './composer.css' import './composer-source.css' import SourceComposerAddMenu, { type AddMenuEntry, type SubmenuContext } from './add-menu/SourceComposerAddMenu' import { SourceFilesSubmenu } from './add-menu/SourceSubmenus' +import type { MessageQuote } from '@shared/conversation' +import { MessageQuoteCard } from '../conversation/MessageQuoteCard' export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 @@ -20,6 +22,8 @@ export interface ComposerSendPayload { text: string attachments: ChatAttachment[] resources: string[] + /** 引用进对话的内容块(WorkBuddy message.quote)。 */ + quotes: MessageQuote[] mode: PermissionMode } @@ -31,6 +35,11 @@ export interface ComposerControlsProps { onAttachmentsChange?(attachments: ChatAttachment[]): void resources?: string[] onResourcesChange?(resources: string[]): void + /** 已引用进对话的内容块(表格等)。 */ + quotes?: MessageQuote[] + onRemoveQuote?(quote: MessageQuote): void + /** 点击引用标签回到来源内容。 */ + onActivateQuote?(quote: MessageQuote): void onRemoveResourcePath?(path: string): void | Promise attachmentHost?: AttachmentHost onRemoveAttachment?(id: string): void | Promise @@ -73,9 +82,10 @@ export function buildComposerSendPayload( text: string, attachments: ChatAttachment[], resources: string[], - mode: PermissionMode + mode: PermissionMode, + quotes: MessageQuote[] = [] ): ComposerSendPayload { - return { text: text.trim(), attachments, resources: [...resources], mode } + return { text: text.trim(), attachments, resources: [...resources], quotes: [...quotes], mode } } async function encodeFile(file: File): Promise { @@ -120,6 +130,9 @@ export function ComposerControls({ onAttachmentsChange, resources, onResourcesChange, + quotes = [], + onRemoveQuote, + onActivateQuote, onRemoveResourcePath, attachmentHost, onRemoveAttachment, @@ -174,6 +187,8 @@ export function ComposerControls({ const resourcePaths = resources ?? localResources const resourcesRef = useRef(resourcePaths) resourcesRef.current = resourcePaths + const quotesRef = useRef(quotes) + quotesRef.current = quotes const beginBusy = (): void => { busyCountRef.current += 1 @@ -323,7 +338,7 @@ export function ComposerControls({ } const submit = async (): Promise => { - if (sendInFlightRef.current || disabled || sending || busyCountRef.current > 0 || (!value.trim() && attachments.length === 0 && resourcesRef.current.length === 0)) return + if (sendInFlightRef.current || disabled || sending || busyCountRef.current > 0 || (!value.trim() && attachments.length === 0 && resourcesRef.current.length === 0 && quotesRef.current.length === 0)) return const tooLarge = attachments.filter((attachment) => attachment.size > MAX_ATTACHMENT_BYTES) if (tooLarge.length) { setError(`文件超过 20 MB:${tooLarge.map((item) => item.name).join('、')}`) @@ -333,7 +348,7 @@ export function ComposerControls({ sendInFlightRef.current = true beginBusy() try { - await onSend(buildComposerSendPayload(value, attachments, resourcesRef.current, permissionMode)) + await onSend(buildComposerSendPayload(value, attachments, resourcesRef.current, permissionMode, quotesRef.current)) } catch (reason) { setError(errorText(reason)) } finally { @@ -457,6 +472,12 @@ export function ComposerControls({ {dragging && }
{capabilityReferences} + {quotes.length > 0 && ( +
+ {quotes.map((quote) => )} +
+ )} {attachments.length > 0 && (
{attachments.map((attachment) => ( @@ -541,7 +562,7 @@ export function ComposerControls({ ) : ( - )} diff --git a/src/renderer/src/workbuddy/conversation/MessageQuoteCard.tsx b/src/renderer/src/workbuddy/conversation/MessageQuoteCard.tsx new file mode 100644 index 0000000..d663757 --- /dev/null +++ b/src/renderer/src/workbuddy/conversation/MessageQuoteCard.tsx @@ -0,0 +1,35 @@ +import type { MouseEvent, ReactElement } from 'react' +import type { MessageQuote } from '@shared/conversation' +import { ChipCloseIcon } from './MessageQuoteIcons' +import './message-quote-source.css' + +export interface MessageQuoteCardProps { + quote: MessageQuote + /** 作者与摘要之间的分隔符,原版 chatInput.quoteSeparator。 */ + separator?: string + removeLabel?: string + onRemove?(quote: MessageQuote): void + onActivate?(quote: MessageQuote): void +} + +/** + * 输入区的引用卡片(WorkBuddy QuoteReferenceCard)。 + * 结构:左侧强调条 + 正文(作者/摘要)+ 移除按钮,尺寸与配色取自回收样式表。 + */ +export function MessageQuoteCard({ quote, separator = ':', removeLabel = '移除引用', onRemove, onActivate }: MessageQuoteCardProps): ReactElement { + const remove = (event: MouseEvent): void => { + event.stopPropagation() + onRemove?.(quote) + } + return ( +
onActivate(quote) : undefined}> +
+ ) +} diff --git a/src/renderer/src/workbuddy/conversation/MessageQuoteIcons.tsx b/src/renderer/src/workbuddy/conversation/MessageQuoteIcons.tsx new file mode 100644 index 0000000..043aaa8 --- /dev/null +++ b/src/renderer/src/workbuddy/conversation/MessageQuoteIcons.tsx @@ -0,0 +1,35 @@ +import type { ReactElement } from 'react' + +/** + * WorkBuddy 5.8.3 引用相关图标。 + * 来源:REA 回收的渲染器 bundle `.tmp/wb-src-DbE1_mgM.js` + * (`MessageQuoteAddIcon` / `MessageQuoteTextIcon` 定义原文), + * 以及 REA 布局证据 `ev_38cca1ac66606ab07fb495035e3509f5c4d8ec3e5e2b74d0fdcfcfadb120da3b` + * 中 `.cr-message-quote-table-menu svg` 的 16×16 与两条 path 的 d/transform。 + */ + +/** 表格“添加到对话”按钮图标(对话气泡 + 加号)。 */ +export function MessageQuoteAddIcon(): ReactElement { + return +} + +/** 文本引用标签图标(带 T 的对话气泡)。 */ +export function MessageQuoteTextIcon(): ReactElement { + return +} + +/** 引用卡片移除按钮图标(ChipCloseIcon)。 */ +export function ChipCloseIcon(): ReactElement { + return +} diff --git a/src/renderer/src/workbuddy/conversation/MessageQuoteTag.tsx b/src/renderer/src/workbuddy/conversation/MessageQuoteTag.tsx new file mode 100644 index 0000000..acc6d5e --- /dev/null +++ b/src/renderer/src/workbuddy/conversation/MessageQuoteTag.tsx @@ -0,0 +1,42 @@ +import type { MouseEvent, ReactElement } from 'react' +import type { MessageQuote } from '@shared/conversation' +import { MessageQuoteTextIcon } from './MessageQuoteIcons' +import './message-quote-source.css' + +export interface MessageQuoteTagProps { + quote: MessageQuote + /** 发送后的历史消息里为只读标签:不可移除,配色切到 message-quote 只读态。 */ + readOnly?: boolean + onRemove?(quote: MessageQuote): void + /** 点击标签时回到原表格并高亮。 */ + onActivate?(quote: MessageQuote): void +} + +/** + * 输入区与用户消息共用的引用标签。 + * 结构复刻 WorkBuddy 内容块标签:图标容器 + 文本 + 悬停替换出的移除按钮。 + */ +export function MessageQuoteTag({ quote, readOnly = false, onRemove, onActivate }: MessageQuoteTagProps): ReactElement { + const removable = !readOnly && !!onRemove + const remove = (event: MouseEvent): void => { + event.stopPropagation() + onRemove?.(quote) + } + return ( + onActivate(quote) : undefined} + onKeyDown={onActivate ? (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onActivate(quote) } } : undefined} + > + + {quote.preview} + {removable && } + + ) +} diff --git a/src/renderer/src/workbuddy/conversation/TableMessageQuoteAction.tsx b/src/renderer/src/workbuddy/conversation/TableMessageQuoteAction.tsx new file mode 100644 index 0000000..d58b874 --- /dev/null +++ b/src/renderer/src/workbuddy/conversation/TableMessageQuoteAction.tsx @@ -0,0 +1,48 @@ +import { useCallback, type ReactElement } from 'react' +import type { MessageQuote } from '@shared/conversation' +import { MessageQuoteAddIcon } from './MessageQuoteIcons' +import './message-quote-source.css' + +export interface TableMessageQuoteActionProps { + markdown: string + /** `${messageSourceId}:table:${tableId}`,与原版 createTableMessageQuoteSourceId 一致。 */ + sourceId: string + /** 已加入对话时隐藏按钮并给表格加品牌色描边。 */ + added: boolean + /** 流式未完成的表格不可引用。 */ + disabled?: boolean + label?: string + onAdd(quote: MessageQuote): void | Promise +} + +/** 原版 markdown 表格上方的“添加到对话”菜单。 */ +/** 原版 getTableMessageQuoteMessageId:从 `${messageId}-text-${index}` 还原消息 id。 */ +export function getTableMessageQuoteMessageId(sourceId: string | undefined): string | undefined { + return sourceId?.match(/^(.*)-text-\d+$/)?.[1] +} + +export function TableMessageQuoteAction({ markdown, sourceId, added, disabled = false, label = '添加到对话', onAdd }: TableMessageQuoteActionProps): ReactElement | null { + const click = useCallback((): void => { + void onAdd({ + id: sourceId, + kind: 'table', + sourceId, + messageId: getTableMessageQuoteMessageId(sourceId.match(/^(.*):table:[^:]*$/)?.[1]), + markdown, + preview: markdown.replace(/\s+/g, ' ').trim() + }) + }, [markdown, onAdd, sourceId]) + if (!sourceId || !markdown.trim()) return null + return ( +
+ {added ? null : } +
+ ) +} diff --git a/src/renderer/src/workbuddy/conversation/message-quote-source.css b/src/renderer/src/workbuddy/conversation/message-quote-source.css new file mode 100644 index 0000000..91c2ff8 --- /dev/null +++ b/src/renderer/src/workbuddy/conversation/message-quote-source.css @@ -0,0 +1,383 @@ +/* + * WorkBuddy 5.8.3 消息引用(添加到对话)样式。 + * 来源:REA 回收的样式表 src-CyXHA0IC.css(SHA-256 a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe) + * 中的 `.cr-message-quote-table-menu`、`.cr-message-quote-table-action`、`.cr-table-block:hover …` + * 与模块化标签规则 `._tag_1krka_19` / `._iconContainer_1krka_38` / `._icon_1krka_38` / `._text_1krka_66` + * (后者的哈希后缀在回收产物中为重导出常量,这里按其声明值复刻)。 + * 几何交叉验证:REA 证据 ev_38cca1ac66606ab07fb495035e3509f5c4d8ec3e5e2b74d0fdcfcfadb120da3b + * ——菜单 116×38、左侧绝对定位 top:8px right:8px、按钮 106×28 / 14px / 22px / padding 3px 8px / gap 4px / 圆角 6px。 + */ + +/* + * 原版 5.8.3 的回收样式表里没有 --cr-brand-secondary 的定义,但同版本 + * contexts-BkjwYSZT.css 在 `var(--cr-brand-secondary,#40d1b3)` 中给出了兜底值。 + * 这里沿用该兜底值,避免 border-color 变成无效声明回退到文字色。 + * 若后续拿到原版运行时 token 定义,应以真实值替换。 + */ +.chat-pane.cr-theme { + --cr-brand-secondary: #40d1b3; +} + +/* 表格悬浮“添加到对话”菜单 */ +.cr-message-quote-table-menu { + z-index: 3; + position: absolute; + top: 8px; + right: 8px; + width: max-content; + border: 1px solid var(--cr-border-default); + background: var(--cr-bg-primary); + box-shadow: var(--cr-shadow-panel-light); + opacity: 0; + pointer-events: none; + border-radius: 8px; + justify-content: flex-end; + align-items: center; + gap: 2px; + padding: 4px; + transition: opacity .15s; + display: flex; +} + +.cr-message-quote-table-menu:focus-within { + opacity: 1; + pointer-events: auto; +} + +.cr-message-quote-table-menu--added { + display: none; +} + +.cr-message-quote-table-action { + width: max-content; + color: var(--cr-text-primary); + cursor: pointer; + background: none; + border: 0; + border-radius: 6px; + flex-shrink: 0; + justify-content: center; + align-items: center; + gap: 4px; + padding: 3px 8px; + font-family: inherit; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 22px; + transition: background-color .15s; + display: inline-flex; +} + +.cr-message-quote-table-action svg { + flex-shrink: 0; +} + +.cr-message-quote-table-action:hover:not(:disabled) { + background: var(--cr-bg-secondary); +} + +.cr-message-quote-table-action:disabled { + cursor: default; +} + +/* 被引用表格:已加入时保留品牌色描边,悬停引用标签时高亮 */ +.cr-table-block:has(> [data-message-quote-table-added=true]) .cr-table-block__inner { + border-color: var(--cr-brand-secondary); +} + +.cr-table-block[data-message-quote-anchor-active=true] .cr-table-block__inner, +.cr-table-block:has([data-message-quote-anchor-active=true]) .cr-table-block__inner { + border-color: var(--cr-brand-secondary); + background: var(--cr-bg-brand-subtle); +} + +/* 输入区引用标签(模块化 _tag_1krka_19 + message-quote 变体) */ +.cr-message-quote-tag { + position: relative; + vertical-align: middle; + background-color: var(--cr-tag-skill-bg); + height: 22px; + color: var(--cr-text-primary); + cursor: pointer; + border-radius: 6px; + outline: none; + align-items: center; + gap: 4px; + max-width: calc(100% - 4px); + margin-inline: 2px; + padding: 2px 6px; + font-size: 13px; + font-weight: 500; + line-height: 14px; + display: inline-flex; +} + +.cr-message-quote-tag[data-content-block-meta-type=message-quote] { + background-color: var(--cr-tag-skill-bg); + color: var(--cr-text-primary); +} + +.cr-message-quote-tag[data-content-block-meta-type=message-quote]:hover { + background-color: var(--cr-tag-skill-bg-hover); +} + +.cr-message-quote-tag[data-content-block-meta-type=message-quote]:focus, +.cr-message-quote-tag[data-content-block-meta-type=message-quote]:focus-visible { + background-color: var(--cr-tag-skill-bg-active); + color: var(--cr-text-primary); +} + +.cr-message-quote-tag--read-only { + cursor: default; +} + +.cr-message-quote-tag[data-content-block-meta-type=message-quote].cr-message-quote-tag--read-only { + background-color: var(--cr-tag-message-quote-bg); +} + +.cr-message-quote-tag[data-content-block-meta-type=message-quote].cr-message-quote-tag--read-only:focus, +.cr-message-quote-tag[data-content-block-meta-type=message-quote].cr-message-quote-tag--read-only:focus-visible { + background-color: var(--cr-tag-message-quote-bg-active); +} + +.cr-message-quote-tag__icon-container { + visibility: visible; + flex-shrink: 0; + justify-content: center; + align-items: center; + width: 16px; + height: 16px; + display: inline-flex; +} + +.cr-message-quote-tag__icon-container > svg, +.cr-message-quote-tag__icon-container > img { + width: 100%; + height: 100%; +} + +.cr-message-quote-tag__text { + white-space: nowrap; + text-overflow: ellipsis; + cursor: pointer; + overflow: hidden; +} + +.cr-message-quote-tag__close { + box-sizing: content-box; + cursor: pointer; + visibility: hidden; + flex-shrink: 0; + justify-content: center; + align-items: center; + width: 12px; + height: 12px; + margin-left: -4px; + padding: 4px; + display: inline-flex; + position: absolute; + top: 50%; + left: 4px; + transform: translateY(-50%); +} + +.cr-message-quote-tag--removable:hover .cr-message-quote-tag__icon-container { + visibility: hidden; +} + +.cr-message-quote-tag--removable:hover .cr-message-quote-tag__close { + visibility: visible; +} + +/* 短语样式引用(悬浮提示 + 圆角标签) */ +.cr-message-quote-phrase-trigger { + vertical-align: middle; + align-items: center; + min-width: 0; + max-width: 160px; + margin-inline: 4px; + display: inline-flex; +} + +.cr-message-quote-phrase-trigger > [data-content-block-meta-type=message-quote] { + margin-inline: 0; +} + +.cr-message-quote-phrase-tag { + box-sizing: border-box; + width: fit-content; + max-width: 160px; + height: 23px; + color: var(--cr-text-primary); + border-radius: 100px; + gap: 4px; + padding: 1px 8px; + font-size: 13px; + font-weight: 500; + line-height: 21px; +} + +.cr-message-quote-phrase-tag .cr-message-quote-tag__icon-container, +.cr-message-quote-phrase-tag .cr-message-quote-tag__icon-container > svg, +.cr-message-quote-phrase-tag .cr-message-quote-tag__icon-container > img { + width: 14px; + height: 14px; +} + +.cr-message-quote-phrase-tag .cr-message-quote-tag__text { + min-width: 0; + line-height: 21px; +} + +.cr-message-quote-phrase-tooltip-surface { + border: .5px solid var(--cr-border-default); + background: var(--cr-bg-elevated); + width: min(400px, 100vw - 24px); + color: var(--cr-text-primary); + border-radius: 16px; + padding: 0; + box-shadow: 0 6px 24px -4px #00000014, 0 4px 6px -2px #0000000f; +} + +.cr-message-quote-phrase-tooltip { + overscroll-behavior: contain; + max-height: min(240px, 100vh - 48px); + color: var(--cr-text-primary); + flex-direction: column; + align-items: stretch; + gap: 8px; + padding: 8px 12px; + display: flex; + overflow: auto; +} + +.cr-message-quote-phrase-tooltip__source { + align-items: flex-start; + gap: 8px; + min-width: 0; + display: flex; +} + +.cr-message-quote-phrase-tooltip__source > svg { + flex: 0 0 16px; + width: 16px; + height: 16px; + margin-top: 3px; +} + +.cr-message-quote-phrase-tooltip__source > span { + white-space: pre-wrap; + word-break: break-word; + min-width: 0; + font-size: 14px; + line-height: 22px; +} + +/* 输入区引用容器:原版 _container_1krka_1 */ +.cr-message-quote-container { + flex-wrap: wrap; + flex-shrink: 0; + align-items: center; + gap: 4px; + min-height: 18px; + line-height: 18px; + display: inline-flex; +} + +/* 输入区引用卡片:原版 QuoteReferenceCard / input-banner-rail */ +.cr-input-banner-rail { + box-sizing: border-box; + flex-wrap: wrap; + gap: 6px; + width: 100%; + padding: 0 16px 8px; + display: flex; +} + +.cr-message-quote-rail { + display: block; + padding: 0 16px 8px; +} + +.cr-input-quote-card { + box-sizing: border-box; + background: var(--cr-item-hover-bg); + width: 100%; + color: var(--cr-text-tertiary); + cursor: pointer; + border: 0; + border-radius: 10px; + align-items: center; + gap: 8px; + padding: 8px; + transition: background .15s; + display: flex; + position: relative; +} + +.cr-input-quote-card__accent { + background: var(--cr-border-faint); + border-radius: 1px; + flex: none; + align-self: stretch; + width: 3px; +} + +.cr-input-quote-card__body { + white-space: nowrap; + flex: 1; + align-items: center; + gap: 4px; + min-width: 0; + display: flex; + overflow: hidden; +} + +.cr-input-quote-card__author { + min-width: 0; + color: var(--cr-text-secondary); + text-overflow: ellipsis; + white-space: nowrap; + flex: 0 auto; + font-size: 12px; + font-weight: 500; + line-height: 20px; + overflow: hidden; +} + +.cr-input-quote-card__summary { + min-width: 0; + color: var(--cr-text-tertiary); + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + font-size: 12px; + line-height: 20px; + overflow: hidden; +} + +.cr-input-quote-card__remove { + width: 16px; + height: 16px; + color: var(--cr-text-tertiary); + cursor: pointer; + background: none; + border: none; + flex: 0 0 16px; + justify-content: center; + align-items: center; + padding: 0; + transition: color .1s; + display: inline-flex; +} + +.cr-input-quote-card__remove > svg { + width: 16px; + height: 16px; + display: block; +} + +.cr-input-quote-card__remove:hover { + color: var(--cr-text-primary); +} diff --git a/src/shared/conversation.ts b/src/shared/conversation.ts index e16ab13..273414b 100644 --- a/src/shared/conversation.ts +++ b/src/shared/conversation.ts @@ -13,6 +13,25 @@ export interface ChatAttachment { path?: string } +/** + * 消息引用(WorkBuddy `message.quote.add`)。 + * 表格引用保存 markdown 快照,发送后随用户消息一起持久化。 + */ +export interface MessageQuote { + id: string + kind: 'table' + /** 稳定来源标识(渲染器 sourceId + table id),用于同一条回复内去重与高亮。 */ + sourceId: string + /** 产生该引用的助手消息 id,可回溯到原表格。 */ + messageId?: string + /** 表格 markdown 快照。 */ + markdown: string + /** 引用来源标题(原版 display.title);未采集到原版取值时留空,不臆造文本。 */ + title?: string + /** 输入区与消息气泡中的展示摘要。 */ + preview: string +} + export interface QuestionOption { id?: string option?: string diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index e0d959b..ca7ea34 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -13,7 +13,7 @@ import type { AppInfo } from './types' import type { DownloadPayload, DownloadResult, ResourceEntry, ResourceRange, WorkspaceResource } from './resources' -import type { ChatAttachment, InteractionResponse, PermissionMode } from './conversation' +import type { ChatAttachment, InteractionResponse, MessageQuote, PermissionMode } from './conversation' import type { CustomModelInput, ModelRegistryView, ModelTestResult } from './models' import type { SessionContext, WorkspaceRegistryView } from './workspaces' import type { BrowserHostApi } from './browser-host' @@ -39,6 +39,8 @@ export interface SendPayload { sessionId: string text: string attachments?: ChatAttachment[] + /** 表格等内容的引用快照,随用户消息持久化。 */ + quotes?: MessageQuote[] permissionMode?: PermissionMode /** Retry/edit from this message; descendants are preserved in an archived branch. */ fromMessageId?: string diff --git a/src/shared/types.ts b/src/shared/types.ts index ea54b1c..96e0f75 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -6,7 +6,7 @@ */ /** 会话内的一条消息(对外视图) */ -import type { ArtifactContent, ChatAttachment, ChatInteraction, PlanContent, TaskContent } from './conversation' +import type { ArtifactContent, ChatAttachment, ChatInteraction, MessageQuote, PlanContent, TaskContent } from './conversation' export interface ChatMessage { id: string @@ -18,6 +18,8 @@ export interface ChatMessage { parts?: MessagePart[] attachments?: ChatAttachment[] resources?: string[] + /** 发送时引用进对话的表格等内容块快照(WorkBuddy messageQuote)。 */ + quotes?: MessageQuote[] skills?: Array<{ id: string; name: string; uri: string }> /** Native-history reconstruction; never includes connector credentials. */ capabilityContext?: string