工作中氣泡顯示工具動作與助理說明
摘要: 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user