diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index 313968c..b74ef0d 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -265,6 +265,40 @@ const registryStore: { ids: string[]; listeners: Set<() => void> } = { ids: [], /** 自定义窗口 → 宿主会话桥(apply 时注入;不可用时 CustomPane 降级提示) */ let sessionBridge: { sessions: any; conversation: any; list: any } | null = null +/** 把文本送入指定会话:宿主同款寻址(binding(id).session.prompt / sendSession(会话面)), + * 插件是根级上下文,无作用域的 conversation.send 会报 requires a session scope,不可用。 */ +async function promptIntoSession(sessionId: string, text: string): Promise { + const b = sessionBridge + if (!b) throw new Error('bridge unavailable') + const sessions = b.sessions as any + // 新会话入列可能异步:最多等 2s 直到 binding 可解析 + let session: any = null + for (let i = 0; i < 10; i++) { + try { session = sessions?.binding?.(sessionId)?.session ?? null } catch { session = null } + if (session) break + await new Promise((r) => setTimeout(r, 200)) + } + if (session) { + // 1) 宿主包装(正确签名:会话面 + 空图片 + queue 投递) + if (typeof b.conversation?.sendSession === 'function') { + try { await b.conversation.sendSession(session, text, [], 'queue'); return } catch { /* 包装失败则直连 */ } + } + // 2) 直连会话面 prompt(宿主 sendSession 内部同款路径) + if (typeof session.prompt === 'function') { + const result = await session.prompt([{ type: 'text', text }], 'queue') + if (result && result.ok) return + if (result && !result.ok) throw new Error('session.prompt: ' + (result.error?.code ?? 'rejected') + (result.error?.message ? ': ' + result.error.message : '')) + } + } + // 3) 备用:作用域上下文里取 conversation 服务再发 + try { + const scoped = sessions?.scope?.(sessionId) + const conv = scoped?.get?.('conversation') + if (conv && typeof conv.send === 'function') { await conv.send(text); return } + } catch { /* 落入最终报错 */ } + throw new Error('no send path: session face unavailable') +} + /** 自定义窗口任务:新建一个专属会话,注入工作台背景 + 用户需求,并打开该会话 */ export async function createCustomSession(projectId: string, projectName: string, requirement: string): Promise { const b = sessionBridge @@ -283,12 +317,8 @@ export async function createCustomSession(projectId: string, projectName: string ' 该窗口位于「' + projectName + '」项目内,如需要也可协助该项目后续的其他自定义工作。', ].join('\n') const sessionId = await b.sessions.create({}) - let sent = false - if (typeof b.conversation?.sendSession === 'function') { - try { await b.conversation.sendSession(sessionId, text); sent = true } catch { sent = false } - } - try { b.sessions.open?.(sessionId) } catch {} - if (!sent) await b.conversation?.send?.(text) + try { await b.sessions.open?.(sessionId) } catch {} + await promptIntoSession(sessionId, text) } /** 把自定义需求直接发到用户选定的已有会话(默认当前会话) */ @@ -302,12 +332,8 @@ export async function sendCustomToSession(sessionId: string, projectName: string '2. 用户需求:' + requirement, '3. 请帮助完成该窗口的自定义设计(内容/页面/实现方案),该窗口位于「' + projectName + '」项目内。', ].join('\n') - let sent = false - if (typeof b.conversation?.sendSession === 'function') { - try { await b.conversation.sendSession(sessionId, text); sent = true } catch { sent = false } - } - try { b.sessions.open?.(sessionId) } catch {} - if (!sent) await b.conversation?.send?.(text) + try { await b.sessions.open?.(sessionId) } catch {} + await promptIntoSession(sessionId, text) } /** 会话作用域快照(模块级;apply 里订阅 ctx.sessions.list 写入,组件与引擎只读) */ @@ -1389,7 +1415,7 @@ export const inject = ['slots', 'locale', 'sessions', 'conversation'] export function apply(ctx: any) { // 自定义窗口 → 宿主会话桥:保存 sessions/conversation/list 服务引用(模块级) sessionBridge = { sessions: ctx.sessions ?? null, conversation: ctx.conversation ?? null, list: ctx.sessions?.list ?? null } - try { (window as any).__dshOpenSession = (id: string) => ctx.sessions?.open?.(id); (window as any).__dshSessions = ctx.sessions } catch {} + try { (window as any).__dshOpenSession = (id: string) => ctx.sessions?.open?.(id); (window as any).__dshSessions = ctx.sessions; (window as any).__dshPromptIntoSession = (id: string, text: string) => promptIntoSession(id, text) } catch {} ctx.effect(() => { diff --git a/02_process/worklogs/2026-08-16_worktable-v2.md b/02_process/worklogs/2026-08-16_worktable-v2.md index 7f3241c..4c36d81 100644 --- a/02_process/worklogs/2026-08-16_worktable-v2.md +++ b/02_process/worklogs/2026-08-16_worktable-v2.md @@ -663,3 +663,21 @@ 仓库根的 lib/,宿主仍加载 01_content/lib 旧 bundle,出现「改完不生效」假象(已删误产物)。 - 备注:纯客户端改动,F5 即生效,无需重启 dsh web。 +## 补记(修复「发送到会话」失败:conversation.send requires a session scope) + +- 用户实测报错:创建对话失败:Error: conversation.send requires a session scope — address one via + ctx.sessions.scope(id).conversation。 +- 根因:① conversation.sendSession(session, text, images, mode) 第一参数是「会话面」对象而非 id + 字符串,旧代码传 id → 静默失败;② 回退的 conversation.send 按插件自身 ctx 解析作用域, + 而 dsh-worktable 是根级插件,无会话标签 → 宿主报 requires a session scope。 +- 探针实测(读宿主 dsh-client-runtime / dsh-client-ui-conversation 源码 + 页面验证): + scope(id).conversation 属性访问被 inject 代理拦截;scope(id).get('conversation') 可用; + sessions.binding(id).session.prompt([{type:'text',text}], 'queue') 是宿主 sendSession 同款路径。 +- 修复:新增 promptIntoSession(sessionId, text)——binding 解析重试 2s → 宿主 + conversation.sendSession(会话面, text, [], 'queue') → 直连 session.prompt → scope(id).get + ('conversation').send 三级降级;createCustomSession / sendCustomToSession 统一走该函数, + open(会话) 后发送(右侧对话窗仍自动切换)。 +- 验证:send-self-test.cjs 端到端自测(新建一次性会话 session-91a6adea,promptOk=true、 + listEntry running=true 轮次启动);functional-diag 全 20 STEP ERROR_COUNT: 0。 +- 备注:纯客户端改动,F5 即生效;自测留下的一次性会话请用户手动删除。 + diff --git a/04_test/scope-probe.cjs b/04_test/scope-probe.cjs new file mode 100644 index 0000000..4f60e62 --- /dev/null +++ b/04_test/scope-probe.cjs @@ -0,0 +1,73 @@ +// scope-probe.cjs — 只读实测会话寻址路径(不发消息) +const http = require('http'); +const { spawn } = require('child_process'); +const WebSocket = require('ws'); +const PORT = 9337; +const proc = spawn('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', [ + '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', + '--window-size=1440,900', '--force-device-scale-factor=1', + '--remote-debugging-port=' + PORT, + '--user-data-dir=C:\\Users\\SJL\\AppData\\Local\\Temp\\wt-chrome-scopeprobe', + 'about:blank', +], { stdio: 'ignore' }); +const getJSON = (p) => new Promise((res, rej) => { + http.get({ host: '127.0.0.1', port: PORT, path: p }, (r) => { let d=''; r.on('data',c=>d+=c); r.on('end',()=>{try{res(JSON.parse(d))}catch(e){rej(e)}}) }).on('error', rej); +}); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +(async () => { + let list = null; + for (let i = 0; i < 40; i++) { try { list = await getJSON('/json/list'); if (list && list.length) break; } catch {} await sleep(500); } + const target = list.find((t) => t.type === 'page') || list[0]; + const ws = new WebSocket(target.webSocketDebuggerUrl); + let id = 0; const pending = new Map(); + const send = (method, params) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); }); + ws.on('message', (raw) => { const m = JSON.parse(String(raw)); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } }); + await new Promise((r) => ws.on('open', r)); + await send('Runtime.enable'); + await send('Page.navigate', { url: 'http://127.0.0.1:3080/' }); + const evaluate = async (expr) => { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); + if (r.result && r.result.exceptionDetails) return { __error: (r.result.exceptionDetails.exception || {}).description || r.result.exceptionDetails.text }; + return r.result && r.result.result ? r.result.result.value : undefined; + }; + let ready = null; + for (let i = 0; i < 30; i++) { await sleep(1000); ready = await evaluate("!!window.__dshSessions"); if (ready === true) break; } + console.log('READY:', JSON.stringify(ready)); + const step = await evaluate(`(function(){ + var out = {}; + var S = window.__dshSessions; + var snap = S.list && S.list.getSnapshot ? S.list.getSnapshot() : null; + var probeId = snap && snap.current; + out.probeId = probeId; + out.sessMethods = Object.getOwnPropertyNames(Object.getPrototypeOf(S) || {}).slice(0, 40); + // 路径 A:scope(id).conversation + var sc = null; + try { sc = S.scope(probeId); } catch(e) { out.scopeErr = e.message; } + if (sc) { + out.scopeType = typeof sc; + try { out.scopeKeys = Object.keys(sc); } catch(e) { out.scopeKeysErr = e.message; } + try { out.convType = typeof sc.conversation; } catch(e) { out.convErr = e.message; } + try { out.getConvType = typeof (sc.get ? sc.get('conversation') : null); } catch(e) { out.getConvErr = e.message; } + } + // 路径 B:binding(id).session.prompt + var b = null; + try { b = S.binding(probeId); } catch(e) { out.bindingErr = e.message; } + if (b) { + try { out.bindingKeys = Object.keys(b); } catch(e) { out.bindingKeysErr = e.message; } + try { + var sess = b.session; + out.sessionType = typeof sess; + var proto = sess && Object.getPrototypeOf(sess); + out.sessionProtoMethods = proto ? Object.getOwnPropertyNames(proto).slice(0, 40) : null; + out.hasPrompt = !!(sess && typeof sess.prompt === 'function'); + out.hasSend = !!(sess && typeof sess.send === 'function'); + } catch(e) { out.sessionErr = e.message; } + } + // 路径 C:conversation.sendSession(sessionFace, ...) + out.convSendSessionArgs = null; + var C = window.__dshSessions ? null : null; + return JSON.stringify(out); + })()`); + console.log('PROBE:', JSON.stringify(step)); + ws.close(); proc.kill(); process.exit(0); +})().catch((e) => { console.error('FATAL', e && e.stack || e); try { proc.kill(); } catch {} process.exit(1); }); diff --git a/04_test/send-self-test.cjs b/04_test/send-self-test.cjs new file mode 100644 index 0000000..b99625b --- /dev/null +++ b/04_test/send-self-test.cjs @@ -0,0 +1,65 @@ +// send-self-test.cjs — 端到端自测:新建一次性会话 → promptIntoSession 真发一条消息 → 验证送达 +// 会创建一个一次性会话并触发一次极小 LLM 轮次,跑完请用户删除该会话。 +const http = require('http'); +const { spawn } = require('child_process'); +const WebSocket = require('ws'); +const PORT = 9338; +const proc = spawn('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', [ + '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', + '--window-size=1440,900', '--force-device-scale-factor=1', + '--remote-debugging-port=' + PORT, + '--user-data-dir=C:\\Users\\SJL\\AppData\\Local\\Temp\\wt-chrome-sendtest', + 'about:blank', +], { stdio: 'ignore' }); +const getJSON = (p) => new Promise((res, rej) => { + http.get({ host: '127.0.0.1', port: PORT, path: p }, (r) => { let d=''; r.on('data',c=>d+=c); r.on('end',()=>{try{res(JSON.parse(d))}catch(e){rej(e)}}) }).on('error', rej); +}); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +(async () => { + let list = null; + for (let i = 0; i < 40; i++) { try { list = await getJSON('/json/list'); if (list && list.length) break; } catch {} await sleep(500); } + const target = list.find((t) => t.type === 'page') || list[0]; + const ws = new WebSocket(target.webSocketDebuggerUrl); + let id = 0; const pending = new Map(); + const send = (method, params) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); }); + ws.on('message', (raw) => { const m = JSON.parse(String(raw)); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } }); + await new Promise((r) => ws.on('open', r)); + await send('Runtime.enable'); + await send('Page.navigate', { url: 'http://127.0.0.1:3080/' }); + const evaluate = async (expr) => { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); + if (r.result && r.result.exceptionDetails) return { __error: (r.result.exceptionDetails.exception || {}).description || r.result.exceptionDetails.text }; + return r.result && r.result.result ? r.result.result.value : undefined; + }; + let ready = null; + for (let i = 0; i < 30; i++) { await sleep(1000); ready = await evaluate("!!window.__dshPromptIntoSession"); if (ready === true) break; } + console.log('READY:', JSON.stringify(ready)); + const step = await evaluate(`(async function(){ + var out = {}; + var S = window.__dshSessions; + try { + var newId = await S.create({}); + out.newId = newId; + try { await S.open(newId); } catch(e) { out.openErr = e.message; } + var sentinel = '【dsh-worktable 自动测试】这是一条自测消息,请只回复:测试通过'; + var t0 = Date.now(); + try { await window.__dshPromptIntoSession(newId, sentinel); out.promptOk = true; } catch(e) { out.promptErr = e && e.message; } + out.promptMs = Date.now() - t0; + // 送达证据:列表快照里该会话 running=true(轮次已启动) + var snap = S.list.getSnapshot(); + var entry = snap.byId && snap.byId[newId]; + out.listEntry = entry ? { running: entry.running, blank: entry.blank, title: entry.displayTitle } : null; + // 会话面快照:找哨兵文本 + var binding = S.binding(newId); + var sess = binding && binding.session; + out.hasSession = !!sess; + var raw = ''; + try { var ss = sess.getSnapshot(); raw = JSON.stringify(ss).slice(0, 4000); out.snapKeys = ss ? Object.keys(ss) : null; } catch(e) { out.snapErr = e.message; } + out.sentinelInSnapshot = raw.indexOf('自测消息') >= 0; + out.snapPreview = raw.slice(0, 300); + } catch(e) { out.fatal = e && e.message; } + return JSON.stringify(out); + })()`); + console.log('SELFTEST:', JSON.stringify(step)); + ws.close(); proc.kill(); process.exit(0); +})().catch((e) => { console.error('FATAL', e && e.stack || e); try { proc.kill(); } catch {} process.exit(1); });