dsh-plugin-deck-studio 0.2.13 → 0.2.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client.js +494 -8
- package/index.js +81 -7
- package/lib/deck-templates.js +6 -1
- package/package.json +2 -2
- package/ui/workbench.css +26 -1
- package/ui/workbench.html +290 -26
package/client.js
CHANGED
|
@@ -29,6 +29,14 @@ window.__ModuleLoader__.load({
|
|
|
29
29
|
const WORKBENCH_KEY = "deck-studio-workbench";
|
|
30
30
|
const ROUTE_PREFIX = "/api/deck-studio.";
|
|
31
31
|
const POLL_MS = 5000;
|
|
32
|
+
/**
|
|
33
|
+
* F-202B(ADR-0027):桌面宿主的 shell 起源是自定义协议(如 dsh-app://),嵌 DSH 页面的
|
|
34
|
+
* 子帧拿不到 boot 注入、流式端点也不可达 —— 桌面里对话改用「原生会话 Dock」;
|
|
35
|
+
* web(http)宿主继续走工作台内嵌的 DSH iframe。
|
|
36
|
+
*/
|
|
37
|
+
const DESKTOP_SHELL = !/^(https?:)$/.test(location.protocol);
|
|
38
|
+
/** 桌面 Dock 渲染会话用的子弹槽名(shell.overlay 条目下的 session 作用域子槽)。 */
|
|
39
|
+
const CHAT_SLOT = `${WORKBENCH_KEY}.chat`;
|
|
32
40
|
|
|
33
41
|
/** 路由白名单:getJson 只放行这一族地址。 */
|
|
34
42
|
async function getJson(path) {
|
|
@@ -274,9 +282,21 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
|
|
|
274
282
|
* 打开先到主页(#/home/decks),点 deck 后是左中右三栏:左原生 Chat / 中间整页图片 / 右资料库。
|
|
275
283
|
* 对话仍走 DSH 原生 Chat(工作台左栏嵌 `/?decklineChat=1`),权限、附件、模型、思考过程、气泡都是原生的。
|
|
276
284
|
*/
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* 桌面 Dock(F-202B / ADR-0027 升级):桌面里不再嵌 DSH 页面,用宿主 `shell.overlay` +
|
|
288
|
+
* SessionProvider + `sessions.retain` 把**原生会话**渲染到工作台对话列的位置
|
|
289
|
+
* (multichat / html-deck-studio 同款做法,注册形状已在该宿主验证)。
|
|
290
|
+
* 工作台(iframe)把当前绑定会话经 `dsh-deck-studio:chat-session` 报上来;
|
|
291
|
+
* Dock 测量工作台 iframe 里 `#chatDockArea` 的矩形后固定覆盖其上。
|
|
292
|
+
*/
|
|
293
|
+
/** 客户端插件体:样式 + 槽位注册。 */
|
|
294
|
+
function apply(ctx) {
|
|
295
|
+
injectStylesheet();
|
|
277
296
|
function WorkbenchFrame() {
|
|
278
297
|
return h("iframe", {
|
|
279
298
|
id: "dsh-deck-workbench-frame",
|
|
299
|
+
ref: (node) => setWorkbenchFrame(node),
|
|
280
300
|
title: "Deckline 工作台",
|
|
281
301
|
src: "/api/deck-studio.app#/home/decks",
|
|
282
302
|
style: { width: "100%", height: "100%", border: 0, display: "block", background: "#faf8f2" },
|
|
@@ -286,9 +306,156 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
|
|
|
286
306
|
});
|
|
287
307
|
}
|
|
288
308
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
309
|
+
const dockState = { sessionId: "", listeners: new Set() };
|
|
310
|
+
function publishDockSession(sessionId) {
|
|
311
|
+
const next = typeof sessionId === "string" ? sessionId : "";
|
|
312
|
+
if (dockState.sessionId === next) return;
|
|
313
|
+
dockState.sessionId = next;
|
|
314
|
+
for (const listener of dockState.listeners) listener();
|
|
315
|
+
}
|
|
316
|
+
function subscribeDockSession(listener) {
|
|
317
|
+
dockState.listeners.add(listener);
|
|
318
|
+
return () => dockState.listeners.delete(listener);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** 工作台 iframe 元素引用(React ref);Dock 靠它测矩形 / 报「Dock 已就绪」。 */
|
|
322
|
+
const frameState = { el: null, listeners: new Set() };
|
|
323
|
+
function setWorkbenchFrame(node) {
|
|
324
|
+
const next = node ?? null;
|
|
325
|
+
if (frameState.el === next) return;
|
|
326
|
+
frameState.el = next;
|
|
327
|
+
for (const listener of frameState.listeners) listener();
|
|
328
|
+
}
|
|
329
|
+
function subscribeWorkbenchFrame(listener) {
|
|
330
|
+
frameState.listeners.add(listener);
|
|
331
|
+
return () => frameState.listeners.delete(listener);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** 会话内容视图:复用宿主 `conversation.session` 槽(不是复制品)。 */
|
|
335
|
+
function DeckChatView(props) {
|
|
336
|
+
return props.renderSlot("conversation.session", { view: "chat" });
|
|
337
|
+
}
|
|
338
|
+
function DeckChatBody({ useSession, useConversation, renderFactorySlot }) {
|
|
339
|
+
const session = useSession((value) => value);
|
|
340
|
+
const active = useConversation((value) => value).activeTargets;
|
|
341
|
+
const phase = (!session.blank || session.running || active.size > 0) ? "active" : "hero";
|
|
342
|
+
/**
|
|
343
|
+
* 0.2.23:**回退** 0.2.22 的 sessionId 透传。
|
|
344
|
+
*
|
|
345
|
+
* 为什么当时要透传:宿主的 composer 链是
|
|
346
|
+
* useSessionStatus(s => s.get(sessionId)?.pendingInteraction) → renderSlotChain(...)
|
|
347
|
+
* 审批面板要靠 pendingInteraction 才渲染,我们没传 sessionId 就永远不弹 ask。
|
|
348
|
+
*
|
|
349
|
+
* 为什么回退:透传之后真机上「点会话 → 工作台闪出 → 不到 1s 整片变灰」——
|
|
350
|
+
* 说明在 shell.overlay 这个上下文里让 composer 链去查宿主的主区 pendingInteraction 会直接崩掉
|
|
351
|
+
* 整棵 Dock 子树(外面看不到原因,只看到灰屏)。宁可先保住对话可用,再想别的办法把 ask 露出来。
|
|
352
|
+
*/
|
|
353
|
+
return renderFactorySlot("conversation.content", { variant: "embedded", phase, hero: phase === "hero" }, { slots: { views: DeckChatView } });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* 0.2.23:Dock 子树的错误边界。
|
|
358
|
+
*
|
|
359
|
+
* 之前 slot 里任何一个组件抛错,整个 overlay 会变成一片空白灰,用户完全不知道发生了什么
|
|
360
|
+
* (这次灰屏就是)。用边界把错误收住,至少把原因写在屏幕上 + 给重试和「去主区对话」的出路。
|
|
361
|
+
*/
|
|
362
|
+
class DockErrorBoundary extends React.Component {
|
|
363
|
+
constructor(props) {
|
|
364
|
+
super(props);
|
|
365
|
+
this.state = { error: null };
|
|
366
|
+
}
|
|
367
|
+
static getDerivedStateFromError(error) {
|
|
368
|
+
return { error };
|
|
369
|
+
}
|
|
370
|
+
componentDidCatch(error) {
|
|
371
|
+
try {
|
|
372
|
+
ctx.logger?.warn?.("deck-studio: chat dock crashed", error);
|
|
373
|
+
} catch {
|
|
374
|
+
/* 日志不可用时忽略 */
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
render() {
|
|
378
|
+
if (!this.state.error) return this.props.children;
|
|
379
|
+
const message = String(this.state.error?.message ?? this.state.error ?? "未知错误");
|
|
380
|
+
return h(
|
|
381
|
+
"div",
|
|
382
|
+
{ style: { height: "100%", padding: 16, overflow: "auto", background: "#fdf7f3", color: "#7a3b2e", fontSize: 12, lineHeight: 1.7 } },
|
|
383
|
+
h("div", { style: { fontWeight: 600, marginBottom: 6 } }, "对话区加载失败"),
|
|
384
|
+
h("div", { style: { color: "#5c6464", wordBreak: "break-word" } }, message),
|
|
385
|
+
h(
|
|
386
|
+
"button",
|
|
387
|
+
{
|
|
388
|
+
type: "button",
|
|
389
|
+
style: { marginTop: 10, marginRight: 8, padding: "4px 10px", fontSize: 12, cursor: "pointer" },
|
|
390
|
+
onClick: () => this.setState({ error: null }),
|
|
391
|
+
},
|
|
392
|
+
"重试",
|
|
393
|
+
),
|
|
394
|
+
h("div", { style: { marginTop: 10, color: "#5c6464" } }, "也可以点顶栏「☰ DSH」去宿主主区的对话里继续。"),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/** Dock:测量工作台对话占位矩形;用 retain 的会话引用喂 SessionProvider。 */
|
|
399
|
+
function DeckChatDock({ SessionProvider, renderSlot }) {
|
|
400
|
+
const sessionId = React.useSyncExternalStore(subscribeDockSession, () => dockState.sessionId);
|
|
401
|
+
// 主区面板卸载/重建时 iframe 元素会换,Dock 必须跟着重算位置,否则切走再切回来就不见了。
|
|
402
|
+
const frameEl = React.useSyncExternalStore(subscribeWorkbenchFrame, () => frameState.el);
|
|
403
|
+
const [rect, setRect] = React.useState(null);
|
|
404
|
+
const [reference, setReference] = React.useState(null);
|
|
405
|
+
React.useEffect(() => {
|
|
406
|
+
const measure = () => {
|
|
407
|
+
let next = null;
|
|
408
|
+
try {
|
|
409
|
+
const outer = frameEl?.getBoundingClientRect?.();
|
|
410
|
+
// 只盖住对话框的内容区(#chatDockArea),把上面的绑定/+/会话菜单那一行留出来。
|
|
411
|
+
const inner = frameEl?.contentDocument?.querySelector("#chatDockArea")?.getBoundingClientRect?.();
|
|
412
|
+
if (outer && inner && outer.width > 0 && inner.width > 4 && inner.height > 4) {
|
|
413
|
+
next = {
|
|
414
|
+
left: Math.round(outer.left + inner.left),
|
|
415
|
+
top: Math.round(outer.top + inner.top),
|
|
416
|
+
width: Math.round(inner.width),
|
|
417
|
+
height: Math.round(inner.height),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
} catch {
|
|
421
|
+
next = null;
|
|
422
|
+
}
|
|
423
|
+
setRect((prev) => (prev && next && prev.left === next.left && prev.top === next.top && prev.width === next.width && prev.height === next.height) ? prev : next);
|
|
424
|
+
};
|
|
425
|
+
measure();
|
|
426
|
+
if (!frameEl) { setRect(null); return undefined; }
|
|
427
|
+
const timer = setInterval(measure, 800);
|
|
428
|
+
window.addEventListener("resize", measure);
|
|
429
|
+
return () => { clearInterval(timer); window.removeEventListener("resize", measure); };
|
|
430
|
+
}, [frameEl]);
|
|
431
|
+
// 引用放 state(不是 ref):StrictMode 的 effect 重放与 ref 释放会让提交渲染时 ref 为空。
|
|
432
|
+
React.useEffect(() => {
|
|
433
|
+
const sessions = ctx.sessions;
|
|
434
|
+
if (!sessionId || typeof sessions?.retain !== "function") { setReference(null); return undefined; }
|
|
435
|
+
let held = null;
|
|
436
|
+
try { held = sessions.retain(sessionId, { source: WORKBENCH_KEY }); } catch (error) { ctx.logger?.warn?.(`deck-studio: retain failed: ${error?.message ?? error}`); }
|
|
437
|
+
setReference(held ?? null);
|
|
438
|
+
return () => { try { held?.release?.(); } catch { /* 已释放 */ } };
|
|
439
|
+
}, [sessionId]);
|
|
440
|
+
// Dock 已在渲染:告诉工作台 iframe 收起降级卡(#chatHostCard)。
|
|
441
|
+
React.useEffect(() => {
|
|
442
|
+
if (!rect || !reference || !frameEl?.contentWindow) return;
|
|
443
|
+
try { frameEl.contentWindow.postMessage({ type: "dsh-deck-studio:chat-dock" }, "*"); } catch { /* 不可达时降级卡留着 */ }
|
|
444
|
+
}, [rect, reference]);
|
|
445
|
+
if (!SessionProvider || !renderSlot || !reference || !rect) return null;
|
|
446
|
+
// 0.2.23:把覆盖层夹在可视区内。测量一旦拿到「整窗」尺寸(面板刚切换、iframe 还没布局),
|
|
447
|
+
// 这个 fixed 覆盖层会把整个应用盖住,看起来就是「整片灰、啥也没有」。
|
|
448
|
+
const viewportW = typeof window !== "undefined" ? window.innerWidth : rect.width;
|
|
449
|
+
const viewportH = typeof window !== "undefined" ? window.innerHeight : rect.height;
|
|
450
|
+
const left = Math.max(0, Math.min(rect.left, Math.max(0, viewportW - 120)));
|
|
451
|
+
const top = Math.max(0, Math.min(rect.top, Math.max(0, viewportH - 120)));
|
|
452
|
+
const width = Math.max(120, Math.min(rect.width, viewportW - left));
|
|
453
|
+
const height = Math.max(160, Math.min(rect.height, viewportH - top));
|
|
454
|
+
return h("div", {
|
|
455
|
+
"data-deck-studio-chat-dock": "",
|
|
456
|
+
style: { position: "fixed", left: `${left}px`, top: `${top}px`, width: `${width}px`, height: `${height}px`, zIndex: 40, display: "flex", flexDirection: "column", minHeight: 0, overflow: "hidden", background: "var(--paper,#faf8f2)", borderRight: "1px solid var(--line,#e8e2d6)", pointerEvents: "auto" },
|
|
457
|
+
}, h(DockErrorBoundary, null, h(SessionProvider, { session: reference }, renderSlot(CHAT_SLOT, {}))));
|
|
458
|
+
}
|
|
292
459
|
// 工作台左栏会把 DSH 原生 Chat 以 `/?decklineChat=1` 嵌进来(同源 iframe)。
|
|
293
460
|
// 那个嵌套的 DSH 实例不再自动打开工作台,否则会层层嵌套(F-68)。
|
|
294
461
|
const chatEmbed = new URLSearchParams(window.location.search).get("decklineChat") === "1";
|
|
@@ -318,6 +485,140 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
|
|
|
318
485
|
};
|
|
319
486
|
const closeWorkbench = () => layout?.selectPanel?.(null);
|
|
320
487
|
|
|
488
|
+
/**
|
|
489
|
+
* 0.2.17:从 DSH 侧栏的会话直接拉起 Deckline 工作区(对齐 html-deck-studio 的同款交互)。
|
|
490
|
+
*
|
|
491
|
+
* 宿主把「当前主区会话」记在 `sessions.list` 的 `retainedBy.mainView` 上。切到一本
|
|
492
|
+
* 已绑定 deck / design 的会话时,自动打开工作台并切到那本 deck —— 省掉「先点 Deckline
|
|
493
|
+
* 面板、再选 deck」两步;会话行「⋯」菜单里也给一个显式入口。
|
|
494
|
+
*/
|
|
495
|
+
const activeMainSessionId = () => {
|
|
496
|
+
try {
|
|
497
|
+
const list = ctx.sessions?.list?.getSnapshot?.();
|
|
498
|
+
if (!list?.byId) return "";
|
|
499
|
+
return Object.values(list.byId).find((row) => (row?.retainedBy?.mainView ?? 0) > 0)?.id ?? "";
|
|
500
|
+
} catch {
|
|
501
|
+
return "";
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
const activePanelIdOf = () => {
|
|
505
|
+
try {
|
|
506
|
+
return layout?.panelInfo?.getSnapshot?.()?.activePanelId ?? null;
|
|
507
|
+
} catch {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
/** 会话 → 绑定实体(服务端绑定表;3s 内复用,列表频繁变化时不重复打接口)。 */
|
|
512
|
+
let bindingsCache = { at: 0, rows: [] };
|
|
513
|
+
const readSessionBindings = async () => {
|
|
514
|
+
if (Date.now() - bindingsCache.at < 3000) return bindingsCache.rows;
|
|
515
|
+
try {
|
|
516
|
+
const res = await fetch("/api/deck-studio.session-bindings", { headers: { accept: "application/json" } });
|
|
517
|
+
const data = await res.json();
|
|
518
|
+
bindingsCache = { at: Date.now(), rows: Array.isArray(data?.bindings) ? data.bindings : [] };
|
|
519
|
+
} catch {
|
|
520
|
+
bindingsCache = { at: Date.now(), rows: bindingsCache.rows };
|
|
521
|
+
}
|
|
522
|
+
return bindingsCache.rows;
|
|
523
|
+
};
|
|
524
|
+
/**
|
|
525
|
+
* 待发往工作台 iframe 的「打开某个 deck / design」指令。
|
|
526
|
+
*
|
|
527
|
+
* 0.2.21:发出去**不清空**——面板刚挂载时 iframe 里的脚本还没起来,早发的那几帧会被丢掉,
|
|
528
|
+
* 表现成「工作区弹出来了但停在首页」。现在等 iframe 回 `open-binding-ack` 才算送达,
|
|
529
|
+
* 期间由 ready 消息 + 多档定时重试兜底;20s 还没 ack 就放弃,避免下次误打开旧 deck。
|
|
530
|
+
*/
|
|
531
|
+
let pendingOpen = null;
|
|
532
|
+
const flushPendingOpen = () => {
|
|
533
|
+
const pending = pendingOpen;
|
|
534
|
+
const win = frameState.el?.contentWindow;
|
|
535
|
+
if (!pending || !win) return;
|
|
536
|
+
try {
|
|
537
|
+
win.postMessage({ type: "dsh-deck-studio:open-binding", ...pending }, "*");
|
|
538
|
+
} catch {
|
|
539
|
+
/* iframe 还没就绪:等 ready 消息或下一次重试 */
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
const scheduleFlush = () => {
|
|
543
|
+
[60, 220, 600, 1400, 2600, 4200].forEach((delay) => window.setTimeout(flushPendingOpen, delay));
|
|
544
|
+
};
|
|
545
|
+
const openBindingInWorkbench = (target) => {
|
|
546
|
+
if (!target || (!target.id && !target.notice)) return false;
|
|
547
|
+
openWorkbench();
|
|
548
|
+
pendingOpen = { kind: target.kind ?? "deck", id: target.id ?? "", title: target.title ?? "", notice: target.notice ?? "" };
|
|
549
|
+
flushPendingOpen();
|
|
550
|
+
scheduleFlush();
|
|
551
|
+
const expected = pendingOpen;
|
|
552
|
+
window.setTimeout(() => {
|
|
553
|
+
if (pendingOpen === expected) pendingOpen = null;
|
|
554
|
+
}, 20000);
|
|
555
|
+
return true;
|
|
556
|
+
};
|
|
557
|
+
/** 侧栏点了一个会话:已绑定就直接进那本 deck;没绑定就进工作台并说明怎么绑。 */
|
|
558
|
+
const openSessionInWorkbench = async (sessionId, options = {}) => {
|
|
559
|
+
const id = String(sessionId ?? "").trim();
|
|
560
|
+
if (!id) return false;
|
|
561
|
+
const rows = await readSessionBindings();
|
|
562
|
+
const binding = rows.find((row) => row?.sessionId === id) ?? null;
|
|
563
|
+
if (!binding) {
|
|
564
|
+
if (options.auto) return false;
|
|
565
|
+
return openBindingInWorkbench({
|
|
566
|
+
kind: "none",
|
|
567
|
+
id: "",
|
|
568
|
+
notice: "这个会话还没有绑定 deck / 风格:先选一本 deck,用「+」新建或切换绑定。",
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
return openBindingInWorkbench({
|
|
572
|
+
kind: binding.kind === "design" ? "design" : "deck",
|
|
573
|
+
id: binding.id,
|
|
574
|
+
title: binding.title ?? "",
|
|
575
|
+
});
|
|
576
|
+
};
|
|
577
|
+
/** 会话行「⋯」菜单项:在 Deckline 里打开这个会话绑定的 deck(未绑定给可见提示)。 */
|
|
578
|
+
function OpenDecklineMenuItem({ sessionId, useMenuOpenState }) {
|
|
579
|
+
const menu = typeof useMenuOpenState === "function" ? useMenuOpenState : () => [false, () => {}];
|
|
580
|
+
const [, setMenuOpen] = menu();
|
|
581
|
+
const [busy, setBusy] = React.useState(false);
|
|
582
|
+
const label = busy ? "正在打开 Deckline…" : "在 Deckline 打开";
|
|
583
|
+
const onSelect = () => {
|
|
584
|
+
try {
|
|
585
|
+
setMenuOpen(false);
|
|
586
|
+
} catch {
|
|
587
|
+
/* 菜单状态不可用时仍然执行动作 */
|
|
588
|
+
}
|
|
589
|
+
if (busy) return;
|
|
590
|
+
setBusy(true);
|
|
591
|
+
void openSessionInWorkbench(sessionId).finally(() => setBusy(false));
|
|
592
|
+
};
|
|
593
|
+
return h(
|
|
594
|
+
"button",
|
|
595
|
+
{
|
|
596
|
+
type: "button",
|
|
597
|
+
role: "menuitem",
|
|
598
|
+
"aria-label": label,
|
|
599
|
+
"data-deck-studio-open": "",
|
|
600
|
+
disabled: busy,
|
|
601
|
+
style: {
|
|
602
|
+
width: "100%",
|
|
603
|
+
minHeight: 32,
|
|
604
|
+
padding: "0 12px",
|
|
605
|
+
fontSize: 13,
|
|
606
|
+
display: "flex",
|
|
607
|
+
alignItems: "center",
|
|
608
|
+
justifyContent: "flex-start",
|
|
609
|
+
textAlign: "left",
|
|
610
|
+
border: 0,
|
|
611
|
+
background: "transparent",
|
|
612
|
+
color: "var(--dsw-alias-label-primary)",
|
|
613
|
+
borderRadius: "var(--dsw-radius-sm)",
|
|
614
|
+
cursor: busy ? "default" : "pointer",
|
|
615
|
+
},
|
|
616
|
+
onClick: onSelect,
|
|
617
|
+
},
|
|
618
|
+
label,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
321
622
|
const currentSessionId = () => {
|
|
322
623
|
try {
|
|
323
624
|
const raw = window.localStorage.getItem("dsh.sessions.current");
|
|
@@ -330,7 +631,20 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
|
|
|
330
631
|
const sessions = ctx.sessions;
|
|
331
632
|
if (!sessions?.scope) throw new Error("sessions service unavailable");
|
|
332
633
|
if (!sessionId) throw new Error("no target session");
|
|
333
|
-
|
|
634
|
+
/**
|
|
635
|
+
* 0.2.20:刚建出来的会话还没被 Dock retain 过,`sessions.scope()` 就是 null——
|
|
636
|
+
* 以前直接抛 `session … is not retained`,于是「新建 deck 后点发送」整条链路失败:
|
|
637
|
+
* 会话建出来了却没绑定、工作区也不弹。这里先自己 retain 一次再取 scope。
|
|
638
|
+
*/
|
|
639
|
+
let scoped = sessions.scope(sessionId);
|
|
640
|
+
if (!scoped && typeof sessions.retain === "function") {
|
|
641
|
+
try {
|
|
642
|
+
sessions.retain(sessionId, { source: WORKBENCH_KEY });
|
|
643
|
+
} catch (error) {
|
|
644
|
+
ctx.logger?.warn?.(`deck-studio: retain before send failed: ${error?.message ?? error}`);
|
|
645
|
+
}
|
|
646
|
+
scoped = sessions.scope(sessionId);
|
|
647
|
+
}
|
|
334
648
|
if (!scoped) throw new Error(`session ${sessionId} is not retained`);
|
|
335
649
|
// 会话级上下文需要显式 inject 才能拿到 conversation 服务。
|
|
336
650
|
await new Promise((resolve, reject) => {
|
|
@@ -431,11 +745,37 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
|
|
|
431
745
|
// 不把插件安装目录或 DSH 私有数据目录登记成用户工作区。
|
|
432
746
|
const path = normalize(cwd);
|
|
433
747
|
const internal = /(^|\/)node_modules(\/|$)|(^|\/)\.dsh(\/|$)/.test(path);
|
|
434
|
-
|
|
748
|
+
// F-202B:桌面宿主里 shell 的 process.cwd 是 App 包目录(/Applications/DeepSeek Harness.app)
|
|
749
|
+
// ——把它登记成用户工作区是错的(web 宿主的 cwd 才是启动目录)。这类系统路径视为不可信,
|
|
750
|
+
// 落到 sourceRoot / 默认工作区兜底,而不是新建一个垃圾工作区。
|
|
751
|
+
const systemPath = path === "/" || path.startsWith("/Applications/") || /^(\/System(\/|$)|\/Library(\/|$)|\/private\/var|\/usr\/|\/opt\/|\/Volumes\/)/.test(path);
|
|
752
|
+
if (path && !internal && !systemPath) return register(cwd);
|
|
435
753
|
}
|
|
436
754
|
const sourceRoot = String(data.sourceRoot ?? "").trim();
|
|
437
755
|
const sourceWorkspace = sourceRoot ? containing(sourceRoot) : null;
|
|
438
756
|
if (sourceWorkspace) return sourceWorkspace.workspaceId;
|
|
757
|
+
/**
|
|
758
|
+
* 0.2.16:deck 自己的项目目录(工作台载荷里的 sessionWorkspacePath)。
|
|
759
|
+
* 桌面宿主里上面几级会全落空——cwd 是 profile 目录被守卫挡掉、源目录还没登记成工作区、
|
|
760
|
+
* 客户端 workspaces 服务也没有 initializeDefault——结果是「从没有会话的 deck」
|
|
761
|
+
* 永远建不出会话、也就永远绑不上(点了 + 没反应)。这里按 deck 项目目录建/复用工作区,
|
|
762
|
+
* 保证每本 deck 都有落点;工作区名只是侧栏文案,命名失败不影响建会话。
|
|
763
|
+
*/
|
|
764
|
+
const projectPath = String(data.sessionWorkspacePath ?? "").trim();
|
|
765
|
+
if (projectPath) {
|
|
766
|
+
const existing = containing(projectPath);
|
|
767
|
+
if (existing) return existing.workspaceId;
|
|
768
|
+
const projectWorkspaceId = await register(projectPath);
|
|
769
|
+
const title = String(data.title ?? "").trim().slice(0, 60);
|
|
770
|
+
if (title && typeof workspaces.rename === "function") {
|
|
771
|
+
try {
|
|
772
|
+
await workspaces.rename(projectWorkspaceId, `Deckline · ${title}`);
|
|
773
|
+
} catch {
|
|
774
|
+
/* 工作区重名等失败只影响侧栏文案,会话照建 */
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return projectWorkspaceId;
|
|
778
|
+
}
|
|
439
779
|
const fallback = await workspaces.initializeDefault?.();
|
|
440
780
|
if (fallback?.workspaceId) return fallback.workspaceId;
|
|
441
781
|
throw new Error("无法确定启动工作区,未创建会话;请配置 workspacePath 后重试");
|
|
@@ -486,11 +826,37 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
|
|
|
486
826
|
let sessionId = String(data.sessionId ?? "").trim();
|
|
487
827
|
if (data.action === "ensure") sessionId = await ensureConversationSession(data);
|
|
488
828
|
if (!sessionId) throw new Error("没有目标会话");
|
|
489
|
-
|
|
829
|
+
/**
|
|
830
|
+
* F-202B(ADR-0027):`open !== false` = 把会话切到宿主主区(web 内嵌 iframe 的原语义,
|
|
831
|
+
* 桌面「在主区打开」的显式入口);`open:false` = 桌面 Dock 模式——会话已在工作台
|
|
832
|
+
* 左栏原生渲染,只把会话准备好并回 id,不抢宿主主区。会话目录未就绪时短重试
|
|
833
|
+
* (`sessions.retain: unknown session...` 是自愈错误,不是会话丢失)。
|
|
834
|
+
*/
|
|
835
|
+
if ((data.action === "open" || data.action === "ensure") && data.open !== false) {
|
|
836
|
+
const delays = [0, 300, 700, 1500, 2500];
|
|
837
|
+
let lastError = null;
|
|
838
|
+
for (const delay of delays) {
|
|
839
|
+
if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
|
|
840
|
+
try { await uiWorkspace.openSession(sessionId); lastError = null; break; } catch (error) {
|
|
841
|
+
lastError = error;
|
|
842
|
+
if (!/sessions\.retain|unknown session/i.test(String(error?.message ?? error))) throw error;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (lastError) throw lastError;
|
|
846
|
+
if (data.takeMain === true && DESKTOP_SHELL) closeWorkbench();
|
|
847
|
+
}
|
|
848
|
+
let notice = "";
|
|
490
849
|
if (data.prompt && data.action === "ensure" && sessionId !== String(data.sessionId ?? "")) {
|
|
491
|
-
|
|
850
|
+
// 0.2.20:首条指令发不出去不该让整条 ensure 失败——会话已经建好,
|
|
851
|
+
// 绑定必须照写(否则 deck 变「未绑定」,工作区也不会自动弹出来)。
|
|
852
|
+
try {
|
|
853
|
+
await sendToSession(sessionId, String(data.prompt));
|
|
854
|
+
} catch (error) {
|
|
855
|
+
notice = `会话已建好并绑定,但首条指令没发出去:${error?.message ?? error};在工作台里再发一次即可。`;
|
|
856
|
+
ctx.logger?.warn?.(`deck-studio: prompt send failed after ensure: ${error?.message ?? error}`);
|
|
857
|
+
}
|
|
492
858
|
}
|
|
493
|
-
reply({ ok: true, sessionId });
|
|
859
|
+
reply({ ok: true, sessionId, ...(notice ? { notice } : {}) });
|
|
494
860
|
} catch (error) {
|
|
495
861
|
ctx.logger?.warn?.("deck-studio: conversation bridge failed", error);
|
|
496
862
|
reply({ ok: false, reason: String(error?.message ?? error) });
|
|
@@ -500,6 +866,39 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
|
|
|
500
866
|
return () => window.removeEventListener("message", onMessage);
|
|
501
867
|
}, "deck-studio: conversation bridge");
|
|
502
868
|
|
|
869
|
+
/* 桌面 Dock 的会话源:工作台(iframe)周期性把当前绑定会话报上来。 */
|
|
870
|
+
ctx.effect(() => {
|
|
871
|
+
const onMessage = (event) => {
|
|
872
|
+
const data = event?.data ?? {};
|
|
873
|
+
if (data?.type !== "dsh-deck-studio:chat-session" || typeof data.sessionId !== "string") return;
|
|
874
|
+
if (event.source !== (frameState.el?.contentWindow ?? null)) return;
|
|
875
|
+
publishDockSession(data.sessionId);
|
|
876
|
+
};
|
|
877
|
+
window.addEventListener("message", onMessage);
|
|
878
|
+
return () => window.removeEventListener("message", onMessage);
|
|
879
|
+
}, "deck-studio: chat-session bridge");
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* F-202B:桌面「原生会话 Dock」——shell.overlay 容器 + session 作用域内容槽
|
|
883
|
+
* (multichat / html-deck-studio 同款注册形状,SessionProvider 由宿主注入)。
|
|
884
|
+
* web(http 起源)不用 Dock:工作台 iframe 里的 DSH 对话本来就是好的。
|
|
885
|
+
*/
|
|
886
|
+
if (DESKTOP_SHELL) {
|
|
887
|
+
ctx.effect(
|
|
888
|
+
() => ctx.slots.inject("shell.overlay", () => ctx.slots.register({
|
|
889
|
+
name: "shell.overlay",
|
|
890
|
+
id: `${WORKBENCH_KEY}.chat`,
|
|
891
|
+
order: 58,
|
|
892
|
+
children: { [CHAT_SLOT]: { kind: "single", scope: "session" } },
|
|
893
|
+
}, DeckChatDock)),
|
|
894
|
+
"deck-studio: chat dock",
|
|
895
|
+
);
|
|
896
|
+
ctx.effect(
|
|
897
|
+
() => ctx.slots.inject(CHAT_SLOT, () => ctx.slots.register({ name: CHAT_SLOT }, DeckChatBody)),
|
|
898
|
+
"deck-studio: chat dock body",
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
|
|
503
902
|
if (!chatEmbed) {
|
|
504
903
|
/* 工作台顶栏「☰ DSH」→ 宿主:临时展开 / 收起左侧栏(需要看会话、插件、设置时用)。 */
|
|
505
904
|
ctx.effect(() => {
|
|
@@ -560,6 +959,78 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
|
|
|
560
959
|
return () => window.clearTimeout(timer);
|
|
561
960
|
}, "deck-studio: auto-open workbench");
|
|
562
961
|
|
|
962
|
+
/**
|
|
963
|
+
* 0.2.17:切到「已绑定 deck / design 的会话」时自动拉起工作区。
|
|
964
|
+
* 只在会话真的换了、且主区没有停在别的插件面板时动作;启动时先只记住当前会话不抢视图,
|
|
965
|
+
* 工作台自己打开的那一下不算用户切换(pendingOpen / 自身面板判定都挡掉)。
|
|
966
|
+
*/
|
|
967
|
+
ctx.effect(() => {
|
|
968
|
+
const store = ctx.sessions?.list;
|
|
969
|
+
if (!store?.subscribe) return undefined;
|
|
970
|
+
let primed = false;
|
|
971
|
+
let lastActive = "";
|
|
972
|
+
let timer = null;
|
|
973
|
+
let token = 0;
|
|
974
|
+
const onChange = () => {
|
|
975
|
+
const list = store.getSnapshot?.();
|
|
976
|
+
if (!list || list.phase !== "ready") return;
|
|
977
|
+
const active = activeMainSessionId();
|
|
978
|
+
if (!primed) {
|
|
979
|
+
primed = true;
|
|
980
|
+
lastActive = active;
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
if (active === lastActive) return;
|
|
984
|
+
lastActive = active;
|
|
985
|
+
if (!active) return;
|
|
986
|
+
// 会话变化的那一刻取面板:宿主随后会 selectPanel(null),晚一步就分不清原来停在哪。
|
|
987
|
+
const panelAtChange = activePanelIdOf();
|
|
988
|
+
const mine = ++token;
|
|
989
|
+
window.clearTimeout(timer);
|
|
990
|
+
timer = window.setTimeout(() => {
|
|
991
|
+
if (mine !== token || activeMainSessionId() !== active) return;
|
|
992
|
+
if (panelAtChange !== null && panelAtChange !== WORKBENCH_KEY) return;
|
|
993
|
+
const panel = activePanelIdOf();
|
|
994
|
+
if (panel !== null && panel !== WORKBENCH_KEY) return;
|
|
995
|
+
void openSessionInWorkbench(active, { auto: true }).catch((error) =>
|
|
996
|
+
ctx.logger?.warn?.(`deck-studio: auto-open bound session failed: ${error?.message ?? error}`));
|
|
997
|
+
}, 260);
|
|
998
|
+
};
|
|
999
|
+
const dispose = store.subscribe(onChange);
|
|
1000
|
+
onChange();
|
|
1001
|
+
return () => {
|
|
1002
|
+
window.clearTimeout(timer);
|
|
1003
|
+
token += 1;
|
|
1004
|
+
if (typeof dispose === "function") dispose();
|
|
1005
|
+
};
|
|
1006
|
+
}, "deck-studio: session → workbench");
|
|
1007
|
+
|
|
1008
|
+
// 工作台 iframe 就绪:把还没送出去的「打开某本 deck」补发一次。
|
|
1009
|
+
ctx.effect(() => {
|
|
1010
|
+
const onReady = (event) => {
|
|
1011
|
+
if (event.data?.type !== "dsh-deck-studio:workbench-ready") return;
|
|
1012
|
+
if (event.source !== (frameState.el?.contentWindow ?? null)) return;
|
|
1013
|
+
flushPendingOpen();
|
|
1014
|
+
};
|
|
1015
|
+
window.addEventListener("message", onReady);
|
|
1016
|
+
return () => window.removeEventListener("message", onReady);
|
|
1017
|
+
}, "deck-studio: workbench ready bridge");
|
|
1018
|
+
|
|
1019
|
+
// 工作台确认收到「打开某本 deck」→ 才算送达,清掉 pending(没 ack 就继续重试)。
|
|
1020
|
+
ctx.effect(() => {
|
|
1021
|
+
const onAck = (event) => {
|
|
1022
|
+
const data = event.data ?? {};
|
|
1023
|
+
if (data.type !== "dsh-deck-studio:open-binding-ack") return;
|
|
1024
|
+
if (event.source !== (frameState.el?.contentWindow ?? null)) return;
|
|
1025
|
+
if (!pendingOpen || String(data.id ?? "") !== String(pendingOpen.id ?? "")) return;
|
|
1026
|
+
const failed = data.ok === false;
|
|
1027
|
+
pendingOpen = null;
|
|
1028
|
+
if (failed) ctx.logger?.warn?.(`deck-studio: open binding failed: ${data.reason ?? "unknown"}`);
|
|
1029
|
+
};
|
|
1030
|
+
window.addEventListener("message", onAck);
|
|
1031
|
+
return () => window.removeEventListener("message", onAck);
|
|
1032
|
+
}, "deck-studio: open-binding ack bridge");
|
|
1033
|
+
|
|
563
1034
|
// 唯一入口:侧栏面板行「Deckline」(与 plugins / schedules 等插件同一种形态,带图标);
|
|
564
1035
|
// → 对话用工作台里的「← 回到对话」。
|
|
565
1036
|
if (hasRightbar) {
|
|
@@ -569,6 +1040,21 @@ div[data-sidebar-collapsed]{grid-template-columns:0px minmax(0px,1fr) 0px!import
|
|
|
569
1040
|
ctx.slots.register({ name: "sidebar.panellist", id: WORKBENCH_KEY, order: 46, label: () => "Deckline" }, DeckGlyph)),
|
|
570
1041
|
"deck-studio: workbench sidebar entry",
|
|
571
1042
|
);
|
|
1043
|
+
// 会话行「⋯」菜单:把当前会话绑定的 deck 在工作台里打开(未绑定给可见提示)。
|
|
1044
|
+
// 该槽位是宿主可选契约,旧版本没有时只记一条警告,不能影响面板本身注册。
|
|
1045
|
+
try {
|
|
1046
|
+
ctx.effect(
|
|
1047
|
+
() => ctx.slots.inject("sidebar.workspaces.session.menu.item", () =>
|
|
1048
|
+
ctx.slots.register({
|
|
1049
|
+
name: "sidebar.workspaces.session.menu.item",
|
|
1050
|
+
id: "dsh-plugin-deck-studio.open-binding",
|
|
1051
|
+
order: 150,
|
|
1052
|
+
}, OpenDecklineMenuItem)),
|
|
1053
|
+
"deck-studio: session menu entry",
|
|
1054
|
+
);
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
ctx.logger?.warn?.(`deck-studio: session menu slot unavailable: ${error?.message ?? error}`);
|
|
1057
|
+
}
|
|
572
1058
|
}
|
|
573
1059
|
ctx.logger?.info?.("deck-studio client: workbench-only mode");
|
|
574
1060
|
} else {
|