From 95c9b97eb7cab4ec500f3e9f9d325cb319155074 Mon Sep 17 00:00:00 2001 From: JianMiau Date: Mon, 24 Aug 2026 10:16:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E4=B8=AD=E6=B0=A3=E6=B3=A1?= =?UTF-8?q?=E9=A1=AF=E7=A4=BA=E5=B7=A5=E5=85=B7=E5=8B=95=E4=BD=9C=E8=88=87?= =?UTF-8?q?=E5=8A=A9=E7=90=86=E8=AA=AA=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 摘要: running 狀態的氣泡改成有東西就播報:優先顯示助理剛說那句話,否則顯示正在用的工具與對象。 根本原因: 使用者希望氣泡能像 telegram bot 那樣顯示 AI 的思考過程。 實際查證後思考文字拿不到——Claude Code 從 2.1.238 起不再把 thinking 寫進 transcript, 只留加密簽章(本 session 169 個 thinking 區塊文字全為空字串); 比對同樣用 claude-opus-5 但版本為 2.1.237 的舊 session 則存得到, 確認是 Claude Code 版本差異而非模型差異。Claude Code 也沒有對外事件串流, 所以 hook 拿不到思考內容,這點與 Codex CLI 的 --json reasoning 事件不同。 而參考的 cluemarket-tg-bot 其實也沒顯示思考:它的變數雖名為 lastThinkingText, 抓的卻是 text 區塊,另外兩個是寫死的「思考中」字串與工具標籤。 原本 running 狀態除了關閉舊氣泡外沒有任何資訊。 影響: 工作期間看不出 Claude Code 正在做什麼,只知道她在忙。 修法: - hook 補送 toolTarget(依 command / file_path / pattern / url 等順序取一個欄位並截斷, 避免 Write 這類工具把整份檔案內容塞進 payload)、agentType 與 transcriptPath。 - main.js 新增 latestNarration:由 transcript 尾端 192 KB 反向找最後一個非空的 assistant text 區塊,依檔案大小快取,transcript 長到數 MB 也不會變慢; 收到事件時把 narration 與其 uuid 併進 payload 再轉給 renderer。 - renderer 新增 sayActivity:有新的助理說明就顯示第一句,否則顯示工具標籤加對象。 同一段說明只播報一次;waiting 時不播報以免蓋掉固定氣泡。 另加 stripMarkdown,否則助理文字的 **粗體**、反引號、標題與連結語法會直接出現在氣泡裡。 - README 新增「能顯示什麼、不能顯示什麼」一節說明思考文字為何拿不到。 驗證: - 單元測試以 DOM stub 載入完整 renderer.js 並抽出 main.js 的 latestNarration,15 項全過: 對真實 transcript 取得助理說明與 uuid、快取一致、路徑不存在或為 null 時回 null 不拋錯、 firstSentence 取第一句與忽略空行、Bash 取指令前段、檔案路徑只取檔名、過長截斷、 新說明更新 id、同段不重複、waiting 時不播報、空 payload 不拋錯。 - markdown 清理另 7 項全過:粗體、反引號、標題、清單、連結、程式碼區塊、無標記純文字。 - 實機啟動並以真實事件驗證:events.log 顯示 hook 確實帶出 toolTarget 與 transcriptPath; 截圖確認氣泡顯示「⚙️ 執行 npm run dist」,以及傳入真實 transcript 後顯示「💬 助理說明」。 markdown 殘留問題就是在這一步的截圖中發現並修掉的。 - 以 ELECTRON_RUN_AS_NODE 讀打包後 app.asar 與 app.asar.unpacked,確認版本 2.0.4 與九項改動都在出貨檔案裡。 Co-Authored-By: Claude Fable 5 --- README.md | 27 +++++++++++++++++- hook/claude-pet-hook.js | 17 ++++++++++++ main.js | 51 ++++++++++++++++++++++++++++++++++ package-lock.json | 4 +-- package.json | 2 +- renderer/renderer.js | 61 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 158 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8e588b7..4b53425 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - [新增 / 切換寵物](#新增--切換寵物) - [設定檔](#設定檔) - [架構與檔案](#架構與檔案) +- [能顯示什麼、不能顯示什麼](#能顯示什麼不能顯示什麼) - [與 Codex 的對照](#與-codex-的對照) - [HTTP API](#http-api) - [打包成 exe](#打包成-exe) @@ -40,6 +41,7 @@ - **不搶焦點** — 視窗設為 non-focusable,點她不會讓終端機失焦。 - **拖曳 / 點擊 / 右鍵** — 拖曳時依方向播 running-left / running-right 並記住位置;點一下揮手;右鍵或系統匣開選單。 - **對話氣泡** — 完成、需要授權、有問題要問你、工具失敗、整理記憶中……一眼看出 Claude Code 在等什麼。 +- **工作中即時播報** — 氣泡會顯示正在用的工具與對象(`⚙️ 執行 npm run dist`、`📖 讀 renderer.js`),以及助理剛說的那句話(`💬 …`)。**注意:這不是思考過程**,原因見[能顯示什麼、不能顯示什麼](#能顯示什麼不能顯示什麼)。 - **多 session** — 同時開好幾個 Claude Code 時,以 `waiting > running > review > idle` 的優先序顯示最需要你注意的那個。 - **Codex 寵物自動出現** — 預設也掃描 `~/.codex/pets`,Codex hatch 出來的寵物直接在選單裡切換。 - **Hook 零負擔** — hook 腳本約 100 ms 完成、寵物沒開也靜默 exit 0,不會拖慢或卡住 Claude Code。 @@ -109,7 +111,7 @@ npm run autostart:install # (可選)開機自動啟動 | (程式啟動) | 該寵物第一次出現 | `waving` × 3 → 慢速 `idle` | 👋 嗨,我是〈寵物名〉(8 秒) | | `SessionStart` | `source = startup` | `idle` | 👋 嗨!(專案) | | `UserPromptSubmit` | | `running` | (關閉舊氣泡) | -| `PreToolUse` | 一般工具 | `running` | | +| `PreToolUse` | 一般工具 | `running` | 有新的助理說明就顯示 `💬 …`,否則顯示 `⚙️ 執行 …` 之類的工具動作 | | `PreToolUse` | `AskUserQuestion` | `waiting` | ❓ 有問題想問你 | | `PreToolUse` | `ExitPlanMode` | `waiting` | 📋 計畫等你確認 | | `PostToolUse` | | `running` | | @@ -244,6 +246,29 @@ Claude Code ──hook(stdin JSON)──▶ hook/claude-pet-hook.js ──PO - **Spritesheet 以 data URL 傳給 renderer**,避免 `file://` 跨來源污染 canvas 導致 `getImageData` 失敗。 - **hook 合併規則**:以指令中是否含 `claude-pet-hook` 辨識自己的項目,重裝只替換自己的、不動別人的。 +## 能顯示什麼、不能顯示什麼 + +氣泡**顯示不了 Claude 的思考過程**。Claude Code 從 **2.1.238** 起就不再把 thinking 的文字寫進 transcript,只留加密簽章: + +```json +{ "type": "thinking", "thinking": "", "signature": "CAQSwgQKEAgRGAI4AUII…" } +``` + +(2.1.237 以前有文字。這是 Claude Code 版本的差異,跟模型無關——同樣是 `claude-opus-5`, +2.1.237 的 session 存得到、2.1.241 的存不到。)Claude Code 也沒有對外的事件串流, +所以 hook 拿不到思考內容。這點跟 Codex 不同:Codex CLI 的 `--json` 會直接送出 `reasoning` 事件。 + +拿得到的是這些,氣泡就顯示這些: + +| 來源 | 內容 | 從哪來 | +|---|---|---| +| 工具動作 | `⚙️ 執行 npm run dist`、`📖 讀 renderer.js` | hook 的 `tool_name` 與 `tool_input` | +| 助理說明 | 助理剛講那段話的第一句 | 由 `transcript_path` 讀 transcript 尾端的 `text` 區塊 | + +同一段說明只播報一次(以 transcript 的 uuid 判斷),markdown 標記會先清掉, +等待授權時不播報以免蓋掉固定氣泡。讀 transcript 只讀最後 192 KB 並依檔案大小快取, +不會因為 transcript 長到幾 MB 就變慢。 + ## 與 Codex 的對照 所有數值都是從 Codex app(`WindowsApps\OpenAI.Codex_*\app\resources\app.asar`)的 `codex-pet-assets`、`avatar-overlay-native-frame` 與主程序讀出來的。 diff --git a/hook/claude-pet-hook.js b/hook/claude-pet-hook.js index 749d991..5f05c95 100644 --- a/hook/claude-pet-hook.js +++ b/hook/claude-pet-hook.js @@ -29,6 +29,19 @@ function parse(text) { try { return text.trim() ? JSON.parse(text) : {}; } catch { return {}; } } +// 從 tool_input 挑一個最有資訊量的欄位當成「正在對什麼做事」。 +// 整包 tool_input 可能很大(例如 Write 帶整份檔案內容),只取一小段。 +const TARGET_KEYS = ["command", "file_path", "pattern", "path", "url", "query", "notebook_path", "description", "prompt"]; + +function toolTarget(toolInput) { + if (!toolInput || typeof toolInput !== "object") return undefined; + for (const key of TARGET_KEYS) { + const v = toolInput[key]; + if (typeof v === "string" && v.trim()) return trim(v.trim().replace(/\s+/g, " "), 160); + } + return undefined; +} + function buildPayload(input) { const error = typeof input.error === "string" ? input.error : input.error?.message; return { @@ -37,6 +50,10 @@ function buildPayload(input) { cwd: input.cwd ?? null, permissionMode: input.permission_mode ?? null, toolName: input.tool_name ?? null, + toolTarget: toolTarget(input.tool_input), + agentType: input.agent_type ?? null, + // 助理的說明文字要從 transcript 撈;寵物那邊才讀,hook 只負責把路徑帶過去 + transcriptPath: input.transcript_path ?? null, notificationType: input.notification_type ?? null, title: trim(input.title, 160), message: trim(input.message, 240), diff --git a/main.js b/main.js index 461397a..f2072a5 100644 --- a/main.js +++ b/main.js @@ -94,6 +94,52 @@ function appendLog(line) { } catch { /* ignore */ } } +// ---------- transcript:取最新的助理說明文字 ---------- + +// Claude Code 從 2.1.238 起不再把 thinking 的文字寫進 transcript(只剩加密簽章), +// 但助理的可見說明(text 區塊)還在。讀檔尾就夠了,transcript 會長到好幾 MB。 +const TRANSCRIPT_TAIL = 192 * 1024; +const narrationCache = new Map(); // path → { size, id, text } + +function latestNarration(file) { + if (typeof file !== "string" || !file) return null; + let stat; + try { stat = fs.statSync(file); } catch { return null; } + const cached = narrationCache.get(file); + if (cached && cached.size === stat.size) return cached.value; + + let value = null; + try { + const start = Math.max(0, stat.size - TRANSCRIPT_TAIL); + const fd = fs.openSync(file, "r"); + const buf = Buffer.alloc(stat.size - start); + fs.readSync(fd, buf, 0, buf.length, start); + fs.closeSync(fd); + const lines = buf.toString("utf8").split("\n"); + if (start > 0) lines.shift(); // 第一行多半被切一半 + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (!line) continue; + let entry; + try { entry = JSON.parse(line); } catch { continue; } + if (entry.type !== "assistant") continue; + const content = entry.message?.content; + if (!Array.isArray(content)) continue; + for (const block of content) { + if (block?.type === "text" && typeof block.text === "string" && block.text.trim()) { + value = { id: entry.uuid || entry.timestamp || String(i), text: block.text.trim().slice(0, 400) }; + break; + } + } + if (value) break; + } + } catch { /* 讀不到就算了,寵物不該因此出錯 */ } + + narrationCache.set(file, { size: stat.size, value }); + if (narrationCache.size > 32) narrationCache.delete(narrationCache.keys().next().value); + return value; +} + // ---------- pets ---------- function dirKey(p) { @@ -300,6 +346,11 @@ function startServer() { try { payload = JSON.parse(body || "{}"); } catch { /* bad json */ } if (payload && typeof payload === "object") { appendLog(JSON.stringify(payload)); + const narration = latestNarration(payload.transcriptPath); + if (narration) { + payload.narration = narration.text; + payload.narrationId = narration.id; + } send("pet:event", payload); res.writeHead(204); } else { diff --git a/package-lock.json b/package-lock.json index 6ee30a4..ce3e065 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-pet", - "version": "2.0.3", + "version": "2.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-pet", - "version": "2.0.3", + "version": "2.0.4", "devDependencies": { "electron": "^43.4.1", "electron-builder": "^26.0.12" diff --git a/package.json b/package.json index b87f5cb..10c84c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-pet", - "version": "2.0.3", + "version": "2.0.4", "description": "Codex Pets 相容的 Claude Code 桌面寵物(spriteVersionNumber 2:9 個動作列 + 16 方向追視)", "main": "main.js", "private": true, diff --git a/renderer/renderer.js b/renderer/renderer.js index 99c4fe0..d38bfc5 100644 --- a/renderer/renderer.js +++ b/renderer/renderer.js @@ -55,6 +55,23 @@ const RUNNING_SILENCE = 30 * 60e3; const IDLE_PRUNE = 30 * 60e3; // idle 的 session 閒置多久後從清單移除(純清理,不影響顯示) const GREETING_MS = 8000; // first-awake 問候通知的存活時間(Pi = 8 s) +const ACTIVITY_MS = 6000; // 工作中氣泡(工具動作/助理說明)的存活時間 + +// 工具對照表;沒列到的就直接顯示工具名稱 +const TOOL_LABELS = { + Bash: "⚙️ 執行", + Read: "📖 讀", + Write: "✏️ 寫", + Edit: "✏️ 改", + NotebookEdit: "✏️ 改", + Glob: "🔍 找檔案", + Grep: "🔍 搜尋", + WebFetch: "🌐 擷取", + WebSearch: "🔎 搜尋", + Task: "🤖 子代理", + TodoWrite: "📝 待辦", + Artifact: "📄 產出", +}; const canvas = document.getElementById("sprite"); const ctx = canvas.getContext("2d", { willReadFrequently: true }); @@ -73,6 +90,7 @@ const look = { idx: null, lastMoveAt: 0 }; // 播放中的序列:frames = [{row, col, ms}],loopStart 為 null 表示播完就結束 let player = { anim: null, frames: [], loopStart: 0, step: 0, stepStart: performance.now() }; let prevBase = "idle"; +let lastNarrationId = null; // 同一段說明只播報一次 let lookCells = null; // 16 個 look 格子的量測結果(含缺陷補正倍率) // 一輪原始動作 @@ -301,6 +319,48 @@ function resolveAnim() { return base; } +// 助理的說明是 markdown,直接塞進氣泡會看到 ** 和反引號 +function stripMarkdown(text) { + return String(text) + .replace(/```[\s\S]*?```/g, " ") // 整段程式碼 + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") // 連結與圖片只留文字 + .replace(/`([^`]*)`/g, "$1") + .replace(/\*\*([^*]*)\*\*/g, "$1") + .replace(/(^|\s)[*_]([^*_\s][^*_]*)[*_](?=\s|$)/g, "$1$2") + .replace(/^\s{0,3}#{1,6}\s+/gm, "") // 標題 + .replace(/^\s{0,3}[-*+>|]\s+/gm, "") // 清單與引言 + .replace(/[ \t]+/g, " "); +} + +// 只取第一句,氣泡是單行,塞整段沒有意義 +function firstSentence(text) { + const line = stripMarkdown(text).split(/\r?\n/).find((l) => l.trim()) || ""; + const m = line.trim().match(/^[^。!?!?]{1,60}[。!?!?]?/); + return (m ? m[0] : line.trim()).slice(0, 60); +} + +// 指令取前半段、路徑只取檔名,其餘截斷 +function shortTarget(target, toolName) { + const t = String(target).trim(); + if (toolName === "Bash") return t.slice(0, 44); + if (/[\\/]/.test(t) && !/\s/.test(t)) return t.split(/[\\/]/).pop().slice(0, 40); + return t.slice(0, 44); +} + +// 有什麼就顯示什麼:優先顯示新的助理說明,否則顯示正在用的工具 +function sayActivity(p) { + if (baseState() === "waiting") return; // 別蓋掉在等授權的固定氣泡 + if (p.narration && p.narrationId && p.narrationId !== lastNarrationId) { + lastNarrationId = p.narrationId; + say(`💬 ${firstSentence(p.narration)}`, ACTIVITY_MS); + return; + } + if (!p.toolName) return; + const label = TOOL_LABELS[p.toolName] || `🔧 ${p.toolName}`; + const detail = p.toolName === "Task" && p.agentType ? p.agentType : p.toolTarget; + say(detail ? `${label} ${shortTarget(detail, p.toolName)}` : label, ACTIVITY_MS); +} + function projectName(cwd) { if (!cwd) return ""; return cwd.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || ""; @@ -352,6 +412,7 @@ function handleEvent(p) { say(`📋 計畫等你確認${suffix}`, 0, true); } else { setState(s, "running"); + sayActivity(p); } break; case "PostToolUse":