feat(conversation): replicate table quote-to-chat action (TODO 02)
This commit is contained in:
1 parent
e335588826
commit
ed31a7c1b4
20 files changed
+939
-46
No files matched your search
@@ -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. 编辑已发送消息
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -31,9 +31,11 @@ async function main(): Promise<void> {
|
||||
assert.doesNotMatch(render('before<script>window.bad = true</script>after'), /<script|window\.bad/)
|
||||
assert.match(render('Waiting $x +', true), /markdown-content__loading-char/)
|
||||
const table = '| a | b |\n| - | - |\n| 1 | 2 |'
|
||||
const tableWithAction = (streaming: boolean): string => renderToStaticMarkup(<MarkdownContent content={table} streaming={streaming} onQuote={() => 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(<MarkdownContent content={table} messageId="fixture-message" sourceId="fixture-message-text-0" streaming={streaming} onQuote={() => 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/)
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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=`<div data-table-native-source-flow style={{position:'absolute',inset:0,transform:'translateY(11220px)'}}><div className="chat-pane cr-theme" data-cr-theme="light" style={{position:'absolute',left:324.28125,top:-10834.671875,width:436.703125,height:'auto',minHeight:0,display:'block',overflow:'visible'}}><MarkdownContent content={source} onQuote={text=>window.qa.quoted.push(text)}/></div></div>`
|
||||
const originalFixture=`<div data-table-native-source-flow style={{position:'absolute',inset:0,transform:'translateY(11220px)'}}><div className="chat-pane cr-theme" data-cr-theme="light" style={{position:'absolute',left:324.28125,top:-10834.671875,width:436.703125,height:'auto',minHeight:0,display:'block',overflow:'visible'}}><MarkdownContent content={source} messageId="fixture-message" sourceId="fixture-message-text-0" onQuote={quote=>window.qa.quoted.push(quote)}/></div></div>`
|
||||
const sourceFixture=`<div className="chat-pane cr-theme" data-cr-theme="light" style={{position:'absolute',inset:0,display:'block',overflow:'visible'}}>
|
||||
<div className="cr-message-list-viewport" data-source-node="772" style={{position:'absolute',left:284.28125,top:94,width:526.703125,height:639}}>
|
||||
<div className="cr-message-list cr-message-list--virtual-anchored" data-source-scroll data-source-node="773" style={{overflow:${JSON.stringify(layerCase==='source-clip-flow'?'hidden':'auto')}}}>
|
||||
@@ -24,7 +24,7 @@ if(!process.versions.electron){
|
||||
<div className="cr-document__virtual-item cr-document__virtual-item--flow" data-source-node="1545" style={{height:477.5}}>
|
||||
<div className="cr-frame cr-frame--left" style={{height:477.5,paddingTop:16,paddingBottom:16}}><div className="cr-frame__content" style={{height:445.5}}><div className="cr-agent" style={{height:445.5}}>
|
||||
<div style={{height:36.5}}/><div className="cr-agent__body-row" style={{height:409}}><div className="cr-agent__body" style={{height:409}}><div className="cr-agent__content" style={{height:293.75}}>
|
||||
<div style={{height:26.25}}/><div className="cr-text-block cr-text-block--spaced-top" style={{marginTop:12}}><MarkdownContent content={sourceMarkdown} onQuote={text=>window.qa.quoted.push(text)}/></div>
|
||||
<div style={{height:26.25}}/><div className="cr-text-block cr-text-block--spaced-top" style={{marginTop:12}}><MarkdownContent content={sourceMarkdown} messageId="source-message" sourceId="source-message-text-0" onQuote={quote=>window.qa.quoted.push(quote)}/></div>
|
||||
</div></div></div>
|
||||
</div></div></div>
|
||||
</div>
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
+5
-2
@@ -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
|
||||
|
||||
@@ -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<Record<string, { text: string; attachments: ChatAttachment[]; resources?: string[]; skills?: Array<Pick<SkillRecord, 'id' | 'name'>>; editingId?: string; autoFilledPrompt?: string }>>({})
|
||||
const [drafts, setDrafts] = useState<Record<string, { text: string; attachments: ChatAttachment[]; resources?: string[]; quotes?: MessageQuote[]; skills?: Array<Pick<SkillRecord, 'id' | 'name'>>; editingId?: string; autoFilledPrompt?: string }>>({})
|
||||
const draftMoves = useRef(new Map<string, string>())
|
||||
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<string | null>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const followBottom = useRef(true)
|
||||
const quoteTable = useCallback((markdown: string): void => {
|
||||
setDraft((current) => current ? `${current}\n\n${markdown}` : markdown)
|
||||
document.querySelector<HTMLTextAreaElement>('.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<HTMLTextAreaElement>('.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<HTMLTextAreaElement>('.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<void> => {
|
||||
if ((!text && !attachments.length) || running || sending) return
|
||||
const submit = async ({ text, attachments, resources, quotes, mode }: ComposerSendPayload): Promise<void> => {
|
||||
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<HTMLTextAreaElement>('.wb-composer__input')?.focus()
|
||||
}
|
||||
const resend = (message: ChatMessage): Promise<void> => 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<void> => 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<void> => {
|
||||
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<HTMLElement>(`[data-message-quote-source-id="${CSS.escape(quote.sourceId)}"]`)
|
||||
const block = target?.closest<HTMLElement>('.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,
|
||||
<div className="chat-thread cr-message-list__content"><div className="cr-document">
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} sessionId={currentId} onQuote={quoteTable} darkMode={darkMode} onRespond={respond}
|
||||
quotedSourceIds={quotedSourceIds}
|
||||
artifactCount={!running && !sending && message.id === lastAssistantId ? artifactCount : 0}
|
||||
onShowArtifacts={() => window.dispatchEvent(new CustomEvent('ycode:open-artifacts', { detail: { sessionId: currentId } }))}
|
||||
buildingPlanId={running ? messages.find((item) => item.role === 'user' && item.requestId === running.runId)?.planId : undefined}
|
||||
|
||||
@@ -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<ImagePreviewRequest | null>(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 ? (
|
||||
<button type="button" className="markdown-content__table-quote" disabled={disabled} onClick={() => onQuote(markdown)}>添加到对话</button>
|
||||
) : 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 <TableMessageQuoteAction markdown={markdown} sourceId={id} added={quoted.has(id)} disabled={disabled} onAdd={onQuote} />
|
||||
}, [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 <>
|
||||
<ReactMarkdownProvider value={value}><ReactMarkdown isFinished={!streaming} messageId={messageId}>{content}</ReactMarkdown></ReactMarkdownProvider>
|
||||
<ReactMarkdownProvider value={value}><ReactMarkdown isFinished={!streaming} messageId={messageId} sourceId={blockSourceId}>{content}</ReactMarkdown></ReactMarkdownProvider>
|
||||
{imagePreview ? createPortal(<ImagePreview {...imagePreview} onClose={closeImagePreview} onDownload={downloadImageInHost} />, document.body) : null}
|
||||
</>
|
||||
}
|
||||
@@ -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<MessageActionToolbarProps, 'message'>
|
||||
onRespond?(id: string, response: InteractionResponse): Promise<void>
|
||||
@@ -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<string | null>(null)
|
||||
const [liveEventsExpanded, setLiveEventsExpanded] = useState(false)
|
||||
const perform = (action: () => Promise<unknown>): void => {
|
||||
@@ -52,7 +55,12 @@ export default function MessageBubble({ message, onQuote, darkMode, actions, onR
|
||||
return (
|
||||
<div className="msg--user cr-frame cr-frame--right"><div className="cr-frame__content">
|
||||
<div className="cr-self-message"><div className="cr-self-message__content">
|
||||
<div className="bubble cr-self-bubble"><div className="cr-user-content"><div className="yc-user-editable">{message.content.split('\n').map((line, index) => <p key={index}><span className="yc-user-leaf"><span>{line || <br />}</span></span></p>)}</div></div></div>
|
||||
<div className="bubble cr-self-bubble">
|
||||
{!!message.quotes?.length && <div className="cr-context-chips" aria-label="引用的内容">
|
||||
{message.quotes.map((quote) => <MessageQuoteTag key={quote.id} quote={quote} readOnly />)}
|
||||
</div>}
|
||||
<div className="cr-user-content"><div className="yc-user-editable">{message.content.split('\n').map((line, index) => <p key={index}><span className="yc-user-leaf"><span>{line || <br />}</span></span></p>)}</div></div>
|
||||
</div>
|
||||
{!!message.skills?.length && <div className="wb-composer__resources" aria-label="使用的技能">{message.skills.map(skill => <span className="wb-composer__resource" data-skill-uri={skill.uri} key={skill.id}>{skill.name}</span>)}</div>}
|
||||
{message.attachments?.map((attachment) => <div className="msg__attachment" key={attachment.id}>
|
||||
{attachment.previewUrl ? <MarkdownContent content={`![${attachment.name.replace(/[\[\]]/g, '')}](${attachment.previewUrl})`} messageId={attachment.id} darkMode={darkMode} /> : <span>{attachment.name}</span>}
|
||||
@@ -224,6 +232,8 @@ export default function MessageBubble({ message, onQuote, darkMode, actions, onR
|
||||
<MarkdownContent
|
||||
content={part.text}
|
||||
messageId={message.id}
|
||||
sourceId={`${message.id}-text-${index}`}
|
||||
quotedSourceIds={quotedSourceIds}
|
||||
darkMode={darkMode}
|
||||
streaming={streaming && index === parts.length - 1}
|
||||
onQuote={onQuote}
|
||||
|
||||
@@ -282,7 +282,7 @@ export const useChatStore = create<ChatState>((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
|
||||
|
||||
@@ -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<void>
|
||||
attachmentHost?: AttachmentHost
|
||||
onRemoveAttachment?(id: string): void | Promise<void>
|
||||
@@ -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<string> {
|
||||
@@ -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<void> => {
|
||||
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 && <div className="wb-composer__drop-overlay" aria-hidden="true">放下文件以添加附件</div>}
|
||||
<div className="cr-input-box__main">
|
||||
{capabilityReferences}
|
||||
{quotes.length > 0 && (
|
||||
<div className="cr-input-banner-rail cr-message-quote-rail" aria-label="引用的内容">
|
||||
{quotes.map((quote) => <MessageQuoteCard key={quote.id} quote={quote}
|
||||
onRemove={onRemoveQuote} onActivate={onActivateQuote} />)}
|
||||
</div>
|
||||
)}
|
||||
{attachments.length > 0 && (
|
||||
<div className="wb-composer__attachments" aria-label="附件">
|
||||
{attachments.map((attachment) => (
|
||||
@@ -541,7 +562,7 @@ export function ComposerControls({
|
||||
<span className="cr-send-button__icon"><IconStop /></span>
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="wb-composer__send cr-send-button cr-input-toolbar__send" title="发送" aria-label="发送" onClick={() => void submit()} disabled={disabled || sending || busy || (!value.trim() && attachments.length === 0 && resourcePaths.length === 0)}>
|
||||
<button type="button" className="wb-composer__send cr-send-button cr-input-toolbar__send" title="发送" aria-label="发送" onClick={() => void submit()} disabled={disabled || sending || busy || (!value.trim() && attachments.length === 0 && resourcePaths.length === 0 && quotes.length === 0)}>
|
||||
<span className="cr-send-button__icon"><RuntimeSendIcon /></span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -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<HTMLButtonElement>): void => {
|
||||
event.stopPropagation()
|
||||
onRemove?.(quote)
|
||||
}
|
||||
return (
|
||||
<div className="cr-input-quote-card" data-message-quote-source-id={quote.sourceId} data-reference-id={quote.id}
|
||||
onClick={onActivate ? () => onActivate(quote) : undefined}>
|
||||
<span className="cr-input-quote-card__accent" aria-hidden="true" />
|
||||
<span className="cr-input-quote-card__body">
|
||||
{quote.title ? <span className="cr-input-quote-card__author">{quote.title}{separator}</span> : null}
|
||||
<span className="cr-input-quote-card__summary">{quote.preview}</span>
|
||||
</span>
|
||||
{onRemove && <button type="button" className="cr-input-quote-card__remove" aria-label={removeLabel} title={removeLabel} onClick={remove}><ChipCloseIcon /></button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd" transform="matrix(1 0 0 1 0.773201 0.778992)"
|
||||
d="M7.5868 0.0001C7.499 -0.0001 7.4144 -0 7.3335 0C7.2971 0.0001 7.2615 0.0001 7.2266 0.0001C7.1914 0.0001 7.1554 0.0001 7.1187 0C6.4251 -0.0005 5.4655 -0.0012 4.5 0.17C3.4838 0.3502 2.392 0.7316 1.5605 1.5636C0.1676 2.9577 0 4.8385 0 6.2804C0 7.7222 0.1678 9.6021 1.5605 10.9962C2.45 11.8863 3.5897 12.2648 4.6387 12.4298L4.7324 11.837L4.8252 11.2442C3.9083 11.1001 3.0494 10.7892 2.4092 10.1485C1.3802 9.1185 1.1993 7.6782 1.1992 6.2804C1.1992 4.8825 1.3801 3.4423 2.4092 2.4122C2.9967 1.8242 3.8153 1.5103 4.71 1.3517C5.5721 1.1988 6.4411 1.1991 7.1476 1.1993L7.2266 1.1993L7.3054 1.1993C7.3966 1.1993 7.4905 1.1992 7.5868 1.1995L7.5868 0.0001ZM13.2418 6.861C13.1877 8.0684 12.9308 9.2608 12.0439 10.1485C11.4695 10.7233 10.7192 11.0335 9.9062 11.1954C9.0891 11.3581 8.2533 11.3624 7.5342 11.3604C6.8348 11.3587 6.2261 11.7597 5.7373 12.2286C5.2367 12.709 4.7714 13.3484 4.3535 14.005L5.3662 14.6495C5.7651 14.0227 6.17 13.4771 6.5684 13.0948C6.9785 12.7013 7.2983 12.56 7.5312 12.5606C8.2453 12.5626 9.1916 12.5611 10.1406 12.3721C11.0936 12.1824 12.094 11.7954 12.8926 10.9962C14.1048 9.7828 14.389 8.2013 14.4424 6.861L13.2418 6.861Z" />
|
||||
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd" transform="matrix(1 0 0 1 9.19556 0.777834)"
|
||||
d="M3.61 3.6L3.61 6L2.41 6L2.41 3.6L0 3.6L0 2.4L2.41 2.4L2.41 0L3.61 0L3.61 2.4L6.02 2.4L6.02 3.6L3.61 3.6Z" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
/** 文本引用标签图标(带 T 的对话气泡)。 */
|
||||
export function MessageQuoteTextIcon(): ReactElement {
|
||||
return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path fill="currentColor" fillRule="evenodd" transform="matrix(1 0 0 1 1.19996 1.19991)"
|
||||
d="M0 6.2449L0 7.3551Q-0.0001 9.7054 0.1102 10.5133Q0.295 11.8673 1.0139 12.5861Q1.7327 13.305 3.0867 13.4898Q3.8946 13.6001 6.2449 13.6L6.3231 13.6L7.2769 13.6L7.3551 13.6Q9.7054 13.6001 10.5133 13.4898Q11.8673 13.305 12.5861 12.5861Q13.305 11.8673 13.4898 10.5133Q13.6001 9.7054 13.6 7.3551L13.6 7.2769L13.6 6.3231L13.6 6.2449Q13.6001 3.8946 13.4898 3.0867Q13.305 1.7327 12.5861 1.0139Q11.8673 0.295 10.5133 0.1102Q9.7054 -0.0001 7.3551 0L6.2449 0Q3.8946 -0.0001 3.0867 0.1102Q1.7327 0.295 1.0139 1.0139Q0.295 1.7327 0.1102 3.0867Q-0.0001 3.8946 0 6.2449ZM1.2 7.2769L1.2 6.3231L1.2 6.2449Q1.1999 3.9761 1.2992 3.2489Q1.4293 2.2955 1.8624 1.8624Q2.2955 1.4293 3.2489 1.2992Q3.9761 1.1999 6.2449 1.2L6.3231 1.2L7.2769 1.2L7.3551 1.2Q9.6239 1.1999 10.3511 1.2992Q11.3045 1.4293 11.7376 1.8624Q12.1707 2.2955 12.3008 3.2489Q12.4001 3.9761 12.4 6.2449L12.4 6.3231L12.4 7.2769L12.4 7.3551Q12.4001 9.6239 12.3008 10.3511Q12.1707 11.3045 11.7376 11.7376Q11.3045 12.1707 10.3511 12.3008Q9.6239 12.4001 7.3551 12.4L7.2769 12.4L6.3231 12.4L6.2449 12.4Q3.9761 12.4001 3.2489 12.3008Q2.2955 12.1707 1.8624 11.7376Q1.4293 11.3045 1.2992 10.3511Q1.1999 9.6239 1.2 7.3551L1.2 7.2769ZM3.3 4.1986L10.3 4.1986L10.3 5.3986L7.4 5.3986L7.4 10.6497L6.2 10.6497L6.2 5.3986L3.3 5.3986L3.3 4.1986Z" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
/** 引用卡片移除按钮图标(ChipCloseIcon)。 */
|
||||
export function ChipCloseIcon(): ReactElement {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path fill="currentColor" fillRule="evenodd" transform="matrix(1 0 0 1 2.5651 2.5651)"
|
||||
d="M6.3046 5.4349L10.8698 10L10 10.8698L5.4349 6.3046L0.8698 10.8698L0 10L4.5651 5.4349L0 0.8698L0.8698 0L5.4349 4.5651L10 0L10.8698 0.8698L6.3046 5.4349Z" />
|
||||
</svg>
|
||||
}
|
||||
@@ -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<HTMLButtonElement>): void => {
|
||||
event.stopPropagation()
|
||||
onRemove?.(quote)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={['cr-message-quote-tag', readOnly ? 'cr-message-quote-tag--read-only' : '', removable ? 'cr-message-quote-tag--removable' : ''].filter(Boolean).join(' ')}
|
||||
data-content-block-meta-type="message-quote"
|
||||
data-message-quote-id={quote.id}
|
||||
data-message-quote-source-id={quote.sourceId}
|
||||
role={onActivate ? 'button' : undefined}
|
||||
tabIndex={onActivate ? 0 : undefined}
|
||||
title={quote.preview}
|
||||
onClick={onActivate ? () => onActivate(quote) : undefined}
|
||||
onKeyDown={onActivate ? (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onActivate(quote) } } : undefined}
|
||||
>
|
||||
<span className="cr-message-quote-tag__icon-container"><MessageQuoteTextIcon /></span>
|
||||
<span className="cr-message-quote-tag__text">{quote.preview}</span>
|
||||
{removable && <button type="button" className="cr-message-quote-tag__close" aria-label={`移除引用 ${quote.preview}`} title={`移除引用 ${quote.preview}`} onClick={remove}>×</button>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
/** 原版 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 (
|
||||
<div
|
||||
className={`cr-message-quote-table-menu${added ? ' cr-message-quote-table-menu--added' : ''}`}
|
||||
data-message-quote-table-added={added ? 'true' : undefined}
|
||||
data-message-quote-source-id={sourceId}
|
||||
>
|
||||
{added ? null : <button type="button" className="cr-message-quote-table-action" disabled={disabled} aria-label={label} onClick={click}>
|
||||
<MessageQuoteAddIcon />
|
||||
<span>{label}</span>
|
||||
</button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user