feat(v0.4.0): support Windows Desktop and isolate project widget mounts

This commit is contained in:
Aisland
2026-10-03 13:02:20 +08:00
parent e63d122142
commit 6cb86b6c73
22 changed files with 1507 additions and 452 deletions
+28 -7
View File
@@ -6,8 +6,8 @@
- **侧边栏「工作台」区块**:收纳用户自建项目与入驻插件项目,支持改名/图标/排序/显示隐藏、项目 × 对话绑定、项目文件夹。
- **分栏工作区引擎(自研)**:声明式布局预设(左栏/顶行/主行 + 右侧对话窗),窗格可拖拽分割、标签页模型;内置 资源管理器 / 终端 / 浏览器 / 动画 / 自定义窗口。
- **控制室**:默认自带项目(固定首位、不可删除),项目卡片网格实时监控所有项目的状态(工作中/待你决定/已完成),零轮询零 Token。
- **自动挂载握手**:项目内 agent 完成窗口任务后写 widget-result.json,产物自动挂进对应窗口。
- **控制室**:默认自带项目(固定首位、不可删除),项目卡片网格镜像可见绑定项目的宿主状态(工作中/待你决定/已完成);状态订阅不调用模型,计时与预览另有刷新机制。
- **项目独立挂载**:窗口任务使用项目 ID、窗格 ID 与绑定 ID 对应的专属结果文件;同一文件夹里的新项目仍从空白窗口开始,不自动读取其他项目的旧结果。
- **平台**:Windows 是本轮验证平台,范围见下方兼容说明;macOS 为实验性支持(核心文件路径代码已做跨平台适配,尚未真机端到端验证)。
## 技术底座
@@ -17,15 +17,26 @@
- 状态监控为宿主会话运行时快照的事件订阅镜像;视图/项目/绑定状态存 localStorage。
- 客户端:TypeScript + React(host externals)+ 原生 CSS;服务端:Node。
## v0.3.4 兼容与验证范围
## v0.4.0:兼容与验证范围
- 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 并刷新页面,仍加载失败需保留完整错误。
- Windows + 0.2.0-rc.2 网页端已有冷启动、工作台/控制室、新建绑定与短文本回复、关闭回切等验收基础;桌面主要页面与交互已点测,共目录串挂修复获用户确认。最终包经过 98 项源码回归、独立安装与客户端加载门禁;这些检查不等于完整桌面端业务验收。旧版沿用此前页面验收并补定向代码回归,未重跑整套旧版 GUI。
- 实际审批、子代理、自动挂载全链路尚未完成业务验收;macOS 未真机验证。升级后请完整重启 DSH 并刷新页面,仍加载失败需保留完整错误。
## v0.4.0 改动
- 针对 Windows 官方 Desktop 0.2.0-rc.2 补上终端连接:从宿主注入的 streamBaseUrl 取得实际 Host 地址,不把 dsh-app://app 当作 WebSocket 主机。普通 HTTP/HTTPS 网页连接保留。
- 更新指令区分 web/desktop profile;桌面安装必须使用该应用自带的 CLI,安装前完整退出,之后由用户手动重新打开。不要把网页端 CLI 当作桌面管理入口。
- 自定义窗口新建对话明确选择「未分组」时,保留项目工作目录但不自动加入宿主工作区;异步默认分组不覆盖用户选择。不移动已经创建的对话。
- 控制室创建的是空对话:新版 DSH 的未分组空对话无法使用原生输入框,因此在这一选择下提示并禁用创建,须由用户选择分组或加入可用的现有对话;不自动改组、不发送激活消息。现有分组未选具体项时也不允许创建。旧宿主未分组分支保留。
- 自动挂载只接收经「自定义」任务登记的项目/窗口结果,握手文件位于项目目录的 `.dsh-worktable/project-<项目 ID>/` 下。换目录、重绑项目、修改布局或手动关闭/替换窗口内容后,旧关联不再自动补挂。
- 同一项目的不同会话只得到和更新各自关联的窗口;同会话追改同名作品可以重新加载。补挂读取失败保留待办,下次打开可重试,成功后才清除。
- 已保存的旧窗口与产物文件保留;根目录旧 `widget-result.json` 不再自动导入。旧对话若需继续自动挂载,请从目标窗口的「自定义 → 发送到会话」发一次任务以建立新关联,随后可在同一对话直接继续修改。不自动清除此前误挂的标签;多个项目主动改写同一实际文件时,文件内容仍可能互相影响。
- 两端共享同一 DSH_HOME 时不必复制会话;项目/布局/媒体和外观设置按浏览器来源保存,不会自动从网页来源同步到桌面来源。不要复制整个浏览器配置或登录状态。
## 权限与外部服务(如实披露)
- **文件访问**:服务端读写工作区与用户指定的目录——文件窗目录列表、MD 编辑保存、项目文件夹 mkdir、站点静态托管、widget 产物读取。除用户当次操作外,插件在加载时、项目状态变化时、绑定会话完成时会自动读取已配置项目的数据(如挂载产物 widget-result.json、项目文件夹与绑定信息),并读取宿主提供的工作区数据(workspaces 路由)。读写路径均来自用户配置或宿主数据;失败返回错误或降级。
- **文件访问**:服务端读写工作区与用户指定的目录——文件窗目录列表、MD 编辑保存、项目文件夹 mkdir、站点静态托管、widget 产物读取。除用户当次操作外,插件在加载时、项目状态变化时、已登记窗口任务完成时会自动读取当前关联的 `.dsh-worktable/` 结果文件及其指定产物,并读取宿主提供的工作区数据(workspaces 路由)。不扫描根目录旧 `widget-result.json`;产物路径可来自任务结果,这不是文件系统沙箱。失败返回错误或降级。
- **网络**:① 更新检查向 api.github.com 发只读 GET(无凭据,请求仅含公开仓库名,不携带会话内容);② 插件与宿主的通信走宿主自身的 API / WebSocket 通道(由宿主管理);③ 「浏览器」「动画」等网页窗加载用户指定的页面,页面自身可能发起任意网络请求——等同于用户在自己的浏览器中打开这些页面。
- **命令执行**:① 「源代码管理」窗运行 git 只读状态命令;② 「终端」窗经 node-pty 启动交互式 Shell(Windows 下为 PowerShell)——这是用户主动使用的完整终端:终端内执行的任何命令都可能读写文件、联网,行为等同用户本人操作。
- **凭据与环境**:插件代码不专门提取、存储或上传任何 API Key / Token / Cookie;但终端窗把宿主进程的完整环境变量传给子 Shell(其中可能包含敏感信息),终端内命令的行为不受插件约束。插件对 process.env 的读取用于运行参数(数据目录 DSH_HOME、终端默认 Shell SHELL 等),不将其外发。以上声明均有对应源码可查;本说明不是安全保证,高权限能力如实列出,使用风险由用户在自身环境评估。
@@ -33,7 +44,9 @@
## 安装
方式 A(推荐,无需 Git)——直接安装 GitHub Release 的安装包:
### 网页端
方式 A(推荐,无需 Git)——直接安装 GitHub Release 的最新安装包:
dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"
@@ -44,6 +57,14 @@
两种方式 `add` 都会把 `dsh-worktable` 注册进 profile 的 bundle 列表(写入 `~/.dsh`),装完重启 dsh web、刷新界面生效。
### Windows 官方桌面端
先保存任务,从应用菜单/托盘完整退出(只关窗口不够);将占位路径替换为实际桌面端安装目录,在 PowerShell 执行,再手动重新打开应用:
& "<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add "https://github.com/Aisland-SJL/dsh-worktable/releases/download/v0.4.0/dsh-worktable-0.4.0.tgz"
不要以不固定版本的 npx 或网页端 CLI 代替桌面端自带 CLI;保留实际 DSH_HOME,勿把测试数据目录覆盖正式数据。新建控制室管理对话时,DSH 0.2 用户请选择分组,或直接绑定可用的现有对话。
## 从源码构建
cd 01_content
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "dsh-worktable",
"description": "工作台:DSH 侧边栏的 agent 级项目容器,收纳如 TravelAtlas 等自装项目",
"version": "0.3.4",
"version": "0.4.0",
"entry": {
"name": "dsh-worktable",
"inject": []
+317 -134
View File
@@ -8372,6 +8372,7 @@ var css = xterm_default + "\n" + [
".dsh-wt_consoleSelect,.dsh-wt_consoleInput{width:100%;box-sizing:border-box;padding:6px 8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:11.5px;line-height:16px;outline:none}",
".dsh-wt_consoleSelect:focus,.dsh-wt_consoleInput:focus{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7)}",
".dsh-wt_consoleErr{margin:0;font-size:11px;line-height:16px;color:var(--dsw-alias-state-danger,#f85149)}",
".dsh-wt_consoleHint{margin:0;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,#aab6cd)}",
".dsh-wt_consoleCreateBtn{margin-top:2px;padding:7px 10px;border:1px solid var(--dsw-alias-state-accent-primary,#4f8ef7);border-radius:8px;background:transparent;color:var(--dsw-alias-state-accent-primary,#4f8ef7);font:inherit;font-size:12px;line-height:18px;cursor:pointer}",
".dsh-wt_consoleCreateBtn:hover:not(:disabled){background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}",
".dsh-wt_consoleCreateBtn:disabled{opacity:.55;cursor:default}",
@@ -8569,6 +8570,8 @@ var zh = {
"console.groupNone": "\u65E0\u5206\u7EC4\uFF08\u7528\u9879\u76EE\u6587\u4EF6\u5939\uFF09",
"console.groupExisting": "\u52A0\u5165\u73B0\u6709\u5206\u7EC4",
"console.groupNew": "\u65B0\u5EFA\u5206\u7EC4",
"console.chooseGroup": "\u9009\u62E9\u5206\u7EC4\u2026",
"console.blankNeedsGroup": "\u5F53\u524D DSH \u7684\u672A\u5206\u7EC4\u7A7A\u5BF9\u8BDD\u65E0\u6CD5\u8F93\u5165\uFF0C\u8BF7\u9009\u62E9\u5206\u7EC4\u6216\u52A0\u5165\u73B0\u6709\u5BF9\u8BDD\u3002",
"console.newParentPh": "\u5206\u7EC4\u7236\u76EE\u5F55\uFF0C\u4F8B\u5982 E:\\AI_Workspace",
"console.newNamePh": "\u5206\u7EC4\u6587\u4EF6\u5939\u540D\uFF08\u5C06\u521B\u5EFA\u5E76\u6CE8\u518C\uFF09",
"console.createBind": "\u65B0\u5EFA\u5E76\u7ED1\u5B9A",
@@ -8630,7 +8633,8 @@ var zh = {
"update.copied": "\u5DF2\u590D\u5236",
"update.copyFail": "\u590D\u5236\u5931\u8D25\uFF0C\u8BF7\u624B\u52A8\u9009\u62E9\u6587\u672C",
"update.skipDone": "\u5DF2\u5FFD\u7565\u6B64\u7248\u672C\uFF08\u4E0B\u4E00\u7248\u53D1\u5E03\u65F6\u518D\u63D0\u9192\uFF09",
"update.upgradeHint": "\u6267\u884C\u547D\u4EE4\u540E\u9700\u91CD\u542F dsh web \u5E76\u5237\u65B0\u9875\u9762"
"update.upgradeHint": "\u6267\u884C\u547D\u4EE4\u540E\u9700\u91CD\u542F dsh web \u5E76\u5237\u65B0\u9875\u9762",
"update.upgradeHintDesktop": "\u8BF7\u4F7F\u7528\u684C\u9762\u7AEF\u81EA\u5E26 CLI\uFF1B\u5B89\u88C5\u524D\u5B8C\u6574\u9000\u51FA\uFF0C\u5B8C\u6210\u540E\u624B\u52A8\u91CD\u65B0\u6253\u5F00\u684C\u9762\u7AEF"
};
var en = {
"title": "Worktable",
@@ -8799,6 +8803,8 @@ var en = {
"console.groupNone": "No group (use project folder)",
"console.groupExisting": "Join existing group",
"console.groupNew": "New group",
"console.chooseGroup": "Choose a group\u2026",
"console.blankNeedsGroup": "Blank ungrouped chats cannot accept input in this DSH. Choose a group or join an existing chat.",
"console.newParentPh": "Group parent folder, e.g. E:\\AI_Workspace",
"console.newNamePh": "Group folder name (created & registered)",
"console.createBind": "Create & bind",
@@ -8860,7 +8866,8 @@ var en = {
"update.copied": "Copied",
"update.copyFail": "Copy failed \u2014 select the text manually",
"update.skipDone": "Skipped (will remind on the next release)",
"update.upgradeHint": "Run the command, then restart dsh web and refresh"
"update.upgradeHint": "Run the command, then restart dsh web and refresh",
"update.upgradeHintDesktop": "Use the Desktop bundled CLI; fully quit before installing, then reopen Desktop manually"
};
var NS = "worktable";
@@ -8895,8 +8902,58 @@ function basenameOf(p) {
// src/client/split.tsx
var import_react = require("react");
// src/client/hostTransport.ts
function hostWebSocketUrl(path, surface = {
href: location.href,
protocol: location.protocol,
streamBaseUrl: globalThis.__DSH_TRANSPORT__?.streamBaseUrl
}) {
const desktop = surface.protocol === "dsh-app:";
if (desktop && !surface.streamBaseUrl) throw new Error("Desktop Host stream URL unavailable");
const url = new URL(path, desktop ? surface.streamBaseUrl : surface.href);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("Unsupported Host transport protocol: " + url.protocol);
}
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
return url.href;
}
function worktableUpgrade(protocol = typeof location === "undefined" ? "http:" : location.protocol) {
const desktop = protocol === "dsh-app:";
const command = "dsh plugin --profile " + (desktop ? "desktop" : "web") + ' add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"';
const prompt = desktop ? "\u5E2E\u6211\u5347\u7EA7 dsh-worktable\u3002\u6211\u5F53\u524D\u4F7F\u7528\u5B98\u65B9\u684C\u9762\u7AEF\uFF1A\u5148\u5B9A\u4F4D\u8BE5\u684C\u9762\u7AEF\u81EA\u5E26\u7684 dsh CLI \u5E76\u51C6\u5907\u66F4\u65B0\uFF0C\u518D\u63D0\u9192\u6211\u5B8C\u6574\u9000\u51FA\u684C\u9762\u7AEF\u3002\u786E\u8BA4\u9000\u51FA\u540E\u4F7F\u7528\u8BE5 CLI \u6267\u884C " + command + "\u3002\u4E0D\u8981\u4F7F\u7528\u7F51\u9875\u7AEF CLI\uFF0C\u4E0D\u8981\u4FEE\u6539 web profile\uFF0C\u4E0D\u8981\u81EA\u52A8\u6740\u8FDB\u7A0B\u6216\u91CD\u542F\uFF1B\u5B8C\u6210\u540E\u63D0\u9192\u6211\u624B\u52A8\u91CD\u65B0\u6253\u5F00\u684C\u9762\u7AEF\u3002" : "\u5E2E\u6211\u5347\u7EA7 dsh-worktable\uFF1A\u6267\u884C " + command + "\uFF0C\u5B8C\u6210\u540E\u63D0\u9192\u6211\u91CD\u542F dsh web \u5E76\u5237\u65B0\u9875\u9762";
return { command, prompt, desktop };
}
// src/client/changelog.ts
var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.3.4
var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.4.0
\u{1F5A5}\uFE0F \u7F51\u9875\u7AEF\u4E0E Windows \u684C\u9762\u7AEF\u9002\u914D\uFF0C\u4FEE\u590D\u9879\u76EE\u5185\u5BB9\u4E32\u6302
\u3010\u53CC\u7AEF\u4F7F\u7528\u3011\u540C\u4E00\u4E2A\u5DE5\u4F5C\u53F0\u63D2\u4EF6\u5305\u652F\u6301\u7F51\u9875\u7AEF\u4E0E\u5B98\u65B9 Windows \u684C\u9762\u7AEF\u3002\u5EF6\u7EED v0.3.4 \u5BF9 DSH 0.2 \u7F51\u9875\u7AEF\u7684\u9879\u76EE\u4E0E\u5BF9\u8BDD\u8054\u52A8\u3001\u6807\u6CE8\u8F93\u5165\u3001\u63A7\u5236\u5BA4\u72B6\u6001\u53CA\u6D88\u606F\u9884\u89C8\u9002\u914D\uFF0C\u672C\u6B21\u8865\u4E0A\u684C\u9762\u7AEF\u8FDE\u63A5\u4E0E\u5347\u7EA7\u5165\u53E3\u3002
\u3010\u684C\u9762\u7AEF\u7EC8\u7AEF\u4E0E\u66F4\u65B0\u3011\u7EC8\u7AEF\u4F7F\u7528\u684C\u9762\u5BBF\u4E3B\u63D0\u4F9B\u7684\u8FDE\u63A5\u5730\u5740\uFF0C\u7F51\u9875\u7AEF\u4FDD\u7559\u539F\u8FDE\u63A5\u65B9\u5F0F\u3002\u66F4\u65B0\u63D0\u793A\u4F1A\u6309\u6240\u5728\u73AF\u5883\u9009\u62E9 web \u6216 desktop \u914D\u7F6E\uFF1B\u684C\u9762\u7528\u6237\u8BF7\u4F7F\u7528\u5E94\u7528\u81EA\u5E26\u7684 CLI\uFF0C\u5B8C\u6574\u9000\u51FA\u540E\u5B89\u88C5\uFF0C\u518D\u624B\u52A8\u91CD\u65B0\u6253\u5F00\u3002
\u3010\u4FDD\u7559\u201C\u672A\u5206\u7EC4\u201D\u9009\u62E9\u3011\u81EA\u5B9A\u4E49\u7A97\u53E3\u65B0\u5EFA\u5BF9\u8BDD\u65F6\uFF0C\u9009\u62E9\u201C\u672A\u5206\u7EC4\u201D\u5C31\u4FDD\u6301\u672A\u5206\u7EC4\uFF0C\u9879\u76EE\u6587\u4EF6\u5939\u4EC5\u4F5C\u4E3A\u5DE5\u4F5C\u76EE\u5F55\u3002\u4FEE\u590D\u9ED8\u8BA4\u5206\u7EC4\u665A\u8FD4\u56DE\u65F6\u8986\u76D6\u7528\u6237\u9009\u62E9\u7684\u95EE\u9898\uFF1B\u5DF2\u6709\u5BF9\u8BDD\u7684\u5206\u7EC4\u4FDD\u6301\u539F\u6837\u3002
\u3010\u63A7\u5236\u5BA4\u65B0\u5EFA\u5BF9\u8BDD\u3011\u5DF2\u6D4B DSH 0.2 \u7684\u672A\u5206\u7EC4\u7A7A\u5BF9\u8BDD\u65E0\u6CD5\u4F7F\u7528\u539F\u751F\u8F93\u5165\u6846\uFF0C\u63A7\u5236\u5BA4\u4F1A\u63D0\u793A\u539F\u56E0\u5E76\u7981\u7528\u8FD9\u4E00\u521B\u5EFA\u65B9\u5F0F\uFF0C\u8BF7\u9009\u62E9\u73B0\u6709/\u65B0\u5EFA\u5206\u7EC4\uFF0C\u6216\u52A0\u5165\u53EF\u7528\u7684\u73B0\u6709\u5BF9\u8BDD\u3002\u9009\u62E9\u201C\u73B0\u6709\u5206\u7EC4\u201D\u4F46\u5C1A\u672A\u9009\u5B9A\u5177\u4F53\u9879\u65F6\uFF0C\u4E5F\u4E0D\u4F1A\u9000\u56DE\u9ED8\u8BA4\u5206\u7EC4\u521B\u5EFA\uFF1B\u65E7\u5BBF\u4E3B\u4FDD\u7559\u539F\u672A\u5206\u7EC4\u521B\u5EFA\u884C\u4E3A\u3002
\u3010\u65B0\u9879\u76EE\u4ECE\u7A7A\u767D\u5F00\u59CB\u3011\u65B0\u9879\u76EE\u5373\u4F7F\u4E0E\u65E7\u9879\u76EE\u540C\u540D\u3001\u4F7F\u7528\u540C\u4E00\u4E2A\u6587\u4EF6\u5939\uFF0C\u4E5F\u4E0D\u4F1A\u81EA\u52A8\u52A0\u8F7D\u65E7\u9879\u76EE\u7684\u7A97\u53E3\u5185\u5BB9\u3002\u81EA\u52A8\u6302\u8F7D\u5206\u522B\u8BC6\u522B\u9879\u76EE\u3001\u7A97\u53E3\u4E0E\u672C\u6B21\u4EFB\u52A1\u7684\u5173\u8054\uFF0C\u4E3A\u5404\u7A97\u53E3\u4F7F\u7528\u4E13\u5C5E\u7ED3\u679C\u6587\u4EF6\uFF1B\u9519\u8BEF\u5F52\u5C5E\u6216\u65E0\u6548\u7A97\u53E3\u7ED3\u679C\u4E0D\u4F1A\u88AB\u585E\u8FDB\u7A97\u53E31\u3002
\u3010\u4E0D\u540C\u5BF9\u8BDD\u4E92\u4E0D\u4E32\u6302\u3011\u540C\u4E00\u9879\u76EE\u7684\u4E0D\u540C\u5BF9\u8BDD\u53EA\u83B7\u5F97\u548C\u66F4\u65B0\u5404\u81EA\u5173\u8054\u7684\u7A97\u53E3\uFF1B\u4E00\u4E2A\u5BF9\u8BDD\u5B8C\u6210\u65F6\uFF0C\u4E0D\u4F1A\u5F3A\u5236\u91CD\u6302\u53E6\u4E00\u5BF9\u8BDD\u7684\u65E7\u7ED3\u679C\u3002\u540C\u4E00\u5BF9\u8BDD\u7EE7\u7EED\u4FEE\u6539\u540C\u540D\u4F5C\u54C1\u65F6\uFF0C\u7A97\u53E3\u4ECD\u53EF\u91CD\u65B0\u52A0\u8F7D\u66F4\u65B0\u540E\u7684\u5185\u5BB9\u3002\u4EFB\u52A1\u63D0\u793A\u8BCD\u8981\u6C42\u786E\u8BA4\u9875\u9762\u5B9E\u9645\u6302\u8F7D\u540E\u518D\u62A5\u544A\u201C\u5DF2\u6302\u8F7D\u201D\uFF0C\u907F\u514D\u53EA\u5199\u51FA\u7ED3\u679C\u6587\u4EF6\u5C31\u62A5\u6210\u529F\u3002
\u3010\u4FDD\u7559\u624B\u5DE5\u8C03\u6574\u4E0E\u8865\u6302\u91CD\u8BD5\u3011\u624B\u5DE5\u5173\u95ED\u3001\u66FF\u6362\u6216\u79FB\u52A8\u6807\u7B7E\uFF0C\u4EE5\u53CA\u66F4\u6362\u76EE\u5F55\u3001\u5E03\u5C40\u6216\u9879\u76EE\u7ED1\u5B9A\u540E\uFF0C\u65E7\u5173\u8054\u4E0D\u4F1A\u7EE7\u7EED\u8865\u6302\u6216\u8986\u76D6\u4F60\u7684\u8C03\u6574\uFF1B\u8FDF\u5230\u7684\u65E7\u8BF7\u6C42\u4E5F\u4F1A\u91CD\u65B0\u6838\u5BF9\u5F52\u5C5E\u3002\u9879\u76EE\u672A\u6253\u5F00\u65F6\u4FDD\u7559\u5F85\u6302\u8F7D\u8BB0\u5F55\uFF0C\u91CD\u65B0\u6253\u5F00\u4F1A\u8BFB\u53D6\u6700\u65B0\u7ED3\u679C\uFF1B\u8BFB\u53D6\u5931\u8D25\u540E\u8BB0\u5F55\u4ECD\u4FDD\u7559\uFF0C\u4E0B\u6B21\u6253\u5F00\u53EF\u91CD\u8BD5\uFF0C\u6210\u529F\u6302\u8F7D\u540E\u624D\u6E05\u9664\u3002
\u3010\u65E7\u9879\u76EE\u7EE7\u7EED\u4F7F\u7528\u3011\u5DF2\u4FDD\u5B58\u7684\u7A97\u53E3\u3001\u4F5C\u54C1\u3001\u4F1A\u8BDD\u7ED1\u5B9A\u4E0E\u5A92\u4F53\u4FDD\u7559\u3002\u6839\u76EE\u5F55\u65E7 widget-result.json \u4E0D\u518D\u81EA\u52A8\u5BFC\u5165\uFF1B\u65E7\u5BF9\u8BDD\u82E5\u9700\u7EE7\u7EED\u81EA\u52A8\u6302\u8F7D\uFF0C\u8BF7\u4ECE\u76EE\u6807\u7A97\u53E3\u7684\u201C\u81EA\u5B9A\u4E49 \u2192 \u53D1\u9001\u5230\u4F1A\u8BDD\u201D\u53D1\u4E00\u6B21\u9700\u6C42\uFF0C\u5EFA\u7ACB\u65B0\u5173\u8054\u540E\u5373\u53EF\u7EE7\u7EED\u8FFD\u6539\u3002\u65E7\u6587\u4EF6\u4E0D\u81EA\u52A8\u5220\u9664\uFF0C\u6B64\u524D\u8BEF\u6302\u5E76\u4FDD\u5B58\u7684\u6807\u7B7E\u8BF7\u5173\u95ED\u4E00\u6B21\uFF1B\u591A\u4E2A\u4EFB\u52A1\u4E3B\u52A8\u5199\u540C\u4E00\u4E2A\u5B9E\u9645\u4F5C\u54C1\u6587\u4EF6\uFF0C\u6587\u4EF6\u5185\u5BB9\u4ECD\u53EF\u80FD\u4E92\u76F8\u8986\u76D6\u3002
\u3010\u53D1\u5E03\u68C0\u67E5\u8865\u5168\u3011\u589E\u52A0\u684C\u9762\u8FDE\u63A5\u3001\u5206\u7EC4\u9009\u62E9\u3001\u9879\u76EE\u5F52\u5C5E\u3001\u4F1A\u8BDD\u9694\u79BB\u548C\u8865\u6302\u91CD\u8BD5\u56DE\u5F52\uFF0C\u53D1\u5E03\u6D41\u7A0B\u5171\u8FD0\u884C 98 \u9879\u6E90\u7801\u68C0\u67E5\uFF0C\u5E76\u7EE7\u7EED\u6838\u5BF9\u72EC\u7ACB\u5B89\u88C5\u3001\u5BA2\u6237\u7AEF\u52A0\u8F7D\u4E0E\u53CC\u5B89\u88C5\u5305\u4E00\u81F4\u6027\u3002\u8FD9\u4E9B\u68C0\u67E5\u7528\u4E8E\u51CF\u5C11\u53D1\u5E03\u9057\u6F0F\uFF0C\u4E0D\u80FD\u66FF\u4EE3\u771F\u5B9E\u9875\u9762\u548C\u6A21\u578B\u4EFB\u52A1\u9A8C\u6536\u3002
\u3010\u517C\u5BB9\u4E0E\u9A8C\u8BC1\u8303\u56F4\u3011\u7F51\u9875\u7AEF\u517C\u5BB9\u58F0\u660E\u4EC5\u5217 DSH 0.1.1-rc.2\u30010.1.2-rc.1\u30010.2.0-rc.2\uFF1B\u684C\u9762\u7AEF\u8303\u56F4\u4E3A\u5DF2\u6D4B Windows \u5B98\u65B9\u684C\u9762\u7AEF + DSH 0.2.0-rc.2\u3002\u65E7\u7248\u6CBF\u7528\u6B64\u524D\u9875\u9762\u9A8C\u6536\u5E76\u8865\u4EE3\u7801\u56DE\u5F52\uFF0C\u672C\u8F6E\u672A\u91CD\u8DD1\u5B8C\u6574\u65E7\u7248 GUI\uFF1B\u684C\u9762\u4E3B\u8981\u4EA4\u4E92\u5DF2\u70B9\u6D4B\uFF0C\u539F\u5171\u76EE\u5F55\u4E32\u6302\u95EE\u9898\u5DF2\u7531\u7528\u6237\u53CD\u9988\u786E\u8BA4\u4FEE\u590D\uFF0C\u4F46\u672C\u7248\u6700\u7EC8\u5305\u5728\u6B63\u5F0F\u684C\u9762\u7AEF\u7684\u5B8C\u6574\u590D\u9A8C\u3001\u5B9E\u9645\u5BA1\u6279/\u5B50\u4EE3\u7406\u53CA\u6A21\u578B\u751F\u6210\u5230\u81EA\u52A8\u6302\u8F7D\u7684\u5B8C\u6574\u4E1A\u52A1\u9A8C\u6536\u4ECD\u5F85\u5B8C\u6210\u3002macOS \u5C1A\u672A\u771F\u673A\u9A8C\u8BC1\u3002
\u3010\u5347\u7EA7\u4E0E\u6570\u636E\u3011\u7F51\u9875\u7AEF\u548C\u684C\u9762\u7AEF\u7684\u9879\u76EE\u3001\u5E03\u5C40\u3001\u5A92\u4F53\u53CA\u5916\u89C2\u8BBE\u7F6E\u6309\u5404\u81EA\u6D4F\u89C8\u5668\u6765\u6E90\u4FDD\u5B58\uFF0C\u4E0D\u4F1A\u81EA\u52A8\u540C\u6B65\uFF1B\u5171\u7528 DSH_HOME \u4E5F\u4E0D\u610F\u5473\u7740\u8FD9\u4E9B\u754C\u9762\u6570\u636E\u5171\u7528\u3002\u5B89\u88C5\u524D\u5907\u4EFD\u91CD\u8981\u6570\u636E\u5E76\u6838\u5BF9\u5176\u4ED6\u63D2\u4EF6\u517C\u5BB9\u6027\uFF0C\u88C5\u5B8C\u5B8C\u6574\u91CD\u542F\u5BBF\u4E3B\uFF0C\u7F51\u9875\u7AEF\u518D\u5237\u65B0\u9875\u9762\uFF1B\u82E5\u4ECD\u52A0\u8F7D\u5931\u8D25\uFF0C\u8BF7\u4FDD\u7559\u7B2C\u4E00\u6761\u5B8C\u6574\u9519\u8BEF\u3002\u672C\u5DE5\u4F5C\u53F0\u5305\u4E0D\u5305\u542B\u5BBF\u4E3B\u6A21\u578B\u8BF7\u6C42 HTTP 400 \u7684\u4FEE\u590D\uFF1B\u516C\u5F00\u53D1\u5E03\u72B6\u6001\u4EE5 GitHub Release \u4E3A\u51C6\u3002
\u2500\u2500\u2500\u2500\u2500\u2500\u2500
\u6B64\u524D\u7248\u672C\u8BF4\u660E\uFF08v0.3.4\uFF09
\u{1F6E0}\uFE0F \u672C\u6B21\u66F4\u65B0\uFF1A\u9002\u914D DSH 0.2 \u7F51\u9875\u7AEF
@@ -8970,7 +9027,7 @@ var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.3.4
\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.4";
var LOCAL_VERSION = false ? "dev" : "0.4.0";
var UPDATE_REPO = "Aisland-SJL/dsh-worktable";
var K_UPDATE_CHECK = "dsh.worktable.updateCheck.v1";
var K_LAST_CHECK = "dsh.worktable.lastUpdateCheck.v1";
@@ -17097,8 +17154,7 @@ function boxPayload(x0, y0, x1, y1) {
}
return { primary, line, candidates, limited, src };
}
var UPGRADE_CMD = 'dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"';
var UPGRADE_AI = "\u5E2E\u6211\u5347\u7EA7 dsh-worktable\uFF1A\u6267\u884C " + UPGRADE_CMD + "\uFF0C\u5B8C\u6210\u540E\u63D0\u9192\u6211\u91CD\u542F dsh web \u5E76\u5237\u65B0\u9875\u9762";
var { prompt: UPGRADE_AI } = worktableUpgrade();
async function copyTextSafe(text2) {
try {
if (navigator.clipboard?.writeText) {
@@ -17366,6 +17422,7 @@ var splitStore = {
lastMarginRight: "",
lastMarginTop: "",
onSpecMutated: null,
onPaneContentEdited: null,
listeners: /* @__PURE__ */ new Set(),
open(spec) {
if (this.active) {
@@ -17654,7 +17711,7 @@ var splitStore = {
lockPane(row, i, content) {
const spec = this.spec;
if (!spec) return;
const tab = { id: "t" + Date.now().toString(36), title: tabTitleOf(content), content, active: 0 };
const tab = { id: "t" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2), title: tabTitleOf(content), content, active: 0 };
const mutate = (pane) => ({ ...pane, content: null, tabs: [tab], active: 0 });
if (row === "left") {
if (!spec.left || i !== 0) return;
@@ -17678,6 +17735,8 @@ var splitStore = {
setTabContent(row, index, tabId, content) {
const spec = this.spec;
if (!spec) return;
const edited = row === "left" ? spec.left : row === "top" ? spec.top?.[index] : spec.main[index];
if (edited?.tabs?.some((t) => t.id === tabId)) this.onPaneContentEdited?.(spec.id, edited.id);
const mutate = (pane) => {
const tabs = (pane.tabs ?? []).map((t) => t.id === tabId ? { ...t, content, title: tabTitleOf(content) } : t);
return { ...pane, tabs };
@@ -17703,6 +17762,8 @@ var splitStore = {
openTab(row, i, content) {
const spec = this.spec;
if (!spec) return;
const edited = row === "left" ? spec.left : row === "top" ? spec.top?.[i] : spec.main[i];
if (edited) this.onPaneContentEdited?.(spec.id, edited.id);
const mutate = (pane) => {
const tabs = [...pane.tabs ?? []];
const existing = tabs.findIndex((t) => sameContent(t.content, content));
@@ -17732,6 +17793,8 @@ var splitStore = {
closeTab(row, i, tabId) {
const spec = this.spec;
if (!spec) return;
const edited = row === "left" ? spec.left : row === "top" ? spec.top?.[i] : spec.main[i];
if (edited?.tabs?.some((t) => t.id === tabId)) this.onPaneContentEdited?.(spec.id, edited.id);
const mutate = (pane) => {
const tabs = (pane.tabs ?? []).filter((t) => t.id !== tabId);
return { ...pane, tabs, active: 0 };
@@ -17769,6 +17832,8 @@ var splitStore = {
if (!fromPane || !toPane) return;
const tab = (fromPane.tabs ?? []).find((t) => t.id === tabId);
if (!tab) return;
this.onPaneContentEdited?.(spec.id, fromPane.id);
this.onPaneContentEdited?.(spec.id, toPane.id);
const fromTabs = (fromPane.tabs ?? []).filter((t) => t.id !== tabId);
const toTabs = [...toPane.tabs ?? [], tab];
const setPane = (row, i, pane) => {
@@ -19137,9 +19202,8 @@ function TerminalPane() {
focusTerm();
el.addEventListener("pointerdown", focusTerm);
const scope = splitEnv?.getScope?.();
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const url = proto + "//" + location.host + "/api/worktable/term?sessionId=" + encodeURIComponent(scope?.sessionId ?? "") + "&cwd=" + encodeURIComponent(scope?.cwd ?? "") + "&cols=80&rows=24";
try {
const url = hostWebSocketUrl("/api/worktable/term?sessionId=" + encodeURIComponent(scope?.sessionId ?? "") + "&cwd=" + encodeURIComponent(scope?.cwd ?? "") + "&cols=80&rows=24");
ws = new WebSocket(url);
} catch {
term.dispose();
@@ -19349,7 +19413,7 @@ function SelectPop(props) {
] });
}
function CustomPane(props) {
const paneTitle = props.paneTitle ?? "";
const paneTitle = props.row === void 0 ? props.paneTitle ?? "" : windowLabelOf(props.row, props.index ?? 0).match(/^窗口\d+/)?.[0] ?? "";
try {
window.__dshLastCustomPaneTitle = paneTitle;
} catch {
@@ -19364,6 +19428,7 @@ function CustomPane(props) {
const [wsGroups, setWsGroups] = (0, import_react.useState)([]);
const [groupMode, setGroupMode] = (0, import_react.useState)("none");
const [groupId, setGroupId] = (0, import_react.useState)(null);
const groupTouchedRef = (0, import_react.useRef)(false);
const [newGroupParent, setNewGroupParent] = (0, import_react.useState)("");
const [newGroupName, setNewGroupName] = (0, import_react.useState)("");
const [busy, setBusy] = (0, import_react.useState)(false);
@@ -19382,7 +19447,7 @@ function CustomPane(props) {
setSessionId(flat.find((s) => s.isCurrent)?.id ?? flat[0]?.id ?? null);
const curId = flat.find((s) => s.isCurrent)?.id;
const home = curId ? (custom?.getWorkspaces?.() ?? []).find((w) => (w.sessionIds ?? []).includes(curId)) : null;
if (home) {
if (home && !groupTouchedRef.current) {
setGroupMode("existing");
setGroupId(home.id);
}
@@ -19496,6 +19561,7 @@ function CustomPane(props) {
}],
placeholder: T("custom.group"),
onChange: (id) => {
groupTouchedRef.current = true;
if (id === "__none") setGroupMode("none");
else if (id === "__new") setGroupMode("new");
else {
@@ -19564,7 +19630,7 @@ function PaneTabBody(props) {
if (content.type === "scm") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(GitPane, {});
if (content.type === "tasks") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(JobsPane, {});
if (content.type === "terminal") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TerminalPane, {});
if (content.type === "custom") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CustomPane, { paneTitle: props.paneTitle ?? "" });
if (content.type === "custom") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CustomPane, { paneTitle: props.paneTitle ?? "", row: props.row, index: props.index });
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-wt_paneWip", children: [
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-wt_paneWipIcon", "aria-hidden": true, children: BUILTIN_ICONS[content.type] }),
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-wt_paneWipText", children: T("pane.wip") })
@@ -20135,6 +20201,116 @@ function appendHostInput(bridge, text2, doc = document) {
}
}
// src/client/widgetMount.ts
var WIDGET_BINDINGS_KEY = "dsh.worktable.widgetBindings.v2";
function newWidgetId(prefix = "binding") {
const id = globalThis.crypto?.randomUUID?.() ?? Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
return prefix + "-" + id;
}
function widgetTargets(spec) {
if (!spec) return [];
const out = [];
const add = (row, panes) => panes.forEach((pane, index) => {
if (pane && typeof pane.id === "string") out.push({ row, index, pane, window: "\u7A97\u53E3" + (out.length + 1) });
});
add("left", spec.left ? [spec.left] : []);
add("top", spec.top ?? []);
add("main", spec.main ?? []);
return out;
}
function widgetManifestPath(binding) {
return joinPath(binding.folder, joinPath(".dsh-worktable", joinPath(
"project-" + encodeURIComponent(binding.projectId),
"pane-" + encodeURIComponent(binding.paneId) + "-" + binding.bindingId + ".json"
)));
}
var WidgetMountRegistry = class {
constructor(storage, makeId = () => newWidgetId()) {
this.storage = storage;
this.makeId = makeId;
try {
const data = JSON.parse(storage?.getItem(WIDGET_BINDINGS_KEY) ?? "{}");
for (const [projectId, panes] of Object.entries(data ?? {})) {
if (!panes || typeof panes !== "object" || Array.isArray(panes)) continue;
const valid = /* @__PURE__ */ Object.create(null);
for (const [paneId, b] of Object.entries(panes)) {
const v = b;
if (v?.projectId !== projectId || v.paneId !== paneId || typeof v.folder !== "string" || !v.folder || typeof v.sessionId !== "string" || !v.sessionId || typeof v.window !== "string" || typeof v.bindingId !== "string" || !/^[a-z0-9-]+$/i.test(v.bindingId) || typeof v.enabled !== "boolean") continue;
valid[paneId] = { ...v };
}
if (Object.keys(valid).length) this.records[projectId] = valid;
}
} catch {
}
}
records = /* @__PURE__ */ Object.create(null);
listeners = /* @__PURE__ */ new Set();
persist() {
this.storage?.setItem(WIDGET_BINDINGS_KEY, JSON.stringify(this.records));
for (const fn of this.listeners) fn();
}
subscribe(fn) {
this.listeners.add(fn);
return () => {
this.listeners.delete(fn);
};
}
entries(projectId) {
const projects = projectId ? [this.records[projectId] ?? {}] : Object.values(this.records);
return projects.flatMap((panes) => Object.values(panes)).filter((b) => b.enabled);
}
begin(projectId, spec, folder, sessionId, window2) {
if (!this.storage) throw new Error("project storage unavailable");
if (!folder || !sessionId || spec.id !== projectId) throw new Error("project folder or session unavailable");
const targets = widgetTargets(spec);
const selected = targets.find((t) => t.window === window2);
if (!selected || new Set(targets.map((t) => t.pane.id)).size !== targets.length) throw new Error("project window changed");
const before = this.records[projectId];
const next = /* @__PURE__ */ Object.create(null);
for (const t of targets) {
const old = before?.[t.pane.id];
next[t.pane.id] = t === selected || !old || old.folder !== folder || old.enabled && old.window !== t.window ? { projectId, paneId: t.pane.id, window: t.window, folder, sessionId, bindingId: this.makeId(), enabled: true } : old;
}
this.records[projectId] = next;
try {
this.persist();
} catch (error2) {
if (before) this.records[projectId] = before;
else delete this.records[projectId];
throw error2;
}
return this.entries(projectId).filter((b) => b.sessionId === sessionId);
}
revokePane(projectId, paneId) {
const b = this.records[projectId]?.[paneId];
if (!b || !b.enabled) return;
this.records[projectId] = { ...this.records[projectId], [paneId]: { ...b, enabled: false } };
try {
this.persist();
} catch {
}
}
revokeProject(projectId) {
if (!this.records[projectId]) return;
for (const b of this.entries(projectId)) this.revokePane(projectId, b.paneId);
}
isCurrent(binding, spec, folder) {
const current = this.records[binding.projectId]?.[binding.paneId];
return !!current?.enabled && current.bindingId === binding.bindingId && current.sessionId === binding.sessionId && current.folder === folder && binding.folder === folder && spec?.id === binding.projectId && widgetTargets(spec).filter((t) => t.pane.id === binding.paneId).length === 1;
}
resolve(binding, raw, spec, folder) {
if (!this.isCurrent(binding, spec, folder)) return null;
try {
const d = JSON.parse(raw);
if (!d || Array.isArray(d) || d.version !== 2 || d.projectId !== binding.projectId || d.paneId !== binding.paneId || d.bindingId !== binding.bindingId || d.window !== binding.window || !["html", "url", "file"].includes(d.kind) || typeof d.path !== "string" || !d.path.trim()) return null;
const target = widgetTargets(spec).find((t) => t.pane.id === binding.paneId);
return { target, item: { kind: d.kind, path: d.path.trim() } };
} catch {
return null;
}
}
};
// 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();
@@ -20186,7 +20362,14 @@ function modelApiOf(ctx, sessions, legacy) {
selectModel: async ({ sessionId, ...selection }) => withDirectory(sessionId, async (directory) => ({ result: await directory.select(selection) }))
};
}
async function createHostSession(sessions, workspaces, options = {}) {
function blankSessionNeedsWorkspace(sessions, grouping) {
return grouping === "none" && typeof sessions?.using === "function";
}
async function createHostSession(sessions, workspaces, options = {}, grouping = "auto") {
if (grouping === "none") {
if (options.workspaceId) throw new Error("ungrouped session cannot specify a workspace");
return sessions.create(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");
@@ -20358,10 +20541,9 @@ 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.4";
var LOCAL_VERSION2 = false ? "dev" : "0.4.0";
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";
var { command: UPGRADE_CMD, prompt: UPGRADE_AI2, desktop: DESKTOP_HOST } = worktableUpgrade();
var ICON_SYNC = /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("svg", { viewBox: "0 0 16 16", "aria-hidden": true, children: [
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", d: "M1.5 8a6.5 6.5 0 0 1 11.1-4.6L14.5 5" }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", d: "M14.5 1.5V5h-3.5" }),
@@ -20432,7 +20614,7 @@ function buildLayout(presetId, name) {
const top = Array.from({ length: def.topCount }, (_, i) => mk(def.leftCount + i));
const main = Array.from({ length: def.contentCount }, (_, i) => mk(def.leftCount + def.topCount + i));
return {
id: "layout-" + Date.now().toString(36),
id: newWidgetId("layout"),
title: name,
left: left ?? null,
top: top.length > 0 ? top : null,
@@ -20949,12 +21131,12 @@ var KNOWLEDGE_PACK = [
" \u754C\u9762\u5373\u53EF\u4E0E DSH \u539F\u751F\u98CE\u683C\u4E00\u81F4\uFF1B\u81EA\u9020\u6837\u5F0F\u63A7\u5236\u5728\u6700\u5C0F\u8303\u56F4\u3002",
"- \u670D\u52A1\u7AEF\u80FD\u529B\uFF1A/api/worktable/fs\uFF08\u5217\u76EE\u5F55\uFF09\u3001/api/worktable/write\uFF08\u5199\u6587\u4EF6\uFF09\u3001/api/worktable/mkdir\uFF08\u5EFA\u76EE\u5F55\uFF09\u3001",
" /api/worktable/git\uFF08git \u72B6\u6001\uFF09\u3001/api/worktable/site\uFF08\u9759\u6001\u6258\u7BA1\uFF09\u3002",
"- \u6539\u5B8C\u63D2\u4EF6\u5728 01_content \u76EE\u5F55\u6267\u884C npm run build\uFF1B\u91CD\u542F dsh web \u6216\u6D4F\u89C8\u5668 F5 \u751F\u6548\u3002",
"- \u6539\u5B8C\u63D2\u4EF6\u5728 01_content \u76EE\u5F55\u6267\u884C npm run build\uFF1B\u7F51\u9875\u7AEF\u91CD\u542F dsh web \u5E76\u5237\u65B0\uFF0C\u684C\u9762\u7AEF\u5B8C\u6574\u9000\u51FA\u540E\u624B\u52A8\u91CD\u65B0\u6253\u5F00\u3002",
"- \u6240\u6709\u4EA7\u51FA\u6587\u4EF6\u4E00\u5F8B\u653E\u8FDB\u672C\u4EFB\u52A1\u6807\u6CE8\u7684\u9879\u76EE\u6587\u4EF6\u5939\uFF0C\u4FDD\u6301\u7528\u6237\u76EE\u5F55\u5E72\u51C0\u3002",
"- \u6807\u6CE8\u534F\u8BAE\uFF1A\u6536\u5230\u300C\u{1F4CC} \u6807\u6CE8-\u7A97\u53E3N \u2026\uFF08\u5750\u6807/\u5143\u7D20/\u6846\u5185\u6587\u5B57\u2026\uFF09\u2026\u300D= \u5DE5\u4F5C\u53F0\u6807\u6CE8\uFF08\u7A97\u53E3\u7F16\u53F7\u7EA6\u5B9A\uFF1A\u5DE6\u680F\u2192\u9876\u884C\u2192\u4E3B\u884C\uFF0C\u4ECE 1 \u8D77\uFF09\u3002",
" \u5904\u7406\uFF1A\u6807\u6CE8\u5DF2\u6807\u660E\u6846\u9009\u533A\u57DF\u4E0E\u300C\u6700\u53EF\u80FD\u76EE\u6807\u300D\u65F6\u76F4\u63A5\u4F5C\u7B54\u3001\u4E0D\u8981\u4E8C\u6B21\u8BE2\u95EE\uFF08\u4EC5\u5F53\u786E\u5B9E\u65E0\u6CD5\u4ECE\u6807\u6CE8\u5B9A\u4F4D\u65F6\u624D\u95EE\u4E00\u6B21\uFF09\uFF1B\u80FD\u67E5\u770B\u622A\u56FE\u6216\u6253\u5F00\u7A97\u53E3\u5219\u5148\u6838\u5B9E\u518D\u56DE\u7B54\u3002"
].join("\n");
function buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, mode) {
function buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, mode, widgetBindings = []) {
const win = windowLabel || "\u4E00\u4E2A\u5185\u5BB9\u7A97";
const folderLine = folder ? "\u9879\u76EE\u6587\u4EF6\u5939\uFF1A" + folder + "\uFF08\u672C\u9879\u76EE\u6240\u6709\u4EA7\u51FA\u6587\u4EF6\u4E00\u5F8B\u653E\u8FDB\u8FD9\u4E2A\u6587\u4EF6\u5939\uFF1B\u4E0D\u8981\u5199\u5230\u522B\u7684\u9ED8\u8BA4\u4F4D\u7F6E\uFF09\u3002" : "\u672C\u9879\u76EE\u6682\u672A\u8BBE\u7F6E\u4E13\u5C5E\u6587\u4EF6\u5939\uFF1A\u4EA7\u51FA\u6587\u4EF6\u5148\u5411\u7528\u6237\u786E\u8BA4\u5B58\u653E\u4F4D\u7F6E\uFF0C\u4E0D\u8981\u968F\u4FBF\u5199\u3002";
const lines = [
@@ -20968,8 +21150,9 @@ function buildWindowTaskText(projectId, projectName, windowLabel, requirement, f
" - \u6587\u6863 / \u6F14\u793A\uFF08PPT\u3001\u62A5\u544A\u3001\u8868\u683C\uFF09\u2192 \u751F\u6210\u771F\u5B9E\u6587\u4EF6\uFF08.pptx / .md / .xlsx\uFF09\u653E\u8FDB\u9879\u76EE\u6587\u4EF6\u5939\uFF0C\u7528\u6237\u7528\u672C\u673A\u8F6F\u4EF6\u6253\u5F00\u7F16\u8F91\uFF1B",
" - \u89C6\u9891 / \u52A8\u753B \u2192 \u751F\u6210 .mp4 / .gif\uFF08\u6216 Lottie JSON\uFF09\u6587\u4EF6\u653E\u8FDB\u9879\u76EE\u6587\u4EF6\u5939\uFF1B",
" - \u5DE5\u4F5C\u53F0\u5DF2\u6709\u5185\u7F6E\u7A97\u80FD\u529B\uFF08\u8D44\u6E90\u7BA1\u7406\u5668 / \u7EC8\u7AEF / \u6D4F\u89C8\u5668 / \u52A8\u753B\u7AD9\uFF09\u2192 \u4E0D\u8981\u91CD\u590D\u9020\u8F6E\u5B50\uFF0C\u76F4\u63A5\u5EFA\u8BAE\u7528\u6237\u6539\u7528\u5185\u7F6E\u7A97\u3002",
"5. \u5B8C\u6210\u5185\u5BB9\u540E\uFF0C\u7528\u4E00\u4E24\u53E5\u8BDD\u544A\u77E5\u7528\u6237\u6302\u8F7D\u7ED3\u679C\uFF08\u4E0D\u8981\u63D0\u95EE\u3001\u4E0D\u8981\u7B49\u5F85\u7528\u6237\u786E\u8BA4\uFF09\uFF1A\u4F8B\u5982\u300C\u5DF2\u5B8C\u6210\u5E76\u81EA\u52A8\u6302\u5230\u300C" + win + "\u300D\u7A97\u53E3\uFF0C\u60F3\u8C03\u6574\u76F4\u63A5\u8BF4\uFF0C\u6211\u6539\u5B8C\u4F1A\u81EA\u52A8\u66F4\u65B0\u300D\u3002\u8BE5\u7A97\u53E3\u4F4D\u4E8E\u300C" + projectName + "\u300D\u9879\u76EE\u5185\uFF0C\u5982\u9700\u8981\u4E5F\u53EF\u534F\u52A9\u8BE5\u9879\u76EE\u540E\u7EED\u7684\u5176\u4ED6\u81EA\u5B9A\u4E49\u5DE5\u4F5C\u3002",
'6. \u5B8C\u6210\u540E\u5199\u5165\u300C\u4EA7\u7269\u6E05\u5355\u300D\u6587\u4EF6\uFF1A\u5728\u9879\u76EE\u6587\u4EF6\u5939\u91CC\u521B\u5EFA widget-result.json\u3002\u6302\u8F7D\u5355\u4E2A\u7A97\u53E3\u65F6\u5199\u5355\u5BF9\u8C61 {"window":"' + win + '","path":"\u4EA7\u7269\u76F8\u5BF9\u8DEF\u5F84","kind":"html"}\uFF1B\u4E00\u6B21\u6302\u8F7D\u591A\u4E2A\u7A97\u53E3\uFF08\u5982\u7A97\u53E31 + \u7A97\u53E32\uFF09\u65F6\u5199 JSON \u6570\u7EC4\uFF0C\u6BCF\u4E2A\u5143\u7D20\u4E3A\u4E0A\u8FF0\u5355\u5BF9\u8C61\uFF08\u53EF\u5F15\u7528\u591A\u4E2A\u4EA7\u7269\u6587\u4EF6\uFF09\u3002kind \u53EF\u9009 html\uFF08\u672C\u5730\u9875\u9762\uFF0Cpath \u76F8\u5BF9\u9879\u76EE\u6587\u4EF6\u5939\uFF09/ url\uFF08\u5916\u90E8\u94FE\u63A5\uFF0Cpath \u4E3A\u5B8C\u6574 URL\uFF09/ file\uFF08\u5176\u4ED6\u6587\u4EF6\uFF0Cpath \u76F8\u5BF9\u9879\u76EE\u6587\u4EF6\u5939\uFF09\u3002\u5199\u5B8C\u8FD9\u4E2A\u6587\u4EF6\uFF0C\u5DE5\u4F5C\u53F0\u4F1A\u81EA\u52A8\u628A\u4EA7\u7269\u6302\u8F7D\u8FDB\u6E05\u5355\u5BF9\u5E94\u7684\u5404\u4E2A\u7A97\u53E3\u5E76\u9501\u5B9A\u4FDD\u5B58\uFF0C\u7528\u6237\u65E0\u9700\u624B\u52A8\u64CD\u4F5C\uFF1B\u7528\u6237\u4E0B\u6B21\u6253\u5F00\u5DE5\u4F5C\u53F0\u65F6\u5404\u7A97\u53E3\u76F4\u63A5\u663E\u793A\u4EA7\u7269\uFF0C\u4E0D\u4F1A\u4E22\u5931\u6216\u91CD\u7F6E\u3002',
"5. \u5B8C\u6210\u540E\u7528\u4E00\u4E24\u53E5\u8BDD\u8BF4\u660E\u4F5C\u54C1\u4E0E\u76EE\u6807\u7A97\u53E3\u3002\u5199\u6E05\u5355\u4E0D\u4EE3\u8868\u754C\u9762\u5DF2\u786E\u8BA4\u6302\u8F7D\u6210\u529F\uFF1B\u53EA\u6709\u5B9E\u9645\u770B\u5230\u6302\u8F7D\u7ED3\u679C\u65F6\uFF0C\u624D\u8BF4\u300C\u5DF2\u6302\u8F7D\u300D\u3002\u8BE5\u7A97\u53E3\u5C5E\u4E8E\u300C" + projectName + "\u300D\u9879\u76EE\u3002",
"6. \u4EA7\u7269\u63E1\u624B v2\uFF1A\u53EA\u4F7F\u7528\u4E0B\u5217\u672C\u9879\u76EE\u4E13\u5C5E\u6E05\u5355\u8DEF\u5F84\uFF0C\u4E0D\u8BFB\u5199\u9879\u76EE\u6839\u76EE\u5F55\u65E7\u7684 widget-result.json\uFF0C\u4E5F\u4E0D\u8981\u590D\u7528\u5176\u4ED6\u9879\u76EE\u7684\u6E05\u5355\u3002\u4F5C\u54C1\u4ECD\u653E\u5728\u9879\u76EE\u6587\u4EF6\u5939\uFF0Cpath \u6309\u8BE5\u6587\u4EF6\u5939\u89E3\u6790\u3002\u5148\u5B8C\u6210\u4F5C\u54C1\uFF0C\u518D\u521B\u5EFA\u6E05\u5355\u7236\u76EE\u5F55\u5E76\u5199\u5165\u5BF9\u5E94 JSON\uFF1Bkind \u4E3A html / url / file\uFF0C\u66FF\u6362\u793A\u4F8B\u4E2D\u7684 path \u4E0E kind\uFF0C\u5176\u4ED6\u8EAB\u4EFD\u5B57\u6BB5\u4FDD\u6301\u539F\u6837\u3002\u4E00\u6B21\u751F\u6210\u591A\u4E2A\u7A97\u53E3\u65F6\u5206\u522B\u5199\u5404\u7A97\u53E3\u7684\u6E05\u5355\u3002\u4EE5\u540E\u5728\u672C\u4F1A\u8BDD\u91CC\u7EE7\u7EED\u4FEE\u6539\u540C\u540D\u4F5C\u54C1\uFF0C\u4ECD\u66F4\u65B0\u8FD9\u4E2A\u6E05\u5355\uFF1B\u4E0D\u5F97\u6839\u636E\u6E05\u5355\u6587\u4EF6\u540D\u53D8\u5316\u4E0E\u5426\u5224\u65AD\u662F\u5426\u5B8C\u6210\u3002",
widgetBindings.length ? widgetBindings.map((b) => b.window + "\uFF1A" + widgetManifestPath(b) + "\n" + JSON.stringify({ version: 2, projectId: b.projectId, paneId: b.paneId, bindingId: b.bindingId, window: b.window, path: "\u4EA7\u7269\u76F8\u5BF9\u8DEF\u5F84", kind: "html" })).join("\n") : "\u5F53\u524D\u6CA1\u6709\u6709\u6548\u7684\u81EA\u52A8\u6302\u8F7D\u7ED1\u5B9A\uFF0C\u8BF7\u7528\u6237\u4ECE\u76EE\u6807\u7A97\u53E3\u91CD\u65B0\u53D1\u9001\u81EA\u5B9A\u4E49\u4EFB\u52A1\uFF1B\u4E0D\u8981\u5199\u65E7\u6E05\u5355\u731C\u6D4B\u5F52\u5C5E\u3002",
KNOWLEDGE_PACK
];
return lines.join("\n");
@@ -20977,7 +21160,6 @@ function buildWindowTaskText(projectId, projectName, windowLabel, requirement, f
async function createCustomSession(projectId, projectName, requirement, group, windowLabel = "", folder = null) {
const b = sessionBridge;
if (!b || typeof b.sessions?.create !== "function") throw new Error("sessions unavailable");
const text2 = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, "new");
let workspaceId = null;
const g = group ?? { kind: "none" };
if (g.kind === "existing") {
@@ -21006,7 +21188,7 @@ async function createCustomSession(projectId, projectName, requirement, group, w
let createOpts = {};
if (workspaceId) createOpts = { workspaceId };
else if (folder) createOpts = { cwd: folder };
const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts);
const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts, g.kind === "none" ? "none" : "auto");
await ensureSessionPreset(sessionId);
await ensureSessionModel(sessionId);
markPluginSessionOpen(sessionId);
@@ -21014,13 +21196,16 @@ async function createCustomSession(projectId, projectName, requirement, group, w
await openHostSession(clientCtx, b.sessions, sessionId);
} catch {
}
const widgetBindings = prepareWidgetTask(projectId, folder, sessionId, windowLabel);
const text2 = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, "new", widgetBindings);
await promptIntoSession(sessionId, text2);
return sessionId;
}
async function sendCustomToSession(sessionId, projectId, projectName, requirement, windowLabel = "", folder = null) {
const b = sessionBridge;
if (!b) throw new Error("bridge unavailable");
const text2 = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, "send");
const widgetBindings = prepareWidgetTask(projectId, folder, sessionId, windowLabel);
const text2 = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, "send", widgetBindings);
markPluginSessionOpen(sessionId);
try {
await openHostSession(clientCtx, b.sessions, sessionId);
@@ -21060,50 +21245,55 @@ function saveNotifyAck(sid, state) {
}
}
var mountConsumedRef = { current: /* @__PURE__ */ new Set() };
var widgetRegistry = new WidgetMountRegistry((() => {
try {
return localStorage;
} catch {
return null;
}
})());
var widgetProjectOf = () => ({});
function prepareWidgetTask(projectId, folder, sessionId, windowLabel) {
if (!folder) return [];
const current = widgetProjectOf(projectId);
if (!folder || current.folder !== folder || !current.spec) throw new Error("project folder or window changed");
return widgetRegistry.begin(projectId, current.spec, folder, sessionId, windowLabel);
}
var pendingMountRef = { current: (() => {
try {
return JSON.parse(localStorage.getItem("dsh.worktable.pendingMount.v1") ?? "{}") ?? {};
return JSON.parse(localStorage.getItem("dsh.worktable.pendingMount.v2") ?? "{}") ?? {};
} catch {
return {};
}
})() };
var mountedWidgetRef = { current: (() => {
try {
return JSON.parse(localStorage.getItem("dsh.worktable.mountedWidget.v1") ?? "{}") ?? {};
return JSON.parse(localStorage.getItem("dsh.worktable.mountedWidget.v2") ?? "{}") ?? {};
} catch {
return {};
}
})() };
function recordMountedWidget(projectId, raw) {
if (!projectId || !raw) return;
mountedWidgetRef.current[projectId] = raw;
var widgetReadSeqRef = { current: {} };
function recordMountedWidget(bindingId, raw) {
if (!bindingId || !raw) return;
mountedWidgetRef.current[bindingId] = raw;
try {
localStorage.setItem("dsh.worktable.mountedWidget.v1", JSON.stringify(mountedWidgetRef.current));
localStorage.setItem("dsh.worktable.mountedWidget.v2", JSON.stringify(mountedWidgetRef.current));
} catch {
}
}
function windowLabelToPane(spec, label) {
const m = /^窗口(\d+)$/.exec(String(label ?? "").trim());
if (!m) return null;
const n = parseInt(m[1], 10);
if (!Number.isFinite(n) || n < 1) return null;
let idx = n - 1;
if (spec?.left) {
if (idx === 0) return { row: "left", index: 0 };
idx -= 1;
function persistPendingWidgets() {
try {
localStorage.setItem("dsh.worktable.pendingMount.v2", JSON.stringify(pendingMountRef.current));
} catch {
}
const top = spec?.top ?? [];
if (idx < top.length) return { row: "top", index: idx };
idx -= top.length;
const main = spec?.main ?? [];
if (idx < main.length) return { row: "main", index: idx };
return null;
}
function paneExists(spec, row, index) {
if (!spec) return false;
if (row === "left") return !!spec.left && index === 0;
if (row === "top") return !!spec.top && !!spec.top[index];
return !!spec.main && !!spec.main[index];
function invalidateWidgetProject(projectId) {
widgetRegistry.revokeProject(projectId);
for (const [id, pending] of Object.entries(pendingMountRef.current)) {
if (pending?.binding?.projectId === projectId) delete pendingMountRef.current[id];
}
persistPendingWidgets();
}
function buildMountContent(folder, d) {
const p = String(d?.path ?? "").trim();
@@ -21348,6 +21538,7 @@ function WorktableSection(props) {
const [consoleName, setConsoleName] = (0, import_react2.useState)("");
const [consoleErr, setConsoleErr] = (0, import_react2.useState)(false);
const [consoleBusy, setConsoleBusy] = (0, import_react2.useState)(false);
const consoleNeedsWorkspace = blankSessionNeedsWorkspace(sessionBridge?.sessions, consoleMode === "none" ? "none" : "auto");
const [customOpen, setCustomOpen] = (0, import_react2.useState)(false);
const [customLayoutText, setCustomLayoutText] = (0, import_react2.useState)("");
const [copiedToast, setCopiedToast] = (0, import_react2.useState)(null);
@@ -21678,8 +21869,16 @@ function WorktableSection(props) {
return { ...prev, views: { ...prev.views, [spec.id]: spec } };
});
};
splitStore.onPaneContentEdited = (projectId, paneId) => {
widgetRegistry.revokePane(projectId, paneId);
for (const [id, p] of Object.entries(pendingMountRef.current)) {
if (p?.binding?.projectId === projectId && p.binding.paneId === paneId) delete pendingMountRef.current[id];
}
persistPendingWidgets();
};
return () => {
splitStore.onSpecMutated = null;
splitStore.onPaneContentEdited = null;
};
}, []);
(0, import_react2.useEffect)(() => {
@@ -21831,23 +22030,9 @@ function WorktableSection(props) {
}
}
ackProjectNotify(spec.id);
const pending = pendingMountRef.current[spec.id];
if (pending) {
try {
const entries = Array.isArray(pending.entries) ? pending.entries : pending.content ? [{ content: pending.content, row: pending.row, index: pending.index ?? 0 }] : [];
let allOk = entries.length > 0;
for (const e of entries) {
splitStore.lockPane(e.row, e.index ?? 0, e.content);
if (!paneExists(splitStore.spec, e.row, e.index ?? 0)) allOk = false;
}
if (allOk && pending.fingerprint) recordMountedWidget(spec.id, pending.fingerprint);
} catch {
}
delete pendingMountRef.current[spec.id];
try {
localStorage.setItem("dsh.worktable.pendingMount.v1", JSON.stringify(pendingMountRef.current));
} catch {
}
for (const pending of Object.values(pendingMountRef.current)) {
if (pending?.binding?.projectId !== spec.id) continue;
void applyWidgetManifest(pending.binding, null, true);
}
}
}, [projects.views]);
@@ -21908,6 +22093,11 @@ function WorktableSection(props) {
setConsoleErr(true);
return;
}
if (blankSessionNeedsWorkspace(b.sessions, consoleMode === "none" ? "none" : "auto")) return;
if (consoleMode === "existing" && !consoleWsId) {
setConsoleErr(true);
return;
}
if (consoleMode === "new" && (!consoleParent.trim() || !consoleName.trim())) {
setConsoleErr(true);
return;
@@ -21940,7 +22130,7 @@ function WorktableSection(props) {
let createOpts = {};
if (workspaceId) createOpts = { workspaceId };
else if (folder) createOpts = { cwd: folder };
const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts);
const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts, consoleMode === "none" ? "none" : "auto");
await ensureSessionPreset(sessionId);
await ensureSessionModel(sessionId);
markPluginSessionOpen(sessionId);
@@ -22043,6 +22233,7 @@ function WorktableSection(props) {
setWsFolderParent(p);
setWsFolderError(false);
} else if (bindPick) {
if (projectsRef.current.projects.folders[bindPick.id] !== p) invalidateWidgetProject(bindPick.id);
persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } }));
}
setManualPathFor(null);
@@ -22052,10 +22243,12 @@ function WorktableSection(props) {
const changeBindFolder = () => {
if (!bindPick || pickBusyRef.current) return;
pickFolder("bind", (p) => {
if (projectsRef.current.projects.folders[bindPick.id] !== p) invalidateWidgetProject(bindPick.id);
persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } }));
});
};
const setProjectBinding = (id, sessionId) => {
if ((projectsRef.current.projects.bindings[id] ?? null) !== sessionId) invalidateWidgetProject(id);
persistProjects((prev) => {
const next = { ...prev.bindings };
if (sessionId) next[id] = sessionId;
@@ -22113,100 +22306,79 @@ function WorktableSection(props) {
(0, import_react2.useEffect)(() => {
notifyConsole();
}, [projects]);
const applyWidgetManifest = (0, import_react2.useCallback)(async (projectId, rawManifest) => {
const folder = projectsRef.current.projects.folders[projectId];
if (!folder) return;
const [widgetTick, setWidgetTick] = (0, import_react2.useState)(0);
(0, import_react2.useEffect)(() => widgetRegistry.subscribe(() => setWidgetTick((n) => n + 1)), []);
const applyWidgetManifest = (0, import_react2.useCallback)(async (binding, rawManifest, force = false) => {
let current = widgetProjectOf(binding.projectId);
if (!mountedRef.current || !widgetRegistry.isCurrent(binding, current.spec, current.folder)) return;
const readSeq = (widgetReadSeqRef.current[binding.bindingId] ?? 0) + 1;
widgetReadSeqRef.current[binding.bindingId] = readSeq;
try {
let raw = rawManifest;
if (raw == null) {
const r = await fetch("/api/worktable/file?path=" + encodeURIComponent(joinPath(folder, "widget-result.json")), { cache: "no-store" });
const r = await fetch("/api/worktable/file?path=" + encodeURIComponent(widgetManifestPath(binding)), { cache: "no-store" });
if (!r.ok) return;
raw = (await r.text()).trim();
}
if (!raw) return;
let d = null;
try {
d = JSON.parse(raw);
} catch {
}
if (!d) return;
const items = Array.isArray(d) ? d : [d];
const open = splitStore.active && splitStore.spec?.id === projectId;
const saved = open ? splitStore.spec : projectsRef.current.projects.views[projectId] ?? projectsRef.current.projects.layouts.find((l) => l.id === projectId);
const targets = [];
for (const it of items) {
if (!it) continue;
const content = buildMountContent(folder, it);
if (!content) continue;
const pane = windowLabelToPane(saved, it.window);
const row = pane?.row ?? "main";
const index = pane?.index ?? 0;
if (targets.some((t2) => t2.row === row && t2.index === index)) continue;
targets.push({ row, index, content });
}
if (!targets.length) return;
current = widgetProjectOf(binding.projectId);
if (!mountedRef.current || !raw || widgetReadSeqRef.current[binding.bindingId] !== readSeq) return;
const resolved = widgetRegistry.resolve(binding, raw, current.spec, current.folder);
if (!resolved || !force && mountedWidgetRef.current[binding.bindingId] === raw) return;
const content = buildMountContent(binding.folder, resolved.item);
if (!content) return;
const open = splitStore.active && splitStore.spec?.id === binding.projectId;
if (open) {
let allOk = true;
for (const t2 of targets) {
splitStore.lockPane(t2.row, t2.index, t2.content);
if (!paneExists(splitStore.spec, t2.row, t2.index)) allOk = false;
}
if (allOk) recordMountedWidget(projectId, raw);
splitStore.lockPane(resolved.target.row, resolved.target.index, content);
recordMountedWidget(binding.bindingId, raw);
delete pendingMountRef.current[binding.bindingId];
persistPendingWidgets();
} else {
pendingMountRef.current[projectId] = { entries: targets, fingerprint: raw };
try {
localStorage.setItem("dsh.worktable.pendingMount.v1", JSON.stringify(pendingMountRef.current));
} catch {
}
pendingMountRef.current[binding.bindingId] = { binding };
persistPendingWidgets();
}
} catch {
}
}, []);
const tryAutoMount = (0, import_react2.useCallback)(async (projectId, sid) => {
void sid;
await applyWidgetManifest(projectId, null);
}, [applyWidgetManifest]);
(0, import_react2.useEffect)(() => {
const byId = sessionsSnapshotStore.snapshot?.byId ?? {};
const scopePairs = new Map(widgetRegistry.entries().map((b) => [JSON.stringify([b.projectId, b.sessionId]), b]));
for (const key of mountConsumedRef.current) if (!scopePairs.has(key)) mountConsumedRef.current.delete(key);
for (const [pid, sid] of Object.entries(projects.bindings)) {
const e = byId[sid];
if (!e) continue;
if (e.completed === true) {
if (doneSeenRef.current[sid] !== true) clearNotifyAck(sid);
doneSeenRef.current[sid] = true;
if (!mountConsumedRef.current.has(sid)) {
mountConsumedRef.current.add(sid);
tryAutoMount(pid, sid);
}
} else {
doneSeenRef.current[sid] = false;
mountConsumedRef.current.delete(sid);
}
}
}, [notifyTick, projects.bindings, tryAutoMount]);
for (const [key, binding] of scopePairs) {
const e = byId[binding.sessionId];
if (!e) continue;
if (e.completed !== true) {
mountConsumedRef.current.delete(key);
continue;
}
if (mountConsumedRef.current.has(key)) continue;
mountConsumedRef.current.add(key);
for (const b of widgetRegistry.entries(binding.projectId)) {
if (b.sessionId === binding.sessionId) void applyWidgetManifest(b, null, true);
}
}
}, [notifyTick, projects.bindings, widgetTick, applyWidgetManifest]);
(0, import_react2.useEffect)(() => {
let cancelled = false;
const folders = projects.folders ?? {};
(async () => {
for (const pid of Object.keys(folders)) {
for (const binding of widgetRegistry.entries()) {
if (cancelled) return;
const folder = folders[pid];
if (!folder) continue;
try {
const r = await fetch("/api/worktable/file?path=" + encodeURIComponent(joinPath(folder, "widget-result.json")), { cache: "no-store" });
if (!r.ok) continue;
const raw = (await r.text()).trim();
if (!raw) continue;
if (mountedWidgetRef.current[pid] === raw) continue;
await applyWidgetManifest(pid, raw);
} catch {
}
await applyWidgetManifest(binding, null);
}
})();
return () => {
cancelled = true;
};
}, [projects.folders, applyWidgetManifest]);
}, [projects.folders, widgetTick, applyWidgetManifest]);
const collectKids = (0, import_react2.useCallback)((sid) => {
return childSessionIdsOf(sessionsSnapshotStore.snapshot, sid);
}, []);
@@ -22267,6 +22439,10 @@ function WorktableSection(props) {
[registeredIds, projects.removed]
);
projectsRef.current = { projects, metas, aliveRegisteredIds };
widgetProjectOf = (id) => ({
folder: projectsRef.current.projects.folders[id],
spec: (splitStore.active && splitStore.spec?.id === id ? splitStore.spec : void 0) ?? projectsRef.current.projects.views[id] ?? projectsRef.current.projects.layouts.find((l) => l.id === id)
});
projectBindingsRef.current = projects.bindings;
const allIds = (0, import_react2.useMemo)(() => [...aliveRegisteredIds, ...layoutIds], [aliveRegisteredIds, layoutIds]);
const effectiveOrder = (0, import_react2.useMemo)(() => {
@@ -22413,9 +22589,11 @@ function WorktableSection(props) {
reportUsed(layout.id);
};
const removeLayout = (id) => {
invalidateWidgetProject(id);
persistProjects((prev) => ({ ...prev, layouts: prev.layouts.filter((l) => l.id !== id) }));
};
const applyLayoutChange = (id, presetId) => {
invalidateWidgetProject(id);
const layout = projects.layouts.find((l) => l.id === id);
const meta = metas[id];
const current = layout ?? projects.views[id];
@@ -22459,6 +22637,7 @@ function WorktableSection(props) {
};
const removeProject = (id) => {
if (id === CONSOLE_ID) return;
invalidateWidgetProject(id);
persistProjects((prev) => {
const next = {
...prev,
@@ -23010,7 +23189,7 @@ function WorktableSection(props) {
updateInfo.latest
] }),
updateInfo.notes && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_updateNotes", children: updateInfo.notes }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_updateCmd", children: UPGRADE_CMD2 }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_updateCmd", children: UPGRADE_CMD }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "dsh-wt_updateBtns", children: [
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_updateBtn dsh-wt_updateBtnCopy", onClick: () => void copyUpgradeAi(), children: updateCopied ? "\u2713 " + t("update.copied") : /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
ICON_SPARK,
@@ -23019,7 +23198,7 @@ function WorktableSection(props) {
] }) }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_updateBtn", onClick: skipUpdate, children: t("update.skip") })
] }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_updateHint", children: t("update.upgradeHint") })
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_updateHint", children: t(DESKTOP_HOST ? "update.upgradeHintDesktop" : "update.upgradeHint") })
] }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_manageHead", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "dsh-wt_manageTitle", children: t("sort.label") }) }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "dsh-wt_sortRow", children: [
@@ -23343,13 +23522,17 @@ function WorktableSection(props) {
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("option", { value: "existing", children: t("console.groupExisting") }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("option", { value: "new", children: t("console.groupNew") })
] }),
consoleMode === "existing" && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("select", { className: "dsh-wt_consoleSelect", value: consoleWsId, onChange: (e) => setConsoleWsId(e.target.value), children: listWorkspaces().map((w) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("option", { value: w.id, children: w.title }, w.id)) }),
consoleMode === "existing" && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("select", { className: "dsh-wt_consoleSelect", value: consoleWsId, onChange: (e) => setConsoleWsId(e.target.value), children: [
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("option", { value: "", children: t("console.chooseGroup") }),
listWorkspaces().map((w) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("option", { value: w.id, children: w.title }, w.id))
] }),
consoleMode === "new" && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("input", { className: "dsh-wt_consoleInput", placeholder: t("console.newParentPh"), value: consoleParent, onChange: (e) => setConsoleParent(e.target.value) }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("input", { className: "dsh-wt_consoleInput", placeholder: t("console.newNamePh"), value: consoleName, onChange: (e) => setConsoleName(e.target.value) })
] }),
consoleErr && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "dsh-wt_consoleErr", children: t("console.bindFail") }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_consoleCreateBtn", disabled: consoleBusy, onClick: bindConsoleNew, children: consoleBusy ? "\u2026" : t("console.createBind") })
consoleNeedsWorkspace && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "dsh-wt_consoleHint", children: t("console.blankNeedsGroup") }),
consoleErr && !consoleNeedsWorkspace && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "dsh-wt_consoleErr", children: t("console.bindFail") }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_consoleCreateBtn", disabled: consoleBusy || consoleNeedsWorkspace || consoleMode === "existing" && !consoleWsId, onClick: bindConsoleNew, children: consoleBusy ? "\u2026" : t("console.createBind") })
] })
] }) }),
customOpen && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_popBackdrop", style: { zIndex: 83 }, onClick: () => setCustomOpen(false) }),
+1 -1
View File
@@ -115,7 +115,7 @@ function resolveDshHomeSafe() {
cachedDshHome = baseDshHome();
return cachedDshHome;
}
var PLUGIN_VERSION = false ? "dev" : "0.3.4";
var PLUGIN_VERSION = false ? "dev" : "0.4.0";
var name = "dsh-worktable";
var inject = ["webServer", "sessions"];
var HEALTH_PATH = "/api/worktable/health";
+5 -2
View File
@@ -1,13 +1,16 @@
{
"name": "dsh-worktable",
"version": "0.2.0",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dsh-worktable",
"version": "0.2.0",
"version": "0.4.0",
"license": "MIT",
"engines": {
"node": ">=22"
},
"devDependencies": {
"esbuild": "^0.25.0",
"highlight.js": "^11.12.0",
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "dsh-worktable",
"description": "工作台 Worktable:DeepSeek Harness 侧边栏的 agent 级项目容器(应用抽屉)",
"version": "0.3.4",
"version": "0.4.0",
"repository": {
"type": "git",
"url": "https://github.com/Aisland-SJL/dsh-worktable.git",
@@ -55,6 +55,8 @@
"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",
"test:transport": "node ../04_test/host-transport.test.mjs",
"test:widget": "node ../04_test/widget-mount.test.mjs",
"verify:remote": "node verify-remote.mjs"
},
"peerDependencies": {
+1 -1
View File
@@ -83,7 +83,7 @@ if (!patchOk) process.exit(1)
console.log('[release-prep] identity check passed (' + IDENTITY + ', cordis.patch.yml strict)')
// ---------- 2. 会话/输入兼容回归 + 构建 + 语法检查 ----------
const compatTests = ['host-input.test.mjs', 'session-compat.test.mjs', 'session-details.test.mjs']
const compatTests = ['host-input.test.mjs', 'session-compat.test.mjs', 'session-details.test.mjs', 'host-transport.test.mjs', 'widget-mount.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) }
+31 -3
View File
@@ -1,6 +1,34 @@
/** 更新公告正文(v0.3.4:DSH 0.2 网页端适配;呈现时按纯文本保留换行,不做 md 渲染) */
export const CHANGELOG_VERSION = 'v0.3.4'
export const CHANGELOG_V030 = `更新公告 · v0.3.4
/** 更新公告正文(v0.4.0:双端适配、分组与项目内容隔离;纯文本呈现,发布状态以 GitHub 为准) */
export const CHANGELOG_VERSION = 'v0.4.0'
export const CHANGELOG_V030 = `更新公告 · v0.4.0
🖥️ 网页端与 Windows 桌面端适配,修复项目内容串挂
【双端使用】同一个工作台插件包支持网页端与官方 Windows 桌面端。延续 v0.3.4 对 DSH 0.2 网页端的项目与对话联动、标注输入、控制室状态及消息预览适配,本次补上桌面端连接与升级入口。
【桌面端终端与更新】终端使用桌面宿主提供的连接地址,网页端保留原连接方式。更新提示会按所在环境选择 web 或 desktop 配置;桌面用户请使用应用自带的 CLI,完整退出后安装,再手动重新打开。
【保留“未分组”选择】自定义窗口新建对话时,选择“未分组”就保持未分组,项目文件夹仅作为工作目录。修复默认分组晚返回时覆盖用户选择的问题;已有对话的分组保持原样。
【控制室新建对话】已测 DSH 0.2 的未分组空对话无法使用原生输入框,控制室会提示原因并禁用这一创建方式,请选择现有/新建分组,或加入可用的现有对话。选择“现有分组”但尚未选定具体项时,也不会退回默认分组创建;旧宿主保留原未分组创建行为。
【新项目从空白开始】新项目即使与旧项目同名、使用同一个文件夹,也不会自动加载旧项目的窗口内容。自动挂载分别识别项目、窗口与本次任务的关联,为各窗口使用专属结果文件;错误归属或无效窗口结果不会被塞进窗口1。
【不同对话互不串挂】同一项目的不同对话只获得和更新各自关联的窗口;一个对话完成时,不会强制重挂另一对话的旧结果。同一对话继续修改同名作品时,窗口仍可重新加载更新后的内容。任务提示词要求确认页面实际挂载后再报告“已挂载”,避免只写出结果文件就报成功。
【保留手工调整与补挂重试】手工关闭、替换或移动标签,以及更换目录、布局或项目绑定后,旧关联不会继续补挂或覆盖你的调整;迟到的旧请求也会重新核对归属。项目未打开时保留待挂载记录,重新打开会读取最新结果;读取失败后记录仍保留,下次打开可重试,成功挂载后才清除。
【旧项目继续使用】已保存的窗口、作品、会话绑定与媒体保留。根目录旧 widget-result.json 不再自动导入;旧对话若需继续自动挂载,请从目标窗口的“自定义 → 发送到会话”发一次需求,建立新关联后即可继续追改。旧文件不自动删除,此前误挂并保存的标签请关闭一次;多个任务主动写同一个实际作品文件,文件内容仍可能互相覆盖。
【发布检查补全】增加桌面连接、分组选择、项目归属、会话隔离和补挂重试回归,发布流程共运行 98 项源码检查,并继续核对独立安装、客户端加载与双安装包一致性。这些检查用于减少发布遗漏,不能替代真实页面和模型任务验收。
【兼容与验证范围】网页端兼容声明仅列 DSH 0.1.1-rc.2、0.1.2-rc.1、0.2.0-rc.2;桌面端范围为已测 Windows 官方桌面端 + DSH 0.2.0-rc.2。旧版沿用此前页面验收并补代码回归,本轮未重跑完整旧版 GUI;桌面主要交互已点测,原共目录串挂问题已由用户反馈确认修复,但本版最终包在正式桌面端的完整复验、实际审批/子代理及模型生成到自动挂载的完整业务验收仍待完成。macOS 尚未真机验证。
【升级与数据】网页端和桌面端的项目、布局、媒体及外观设置按各自浏览器来源保存,不会自动同步;共用 DSH_HOME 也不意味着这些界面数据共用。安装前备份重要数据并核对其他插件兼容性,装完完整重启宿主,网页端再刷新页面;若仍加载失败,请保留第一条完整错误。本工作台包不包含宿主模型请求 HTTP 400 的修复;公开发布状态以 GitHub Release 为准。
───────
此前版本说明(v0.3.4)
🛠️ 本次更新:适配 DSH 0.2 网页端
+28
View File
@@ -0,0 +1,28 @@
/** Web and Desktop share HTTP routes; Desktop exposes its Host URL for streams. */
export function hostWebSocketUrl(
path: string,
surface: { href: string; protocol: string; streamBaseUrl?: string } = {
href: location.href,
protocol: location.protocol,
streamBaseUrl: (globalThis as any).__DSH_TRANSPORT__?.streamBaseUrl,
},
): string {
const desktop = surface.protocol === 'dsh-app:'
if (desktop && !surface.streamBaseUrl) throw new Error('Desktop Host stream URL unavailable')
const url = new URL(path, desktop ? surface.streamBaseUrl : surface.href)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Unsupported Host transport protocol: ' + url.protocol)
}
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
return url.href
}
/** Keep update instructions on the active profile; Desktop must use its bundled CLI. */
export function worktableUpgrade(protocol = typeof location === 'undefined' ? 'http:' : location.protocol) {
const desktop = protocol === 'dsh-app:'
const command = 'dsh plugin --profile ' + (desktop ? 'desktop' : 'web') + ' add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"'
const prompt = desktop
? '帮我升级 dsh-worktable。我当前使用官方桌面端:先定位该桌面端自带的 dsh CLI 并准备更新,再提醒我完整退出桌面端。确认退出后使用该 CLI 执行 ' + command + '。不要使用网页端 CLI,不要修改 web profile,不要自动杀进程或重启;完成后提醒我手动重新打开桌面端。'
: '帮我升级 dsh-worktable:执行 ' + command + ',完成后提醒我重启 dsh web 并刷新页面'
return { command, prompt, desktop }
}
+121 -139
View File
@@ -4,8 +4,10 @@ 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 { worktableUpgrade } from './hostTransport'
import { WidgetMountRegistry, newWidgetId, widgetManifestPath, type WidgetBinding } from './widgetMount'
import { currentSessionOf, openHostSession, sendHostSession, createSessionSnapshotAdapter, childSessionIdsOf, pendingAckOf, sessionRuntimeMs } from './sessionCompat'
import { cleanPreviewText, previewFromEvents, readSessionPreview, presetApiOf, modelApiOf, createHostSession } from './sessionDetails'
import { cleanPreviewText, previewFromEvents, readSessionPreview, presetApiOf, modelApiOf, createHostSession, blankSessionNeedsWorkspace } from './sessionDetails'
import { photoStore, kindOf } from './photoStore'
import { DEFAULT_BG_SVG, b64ToBlob } from './defaultBg'
import { WAVE_BG_B64 } from './waveBg'
@@ -30,8 +32,7 @@ type DockMode = 'footer' | 'float'
declare const __WT_VERSION__: string
const LOCAL_VERSION = typeof __WT_VERSION__ === 'undefined' ? 'dev' : __WT_VERSION__
const UPDATE_REPO = 'Aisland-SJL/dsh-worktable'
const UPGRADE_CMD = 'dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"'
const UPGRADE_AI = '帮我升级 dsh-worktable:执行 ' + UPGRADE_CMD + ',完成后提醒我重启 dsh web 并刷新页面'
const { command: UPGRADE_CMD, prompt: UPGRADE_AI, desktop: DESKTOP_HOST } = worktableUpgrade()
// 更新提示图标(手绘 SVG,避免 emoji 跨平台渲染差异)
const ICON_SYNC = (
<svg viewBox="0 0 16 16" aria-hidden>
@@ -194,7 +195,7 @@ function buildLayout(presetId: string, name: string): LayoutSpec {
const top = Array.from({ length: def.topCount }, (_, i) => mk(def.leftCount + i))
const main = Array.from({ length: def.contentCount }, (_, i) => mk(def.leftCount + def.topCount + i))
return {
id: 'layout-' + Date.now().toString(36),
id: newWidgetId('layout'),
title: name,
left: left ?? null,
top: top.length > 0 ? top : null,
@@ -745,14 +746,14 @@ const KNOWLEDGE_PACK = [
' 界面即可与 DSH 原生风格一致;自造样式控制在最小范围。',
'- 服务端能力:/api/worktable/fs(列目录)、/api/worktable/write(写文件)、/api/worktable/mkdir(建目录)、',
' /api/worktable/git(git 状态)、/api/worktable/site(静态托管)。',
'- 改完插件在 01_content 目录执行 npm run build;重启 dsh web 或浏览器 F5 生效。',
'- 改完插件在 01_content 目录执行 npm run build;网页端重启 dsh web 并刷新,桌面端完整退出后手动重新打开。',
'- 所有产出文件一律放进本任务标注的项目文件夹,保持用户目录干净。',
'- 标注协议:收到「📌 标注-窗口N …(坐标/元素/框内文字…)…」= 工作台标注(窗口编号约定:左栏→顶行→主行,从 1 起)。',
' 处理:标注已标明框选区域与「最可能目标」时直接作答、不要二次询问(仅当确实无法从标注定位时才问一次);能查看截图或打开窗口则先核实再回答。',
].join('\n')
/** 组装窗口任务提示词(新建/发送两模式共用;导出到 window 供自测校验) */
function buildWindowTaskText(projectId: string, projectName: string, windowLabel: string, requirement: string, folder: string | null, mode: 'new' | 'send'): string {
function buildWindowTaskText(projectId: string, projectName: string, windowLabel: string, requirement: string, folder: string | null, mode: 'new' | 'send', widgetBindings: WidgetBinding[] = []): string {
const win = windowLabel || '一个内容窗'
const folderLine = folder
? '项目文件夹:' + folder + '(本项目所有产出文件一律放进这个文件夹;不要写到别的默认位置)。'
@@ -768,8 +769,9 @@ function buildWindowTaskText(projectId: string, projectName: string, windowLabel
' - 文档 / 演示(PPT、报告、表格)→ 生成真实文件(.pptx / .md / .xlsx)放进项目文件夹,用户用本机软件打开编辑;',
' - 视频 / 动画 → 生成 .mp4 / .gif(或 Lottie JSON)文件放进项目文件夹;',
' - 工作台已有内置窗能力(资源管理器 / 终端 / 浏览器 / 动画站)→ 不要重复造轮子,直接建议用户改用内置窗。',
'5. 完成内容后,用一两句话告知用户挂载结果(不要提问、不要等待用户确认):例如「已完成并自动挂到「' + win + '」窗口,想调整直接说,我改完会自动更新」。该窗口位于「' + projectName + '」项目内,如需要也可协助该项目后续的其他自定义工作。',
'6. 完成后写入「产物清单」文件:在项目文件夹里创建 widget-result.json。挂载单个窗口时写单对象 {\"window\":\"' + win + '\",\"path\":\"产物相对路径\",\"kind\":\"html\"};一次挂载多个窗口(如窗口1 + 窗口2)时写 JSON 数组,每个元素为上述单对象(可引用多个产物文件)。kind 可选 html(本地页面,path 相对项目文件夹)/ url(外部链接,path 为完整 URL)/ file(其他文件,path 相对项目文件夹)。写完这个文件,工作台会自动把产物挂载进清单对应的各个窗口并锁定保存,用户无需手动操作;用户下次打开工作台时各窗口直接显示产物,不会丢失或重置。',
'5. 完成后用一两句话说明作品与目标窗口。写清单不代表界面已确认挂载成功;只有实际看到挂载结果时,才说「已挂载」。该窗口属于「' + projectName + '」项目。',
'6. 产物握手 v2:只使用下列本项目专属清单路径,不读写项目根目录旧的 widget-result.json,也不要复用其他项目的清单。作品仍放在项目文件夹,path 按该文件夹解析。先完成作品,再创建清单父目录并写入对应 JSON;kind 为 html / url / file,替换示例中的 path 与 kind,其他身份字段保持原样。一次生成多个窗口时分别写各窗口的清单。以后在本会话里继续修改同名作品,仍更新这个清单;不得根据清单文件名变化与否判断是否完成。',
widgetBindings.length ? widgetBindings.map((b) => b.window + ':' + widgetManifestPath(b) + '\n' + JSON.stringify({ version: 2, projectId: b.projectId, paneId: b.paneId, bindingId: b.bindingId, window: b.window, path: '产物相对路径', kind: 'html' })).join('\n') : '当前没有有效的自动挂载绑定,请用户从目标窗口重新发送自定义任务;不要写旧清单猜测归属。',
KNOWLEDGE_PACK,
]
return lines.join('\n')
@@ -779,7 +781,6 @@ function buildWindowTaskText(projectId: string, projectName: string, windowLabel
export async function createCustomSession(projectId: string, projectName: string, requirement: string, group?: NewSessionGroup, windowLabel = '', folder: string | null = null): Promise<string> {
const b = sessionBridge
if (!b || typeof b.sessions?.create !== 'function') throw new Error('sessions unavailable')
const text = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, 'new')
// 分组解析:existing → 直接带 workspaceId 建会话;new → 先建目录并注册为工作区
let workspaceId: string | null = null
const g = group ?? { kind: 'none' as const }
@@ -807,11 +808,13 @@ export async function createCustomSession(projectId: string, projectName: string
let createOpts: any = {}
if (workspaceId) createOpts = { workspaceId }
else if (folder) createOpts = { cwd: folder }
const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts)
const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts, g.kind === 'none' ? 'none' : 'auto')
await ensureSessionPreset(sessionId) // 新会话应用部署默认预设
await ensureSessionModel(sessionId) // 修复继承失效 provider(selectModel 同步存默认,顺带修复后续新会话)
markPluginSessionOpen(sessionId) // 插件发起的切换:不触发「切会话关项目」联动
try { await openHostSession(clientCtx, b.sessions, sessionId) } catch {}
const widgetBindings = prepareWidgetTask(projectId, folder, sessionId, windowLabel)
const text = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, 'new', widgetBindings)
await promptIntoSession(sessionId, text)
return sessionId
}
@@ -820,7 +823,8 @@ export async function createCustomSession(projectId: string, projectName: string
export async function sendCustomToSession(sessionId: string, projectId: string, projectName: string, requirement: string, windowLabel = '', folder: string | null = null): Promise<void> {
const b = sessionBridge
if (!b) throw new Error('bridge unavailable')
const text = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, 'send')
const widgetBindings = prepareWidgetTask(projectId, folder, sessionId, windowLabel)
const text = buildWindowTaskText(projectId, projectName, windowLabel, requirement, folder, 'send', widgetBindings)
markPluginSessionOpen(sessionId) // 插件发起的切换:不触发「切会话关项目」联动
try { await openHostSession(clientCtx, b.sessions, sessionId) } catch {}
await promptIntoSession(sessionId, text)
@@ -867,48 +871,40 @@ function saveNotifyAck(sid: string, state: string) {
localStorage.setItem('dsh.worktable.notifyAck.v1', JSON.stringify(ack))
} catch {}
}
/** 自动挂载:已消费过完成事件的会话集(一次完成只挂载一次) */
/** 自动挂载:按项目与会话消费完成边沿,同一会话可服务不同项目。 */
const mountConsumedRef: { current: Set<string> } = { current: new Set<string>() }
/** 待挂载(项目未打开时暂存):projectId → 挂载内容;刷新后从 localStorage 恢复 */
const pendingMountRef: { current: Record<string, any> } = { current: (() => {
try { return JSON.parse(localStorage.getItem('dsh.worktable.pendingMount.v1') ?? '{}') ?? {} } catch { return {} }
/** v1 无归属清单/pending 不执行;原有 layouts/views 仍按项目 id 恢复。 */
const widgetRegistry = new WidgetMountRegistry((() => { try { return localStorage } catch { return null } })())
let widgetProjectOf: (id: string) => { folder?: string; spec?: LayoutSpec } = () => ({})
function prepareWidgetTask(projectId: string, folder: string | null, sessionId: string, windowLabel: string): WidgetBinding[] {
if (!folder) return []
const current = widgetProjectOf(projectId)
if (!folder || current.folder !== folder || !current.spec) throw new Error('project folder or window changed')
return widgetRegistry.begin(projectId, current.spec, folder, sessionId, windowLabel)
}
/** 待挂载保存归属,打开时重新读取文件并校验当前目录/绑定/稳定窗格 id。 */
const pendingMountRef: { current: Record<string, { binding: WidgetBinding }> } = { current: (() => {
try { return JSON.parse(localStorage.getItem('dsh.worktable.pendingMount.v2') ?? '{}') ?? {} } catch { return {} }
})() }
/** 已挂载指纹:projectId → widget-result.json 原文。自愈扫挂据此去重:
* 同清单不重复锁定(不覆盖用户后续对窗口的手动调整),清单变化才重新挂载。 */
/** 启动扫挂按绑定指纹去重;新的完成边沿允许同路径、同清单作品重新加载。 */
const mountedWidgetRef: { current: Record<string, string> } = { current: (() => {
try { return JSON.parse(localStorage.getItem('dsh.worktable.mountedWidget.v1') ?? '{}') ?? {} } catch { return {} }
try { return JSON.parse(localStorage.getItem('dsh.worktable.mountedWidget.v2') ?? '{}') ?? {} } catch { return {} }
})() }
function recordMountedWidget(projectId: string, raw: string) {
if (!projectId || !raw) return
mountedWidgetRef.current[projectId] = raw
try { localStorage.setItem('dsh.worktable.mountedWidget.v1', JSON.stringify(mountedWidgetRef.current)) } catch {}
const widgetReadSeqRef: { current: Record<string, number> } = { current: {} }
function recordMountedWidget(bindingId: string, raw: string) {
if (!bindingId || !raw) return
mountedWidgetRef.current[bindingId] = raw
try { localStorage.setItem('dsh.worktable.mountedWidget.v2', JSON.stringify(mountedWidgetRef.current)) } catch {}
}
/** 「窗口N」→ 窗格位置(左栏 → 顶行 → 主行 编号规则,与 AGENTS.md 窗口编号一致) */
function windowLabelToPane(spec: any, label: string): { row: 'left' | 'top' | 'main'; index: number } | null {
const m = /^窗口(\d+)$/.exec(String(label ?? '').trim())
if (!m) return null
const n = parseInt(m[1], 10)
if (!Number.isFinite(n) || n < 1) return null
let idx = n - 1
if (spec?.left) {
if (idx === 0) return { row: 'left', index: 0 }
idx -= 1
function persistPendingWidgets() {
try { localStorage.setItem('dsh.worktable.pendingMount.v2', JSON.stringify(pendingMountRef.current)) } catch {}
}
function invalidateWidgetProject(projectId: string) {
widgetRegistry.revokeProject(projectId)
for (const [id, pending] of Object.entries(pendingMountRef.current)) {
if (pending?.binding?.projectId === projectId) delete pendingMountRef.current[id]
}
const top = spec?.top ?? []
if (idx < top.length) return { row: 'top', index: idx }
idx -= top.length
const main = spec?.main ?? []
if (idx < main.length) return { row: 'main', index: idx }
return null
}
/** 窗格是否存在于布局 spec(lockPane 对不存在的窗格会静默放弃;据此决定是否记录挂载指纹) */
function paneExists(spec: any, row: 'left' | 'top' | 'main', index: number): boolean {
if (!spec) return false
if (row === 'left') return !!spec.left && index === 0
if (row === 'top') return !!spec.top && !!spec.top[index]
return !!spec.main && !!spec.main[index]
persistPendingWidgets()
}
/** 产物清单 → 窗口内容(html=目录级托管 iframe / url=外链 iframe / file=文件预览) */
@@ -1153,6 +1149,7 @@ function WorktableSection(props: any) {
const [consoleName, setConsoleName] = useState('')
const [consoleErr, setConsoleErr] = useState(false)
const [consoleBusy, setConsoleBusy] = useState(false)
const consoleNeedsWorkspace = blankSessionNeedsWorkspace(sessionBridge?.sessions, consoleMode === 'none' ? 'none' : 'auto')
/** 自定义布局弹窗(预设网格末尾的 + 磁贴):描述 → 复制提示词到剪贴板 */
const [customOpen, setCustomOpen] = useState(false)
@@ -1462,7 +1459,14 @@ function WorktableSection(props: any) {
return { ...prev, views: { ...prev.views, [spec.id]: spec } }
})
}
return () => { splitStore.onSpecMutated = null }
splitStore.onPaneContentEdited = (projectId, paneId) => {
widgetRegistry.revokePane(projectId, paneId)
for (const [id, p] of Object.entries(pendingMountRef.current)) {
if (p?.binding?.projectId === projectId && p.binding.paneId === paneId) delete pendingMountRef.current[id]
}
persistPendingWidgets()
}
return () => { splitStore.onSpecMutated = null; splitStore.onPaneContentEdited = null }
}, [])
// 分栏引擎 UI 文案(窗选择器等)
@@ -1634,23 +1638,10 @@ function buildCustomLayoutPrompt(req: string): string {
const bound = projectsRef.current.projects.bindings[spec.id]
if (bound) { try { openHostSession(clientCtx, sessionBridge?.sessions, bound) } catch {} }
ackProjectNotify(spec.id)
// 补挂:此前项目未打开时暂存的产物(entries = 多窗口挂载列表),现在自动挂进各目标窗格;
// 全部落位成功记录指纹(供自愈扫挂去重)
const pending = pendingMountRef.current[spec.id]
if (pending) {
try {
const entries = Array.isArray(pending.entries)
? pending.entries
: (pending.content ? [{ content: pending.content, row: pending.row, index: pending.index ?? 0 }] : [])
let allOk = entries.length > 0
for (const e of entries) {
splitStore.lockPane(e.row, e.index ?? 0, e.content)
if (!paneExists(splitStore.spec, e.row, e.index ?? 0)) allOk = false
}
if (allOk && pending.fingerprint) recordMountedWidget(spec.id, pending.fingerprint)
} catch {}
delete pendingMountRef.current[spec.id]
try { localStorage.setItem('dsh.worktable.pendingMount.v1', JSON.stringify(pendingMountRef.current)) } catch {}
// 读取失败保留 pending 供下次打开重试;成功挂载后才由 loader 清除。
for (const pending of Object.values(pendingMountRef.current)) {
if (pending?.binding?.projectId !== spec.id) continue
void applyWidgetManifest(pending.binding, null, true)
}
}
}, [projects.views])
@@ -1697,10 +1688,12 @@ function buildCustomLayoutPrompt(req: string): string {
openConsole(sid)
}
/** 强制绑定:新建空会话并绑定(分组同自定义窗:无 / 现有 / 新建) */
/** 新版空会话需显式分组才能输入;不能替用户改组或自动发送激活消息。 */
const bindConsoleNew = async () => {
const b = sessionBridge
if (!b || typeof b.sessions?.create !== 'function') { setConsoleErr(true); return }
if (blankSessionNeedsWorkspace(b.sessions, consoleMode === 'none' ? 'none' : 'auto')) return
if (consoleMode === 'existing' && !consoleWsId) { setConsoleErr(true); return }
if (consoleMode === 'new' && (!consoleParent.trim() || !consoleName.trim())) { setConsoleErr(true); return }
setConsoleBusy(true); setConsoleErr(false)
try {
@@ -1725,7 +1718,7 @@ function buildCustomLayoutPrompt(req: string): string {
let createOpts: any = {}
if (workspaceId) createOpts = { workspaceId }
else if (folder) createOpts = { cwd: folder }
const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts)
const sessionId = await createHostSession(b.sessions, b.workspaces, createOpts, consoleMode === 'none' ? 'none' : 'auto')
await ensureSessionPreset(sessionId) // 管理对话同样应用部署默认预设
await ensureSessionModel(sessionId) // 管理对话同样修复失效 provider
markPluginSessionOpen(sessionId)
@@ -1833,7 +1826,10 @@ function buildCustomLayoutPrompt(req: string): string {
pickBusyRef.current = false
setPickBusy(false)
if (target === 'add') { setWsFolderParent(p); setWsFolderError(false) }
else if (bindPick) { persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } })) }
else if (bindPick) {
if (projectsRef.current.projects.folders[bindPick.id] !== p) invalidateWidgetProject(bindPick.id)
persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } }))
}
setManualPathFor(null)
setManualPathText('')
setPickErr((prev) => ({ ...prev, [target]: '' }))
@@ -1843,12 +1839,14 @@ function buildCustomLayoutPrompt(req: string): string {
const changeBindFolder = () => {
if (!bindPick || pickBusyRef.current) return
pickFolder('bind', (p) => {
if (projectsRef.current.projects.folders[bindPick.id] !== p) invalidateWidgetProject(bindPick.id)
persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } }))
})
}
/** 绑定 / 解绑会话(弹窗保持打开,绑定结果即时显示在「绑定对话」框里) */
const setProjectBinding = (id: string, sessionId: string | null) => {
if ((projectsRef.current.projects.bindings[id] ?? null) !== sessionId) invalidateWidgetProject(id)
persistProjects((prev) => {
const next = { ...prev.bindings }
if (sessionId) next[id] = sessionId
@@ -1898,106 +1896,80 @@ function buildCustomLayoutPrompt(req: string): string {
// 项目表变化 → 控制室卡片刷新(打开中的面板即时更新)
useEffect(() => { notifyConsole() }, [projects])
const [widgetTick, setWidgetTick] = useState(0)
useEffect(() => widgetRegistry.subscribe(() => setWidgetTick((n) => n + 1)), [])
/** 执行产物清单挂载:项目开着 → 锁定各「窗口N」;未开 → 待挂载(打开项目时补挂)。
* 清单支持单窗口对象(旧格式)与多窗口 JSON 数组(新格式,每元素 {window,path,kind})。
* rawManifest 传入时免二次读取(自愈扫挂先读原文做指纹去重)。全部落位成功后记录挂载指纹。 */
const applyWidgetManifest = useCallback(async (projectId: string, rawManifest: string | null): Promise<void> => {
const folder = projectsRef.current.projects.folders[projectId]
if (!folder) return
/** 只处理当前有效绑定。await 后与实际替换前重新查归属,窗口编号非法时不退到窗口1。 */
const applyWidgetManifest = useCallback(async (binding: WidgetBinding, rawManifest: string | null, force = false): Promise<void> => {
let current = widgetProjectOf(binding.projectId)
if (!mountedRef.current || !widgetRegistry.isCurrent(binding, current.spec, current.folder)) return
const readSeq = (widgetReadSeqRef.current[binding.bindingId] ?? 0) + 1
widgetReadSeqRef.current[binding.bindingId] = readSeq
try {
let raw = rawManifest
if (raw == null) {
const r = await fetch('/api/worktable/file?path=' + encodeURIComponent(joinPath(folder, 'widget-result.json')), { cache: 'no-store' })
const r = await fetch('/api/worktable/file?path=' + encodeURIComponent(widgetManifestPath(binding)), { cache: 'no-store' })
if (!r.ok) return
raw = (await r.text()).trim()
}
if (!raw) return
let d: any = null
try { d = JSON.parse(raw) } catch {}
if (!d) return
const items = Array.isArray(d) ? d : [d]
const open = splitStore.active && splitStore.spec?.id === projectId
const saved = open ? splitStore.spec : (projectsRef.current.projects.views[projectId] ?? projectsRef.current.projects.layouts.find((l) => l.id === projectId))
// 逐项解析目标窗格;同窗格冲突时保留先出现者(防止把两个窗口压进同一个窗格互相覆盖)
const targets: { row: 'left' | 'top' | 'main'; index: number; content: any }[] = []
for (const it of items) {
if (!it) continue
const content = buildMountContent(folder, it)
if (!content) continue
const pane = windowLabelToPane(saved, it.window)
const row = pane?.row ?? 'main'
const index = pane?.index ?? 0
if (targets.some((t) => t.row === row && t.index === index)) continue
targets.push({ row, index, content })
}
if (!targets.length) return
current = widgetProjectOf(binding.projectId)
if (!mountedRef.current || !raw || widgetReadSeqRef.current[binding.bindingId] !== readSeq) return
const resolved = widgetRegistry.resolve(binding, raw, current.spec, current.folder)
if (!resolved || (!force && mountedWidgetRef.current[binding.bindingId] === raw)) return
const content = buildMountContent(binding.folder, resolved.item)
if (!content) return
const open = splitStore.active && splitStore.spec?.id === binding.projectId
if (open) {
// 项目开着:逐窗锁定;全部落位成功才记录指纹(有失败则留待下次自愈重试)
let allOk = true
for (const t of targets) {
splitStore.lockPane(t.row, t.index, t.content)
if (!paneExists(splitStore.spec, t.row, t.index)) allOk = false
}
if (allOk) recordMountedWidget(projectId, raw)
// 新标签 id 使同名 HTML 在下一次完成后重新加载;不会用 raw 指纹挡住追改。
splitStore.lockPane(resolved.target.row, resolved.target.index, content)
recordMountedWidget(binding.bindingId, raw)
delete pendingMountRef.current[binding.bindingId]
persistPendingWidgets()
} else {
// 项目没开:按项目已保存的 spec 解析目标窗格,暂存,打开项目时锁死补挂
pendingMountRef.current[projectId] = { entries: targets, fingerprint: raw }
try { localStorage.setItem('dsh.worktable.pendingMount.v1', JSON.stringify(pendingMountRef.current)) } catch {}
pendingMountRef.current[binding.bindingId] = { binding }
persistPendingWidgets()
}
} catch { /* 无清单文件 = 不挂载 */ }
} catch { /* 没有可验证的结果,不挂载旧目录的其他文件。 */ }
}, [])
/** 自动挂载:绑定会话完成 → 读项目文件夹 widget-result.json → 产物自动挂进对应窗口 */
const tryAutoMount = useCallback(async (projectId: string, sid: string) => {
void sid
await applyWidgetManifest(projectId, null)
}, [applyWidgetManifest])
// 完成事件 → 尝试自动挂载(一次完成只消费一次)
// 完成边沿只触发读取,不是成功证明;实际挂载仍要求有效 v2 结果。
useEffect(() => {
const byId = sessionsSnapshotStore.snapshot?.byId ?? {}
const scopePairs = new Map(widgetRegistry.entries().map((b) => [JSON.stringify([b.projectId, b.sessionId]), b]))
for (const key of mountConsumedRef.current) if (!scopePairs.has(key)) mountConsumedRef.current.delete(key)
for (const [pid, sid] of Object.entries(projects.bindings)) {
const e = byId[sid]
if (!e) continue
if (e.completed === true) {
// 新一次完成 → 清除旧 done ack,控制室绿光重新点亮(即使旧完成已被确认过)
if (doneSeenRef.current[sid] !== true) clearNotifyAck(sid)
doneSeenRef.current[sid] = true
if (!mountConsumedRef.current.has(sid)) {
mountConsumedRef.current.add(sid)
tryAutoMount(pid, sid)
}
} else {
doneSeenRef.current[sid] = false
mountConsumedRef.current.delete(sid)
}
}
}, [notifyTick, projects.bindings, tryAutoMount])
for (const [key, binding] of scopePairs) {
const e = byId[binding.sessionId]
if (!e) continue
if (e.completed !== true) { mountConsumedRef.current.delete(key); continue }
if (mountConsumedRef.current.has(key)) continue
mountConsumedRef.current.add(key)
for (const b of widgetRegistry.entries(binding.projectId)) {
if (b.sessionId === binding.sessionId) void applyWidgetManifest(b, null, true)
}
}
}, [notifyTick, projects.bindings, widgetTick, applyWidgetManifest])
// 自愈扫挂:插件加载 / 项目文件夹变化时,扫描每个项目的 widget-result.json 并落位
// (「完成事件」通道的兜底:事件丢失、绑定错位、完成时页面未加载等场景下仍能自动挂载;
// 按清单原文指纹去重——同清单不重复锁定,用户后续手动改窗口内容不会被覆盖)。
// 只扫描已明确登记的专属路径。新项目没有绑定,所以即使共用目录也保持空白。
useEffect(() => {
let cancelled = false
const folders = projects.folders ?? {}
;(async () => {
for (const pid of Object.keys(folders)) {
for (const binding of widgetRegistry.entries()) {
if (cancelled) return
const folder = folders[pid]
if (!folder) continue
try {
const r = await fetch('/api/worktable/file?path=' + encodeURIComponent(joinPath(folder, 'widget-result.json')), { cache: 'no-store' })
if (!r.ok) continue
const raw = (await r.text()).trim()
if (!raw) continue
if (mountedWidgetRef.current[pid] === raw) continue
await applyWidgetManifest(pid, raw)
} catch { /* 单项目失败不影响其余 */ }
await applyWidgetManifest(binding, null)
}
})()
return () => { cancelled = true }
}, [projects.folders, applyWidgetManifest])
}, [projects.folders, widgetTick, applyWidgetManifest])
/** 收集某会话的子代理 id 集:byId 的 parentId 标注 + subagentsByParent 目录双通道 */
const collectKids = useCallback((sid: string): Set<string> => {
@@ -2056,6 +2028,11 @@ function buildCustomLayoutPrompt(req: string): string {
[registeredIds, projects.removed],
)
projectsRef.current = { projects, metas, aliveRegisteredIds }
widgetProjectOf = (id) => ({
folder: projectsRef.current.projects.folders[id],
spec: (splitStore.active && splitStore.spec?.id === id ? splitStore.spec : undefined) ??
projectsRef.current.projects.views[id] ?? projectsRef.current.projects.layouts.find((l) => l.id === id),
})
projectBindingsRef.current = projects.bindings
const allIds = useMemo(() => [...aliveRegisteredIds, ...layoutIds], [aliveRegisteredIds, layoutIds])
const effectiveOrder = useMemo(() => {
@@ -2206,12 +2183,14 @@ function buildCustomLayoutPrompt(req: string): string {
}
const removeLayout = (id: string) => {
invalidateWidgetProject(id)
persistProjects((prev) => ({ ...prev, layouts: prev.layouts.filter((l) => l.id !== id) }))
}
// ── 变更视图:所有项目通用。布局项目 = 重建其布局条目;入驻项目 = 建立/更新视图覆盖。
// 现有窗内容(标签)按序迁入新拓扑,不丢失。 ──
const applyLayoutChange = (id: string, presetId: string) => {
invalidateWidgetProject(id)
const layout = projects.layouts.find((l) => l.id === id)
const meta = metas[id]
const current = layout ?? projects.views[id]
@@ -2267,6 +2246,7 @@ function buildCustomLayoutPrompt(req: string): string {
// 「工作台」控制室项目不可删除(界面不提供入口,这里兜底拒绝)
const removeProject = (id: string) => {
if (id === CONSOLE_ID) return
invalidateWidgetProject(id)
persistProjects((prev) => {
const next = {
...prev,
@@ -2766,7 +2746,7 @@ function buildCustomLayoutPrompt(req: string): string {
</button>
<button type="button" className="dsh-wt_updateBtn" onClick={skipUpdate}>{t('update.skip')}</button>
</div>
<div className="dsh-wt_updateHint">{t('update.upgradeHint')}</div>
<div className="dsh-wt_updateHint">{t(DESKTOP_HOST ? 'update.upgradeHintDesktop' : 'update.upgradeHint')}</div>
</div>
)}
<div className="dsh-wt_manageHead">
@@ -3025,6 +3005,7 @@ function buildCustomLayoutPrompt(req: string): string {
</select>
{consoleMode === 'existing' && (
<select className="dsh-wt_consoleSelect" value={consoleWsId} onChange={(e) => setConsoleWsId(e.target.value)}>
<option value="">{t('console.chooseGroup')}</option>
{listWorkspaces().map((w) => <option key={w.id} value={w.id}>{w.title}</option>)}
</select>
)}
@@ -3034,8 +3015,9 @@ function buildCustomLayoutPrompt(req: string): string {
<input className="dsh-wt_consoleInput" placeholder={t('console.newNamePh')} value={consoleName} onChange={(e) => setConsoleName(e.target.value)} />
</>
)}
{consoleErr && <p className="dsh-wt_consoleErr">{t('console.bindFail')}</p>}
<button type="button" className="dsh-wt_consoleCreateBtn" disabled={consoleBusy} onClick={bindConsoleNew}>
{consoleNeedsWorkspace && <p className="dsh-wt_consoleHint">{t('console.blankNeedsGroup')}</p>}
{consoleErr && !consoleNeedsWorkspace && <p className="dsh-wt_consoleErr">{t('console.bindFail')}</p>}
<button type="button" className="dsh-wt_consoleCreateBtn" disabled={consoleBusy || consoleNeedsWorkspace || (consoleMode === 'existing' && !consoleWsId)} onClick={bindConsoleNew}>
{consoleBusy ? '…' : t('console.createBind')}
</button>
</div>
+6
View File
@@ -172,6 +172,8 @@ export const zh = {
'console.groupNone': '无分组(用项目文件夹)',
'console.groupExisting': '加入现有分组',
'console.groupNew': '新建分组',
'console.chooseGroup': '选择分组…',
'console.blankNeedsGroup': '当前 DSH 的未分组空对话无法输入,请选择分组或加入现有对话。',
'console.newParentPh': '分组父目录,例如 E:\\AI_Workspace',
'console.newNamePh': '分组文件夹名(将创建并注册)',
'console.createBind': '新建并绑定',
@@ -234,6 +236,7 @@ export const zh = {
'update.copyFail': '复制失败,请手动选择文本',
'update.skipDone': '已忽略此版本(下一版发布时再提醒)',
'update.upgradeHint': '执行命令后需重启 dsh web 并刷新页面',
'update.upgradeHintDesktop': '请使用桌面端自带 CLI;安装前完整退出,完成后手动重新打开桌面端',
} satisfies Record<string, string>
/** The worktable namespace key union. */
@@ -407,6 +410,8 @@ export const en = {
'console.groupNone': 'No group (use project folder)',
'console.groupExisting': 'Join existing group',
'console.groupNew': 'New group',
'console.chooseGroup': 'Choose a group…',
'console.blankNeedsGroup': 'Blank ungrouped chats cannot accept input in this DSH. Choose a group or join an existing chat.',
'console.newParentPh': 'Group parent folder, e.g. E:\\AI_Workspace',
'console.newNamePh': 'Group folder name (created & registered)',
'console.createBind': 'Create & bind',
@@ -469,6 +474,7 @@ export const en = {
'update.copyFail': 'Copy failed — select the text manually',
'update.skipDone': 'Skipped (will remind on the next release)',
'update.upgradeHint': 'Run the command, then restart dsh web and refresh',
'update.upgradeHintDesktop': 'Use the Desktop bundled CLI; fully quit before installing, then reopen Desktop manually',
} satisfies Record<WorktableKey, string>
/** Locale namespace id registered under ctx.locale. */
+11 -2
View File
@@ -62,8 +62,17 @@ export function modelApiOf(ctx: any, sessions: any, legacy: any): any {
}
}
/** 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> {
/** On the retained-scope host, a blank ungrouped session has a disabled native composer. */
export function blankSessionNeedsWorkspace(sessions: any, grouping: 'auto' | 'none'): boolean {
return grouping === 'none' && typeof sessions?.using === 'function'
}
/** Preserve an explicit Ungrouped choice; only implicit defaults may register a Workspace. */
export async function createHostSession(sessions: any, workspaces: any, options: { workspaceId?: string; cwd?: string; sessionId?: string } = {}, grouping: 'auto' | 'none' = 'auto'): Promise<string> {
if (grouping === 'none') {
if (options.workspaceId) throw new Error('ungrouped session cannot specify a workspace')
return sessions.create(options)
}
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
+23 -9
View File
@@ -1,5 +1,6 @@
import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
import { parentPathOf } from './pathutil'
import { hostWebSocketUrl, worktableUpgrade } from './hostTransport'
import { CHANGELOG_V030 } from './changelog'
import { LOCAL_VERSION, checkUpdate, getAutoCheck, readCache, setAutoCheck as storeAutoCheck, setSkipVersion, type UpdateInfo, type UpdateStatus } from './updateCheck'
import { Terminal } from 'xterm'
@@ -99,6 +100,8 @@ type SplitState = {
lastMarginRight: string
lastMarginTop: string
onSpecMutated: ((spec: LayoutSpec) => void) | null
/** Manual edits revoke that pane's automatic result binding; lockPane itself does not. */
onPaneContentEdited: ((projectId: string, paneId: string) => void) | null
listeners: Set<() => void>
open(spec: LayoutSpec): boolean
close(): void
@@ -487,8 +490,7 @@ function boxPayload(x0: number, y0: number, x1: number, y1: number): { primary:
}
/** 更新方法:复制给 AI 的升级指令(插件不自更新;升级由用户或其 Agent 执行 + 重启) */
const UPGRADE_CMD = 'dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"'
const UPGRADE_AI = '帮我升级 dsh-worktable:执行 ' + UPGRADE_CMD + ',完成后提醒我重启 dsh web 并刷新页面'
const { prompt: UPGRADE_AI } = worktableUpgrade()
async function copyTextSafe(text: string): Promise<boolean> {
try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true } } catch {}
@@ -758,6 +760,7 @@ export const splitStore: SplitState = {
lastMarginRight: '',
lastMarginTop: '',
onSpecMutated: null,
onPaneContentEdited: null,
listeners: new Set(),
open(spec) {
@@ -1070,7 +1073,7 @@ export const splitStore: SplitState = {
lockPane(row, i, content) {
const spec = this.spec
if (!spec) return
const tab: PaneTab = { id: 't' + Date.now().toString(36), title: tabTitleOf(content), content, active: 0 }
const tab: PaneTab = { id: 't' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2), title: tabTitleOf(content), content, active: 0 }
const mutate = (pane: SplitPane): SplitPane => ({ ...pane, content: null, tabs: [tab], active: 0 })
if (row === 'left') {
if (!spec.left || i !== 0) return
@@ -1095,6 +1098,8 @@ export const splitStore: SplitState = {
setTabContent(row, index, tabId, content) {
const spec = this.spec
if (!spec) return
const edited = row === 'left' ? spec.left : row === 'top' ? spec.top?.[index] : spec.main[index]
if (edited?.tabs?.some((t) => t.id === tabId)) this.onPaneContentEdited?.(spec.id, edited.id)
const mutate = (pane: SplitPane): SplitPane => {
const tabs = (pane.tabs ?? []).map((t) => (t.id === tabId ? { ...t, content, title: tabTitleOf(content) } : t))
return { ...pane, tabs }
@@ -1115,6 +1120,8 @@ export const splitStore: SplitState = {
openTab(row, i, content) {
const spec = this.spec
if (!spec) return
const edited = row === 'left' ? spec.left : row === 'top' ? spec.top?.[i] : spec.main[i]
if (edited) this.onPaneContentEdited?.(spec.id, edited.id)
const mutate = (pane: SplitPane): SplitPane => {
const tabs = [...(pane.tabs ?? [])]
// 去重:同内容已有标签 → 直接激活
@@ -1146,6 +1153,8 @@ export const splitStore: SplitState = {
closeTab(row, i, tabId) {
const spec = this.spec
if (!spec) return
const edited = row === 'left' ? spec.left : row === 'top' ? spec.top?.[i] : spec.main[i]
if (edited?.tabs?.some((t) => t.id === tabId)) this.onPaneContentEdited?.(spec.id, edited.id)
const mutate = (pane: SplitPane): SplitPane => {
const tabs = (pane.tabs ?? []).filter((t) => t.id !== tabId)
return { ...pane, tabs, active: 0 }
@@ -1184,6 +1193,8 @@ export const splitStore: SplitState = {
if (!fromPane || !toPane) return
const tab = (fromPane.tabs ?? []).find((t) => t.id === tabId)
if (!tab) return
this.onPaneContentEdited?.(spec.id, fromPane.id)
this.onPaneContentEdited?.(spec.id, toPane.id)
const fromTabs = (fromPane.tabs ?? []).filter((t) => t.id !== tabId)
const toTabs = [...(toPane.tabs ?? []), tab]
const setPane = (row: PaneRow, i: number, pane: SplitPane) => {
@@ -2418,9 +2429,8 @@ function TerminalPane() {
focusTerm()
el.addEventListener('pointerdown', focusTerm)
const scope = splitEnv?.getScope?.()
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = proto + '//' + location.host + '/api/worktable/term?sessionId=' + encodeURIComponent(scope?.sessionId ?? '') + '&cwd=' + encodeURIComponent(scope?.cwd ?? '') + '&cols=80&rows=24'
try {
const url = hostWebSocketUrl('/api/worktable/term?sessionId=' + encodeURIComponent(scope?.sessionId ?? '') + '&cwd=' + encodeURIComponent(scope?.cwd ?? '') + '&cols=80&rows=24')
ws = new WebSocket(url)
} catch {
term.dispose()
@@ -2623,8 +2633,9 @@ function SelectPop(props: {
}
/** 自定义窗口:居中对话框。两种模式:新建专属会话 / 发送到已有会话(默认当前会话)。 */
function CustomPane(props: { paneTitle?: string }) {
const paneTitle = props.paneTitle ?? ''
function CustomPane(props: { paneTitle?: string; row?: PaneRow; index?: number }) {
const paneTitle = props.row === undefined ? (props.paneTitle ?? '') :
(windowLabelOf(props.row, props.index ?? 0).match(/^窗口\d+/)?.[0] ?? '')
try { (window as any).__dshLastCustomPaneTitle = paneTitle } catch {}
const custom = splitEnv?.custom
const [requirement, setRequirement] = useState('')
@@ -2637,6 +2648,7 @@ function CustomPane(props: { paneTitle?: string }) {
const [wsGroups, setWsGroups] = useState<{ id: string; title: string; path: string }[]>([])
const [groupMode, setGroupMode] = useState<'none' | 'existing' | 'new'>('none')
const [groupId, setGroupId] = useState<string | null>(null)
const groupTouchedRef = useRef(false)
const [newGroupParent, setNewGroupParent] = useState('')
const [newGroupName, setNewGroupName] = useState('')
const [busy, setBusy] = useState(false)
@@ -2656,7 +2668,8 @@ function CustomPane(props: { paneTitle?: string }) {
setSessionId(flat.find((s) => s.isCurrent)?.id ?? flat[0]?.id ?? null)
const curId = flat.find((s) => s.isCurrent)?.id
const home = curId ? (custom?.getWorkspaces?.() ?? []).find((w) => (w.sessionIds ?? []).includes(curId)) : null
if (home) { setGroupMode('existing'); setGroupId(home.id) }
// A delayed default lookup must not overwrite the user's explicit choice.
if (home && !groupTouchedRef.current) { setGroupMode('existing'); setGroupId(home.id) }
}).catch(() => { setSessionGroups([]) })
}, [custom])
const submit = async () => {
@@ -2757,6 +2770,7 @@ function CustomPane(props: { paneTitle?: string }) {
}]}
placeholder={T('custom.group')}
onChange={(id) => {
groupTouchedRef.current = true
if (id === '__none') setGroupMode('none')
else if (id === '__new') setGroupMode('new')
else { setGroupMode('existing'); setGroupId(id) }
@@ -2814,7 +2828,7 @@ function PaneTabBody(props: { tab: PaneTab; row: PaneRow; index: number; paneTit
if (content.type === 'scm') return <GitPane />
if (content.type === 'tasks') return <JobsPane />
if (content.type === 'terminal') return <TerminalPane />
if (content.type === 'custom') return <CustomPane paneTitle={props.paneTitle ?? ''} />
if (content.type === 'custom') return <CustomPane paneTitle={props.paneTitle ?? ''} row={props.row} index={props.index} />
return (
<div className="dsh-wt_paneWip">
<span className="dsh-wt_paneWipIcon" aria-hidden>{BUILTIN_ICONS[content.type]}</span>
+1
View File
@@ -621,6 +621,7 @@ export const css = xtermCss + '\n' + [
'.dsh-wt_consoleSelect,.dsh-wt_consoleInput{width:100%;box-sizing:border-box;padding:6px 8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:11.5px;line-height:16px;outline:none}',
'.dsh-wt_consoleSelect:focus,.dsh-wt_consoleInput:focus{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
'.dsh-wt_consoleErr{margin:0;font-size:11px;line-height:16px;color:var(--dsw-alias-state-danger,#f85149)}',
'.dsh-wt_consoleHint{margin:0;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,#aab6cd)}',
'.dsh-wt_consoleCreateBtn{margin-top:2px;padding:7px 10px;border:1px solid var(--dsw-alias-state-accent-primary,#4f8ef7);border-radius:8px;background:transparent;color:var(--dsw-alias-state-accent-primary,#4f8ef7);font:inherit;font-size:12px;line-height:18px;cursor:pointer}',
'.dsh-wt_consoleCreateBtn:hover:not(:disabled){background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
'.dsh-wt_consoleCreateBtn:disabled{opacity:.55;cursor:default}',
+121
View File
@@ -0,0 +1,121 @@
import { joinPath } from './pathutil'
/** Browser-local ownership of auto-mounted results. Folders never identify projects. */
export const WIDGET_BINDINGS_KEY = 'dsh.worktable.widgetBindings.v2'
export type WidgetSpec = { id: string; left?: any; top?: any[] | null; main: any[] }
export type WidgetBinding = {
projectId: string
paneId: string
window: string
bindingId: string
sessionId: string
folder: string
enabled: boolean
}
export type WidgetTarget = { row: 'left' | 'top' | 'main'; index: number; pane: any; window: string }
type Store = Pick<Storage, 'getItem' | 'setItem'>
export function newWidgetId(prefix = 'binding'): string {
const id = globalThis.crypto?.randomUUID?.() ?? (Date.now().toString(36) + '-' + Math.random().toString(36).slice(2))
return prefix + '-' + id
}
export function widgetTargets(spec: WidgetSpec | null | undefined): WidgetTarget[] {
if (!spec) return []
const out: WidgetTarget[] = []
const add = (row: WidgetTarget['row'], panes: any[]) => panes.forEach((pane, index) => {
if (pane && typeof pane.id === 'string') out.push({ row, index, pane, window: '窗口' + (out.length + 1) })
})
add('left', spec.left ? [spec.left] : [])
add('top', spec.top ?? [])
add('main', spec.main ?? [])
return out
}
export function widgetManifestPath(binding: WidgetBinding): string {
// Binding-specific files also keep late writers from overwriting a newer binding's result.
return joinPath(binding.folder, joinPath('.dsh-worktable', joinPath(
'project-' + encodeURIComponent(binding.projectId),
'pane-' + encodeURIComponent(binding.paneId) + '-' + binding.bindingId + '.json',
)))
}
export class WidgetMountRegistry {
private records: Record<string, Record<string, WidgetBinding>> = Object.create(null)
private listeners = new Set<() => void>()
constructor(private storage: Store | null, private makeId = () => newWidgetId()) {
try {
const data = JSON.parse(storage?.getItem(WIDGET_BINDINGS_KEY) ?? '{}')
for (const [projectId, panes] of Object.entries(data ?? {})) {
if (!panes || typeof panes !== 'object' || Array.isArray(panes)) continue
const valid: Record<string, WidgetBinding> = Object.create(null)
for (const [paneId, b] of Object.entries(panes)) {
const v = b as WidgetBinding
if (v?.projectId !== projectId || v.paneId !== paneId || typeof v.folder !== 'string' || !v.folder ||
typeof v.sessionId !== 'string' || !v.sessionId || typeof v.window !== 'string' ||
typeof v.bindingId !== 'string' || !/^[a-z0-9-]+$/i.test(v.bindingId) || typeof v.enabled !== 'boolean') continue
valid[paneId] = { ...v }
}
if (Object.keys(valid).length) this.records[projectId] = valid
}
} catch { /* Invalid registry cannot authorize an old manifest. */ }
}
private persist() {
// A send must not proceed with ownership that cannot survive reload.
this.storage?.setItem(WIDGET_BINDINGS_KEY, JSON.stringify(this.records))
for (const fn of this.listeners) fn()
}
subscribe(fn: () => void): () => void { this.listeners.add(fn); return () => { this.listeners.delete(fn) } }
entries(projectId?: string): WidgetBinding[] {
const projects = projectId ? [this.records[projectId] ?? {}] : Object.values(this.records)
return projects.flatMap((panes) => Object.values(panes)).filter((b) => b.enabled)
}
begin(projectId: string, spec: WidgetSpec, folder: string, sessionId: string, window: string): WidgetBinding[] {
if (!this.storage) throw new Error('project storage unavailable')
if (!folder || !sessionId || spec.id !== projectId) throw new Error('project folder or session unavailable')
const targets = widgetTargets(spec)
const selected = targets.find((t) => t.window === window)
if (!selected || new Set(targets.map((t) => t.pane.id)).size !== targets.length) throw new Error('project window changed')
const before = this.records[projectId]
const next: Record<string, WidgetBinding> = Object.create(null)
for (const t of targets) {
const old = before?.[t.pane.id]
// Unassigned windows can participate in one multi-window result. A manually revoked
// window stays revoked until explicitly selected for another custom task.
next[t.pane.id] = t === selected || !old || old.folder !== folder || (old.enabled && old.window !== t.window)
? { projectId, paneId: t.pane.id, window: t.window, folder, sessionId, bindingId: this.makeId(), enabled: true }
: old
}
this.records[projectId] = next
try { this.persist() } catch (error) { if (before) this.records[projectId] = before; else delete this.records[projectId]; throw error }
return this.entries(projectId).filter((b) => b.sessionId === sessionId)
}
revokePane(projectId: string, paneId: string) {
const b = this.records[projectId]?.[paneId]
if (!b || !b.enabled) return
this.records[projectId] = { ...this.records[projectId], [paneId]: { ...b, enabled: false } }
try { this.persist() } catch { /* Revocation takes effect in memory even if storage is full. */ }
}
revokeProject(projectId: string) {
if (!this.records[projectId]) return
for (const b of this.entries(projectId)) this.revokePane(projectId, b.paneId)
}
isCurrent(binding: WidgetBinding, spec: WidgetSpec | null | undefined, folder: string | undefined): boolean {
const current = this.records[binding.projectId]?.[binding.paneId]
return !!current?.enabled && current.bindingId === binding.bindingId && current.sessionId === binding.sessionId &&
current.folder === folder && binding.folder === folder && spec?.id === binding.projectId &&
widgetTargets(spec).filter((t) => t.pane.id === binding.paneId).length === 1
}
resolve(binding: WidgetBinding, raw: string, spec: WidgetSpec | null | undefined, folder: string | undefined):
{ target: WidgetTarget; item: { kind: 'html' | 'url' | 'file'; path: string } } | null {
if (!this.isCurrent(binding, spec, folder)) return null
try {
const d = JSON.parse(raw)
if (!d || Array.isArray(d) || d.version !== 2 || d.projectId !== binding.projectId ||
d.paneId !== binding.paneId || d.bindingId !== binding.bindingId || d.window !== binding.window ||
!['html', 'url', 'file'].includes(d.kind) || typeof d.path !== 'string' || !d.path.trim()) return null
const target = widgetTargets(spec).find((t) => t.pane.id === binding.paneId)!
return { target, item: { kind: d.kind, path: d.path.trim() } }
} catch { return null }
}
}