@co0ontty/wand 1.32.1 → 1.32.3
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/dist/path-repair.d.ts +78 -0
- package/dist/path-repair.js +374 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +23 -0
- package/dist/structured-session-manager.d.ts +11 -0
- package/dist/structured-session-manager.js +71 -12
- package/dist/web-ui/content/scripts.js +47 -70
- package/dist/web-ui/content/styles.css +61 -74
- package/package.json +1 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 运行时 PATH 自修复。
|
|
3
|
+
*
|
|
4
|
+
* 背景:用户把 wand 装成 systemd / launchd 服务时,service unit 文件里的 PATH
|
|
5
|
+
* 是"安装那一刻"的快照(见 src/tui/commands.ts:buildServicePath)。之后:
|
|
6
|
+
* - 用户切 node 版本(nvm/fnm/volta),claude 装到新的 bin 目录;
|
|
7
|
+
* - 或 `npm install -g @co0ontty/wand` 重装,但没重新跑 `wand service:install`,
|
|
8
|
+
* install.sh 只 `systemctl start`,unit 里的 PATH 一直是老的;
|
|
9
|
+
* - 或 claude 装到 `~/.bun/bin`、`~/.local/bin` 等当时不在 PATH 里的位置。
|
|
10
|
+
*
|
|
11
|
+
* 服务进程的 process.env.PATH 就是这份陈旧快照。process-manager 给 PTY 子进程
|
|
12
|
+
* 用 buildChildEnv(inheritEnv=true) 时把这份 PATH 直接透传,于是 spawn 出来的
|
|
13
|
+
* shell 里 `claude` 找不到 → "command not found"。
|
|
14
|
+
*
|
|
15
|
+
* 修复分两层(都在 startServer() 开头跑):
|
|
16
|
+
* 1. repairRuntimePath()(同步、cheap):扫常见工具链 bin 目录,把存在但 PATH
|
|
17
|
+
* 里缺的"追加"到 process.env.PATH 末尾。
|
|
18
|
+
* 2. deepRepairRuntimePath()(异步、贵一点):起一个 login shell 拉用户实际的
|
|
19
|
+
* PATH(覆盖 ~/.bashrc、~/.zshrc、nvm/fnm/volta 的 shell-init 等所有动态
|
|
20
|
+
* 逻辑),把里面 PATH 里我们还没的目录"前插"到 process.env.PATH 前面。这
|
|
21
|
+
* 是真正能修好"unit 里 PATH 太旧、用户 shell 里却好好的"这种场景的关键。
|
|
22
|
+
*
|
|
23
|
+
* 设计决策:
|
|
24
|
+
* - 同步那层只追加不前插:尊重用户已有顺序;异步那层愿意前插,因为它拿到的是
|
|
25
|
+
* 用户实际"会用的"PATH,比 unit 里的更可信。
|
|
26
|
+
* - 只加 existsSync 真实存在的:避免 PATH 里塞一堆死路径。
|
|
27
|
+
* - 用 path.delimiter:Windows 用 `;`,POSIX 用 `:`,跨平台安全。
|
|
28
|
+
* - 不重写 service unit:那需要 sudo,且语义太重;只在 install.sh 主动调
|
|
29
|
+
* `wand service:install` 时才重新烧 PATH。
|
|
30
|
+
* - login shell 用 -lc 跑,4 秒超时;失败就静默走同步路径,不阻塞启动。
|
|
31
|
+
*
|
|
32
|
+
* WAND_PATH_REPAIR_DISABLE=1 可以彻底关掉同步那层(极端情况下用户想完全控制 PATH 时用)。
|
|
33
|
+
* WAND_PATH_REPAIR_DEEP_DISABLE=1 只关掉 login shell 探测,但保留同步追加。
|
|
34
|
+
*/
|
|
35
|
+
export interface PathRepairResult {
|
|
36
|
+
/** 实际新追加进 PATH 的目录(按追加顺序)。 */
|
|
37
|
+
added: string[];
|
|
38
|
+
/** 关键命令的解析结果(PATH 修复后 `which` 出来的)。null = 没找到。 */
|
|
39
|
+
resolved: Record<string, string | null>;
|
|
40
|
+
/** 修复后的 PATH 整体;调试用。 */
|
|
41
|
+
finalPath: string;
|
|
42
|
+
/** login shell 探测阶段的状态:success / disabled / failed / skipped。 */
|
|
43
|
+
deepProbe: "success" | "disabled" | "failed" | "skipped";
|
|
44
|
+
/** 异步阶段产生的告警信息(login shell 超时、解析失败等)。 */
|
|
45
|
+
warnings: string[];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 把候选目录中"存在 + 未在 PATH 中"的追加到 process.env.PATH。
|
|
49
|
+
*
|
|
50
|
+
* 不会去重已经在 PATH 里的项(即使顺序不理想也保持原样),只往末尾补。
|
|
51
|
+
*/
|
|
52
|
+
export declare function repairRuntimePath(): PathRepairResult;
|
|
53
|
+
/**
|
|
54
|
+
* 在 repairRuntimePath() 同步追加完之后,再用 login shell 拉一份用户实际的 PATH
|
|
55
|
+
* 合并进来。这是真正能修好"unit 里 PATH 太旧、用户 shell 里 claude 好好的"那种
|
|
56
|
+
* 升级回归的关键——同步阶段只能扫已知路径,login shell 才能拿到 nvm/fnm/volta
|
|
57
|
+
* 这种动态注入的 PATH。
|
|
58
|
+
*
|
|
59
|
+
* 行为:
|
|
60
|
+
* - 跑 `${shell} -l -c '...'` 拉 $PATH 和 command -v claude/codex(4s 超时)
|
|
61
|
+
* - 把 login shell PATH 里我们还没收录的目录 **前插** 到 process.env.PATH(注意
|
|
62
|
+
* 是前插,不是追加 —— 它比 unit 里的更可信)
|
|
63
|
+
* - 失败时静默走同步那一版结果
|
|
64
|
+
*
|
|
65
|
+
* 接受一个已经跑过同步阶段的 result,在上面 mutate。
|
|
66
|
+
*/
|
|
67
|
+
export declare function deepRepairRuntimePath(result: PathRepairResult, opts?: {
|
|
68
|
+
shell?: string;
|
|
69
|
+
timeoutMs?: number;
|
|
70
|
+
}): Promise<PathRepairResult>;
|
|
71
|
+
/**
|
|
72
|
+
* 把修复结果格式化为一行可读摘要(startServer 启动日志用)。
|
|
73
|
+
*
|
|
74
|
+
* 例:
|
|
75
|
+
* `[wand] PATH augmented (+3 dirs); claude=/usr/local/bin/claude, codex=<missing>`
|
|
76
|
+
* `[wand] PATH already complete; claude=/Users/foo/.bun/bin/claude`
|
|
77
|
+
*/
|
|
78
|
+
export declare function formatPathRepairSummary(result: PathRepairResult): string;
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
/** 关键的 CLI 工具,会被诊断输出。 */
|
|
7
|
+
const PROBE_COMMANDS = ["claude", "codex"];
|
|
8
|
+
const DEEP_PROBE_TIMEOUT_MS = 4000;
|
|
9
|
+
/**
|
|
10
|
+
* 构造候选 bin 目录列表(按优先级,前面的更可信)。
|
|
11
|
+
*
|
|
12
|
+
* 顺序原则:
|
|
13
|
+
* 1. 当前 node 的 bin 目录(claude 大概率就装这里:`npm install -g claude` 把 bin
|
|
14
|
+
* 放在与 node 同 prefix 的 bin 目录);
|
|
15
|
+
* 2. 用户级常见 npm-global / 语言工具链路径;
|
|
16
|
+
* 3. Homebrew(macOS);
|
|
17
|
+
* 4. /usr/local/... 等系统标准路径兜底。
|
|
18
|
+
*/
|
|
19
|
+
function candidateBinDirs() {
|
|
20
|
+
const home = os.homedir();
|
|
21
|
+
const nodeBinDir = path.dirname(process.execPath);
|
|
22
|
+
const candidates = [
|
|
23
|
+
nodeBinDir,
|
|
24
|
+
path.join(home, ".npm-global", "bin"),
|
|
25
|
+
path.join(home, ".local", "bin"),
|
|
26
|
+
path.join(home, "bin"),
|
|
27
|
+
path.join(home, ".bun", "bin"),
|
|
28
|
+
path.join(home, ".volta", "bin"),
|
|
29
|
+
path.join(home, ".cargo", "bin"),
|
|
30
|
+
path.join(home, ".deno", "bin"),
|
|
31
|
+
path.join(home, ".pnpm"),
|
|
32
|
+
path.join(home, "Library", "pnpm"), // pnpm 在 macOS 默认这里
|
|
33
|
+
];
|
|
34
|
+
// nvm / fnm / n 这种多版本管理器:扫最新几个 node 版本的 bin 目录加进去。
|
|
35
|
+
// 不会拿单一"激活"版本(service 进程拿不到 shell init),所以宁可多塞几个;
|
|
36
|
+
// 重复目录会在 repairRuntimePath() 的 Set 里被去重。
|
|
37
|
+
for (const dir of scanNodeVersionManagerBins(home))
|
|
38
|
+
candidates.push(dir);
|
|
39
|
+
if (process.platform === "darwin") {
|
|
40
|
+
candidates.push("/opt/homebrew/bin", "/opt/homebrew/sbin");
|
|
41
|
+
}
|
|
42
|
+
candidates.push("/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin");
|
|
43
|
+
return candidates;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 扫各种 node 版本管理器的 bin 目录。返回的路径都不带 versions/<v>/bin 检查——
|
|
47
|
+
* 调用方负责 existsSync 过滤。
|
|
48
|
+
*
|
|
49
|
+
* 覆盖:
|
|
50
|
+
* - nvm: ~/.nvm/versions/node/<v>/bin
|
|
51
|
+
* - fnm: ~/.local/share/fnm/node-versions/<v>/installation/bin 或
|
|
52
|
+
* ~/Library/Application Support/fnm/node-versions/<v>/installation/bin
|
|
53
|
+
* - n(tj/n): /usr/local/n/versions/node/<v>/bin
|
|
54
|
+
*/
|
|
55
|
+
function scanNodeVersionManagerBins(home) {
|
|
56
|
+
const results = [];
|
|
57
|
+
// nvm
|
|
58
|
+
const nvmBase = path.join(home, ".nvm", "versions", "node");
|
|
59
|
+
for (const v of listLatestVersions(nvmBase, 4)) {
|
|
60
|
+
results.push(path.join(nvmBase, v, "bin"));
|
|
61
|
+
}
|
|
62
|
+
// fnm(Linux 默认 / macOS 默认)
|
|
63
|
+
const fnmCandidates = [
|
|
64
|
+
path.join(home, ".local", "share", "fnm", "node-versions"),
|
|
65
|
+
path.join(home, "Library", "Application Support", "fnm", "node-versions"),
|
|
66
|
+
];
|
|
67
|
+
for (const fnmBase of fnmCandidates) {
|
|
68
|
+
for (const v of listLatestVersions(fnmBase, 4)) {
|
|
69
|
+
results.push(path.join(fnmBase, v, "installation", "bin"));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// tj/n
|
|
73
|
+
const nBase = "/usr/local/n/versions/node";
|
|
74
|
+
for (const v of listLatestVersions(nBase, 4)) {
|
|
75
|
+
results.push(path.join(nBase, v, "bin"));
|
|
76
|
+
}
|
|
77
|
+
return results;
|
|
78
|
+
}
|
|
79
|
+
/** 列出 base 下"看起来像 node 版本号"的目录,按 semver 降序取前 N 个。 */
|
|
80
|
+
function listLatestVersions(base, limit) {
|
|
81
|
+
if (!existsSync(base))
|
|
82
|
+
return [];
|
|
83
|
+
try {
|
|
84
|
+
const entries = readdirSync(base);
|
|
85
|
+
return entries
|
|
86
|
+
.filter((e) => /^v?\d+\.\d+\.\d+/.test(e))
|
|
87
|
+
.sort((a, b) => semverCompare(b, a))
|
|
88
|
+
.slice(0, limit);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function semverCompare(a, b) {
|
|
95
|
+
const pa = a.replace(/^v/, "").split(/[.\-+]/).map((x) => Number(x) || 0);
|
|
96
|
+
const pb = b.replace(/^v/, "").split(/[.\-+]/).map((x) => Number(x) || 0);
|
|
97
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
98
|
+
const da = pa[i] ?? 0;
|
|
99
|
+
const db = pb[i] ?? 0;
|
|
100
|
+
if (da !== db)
|
|
101
|
+
return da - db;
|
|
102
|
+
}
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 把候选目录中"存在 + 未在 PATH 中"的追加到 process.env.PATH。
|
|
107
|
+
*
|
|
108
|
+
* 不会去重已经在 PATH 里的项(即使顺序不理想也保持原样),只往末尾补。
|
|
109
|
+
*/
|
|
110
|
+
export function repairRuntimePath() {
|
|
111
|
+
if (process.env.WAND_PATH_REPAIR_DISABLE === "1") {
|
|
112
|
+
return {
|
|
113
|
+
added: [],
|
|
114
|
+
resolved: probeCommands(),
|
|
115
|
+
finalPath: process.env.PATH ?? "",
|
|
116
|
+
deepProbe: "disabled",
|
|
117
|
+
warnings: [],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const delim = path.delimiter;
|
|
121
|
+
const currentPath = process.env.PATH ?? "";
|
|
122
|
+
const existing = new Set(currentPath
|
|
123
|
+
.split(delim)
|
|
124
|
+
.map((seg) => seg.trim())
|
|
125
|
+
.filter((seg) => seg.length > 0));
|
|
126
|
+
const added = [];
|
|
127
|
+
for (const dir of candidateBinDirs()) {
|
|
128
|
+
if (!dir || existing.has(dir))
|
|
129
|
+
continue;
|
|
130
|
+
// existsSync 不抛错,权限不够 / 路径里有 EACCES 都返回 false,安全。
|
|
131
|
+
let ok = false;
|
|
132
|
+
try {
|
|
133
|
+
ok = existsSync(dir);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
ok = false;
|
|
137
|
+
}
|
|
138
|
+
if (!ok)
|
|
139
|
+
continue;
|
|
140
|
+
existing.add(dir);
|
|
141
|
+
added.push(dir);
|
|
142
|
+
}
|
|
143
|
+
if (added.length > 0) {
|
|
144
|
+
const suffix = added.join(delim);
|
|
145
|
+
process.env.PATH = currentPath
|
|
146
|
+
? `${currentPath}${delim}${suffix}`
|
|
147
|
+
: suffix;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
added,
|
|
151
|
+
resolved: probeCommands(),
|
|
152
|
+
finalPath: process.env.PATH ?? "",
|
|
153
|
+
deepProbe: "skipped",
|
|
154
|
+
warnings: [],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* 在 repairRuntimePath() 同步追加完之后,再用 login shell 拉一份用户实际的 PATH
|
|
159
|
+
* 合并进来。这是真正能修好"unit 里 PATH 太旧、用户 shell 里 claude 好好的"那种
|
|
160
|
+
* 升级回归的关键——同步阶段只能扫已知路径,login shell 才能拿到 nvm/fnm/volta
|
|
161
|
+
* 这种动态注入的 PATH。
|
|
162
|
+
*
|
|
163
|
+
* 行为:
|
|
164
|
+
* - 跑 `${shell} -l -c '...'` 拉 $PATH 和 command -v claude/codex(4s 超时)
|
|
165
|
+
* - 把 login shell PATH 里我们还没收录的目录 **前插** 到 process.env.PATH(注意
|
|
166
|
+
* 是前插,不是追加 —— 它比 unit 里的更可信)
|
|
167
|
+
* - 失败时静默走同步那一版结果
|
|
168
|
+
*
|
|
169
|
+
* 接受一个已经跑过同步阶段的 result,在上面 mutate。
|
|
170
|
+
*/
|
|
171
|
+
export async function deepRepairRuntimePath(result, opts = {}) {
|
|
172
|
+
if (process.env.WAND_PATH_REPAIR_DISABLE === "1") {
|
|
173
|
+
result.deepProbe = "disabled";
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
if (process.env.WAND_PATH_REPAIR_DEEP_DISABLE === "1") {
|
|
177
|
+
result.deepProbe = "disabled";
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
const shell = pickProbeShell(opts.shell);
|
|
181
|
+
if (!shell) {
|
|
182
|
+
result.warnings.push("跳过 login shell 探测:未找到可用 shell");
|
|
183
|
+
result.deepProbe = "skipped";
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
let probe;
|
|
187
|
+
try {
|
|
188
|
+
probe = await probeLoginShell(shell, opts.timeoutMs ?? DEEP_PROBE_TIMEOUT_MS);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
result.warnings.push(`login shell 探测失败 (${shell}): ${err instanceof Error ? err.message : String(err)}`);
|
|
192
|
+
result.deepProbe = "failed";
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
const delim = path.delimiter;
|
|
196
|
+
const existing = new Set((process.env.PATH ?? "")
|
|
197
|
+
.split(delim)
|
|
198
|
+
.map((seg) => seg.trim())
|
|
199
|
+
.filter((seg) => seg.length > 0));
|
|
200
|
+
// login shell 报告的所有 PATH 段 + claude/codex 解析出的目录都纳入候选。
|
|
201
|
+
const fromShell = [];
|
|
202
|
+
for (const seg of probe.path.split(delim).map((s) => s.trim()).filter(Boolean)) {
|
|
203
|
+
fromShell.push(seg);
|
|
204
|
+
}
|
|
205
|
+
if (probe.claude)
|
|
206
|
+
fromShell.push(path.dirname(probe.claude));
|
|
207
|
+
if (probe.codex)
|
|
208
|
+
fromShell.push(path.dirname(probe.codex));
|
|
209
|
+
const additions = [];
|
|
210
|
+
for (const dir of fromShell) {
|
|
211
|
+
if (!dir || existing.has(dir))
|
|
212
|
+
continue;
|
|
213
|
+
let ok = false;
|
|
214
|
+
try {
|
|
215
|
+
ok = existsSync(dir);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
ok = false;
|
|
219
|
+
}
|
|
220
|
+
if (!ok)
|
|
221
|
+
continue;
|
|
222
|
+
existing.add(dir);
|
|
223
|
+
additions.push(dir);
|
|
224
|
+
}
|
|
225
|
+
if (additions.length > 0) {
|
|
226
|
+
// 前插:login shell 的 PATH 优先级比 unit 写死的高,让 claude 解析到用户期望的版本。
|
|
227
|
+
const prefix = additions.join(delim);
|
|
228
|
+
const currentPath = process.env.PATH ?? "";
|
|
229
|
+
process.env.PATH = currentPath ? `${prefix}${delim}${currentPath}` : prefix;
|
|
230
|
+
result.added.push(...additions);
|
|
231
|
+
}
|
|
232
|
+
result.finalPath = process.env.PATH ?? "";
|
|
233
|
+
// 修复完再 probe 一次,让 resolved 字段反映最终能找到的位置(之前 sync 阶段
|
|
234
|
+
// 可能 claude 还是 missing,login shell 注入 nvm 目录后这次能命中)。
|
|
235
|
+
result.resolved = probeCommands();
|
|
236
|
+
result.deepProbe = "success";
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
function pickProbeShell(configured) {
|
|
240
|
+
const candidates = [
|
|
241
|
+
configured,
|
|
242
|
+
process.env.SHELL,
|
|
243
|
+
"/bin/bash",
|
|
244
|
+
"/usr/bin/bash",
|
|
245
|
+
"/bin/zsh",
|
|
246
|
+
"/usr/bin/zsh",
|
|
247
|
+
"/bin/sh",
|
|
248
|
+
].filter((s) => typeof s === "string" && s.length > 0);
|
|
249
|
+
for (const c of candidates) {
|
|
250
|
+
if (existsSync(c))
|
|
251
|
+
return c;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* 用 login shell 跑一段最小脚本,拿到用户实际的 PATH 和 claude/codex 解析路径。
|
|
257
|
+
* 用 \x1f(ASCII Unit Separator)作字段分隔符避免和路径里的字符冲突。
|
|
258
|
+
*/
|
|
259
|
+
function probeLoginShell(shell, timeoutMs) {
|
|
260
|
+
const script = `printf 'PATH\\x1f%s\\n' "$PATH"; ` +
|
|
261
|
+
`printf 'CLAUDE\\x1f%s\\n' "$(command -v claude 2>/dev/null)"; ` +
|
|
262
|
+
`printf 'CODEX\\x1f%s\\n' "$(command -v codex 2>/dev/null)"`;
|
|
263
|
+
return new Promise((resolve, reject) => {
|
|
264
|
+
const child = spawn(shell, ["-l", "-c", script], {
|
|
265
|
+
env: {
|
|
266
|
+
...process.env,
|
|
267
|
+
// 防止 PROMPT_COMMAND / PS1 等钩子往 stdout 喷东西干扰解析
|
|
268
|
+
PS1: "",
|
|
269
|
+
PROMPT_COMMAND: "",
|
|
270
|
+
},
|
|
271
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
272
|
+
});
|
|
273
|
+
let stdout = "";
|
|
274
|
+
let stderr = "";
|
|
275
|
+
let settled = false;
|
|
276
|
+
const finalize = (fn) => {
|
|
277
|
+
if (settled)
|
|
278
|
+
return;
|
|
279
|
+
settled = true;
|
|
280
|
+
fn();
|
|
281
|
+
};
|
|
282
|
+
const timer = setTimeout(() => {
|
|
283
|
+
try {
|
|
284
|
+
child.kill("SIGTERM");
|
|
285
|
+
}
|
|
286
|
+
catch { /* noop */ }
|
|
287
|
+
finalize(() => reject(new Error(`login shell probe timed out after ${timeoutMs}ms`)));
|
|
288
|
+
}, timeoutMs);
|
|
289
|
+
child.stdout?.on("data", (b) => { stdout += b.toString(); });
|
|
290
|
+
child.stderr?.on("data", (b) => { stderr += b.toString(); });
|
|
291
|
+
child.on("error", (err) => {
|
|
292
|
+
clearTimeout(timer);
|
|
293
|
+
finalize(() => reject(err));
|
|
294
|
+
});
|
|
295
|
+
child.on("close", (code) => {
|
|
296
|
+
clearTimeout(timer);
|
|
297
|
+
finalize(() => {
|
|
298
|
+
if (!stdout && code !== 0) {
|
|
299
|
+
reject(new Error(`login shell exited ${code}: ${stderr.trim().slice(0, 200)}`));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const out = { path: "", claude: null, codex: null };
|
|
303
|
+
for (const line of stdout.split("\n")) {
|
|
304
|
+
const idx = line.indexOf("\x1f");
|
|
305
|
+
if (idx < 0)
|
|
306
|
+
continue;
|
|
307
|
+
const key = line.slice(0, idx);
|
|
308
|
+
const val = line.slice(idx + 1).trim();
|
|
309
|
+
if (!val)
|
|
310
|
+
continue;
|
|
311
|
+
if (key === "PATH")
|
|
312
|
+
out.path = val;
|
|
313
|
+
else if (key === "CLAUDE")
|
|
314
|
+
out.claude = val;
|
|
315
|
+
else if (key === "CODEX")
|
|
316
|
+
out.codex = val;
|
|
317
|
+
}
|
|
318
|
+
resolve(out);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
/** 用系统 `which` / `where` 查询关键命令。失败/没找到统一返回 null。 */
|
|
324
|
+
function probeCommands() {
|
|
325
|
+
const out = {};
|
|
326
|
+
for (const name of PROBE_COMMANDS) {
|
|
327
|
+
out[name] = whichSync(name);
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
function whichSync(cmd) {
|
|
332
|
+
// Windows 用 `where`,POSIX 用 `which`;都走 process.env.PATH,因此一定要在
|
|
333
|
+
// repairRuntimePath() 追加完 PATH 之后再调。
|
|
334
|
+
const tool = process.platform === "win32" ? "where" : "which";
|
|
335
|
+
try {
|
|
336
|
+
const res = spawnSync(tool, [cmd], { encoding: "utf8" });
|
|
337
|
+
if (res.status !== 0)
|
|
338
|
+
return null;
|
|
339
|
+
const first = (res.stdout || "").split(/\r?\n/).find((line) => line.trim().length > 0);
|
|
340
|
+
return first ? first.trim() : null;
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* 把修复结果格式化为一行可读摘要(startServer 启动日志用)。
|
|
348
|
+
*
|
|
349
|
+
* 例:
|
|
350
|
+
* `[wand] PATH augmented (+3 dirs); claude=/usr/local/bin/claude, codex=<missing>`
|
|
351
|
+
* `[wand] PATH already complete; claude=/Users/foo/.bun/bin/claude`
|
|
352
|
+
*/
|
|
353
|
+
export function formatPathRepairSummary(result) {
|
|
354
|
+
const parts = [];
|
|
355
|
+
if (result.added.length > 0) {
|
|
356
|
+
parts.push(`PATH augmented (+${result.added.length} dirs: ${result.added.join(", ")})`);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
parts.push("PATH already complete");
|
|
360
|
+
}
|
|
361
|
+
const probes = [];
|
|
362
|
+
for (const [name, resolved] of Object.entries(result.resolved)) {
|
|
363
|
+
probes.push(`${name}=${resolved ?? "<missing>"}`);
|
|
364
|
+
}
|
|
365
|
+
if (probes.length > 0)
|
|
366
|
+
parts.push(probes.join(", "));
|
|
367
|
+
if (result.deepProbe === "failed" && result.warnings.length > 0) {
|
|
368
|
+
parts.push(`deep-probe: ${result.warnings[0]}`);
|
|
369
|
+
}
|
|
370
|
+
else if (result.deepProbe === "success") {
|
|
371
|
+
parts.push("deep-probe: ok");
|
|
372
|
+
}
|
|
373
|
+
return parts.join("; ");
|
|
374
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ProcessManager } from "./process-manager.js";
|
|
2
2
|
import { StructuredSessionManager } from "./structured-session-manager.js";
|
|
3
3
|
import { WandStorage } from "./storage.js";
|
|
4
|
+
import { type PathRepairResult } from "./path-repair.js";
|
|
4
5
|
import { WandConfig } from "./types.js";
|
|
5
6
|
/** Persist a cwd to recent paths. Used by both REST and session creation hooks. */
|
|
6
7
|
export declare function recordRecentPath(storage: WandStorage, cwd: string | undefined | null): void;
|
|
@@ -18,6 +19,7 @@ export interface ServerHandle {
|
|
|
18
19
|
httpsEnabled: boolean;
|
|
19
20
|
version: string;
|
|
20
21
|
orphanRecoveredCount: number;
|
|
22
|
+
pathRepair: PathRepairResult;
|
|
21
23
|
close(): Promise<void>;
|
|
22
24
|
}
|
|
23
25
|
export declare function startServer(config: WandConfig, configPath: string): Promise<ServerHandle>;
|
package/dist/server.js
CHANGED
|
@@ -25,6 +25,7 @@ import { installPackageGloballyAsync } from "./npm-update-utils.js";
|
|
|
25
25
|
import { registerUploadRoutes } from "./upload-routes.js";
|
|
26
26
|
import { optimizePrompt, PromptOptimizeError } from "./prompt-optimizer.js";
|
|
27
27
|
import { resolveDatabasePath, WandStorage } from "./storage.js";
|
|
28
|
+
import { deepRepairRuntimePath, formatPathRepairSummary, repairRuntimePath } from "./path-repair.js";
|
|
28
29
|
import { isLogBusActive, wandTuiLog } from "./tui/log-bus.js";
|
|
29
30
|
import { renderApp } from "./web-ui/index.js";
|
|
30
31
|
import { WsBroadcastManager } from "./ws-broadcast.js";
|
|
@@ -734,6 +735,27 @@ function mimeForExt(ext) {
|
|
|
734
735
|
return MIME_BY_EXT[ext.toLowerCase()] || "application/octet-stream";
|
|
735
736
|
}
|
|
736
737
|
export async function startServer(config, configPath) {
|
|
738
|
+
// 关键:在创建 ProcessManager / 任何 spawn 之前先修 PATH。
|
|
739
|
+
// 服务被注册为 systemd / launchd 时,unit 文件里的 PATH 是安装那一刻烧死的,
|
|
740
|
+
// 之后用户切 node 版本 / 重装 wand / 把 claude 装到新位置都不会更新 unit,
|
|
741
|
+
// 服务进程的 process.env.PATH 就长期 stale。这里追加常见工具链 bin 目录,
|
|
742
|
+
// 让 spawn 出的 PTY 子进程能找到 claude / codex。详见 src/path-repair.ts。
|
|
743
|
+
const pathRepair = repairRuntimePath();
|
|
744
|
+
// 同步追加完后还有一层兜底:起 login shell 拉用户实际 $PATH,把 nvm / fnm /
|
|
745
|
+
// volta 这类动态注入的目录也合并进来。失败会静默走 sync 结果,不阻塞启动。
|
|
746
|
+
try {
|
|
747
|
+
await deepRepairRuntimePath(pathRepair, { shell: config.shell });
|
|
748
|
+
}
|
|
749
|
+
catch {
|
|
750
|
+
// deepRepairRuntimePath 内部已经 catch 了所有异常并写到 result.warnings;
|
|
751
|
+
// 这里只是兜底,避免任何意外 throw 阻断启动。
|
|
752
|
+
}
|
|
753
|
+
if (pathRepair.added.length > 0
|
|
754
|
+
|| Object.values(pathRepair.resolved).some((v) => v === null)
|
|
755
|
+
|| pathRepair.warnings.length > 0) {
|
|
756
|
+
// 有改动 / 有命令没解析到 / 有告警时才打 log,避免正常启动刷屏。
|
|
757
|
+
process.stdout.write(`[wand] ${formatPathRepairSummary(pathRepair)}\n`);
|
|
758
|
+
}
|
|
737
759
|
const app = express();
|
|
738
760
|
const storage = new WandStorage(resolveDatabasePath(configPath));
|
|
739
761
|
setAuthStorage(storage);
|
|
@@ -2031,6 +2053,7 @@ export async function startServer(config, configPath) {
|
|
|
2031
2053
|
httpsEnabled: useHttps,
|
|
2032
2054
|
version: PKG_VERSION,
|
|
2033
2055
|
orphanRecoveredCount: processes.getOrphanRecoveredCount(),
|
|
2056
|
+
pathRepair,
|
|
2034
2057
|
close,
|
|
2035
2058
|
};
|
|
2036
2059
|
}
|
|
@@ -152,6 +152,17 @@ export declare class StructuredSessionManager {
|
|
|
152
152
|
private extractCodexText;
|
|
153
153
|
private extractCodexItemBlock;
|
|
154
154
|
private upsertCodexBlock;
|
|
155
|
+
/**
|
|
156
|
+
* 组装结构化 runner 退出失败时的可读错误字符串。
|
|
157
|
+
*
|
|
158
|
+
* 痛点:之前 claude -p / codex exec 异常退出只把"stderr.trim() || `... exited
|
|
159
|
+
* with code N`"塞给 UI。如果 stderr 是空的,用户在前端只能看到 "EXIT 1" 这种
|
|
160
|
+
* 没有任何上下文的串,根本不知道是网络错误、参数错误还是 binary 找不着。
|
|
161
|
+
*
|
|
162
|
+
* 这里固定把"provider + 退出码 / 信号"放在最前面,再把 stderr / NDJSON 错误
|
|
163
|
+
* 事件 / 最后一段 stdout 之类的上下文跟在后面,方便定位。
|
|
164
|
+
*/
|
|
165
|
+
private formatStructuredExitError;
|
|
155
166
|
private finishStructuredFailure;
|
|
156
167
|
private extractModelName;
|
|
157
168
|
private extractUsage;
|
|
@@ -1297,9 +1297,17 @@ export class StructuredSessionManager {
|
|
|
1297
1297
|
closedAt: new Date().toISOString(),
|
|
1298
1298
|
spawnError: error.message,
|
|
1299
1299
|
});
|
|
1300
|
-
|
|
1300
|
+
// spawn 直接失败(最常见是 ENOENT —— PATH 里找不到 codex 可执行文件)。
|
|
1301
|
+
// 之前只 reject(error),外层 catch 会把 error.message 直接当 lastError,
|
|
1302
|
+
// 用户看到的就是裸的 "spawn codex ENOENT",没法快速反应。这里加一层
|
|
1303
|
+
// 包装把上下文(runner 名 + 常见排查建议)拼好。
|
|
1304
|
+
const nodeErr = error;
|
|
1305
|
+
const hint = nodeErr.code === "ENOENT"
|
|
1306
|
+
? "(PATH 中找不到 codex 可执行文件;请确认 codex 已安装,或重跑 `wand service:install` 刷新服务的 PATH)"
|
|
1307
|
+
: "";
|
|
1308
|
+
reject(new Error(`codex exec 启动失败:${error.message}${hint}`));
|
|
1301
1309
|
});
|
|
1302
|
-
child.on("close", (code) => {
|
|
1310
|
+
child.on("close", (code, signal) => {
|
|
1303
1311
|
this.pendingChildren.delete(sessionId);
|
|
1304
1312
|
this.lastStreamSaveAt.delete(sessionId);
|
|
1305
1313
|
if (lineBuf.trim()) {
|
|
@@ -1329,11 +1337,12 @@ export class StructuredSessionManager {
|
|
|
1329
1337
|
// codex 把模型/网络/沙箱等错误写到 stdout 的 NDJSON 流(type: error / turn.failed),
|
|
1330
1338
|
// 而不是 stderr。我们以 turn.failed 的 message 为准,其次是最后一个 error 事件。
|
|
1331
1339
|
const codexFailed = codexTurnFailed !== null;
|
|
1332
|
-
if ((codexFailed || (code !== 0 && code !== null)) && !interruptedByUser) {
|
|
1333
|
-
const errorText = (
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1340
|
+
if ((codexFailed || (code !== 0 && code !== null) || signal) && !interruptedByUser) {
|
|
1341
|
+
const errorText = this.formatStructuredExitError("codex exec", code, signal, {
|
|
1342
|
+
stderr,
|
|
1343
|
+
primary: codexTurnFailed,
|
|
1344
|
+
extras: codexErrors,
|
|
1345
|
+
});
|
|
1337
1346
|
const exitForSnapshot = typeof code === "number" ? code : 1;
|
|
1338
1347
|
const failed = this.finishStructuredFailure(current, exitForSnapshot, errorText, turnState);
|
|
1339
1348
|
this.sessions.set(sessionId, failed);
|
|
@@ -1754,9 +1763,16 @@ export class StructuredSessionManager {
|
|
|
1754
1763
|
}
|
|
1755
1764
|
};
|
|
1756
1765
|
let stderr = "";
|
|
1766
|
+
// 兜底:当 stderr 是空、JSON 也没解析到任何错误事件时,把最后一段非空
|
|
1767
|
+
// stdout 文本作为上下文塞给错误信息。claude -p 偶尔会把 fatal error 以
|
|
1768
|
+
// 纯文本(非 JSON)打到 stdout 然后非零退出,之前的实现会丢掉这部分。
|
|
1769
|
+
let lastRawStdoutChunk = "";
|
|
1757
1770
|
child.stdout?.on("data", (chunk) => {
|
|
1758
1771
|
const text = chunk.toString();
|
|
1759
1772
|
this.logger?.appendStructuredStdout(sessionId, text);
|
|
1773
|
+
const trimmed = text.trim();
|
|
1774
|
+
if (trimmed)
|
|
1775
|
+
lastRawStdoutChunk = trimmed.slice(-1024);
|
|
1760
1776
|
lineBuf += text;
|
|
1761
1777
|
const lines = lineBuf.split("\n");
|
|
1762
1778
|
// Keep the last (possibly incomplete) segment in the buffer.
|
|
@@ -1782,9 +1798,14 @@ export class StructuredSessionManager {
|
|
|
1782
1798
|
closedAt: new Date().toISOString(),
|
|
1783
1799
|
spawnError: error.message,
|
|
1784
1800
|
});
|
|
1785
|
-
|
|
1801
|
+
// 同 codex 那边:spawn ENOENT 最常见,提示用户去 service:install 刷 PATH。
|
|
1802
|
+
const nodeErr = error;
|
|
1803
|
+
const hint = nodeErr.code === "ENOENT"
|
|
1804
|
+
? "(PATH 中找不到 claude 可执行文件;请确认 claude 已安装,或重跑 `wand service:install` 刷新服务的 PATH)"
|
|
1805
|
+
: "";
|
|
1806
|
+
reject(new Error(`claude -p 启动失败:${error.message}${hint}`));
|
|
1786
1807
|
});
|
|
1787
|
-
child.on("close", (code) => {
|
|
1808
|
+
child.on("close", (code, signal) => {
|
|
1788
1809
|
this.pendingChildren.delete(sessionId);
|
|
1789
1810
|
this.lastStreamSaveAt.delete(sessionId);
|
|
1790
1811
|
this.logger?.appendStructuredSpawn(sessionId, {
|
|
@@ -1812,8 +1833,14 @@ export class StructuredSessionManager {
|
|
|
1812
1833
|
// 可能以非零 exit code 退出(内部 handler 调了 exit(1))。这种情况属于正常
|
|
1813
1834
|
// 中断流程,不应走失败路径——后续 interruptedWith 逻辑会发送新消息。
|
|
1814
1835
|
const interruptedByUser = this.interruptedWith.has(sessionId);
|
|
1815
|
-
|
|
1816
|
-
|
|
1836
|
+
const failedExit = (code !== null && code !== 0) || signal !== null;
|
|
1837
|
+
if (failedExit && !interruptedByUser) {
|
|
1838
|
+
const errorText = this.formatStructuredExitError("claude -p", code, signal, {
|
|
1839
|
+
stderr,
|
|
1840
|
+
// claude -p 没有 codex 那种独立的 turn.failed 事件,所以 primary 留空;
|
|
1841
|
+
// 退路是 stderr / stdoutTail。
|
|
1842
|
+
stdoutTail: lastRawStdoutChunk,
|
|
1843
|
+
});
|
|
1817
1844
|
const failureTurn = {
|
|
1818
1845
|
role: "assistant",
|
|
1819
1846
|
content: [{ type: "text", text: `结构化会话执行失败:${errorText}` }],
|
|
@@ -1826,10 +1853,12 @@ export class StructuredSessionManager {
|
|
|
1826
1853
|
else {
|
|
1827
1854
|
msgs.push(failureTurn);
|
|
1828
1855
|
}
|
|
1856
|
+
// 仅 signal 终止时 code 为 null;用 1 占位,让 UI 的"exitCode !== 0"判定也能命中。
|
|
1857
|
+
const exitForSnapshot = typeof code === "number" ? code : 1;
|
|
1829
1858
|
const failed = {
|
|
1830
1859
|
...current,
|
|
1831
1860
|
status: "failed",
|
|
1832
|
-
exitCode:
|
|
1861
|
+
exitCode: exitForSnapshot,
|
|
1833
1862
|
endedAt: new Date().toISOString(),
|
|
1834
1863
|
output: errorText,
|
|
1835
1864
|
claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
|
|
@@ -2587,6 +2616,36 @@ export class StructuredSessionManager {
|
|
|
2587
2616
|
}
|
|
2588
2617
|
blocks.push(block);
|
|
2589
2618
|
}
|
|
2619
|
+
/**
|
|
2620
|
+
* 组装结构化 runner 退出失败时的可读错误字符串。
|
|
2621
|
+
*
|
|
2622
|
+
* 痛点:之前 claude -p / codex exec 异常退出只把"stderr.trim() || `... exited
|
|
2623
|
+
* with code N`"塞给 UI。如果 stderr 是空的,用户在前端只能看到 "EXIT 1" 这种
|
|
2624
|
+
* 没有任何上下文的串,根本不知道是网络错误、参数错误还是 binary 找不着。
|
|
2625
|
+
*
|
|
2626
|
+
* 这里固定把"provider + 退出码 / 信号"放在最前面,再把 stderr / NDJSON 错误
|
|
2627
|
+
* 事件 / 最后一段 stdout 之类的上下文跟在后面,方便定位。
|
|
2628
|
+
*/
|
|
2629
|
+
formatStructuredExitError(provider, code, signal, options = {}) {
|
|
2630
|
+
const head = signal
|
|
2631
|
+
? `${provider} terminated by signal ${signal}${code !== null ? ` (code ${code})` : ""}`
|
|
2632
|
+
: code !== null
|
|
2633
|
+
? `${provider} exited with code ${code}`
|
|
2634
|
+
: `${provider} exited (unknown status)`;
|
|
2635
|
+
const primary = options.primary?.trim();
|
|
2636
|
+
const stderrTrim = options.stderr?.trim() ?? "";
|
|
2637
|
+
const lastExtra = options.extras && options.extras.length > 0
|
|
2638
|
+
? options.extras[options.extras.length - 1].trim()
|
|
2639
|
+
: "";
|
|
2640
|
+
const stdoutTail = options.stdoutTail?.trim() ?? "";
|
|
2641
|
+
// 选第一个非空的"详情"作为正文展示,剩下的不再追加避免太长。
|
|
2642
|
+
const detail = primary || lastExtra || stderrTrim || stdoutTail;
|
|
2643
|
+
if (!detail)
|
|
2644
|
+
return head;
|
|
2645
|
+
// 控制长度,避免大段 stderr 撑爆 UI;保留尾部信息(最近的更相关)。
|
|
2646
|
+
const trimmed = detail.length > 2048 ? `...${detail.slice(-2048)}` : detail;
|
|
2647
|
+
return `${head}\n${trimmed}`;
|
|
2648
|
+
}
|
|
2590
2649
|
finishStructuredFailure(current, code, errorText, turnState) {
|
|
2591
2650
|
const failureTurn = {
|
|
2592
2651
|
role: "assistant",
|
|
@@ -1786,6 +1786,9 @@
|
|
|
1786
1786
|
'<span class="chat-unread-bubble-icon"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3.5v9M3.5 8l4.5 4.5L12.5 8"/></svg></span>' +
|
|
1787
1787
|
'<span class="chat-unread-bubble-count" aria-hidden="true"></span>' +
|
|
1788
1788
|
'</button>' +
|
|
1789
|
+
// 排队气泡宿主:贴在对话显示区域的右下角(在"回复中"状态线上方),
|
|
1790
|
+
// 不进输入框 panel。updateQueueBar() 仅在 queuedMessages 非空时显形。
|
|
1791
|
+
'<div id="queue-bar-host" class="queue-bar-host" hidden></div>' +
|
|
1789
1792
|
'</div>' +
|
|
1790
1793
|
'<div id="blank-chat" class="blank-chat' + (state.selectedId ? " hidden" : "") + '">' +
|
|
1791
1794
|
'<div class="blank-chat-inner">' +
|
|
@@ -1814,10 +1817,7 @@
|
|
|
1814
1817
|
'</div>' +
|
|
1815
1818
|
'</div>' +
|
|
1816
1819
|
'<div class="input-panel' + (state.selectedId ? "" : " hidden") + '">' +
|
|
1817
|
-
//
|
|
1818
|
-
// 显形。位置在 composer-top-row(含 "回复中" 状态条)之上,对话框右下角,
|
|
1819
|
-
// 不进入输入框内部。所有内容由 updater 注入;这里只保留稳定的外层骨架。
|
|
1820
|
-
'<div id="queue-bar-host" class="queue-bar-host" hidden></div>' +
|
|
1820
|
+
// #queue-bar-host 已搬到 #chat-output 内部(对话区右下角),不在这里了。
|
|
1821
1821
|
'<div class="composer-top-row">' +
|
|
1822
1822
|
'<div id="todo-progress" class="todo-progress hidden">' +
|
|
1823
1823
|
'<div class="todo-progress-header" id="todo-progress-toggle">' +
|
|
@@ -1916,9 +1916,14 @@
|
|
|
1916
1916
|
if (selectedSession.provider === "claude" && selectedSession.claudeSessionId) {
|
|
1917
1917
|
bits += '<span id="claude-session-id-badge" class="claude-session-id-badge" data-claude-id="' + escapeHtml(selectedSession.claudeSessionId) + '" title="点击复制 Claude 会话 ID">' + iconSvg("cloud", { size: 11, strokeWidth: 1.7, cls: "claude-session-id-icon" }) + '<span class="claude-session-id-text">' + escapeHtml(selectedSession.claudeSessionId.slice(0, 8)) + '</span></span>';
|
|
1918
1918
|
}
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1919
|
+
// 非结构化会话:进程退出后展示退出码(哪怕 0,告诉用户已正常结束)。
|
|
1920
|
+
// 结构化会话:只在退出码非 0(即真有失败)时展示,避免成功的多轮对话也挂个 "退出码=0" 误导。
|
|
1921
|
+
if (selectedSession.exitCode !== undefined && selectedSession.exitCode !== null) {
|
|
1922
|
+
var showExit = !isStructuredSession(selectedSession) || selectedSession.exitCode !== 0;
|
|
1923
|
+
if (showExit) {
|
|
1924
|
+
if (bits) bits += '<span class="session-info-separator">|</span>';
|
|
1925
|
+
bits += '<span id="session-exit-display" class="session-exit-display">退出码=' + selectedSession.exitCode + '</span>';
|
|
1926
|
+
}
|
|
1922
1927
|
}
|
|
1923
1928
|
return bits ? '<div class="input-session-info-bar">' + bits + '</div>' : '';
|
|
1924
1929
|
})()
|
|
@@ -8214,9 +8219,9 @@
|
|
|
8214
8219
|
return "会话已结束";
|
|
8215
8220
|
}
|
|
8216
8221
|
// 结构化会话在出 token 时,输入框仍然可用——告诉用户默认行为是排队,
|
|
8217
|
-
//
|
|
8222
|
+
// 想插队请按气泡上的 ⚡ 按钮。短语尽量短,避免在窄屏手机上换行。
|
|
8218
8223
|
if (isStructuredSession(session) && session.structuredState && session.structuredState.inFlight) {
|
|
8219
|
-
return "回复中…Enter 排队 ·
|
|
8224
|
+
return "回复中…Enter 排队 · ⚡ 立即发送";
|
|
8220
8225
|
}
|
|
8221
8226
|
return "";
|
|
8222
8227
|
}
|
|
@@ -12544,12 +12549,15 @@
|
|
|
12544
12549
|
return 0;
|
|
12545
12550
|
}
|
|
12546
12551
|
|
|
12547
|
-
function renderQueueBarHtml(items, inFlight, atCapacity
|
|
12552
|
+
function renderQueueBarHtml(items, inFlight, atCapacity) {
|
|
12553
|
+
// 底部独立 ⚡ 按钮已下线,每条 chip 内部自带 ⚡ "立即"按钮 ——
|
|
12554
|
+
// 这样用户一眼就能看出"是把哪一条插队"。
|
|
12548
12555
|
var single = items.length <= 1;
|
|
12549
12556
|
var barClass = "queue-bar";
|
|
12550
12557
|
if (atCapacity) barClass += " queue-bar-capacity";
|
|
12551
12558
|
if (inFlight) barClass += " queue-bar-inflight";
|
|
12552
12559
|
var expandedIdx = queueBarExpandedIndex(items.length);
|
|
12560
|
+
var promoteTip = inFlight ? "中断当前回复,立即发送这条" : "立即发送这条";
|
|
12553
12561
|
var chips = "";
|
|
12554
12562
|
for (var i = 0; i < items.length; i++) {
|
|
12555
12563
|
var raw = items[i] == null ? "" : String(items[i]);
|
|
@@ -12557,13 +12565,21 @@
|
|
|
12557
12565
|
var itemClass = "queue-bar-item";
|
|
12558
12566
|
if (isExpanded) itemClass += " expanded";
|
|
12559
12567
|
if (single) itemClass += " queue-bar-item-single";
|
|
12560
|
-
//
|
|
12561
|
-
var titleAttr = isExpanded ? raw + "
|
|
12568
|
+
// chip 本体是"拖拽起手区";内部 ⚡ 按钮独占 click 用于立即发送、× 用于删除。
|
|
12569
|
+
var titleAttr = isExpanded ? raw + "(按住可拖动调序)" : raw;
|
|
12562
12570
|
chips +=
|
|
12563
12571
|
'<li class="' + itemClass + '" data-index="' + i + '" data-action="drag"' +
|
|
12564
12572
|
' title="' + escapeHtml(titleAttr) + '">' +
|
|
12565
12573
|
'<span class="queue-bar-item-index" aria-hidden="true">' + (i + 1) + '</span>' +
|
|
12566
12574
|
'<span class="queue-bar-item-text">' + escapeHtml(queueChipTruncate(raw)) + '</span>' +
|
|
12575
|
+
'<button type="button" class="queue-bar-item-promote" data-action="promote-item"' +
|
|
12576
|
+
' title="' + escapeHtml(promoteTip) + '" aria-label="立即发送这条"' +
|
|
12577
|
+
' tabindex="' + (isExpanded ? "0" : "-1") + '">' +
|
|
12578
|
+
'<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
|
|
12579
|
+
'<path d="M13 2 L4 14 L11 14 L10 22 L20 9 L13 9 Z"/>' +
|
|
12580
|
+
'</svg>' +
|
|
12581
|
+
'<span class="queue-bar-item-promote-label">立即</span>' +
|
|
12582
|
+
'</button>' +
|
|
12567
12583
|
'<button type="button" class="queue-bar-item-delete" data-action="delete"' +
|
|
12568
12584
|
' aria-label="删除这条排队消息" title="删除" tabindex="' + (isExpanded ? "0" : "-1") + '">' +
|
|
12569
12585
|
'<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor"' +
|
|
@@ -12575,13 +12591,6 @@
|
|
|
12575
12591
|
return (
|
|
12576
12592
|
'<div class="' + barClass + '" data-queue-bar="1">' +
|
|
12577
12593
|
'<ol class="queue-bar-list" data-queue-list="1">' + chips + '</ol>' +
|
|
12578
|
-
'<button type="button" class="queue-bar-promote" data-action="promote"' +
|
|
12579
|
-
' title="中断当前回复,立刻发送队首这条"' +
|
|
12580
|
-
' aria-label="' + escapeHtml(immediateLabel) + '队首">' +
|
|
12581
|
-
'<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
|
|
12582
|
-
'<path d="M13 2 L4 14 L11 14 L10 22 L20 9 L13 9 Z"/>' +
|
|
12583
|
-
'</svg>' +
|
|
12584
|
-
'</button>' +
|
|
12585
12594
|
'</div>'
|
|
12586
12595
|
);
|
|
12587
12596
|
}
|
|
@@ -12607,9 +12616,8 @@
|
|
|
12607
12616
|
host.hidden = false;
|
|
12608
12617
|
var inFlight = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
|
|
12609
12618
|
var atCapacity = queue.length >= QUEUE_BAR_MAX;
|
|
12610
|
-
var immediateLabel = inFlight ? "立即" : "发送";
|
|
12611
12619
|
|
|
12612
|
-
host.innerHTML = renderQueueBarHtml(queue, inFlight, atCapacity
|
|
12620
|
+
host.innerHTML = renderQueueBarHtml(queue, inFlight, atCapacity);
|
|
12613
12621
|
}
|
|
12614
12622
|
|
|
12615
12623
|
// 只切换 .expanded class,不重建 DOM —— 避免鼠标移过去触发的重建
|
|
@@ -12711,10 +12719,6 @@
|
|
|
12711
12719
|
});
|
|
12712
12720
|
}
|
|
12713
12721
|
|
|
12714
|
-
function queueBarPromoteHead() {
|
|
12715
|
-
queueBarPromoteIndex(0);
|
|
12716
|
-
}
|
|
12717
|
-
|
|
12718
12722
|
// 把队列里第 index 条剥下来,作为新的输入立刻发送出去。
|
|
12719
12723
|
// - inFlight:interrupt + preserveQueue(中断当前回复,保留其它排队)
|
|
12720
12724
|
// - 非 inFlight:当作普通新消息发出去
|
|
@@ -12815,7 +12819,6 @@
|
|
|
12815
12819
|
startY: ev.clientY,
|
|
12816
12820
|
gap: gap,
|
|
12817
12821
|
queueSnapshot: queue,
|
|
12818
|
-
moved: false, // 没真正拖动过 → 抬手时按 tap 处理:promote 这条
|
|
12819
12822
|
};
|
|
12820
12823
|
|
|
12821
12824
|
chipEl.classList.add("dragging");
|
|
@@ -12863,8 +12866,6 @@
|
|
|
12863
12866
|
if (!d || ev.pointerId !== d.pointerId) return;
|
|
12864
12867
|
ev.preventDefault();
|
|
12865
12868
|
var deltaY = ev.clientY - d.startY;
|
|
12866
|
-
// 4px 阈值过滤抖动 / 触屏轻微滑动;超过才算"真的在拖",否则抬手当 tap。
|
|
12867
|
-
if (Math.abs(deltaY) > 4) d.moved = true;
|
|
12868
12869
|
d.itemEl.style.transform = "translateY(" + deltaY + "px)";
|
|
12869
12870
|
|
|
12870
12871
|
// 拖动中心 Y 决定目标插入位置
|
|
@@ -12899,7 +12900,6 @@
|
|
|
12899
12900
|
var origIndex = d.origIndex;
|
|
12900
12901
|
var targetIndex = d.targetIndex;
|
|
12901
12902
|
var queueSnapshot = d.queueSnapshot;
|
|
12902
|
-
var wasTap = !d.moved;
|
|
12903
12903
|
|
|
12904
12904
|
// 清掉 inline transform 让 CSS 自然回位
|
|
12905
12905
|
d.siblings.forEach(function(el) {
|
|
@@ -12911,9 +12911,9 @@
|
|
|
12911
12911
|
state.queueBarDrag = null;
|
|
12912
12912
|
|
|
12913
12913
|
if (origIndex === targetIndex) {
|
|
12914
|
-
//
|
|
12914
|
+
// 没动 → 单纯刷新一下。立即发送由 chip 内部的 ⚡ 按钮触发,
|
|
12915
|
+
// 不在 chip 本体上做隐式 tap-to-promote(容易误触)。
|
|
12915
12916
|
updateQueueBar();
|
|
12916
|
-
if (wasTap) queueBarPromoteIndex(origIndex);
|
|
12917
12917
|
return;
|
|
12918
12918
|
}
|
|
12919
12919
|
|
|
@@ -12960,19 +12960,18 @@
|
|
|
12960
12960
|
var actionEl = ev.target && ev.target.closest ? ev.target.closest("[data-action]") : null;
|
|
12961
12961
|
if (!actionEl || !host.contains(actionEl)) return;
|
|
12962
12962
|
var action = actionEl.getAttribute("data-action");
|
|
12963
|
-
|
|
12964
|
-
|
|
12965
|
-
|
|
12966
|
-
// 把这条直接 promote 出去。
|
|
12967
|
-
ev.preventDefault();
|
|
12968
|
-
ev.stopPropagation();
|
|
12969
|
-
var idx = Number(actionEl.getAttribute("data-index"));
|
|
12970
|
-
queueBarPromoteIndex(idx);
|
|
12971
|
-
return;
|
|
12972
|
-
}
|
|
12963
|
+
// chip 本体(data-action="drag")由 pointerdown 走 drag-or-tap 流程;
|
|
12964
|
+
// click 阶段不处理,否则会和拖拽收尾冲突。
|
|
12965
|
+
if (action === "drag") return;
|
|
12973
12966
|
ev.preventDefault();
|
|
12974
12967
|
ev.stopPropagation();
|
|
12975
|
-
if (action === "promote") {
|
|
12968
|
+
if (action === "promote-item") {
|
|
12969
|
+
// chip 内部的 ⚡ "立即"按钮:把这一条剥下来插队发送,让用户一眼看到
|
|
12970
|
+
// 自己点的就是哪一条。
|
|
12971
|
+
var pItem = actionEl.closest(".queue-bar-item");
|
|
12972
|
+
if (pItem) queueBarPromoteIndex(Number(pItem.getAttribute("data-index")));
|
|
12973
|
+
return;
|
|
12974
|
+
}
|
|
12976
12975
|
if (action === "delete") {
|
|
12977
12976
|
var itemEl = actionEl.closest(".queue-bar-item");
|
|
12978
12977
|
if (itemEl) queueBarDeleteItem(Number(itemEl.getAttribute("data-index")));
|
|
@@ -12990,10 +12989,11 @@
|
|
|
12990
12989
|
if (state.queueBarDrag) return;
|
|
12991
12990
|
setQueueBarHoverIndex(null);
|
|
12992
12991
|
});
|
|
12993
|
-
// 整个气泡都是拖拽起手区。
|
|
12992
|
+
// 整个气泡都是拖拽起手区。chip 内部的 ⚡ / × 按钮通过 closest 跳过,
|
|
12993
|
+
// 让 click 阶段去处理它们。
|
|
12994
12994
|
host.addEventListener("pointerdown", function(ev) {
|
|
12995
12995
|
if (ev.button !== undefined && ev.button !== 0) return;
|
|
12996
|
-
if (ev.target && ev.target.closest && ev.target.closest('[data-action="delete"], [data-action="promote"]')) return;
|
|
12996
|
+
if (ev.target && ev.target.closest && ev.target.closest('[data-action="delete"], [data-action="promote-item"]')) return;
|
|
12997
12997
|
var chip = ev.target && ev.target.closest ? ev.target.closest('.queue-bar-item') : null;
|
|
12998
12998
|
if (!chip) return;
|
|
12999
12999
|
// 拖拽前先把这条切到 expanded(鼠标按下时通常已经 hovered,但触屏没 hover)
|
|
@@ -13044,38 +13044,15 @@
|
|
|
13044
13044
|
});
|
|
13045
13045
|
}
|
|
13046
13046
|
|
|
13047
|
-
//
|
|
13048
|
-
//
|
|
13047
|
+
// 结构化会话的"对话视图"现在只渲染真实的 user/assistant turn。排队消息(还没
|
|
13048
|
+
// flush 出去那批)由 .queue-bar 在对话区右下角统一展示,不再在 chat 流里贴一份
|
|
13049
|
+
// 半透明 "排队中" 用户气泡——避免同一条消息在 UI 上出现两次。
|
|
13049
13050
|
function buildMessagesForRender(session, messages) {
|
|
13050
13051
|
var sanitized = Array.isArray(messages) ? stripRenderOnlyStructuredMessages(messages) : [];
|
|
13051
13052
|
var base = Array.isArray(sanitized) ? sanitized.slice() : [];
|
|
13052
13053
|
if (!session || session.sessionKind !== "structured") {
|
|
13053
13054
|
return base;
|
|
13054
13055
|
}
|
|
13055
|
-
var queued = getStructuredQueuedInputs(session);
|
|
13056
|
-
if (queued && queued.length > 0) {
|
|
13057
|
-
// Collect recent user message texts to deduplicate against queued items.
|
|
13058
|
-
// A queued message that already appears as a real user turn should not
|
|
13059
|
-
// be rendered a second time with the "排队中" badge.
|
|
13060
|
-
var existingUserTexts = {};
|
|
13061
|
-
for (var ei = base.length - 1; ei >= 0 && Object.keys(existingUserTexts).length < queued.length + 5; ei--) {
|
|
13062
|
-
var em = base[ei];
|
|
13063
|
-
if (em && em.role === "user" && Array.isArray(em.content)) {
|
|
13064
|
-
for (var ej = 0; ej < em.content.length; ej++) {
|
|
13065
|
-
if (em.content[ej] && em.content[ej].type === "text" && em.content[ej].text) {
|
|
13066
|
-
existingUserTexts[em.content[ej].text] = (existingUserTexts[em.content[ej].text] || 0) + 1;
|
|
13067
|
-
}
|
|
13068
|
-
}
|
|
13069
|
-
}
|
|
13070
|
-
}
|
|
13071
|
-
for (var qi = 0; qi < queued.length; qi++) {
|
|
13072
|
-
if (existingUserTexts[queued[qi]]) {
|
|
13073
|
-
existingUserTexts[queued[qi]]--;
|
|
13074
|
-
continue; // Skip — this queued text is already shown as a real message
|
|
13075
|
-
}
|
|
13076
|
-
base.push({ role: "user", content: [{ type: "text", text: queued[qi], __queued: true }] });
|
|
13077
|
-
}
|
|
13078
|
-
}
|
|
13079
13056
|
if (session.structuredState && session.structuredState.inFlight) {
|
|
13080
13057
|
var last = base[base.length - 1];
|
|
13081
13058
|
if (!last || last.role !== "assistant") {
|
|
@@ -6457,23 +6457,33 @@
|
|
|
6457
6457
|
}
|
|
6458
6458
|
|
|
6459
6459
|
/* ─────────────────────────────────────────────────────────────────────
|
|
6460
|
-
排队消息条(.queue-bar)——
|
|
6461
|
-
|
|
6462
|
-
|
|
6460
|
+
排队消息条(.queue-bar)—— 绝对定位在对话显示区域(.chat-container)的右下角,
|
|
6461
|
+
压在 "回复中" 状态线之上。
|
|
6462
|
+
交互:
|
|
6463
|
+
· 默认只展开队首 chip(橙色完整气泡,显示编号 + 文本 + ⚡ 立即 + × 删除)
|
|
6463
6464
|
· 其他 chip 收成一根小横杠(橙色细线,宽 32px 高 4px)
|
|
6464
6465
|
· 鼠标悬到任一横杠 → 该条展开、原本展开的收回横杠
|
|
6465
|
-
·
|
|
6466
|
-
|
|
6466
|
+
· 悬停期间可按住 chip 本体上下拖拽换序
|
|
6467
|
+
· chip 内部 ⚡ "立即"按钮:把这一条剥下来插队发送(看得见点的是哪一条)
|
|
6468
|
+
· chip 内部 × 按钮:单独删除这一条
|
|
6467
6469
|
───────────────────────────────────────────────────────────────────── */
|
|
6468
6470
|
.queue-bar-host {
|
|
6471
|
+
position: absolute;
|
|
6472
|
+
right: 14px;
|
|
6473
|
+
bottom: 10px;
|
|
6474
|
+
max-width: calc(100% - 28px);
|
|
6475
|
+
z-index: 14; /* 必须高于 .chat-unread-bubble(z-index 13) 和 ::after fade(z-index 12) */
|
|
6476
|
+
pointer-events: none; /* host 自身不挡点击,bar 内子节点 auto */
|
|
6469
6477
|
display: flex;
|
|
6470
6478
|
justify-content: flex-end;
|
|
6471
|
-
|
|
6472
|
-
position: relative;
|
|
6473
|
-
z-index: 2;
|
|
6474
|
-
pointer-events: none; /* host 自身不挡点击,bar 内子节点 auto */
|
|
6479
|
+
transition: bottom 220ms var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1));
|
|
6475
6480
|
}
|
|
6476
6481
|
.queue-bar-host[hidden] { display: none; }
|
|
6482
|
+
/* 当"回到最新"胶囊也在显示时,把排队气泡条向上抬一截,避免叠在它头上。
|
|
6483
|
+
sibling 选择器走得通是因为 #queue-bar-host 在 DOM 里紧跟在 .chat-unread-bubble 后面。 */
|
|
6484
|
+
.chat-unread-bubble.visible ~ .queue-bar-host {
|
|
6485
|
+
bottom: 52px;
|
|
6486
|
+
}
|
|
6477
6487
|
|
|
6478
6488
|
.queue-bar {
|
|
6479
6489
|
pointer-events: auto;
|
|
@@ -6629,88 +6639,65 @@
|
|
|
6629
6639
|
}
|
|
6630
6640
|
.queue-bar-item-delete svg { flex-shrink: 0; }
|
|
6631
6641
|
|
|
6632
|
-
/* ⚡ 立即按钮 ——
|
|
6633
|
-
|
|
6642
|
+
/* ⚡ 立即按钮 —— 每个 chip 内部都自带一颗,跟在文本和 × 删除之间。
|
|
6643
|
+
展开态可见、可点;折叠态隐起来不参与 hit-test。 */
|
|
6644
|
+
.queue-bar-item-promote {
|
|
6634
6645
|
flex-shrink: 0;
|
|
6635
|
-
|
|
6636
|
-
|
|
6637
|
-
|
|
6646
|
+
display: inline-flex;
|
|
6647
|
+
align-items: center;
|
|
6648
|
+
gap: 3px;
|
|
6649
|
+
height: 18px;
|
|
6650
|
+
padding: 0 7px 0 6px;
|
|
6638
6651
|
border: none;
|
|
6652
|
+
border-radius: 999px;
|
|
6639
6653
|
cursor: pointer;
|
|
6640
6654
|
color: #fff;
|
|
6641
|
-
background:
|
|
6642
|
-
|
|
6643
|
-
|
|
6644
|
-
|
|
6645
|
-
|
|
6646
|
-
box-shadow: 0
|
|
6647
|
-
|
|
6648
|
-
|
|
6649
|
-
|
|
6655
|
+
background: rgba(255, 255, 255, 0.22);
|
|
6656
|
+
font-size: 0.65rem;
|
|
6657
|
+
font-weight: 600;
|
|
6658
|
+
letter-spacing: 0.02em;
|
|
6659
|
+
line-height: 1;
|
|
6660
|
+
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22);
|
|
6661
|
+
opacity: 0;
|
|
6662
|
+
pointer-events: none;
|
|
6663
|
+
transition: opacity 120ms ease 60ms, background var(--transition-fast),
|
|
6664
|
+
box-shadow var(--transition-fast), transform 120ms ease;
|
|
6650
6665
|
}
|
|
6651
|
-
.queue-bar-promote
|
|
6652
|
-
|
|
6653
|
-
|
|
6666
|
+
.queue-bar-item.expanded .queue-bar-item-promote,
|
|
6667
|
+
.queue-bar-item.dragging .queue-bar-item-promote {
|
|
6668
|
+
opacity: 1;
|
|
6669
|
+
pointer-events: auto;
|
|
6654
6670
|
}
|
|
6655
|
-
.queue-bar-promote:
|
|
6656
|
-
|
|
6657
|
-
|
|
6658
|
-
|
|
6671
|
+
.queue-bar-item-promote:hover {
|
|
6672
|
+
background: rgba(255, 255, 255, 0.36);
|
|
6673
|
+
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45);
|
|
6674
|
+
}
|
|
6675
|
+
.queue-bar-item-promote:active { transform: scale(0.94); }
|
|
6676
|
+
.queue-bar-item-promote:focus-visible {
|
|
6677
|
+
outline: 2px solid #fff;
|
|
6678
|
+
outline-offset: 1px;
|
|
6679
|
+
}
|
|
6680
|
+
.queue-bar-item-promote svg { flex-shrink: 0; }
|
|
6681
|
+
.queue-bar-item-promote-label {
|
|
6682
|
+
display: inline;
|
|
6659
6683
|
}
|
|
6660
|
-
.queue-bar-promote svg { flex-shrink: 0; }
|
|
6661
|
-
.queue-bar-promote-label { display: none; } /* label 留作 aria,视觉不展示 */
|
|
6662
6684
|
|
|
6663
6685
|
/* 容量满了的视觉提示 —— 整组颜色稍暗 */
|
|
6664
|
-
.queue-bar.queue-bar-capacity .queue-bar-item
|
|
6665
|
-
.queue-bar.queue-bar-capacity .queue-bar-promote {
|
|
6686
|
+
.queue-bar.queue-bar-capacity .queue-bar-item {
|
|
6666
6687
|
background: linear-gradient(180deg, #a8522f 0%, #8e4426 100%);
|
|
6667
6688
|
}
|
|
6668
6689
|
|
|
6669
|
-
/*
|
|
6690
|
+
/* 窄屏:宽度收紧 + 横杠加粗便于指 + ⚡ 文字省略 */
|
|
6670
6691
|
@media (max-width: 560px) {
|
|
6671
|
-
.queue-bar-host {
|
|
6692
|
+
.queue-bar-host { right: 8px; bottom: 8px; }
|
|
6672
6693
|
.queue-bar { max-width: calc(100vw - 20px); }
|
|
6673
6694
|
.queue-bar-item { width: 40px; height: 6px; }
|
|
6695
|
+
.queue-bar-item-promote-label { display: none; }
|
|
6696
|
+
.queue-bar-item-promote { padding: 0 6px; }
|
|
6674
6697
|
}
|
|
6675
6698
|
|
|
6676
|
-
/*
|
|
6677
|
-
|
|
6678
|
-
让排队这件事在视觉上和"已发出"清楚拉开。 */
|
|
6679
|
-
.chat-message.user.queued {
|
|
6680
|
-
opacity: 0.92;
|
|
6681
|
-
}
|
|
6682
|
-
.chat-message.user.queued .chat-message-bubble,
|
|
6683
|
-
.chat-message.user.queued .chat-message-content {
|
|
6684
|
-
box-shadow: 0 0 0 1.5px rgba(197, 101, 61, 0.55) inset,
|
|
6685
|
-
0 1px 0 rgba(197, 101, 61, 0.10);
|
|
6686
|
-
background: linear-gradient(180deg, rgba(255, 245, 235, 0.85) 0%, rgba(255, 235, 220, 0.65) 100%);
|
|
6687
|
-
border-radius: 14px;
|
|
6688
|
-
position: relative;
|
|
6689
|
-
}
|
|
6690
|
-
.queued-badge {
|
|
6691
|
-
display: inline-flex;
|
|
6692
|
-
align-items: center;
|
|
6693
|
-
gap: 5px;
|
|
6694
|
-
font-size: 0.75rem;
|
|
6695
|
-
color: #fff;
|
|
6696
|
-
background: linear-gradient(180deg, var(--accent) 0%, #a8522f 100%);
|
|
6697
|
-
padding: 3px 10px 3px 9px;
|
|
6698
|
-
border-radius: 999px;
|
|
6699
|
-
font-weight: 600;
|
|
6700
|
-
margin-top: 6px;
|
|
6701
|
-
letter-spacing: 0.04em;
|
|
6702
|
-
box-shadow: 0 2px 6px rgba(197, 101, 61, 0.30);
|
|
6703
|
-
animation: queuePulse 1.5s ease-in-out infinite;
|
|
6704
|
-
}
|
|
6705
|
-
.queued-badge::before {
|
|
6706
|
-
content: '';
|
|
6707
|
-
width: 7px;
|
|
6708
|
-
height: 7px;
|
|
6709
|
-
border-radius: 50%;
|
|
6710
|
-
background: #fff;
|
|
6711
|
-
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.45);
|
|
6712
|
-
animation: queueDotPulse 1.0s ease-in-out infinite;
|
|
6713
|
-
}
|
|
6699
|
+
/* .chat-message.user.queued 与 .queued-badge 的 CSS 已下线 —— 排队消息现在只在
|
|
6700
|
+
右下角的 .queue-bar 里展示,不再以"半透明气泡 + 排队中徽章"形式占据 chat 流。 */
|
|
6714
6701
|
|
|
6715
6702
|
.input-hint {
|
|
6716
6703
|
font-size: 0.5rem; /* 8px — 让位给同一行的按钮,桌面端仍清晰可读 */
|