長回應結束後自動產生 Mermaid 流程圖:cmux 瀏覽器窗格顯示彩色圖,終端機面板顯示彩色摘要

兩個 Claude Code mod(function hooks 外掛),在 Claude Code 自己的介面裡運作。
| mod | 功能 | 指令 | ||
|---|---|---|---|---|
| flowmap | 長回應結束後由 Haiku 判斷是否值得畫圖,值得就由主模型產生 Mermaid 圖,顯示在 cmux 瀏覽器窗格;預設不顯示,提示框上方(VS Code 擴充套件改用通知)會問要不要看,按「看圖解」才打開;圖解頁面可以編輯 Mermaid 語法重畫,並匯出 SVG、PNG、Mermaid 原始碼 | /flow、/flow hide、`/flow on\ | off、/flow web on\ | off` |
| gitgraph | 仿 GitHub Desktop 的提交列表,可同時勾選多條分支比對 | /branches、/branches 分支 分支、/branches all、/branches hide |
~/.claude/mods: git clone https://github.com/TCcodemaster/claude-mods ~/.claude/mods
~/.claude/settings.json 加上(一定要放在家目錄的設定檔,專案的設定檔不會生效): "env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/flowmap:~/.claude/mods/gitgraph",
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
第二行是強制開啟 mod 功能。mod 目前是逐步開放的早期功能,開關狀態快取在本機,偶爾會被存成關閉,導致新對話完全不載入 mod。
code --install-extension ~/.claude/mods/vscode-viewer/flowmap-viewer.vsix
每次開 Claude Code 對話時,flowmap 會在背景對這個 repo 跑 git pull --ff-only,有更新會跳提示。本機有未推送的提交或衝突時會跳過,不會動任何檔案。VS Code 檢視器(.vsix)不會自動重裝,更新後要再跑一次安裝步驟 3。
| 執行環境 | 顯示位置 |
|---|---|
| cmux | cmux 右邊的瀏覽器窗格 |
| VS Code,有裝檢視器 | VS Code 旁邊一欄 |
| VS Code,沒裝檢視器 | 用 mermaid-cli 與 Chrome 渲染成 PNG,在 VS Code 開啟 |
| 其他 | 不另外開視窗,輸入 /flow 在面板裡看 |
claude plugin validate flowmap && claude plugin test flowmap
claude plugin validate gitgraph && claude plugin test gitgraphhooks/register.tsx 452 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { FlowMap, FlowView } from '../types'
5import { BOOT_HTML, BOOT_READY, bootScripts, buildHtml, checkFlow, CMUX_LIMIT, findPane, JUDGE, mermaidForImage, parseReply, parseSurface, renderScripts, SYSTEM, TERMINAL_COLORS, toDataUrl } from './flow'
6import type { Parsed } from './flow'
7
8const PANE = 'flowmap'
9const TITLE = '圖解'
10const map = atom({ plugin: 'flowmap', key: 'map' } as const, null)
11const isBusy = atom({ plugin: 'flowmap', key: 'isBusy' } as const, false)
12const isAuto = atom({ plugin: 'flowmap', key: 'isAuto' } as const, true)
13const isWeb = atom({ plugin: 'flowmap', key: 'isWeb' } as const, true)
14const DIAGRAM_NAMES: Record<string, string> = {
15 mindmap: '心智圖',
16 sequenceDiagram: '時序圖',
17 'stateDiagram-v2': '狀態圖',
18 stateDiagram: '狀態圖',
19 timeline: '時間軸',
20 quadrantChart: '象限圖',
21 erDiagram: '資料表關聯圖',
22}
23const browser = atom({ plugin: 'flowmap', key: 'browser' } as const, null)
24// 存在對話狀態裡,mod 重新載入後 /flow 仍找得到上一則回應。
25const lastAnswer = atom({ plugin: 'flowmap', key: 'lastAnswer' } as const, '')
26// 提示框上方的按鈕依這個狀態決定要顯示「隱藏」還是「畫這則」。
27const view = atom({ plugin: 'flowmap', key: 'view' } as const, null)
28
29// 太短的回應直接略過,不必花一次 Haiku 判斷。
30const MIN_CHARS = 300
31
32// 由 Haiku 依內容判斷值不值得畫圖,只回答「是」或「否」。
33async function isWorthDrawing($: EngineInterface, answer: string): Promise<boolean> {
34 if (answer.length < MIN_CHARS) return false
35 const reply = await $.model.complete({
36 model: 'haiku',
37 system: JUDGE,
38 prompt: `<回應>\n${answer.slice(0, 12000)}\n</回應>\n\n這則回應值得附一張示意圖嗎?只回答「是」或「否」。`,
39 maxTokens: 8,
40 effort: 'low',
41 timeoutMs: 15000,
42 })
43 return reply.isAnswered && reply.text.trim().startsWith('是')
44}
45
46// 把頁面送進 cmux 的瀏覽器窗格;窗格被關掉就重開一個。
47// 頁面直接編進 data URL,不寫檔:SSH 到遠端時,本機的 cmux 瀏覽器讀不到伺服器上的檔案。
48// 只用本機版與遠端版 cmux 都認得的參數,輸出則兩種格式都接受。
49// 已經開著的窗格不用 navigate 換網址(cmux 會把 data URL 當成搜尋字詞),
50// 改用 browser.eval 呼叫頁面內建的換圖函式。
51// 遠端版 cmux 單次請求有上限(見 CMUX_LIMIT):整頁塞得下就一次開,塞不下就先開啟動頁再把整頁分段送進去。
52async function showInCmux($: EngineInterface, html: string, scripts: string[]): Promise<void> {
53 // 不在 cmux 裡就不開任何瀏覽器,圖只留在 /flow 面板。
54 if (!(await $.env.get('CMUX_WORKSPACE_ID'))) return
55 const cmux = (await $.env.get('CMUX_BUNDLED_CLI_PATH')) ?? 'cmux'
56 const evaluate = (surface: string, script: string) =>
57 $.process.run([cmux, 'rpc', 'browser.eval', JSON.stringify({ surface_id: surface, script })])
58
59 // 記住的窗格只在這個工作階段有效;重開 Claude Code 或另開對話時,改找工作區裡已經開著的圖解分頁。
60 const swap = async (surface: string): Promise<boolean> => {
61 // 遠端版 cmux 的 browser 子指令不收窗格參數,換圖改走兩邊都支援的 rpc。
62 for (const script of scripts) {
63 const ran = await evaluate(surface, script)
64 if (ran.exitCode !== 0) return false
65 if (script === scripts.at(-1) && !ran.stdout.includes('flowmap-ok')) return false
66 }
67 return true
68 }
69 const current = await read($, browser)
70 if (current && (await swap(current))) return
71 const listed = await $.process.run([cmux, '--json', 'list-panels'])
72 const found = listed.exitCode === 0 ? findPane(listed.stdout) : null
73 if (found && found !== current && (await swap(found))) {
74 await update($, browser, () => found)
75 return
76 }
77 const url = toDataUrl(html)
78 const fits = url.length <= CMUX_LIMIT
79 const opened = await $.process.run([cmux, 'browser', 'open-split', fits ? url : toDataUrl(BOOT_HTML)])
80 const surface = parseSurface(opened.stdout)
81 if (opened.exitCode !== 0 || !surface) {
82 $.ui.toast(`圖解:無法開啟 cmux 瀏覽器窗格(${(opened.stderr || opened.stdout).trim() || opened.exitCode})`)
83 return
84 }
85 await update($, browser, () => surface)
86 if (fits) return
87 // 啟動頁要先載入完才認得接收函式;最多等 5 秒。
88 for (let tries = 0; tries < 20; tries += 1) {
89 const probe = await evaluate(surface, BOOT_READY)
90 if (probe.exitCode === 0 && probe.stdout.includes('function')) {
91 const pages = bootScripts(html)
92 for (const script of pages) {
93 const ran = await evaluate(surface, script)
94 if (ran.exitCode !== 0 || (script === pages.at(-1) && !ran.stdout.includes('flowmap-ok'))) {
95 $.ui.toast('圖解:頁面開了但送圖失敗,輸入 /flow 再試一次')
96 return
97 }
98 }
99 return
100 }
101 await new Promise(resolve => setTimeout(resolve, 250))
102 }
103 $.ui.toast('圖解:頁面載入逾時,輸入 /flow 再試一次')
104}
105
106const CODE_CLI = '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code'
107const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
108
109// 在 VS Code 裡(擴充套件或內建終端機):用 mermaid-cli 借 Chrome 渲染成 PNG,
110// 再用 VS Code 的圖片檢視器打開。檔名固定,已開著的分頁會自動換成新圖。
111// 少了 mermaid-cli 或 Chrome 就回傳 false。
112async function showInVSCode($: EngineInterface, source: string): Promise<boolean> {
113 const home = await $.env.get('HOME')
114 if (!home) return false
115 const dir = `${home}/.claude/flowmap`
116 await $.fs.write(`${dir}/latest.mmd`, source)
117 await $.fs.write(`${dir}/puppeteer.json`, JSON.stringify({ executablePath: CHROME, headless: 'new' }))
118 // VS Code 擴充套件的 PATH 可能沒有 Homebrew,mermaid-cli 需要找得到 node。
119 const path = `/opt/homebrew/bin:/usr/local/bin:${(await $.env.get('PATH')) ?? '/usr/bin:/bin'}`
120 try {
121 const drawn = await $.process.run(
122 ['mmdc', '-q', '-p', `${dir}/puppeteer.json`, '-i', `${dir}/latest.mmd`, '-o', `${dir}/latest.png`, '-s', '2', '-b', 'white'],
123 { env: { PATH: path }, timeoutMs: 60000 },
124 )
125 if (drawn.exitCode !== 0) return false
126 const opened = await $.process.run([CODE_CLI, '-r', `${dir}/latest.png`], { env: { PATH: path } })
127 return opened.exitCode === 0
128 } catch {
129 return false
130 }
131}
132
133// 裝了 vscode-viewer 擴充套件時,寫出網頁讓它在旁邊一欄顯示,不需要 Chrome。
134// 網頁給第一次開啟用;之後檢視器讀 json,用 postMessage 換圖,頁面保留上一張、下一張的紀錄。
135async function showInViewer($: EngineInterface, html: string, data: { summary: string; mermaid: string; stamp: string }, suffix = ''): Promise<boolean> {
136 const home = await $.env.get('HOME')
137 if (!home) return false
138 const installed = await $.fs
139 .list(`${home}/.vscode/extensions`)
140 .then(entries => entries.some(entry => entry.name.startsWith('tccodemaster.flowmap-viewer-')))
141 .catch(() => false)
142 if (!installed) return false
143 const pid = (await $.env.get('VSCODE_PID')) ?? 'default'
144 await $.fs.write(`${home}/.claude/flowmap/vscode-${pid}${suffix}.html`, html)
145 await $.fs.write(`${home}/.claude/flowmap/vscode-${pid}${suffix}.json`, JSON.stringify({ type: 'flowmap', ...data }))
146 return true
147}
148
149async function isVSCode($: EngineInterface): Promise<boolean> {
150 return (await $.env.get('CLAUDE_CODE_ENTRYPOINT')) === 'claude-vscode' || (await $.env.get('TERM_PROGRAM')) === 'vscode'
151}
152
153// 畫圖不用 fork:fork 沿用對話的 effort,主模型想很久。改用 Opus 低 effort 只看回應原文,
154// 用 claude -p 量同一則回應(含啟動時間):Opus 高 effort 約 19 秒,低 effort 約 8 秒。
155async function draw($: EngineInterface, extra: string): Promise<Parsed | string> {
156 const reply = await $.model.complete({
157 model: 'opus',
158 effort: 'low',
159 system: SYSTEM,
160 prompt: `<回應>\n${(await read($, lastAnswer)).slice(0, 20000)}\n</回應>\n\n請把這則回應整理成一張 Mermaid 圖。${extra}`,
161 timeoutMs: 60000,
162 })
163 if (!reply.isAnswered) return `圖解產生失敗:${reply.reason}`
164 return parseReply(reply.text) ?? '圖解產生失敗:模型沒有回傳 Mermaid 圖'
165}
166
167// 只更新同一則回應的狀態,避免舊回應的結果蓋掉新回應。
168async function setView($: EngineInterface, turn: number, state: FlowView['state']): Promise<void> {
169 await update($, view, current => (current && current.turn !== turn ? current : { turn, state }))
170}
171
172// 把圖送到外部檢視器(cmux 瀏覽器窗格或 VS Code);有送出去就回傳 true。
173async function display($: EngineInterface, shown: FlowMap): Promise<boolean> {
174 if (!(await read($, isWeb))) return false
175 const inCmux = Boolean(await $.env.get('CMUX_WORKSPACE_ID'))
176 const stamp = new Date(shown.at).toLocaleString('zh-TW', { hour12: false })
177 const html = buildHtml(shown.summary, shown.mermaid, stamp)
178 if (!inCmux && (await isVSCode($))) {
179 const data = { summary: shown.summary.replace(/^摘要[::]\s*/, ''), mermaid: shown.mermaid, stamp }
180 if (await showInViewer($, html, data)) return true
181 if (await showInVSCode($, mermaidForImage(shown.summary, shown.mermaid, shown.diagram))) return true
182 return false
183 }
184 await showInCmux($, html, renderScripts(shown.summary, shown.mermaid, stamp))
185 return inCmux
186}
187
188// 正在產生的回應,避免同一則重複請模型畫。
189const drawing = new Set<number>()
190
191async function reveal($: EngineInterface, shown: FlowMap): Promise<void> {
192 await setView($, shown.turn, 'shown')
193 if (!(await display($, shown))) await $.ui.open({ id: PANE, title: TITLE })
194}
195
196// 收起圖解:關掉終端機面板,cmux 裡一併關掉瀏覽器窗格,按鈕回到詢問狀態。
197async function hide($: EngineInterface, turn: number): Promise<void> {
198 await setView($, turn, 'offer')
199 await $.ui.close({ id: PANE })
200 const surface = await read($, browser)
201 if (!surface || !(await $.env.get('CMUX_WORKSPACE_ID'))) return
202 const cmux = (await $.env.get('CMUX_BUNDLED_CLI_PATH')) ?? 'cmux'
203 // 遠端版 cmux 不一定有 close-surface 子指令,改走兩邊都支援的 rpc。
204 await $.process.run([cmux, 'rpc', 'surface.close', JSON.stringify({ workspace_id: await $.env.get('CMUX_WORKSPACE_ID'), surface_id: surface })])
205 await update($, browser, () => null)
206}
207
208// 看圖解:背景已經畫好就直接顯示,還沒畫好就等產生完自動顯示。
209async function show($: EngineInterface, turn: number): Promise<void> {
210 const current = await read($, map)
211 if (current?.turn === turn) {
212 await reveal($, current)
213 return
214 }
215 await setView($, turn, 'wanted')
216 await generate($, turn)
217}
218
219async function generate($: EngineInterface, turn: number): Promise<void> {
220 if (drawing.has(turn)) return
221 drawing.add(turn)
222 await update($, isBusy, () => true)
223 let parsed = await draw($, '')
224 // 檢查出問題就把問題清單交回去重畫一次;第二次還有問題就照用。
225 const problems = typeof parsed === 'string' ? [] : checkFlow(parsed)
226 if (typeof parsed !== 'string' && problems.length > 0) {
227 const retry = await draw(
228 $,
229 `\n\n你剛才畫的圖:\n\`\`\`mermaid\n${parsed.mermaid}\n\`\`\`\n\n有以下問題,請修正後重畫:\n${problems.map(p => `- ${p}`).join('\n')}`,
230 )
231 if (typeof retry !== 'string') parsed = retry
232 }
233 await update($, isBusy, () => false)
234 drawing.delete(turn)
235 const latest = await read($, view)
236 const isWanted = latest?.turn === turn && latest.state === 'wanted'
237 if (typeof parsed === 'string') {
238 if (isWanted) {
239 await setView($, turn, 'offer')
240 $.ui.toast(parsed)
241 }
242 return
243 }
244 const at = await $.clock.now()
245 const next: FlowMap = { ...parsed, at, turn }
246 await update($, map, () => next)
247 // 預設不顯示,只有使用者按了「看圖解」才送出去。
248 if (isWanted) await reveal($, next)
249 else await offerInVSCode($, next)
250}
251
252// VS Code 擴充套件沒有提示框上方的位置,改寫出 -offer 檔,由 flowmap-viewer 跳出原生通知詢問要不要看。
253async function offerInVSCode($: EngineInterface, drawn: FlowMap): Promise<void> {
254 if ((await $.env.get('CLAUDE_CODE_ENTRYPOINT')) !== 'claude-vscode' || !(await read($, isWeb))) return
255 const stamp = new Date(drawn.at).toLocaleString('zh-TW', { hour12: false })
256 const data = { summary: drawn.summary.replace(/^摘要[::]\s*/, ''), mermaid: drawn.mermaid, stamp }
257 await showInViewer($, buildHtml(drawn.summary, drawn.mermaid, stamp), data, '-offer')
258}
259
260function isCardShown(current: FlowView | null): current is FlowView {
261 return current !== null && current.state !== 'judging' && current.state !== 'skipped'
262}
263
264// 圓角卡片:左邊標籤,右邊問句與按鈕。按鈕帶數字快捷鍵,提示框是空的時候直接按 1、2 就能選。
265function drawCard($: EngineInterface, ui: Pick<Elements['vscode'], 'Box' | 'Button' | 'Text'>, current: FlowView) {
266 const { Box, Button, Text } = ui
267 const turn = current.turn
268 const message = {
269 offer: '要看這則回應的圖解嗎?',
270 wanted: '圖解產生中,完成後會自動打開。',
271 shown: '圖解已經打開在右側。',
272 }[current.state as 'offer' | 'wanted' | 'shown']
273 const actions =
274 current.state === 'shown'
275 ? [{ key: 'flowmap-hide', label: '收起', run: () => hide($, turn) }]
276 : current.state === 'wanted'
277 ? [{ key: 'flowmap-cancel', label: '取消', run: () => setView($, turn, 'offer') }]
278 : [
279 { key: 'flowmap-show', label: '看圖解', run: () => show($, turn) },
280 { key: 'flowmap-dismiss', label: '不用', run: () => setView($, turn, 'skipped') },
281 ]
282 return (
283 <Box borderStyle="round" borderColor={TERMINAL_COLORS.start} paddingX={1} gap={2} alignSelf="flex-start">
284 <Text color={TERMINAL_COLORS.start} bold>
285 ◆ 圖解
286 </Text>
287 <Box flexDirection="column">
288 <Text>{message}</Text>
289 <Box gap={1}>
290 {actions.flatMap((action, i) => [
291 ...(i > 0 ? [<Text dimColor>·</Text>] : []),
292 <Button key={action.key} label={action.label} hotkey={String(i + 1)} plain onPress={action.run} />,
293 ])}
294 </Box>
295 </Box>
296 </Box>
297 )
298}
299
300// 每次開對話時把 mods repo 拉到最新,push 之後各台機器下次開對話就會更新。
301// 只做快轉合併,本機有未推送的提交或衝突就跳過,不動任何檔案。資料夾受監看,拉下來的改動會自動重新載入。
302async function pullLatest($: EngineInterface): Promise<void> {
303 const git = (args: string[]) => $.process.run(['git', '-C', $.plugin.root, ...args], { env: { GIT_TERMINAL_PROMPT: '0' }, timeoutMs: 30000 }).catch(() => null)
304 const before = await git(['rev-parse', 'HEAD'])
305 if (!before || before.exitCode !== 0) return
306 const pulled = await git(['pull', '--ff-only', '--quiet'])
307 if (!pulled || pulled.exitCode !== 0) return
308 const after = await git(['rev-parse', 'HEAD'])
309 if (after && after.stdout.trim() !== before.stdout.trim()) $.ui.toast('claude-mods 已更新到最新版。')
310}
311
312export const register: Register = on => {
313 on('session.start', async ($, e, next) => {
314 $.clock.after(0, () => {
315 void pullLatest($)
316 })
317 await $.command.register({
318 name: 'flow',
319 description: '圖解:/flow 畫上一則並打開;/flow hide 收起;/flow on|off 自動產生;/flow web on|off 瀏覽器窗格',
320 })
321 return next(e)
322 })
323
324 on('command.run', { command: 'flow' }, async ($, e) => {
325 const arg = e.args.trim()
326 if (arg === 'on' || arg === 'off') {
327 await update($, isAuto, () => arg === 'on')
328 return { text: arg === 'on' ? '已開啟自動產生流程圖。' : '已關閉自動產生流程圖。' }
329 }
330 if (arg === 'web on' || arg === 'web off') {
331 await update($, isWeb, () => arg === 'web on')
332 return { text: arg === 'web on' ? '流程圖會顯示在 cmux 瀏覽器窗格。' : '流程圖只顯示在終端機面板。' }
333 }
334 if (arg === 'hide') {
335 await $.ui.close({ id: PANE })
336 return { text: '已收起圖解面板,輸入 /flow 可再打開。' }
337 }
338 await $.ui.open({ id: PANE, title: TITLE })
339 if ((await read($, lastAnswer)) === '') return { text: '目前還沒有可以整理的回應。' }
340 const turn = (await read($, view))?.turn ?? (await $.clock.now())
341 $.clock.after(0, () => {
342 void show($, turn)
343 })
344
345 return { text: '正在重畫上一則回應的流程圖。' }
346 })
347
348 on('turn.complete', async ($, e, next) => {
349 const result = await next(e)
350 if (e.agentId || e.reason !== 'answer' || !e.answer) return result
351 await update($, lastAnswer, () => e.answer)
352 const turn = await $.clock.now()
353 // 太短的回應不出現按鈕,要畫還是可以輸入 /flow。
354 if (e.answer.length < MIN_CHARS) {
355 await update($, view, () => null)
356 return result
357 }
358 // 自動模式先由 Haiku 判斷,值得畫才詢問並在背景先畫好;關閉時每則長回應都直接詢問。
359 if (!(await read($, isAuto))) {
360 await update($, view, () => ({ turn, state: 'offer' as const }))
361 return result
362 }
363 await update($, view, () => ({ turn, state: 'judging' as const }))
364 const answer = e.answer
365 // 判斷與產生都交給計時器跑,不卡住回合結束。
366 $.clock.after(0, () => {
367 void (async () => {
368 if (!(await isWorthDrawing($, answer))) {
369 await update($, view, (current): FlowView | null => (current?.turn === turn && current.state === 'judging' ? { turn, state: 'skipped' } : current))
370 return
371 }
372 await update($, view, (current): FlowView | null => (current?.turn === turn && current.state === 'judging' ? { turn, state: 'offer' } : current))
373 await generate($, turn)
374 })()
375 })
376
377 return result
378 })
379
380 // 終端機(含 SSH 與 VS Code 內建終端機)與桌機版畫在提示框上方。
381 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
382 const current = await read($, view)
383 // 新的回合在跑時先收起,等回應結束再換成新的一則。
384 if (e.props.hasSurvey || e.props.isWorking || !isCardShown(current)) return next(e)
385 return drawCard($, $.ui.resolve(e), current)
386 })
387
388 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
389 const { Box, Text } = $.ui.resolve(e)
390 const current = await read($, map)
391 const busy = await read($, isBusy)
392 const auto = await read($, isAuto)
393
394 if (!current) {
395 return (
396 <Box flexDirection="column">
397 <Text dimColor>
398 {busy ? '流程圖產生中,請稍候。' : auto ? '長回應結束後會自動產生流程圖。' : '自動產生已關閉,輸入 /flow 手動產生。'}
399 </Text>
400 </Box>
401 )
402 }
403
404 if (current.diagram !== 'flowchart') {
405 return (
406 <Box flexDirection="column">
407 {busy && <Text dimColor>更新中…</Text>}
408 <Text bold>{current.summary.replace(/^摘要[::]\s*/, '')}</Text>
409 <Text dimColor>{`${DIAGRAM_NAMES[current.diagram] ?? current.diagram},完整圖在 cmux 瀏覽器窗格`}</Text>
410 <Text> </Text>
411 {current.outline.map((line, i) => (
412 <Text wrap="truncate-end" color={i === 0 ? TERMINAL_COLORS.start : undefined} bold={!line.startsWith(' ')}>
413 {line}
414 </Text>
415 ))}
416 </Box>
417 )
418 }
419
420 const order = current.nodes.map(node => node.id)
421 const labelOf = new Map(current.nodes.map(node => [node.id, node.label]))
422 return (
423 <Box flexDirection="column">
424 {busy && <Text dimColor>更新中…</Text>}
425 <Text bold>{current.summary.replace(/^摘要[::]\s*/, '')}</Text>
426 <Text> </Text>
427 {current.nodes.map((node, i) => {
428 const outs = current.edges.filter(edge => edge.from === node.id)
429 const isStraight = outs.length === 1 && outs[0]?.label === '' && outs[0]?.to === order[i + 1]
430 return (
431 <Box flexDirection="column">
432 <Text wrap="truncate-end">
433 <Text color={TERMINAL_COLORS[node.kind] ?? 'claude'}>{node.kind === 'decide' ? '◆ ' : '■ '}</Text>
434 <Text bold={node.kind !== 'step'}>{node.label}</Text>
435 </Text>
436 {isStraight && <Text dimColor>│</Text>}
437 {!isStraight &&
438 outs.map(edge => (
439 <Text wrap="truncate-end">
440 <Text dimColor>├▶ </Text>
441 {edge.label !== '' && <Text color={TERMINAL_COLORS.decide}>{edge.label} </Text>}
442 <Text dimColor>{labelOf.get(edge.to) ?? edge.to}</Text>
443 </Text>
444 ))}
445 </Box>
446 )
447 })}
448 </Box>
449 )
450 })
451}
452hooks/flow.ts 607 lines1import type { FlowEdge, FlowNode } from '../types'
2
3export const KINDS = ['start', 'step', 'decide', 'done', 'warn'] as const
4
5// 終端機用主題色,跟著 Claude Code 的淺色或深色主題走。
6export const TERMINAL_COLORS: Record<string, string> = {
7 start: 'suggestion',
8 step: 'claude',
9 decide: 'warning',
10 done: 'success',
11 warn: 'error',
12}
13
14export const JUDGE = [
15 '你是分類器。使用者會給你一則 AI 助理寫的回應原文,包在 <回應> 標籤裡。',
16 '你要判斷:如果在這則回應旁邊另外附一張示意圖(流程圖、架構圖、時序圖之類),能不能明顯幫助讀者理解。',
17 '只輸出一個字:「是」或「否」。不要輸出其他任何文字,不要要求更多資料。',
18 '輸出「是」只限這些情況:',
19 '- 有三個以上有先後順序的步驟,或有判斷分支。',
20 '- 多個元件、系統或角色之間有互動或資料流向。',
21 '- 有明確的架構、層級或分類關係。',
22 '- 有狀態轉換,或依時間、階段排列的事件。',
23 '- 多個方案在兩個以上面向上做取捨比較。',
24 '輸出「否」的情況:',
25 '- 回答一個問題、說明原因、給出單一結論或建議。',
26 '- 回報做了什麼、測試結果、操作說明只有一兩步。',
27 '- 主要在解說程式碼、錯誤訊息或指令輸出。',
28 '- 內容已經是清楚的表格,畫圖只是重複。',
29 '- 閒聊、道歉、提問,或請使用者做選擇。',
30 '拿不準時一律輸出「否」。',
31].join('\n')
32
33export const SYSTEM = [
34 '你負責把一段技術回應整理成一張最適合的 Mermaid 圖。',
35 '輸出格式固定如下,不要加任何其他文字:',
36 '第一行:以「摘要:」開頭,用一句完整的繁體中文句子說明重點,不超過 40 個字。',
37 '接著是一個 ```mermaid 程式碼區塊。',
38 '目的是讓人不讀原文也能看懂完整流程:主軸要有,關鍵的條件、分支、例外處理和產出也要畫出來。只省略純粹的措辭與重複說明。',
39 '先依內容挑圖的種類:',
40 '- 有先後步驟或判斷分支:flowchart TD。',
41 '- 分類、比較、選項整理:flowchart TD 的樹狀圖,根節點在上,每層最多 5 個分支,最多三層。',
42 '- 多個方案比較:樹狀圖,每個方案一個節點,標籤寫最關鍵的取捨,不要把每個優缺點拆成節點;原文有建議就加一個結論節點。',
43 '- 除錯或追查報告:最多 8 個節點,畫「症狀、原因、修法、結果」這條主線;追查過程合併成一個節點,修法有幾項就分幾支。',
44 '- 分階段的計畫或有編號的項目清單(沒有月份或日期):樹狀圖,根節點是計畫名稱,第二層是各階段,第三層是每個階段底下的項目。原文列了幾項就畫幾項,不受每層分支數限制,不要只畫階段。',
45 '- 只有 3 到 6 個並列步驟、沒有分支,而且每步底下沒有子項目:flowchart LR 橫向。',
46 '- 多個角色或系統之間來回呼叫:sequenceDiagram。',
47 '- 狀態之間的轉換:stateDiagram-v2。',
48 '- 依月份、日期排列的時程:timeline,優先於樹狀圖。',
49 '- 兩個維度的比較或優先順序:quadrantChart。',
50 '- 資料表與關聯:erDiagram,每張表只列主鍵、外鍵和最多 2 個關鍵欄位。',
51 '- 不要使用 mindmap。',
52 '共同規則:文字用繁體中文短句,每個標籤不超過 16 個字;指令、檔名、API 名稱保留原文;元素數量跟著原文走:原文只有三步就畫三步,一般圖不超過 16 個元素,分階段計畫或編號清單最多 30 個;不要寫 style、classDef 或註解。',
53 'flowchart 額外規則:',
54 '- 節點代號用 A、B、C 這類英文字母,標籤一律加雙引號,例如 A["讀取設定"]。',
55 '- 只有原文明確寫出兩種以上結果時才畫判斷。判斷用菱形 {"…?"},每個判斷都要畫出所有分支(至少兩條),每條分支都加標籤,例如 C -->|是| D、C -->|否| E。',
56 '- 箭頭只代表「接下來會發生」。並列的類別或管道從同一個上層節點各自分出去,不要用箭頭串成先後。',
57 '- 期限、條件、審查範圍這類說明,寫進同一個節點的標籤裡,不要拆成下一個節點。',
58 '- 只畫原文有寫的內容。原文沒寫的判斷、迴圈、等待或重試都不要自己加,也不要把一步拆成好幾個節點。',
59 '- 原文有失敗、重試、回退或例外路徑時要畫出來,不要只畫順利的那條路。',
60 '- 節點數超過上限時,才合併次要細節;編號清單的項目不要合併,改成只留項目名稱。',
61 '- 每個節點第一次出現時加上類別::::start 起點、:::step 一般步驟、:::decide 判斷、:::done 結果、:::warn 風險或注意事項。',
62 '各種圖的語法範本(照抄結構,不要自創語法):',
63 'sequenceDiagram 範本:\nsequenceDiagram\n participant A as 使用者\n participant B as 伺服器\n A->>B: 送出請求\n B-->>A: 回傳結果',
64 'stateDiagram-v2 範本:\nstateDiagram-v2\n [*] --> 待處理\n 待處理 --> 處理中: 開始\n 處理中 --> [*]',
65 'timeline 範本:\ntimeline\n title 標題\n 第一階段 : 事件一 : 事件二\n 第二階段 : 事件三',
66 'quadrantChart 範本(座標一定要用中括號,數值介於 0 到 1):\nquadrantChart\n title 標題\n x-axis 低成本 --> 高成本\n y-axis 低效益 --> 高效益\n quadrant-1 優先做\n quadrant-2 值得投資\n quadrant-3 暫緩\n quadrant-4 快速見效\n 方案甲: [0.3, 0.6]',
67 'erDiagram 範本:\nerDiagram\n USER ||--o{ ORDER : places\n USER {\n int id\n }',
68].join('\n')
69
70// 檢查流程圖常見的錯誤,回傳問題清單;空陣列代表沒有問題。
71export const MAX_NODES = 32
72export function checkFlow(parsed: Parsed): string[] {
73 if (parsed.diagram !== 'flowchart') return []
74 const problems: string[] = []
75 if (parsed.nodes.length > MAX_NODES) {
76 problems.push(`節點有 ${parsed.nodes.length} 個,超過 ${MAX_NODES} 個,請合併或省略細節。`)
77 }
78 for (const node of parsed.nodes) {
79 const outs = parsed.edges.filter(edge => edge.from === node.id)
80 const touched = outs.length > 0 || parsed.edges.some(edge => edge.to === node.id)
81 if (node.kind === 'decide' && outs.length < 2) {
82 problems.push(`判斷「${node.label}」只有 ${outs.length} 條分支,每個判斷都要畫出所有分支。`)
83 }
84 if (node.kind === 'decide' && outs.some(edge => edge.label === '')) {
85 problems.push(`判斷「${node.label}」有分支沒有標籤。`)
86 }
87 if (!touched && parsed.nodes.length > 1) {
88 problems.push(`節點「${node.label}」沒有任何連線。`)
89 }
90 }
91 return problems
92}
93
94export type Parsed = { summary: string; mermaid: string; diagram: string; nodes: FlowNode[]; edges: FlowEdge[]; outline: string[] }
95
96const NODE = /^([A-Za-z_][\w-]*)\s*(\(\[[\s\S]*?\]\)|\[\[[\s\S]*?\]\]|\(\([\s\S]*?\)\)|\[[\s\S]*?\]|\([\s\S]*?\)|\{\{[\s\S]*?\}\}|\{[\s\S]*?\})?\s*(?::::(\w+))?$/
97const ARROW = /\s*(?:-->|---|-\.->|==>|-\.-)\s*(?:\|([^|]*)\|\s*)?/
98
99function cleanLabel(shape: string): string {
100 return shape
101 .replace(/^[\[({]+\/?|\/?[\])}]+$/g, '')
102 .replace(/^"|"$/g, '')
103 .replace(/<br\s*\/?>/g, ' ')
104 .trim()
105}
106
107// 從模型回覆取出摘要與 Mermaid 原始碼,再解析出節點和邊。
108export function parseReply(text: string): Parsed | null {
109 const fence = /```mermaid\s*\n([\s\S]*?)```/.exec(text)
110 let mermaid = (fence?.[1] ?? '').trim()
111 const diagram = /^([\w-]+)/.exec(mermaid)?.[1] ?? ''
112 if (diagram === '') return null
113 const summaryLine = text.split('\n').find(line => line.trim().startsWith('摘要')) ?? ''
114 if (diagram === 'quadrantChart') mermaid = repairQuadrant(mermaid)
115 if (diagram !== 'flowchart' && diagram !== 'graph') {
116 return { summary: summaryLine.trim(), mermaid, diagram, nodes: [], edges: [], outline: outlineOf(diagram, mermaid) }
117 }
118
119 const nodes: FlowNode[] = []
120 const edges: FlowEdge[] = []
121 const byId = new Map<string, FlowNode>()
122 const touch = (token: string): string | null => {
123 const m = NODE.exec(token.trim())
124 if (!m) return null
125 const id = m[1] ?? ''
126 let node = byId.get(id)
127 if (!node) {
128 node = { id, label: id, kind: 'step' }
129 byId.set(id, node)
130 nodes.push(node)
131 }
132 if (m[2]) node.label = cleanLabel(m[2])
133 if (m[3] && (KINDS as readonly string[]).includes(m[3])) node.kind = m[3]
134 return id
135 }
136
137 for (const raw of mermaid.split('\n').slice(1)) {
138 const line = raw.trim().replace(/;$/, '')
139 if (line === '' || line.startsWith('%%')) continue
140 const cls = /^class\s+([\w,\s-]+?)\s+(\w+)$/.exec(line)
141 if (cls) {
142 for (const id of (cls[1] ?? '').split(',')) {
143 const node = byId.get(id.trim())
144 if (node && (KINDS as readonly string[]).includes(cls[2] ?? '')) node.kind = cls[2] ?? node.kind
145 }
146 continue
147 }
148 if (/^(classDef|style|subgraph|end|direction|linkStyle)\b/.test(line)) continue
149 const parts = line.split(ARROW)
150 // split 帶捕獲群組:節點、邊標籤、節點、邊標籤……交錯出現。
151 let prev: string | null = touch(parts[0] ?? '')
152 for (let i = 1; i + 1 < parts.length; i += 2) {
153 const id = touch(parts[i + 1] ?? '')
154 if (prev && id) edges.push({ from: prev, to: id, label: (parts[i] ?? '').replace(/^"|"$/g, '').trim() })
155 prev = id
156 }
157 }
158
159 // 用解析結果重寫一份乾淨的 Mermaid,避開模型多打空白或符號造成的語法錯誤。
160 // 同一層分支太多時,直向排會太寬;改橫向讓分支上下堆疊,適合窄窗格。
161 // 但主線很長時改橫向會拉得太寬,所以只在層數不多時才改。
162 const fanOut = Math.max(0, ...nodes.map(node => edges.filter(edge => edge.from === node.id).length))
163 const asked = /^(?:flowchart|graph)\s+(TD|TB|LR|RL|BT)/.exec(mermaid)?.[1] ?? 'TD'
164 const direction = fanOut > 3 && depthOf(nodes, edges) <= 4 ? 'LR' : asked
165 const quote = (text: string) => text.replace(/"/g, "'")
166 const clean = [
167 `flowchart ${direction}`,
168 ...nodes.map(node =>
169 node.kind === 'decide'
170 ? ` ${node.id}{"${quote(node.label)}"}:::decide`
171 : ` ${node.id}["${quote(node.label)}"]:::${node.kind}`,
172 ),
173 ...edges.map(edge => ` ${edge.from} -->${edge.label === '' ? '' : `|"${quote(edge.label)}"|`} ${edge.to}`),
174 ].join('\n')
175 return { summary: summaryLine.trim(), mermaid: clean, diagram: 'flowchart', nodes, edges, outline: [] }
176}
177
178// 從沒有入邊的節點往下走,算出最長路徑有幾層;遇到迴圈不重複走。
179export function depthOf(nodes: FlowNode[], edges: FlowEdge[]): number {
180 const level = new Map<string, number>()
181 let frontier = nodes.filter(node => !edges.some(edge => edge.to === node.id)).map(node => node.id)
182 if (frontier.length === 0) frontier = nodes.slice(0, 1).map(node => node.id)
183 for (let depth = 1; frontier.length > 0; depth += 1) {
184 for (const id of frontier) level.set(id, depth)
185 frontier = [...new Set(edges.filter(edge => frontier.includes(edge.from) && !level.has(edge.to)).map(edge => edge.to))]
186 }
187 return Math.max(0, ...level.values())
188}
189
190// 模型常把象限圖座標寫成「名稱: 0.3, 0.6」,補成 Mermaid 要求的中括號。
191export function repairQuadrant(mermaid: string): string {
192 return mermaid
193 .split('\n')
194 .map(line => line.replace(/^(\s*[^:\[\]]+?):\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*$/, '$1: [$2, $3]'))
195 .join('\n')
196}
197
198// 非流程圖給終端機面板的縮排大綱:保留層級,去掉 Mermaid 語法符號。
199function outlineOf(diagram: string, mermaid: string): string[] {
200 const lines = mermaid.split('\n').slice(1).filter(line => line.trim() !== '' && !line.trim().startsWith('%%'))
201 if (diagram === 'mindmap') {
202 const base = Math.min(...lines.map(line => line.length - line.trimStart().length))
203 return lines.map(line => {
204 const depth = Math.round((line.length - line.trimStart().length - base) / 2)
205 const label = line.trim().replace(/^[\w-]*\s*[\[({]+"?|"?[\])}]+$/g, '').trim()
206 return `${' '.repeat(depth)}${depth === 0 ? '' : '・'}${label}`
207 })
208 }
209 return lines.map(line => line.trim().replace(/"/g, ''))
210}
211
212// 本機版 cmux 回「OK surface=surface:7 ...」,遠端版回 JSON(surface_id 或 surface_ref)。
213export function parseSurface(stdout: string): string | null {
214 try {
215 const data = JSON.parse(stdout) as { surface_id?: unknown; surface_ref?: unknown }
216 const id = data.surface_id ?? data.surface_ref
217 if (typeof id === 'string' && id !== '') return id
218 } catch {
219 // 不是 JSON,改用文字格式解析。
220 }
221 return /surface=(\S+)/.exec(stdout)?.[1] ?? null
222}
223
224// 從 `cmux --json list-panels` 找這個工作區裡已經開著的圖解分頁:優先選分頁列上正在顯示的,否則選最後開的。
225export function findPane(stdout: string): string | null {
226 try {
227 const data = JSON.parse(stdout) as { surfaces?: { type?: string; title?: string; ref?: string; selected_in_pane?: boolean }[] }
228 const panes = (data.surfaces ?? []).filter(s => s.type === 'browser' && s.title === '圖解' && typeof s.ref === 'string')
229 return (panes.find(s => s.selected_in_pane) ?? panes.at(-1))?.ref ?? null
230 } catch {
231 return null
232 }
233}
234
235// 給 mermaid-cli 渲染成圖片的原始碼:摘要當標題,流程圖補上淺色配色。
236export function mermaidForImage(summary: string, mermaid: string, diagram: string): string {
237 const title = summary.replace(/^摘要[::]\s*/, '').replace(/"/g, "'")
238 const init = {
239 theme: 'base',
240 themeVariables: {
241 fontFamily: 'PingFang TC, sans-serif',
242 fontSize: '18px',
243 lineColor: '#9aa1ab',
244 edgeLabelBackground: '#ffffff',
245 },
246 }
247 const defs = diagram === 'flowchart'
248 ? [
249 'classDef start fill:#e3efff,stroke:#2f6fde,stroke-width:2px,color:#1f2328',
250 'classDef step fill:#f3f4f6,stroke:#8b95a5,stroke-width:2px,color:#1f2328',
251 'classDef decide fill:#fff4d6,stroke:#d99a00,stroke-width:2px,color:#1f2328',
252 'classDef done fill:#e3f7ea,stroke:#1f9d55,stroke-width:2px,color:#1f2328',
253 'classDef warn fill:#ffe7e5,stroke:#d63b2f,stroke-width:2px,color:#1f2328',
254 ]
255 : []
256 return [
257 ...(title === '' ? [] : ['---', `title: "${title}"`, '---']),
258 `%%{init: ${JSON.stringify(init)}}%%`,
259 mermaid,
260 ...defs.map(line => ` ${line}`),
261 ].join('\n')
262}
263
264// 遠端版 cmux(SSH 工作區)單次請求超過約 16 KB 就回 server returned error response,
265// data URL 與 browser.eval 的腳本都受限;這個值留了餘裕給 JSON 包裝。
266export const CMUX_LIMIT = 12000
267
268// 中文與空白用 percent 編碼會膨脹三倍,base64 只有 4/3,頁面能小很多。
269export function toBase64(text: string): string {
270 const bytes = new TextEncoder().encode(text)
271 let binary = ''
272 for (let i = 0; i < bytes.length; i += 8192) binary += String.fromCharCode(...bytes.subarray(i, i + 8192))
273 return btoa(binary)
274}
275
276export function toDataUrl(html: string): string {
277 return `data:text/html;charset=utf-8;base64,${toBase64(html)}`
278}
279
280export function escapeHtml(text: string): string {
281 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
282}
283
284// 給 cmux 瀏覽器窗格的頁面;配色依系統深淺色切換。
285// 換圖函式的參數:JSON 字串化,並把 < 換掉,避免提早結束 <script>。
286export function renderArgs(summary: string, mermaid: string, stamp: string): string {
287 return [summary.replace(/^摘要[::]\s*/, ''), mermaid, stamp]
288 .map(value => JSON.stringify(value).replace(/</g, '\\u003c'))
289 .join(', ')
290}
291
292// 把 base64 內容交給頁面裡的函式,拆成幾段依序用 browser.eval 執行:
293// 塞得進單次上限就直接呼叫;塞不進就先累積在頁面裡,最後一段才呼叫。累積段只回短字串,不把整個緩衝傳回來。
294export function chunkScripts(payload: string, call: (arg: string) => string, limit = CMUX_LIMIT): string[] {
295 if (payload.length <= limit) return [call(`"${payload}"`)]
296 const chunks: string[] = []
297 for (let i = 0; i < payload.length; i += limit) chunks.push(payload.slice(i, i + limit))
298 return [
299 ...chunks.map((chunk, i) => `(window.flowmapBuf = ${i === 0 ? '' : 'window.flowmapBuf + '}"${chunk}", 'flowmap-buf')`),
300 call('window.flowmapBuf'),
301 ]
302}
303
304// 在已經開著的圖解頁面上換圖;最後一段回 ok 代表頁面認得換圖函式。
305export function renderScripts(summary: string, mermaid: string, stamp: string, limit = CMUX_LIMIT): string[] {
306 const payload = toBase64(JSON.stringify([summary.replace(/^摘要[::]\s*/, ''), mermaid, stamp]))
307 return chunkScripts(payload, arg => `window.flowmapRenderB64 ? (window.flowmapRenderB64(${arg}), 'flowmap-ok') : 'flowmap-missing'`, limit)
308}
309
310// 完整頁面塞不進 data URL 時先開這個啟動頁,再把整頁分段送進去,用 document.write 換成真正的頁面。
311export const BOOT_HTML = `<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><title>圖解</title></head><body><script>
312window.flowmapBoot = b64 => {
313 const html = new TextDecoder().decode(Uint8Array.from(atob(b64), ch => ch.charCodeAt(0)))
314 document.open(); document.write(html); document.close()
315 return 'flowmap-ok'
316}
317</script></body></html>`
318export const BOOT_READY = 'typeof window.flowmapBoot'
319
320export function bootScripts(html: string, limit = CMUX_LIMIT): string[] {
321 return chunkScripts(toBase64(html), arg => `window.flowmapBoot ? window.flowmapBoot(${arg}) : 'flowmap-missing'`, limit)
322}
323
324export function buildHtml(summary: string, mermaid: string, stamp: string): string {
325 return `<!doctype html>
326<html lang="zh-Hant"><head><meta charset="utf-8">
327<meta name="viewport" content="width=device-width, initial-scale=1">
328<title>圖解</title>
329<style>
330 :root { --bg: #fbfaf7; --fg: #1f2328; --muted: #6a737d; --card: #ffffff; --line: #e4e1da; }
331 @media (prefers-color-scheme: dark) {
332 :root { --bg: #16181d; --fg: #e6e6e6; --muted: #9aa4b2; --card: #1e2128; --line: #2c313a; }
333 }
334 html, body { margin: 0; background: var(--bg); color: var(--fg);
335 font: 17px/1.6 -apple-system, "PingFang TC", "Noto Sans TC", sans-serif; }
336 main { padding: 20px 20px 32px; }
337 .summary { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
338 .stamp { color: var(--muted); font-size: 13px; margin: 0 0 16px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; }
339 .card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 16px; overflow: auto; }
340 .legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px; color: var(--muted); font-size: 14px; }
341 .legend span::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 3px;
342 margin-right: 6px; vertical-align: -1px; background: var(--c); }
343 pre.mermaid { margin: 0; text-align: center; }
344 pre.mermaid svg { max-width: none !important; height: auto; }
345 .zoom { margin-left: auto; display: inline-flex; flex-wrap: wrap; gap: 6px; align-items: center; }
346 .zoom .gap { width: 10px; }
347 .zoom button:disabled { opacity: 0.35; cursor: default; }
348 .zoom button { font: inherit; width: 26px; height: 26px; border-radius: 6px; border: 1px solid var(--line);
349 background: var(--card); color: var(--fg); cursor: pointer; }
350 pre.raw { margin: 0; white-space: pre-wrap; font: 13px/1.6 ui-monospace, Menlo, monospace; }
351 .zoom .wide { width: auto; padding: 0 8px; }
352 .zoom select { font: inherit; height: 26px; border-radius: 6px; border: 1px solid var(--line);
353 background: var(--card); color: var(--fg); padding: 0 6px; }
354 .editor { margin-top: 12px; }
355 .editor[hidden] { display: none; }
356 .editor textarea { width: 100%; box-sizing: border-box; min-height: 180px; resize: vertical; padding: 10px;
357 border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--fg);
358 font: 13px/1.6 ui-monospace, Menlo, monospace; }
359 .editor .row { display: flex; gap: 8px; align-items: center; margin-top: 8px; color: var(--muted); font-size: 13px; }
360 .editor .row button { font: inherit; height: 26px; padding: 0 10px; border-radius: 6px; border: 1px solid var(--line);
361 background: var(--card); color: var(--fg); cursor: pointer; }
362 .zoom button:not(:disabled):hover, .editor .row button:hover { border-color: var(--fg); }
363 .zoom button:active, .editor .row button:active { opacity: 0.6; }
364 body.editing .card, body.editing .legend { display: none; }
365 .note { color: var(--muted); font-size: 13px; margin: 8px 0 0; min-height: 1.2em; }
366</style></head>
367<body><main>
368<p class="summary" id="summary"></p>
369<p class="stamp"><span id="stamp"></span><span class="zoom"><button id="prev" title="上一張(←)">‹</button><span id="pos">1 / 1</span><button id="next" title="下一張(→)">›</button><span class="gap"></span><button id="zout" title="縮小">-</button><span id="zval">100%</span><button id="zin" title="放大">+</button><span class="gap"></span><button class="wide" id="edit" title="編輯 Mermaid 語法">編輯</button><select id="export" title="匯出"><option value="">匯出</option><option value="svg">SVG 圖檔</option><option value="png">PNG 圖檔</option><option value="mmd">Mermaid 原始碼</option><option value="copy">複製原始碼</option></select></span></p>
370<div class="card" id="card"></div>
371<div class="legend" id="legend"></div>
372<div class="editor" id="editor" hidden><textarea id="code" spellcheck="false"></textarea><div class="row"><button id="apply">套用</button><button id="revert">還原</button><span>⌘Enter 套用;套用後會存成新的一張,可用上一張回到原圖</span></div></div>
373<p class="note" id="note"></p>
374</main>
375<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
376<script>
377 const dark = matchMedia('(prefers-color-scheme: dark)').matches
378 const palette = dark
379 ? { start: ['#1d3a5c', '#7cb7ff'], step: ['#2a2f3a', '#aab4c3'], decide: ['#4a3a12', '#f2c14e'],
380 done: ['#173d2a', '#5fd38d'], warn: ['#4a1f22', '#ff7b72'] }
381 : { start: ['#e3efff', '#2f6fde'], step: ['#f3f4f6', '#8b95a5'], decide: ['#fff4d6', '#d99a00'],
382 done: ['#e3f7ea', '#1f9d55'], warn: ['#ffe7e5', '#d63b2f'] }
383 const names = { start: '起點', step: '步驟', decide: '判斷', done: '結果', warn: '注意' }
384 const defs = Object.entries(palette).map(([k, [fill, stroke]]) =>
385 'classDef ' + k + ' fill:' + fill + ',stroke:' + stroke + ',stroke-width:2px,color:' + (dark ? '#e6e6e6' : '#1f2328'))
386 const config = { startOnLoad: false, theme: 'base', darkMode: dark, securityLevel: 'strict',
387 flowchart: { curve: 'basis', htmlLabels: true, nodeSpacing: 36, rankSpacing: 48, useMaxWidth: false },
388 sequence: { useMaxWidth: false }, state: { useMaxWidth: false }, timeline: { useMaxWidth: false },
389 er: { useMaxWidth: false }, quadrantChart: { useMaxWidth: false }, mindmap: { useMaxWidth: false },
390 themeVariables: { fontFamily: '-apple-system, "PingFang TC", sans-serif', fontSize: '22px',
391 lineColor: dark ? '#6b7280' : '#9aa1ab', edgeLabelBackground: dark ? '#1e2128' : '#ffffff',
392 primaryColor: dark ? '#1d3a5c' : '#e3efff', primaryBorderColor: dark ? '#7cb7ff' : '#2f6fde',
393 primaryTextColor: dark ? '#e6e6e6' : '#1f2328', secondaryColor: dark ? '#173d2a' : '#e3f7ea',
394 tertiaryColor: dark ? '#4a3a12' : '#fff4d6', actorBkg: dark ? '#1d3a5c' : '#e3efff',
395 actorBorder: dark ? '#7cb7ff' : '#2f6fde', noteBkgColor: dark ? '#4a3a12' : '#fff4d6' } }
396 mermaid.initialize(config)
397 // 縮放比例記在這個頁面的瀏覽器儲存裡,下一張圖沿用。
398 let scale = 1
399 try { scale = Number(localStorage.getItem('flowmap-zoom')) || 1 } catch (e) {}
400 // 用 SVG 本身的寬度縮放;CSS zoom 在 WebKit 會切掉節點裡的文字。
401 const applyZoom = () => {
402 const svg = document.querySelector('#card svg')
403 const box = svg && svg.viewBox && svg.viewBox.baseVal
404 if (svg && box && box.width) {
405 svg.style.width = Math.round(box.width * scale) + 'px'
406 svg.style.height = Math.round(box.height * scale) + 'px'
407 }
408 document.getElementById('zval').textContent = Math.round(scale * 100) + '%'
409 try { localStorage.setItem('flowmap-zoom', String(scale)) } catch (e) {}
410 }
411 const step = d => { scale = Math.min(2, Math.max(0.5, Math.round((scale + d) * 10) / 10)); applyZoom() }
412 document.getElementById('zin').onclick = () => step(0.1)
413 document.getElementById('zout').onclick = () => step(-0.1)
414 // 這個窗格看過的圖都記在 history,用上一張、下一張切換,最多保留 20 張。
415 const history = []
416 let index = -1
417 let seq = 0
418 const updateNav = () => {
419 document.getElementById('pos').textContent = (index + 1) + ' / ' + history.length
420 document.getElementById('prev').disabled = index <= 0
421 document.getElementById('next').disabled = index >= history.length - 1
422 }
423 const show = i => {
424 index = i
425 updateNav()
426 const { summary, code, stamp } = history[i]
427 document.getElementById('summary').textContent = summary
428 document.getElementById('stamp').textContent = stamp
429 document.getElementById('code').value = code
430 const isFlow = /^(flowchart|graph)\\s/.test(code.trim())
431 document.getElementById('legend').innerHTML = isFlow
432 ? Object.entries(palette).map(([k, [, stroke]]) => '<span style="--c:' + stroke + '">' + names[k] + '</span>').join('')
433 : ''
434 const full = isFlow ? code + '\\n' + defs.join('\\n') : code
435 const card = document.getElementById('card')
436 seq += 1
437 const id = 'flowmap-' + seq
438 return mermaid.parse(full)
439 .then(() => mermaid.render(id, full))
440 .then(({ svg }) => {
441 // 快速切換時,晚畫完的舊圖不要蓋掉新圖。
442 if (id !== 'flowmap-' + seq) return
443 card.innerHTML = '<pre class="mermaid">' + svg + '</pre>'
444 applyZoom()
445 })
446 .catch(err => {
447 if (id !== 'flowmap-' + seq) return
448 card.innerHTML = '<p class="stamp">這張圖的語法有誤,改顯示原始內容。輸入 /flow 可以重畫。</p><pre class="raw"></pre>'
449 card.querySelector('.raw').textContent = code
450 console.error(err)
451 })
452 }
453 // 換圖:記進 history 並顯示最新一張;外掛用 eval 或 postMessage 呼叫,不必重新載入頁面。
454 window.flowmapRender = (summary, code, stamp) => {
455 history.push({ summary, code, stamp })
456 if (history.length > 20) history.shift()
457 return show(history.length - 1)
458 }
459 // cmux 的 browser.eval 用這個入口:參數是 [摘要, 圖, 時間] 的 JSON 再 base64。
460 window.flowmapRenderB64 = b64 => {
461 const bytes = Uint8Array.from(atob(b64), ch => ch.charCodeAt(0))
462 const [summary, code, stamp] = JSON.parse(new TextDecoder().decode(bytes))
463 return window.flowmapRender(summary, code, stamp)
464 }
465 document.getElementById('prev').onclick = () => index > 0 && show(index - 1)
466 document.getElementById('next').onclick = () => index < history.length - 1 && show(index + 1)
467 document.addEventListener('keydown', e => {
468 if (e.target && e.target.tagName === 'TEXTAREA') return
469 if (e.key === 'ArrowLeft' && index > 0) show(index - 1)
470 if (e.key === 'ArrowRight' && index < history.length - 1) show(index + 1)
471 })
472 // 編輯語法:套用後存成新的一張並重畫,原圖留在歷史裡。
473 const editor = document.getElementById('editor')
474 const codeBox = document.getElementById('code')
475 const note = msg => { document.getElementById('note').textContent = msg }
476 // 編輯時把圖收起,只留編輯區;套用後展開新圖、收起編輯區,看到新圖就是結果。
477 const setEditing = on => {
478 editor.hidden = !on
479 document.body.classList.toggle('editing', on)
480 document.getElementById('edit').textContent = on ? '看圖' : '編輯'
481 if (on) codeBox.focus()
482 }
483 document.getElementById('edit').onclick = () => setEditing(editor.hidden)
484 const apply = () => {
485 if (index < 0) return
486 const { summary, code, stamp } = history[index]
487 if (codeBox.value.trim() === code.trim()) { note('語法沒有變更。'); return }
488 const base = stamp.replace(/(已編輯)$/, '')
489 note('')
490 window.flowmapRender(summary, codeBox.value, base + '(已編輯)').then(() => setEditing(false))
491 }
492 document.getElementById('apply').onclick = apply
493 document.getElementById('revert').onclick = () => {
494 if (index < 0) return
495 codeBox.value = history[index].code
496 note('已還原成目前這張的語法。')
497 }
498 codeBox.addEventListener('keydown', e => {
499 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); apply() }
500 })
501 // 匯出:SVG 與原始碼直接下載;PNG 先改用純 SVG 文字重畫(HTML 標籤畫進 canvas 會失敗),再轉成圖片。
502 // 在 VS Code 的 webview 裡下載連結不會動作,改用 postMessage 請擴充套件開存檔對話框。
503 const vscodeApi = window.acquireVsCodeApi ? window.acquireVsCodeApi() : null
504 const pad = n => String(n).padStart(2, '0')
505 const fileStem = () => {
506 const d = new Date()
507 return 'flowmap-' + d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) + '-' + pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds())
508 }
509 const saveHref = (name, href) => {
510 const a = document.createElement('a')
511 a.href = href
512 a.download = name
513 document.body.appendChild(a)
514 a.click()
515 a.remove()
516 }
517 const download = (name, mime, text) => {
518 if (vscodeApi) { vscodeApi.postMessage({ type: 'flowmap-save', name, text }); return }
519 saveHref(name, 'data:' + mime + ';charset=utf-8,' + encodeURIComponent(text))
520 }
521 const currentSvg = () => {
522 const svg = document.querySelector('#card svg')
523 if (!svg) return null
524 const copy = svg.cloneNode(true)
525 copy.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
526 copy.removeAttribute('style')
527 return copy.outerHTML
528 }
529 const renderPlain = code => {
530 const isFlow = /^(flowchart|graph)\\s/.test(code.trim())
531 const full = isFlow ? code + '\\n' + defs.join('\\n') : code
532 mermaid.initialize({ ...config, flowchart: { ...config.flowchart, htmlLabels: false } })
533 return mermaid.render('flowmap-export', full).finally(() => mermaid.initialize(config))
534 }
535 const toPng = svgText => new Promise((resolve, reject) => {
536 const img = new Image()
537 img.onload = () => {
538 const canvas = document.createElement('canvas')
539 const ratio = 2
540 canvas.width = Math.ceil(img.width * ratio)
541 canvas.height = Math.ceil(img.height * ratio)
542 const ctx = canvas.getContext('2d')
543 ctx.fillStyle = dark ? '#16181d' : '#ffffff'
544 ctx.fillRect(0, 0, canvas.width, canvas.height)
545 ctx.scale(ratio, ratio)
546 ctx.drawImage(img, 0, 0)
547 try { resolve(canvas.toDataURL('image/png')) } catch (err) { reject(err) }
548 }
549 img.onerror = () => reject(new Error('svg load failed'))
550 img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgText)
551 })
552 const copyText = text => {
553 if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).catch(() => {}); return true }
554 const box = document.createElement('textarea')
555 box.value = text
556 document.body.appendChild(box)
557 box.select()
558 const ok = document.execCommand('copy')
559 box.remove()
560 return ok
561 }
562 document.getElementById('export').onchange = e => {
563 const kind = e.target.value
564 e.target.value = ''
565 if (index < 0) return
566 const code = history[index].code
567 note('')
568 if (kind === 'mmd') download(fileStem() + '.mmd', 'text/plain', code)
569 if (kind === 'copy') note(copyText(code) ? '已複製 Mermaid 原始碼。' : '複製失敗,請改用匯出原始碼。')
570 if (kind === 'svg') {
571 const svg = currentSvg()
572 if (svg) download(fileStem() + '.svg', 'image/svg+xml', svg)
573 else note('目前沒有畫出的圖可以匯出。')
574 }
575 if (kind === 'png') {
576 note('正在產生 PNG…')
577 renderPlain(code)
578 .then(({ svg }) => {
579 const box = document.createElement('div')
580 box.innerHTML = svg
581 const el = box.querySelector('svg')
582 el.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
583 const vb = el.viewBox && el.viewBox.baseVal
584 if (vb && vb.width) { el.setAttribute('width', Math.ceil(vb.width)); el.setAttribute('height', Math.ceil(vb.height)) }
585 return toPng(el.outerHTML)
586 })
587 .then(url => {
588 const name = fileStem() + '.png'
589 if (vscodeApi) { vscodeApi.postMessage({ type: 'flowmap-save', name, base64: url.split(',')[1] }); note(''); return }
590 saveHref(name, url)
591 note('PNG 已下載到「下載項目」。')
592 })
593 .catch(err => { console.error(err); note('PNG 產生失敗,請改匯出 SVG。') })
594 }
595 }
596 // VS Code 檢視器用 postMessage 送新圖。
597 window.addEventListener('message', e => {
598 const d = e.data
599 if (d && d.type === 'flowmap') window.flowmapRender(d.summary, d.mermaid, d.stamp)
600 })
601 applyZoom()
602 window.flowmapRender(${renderArgs(summary, mermaid, stamp)})
603</script>
604</body></html>
605`
606}
607types/index.d.ts 21 lines1export type FlowNode = { id: string; label: string; kind: string }
2export type FlowEdge = { from: string; to: string; label: string }
3export type FlowMap = {
4 summary: string
5 mermaid: string
6 diagram: string
7 nodes: FlowNode[]
8 edges: FlowEdge[]
9 outline: string[]
10 at: number
11 turn: number
12}
13// 每則回應的圖解狀態:判斷中、詢問要不要看、按了要看但還在產生、已顯示、不需要畫。
14export type FlowView = { turn: number; state: 'judging' | 'offer' | 'wanted' | 'shown' | 'skipped' }
15
16declare module 'claude-code' {
17 interface PluginState {
18 flowmap: { map: FlowMap | null; isBusy: boolean; isAuto: boolean; isWeb: boolean; browser: string | null; lastAnswer: string; view: FlowView | null }
19 }
20}
21