fix(v0.3.4): adapt worktable to DSH 0.2 Web

This commit is contained in:
Aisland
2026-10-02 12:58:44 +08:00
parent 2f4f6c87d5
commit e63d122142
19 changed files with 1312 additions and 486 deletions
+8 -2
View File
@@ -8,15 +8,21 @@
- **分栏工作区引擎(自研)**:声明式布局预设(左栏/顶行/主行 + 右侧对话窗),窗格可拖拽分割、标签页模型;内置 资源管理器 / 终端 / 浏览器 / 动画 / 自定义窗口。
- **控制室**:默认自带项目(固定首位、不可删除),项目卡片网格实时监控所有项目的状态(工作中/待你决定/已完成),零轮询零 Token。
- **自动挂载握手**:项目内 agent 完成窗口任务后写 widget-result.json,产物自动挂进对应窗口。
- **平台**:Windows 是当前完整验证平台;macOS 为实验性支持(核心文件路径代码已做跨平台适配,尚未真机端到端验证)。
- **平台**:Windows 是本轮验证平台,范围见下方兼容说明;macOS 为实验性支持(核心文件路径代码已做跨平台适配,尚未真机端到端验证)。
## 技术底座
- Cordis 插件协议(客户端 bundle + 服务端路由 /api/worktable/*、终端 WebSocket)。
- 界面经 slot 座位协议注入侧边栏与 shell.overlay;对话窗复用宿主会话服务(sessions.open)。
- 界面经 slot 座位协议注入侧边栏与 shell.overlay;对话窗复用宿主会话服务(新版 uiWorkspace.openSession,旧版 sessions.open)。
- 状态监控为宿主会话运行时快照的事件订阅镜像;视图/项目/绑定状态存 localStorage。
- 客户端:TypeScript + React(host externals)+ 原生 CSS;服务端:Node。
## v0.3.4 兼容与验证范围
- DSH 网页端兼容声明仅列 **0.1.1-rc.2 / 0.1.2-rc.1 / 0.2.0-rc.2**,不是对任意新旧版本的保证。
- 本轮在 Windows + 0.2.0-rc.2 验证冷启动、工作台/控制室、新建并绑定对话、短文本发送与模型回复、项目关闭回切和重新打开;旧版沿用此前页面验收并补接口回退与标注引用坐标回归,未重跑整套旧版 GUI。
- 实际审批、子代理、自动挂载全链路尚未完成业务验收;官方桌面端未适配,macOS 未真机验证。升级后请完整重启 DSH 并刷新页面,仍加载失败需保留完整错误。
## 权限与外部服务(如实披露)
- **文件访问**:服务端读写工作区与用户指定的目录——文件窗目录列表、MD 编辑保存、项目文件夹 mkdir、站点静态托管、widget 产物读取。除用户当次操作外,插件在加载时、项目状态变化时、绑定会话完成时会自动读取已配置项目的数据(如挂载产物 widget-result.json、项目文件夹与绑定信息),并读取宿主提供的工作区数据(workspaces 路由)。读写路径均来自用户配置或宿主数据;失败返回错误或降级。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "dsh-worktable",
"description": "工作台:DSH 侧边栏的 agent 级项目容器,收纳如 TravelAtlas 等自装项目",
"version": "0.3.3",
"version": "0.3.4",
"entry": {
"name": "dsh-worktable",
"inject": []
+358 -218
View File
@@ -8896,7 +8896,29 @@ function basenameOf(p) {
var import_react = require("react");
// src/client/changelog.ts
var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.3.3
var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.3.4
\u{1F6E0}\uFE0F \u672C\u6B21\u66F4\u65B0\uFF1A\u9002\u914D DSH 0.2 \u7F51\u9875\u7AEF
\u3010\u9879\u76EE\u4E0E\u4F1A\u8BDD\u8054\u52A8\u3011\u63A5\u5165\u65B0\u7248 DSH \u7684\u4F1A\u8BDD\u8BC6\u522B\u4E0E\u8DF3\u8F6C\u63A5\u53E3\uFF0C\u9002\u914D\u6253\u5F00\u7ED1\u5B9A\u9879\u76EE\u3001\u5173\u95ED\u9879\u76EE\u540E\u56DE\u5230\u539F\u4F1A\u8BDD\u3001\u63A7\u5236\u5BA4\u5361\u7247\u8DF3\u8F6C\u7B49\u5165\u53E3\uFF0C\u4E0D\u66FF\u6362\u5BBF\u4E3B\u5B98\u65B9\u7EC4\u4EF6\u3002
\u3010\u65B0\u5EFA\u5BF9\u8BDD\u3011\u4FEE\u590D\u65B0\u7248 DSH \u4E2D\u53EA\u8BBE\u7F6E\u9879\u76EE\u8DEF\u5F84\u521B\u5EFA\u4F1A\u8BDD\uFF0C\u53EF\u80FD\u51FA\u73B0\u300C\u5BF9\u8BDD\u80FD\u6253\u5F00\uFF0C\u5374\u65E0\u6CD5\u8F93\u5165\u300D\u7684\u95EE\u9898\u3002\u65B0\u5EFA\u65F6\u5148\u901A\u8FC7\u5BBF\u4E3B\u767B\u8BB0\u6216\u590D\u7528\u5DE5\u4F5C\u533A\uFF0C\u518D\u521B\u5EFA\u4F1A\u8BDD\uFF1B\u9884\u8BBE\u4E0E\u6A21\u578B\u9009\u62E9\u4E5F\u63A5\u5165\u65B0\u7248\u63A5\u53E3\u3002\u6B64\u6539\u52A8\u4E0D\u81EA\u52A8\u8FC1\u79FB\u5DF2\u6709\u4F1A\u8BDD\u3002
\u3010\u6807\u6CE8\u8F93\u5165\u3011\u9002\u914D\u65B0\u7248\u804A\u5929\u8F93\u5165\u6846\uFF0C\u901A\u8FC7\u5BBF\u4E3B\u63A5\u53E3\u8FFD\u52A0\u6807\u6CE8\uFF0C\u4FDD\u7559\u5DF2\u6709\u8349\u7A3F\u4E0E\u6587\u4EF6\u5F15\u7528\uFF0C\u4E0D\u81EA\u52A8\u53D1\u9001\uFF1B\u65E0\u6CD5\u5199\u5165\u65F6\u4ECD\u53EF\u590D\u5236\u5230\u526A\u8D34\u677F\u3002\u4FEE\u590D\u65E7\u7248\u5DF2\u6709\u6587\u4EF6\u5F15\u7528\u65F6\u8FFD\u52A0\u4F4D\u7F6E\u8BA1\u7B97\u9519\u8BEF\u7684\u95EE\u9898\uFF1B\u65E7\u7248\u8F93\u5165\u6846\u4FDD\u7559\u517C\u5BB9\u5206\u652F\uFF0C\u4E0D\u518D\u8BEF\u9009\u5DE5\u4F5C\u53F0\u81EA\u5DF1\u7684\u6807\u6CE8\u6846\u6216\u6587\u4EF6\u7F16\u8F91\u6846\u3002
\u3010\u63A7\u5236\u5BA4\u72B6\u6001\u4E0E\u8BA1\u65F6\u3011\u9002\u914D\u65B0\u7248\u4F1A\u8BDD\u72B6\u6001\u3001\u5B50\u4EE3\u7406\u5217\u8868\u53CA\u6700\u8FD1\u6D88\u606F\u8BFB\u53D6\u3002\u652F\u6301\u8BC6\u522B\u65B0\u7684\u5F85\u786E\u8BA4\u4E8B\u9879\uFF1A\u786E\u8BA4\u8FC7\u4E0A\u4E00\u6761\u540E\uFF0C\u4E0B\u4E00\u6761\u4ECD\u4F1A\u91CD\u65B0\u4EAE\u8D77\u63D0\u9192\uFF1B\u8BA1\u65F6\u8BFB\u53D6\u5BBF\u4E3B\u5DF2\u6709\u7684\u4EFB\u52A1\u6216\u4F1A\u8BDD\u8D77\u70B9\uFF0C\u53D6\u4E0D\u5230\u6709\u6548\u8D77\u70B9\u65F6\u4E0D\u663E\u793A\uFF0C\u4E0D\u7F16\u9020\u8FD0\u884C\u65F6\u957F\u3002\u4EFB\u52A1\u505C\u6B62\u4E0D\u7B49\u4E8E\u4EFB\u52A1\u6210\u529F\u3002
\u3010\u53D1\u9001\u4E0E\u6D88\u606F\u9884\u89C8\u3011\u9002\u914D\u65B0\u7248\u4F1A\u8BDD\u7684\u4E34\u65F6\u6301\u6709\u4E0E\u91CA\u653E\u673A\u5236\u3002\u53D1\u9001\u8BF7\u6C42\u5931\u8D25\u6216\u88AB\u62D2\u7EDD\u540E\uFF0C\u4E0D\u518D\u6362\u53E6\u4E00\u6761\u901A\u9053\u91CD\u590D\u53D1\u9001\uFF1B\u63A7\u5236\u5BA4\u9884\u89C8\u4ECE\u5BBF\u4E3B\u5DF2\u52A0\u8F7D\u7684\u6D88\u606F\u7A97\u53E3\u63D0\u53D6\u666E\u901A\u6587\u672C\uFF0C\u4E0D\u4EE3\u8868\u8BFB\u53D6\u4E86\u5B8C\u6574\u5386\u53F2\u3002
\u3010\u6570\u636E\u4E0E\u5347\u7EA7\u3011\u672C\u6B21\u672A\u66F4\u540D\u6216\u8FC1\u79FB\u5DE5\u4F5C\u53F0\u7684\u9879\u76EE\u3001\u5E03\u5C40\u4E0E\u5A92\u4F53\u5B58\u50A8\uFF0C\u4E0D\u9700\u8981\u4E3A\u4E86\u672C\u6B21\u66F4\u65B0\u4E3B\u52A8\u6E05\u7A7A\u6D4F\u89C8\u5668\u6570\u636E\u3002\u5347\u7EA7\u524D\u8BF7\u5907\u4EFD\u914D\u7F6E\u53CA\u91CD\u8981\u6570\u636E\uFF0C\u786E\u8BA4\u5176\u4ED6\u63D2\u4EF6\u4E5F\u652F\u6301\u76EE\u6807 DSH \u7248\u672C\uFF1B\u5B89\u88C5\u540E\u5B8C\u6574\u9000\u51FA\u5E76\u91CD\u542F DSH\uFF0C\u518D\u5237\u65B0\u9875\u9762\u3002\u82E5\u4ECD\u52A0\u8F7D\u5931\u8D25\uFF0C\u8BF7\u4FDD\u7559\u5B8C\u6574\u9519\u8BEF\u4FE1\u606F\u53CD\u9988\uFF0C\u4E0D\u8981\u628A\u5B83\u5F53\u4F5C\u6B63\u5E38\u73B0\u8C61\u3002
\u3010\u9A8C\u8BC1\u8303\u56F4\u3011\u672C\u8F6E\u5728 Windows + DSH 0.2.0-rc.2 \u7F51\u9875\u7AEF\u9A8C\u8BC1\u4E86\u51B7\u542F\u52A8\u3001\u5DE5\u4F5C\u53F0/\u63A7\u5236\u5BA4\u9875\u9762\u3001\u901A\u8FC7\u5DE5\u4F5C\u53F0\u65B0\u5EFA\u5E76\u7ED1\u5B9A\u5BF9\u8BDD\u3001\u53D1\u9001\u77ED\u6587\u672C\u5E76\u6536\u5230\u6A21\u578B\u56DE\u590D\u3001\u5173\u95ED\u9879\u76EE\u540E\u56DE\u5207\u4E0E\u91CD\u65B0\u6253\u5F00\uFF1B\u540C\u65F6\u8865\u5145\u65E7\u7248\u670D\u52A1\u56DE\u9000\u4E0E\u5F15\u7528\u5750\u6807\u7B49\u5B9A\u5411\u56DE\u5F52\u3002\u517C\u5BB9\u58F0\u660E\u4EC5\u5217 0.1.1-rc.2\u30010.1.2-rc.1\u30010.2.0-rc.2\uFF1B\u524D\u4E24\u4E2A\u7248\u672C\u6CBF\u7528\u6B64\u524D\u9875\u9762\u9A8C\u6536\u57FA\u7840\u5E76\u505A\u4EE3\u7801\u56DE\u5F52\uFF0C\u672C\u8F6E\u672A\u91CD\u8DD1\u6574\u5957\u65E7\u7248 GUI\uFF0C\u4E0D\u627F\u8BFA\u5176\u4ED6\u7248\u672C\u5168\u90E8\u517C\u5BB9\u3002
\u3010\u5C1A\u672A\u95ED\u73AF\u3011\u5B9E\u9645\u5BA1\u6279/\u5B50\u4EE3\u7406\u6D41\u7A0B\u53CA\u81EA\u52A8\u6302\u8F7D\u5168\u94FE\u8DEF\u5C1A\u672A\u5B8C\u6210\u4E1A\u52A1\u9A8C\u6536\u3002\u5B98\u65B9\u684C\u9762\u7AEF\u5C1A\u672A\u9002\u914D\uFF0CmacOS \u5C1A\u672A\u771F\u673A\u9A8C\u8BC1\uFF1B\u672C\u6B21\u4E5F\u4E0D\u4EE3\u8868\u6240\u6709\u7528\u6237\u7684 DSH \u542F\u52A8\u5931\u8D25\u5DF2\u89E3\u51B3\u3002\u516C\u5F00\u53D1\u5E03\u4FE1\u606F\u4EE5 GitHub Release \u4E3A\u51C6\u3002
\u2500\u2500\u2500\u2500\u2500\u2500\u2500
\u6B64\u524D\u7248\u672C\u8BF4\u660E\uFF08v0.3.3\uFF09
\u{1F6E0}\uFE0F \u672C\u7248\u672C\u66F4\u65B0
@@ -8948,7 +8970,7 @@ var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.3.3
\u30103.1 \u6587\u5B57\u4E0E\u7EC6\u8282\u6253\u78E8\u3011\u5168\u5C40\u5B57\u4F53\u4E0E\u5B57\u53F7\u7EDF\u4E00\u4F18\u5316\u3001\u4E0B\u62C9\u9762\u677F\u73BB\u7483\u5316\u4E0E\u5BF9\u9F50\u3001\u83DC\u5355\u70B9\u9009\u540E\u4FDD\u6301\u6253\u5F00\u4FBF\u4E8E\u8FDE\u7EED\u9884\u89C8\u3001\u6309\u94AE\u63CF\u8FB9\u4E0E\u60AC\u505C\u53CD\u9988\u7B49\u4EA4\u4E92\u7EC6\u8282\uFF1B\u540C\u65F6\u4FEE\u590D\u4E86\u591A\u9879\u4F53\u9A8C\u95EE\u9898\uFF08\u7167\u7247\u4E0A\u4F20\u6E05\u6670\u5EA6\u3001\u80CC\u666F\u7F51\u683C\u7EBF\u5728\u7167\u7247\u6A21\u5F0F\u4E0B\u4E0D\u751F\u6548\u3001\u6D45\u8272\u4E3B\u9898\u4E0B\u5DE5\u4F5C\u72B6\u6001\u5149\u6548\u4E0D\u53EF\u89C1\u7B49\uFF09\u3002`;
// src/client/updateCheck.ts
var LOCAL_VERSION = false ? "dev" : "0.3.3";
var LOCAL_VERSION = false ? "dev" : "0.3.4";
var UPDATE_REPO = "Aisland-SJL/dsh-worktable";
var K_UPDATE_CHECK = "dsh.worktable.updateCheck.v1";
var K_LAST_CHECK = "dsh.worktable.lastUpdateCheck.v1";
@@ -17098,24 +17120,7 @@ async function copyTextSafe(text2) {
}
}
function fillHostInput(text2) {
try {
const ta = document.querySelector("textarea[data-phase]") ?? Array.from(document.querySelectorAll("textarea")).pop();
if (!ta) return false;
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
const next = ta.value && ta.value.trim() ? ta.value + "\n\n" + text2 : text2;
if (setter) setter.call(ta, next);
else ta.value = next;
ta.dispatchEvent(new Event("input", { bubbles: true }));
try {
ta.focus();
ta.dispatchEvent(new Event("change", { bubbles: true }));
ta.setSelectionRange(ta.value.length, ta.value.length);
} catch {
}
return true;
} catch {
return false;
}
return splitEnv?.fillHostInput(text2) ?? false;
}
function confirmAnnot() {
const s = annotState;
@@ -19940,6 +19945,255 @@ try {
} catch {
}
// src/client/sessionCompat.ts
function currentSessionOf(snapshot) {
if (typeof snapshot?.current === "string") return snapshot.current;
const rows = Object.values(snapshot?.byId ?? {});
return rows.find((row) => (row?.retainedBy?.mainView ?? 0) > 0)?.id ?? "";
}
function openHostSession(ctx, sessions, id) {
const ui = typeof ctx?.get === "function" ? ctx.get("uiWorkspace") : ctx?.uiWorkspace;
if (typeof ui?.openSession === "function") return ui.openSession(id);
if (typeof sessions?.open === "function") return sessions.open(id);
throw new Error("Host session navigation unavailable");
}
async function sendHostSession(bridge, id, text2) {
const sessions = bridge?.sessions;
const check = (result) => {
if (result?.kind === "error" || result?.ok === false) {
throw new Error(result.text || result.error?.message || result.error?.code || "Session submission rejected");
}
};
const send = async (session) => {
if (session && typeof bridge?.conversation?.sendSession === "function") {
check(await bridge.conversation.sendSession(session, text2, [], "queue"));
return;
}
if (session && typeof session.prompt === "function") {
check(await session.prompt([{ type: "text", text: text2 }], "queue"));
return;
}
const conv = sessions?.scope?.(id)?.get?.("conversation");
if (typeof conv?.send === "function") {
check(await conv.send(text2));
return;
}
throw new Error("no send path: session face unavailable");
};
if (typeof sessions?.using === "function") {
await sessions.using(id, { source: "dshWorktable" }, async (ref) => {
const binding = await ref.ready;
await send(binding.session);
});
return;
}
let face = null;
for (let i = 0; i < 10; i++) {
face = sessions?.binding?.(id)?.session;
if (face) break;
await new Promise((resolve) => setTimeout(resolve, 200));
}
await send(face);
}
function createSessionSnapshotAdapter() {
const previousRunning = /* @__PURE__ */ new Map();
const stopped = /* @__PURE__ */ new Set();
return (snapshot, statusSource) => {
if (!snapshot || typeof snapshot.current === "string") return snapshot;
const statuses = statusSource?.getSnapshot?.();
const byId = {};
const subagentsByParent = { ...snapshot.subagentsByParent };
for (const [id, row] of Object.entries(snapshot.byId ?? {})) {
const status = statuses?.get?.(id);
const running = status?.running ?? row.running;
if (running === true) stopped.delete(id);
else if (running === false && previousRunning.get(id) === true) stopped.add(id);
if (typeof running === "boolean") previousRunning.set(id, running);
byId[id] = {
...row,
running,
pendingInteraction: status ? status.pendingInteraction : row.pendingInteraction,
// completionUnread clears when opening the main view. Keep a witnessed run-stop
// until the next run so an active project's widget-result handshake still runs.
completed: running !== true && (stopped.has(id) || status?.completionUnread === true || row.completed === true)
};
const children = snapshot.projectionsBySession?.[id]?.values?.subagentCatalog ?? row.projectionValues?.subagentCatalog;
if (Array.isArray(children)) subagentsByParent[id] = children;
}
if (snapshot.phase === "ready") {
for (const id of previousRunning.keys()) if (!byId[id]) {
previousRunning.delete(id);
stopped.delete(id);
}
}
return { ...snapshot, byId, subagentsByParent };
};
}
function childSessionIdsOf(snapshot, sid) {
const children = /* @__PURE__ */ new Set();
for (const [id, row] of Object.entries(snapshot?.byId ?? {})) {
if (id !== sid && row?.parentId === sid) children.add(id);
}
const catalog = snapshot?.subagentsByParent?.[sid];
const rows = Array.isArray(catalog) ? catalog : catalog?.entries ?? catalog?.items ?? [];
if (Array.isArray(rows)) for (const row of rows) {
const id = row?.sessionId ?? row?.id;
if (typeof id === "string" && id !== sid) children.add(id);
}
return children;
}
function pendingAckOf(snapshot, sid, legacyFace) {
const identities = [];
let identified = false;
const add = (id, pending) => {
if (pending == null) return;
const key = typeof pending?.key === "string" ? pending.key : "";
if (key) identified = true;
identities.push(JSON.stringify([id, String(pending?.kind ?? ""), key]));
};
const ids = [sid, ...childSessionIdsOf(snapshot, sid)];
for (const id of ids) add(id, snapshot?.byId?.[id]?.pendingInteraction);
if (snapshot?.byId?.[sid]?.pendingInteraction == null && Array.isArray(legacyFace?.pending)) {
for (const pending of legacyFace.pending) add(sid, pending);
}
if (!identities.length) return null;
return identified ? "need:" + JSON.stringify([...new Set(identities)].sort()) : "need";
}
function sessionRuntimeMs(snapshot, sid, sessions, uiConversation, now = Date.now()) {
if (snapshot?.byId?.[sid]?.running !== true) return null;
const validStart = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
let start = null;
for (const job of snapshot?.jobsBySession?.[sid] ?? []) {
if (job?.status === "running" && validStart(job.startedAt) && (start == null || job.startedAt < start)) start = job.startedAt;
}
if (start != null) return Math.max(0, now - start);
let timings;
try {
timings = uiConversation?.binding?.(sid)?.target?.("chat")?.getSnapshot?.()?.legacy?.turnTimings;
} catch {
}
if (timings == null) {
try {
timings = sessions?.binding?.(sid)?.session?.getSnapshot?.()?.turnTimings;
} catch {
}
}
const turns = typeof timings?.values === "function" ? Array.from(timings.values()) : Object.values(timings ?? {});
for (const turn of turns) {
if (turn?.endTime == null && validStart(turn?.startTime) && (start == null || turn.startTime > start)) start = turn.startTime;
}
return start == null ? null : Math.max(0, now - start);
}
// src/client/hostInput.ts
function appendHostInput(bridge, text2, doc = document) {
try {
const resolver = bridge?.conversation?.input;
if (typeof resolver?.for === "function") {
const snapshot = bridge.sessions?.list?.getSnapshot?.();
const id = currentSessionOf(snapshot);
const scope = id ? bridge.sessions?.scope?.(id) : null;
if (!scope) return false;
const input = resolver.for(scope);
const state = input?.state?.getSnapshot?.();
if (!state || typeof state.draft !== "string" || !Number.isInteger(state.draftRev) || !["plain", "claimed"].includes(state.phase) || !Array.isArray(state.occurrences)) return false;
const compactReferences = typeof snapshot?.current !== "string";
let end = state.draft.length;
let previousEnd = 0;
for (const item of state.occurrences) {
if (!Number.isInteger(item.offset) || !Number.isInteger(item.length) || item.length < 0 || item.offset < previousEnd || item.offset + item.length > state.draft.length) return false;
previousEnd = item.offset + item.length;
if (compactReferences) end += 1 - item.length;
}
const span = { start: end, end, draftRev: state.draftRev };
const addition = (state.draft.trim() ? "\n\n" : "") + text2;
const inserted = typeof input.actions?.insertText === "function" ? input.actions.insertText(addition, span) === true : scope.bail?.("slash/input-insert-text", { text: addition, span }) === true;
if (inserted) {
try {
input.focus?.();
} catch {
}
}
return inserted;
}
const ta = doc.querySelector("textarea[data-phase]");
if (!ta || ta.disabled || ta.readOnly || ta.closest(".dsh-wt_annotUi")) return false;
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
const next = ta.value && ta.value.trim() ? ta.value + "\n\n" + text2 : text2;
if (setter) setter.call(ta, next);
else ta.value = next;
ta.dispatchEvent(new Event("input", { bubbles: true }));
try {
ta.focus();
ta.dispatchEvent(new Event("change", { bubbles: true }));
ta.setSelectionRange(ta.value.length, ta.value.length);
} catch {
}
return true;
} catch {
return false;
}
}
// src/client/sessionDetails.ts
function cleanPreviewText(raw) {
return String(raw ?? "").replace(/```[a-zA-Z0-9_+-]*[\s\S]*?```/g, " ").replace(/```[a-zA-Z0-9_+-]*[\s\S]*$/g, " ").replace(/`[^`\n]{1,200}`/g, " ").replace(/```/g, " ").replace(/\s+/g, " ").trim();
}
function previewFromEvents(entries) {
if (!Array.isArray(entries)) return "";
for (let i = entries.length - 1; i >= 0; i--) {
const ev = entries[i]?.event;
if (ev?.type !== "user/message" && ev?.type !== "assistant/message") continue;
const data = ev.type === "assistant/message" ? ev.data?.message ?? ev.data : ev.data;
const blocks = data?.content ?? data?.blocks;
if (!Array.isArray(blocks)) continue;
const raw = blocks.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n");
const text2 = cleanPreviewText(raw);
if (text2.length >= 8) return text2.slice(0, 220);
}
return "";
}
async function readSessionPreview(sessions, id) {
if (typeof sessions?.using === "function") {
return sessions.using(id, { source: "dshWorktablePreview" }, async (ref) => {
const binding = await ref.ready;
return previewFromEvents(binding.eventSource?.getSnapshot?.()?.entries);
});
}
const face = sessions?.binding?.(id)?.session;
if (typeof face?.history !== "function") return "";
const result = await face.history({ maxMessages: 6 });
return previewFromEvents(result?.result?.value?.events);
}
function presetApiOf(ctx, legacy) {
const api = typeof ctx?.get === "function" ? ctx.get("remote.agentPresets") : ctx?.remote?.agentPresets;
if (typeof api?.list !== "function" || typeof api?.select !== "function") return legacy;
return {
list: async () => ({ result: await api.list() }),
select: async ({ sessionId, agentPreset }) => ({ result: await api.select(sessionId, agentPreset) })
};
}
function modelApiOf(ctx, sessions, legacy) {
if (typeof sessions?.using !== "function") return legacy;
const resolver = typeof ctx?.get === "function" ? ctx.get("modelDirectories") : ctx?.modelDirectories;
if (typeof resolver?.directoryFor !== "function") return legacy;
const withDirectory = (id, operation) => sessions.using(id, { source: "dshWorktableModel" }, async (ref) => {
await ref.ready;
return operation(resolver.directoryFor(id));
});
return {
models: async ({ sessionId }) => withDirectory(sessionId, async (directory) => ({ result: { ok: true, value: await directory.load() } })),
selectModel: async ({ sessionId, ...selection }) => withDirectory(sessionId, async (directory) => ({ result: await directory.select(selection) }))
};
}
async function createHostSession(sessions, workspaces, options = {}) {
if (options.workspaceId || typeof sessions?.using !== "function") return sessions.create(options);
const workspace = options.cwd ? await workspaces?.create?.({ path: options.cwd }) : await workspaces?.initializeDefault?.();
if (typeof workspace?.workspaceId !== "string" || !workspace.workspaceId) throw new Error("workspace unavailable for new session");
const { cwd: _cwd, ...rest } = options;
return sessions.create({ ...rest, workspaceId: workspace.workspaceId });
}
// src/client/photoStore.ts
var DB_NAME = "dsh-worktable";
var STORE = "photoRecords";
@@ -20104,7 +20358,7 @@ var WAVE_BG_B64 = "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAYEBQUFBAYFBQUHBgYHCQ8KCQgIC
// src/client/index.tsx
var import_jsx_runtime2 = require("react/jsx-runtime");
var LOCAL_VERSION2 = false ? "dev" : "0.3.3";
var LOCAL_VERSION2 = false ? "dev" : "0.3.4";
var UPDATE_REPO2 = "Aisland-SJL/dsh-worktable";
var UPGRADE_CMD2 = 'dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"';
var UPGRADE_AI2 = "\u5E2E\u6211\u5347\u7EA7 dsh-worktable\uFF1A\u6267\u884C " + UPGRADE_CMD2 + "\uFF0C\u5B8C\u6210\u540E\u63D0\u9192\u6211\u91CD\u542F dsh web \u5E76\u5237\u65B0\u9875\u9762";
@@ -20391,7 +20645,7 @@ var sessionBridge = null;
var clientCtx = null;
var hostApi = null;
async function ensureSessionPreset(sessionId) {
const api = hostApi?.agentPresets;
const api = presetApiOf(clientCtx, hostApi?.agentPresets);
if (!api || typeof api.list !== "function" || typeof api.select !== "function") return;
try {
const listRes = await api.list({});
@@ -20447,7 +20701,7 @@ function pickInheritedModel(groups, baseModel) {
return { provider: flat[0].provider, model: flat[0].model };
}
async function ensureSessionModel(sessionId) {
const api = hostApi?.sessions;
const api = modelApiOf(clientCtx, sessionBridge?.sessions, hostApi?.sessions);
if (!api || typeof api.models !== "function" || typeof api.selectModel !== "function") return;
try {
const mRes = await api.models({ sessionId });
@@ -20457,7 +20711,7 @@ async function ensureSessionModel(sessionId) {
let target = null;
let effort;
try {
const cur = sessionBridge?.list?.getSnapshot?.()?.current;
const cur = currentSessionOf(sessionBridge?.list?.getSnapshot?.());
if (cur && cur !== sessionId) {
const cRes = await api.models({ sessionId: cur });
if (cRes?.result?.ok) {
@@ -20551,51 +20805,10 @@ function listWorkspaces() {
return [];
}
}
function cleanPreviewText(raw) {
let s = String(raw ?? "");
s = s.replace(/```[a-zA-Z0-9_+-]*[\s\S]*?```/g, " ");
s = s.replace(/```[a-zA-Z0-9_+-]*[\s\S]*$/g, " ");
s = s.replace(/`[^`\n]{1,200}`/g, " ");
s = s.replace(/```/g, " ");
s = s.replace(/\s+/g, " ").trim();
return s;
}
var previewCache = /* @__PURE__ */ new Map();
var previewFetching = /* @__PURE__ */ new Set();
var previewSweepBusy = false;
var previewTimer = null;
async function coldPreviewOf(face) {
if (!face || typeof face.history !== "function") return "";
const r1 = await face.history({ maxMessages: 6 });
const evs = r1?.result?.value?.events;
if (!Array.isArray(evs)) return "";
const textOf = (blocks) => {
let fallback = "";
if (!Array.isArray(blocks)) return "";
for (const b of blocks) {
const s = typeof b?.text === "string" ? b.text.trim() : "";
if (!s) continue;
if (b?.type === "text") return s;
if (!fallback) fallback = s;
}
return fallback;
};
for (let i = evs.length - 1; i >= 0; i--) {
const ev = evs[i]?.event;
if (!ev) continue;
const d = ev.data ?? {};
let raw = "";
if (ev.type === "user/message") raw = textOf(d.content ?? d.blocks);
else if (ev.type === "assistant/message") {
const m = d.message ?? d;
raw = textOf(m.content ?? m.blocks);
}
if (!raw) continue;
const clean = cleanPreviewText(raw);
if (clean.length >= 8) return clean.slice(0, 220);
}
return "";
}
async function sweepPreviews() {
if (previewSweepBusy) return;
previewSweepBusy = true;
@@ -20605,12 +20818,9 @@ async function sweepPreviews() {
if (previewFetching.has(sid)) continue;
previewFetching.add(sid);
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session;
const txt = await coldPreviewOf(face);
if (txt) {
previewCache.set(sid, txt);
notifyConsole();
}
const txt = await readSessionPreview(sessionBridge?.sessions, sid);
previewCache.set(sid, txt);
notifyConsole();
} catch {
} finally {
previewFetching.delete(sid);
@@ -20621,7 +20831,7 @@ async function sweepPreviews() {
}
}
function schedulePreviewSweep() {
if (previewTimer != null) return;
if (previewSweepBusy || previewTimer != null) return;
previewTimer = window.setTimeout(() => {
previewTimer = null;
sweepPreviews();
@@ -20629,7 +20839,9 @@ function schedulePreviewSweep() {
}
function lastTextOf(sid) {
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.();
const binding = sessionBridge?.sessions?.binding?.(sid);
if (binding?.eventSource?.getSnapshot) return previewFromEvents(binding.eventSource.getSnapshot().entries);
const face = binding?.session?.getSnapshot?.();
const nodes = face?.nodes ?? [];
for (let i = nodes.length - 1; i >= 0; i--) {
const n = nodes[i];
@@ -20656,7 +20868,7 @@ async function fetchSessionGroups() {
try {
const snap = sessionBridge?.list?.getSnapshot?.();
const byId = snap?.byId ?? {};
const current = snap?.current ?? "";
const current = currentSessionOf(snap);
const subKids = /* @__PURE__ */ new Set();
try {
const map2 = snap?.subagentsByParent ?? {};
@@ -20723,41 +20935,7 @@ function hideBindTip() {
async function promptIntoSession(sessionId, text2) {
const b = sessionBridge;
if (!b) throw new Error("bridge unavailable");
const sessions = b.sessions;
let session = 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) {
if (typeof b.conversation?.sendSession === "function") {
try {
await b.conversation.sendSession(session, text2, [], "queue");
return;
} catch {
}
}
if (typeof session.prompt === "function") {
const result = await session.prompt([{ type: "text", text: text2 }], "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 : ""));
}
}
try {
const scoped = sessions?.scope?.(sessionId);
const conv = scoped?.get?.("conversation");
if (conv && typeof conv.send === "function") {
await conv.send(text2);
return;
}
} catch {
}
throw new Error("no send path: session face unavailable");
await sendHostSession(b, sessionId, text2);
}
var KNOWLEDGE_PACK = [
"\u3010\u63D2\u4EF6\u77E5\u8BC6\u5305\xB7\u8BF7\u76F4\u63A5\u91C7\u7528\uFF0C\u4E0D\u8981\u91CD\u65B0\u4FA6\u5BDF\u63D2\u4EF6\u6E90\u7801\u3011",
@@ -20828,12 +21006,12 @@ async function createCustomSession(projectId, projectName, requirement, group, w
let createOpts = {};
if (workspaceId) createOpts = { workspaceId };
else if (folder) createOpts = { cwd: folder };
const sessionId = await b.sessions.create(createOpts);
const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts);
await ensureSessionPreset(sessionId);
await ensureSessionModel(sessionId);
markPluginSessionOpen(sessionId);
try {
await b.sessions.open?.(sessionId);
await openHostSession(clientCtx, b.sessions, sessionId);
} catch {
}
await promptIntoSession(sessionId, text2);
@@ -20845,7 +21023,7 @@ async function sendCustomToSession(sessionId, projectId, projectName, requiremen
const text2 = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, "send");
markPluginSessionOpen(sessionId);
try {
await b.sessions.open?.(sessionId);
await openHostSession(clientCtx, b.sessions, sessionId);
} catch {
}
await promptIntoSession(sessionId, text2);
@@ -20955,10 +21133,21 @@ function sessionNotifyState(entry) {
if (entry.completed === true) return "done";
return null;
}
var sessionStatusSource = null;
var sessionConversationSource = null;
var adaptSessionSnapshot = createSessionSnapshotAdapter();
function pendingAckForSession(sid) {
let face;
try {
face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.();
} catch {
}
return pendingAckOf(sessionsSnapshotStore.snapshot, sid, face);
}
function syncSessionScope(list2) {
try {
const snap = list2.getSnapshot();
const current = snap?.current ?? "";
const snap = adaptSessionSnapshot(list2.getSnapshot(), sessionStatusSource);
const current = currentSessionOf(snap);
const entry = snap?.byId?.[current] ?? snap?.items?.find((it) => it.sessionId === current) ?? null ?? null;
const cat = snap?.subagentsByParent?.[current];
let subagents = [];
@@ -21190,32 +21379,13 @@ function WorktableSection(props) {
const pr = projectsRef.current;
const snap = sessionsSnapshotStore.snapshot;
const byId = snap?.byId ?? {};
const jobsMap = snap?.jobsBySession ?? {};
const now = Date.now();
const kidsSetOf = (sid) => {
const set = /* @__PURE__ */ new Set();
for (const [cid, ce] of Object.entries(byId)) if (ce?.parentId === sid) set.add(cid);
const v = (snap?.subagentsByParent ?? {})[sid];
const arr = Array.isArray(v) ? v : v?.entries ?? v?.items ?? [];
if (Array.isArray(arr)) arr.forEach((c) => {
const cid = c?.sessionId ?? c?.id;
if (typeof cid === "string") set.add(cid);
});
return set;
};
const kidsSetOf = (sid) => childSessionIdsOf(snap, sid);
const statusOf = (sid) => {
if (!sid) return "idle";
const e = byId[sid];
if (!e) return "idle";
if (sessionNotifyState(e) === "need") return "need";
for (const cid of kidsSetOf(sid)) {
if (byId[cid] && sessionNotifyState(byId[cid]) === "need") return "need";
}
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.();
if (Array.isArray(face?.pending) && face.pending.length > 0) return "need";
} catch {
}
if (pendingAckForSession(sid) != null) return "need";
if (e.completed === true) return "done";
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.();
@@ -21227,36 +21397,13 @@ function WorktableSection(props) {
};
const runtimeOf = (sid) => {
if (!sid) return null;
const e = byId[sid];
if (!e || e.running !== true) return null;
let start = null;
const jobs = jobsMap[sid] ?? [];
for (const j of jobs) {
if (j?.status === "running" && typeof j.startedAt === "number" && (start == null || j.startedAt < start)) start = j.startedAt;
}
if (start != null) return Math.max(0, now - start);
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.();
const timings = face?.turnTimings;
if (timings instanceof Map) {
for (const t2 of Array.from(timings.values()).reverse()) {
if (t2 && typeof t2.startTime === "number" && t2.endTime == null) return Math.max(0, now - t2.startTime);
}
} else if (timings && typeof timings === "object") {
for (const k of Object.keys(timings).reverse()) {
const t2 = timings[k];
if (t2 && typeof t2.startTime === "number" && t2.endTime == null) return Math.max(0, now - t2.startTime);
}
}
} catch {
}
return null;
return sessionRuntimeMs(snap, sid, sessionBridge?.sessions, sessionConversationSource, now);
};
const ackMap = loadNotifyAck();
const make = (id, name, icon, self2) => {
const sid = pr.projects.bindings[id];
const status = statusOf(sid);
const glow = !!sid && (status === "done" && ackMap[sid] !== "done" || status === "need" && ackMap[sid] !== "need");
const glow = !!sid && (status === "done" && ackMap[sid] !== "done" || status === "need" && ackMap[sid] !== pendingAckForSession(sid));
return {
id,
name,
@@ -21297,6 +21444,7 @@ function WorktableSection(props) {
};
(0, import_react2.useEffect)(() => {
const env = {
fillHostInput: (text2) => appendHostInput(sessionBridge, text2),
getScope: () => {
const s = sessionScopeStore.snapshot;
return s ? { sessionId: s.sessionId, cwd: s.cwd } : null;
@@ -21439,7 +21587,7 @@ function WorktableSection(props) {
const bound = pr.bindings[id];
if (bound) {
try {
sessionBridge?.sessions?.open?.(bound);
openHostSession(clientCtx, sessionBridge?.sessions, bound);
} catch {
}
}
@@ -21450,7 +21598,7 @@ function WorktableSection(props) {
if (!sid) return;
markPluginSessionOpen(sid);
try {
sessionBridge?.sessions?.open?.(sid);
openHostSession(clientCtx, sessionBridge?.sessions, sid);
} catch {
}
}
@@ -21511,7 +21659,7 @@ function WorktableSection(props) {
const prev = projectAttachRef.sessionId;
if (prev) {
try {
sessionBridge?.sessions?.open?.(prev);
openHostSession(clientCtx, sessionBridge?.sessions, prev);
} catch {
}
}
@@ -21670,7 +21818,7 @@ function WorktableSection(props) {
if (splitStore.active && splitStore.spec?.id === spec.id) {
let prev = null;
try {
prev = sessionBridge?.list?.getSnapshot?.()?.current ?? null;
prev = currentSessionOf(sessionBridge?.list?.getSnapshot?.()) || null;
} catch {
}
projectAttachRef.sessionId = prev;
@@ -21678,7 +21826,7 @@ function WorktableSection(props) {
const bound = projectsRef.current.projects.bindings[spec.id];
if (bound) {
try {
sessionBridge?.sessions?.open?.(bound);
openHostSession(clientCtx, sessionBridge?.sessions, bound);
} catch {
}
}
@@ -21713,7 +21861,7 @@ function WorktableSection(props) {
if (splitStore.active && splitStore.spec?.id === CONSOLE_ID) {
let prev = null;
try {
prev = sessionBridge?.list?.getSnapshot?.()?.current ?? null;
prev = currentSessionOf(sessionBridge?.list?.getSnapshot?.()) || null;
} catch {
}
projectAttachRef.sessionId = prev;
@@ -21721,7 +21869,7 @@ function WorktableSection(props) {
projectAttachRef.attached = bound ?? prev;
if (bound) {
try {
sessionBridge?.sessions?.open?.(bound);
openHostSession(clientCtx, sessionBridge?.sessions, bound);
} catch {
}
}
@@ -21792,7 +21940,7 @@ function WorktableSection(props) {
let createOpts = {};
if (workspaceId) createOpts = { workspaceId };
else if (folder) createOpts = { cwd: folder };
const sessionId = await b.sessions.create(createOpts);
const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts);
await ensureSessionPreset(sessionId);
await ensureSessionModel(sessionId);
markPluginSessionOpen(sessionId);
@@ -22060,20 +22208,7 @@ function WorktableSection(props) {
};
}, [projects.folders, applyWidgetManifest]);
const collectKids = (0, import_react2.useCallback)((sid) => {
const kids = /* @__PURE__ */ new Set();
const snap = sessionsSnapshotStore.snapshot;
const byId = snap?.byId ?? {};
for (const [cid, ce] of Object.entries(byId)) {
if (ce?.parentId === sid) kids.add(cid);
}
const subMap = snap?.subagentsByParent ?? {};
const v = subMap[sid];
const arr = Array.isArray(v) ? v : v?.entries ?? v?.items ?? [];
if (Array.isArray(arr)) arr.forEach((c) => {
const cid = c?.sessionId ?? c?.id;
if (typeof cid === "string") kids.add(cid);
});
return kids;
return childSessionIdsOf(sessionsSnapshotStore.snapshot, sid);
}, []);
const bindNotifyMap = (0, import_react2.useMemo)(() => {
const map2 = {};
@@ -22083,29 +22218,16 @@ function WorktableSection(props) {
for (const [pid, sid] of Object.entries(projects.bindings)) {
const e = byId[sid];
if (!e) continue;
let needNow = sessionNotifyState(e) === "need";
if (!needNow) {
for (const cid of collectKids(sid)) {
const ce = byId[cid];
if (ce && sessionNotifyState(ce) === "need") {
needNow = true;
break;
}
const pendingAck = pendingAckForSession(sid);
if (pendingAck !== seen[sid]) {
if (seen[sid] !== void 0) {
clearNotifyAck(sid);
delete ack[sid];
}
seen[sid] = pendingAck;
}
if (!needNow) {
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.();
if (Array.isArray(face?.pending) && face.pending.length > 0) needNow = true;
} catch {
}
}
if (needNow !== seen[sid]) {
if (seen[sid] !== void 0) clearNotifyAck(sid);
seen[sid] = needNow;
}
if (needNow) {
if (ack[sid] !== "need") map2[pid] = "need";
if (pendingAck != null) {
if (ack[sid] !== pendingAck) map2[pid] = "need";
continue;
}
if (sessionNotifyState(e) === "done" && ack[sid] !== "done") {
@@ -22120,22 +22242,20 @@ function WorktableSection(props) {
const sid = projectsRef.current.projects.bindings[projectId];
if (!sid) return;
const byId = sessionsSnapshotStore.snapshot?.byId ?? {};
let needNow = sessionNotifyState(byId[sid]) === "need";
const pendingAck = pendingAckForSession(sid);
for (const cid of collectKids(sid)) {
if (sessionNotifyState(byId[cid]) === "need") {
needNow = true;
saveNotifyAck(cid, "need");
const childAck = pendingAckForSession(cid);
if (childAck != null) {
saveNotifyAck(cid, childAck);
notifyStateSeenRef.current[cid] = childAck;
}
}
}
if (!needNow) {
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.();
if (Array.isArray(face?.pending) && face.pending.length > 0) needNow = true;
} catch {
}
}
if (needNow) saveNotifyAck(sid, "need");
else {
if (pendingAck != null) {
saveNotifyAck(sid, pendingAck);
notifyStateSeenRef.current[sid] = pendingAck;
} else {
const st = sessionNotifyState(byId[sid]);
if (st === "done") saveNotifyAck(sid, "done");
}
@@ -22479,7 +22599,7 @@ function WorktableSection(props) {
const bound = projectsRef.current.projects.bindings[pid];
if (bound) {
try {
sessionBridge?.sessions?.open?.(bound);
openHostSession(clientCtx, sessionBridge?.sessions, bound);
} catch {
}
}
@@ -22579,7 +22699,7 @@ function WorktableSection(props) {
const bound = pr.bindings[id];
if (bound) {
try {
sessionBridge?.sessions?.open?.(bound);
openHostSession(clientCtx, sessionBridge?.sessions, bound);
} catch {
}
}
@@ -23393,7 +23513,7 @@ function apply(ctx) {
} catch {
}
try {
window.__dshOpenSession = (id) => ctx.sessions?.open?.(id);
window.__dshOpenSession = (id) => openHostSession(ctx, ctx.sessions, id);
window.__dshSessions = ctx.sessions;
window.__dshPromptIntoSession = (id, text2) => promptIntoSession(id, text2);
window.__dshWorkspaces = ctx.workspaces;
@@ -23454,9 +23574,29 @@ function apply(ctx) {
ctx.effect(() => disposeSubscribe, "dsh-worktable: project registry watch");
const sessionsList = ctx.sessions?.list;
if (sessionsList && typeof sessionsList.getSnapshot === "function") {
adaptSessionSnapshot = createSessionSnapshotAdapter();
syncSessionScope(sessionsList);
const disposeScope = sessionsList.subscribe(() => syncSessionScope(sessionsList));
ctx.effect(() => disposeScope, "dsh-worktable: session scope watch");
ctx.inject(["uiSession"], (statusCtx) => {
const source = statusCtx.uiSession?.sessionStatus;
if (!source?.getSnapshot || !source?.subscribe) return;
sessionStatusSource = source;
syncSessionScope(sessionsList);
const disposeStatus = source.subscribe(() => syncSessionScope(sessionsList));
statusCtx.effect(() => () => {
disposeStatus();
if (sessionStatusSource === source) sessionStatusSource = null;
}, "dsh-worktable: session status watch");
});
ctx.inject(["uiConversation"], (conversationCtx) => {
const source = conversationCtx.uiConversation;
if (typeof source?.binding !== "function") return;
sessionConversationSource = source;
conversationCtx.effect(() => () => {
if (sessionConversationSource === source) sessionConversationSource = null;
}, "dsh-worktable: conversation timer source");
});
}
ctx.slots.inject("shell.overlay", () => ctx.slots.register({
name: "shell.overlay",
+1 -1
View File
@@ -115,7 +115,7 @@ function resolveDshHomeSafe() {
cachedDshHome = baseDshHome();
return cachedDshHome;
}
var PLUGIN_VERSION = false ? "dev" : "0.3.3";
var PLUGIN_VERSION = false ? "dev" : "0.3.4";
var name = "dsh-worktable";
var inject = ["webServer", "sessions"];
var HEALTH_PATH = "/api/worktable/health";
+7 -3
View File
@@ -1,7 +1,7 @@
{
"name": "dsh-worktable",
"description": "工作台 Worktable:DeepSeek Harness 侧边栏的 agent 级项目容器(应用抽屉)",
"version": "0.3.3",
"version": "0.3.4",
"repository": {
"type": "git",
"url": "https://github.com/Aisland-SJL/dsh-worktable.git",
@@ -39,10 +39,11 @@
]
},
"compatibility": {
"dsh": "0.1.1-rc.2 || 0.1.2-rc.1",
"dsh": "0.1.1-rc.2 || 0.1.2-rc.1 || 0.2.0-rc.2",
"dshReleases": {
"0.1.1-rc.2": "compatible",
"0.1.2-rc.1": "compatible"
"0.1.2-rc.1": "compatible",
"0.2.0-rc.2": "compatible"
}
}
},
@@ -51,6 +52,9 @@
"check": "node --check lib/index.js && node --check lib/client.js",
"pack": "node release-prep.mjs",
"test:gate": "node client-factory-gate.test.mjs",
"test:input": "node ../04_test/host-input.test.mjs",
"test:session": "node ../04_test/session-compat.test.mjs",
"test:details": "node ../04_test/session-details.test.mjs",
"verify:remote": "node verify-remote.mjs"
},
"peerDependencies": {
+6 -1
View File
@@ -82,7 +82,12 @@ if (patchOk && (patchEntries[0].id !== IDENTITY || patchEntries[0].name !== IDEN
if (!patchOk) process.exit(1)
console.log('[release-prep] identity check passed (' + IDENTITY + ', cordis.patch.yml strict)')
// ---------- 2. 构建 + 语法检查 ----------
// ---------- 2. 会话/输入兼容回归 + 构建 + 语法检查 ----------
const compatTests = ['host-input.test.mjs', 'session-compat.test.mjs', 'session-details.test.mjs']
.map((file) => join(HERE, '..', '04_test', file))
const compatRun = spawnSync(process.execPath, ['--test', ...compatTests], { cwd: HERE, stdio: 'inherit' })
if (compatRun.status !== 0) { console.error('[release-prep] FAIL: session/input compatibility tests'); process.exit(1) }
console.log('[release-prep] session/input compatibility tests passed (source contracts)')
// 构建必带 cwd: HERE(脚本自身位置=01_content),否则从仓库根调用会输出到根 lib/ 造成旧包
const build = spawnSync(process.execPath, [join(HERE, 'build.mjs')], { stdio: 'inherit', cwd: HERE })
if (build.status !== 0) { console.error('[release-prep] FAIL: build failed'); process.exit(1) }
+25 -3
View File
@@ -1,6 +1,28 @@
/** 更新公告正文(v0.3.3:DSH 0.1.2 兼容 + 数据路径修复;呈现时按纯文本保留换行,不做 md 渲染) */
export const CHANGELOG_VERSION = 'v0.3.3'
export const CHANGELOG_V030 = `更新公告 · v0.3.3
/** 更新公告正文(v0.3.4:DSH 0.2 网页端适配;呈现时按纯文本保留换行,不做 md 渲染) */
export const CHANGELOG_VERSION = 'v0.3.4'
export const CHANGELOG_V030 = `更新公告 · v0.3.4
🛠️ 本次更新:适配 DSH 0.2 网页端
【项目与会话联动】接入新版 DSH 的会话识别与跳转接口,适配打开绑定项目、关闭项目后回到原会话、控制室卡片跳转等入口,不替换宿主官方组件。
【新建对话】修复新版 DSH 中只设置项目路径创建会话,可能出现「对话能打开,却无法输入」的问题。新建时先通过宿主登记或复用工作区,再创建会话;预设与模型选择也接入新版接口。此改动不自动迁移已有会话。
【标注输入】适配新版聊天输入框,通过宿主接口追加标注,保留已有草稿与文件引用,不自动发送;无法写入时仍可复制到剪贴板。修复旧版已有文件引用时追加位置计算错误的问题;旧版输入框保留兼容分支,不再误选工作台自己的标注框或文件编辑框。
【控制室状态与计时】适配新版会话状态、子代理列表及最近消息读取。支持识别新的待确认事项:确认过上一条后,下一条仍会重新亮起提醒;计时读取宿主已有的任务或会话起点,取不到有效起点时不显示,不编造运行时长。任务停止不等于任务成功。
【发送与消息预览】适配新版会话的临时持有与释放机制。发送请求失败或被拒绝后,不再换另一条通道重复发送;控制室预览从宿主已加载的消息窗口提取普通文本,不代表读取了完整历史。
【数据与升级】本次未更名或迁移工作台的项目、布局与媒体存储,不需要为了本次更新主动清空浏览器数据。升级前请备份配置及重要数据,确认其他插件也支持目标 DSH 版本;安装后完整退出并重启 DSH,再刷新页面。若仍加载失败,请保留完整错误信息反馈,不要把它当作正常现象。
【验证范围】本轮在 Windows + DSH 0.2.0-rc.2 网页端验证了冷启动、工作台/控制室页面、通过工作台新建并绑定对话、发送短文本并收到模型回复、关闭项目后回切与重新打开;同时补充旧版服务回退与引用坐标等定向回归。兼容声明仅列 0.1.1-rc.2、0.1.2-rc.1、0.2.0-rc.2;前两个版本沿用此前页面验收基础并做代码回归,本轮未重跑整套旧版 GUI,不承诺其他版本全部兼容。
【尚未闭环】实际审批/子代理流程及自动挂载全链路尚未完成业务验收。官方桌面端尚未适配,macOS 尚未真机验证;本次也不代表所有用户的 DSH 启动失败已解决。公开发布信息以 GitHub Release 为准。
───────
此前版本说明(v0.3.3)
🛠️ 本版本更新
+54
View File
@@ -0,0 +1,54 @@
import { currentSessionOf } from './sessionCompat'
/** Append annotations without sending or replacing existing reference chips. */
export function appendHostInput(bridge: any, text: string, doc: Document = document): boolean {
try {
const resolver = bridge?.conversation?.input
if (typeof resolver?.for === 'function') {
const snapshot = bridge.sessions?.list?.getSnapshot?.()
const id = currentSessionOf(snapshot)
const scope = id ? bridge.sessions?.scope?.(id) : null
if (!scope) return false
const input = resolver.for(scope)
const state = input?.state?.getSnapshot?.()
if (!state || typeof state.draft !== 'string' || !Number.isInteger(state.draftRev)
|| !['plain', 'claimed'].includes(state.phase) || !Array.isArray(state.occurrences)) return false
// DSH 0.1's current-based list uses display-text coordinates. DSH 0.2's
// mainView list uses one U+FFFC unit per chip in insertion coordinates.
const compactReferences = typeof snapshot?.current !== 'string'
let end = state.draft.length
let previousEnd = 0
for (const item of state.occurrences) {
if (!Number.isInteger(item.offset) || !Number.isInteger(item.length)
|| item.length < 0 || item.offset < previousEnd
|| item.offset + item.length > state.draft.length) return false
previousEnd = item.offset + item.length
if (compactReferences) end += 1 - item.length
}
const span = { start: end, end, draftRev: state.draftRev }
const addition = (state.draft.trim() ? '\n\n' : '') + text
// Public action face provides revision/phase guards and an undo boundary.
// The scoped event is the public compatibility path when actions are absent.
const inserted = typeof input.actions?.insertText === 'function'
? input.actions.insertText(addition, span) === true
: scope.bail?.('slash/input-insert-text', { text: addition, span }) === true
if (inserted) { try { input.focus?.() } catch {} }
return inserted
}
// Legacy DSH 0.1 textarea. Never select an arbitrary textarea: it may be
// the annotation editor itself or an open Worktable file editor.
const ta = doc.querySelector<HTMLTextAreaElement>('textarea[data-phase]')
if (!ta || ta.disabled || ta.readOnly || ta.closest('.dsh-wt_annotUi')) return false
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
const next = ta.value && ta.value.trim() ? ta.value + '\n\n' + text : text
if (setter) setter.call(ta, next)
else ta.value = next
ta.dispatchEvent(new Event('input', { bubbles: true }))
try {
ta.focus()
ta.dispatchEvent(new Event('change', { bubbles: true }))
ta.setSelectionRange(ta.value.length, ta.value.length)
} catch {}
return true
} catch { return false }
}
+87 -188
View File
@@ -3,6 +3,9 @@ import { css } from './styles'
import { NS, zh, en, type WorktableKey } from './locales'
import { isAbs, joinPath, parentPathOf, basenameOf } from './pathutil'
import { splitStore, SplitWorkspace, setSplitT, setSplitEnv, type LayoutSpec, type SplitPane, type ConsoleCardData } from './split'
import { appendHostInput } from './hostInput'
import { currentSessionOf, openHostSession, sendHostSession, createSessionSnapshotAdapter, childSessionIdsOf, pendingAckOf, sessionRuntimeMs } from './sessionCompat'
import { cleanPreviewText, previewFromEvents, readSessionPreview, presetApiOf, modelApiOf, createHostSession } from './sessionDetails'
import { photoStore, kindOf } from './photoStore'
import { DEFAULT_BG_SVG, b64ToBlob } from './defaultBg'
import { WAVE_BG_B64 } from './waveBg'
@@ -421,7 +424,7 @@ let hostApi: { agentPresets?: any; sessions?: any } | null = null
/** 新会话显式应用「部署默认预设」:宿主新会话座位同款逻辑(api.agentPresets.select,仅对 blank 会话生效)。
* 修复:用户删掉默认模型后,新建会话继承到失效预设,prompt 报 model-unavailable 导致窗口建不出来。 */
async function ensureSessionPreset(sessionId: string): Promise<void> {
const api = hostApi?.agentPresets
const api = presetApiOf(clientCtx, hostApi?.agentPresets)
if (!api || typeof api.list !== 'function' || typeof api.select !== 'function') return
try {
const listRes = await api.list({})
@@ -475,7 +478,7 @@ function pickInheritedModel(groups: any[], baseModel: string | undefined): { pro
* 不擅自选模型——① 优先继承用户当前会话正在用的模型;② 兜底按失效选择里的模型家族词
* 找同款;③ 最后才目录首个。session.selectModel 同时把新选择存为默认,顺带修复后续新会话。 */
async function ensureSessionModel(sessionId: string): Promise<void> {
const api = hostApi?.sessions
const api = modelApiOf(clientCtx, sessionBridge?.sessions, hostApi?.sessions)
if (!api || typeof api.models !== 'function' || typeof api.selectModel !== 'function') return
try {
const mRes = await api.models({ sessionId })
@@ -486,7 +489,7 @@ async function ensureSessionModel(sessionId: string): Promise<void> {
let effort: string | undefined
// ① 无条件继承用户当前会话的选择(用户正在用哪个模型,新会话就用哪个)
try {
const cur = sessionBridge?.list?.getSnapshot?.()?.current
const cur = currentSessionOf(sessionBridge?.list?.getSnapshot?.())
if (cur && cur !== sessionId) {
const cRes = await api.models({ sessionId: cur })
if (cRes?.result?.ok) {
@@ -582,57 +585,14 @@ function listWorkspaces(): { id: string; title: string; path: string }[] {
} catch { return [] }
}
/** 预览文本清洗:去掉围栏代码块(```…```,含 dsh-ui 等)与行内代码,压缩空白;
* 代码为主的片段会被滤空 → 调用方回退到更早的消息。只清洗显示用副本,不改原文。 */
function cleanPreviewText(raw: string): string {
let s = String(raw ?? '')
s = s.replace(/```[a-zA-Z0-9_+-]*[\s\S]*?```/g, ' ')
s = s.replace(/```[a-zA-Z0-9_+-]*[\s\S]*$/g, ' ')
s = s.replace(/`[^`\n]{1,200}`/g, ' ')
s = s.replace(/```/g, ' ')
s = s.replace(/\s+/g, ' ').trim()
return s
}
/** 冷会话最近消息缓存与预热:宿主 history 只读通道(face.history 为运行期内建方法,非公开接口,
* 只读侦察确认可用;拉取是带宽成本不是 Token 成本,模型不参与)。 */
/** 冷会话最近消息缓存与预热:0.2 临时持有会话、读 eventSource 后释放;旧版保留 history 通道。
* 只读消息文本,不请求模型;拉取是带宽成本,不产生模型 Token。 */
const previewCache = new Map<string, string>()
const previewFetching = new Set<string>()
let previewSweepBusy = false
let previewTimer: number | null = null
/** 从 history 事件流尾部提取最近一条成品消息文本(优先 text 块;清洗代码后仍太短则回退更早消息) */
async function coldPreviewOf(face: any): Promise<string> {
if (!face || typeof face.history !== 'function') return ''
const r1 = await face.history({ maxMessages: 6 })
const evs = r1?.result?.value?.events
if (!Array.isArray(evs)) return ''
const textOf = (blocks: any): string => {
let fallback = ''
if (!Array.isArray(blocks)) return ''
for (const b of blocks) {
const s = typeof b?.text === 'string' ? b.text.trim() : ''
if (!s) continue
if (b?.type === 'text') return s
if (!fallback) fallback = s
}
return fallback
}
for (let i = evs.length - 1; i >= 0; i--) {
const ev = evs[i]?.event
if (!ev) continue
const d = ev.data ?? {}
let raw = ''
if (ev.type === 'user/message') raw = textOf(d.content ?? d.blocks)
else if (ev.type === 'assistant/message') { const m = d.message ?? d; raw = textOf(m.content ?? m.blocks) }
if (!raw) continue
const clean = cleanPreviewText(raw)
if (clean.length >= 8) return clean.slice(0, 220)
}
return ''
}
/** 预热所有已绑定会话的最近消息(逐个只读拉取;已在拉/已拉过未完成的跳过,失败静默回退内存路径) */
/** 预热所有已绑定会话的最近消息(逐个只读拉取;同一在途请求不重复,失败回退缓存/内存路径) */
async function sweepPreviews() {
if (previewSweepBusy) return
previewSweepBusy = true
@@ -642,9 +602,9 @@ async function sweepPreviews() {
if (previewFetching.has(sid)) continue
previewFetching.add(sid)
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session
const txt = await coldPreviewOf(face)
if (txt) { previewCache.set(sid, txt); notifyConsole() }
const txt = await readSessionPreview(sessionBridge?.sessions, sid)
previewCache.set(sid, txt)
notifyConsole()
} catch {} finally { previewFetching.delete(sid) }
}
} finally { previewSweepBusy = false }
@@ -652,14 +612,17 @@ async function sweepPreviews() {
/** 防抖调度:控制室开着且会话快照变化时刷新预览(合并 6s 内的连续变化) */
function schedulePreviewSweep() {
if (previewTimer != null) return
// Temporary retain/release also changes the catalog: do not schedule ourselves forever.
if (previewSweepBusy || previewTimer != null) return
previewTimer = window.setTimeout(() => { previewTimer = null; sweepPreviews() }, 6000)
}
/** 从会话快照节点里提取最近一条文本(纯读内存镜像,零 Token;代码清洗后无则 '') */
function lastTextOf(sid: string): string {
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.()
const binding = sessionBridge?.sessions?.binding?.(sid)
if (binding?.eventSource?.getSnapshot) return previewFromEvents(binding.eventSource.getSnapshot().entries)
const face = binding?.session?.getSnapshot?.()
const nodes: any[] = face?.nodes ?? []
for (let i = nodes.length - 1; i >= 0; i--) {
const n = nodes[i]
@@ -684,7 +647,7 @@ async function fetchSessionGroups(): Promise<{ groups: { title: string; sessions
try {
const snap = sessionBridge?.list?.getSnapshot?.()
const byId = snap?.byId ?? {}
const current = snap?.current ?? ''
const current = currentSessionOf(snap)
// 子代理会话(后台产生的,用户面板看不到)排除
const subKids = new Set<string>()
try {
@@ -763,33 +726,7 @@ function hideBindTip() {
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')
await sendHostSession(b, sessionId, text)
}
/** 新建会话的分组选择:未分组 / 加入现有分组 / 新建一个分组(父目录 + 名称) */
@@ -870,11 +807,11 @@ export async function createCustomSession(projectId: string, projectName: string
let createOpts: any = {}
if (workspaceId) createOpts = { workspaceId }
else if (folder) createOpts = { cwd: folder }
const sessionId = await b.sessions.create(createOpts)
const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts)
await ensureSessionPreset(sessionId) // 新会话应用部署默认预设
await ensureSessionModel(sessionId) // 修复继承失效 provider(selectModel 同步存默认,顺带修复后续新会话)
markPluginSessionOpen(sessionId) // 插件发起的切换:不触发「切会话关项目」联动
try { await b.sessions.open?.(sessionId) } catch {}
try { await openHostSession(clientCtx, b.sessions, sessionId) } catch {}
await promptIntoSession(sessionId, text)
return sessionId
}
@@ -885,7 +822,7 @@ export async function sendCustomToSession(sessionId: string, projectId: string,
if (!b) throw new Error('bridge unavailable')
const text = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, 'send')
markPluginSessionOpen(sessionId) // 插件发起的切换:不触发「切会话关项目」联动
try { await b.sessions.open?.(sessionId) } catch {}
try { await openHostSession(clientCtx, b.sessions, sessionId) } catch {}
await promptIntoSession(sessionId, text)
}
@@ -915,7 +852,7 @@ const sessionScopeStore: {
/** 完整会话快照(模块级):项目卡片的任务完成/待决提醒镜像数据源 */
const sessionsSnapshotStore: { snapshot: any | null; listeners: Set<() => void> } = { snapshot: null, listeners: new Set() }
/** 提醒确认(ack)持久化:会话 id → 已确认的状态('done' | 'need') */
/** 提醒确认:会话 id → done / 旧 need / need:不含正文的待决身份集合。 */
function loadNotifyAck(): Record<string, string> {
try {
const raw = localStorage.getItem('dsh.worktable.notifyAck.v1')
@@ -986,8 +923,8 @@ function buildMountContent(folder: string, d: any): any {
return { kind: 'iframe', url: '/api/worktable/site/' + encodeURIComponent(dir) + '/' + encodeURIComponent(name), title: name }
}
/** 每会话最近一次观察到的「需要判断」布尔值:状态转移时清除旧 ack(新一轮待决重新点亮) */
const notifyStateSeenRef: { current: Record<string, boolean> } = { current: {} }
/** 每会话最近观察到的待决身份;同为 need 的问题替换也必须重新点亮。 */
const notifyStateSeenRef: { current: Record<string, string | null> } = { current: {} }
/** 每会话最近观察到的「已完成」布尔值:新一次完成时清除旧 done ack(控制室绿光重新点亮) */
const doneSeenRef: { current: Record<string, boolean> } = { current: {} }
/** 清除某会话的 ack 记录(状态转移时调用,保证新问题不会被旧确认压住) */
@@ -1010,10 +947,18 @@ function sessionNotifyState(entry: any): 'done' | 'need' | null {
return null
}
let sessionStatusSource: any = null
let sessionConversationSource: any = null
let adaptSessionSnapshot = createSessionSnapshotAdapter()
function pendingAckForSession(sid: string): string | null {
let face: any
try { face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.() } catch {}
return pendingAckOf(sessionsSnapshotStore.snapshot, sid, face)
}
function syncSessionScope(list: any) {
try {
const snap = list.getSnapshot()
const current: string = snap?.current ?? ''
const snap = adaptSessionSnapshot(list.getSnapshot(), sessionStatusSource)
const current = currentSessionOf(snap)
const entry = snap?.byId?.[current] ?? snap?.items?.find((it: any) => it.sessionId === current) ?? null ?? null
const cat = snap?.subagentsByParent?.[current]
let subagents: any[] = []
@@ -1249,32 +1194,14 @@ function WorktableSection(props: any) {
const pr = projectsRef.current
const snap = sessionsSnapshotStore.snapshot
const byId: Record<string, any> = snap?.byId ?? {}
const jobsMap: Record<string, any[]> = snap?.jobsBySession ?? {}
const now = Date.now()
const kidsSetOf = (sid: string): Set<string> => {
const set = new Set<string>()
for (const [cid, ce] of Object.entries<any>(byId)) if (ce?.parentId === sid) set.add(cid)
const v = (snap?.subagentsByParent ?? {})[sid]
const arr = Array.isArray(v) ? v : (v?.entries ?? v?.items ?? [])
if (Array.isArray(arr)) arr.forEach((c: any) => {
const cid = c?.sessionId ?? c?.id
if (typeof cid === 'string') set.add(cid)
})
return set
}
const kidsSetOf = (sid: string) => childSessionIdsOf(snap, sid)
// 三态判定(同卡片提醒逻辑,但不过滤 ack——监控室永远显示事实状态)
const statusOf = (sid: string | undefined): ConsoleCardData['status'] => {
if (!sid) return 'idle'
const e = byId[sid]
if (!e) return 'idle'
if (sessionNotifyState(e) === 'need') return 'need'
for (const cid of kidsSetOf(sid)) {
if (byId[cid] && sessionNotifyState(byId[cid]) === 'need') return 'need'
}
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.()
if (Array.isArray(face?.pending) && face.pending.length > 0) return 'need'
} catch {}
if (pendingAckForSession(sid) != null) return 'need'
if (e.completed === true) return 'done'
// 会话面兜底:宿主快照可能滞后或缺少完成字段
try {
@@ -1284,39 +1211,17 @@ function WorktableSection(props: any) {
if (e.running === true) return 'busy'
return 'idle'
}
// 运行时长:本会话正在运行的后台任务最早 startedAt 起算;无任务时读会话面 turnTimings 未结束轮次
// 运行时长:任务 startedAt → 新版 chat legacy.turnTimings → 旧会话面;未知不虚构。
const runtimeOf = (sid: string | undefined): number | null => {
if (!sid) return null
const e = byId[sid]
if (!e || e.running !== true) return null
let start: number | null = null
const jobs = jobsMap[sid] ?? []
for (const j of jobs) {
if (j?.status === 'running' && typeof j.startedAt === 'number' && (start == null || j.startedAt < start)) start = j.startedAt
}
if (start != null) return Math.max(0, now - start)
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.()
const timings = face?.turnTimings
if (timings instanceof Map) {
for (const t of Array.from(timings.values()).reverse()) {
if (t && typeof t.startTime === 'number' && t.endTime == null) return Math.max(0, now - t.startTime)
}
} else if (timings && typeof timings === 'object') {
for (const k of Object.keys(timings).reverse()) {
const t = (timings as any)[k]
if (t && typeof t.startTime === 'number' && t.endTime == null) return Math.max(0, now - t.startTime)
}
}
} catch {}
return null
return sessionRuntimeMs(snap, sid, sessionBridge?.sessions, sessionConversationSource, now)
}
const ackMap = loadNotifyAck()
const make = (id: string, name: string, icon: string, self: boolean): ConsoleCardData => {
const sid = pr.projects.bindings[id]
const status = statusOf(sid)
// 发光 = 完成/待决且本轮未被确认;点卡片确认后熄灭
const glow = !!sid && ((status === 'done' && ackMap[sid] !== 'done') || (status === 'need' && ackMap[sid] !== 'need'))
const glow = !!sid && ((status === 'done' && ackMap[sid] !== 'done') || (status === 'need' && ackMap[sid] !== pendingAckForSession(sid)))
return {
id, name, icon,
status,
@@ -1361,6 +1266,7 @@ function WorktableSection(props: any) {
// 崩溃),改为 apply 里订阅 ctx.sessions.list 后写入模块级 store,此处直接读取。
useEffect(() => {
const env = {
fillHostInput: (text: string) => appendHostInput(sessionBridge, text),
getScope: () => {
const s = sessionScopeStore.snapshot
return s ? { sessionId: s.sessionId, cwd: s.cwd } : null
@@ -1481,14 +1387,14 @@ function WorktableSection(props: any) {
else {
// 入驻项目无视图覆盖:仅切换其绑定对话(对齐卡片自带打开行为)
const bound = pr.bindings[id]
if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} }
if (bound) { try { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} }
}
},
onJump: (id) => {
const sid = projectsRef.current.projects.bindings[id]
if (!sid) return
markPluginSessionOpen(sid) // 插件发起的切换:不触发「切会话关项目」联动
try { sessionBridge?.sessions?.open?.(sid) } catch {}
try { openHostSession(clientCtx, sessionBridge?.sessions, sid) } catch {}
},
},
}
@@ -1537,7 +1443,7 @@ function WorktableSection(props: any) {
if (!splitStore.active) {
if (!suppressRestoreRef.current) {
const prev = projectAttachRef.sessionId
if (prev) { try { sessionBridge?.sessions?.open?.(prev) } catch {} }
if (prev) { try { openHostSession(clientCtx, sessionBridge?.sessions, prev) } catch {} }
}
projectAttachRef.sessionId = null
projectAttachRef.attached = null
@@ -1722,11 +1628,11 @@ function buildCustomLayoutPrompt(req: string): string {
if (splitStore.active && splitStore.spec?.id === spec.id) {
// 记录打开前会话(关项目时回切)与该项目「归属会话」(切到别的会话 = 自动关项目)
let prev: string | null = null
try { prev = sessionBridge?.list?.getSnapshot?.()?.current ?? null } catch {}
try { prev = currentSessionOf(sessionBridge?.list?.getSnapshot?.()) || null } catch {}
projectAttachRef.sessionId = prev
projectAttachRef.attached = projectsRef.current.projects.bindings[spec.id] ?? prev
const bound = projectsRef.current.projects.bindings[spec.id]
if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} }
if (bound) { try { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} }
ackProjectNotify(spec.id)
// 补挂:此前项目未打开时暂存的产物(entries = 多窗口挂载列表),现在自动挂进各目标窗格;
// 全部落位成功记录指纹(供自愈扫挂去重)
@@ -1760,14 +1666,14 @@ function buildCustomLayoutPrompt(req: string): string {
splitStore.open(spec)
if (splitStore.active && splitStore.spec?.id === CONSOLE_ID) {
let prev: string | null = null
try { prev = sessionBridge?.list?.getSnapshot?.()?.current ?? null } catch {}
try { prev = currentSessionOf(sessionBridge?.list?.getSnapshot?.()) || null } catch {}
projectAttachRef.sessionId = prev
const bound = explicitBound !== undefined ? explicitBound : projectsRef.current.projects.bindings[CONSOLE_ID]
projectAttachRef.attached = bound ?? prev
if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} }
if (bound) { try { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} }
ackProjectNotify(CONSOLE_ID)
try { notifyConsole() } catch {}
// 打开即预热所有绑定会话的最近消息(冷会话走 history 只读通道)
// 打开即预热所有绑定会话的最近消息(新宿主临时持有,旧宿主 history)
try { sweepPreviews() } catch {}
}
}, [projects.views, t])
@@ -1819,7 +1725,7 @@ function buildCustomLayoutPrompt(req: string): string {
let createOpts: any = {}
if (workspaceId) createOpts = { workspaceId }
else if (folder) createOpts = { cwd: folder }
const sessionId = await b.sessions.create(createOpts)
const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts)
await ensureSessionPreset(sessionId) // 管理对话同样应用部署默认预设
await ensureSessionModel(sessionId) // 管理对话同样修复失效 provider
markPluginSessionOpen(sessionId)
@@ -2095,20 +2001,7 @@ function buildCustomLayoutPrompt(req: string): string {
/** 收集某会话的子代理 id 集:byId 的 parentId 标注 + subagentsByParent 目录双通道 */
const collectKids = useCallback((sid: string): Set<string> => {
const kids = new Set<string>()
const snap = sessionsSnapshotStore.snapshot
const byId = snap?.byId ?? {}
for (const [cid, ce] of Object.entries<any>(byId)) {
if (ce?.parentId === sid) kids.add(cid)
}
const subMap = snap?.subagentsByParent ?? {}
const v = (subMap as any)[sid]
const arr = Array.isArray(v) ? v : (v?.entries ?? v?.items ?? [])
if (Array.isArray(arr)) arr.forEach((c: any) => {
const cid = c?.sessionId ?? c?.id
if (typeof cid === 'string') kids.add(cid)
})
return kids
return childSessionIdsOf(sessionsSnapshotStore.snapshot, sid)
}, [])
/** 项目 → 提醒态:绑定会话(含其子代理)待决(黄) > 完成(绿) > 工作中(蓝);
@@ -2121,26 +2014,13 @@ function buildCustomLayoutPrompt(req: string): string {
for (const [pid, sid] of Object.entries(projects.bindings)) {
const e = byId[sid]
if (!e) continue
// 是否需要判断:自身列表字段 / 子代理 / 会话面兜底,三通道聚合
let needNow = sessionNotifyState(e) === 'need'
if (!needNow) {
for (const cid of collectKids(sid)) {
const ce = byId[cid]
if (ce && sessionNotifyState(ce) === 'need') { needNow = true; break }
}
}
if (!needNow) {
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.()
if (Array.isArray(face?.pending) && face.pending.length > 0) needNow = true
} catch {}
}
const pendingAck = pendingAckForSession(sid)
// 状态转移 → 清除旧 ack(新一轮待决重新点亮)
if (needNow !== seen[sid]) {
if (seen[sid] !== undefined) clearNotifyAck(sid)
seen[sid] = needNow
if (pendingAck !== seen[sid]) {
if (seen[sid] !== undefined) { clearNotifyAck(sid); delete ack[sid] }
seen[sid] = pendingAck
}
if (needNow) { if (ack[sid] !== 'need') map[pid] = 'need'; continue }
if (pendingAck != null) { if (ack[sid] !== pendingAck) map[pid] = 'need'; continue }
if (sessionNotifyState(e) === 'done' && ack[sid] !== 'done') { map[pid] = 'done'; continue }
if (e.running === true) map[pid] = 'busy'
}
@@ -2152,17 +2032,14 @@ function buildCustomLayoutPrompt(req: string): string {
const sid = projectsRef.current.projects.bindings[projectId]
if (!sid) return
const byId = sessionsSnapshotStore.snapshot?.byId ?? {}
let needNow = sessionNotifyState(byId[sid]) === 'need'
const pendingAck = pendingAckForSession(sid)
for (const cid of collectKids(sid)) {
if (sessionNotifyState(byId[cid]) === 'need') { needNow = true; saveNotifyAck(cid, 'need') }
if (sessionNotifyState(byId[cid]) === 'need') {
const childAck = pendingAckForSession(cid)
if (childAck != null) { saveNotifyAck(cid, childAck); notifyStateSeenRef.current[cid] = childAck }
}
}
if (!needNow) {
try {
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.()
if (Array.isArray(face?.pending) && face.pending.length > 0) needNow = true
} catch {}
}
if (needNow) saveNotifyAck(sid, 'need')
if (pendingAck != null) { saveNotifyAck(sid, pendingAck); notifyStateSeenRef.current[sid] = pendingAck }
else {
const st = sessionNotifyState(byId[sid])
if (st === 'done') saveNotifyAck(sid, 'done')
@@ -2543,7 +2420,7 @@ function buildCustomLayoutPrompt(req: string): string {
}
// 无视图覆盖:项目自带打开行为照旧,仅当绑定了会话时切换右侧对话窗
const bound = projectsRef.current.projects.bindings[pid]
if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} }
if (bound) { try { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} }
ackProjectNotify(pid)
}
document.addEventListener('click', onDocClick, true)
@@ -2655,7 +2532,7 @@ function buildCustomLayoutPrompt(req: string): string {
if (view || layout) openSplit((view ?? layout) as LayoutSpec)
else {
const bound = pr.bindings[id]
if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} }
if (bound) { try { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} }
}
}
@@ -3308,7 +3185,7 @@ export function apply(ctx: any) {
clientCtx = ctx
try { hostApi = ctx.get?.('connection')?.api ?? null } catch { hostApi = null }
try { (window as any).__dshHostApi = hostApi } 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); (window as any).__dshWorkspaces = ctx.workspaces; (window as any).__dshBuildWindowTaskText = buildWindowTaskText; (window as any).__dshSyncSessionScope = () => syncSessionScope(sessionBridge?.list) } catch {}
try { (window as any).__dshOpenSession = (id: string) => openHostSession(ctx, ctx.sessions, id); (window as any).__dshSessions = ctx.sessions; (window as any).__dshPromptIntoSession = (id: string, text: string) => promptIntoSession(id, text); (window as any).__dshWorkspaces = ctx.workspaces; (window as any).__dshBuildWindowTaskText = buildWindowTaskText; (window as any).__dshSyncSessionScope = () => syncSessionScope(sessionBridge?.list) } catch {}
ctx.effect(() => {
@@ -3367,9 +3244,31 @@ export function apply(ctx: any) {
// 会话作用域(当前会话 cwd 与后台任务)→ 功能窗数据源
const sessionsList = ctx.sessions?.list
if (sessionsList && typeof sessionsList.getSnapshot === 'function') {
adaptSessionSnapshot = createSessionSnapshotAdapter()
syncSessionScope(sessionsList)
const disposeScope = sessionsList.subscribe(() => syncSessionScope(sessionsList))
ctx.effect(() => disposeScope, 'dsh-worktable: session scope watch')
// Optional dependency: older DSH hosts have no uiSession service.
ctx.inject(['uiSession'], (statusCtx: any) => {
const source = statusCtx.uiSession?.sessionStatus
if (!source?.getSnapshot || !source?.subscribe) return
sessionStatusSource = source
syncSessionScope(sessionsList)
const disposeStatus = source.subscribe(() => syncSessionScope(sessionsList))
statusCtx.effect(() => () => {
disposeStatus()
if (sessionStatusSource === source) sessionStatusSource = null
}, 'dsh-worktable: session status watch')
})
// Optional read-only timer source; getting a snapshot does not activate a chat target.
ctx.inject(['uiConversation'], (conversationCtx: any) => {
const source = conversationCtx.uiConversation
if (typeof source?.binding !== 'function') return
sessionConversationSource = source
conversationCtx.effect(() => () => {
if (sessionConversationSource === source) sessionConversationSource = null
}, 'dsh-worktable: conversation timer source')
})
}
// 分栏工作区浮层(M1 通用引擎,shell.overlay 座位)
+149
View File
@@ -0,0 +1,149 @@
/** DSH 0.1 stores current on the list; DSH 0.2 uses mainView retention. */
export function currentSessionOf(snapshot: any): string {
if (typeof snapshot?.current === 'string') return snapshot.current
const rows = Object.values(snapshot?.byId ?? {}) as any[]
return rows.find((row) => (row?.retainedBy?.mainView ?? 0) > 0)?.id ?? ''
}
/** Navigate through the host's UI owner, without modifying the host service. */
export function openHostSession(ctx: any, sessions: any, id: string): any {
// Cordis property access throws for an uninjected service; get() safely returns undefined.
const ui = typeof ctx?.get === 'function' ? ctx.get('uiWorkspace') : ctx?.uiWorkspace
if (typeof ui?.openSession === 'function') return ui.openSession(id)
if (typeof sessions?.open === 'function') return sessions.open(id)
throw new Error('Host session navigation unavailable')
}
/** Own a temporary reference on 0.2; borrowing a cold binding is no longer sufficient. */
export async function sendHostSession(bridge: any, id: string, text: string): Promise<void> {
const sessions = bridge?.sessions
const check = (result: any) => {
if (result?.kind === 'error' || result?.ok === false) {
throw new Error(result.text || result.error?.message || result.error?.code || 'Session submission rejected')
}
}
const send = async (session: any) => {
// Select one available transport BEFORE sending. A rejection/timeout must never retry
// through a second transport: the first request may already have reached the host.
if (session && typeof bridge?.conversation?.sendSession === 'function') {
check(await bridge.conversation.sendSession(session, text, [], 'queue'))
return
}
if (session && typeof session.prompt === 'function') {
check(await session.prompt([{ type: 'text', text }], 'queue'))
return
}
const conv = sessions?.scope?.(id)?.get?.('conversation')
if (typeof conv?.send === 'function') { check(await conv.send(text)); return }
throw new Error('no send path: session face unavailable')
}
if (typeof sessions?.using === 'function') {
await sessions.using(id, { source: 'dshWorktable' }, async (ref: any) => {
const binding = await ref.ready
await send(binding.session)
})
return
}
let face: any = null
for (let i = 0; i < 10; i++) {
face = sessions?.binding?.(id)?.session
if (face) break
await new Promise((resolve) => setTimeout(resolve, 200))
}
await send(face)
}
/** Read-only adapter. A stopped run is not proof that a task succeeded. */
export function createSessionSnapshotAdapter() {
const previousRunning = new Map<string, boolean>()
const stopped = new Set<string>()
return (snapshot: any, statusSource?: any): any => {
// Preserve the legacy contract verbatim; idle old sessions must not become completed.
if (!snapshot || typeof snapshot.current === 'string') return snapshot
const statuses = statusSource?.getSnapshot?.()
const byId: Record<string, any> = {}
const subagentsByParent = { ...snapshot.subagentsByParent }
for (const [id, row] of Object.entries(snapshot.byId ?? {}) as [string, any][]) {
const status = statuses?.get?.(id)
const running = status?.running ?? row.running
if (running === true) stopped.delete(id)
else if (running === false && previousRunning.get(id) === true) stopped.add(id)
if (typeof running === 'boolean') previousRunning.set(id, running)
byId[id] = {
...row, running,
pendingInteraction: status ? status.pendingInteraction : row.pendingInteraction,
// completionUnread clears when opening the main view. Keep a witnessed run-stop
// until the next run so an active project's widget-result handshake still runs.
completed: running !== true && (stopped.has(id) || status?.completionUnread === true || row.completed === true),
}
const children = snapshot.projectionsBySession?.[id]?.values?.subagentCatalog ?? row.projectionValues?.subagentCatalog
if (Array.isArray(children)) subagentsByParent[id] = children
}
if (snapshot.phase === 'ready') {
for (const id of previousRunning.keys()) if (!byId[id]) { previousRunning.delete(id); stopped.delete(id) }
}
return { ...snapshot, byId, subagentsByParent }
}
}
/** Parent links and the host's child catalog can each contain otherwise missing children. */
export function childSessionIdsOf(snapshot: any, sid: string): Set<string> {
const children = new Set<string>()
for (const [id, row] of Object.entries(snapshot?.byId ?? {}) as [string, any][]) {
if (id !== sid && row?.parentId === sid) children.add(id)
}
const catalog = snapshot?.subagentsByParent?.[sid]
const rows = Array.isArray(catalog) ? catalog : (catalog?.entries ?? catalog?.items ?? [])
if (Array.isArray(rows)) for (const row of rows) {
const id = row?.sessionId ?? row?.id
if (typeof id === 'string' && id !== sid) children.add(id)
}
return children
}
/** Persist only opaque interaction identities, never their questions or answers.
* Legacy hosts with no interaction key keep the old boolean-state acknowledgement.
* New hosts must not let acknowledgement of question A suppress replacement B.
*/
export function pendingAckOf(snapshot: any, sid: string, legacyFace?: any): string | null {
const identities: string[] = []
let identified = false
const add = (id: string, pending: any) => {
if (pending == null) return
const key = typeof pending?.key === 'string' ? pending.key : ''
if (key) identified = true
identities.push(JSON.stringify([id, String(pending?.kind ?? ''), key]))
}
const ids = [sid, ...childSessionIdsOf(snapshot, sid)]
for (const id of ids) add(id, snapshot?.byId?.[id]?.pendingInteraction)
// Read the old face only when the parent list has not already supplied its pending state.
if (snapshot?.byId?.[sid]?.pendingInteraction == null && Array.isArray(legacyFace?.pending)) {
for (const pending of legacyFace.pending) add(sid, pending)
}
if (!identities.length) return null
return identified ? 'need:' + JSON.stringify([...new Set(identities)].sort()) : 'need'
}
/** Read host memory only. Do not activate a chat target or retain a cold session to get a timer. */
export function sessionRuntimeMs(snapshot: any, sid: string, sessions: any, uiConversation?: any, now = Date.now()): number | null {
if (snapshot?.byId?.[sid]?.running !== true) return null
const validStart = (value: any): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0
let start: number | null = null
for (const job of snapshot?.jobsBySession?.[sid] ?? []) {
if (job?.status === 'running' && validStart(job.startedAt) && (start == null || job.startedAt < start)) start = job.startedAt
}
if (start != null) return Math.max(0, now - start)
let timings: any
try { timings = uiConversation?.binding?.(sid)?.target?.('chat')?.getSnapshot?.()?.legacy?.turnTimings } catch {}
if (timings == null) {
try { timings = sessions?.binding?.(sid)?.session?.getSnapshot?.()?.turnTimings } catch {}
}
// ReadonlyMap/another realm's Map must work as well as the old object representation.
const turns: any[] = typeof timings?.values === 'function'
? Array.from(timings.values()) : Object.values(timings ?? {})
for (const turn of turns) {
if (turn?.endTime == null && validStart(turn?.startTime) && (start == null || turn.startTime > start)) start = turn.startTime
}
return start == null ? null : Math.max(0, now - start)
}
+75
View File
@@ -0,0 +1,75 @@
/** Text-only preview. Never returns reasoning, tool arguments, or modifies stored messages. */
export function cleanPreviewText(raw: string): string {
return String(raw ?? '')
.replace(/```[a-zA-Z0-9_+-]*[\s\S]*?```/g, ' ')
.replace(/```[a-zA-Z0-9_+-]*[\s\S]*$/g, ' ')
.replace(/`[^`\n]{1,200}`/g, ' ')
.replace(/```/g, ' ')
.replace(/\s+/g, ' ').trim()
}
export function previewFromEvents(entries: any): string {
if (!Array.isArray(entries)) return ''
for (let i = entries.length - 1; i >= 0; i--) {
const ev = entries[i]?.event
if (ev?.type !== 'user/message' && ev?.type !== 'assistant/message') continue
const data = ev.type === 'assistant/message' ? ev.data?.message ?? ev.data : ev.data
const blocks = data?.content ?? data?.blocks
if (!Array.isArray(blocks)) continue
const raw = blocks.filter((b: any) => b?.type === 'text' && typeof b.text === 'string').map((b: any) => b.text).join('\n')
const text = cleanPreviewText(raw)
if (text.length >= 8) return text.slice(0, 220)
}
return ''
}
export async function readSessionPreview(sessions: any, id: string): Promise<string> {
if (typeof sessions?.using === 'function') {
return sessions.using(id, { source: 'dshWorktablePreview' }, async (ref: any) => {
const binding = await ref.ready
return previewFromEvents(binding.eventSource?.getSnapshot?.()?.entries)
})
}
const face = sessions?.binding?.(id)?.session
if (typeof face?.history !== 'function') return ''
const result = await face.history({ maxMessages: 6 })
return previewFromEvents(result?.result?.value?.events)
}
/** Normalize only the transport envelope; preserve the existing selection policy. */
export function presetApiOf(ctx: any, legacy: any): any {
const api = typeof ctx?.get === 'function' ? ctx.get('remote.agentPresets') : ctx?.remote?.agentPresets
if (typeof api?.list !== 'function' || typeof api?.select !== 'function') return legacy
return {
list: async () => ({ result: await api.list() }),
select: async ({ sessionId, agentPreset }: any) => ({ result: await api.select(sessionId, agentPreset) }),
}
}
/** 0.2 model directories belong to retained scopes. Do not cache them after release. */
export function modelApiOf(ctx: any, sessions: any, legacy: any): any {
if (typeof sessions?.using !== 'function') return legacy
const resolver = typeof ctx?.get === 'function' ? ctx.get('modelDirectories') : ctx?.modelDirectories
if (typeof resolver?.directoryFor !== 'function') return legacy
const withDirectory = (id: string, operation: (directory: any) => Promise<any>) =>
sessions.using(id, { source: 'dshWorktableModel' }, async (ref: any) => {
await ref.ready
return operation(resolver.directoryFor(id))
})
return {
models: async ({ sessionId }: any) => withDirectory(sessionId, async (directory) => ({ result: { ok: true, value: await directory.load() } })),
selectModel: async ({ sessionId, ...selection }: any) => withDirectory(sessionId, async (directory) => ({ result: await directory.select(selection) })),
}
}
/** 0.2's blank composer requires a registered Workspace, not only a Session cwd. */
export async function createHostSession(sessions: any, workspaces: any, options: { workspaceId?: string; cwd?: string; sessionId?: string } = {}): Promise<string> {
if (options.workspaceId || typeof sessions?.using !== 'function') return sessions.create(options)
// The Host owns path canonicalization and idempotent registration. Never invent an id.
const workspace = options.cwd
? await workspaces?.create?.({ path: options.cwd })
: await workspaces?.initializeDefault?.()
if (typeof workspace?.workspaceId !== 'string' || !workspace.workspaceId) throw new Error('workspace unavailable for new session')
const { cwd: _cwd, ...rest } = options
return sessions.create({ ...rest, workspaceId: workspace.workspaceId })
}
+2 -19
View File
@@ -220,6 +220,7 @@ export type ConsoleCardData = {
}
type SplitEnv = {
fillHostInput: (text: string) => boolean
getScope: () => SplitScope | null
getJobs: () => SplitJob[]
getSubagents: () => any[]
@@ -504,25 +505,7 @@ async function copyTextSafe(text: string): Promise<boolean> {
/** 注入宿主对话框输入框(不发送);失败返回 false */
function fillHostInput(text: string): boolean {
try {
const ta = document.querySelector<HTMLTextAreaElement>('textarea[data-phase]')
?? Array.from(document.querySelectorAll<HTMLTextAreaElement>('textarea')).pop()
if (!ta) return false
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
// 连续标注:已有内容不顶掉,换行追加(用户可以同一对话连续标注多处)
const next = ta.value && ta.value.trim() ? ta.value + '\n\n' + text : text
if (setter) setter.call(ta, next)
else ta.value = next
ta.dispatchEvent(new Event('input', { bubbles: true }))
try {
ta.focus()
ta.dispatchEvent(new Event('change', { bubbles: true }))
ta.setSelectionRange(ta.value.length, ta.value.length)
} catch {}
return true
} catch {
return false
}
return splitEnv?.fillHostInput(text) ?? false
}
/** ✓ 确认:打包并注入,不发送 */