feat: 自定义窗口——需求对话框+项目选择(默认当前项目)+发送自动新建专属会话并注入worktable上下文

This commit is contained in:
Aisland
2026-08-18 01:56:37 +08:00
parent 66c6b4024c
commit 42645f2f51
6 changed files with 231 additions and 53 deletions
+49 -1
View File
@@ -262,6 +262,35 @@ function findSidebar(start: HTMLElement | null): HTMLElement | null {
/** 子座位注册 id 序列(模块级 store;apply 里订阅 slots 变化写入)。 */
const registryStore: { ids: string[]; listeners: Set<() => void> } = { ids: [], listeners: new Set() }
/** 自定义窗口 → 宿主会话桥(apply 时注入;不可用时 CustomPane 降级提示) */
let sessionBridge: { sessions: any; conversation: any } | null = null
/** 自定义窗口任务:新建一个专属会话,注入工作台背景 + 用户需求,并打开该会话 */
export async function createCustomSession(projectId: string, projectName: string, requirement: string): Promise<void> {
const b = sessionBridge
if (!b || typeof b.sessions?.create !== 'function') throw new Error('sessions unavailable')
const text = [
'【工作台自定义窗口任务】',
'以下背景由 dsh-worktable(工作台)插件自动附加,用于帮助你理解任务:',
'1. dsh-worktable 是什么:它是本机 DeepSeek Harness 的自建容器插件——侧边栏底部的「工作台」区块管理多个项目;',
' 每个项目打开后是一个平铺工作区(若干内容窗 + 对话窗);内容窗可放浏览器、资源管理器、终端、',
' 文件预览(MD/TXT/代码/本地网页)等,支持 6 种布局预设、变更视图、窗内容以标签页组织、状态持久化。',
' 它是用户自建的工具,官方文档中没有它的说明;不了解之处可直接向用户提问。',
'2. 本对话的用途:专门负责「' + projectName + '」项目中一个窗口(内容窗)的自定义设计——',
' 用户希望把这个窗口做成自定义内容。',
'3. 用户需求:' + requirement,
'4. 请基于以上背景,帮助用户完成这个窗口的自定义设计(例如产出可直接放入窗口的内容、页面或实现方案);',
' 该窗口位于「' + projectName + '」项目内,如需要也可协助该项目后续的其他自定义工作。',
].join('\n')
const sessionId = await b.sessions.create({})
let sent = false
if (typeof b.conversation?.sendSession === 'function') {
try { await b.conversation.sendSession(sessionId, text); sent = true } catch { sent = false }
}
try { b.sessions.open?.(sessionId) } catch {}
if (!sent) await b.conversation?.send?.(text)
}
/** 会话作用域快照(模块级;apply 里订阅 ctx.sessions.list 写入,组件与引擎只读) */
const sessionScopeStore: {
snapshot: { sessionId: string; cwd: string; jobs: any[]; subagents: any[] } | null
@@ -357,6 +386,10 @@ function WorktableSection(props: any) {
const [activeSplitId, setActiveSplitId] = useState<string | null>(() =>
splitStore.active && splitStore.spec ? splitStore.spec.id : null,
)
/** 供分栏引擎读取的最新项目快照(setSplitEnv 闭包取最新值用) */
const projectsRef = useRef<{ projects: ProjectsState; metas: Record<string, ProjectMeta>; aliveRegisteredIds: string[] }>(
{ projects, metas, aliveRegisteredIds: registeredIds },
)
// 会话作用域(当前会话 + 工作目录)与后台任务:注入分栏引擎环境
// 注意:不走 props.useSessions hook(其宿主包装在部分版本会触发 useSyncExternalStore
@@ -369,6 +402,17 @@ function WorktableSection(props: any) {
},
getJobs: () => sessionScopeStore.snapshot?.jobs ?? [],
getSubagents: () => sessionScopeStore.snapshot?.subagents ?? [],
custom: {
getProjects: () => {
const p = projectsRef.current
return [
...p.aliveRegisteredIds.map((id) => ({ id, name: p.projects.nameOverrides[id] ?? p.metas[id]?.name ?? id })),
...p.projects.layouts.map((l) => ({ id: l.id, name: p.projects.nameOverrides[l.id] ?? l.title })),
]
},
currentProjectId: () => (splitStore.active && splitStore.spec ? splitStore.spec.id : null),
submit: (projectId, projectName, requirement) => createCustomSession(projectId, projectName, requirement),
},
})
return () => setSplitEnv(null)
}, [])
@@ -538,6 +582,7 @@ function WorktableSection(props: any) {
() => registeredIds.filter((id) => !projects.removed.includes(id)),
[registeredIds, projects.removed],
)
projectsRef.current = { projects, metas, aliveRegisteredIds }
const allIds = useMemo(() => [...aliveRegisteredIds, ...layoutIds], [aliveRegisteredIds, layoutIds])
const effectiveOrder = useMemo(() => {
const known = new Set(allIds)
@@ -1277,9 +1322,12 @@ function WorktableSection(props: any) {
)
}
export const inject = ['slots', 'locale', 'sessions']
export const inject = ['slots', 'locale', 'sessions', 'conversation']
export function apply(ctx: any) {
// 自定义窗口 → 新建会话桥:保存宿主 sessions/conversation 服务引用(模块级)
sessionBridge = { sessions: ctx.sessions ?? null, conversation: ctx.conversation ?? null }
ctx.effect(() => {
const style = document.createElement('style')
style.setAttribute('data-dsh-plugin', 'dsh-worktable')
+16 -4
View File
@@ -58,8 +58,14 @@ export const zh = {
'pane.tasks': '任务管理',
'pane.terminal': '终端',
'pane.custom': '自定义',
'pane.customUrlPh': '地址(/xxx/ 或 https://)',
'pane.open': '打开',
'custom.title': '自定义这个窗口',
'custom.hint': '发送后将自动新建一个会话,专门负责这个窗口的自定义设计。',
'custom.placeholder': '告诉 AI 你想让这个窗口做什么,例如:做一个展示我旅行足迹的时间线页面…',
'custom.project': '所属项目',
'custom.send': '发送并新建对话',
'custom.done': '已新建专属对话',
'custom.doneHint': 'AI 正在新会话中为你设计这个窗口。关闭本标签即可返回窗口选择。',
'custom.fail': '创建对话失败',
'pane.wip': '开发中(后续版本接入)',
'pane.explorerNoCwd': '当前会话没有工作目录',
'pane.openLater': '该类型暂不支持预览',
@@ -141,8 +147,14 @@ export const en = {
'pane.tasks': 'Tasks',
'pane.terminal': 'Terminal',
'pane.custom': 'Custom',
'pane.customUrlPh': 'URL (/xxx/ or https://)',
'pane.open': 'Open',
'custom.title': 'Customize this pane',
'custom.hint': 'Sending will create a new conversation dedicated to designing this pane.',
'custom.placeholder': 'Tell the AI what you want this pane to do, e.g. a timeline page of my travels…',
'custom.project': 'Project',
'custom.send': 'Send & create conversation',
'custom.done': 'Conversation created',
'custom.doneHint': 'The AI is designing your pane in the new conversation. Close this tab to return to the pane picker.',
'custom.fail': 'Failed to create conversation',
'pane.wip': 'Coming in a later version',
'pane.explorerNoCwd': 'No working directory for the current session',
'pane.openLater': 'Preview not supported for this type',
+101 -35
View File
@@ -23,7 +23,7 @@ hljs.registerLanguage('json', hljsJson)
* 内容与 chatSide 的变更经 onSpecMutated 回调交给工作台持久化(布局条目)。
*/
export type BuiltinType = 'browser' | 'explorer' | 'scm' | 'tasks' | 'terminal'
export type BuiltinType = 'browser' | 'explorer' | 'scm' | 'tasks' | 'terminal' | 'custom'
export type SplitContent =
| { kind: 'iframe'; url: string; title?: string }
@@ -125,6 +125,7 @@ const BUILTIN_ICONS: Record<BuiltinType, string> = {
scm: '🔀',
tasks: '✅',
terminal: '▸_',
custom: '✨',
}
const BUILTIN_LABEL_KEYS: Record<BuiltinType, string> = {
@@ -133,6 +134,7 @@ const BUILTIN_LABEL_KEYS: Record<BuiltinType, string> = {
scm: 'pane.scm',
tasks: 'pane.tasks',
terminal: 'pane.terminal',
custom: 'pane.custom',
}
function tabTitleOf(content: SplitContent): string {
@@ -183,6 +185,12 @@ type SplitEnv = {
getScope: () => SplitScope | null
getJobs: () => SplitJob[]
getSubagents: () => any[]
/** 自定义窗口:项目列表 + 当前项目 + 提交创建专属会话 */
custom?: {
getProjects: () => { id: string; name: string }[]
currentProjectId: () => string | null
submit: (projectId: string, projectName: string, requirement: string) => Promise<void>
}
}
let splitEnv: SplitEnv | null = null
export function setSplitEnv(env: SplitEnv | null) {
@@ -1235,6 +1243,85 @@ function TextViewer(props: { path: string; fileUrl: string; isMd: boolean }) {
)
}
/** 自定义窗口:居中对话框。用户写下需求 + 选择所属项目 → 发送 = 新建专属会话并由 AI 辅助设计。 */
function CustomPane() {
const custom = splitEnv?.custom
const [requirement, setRequirement] = useState('')
const [projectId, setProjectId] = useState<string | null>(null)
const [projects, setProjects] = useState<{ id: string; name: string }[]>(() => custom?.getProjects?.() ?? [])
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
const [fail, setFail] = useState('')
// 默认项目 = 当前工作区所属项目(用户刚在用的项目)
useEffect(() => {
const list = custom?.getProjects?.() ?? []
setProjects(list)
const cur = custom?.currentProjectId?.() ?? null
setProjectId(cur && list.some((p) => p.id === cur) ? cur : (list[0]?.id ?? null))
}, [custom])
const submit = async () => {
const text = requirement.trim()
if (!text || !projectId || !custom) return
setBusy(true)
setFail('')
try {
const proj = projects.find((p) => p.id === projectId)
await custom.submit(projectId, proj?.name ?? projectId, text)
setDone(true)
} catch (e) {
setFail(String(e))
} finally {
setBusy(false)
}
}
if (!custom) {
return <div className="dsh-wt_paneWip"><span className="dsh-wt_paneWipText">{T('pane.wip')}</span></div>
}
if (done) {
return (
<div className="dsh-wt_customBox">
<span className="dsh-wt_customDone" aria-hidden>✅</span>
<p className="dsh-wt_customDoneText">{T('custom.done')}</p>
<p className="dsh-wt_customDoneHint">{T('custom.doneHint')}</p>
</div>
)
}
return (
<div className="dsh-wt_customBox">
<div className="dsh-wt_customCard">
<span className="dsh-wt_customTitle">✨ {T('custom.title')}</span>
<p className="dsh-wt_customHint">{T('custom.hint')}</p>
<textarea
className="dsh-wt_customInput"
autoFocus
placeholder={T('custom.placeholder')}
value={requirement}
onChange={(e) => setRequirement(e.target.value)}
/>
<div className="dsh-wt_customRow">
<span className="dsh-wt_customLabel">{T('custom.project')}</span>
<select
className="dsh-wt_customSelect"
value={projectId ?? ''}
onChange={(e) => setProjectId(e.target.value)}
>
{projects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<button
type="button"
className="dsh-wt_customSend"
disabled={busy || !requirement.trim() || !projectId}
onClick={submit}
>{busy ? '…' : T('custom.send')}</button>
{fail && <p className="dsh-wt_customFail">{T('custom.fail')}:{fail}</p>}
</div>
</div>
)
}
/** 单个标签页的内容渲染 */
function PaneTabBody(props: { tab: PaneTab; row: PaneRow; index: number }) {
const content = props.tab.content
@@ -1249,6 +1336,7 @@ function PaneTabBody(props: { tab: PaneTab; row: PaneRow; index: number }) {
if (content.type === 'scm') return <GitPane />
if (content.type === 'tasks') return <JobsPane />
if (content.type === 'terminal') return <TerminalPane />
if (content.type === 'custom') return <CustomPane />
return (
<div className="dsh-wt_paneWip">
<span className="dsh-wt_paneWipIcon" aria-hidden>{BUILTIN_ICONS[content.type]}</span>
@@ -1296,8 +1384,6 @@ function PaneBody(props: { pane: SplitPane; row: PaneRow; index: number }) {
/** 未指派内容:4 选 1 选择器。按钮固定大小、整体居中;
* 按窗位宽高比自适应排列:宽窗横排 4 连 / 方窗 2×2 / 竖窗竖排。 */
function PanePicker(props: { row: PaneRow; index: number }) {
const [custom, setCustom] = useState(false)
const [url, setUrl] = useState('')
const [mode, setMode] = useState<'row' | 'grid' | 'col'>('grid')
const hostRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
@@ -1315,40 +1401,20 @@ function PanePicker(props: { row: PaneRow; index: number }) {
return () => ro.disconnect()
}, [])
const pick = (content: SplitContent) => splitStore.openTab(props.row, props.index, content)
const applyCustom = () => {
const u = url.trim()
if (/^(\/|https?:\/\/)/i.test(u)) pick({ kind: 'iframe', url: u })
}
return (
<div ref={hostRef} className={'dsh-wt_panePicker dsh-wt_panePicker-' + mode}>
{custom ? (
<div className="dsh-wt_paneCustom">
<input
autoFocus
type="text"
placeholder={T('pane.customUrlPh')}
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') applyCustom() }}
/>
<button type="button" className="dsh-wt_paneCustomGo" onClick={applyCustom}>{T('pane.open')}</button>
</div>
) : (
<>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'browser' })}>
<span aria-hidden>🌐</span>{T('pane.browser')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'explorer' })}>
<span aria-hidden>📁</span>{T('pane.explorer')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'terminal' })}>
<span aria-hidden>▸_</span>{T('pane.terminal')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => setCustom(true)}>
<span aria-hidden>✨</span>{T('pane.custom')}
</button>
</>
)}
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'browser' })}>
<span aria-hidden>🌐</span>{T('pane.browser')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'explorer' })}>
<span aria-hidden>📁</span>{T('pane.explorer')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'terminal' })}>
<span aria-hidden>▸_</span>{T('pane.terminal')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'custom' })}>
<span aria-hidden>✨</span>{T('pane.custom')}
</button>
</div>
)
}
+18
View File
@@ -189,6 +189,24 @@ export const css = [
'.dsh-wt_panePicker-row{flex-direction:row;flex-wrap:wrap}',
'.dsh-wt_panePicker-grid{display:grid;grid-template-columns:auto auto;grid-template-rows:auto auto;gap:10px;place-content:center}',
'.dsh-wt_panePicker-col{flex-direction:column}',
// 自定义窗口对话框(居中卡片)
'.dsh-wt_customBox{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:14px;overflow:auto}',
'.dsh-wt_customCard{display:flex;flex-direction:column;gap:10px;width:100%;max-width:360px;padding:14px;border:1px solid var(--dsw-alias-border-l2,#3a4150);border-radius:10px;background:var(--dsw-alias-bg-base,#0b0e14);box-shadow:var(--dsw-shadow-lv2,0 8px 24px rgba(0,0,0,.4))}',
'.dsh-wt_customTitle{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,#e6e8eb)}',
'.dsh-wt_customHint{font-size:11.5px;line-height:17px;color:var(--dsw-alias-label-secondary,#9aa4b2);margin:0}',
'.dsh-wt_customInput{width:100%;box-sizing:border-box;min-height:96px;padding:8px 10px;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:12px;line-height:18px;outline:none;resize:vertical}',
'.dsh-wt_customInput:focus{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
'.dsh-wt_customRow{display:flex;align-items:center;gap:8px}',
'.dsh-wt_customLabel{flex:none;font-size:11.5px;color:var(--dsw-alias-label-secondary,#9aa4b2)}',
'.dsh-wt_customSelect{flex:1;min-width:0;padding:5px 8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;outline:none}',
'.dsh-wt_customSend{align-self:flex-end;padding:6px 14px;border:none;border-radius:6px;background:var(--dsw-alias-state-accent-primary,#4f8ef7);color:#fff;font:inherit;font-size:12px;cursor:pointer}',
'.dsh-wt_customSend:disabled{opacity:.55;cursor:default}',
'.dsh-wt_customFail{font-size:11px;color:var(--dsw-alias-state-danger,#e5484d);margin:0}',
'.dsh-wt_customDone{font-size:26px;line-height:32px}',
'.dsh-wt_customDoneText{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,#e6e8eb);margin:4px 0 0}',
'.dsh-wt_customDoneHint{font-size:11.5px;color:var(--dsw-alias-label-secondary,#9aa4b2);margin:0}',
'.dsh-wt_customDone{display:block}',
'.dsh-wt_customBox:has(.dsh-wt_customDone){flex-direction:column;gap:4px;text-align:center}',
'.dsh-wt_panePick{flex:none;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;width:92px;height:78px;padding:8px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:10px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02));color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:11px;line-height:15px;cursor:pointer;transition:border-color .15s,background .15s,transform .15s}',
'.dsh-wt_panePick:hover{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05));transform:translateY(-1px)}',
'.dsh-wt_panePick span{font-size:22px;line-height:24px}',
+19 -13
View File
@@ -639,17 +639,23 @@
原生视图内部是浏览器扩展页,插件注入不了按钮、拦不到鼠标,也驱动不了内部滚动——
向用户如实说明后按指示回退);② 视图选项改名「设置」,点开直接内嵌「排序方式 + 管理项目
展开列表」,不再二次点击;③ 管理项目里给现有项目加「变更视图」:换预设拓扑,
## 补记(网页 iframe 内部滚动位置保活)
## 补记(自定义窗口:需求对话框 + 新建专属会话 + 项目选择 + 上下文注入)
- 用户反馈:MD 位置已能记忆,网页(iframe)内部滚动位置切走再切回仍丢。
- 根因:保活池用 display:none 隐藏 → iframe 视口被压成 0×0,其内部文档滚动被钳制清零;
MD 等普通 div 不受影响,iframe 受影响。
- 处理:保活包装器 display:none → visibility:hidden(保留布局尺寸,内部滚动不丢;
不可见即不可交互,无副作用)。fixture-site 加高 body 用于滚动测试。
- 测试事故(二次):visibility 保活让隐藏层保留尺寸,回归脚本里 height>0 的可见性筛选
全部失效 → STEP16 再次误点隐藏层保存按钮写坏 styles.ts(已 git 恢复)。修复:
① 所有可见性筛选改 getComputedStyle(visibility)!=='hidden';
② STEP16 保存前 fail-safe 校验激活标签目标路径必须含 wt-edit-test.md 才点击保存。
- 验证(STEP19 + 探针):iframe contentWindow.scrollTo(0,400) → 切走 → 切回 scrollY=400;
全 19 STEP 回归 ERROR_COUNT: 0。
- 备注:纯客户端改动,F5 即生效。checkpoint tag: checkpoint-2026-08-17-pre-iframescroll。
- 用户需求:点「自定义」→ 新开标签(关标签可回退)→ 居中对话框:写需求 + 选所属项目
(默认 = 刚在用的项目)+ 提示发送后将新建一个对话 → 发送 = 自动新建会话,
会话需知道:worktable 是什么(自建工具,官方文档没有)、本对话是解决一个窗口的自定义、
用户需求原文、窗口位于哪个项目(便于窗口级与项目级后续自定义)。
- 实现:
① BuiltinType 新增 custom;PanePicker ✨ 直接 openTab 自定义标签(旧内联 URL 表单移除);
② CustomPane:居中卡片对话框(需求 textarea + 项目 select + 提示文案 + 发送按钮),
默认项目 = splitStore.spec.id(当前工作区所属项目),不在列表时回退第一个;发送成功后 ✓ 态;
③ 会话创建桥:inject 增加 'conversation'(探针实测 sessions.create/open、conversation.send/
sendSession 可用;api 不可注入已排除);createCustomSession:sessions.create({}) → 优先
conversation.sendSession(sessionId, text) 否则 open 后 send;注入上下文消息:
「worktable 是什么 / 本对话用途 / 用户需求 / 所在项目」四段式;
④ WorktableSection 经 projectsRef 快照向引擎注入 custom.getProjects/currentProjectId/submit;
locale custom.* 八键 + 对话框 CSS。
- 验证(functional-diag STEP20,仅 UI 不点发送防真实建会话):tabType=custom、对话框/
textarea/提示语/项目下拉/发送禁用(空需求)全部正确;全 20 STEP ERROR_COUNT: 0。
真实建会话闭环需用户在 GUI 实测。
- 备注:纯客户端改动,F5 即生效。checkpoint tag: checkpoint-2026-08-17-pre-customdialog。
+28
View File
@@ -637,6 +637,34 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
})()`);
console.log('STEP19:', JSON.stringify(step19));
// 自定义窗口:点 ✨ → 新标签打开居中对话框;默认项目 = 当前项目(仅测 UI,不点发送以免真实建会话)
const step20 = await evaluate(`(async function(){
var out={};
try{
var st=window.__dshWorktable.splitStore;
var vis=function(sel){ return [].slice.call(document.querySelectorAll(sel)).find(function(el){ return (getComputedStyle(el).visibility!=='hidden'&&el.getBoundingClientRect().height>0); }); };
st.open({id:'t-custom',title:'c',top:null,main:[{id:'p1',title:'p',min:200,content:null}],chatWidth:{default:320,min:240,max:600}});
await new Promise(function(r){setTimeout(r,400)});
var pick=[].slice.call(document.querySelectorAll('.dsh-wt_panePick')).find(function(el){ var t=el.textContent||''; return (t.indexOf('\u81ea\u5b9a\u4e49')>=0||t.indexOf('Custom')>=0) && (getComputedStyle(el).visibility!=='hidden'); });
if(pick){ pick.click(); }
await new Promise(function(r){setTimeout(r,400)});
out.tabOpened=st.spec.main[0].tabs.length===1;
out.tabType=st.spec.main[0].tabs[0].content.type;
out.dialog=!!vis('.dsh-wt_customCard');
out.textarea=!!vis('.dsh-wt_customInput');
var hint=vis('.dsh-wt_customHint');
out.hint=hint?String(hint.textContent).slice(0,26):null;
var sel=vis('.dsh-wt_customSelect');
out.selectValue=sel?sel.value:null;
out.defaultIsCurrent=sel?sel.value==='t-custom':false;
var send=vis('.dsh-wt_customSend');
out.sendDisabled=send?send.disabled:null;
st.close();
}catch(err){ out.err=String(err) }
return JSON.stringify(out);
})()`);
console.log('STEP20:', JSON.stringify(step20));
// 还原临时夹具
try { fs.writeFileSync(TEMP_MD, '# ORIG\n', 'utf8') } catch {}