fix: send-to-session addressing — use binding(id).session.prompt (host sendSession path) with 3-tier fallback
This commit is contained in:
@@ -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 即生效;自测留下的一次性会话请用户手动删除。
|
||||
|
||||
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
Reference in New Issue
Block a user