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
+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>