@co0ontty/wand 1.25.2 → 1.25.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,6 +31,18 @@ wand web
31
31
 
32
32
  安装完成后打开浏览器访问终端中提示的地址即可。
33
33
 
34
+ ### 升级
35
+
36
+ 推荐用同一条一键脚本升级(脚本会自动停掉正在运行的 wand 进程、清理 npm 改名残留再装最新版):
37
+
38
+ ```bash
39
+ bash <(curl -Ls https://raw.githubusercontent.com/co0ontty/wand/master/install.sh)
40
+ ```
41
+
42
+ > 也可以直接在网页设置里点「更新」按钮,或在 TUI 模式按 `u`,wand 自己会调用同样的清理逻辑。Web 端点击更新后会自动重启服务,无需手动操作。
43
+
44
+ 如果以前装过 systemd 自启服务但还是 `Restart=on-failure`(v1.25.x 前的版本),重新进入 TUI 按一次 `i` 重装服务即可换成 `Restart=always`,自动更新后才能正确拉起新进程。
45
+
34
46
  ## 功能
35
47
 
36
48
  <p align="center">
@@ -0,0 +1,51 @@
1
+ /**
2
+ * npm 全局更新通用辅助。
3
+ *
4
+ * 共用于 server.ts 的 /api/update / performAutoUpdate,以及 TUI 的 installUpdate。
5
+ *
6
+ * 解决的核心问题:当 wand 进程正在运行(systemd/launchd/nohup/直接前台都算)时,
7
+ * `npm install -g @co0ontty/wand@latest` 会把旧包目录 rename 成 `.wand-XXXXXX` 备份。
8
+ * 如果安装中途失败,这个备份目录会留下,之后每次 npm install 都会因为目标 dest 已存在
9
+ * 报 `ENOTEMPTY: directory not empty, rename ...`。
10
+ *
11
+ * 我们的策略:每次 npm install 之前先清掉 `@co0ontty/.wand-*` 残留目录;
12
+ * 如果第一次安装仍然撞上 ENOTEMPTY,清理后重试;再不行就 uninstall + force install。
13
+ */
14
+ /**
15
+ * 解析当前 `npm root -g` 的目录。失败返回 null。
16
+ */
17
+ export declare function getNpmGlobalRoot(): string | null;
18
+ /**
19
+ * 清理上一次 npm install 失败留下的 `.wand-XXXXXX` 残留目录。
20
+ *
21
+ * 同步执行,best-effort:找不到 npm root、目录不存在、无权限删除等都不会抛错。
22
+ * 返回被清理的目录列表,方便调用方记录日志。
23
+ */
24
+ export declare function cleanupNpmLeftovers(): {
25
+ removed: string[];
26
+ errors: string[];
27
+ };
28
+ /**
29
+ * 异步版本的全局安装:
30
+ * 1. 清理残留
31
+ * 2. `npm install -g <pkg>`
32
+ * 3. 撞上 ENOTEMPTY/EEXIST:再清一次 + 重试一次
33
+ * 4. 再不行:`npm uninstall -g <pkg-no-tag>` + `npm install -g --force <pkg>`
34
+ *
35
+ * @param pkg 包名带版本,例如 `@co0ontty/wand@latest`
36
+ * @param timeoutMs 单次 npm 调用超时
37
+ * @param log 可选 logger,用来把过程写入控制台或前端日志
38
+ */
39
+ export declare function installPackageGloballyAsync(pkg: string, timeoutMs: number, log?: (line: string) => void): Promise<void>;
40
+ /**
41
+ * 同步版本,给 TUI installUpdate 用。
42
+ *
43
+ * 返回值兼容 spawnSync:包含最后一次尝试的 stdout/stderr/status。
44
+ */
45
+ export declare function installPackageGloballySync(pkg: string, timeoutMs: number): {
46
+ status: number | null;
47
+ stdout: string;
48
+ stderr: string;
49
+ attempts: string[];
50
+ };
51
+ export declare const NPM_UPDATE_PACKAGE_NAME = "@co0ontty/wand";
@@ -0,0 +1,171 @@
1
+ /**
2
+ * npm 全局更新通用辅助。
3
+ *
4
+ * 共用于 server.ts 的 /api/update / performAutoUpdate,以及 TUI 的 installUpdate。
5
+ *
6
+ * 解决的核心问题:当 wand 进程正在运行(systemd/launchd/nohup/直接前台都算)时,
7
+ * `npm install -g @co0ontty/wand@latest` 会把旧包目录 rename 成 `.wand-XXXXXX` 备份。
8
+ * 如果安装中途失败,这个备份目录会留下,之后每次 npm install 都会因为目标 dest 已存在
9
+ * 报 `ENOTEMPTY: directory not empty, rename ...`。
10
+ *
11
+ * 我们的策略:每次 npm install 之前先清掉 `@co0ontty/.wand-*` 残留目录;
12
+ * 如果第一次安装仍然撞上 ENOTEMPTY,清理后重试;再不行就 uninstall + force install。
13
+ */
14
+ import { exec, spawnSync } from "node:child_process";
15
+ import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
16
+ import path from "node:path";
17
+ import { promisify } from "node:util";
18
+ const execAsync = promisify(exec);
19
+ const PACKAGE_NAME = "@co0ontty/wand";
20
+ const PACKAGE_SCOPE = "@co0ontty";
21
+ const PACKAGE_BASENAME = "wand";
22
+ /**
23
+ * 解析当前 `npm root -g` 的目录。失败返回 null。
24
+ */
25
+ export function getNpmGlobalRoot() {
26
+ try {
27
+ const res = spawnSync("npm", ["root", "-g"], { encoding: "utf8", timeout: 10_000 });
28
+ if (res.status !== 0)
29
+ return null;
30
+ const out = (res.stdout || "").trim();
31
+ return out.length > 0 ? out : null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ /**
38
+ * 清理上一次 npm install 失败留下的 `.wand-XXXXXX` 残留目录。
39
+ *
40
+ * 同步执行,best-effort:找不到 npm root、目录不存在、无权限删除等都不会抛错。
41
+ * 返回被清理的目录列表,方便调用方记录日志。
42
+ */
43
+ export function cleanupNpmLeftovers() {
44
+ const removed = [];
45
+ const errors = [];
46
+ const root = getNpmGlobalRoot();
47
+ if (!root)
48
+ return { removed, errors };
49
+ const scopeDir = path.join(root, PACKAGE_SCOPE);
50
+ if (!existsSync(scopeDir))
51
+ return { removed, errors };
52
+ let entries;
53
+ try {
54
+ entries = readdirSync(scopeDir);
55
+ }
56
+ catch (err) {
57
+ errors.push(`readdir ${scopeDir}: ${err instanceof Error ? err.message : String(err)}`);
58
+ return { removed, errors };
59
+ }
60
+ // 残留目录形如 `.wand-PdFXStca`:以点开头 + 包基名 + 短横线 + 随机后缀
61
+ const leftoverPattern = new RegExp(`^\\.${PACKAGE_BASENAME}-[A-Za-z0-9]+$`);
62
+ for (const name of entries) {
63
+ if (!leftoverPattern.test(name))
64
+ continue;
65
+ const fullPath = path.join(scopeDir, name);
66
+ try {
67
+ // 仅清理目录,避免误删同名文件
68
+ if (!statSync(fullPath).isDirectory())
69
+ continue;
70
+ rmSync(fullPath, { recursive: true, force: true });
71
+ removed.push(fullPath);
72
+ }
73
+ catch (err) {
74
+ errors.push(`rm ${fullPath}: ${err instanceof Error ? err.message : String(err)}`);
75
+ }
76
+ }
77
+ return { removed, errors };
78
+ }
79
+ /**
80
+ * 异步版本的全局安装:
81
+ * 1. 清理残留
82
+ * 2. `npm install -g <pkg>`
83
+ * 3. 撞上 ENOTEMPTY/EEXIST:再清一次 + 重试一次
84
+ * 4. 再不行:`npm uninstall -g <pkg-no-tag>` + `npm install -g --force <pkg>`
85
+ *
86
+ * @param pkg 包名带版本,例如 `@co0ontty/wand@latest`
87
+ * @param timeoutMs 单次 npm 调用超时
88
+ * @param log 可选 logger,用来把过程写入控制台或前端日志
89
+ */
90
+ export async function installPackageGloballyAsync(pkg, timeoutMs, log) {
91
+ const note = (line) => {
92
+ if (log)
93
+ log(line);
94
+ };
95
+ const cleanup = cleanupNpmLeftovers();
96
+ if (cleanup.removed.length > 0) {
97
+ note(`[wand] 清理 npm 残留目录: ${cleanup.removed.join(", ")}`);
98
+ }
99
+ try {
100
+ await execAsync(`npm install -g ${pkg}`, { timeout: timeoutMs });
101
+ return;
102
+ }
103
+ catch (error) {
104
+ const msg = error instanceof Error ? error.message : String(error);
105
+ if (!/ENOTEMPTY|EEXIST/.test(msg)) {
106
+ throw error;
107
+ }
108
+ note(`[wand] npm install 遇到 ENOTEMPTY/EEXIST,清理后重试一次...`);
109
+ }
110
+ cleanupNpmLeftovers();
111
+ try {
112
+ await execAsync(`npm install -g ${pkg}`, { timeout: timeoutMs });
113
+ return;
114
+ }
115
+ catch (error) {
116
+ const msg = error instanceof Error ? error.message : String(error);
117
+ if (!/ENOTEMPTY|EEXIST/.test(msg)) {
118
+ throw error;
119
+ }
120
+ note(`[wand] 重试仍失败,尝试先卸载再强制安装...`);
121
+ }
122
+ // 终极兜底:uninstall + force install
123
+ const baseName = pkg.replace(/@[^@/]*$/, ""); // strip @latest / @1.2.3
124
+ try {
125
+ await execAsync(`npm uninstall -g ${baseName}`, { timeout: timeoutMs });
126
+ }
127
+ catch {
128
+ /* 卸载失败也继续,下一步 --force 可能仍然能装上 */
129
+ }
130
+ cleanupNpmLeftovers();
131
+ await execAsync(`npm install -g --force ${pkg}`, { timeout: timeoutMs });
132
+ }
133
+ /**
134
+ * 同步版本,给 TUI installUpdate 用。
135
+ *
136
+ * 返回值兼容 spawnSync:包含最后一次尝试的 stdout/stderr/status。
137
+ */
138
+ export function installPackageGloballySync(pkg, timeoutMs) {
139
+ const attempts = [];
140
+ const tryInstall = (extra) => {
141
+ const args = ["install", "-g", ...extra, pkg];
142
+ attempts.push(`npm ${args.join(" ")}`);
143
+ const r = spawnSync("npm", args, { encoding: "utf8", timeout: timeoutMs });
144
+ return {
145
+ status: r.status,
146
+ stdout: r.stdout || "",
147
+ stderr: r.stderr || "",
148
+ };
149
+ };
150
+ cleanupNpmLeftovers();
151
+ let res = tryInstall([]);
152
+ if (res.status === 0)
153
+ return { ...res, attempts };
154
+ const hitENOTEMPTY = (r) => /ENOTEMPTY|EEXIST/.test(r.stdout + r.stderr);
155
+ if (!hitENOTEMPTY(res))
156
+ return { ...res, attempts };
157
+ cleanupNpmLeftovers();
158
+ res = tryInstall([]);
159
+ if (res.status === 0)
160
+ return { ...res, attempts };
161
+ if (!hitENOTEMPTY(res))
162
+ return { ...res, attempts };
163
+ // 终极兜底
164
+ const baseName = pkg.replace(/@[^@/]*$/, "");
165
+ attempts.push(`npm uninstall -g ${baseName}`);
166
+ spawnSync("npm", ["uninstall", "-g", baseName], { encoding: "utf8", timeout: timeoutMs });
167
+ cleanupNpmLeftovers();
168
+ res = tryInstall(["--force"]);
169
+ return { ...res, attempts };
170
+ }
171
+ export const NPM_UPDATE_PACKAGE_NAME = PACKAGE_NAME;
package/dist/server.js CHANGED
@@ -21,6 +21,7 @@ import { SessionLogger } from "./session-logger.js";
21
21
  import { StructuredSessionManager } from "./structured-session-manager.js";
22
22
  import { generatePwaManifest, generateServiceWorker } from "./pwa.js";
23
23
  import { getErrorMessage, registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
24
+ import { installPackageGloballyAsync } from "./npm-update-utils.js";
24
25
  import { registerUploadRoutes } from "./upload-routes.js";
25
26
  import { optimizePrompt, PromptOptimizeError } from "./prompt-optimizer.js";
26
27
  import { resolveDatabasePath, WandStorage } from "./storage.js";
@@ -1063,23 +1064,13 @@ export async function startServer(config, configPath) {
1063
1064
  res.status(500).json({ error: getErrorMessage(error, "保存证书失败。") });
1064
1065
  }
1065
1066
  });
1066
- // ── Global npm install with ENOTEMPTY fallback ──
1067
+ // ── Global npm install (with leftover cleanup + ENOTEMPTY fallback) ──
1068
+ // 把所有恢复逻辑下沉到 ./npm-update-utils,TUI 和 server 共用,确保自动更新、
1069
+ // /api/update、tui installUpdate 三处行为一致。
1067
1070
  async function npmInstallGlobal(pkg, timeoutMs) {
1068
- try {
1069
- await execAsync(`npm install -g ${pkg}`, { timeout: timeoutMs });
1070
- }
1071
- catch (error) {
1072
- const msg = getErrorMessage(error, "");
1073
- if (msg.includes("ENOTEMPTY")) {
1074
- // Running process holds files in the install dir; uninstall first, then reinstall with --force.
1075
- process.stdout.write(`[wand] npm install 遇到 ENOTEMPTY,尝试先卸载再安装...\n`);
1076
- await execAsync(`npm uninstall -g ${pkg}`, { timeout: timeoutMs });
1077
- await execAsync(`npm install -g --force ${pkg}`, { timeout: timeoutMs });
1078
- }
1079
- else {
1080
- throw error;
1081
- }
1082
- }
1071
+ await installPackageGloballyAsync(pkg, timeoutMs, (line) => {
1072
+ process.stdout.write(`${line}\n`);
1073
+ });
1083
1074
  }
1084
1075
  app.get("/api/check-update", async (_req, res) => {
1085
1076
  try {
@@ -1104,7 +1095,13 @@ export async function startServer(config, configPath) {
1104
1095
  return;
1105
1096
  }
1106
1097
  await npmInstallGlobal(`${PKG_NAME}@latest`, 120000);
1107
- res.json({ ok: true, message: `已更新到 ${latest},请重启 wand 服务以生效。` });
1098
+ // 装包成功后告知前端可以发起重启;前端会随即调用 /api/restart 完成自动重启。
1099
+ res.json({
1100
+ ok: true,
1101
+ message: `已更新到 ${latest}`,
1102
+ restartRequired: true,
1103
+ version: latest,
1104
+ });
1108
1105
  }
1109
1106
  catch (error) {
1110
1107
  res.status(500).json({ error: getErrorMessage(error, "更新失败。") });
@@ -1177,10 +1177,66 @@ export class StructuredSessionManager {
1177
1177
  const blocksByKey = new Map();
1178
1178
  const keyOrder = [];
1179
1179
  let toolResultSeq = 0;
1180
+ // 估算单个 ContentBlock 的"信息体积"——文字 / thinking / tool input 长度之和。
1181
+ // 用于 upsertBlocks 的防御性合并:同一 message.id 重发时,按位置取信息量更大的
1182
+ // 那个版本,保证已经吐出的文字 / tool_use input 不会被一条更短的同 id 事件
1183
+ // 整段覆盖。
1184
+ const blockVolume = (b) => {
1185
+ if (!b)
1186
+ return 0;
1187
+ const anyB = b;
1188
+ let total = 0;
1189
+ if (typeof anyB.text === "string")
1190
+ total += anyB.text.length;
1191
+ if (typeof anyB.thinking === "string")
1192
+ total += anyB.thinking.length;
1193
+ if (typeof anyB.content === "string")
1194
+ total += anyB.content.length;
1195
+ if (anyB.input) {
1196
+ try {
1197
+ total += JSON.stringify(anyB.input).length;
1198
+ }
1199
+ catch (_e) { /* ignore */ }
1200
+ }
1201
+ return total;
1202
+ };
1180
1203
  const upsertBlocks = (key, blocks) => {
1181
- if (!blocksByKey.has(key))
1204
+ const prev = blocksByKey.get(key);
1205
+ if (!prev) {
1182
1206
  keyOrder.push(key);
1183
- blocksByKey.set(key, blocks);
1207
+ blocksByKey.set(key, blocks);
1208
+ return;
1209
+ }
1210
+ // claude -p 在同一 message.id 的多次 assistant 事件里**应当**每次发出累加内容。
1211
+ // 但偶发会观察到某次重发只带其中一部分 block(典型场景:text 后又开始一段
1212
+ // tool_use,下一帧 text 字段意外为空字符串),导致先前已渲染的文本被覆盖
1213
+ // 为空——刷新页面后从持久化又恢复出来,就是用户反馈的"显示了又消失"。
1214
+ // 这里按 index 逐块取较"重"的版本,类型一致则取信息量大者,否则信任 incoming
1215
+ // 但**绝不**让 incoming 比 prev 的总块数少(短数组拼接 prev 的尾部)。
1216
+ const merged = [];
1217
+ const maxLen = Math.max(prev.length, blocks.length);
1218
+ for (let i = 0; i < maxLen; i++) {
1219
+ const a = prev[i];
1220
+ const b = blocks[i];
1221
+ if (a && !b) {
1222
+ merged.push(a);
1223
+ continue;
1224
+ }
1225
+ if (!a && b) {
1226
+ merged.push(b);
1227
+ continue;
1228
+ }
1229
+ if (a && b) {
1230
+ if (a.type === b.type) {
1231
+ merged.push(blockVolume(b) >= blockVolume(a) ? b : a);
1232
+ }
1233
+ else {
1234
+ // 类型变了(极少见,多半是上游 bug)——保留信息量大者,不丢字。
1235
+ merged.push(blockVolume(b) >= blockVolume(a) ? b : a);
1236
+ }
1237
+ }
1238
+ }
1239
+ blocksByKey.set(key, merged);
1184
1240
  };
1185
1241
  const rebuildTurnBlocks = () => {
1186
1242
  const flat = [];
@@ -26,7 +26,11 @@ export interface UpdateInfo {
26
26
  export declare function checkUpdate(currentVersion: string): UpdateInfo;
27
27
  /**
28
28
  * 执行 `npm install -g @co0ontty/wand@latest`。
29
+ *
29
30
  * 此调用同步阻塞(TUI 上层应在另一线程的 setImmediate 调度,或直接 await)。
31
+ * 通过 npm-update-utils 自动处理 `.wand-XXXXXX` 残留目录和 ENOTEMPTY 回退,
32
+ * 行为与 server.ts 的 /api/update / performAutoUpdate 保持一致。
33
+ *
30
34
  * 返回 npm 输出供调试。
31
35
  */
32
36
  export declare function installUpdate(): CommandResult;
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
9
9
  import os from "node:os";
10
10
  import path from "node:path";
11
11
  import process from "node:process";
12
+ import { installPackageGloballySync } from "../npm-update-utils.js";
12
13
  const PACKAGE_NAME = "@co0ontty/wand";
13
14
  // ─── 重启 ────────────────────────────────────────────────────────────────
14
15
  /**
@@ -52,26 +53,30 @@ export function checkUpdate(currentVersion) {
52
53
  }
53
54
  /**
54
55
  * 执行 `npm install -g @co0ontty/wand@latest`。
56
+ *
55
57
  * 此调用同步阻塞(TUI 上层应在另一线程的 setImmediate 调度,或直接 await)。
58
+ * 通过 npm-update-utils 自动处理 `.wand-XXXXXX` 残留目录和 ENOTEMPTY 回退,
59
+ * 行为与 server.ts 的 /api/update / performAutoUpdate 保持一致。
60
+ *
56
61
  * 返回 npm 输出供调试。
57
62
  */
58
63
  export function installUpdate() {
59
- const res = spawnSync("npm", ["install", "-g", `${PACKAGE_NAME}@latest`], {
60
- encoding: "utf8",
61
- timeout: 180_000,
62
- });
64
+ const res = installPackageGloballySync(`${PACKAGE_NAME}@latest`, 180_000);
63
65
  const out = (res.stdout || "") + (res.stderr ? "\n" + res.stderr : "");
66
+ const trail = res.attempts.length > 1
67
+ ? `\n\n尝试过的命令:\n ${res.attempts.join("\n ")}`
68
+ : "";
64
69
  if (res.status === 0) {
65
70
  return {
66
71
  ok: true,
67
72
  message: "更新已安装。按 [R] 重启以生效。",
68
- detail: out.trim(),
73
+ detail: (out.trim() + trail).trim(),
69
74
  };
70
75
  }
71
76
  return {
72
77
  ok: false,
73
78
  message: `npm install 失败 (exit ${res.status})`,
74
- detail: out.trim(),
79
+ detail: (out.trim() + trail).trim(),
75
80
  };
76
81
  }
77
82
  // ─── 打开浏览器 ─────────────────────────────────────────────────────────
@@ -358,6 +363,10 @@ function installSystemdUserService(ctx) {
358
363
  const unitPath = servicePath();
359
364
  const wandBin = resolveWandBin(ctx);
360
365
  const nodeBin = process.execPath;
366
+ // Restart=always 是关键:自动更新 / /api/restart 走的是 "spawn detached + exit 0",
367
+ // 在 systemd 用户服务的 cgroup 下,detached 子进程会随父进程一起被清理(默认
368
+ // KillMode=control-group),只有 Restart=always 能让 systemd 在 exit 0 后拉起
369
+ // 新进程,把更新真正生效。
361
370
  const unit = [
362
371
  "[Unit]",
363
372
  "Description=wand web console",
@@ -367,7 +376,7 @@ function installSystemdUserService(ctx) {
367
376
  "Type=simple",
368
377
  `ExecStart=${nodeBin} ${wandBin} web -c ${ctx.configPath}`,
369
378
  `Environment=WAND_NO_TUI=1`,
370
- "Restart=on-failure",
379
+ "Restart=always",
371
380
  "RestartSec=3",
372
381
  "",
373
382
  "[Install]",
@@ -1076,6 +1076,49 @@
1076
1076
  scheduleForegroundSync("android-resume", { immediate: true });
1077
1077
  ensureTerminalFitWithRetry("android-resume");
1078
1078
  });
1079
+
1080
+ // Bridge from Android IME animation. State values: "start" / "shown" / "hidden".
1081
+ // 原生层用 setPadding 在 WebView 父容器上 resize WebView, 视觉上键盘
1082
+ // 动画跟系统同步, 但带来一个副作用: window.innerHeight === visualViewport.height,
1083
+ // 导致 setupVisualViewportHandlers 里的 isKeyboardOpen 检测 (基于
1084
+ // offsetBottom) 永远是 false, 不会进 keyboard-open / keyboard-close 分支,
1085
+ // 终端 forceReplay 路径也就不跑了。
1086
+ //
1087
+ // 这里直接听原生层的"键盘动画收尾"事件, 触发 ensureTerminalFit
1088
+ // (forceReplay=true), 把 wterm 的网格按真实视口重排一遍。
1089
+ window.addEventListener("wand-ime-state", function(e) {
1090
+ var which = e && e.detail && e.detail.state;
1091
+ if (which === "shown" || which === "hidden") {
1092
+ try {
1093
+ ensureTerminalFit("native-ime-" + which, { forceReplay: true });
1094
+ maybeScrollTerminalToBottom("native-ime");
1095
+ } catch (_e) {}
1096
+ }
1097
+ });
1098
+
1099
+ // Bridge from Android ConnectivityManager.NetworkCallback. State values:
1100
+ // "available" — 默认网络刚刚可用 (启动期没网 → 接上)
1101
+ // "changed" — 已有网络切到另一个 (Wi-Fi ↔ 4G), socket 必死
1102
+ // "validated" — captive portal / VPN 验证完成, internet 才真正通
1103
+ // "lost" — 默认网络断了, 还没有备援网络
1104
+ // 前三种都强制重连; "lost" 不动 socket, 只更新 isOnline 让 UI 提示。
1105
+ // 这条路径比 navigator.online / visibilitychange 早 2-8 秒触发,
1106
+ // 切网后用户基本看不到断线提示。
1107
+ window.addEventListener("wand-android-network", function(e) {
1108
+ var which = e && e.detail && e.detail.state;
1109
+ if (which === "lost") {
1110
+ state.isOnline = false;
1111
+ try { updateOfflineBanner(); } catch (_e) {}
1112
+ return;
1113
+ }
1114
+ if (which === "available" || which === "changed" || which === "validated") {
1115
+ // 以原生信号为权威, 立刻翻 isOnline 给 UI; 有些 ROM 上
1116
+ // navigator.onLine 要等几秒才更新, 否则 banner 会闪一下。
1117
+ state.isOnline = true;
1118
+ try { updateOfflineBanner(); } catch (_e) {}
1119
+ forceReconnectWebSocket("android-network-" + which);
1120
+ }
1121
+ });
1079
1122
  }
1080
1123
 
1081
1124
  function restoreLoginSession() {
@@ -9156,16 +9199,27 @@
9156
9199
  })
9157
9200
  .then(function(res) { return res.json(); })
9158
9201
  .then(function(data) {
9202
+ if (data.error) {
9203
+ if (msgEl) {
9204
+ msgEl.textContent = data.error;
9205
+ msgEl.style.color = "var(--error)";
9206
+ msgEl.classList.remove("hidden");
9207
+ }
9208
+ updateBtn.disabled = false;
9209
+ return;
9210
+ }
9211
+ // \u5b89\u88c5\u6210\u529f\uff1a\u81ea\u52a8\u8c03\u7528 /api/restart\uff0c\u8ba9\u670d\u52a1\u91cd\u542f\u751f\u6548\uff0c
9212
+ // \u9875\u9762\u4f1a\u88ab restart overlay \u63a5\u624b\uff0c\u7b49\u540e\u7aef\u56de\u6765\u540e\u81ea\u52a8\u5237\u65b0\u3002
9159
9213
  if (msgEl) {
9160
- msgEl.textContent = data.message || data.error || "\u66f4\u65b0\u5b8c\u6210\u3002";
9161
- msgEl.style.color = data.error ? "var(--error)" : "var(--success)";
9214
+ msgEl.textContent = (data.message || "\u66f4\u65b0\u5b8c\u6210") + "\uff0c\u6b63\u5728\u91cd\u542f\u670d\u52a1\u2026";
9215
+ msgEl.style.color = "var(--success)";
9162
9216
  msgEl.classList.remove("hidden");
9163
9217
  }
9164
- if (data.error) {
9165
- updateBtn.disabled = false;
9218
+ updateBtn.classList.add("hidden");
9219
+ if (data.restartRequired !== false) {
9220
+ performRestart(null, msgEl);
9166
9221
  } else {
9167
- updateBtn.classList.add("hidden");
9168
- // Show restart button
9222
+ // \u670d\u52a1\u7aef\u660e\u786e\u8868\u793a\u4e0d\u9700\u8981\u91cd\u542f\uff0c\u4fdd\u7559\u624b\u52a8\u91cd\u542f\u6309\u94ae
9169
9223
  var restartBtn = document.getElementById("do-restart-button");
9170
9224
  if (restartBtn) restartBtn.classList.remove("hidden");
9171
9225
  }
@@ -10867,6 +10921,48 @@
10867
10921
  }
10868
10922
  }
10869
10923
 
10924
+ // 计算一条 ConversationTurn 里所有 content block 的"信息体积"——文字 / 思考 /
10925
+ // tool_result 内容长度之和。用于在 lastMessage 增量更新里判断 incoming 是否
10926
+ // 至少和 localLast 一样完整,防止服务端偶发吐出更短的同 message.id 导致
10927
+ // 已显示的文字段被回退覆盖("显示了又消失"的根因之一)。
10928
+ function turnContentVolume(turn) {
10929
+ if (!turn || !Array.isArray(turn.content)) return 0;
10930
+ var total = 0;
10931
+ for (var i = 0; i < turn.content.length; i++) {
10932
+ var b = turn.content[i];
10933
+ if (!b) continue;
10934
+ if (typeof b.text === "string") total += b.text.length;
10935
+ if (typeof b.thinking === "string") total += b.thinking.length;
10936
+ if (typeof b.content === "string") total += b.content.length;
10937
+ else if (Array.isArray(b.content)) {
10938
+ for (var k = 0; k < b.content.length; k++) {
10939
+ var sub = b.content[k];
10940
+ if (sub && typeof sub.text === "string") total += sub.text.length;
10941
+ }
10942
+ }
10943
+ if (b.input) {
10944
+ try { total += JSON.stringify(b.input).length; } catch (_e) {}
10945
+ }
10946
+ }
10947
+ return total;
10948
+ }
10949
+
10950
+ // 合并同 role 的 last assistant turn:incoming 通常是权威更新(包含完整 usage、
10951
+ // 完整 block 序列),但偶发的服务端回退会让 incoming 比 local 更短。此时
10952
+ // 保留本地内容——下一次正常 emit 会校正。usage 字段以 incoming 优先(因为
10953
+ // 那是 result event 给的最终值)。
10954
+ function mergeAssistantTurn(localLast, incoming) {
10955
+ if (!localLast) return incoming;
10956
+ if (!incoming) return localLast;
10957
+ var localVol = turnContentVolume(localLast);
10958
+ var incVol = turnContentVolume(incoming);
10959
+ if (incVol >= localVol) return incoming;
10960
+ // incoming 更短:保留 local 的 content,但允许 incoming 更新 usage / 元字段。
10961
+ return Object.assign({}, localLast, {
10962
+ usage: incoming.usage || localLast.usage,
10963
+ });
10964
+ }
10965
+
10870
10966
  // Append queued user message placeholders to currentMessages so they
10871
10967
  // remain visible across WS updates and re-renders.
10872
10968
  function buildMessagesForRender(session, messages) {
@@ -12868,20 +12964,31 @@
12868
12964
  // 这里在 visualViewport 检测到键盘收起的瞬间直接强制复位一次,
12869
12965
  // 并把 --app-viewport-height 兜底清掉,确保 .app-container 回到
12870
12966
  // 100dvh、input-panel 重新贴屏幕底部。
12967
+ //
12968
+ // Android APK (window.__wandImeNative=true) 跳过这段 iOS hack —
12969
+ // MainActivity 已经在 IME 动画 callback 里逐帧把 root setPadding,
12970
+ // WebView 直接被原生层 resize 推回到了正确位置, 这里再调
12971
+ // scrollTo(0,0) 反而会跟原生 padding 打架, 偶尔产生一帧抖。
12972
+ // 只保留 refit / 滚底两个纯展示动作。
12871
12973
  var rootEl = document.documentElement;
12872
- rootEl.style.removeProperty('--app-viewport-height');
12873
- window.scrollTo(0, 0);
12874
- if (document.scrollingElement) document.scrollingElement.scrollTop = 0;
12875
- rootEl.scrollTop = 0;
12876
- if (document.body) document.body.scrollTop = 0;
12877
- setTimeout(function() {
12878
- // 二次复位:等键盘收起动画 + iOS 自身的回滚跑完后再清一次,
12879
- // 防止 iOS 在动画过程中又把 scrollTop 推上去。
12974
+ var imeIsNative = !!window.__wandImeNative;
12975
+ if (!imeIsNative) {
12976
+ rootEl.style.removeProperty('--app-viewport-height');
12880
12977
  window.scrollTo(0, 0);
12881
12978
  if (document.scrollingElement) document.scrollingElement.scrollTop = 0;
12882
12979
  rootEl.scrollTop = 0;
12883
12980
  if (document.body) document.body.scrollTop = 0;
12884
- syncAppViewportHeight();
12981
+ }
12982
+ setTimeout(function() {
12983
+ if (!imeIsNative) {
12984
+ // 二次复位:等键盘收起动画 + iOS 自身的回滚跑完后再清一次,
12985
+ // 防止 iOS 在动画过程中又把 scrollTop 推上去。
12986
+ window.scrollTo(0, 0);
12987
+ if (document.scrollingElement) document.scrollingElement.scrollTop = 0;
12988
+ rootEl.scrollTop = 0;
12989
+ if (document.body) document.body.scrollTop = 0;
12990
+ syncAppViewportHeight();
12991
+ }
12885
12992
  ensureTerminalFit("keyboard-close", { forceReplay: true });
12886
12993
  maybeScrollTerminalToBottom("force");
12887
12994
  }, 200);
@@ -13500,23 +13607,30 @@
13500
13607
  snapshot.sessionKind = msg.data.sessionKind;
13501
13608
  }
13502
13609
 
13503
- if (isIncremental && msg.data.lastMessage) {
13610
+ // 优先级修正:若同一事件里同时带 messages(全量)和 lastMessage(增量),
13611
+ // 让全量赢。WS 端的 debounce 已经会在跨形状时 flush,但保留这层
13612
+ // 客户端兜底,避免任何上游再合并出双载体事件时再次丢消息。
13613
+ if (msg.data.messages) {
13614
+ // Full mode (authoritative)
13615
+ snapshot.messages = msg.data.messages;
13616
+ } else if (isIncremental && msg.data.lastMessage) {
13504
13617
  // Incremental mode: merge lastMessage into existing session messages
13505
13618
  var existingSession = state.sessions.find(function(s) { return s.id === msg.sessionId; });
13506
13619
  if (existingSession) {
13507
13620
  var msgs = Array.isArray(existingSession.messages) ? existingSession.messages.slice() : [];
13508
13621
  var expectedCount = msg.data.messageCount || 0;
13509
- // Replace last turn if same role, or append if new turn
13510
- if (msgs.length > 0 && msg.data.lastMessage.role && msgs[msgs.length - 1].role === msg.data.lastMessage.role) {
13511
- msgs[msgs.length - 1] = msg.data.lastMessage;
13622
+ // 防御性合并:lastMessage 应当至少和本地最后一条一样长。如果服务端
13623
+ // 因为上游 bug(如 upsertBlocks 整段覆盖)回退发来一条更短的同 role
13624
+ // 消息,保留本地版本——文字会被刷新或下一次 emit 修正。
13625
+ var localLast = msgs.length > 0 ? msgs[msgs.length - 1] : null;
13626
+ var incoming = msg.data.lastMessage;
13627
+ if (localLast && incoming.role && localLast.role === incoming.role) {
13628
+ msgs[msgs.length - 1] = mergeAssistantTurn(localLast, incoming);
13512
13629
  } else if (msgs.length < expectedCount) {
13513
- msgs.push(msg.data.lastMessage);
13630
+ msgs.push(incoming);
13514
13631
  }
13515
13632
  snapshot.messages = msgs;
13516
13633
  }
13517
- } else if (!isIncremental && msg.data.messages) {
13518
- // Full mode (backward compatible)
13519
- snapshot.messages = msg.data.messages;
13520
13634
  }
13521
13635
 
13522
13636
  // Fast path: chunk-only incremental events skip expensive chat update
@@ -14299,7 +14413,11 @@
14299
14413
  var renderWasAtBottom = isChatNearBottom(chatMessages);
14300
14414
  if (renderWasAtBottom) state.chatStickToBottom = true;
14301
14415
 
14302
- var existingCount = chatMessages.querySelectorAll(".chat-message").length;
14416
+ // .system-info 卡片从计数里剔除——它由 extractPtySystemInfo 在
14417
+ // fullRenderChat 里穿插注入,不存在于 messages 数组中,混进 existingCount
14418
+ // 会让 msgCount !== existingCount 永远为真,每帧都走 fullRenderChat,从而
14419
+ // 不断 wipe innerHTML,触发"莫名其妙跳到最上面"的视觉错位。
14420
+ var existingCount = chatMessages.querySelectorAll(".chat-message:not(.system-info)").length;
14303
14421
  // Full render when: forced, no existing messages, or message count decreased/changed
14304
14422
  var needsFullRender = forceRender || existingCount === 0 || msgCount !== existingCount;
14305
14423
 
@@ -14347,6 +14465,29 @@
14347
14465
  '</div>';
14348
14466
  }
14349
14467
 
14468
+ // 在 innerHTML 整段重写前,先记下当前视口里"最靠近顶部边缘"的那条消息
14469
+ // 的 data-msg-index 和它到容器顶部的偏移。重写完成后找到同一 data-msg-index
14470
+ // 的新节点,把它放回原来的偏移——这是 column-reverse 下保住用户视线的
14471
+ // 标准锚点法。没有锚点时(首次渲染、空 → 非空)才走 scrollTop=0 兜底。
14472
+ var anchorMsgIndex = -1;
14473
+ var anchorOffset = 0;
14474
+ if (prevMsgCount > 0 && !renderWasAtBottom) {
14475
+ var containerTop = chatMessages.getBoundingClientRect().top;
14476
+ var preEls = chatMessages.querySelectorAll(".chat-message:not(.system-info)");
14477
+ for (var pi = 0; pi < preEls.length; pi++) {
14478
+ var rect = preEls[pi].getBoundingClientRect();
14479
+ // 第一条 top >= containerTop 的就是视口内最靠上的可见消息
14480
+ if (rect.bottom >= containerTop) {
14481
+ var idxAttr = preEls[pi].getAttribute("data-msg-index");
14482
+ if (idxAttr != null) {
14483
+ anchorMsgIndex = parseInt(idxAttr, 10);
14484
+ anchorOffset = rect.top - containerTop;
14485
+ }
14486
+ break;
14487
+ }
14488
+ }
14489
+ }
14490
+
14350
14491
  chatMessages.innerHTML = html;
14351
14492
  // 给每条消息打 data-msg-index(用 state.currentMessages 的全局索引),
14352
14493
  // 后面 refreshChatUnreadDivider 用它找未读分割线的位置。
@@ -14371,6 +14512,21 @@
14371
14512
  // 同一会话内的全量重渲染:用户原本贴底就保持贴底,浏览器在 innerHTML
14372
14513
  // 重置后可能把 scrollTop 钳到一个奇怪的值,这里显式拉回 0。
14373
14514
  chatMessages.scrollTop = 0;
14515
+ } else if (anchorMsgIndex >= 0) {
14516
+ // 用户当前不在底部——根据保存的锚点恢复视图位置,避免被"踢到最上面"。
14517
+ var newAnchor = chatMessages.querySelector(
14518
+ '.chat-message[data-msg-index="' + anchorMsgIndex + '"]'
14519
+ );
14520
+ if (newAnchor) {
14521
+ var newContainerTop = chatMessages.getBoundingClientRect().top;
14522
+ var newRect = newAnchor.getBoundingClientRect();
14523
+ var delta = (newRect.top - newContainerTop) - anchorOffset;
14524
+ if (Math.abs(delta) > 0.5) {
14525
+ state.chatIsProgrammaticScroll = true;
14526
+ chatMessages.scrollTop += delta;
14527
+ requestAnimationFrame(function() { state.chatIsProgrammaticScroll = false; });
14528
+ }
14529
+ }
14374
14530
  }
14375
14531
  attachAllCopyHandlers(chatMessages);
14376
14532
  bindChatScrollListener();
@@ -14530,7 +14686,10 @@
14530
14686
  // Optimization: only re-render the newest N messages (column-reverse: first children)
14531
14687
  // that actually differ, starting from the top (newest). Most streaming updates only
14532
14688
  // touch the latest assistant turn, so we can skip scanning all older messages.
14533
- var existingEls = Array.from(chatMessages.querySelectorAll(".chat-message"));
14689
+ // 同样剔除 system-info 卡片,否则 existingEls 长度对不上 reversedMessages,
14690
+ // top-N 对照会拿 system-info 卡片去比真消息的 HTML,永远 replacedAny=false,
14691
+ // 触发 fullRenderChat 兜底分支——这是滚动跳顶的另一条触发路径。
14692
+ var existingEls = Array.from(chatMessages.querySelectorAll(".chat-message:not(.system-info)"));
14534
14693
  var reversedMessages = messages.slice().reverse();
14535
14694
  var replacedAny = false;
14536
14695
  // Scan from newest (index 0 in reversed) up to MAX_STREAMING_SCAN messages
@@ -17649,29 +17808,29 @@
17649
17808
  })
17650
17809
  .then(function(res) { return res.json(); })
17651
17810
  .then(function(data) {
17652
- setProgress(false);
17653
- card.classList.remove("is-busy");
17654
17811
  if (data.error) {
17655
17812
  // Update failed
17813
+ setProgress(false);
17814
+ card.classList.remove("is-busy");
17656
17815
  setSubtitle("\u66f4\u65b0\u672a\u5b8c\u6210");
17657
17816
  setStatus(data.error, "error");
17658
17817
  actionBtn.disabled = false;
17659
17818
  if (actionLabel) actionLabel.textContent = "\u91cd\u8bd5";
17660
17819
  return;
17661
17820
  }
17662
- // Phase 2: Update succeeded, show restart button
17663
- setSubtitle(data.message || "\u66f4\u65b0\u5b8c\u6210\uff0c\u91cd\u542f\u540e\u751f\u6548");
17664
- setStatus("");
17821
+ // Phase 2: \u5b89\u88c5\u6210\u529f\uff0c\u81ea\u52a8\u8c03\u7528 /api/restart\uff0c\u7531 restart overlay \u63a5\u7ba1 UX\u3002
17665
17822
  card.classList.add("is-success");
17666
- if (actionLabel) actionLabel.textContent = "\u91cd\u542f\u751f\u6548";
17667
- actionBtn.disabled = false;
17668
- actionBtn.onclick = function() {
17669
- actionBtn.disabled = true;
17670
- if (actionLabel) actionLabel.textContent = "\u6b63\u5728\u91cd\u542f\u2026";
17671
- setSubtitle("\u670d\u52a1\u6b63\u5728\u91cd\u542f\u2026");
17672
- setProgress(true);
17673
- performRestartCard(actionBtn, actionLabel, subtitleEl, statusEl, progressEl);
17674
- };
17823
+ setSubtitle((data.message || "\u66f4\u65b0\u5b8c\u6210") + "\uff0c\u6b63\u5728\u91cd\u542f\u670d\u52a1\u2026");
17824
+ setStatus("");
17825
+ if (actionLabel) actionLabel.textContent = "\u6b63\u5728\u91cd\u542f\u2026";
17826
+ if (data.restartRequired === false) {
17827
+ setProgress(false);
17828
+ card.classList.remove("is-busy");
17829
+ actionBtn.disabled = false;
17830
+ if (actionLabel) actionLabel.textContent = "\u5df2\u5b8c\u6210";
17831
+ return;
17832
+ }
17833
+ performRestartCard(actionBtn, actionLabel, subtitleEl, statusEl, progressEl);
17675
17834
  })
17676
17835
  .catch(function() {
17677
17836
  setProgress(false);
@@ -78,23 +78,46 @@ export class WsBroadcastManager {
78
78
  if (event.type === "output") {
79
79
  const existing = this.outputDebounceCache.get(event.sessionId);
80
80
  if (existing) {
81
- clearTimeout(existing.timer);
82
- // Merge prev + cur. Cur takes precedence for identically-named fields,
83
- // but fields only present on prev (e.g. chunk while cur carries
84
- // messages, or messages while cur carries chunk) survive — the old
85
- // implementation silently dropped them.
86
81
  const prevData = existing.event.data ?? {};
87
82
  const curData = event.data ?? {};
88
- const merged = { ...prevData, ...curData };
89
- const prevChunk = prevData.chunk;
90
- const curChunk = curData.chunk;
91
- if (prevChunk && curChunk) {
92
- merged.chunk = prevChunk + curChunk;
83
+ // 跨"事件形状"不能简单 shallow-merge:
84
+ // - 全量事件(带 messages)+ 增量事件(带 lastMessage,不带 messages)
85
+ // 合并后变成 { messages, incremental: true, lastMessage }。客户端
86
+ // reducer 看到 incremental 就只读 lastMessage,把权威的 messages 丢掉,
87
+ // 表现是"刷新页面才出来的消失文字"。
88
+ // - 反过来:增量在前,全量在后,cur 覆盖 prev 后 incremental 仍为 true
89
+ // 而 messages 来自 cur——这种顺序原本是安全的,但风险一致就一起处理。
90
+ // 形状不一致时 flush 上一条立即广播,新事件单独开窗口。这样客户端永远
91
+ // 不会在一条 WS 消息里同时看到 messages 和 lastMessage 两种语义。
92
+ const prevHasMessages = "messages" in prevData && prevData.messages !== undefined;
93
+ const prevHasLastMsg = "lastMessage" in prevData && prevData.lastMessage !== undefined;
94
+ const curHasMessages = "messages" in curData && curData.messages !== undefined;
95
+ const curHasLastMsg = "lastMessage" in curData && curData.lastMessage !== undefined;
96
+ const shapeMismatch = (prevHasMessages && curHasLastMsg && !curHasMessages) ||
97
+ (prevHasLastMsg && curHasMessages && !curHasLastMsg);
98
+ if (shapeMismatch) {
99
+ clearTimeout(existing.timer);
100
+ this.outputDebounceCache.delete(event.sessionId);
101
+ this.broadcast(existing.event);
102
+ // Fall through to schedule cur on a fresh debounce window
93
103
  }
94
- else if (prevChunk && !curChunk) {
95
- merged.chunk = prevChunk;
104
+ else {
105
+ clearTimeout(existing.timer);
106
+ // Merge prev + cur. Cur takes precedence for identically-named fields,
107
+ // but fields only present on prev (e.g. chunk while cur carries
108
+ // messages, or messages while cur carries chunk) survive — the old
109
+ // implementation silently dropped them.
110
+ const merged = { ...prevData, ...curData };
111
+ const prevChunk = prevData.chunk;
112
+ const curChunk = curData.chunk;
113
+ if (prevChunk && curChunk) {
114
+ merged.chunk = prevChunk + curChunk;
115
+ }
116
+ else if (prevChunk && !curChunk) {
117
+ merged.chunk = prevChunk;
118
+ }
119
+ event = { ...event, data: merged };
96
120
  }
97
- event = { ...event, data: merged };
98
121
  }
99
122
  const timer = setTimeout(() => {
100
123
  this.outputDebounceCache.delete(event.sessionId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "1.25.2",
3
+ "version": "1.25.5",
4
4
  "description": "A web terminal for local CLI tools like Claude.",
5
5
  "type": "module",
6
6
  "bin": {