氣泡改成兩行,第一行顯示對話名稱或專案名

摘要:
氣泡上方加一行標題(/rename 設的對話名稱,沒設就用專案資料夾名),訊息移到第二行。

根本原因:
原本把專案名以「(專案名)」的形式接在訊息尾巴,訊息本身就短,
加了括號後更容易被氣泡的單行省略號截掉,而且同時開多個 session 時
要看到最後才知道是哪一個在叫你。

影響:
多 session 並行時不容易分辨氣泡是哪個專案發出的;訊息也被括號擠掉。

修法:
- index.html 氣泡內加 #bubble-title;style.css 把單行限制從 #bubble 移到
  #bubble-title 與 #bubble-text 各自身上,兩行各自省略,
  並以 :empty 讓沒有標題時整行收起來,不留空白。
- say() 增加第四個參數 title,事件處理處的八個呼叫點改傳標題、移除 suffix。
- main.js 的 latestNarration 改名為 readTranscript,同一次反向掃描同時取出
  助理說明與 custom-title(/rename 會把它寫進 transcript),兩者都拿到就提早結束;
  收到事件時把 sessionTitle 一併併進 payload。
- 標題優先序:對話名稱 > 專案資料夾名 > 不顯示。

驗證:
- 單元測試以真實 transcript 執行 readTranscript:取到對話名稱 "claude-pet"、
  同時取到助理說明、快取一致、路徑不存在或為 null 時回 null,6 項全過。
- 實機截圖確認兩行版面正確。截圖當下剛好收到另一個 session 的真實事件,
  標題顯示 "telegram-codex-bot"、第二行是該 session 的助理說明,
  等於用實際流量驗證了多 session 下的標題辨識。
- 以 ELECTRON_RUN_AS_NODE 讀打包後 app.asar,確認版本 2.0.5 與十項改動都在出貨檔案裡,
  包含舊的 suffix 已完全移除。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:09:01 +08:00
co-authored by Claude Fable 5
parent 95c9b97eb7
commit 93f635dd01
7 changed files with 78 additions and 33 deletions
+22 -11
View File
@@ -99,16 +99,19 @@ function appendLog(line) {
// Claude Code 從 2.1.238 起不再把 thinking 的文字寫進 transcript(只剩加密簽章),
// 但助理的可見說明(text 區塊)還在。讀檔尾就夠了,transcript 會長到好幾 MB。
const TRANSCRIPT_TAIL = 192 * 1024;
const narrationCache = new Map(); // path → { size, id, text }
const narrationCache = new Map(); // path → { size, value }
function latestNarration(file) {
// 回傳 { narration: { id, text } | null, title: string | null }
// title 取自 /rename 寫進 transcript 的 custom-title,沒有就讓呼叫端退回專案名
function readTranscript(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;
let narration = null;
let title = null;
try {
const start = Math.max(0, stat.size - TRANSCRIPT_TAIL);
const fd = fs.openSync(file, "r");
@@ -117,24 +120,29 @@ function latestNarration(file) {
fs.closeSync(fd);
const lines = buf.toString("utf8").split("\n");
if (start > 0) lines.shift(); // 第一行多半被切一半
for (let i = lines.length - 1; i >= 0; i--) {
// 由後往前找,先遇到的就是最新的;兩樣都拿到就停
for (let i = lines.length - 1; i >= 0 && !(narration && title); i--) {
const line = lines[i].trim();
if (!line) continue;
let entry;
try { entry = JSON.parse(line); } catch { continue; }
if (entry.type !== "assistant") continue;
if (!title && entry.type === "custom-title" && typeof entry.customTitle === "string" && entry.customTitle.trim()) {
title = entry.customTitle.trim().slice(0, 60);
continue;
}
if (narration || 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) };
narration = { id: entry.uuid || entry.timestamp || String(i), text: block.text.trim().slice(0, 400) };
break;
}
}
if (value) break;
}
} catch { /* 讀不到就算了,寵物不該因此出錯 */ }
const value = { narration, title };
narrationCache.set(file, { size: stat.size, value });
if (narrationCache.size > 32) narrationCache.delete(narrationCache.keys().next().value);
return value;
@@ -346,10 +354,13 @@ 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;
const info = readTranscript(payload.transcriptPath);
if (info) {
if (info.narration) {
payload.narration = info.narration.text;
payload.narrationId = info.narration.id;
}
if (info.title) payload.sessionTitle = info.title;
}
send("pet:event", payload);
res.writeHead(204);