SLOPSHOPPER

flowmap

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

newpanebandcommandtoastmodel
v0.4.0no licenseupdated 2026-10-07TCcodemaster/claude-mods/flowmap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · flowmap
│ ┃ 圖解 ✕ › fix the failing auth test and add an audit log call │ ┃ 長回應結束後會自動產生流程圖。 │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /flow │ ⎿ flowmap: 正在重畫上一則回應的流程圖。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 圖解
長回應結束後會自動產生流程圖。
README

Claude Code mods

兩個 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

安裝

  1. 把這個 repo clone 到 ~/.claude/mods:
   git clone https://github.com/TCcodemaster/claude-mods ~/.claude/mods
  1. 在 ~/.claude/settings.json 加上(一定要放在家目錄的設定檔,專案的設定檔不會生效):
   "env": {
     "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/flowmap:~/.claude/mods/gitgraph",
     "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
   }

第二行是強制開啟 mod 功能。mod 目前是逐步開放的早期功能,開關狀態快取在本機,偶爾會被存成關閉,導致新對話完全不載入 mod。

  1. 使用 VS Code 的話,安裝圖解檢視器,圖就會顯示在旁邊一欄:
   code --install-extension ~/.claude/mods/vscode-viewer/flowmap-viewer.vsix
  1. 重開 Claude Code 對話。

自動更新

每次開 Claude Code 對話時,flowmap 會在背景對這個 repo 跑 git pull --ff-only,有更新會跳提示。本機有未推送的提交或衝突時會跳過,不會動任何檔案。VS Code 檢視器(.vsix)不會自動重裝,更新後要再跑一次安裝步驟 3。

圖顯示在哪裡

執行環境顯示位置
cmuxcmux 右邊的瀏覽器窗格
VS Code,有裝檢視器VS Code 旁邊一欄
VS Code,沒裝檢視器用 mermaid-cli 與 Chrome 渲染成 PNG,在 VS Code 開啟
其他不另外開視窗,輸入 /flow 在面板裡看

依賴與限制

  • 目前只在 macOS 上測試過。
  • 圖的頁面從 jsDelivr 載入 Mermaid,需要網路。
  • 判斷要不要畫用 Haiku,畫圖用對話當下的主模型(沿用快取),都算在使用者自己的 Claude 額度。
  • gitgraph 只需要 git;它的面板在 VS Code 擴充套件裡不會顯示,只能在終端機裡使用。

測試

claude plugin validate flowmap && claude plugin test flowmap
claude plugin validate gitgraph && claude plugin test gitgraph
Source 3 files
hooks/register.tsx 452 lines
1import { 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}
452
hooks/flow.ts 607 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
607
types/index.d.ts 21 lines
1export 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