diff --git a/01_content/dsh.plugin.json b/01_content/dsh.plugin.json index c30b85a..ded9c8d 100644 --- a/01_content/dsh.plugin.json +++ b/01_content/dsh.plugin.json @@ -1,7 +1,7 @@ { "name": "dsh-worktable", "description": "工作台:DSH 侧边栏的 agent 级项目容器,收纳如 TravelAtlas 等自装项目", - "version": "0.3.2", + "version": "0.3.3", "entry": { "name": "dsh-worktable", "inject": [] diff --git a/01_content/lib/client.js b/01_content/lib/client.js index 05afa2b..37c3506 100644 --- a/01_content/lib/client.js +++ b/01_content/lib/client.js @@ -8896,10 +8896,22 @@ function basenameOf(p) { var import_react = require("react"); // src/client/changelog.ts -var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.3.2 +var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.3.3 \u{1F6E0}\uFE0F \u672C\u7248\u672C\u66F4\u65B0 +\u3010\u517C\u5BB9\u65B0\u7248 DeepSeek Harness\u3011\u9002\u914D\u5DF2\u6D4B\u7248\u672C DSH 0.1.2-rc.1 \u4E0E 0.1.1-rc.2\uFF1A\u70B9\u51FB\u9879\u76EE\u5361\u7247\u4E0D\u518D\u65E0\u53CD\u5E94\u2014\u2014\u5206\u680F\u5DE5\u4F5C\u533A\u5728\u65E0\u4F1A\u8BDD / \u7A7A\u4F1A\u8BDD / \u6709\u6D88\u606F\u4F1A\u8BDD\u4E0B\u90FD\u80FD\u6B63\u5E38\u6253\u5F00\uFF080.1.1-rc.2 \u5B9E\u6D4B\u672A\u53D1\u73B0\u56DE\u5F52\uFF09\uFF1B\u9009\u62E9\u9879\u76EE\u6587\u4EF6\u5939\u7684\u7A97\u53E3\u5728 0.1.2-rc.1 \u4E0A\u6062\u590D\u6B63\u5E38\u53EF\u7528\uFF1B\u5DE5\u4F5C\u53F0\u6570\u636E\u8BFB\u53D6\u4E0D\u518D\u4F9D\u8D56\u9ED8\u8BA4\u5B89\u88C5\u8DEF\u5F84\uFF0C\u81EA\u5B9A\u4E49\u5B89\u88C5\u4F4D\u7F6E\uFF08DSH_HOME\uFF09\u4E5F\u80FD\u6B63\u786E\u8BFB\u53D6\u9879\u76EE\u4E0E\u5206\u7EC4\u6570\u636E\u3002 + +\u3010\u5173\u4E8E\u9009\u62E9\u6587\u4EF6\u5939\u7A97\u53E3\u7684\u5DF2\u77E5\u95EE\u9898\uFF08DeepSeek Harness \u5BBF\u4E3B\u81EA\u8EAB\u95EE\u9898\uFF0C\u4E0D\u662F\u672C\u63D2\u4EF6\u7684\u95EE\u9898\uFF09\u3011\u5728\u90E8\u5206\u7CFB\u7EDF\u4E0A\uFF0C\u70B9\u51FB\u300C\u9009\u62E9\u4F4D\u7F6E\u2026\u300D\u4E4B\u540E\u6587\u4EF6\u5939\u9009\u62E9\u7A97\u53E3\u53EF\u80FD\u4E0D\u4F1A\u81EA\u52A8\u8DF3\u5230\u6700\u524D\u9762\uFF0C\u800C\u662F\u85CF\u5728\u4EFB\u52A1\u680F\u6216\u5176\u4ED6\u7A97\u53E3\u540E\u9762\u3002\u8FD9\u662F DeepSeek Harness \u5BBF\u4E3B\u9009\u62E9\u5668\u7684\u5DF2\u77E5\u7F3A\u9677\uFF0C\u4E0E\u5DE5\u4F5C\u53F0\u65E0\u5173\uFF0C\u7B49\u5F85\u5B98\u65B9\u4FEE\u590D\u3002\u9047\u5230\u65F6\u70B9\u4E00\u4E0B\u4EFB\u52A1\u680F\u91CC\u7684 DSH \u56FE\u6807\u5373\u53EF\u5207\u51FA\u7A97\u53E3\uFF1B\u82E5\u4E0D\u60F3\u7B49\uFF0C\u76F4\u63A5\u7528\u300C\u624B\u52A8\u8F93\u5165\u300D\u7C98\u8D34\u6587\u4EF6\u5939\u7EDD\u5BF9\u8DEF\u5F84\uFF0C\u529F\u80FD\u5B8C\u5168\u4E0D\u53D7\u5F71\u54CD\u3002 + +\u3010\u8C01\u9700\u8981\u5347\u7EA7\u3011\u4ECD\u5728\u7528\u8001\u7248\u672C DSH \u4E14\u5DE5\u4F5C\u53F0\u4E00\u5207\u6B63\u5E38\u7684\u7528\u6237\uFF0C\u53EF\u4EE5\u6682\u4E0D\u5347\u7EA7\uFF1B\u5347\u7EA7\u5230 0.1.2 \u7CFB\u5217\u3001\u6216\u9047\u5230\u300C\u70B9\u51FB\u9879\u76EE\u5361\u7247\u6CA1\u6709\u53CD\u5E94\u300D\u7684\u7528\u6237\uFF0C\u5EFA\u8BAE\u5347\u7EA7\u672C\u7248\u672C\u3002 + +\u3010\u5347\u7EA7\u8BF4\u660E\u3011\u5347\u7EA7\u5B89\u88C5\u540E\u8BF7\u5B8C\u6574\u9000\u51FA\u5E76\u91CD\u542F DSH \u518D\u5237\u65B0\u9875\u9762\uFF1B\u82E5\u91CD\u542F\u540E\u4ECD\u663E\u793A\u52A0\u8F7D\u5931\u8D25\uFF0C\u8BF7\u4FDD\u7559\u5B8C\u6574\u9519\u8BEF\u4FE1\u606F\u53CD\u9988\u3002 + +\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + +\u6B64\u524D\u7248\u672C\u8BF4\u660E\uFF08v0.3.2\uFF09 + \u3010\u6587\u4EF6\u5939\u9009\u62E9\u4F53\u9A8C\u3011\u9009\u62E9\u9879\u76EE\u6587\u4EF6\u5939\u65F6\uFF1A\u7B49\u5F85\u671F\u95F4\u6309\u94AE\u663E\u793A\u300C\u7B49\u5F85\u9009\u62E9\u2026\u300D\u5E76\u63D0\u793A\u300C\u82E5\u672A\u770B\u5230\u9009\u62E9\u7A97\u53E3\uFF0C\u53EF\u68C0\u67E5\u4EFB\u52A1\u680F\uFF0C\u6216\u4F7F\u7528\u624B\u52A8\u8F93\u5165\u300D\uFF1B\u65B0\u589E\u300C\u624B\u52A8\u8F93\u5165\u300D\u5165\u53E3\uFF0C\u53EF\u76F4\u63A5\u7C98\u8D34\u6587\u4EF6\u5939\u7EDD\u5BF9\u8DEF\u5F84\uFF08\u76F8\u5BF9\u8DEF\u5F84\u4F1A\u88AB\u62D2\u7EDD\u5E76\u63D0\u793A\uFF09\uFF1B\u9009\u62E9\u5931\u8D25\u65F6\u663E\u793A\u5177\u4F53\u9519\u8BEF\uFF0C\u4E0D\u518D\u9759\u9ED8\u65E0\u53CD\u5E94\uFF1B\u4FEE\u590D\u4E86\u65E7\u9009\u62E9\u7A97\u53E3\u8FD4\u56DE\u540E\u8986\u76D6\u624B\u5DE5\u8F93\u5165\u3001\u5F39\u7A97\u5173\u95ED/\u5207\u6362\u540E\u72B6\u6001\u6B8B\u7559\u7B49\u95EE\u9898\u3002 \u3010\u53D1\u5E03\u6D41\u7A0B\u52A0\u56FA\u3011\u53D1\u5E03\u5305\u65B0\u589E\u7ED3\u6784\u4E0E\u8EAB\u4EFD\u6821\u9A8C\u3001\u5BA2\u6237\u7AEF\u5DE5\u5382\u6C42\u503C\u95E8\u7981\u3001\u53CC\u8D44\u4EA7\u540C\u6E90\u6838\u5BF9\u4E0E\u8FDC\u7AEF SHA \u6BD4\u5BF9\uFF08\u5F00\u53D1\u8005\u4FA7\u6539\u8FDB\uFF0C\u4E0D\u5F71\u54CD\u63D2\u4EF6\u529F\u80FD\uFF09\u3002 @@ -8936,7 +8948,7 @@ var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.3.2 \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.2"; +var LOCAL_VERSION = false ? "dev" : "0.3.3"; var UPDATE_REPO = "Aisland-SJL/dsh-worktable"; var K_UPDATE_CHECK = "dsh.worktable.updateCheck.v1"; var K_LAST_CHECK = "dsh.worktable.lastUpdateCheck.v1"; @@ -17266,8 +17278,33 @@ function AnnotationOverlay() { } function findConversationRoot() { const candidates = Array.from(document.querySelectorAll("[data-phase]")); - const ok = (el) => el.tagName !== "TEXTAREA" && el.tagName !== "INPUT" && el.children.length >= 2; - return candidates.find((el) => ok(el) && el.dataset.phase === "active") ?? candidates.find(ok) ?? null; + const ok = (el) => el.tagName !== "TEXTAREA" && el.tagName !== "INPUT" && el.children.length >= 1 && !/input/i.test(String(el.className)); + const rank = (el) => el.dataset.phase === "active" ? 0 : el.dataset.phase === "hero" ? 1 : 2; + let best = null; + let bestRank = Infinity; + for (const el of candidates) { + if (!ok(el)) continue; + const r = rank(el); + if (r < bestRank) { + bestRank = r; + best = el; + } + } + return best; +} +function visibleHeaderIn(first) { + if (first.getBoundingClientRect().height > 0) return first; + for (const el of Array.from(first.querySelectorAll("*"))) { + const r = el.getBoundingClientRect(); + if (r.height > 0 && r.width > 0) return el; + } + return null; +} +function resolveAnchor(root) { + const first = root.children[0]; + const second = root.children[1]; + const header = !!second && !!first ? visibleHeaderIn(first) : null; + return { header, viewArea: second ?? first }; } function loadSaved(layoutId) { try { @@ -17316,6 +17353,7 @@ var splitStore = { savedMarginLeft: "", savedMarginRight: "", savedMarginTop: "", + savedWidthVar: "", observer: null, fallback: null, yieldObserver: null, @@ -17343,9 +17381,7 @@ var splitStore = { } const root = findConversationRoot(); if (!root) return false; - const header = root.children[0]; - const viewArea = root.children[1]; - if (!header || !viewArea) return false; + const { header, viewArea } = resolveAnchor(root); this.spec = { ...spec, chatSide: spec.chatSide === "left" ? "left" : "right" }; const normalize2 = (p) => { if (p.tabs && p.tabs.length > 0) return p; @@ -17376,6 +17412,7 @@ var splitStore = { this.root = root; this.header = header; this.viewArea = viewArea; + this.savedWidthVar = root.style.getPropertyValue("--dsh-chat-user-width"); this.savedMarginLeft = viewArea.style.marginLeft; this.savedMarginRight = viewArea.style.marginRight; this.savedMarginTop = viewArea.style.marginTop; @@ -17420,7 +17457,12 @@ var splitStore = { this.applyMargin(); this.observer = new ResizeObserver(() => { const r = this.root; - if (!(r && r.isConnected && r.dataset.phase === "active")) { + if (!(r && r.isConnected)) { + this.syncAnchor(); + return; + } + const anchor = resolveAnchor(r); + if (anchor.header !== this.header || anchor.viewArea !== this.viewArea) { this.syncAnchor(); return; } @@ -17431,8 +17473,12 @@ var splitStore = { this.observer.observe(root); this.fallback = new MutationObserver(() => { const r = this.root; - if (r && r.isConnected && r.dataset.phase === "active") return; - this.syncAnchor(); + if (!(r && r.isConnected)) { + this.syncAnchor(); + return; + } + const anchor = resolveAnchor(r); + if (anchor.header !== this.header || anchor.viewArea !== this.viewArea) this.syncAnchor(); }); this.fallback.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-phase"] }); this.yieldObserver = new MutationObserver(() => { @@ -17446,7 +17492,7 @@ var splitStore = { this.notify(); return true; }, - /** 会话根失效(切换会话)时重新锚定:左侧内容保持不关闭;无会话才关闭 */ + /** 会话根失效(切换会话 / hero↔active 结构变化)时重新锚定:左侧内容保持不关闭;无会话根才关闭 */ syncAnchor() { if (!this.active) return; const next = findConversationRoot(); @@ -17454,30 +17500,38 @@ var splitStore = { this.close(); return; } - if (next.dataset.phase !== "active") return; - if (next === this.root) { + const ph = next.dataset.phase; + if (ph !== "active" && ph !== "hero") return; + const anchor = resolveAnchor(next); + if (next === this.root && anchor.header === this.header && anchor.viewArea === this.viewArea) { this.refreshGeom(); this.applyMargin(); this.notify(); return; } - const header = next.children[0]; - const viewArea = next.children[1]; - if (!header || !viewArea) { - this.close(); - return; + const viewArea = anchor.viewArea; + const oldRoot = this.root; + const oldViewArea = this.viewArea; + if (oldViewArea && oldViewArea.isConnected && oldViewArea !== viewArea) { + oldViewArea.style.marginLeft = this.savedMarginLeft; + oldViewArea.style.marginRight = this.savedMarginRight; + oldViewArea.style.marginTop = this.savedMarginTop; } - if (this.viewArea && this.viewArea.isConnected && this.viewArea !== viewArea) { - this.viewArea.style.marginLeft = this.savedMarginLeft; - this.viewArea.style.marginRight = this.savedMarginRight; - this.viewArea.style.marginTop = this.savedMarginTop; + if (oldRoot && oldRoot.isConnected && oldRoot !== next) { + if (this.savedWidthVar) oldRoot.style.setProperty("--dsh-chat-user-width", this.savedWidthVar); + else oldRoot.style.removeProperty("--dsh-chat-user-width"); } this.root = next; - this.header = header; + this.header = anchor.header; this.viewArea = viewArea; - this.savedMarginLeft = viewArea.style.marginLeft; - this.savedMarginRight = viewArea.style.marginRight; - this.savedMarginTop = viewArea.style.marginTop; + if (oldRoot !== next) { + this.savedWidthVar = next.style.getPropertyValue("--dsh-chat-user-width"); + } + if (oldViewArea !== viewArea) { + this.savedMarginLeft = viewArea.style.marginLeft; + this.savedMarginRight = viewArea.style.marginRight; + this.savedMarginTop = viewArea.style.marginTop; + } this.observer?.disconnect(); this.observer.observe(next); this.refreshGeom(); @@ -17486,11 +17540,11 @@ var splitStore = { }, refreshGeom() { const root = this.root; - const header = this.header; - if (!root || !header) return; + if (!root) return; const rr = root.getBoundingClientRect(); - const hr2 = header.getBoundingClientRect(); - this.geom = { left: rr.left, top: hr2.bottom, right: rr.right, bottom: rr.bottom }; + const hr2 = this.header ? this.header.getBoundingClientRect() : null; + const top = hr2 && hr2.height > 0 ? hr2.bottom : rr.top; + this.geom = { left: rr.left, top, right: rr.right, bottom: rr.bottom }; }, applyMargin() { const viewArea = this.viewArea; @@ -17502,6 +17556,9 @@ var splitStore = { const hasLeft = !!spec.left; const hasTop = !!(spec.top && spec.top.length > 0); const chatW = clamp(this.chatW, spec.chatWidth.min, Math.max(spec.chatWidth.min, colW - 60)); + if (this.root && this.root.isConnected) { + this.root.style.setProperty("--dsh-chat-user-width", Math.round(chatW) + "px"); + } const topH = hasTop ? clamp(this.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80)) : 0; const leftW = hasLeft ? clamp(this.leftW, spec.leftWidth?.min ?? 160, Math.max(spec.leftWidth?.min ?? 160, colW - 260)) : 0; const chatFull = spec.chatFullHeight === true; @@ -17826,6 +17883,10 @@ var splitStore = { persistSaved(this.spec.id, { chatW: this.chatW, topH: this.topH, leftW: this.leftW, paneWs: this.paneWs, topWs: this.topWs, leftWs: this.leftWs }); }, close() { + if (this.root && this.root.isConnected) { + if (this.savedWidthVar) this.root.style.setProperty("--dsh-chat-user-width", this.savedWidthVar); + else this.root.style.removeProperty("--dsh-chat-user-width"); + } if (this.viewArea) { this.viewArea.style.marginLeft = this.savedMarginLeft; this.viewArea.style.marginRight = this.savedMarginRight; @@ -17840,6 +17901,7 @@ var splitStore = { this.root = null; this.header = null; this.viewArea = null; + this.savedWidthVar = ""; this.geom = null; this.spec = null; this.active = false; @@ -20042,7 +20104,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.2"; +var LOCAL_VERSION2 = false ? "dev" : "0.3.3"; 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"; @@ -20326,6 +20388,7 @@ function findSidebar(start) { } var registryStore = { ids: [], listeners: /* @__PURE__ */ new Set() }; var sessionBridge = null; +var clientCtx = null; var hostApi = null; async function ensureSessionPreset(sessionId) { const api = hostApi?.agentPresets; @@ -21756,7 +21819,21 @@ function WorktableSection(props) { const pickFolder = async (target, apply2) => { if (pickBusyRef.current) return; const ws = sessionBridge?.workspaces; - if (!ws || typeof ws.pickDirectory !== "function") { + const legacy = ws && typeof ws.pickDirectory === "function" ? () => ws.pickDirectory() : null; + let remotePicker = null; + try { + const rp = clientCtx?.get?.("remote.directoryPicker"); + if (rp && typeof rp.pick === "function") remotePicker = () => rp.pick(); + } catch { + } + if (!remotePicker) { + try { + const dp = clientCtx?.directoryPicker; + if (dp && typeof dp.pick === "function") remotePicker = () => dp.pick(); + } catch { + } + } + if (!legacy && !remotePicker) { setPickErr((prev) => ({ ...prev, [target]: t("add.folderPickerUnavailable") })); return; } @@ -21765,7 +21842,19 @@ function WorktableSection(props) { setPickErr((prev) => ({ ...prev, [target]: "" })); const seq = ++pickSeqRef.current; try { - const p = await ws.pickDirectory(); + let p = null; + if (legacy) { + p = await legacy(); + } else { + const r = await remotePicker(); + if (r && typeof r === "object" && "ok" in r) { + if (!r.ok) throw new Error(r?.error?.message ?? "directory picker failed"); + const v = r.value; + p = typeof v === "string" ? v : v && typeof v === "object" && typeof v.path === "string" ? v.path : null; + } else { + p = typeof r === "string" ? r : null; + } + } if (seq !== pickSeqRef.current) return; if (!mountedRef.current) return; if (p && typeof p === "string") apply2(p); @@ -23293,6 +23382,7 @@ function WorktableSection(props) { var inject = ["slots", "locale", "sessions", "conversation", "workspaces"]; function apply(ctx) { sessionBridge = { sessions: ctx.sessions ?? null, conversation: ctx.conversation ?? null, list: ctx.sessions?.list ?? null, workspaces: ctx.workspaces ?? null }; + clientCtx = ctx; try { hostApi = ctx.get?.("connection")?.api ?? null; } catch { diff --git a/01_content/lib/index.js b/01_content/lib/index.js index 19bcfb9..d4fa695 100644 --- a/01_content/lib/index.js +++ b/01_content/lib/index.js @@ -88,7 +88,34 @@ body { var dshell_default2 = '\n\n\n\n \n \n \u6211\u7684\u7A97\u53E3\n \n\n\n
\n \n

\u7A97\u53E3\u6807\u9898

\n

\u4E00\u53E5\u8BDD\u8BF4\u660E\u8FD9\u4E2A\u7A97\u53E3\u505A\u4EC0\u4E48\u3002

\n\n \n
\n \u5DF2\u5B8C\u6210\n \u8FDB\u884C\u4E2D\n \u672A\u5F00\u59CB\n
\n\n \n
\n \n \n \n
\n\n \n
\n
\n
\u603B\u6570
\n
128
\n
+12.4%
\n
\n
\n
\u8FDB\u884C\u4E2D
\n
7
\n
\n
\n
\u5DF2\u5B8C\u6210
\n
121
\n
\n
\n\n \n
\n
\n \u6761\u76EE\u4E00\uFF1A\u793A\u4F8B\u5185\u5BB9\u6807\u9898\n \u6628\u5929\n
\n
\n \u6761\u76EE\u4E8C\uFF1A\u793A\u4F8B\u5185\u5BB9\u6807\u9898\n \u5DF2\u53D1\u5E03\n
\n
\n\n \n
\n

\u8BE6\u60C5

\n
\n
\u5B57\u6BB5 A\u503C A
\n
\u5B57\u6BB5 B\u503C B
\n
\n
\n
\n
\n\n \n
\n \n \n
\n
\n\n\n'; // src/index.ts -var PLUGIN_VERSION = false ? "dev" : "0.3.2"; +function baseDshHome() { + const env = process.env.DSH_HOME; + const value = env !== void 0 && env.trim().length > 0 ? env : pathResolve(homedir(), ".dsh"); + if (value === "~") return homedir(); + if (value.startsWith("~/") || value.startsWith("~\\")) return pathResolve(homedir(), value.slice(2)); + return pathResolve(value); +} +var cachedDshHome = null; +var dshHomeSource = "fallback"; +function resolveDshHomeSafe() { + if (cachedDshHome) return cachedDshHome; + try { + const pkg = loadPkg("@deepseek-ai/dsh-home-paths"); + if (pkg && typeof pkg.resolveDshHome === "function") { + const home = pkg.resolveDshHome(void 0, process.env); + if (typeof home === "string" && home.trim() !== "") { + dshHomeSource = "official"; + cachedDshHome = home; + return cachedDshHome; + } + } + } catch { + } + dshHomeSource = "fallback"; + cachedDshHome = baseDshHome(); + return cachedDshHome; +} +var PLUGIN_VERSION = false ? "dev" : "0.3.3"; var name = "dsh-worktable"; var inject = ["webServer", "sessions"]; var HEALTH_PATH = "/api/worktable/health"; @@ -126,6 +153,7 @@ var FILE_TYPES = { }; var SITE_PREFIX = "/api/worktable/site"; var TEMPLATE_PREFIX = "/api/worktable/template"; +var loadProbeAttempts = 0; function loadPkg(pkg) { const starts = /* @__PURE__ */ new Set(); try { @@ -139,6 +167,7 @@ function loadPkg(pkg) { for (const start of starts) { let dir = start; while (dir && dir !== pathResolve(dir, "..")) { + loadProbeAttempts++; try { const req = createRequire(pathToFileURL(pathResolve(dir, "__wt_probe__.js")).href); return req(pkg); @@ -148,10 +177,11 @@ function loadPkg(pkg) { } } try { - const profilesDir = pathResolve(homedir(), ".dsh", "profiles"); + const profilesDir = pathResolve(baseDshHome(), "profiles"); for (const profile of readdirSync(profilesDir, { withFileTypes: true })) { if (!profile.isDirectory() && !profile.isSymbolicLink()) continue; const nm = pathResolve(profilesDir, profile.name, "node_modules"); + loadProbeAttempts++; try { const req = createRequire(pathToFileURL(pathResolve(nm, "__wt_probe__.js")).href); return req(pkg); @@ -162,6 +192,9 @@ function loadPkg(pkg) { } return null; } +function __wtLoadProbeStats() { + return { attempts: loadProbeAttempts, homeSource: dshHomeSource }; +} function serverCwd(ctx, sessionId, clientCwd) { if (sessionId) { try { @@ -447,7 +480,43 @@ function apply(ctx) { path: "/api/worktable/workspaces", handler: async (_req, res) => { try { - const file = pathResolve(homedir(), ".dsh", "storages", "workspace.json"); + let registry = null; + try { + registry = ctx.workspaceRegistry ?? null; + } catch { + } + if (!registry) { + try { + registry = ctx.get?.("workspaceRegistry") ?? null; + } catch { + } + } + if (registry && typeof registry.list === "function") { + const list = registry.list() ?? []; + const workspaceIds = []; + const tables = {}; + for (const ws of list) { + const id = String(ws?.id ?? ""); + if (!id) continue; + workspaceIds.push(id); + tables[id] = { + title: typeof ws?.title === "string" ? ws.title : void 0, + sessionIds: Array.isArray(ws?.sessionIds) ? ws.sessionIds.map(String) : [] + }; + } + let archived = []; + try { + archived = (registry.archivedSessionIds ?? []).map(String); + } catch { + } + json(res, 200, { + unit: { name: "workspace", version: 2 }, + global: { initialized: true, workspaceIds, archivedSessionIds: archived }, + tables: { workspaces: tables } + }); + return; + } + const file = pathResolve(resolveDshHomeSafe(), "storages", "workspace.json"); const raw = await readFile(file, "utf8"); json(res, 200, JSON.parse(raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw)); } catch (err) { @@ -529,6 +598,7 @@ function apply(ctx) { } export { HEALTH_PATH, + __wtLoadProbeStats, apply, inject, name diff --git a/01_content/package.json b/01_content/package.json index 3426733..265f2f0 100644 --- a/01_content/package.json +++ b/01_content/package.json @@ -1,7 +1,7 @@ { "name": "dsh-worktable", "description": "工作台 Worktable:DeepSeek Harness 侧边栏的 agent 级项目容器(应用抽屉)", - "version": "0.3.2", + "version": "0.3.3", "license": "MIT", "type": "module", "main": "lib/index.js", diff --git a/01_content/release-prep.mjs b/01_content/release-prep.mjs index cbf8503..3a2bbc7 100644 --- a/01_content/release-prep.mjs +++ b/01_content/release-prep.mjs @@ -173,6 +173,37 @@ try { } console.log('[release-prep] client factory gate passed (ModuleLoader id + factory evaluation + inject)') +// ---------- 5c. 分栏引擎 DOM 锚点回归(0.1.1/0.1.2 双会话根结构 + 过渡重锚 + 关闭还原) ---------- +// 评估「安装目录中的最终产物」lib/client.js(与 5b 同一文件),用假 DOM 驱动 splitStore 全路径 +const anchorTest = join(HERE, '..', '04_test', 'anchor-dom.test.mjs') +if (!existsSync(anchorTest)) { + console.error('[release-prep] FAIL: anchor-dom.test.mjs missing') + process.exit(1) +} +const anchorRun = spawnSync(process.execPath, [anchorTest, join(inst, 'node_modules', 'dsh-worktable', 'lib', 'client.js')], { cwd: HERE, stdio: 'pipe', encoding: 'utf8' }) +if (anchorRun.status !== 0) { + console.error('[release-prep] FAIL: anchor-dom.test.mjs') + console.error(anchorRun.stderr || anchorRun.stdout) + process.exit(1) +} +console.log('[release-prep] split anchor dom tests passed (8 scenarios, on installed artifact)') + +// ---------- 5d. 服务端数据目录解析回归(loadPkg ↔ resolveDshHomeSafe 循环调用修复) ---------- +// 评估「安装目录中的最终产物」lib/index.js;官方路径 / 包不可解析兜底 / ~ 与相对路径展开 +const serverHomeTest = join(HERE, '..', '04_test', 'server-home.test.mjs') +const serverSrc = join(inst, 'node_modules', 'dsh-worktable', 'lib', 'index.js') +if (!existsSync(serverHomeTest) || !existsSync(serverSrc)) { + console.error('[release-prep] FAIL: server-home.test.mjs or installed lib/index.js missing') + process.exit(1) +} +const serverRun = spawnSync(process.execPath, [serverHomeTest, serverSrc], { cwd: HERE, stdio: 'pipe', encoding: 'utf8' }) +if (serverRun.status !== 0) { + console.error('[release-prep] FAIL: server-home.test.mjs') + console.error(serverRun.stderr || serverRun.stdout) + process.exit(1) +} +console.log('[release-prep] server home resolution tests passed (3 scenarios, on installed artifact)') + // ---------- 6. 双资产从同一已验证包复制 + SHA-256 ---------- const outDir = join(HERE, 'dist', 'v' + VERSION) mkdirSync(outDir, { recursive: true }) diff --git a/01_content/src/client/changelog.ts b/01_content/src/client/changelog.ts index 0748d76..269b358 100644 --- a/01_content/src/client/changelog.ts +++ b/01_content/src/client/changelog.ts @@ -1,9 +1,21 @@ -/** 更新公告正文(v0.3.2:文件夹选择体验 + 发布流程加固;呈现时按纯文本保留换行,不做 md 渲染) */ -export const CHANGELOG_VERSION = 'v0.3.2' -export const CHANGELOG_V030 = `更新公告 · v0.3.2 +/** 更新公告正文(v0.3.3:DSH 0.1.2 兼容 + 数据路径修复;呈现时按纯文本保留换行,不做 md 渲染) */ +export const CHANGELOG_VERSION = 'v0.3.3' +export const CHANGELOG_V030 = `更新公告 · v0.3.3 🛠️ 本版本更新 +【兼容新版 DeepSeek Harness】适配已测版本 DSH 0.1.2-rc.1 与 0.1.1-rc.2:点击项目卡片不再无反应——分栏工作区在无会话 / 空会话 / 有消息会话下都能正常打开(0.1.1-rc.2 实测未发现回归);选择项目文件夹的窗口在 0.1.2-rc.1 上恢复正常可用;工作台数据读取不再依赖默认安装路径,自定义安装位置(DSH_HOME)也能正确读取项目与分组数据。 + +【关于选择文件夹窗口的已知问题(DeepSeek Harness 宿主自身问题,不是本插件的问题)】在部分系统上,点击「选择位置…」之后文件夹选择窗口可能不会自动跳到最前面,而是藏在任务栏或其他窗口后面。这是 DeepSeek Harness 宿主选择器的已知缺陷,与工作台无关,等待官方修复。遇到时点一下任务栏里的 DSH 图标即可切出窗口;若不想等,直接用「手动输入」粘贴文件夹绝对路径,功能完全不受影响。 + +【谁需要升级】仍在用老版本 DSH 且工作台一切正常的用户,可以暂不升级;升级到 0.1.2 系列、或遇到「点击项目卡片没有反应」的用户,建议升级本版本。 + +【升级说明】升级安装后请完整退出并重启 DSH 再刷新页面;若重启后仍显示加载失败,请保留完整错误信息反馈。 + +─────── + +此前版本说明(v0.3.2) + 【文件夹选择体验】选择项目文件夹时:等待期间按钮显示「等待选择…」并提示「若未看到选择窗口,可检查任务栏,或使用手动输入」;新增「手动输入」入口,可直接粘贴文件夹绝对路径(相对路径会被拒绝并提示);选择失败时显示具体错误,不再静默无反应;修复了旧选择窗口返回后覆盖手工输入、弹窗关闭/切换后状态残留等问题。 【发布流程加固】发布包新增结构与身份校验、客户端工厂求值门禁、双资产同源核对与远端 SHA 比对(开发者侧改进,不影响插件功能)。 diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index fe506d2..5d2bdd4 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -412,6 +412,8 @@ const registryStore: { ids: string[]; listeners: Set<() => void> } = { ids: [], /** 自定义窗口 → 宿主会话桥(apply 时注入;不可用时 CustomPane 降级提示) */ let sessionBridge: { sessions: any; conversation: any; list: any; workspaces: any } | null = null +/** 客户端根 ctx(apply 时保存;用于运行时探测 0.1.2 的 remote.directoryPicker 等新服务,不硬写 inject) */ +let clientCtx: any = null /** 宿主 API 客户端(apply 时从 connection 服务取;agentPresets/sessions 用于修复新会话继承失效模型的 bug) */ let hostApi: { agentPresets?: any; sessions?: any } | null = null @@ -1845,11 +1847,29 @@ function buildCustomLayoutPrompt(req: string): string { * - useRef 在途锁 + 请求序号:真实防重入;手工确认/新请求会使旧请求失效,旧弹窗返回后丢弃 * - 返回 null = 用户取消:不报错 * - 抛异常 = 显示真实错误信息,不再静默吞掉 - * - 宿主能力不可用 = 提示改用「手动输入」 */ + * - 宿主能力三级探测(运行时,不硬写 inject): + * ① 0.1.1-rc.2:ctx.workspaces.pickDirectory() → string | null + * ② 0.1.2-rc.1:ctx remote.directoryPicker.pick() → { ok, value, error }(value 即路径或 null) + * ③ 0.1.2 新能力不可用 → 提示改用「手动输入」 */ const pickFolder = async (target: 'add' | 'bind', apply: (p: string) => void) => { if (pickBusyRef.current) return + // 解析本次选择器调用路径 const ws = sessionBridge?.workspaces as any - if (!ws || typeof ws.pickDirectory !== 'function') { + const legacy = ws && typeof ws.pickDirectory === 'function' + ? () => ws.pickDirectory() + : null + let remotePicker: (() => Promise) | null = null + try { + const rp = clientCtx?.get?.('remote.directoryPicker') + if (rp && typeof rp.pick === 'function') remotePicker = () => rp.pick() + } catch {} + if (!remotePicker) { + try { + const dp = (clientCtx as any)?.directoryPicker + if (dp && typeof dp.pick === 'function') remotePicker = () => dp.pick() + } catch {} + } + if (!legacy && !remotePicker) { setPickErr((prev) => ({ ...prev, [target]: t('add.folderPickerUnavailable') })) return } @@ -1858,7 +1878,19 @@ function buildCustomLayoutPrompt(req: string): string { setPickErr((prev) => ({ ...prev, [target]: '' })) const seq = ++pickSeqRef.current try { - const p = await ws.pickDirectory() + let p: string | null = null + if (legacy) { + p = await legacy() + } else { + const r = await remotePicker!() + if (r && typeof r === 'object' && 'ok' in r) { + if (!r.ok) throw new Error(r?.error?.message ?? 'directory picker failed') + const v = r.value + p = typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.path === 'string' ? v.path : null) + } else { + p = typeof r === 'string' ? r : null + } + } if (seq !== pickSeqRef.current) return // 已被手工确认/新请求/弹窗关闭失效:丢弃旧弹窗结果 if (!mountedRef.current) return // 组件已卸载:不再应用 if (p && typeof p === 'string') apply(p) @@ -3273,6 +3305,7 @@ export const inject = ['slots', 'locale', 'sessions', 'conversation', 'workspace export function apply(ctx: any) { // 自定义窗口 → 宿主会话桥:保存 sessions/conversation/list 服务引用(模块级) sessionBridge = { sessions: ctx.sessions ?? null, conversation: ctx.conversation ?? null, list: ctx.sessions?.list ?? null, workspaces: ctx.workspaces ?? null } + 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 {} diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index aed6c7d..280eca6 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -90,6 +90,8 @@ type SplitState = { savedMarginLeft: string savedMarginRight: string savedMarginTop: string + /** 打开前会话根上 --dsh-chat-user-width 的内联值(关闭时原样恢复;0.1.1 上该变量闲置无害) */ + savedWidthVar: string observer: ResizeObserver | null fallback: MutationObserver | null yieldObserver: MutationObserver | null @@ -671,13 +673,48 @@ function AnnotationOverlay() { ) } -/** 找到会话根容器:data-phase 元素中排除输入框、取含子元素者;优先 phase=active;无活动会话返回 null */ +/** 找到会话根容器:data-phase 元素中排除输入框、取含子元素者。 + * DSH 0.1.1-rc.2 与 0.1.2-rc.1 的会话阶段一致:active(有消息)/ hero(无会话或空会话)/ settling(过渡)。 + * 优先 active,其次 hero(空会话/无会话时也能打开分栏),settling 兜底(锚定后由观察器修正)。 */ function findConversationRoot(): HTMLElement | null { const candidates = Array.from(document.querySelectorAll('[data-phase]')) - const ok = (el: HTMLElement) => el.tagName !== 'TEXTAREA' && el.tagName !== 'INPUT' && el.children.length >= 2 - return candidates.find((el) => ok(el) && el.dataset.phase === 'active') - ?? candidates.find(ok) - ?? null + const ok = (el: HTMLElement) => el.tagName !== 'TEXTAREA' && el.tagName !== 'INPUT' + && el.children.length >= 1 && !/input/i.test(String(el.className)) + const rank = (el: HTMLElement) => (el.dataset.phase === 'active' ? 0 : el.dataset.phase === 'hero' ? 1 : 2) + let best: HTMLElement | null = null + let bestRank = Infinity + for (const el of candidates) { + if (!ok(el)) continue + const r = rank(el) + if (r < bestRank) { bestRank = r; best = el } + } + return best +} + +/** 从首个槽位元素里解析「可见头部」: + * - 0.1.1:children[0] 就是
本身(可见时自身有高度)。 + * - 0.1.2:children[0] 是零高的槽位包装(display:contents 式),真实头部渲染在其内部—— + * 此时向内找第一个可见后代(文档序 = 头部自身,先于其子元素)。 + * - 空会话(headerHidden,整棵子树 display:none):找不到任何可见元素 → null(顶部 = 根顶部)。 */ +function visibleHeaderIn(first: HTMLElement): HTMLElement | null { + if (first.getBoundingClientRect().height > 0) return first + for (const el of Array.from(first.querySelectorAll('*'))) { + const r = el.getBoundingClientRect() + if (r.height > 0 && r.width > 0) return el + } + return null +} + +/** 从会话根解析分栏锚点(兼容 0.1.1 与 0.1.2 两种 DOM 结构): + * - 0.1.1 与 0.1.2 会话打开:root.children = [头部(或头部包装), 内容滚动区](0.1.2 多一层 body 包装,不影响取 children[1])。 + * - 0.1.2 无会话:root.children = [body](无头部元素)。 + * - 空会话(blank):头部带 headerHidden 样式(高度为 0),按「无头部」处理(顶部 = 根顶部)。 + * header 仅当存在第二个子元素且能解析出可见头部时生效;viewArea = 第二个子元素,只有一个时 = 第一个。 */ +function resolveAnchor(root: HTMLElement): { header: HTMLElement | null; viewArea: HTMLElement } { + const first = root.children[0] as HTMLElement | undefined + const second = root.children[1] as HTMLElement | undefined + const header = !!second && !!first ? visibleHeaderIn(first) : null + return { header, viewArea: second ?? first! } } function loadSaved(layoutId: string): { chatW: number; topH: number; leftW: number; paneWs: number[]; topWs: number[]; leftWs: number[] } | null { @@ -730,6 +767,7 @@ export const splitStore: SplitState = { savedMarginLeft: '', savedMarginRight: '', savedMarginTop: '', + savedWidthVar: '', observer: null, fallback: null, yieldObserver: null, @@ -759,9 +797,7 @@ export const splitStore: SplitState = { } catch {} const root = findConversationRoot() if (!root) return false - const header = root.children[0] as HTMLElement | undefined - const viewArea = root.children[1] as HTMLElement | undefined - if (!header || !viewArea) return false + const { header, viewArea } = resolveAnchor(root) this.spec = { ...spec, chatSide: spec.chatSide === 'left' ? 'left' : 'right' } // 向后兼容归一化:单内容声明 → 一个标签页 const normalize = (p: SplitPane): SplitPane => { @@ -793,6 +829,7 @@ export const splitStore: SplitState = { this.root = root this.header = header this.viewArea = viewArea + this.savedWidthVar = root.style.getPropertyValue('--dsh-chat-user-width') this.savedMarginLeft = viewArea.style.marginLeft this.savedMarginRight = viewArea.style.marginRight this.savedMarginTop = viewArea.style.marginTop @@ -841,20 +878,21 @@ export const splitStore: SplitState = { this.applyMargin() this.observer = new ResizeObserver(() => { const r = this.root - if (!(r && r.isConnected && r.dataset.phase === 'active')) { - this.syncAnchor() - return - } + if (!(r && r.isConnected)) { this.syncAnchor(); return } + const anchor = resolveAnchor(r) + if (anchor.header !== this.header || anchor.viewArea !== this.viewArea) { this.syncAnchor(); return } this.refreshGeom() this.applyMargin() this.notify() }) this.observer.observe(root) - // 兜底:会话根被替换/phase 变化时 RO 可能不再回调,用 body 级 MutationObserver 驱动重锚定 + // 兜底:会话根被替换/子结构变化(如 hero→active 头部出现)时 RO 可能不再回调, + // 用 body 级 MutationObserver 驱动重锚定;锚点未变时静默(避免流式消息期间高频刷新) this.fallback = new MutationObserver(() => { const r = this.root - if (r && r.isConnected && r.dataset.phase === 'active') return - this.syncAnchor() + if (!(r && r.isConnected)) { this.syncAnchor(); return } + const anchor = resolveAnchor(r) + if (anchor.header !== this.header || anchor.viewArea !== this.viewArea) this.syncAnchor() }) this.fallback.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-phase'] }) // 让位观察器:会话视图区 margin 被外部改写(其他未接入协议的分栏引擎接管)时关闭自身 @@ -872,7 +910,7 @@ export const splitStore: SplitState = { return true }, - /** 会话根失效(切换会话)时重新锚定:左侧内容保持不关闭;无会话才关闭 */ + /** 会话根失效(切换会话 / hero↔active 结构变化)时重新锚定:左侧内容保持不关闭;无会话根才关闭 */ syncAnchor() { if (!this.active) return const next = findConversationRoot() @@ -880,31 +918,42 @@ export const splitStore: SplitState = { this.close() return } - if (next.dataset.phase !== 'active') return // 过渡态:保持等待(phase 变化会再次触发) - if (next === this.root) { + const ph = next.dataset.phase + if (ph !== 'active' && ph !== 'hero') return // settling 等过渡态:保持等待(phase 变化会再次触发) + const anchor = resolveAnchor(next) + if (next === this.root && anchor.header === this.header && anchor.viewArea === this.viewArea) { + // 同锚点:头部可能由隐藏变可见(hero→active),刷新几何与挤位 this.refreshGeom() this.applyMargin() this.notify() return } - const header = next.children[0] as HTMLElement | undefined - const viewArea = next.children[1] as HTMLElement | undefined - if (!header || !viewArea) { - this.close() - return + const viewArea = anchor.viewArea + const oldRoot = this.root + const oldViewArea = this.viewArea + // 恢复旧锚点(若仍连接),锚定到新会话根 + if (oldViewArea && oldViewArea.isConnected && oldViewArea !== viewArea) { + oldViewArea.style.marginLeft = this.savedMarginLeft + oldViewArea.style.marginRight = this.savedMarginRight + oldViewArea.style.marginTop = this.savedMarginTop } - // 恢复旧视图区 margin(若仍连接),锚定到新会话根 - if (this.viewArea && this.viewArea.isConnected && this.viewArea !== viewArea) { - this.viewArea.style.marginLeft = this.savedMarginLeft - this.viewArea.style.marginRight = this.savedMarginRight - this.viewArea.style.marginTop = this.savedMarginTop + if (oldRoot && oldRoot.isConnected && oldRoot !== next) { + if (this.savedWidthVar) oldRoot.style.setProperty('--dsh-chat-user-width', this.savedWidthVar) + else oldRoot.style.removeProperty('--dsh-chat-user-width') } this.root = next - this.header = header + this.header = anchor.header this.viewArea = viewArea - this.savedMarginLeft = viewArea.style.marginLeft - this.savedMarginRight = viewArea.style.marginRight - this.savedMarginTop = viewArea.style.marginTop + // 仅锚点元素真正更换时才记录「打开前」原值——同元素重锚(hero→active 头部出现) + // 必须保留最初存档,否则关闭时会把已应用的 margin/变量误还原成「新原值」 + if (oldRoot !== next) { + this.savedWidthVar = next.style.getPropertyValue('--dsh-chat-user-width') + } + if (oldViewArea !== viewArea) { + this.savedMarginLeft = viewArea.style.marginLeft + this.savedMarginRight = viewArea.style.marginRight + this.savedMarginTop = viewArea.style.marginTop + } this.observer?.disconnect() this.observer.observe(next) this.refreshGeom() @@ -914,11 +963,12 @@ export const splitStore: SplitState = { refreshGeom() { const root = this.root - const header = this.header - if (!root || !header) return + if (!root) return const rr = root.getBoundingClientRect() - const hr = header.getBoundingClientRect() - this.geom = { left: rr.left, top: hr.bottom, right: rr.right, bottom: rr.bottom } + const hr = this.header ? this.header.getBoundingClientRect() : null + // 头部隐藏(blank 会话)或无头部(0.1.2 无会话)时以根顶部为分栏上沿 + const top = hr && hr.height > 0 ? hr.bottom : rr.top + this.geom = { left: rr.left, top, right: rr.right, bottom: rr.bottom } }, applyMargin() { @@ -931,6 +981,11 @@ export const splitStore: SplitState = { const hasLeft = !!spec.left const hasTop = !!(spec.top && spec.top.length > 0) const chatW = clamp(this.chatW, spec.chatWidth.min, Math.max(spec.chatWidth.min, colW - 60)) + // 会话内容宽 clamp(680px, …, 920px) 不随挤出的聊天列收缩 → 显式钉到挤出的列宽 + // (close/重锚定时原样恢复;0.1.1 上该 CSS 变量闲置,设置无害) + if (this.root && this.root.isConnected) { + this.root.style.setProperty('--dsh-chat-user-width', Math.round(chatW) + 'px') + } const topH = hasTop ? clamp(this.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80)) : 0 @@ -1261,6 +1316,10 @@ export const splitStore: SplitState = { }, close() { + if (this.root && this.root.isConnected) { + if (this.savedWidthVar) this.root.style.setProperty('--dsh-chat-user-width', this.savedWidthVar) + else this.root.style.removeProperty('--dsh-chat-user-width') + } if (this.viewArea) { this.viewArea.style.marginLeft = this.savedMarginLeft this.viewArea.style.marginRight = this.savedMarginRight @@ -1275,6 +1334,7 @@ export const splitStore: SplitState = { this.root = null this.header = null this.viewArea = null + this.savedWidthVar = '' this.geom = null this.spec = null this.active = false diff --git a/01_content/src/index.ts b/01_content/src/index.ts index 6c14c41..2d41633 100644 --- a/01_content/src/index.ts +++ b/01_content/src/index.ts @@ -7,6 +7,45 @@ import { createRequire } from 'node:module' import { homedir } from 'node:os' import { fileURLToPath, pathToFileURL } from 'node:url' +/** + * 基础数据目录解析:不加载任何官方包(loadPkg 的兜底只能用它,禁止反向调用包加载函数——否则成环)。 + * 规则与官方 @deepseek-ai/dsh-home-paths 的 resolveDshHome 一致: + * DSH_HOME 环境变量优先(空/纯空白视为未设置),否则 ~/.dsh; + * 支持 ~、~/、~\ 前缀展开;相对路径按进程 cwd 解析;结果归一为绝对路径。 + * 禁止任何业务代码直接拼 homedir()/.dsh —— 自定义 DSH_HOME(Desktop/隔离测试)会读错数据。 + */ +function baseDshHome(): string { + const env = process.env.DSH_HOME + // 与官方一致:trim 只用于判断是否全空白,实际路径保留原字符串(两端空格有含义) + const value = env !== undefined && env.trim().length > 0 ? env : pathResolve(homedir(), '.dsh') + if (value === '~') return homedir() + if (value.startsWith('~/') || value.startsWith('~\\')) return pathResolve(homedir(), value.slice(2)) + return pathResolve(value) +} + +/** 解析 DSH 数据根目录:优先官方 @deepseek-ai/dsh-home-paths(显式配置/DSH_HOME/默认), + * 不可用或返回非法值时回退 baseDshHome()(同官方规则)。带缓存。 */ +let cachedDshHome: string | null = null +/** 本次解析实际走了哪条路径(供测试断言官方包是否真的被使用) */ +let dshHomeSource: 'official' | 'fallback' = 'fallback' +function resolveDshHomeSafe(): string { + if (cachedDshHome) return cachedDshHome + try { + const pkg = loadPkg('@deepseek-ai/dsh-home-paths') as any + if (pkg && typeof pkg.resolveDshHome === 'function') { + const home = pkg.resolveDshHome(undefined, process.env) + if (typeof home === 'string' && home.trim() !== '') { + dshHomeSource = 'official' + cachedDshHome = home + return cachedDshHome + } + } + } catch {} + dshHomeSource = 'fallback' + cachedDshHome = baseDshHome() + return cachedDshHome +} + /** * dsh-worktable 服务端:健康路由 + 工作区内容窗的数据路由。 * 参考 dsh-better-sidebar 的架构——内容窗能力由本插件自己的服务端路由提供: @@ -53,6 +92,8 @@ const TEMPLATE_PREFIX = '/api/worktable/template' * 本包经 junction 链接进 profile,普通 import 可能解析不到 profile 级依赖; * 同时尝试 junction 路径与 realpath 两条祖先链。 */ +/** 依赖探测尝试计数(供测试断言「有界探测、无循环重入」) */ +let loadProbeAttempts = 0 function loadPkg(pkg: string): any | null { const starts = new Set() try { starts.add(dirname(fileURLToPath(import.meta.url))) } catch {} @@ -60,6 +101,7 @@ function loadPkg(pkg: string): any | null { for (const start of starts) { let dir: string | null = start while (dir && dir !== pathResolve(dir, '..')) { + loadProbeAttempts++ try { const req = createRequire(pathToFileURL(pathResolve(dir, '__wt_probe__.js')).href) return req(pkg) @@ -67,12 +109,13 @@ function loadPkg(pkg: string): any | null { dir = pathResolve(dir, '..') } } - // 兜底:DSH 标准目录 ~/.dsh/profiles/*/node_modules(宿主按 realpath 加载时前两条链都找不到) + // 兜底:DSH profiles/*/node_modules(按 baseDshHome 解析根目录——不能用 resolveDshHomeSafe,否则与本函数成环) try { - const profilesDir = pathResolve(homedir(), '.dsh', 'profiles') + const profilesDir = pathResolve(baseDshHome(), 'profiles') for (const profile of readdirSync(profilesDir, { withFileTypes: true })) { if (!profile.isDirectory() && !profile.isSymbolicLink()) continue const nm = pathResolve(profilesDir, profile.name, 'node_modules') + loadProbeAttempts++ try { const req = createRequire(pathToFileURL(pathResolve(nm, '__wt_probe__.js')).href) return req(pkg) @@ -82,6 +125,11 @@ function loadPkg(pkg: string): any | null { return null } +/** 测试钩子:依赖探测尝试次数 + 数据目录解析路径(循环回归与官方路径断言用) */ +export function __wtLoadProbeStats(): { attempts: number; homeSource: 'official' | 'fallback' } { + return { attempts: loadProbeAttempts, homeSource: dshHomeSource } +} + /** 解析会话工作目录:服务端 header.cwd 优先,其次客户端传入 cwd,最后进程 cwd */ function serverCwd(ctx: any, sessionId?: string, clientCwd?: string): string { if (sessionId) { @@ -316,13 +364,44 @@ export function apply(ctx: Context) { }, }) - // 工作区列表(自定义窗口会话分组用):读宿主 ~/.dsh/storages/workspace.json(只读) + // 工作区列表(自定义窗口会话分组用): + // 优先走宿主正式服务 ctx.workspaceRegistry(0.1.1/0.1.2 均有,正确感知 DSH_HOME 与存储后端); + // 不可用时回退按 resolveDshHomeSafe() 读 storages/workspace.json(只读)。 + // 返回结构是客户端契约,两种来源都映射成同一 shape。 webServer.register({ kind: 'exact', path: '/api/worktable/workspaces', handler: async (_req: any, res: any) => { try { - const file = pathResolve(homedir(), '.dsh', 'storages', 'workspace.json') + // cordis 对未 inject 服务的属性访问会直接 throw(不返回 undefined),必须 try-catch 探测 + let registry: any = null + try { registry = (ctx as any).workspaceRegistry ?? null } catch {} + if (!registry) { + try { registry = ctx.get?.('workspaceRegistry') ?? null } catch {} + } + if (registry && typeof registry.list === 'function') { + const list = registry.list() ?? [] + const workspaceIds: string[] = [] + const tables: Record = {} + for (const ws of list) { + const id = String(ws?.id ?? '') + if (!id) continue + workspaceIds.push(id) + tables[id] = { + title: typeof ws?.title === 'string' ? ws.title : undefined, + sessionIds: Array.isArray(ws?.sessionIds) ? ws.sessionIds.map(String) : [], + } + } + let archived: string[] = [] + try { archived = (registry.archivedSessionIds ?? []).map(String) } catch {} + json(res, 200, { + unit: { name: 'workspace', version: 2 }, + global: { initialized: true, workspaceIds, archivedSessionIds: archived }, + tables: { workspaces: tables }, + }) + return + } + const file = pathResolve(resolveDshHomeSafe(), 'storages', 'workspace.json') const raw = await readFile(file, 'utf8') // 容忍 BOM(外部工具改写可能带 EF BB BF,JSON.parse 会抛错) json(res, 200, JSON.parse(raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw)) diff --git a/02_process/PRD.md b/02_process/PRD.md index ad2df59..e21aee6 100644 --- a/02_process/PRD.md +++ b/02_process/PRD.md @@ -291,7 +291,9 @@ type SplitPane = { ### 12.4 框架职责 - 几何:查找会话根(`[data-phase]` 探测 + 结构化甄别,与 travelatlas 现行 hack 一致, - 集中一处维护)、marginLeft 右挤对话区、分隔线拖宽(逐栏 width 约束)、Esc/✕ 退出; + 集中一处维护;v0.3.3 起按双宿主契约甄别:0.1.1 active=[头部,滚动区]、空会话 hero=[隐藏头部,内容区]、 + 0.1.2 无会话 hero=[内容区];0.1.2 的 children[0] 是零高槽位包装、真实标题栏在内部(visibleHeaderIn 向内解析), + 细节与回归见 AGENTS.md「分栏引擎双版本锚点」与 04_test/anchor-dom.test.mjs)、marginLeft 右挤对话区、分隔线拖宽(逐栏 width 约束)、Esc/✕ 退出; - **会话切换行为(2026-08-16 用户需求定案)**:切换不同对话时分栏与左侧内容**保持不关闭**—— 会话根变化时重新锚定(重算几何、改观察新根),iframe 组件保持挂载不卸载不刷新; 新会话过渡态(phase 非 active)短暂保持等待、不误关;关闭条件仅:✕ / 再次点击工作台卡片 / diff --git a/04_test/anchor-dom.test.mjs b/04_test/anchor-dom.test.mjs new file mode 100644 index 0000000..3918383 --- /dev/null +++ b/04_test/anchor-dom.test.mjs @@ -0,0 +1,329 @@ +/** + * 分栏引擎 DOM 锚点回归测试(P2:DSH 0.1.1-rc.2 与 0.1.2-rc.1 双版本会话根结构)。 + * 用法:node anchor-dom.test.mjs(纯 node,无外部依赖;评估真实构建产物 lib/client.js)。 + * 覆盖: + * A. 0.1.1 active(2 子元素 + 可见头部)→ 顶部 = 头部底边 + * B. 0.1.1/0.1.2 blank hero(2 子元素 + 隐藏头部)→ 头部忽略、顶部 = 根顶 + * C. 0.1.2 无会话 hero(1 子元素)→ 无头部、viewArea = 唯一子元素 + * D. hero→active 过渡(同根插入可见头部)→ 重锚定且关闭还原干净(不泄漏已应用值) + * E. 关闭还原:margin 清空、--dsh-chat-user-width 移除 + * F. 输入包装器(uV2eYG_input / textarea)不被误选为会话根 + * G. settling 过渡态:不关闭、等待 + */ +import { readFileSync } from 'node:fs' +import { createContext, runInNewContext } from 'node:vm' +import { fileURLToPath } from 'node:url' +import { dirname, join, resolve } from 'node:path' + +// bundle 路径参数化:发布脚本传入「已安装包内」的 lib/client.js(验收最终产物); +// 缺省回退到工作目录构建产物(本地开发跑)。 +const BUNDLE = resolve(process.argv[2] ?? join(dirname(fileURLToPath(import.meta.url)), '..', '01_content', 'lib', 'client.js')) +const source = readFileSync(BUNDLE, 'utf8') + +// —— 假 DOM —— +// 模拟浏览器 CSSStyleDeclaration:未设置的属性读作 ''(插件保存/还原依赖该语义) +class StyleProxy { + constructor() { + return new Proxy(this, { + get(t, k) { + if (k === Symbol.toPrimitive) return () => 'style' + return k in t ? t[k] : '' + }, + }) + } + getPropertyValue(k) { return Object.prototype.hasOwnProperty.call(this, k) ? this[k] : '' } + setProperty(k, v) { this[k] = v } + removeProperty(k) { delete this[k] } +} +// 万能桩:任何未实现的 DOM API(getContext 等)返回自身同型桩 +const universalStub = new Proxy(function () { return universalStub }, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 'stub' : universalStub), + apply: () => universalStub, + construct: () => universalStub, +}) +class FakeEl { + constructor(tag = 'DIV', cls = '', attrs = {}) { + this.tagName = tag + this.className = cls + this.dataset = {} + for (const [k, v] of Object.entries(attrs)) this.dataset[k] = v + this.children = [] + this.parentElement = null + this.style = new StyleProxy() + this._rect = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 } + this._connected = true + return new Proxy(this, { + get(t, k) { + if (k in t) return t[k] + if (k === Symbol.toPrimitive) return () => 'stub' + return universalStub + }, + }) + } + get isConnected() { return this._connected } + getBoundingClientRect() { return { ...this._rect } } + setRect(o) { + Object.assign(this._rect, o) + this._rect.width = this._rect.right - this._rect.left + this._rect.height = this._rect.bottom - this._rect.top + return this + } + appendChild(c) { c.parentElement = this; this.children.push(c); return c } + insertBefore(c) { c.parentElement = this; this.children.unshift(c); return c } + removeChild(c) { c.parentElement = null; this.children = this.children.filter((x) => x !== c); return c } + _walk(out) { for (const c of this.children) { out.push(c); c._walk(out) } return out } + querySelectorAll() { return this._walk([]) } + querySelector() { return null } + closest() { return null } + addEventListener() {} + removeEventListener() {} + setAttribute(k, v) { this.dataset[k] = v } + getAttribute(k) { return this.dataset[k] } + removeAttribute(k) { delete this.dataset[k] } +} + +let pass = 0 +const fail = (name, detail) => { console.error('FAIL(' + name + '): ' + detail); process.exit(1) } +const ok = (name) => { console.log('ok ' + name); pass++ } + +function matches(el, sel) { + if (sel === '[data-phase]') return el.dataset && el.dataset.phase != null + if (sel.startsWith('.')) { + const cls = String(el.className || '').split(/\s+/) + return cls.includes(sel.slice(1)) + } + return false +} + +function buildSandbox() { + const allEls = [] + const moInstances = [] + class MOCtor { + constructor(cb) { this.cb = cb; moInstances.push(this) } + observe(target) { this._target = target } + disconnect() {} + } + class ROCtor { + constructor(cb) { this.cb = cb } + observe() {} + unobserve() {} + disconnect() {} + } + const localStorageStub = { getItem: () => null, setItem: () => {}, removeItem: () => {} } + const documentStub = { + body: new FakeEl('BODY'), + documentElement: new FakeEl('HTML'), + querySelector: (sel) => allEls.find((e) => matches(e, sel)) || null, + querySelectorAll: (sel) => allEls.filter((e) => matches(e, sel)), + createElement: (tag) => new FakeEl(String(tag).toUpperCase()), + addEventListener() {}, + removeEventListener() {}, + } + const sandbox = { + window: {}, + console: { log() {}, warn() {}, error() {} }, + setTimeout, clearTimeout, + localStorage: localStorageStub, + document: documentStub, + atob: (s) => Buffer.from(String(s), 'base64').toString('binary'), + btoa: (s) => Buffer.from(String(s), 'binary').toString('base64'), + Blob: function Blob() {}, + URL: { createObjectURL: () => 'blob:stub', revokeObjectURL: () => {} }, + MutationObserver: MOCtor, + ResizeObserver: ROCtor, + IntersectionObserver: function () { return { observe() {}, unobserve() {}, disconnect() {} } }, + CustomEvent: class CustomEvent { constructor(type, init) { this.type = type; this.detail = init && init.detail } }, + requestAnimationFrame: (cb) => 0, + cancelAnimationFrame: () => {}, + navigator: { userAgent: 'test', platform: 'Win32', language: 'zh-CN' }, + fetch: () => Promise.reject(new Error('fetch unavailable')), + indexedDB: undefined, + } + sandbox.window.__ModuleLoader__ = { load: (reg) => { sandbox.__captured = reg } } + sandbox.window.addEventListener = () => {} + sandbox.window.removeEventListener = () => {} + sandbox.window.dispatchEvent = () => {} + sandbox.window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) + sandbox.window.localStorage = localStorageStub + sandbox.window.document = documentStub + sandbox.self = sandbox.window + createContext(sandbox) + runInNewContext(source, sandbox, { filename: 'client.js', timeout: 20000 }) + if (!sandbox.__captured || typeof sandbox.__captured.factory !== 'function') fail('load', 'factory not captured') + const makeStub = () => { + const stub = function () { return makeStub() } + return new Proxy(stub, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 'stub' : makeStub()), + apply: () => makeStub(), + construct: () => makeStub(), + }) + } + const requireStub = (id) => { + if (id === 'react' || id === 'react/jsx-runtime') return makeStub() + throw new Error('unexpected external require: ' + id) + } + const runSandbox = { __factory: sandbox.__captured.factory, __requireStub: requireStub } + createContext(runSandbox) + runInNewContext('__factory(__requireStub)', runSandbox, { timeout: 20000 }) + const splitStore = sandbox.window.__dshWorktable && sandbox.window.__dshWorktable.splitStore + if (!splitStore) fail('load', 'window.__dshWorktable.splitStore not exported') + return { sandbox, splitStore, allEls, moInstances } +} + +function makeSpec(id) { + return { id, title: id, top: null, main: [{ id: 'p1', title: 'p1', min: 200, content: null }], chatWidth: { default: 320, min: 240, max: 600 } } +} + +// 每个场景独立 sandbox(避免模块级 taObserver 等状态串扰) +function fresh() { + const b = buildSandbox() + return b +} + +// —— A. 0.1.1 active:2 子元素 + 可见头部 —— +{ + const { sandbox, splitStore, allEls } = fresh() + const root = new FakeEl('DIV', 'wSkVaW_root', { phase: 'active' }).setRect({ left: 200, top: 100, right: 1200, bottom: 800 }) + const header = new FakeEl('HEADER', 'wSkVaW_header').setRect({ left: 200, top: 100, right: 1200, bottom: 148 }) + const scroll = new FakeEl('DIV', 'wSkVaW_scrollBody').setRect({ left: 200, top: 148, right: 1200, bottom: 800 }) + root.appendChild(header); root.appendChild(scroll) + allEls.push(root); sandbox.document.body.appendChild(root) + const r = splitStore.open(makeSpec('t-a')) + if (r !== true) fail('A.open', 'open() !== true') + if (splitStore.header !== header) fail('A.header', 'header not anchored') + if (splitStore.viewArea !== scroll) fail('A.viewArea', 'viewArea not children[1]') + if (splitStore.geom.top !== 148) fail('A.geom', 'top=' + splitStore.geom.top + ' != 148') + if (scroll.style.marginTop !== '26px') fail('A.marginTop', JSON.stringify(scroll.style.marginTop)) + if (root.style.getPropertyValue('--dsh-chat-user-width') === '') fail('A.widthVar', 'width var not pinned') + splitStore.close() + if (scroll.style.marginLeft !== '' || scroll.style.marginTop !== '') fail('A.close', 'margins not restored: ' + JSON.stringify(scroll.style)) + if (root.style.getPropertyValue('--dsh-chat-user-width') !== '') fail('A.closeVar', 'width var not removed') + ok('A. 0.1.1 active(可见头部)锚定 + 关闭还原') +} + +// —— B. 0.1.1/0.1.2 blank hero:2 子元素 + 隐藏头部 —— +{ + const { sandbox, splitStore, allEls } = fresh() + const root = new FakeEl('DIV', 'wSkVaW_root', { phase: 'hero' }).setRect({ left: 200, top: 100, right: 1200, bottom: 800 }) + const header = new FakeEl('HEADER', 'wSkVaW_header wSkVaW_headerHidden').setRect({ left: 200, top: 100, right: 1200, bottom: 100 }) + const body = new FakeEl('DIV', 'wSkVaW_body').setRect({ left: 200, top: 100, right: 1200, bottom: 800 }) + root.appendChild(header); root.appendChild(body) + allEls.push(root); sandbox.document.body.appendChild(root) + const r = splitStore.open(makeSpec('t-b')) + if (r !== true) fail('B.open', 'open() !== true') + if (splitStore.header !== null) fail('B.header', 'hidden header should be null') + if (splitStore.viewArea !== body) fail('B.viewArea', 'viewArea not body') + if (splitStore.geom.top !== 100) fail('B.geom', 'top=' + splitStore.geom.top + ' != 100 (root top)') + splitStore.close() + ok('B. blank hero(隐藏头部)锚定') +} + +// —— C. 0.1.2 无会话 hero:1 子元素 —— +{ + const { sandbox, splitStore, allEls } = fresh() + const root = new FakeEl('DIV', 'wSkVaW_root', { phase: 'hero' }).setRect({ left: 280, top: 0, right: 1258, bottom: 761 }) + const body = new FakeEl('DIV', 'wSkVaW_body').setRect({ left: 280, top: 0, right: 1258, bottom: 761 }) + root.appendChild(body) + allEls.push(root); sandbox.document.body.appendChild(root) + const r = splitStore.open(makeSpec('t-c')) + if (r !== true) fail('C.open', 'open() !== true') + if (splitStore.header !== null) fail('C.header', 'header should be null') + if (splitStore.viewArea !== body) fail('C.viewArea', 'viewArea not only-child') + if (splitStore.geom.top !== 0) fail('C.geom', 'top=' + splitStore.geom.top + ' != 0') + splitStore.close() + ok('C. 0.1.2 无会话 hero(单子元素)锚定') +} + +// —— D. hero→active 同根过渡:头部出现 → 重锚定;关闭还原干净 —— +{ + const { sandbox, splitStore, allEls, moInstances } = fresh() + const root = new FakeEl('DIV', 'wSkVaW_root', { phase: 'hero' }).setRect({ left: 280, top: 0, right: 1258, bottom: 761 }) + const body = new FakeEl('DIV', 'wSkVaW_body').setRect({ left: 280, top: 0, right: 1258, bottom: 761 }) + root.appendChild(body) + allEls.push(root); sandbox.document.body.appendChild(root) + splitStore.open(makeSpec('t-d')) + const mlBefore = body.style.marginLeft + const bodyFallbackObs = moInstances.filter((m) => m._targeted === undefined)[moInstances.length - 1] + // 模拟 hero→active:头部插入 + phase 翻转 + const header = new FakeEl('HEADER', 'wSkVaW_header').setRect({ left: 280, top: 0, right: 1258, bottom: 48 }) + root.insertBefore(header) + root.dataset.phase = 'active' + // 触发 body 级 fallback MutationObserver(按 observe 目标识别;取最后一个 = open() 里建的 fallback,跳过模块级 taObserver) + const bodyMOs = moInstances.filter((m) => m._target === sandbox.document.body) + const fallbackMO = bodyMOs[bodyMOs.length - 1] + if (!fallbackMO || typeof fallbackMO.cb !== 'function') fail('D.mo', 'fallback MO not captured') + fallbackMO.cb() + if (splitStore.header !== header) fail('D.header', 're-anchor did not pick visible header') + if (splitStore.viewArea !== body) fail('D.viewArea', 're-anchor lost body') + if (splitStore.geom.top !== 48) fail('D.geom', 'top=' + splitStore.geom.top + ' != 48') + if (body.style.marginLeft === '') fail('D.margin', 'margin not re-applied after transition') + // 关闭:margin 与宽度变量必须回到「打开前」原值(空),不得泄漏已应用值 + splitStore.close() + if (body.style.marginLeft !== '') fail('D.closeMargin', 'leaked margin: ' + JSON.stringify(body.style.marginLeft)) + if (body.style.marginTop !== '') fail('D.closeMarginTop', 'leaked marginTop: ' + JSON.stringify(body.style.marginTop)) + if (root.style.getPropertyValue('--dsh-chat-user-width') !== '') fail('D.closeVar', 'leaked width var: ' + root.style.getPropertyValue('--dsh-chat-user-width')) + ok('D. hero→active 过渡重锚定 + 关闭零泄漏') +} + +// —— E. 无会话根:findConversationRoot 返回 null → open 返回 false(安全失败)—— +{ + const { sandbox, splitStore } = fresh() + const r = splitStore.open(makeSpec('t-e')) + if (r !== false) fail('E.open', 'should fail without conversation root') + ok('E. 无会话根时安全失败(不崩溃)') +} + +// —— F. 输入包装器 / textarea 不被误选 —— +{ + const { sandbox, splitStore, allEls } = fresh() + const root = new FakeEl('DIV', 'wSkVaW_root', { phase: 'hero' }).setRect({ left: 280, top: 0, right: 1258, bottom: 761 }) + const body = new FakeEl('DIV', 'wSkVaW_body').setRect({ left: 280, top: 0, right: 1258, bottom: 761 }) + const inputWrap = new FakeEl('DIV', 'uV2eYG_input', { phase: 'plain' }) + const p = new FakeEl('P') + inputWrap.appendChild(p) + const ta = new FakeEl('TEXTAREA', '', { phase: 'inert' }) + root.appendChild(body); body.appendChild(inputWrap); body.appendChild(ta) + allEls.push(root, inputWrap, ta); sandbox.document.body.appendChild(root) + const r = splitStore.open(makeSpec('t-f')) + if (r !== true) fail('F.open', 'open failed') + if (splitStore.root !== root) fail('F.root', 'wrong root chosen') + splitStore.close() + ok('F. 输入包装器/textarea 不被误选为会话根') +} + +// —— H. 0.1.2 active:零高槽位包装 + 内部真实头部(实测结构) —— +{ + const { sandbox, splitStore, allEls } = fresh() + const root = new FakeEl('DIV', 'wSkVaW_root', { phase: 'active' }).setRect({ left: 280, top: 0, right: 1258, bottom: 761 }) + const wrap = new FakeEl('DIV', '').setRect({ left: 280, top: 0, right: 1258, bottom: 0 }) // display:contents 式零高包装 + const header = new FakeEl('HEADER', 'wSkVaW_header').setRect({ left: 280, top: 0, right: 1258, bottom: 75.67 }) + const body = new FakeEl('DIV', 'wSkVaW_body').setRect({ left: 280, top: 75.67, right: 1258, bottom: 761 }) + wrap.appendChild(header) + root.appendChild(wrap); root.appendChild(body) + allEls.push(root); sandbox.document.body.appendChild(root) + const r = splitStore.open(makeSpec('t-h')) + if (r !== true) fail('H.open', 'open() !== true') + if (splitStore.header !== header) fail('H.header', 'should anchor to inner header, got ' + (splitStore.header && splitStore.header.tagName)) + if (splitStore.viewArea !== body) fail('H.viewArea', 'viewArea not body') + if (splitStore.geom.top !== 75.67) fail('H.geom', 'top=' + splitStore.geom.top + ' != 75.67') + splitStore.close() + ok('H. 0.1.2 active(零高包装+内部头部)锚定到真实头部') +} + +// —— G. settling 过渡态:syncAnchor 等待(不关闭)—— +{ + const { sandbox, splitStore, allEls, moInstances } = fresh() + const root = new FakeEl('DIV', 'wSkVaW_root', { phase: 'hero' }).setRect({ left: 280, top: 0, right: 1258, bottom: 761 }) + const body = new FakeEl('DIV', 'wSkVaW_body').setRect({ left: 280, top: 0, right: 1258, bottom: 761 }) + root.appendChild(body) + allEls.push(root); sandbox.document.body.appendChild(root) + splitStore.open(makeSpec('t-g')) + root.dataset.phase = 'settling' + const bodyMOs = moInstances.filter((m) => m._target === sandbox.document.body) + const fallbackMO = bodyMOs[bodyMOs.length - 1] + fallbackMO.cb() + if (!splitStore.active) fail('G.close', 'settling should not close') + ok('G. settling 过渡态保持等待') +} + +console.log('all anchor-dom tests passed: ' + pass + '/' + pass) diff --git a/04_test/server-home.test.mjs b/04_test/server-home.test.mjs new file mode 100644 index 0000000..1438f0b --- /dev/null +++ b/04_test/server-home.test.mjs @@ -0,0 +1,227 @@ +/** + * 服务端数据目录解析回归测试(Codex 对抗审查:loadPkg ↔ resolveDshHomeSafe 循环调用 + 降级规则对齐官方)。 + * 用法:node server-home.test.mjs [lib/index.js 路径](缺省 = 工作目录构建产物) + * + * 场景(全部在子进程隔离目录中运行,父进程不改环境变量、不残留临时目录): + * A. 官方分支:隔离目录里「明确准备官方工具包夹具」(与 @deepseek-ai/dsh-home-paths 同布局、同实现语义), + * 断言 homeSource=official(证明代码确实走了官方分支并调用其 resolveDshHome)+ 探测次数有界 + 内容正确 + * B. BOM 容忍:状态码 + 解析后内容双断言 + * C. 兜底分支:官方包不可解析 → homeSource=fallback + 探测次数有界 + 内容正确(旧代码循环重入会产生上万次探测) + * D/E/F. ~ / ~/ / ~\ 前缀展开与相对路径:断言错误信息包含「JSON 转义后的完整预期绝对路径」 + * 说明:DSH_HOME 空/纯空白 → 默认 ~/.dsh(真实目录)——测试刻意不触发,避免读取真实用户数据。 + * 失败路径不调用 process.exit(避免跳过 finally),统一收口到 process.exitCode。 + */ +import { spawnSync } from 'node:child_process' +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const HERE = dirname(fileURLToPath(import.meta.url)) +// 构建产物路径参数化:发布脚本传入「已安装包内」的 lib/index.js;缺省回退工作目录构建产物 +const BUNDLE_SRC = resolve(process.argv[2] ?? join(HERE, '..', '01_content', 'lib', 'index.js')) + +// 探测次数上界:正常解析(祖先链 + profiles 枚举)远低于此;旧代码循环重入会达到数千次 +const PROBE_BOUND = 100 + +let pass = 0 +const failures = [] +function fail(name, detail) { + failures.push(name + ': ' + detail) + console.error('FAIL(' + name + '): ' + detail) +} +const ok = (name) => { console.log('ok ' + name); pass++ } + +/** 临时 DSH 数据目录(含 storages/workspace.json 标记内容;无 BOM) */ +function makeHome(marker) { + const home = mkdtempSync(join(tmpdir(), 'wt-home-test-')) + mkdirSync(join(home, 'storages'), { recursive: true }) + writeFileSync(join(home, 'storages', 'workspace.json'), JSON.stringify({ + unit: { name: 'workspace', version: 2 }, + global: { initialized: true, workspaceIds: ['fake-' + marker], archivedSessionIds: [] }, + tables: { workspaces: { ['fake-' + marker]: { title: marker, sessionIds: ['s-' + marker] } } }, + }), 'utf8') + return home +} + +/** + * 子进程跑一个隔离场景: + * - withOfficial=true 时,在隔离目录 node_modules 下「明确准备官方工具包夹具」 + * (与 @deepseek-ai/dsh-home-paths 同布局:lib/index.js 导出 resolveDshHome/expandHomePath/defaultDshHome, + * 实现为其公开文档语义的忠实镜像)——验证我们调用官方分支与返回值处理的集成, + * 上游包自身语义由官方维护;夹具不引入生产依赖。 + */ +function runChild(envHome, name, withOfficial = false) { + const isoDir = mkdtempSync(join(tmpdir(), 'wt-home-iso-')) + let home = null + try { + mkdirSync(join(isoDir, 'lib'), { recursive: true }) + copyFileSync(BUNDLE_SRC, join(isoDir, 'lib', 'index.js')) + if (withOfficial) { + const pkgDir = join(isoDir, 'node_modules', '@deepseek-ai', 'dsh-home-paths') + mkdirSync(join(pkgDir, 'lib'), { recursive: true }) + writeFileSync(join(pkgDir, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-home-paths', version: '0.1.2-rc.1', type: 'module', main: 'lib/index.js' }), 'utf8') + writeFileSync(join(pkgDir, 'lib', 'index.js'), [ + "import { homedir } from 'node:os'", + "import { join, resolve } from 'node:path'", + "export const DSH_HOME_DIR_NAME = '.dsh'", + "export function defaultDshHome() { return join(homedir(), DSH_HOME_DIR_NAME) }", + "export function expandHomePath(path) {", + " if (path === '~') return homedir()", + " if (path.startsWith('~/') || path.startsWith('~' + String.fromCharCode(92))) return join(homedir(), path.slice(2))", + " return path", + "}", + "export function resolveDshHome(configured, env = process.env) {", + " const fromEnv = env['DSH_HOME']", + " return resolve(expandHomePath(configured ?? (fromEnv !== undefined && fromEnv.trim().length > 0 ? fromEnv : defaultDshHome())))", + "}", + "export function dshHomePath(...segments) { return join(resolveDshHome(), ...segments) }", + "export function dshHomeDisplay(resolvedHome) {", + " return resolvedHome === resolve(defaultDshHome()) ? '~/.dsh' : '$DSH_HOME'", + "}", + "export const DSH_HOME_ENV = 'DSH_HOME'", + "export const DEFAULT_DSH_HOME_DISPLAY = '~/.dsh'", + ].join('\n'), 'utf8') + } + const homeVal = envHome === undefined ? (home = makeHome('MARKER_' + name)) : envHome + const script = [ + "import { pathToFileURL } from 'node:url'", + "import { resolve as pResolve, join as pJoin } from 'node:path'", + "import { homedir } from 'node:os'", + "const mod = await import(pathToFileURL(process.argv[1]).href)", + "const routes = []", + "const fakeCtx = { webServer: { register: (r) => { routes.push(r) } }, effect: () => {}, logger: { warn() {}, info() {} } }", + "mod.apply(fakeCtx)", + "const route = routes.find((r) => r.path === '/api/worktable/workspaces')", + "let status = 0; let body = ''", + "const res = { writeHead(s) { status = s }, end(b) { body = String(b) } }", + "await route.handler({}, res)", + "const stats = mod.__wtLoadProbeStats ? mod.__wtLoadProbeStats() : null", + "const raw = process.env.DSH_HOME || ''", + "const BS = String.fromCharCode(92)", + "const expanded = raw === '~' ? homedir() : (raw.startsWith('~/') || raw.startsWith('~' + BS)) ? pJoin(homedir(), raw.slice(2)) : raw", + "const expectedFull = pResolve(expanded, 'storages', 'workspace.json')", + "const expectedEncoded = JSON.stringify(expectedFull).slice(1, -1)", + "console.log(JSON.stringify({ status, body: body.slice(0, 500), stats, expectedFull, fullMatch: body.includes(expectedEncoded) }))", + ].join('\n') + const r = spawnSync(process.execPath, ['--input-type=module', '-e', script, join(isoDir, 'lib', 'index.js')], { + env: { ...process.env, DSH_HOME: homeVal ?? '' }, + encoding: 'utf8', + timeout: 15000, + }) + if (r.error) { fail(name, 'spawn error: ' + r.error.message); return null } + if (r.status !== 0) { fail(name, 'exit=' + r.status + ' stderr=' + (r.stderr || '').slice(0, 400)); return null } + return JSON.parse(r.stdout.trim().split('\n').pop()) + } finally { + rmSync(isoDir, { recursive: true, force: true }) + if (home) rmSync(home, { recursive: true, force: true }) + } +} + +// —— A+B. 官方分支(测试环境明确准备官方夹具)+ BOM 容忍 —— +{ + const out = runChild(undefined, 'A', true) + if (!out) fail('A.官方路径', 'child did not return') + else { + if (out.status !== 200) fail('A.官方路径', 'status=' + out.status + ' body=' + out.body.slice(0, 120)) + else { + try { + const parsed = JSON.parse(out.body) + if (parsed.tables?.workspaces?.['fake-MARKER_A']?.title !== 'MARKER_A') fail('A.官方路径', 'content mismatch: ' + out.body.slice(0, 160)) + } catch { fail('A.官方路径', 'body not JSON: ' + out.body.slice(0, 160)) } + } + if (!out.stats) fail('A.官方路径', '__wtLoadProbeStats missing') + else { + if (out.stats.homeSource !== 'official') fail('A.官方路径', 'homeSource=' + out.stats.homeSource + ' (expect official; 场景 A 必须证明官方分支被实际调用)') + if (out.stats.attempts > PROBE_BOUND) fail('A.官方路径', 'probe attempts=' + out.stats.attempts + ' > ' + PROBE_BOUND) + } + // BOM 容忍:在子进程里没法重写(缓存单次解析),这里再跑一次带 BOM 夹具文件的场景 B + } + // B. BOM:单独子进程,写入带 BOM 的 workspace.json + const outB = (() => { + const isoDir = mkdtempSync(join(tmpdir(), 'wt-home-iso-')) + let home = null + try { + mkdirSync(join(isoDir, 'lib'), { recursive: true }) + copyFileSync(BUNDLE_SRC, join(isoDir, 'lib', 'index.js')) + const pkgDir = join(isoDir, 'node_modules', '@deepseek-ai', 'dsh-home-paths') + mkdirSync(join(pkgDir, 'lib'), { recursive: true }) + writeFileSync(join(pkgDir, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-home-paths', version: '0.1.2-rc.1', type: 'module', main: 'lib/index.js' }), 'utf8') + writeFileSync(join(pkgDir, 'lib', 'index.js'), [ + "import { homedir } from 'node:os'", + "import { join, resolve } from 'node:path'", + "export function resolveDshHome(configured, env = process.env) {", + " const fromEnv = env['DSH_HOME']", + " return resolve(configured ?? (fromEnv !== undefined && fromEnv.trim().length > 0 ? fromEnv : join(homedir(), '.dsh')))", + "}", + ].join('\n'), 'utf8') + home = makeHome('MARKER_B') + const file = join(home, 'storages', 'workspace.json') + writeFileSync(file, '\uFEFF' + readFileSync(file, 'utf8'), 'utf8') + const script = [ + "import { pathToFileURL } from 'node:url'", + "const mod = await import(pathToFileURL(process.argv[1]).href)", + "const routes = []", + "const fakeCtx = { webServer: { register: (r) => { routes.push(r) } }, effect: () => {}, logger: { warn() {}, info() {} } }", + "mod.apply(fakeCtx)", + "const route = routes.find((r) => r.path === '/api/worktable/workspaces')", + "let status = 0; let body = ''", + "const res = { writeHead(s) { status = s }, end(b) { body = String(b) } }", + "await route.handler({}, res)", + "console.log(JSON.stringify({ status, body: body.slice(0, 500) }))", + ].join('\n') + const r = spawnSync(process.execPath, ['--input-type=module', '-e', script, join(isoDir, 'lib', 'index.js')], { + env: { ...process.env, DSH_HOME: home }, + encoding: 'utf8', + timeout: 15000, + }) + if (r.error) { fail('B.BOM', 'spawn error: ' + r.error.message); return null } + if (r.status !== 0) { fail('B.BOM', 'exit=' + r.status + ' stderr=' + (r.stderr || '').slice(0, 400)); return null } + return JSON.parse(r.stdout.trim().split('\n').pop()) + } finally { + rmSync(isoDir, { recursive: true, force: true }) + if (home) rmSync(home, { recursive: true, force: true }) + } + })() + if (outB) { + let parsed = null + try { parsed = JSON.parse(outB.body) } catch {} + if (outB.status !== 200 || !parsed || parsed.tables?.workspaces?.['fake-MARKER_B']?.title !== 'MARKER_B') fail('B.BOM', 'status=' + outB.status + ' body=' + outB.body.slice(0, 160)) + } + if (!failures.some((f) => f.startsWith('A.')) && !failures.some((f) => f.startsWith('B.'))) ok('A+B. 官方分支(夹具验证 homeSource=official + 有界探测)+ BOM 容忍') +} + +// —— C. 兜底分支:官方包不可解析 → baseDshHome,且探测次数有界(循环回归) —— +{ + const out = runChild(undefined, 'C') + if (!out) fail('C.兜底路径', 'child did not return') + else { + if (out.status !== 200) fail('C.兜底路径', 'status=' + out.status + ' body=' + out.body.slice(0, 200)) + if (!out.body.includes('MARKER_C')) fail('C.兜底路径', 'content mismatch: ' + out.body.slice(0, 200)) + if (!out.stats) fail('C.兜底路径', '__wtLoadProbeStats missing') + else { + if (out.stats.homeSource !== 'fallback') fail('C.兜底路径', 'homeSource=' + out.stats.homeSource + ' (expect fallback)') + if (out.stats.attempts > PROBE_BOUND) fail('C.兜底路径', 'probe attempts=' + out.stats.attempts + ' > ' + PROBE_BOUND + '(疑似循环重入)') + } + } + if (!failures.some((f) => f.startsWith('C.'))) ok('C. 官方包不可解析 → baseDshHome 兜底(homeSource=fallback + 探测有界 + 内容正确)') +} + +// —— D/E/F. ~ / ~/ / ~\ 前缀展开 + 相对路径:完整绝对路径断言 —— +{ + const cases = [ + ['D.波浪号', '~/__wt_probe_tilde__'], + ['E.相对路径', '__wt_probe_rel__'], + ['F.反斜杠波浪号', '~' + String.fromCharCode(92) + '__wt_probe_bs__'], + ] + for (const [label, envVal] of cases) { + const out = runChild(envVal, label.replace(/^\w\./, '')) + if (!out) { fail(label, 'child did not return'); continue } + if (out.status !== 404) { fail(label, 'expect 404, got ' + out.status + ' ' + out.body.slice(0, 120)); continue } + if (out.fullMatch !== true) fail(label, 'error missing FULL path: expected ' + out.expectedFull + ' body=' + out.body.slice(0, 200)) + } + if (!failures.some((f) => /^[DEF]\./.test(f))) ok('D+E+F. ~ 与 ~/ 与 ~\\ 前缀展开 + 相对路径按 cwd 解析(完整绝对路径断言)') +} + +console.log('all server-home tests passed: ' + pass + ' scenarios, ' + failures.length + ' failures') +process.exitCode = failures.length > 0 ? 1 : 0 diff --git a/AGENTS.md b/AGENTS.md index d01b27e..8a8cec4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,10 +54,14 @@ node --check lib/index.js 版本一致性 → 构建(cwd 固定 01_content)→ node --check → npm pack → 结构清单断言(package/ 前缀 + 精确 7 文件, 无 src/、无 .map)→ 独立临时目录 npm install + import() 断言(apply 函数/inject 含 webServer+sessions/name/ HEALTH_PATH/包内双 bundle 版本)→ **客户端工厂求值门禁**(ModuleLoader 恰好注册一次 + ID 校验 + 精确外部依赖 - 白名单 react/react/jsx-runtime + apply/inject 断言)→ dist/v版本号/ 双资产(终态恰好 2 文件 + 双 SHA 同源)。 + 白名单 react/react/jsx-runtime + apply/inject 断言)→ **分栏锚点 DOM 回归**(8 场景,评估安装产物 lib/client.js; + 测试接受包路径参数)→ **服务端数据目录回归**(3 组场景,评估安装产物 lib/index.js)→ dist/v版本号/ 双资产(终态恰好 2 文件 + 双 SHA 同源)。 脚本零 git/gh 动作,发布上传由 gh 手动完成。 **发布禁止裸 npm pack 或手工 tar 生成发布包**;脚本从仓库任意目录调用均安全(以自身位置解析)。 -- **配套检查入口**:`npm run test:gate` = 工厂门禁 9 个失败/正向用例; +- **配套检查入口**:`npm run test:gate` = 工厂门禁 10 个失败/正向用例; + `node 04_test/anchor-dom.test.mjs [lib/client.js 路径]` = 分栏锚点 8 场景(缺省用工作目录构建产物); + `node 04_test/server-home.test.mjs [lib/index.js 路径]` = 数据目录解析 3 组场景(子进程隔离夹具; + **突变体验证法**:把 loadPkg 兜底的 baseDshHome() 故意改回 resolveDshHomeSafe() 恢复循环,测试必须红)。 `npm run verify:remote -- --expect-sha [tag]` = 发布后只读核对 (远端固定名+版本化双资产文件名/结构/版本/双 SHA 同源且等于本地验收 SHA/安装/导入; 远端只读,本地仅临时目录)。上传后必须跑 verify:remote 并用 --expect-sha 比对, @@ -71,6 +75,20 @@ node --check lib/index.js - **预设追加规则**:新布局预设只允许追加到 `PRESET_DEFS` 末尾(选择器里的「+自定义」磁贴 永远是最后一个);字段 leftCount/topCount/contentCount/chatFull/topHeightDefault/topHeightRatio, 聊天窗恒在右侧;缩略图在 presetThumb() 加分支。 +- **分栏引擎双版本锚点(v0.3.3 兼容契约,0.1.1-rc.2 与 0.1.2-rc.1)**:会话根三种结构—— + 0.1.1 active = [头部, 滚动区];空会话 hero = [隐藏头部(headerHidden), 内容区];0.1.2 无会话 + hero = [内容区](单子元素)。resolveAnchor:仅「存在第二个子元素」且第一个可见(自身有高度, + 或零高槽位包装内第一个可见后代——visibleHeaderIn)才作头部;否则顶部取根顶部。phase 排序 + active > hero > settling,settling 等待不关闭。**同根重锚(hero↔active)只在锚点元素真正 + 更换时才更新 saved 原值**,否则关闭时泄漏已应用的 margin/宽度变量。改锚点必须跑 + anchor-dom 8 场景(含 H:0.1.2 零高包装+内部头部)。0.1.2 的 children[0] 是 display:contents + 式零高包装、真实标题栏在内部——不要凭 0.1.1 的结构想当然。 +- **数据目录解析无循环(v0.3.3 红线)**:resolveDshHomeSafe = 官方 @deepseek-ai/dsh-home-paths + (可用时)→ baseDshHome 兜底;**loadPkg 的 profiles 兜底只能用 baseDshHome,禁止回调 + resolveDshHomeSafe(会成环)**。baseDshHome 规则对齐官方:DSH_HOME 优先、空/纯空白视为未设、 + ~ 与 ~/ 与 ~\ 展开、相对路径按 cwd、默认 ~/.dsh;trim 只用于空白判断、路径保留原字符串。 + **不把 dsh-home-paths 声明为生产依赖**(其 peer cordis ^4.0.2 不满足 0.1.1-rc.2 的 4.0.1, + 已试过并撤回)。改这块必须跑 server-home 3 组场景 + 突变体验证(恢复循环测试必须红)。 - **新会话预设修复**:新建会话(createCustomSession / bindConsoleNew)创建后调用 ensureSessionPreset——用宿主 api.agentPresets.list/select 显式应用「部署默认预设」 (isDefault ?? 首个,失败逐个尝试其余预设;select 仅对 blank 会话生效)。 @@ -197,29 +215,17 @@ node --check lib/index.js 与版本化资产。版本注入:build.mjs 把 package.json version 打进 __WT_VERSION__,发版前 改 version 再构建;升级动作(执行 add + 重启)永远留给用户或其 Agent,插件不自更新。 -## 宿主升级事故(0.1.1-rc.2 link 插件回归) +## 宿主升级事故(0.1.1-rc.2 link 插件回归,备忘) -- 现象:rc.2 的 cordis-plugin-loader 对裸包名走原生 ESM import()(realpath 解析), - link: 挂载的自研插件(dsh-worktable 等服务端 cordis 插件)找不到 profile 里的 peer - 依赖,插件树加载失败、服务启动即崩。--preserve-symlinks 不可用(破坏 pnpm 虚拟存储 - 下 sharp/koffi 等 native 模块)。 -- 临时修复(已验证):在 link 插件共同祖先目录建 junction: - `E:\AI_Workspace\DeepseekHarness\node_modules` → `C:\Users\SJL\.dsh\profiles\node_modules` - (覆盖本目录下所有 link 插件;另一个 Agent 已建好)。**上游 loader 修复后必须删除该 - junction**,避免双解析叠加。 -- 我们仓库侧核查结论:package.json peerDependencies 全部 `"*"` + optional(干净); - dsh.client.inject 里的 dsh-client-ui-slots / dsh-client-ui-primitives 是运行时服务注入 - (无版本解析),工作正常,不删除。 -- 恢复后验收:functional-diag 全 20 STEP ERROR_COUNT: 0;/api/worktable/health ok、 - /workspaces 200。 +- rc.2 的 loader 对裸包名走原生 ESM import(),link: 插件找不到 profile 里的 peer 依赖 → 启动即崩。 + 临时修复:junction `E:\AI_Workspace\DeepseekHarness\node_modules` → `C:\Users\SJL\.dsh\profiles\node_modules`。 + **上游 loader 修复后必须删除该 junction**(双解析叠加)。peerDependencies 全部 `"*"` + optional 已核查(干净)。 ## 宿主 bug 跟踪(UTF-16 路径截断) -- 官方 `dsh-host-directory-picker-native` 的 readUtf16 只查 UTF-16LE 码元低字节,含 U+XX00 - 字符(开/一/言/Ā/🀀 等)的文件夹路径在选目录时被截断 → 创建工作区异常。非我们插件问题。 -- 修复与回归测试以 patch 存档:`02_process/upstream/utf16-picker-fix.patch`;官方 Discussions - #580 已接单(tianyicui「我们修复一下」),我们的补证评论已发(Aisland-SJL)。 -- 待办:官方修 master 后核验对应 npm 版本是否含修复,随后可清理 patch 存档。 +- 官方 native picker 的 readUtf16 只看 UTF-16LE 低字节,含 U+XX00 字符(开/一/言/Ā/🀀 等)的路径被截断。非我们插件问题。 + 修复与回归测试存档:`02_process/upstream/utf16-picker-fix.patch`;官方 Discussions #580 已接单。 + 待办:官方修 master 后核验对应 npm 版本是否含修复,随后可清理 patch 存档。 ## 安装 / 重启 diff --git a/README.md b/README.md index 742d31b..9f36552 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ node --check lib/index.js - **Build must run inside `01_content`** — building from the repo root writes `lib/` to the wrong place while the host keeps loading the old bundle - The client bundle keeps the `window.__ModuleLoader__.load` handshake; `react` and `@deepseek-ai/*` stay external - Regression: `04_test/functional-diag.cjs` (20 steps, strict gate) plus targeted probes (control room, bind panel, collapsed rail, model inheritance), the path matrix (`04_test/pathutil-matrix.cjs`) and update-check scenarios (`04_test/probe-update-scenarios.cjs`) +- In the release pipeline: split-anchor DOM regression `04_test/anchor-dom.test.mjs` (8 scenarios, both host conversation-root shapes) and data-home resolution regression `04_test/server-home.test.mjs` (3 groups: no-cycle fallback, path expansion, official-branch fixture) --- diff --git a/README.zh.md b/README.zh.md index ad88ac0..10eecf6 100644 --- a/README.zh.md +++ b/README.zh.md @@ -98,7 +98,9 @@ node --check lib/index.js - **构建必须在 01_content 内执行**——在仓库根构建会把 lib 写到错误位置,宿主仍加载旧 bundle - 客户端 bundle 保持 `window.__ModuleLoader__.load` 握手;react 与 @deepseek-ai/* 全部 external -- 回归:`04_test/functional-diag.cjs`(20 步)+ 专项探测(控制室、绑定弹窗、收起态贴片、模型继承) +- 回归:`04_test/functional-diag.cjs`(20 步)+ 专项探测(控制室、绑定弹窗、收起态贴片、模型继承); + 发布流水线内嵌:分栏锚点 DOM 回归 `04_test/anchor-dom.test.mjs`(8 场景,双宿主会话结构)与 + 数据目录解析回归 `04_test/server-home.test.mjs`(3 组场景,无循环/路径展开/官方分支夹具) ---