feat(chat): switch to the conversation on send with a startup placeholder (TODO 08)
This commit is contained in:
1 parent
0b549ca794
commit
6173a77918
9 files changed
+403
-21
No files matched your search
@@ -220,12 +220,41 @@
|
|||||||
|
|
||||||
### 08. 新建任务发送后的立即切页与占位
|
### 08. 新建任务发送后的立即切页与占位
|
||||||
|
|
||||||
- [ ] 盘点发送到创建会话、准备环境、接收首个事件之间的阻塞位置。
|
- [x] 盘点发送到创建会话、准备环境、接收首个事件之间的阻塞位置。
|
||||||
- [ ] 对照原版发送后的页面切换时机与等待状态。
|
- [x] 对照原版发送后的页面切换时机与等待状态。
|
||||||
- [ ] 发送后立即进入消息页面并显示用户消息;启动较慢时显示合适的助手占位/准备状态。
|
- [x] 发送后立即进入消息页面并显示用户消息;启动较慢时显示合适的助手占位/准备状态。
|
||||||
- [ ] 验收:慢启动、正常启动、发送失败、停止、切换任务;不等到模型输出后才切页,不重复创建会话或丢失草稿。
|
- [x] 验收:慢启动、正常启动、发送失败、停止、切换任务;不等到模型输出后才切页,不重复创建会话或丢失草稿。
|
||||||
|
|
||||||
状态:待 REA 对照与发送状态链路排查。
|
状态:已实现并通过真实窗口回归(发送即切页 + 乐观用户消息 + 启动占位 + 真实回合替换 + 失败/停止/切换任务)。
|
||||||
|
|
||||||
|
阻塞位置盘点(YCode 现状):
|
||||||
|
- 渲染进程 `stores/chat.ts` 的 `send()` 在无会话时依次 `await session.create` → `await chat.send`,旧实现只有两步都完成后才把用户消息与助手占位写入 `messages`,因此「点了发送什么也不动」直到回合就绪。
|
||||||
|
- 主进程 `chat:send` 在返回前要完成:附件/上下文准备、`createCapabilitySnapshot`、`getOmpKernel`(运行环境启动,本轮实测约 1.9–2.3 s)、`sessions.startTurn`;模型回合本身在后台推进,不属于返回前的阻塞。
|
||||||
|
|
||||||
|
原版证据:
|
||||||
|
- 启动阶段文案:REA `ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb`(WorkBuddy 5.8.2,`contexts-BMeWSUHk.js`,SHA-256 `5f35b7489b107e21ef452c8ee88a95265756f6fc444841eef869228bbc44768b`):`chat.prepare.<stage>` 表与显示逻辑见 `docs/对话启动提示文案-2026-10-16.md`——阶段文案渲染在流式状态栏主文案位置,进入终态/失败/非 idle/MCP 进度后隐藏,其余回落「思考中」。本次占位使用 `acquiring_runtime` =「正在启动对话运行环境…」(用户确认沿用 5.8.2 已有内容,不重新采集)。
|
||||||
|
- REA 采集限制(已核到实现层):visual probe 的 `text` 动作要求 enabled text/search `INPUT`,`key` 动作对可编辑内容上的 Enter/Space 直接拒绝(`.tmp/rea-css-runtime/dist/browser/CdpElectronVisualProbe.js`;schema `.tmp/rea-css-runtime/dist/domain/javascript/ElectronVisualProbeSchema.js`)。WorkBuddy 新建任务编辑器是 `DIV[role=textbox]._editable_1cxd2_1`,因此无法用 REA 驱动发送并抓取「发送后」逐帧等待态;本项不通过其它通道查看/驱动原版。三次采集(`ev_a492a83d…`、`ev_150e8217…`、`ev_5b13ec7d…`)与限制说明见 `.tmp/todo08-wb-new-task/notes.md`。
|
||||||
|
- 观察范围:桌面端浅色主题、本机 5.8.3 运行时(文案表来自 5.8.2 已回收证据)。未覆盖:原版发送后逐帧顺序与各段时长。
|
||||||
|
|
||||||
|
实现:
|
||||||
|
- `stores/chat.ts`:无会话发送时先落地乐观的「用户消息 + 助手占位」(`local-user-*` / `local-assistant-*`,占位带 `prepareStage: acquiring_runtime` 与 `turnStartedAt`),随后才 `await session.create` / `chat.send`;`newSession(context, seedMessages)` 允许把乐观消息带进新会话;真实结果到达时先过滤 `local-*` 再追加真实用户消息与占位,避免重复;失败时移除乐观消息并写入 `error`。
|
||||||
|
- `components/ChatPane.tsx`:`submit()` 在发送前清空输入框,失败时把草稿恢复到当前所有者键(会话创建后草稿已迁移到会话 id);消息非空即离开首页布局(`isHome = currentId === null || messages.length === 0`)。
|
||||||
|
- `workbuddy/conversation/prepare-phases.ts`(新增)与 `StreamingFooter` 的 `prepareText`:按回收的 `chat.prepare` 表渲染「正在启动对话运行环境…」替换「思考中」,真实回合一到位即自动回落。
|
||||||
|
- `shared/types.ts`:`ChatMessage.prepareStage`(仅渲染进程启动占位使用,不持久化)。
|
||||||
|
|
||||||
|
验证:新增 `scripts/verify-new-task-switch.cjs`(真实隔离 Electron + 真实 preload/IPC/渲染进程 + 真实 OMP + 受控本地上游)30 项断言全 PASS:
|
||||||
|
- 计时(点击发送为 t0):对话页、用户消息、启动占位、「已处理」计时均在 **+26 ms** 出现;启动占位在 **+1.9 s** 让位给真实回合,真实回复在 **+2.6 s** 渲染(另一次跑分 +23.8 ms / +2.27 s / +2.97 s)——占位确实覆盖了运行环境启动窗口。
|
||||||
|
- 不重复:一次发送只创建 1 个会话;回合结束后 `.cr-self-bubble` 恰好 1 条(无乐观+真实重复);占位在真实回合出现前清除。
|
||||||
|
- 发送失败(清空模型注册表后发送):草稿恢复为原文、无残留乐观消息、错误「请先添加并选择自定义模型」显示、回到新建页、无启动占位。
|
||||||
|
- 停止与切换任务:回合运行中切到新任务再切回,消息与运行状态都在;点击停止后助手消息状态为 `cancelled`、取消指示可见、无残留占位、仍只有 1 条用户消息。
|
||||||
|
- 回归:`npm run typecheck`、`npm run build`、`npm run test:chat`、`npm run test:conversation`、`npm run test:conversation-ipc`、`node scripts/verify-user-message-edit.cjs` 全部通过。
|
||||||
|
- 报告:`.tmp/new-task-switch-641vdN/report.json`(另有 `.tmp/new-task-switch-TX5VSX/`)。
|
||||||
|
|
||||||
|
遗留:
|
||||||
|
1. 原版「发送后」逐帧等待态(先后顺序、每段时长)无 REA 运行时证据(探针限制见上),本项只对齐可证部分。
|
||||||
|
2. 发送失败时仍会留下一个空会话(`session.create` 已成功、回合未开始);原版在准备失败时是否保留会话未采集。
|
||||||
|
3. 启动阶段目前只使用 `acquiring_runtime` 一条;原版多阶段(expert → … → checking_launch_config)的发出方、顺序与时长未采集,不按字符串表臆造。
|
||||||
|
4. 发送窗口内没有「停止」入口(`停止` 只在回合运行后出现,沿用既有实现);原版在准备阶段是否可直接取消未采集。
|
||||||
|
|
||||||
### 09. 账户菜单与页面
|
### 09. 账户菜单与页面
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"include_fonts": false,
|
||||||
|
"include_message_content": true,
|
||||||
|
"track_selectors": [
|
||||||
|
".conversation-page-chrome__content",
|
||||||
|
".conversation-input",
|
||||||
|
".cr-input-editor-host",
|
||||||
|
".cr-send-button"
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{ "kind": "wait", "step_id": "wait-100ms", "duration_ms": 0, "frames": 1 },
|
||||||
|
{ "kind": "wait", "step_id": "wait-300ms", "duration_ms": 200, "frames": 1 },
|
||||||
|
{ "kind": "wait", "step_id": "wait-500ms", "duration_ms": 100, "frames": 1 },
|
||||||
|
{ "kind": "wait", "step_id": "wait-1000ms", "duration_ms": 400, "frames": 1 },
|
||||||
|
{ "kind": "wait", "step_id": "wait-2000ms", "duration_ms": 1000, "frames": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
// TODO 08 验收:新任务发送后的立即切页与占位。
|
||||||
|
// 真实隔离 YCode(真实 preload/IPC/渲染进程)+ 真实 OMP + 受控本地 OpenAI 兼容上游。
|
||||||
|
//
|
||||||
|
// 原版依据(REA ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb,
|
||||||
|
// WorkBuddy 5.8.2,见 docs/对话启动提示文案-2026-10-16.md):启动阶段文案取自
|
||||||
|
// chat.prepare.<stage>,渲染在流式状态栏主文案位置。YCode 侧使用 acquiring_runtime
|
||||||
|
// =「正在启动对话运行环境…」作为主进程回合就绪前的占位文案。
|
||||||
|
// 原版「发送后」逐帧等待态没有 REA 运行时证据:REA visual probe 的 text 动作只接受
|
||||||
|
// INPUT、key 动作排除可编辑内容上的 Enter/Space(见 .tmp/todo08-wb-new-task/notes.md),
|
||||||
|
// 因此本脚本只验收可证行为:发送即切页 + 用户消息先出现 + 准备阶段占位先于真实回合 +
|
||||||
|
// 真实消息替换不重复 + 发送失败恢复草稿且不残留乐观消息。
|
||||||
|
const fs = require('node:fs/promises')
|
||||||
|
const path = require('node:path')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
if (!process.versions.electron) {
|
||||||
|
;(async () => {
|
||||||
|
const directory = await fs.mkdtemp(path.resolve('.tmp/new-task-switch-'))
|
||||||
|
const home = path.join(directory, 'home')
|
||||||
|
await fs.mkdir(home, { recursive: true })
|
||||||
|
const env = { ...process.env, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_QA_DIRECTORY: directory,
|
||||||
|
YCODE_QA_REAL_OMP: process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA || '', 'Ygent', 'omp.exe'),
|
||||||
|
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('NEW_TASK_SWITCH_')) process.stdout.write(bytes) })
|
||||||
|
child.stderr.on('data', bytes => { output += bytes })
|
||||||
|
const timer = setTimeout(() => child.kill(), 240000)
|
||||||
|
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.slice(-8000))
|
||||||
|
process.exitCode = code ? 1 : 0
|
||||||
|
})
|
||||||
|
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||||
|
} else {
|
||||||
|
const { app, BrowserWindow } = require('electron')
|
||||||
|
const { createServer } = require('node:http')
|
||||||
|
const { pathToFileURL } = require('node:url')
|
||||||
|
const directory = process.env.YCODE_QA_DIRECTORY
|
||||||
|
app.disableHardwareAcceleration()
|
||||||
|
process.env.YCODE_OMP_PATH = process.env.YCODE_QA_REAL_OMP
|
||||||
|
app.setPath('home', path.join(directory, 'home'))
|
||||||
|
const checks = []
|
||||||
|
const snapshots = {}
|
||||||
|
const rendererErrors = []
|
||||||
|
let main, server, phase = 'boot'
|
||||||
|
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
const read = (source, win = main) => Promise.race([win.webContents.executeJavaScript(source),
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 20000))])
|
||||||
|
async function wait(source, label) {
|
||||||
|
for (let index = 0; index < 400; index++) { const value = await read(source); if (value) return value; await pause(40) }
|
||||||
|
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 fill(value) {
|
||||||
|
await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
|
||||||
|
if(!input)throw new Error('Missing composer input');
|
||||||
|
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,${JSON.stringify(value)});
|
||||||
|
input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
|
||||||
|
}
|
||||||
|
const within = limit => value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value < limit
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
// 受控上游:首个响应刻意延迟,保证「真实回合」到达前有一段可观测的占位窗口。
|
||||||
|
server = createServer(async (req, res) => {
|
||||||
|
let raw = ''
|
||||||
|
for await (const part of req) raw += part
|
||||||
|
const body = JSON.parse(raw || '{}')
|
||||||
|
// 「停止验收」场景需要一个更长的回合,方便在运行中切走/切回再停止。
|
||||||
|
await pause(JSON.stringify(body).includes('停止验收') ? 5000 : 600)
|
||||||
|
const content = `LOCAL_REPLY_${body.model}`
|
||||||
|
if (!body.stream) { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content } }] })); return }
|
||||||
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
|
||||||
|
const chunk = delta => `data: ${JSON.stringify({ id: 'local-test', object: 'chat.completion.chunk', created: 1, model: body.model, choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`
|
||||||
|
res.write(chunk({ role: 'assistant', content }))
|
||||||
|
res.write(`data: ${JSON.stringify({ id: 'local-test', object: 'chat.completion.chunk', created: 1, model: body.model, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 } })}\n\ndata: [DONE]\n\n`)
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||||
|
const baseUrl = `http://127.0.0.1:${server.address().port}/v1`
|
||||||
|
|
||||||
|
await fs.mkdir(process.env.YCODE_USER_DATA_DIR, { recursive: true })
|
||||||
|
app.setPath('userData', process.env.YCODE_USER_DATA_DIR)
|
||||||
|
app.setPath('sessionData', process.env.YCODE_USER_DATA_DIR)
|
||||||
|
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||||
|
await app.whenReady()
|
||||||
|
for (let index = 0; index < 120; index++) {
|
||||||
|
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
|
||||||
|
if (main && !main.webContents.isLoading()) break
|
||||||
|
await pause(50)
|
||||||
|
}
|
||||||
|
assert.ok(main)
|
||||||
|
main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) })
|
||||||
|
await wait('!!window.ycode && !!document.querySelector(".chat-pane")', 'main renderer')
|
||||||
|
await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-one', name: 'Local One', providerId: 'custom',
|
||||||
|
model: 'model-one', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true, maxInputTokens: 64000, maxOutputTokens: 4096 })})`)
|
||||||
|
await read('window.ycode.models.select("local-one")')
|
||||||
|
|
||||||
|
await step('home-surface')
|
||||||
|
await wait(`!!document.querySelector('.wb-home-composer') && !!document.querySelector('.wb-composer__input')`, 'home composer')
|
||||||
|
const sessionCountBefore = (await read('window.ycode.session.list()')).length
|
||||||
|
check('no session exists before the first send', sessionCountBefore, 0)
|
||||||
|
|
||||||
|
await step('send-new-task')
|
||||||
|
const message = 'QA 新任务发送验收'
|
||||||
|
await read(`(()=>{const marks={};const text=${JSON.stringify(message)};
|
||||||
|
const state=()=>({conversation:!!document.querySelector('.cr-message-list'),
|
||||||
|
user:[...document.querySelectorAll('.cr-self-bubble')].some(node=>node.textContent.includes(text)),
|
||||||
|
prepare:document.body.textContent.includes('正在启动对话运行环境…'),
|
||||||
|
duration:!!document.querySelector('.cr-streaming-duration'),
|
||||||
|
reply:document.body.textContent.includes('LOCAL_REPLY_model-one')});
|
||||||
|
const tick=()=>{const now=performance.now();const value=state();
|
||||||
|
if(!marks.conversation&&value.conversation)marks.conversation=now;
|
||||||
|
if(!marks.userMessage&&value.user)marks.userMessage=now;
|
||||||
|
if(!marks.prepareText&&value.prepare)marks.prepareText=now;
|
||||||
|
if(marks.prepareText&&!marks.prepareCleared&&!value.prepare)marks.prepareCleared=now;
|
||||||
|
if(!marks.duration&&value.duration)marks.duration=now;
|
||||||
|
if(!marks.reply&&value.reply)marks.reply=now;};
|
||||||
|
window.__qa={t0:0,marks};window.__qaTick=tick;
|
||||||
|
const observer=new MutationObserver(tick);observer.observe(document.documentElement,{subtree:true,childList:true,characterData:true});
|
||||||
|
tick();return true})()`)
|
||||||
|
await fill(message)
|
||||||
|
await wait(`(()=>{const button=document.querySelector('button[aria-label="发送"]');return !!button&&!button.disabled})()`, 'send button enabled')
|
||||||
|
await read(`(()=>{window.__qa.t0=performance.now();document.querySelector('button[aria-label="发送"]').click();return true})()`)
|
||||||
|
await wait(`!!(window.__qa.marks.conversation&&window.__qa.marks.userMessage&&window.__qa.marks.prepareText)`, 'optimistic marks')
|
||||||
|
await pause(150)
|
||||||
|
snapshots.optimistic = await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
|
||||||
|
return {t0:window.__qa.t0,marks:{...window.__qa.marks},inputValue:input?input.value:null,
|
||||||
|
userBubbles:[...document.querySelectorAll('.cr-self-bubble')].map(node=>node.textContent.trim()),
|
||||||
|
home:!!document.querySelector('.wb-home-composer'),conversation:!!document.querySelector('.cr-message-list'),
|
||||||
|
prepare:document.body.textContent.includes('正在启动对话运行环境…'),duration:!!document.querySelector('.cr-streaming-duration')}})()`)
|
||||||
|
const marks = snapshots.optimistic.marks
|
||||||
|
const t0 = snapshots.optimistic.t0
|
||||||
|
check('send switches to the conversation view immediately', marks.conversation - t0, within(500))
|
||||||
|
check('user message is visible immediately after send', marks.userMessage - t0, within(500))
|
||||||
|
check('startup placeholder appears immediately after send', marks.prepareText - t0, within(500))
|
||||||
|
check('streaming duration is running during the wait', marks.duration - t0, within(500))
|
||||||
|
check('composer draft is cleared at send time', snapshots.optimistic.inputValue, '')
|
||||||
|
check('home surface is left at send time', snapshots.optimistic.home, false)
|
||||||
|
check('optimistic user message is on screen', snapshots.optimistic.userBubbles.some(text => text.includes(message)), true)
|
||||||
|
check('startup placeholder text is the recovered acquiring_runtime string', snapshots.optimistic.prepare, true)
|
||||||
|
|
||||||
|
await step('real-turn')
|
||||||
|
const sessions = await read('window.ycode.session.list()')
|
||||||
|
const sessionId = sessions[0] && sessions[0].id
|
||||||
|
check('exactly one session was created for the send', sessions.length, 1)
|
||||||
|
const done = await wait(`window.ycode.session.messages(${JSON.stringify(sessionId)}).then(list=>{const last=list.at(-1);return last&&last.status==='done'&&last})`, 'turn finished')
|
||||||
|
check('local upstream answered the turn', done.content, 'LOCAL_REPLY_model-one')
|
||||||
|
await wait(`document.body.textContent.includes('LOCAL_REPLY_model-one')`, 'reply rendered')
|
||||||
|
await pause(250)
|
||||||
|
snapshots.finished = await read(`(()=>({userBubbles:[...document.querySelectorAll('.cr-self-bubble')].map(node=>node.textContent.trim()),
|
||||||
|
prepare:document.body.textContent.includes('正在启动对话运行环境…'),duration:!!document.querySelector('.cr-streaming-duration'),
|
||||||
|
reply:document.body.textContent.includes('LOCAL_REPLY_model-one'),marks:{...window.__qa.marks}}))()`)
|
||||||
|
const finalMarks = snapshots.finished.marks
|
||||||
|
check('startup placeholder is cleared before the reply arrives', finalMarks.prepareCleared !== undefined && finalMarks.prepareCleared <= (finalMarks.reply ?? Number.POSITIVE_INFINITY), true)
|
||||||
|
check('startup placeholder is gone after the turn', snapshots.finished.prepare, false)
|
||||||
|
check('streaming placeholder is replaced by the finished footer', snapshots.finished.duration, false)
|
||||||
|
check('exactly one user message after the turn (no optimistic duplicate)', snapshots.finished.userBubbles.length, 1)
|
||||||
|
check('reply is rendered in the conversation', snapshots.finished.reply, true)
|
||||||
|
check('real turn took longer than the optimistic render (placeholder was needed)', finalMarks.reply - finalMarks.userMessage,
|
||||||
|
value => typeof value === 'number' && value > 300)
|
||||||
|
snapshots.timings = { conversationMs: finalMarks.conversation - t0, userMessageMs: finalMarks.userMessage - t0,
|
||||||
|
prepareTextMs: finalMarks.prepareText - t0, durationMs: finalMarks.duration - t0,
|
||||||
|
prepareClearedMs: finalMarks.prepareCleared === undefined ? null : finalMarks.prepareCleared - t0,
|
||||||
|
replyMs: finalMarks.reply === undefined ? null : finalMarks.reply - t0 }
|
||||||
|
|
||||||
|
await step('send-failure')
|
||||||
|
// 清空模型注册表:发送会在 chat:send 的模型解析阶段被拒绝(早于任何回合/网络请求),
|
||||||
|
// 这是「发送失败」路径;同时避免误连外部网关。
|
||||||
|
const removed = await read(`window.ycode.models.list()`)
|
||||||
|
for (const model of removed.models) await read(`window.ycode.models.remove(${JSON.stringify(model.id)})`)
|
||||||
|
const registryAfterRemoval = await read(`window.ycode.models.list()`)
|
||||||
|
check('model registry is empty for the failure case', registryAfterRemoval.models.length, 0)
|
||||||
|
await read(`document.querySelector('[title="新建任务"]').click()`)
|
||||||
|
await wait(`!!document.querySelector('.wb-home-composer')`, 'home surface after new task')
|
||||||
|
const failureMessage = 'QA 发送失败草稿恢复'
|
||||||
|
await fill(failureMessage)
|
||||||
|
await wait(`(()=>{const button=document.querySelector('button[aria-label="发送"]');return !!button&&!button.disabled})()`, 'send enabled again')
|
||||||
|
await read(`document.querySelector('button[aria-label="发送"]').click()`)
|
||||||
|
// 发送失败的错误可能落在发送框(发送框自身状态)或会话区状态行(store error);
|
||||||
|
// 首条消息失败时布局会从对话页切回新建页,发送框重挂载,因此两处都接受。
|
||||||
|
await wait(`!!(document.querySelector('.wb-composer__error[role="alert"]')||document.querySelector('.msg__status--error'))`, 'send error surfaced')
|
||||||
|
await pause(250)
|
||||||
|
snapshots.failure = await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
|
||||||
|
const composerError=document.querySelector('.wb-composer__error');const statusError=document.querySelector('.msg__status--error');
|
||||||
|
return {inputValue:input?input.value:null,
|
||||||
|
userBubbles:document.querySelectorAll('.cr-self-bubble').length,
|
||||||
|
composerError:composerError?composerError.textContent:null,statusError:statusError?statusError.textContent:null,
|
||||||
|
prepare:document.body.textContent.includes('正在启动对话运行环境…'),home:!!document.querySelector('.wb-home-composer')}})()`)
|
||||||
|
check('failed send restores the draft text', snapshots.failure.inputValue, failureMessage)
|
||||||
|
check('failed send leaves no optimistic user message', snapshots.failure.userBubbles, 0)
|
||||||
|
check('failed send surfaces an error', snapshots.failure.composerError ?? snapshots.failure.statusError,
|
||||||
|
value => typeof value === 'string' && /模型/.test(value))
|
||||||
|
check('failed send leaves no startup placeholder', snapshots.failure.prepare, false)
|
||||||
|
check('failed send returns to the home surface', snapshots.failure.home, true)
|
||||||
|
|
||||||
|
await step('stop-and-switch')
|
||||||
|
await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-one', name: 'Local One', providerId: 'custom',
|
||||||
|
model: 'model-one', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true, maxInputTokens: 64000, maxOutputTokens: 4096 })})`)
|
||||||
|
await read('window.ycode.models.select("local-one")')
|
||||||
|
const idsBeforeStopScenario = (await read('window.ycode.session.list()')).map(session => session.id)
|
||||||
|
await read(`document.querySelector('[title="新建任务"]').click()`)
|
||||||
|
await wait(`!!document.querySelector('.wb-home-composer')`, 'home surface for the stop scenario')
|
||||||
|
const stopMessage = 'QA 停止验收'
|
||||||
|
await fill(stopMessage)
|
||||||
|
await wait(`(()=>{const button=document.querySelector('button[aria-label="发送"]');return !!button&&!button.disabled})()`, 'send enabled for the stop scenario')
|
||||||
|
await read(`document.querySelector('button[aria-label="发送"]').click()`)
|
||||||
|
await wait(`document.body.textContent.includes('正在启动对话运行环境…')`, 'startup placeholder for the stop scenario')
|
||||||
|
const stopSessionId = (await read('window.ycode.session.list()')).map(session => session.id)
|
||||||
|
.find(id => !idsBeforeStopScenario.includes(id))
|
||||||
|
check('stop scenario created exactly one new session', typeof stopSessionId === 'string' && !!stopSessionId, true)
|
||||||
|
await wait(`!!document.querySelector('button[aria-label="停止"]')`, 'stop button available while running')
|
||||||
|
// 运行中切到新任务再切回:消息与运行状态不丢。
|
||||||
|
await read(`document.querySelector('[title="新建任务"]').click()`)
|
||||||
|
await wait(`!!document.querySelector('.wb-home-composer') && !document.querySelector('.cr-self-bubble')`, 'switched to a new task mid-turn')
|
||||||
|
await read(`(()=>{const row=[...document.querySelectorAll('[data-session-id]')].find(node=>node.getAttribute('data-session-id')===${JSON.stringify(stopSessionId)});if(row)row.click();return !!row})()`)
|
||||||
|
await wait(`[...document.querySelectorAll('.cr-self-bubble')].some(node=>node.textContent.includes(${JSON.stringify(stopMessage)}))`, 'session restored after switching back')
|
||||||
|
snapshots.switchedBack = await read(`(()=>({user:[...document.querySelectorAll('.cr-self-bubble')].some(node=>node.textContent.includes(${JSON.stringify(stopMessage)})),
|
||||||
|
streaming:!!document.querySelector('.wb-cr-streaming-footer'),stop:!!document.querySelector('button[aria-label="停止"]')}))()`)
|
||||||
|
check('switching tasks mid-turn keeps the message and the running turn', snapshots.switchedBack.user && snapshots.switchedBack.streaming && snapshots.switchedBack.stop, true)
|
||||||
|
await read(`document.querySelector('button[aria-label="停止"]').click()`)
|
||||||
|
await wait(`window.ycode.session.messages(${JSON.stringify(stopSessionId)}).then(list=>list.at(-1)?.status==='cancelled')`, 'turn cancelled')
|
||||||
|
await pause(250)
|
||||||
|
snapshots.stopped = await read(`(()=>({userBubbles:[...document.querySelectorAll('.cr-self-bubble')].map(node=>node.textContent.trim()),
|
||||||
|
cancelled:!!document.querySelector('.cr-cancelled-indicator'),streaming:!!document.querySelector('.wb-cr-streaming-footer'),
|
||||||
|
prepare:document.body.textContent.includes('正在启动对话运行环境…')}))()`)
|
||||||
|
check('cancelled turn keeps exactly one user message', snapshots.stopped.userBubbles.length, 1)
|
||||||
|
check('cancelled turn shows the cancelled indicator', snapshots.stopped.cancelled, true)
|
||||||
|
check('cancelled turn leaves no streaming placeholder', snapshots.stopped.streaming, false)
|
||||||
|
check('cancelled turn leaves no startup placeholder', snapshots.stopped.prepare, false)
|
||||||
|
check('no renderer error logs', rendererErrors, [])
|
||||||
|
|
||||||
|
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
|
||||||
|
scope: 'Real isolated YCode App/preload/IPC/renderer with real OMP and a controlled local upstream; new-task send timing, startup placeholder, real-turn replacement and failure recovery measured in the live window.',
|
||||||
|
sourceEvidence: ['ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb', 'docs/对话启动提示文案-2026-10-16.md', '.tmp/todo08-wb-new-task/notes.md'],
|
||||||
|
snapshots, checks, rendererErrors }
|
||||||
|
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||||
|
console.log('NEW_TASK_SWITCH_' + report.result + ' ' + directory)
|
||||||
|
for (const failure of checks.filter(item => !item.pass)) console.log('NEW_TASK_SWITCH_FAILURE ' + JSON.stringify(failure))
|
||||||
|
server.close()
|
||||||
|
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)
|
||||||
|
server?.close()
|
||||||
|
app.exit(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -221,10 +221,22 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
|||||||
const submit = async ({ text, attachments, resources, quotes, mode }: ComposerSendPayload): Promise<void> => {
|
const submit = async ({ text, attachments, resources, quotes, mode }: ComposerSendPayload): Promise<void> => {
|
||||||
if ((!text && !attachments.length && !quotes.length) || running || sending) return
|
if ((!text && !attachments.length && !quotes.length) || running || sending) return
|
||||||
followBottom.current = true
|
followBottom.current = true
|
||||||
|
// 原版发送即清空输入框并立即切到对话页:先清空草稿,失败时再恢复(TODO 08)。
|
||||||
|
const submitKey = draftKey
|
||||||
|
const restore = { ...entry, text, attachments, resources, quotes }
|
||||||
|
setDrafts((all) => ({ ...all, [submitKey]: { text: '', attachments: [] } }))
|
||||||
|
try {
|
||||||
await send(text, { attachments, resources, quotes, skillIds: entry.skills?.map(item => item.id), permissionMode: mode })
|
await send(text, { attachments, resources, quotes, skillIds: entry.skills?.map(item => item.id), permissionMode: mode })
|
||||||
const destination = draftMoves.current.get(draftKey) ?? draftKey
|
// 会话创建后草稿所有权已迁移到会话 id;两处都清空,避免留下旧文本。
|
||||||
draftMoves.current.delete(draftKey)
|
const destination = draftMoves.current.get(submitKey) ?? submitKey
|
||||||
setDrafts((all) => ({ ...all, [draftKey]: { text: '', attachments: [] }, [destination]: { text: '', attachments: [] } }))
|
draftMoves.current.delete(submitKey)
|
||||||
|
setDrafts((all) => ({ ...all, [submitKey]: { text: '', attachments: [] }, [destination]: { text: '', attachments: [] } }))
|
||||||
|
} catch (error) {
|
||||||
|
const destination = draftMoves.current.get(submitKey)
|
||||||
|
draftMoves.current.delete(submitKey)
|
||||||
|
setDrafts((all) => ({ ...all, [submitKey]: restore, ...(destination && destination !== submitKey ? { [destination]: restore } : {}) }))
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 就地编辑:进入、取消、提交(提交走 send 的 action='edit' 分支,与原版 message.edit.submit 对齐)。 */
|
/** 就地编辑:进入、取消、提交(提交走 send 的 action='edit' 分支,与原版 message.edit.submit 对齐)。 */
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type { InteractionResponse, MessageQuote, PlanContent } from '@shared/con
|
|||||||
import MarkdownContent from './MarkdownContent'
|
import MarkdownContent from './MarkdownContent'
|
||||||
import StreamingFooter from '../workbuddy/conversation/StreamingFooter'
|
import StreamingFooter from '../workbuddy/conversation/StreamingFooter'
|
||||||
import StreamingTurnDuration from '../workbuddy/conversation/StreamingTurnDuration'
|
import StreamingTurnDuration from '../workbuddy/conversation/StreamingTurnDuration'
|
||||||
|
import { resolvePrepareText } from '../workbuddy/conversation/prepare-phases'
|
||||||
import { getMessageParts } from '@shared/message-parts'
|
import { getMessageParts } from '@shared/message-parts'
|
||||||
import { projectWorkBuddyHistoryTool } from '@shared/workbuddy-history-projection'
|
import { projectWorkBuddyHistoryTool } from '@shared/workbuddy-history-projection'
|
||||||
import { MessageQuoteTag } from '../workbuddy/conversation/MessageQuoteTag'
|
import { MessageQuoteTag } from '../workbuddy/conversation/MessageQuoteTag'
|
||||||
@@ -265,7 +266,7 @@ export default function MessageBubble({ message, onQuote, quotedSourceIds, darkM
|
|||||||
return option ? onRespond?.(id, { kind: 'approval', optionId: option.optionId }) : undefined
|
return option ? onRespond?.(id, { kind: 'approval', optionId: option.optionId }) : undefined
|
||||||
}} /> : interaction.answer && !message.tools?.some((tool) => tool.id === interaction.toolCallId && tool.metadata?.questionAnswer) ? <QuestionAnswerTranscript key={interaction.id} questionAnswer={interaction.answer} /> : null)}
|
}} /> : interaction.answer && !message.tools?.some((tool) => tool.id === interaction.toolCallId && tool.metadata?.questionAnswer) ? <QuestionAnswerTranscript key={interaction.id} questionAnswer={interaction.answer} /> : null)}
|
||||||
|
|
||||||
{streaming && <StreamingFooter />}
|
{streaming && <StreamingFooter prepareText={resolvePrepareText(message.prepareStage)} />}
|
||||||
|
|
||||||
{message.status === 'error' && message.error ? (
|
{message.status === 'error' && message.error ? (
|
||||||
<div className="msg__status msg__status--error">{message.error}</div>
|
<div className="msg__status msg__status--error">{message.error}</div>
|
||||||
|
|||||||
@@ -8,10 +8,30 @@ import type { ThinkingLevel } from '@shared/workspaces'
|
|||||||
import type { AgentMode } from '@shared/conversation'
|
import type { AgentMode } from '@shared/conversation'
|
||||||
import { expertText, type ExpertActivation } from '@shared/capabilities'
|
import { expertText, type ExpertActivation } from '@shared/capabilities'
|
||||||
import { ChatStreamCommitter, type ChatStreamKey } from './chat-stream-committer'
|
import { ChatStreamCommitter, type ChatStreamKey } from './chat-stream-committer'
|
||||||
|
import { PENDING_START_STAGE } from '../workbuddy/conversation/prepare-phases'
|
||||||
|
|
||||||
interface SummonExpertOptions { newTask?: boolean; initPrompt?: string }
|
interface SummonExpertOptions { newTask?: boolean; initPrompt?: string }
|
||||||
|
|
||||||
interface RunningState { runId: string; assistantId: string; sessionId?: string }
|
interface RunningState { runId: string; assistantId: string; sessionId?: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新任务发送后、主进程回合就绪前渲染的乐观占位(用户消息 + 助手占位)。
|
||||||
|
* id 以 local- 开头,只在渲染进程存在;真实消息到达或被替换/清理。
|
||||||
|
*/
|
||||||
|
function optimisticStartMessages(draftOwner: string, text: string, options: Omit<SendPayload, 'sessionId' | 'text'>): ChatMessage[] {
|
||||||
|
const startedAt = Date.now()
|
||||||
|
return [
|
||||||
|
{ id: `local-user-${draftOwner}`, role: 'user', content: text, createdAt: startedAt, status: 'done',
|
||||||
|
attachments: options.attachments, resources: options.resources, quotes: options.quotes },
|
||||||
|
{ id: `local-assistant-${draftOwner}`, role: 'assistant', content: '', tools: [], parts: [],
|
||||||
|
createdAt: startedAt, turnStartedAt: startedAt, status: 'streaming', prepareStage: PENDING_START_STAGE }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOptimisticStartMessage(message: ChatMessage): boolean {
|
||||||
|
return message.id.startsWith('local-user-') || message.id.startsWith('local-assistant-')
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatState {
|
interface ChatState {
|
||||||
sessions: SessionMeta[]
|
sessions: SessionMeta[]
|
||||||
currentSessionId: string | null
|
currentSessionId: string | null
|
||||||
@@ -27,7 +47,7 @@ interface ChatState {
|
|||||||
draftId: number
|
draftId: number
|
||||||
init(): Promise<void>
|
init(): Promise<void>
|
||||||
selectSession(id: string): Promise<void>
|
selectSession(id: string): Promise<void>
|
||||||
newSession(context?: SessionContext): Promise<SessionMeta>
|
newSession(context?: SessionContext, seedMessages?: ChatMessage[]): Promise<SessionMeta>
|
||||||
startTask(context?: SessionContext): void
|
startTask(context?: SessionContext): void
|
||||||
selectModel(id: string): Promise<void>
|
selectModel(id: string): Promise<void>
|
||||||
selectExpert(id: string | null): Promise<void>
|
selectExpert(id: string | null): Promise<void>
|
||||||
@@ -225,14 +245,14 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|||||||
window.dispatchEvent(new CustomEvent('ycode:expert-input', { detail: { owner: targetOwner, clear: true } }))
|
window.dispatchEvent(new CustomEvent('ycode:expert-input', { detail: { owner: targetOwner, clear: true } }))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async newSession(context) {
|
async newSession(context, seedMessages: ChatMessage[] = []) {
|
||||||
const previousSessionId = get().currentSessionId
|
const previousSessionId = get().currentSessionId
|
||||||
if (previousSessionId) streamCommitter.flushSession(previousSessionId)
|
if (previousSessionId) streamCommitter.flushSession(previousSessionId)
|
||||||
const version = ++selectionVersion
|
const version = ++selectionVersion
|
||||||
const created = await window.ycode.session.create(undefined, context)
|
const created = await window.ycode.session.create(undefined, context)
|
||||||
set((state) => ({ sessions: [created, ...state.sessions],
|
set((state) => ({ sessions: [created, ...state.sessions],
|
||||||
sessionMessages: { ...state.sessionMessages, [created.id]: [] },
|
sessionMessages: { ...state.sessionMessages, [created.id]: seedMessages },
|
||||||
...(version === selectionVersion ? { currentSessionId: created.id, messages: [], running: null,
|
...(version === selectionVersion ? { currentSessionId: created.id, messages: seedMessages, running: null,
|
||||||
sending: false, loading: false, error: null } : {}) }))
|
sending: false, loading: false, error: null } : {}) }))
|
||||||
return created
|
return created
|
||||||
},
|
},
|
||||||
@@ -286,13 +306,20 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|||||||
let sessionId = get().currentSessionId
|
let sessionId = get().currentSessionId
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
if (get().sending) return
|
if (get().sending) return
|
||||||
|
// 原版在新任务发送后立即切到对话页:先渲染乐观的「用户消息 + 助手占位」,
|
||||||
|
// 等主进程建好会话与回合(可能包含运行环境启动)后再用真实消息替换(TODO 08)。
|
||||||
const draftOwner = `new-${get().draftId}`
|
const draftOwner = `new-${get().draftId}`
|
||||||
set({ sending: true })
|
const optimistic = optimisticStartMessages(draftOwner, trimmed, options)
|
||||||
|
set({ sending: true, messages: optimistic })
|
||||||
try {
|
try {
|
||||||
sessionId = (await get().newSession({ ...get().draftContext })).id
|
sessionId = (await get().newSession({ ...get().draftContext }, optimistic)).id
|
||||||
if (typeof window.dispatchEvent === 'function') window.dispatchEvent(new CustomEvent('ycode:composer-owner-migrated', { detail: { from: draftOwner, to: sessionId } }))
|
if (typeof window.dispatchEvent === 'function') window.dispatchEvent(new CustomEvent('ycode:composer-owner-migrated', { detail: { from: draftOwner, to: sessionId } }))
|
||||||
}
|
}
|
||||||
catch (error) { set({ sending: false }); throw error }
|
catch (error) {
|
||||||
|
set((state) => ({ sending: false, messages: state.currentSessionId === null ? [] : state.messages,
|
||||||
|
error: error instanceof Error ? error.message : String(error) }))
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!sessionId || get().sendingSessions.includes(sessionId) || currentRun(get().runs, sessionId)) return
|
if (!sessionId || get().sendingSessions.includes(sessionId) || currentRun(get().runs, sessionId)) return
|
||||||
const owner = sessionId
|
const owner = sessionId
|
||||||
@@ -306,7 +333,8 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|||||||
assistantDisplay: result.assistantDisplay, createdAt: Date.now(),
|
assistantDisplay: result.assistantDisplay, createdAt: Date.now(),
|
||||||
turnStartedAt: result.userMessage.createdAt, status: 'streaming' }
|
turnStartedAt: result.userMessage.createdAt, status: 'streaming' }
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const history = state.currentSessionId === owner ? state.messages : state.sessionMessages[owner] ?? []
|
const history = (state.currentSessionId === owner ? state.messages : state.sessionMessages[owner] ?? [])
|
||||||
|
.filter((item) => !isOptimisticStartMessage(item))
|
||||||
const messages = result.messages ?? [...history, result.userMessage, placeholder]
|
const messages = result.messages ?? [...history, result.userMessage, placeholder]
|
||||||
const running = { runId: result.runId, assistantId: result.assistantMessageId, sessionId: owner }
|
const running = { runId: result.runId, assistantId: result.assistantMessageId, sessionId: owner }
|
||||||
return { sessionMessages: { ...state.sessionMessages, [owner]: messages },
|
return { sessionMessages: { ...state.sessionMessages, [owner]: messages },
|
||||||
@@ -320,8 +348,14 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|||||||
void window.ycode.session.list().then((sessions) => set({ sessions })).catch(() => undefined)
|
void window.ycode.session.list().then((sessions) => set({ sessions })).catch(() => undefined)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pendingEvents.delete(owner)
|
pendingEvents.delete(owner)
|
||||||
set((state) => ({ sendingSessions: state.sendingSessions.filter((id) => id !== owner),
|
// 首条消息发送失败:移除乐观占位(用户草稿由输入框恢复),不留下未持久化的消息。
|
||||||
...(state.currentSessionId === owner ? { sending: false, error: error instanceof Error ? error.message : String(error) } : {}) }))
|
set((state) => {
|
||||||
|
const base = state.currentSessionId === owner ? state.messages : state.sessionMessages[owner] ?? []
|
||||||
|
const cleaned = base.filter((item) => !isOptimisticStartMessage(item))
|
||||||
|
return { sessionMessages: { ...state.sessionMessages, [owner]: cleaned },
|
||||||
|
sendingSessions: state.sendingSessions.filter((id) => id !== owner),
|
||||||
|
...(state.currentSessionId === owner ? { messages: cleaned, sending: false, error: error instanceof Error ? error.message : String(error) } : {}) }
|
||||||
|
})
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -97,9 +97,11 @@ function activeBrandName(): string {
|
|||||||
|
|
||||||
export interface StreamingFooterProps {
|
export interface StreamingFooterProps {
|
||||||
locale?: string
|
locale?: string
|
||||||
|
/** 新任务启动阶段文案(原版 chat.prepare.<stage>);提供时替换「思考中」。 */
|
||||||
|
prepareText?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StreamingFooter({ locale }: StreamingFooterProps): React.JSX.Element {
|
export function StreamingFooter({ locale, prepareText }: StreamingFooterProps): React.JSX.Element {
|
||||||
const resolvedLocale = locale ?? activeLocale()
|
const resolvedLocale = locale ?? activeLocale()
|
||||||
const brandName = activeBrandName()
|
const brandName = activeBrandName()
|
||||||
const tips = useMemo(() => getSourceLoadingTips(resolvedLocale, brandName), [brandName, resolvedLocale])
|
const tips = useMemo(() => getSourceLoadingTips(resolvedLocale, brandName), [brandName, resolvedLocale])
|
||||||
@@ -112,7 +114,7 @@ export function StreamingFooter({ locale }: StreamingFooterProps): React.JSX.Ele
|
|||||||
isPaused: () => isPaused.current
|
isPaused: () => isPaused.current
|
||||||
}), [tips])
|
}), [tips])
|
||||||
|
|
||||||
const thinkingLabel = conversationLabel('message.loading.thinking', {}, resolveLoadingTipLocale(resolvedLocale) === 'en' ? 'en' : 'zh-cn')
|
const thinkingLabel = prepareText ?? conversationLabel('message.loading.thinking', {}, resolveLoadingTipLocale(resolvedLocale) === 'en' ? 'en' : 'zh-cn')
|
||||||
|
|
||||||
return <div className="wb-cr-streaming-footer">
|
return <div className="wb-cr-streaming-footer">
|
||||||
<div className="_loadingWithCredit_ttf8p_463" style={{ '--cb-assistant-inline-padding': '0px' } as CSSProperties}>
|
<div className="_loadingWithCredit_ttf8p_463" style={{ '--cb-assistant-inline-padding': '0px' } as CSSProperties}>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Recovered from REA ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb
|
||||||
|
// (WorkBuddy 5.8.2 renderer/assets/contexts-BMeWSUHk.js, SHA-256
|
||||||
|
// 5f35b7489b107e21ef452c8ee88a95265756f6fc444841eef869228bbc44768b);
|
||||||
|
// 文案表与显示逻辑见 docs/对话启动提示文案-2026-10-16.md。
|
||||||
|
// 原版按会话 prepareStatus.stage 选择 chat.prepare.<stage>;YCode 的 chat:send 在
|
||||||
|
// 主进程启动运行环境期间是单个阻塞阶段,因此发送后的占位只使用 acquiring_runtime,
|
||||||
|
// 不按字符串表臆造原版阶段顺序与持续时长。
|
||||||
|
export const CONVERSATION_PREPARE_TEXT = {
|
||||||
|
expert: '正在准备专家能力…',
|
||||||
|
reading_conversation_settings: '正在读取对话设置…',
|
||||||
|
resolving_system_prompt: '正在生成系统提示词…',
|
||||||
|
configuring_model_and_tools: '正在配置模型与工具…',
|
||||||
|
resolving_skill_requirements: '正在整理技能配置…',
|
||||||
|
resolving_plugin_requirements: '正在整理插件配置…',
|
||||||
|
preparing_environment: '正在准备运行环境…',
|
||||||
|
mcp: '正在整理连接器配置…',
|
||||||
|
checking_launch_config: '正在检查启动配置…',
|
||||||
|
acquiring_runtime: '正在启动对话运行环境…'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ConversationPrepareStage = keyof typeof CONVERSATION_PREPARE_TEXT
|
||||||
|
|
||||||
|
/** 新任务发送后、主进程回合就绪前的占位阶段(对应运行环境启动)。 */
|
||||||
|
export const PENDING_START_STAGE: ConversationPrepareStage = 'acquiring_runtime'
|
||||||
|
|
||||||
|
export function resolvePrepareText(stage: string | null | undefined): string | null {
|
||||||
|
if (!stage) return null
|
||||||
|
return stage in CONVERSATION_PREPARE_TEXT ? CONVERSATION_PREPARE_TEXT[stage as ConversationPrepareStage] : null
|
||||||
|
}
|
||||||
@@ -43,6 +43,8 @@ export interface ChatMessage {
|
|||||||
/** 本回合的终态 */
|
/** 本回合的终态 */
|
||||||
status?: 'streaming' | 'done' | 'error' | 'cancelled'
|
status?: 'streaming' | 'done' | 'error' | 'cancelled'
|
||||||
error?: string
|
error?: string
|
||||||
|
/** 渲染进程专用的启动占位阶段(新任务发送后、主进程回合就绪前);不参与持久化。 */
|
||||||
|
prepareStage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolCallRecord {
|
export interface ToolCallRecord {
|
||||||
|
|||||||
Reference in new issue
Block a user