pi-web-ui 0.91.0 → 0.93.0
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/CHANGELOG.md +90 -1
- package/README.md +69 -14
- package/README.zh-CN.md +36 -10
- package/bin/pi-web-ui.mjs +39 -8
- package/dist/server/agent-service.js +938 -163
- package/dist/server/auth-cookie.js +26 -0
- package/dist/server/claim-files-tool.js +115 -0
- package/dist/server/claim-store.js +301 -0
- package/dist/server/client-state.js +114 -6
- package/dist/server/conversation-read-tool.js +371 -28
- package/dist/server/conversation-touches.js +357 -0
- package/dist/server/delegate-task.js +10 -3
- package/dist/server/dsh/dsh-agent-service.js +50 -1
- package/dist/server/files-service.js +78 -26
- package/dist/server/http-proxy.js +72 -0
- package/dist/server/index.js +94 -11
- package/dist/server/model-admin.js +124 -2
- package/dist/server/model-enrich.js +478 -0
- package/dist/server/patch-node-pty.js +11 -0
- package/dist/server/plugin-schedule.js +27 -2
- package/dist/server/plugins.js +66 -10
- package/dist/server/present-files-tool.js +303 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +125 -0
- package/dist/server/resolve-global-sdk.js +72 -0
- package/dist/server/sdk-origin.js +64 -0
- package/dist/server/serialize.js +80 -22
- package/dist/server/settings-service.js +19 -1
- package/dist/server/skill-tool.js +2 -1
- package/dist/server/subagents.js +30 -13
- package/dist/server/terminals.js +38 -18
- package/dist/server/text-sniff.js +15 -0
- package/dist/server/themes.js +32 -2
- package/dist/server/tool-info.js +89 -0
- package/dist/server/tool-manager.js +110 -11
- package/package.json +2 -2
- package/plugin-sdk/README.md +1 -0
- package/plugins/catalog.json +20 -0
- package/themes/ayu-light.css +136 -0
- package/themes/catppuccin-latte.css +140 -0
- package/themes/catppuccin.css +144 -0
- package/themes/codex.css +136 -0
- package/themes/cyberpunk.css +4 -0
- package/themes/dazzle.css +4 -0
- package/themes/everforest-light.css +136 -0
- package/themes/geist.css +136 -0
- package/themes/gruvbox-light.css +136 -0
- package/themes/kanagawa-lotus.css +136 -0
- package/themes/md-preview.css +4 -0
- package/themes/mist.css +4 -0
- package/themes/nord.css +134 -0
- package/themes/one-dark.css +135 -0
- package/themes/paper.css +16 -12
- package/themes/rose-pine-dawn.css +136 -0
- package/themes/sakura.css +4 -0
- package/themes/solarized-light.css +135 -0
- package/themes/tokyo-night.css +144 -0
- package/themes/white.css +4 -0
- package/web/dist/assets/{TerminalPanel-GtDQ1h85.js → TerminalPanel-C1A23aI-.js} +1 -1
- package/web/dist/assets/index-B48QWf0I.js +364 -0
- package/web/dist/assets/index-DGL5OInG.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CdsKltOS.css +0 -1
- package/web/dist/assets/index-OoULNF70.js +0 -363
|
@@ -21,3 +21,29 @@ export function isTlsRequest(req) {
|
|
|
21
21
|
export function buildPiWebTokenCookie(encodedValue, maxAge, secure) {
|
|
22
22
|
return `pi_web_token=${encodedValue}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure ? "; Secure" : ""}`;
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* `pi_web_token` cookie 值 → 原始口令。
|
|
26
|
+
*
|
|
27
|
+
* 下发时按 `encodeURIComponent` 编码(RFC 6265 的 cookie-value 只允许 ASCII,
|
|
28
|
+
* `=` / `+` / 空格 / 非 ASCII 都必须转义),所以**比对前必须先解码** —— 否则
|
|
29
|
+
* 含这些字符的口令永远匹配不上,表现为「`?token=` 那一次 200,之后所有资源与
|
|
30
|
+
* WS 全 401」(issue #261:base64 口令尾巴上的 `=` 被存成 `%3D`,服务端却拿
|
|
31
|
+
* `%3D` 去和 `=` 比)。
|
|
32
|
+
*
|
|
33
|
+
* 浏览器回来的一定是编码值,但手写 cookie / 旧客户端可能是明文(`=` 在值里
|
|
34
|
+
* 合法,我们按首个 `=` 切分),所以调用方应把「原样」与「解码后」两种候选都
|
|
35
|
+
* 试一遍(见 index.ts 的 requestTokens)。
|
|
36
|
+
*
|
|
37
|
+
* 不是合法百分号编码时原样返回:`decodeURIComponent` 会抛 `URIError`,
|
|
38
|
+
* 而一个手写的脏 cookie 绝不该把请求打成 500。
|
|
39
|
+
*/
|
|
40
|
+
export function decodeCookieToken(raw) {
|
|
41
|
+
if (!raw)
|
|
42
|
+
return "";
|
|
43
|
+
try {
|
|
44
|
+
return decodeURIComponent(raw);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return raw;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// claim-files-tool.ts — 文件认领(claim_files):并行冲突的「事前打招呼」
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// 触碰集是事后信息(写完才知道),claim 是事前意图("我要改这几个文件")。
|
|
5
|
+
// advisory 语义:认领只出现在提醒/files/list 里升级措辞,不拦 edit/bash ——
|
|
6
|
+
// 强制锁会死锁(见 claim-store.ts 文件头)。先到先得;自己的可刷新,
|
|
7
|
+
// 别人的动不了;过期自动释放 + 发 prompt 心跳续期 + 对话关闭释放。
|
|
8
|
+
//
|
|
9
|
+
// 开关走统一工具 tab(AGENT_TOOL_CATALOG 目录 + 设置页开关行;ActiveSet 门控,
|
|
10
|
+
// live 生效):认领是纯 advisory(关掉只会少提醒),默认开。DSH 引擎无 customTool
|
|
11
|
+
// 注册面,只有 pi 引擎对话能认领(提醒是服务端算的,DSH 照样能看到别人的认领)。
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { Type } from "typebox";
|
|
15
|
+
import { bilingual, pick } from "./i18n.js";
|
|
16
|
+
import { CLAIM_FILES_TOOL_NAME } from "./tool-manager.js";
|
|
17
|
+
import { CLAIM_TTL_MS, resolveClaimPath } from "./claim-store.js";
|
|
18
|
+
/** 工具名(唯一定义在 tool-manager.ts,本模块 re-export 供单测沿用,模式同 present-files-tool.ts)。 */
|
|
19
|
+
export { CLAIM_FILES_TOOL_NAME };
|
|
20
|
+
function fmtTtl(ms) {
|
|
21
|
+
const m = Math.round(ms / 60000);
|
|
22
|
+
return m >= 60 ? `${Math.floor(m / 60)}h${m % 60 ? `${m % 60}m` : ""}` : `${m}m`;
|
|
23
|
+
}
|
|
24
|
+
export function makeClaimFilesTool(host, lang) {
|
|
25
|
+
const getLang = lang ?? (() => "en");
|
|
26
|
+
const text = (t, details = {}) => ({
|
|
27
|
+
content: [{ type: "text", text: t }],
|
|
28
|
+
details,
|
|
29
|
+
});
|
|
30
|
+
return defineTool({
|
|
31
|
+
name: CLAIM_FILES_TOOL_NAME,
|
|
32
|
+
label: "Claim files to avoid parallel conflicts",
|
|
33
|
+
description: bilingual("Declare which files you are about to edit so parallel runs in the same project can steer clear (advisory only — it never blocks edits). " +
|
|
34
|
+
"action=claim reserves paths for THIS conversation (first wins; already claimed by others is reported, not overridden); action=release frees yours (no paths = all yours); action=list shows this project's claim table. " +
|
|
35
|
+
"Claims expire after ~30 min idle (each of your prompts refreshes them) and are released when the conversation closes. " +
|
|
36
|
+
"Paths must stay inside the project directory. Only pi-engine conversations can claim (no customTool surface on DSH).", "声明你要改哪些文件,让同项目的并行对话绕行(纯建议,不拦任何编辑)。" +
|
|
37
|
+
"action=claim 把路径记到本对话名下(先到先得;别人已认领的会告诉你,不抢占);action=release 放掉你自己的(不给 paths = 全放);action=list 看本项目的认领表。" +
|
|
38
|
+
"认领闲置约 30 分钟过期(你每次发 prompt 自动续期),对话关闭时释放。" +
|
|
39
|
+
"路径必须在项目目录内。只有 pi 引擎对话能认领(DSH 无 customTool 注册面)。"),
|
|
40
|
+
promptSnippet: "claim files you are about to edit (advisory, first-wins) so parallel runs steer clear",
|
|
41
|
+
parameters: Type.Object({
|
|
42
|
+
action: Type.Optional(Type.String({ description: 'claim = reserve paths; release = free yours; list = show table. Default "list".' })),
|
|
43
|
+
paths: Type.Optional(Type.Array(Type.String(), { description: "claim/release only: file paths (inside the project)." })),
|
|
44
|
+
note: Type.Optional(Type.String({ description: "claim only: why you need them (shown to others, ≤200 chars)." })),
|
|
45
|
+
ttlMinutes: Type.Optional(Type.Number({ description: "claim only: expiry in minutes (1-480, default 30; refreshed by your prompts)." })),
|
|
46
|
+
}),
|
|
47
|
+
execute: async (_id, p, _signal, _onUpdate, _ctx) => {
|
|
48
|
+
const store = host.store();
|
|
49
|
+
if (!store) {
|
|
50
|
+
return text(pick(getLang(), `认领表暂不可用(服务端未接线),先按无认领处理。`, `Claim store unavailable (not wired); proceed as if nothing is claimed.`, "claimfiles.no.store"));
|
|
51
|
+
}
|
|
52
|
+
const cwd = host.cwd();
|
|
53
|
+
const self = host.self();
|
|
54
|
+
const action = (p.action ?? "list").trim().toLowerCase();
|
|
55
|
+
if (action === "list") {
|
|
56
|
+
const rows = store.list(cwd);
|
|
57
|
+
const L = getLang();
|
|
58
|
+
if (rows.length === 0) {
|
|
59
|
+
return text(pick(L, `本项目暂无认领(parallel reminder 的触碰集照常工作)。`, `No claims in this project (parallel touch reminders still apply).`, "claimfiles.list.empty"), { cwd, total: 0, claims: [] });
|
|
60
|
+
}
|
|
61
|
+
const lines = rows.map((c) => `- ${c.path} · 「${c.ownerTitle}」${c.note ? ` · ${c.note}` : ""} · ${pick(L, `剩约 ${fmtTtl(Math.max(0, c.expiresAt - Date.now()))}`, `~${fmtTtl(Math.max(0, c.expiresAt - Date.now()))} left`, "claimfiles.list.ttl", { ttl: fmtTtl(Math.max(0, c.expiresAt - Date.now())) })}`);
|
|
62
|
+
return text(`${pick(L, `本项目认领表(${rows.length} 条,先到先得,只作绕行参考):`, `Claim table for this project (${rows.length}, first-wins, advisory):`, "claimfiles.list.head", { total: rows.length })}\n${lines.join("\n")}`, {
|
|
63
|
+
cwd,
|
|
64
|
+
total: rows.length,
|
|
65
|
+
claims: rows.map((c) => ({ path: c.path, ownerTitle: c.ownerTitle, note: c.note })),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (action === "claim" || action === "release") {
|
|
69
|
+
const rawPaths = Array.isArray(p.paths) ? p.paths : [];
|
|
70
|
+
if (action === "release" && rawPaths.length === 0) {
|
|
71
|
+
const n = store.release(cwd, self.convId);
|
|
72
|
+
return text(pick(getLang(), n > 0 ? `已释放你名下 ${n} 条认领。` : `你名下没有认领,无事可做。`, n > 0 ? `Released ${n} of your claim(s).` : `You hold no claims; nothing to do.`, "claimfiles.release.all", { n }), { cwd, released: n });
|
|
73
|
+
}
|
|
74
|
+
if (rawPaths.length === 0) {
|
|
75
|
+
return text(pick(getLang(), `action=${action} 需要 paths(release 不给 paths = 全放自己名下的)。`, `action=${action} needs paths (release without paths = free all yours).`, "claimfiles.bad.paths", { action }));
|
|
76
|
+
}
|
|
77
|
+
const bad = rawPaths.filter((s) => !resolveClaimPath(s, cwd));
|
|
78
|
+
if (bad.length > 0) {
|
|
79
|
+
return text(pick(getLang(), `这些路径不在项目目录内,认领只接受项目内路径:${bad.slice(0, 3).join("、")}${bad.length > 3 ? `(等 ${bad.length} 个)` : ""}。`, `These paths escape the project directory (claims are project-local): ${bad.slice(0, 3).join(", ")}${bad.length > 3 ? ` (+${bad.length - 3})` : ""}.`, "claimfiles.bad.outside", { paths: bad.slice(0, 3).join(", ") }));
|
|
80
|
+
}
|
|
81
|
+
if (action === "claim") {
|
|
82
|
+
const ttlMs = typeof p.ttlMinutes === "number" && Number.isFinite(p.ttlMinutes)
|
|
83
|
+
? Math.floor(p.ttlMinutes * 60000)
|
|
84
|
+
: CLAIM_TTL_MS;
|
|
85
|
+
const { claimed, conflicts } = store.claim(cwd, { convId: self.convId, title: self.title }, rawPaths.map((s) => ({
|
|
86
|
+
path: s,
|
|
87
|
+
...(typeof p.note === "string" && p.note.trim() ? { note: p.note } : {}),
|
|
88
|
+
ttlMs,
|
|
89
|
+
})));
|
|
90
|
+
const L = getLang();
|
|
91
|
+
const okLine = claimed.length > 0
|
|
92
|
+
? pick(L, `已认领 ${claimed.length} 个(${claimed.map((c) => c.path).join("、")}),约 ${fmtTtl(ttlMs)} 后过期(你发 prompt 自动续)。`, `Claimed ${claimed.length} (${claimed.map((c) => c.path).join(", ")}), expiring in ~${fmtTtl(ttlMs)} (refreshed by your prompts).`, "claimfiles.claim.ok", { n: claimed.length })
|
|
93
|
+
: "";
|
|
94
|
+
const clashLine = conflicts.length > 0
|
|
95
|
+
? pick(L, `先到先得,以下已被别人认领(没抢占):${conflicts.map((c) => `${c.path}(「${c.claim.ownerTitle}」${c.claim.note ? `:${c.claim.note}` : ""})`).join(";")} —— 绕行,或问用户。`, `First-wins; already claimed by others (not overridden): ${conflicts.map((c) => `${c.path} ("${c.claim.ownerTitle}"${c.claim.note ? `: ${c.claim.note}` : ""})`).join("; ")} — steer clear or ask the user.`, "claimfiles.claim.conflict", { n: conflicts.length })
|
|
96
|
+
: "";
|
|
97
|
+
return text([okLine, clashLine].filter(Boolean).join("\n") ||
|
|
98
|
+
pick(L, `无事可做。`, `Nothing to do.`, "claimfiles.claim.noop"), {
|
|
99
|
+
cwd,
|
|
100
|
+
claimed: claimed.map((c) => c.path),
|
|
101
|
+
conflicts: conflicts.map((c) => ({ path: c.path, ownerTitle: c.claim.ownerTitle })),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// release 指定 paths:只放自己的,别人的动不了(返回里如实说)。
|
|
105
|
+
const n = store.release(cwd, self.convId, rawPaths);
|
|
106
|
+
return text(pick(getLang(), n > 0
|
|
107
|
+
? `已释放 ${n} 条你名下的认领(别人的动不了,也没动)。`
|
|
108
|
+
: `这些路径没有你名下的认领(可能是别人的,无权释放)。`, n > 0
|
|
109
|
+
? `Released ${n} of your claim(s) (others' untouched, as they should be).`
|
|
110
|
+
: `None of these are yours (possibly someone else's — not yours to release).`, "claimfiles.release.paths", { n }), { cwd, released: n });
|
|
111
|
+
}
|
|
112
|
+
return text(pick(getLang(), `action 非法:${p.action}(只能是 claim、release 或 list)。`, `Invalid action: ${p.action} (must be "claim", "release" or "list").`, "claimfiles.bad.action", { "p.action": p.action }));
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// claim-store.ts — 文件认领表(claim store)+ 触碰 sidecar
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// 背景:触碰集是「事后诸葛亮」(写完才知道),并行冲突要的是「事前打招呼」。
|
|
5
|
+
// claim = 某对话声明「我要改这几个文件」,其他对话在提醒/files/status 里看到
|
|
6
|
+
// 并绕行。注意这只是 advisory(建议):提醒措辞升级,但不拦 edit/bash ——
|
|
7
|
+
// 强制锁会引入死锁与 stall(见设计说明), first-wins + 过期自动释放是唯一的
|
|
8
|
+
// 防腐机制(fail-open:丢认领只会少一条提醒,绝不卡死任何人)。
|
|
9
|
+
//
|
|
10
|
+
// 存放:
|
|
11
|
+
// - 认领表全局共享(跨浏览器标签页可见)→ AgentService 级单例,落盘
|
|
12
|
+
// <dataDir>/claims.json(SubagentTemplatesStore 同款 best-effort 持久化:
|
|
13
|
+
// 写坏/读坏绝不弄崩 server,坏文件直接丢弃重建)。
|
|
14
|
+
// - 触碰 sidecar 按会话落盘(<转录文件>.touches.json):压缩后旧消息被摘要
|
|
15
|
+
// 替代,sidecar 让 files/status 在压缩后仍有答案;只读小文件,不扫全量转录。
|
|
16
|
+
// inMemory 子代理没有转录文件 → 无 sidecar,走实时消息(老样子)。
|
|
17
|
+
//
|
|
18
|
+
// key 口径:认领按「项目 cwd」分表(默认不跨项目);路径一律存绝对路径
|
|
19
|
+
// (认领时按 conv cwd resolve),比对时触碰路径同样 resolve 后比较 —— 后缀
|
|
20
|
+
// 模糊匹配继续禁用(假阳性比漏报更糟,见 conversation-touches 文件头)。
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
import { mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { dirname, resolve, sep } from "node:path";
|
|
24
|
+
import { normalizeTouchPath, unionTouchLists } from "./conversation-touches.js";
|
|
25
|
+
/** 认领默认 TTL:30 分钟(每次发 prompt 心跳续期,对话关闭时释放)。 */
|
|
26
|
+
export const CLAIM_TTL_MS = 30 * 60 * 1000;
|
|
27
|
+
/** 认领 TTL 上下限(工具参数钳制用)。 */
|
|
28
|
+
export const CLAIM_TTL_MIN_MS = 60 * 1000;
|
|
29
|
+
export const CLAIM_TTL_MAX_MS = 8 * 60 * 60 * 1000;
|
|
30
|
+
/** sidecar 条目上限(防无界增长;超了按 lastTs 掐尾)。 */
|
|
31
|
+
export const SIDECAR_MAX_ENTRIES = 500;
|
|
32
|
+
/** claims.json 版本(以后改格式时迁移用)。 */
|
|
33
|
+
const CLAIMS_FILE_VERSION = 1;
|
|
34
|
+
/** cwd 分表键:绝对归一(大小写不归一,见 touches 局限 3)。 */
|
|
35
|
+
function cwdKey(cwd) {
|
|
36
|
+
try {
|
|
37
|
+
return resolve(cwd);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return cwd;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** 认领路径合法性:必须落在 conv cwd 内(../ 逃逸拒绝,由工具转报错)。 */
|
|
44
|
+
export function resolveClaimPath(raw, cwd) {
|
|
45
|
+
if (typeof raw !== "string" || !raw.trim())
|
|
46
|
+
return undefined;
|
|
47
|
+
let abs;
|
|
48
|
+
try {
|
|
49
|
+
abs = resolve(cwd, normalizeTouchPath(raw));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const root = cwdKey(cwd);
|
|
55
|
+
if (abs !== root && !abs.startsWith(root + sep))
|
|
56
|
+
return undefined;
|
|
57
|
+
return abs;
|
|
58
|
+
}
|
|
59
|
+
function isValidClaim(c) {
|
|
60
|
+
if (!c || typeof c !== "object")
|
|
61
|
+
return false;
|
|
62
|
+
const o = c;
|
|
63
|
+
return (typeof o.path === "string" &&
|
|
64
|
+
!!o.path &&
|
|
65
|
+
typeof o.ownerConvId === "string" &&
|
|
66
|
+
!!o.ownerConvId &&
|
|
67
|
+
typeof o.ownerTitle === "string" &&
|
|
68
|
+
typeof o.claimedAt === "number" &&
|
|
69
|
+
typeof o.expiresAt === "number");
|
|
70
|
+
}
|
|
71
|
+
/** 项目级认领表(AgentService 级单例;I/O 全 best-effort)。 */
|
|
72
|
+
export class ClaimStore {
|
|
73
|
+
file;
|
|
74
|
+
tables = new Map();
|
|
75
|
+
constructor(file) {
|
|
76
|
+
this.file = file;
|
|
77
|
+
this.load();
|
|
78
|
+
}
|
|
79
|
+
/** 认领:先到先得。别人(含过期前)的 → conflicts;自己的 → 刷新 note/过期。 */
|
|
80
|
+
claim(cwd, owner, inputs, now = Date.now()) {
|
|
81
|
+
const key = cwdKey(cwd);
|
|
82
|
+
this.sweepTable(key, now);
|
|
83
|
+
const table = this.tables.get(key) ?? [];
|
|
84
|
+
const claimed = [];
|
|
85
|
+
const conflicts = [];
|
|
86
|
+
for (const input of inputs) {
|
|
87
|
+
const abs = resolveClaimPath(input.path, cwd);
|
|
88
|
+
if (!abs)
|
|
89
|
+
continue; // 非法路径调用方已拦,这里只跳过不抛错
|
|
90
|
+
const ttl = typeof input.ttlMs === "number" && Number.isFinite(input.ttlMs)
|
|
91
|
+
? Math.min(CLAIM_TTL_MAX_MS, Math.max(CLAIM_TTL_MIN_MS, Math.floor(input.ttlMs)))
|
|
92
|
+
: CLAIM_TTL_MS;
|
|
93
|
+
const existing = table.find((c) => c.path === abs);
|
|
94
|
+
if (existing && existing.ownerConvId !== owner.convId) {
|
|
95
|
+
conflicts.push({ path: abs, claim: { ...existing } });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const row = {
|
|
99
|
+
path: abs,
|
|
100
|
+
ownerConvId: owner.convId,
|
|
101
|
+
ownerTitle: owner.title,
|
|
102
|
+
...(typeof input.note === "string" && input.note.trim() ? { note: input.note.trim().slice(0, 200) } : {}),
|
|
103
|
+
claimedAt: existing?.claimedAt ?? now,
|
|
104
|
+
expiresAt: now + ttl,
|
|
105
|
+
};
|
|
106
|
+
if (existing)
|
|
107
|
+
Object.assign(existing, row);
|
|
108
|
+
else
|
|
109
|
+
table.push(row);
|
|
110
|
+
claimed.push({ ...row });
|
|
111
|
+
}
|
|
112
|
+
this.tables.set(key, table);
|
|
113
|
+
this.save();
|
|
114
|
+
return { claimed, conflicts };
|
|
115
|
+
}
|
|
116
|
+
/** 释放:只放自己的(别人的动不了);paths 空 = 全放。返回释放条数。 */
|
|
117
|
+
release(cwd, ownerConvId, paths, now = Date.now()) {
|
|
118
|
+
const key = cwdKey(cwd);
|
|
119
|
+
this.sweepTable(key, now);
|
|
120
|
+
const table = this.tables.get(key) ?? [];
|
|
121
|
+
let drop = null;
|
|
122
|
+
if (Array.isArray(paths) && paths.length > 0) {
|
|
123
|
+
drop = new Set();
|
|
124
|
+
for (const p of paths) {
|
|
125
|
+
const abs = resolveClaimPath(p, cwd);
|
|
126
|
+
if (abs)
|
|
127
|
+
drop.add(abs);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const before = table.length;
|
|
131
|
+
this.tables.set(key, table.filter((c) => c.ownerConvId !== ownerConvId || (drop !== null && !drop.has(c.path))));
|
|
132
|
+
const n = before - (this.tables.get(key)?.length ?? 0);
|
|
133
|
+
if (n > 0)
|
|
134
|
+
this.save();
|
|
135
|
+
return n;
|
|
136
|
+
}
|
|
137
|
+
/** 按 owner 全放(对话关闭/释放时调)。返回释放条数。 */
|
|
138
|
+
releaseByOwner(ownerConvId, now = Date.now()) {
|
|
139
|
+
let n = 0;
|
|
140
|
+
for (const [key, table] of this.tables) {
|
|
141
|
+
this.sweepTable(key, now);
|
|
142
|
+
const kept = (this.tables.get(key) ?? table).filter((c) => c.ownerConvId !== ownerConvId);
|
|
143
|
+
n += table.length - kept.length;
|
|
144
|
+
this.tables.set(key, kept);
|
|
145
|
+
}
|
|
146
|
+
if (n > 0)
|
|
147
|
+
this.save();
|
|
148
|
+
return n;
|
|
149
|
+
}
|
|
150
|
+
/** 心跳续期:该 owner 的全部认领延长 TTL(发 prompt 时调)。返回续期条数。 */
|
|
151
|
+
touch(ownerConvId, now = Date.now()) {
|
|
152
|
+
let n = 0;
|
|
153
|
+
for (const [key, table] of this.tables) {
|
|
154
|
+
this.sweepTable(key, now);
|
|
155
|
+
for (const c of this.tables.get(key) ?? table) {
|
|
156
|
+
if (c.ownerConvId === ownerConvId) {
|
|
157
|
+
c.expiresAt = now + CLAIM_TTL_MS;
|
|
158
|
+
n++;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (n > 0)
|
|
163
|
+
this.save();
|
|
164
|
+
return n;
|
|
165
|
+
}
|
|
166
|
+
/** 读某项目认领表(已扫过期,返回副本,调用方随便改)。 */
|
|
167
|
+
list(cwd, now = Date.now()) {
|
|
168
|
+
const key = cwdKey(cwd);
|
|
169
|
+
this.sweepTable(key, now);
|
|
170
|
+
return (this.tables.get(key) ?? []).map((c) => ({ ...c }));
|
|
171
|
+
}
|
|
172
|
+
sweepTable(key, now) {
|
|
173
|
+
const table = this.tables.get(key);
|
|
174
|
+
if (!table || table.length === 0)
|
|
175
|
+
return;
|
|
176
|
+
const kept = table.filter((c) => c.expiresAt > now);
|
|
177
|
+
if (kept.length !== table.length) {
|
|
178
|
+
this.tables.set(key, kept);
|
|
179
|
+
this.save();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
load() {
|
|
183
|
+
let raw = "";
|
|
184
|
+
try {
|
|
185
|
+
raw = readFileSync(this.file, "utf8");
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return; // 文件不存在 = 空表
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const data = JSON.parse(raw);
|
|
192
|
+
if (!data || typeof data !== "object" || data.version !== CLAIMS_FILE_VERSION)
|
|
193
|
+
return;
|
|
194
|
+
if (!data.tables || typeof data.tables !== "object")
|
|
195
|
+
return;
|
|
196
|
+
for (const [key, rows] of Object.entries(data.tables)) {
|
|
197
|
+
if (!Array.isArray(rows))
|
|
198
|
+
continue;
|
|
199
|
+
const valid = rows.filter(isValidClaim).map((c) => ({
|
|
200
|
+
...c,
|
|
201
|
+
note: typeof c.note === "string" ? c.note : undefined,
|
|
202
|
+
}));
|
|
203
|
+
if (valid.length > 0)
|
|
204
|
+
this.tables.set(key, valid);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// 坏文件丢弃(下次 save 覆盖重建),绝不抛错
|
|
209
|
+
this.tables.clear();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
save() {
|
|
213
|
+
try {
|
|
214
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
215
|
+
const tmp = `${this.file}.tmp`;
|
|
216
|
+
writeFileSync(tmp, JSON.stringify({ version: CLAIMS_FILE_VERSION, tables: Object.fromEntries(this.tables) }));
|
|
217
|
+
renameSync(tmp, this.file);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// 持久化故障绝不能弄崩 server(内存表照常用)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** 触碰集里命中认领表的条目(双方都 resolve 成绝对路径后比较)。 */
|
|
225
|
+
export function matchClaims(touches, claims, cwd) {
|
|
226
|
+
if (!Array.isArray(touches) || !Array.isArray(claims) || touches.length === 0 || claims.length === 0)
|
|
227
|
+
return [];
|
|
228
|
+
const byPath = new Map(claims.map((c) => [c.path, c]));
|
|
229
|
+
const out = [];
|
|
230
|
+
for (const t of touches) {
|
|
231
|
+
let abs;
|
|
232
|
+
try {
|
|
233
|
+
abs = resolve(cwd, normalizeTouchPath(t.path));
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const hit = byPath.get(abs);
|
|
239
|
+
if (hit)
|
|
240
|
+
out.push({ touch: t, claim: hit });
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
function sidecarPath(sessionFile) {
|
|
245
|
+
return `${sessionFile}.touches.json`;
|
|
246
|
+
}
|
|
247
|
+
/** 读触碰 sidecar(同步小文件;不存在/坏了 → undefined,调用方回落现算)。 */
|
|
248
|
+
export function readTouchSidecar(sessionFile) {
|
|
249
|
+
if (typeof sessionFile !== "string" || !sessionFile)
|
|
250
|
+
return undefined;
|
|
251
|
+
let raw = "";
|
|
252
|
+
try {
|
|
253
|
+
if (statSync(sidecarPath(sessionFile)).size > 1024 * 1024)
|
|
254
|
+
return undefined;
|
|
255
|
+
raw = readFileSync(sidecarPath(sessionFile), "utf8");
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const data = JSON.parse(raw);
|
|
262
|
+
if (!data || !Array.isArray(data.files))
|
|
263
|
+
return undefined;
|
|
264
|
+
const valid = data.files.filter((f) => !!f && typeof f === "object" && typeof f.path === "string");
|
|
265
|
+
return valid.map((f) => ({
|
|
266
|
+
path: f.path,
|
|
267
|
+
count: typeof f.count === "number" && Number.isFinite(f.count) ? f.count : 1,
|
|
268
|
+
lastTs: typeof f.lastTs === "number" && Number.isFinite(f.lastTs) ? f.lastTs : 0,
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** 写触碰 sidecar(读-合并-写;空集不写;全 best-effort, never throw)。 */
|
|
276
|
+
export async function mergeTouchSidecar(sessionFile, fresh) {
|
|
277
|
+
if (typeof sessionFile !== "string" || !sessionFile)
|
|
278
|
+
return;
|
|
279
|
+
if (!Array.isArray(fresh) || fresh.length === 0)
|
|
280
|
+
return;
|
|
281
|
+
try {
|
|
282
|
+
const merged = unionTouchLists(readTouchSidecar(sessionFile) ?? [], fresh).slice(0, SIDECAR_MAX_ENTRIES);
|
|
283
|
+
const tmp = `${sidecarPath(sessionFile)}.tmp`;
|
|
284
|
+
writeFileSync(tmp, JSON.stringify({ version: 1, updatedAt: Date.now(), files: merged }));
|
|
285
|
+
renameSync(tmp, sidecarPath(sessionFile));
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
// sidecar 只是加速+防压缩丢失,写坏了下次重算
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/** 删 sidecar(会话删除时顺带;不存在不报错)。 */
|
|
292
|
+
export function removeTouchSidecar(sessionFile) {
|
|
293
|
+
if (typeof sessionFile !== "string" || !sessionFile)
|
|
294
|
+
return;
|
|
295
|
+
try {
|
|
296
|
+
unlinkSync(sidecarPath(sessionFile));
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
// 不存在或删不掉都无所谓
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -12,6 +12,40 @@ import { normalizeSoftCapByModel, normalizeSoftCapTokens } from "./soft-cap.js";
|
|
|
12
12
|
import { deriveLegacy, legacyToDisabled, normalizeDisabledAgentTools } from "./tool-manager.js";
|
|
13
13
|
/** 大模型 API 出错自动重试次数的默认值(SDK 默认 3)。 */
|
|
14
14
|
export const DEFAULT_RETRY_MAX_ATTEMPTS = 6;
|
|
15
|
+
/** 工具执行看门狗超时的默认值(默认 20 分钟;环境变量 PI_WEB_TOOL_TIMEOUT_MS 覆盖)。 */
|
|
16
|
+
export const DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS = (() => {
|
|
17
|
+
const v = Number(process.env.PI_WEB_TOOL_TIMEOUT_MS);
|
|
18
|
+
return Number.isFinite(v) && v >= 0 ? v : 20 * 60_000;
|
|
19
|
+
})();
|
|
20
|
+
/** 归一化工具看门狗超时(毫秒):0 = 禁用;非数值、负数或空值回落默认值,非负整数保留。 */
|
|
21
|
+
export function normalizeToolWatchdogTimeoutMs(v) {
|
|
22
|
+
if (v === null || v === undefined || v === "")
|
|
23
|
+
return DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS;
|
|
24
|
+
const n = Math.floor(Number(v));
|
|
25
|
+
if (!Number.isFinite(n) || n < 0)
|
|
26
|
+
return DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS;
|
|
27
|
+
return n;
|
|
28
|
+
}
|
|
29
|
+
/** 工具自己声明超时时留给它的余量(毫秒):让工具先自己超时并返回错误,
|
|
30
|
+
* 而不是看门狗先下手把整轮对话 abort 掉。 */
|
|
31
|
+
export const TOOL_WATCHDOG_EXPLICIT_GRACE_MS = 5_000;
|
|
32
|
+
/** 计算单次工具调用的有效看门狗超时(毫秒;≤0 = 不布看门狗)。
|
|
33
|
+
* 基础值来自设置/环境变量(见 DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS);**若工具调用
|
|
34
|
+
* 自己声明了更长的超时**(目前只有 bash 的 `args.timeout`,单位秒),自动顺延到
|
|
35
|
+
* 「工具超时 + 余量」—— 否则 AI 明确要求跑 90 分钟的命令,会被 20 分钟的看门狗
|
|
36
|
+
* 连同整轮对话一起剁掉。纯函数,可单测。 */
|
|
37
|
+
export function effectiveToolWatchdogMs(baseMs, toolName, args) {
|
|
38
|
+
if (!Number.isFinite(baseMs) || baseMs <= 0)
|
|
39
|
+
return 0;
|
|
40
|
+
let ms = baseMs;
|
|
41
|
+
if (toolName === "bash" && args && typeof args === "object") {
|
|
42
|
+
const raw = args.timeout;
|
|
43
|
+
const sec = typeof raw === "number" ? raw : Number(raw);
|
|
44
|
+
if (Number.isFinite(sec) && sec > 0)
|
|
45
|
+
ms = Math.max(ms, sec * 1000 + TOOL_WATCHDOG_EXPLICIT_GRACE_MS);
|
|
46
|
+
}
|
|
47
|
+
return ms;
|
|
48
|
+
}
|
|
15
49
|
/** 归一化插件 AI 工具禁用名单:只收非空字符串(去重,上限 256 个)。
|
|
16
50
|
* 与 disabledAgentTools 不同——插件工具名是动态的(注册才知道),不能按
|
|
17
51
|
* 固定目录校验;未知/已卸载插件的条目刻意保留(重装后仍保持关闭)。
|
|
@@ -285,15 +319,20 @@ export class ClientStateStore {
|
|
|
285
319
|
state.lastCwd = cwd;
|
|
286
320
|
const now = Date.now();
|
|
287
321
|
state.projects = [{ path: cwd, lastUsed: now }, ...state.projects.filter((p) => p.path !== cwd)].slice(0, 30);
|
|
288
|
-
// Opening the workspace again clears its removal tombstone.
|
|
289
|
-
|
|
290
|
-
|
|
322
|
+
// Opening the workspace again clears its removal tombstone across all clients and global settings.
|
|
323
|
+
for (const cState of Object.values(all)) {
|
|
324
|
+
if (cState.removedProjects?.length) {
|
|
325
|
+
cState.removedProjects = cState.removedProjects.filter((p) => p !== cwd);
|
|
326
|
+
}
|
|
291
327
|
}
|
|
292
328
|
this.save();
|
|
293
329
|
}
|
|
294
330
|
/** Drop one workspace from the recent-project list (user-requested removal).
|
|
295
331
|
* Records a tombstone too: pushProjects() re-discovers cwds from session
|
|
296
|
-
* files on every listing, so without it the entry would instantly reappear.
|
|
332
|
+
* files on every listing, so without it the entry would instantly reappear.
|
|
333
|
+
* Recorded in both the requesting client and GLOBAL_SETTINGS_KEY so the
|
|
334
|
+
* removal stays across all browser tabs and server restarts until the user
|
|
335
|
+
* explicitly opens that project again. */
|
|
297
336
|
removeProject(clientId, cwd) {
|
|
298
337
|
const all = this.load();
|
|
299
338
|
const state = (all[clientId] ??= { projects: [] });
|
|
@@ -303,12 +342,28 @@ export class ClientStateStore {
|
|
|
303
342
|
const removed = new Set(state.removedProjects ?? []);
|
|
304
343
|
removed.add(cwd);
|
|
305
344
|
state.removedProjects = [...removed];
|
|
345
|
+
const globalState = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
|
|
346
|
+
const globalRemoved = new Set(globalState.removedProjects ?? []);
|
|
347
|
+
globalRemoved.add(cwd);
|
|
348
|
+
globalState.removedProjects = [...globalRemoved];
|
|
349
|
+
for (const [id, cState] of Object.entries(all)) {
|
|
350
|
+
if (id !== ClientStateStore.GLOBAL_SETTINGS_KEY && cState.projects) {
|
|
351
|
+
cState.projects = cState.projects.filter((p) => p.path !== cwd);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
306
354
|
this.save();
|
|
307
355
|
}
|
|
308
356
|
/** Tombstoned projects (explicitly removed by the user) for filtering the
|
|
309
|
-
* merged recent-project list. */
|
|
357
|
+
* merged recent-project list across clients. */
|
|
310
358
|
getRemovedProjects(clientId) {
|
|
311
|
-
|
|
359
|
+
const all = this.load();
|
|
360
|
+
const clientRemoved = all[clientId]?.removedProjects ?? [];
|
|
361
|
+
const globalRemoved = all[ClientStateStore.GLOBAL_SETTINGS_KEY]?.removedProjects ?? [];
|
|
362
|
+
if (globalRemoved.length === 0)
|
|
363
|
+
return clientRemoved;
|
|
364
|
+
if (clientRemoved.length === 0)
|
|
365
|
+
return globalRemoved;
|
|
366
|
+
return [...new Set([...clientRemoved, ...globalRemoved])];
|
|
312
367
|
}
|
|
313
368
|
/** Last-used goal/review prefs for a client, or undefined if never set. */
|
|
314
369
|
getGoalPrefs(clientId) {
|
|
@@ -419,6 +474,8 @@ export class ClientStateStore {
|
|
|
419
474
|
: (stored?.terminalToolsEnabled ?? false),
|
|
420
475
|
terminalBash: stored?.terminalBash ?? false,
|
|
421
476
|
terminalBashIdleMs: stored?.terminalBashIdleMs ?? 15_000,
|
|
477
|
+
toolWatchdogTimeoutMs: normalizeToolWatchdogTimeoutMs(stored?.toolWatchdogTimeoutMs),
|
|
478
|
+
readDirEnabled: stored?.readDirEnabled ?? true,
|
|
422
479
|
editSoftEnabled: stored?.disabledAgentTools !== undefined
|
|
423
480
|
? deriveLegacy(legacyToDisabled(stored)).editSoftEnabled
|
|
424
481
|
: (stored?.editSoftEnabled ?? false),
|
|
@@ -431,6 +488,7 @@ export class ClientStateStore {
|
|
|
431
488
|
devNoCache: stored?.devNoCache,
|
|
432
489
|
autoReload: stored?.autoReload,
|
|
433
490
|
toolsWrap: stored?.toolsWrap ?? true,
|
|
491
|
+
toolImagesEnabled: stored?.toolImagesEnabled ?? true,
|
|
434
492
|
skillsFullText: normalizeSkillList(stored?.skillsFullText),
|
|
435
493
|
visionBridgeEnabled: stored?.visionBridgeEnabled ?? true,
|
|
436
494
|
visionBridgeModel: stored?.visionBridgeModel ?? null,
|
|
@@ -473,6 +531,8 @@ export class ClientStateStore {
|
|
|
473
531
|
terminalToolsEnabled: settings.terminalToolsEnabled ?? cur.terminalToolsEnabled ?? false,
|
|
474
532
|
terminalBash: settings.terminalBash ?? cur.terminalBash ?? false,
|
|
475
533
|
terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
|
|
534
|
+
toolWatchdogTimeoutMs: normalizeToolWatchdogTimeoutMs(settings.toolWatchdogTimeoutMs ?? cur.toolWatchdogTimeoutMs ?? DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS),
|
|
535
|
+
readDirEnabled: settings.readDirEnabled ?? cur.readDirEnabled ?? true,
|
|
476
536
|
editSoftEnabled: settings.editSoftEnabled ?? cur.editSoftEnabled ?? false,
|
|
477
537
|
questionnaireEnabled: settings.questionnaireEnabled ?? cur.questionnaireEnabled ?? true,
|
|
478
538
|
goalModeEnabled: settings.goalModeEnabled ?? cur.goalModeEnabled ?? true,
|
|
@@ -481,6 +541,7 @@ export class ClientStateStore {
|
|
|
481
541
|
devNoCache: settings.devNoCache ?? cur.devNoCache,
|
|
482
542
|
autoReload: settings.autoReload ?? cur.autoReload,
|
|
483
543
|
toolsWrap: settings.toolsWrap ?? cur.toolsWrap ?? true,
|
|
544
|
+
toolImagesEnabled: settings.toolImagesEnabled ?? cur.toolImagesEnabled ?? true,
|
|
484
545
|
skillsFullText: normalizeSkillList(settings.skillsFullText ?? cur.skillsFullText),
|
|
485
546
|
visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
|
|
486
547
|
visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
|
|
@@ -632,6 +693,53 @@ export class ClientStateStore {
|
|
|
632
693
|
delete all[clientId].projectModels;
|
|
633
694
|
this.save();
|
|
634
695
|
}
|
|
696
|
+
/** 全局默认模型(跨客户端共享,存 __settings__ 键)。缺省 = 未设置。 */
|
|
697
|
+
getDefaultModel() {
|
|
698
|
+
return this.load()[ClientStateStore.GLOBAL_SETTINGS_KEY]?.defaultModel;
|
|
699
|
+
}
|
|
700
|
+
/** 设置全局默认模型("provider/id")。 */
|
|
701
|
+
saveDefaultModel(modelId) {
|
|
702
|
+
const all = this.load();
|
|
703
|
+
const state = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
|
|
704
|
+
state.defaultModel = modelId;
|
|
705
|
+
this.save();
|
|
706
|
+
}
|
|
707
|
+
/** 清除全局默认模型(连带其 key 记忆)。 */
|
|
708
|
+
clearDefaultModel() {
|
|
709
|
+
const all = this.load();
|
|
710
|
+
const state = all[ClientStateStore.GLOBAL_SETTINGS_KEY];
|
|
711
|
+
if (!state)
|
|
712
|
+
return;
|
|
713
|
+
delete state.defaultModel;
|
|
714
|
+
delete state.defaultProviderKeys;
|
|
715
|
+
this.save();
|
|
716
|
+
}
|
|
717
|
+
/** 取全局默认的某 provider key。 */
|
|
718
|
+
getDefaultProviderKey(provider) {
|
|
719
|
+
return this.load()[ClientStateStore.GLOBAL_SETTINGS_KEY]?.defaultProviderKeys?.[provider];
|
|
720
|
+
}
|
|
721
|
+
/** 记全局默认的某 provider key(设全局默认模型时连带记)。 */
|
|
722
|
+
saveDefaultProviderKey(provider, keyName) {
|
|
723
|
+
const all = this.load();
|
|
724
|
+
const state = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
|
|
725
|
+
(state.defaultProviderKeys ??= {})[provider] = keyName;
|
|
726
|
+
this.save();
|
|
727
|
+
}
|
|
728
|
+
/** 全局默认 key 跟随删除:被删的 key 若是全局默认记的,指到接替者(无接替则删引用)。 */
|
|
729
|
+
repointDeletedKeyInDefault(provider, deletedKeyName, newActive) {
|
|
730
|
+
const all = this.load();
|
|
731
|
+
const map = all[ClientStateStore.GLOBAL_SETTINGS_KEY]?.defaultProviderKeys;
|
|
732
|
+
if (!map || map[provider] !== deletedKeyName)
|
|
733
|
+
return;
|
|
734
|
+
if (newActive)
|
|
735
|
+
map[provider] = newActive;
|
|
736
|
+
else {
|
|
737
|
+
delete map[provider];
|
|
738
|
+
if (Object.keys(map).length === 0)
|
|
739
|
+
delete all[ClientStateStore.GLOBAL_SETTINGS_KEY].defaultProviderKeys;
|
|
740
|
+
}
|
|
741
|
+
this.save();
|
|
742
|
+
}
|
|
635
743
|
/** 全局「快捷短语已 seed」标记(非 per-clientId)。
|
|
636
744
|
*
|
|
637
745
|
* 为什么全局:clientId 存 sessionStorage(每标签页独立、关浏览器即失),按
|