Files
JianMiauandClaude Fable 5 4e4563e861 完成時氣泡改顯示最後一句回覆,不再延到下一輪才出現
根本原因:
Stop 事件時 main.js 已從 transcript 附上最後一句助理訊息(narration),
但 renderer 的 Stop 分支寫死顯示「完成!」沒使用它,也沒把 narrationId
標記為已顯示;下一輪第一個工具事件呼叫 sayActivity 時,transcript 裡
最新的助理訊息仍是上一輪結尾、id 又沒被消費過,就被當成新話顯示出來。

影響:
對話收尾只看得到「完成!」,看不到 AI 的最後一句回覆;等使用者送出
下一句指令後,寵物反而冒出上一輪的舊訊息,時序錯亂。

修法:
Stop 時若帶有 narration,氣泡直接顯示「 最後一句」並消費 narrationId,
沒有才退回「完成!」;UserPromptSubmit 也先把當下 narrationId 標記為已
顯示,雙重保險。另修 release.js 只挑檔名含當前版本的產物,避免 dist/
殘留的舊版檔案混進新 release。版號 2.0.9。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 16:40:32 +08:00

230 lines
8.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// 把 dist/ 的產物發佈到 Gitea 的 Releases。
//
// set GITEA_TOKEN=<token> PowerShell$env:GITEA_TOKEN="<token>"
// npm run release 實際發佈
// npm run release -- --dry-run 只檢查,不做任何寫入
//
// token 需要 write:repository 權限。owner/repo 由 git remote 推導,
// 網址預設為 https://<remote 主機>SSH 埠與網頁埠不同時用 GITEA_URL 覆寫。
const fs = require("node:fs");
const http = require("node:http");
const https = require("node:https");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const APP_DIR = path.resolve(__dirname, "..");
const DIST_DIR = path.join(APP_DIR, "dist");
const DRY_RUN = process.argv.includes("--dry-run");
const TOKEN = process.env.GITEA_TOKEN || "";
const pkg = JSON.parse(fs.readFileSync(path.join(APP_DIR, "package.json"), "utf8"));
const TAG = `v${pkg.version}`;
// 要上傳的產物;.blockmap 只有 electron-updater 的差分更新才需要,目前沒用到
const ASSET_PATTERNS = [/\.exe$/i, /\.zip$/i];
function git(...args) {
// stderr 丟掉:releaseNotes 會刻意去 describe 一個可能不存在的舊 tag,
// 讓 git 的 fatal 訊息印出來會被誤認成發佈失敗
return execFileSync("git", args, { cwd: APP_DIR, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
}
function remoteInfo() {
const url = git("remote", "get-url", "origin");
const m = url.match(/^(?:ssh:\/\/(?:[^@]+@)?|(?:https?:\/\/)(?:[^@]+@)?|(?:[^@]+@))([^:/]+)(?::\d+)?[:/](.+?)\/([^/]+?)(?:\.git)?$/);
if (!m) throw new Error(`看不懂 remote 網址:${url}`);
return { host: m[1], owner: m[2], repo: m[3] };
}
// stream 模式:先送 head,再串流檔案,最後以 tail 收尾。
// 產物上百 MB,不能整份讀進記憶體。
function request(method, url, { headers = {}, body = null, head = null, tail = null, stream = null, size = 0 } = {}) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const lib = u.protocol === "https:" ? https : http;
const req = lib.request(u, { method, headers }, (res) => {
let text = "";
res.setEncoding("utf8");
res.on("data", (c) => { text += c; });
res.on("end", () => {
let json = null;
try { json = text ? JSON.parse(text) : null; } catch { /* 非 JSON 就留 null */ }
resolve({ status: res.statusCode, json, text });
});
});
req.on("error", reject);
if (!stream) {
req.end(body);
return;
}
req.write(head);
let sent = 0;
let lastPct = -1;
stream.on("data", (chunk) => {
sent += chunk.length;
const pct = Math.floor((sent / size) * 100);
if (pct >= lastPct + 10) { lastPct = pct; process.stdout.write(`\r 上傳中 ${pct}% `); }
});
stream.on("error", reject);
stream.pipe(req, { end: false });
stream.on("end", () => req.end(tail));
});
}
function apiUrl(base, owner, repo, suffix) {
return `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${suffix}`;
}
async function api(method, url, payload) {
const headers = { Accept: "application/json" };
if (TOKEN) headers.Authorization = `token ${TOKEN}`;
let body = null;
if (payload !== undefined) {
body = Buffer.from(JSON.stringify(payload), "utf8");
headers["Content-Type"] = "application/json";
headers["Content-Length"] = body.length;
}
return request(method, url, { headers, body });
}
async function uploadAsset(base, owner, repo, releaseId, filePath) {
const name = path.basename(filePath);
const size = fs.statSync(filePath).size;
const boundary = `----claudepet${Date.now().toString(16)}`;
const head = Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="attachment"; filename="${name}"\r\n` +
"Content-Type: application/octet-stream\r\n\r\n",
"utf8",
);
const tail = Buffer.from(`\r\n--${boundary}--\r\n`, "utf8");
const url = `${apiUrl(base, owner, repo, `releases/${releaseId}/assets`)}?name=${encodeURIComponent(name)}`;
const res = await request("POST", url, {
headers: {
Authorization: `token ${TOKEN}`,
Accept: "application/json",
"Content-Type": `multipart/form-data; boundary=${boundary}`,
"Content-Length": head.length + size + tail.length,
},
head,
tail,
stream: fs.createReadStream(filePath),
size,
});
process.stdout.write("\r");
return res;
}
function releaseNotes() {
let range = "";
try {
const prev = git("describe", "--tags", "--abbrev=0", `${TAG}^`);
range = `${prev}..HEAD`;
} catch {
try { git("rev-parse", "HEAD"); range = "HEAD"; } catch { range = ""; }
}
try {
const log = range === "HEAD"
? git("log", "-n", "30", "--pretty=- %s") // 沒有舊 tag(第一次發佈)就取最近 30 筆
: git("log", range, "--pretty=- %s");
return log || `Claude Pet ${TAG}`;
} catch {
return `Claude Pet ${TAG}`;
}
}
async function main() {
const { host, owner, repo } = remoteInfo();
const base = (process.env.GITEA_URL || `https://${host}`).replace(/\/+$/, "");
console.log(`Gitea : ${base}`);
console.log(`Repo : ${owner}/${repo}`);
console.log(`Tag : ${TAG}`);
if (!fs.existsSync(DIST_DIR)) throw new Error(`找不到 ${DIST_DIR},先執行 npm run dist`);
// dist/ 可能留著前幾版的產物,只挑檔名含當前版本的,免得舊檔混進新 release
const files = fs.readdirSync(DIST_DIR)
.filter((f) => ASSET_PATTERNS.some((re) => re.test(f)) && f.includes(pkg.version))
.map((f) => path.join(DIST_DIR, f))
.filter((f) => fs.statSync(f).isFile());
if (!files.length) throw new Error(`${DIST_DIR} 裡沒有 .exe / .zip,先執行 npm run dist`);
console.log("產物 :");
for (const f of files) console.log(` ${path.basename(f)} ${(fs.statSync(f).size / 1048576).toFixed(1)} MB`);
// 附件限制:超過就先講清楚,不要傳到一半才失敗
const settings = await request("GET", `${base}/api/v1/settings/attachment`, { headers: { Accept: "application/json" } });
if (settings.json) {
const maxMB = Number(settings.json.max_size);
const types = String(settings.json.allowed_types || "");
const anyType = types.includes("*/*") || types.trim() === "";
const problems = [];
for (const f of files) {
const sizeMB = fs.statSync(f).size / 1048576;
if (maxMB && sizeMB > maxMB) problems.push(`${path.basename(f)}${sizeMB.toFixed(1)} MB,超過上限 ${maxMB} MB`);
const ext = path.extname(f).toLowerCase();
if (!anyType && !types.split(",").map((t) => t.trim().toLowerCase()).includes(ext)) {
problems.push(`${path.basename(f)} 的副檔名 ${ext} 不在 allowed_types 內`);
}
}
if (problems.length) {
console.error("\n伺服器的附件設定擋住這次發佈:");
for (const p of problems) console.error(` - ${p}`);
console.error("\n請在 Gitea 的 app.ini 調整後重啟:");
console.error(" [attachment]\n MAX_SIZE = 300\n ALLOWED_TYPES = */*");
process.exitCode = 1;
return;
}
}
const existing = await api("GET", apiUrl(base, owner, repo, `releases/tags/${encodeURIComponent(TAG)}`));
if (DRY_RUN) {
console.log(`\n[dry-run] release ${TAG} ${existing.status === 200 ? "已存在,會重用並覆蓋同名附件" : "不存在,會新建"}`);
console.log("[dry-run] 變更說明:");
console.log(releaseNotes().split("\n").map((l) => ` ${l}`).join("\n"));
console.log("\n[dry-run] 沒有做任何寫入。");
return;
}
if (!TOKEN) throw new Error("缺少 GITEA_TOKEN 環境變數(需要 write:repository 權限)");
let release = existing.json;
if (existing.status !== 200) {
const created = await api("POST", apiUrl(base, owner, repo, "releases"), {
tag_name: TAG,
target_commitish: git("rev-parse", "--abbrev-ref", "HEAD"),
name: `Claude Pet ${TAG}`,
body: releaseNotes(),
draft: false,
prerelease: false,
});
if (created.status !== 201) throw new Error(`建立 release 失敗(HTTP ${created.status}):${created.text}`);
release = created.json;
console.log(`\n已建立 release ${TAG}`);
} else {
console.log(`\nrelease ${TAG} 已存在,重用`);
}
for (const f of files) {
const name = path.basename(f);
const dup = (release.assets || []).find((a) => a.name === name);
if (dup) {
const del = await api("DELETE", apiUrl(base, owner, repo, `releases/${release.id}/assets/${dup.id}`));
console.log(` 移除舊的 ${name}HTTP ${del.status}`);
}
console.log(` 上傳 ${name}`);
const res = await uploadAsset(base, owner, repo, release.id, f);
if (res.status !== 201) throw new Error(`上傳 ${name} 失敗(HTTP ${res.status}):${res.text}`);
console.log(` 完成 ${name}`);
}
console.log(`\n發佈完成:${base}/${owner}/${repo}/releases/tag/${TAG}`);
}
main().catch((err) => {
console.error(err.message || err);
process.exitCode = 1;
});