根本原因: 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>
230 lines
8.9 KiB
JavaScript
230 lines
8.9 KiB
JavaScript
"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;
|
||
});
|