fix: send-to-session addressing — use binding(id).session.prompt (host sendSession path) with 3-tier fallback

This commit is contained in:
Aisland
2026-08-18 23:10:50 +08:00
parent c60133bc88
commit fa1a0b3866
4 changed files with 195 additions and 13 deletions
+39 -13
View File
@@ -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<void> {
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<void> {
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(() => {
@@ -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 即生效;自测留下的一次性会话请用户手动删除。
+73
View File
@@ -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); });
+65
View File
@@ -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); });