pi-web-ui 0.91.0 → 0.92.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 +34 -1
- package/bin/pi-web-ui.mjs +21 -2
- package/dist/server/agent-service.js +443 -45
- package/dist/server/claim-files-tool.js +116 -0
- package/dist/server/claim-store.js +301 -0
- package/dist/server/client-state.js +49 -0
- package/dist/server/conversation-read-tool.js +369 -27
- package/dist/server/conversation-touches.js +357 -0
- package/dist/server/dsh/dsh-agent-service.js +7 -0
- package/dist/server/index.js +32 -4
- package/dist/server/model-admin.js +124 -2
- package/dist/server/model-enrich.js +478 -0
- package/dist/server/plugin-schedule.js +27 -2
- package/dist/server/plugins.js +65 -10
- package/dist/server/present-files-tool.js +303 -0
- package/dist/server/read-tool.js +91 -0
- package/dist/server/serialize.js +21 -1
- package/dist/server/settings-service.js +7 -0
- package/dist/server/text-sniff.js +15 -0
- package/dist/server/themes.js +17 -2
- package/dist/server/tool-manager.js +7 -1
- package/package.json +1 -1
- package/plugin-sdk/README.md +1 -0
- package/plugins/catalog.json +10 -0
- package/themes/catppuccin-latte.css +136 -0
- package/themes/catppuccin.css +140 -0
- package/themes/nord.css +130 -0
- package/themes/one-dark.css +131 -0
- package/themes/paper.css +12 -12
- package/themes/solarized-light.css +131 -0
- package/themes/tokyo-night.css +140 -0
- package/web/dist/assets/{TerminalPanel-GtDQ1h85.js → TerminalPanel-RACpk6AX.js} +1 -1
- package/web/dist/assets/index-BgrnP9Jz.js +364 -0
- package/web/dist/assets/index-Cy8dSTZt.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
|
@@ -40,14 +40,21 @@ import { bilingual, pick, resolveServerLang } from "./i18n.js";
|
|
|
40
40
|
import { SubagentTemplatesStore, pickTemplatePrompt } from "./subagent-templates.js";
|
|
41
41
|
import { ComposerDraftsStore } from "./composer-drafts.js";
|
|
42
42
|
import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, } from "./terminals.js";
|
|
43
|
-
import { applyAgentToolsGating, ASK_USER_QUESTION_TOOL_NAME, BROWSER_PAGE_TOOL_NAME, effectiveDisabledAgentTools, isTerminalGuidanceOn, MARKERS_LIST_TOOL_NAME, } from "./tool-manager.js";
|
|
43
|
+
import { applyAgentToolsGating, ASK_USER_QUESTION_TOOL_NAME, BROWSER_PAGE_TOOL_NAME, effectiveDisabledAgentTools, isAgentToolEnabled, isTerminalGuidanceOn, MARKERS_LIST_TOOL_NAME, PRESENT_FILES_TOOL_NAME, } from "./tool-manager.js";
|
|
44
44
|
import { WebUIContext } from "./webui-context.js";
|
|
45
45
|
import { DEFAULT_COMPACTION_RESERVE_TOKENS, effectiveSoftCap, softCapToReserve } from "./soft-cap.js";
|
|
46
46
|
import { decodeText } from "./text-sniff.js";
|
|
47
47
|
import { makeEditSoftTool } from "./edit-soft-tool.js";
|
|
48
|
+
// 覆盖 SDK 内置 read:路径是目录时列出目录条目(行为开关 readDirEnabled,默认开)。
|
|
49
|
+
import { makeReadDirTool } from "./read-tool.js";
|
|
50
|
+
// 展示文件给用户(present_files):图片/视频内联、文本开预览弹窗、本地打开按钮。
|
|
51
|
+
import { makePresentFilesTool } from "./present-files-tool.js";
|
|
48
52
|
import { collectSubagentDescendantIds, makeSubagentTools, subagentTitle, withSubagentOwner, } from "./subagents.js";
|
|
49
53
|
import { makeDelegateTaskTool } from "./delegate-task.js";
|
|
50
54
|
import { makeConversationReadTool, parseTranscriptLines, toTranscriptInput, } from "./conversation-read-tool.js";
|
|
55
|
+
import { extractTouches, formatTouchesCompact, intersectTouches } from "./conversation-touches.js";
|
|
56
|
+
import { ClaimStore, matchClaims, mergeTouchSidecar, readTouchSidecar, removeTouchSidecar } from "./claim-store.js";
|
|
57
|
+
import { makeClaimFilesTool } from "./claim-files-tool.js";
|
|
51
58
|
import { makeSkillTool } from "./skill-tool.js";
|
|
52
59
|
import { makeScheduleTools } from "./schedule-agent-tool.js";
|
|
53
60
|
import { sameSessionFile } from "./scheduler-tasks.js";
|
|
@@ -1510,16 +1517,8 @@ export class ClientSession {
|
|
|
1510
1517
|
const c = this.convs.get(id);
|
|
1511
1518
|
if (!c)
|
|
1512
1519
|
return undefined;
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
raw = (c.session.messages ??
|
|
1516
|
-
c.session.agent.state.messages ??
|
|
1517
|
-
[]);
|
|
1518
|
-
}
|
|
1519
|
-
catch {
|
|
1520
|
-
raw = [];
|
|
1521
|
-
}
|
|
1522
|
-
return { title: c.title, cwd: c.cwd, isSubagent: !!c.isSubagent, messages: raw.map(toTranscriptInput) };
|
|
1520
|
+
// 取数与并行提醒的触碰集同一路径(convTranscript),不另起读取逻辑。
|
|
1521
|
+
return { title: c.title, cwd: c.cwd, isSubagent: !!c.isSubagent, messages: this.convTranscript(c) };
|
|
1523
1522
|
},
|
|
1524
1523
|
listHistorySessions: async (scope, cwd) => {
|
|
1525
1524
|
const infos = scope === "all"
|
|
@@ -1534,6 +1533,29 @@ export class ClientSession {
|
|
|
1534
1533
|
cwd: s.cwd,
|
|
1535
1534
|
}));
|
|
1536
1535
|
},
|
|
1536
|
+
readTouchSidecar: (id, path) => {
|
|
1537
|
+
// sidecar 让 files/status 在压缩后仍有答案(additive 可选方法:
|
|
1538
|
+
// 读不到就回 undefined,调用方回落现算转录 —— 绝不抛错阻塞工具)。
|
|
1539
|
+
try {
|
|
1540
|
+
if (id) {
|
|
1541
|
+
const c = this.convs.get(id);
|
|
1542
|
+
let file;
|
|
1543
|
+
try {
|
|
1544
|
+
file = c?.session.sessionFile ?? undefined;
|
|
1545
|
+
}
|
|
1546
|
+
catch {
|
|
1547
|
+
file = undefined;
|
|
1548
|
+
}
|
|
1549
|
+
return readTouchSidecar(file);
|
|
1550
|
+
}
|
|
1551
|
+
if (path)
|
|
1552
|
+
return readTouchSidecar(path);
|
|
1553
|
+
return undefined;
|
|
1554
|
+
}
|
|
1555
|
+
catch {
|
|
1556
|
+
return undefined;
|
|
1557
|
+
}
|
|
1558
|
+
},
|
|
1537
1559
|
readHistorySession: async (path) => {
|
|
1538
1560
|
const all = await SessionManager.listAll(piSessionsRoot());
|
|
1539
1561
|
const hit = all.find((s) => resolve(s.path) === resolve(path));
|
|
@@ -1556,6 +1578,28 @@ export class ClientSession {
|
|
|
1556
1578
|
},
|
|
1557
1579
|
};
|
|
1558
1580
|
}
|
|
1581
|
+
/** claim_files 工具的数据宿主:owner 口径同 subagent/skill(本 runtime 所属会话)。
|
|
1582
|
+
* 常驻注册、不进 AGENT_TOOL_CATALOG(例外:目录工具必须有设置页行,见
|
|
1583
|
+
* settings-tool-rows.test.ts,而 web/src 正被并行任务占用;advisory 工具常驻
|
|
1584
|
+
* 默认开可接受,目录项 + 设置行等 web/src 空出来后补)。 */
|
|
1585
|
+
claimToolHost(ownerId) {
|
|
1586
|
+
const target = () => {
|
|
1587
|
+
try {
|
|
1588
|
+
return (ownerId ?? "").trim() !== "" ? this.convs.get(ownerId.trim()) : this.convs.get(this.activeId);
|
|
1589
|
+
}
|
|
1590
|
+
catch {
|
|
1591
|
+
return undefined;
|
|
1592
|
+
}
|
|
1593
|
+
};
|
|
1594
|
+
return {
|
|
1595
|
+
cwd: () => target()?.cwd ?? this.cwd,
|
|
1596
|
+
self: () => {
|
|
1597
|
+
const t = target();
|
|
1598
|
+
return { convId: t?.id ?? this.activeId, title: t?.title ?? "" };
|
|
1599
|
+
},
|
|
1600
|
+
store: () => this.getClaimStore?.(),
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1559
1603
|
/** skill 工具的数据宿主:读所属会话 loader 的实时技能表 + 主会话禁用集过滤
|
|
1560
1604
|
* (与 skillsOverride 主会话语义一致)。ownerId 语义同 browser_page(本
|
|
1561
1605
|
* runtime 所属会话,不是派发瞬间的 active)。失败回空目录,不抛错。 */
|
|
@@ -1966,6 +2010,11 @@ export class ClientSession {
|
|
|
1966
2010
|
],
|
|
1967
2011
|
},
|
|
1968
2012
|
});
|
|
2013
|
+
// 桥接工具目标(问卷 / 页面):每次调用都解析「现在谁持有这条对话」,
|
|
2014
|
+
// 而不是认这个 runtime 是在哪个 ClientSession 里建出来的(过户会换主)。
|
|
2015
|
+
// anchor 在拿到 created.session 后回填(SDK 的 runtime.session 就是它)。
|
|
2016
|
+
const bridgeAnchor = {};
|
|
2017
|
+
const bridge = this.bridgeTarget(bridgeAnchor, ownerId);
|
|
1969
2018
|
const created = await createAgentSessionFromServices({
|
|
1970
2019
|
services,
|
|
1971
2020
|
sessionManager,
|
|
@@ -1988,6 +2037,14 @@ export class ClientSession {
|
|
|
1988
2037
|
// 设置关 → 原生 bash;开 → 终端 bash。
|
|
1989
2038
|
() => this.settingsSvc.current.terminalBash),
|
|
1990
2039
|
...makePersistentTerminalTools(terminals, effectiveCwd, () => this.getLang()),
|
|
2040
|
+
// 覆盖 SDK 内置 read(customTools 按 name 覆盖):路径是目录时列出目录
|
|
2041
|
+
// 条目(复用 SDK ls 的排序/`/` 后缀/截断口径),其余情况原样转发内置实现。
|
|
2042
|
+
// 开关是行为开关(read 本体不可关),每次调用实时读设置——不进
|
|
2043
|
+
// tool-manager 的 ActiveSet 目录。DSH 引擎无 customTool 注册面,不接。
|
|
2044
|
+
makeReadDirTool(effectiveCwd, {
|
|
2045
|
+
dirEnabled: () => this.settingsSvc.current.readDirEnabled !== false,
|
|
2046
|
+
getLang: () => this.getLang(),
|
|
2047
|
+
}),
|
|
1991
2048
|
// 不覆盖内置 edit 的独立宽松编辑工具(缩进不敏感匹配;开关看设置)。
|
|
1992
2049
|
makeEditSoftTool(effectiveCwd, () => this.getLang()),
|
|
1993
2050
|
// 插件注册的 AI 工具(创建时刻的实时快照,已按 disabledPluginTools 过滤;
|
|
@@ -2017,16 +2074,38 @@ export class ClientSession {
|
|
|
2017
2074
|
// 标准引擎的 ask_user_question:模型调用 → 浏览器富渲染问卷(复用 DSH
|
|
2018
2075
|
// 的 question_pending/question_answer 协议,前端 DshQuestionDialog)。
|
|
2019
2076
|
// DSH 引擎不经此(它走 goal-rpc 的 userQuestions provider)。
|
|
2020
|
-
makeAskUserQuestionTool(
|
|
2077
|
+
makeAskUserQuestionTool(bridge, ownerId),
|
|
2021
2078
|
// 浏览器页面工具:模型调用 → page_request 给浏览器 → page-picker 扩展
|
|
2022
2079
|
// 操作用户授权的页面 → page_response 回来。ownerId 语义同上(本 runtime
|
|
2023
2080
|
// 所属会话,不是派发瞬间的 active)。
|
|
2024
|
-
makeBrowserPageTool(
|
|
2081
|
+
makeBrowserPageTool(bridge, ownerId),
|
|
2025
2082
|
// 别的对话读取(运行中含子代理 + 历史转录,只读):用户引用了别的
|
|
2026
2083
|
// 对话(引用 chip / 粘过来的 id / “看看之前那个对话”)时用。子代理
|
|
2027
2084
|
// 会话同样注册了它,可自然嵌套读取。不需要 ownerId——读的是本
|
|
2028
2085
|
// 客户端的 conversation 体系与落盘历史,与派发者无关。
|
|
2029
|
-
|
|
2086
|
+
// extras 认领表:files/status 顺带展示(AgentService 级共享)。
|
|
2087
|
+
makeConversationReadTool(this.conversationReadHost(), () => this.getLang(), {
|
|
2088
|
+
listClaims: (cwd) => (this.getClaimStore?.().list(cwd) ?? []).map((c) => ({
|
|
2089
|
+
path: c.path,
|
|
2090
|
+
ownerTitle: c.ownerTitle,
|
|
2091
|
+
...(c.note ? { note: c.note } : {}),
|
|
2092
|
+
})),
|
|
2093
|
+
}),
|
|
2094
|
+
// 文件认领(claim_files):常驻注册、不进工具目录(见 claimToolHost
|
|
2095
|
+
// 注释)。ownerId 语义同 subagent/skill(本 runtime 所属会话)。
|
|
2096
|
+
// DSH 引擎无 customTool 注册面,不接(提醒里照样能看到认领)。
|
|
2097
|
+
makeClaimFilesTool(this.claimToolHost(ownerId), () => this.getLang()),
|
|
2098
|
+
// 展示文件给用户(present_files,issue #231):模型给路径清单,服务端
|
|
2099
|
+
// 只做只读探测(stat + 未知扩展嗅探 + 文本摘录),结构化 items 走 tool
|
|
2100
|
+
// result 的 details 下发,前端渲染成图片/视频内联 + 预览/本地打开/
|
|
2101
|
+
// 在文件夹中显示/下载/复制路径的卡片。不打开任何窗口,系统级动作
|
|
2102
|
+
// 一律由用户点卡片触发(file_open_default / file_reveal)。
|
|
2103
|
+
// 开关走统一工具 tab(ActiveSet 门控;enabled 兜底只做报错文案)。
|
|
2104
|
+
// DSH 引擎无 customTool 注册面,不接。
|
|
2105
|
+
makePresentFilesTool(effectiveCwd, {
|
|
2106
|
+
enabled: () => isAgentToolEnabled(PRESENT_FILES_TOOL_NAME, effectiveDisabledAgentTools(this.settingsSvc.current)),
|
|
2107
|
+
getLang: () => this.getLang(),
|
|
2108
|
+
}),
|
|
2030
2109
|
// 技能全文按名加载(名录在 {{skills}} 段):模型不再拼路径调 read。
|
|
2031
2110
|
// 子代理会话同样注册(owner 即真正派发的父对话,读该会话 loader)。
|
|
2032
2111
|
// DSH 引擎无 customTool 注册面,不接。开关走统一工具 tab。
|
|
@@ -2038,6 +2117,9 @@ export class ClientSession {
|
|
|
2038
2117
|
...makeScheduleTools(this.scheduleToolHost(), ownerId, () => this.getLang()),
|
|
2039
2118
|
],
|
|
2040
2119
|
});
|
|
2120
|
+
// 桥接工具归属锚点:SDK 会话对象在本 runtime 生命周期内稳定,过户只搬对话
|
|
2121
|
+
// 不改它(见 ClientSession.findConversationHome)。
|
|
2122
|
+
bridgeAnchor.session = created.session;
|
|
2041
2123
|
// 终端工具开关从创建起就生效(工具始终注册进注册表,只调活跃集)。
|
|
2042
2124
|
this.applyToolGating(created.session);
|
|
2043
2125
|
return {
|
|
@@ -2203,6 +2285,9 @@ export class ClientSession {
|
|
|
2203
2285
|
// model picker needs it even before the client asks).
|
|
2204
2286
|
this.modelAdmin.listProviderKeys();
|
|
2205
2287
|
this.modelAdmin.listProviderOAuthFlows();
|
|
2288
|
+
// Reconnect: push the global default model (the picker's ★ marker +
|
|
2289
|
+
// "set as global default" button state).
|
|
2290
|
+
this.pushDefaultModel();
|
|
2206
2291
|
// PTYs are conversation-owned and survive a socket reconnect.
|
|
2207
2292
|
this.pushTerminals();
|
|
2208
2293
|
}
|
|
@@ -2962,6 +3047,31 @@ export class ClientSession {
|
|
|
2962
3047
|
this.settingsSvc.consumePendingReload();
|
|
2963
3048
|
void this.applySettingsReload();
|
|
2964
3049
|
}
|
|
3050
|
+
// 触碰 sidecar 落盘:压缩后旧消息被摘要替代,files 回落现算会丢历史;
|
|
3051
|
+
// 条数没涨就不写(不在热路径);子代理 inMemory 无转录文件,merge 内 no-op。
|
|
3052
|
+
try {
|
|
3053
|
+
let count = conv.touchSidecarCount ?? -1;
|
|
3054
|
+
try {
|
|
3055
|
+
count = conv.session.getSessionStats().totalMessages;
|
|
3056
|
+
}
|
|
3057
|
+
catch {
|
|
3058
|
+
// 会话替换中 —— 按上次条数处理(多半直接跳过)
|
|
3059
|
+
}
|
|
3060
|
+
if (count !== (conv.touchSidecarCount ?? -1)) {
|
|
3061
|
+
conv.touchSidecarCount = count;
|
|
3062
|
+
let file;
|
|
3063
|
+
try {
|
|
3064
|
+
file = conv.session.sessionFile ?? undefined;
|
|
3065
|
+
}
|
|
3066
|
+
catch {
|
|
3067
|
+
file = undefined;
|
|
3068
|
+
}
|
|
3069
|
+
void mergeTouchSidecar(file, extractTouches(this.convTranscript(conv)));
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
catch {
|
|
3073
|
+
// sidecar 只是加速 + 防压缩丢失,失败了下次重算
|
|
3074
|
+
}
|
|
2965
3075
|
break;
|
|
2966
3076
|
}
|
|
2967
3077
|
case "entry_appended": {
|
|
@@ -3326,6 +3436,37 @@ export class ClientSession {
|
|
|
3326
3436
|
// -----------------------------------------------------------------------
|
|
3327
3437
|
// 用户提问桥(标准 pi 引擎 ask_user_question customTool)
|
|
3328
3438
|
// -----------------------------------------------------------------------
|
|
3439
|
+
/**
|
|
3440
|
+
* 桥接工具目标(ask_user_question / browser_page):**调用瞬间**按 runtime 身份
|
|
3441
|
+
* 解析当前持有它的会话与对话 id,而不是用建 runtime 时捕获的 `this` + ownerId
|
|
3442
|
+
* (过户会把 runtime 搬到另一个 ClientSession,闭包里的会话引用与 id 都不跟着
|
|
3443
|
+
* 搬 —— 详见 findConversationHome)。
|
|
3444
|
+
*
|
|
3445
|
+
* 工厂(makeAskUserQuestionTool / makeBrowserPageTool)是鸭子类型,只要求这几个
|
|
3446
|
+
* 方法,所以这里给的是一个按需转发的小适配器:工厂传进来的 convId 是**建时**的
|
|
3447
|
+
* 旧 id(过户撞车改名后它要么查不到、要么撞到别的对话),一律以解析结果为准。
|
|
3448
|
+
* 解析不到(未接线 / 会话被替换 / 对话已关闭)时兜底建时的会话与 id —— 与改动前
|
|
3449
|
+
* 行为一致。anchor 由 runtime 创建处在拿到 `created.session` 后回填。
|
|
3450
|
+
*/
|
|
3451
|
+
bridgeTarget(anchor, ownerId) {
|
|
3452
|
+
const home = () => {
|
|
3453
|
+
const found = anchor.session ? this.findConversationHome?.(anchor.session) : undefined;
|
|
3454
|
+
return found ?? { session: this, convId: ownerId };
|
|
3455
|
+
};
|
|
3456
|
+
return {
|
|
3457
|
+
askUser: (questions, sig) => {
|
|
3458
|
+
const h = home();
|
|
3459
|
+
return h.session.askUser(questions, sig, h.convId);
|
|
3460
|
+
},
|
|
3461
|
+
pageCall: (req, sig) => {
|
|
3462
|
+
const h = home();
|
|
3463
|
+
return h.session.pageCall(req, sig, h.convId);
|
|
3464
|
+
},
|
|
3465
|
+
// 截图「给图还是走视觉桥」按当前持有方的模型与设置判定(过户后由它驱动)。
|
|
3466
|
+
canSeeImages: () => home().session.canSeeImages(),
|
|
3467
|
+
transcribeToolImage: (image, signal) => home().session.transcribeToolImage(image, signal),
|
|
3468
|
+
};
|
|
3469
|
+
}
|
|
3329
3470
|
/** 标准引擎模型调 ask_user_question:发 question_pending 给浏览器并阻塞等待
|
|
3330
3471
|
* question_answer。sig 为工具执行信号的当前状态(aborted → 立即 reject)。
|
|
3331
3472
|
* 返回 answers(用户选中/自定义),或 null(用户取消)。
|
|
@@ -3651,6 +3792,22 @@ export class ClientSession {
|
|
|
3651
3792
|
* attach 时由 AgentService 接到全局 onClientCwdChanged —— 编辑器等
|
|
3652
3793
|
* 工作区跟随型插件借此把根目录切到用户当前项目。 */
|
|
3653
3794
|
onCwdChanged = undefined;
|
|
3795
|
+
/** 过户后的桥接投递解析(attach 时由 AgentService 接线):给一个 SDK 会话(=
|
|
3796
|
+
* 一个 runtime 的身份),返回**当前**持有它的客户端会话 + 该对话**当前** id。
|
|
3797
|
+
*
|
|
3798
|
+
* 为什么需要它:runtime 创建时把 `this`(当时的 ClientSession)与当时的
|
|
3799
|
+
* conversationId 闭包进桥接工具(ask_user_question / browser_page),而
|
|
3800
|
+
* `take_over_conversation` 只搬对话(runtime/终端/订阅/看门狗/在途问卷与页
|
|
3801
|
+
* 调用),搬不动闭包里的会话引用。过户后模型再提问/截图,用捕获的 `this` 就会把
|
|
3802
|
+
* question_pending / page_request 推给过户前那台设备 —— 持有方收不到,问卷还会
|
|
3803
|
+
* 挂在老设备的注册表里(且不进它的快照:该 conv 已不在它名下)→ 刷新即丢,
|
|
3804
|
+
* 而 pi 引擎问卷不超时,那一轮 run 就永远挂住。
|
|
3805
|
+
*
|
|
3806
|
+
* 用 SDK 会话对象(稳定标识)而不是建时的 conversationId:过户遇到 id 撞车会把
|
|
3807
|
+
* 对话改名(c1→c2),那时旧 id 要么查不到、要么查到老会话里的另一条对话,都会
|
|
3808
|
+
* 投错。(注意不能用 runtime 对象比:SDK 会把工厂返回值包成新的 AgentSessionRuntime
|
|
3809
|
+
* 实例,而 `runtime.session` 与本工厂的 `created.session` 是同一个对象。) */
|
|
3810
|
+
findConversationHome = undefined;
|
|
3654
3811
|
/** issue #145 跨客户端同会话感知 —— attach 时由 AgentService 接线:
|
|
3655
3812
|
* - findSessionOwner:别处是否已持有同一 session 文件(查重建第二个 writer 用);
|
|
3656
3813
|
* - listProjectRunners:别处在同一 cwd 下正在跑的对话(同项目并行感知用);
|
|
@@ -3665,6 +3822,8 @@ export class ClientSession {
|
|
|
3665
3822
|
/** issue #145:除本客户端外是否有人在跑(扫目录查重前置的无 I/O 判断)。 */
|
|
3666
3823
|
hasStreamingElsewhere = undefined;
|
|
3667
3824
|
listProjectRunners = undefined;
|
|
3825
|
+
/** issue #145 同款接线:全局认领表(AgentService 级单例,attach 时由 AgentService 接线)。 */
|
|
3826
|
+
getClaimStore = undefined;
|
|
3668
3827
|
listExternalRunning = undefined;
|
|
3669
3828
|
notifyExternalClients = undefined;
|
|
3670
3829
|
onRunningChanged = undefined;
|
|
@@ -3889,8 +4048,12 @@ export class ClientSession {
|
|
|
3889
4048
|
await this.modelAdmin.clearProviderApiKey(provider);
|
|
3890
4049
|
// The provider is back to unconfigured — drop its key preference in
|
|
3891
4050
|
// EVERY project, otherwise each project switch re-tries a restore.
|
|
3892
|
-
if (!usingOAuth)
|
|
4051
|
+
if (!usingOAuth) {
|
|
3893
4052
|
this.stateStore.deleteProviderEverywhere(provider.trim());
|
|
4053
|
+
const defKey = this.stateStore.getDefaultProviderKey(provider.trim());
|
|
4054
|
+
if (defKey)
|
|
4055
|
+
this.stateStore.repointDeletedKeyInDefault(provider.trim(), defKey, null);
|
|
4056
|
+
}
|
|
3894
4057
|
}
|
|
3895
4058
|
startProviderOAuth(provider) {
|
|
3896
4059
|
this.modelAdmin.startProviderOAuth(provider);
|
|
@@ -3937,6 +4100,14 @@ export class ClientSession {
|
|
|
3937
4100
|
cloneProvider(providerId, reqId) {
|
|
3938
4101
|
return this.modelAdmin.cloneProvider(providerId, reqId);
|
|
3939
4102
|
}
|
|
4103
|
+
/** Enrich custom-provider draft rows from public catalogs (enrich_models_result). */
|
|
4104
|
+
enrichModels(reqId, ids, hints) {
|
|
4105
|
+
return this.modelAdmin.enrichModels(reqId, ids, hints, () => this.getLang());
|
|
4106
|
+
}
|
|
4107
|
+
/** Abort in-flight enrich_models request. */
|
|
4108
|
+
abortEnrichModels(reqId) {
|
|
4109
|
+
this.modelAdmin.abortEnrichModels(reqId);
|
|
4110
|
+
}
|
|
3940
4111
|
saveModelConfig(providerId, config) {
|
|
3941
4112
|
return this.modelAdmin.saveModelConfig(providerId, config);
|
|
3942
4113
|
}
|
|
@@ -3968,6 +4139,7 @@ export class ClientSession {
|
|
|
3968
4139
|
// (or drop the pin when no keys remain), not just the current one.
|
|
3969
4140
|
const active = this.modelAdmin.getActiveKeyName(provider);
|
|
3970
4141
|
this.stateStore.repointDeletedKeyEverywhere(provider, keyName, active);
|
|
4142
|
+
this.stateStore.repointDeletedKeyInDefault(provider, keyName, active);
|
|
3971
4143
|
}
|
|
3972
4144
|
/** Restore per-project provider keys when entering a project. For each
|
|
3973
4145
|
* provider that has a saved key for `cwd`, activate it if it differs from
|
|
@@ -3992,25 +4164,40 @@ export class ClientSession {
|
|
|
3992
4164
|
}
|
|
3993
4165
|
}
|
|
3994
4166
|
/** When a model is set, ensure its provider's per-project key is restored.
|
|
3995
|
-
* Silent + self-healing like the bulk restore above.
|
|
4167
|
+
* Silent + self-healing like the bulk restore above. Falls back to the
|
|
4168
|
+
* GLOBAL default key when the project has no pin for this provider (new
|
|
4169
|
+
* project following the global default model). */
|
|
3996
4170
|
async restoreKeyForModel(modelId, cwd) {
|
|
3997
4171
|
const slash = modelId.indexOf("/");
|
|
3998
4172
|
if (slash <= 0)
|
|
3999
4173
|
return;
|
|
4000
4174
|
const provider = modelId.slice(0, slash);
|
|
4001
|
-
const
|
|
4002
|
-
if (
|
|
4175
|
+
const projectPin = this.stateStore.getProjectProviderKey(this.clientId, cwd, provider);
|
|
4176
|
+
if (projectPin) {
|
|
4177
|
+
const cur = this.modelAdmin.getActiveKeyName(provider);
|
|
4178
|
+
if (cur === projectPin)
|
|
4179
|
+
return;
|
|
4180
|
+
if (!this.modelAdmin.hasProviderKey(provider, projectPin)) {
|
|
4181
|
+
this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
|
|
4182
|
+
return;
|
|
4183
|
+
}
|
|
4184
|
+
const ok = await this.modelAdmin.activateProviderKey(provider, projectPin, { silent: true });
|
|
4185
|
+
if (!ok)
|
|
4186
|
+
this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
|
|
4187
|
+
return;
|
|
4188
|
+
}
|
|
4189
|
+
// No project pin — follow the global default key (if any). No
|
|
4190
|
+
// self-healing deletes here: the ref belongs to the global default,
|
|
4191
|
+
// not this project (key deletions already repoint it, see
|
|
4192
|
+
// repointDeletedKeyInDefault).
|
|
4193
|
+
const globalPin = this.stateStore.getDefaultProviderKey(provider);
|
|
4194
|
+
if (!globalPin)
|
|
4003
4195
|
return;
|
|
4004
|
-
|
|
4005
|
-
if (cur === saved)
|
|
4196
|
+
if (this.modelAdmin.getActiveKeyName(provider) === globalPin)
|
|
4006
4197
|
return;
|
|
4007
|
-
if (!this.modelAdmin.hasProviderKey(provider,
|
|
4008
|
-
this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
|
|
4198
|
+
if (!this.modelAdmin.hasProviderKey(provider, globalPin))
|
|
4009
4199
|
return;
|
|
4010
|
-
}
|
|
4011
|
-
const ok = await this.modelAdmin.activateProviderKey(provider, saved, { silent: true });
|
|
4012
|
-
if (!ok)
|
|
4013
|
-
this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
|
|
4200
|
+
await this.modelAdmin.activateProviderKey(provider, globalPin, { silent: true });
|
|
4014
4201
|
}
|
|
4015
4202
|
/** Remember the just-selected model (and the key that was active for its
|
|
4016
4203
|
* provider) for the current project. Called IMMEDIATELY on model selection —
|
|
@@ -4034,9 +4221,10 @@ export class ClientSession {
|
|
|
4034
4221
|
* own per-session model: switching back to a RUNNING / completed chat must not
|
|
4035
4222
|
* silently overwrite its model with the project default. So a fresh chat in the
|
|
4036
4223
|
* project gets the remembered model; an in-progress one keeps what it had and
|
|
4037
|
-
* the user switches via the picker. Silent on failure (model no longer in catalog).
|
|
4224
|
+
* the user switches via the picker. Silent on failure (model no longer in catalog).
|
|
4225
|
+
* Fallback chain: project memory > GLOBAL default model > SDK default (no-op). */
|
|
4038
4226
|
async restoreProjectModelForCwd(cwd) {
|
|
4039
|
-
const savedModel = this.stateStore.getProjectModel(this.clientId, cwd);
|
|
4227
|
+
const savedModel = this.stateStore.getProjectModel(this.clientId, cwd) ?? this.stateStore.getDefaultModel();
|
|
4040
4228
|
if (!savedModel)
|
|
4041
4229
|
return;
|
|
4042
4230
|
try {
|
|
@@ -4338,6 +4526,21 @@ export class ClientSession {
|
|
|
4338
4526
|
return false;
|
|
4339
4527
|
}
|
|
4340
4528
|
}
|
|
4529
|
+
/** 某对话的转录最小结构(内存实时消息,含未落盘的;与 conversationReadHost
|
|
4530
|
+
* 的 readRunningConversation 同一取数逻辑 —— 并行提醒算触碰集时复用,
|
|
4531
|
+
* 不另起读取路径)。 */
|
|
4532
|
+
convTranscript(conv) {
|
|
4533
|
+
let raw = [];
|
|
4534
|
+
try {
|
|
4535
|
+
raw = (conv.session.messages ??
|
|
4536
|
+
conv.session.agent.state.messages ??
|
|
4537
|
+
[]);
|
|
4538
|
+
}
|
|
4539
|
+
catch {
|
|
4540
|
+
raw = [];
|
|
4541
|
+
}
|
|
4542
|
+
return raw.map(toTranscriptInput);
|
|
4543
|
+
}
|
|
4341
4544
|
/** issue #145:当前活动对话的 session 文件(resolved),无则 undefined。 */
|
|
4342
4545
|
activeSessionFileResolved() {
|
|
4343
4546
|
try {
|
|
@@ -4503,32 +4706,133 @@ export class ClientSession {
|
|
|
4503
4706
|
// 通告一次(steer/排队等流式中发送不重复打扰)。
|
|
4504
4707
|
// parallelReminderEnabled=false 时整段跳过(不发 notice、不注 AI、不通知对端)。
|
|
4505
4708
|
if (!conv.isSubagent && !s.isStreaming && this.settingsSvc.current.parallelReminderEnabled !== false) {
|
|
4709
|
+
// 认领心跳:本对话发 prompt = 还活着,自己名下的认领续期(同步内存操作)。
|
|
4710
|
+
try {
|
|
4711
|
+
this.getClaimStore?.().touch(conv.id);
|
|
4712
|
+
}
|
|
4713
|
+
catch {
|
|
4714
|
+
// ignore
|
|
4715
|
+
}
|
|
4716
|
+
let projectClaims = [];
|
|
4717
|
+
try {
|
|
4718
|
+
projectClaims = this.getClaimStore?.().list(conv.cwd) ?? [];
|
|
4719
|
+
}
|
|
4720
|
+
catch {
|
|
4721
|
+
projectClaims = [];
|
|
4722
|
+
}
|
|
4723
|
+
// 本窗口正在跑的:子代理也算进来(以前 !c.isSubagent 把它们排除在外,
|
|
4724
|
+
// 对方用子代理干活时 AI 完全收不到提示),标明归属。触碰集就地从内存
|
|
4725
|
+
// 消息算,无 I/O,不阻塞发送路径。
|
|
4506
4726
|
const localRunners = [...this.convs.values()]
|
|
4507
|
-
.filter((c) => c.id !== conv.id &&
|
|
4508
|
-
.map((c) =>
|
|
4727
|
+
.filter((c) => c.id !== conv.id && c.cwd === conv.cwd && this.conversationStreaming(c))
|
|
4728
|
+
.map((c) => {
|
|
4729
|
+
let label;
|
|
4730
|
+
if (c.isSubagent) {
|
|
4731
|
+
const parent = c.parentId ? this.convs.get(c.parentId) : undefined;
|
|
4732
|
+
label = parent ? `本窗口「${parent.title}」的子代理「${c.title}」` : `本窗口子代理「${c.title}」`;
|
|
4733
|
+
}
|
|
4734
|
+
else {
|
|
4735
|
+
label = `本窗口「${c.title}」`;
|
|
4736
|
+
}
|
|
4737
|
+
return { label, touches: extractTouches(this.convTranscript(c)) };
|
|
4738
|
+
});
|
|
4509
4739
|
const externalRunners = (this.listProjectRunners?.(conv.cwd) ?? []).filter((r) => r.sessionFile === undefined || (activeFile !== undefined && resolve(r.sessionFile) !== activeFile));
|
|
4510
|
-
|
|
4511
|
-
|
|
4740
|
+
// 取舍(诚实降级):外部运行只有 title + sessionFile,触碰集要读对方
|
|
4741
|
+
// 转录文件 —— 同步文件 I/O 会阻塞发送路径,不做;提醒里如实写
|
|
4742
|
+
// 「外部运行的文件触碰未知」,不编造。
|
|
4743
|
+
// 每条 ≤60 字符(以前整串 slice(0, 600),经常从半截路径处拦腰截断)。
|
|
4744
|
+
const capItem = (st) => (st.length <= 60 ? st : `${st.slice(0, 59)}…`);
|
|
4745
|
+
const noticeTitles = [
|
|
4746
|
+
...localRunners.map((r) => r.label),
|
|
4512
4747
|
...externalRunners.map((r) => `另一处「${r.title}」`),
|
|
4513
|
-
];
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4748
|
+
].map(capItem);
|
|
4749
|
+
const aiItems = [
|
|
4750
|
+
...localRunners.map((r) => `${r.label}·${r.touches.length} files`),
|
|
4751
|
+
...externalRunners.map((r) => `另一处「${r.title}」·touches unknown`),
|
|
4752
|
+
].map(capItem);
|
|
4753
|
+
if (noticeTitles.length > 0) {
|
|
4754
|
+
const shown = noticeTitles.slice(0, 3).join("、");
|
|
4755
|
+
const more = noticeTitles.length > 3 ? `等 ${noticeTitles.length} 处` : "";
|
|
4517
4756
|
this.emit({
|
|
4518
4757
|
type: "notice",
|
|
4519
4758
|
level: "info",
|
|
4520
4759
|
text: `同项目并行提醒:${shown}${more}正在同一项目运行。你可以继续(适合改不同文件),改动同一文件前请先确认;拿不准就等它跑完。`,
|
|
4521
4760
|
textEn: `Parallel-work notice: ${shown}${more ? " and more" : ""} running in the same project. You may continue (fine for different files); confirm before touching the same files, or wait for it to finish when unsure.`,
|
|
4522
4761
|
});
|
|
4523
|
-
// 给 AI
|
|
4524
|
-
//
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4762
|
+
// 给 AI 的上下文:交集由服务端算好写明“⚠ 双方都动过 X”,AI 不用自己
|
|
4763
|
+
// 算;拿不准就 ask_user_question 让用户选(并行 / 等它跑完 / 只读围观)。
|
|
4764
|
+
// display:false —— 用户界面只看上面的 notice。分两档:无交集只给一行
|
|
4765
|
+
// (省 token),有交集才展开细节。
|
|
4766
|
+
const mine = extractTouches(this.convTranscript(conv));
|
|
4767
|
+
// 认领升级(advisory,但比触碰更强:这是对方的事前意图):
|
|
4768
|
+
// 我动过 + 对方认领 → 最强信号单独点名;其他认领只给一行汇总。
|
|
4769
|
+
const othersClaims = projectClaims.filter((c) => c.ownerConvId !== conv.id);
|
|
4770
|
+
const myClaimed = matchClaims(mine, othersClaims.map((c) => ({
|
|
4771
|
+
path: c.path,
|
|
4772
|
+
ownerConvId: c.ownerConvId,
|
|
4773
|
+
ownerTitle: c.ownerTitle,
|
|
4774
|
+
claimedAt: 0,
|
|
4775
|
+
expiresAt: 0,
|
|
4776
|
+
})), conv.cwd);
|
|
4777
|
+
const claimHitEn = myClaimed
|
|
4778
|
+
.slice(0, 3)
|
|
4779
|
+
.map((h) => `${h.touch.path} (claimed by ${h.claim.ownerTitle})`)
|
|
4780
|
+
.join("; ");
|
|
4781
|
+
const claimHitZh = myClaimed
|
|
4782
|
+
.slice(0, 3)
|
|
4783
|
+
.map((h) => `${h.touch.path}(${h.claim.ownerTitle}已认领)`)
|
|
4784
|
+
.join("、");
|
|
4785
|
+
const claimMore = myClaimed.length > 3 ? ` (+${myClaimed.length - 3})` : "";
|
|
4786
|
+
const claimsSummaryEn = othersClaims.length > 0
|
|
4787
|
+
? ` Claimed by others (steer clear): ${othersClaims
|
|
4788
|
+
.slice(0, 3)
|
|
4789
|
+
.map((c) => `${c.path} ("${c.ownerTitle}")`)
|
|
4790
|
+
.join("; ")}${othersClaims.length > 3 ? ` (+${othersClaims.length - 3})` : ""}.`
|
|
4791
|
+
: "";
|
|
4792
|
+
const claimsSummaryZh = othersClaims.length > 0
|
|
4793
|
+
? ` 对方认领(绕行):${othersClaims
|
|
4794
|
+
.slice(0, 3)
|
|
4795
|
+
.map((c) => `${c.path}(「${c.ownerTitle}」)`)
|
|
4796
|
+
.join("、")}${othersClaims.length > 3 ? `(等 ${othersClaims.length - 3} 处)` : ""}。`
|
|
4797
|
+
: "";
|
|
4798
|
+
const clashes = localRunners
|
|
4799
|
+
.map((r) => ({ label: r.label, hits: intersectTouches(r.touches, mine) }))
|
|
4800
|
+
.filter((r) => r.hits.length > 0);
|
|
4801
|
+
const extNoteEn = externalRunners.length > 0 ? ` Touched files of external run(s) are unknown.` : "";
|
|
4802
|
+
const extNoteZh = externalRunners.length > 0 ? `外部运行的文件触碰未知。` : "";
|
|
4803
|
+
let aiReminder;
|
|
4804
|
+
if (clashes.length === 0 && myClaimed.length === 0) {
|
|
4805
|
+
aiReminder =
|
|
4806
|
+
`(System reminder: ${aiItems.length} other run(s) [${aiItems.join("; ")}] ` +
|
|
4807
|
+
`are currently running in the same project directory. No file written by both you and them was detected, ` +
|
|
4808
|
+
`so working on different files in parallel is fine; before writing the same files or running project-wide ` +
|
|
4809
|
+
`commands, assess the conflict risk first, and use ask_user_question when unsure ` +
|
|
4810
|
+
`(continue in parallel / wait / watch read-only).${extNoteEn}${claimsSummaryEn})\n` +
|
|
4811
|
+
`(系统提醒:同一项目另有 ${aiItems.length} 处运行(${shown}${more})。未发现双方都写过的文件,` +
|
|
4812
|
+
`改不同文件可并行;动同一文件或跑全局命令前先评估冲突,拿不准就用 ask_user_question 让用户选择:` +
|
|
4813
|
+
`并行 / 等它跑完 / 只读围观。${extNoteZh}${claimsSummaryZh})`;
|
|
4814
|
+
}
|
|
4815
|
+
else {
|
|
4816
|
+
// 有交集档:每处 ≤3 条完整路径 + 计数(路径永不截断,见 conversation-touches)。
|
|
4817
|
+
const clashPartsEn = clashes.map((h) => `${h.label} — you both wrote: ${formatTouchesCompact(h.hits)}`);
|
|
4818
|
+
if (myClaimed.length > 0) {
|
|
4819
|
+
clashPartsEn.push(`⚠ you touched and others claimed: ${claimHitEn}${claimMore} — ask the user before touching these again`);
|
|
4820
|
+
}
|
|
4821
|
+
const clashEn = clashPartsEn.join("; ");
|
|
4822
|
+
const clashPartsZh = clashes.map((h) => `⚠ ${h.label}双方都动过:${formatTouchesCompact(h.hits)}`);
|
|
4823
|
+
if (myClaimed.length > 0) {
|
|
4824
|
+
clashPartsZh.push(`⚠ 你动过、对方已认领:${claimHitZh}${claimMore} —— 动之前先问用户`);
|
|
4825
|
+
}
|
|
4826
|
+
const clashZh = clashPartsZh.join(";");
|
|
4827
|
+
aiReminder =
|
|
4828
|
+
`(System reminder: ${aiItems.length} other run(s) [${aiItems.join("; ")}] ` +
|
|
4829
|
+
`are currently running in the same project directory. ⚠ ${clashEn} — re-read these files before ` +
|
|
4830
|
+
`touching them again, and use ask_user_question when unsure ` +
|
|
4831
|
+
`(continue in parallel / wait / watch read-only).${extNoteEn})\n` +
|
|
4832
|
+
`(系统提醒:同一项目另有 ${aiItems.length} 处运行(${shown}${more})。` +
|
|
4833
|
+
`${clashZh} —— 再动这些文件前先读最新内容,拿不准就用 ask_user_question 让用户选择:` +
|
|
4834
|
+
`并行 / 等它跑完 / 只读围观。${extNoteZh})`;
|
|
4835
|
+
}
|
|
4532
4836
|
try {
|
|
4533
4837
|
await s.sendCustomMessage({
|
|
4534
4838
|
customType: "parallel-work-reminder",
|
|
@@ -5340,6 +5644,14 @@ export class ClientSession {
|
|
|
5340
5644
|
const conv = this.convs.get(id);
|
|
5341
5645
|
if (!conv || id === this.activeId)
|
|
5342
5646
|
return;
|
|
5647
|
+
// 对话真关闭(dismiss/释放)→ 放掉它的认领。过户不走这里(对话换个会话
|
|
5648
|
+
// 继续,owner 不变,认领继续有效),所以只在此处释放。
|
|
5649
|
+
try {
|
|
5650
|
+
this.getClaimStore?.().releaseByOwner(id);
|
|
5651
|
+
}
|
|
5652
|
+
catch {
|
|
5653
|
+
// ignore
|
|
5654
|
+
}
|
|
5343
5655
|
this.convs.delete(id);
|
|
5344
5656
|
this.clearAllToolWatchdogs(conv);
|
|
5345
5657
|
conv.terminals.killAll();
|
|
@@ -5347,6 +5659,15 @@ export class ClientSession {
|
|
|
5347
5659
|
conv.unsubscribe = undefined;
|
|
5348
5660
|
void conv.runtime.dispose().catch(() => { });
|
|
5349
5661
|
}
|
|
5662
|
+
/** 本会话是否持有这个 SDK 会话对应的对话;命中则给出它的**当前** id
|
|
5663
|
+
* (AgentService 按 runtime 身份解析过户后的归属方用,见 findConversationHome)。 */
|
|
5664
|
+
conversationIdOfSession(sdkSession) {
|
|
5665
|
+
for (const c of this.convs.values()) {
|
|
5666
|
+
if (c.session === sdkSession)
|
|
5667
|
+
return c.id;
|
|
5668
|
+
}
|
|
5669
|
+
return undefined;
|
|
5670
|
+
}
|
|
5350
5671
|
/** 过户用的对话摘要(AgentService 拼移动集合 + 容量检查用)。 */
|
|
5351
5672
|
takeoverBriefs() {
|
|
5352
5673
|
return [...this.convs.values()].map((c) => ({
|
|
@@ -5807,6 +6128,8 @@ export class ClientSession {
|
|
|
5807
6128
|
}
|
|
5808
6129
|
}
|
|
5809
6130
|
rmSync(abs, { force: true });
|
|
6131
|
+
// 转录删了,sidecar 再留着就是孤儿,一起清掉(不存在不报错)。
|
|
6132
|
+
removeTouchSidecar(abs);
|
|
5810
6133
|
// 转录删了,未发送草稿再留着就是孤儿,一起清掉。
|
|
5811
6134
|
try {
|
|
5812
6135
|
this.drafts.pruneSessionFile(abs);
|
|
@@ -7138,6 +7461,59 @@ export class ClientSession {
|
|
|
7138
7461
|
}
|
|
7139
7462
|
this.flushSnapshot();
|
|
7140
7463
|
}
|
|
7464
|
+
/** Set the GLOBAL default model ("provider/id"): projects with no memory
|
|
7465
|
+
* fall back to it (project memory wins). Also remembers the provider's
|
|
7466
|
+
* currently-active key globally so new projects restore the same {model,
|
|
7467
|
+
* key} pair. Shared across clients, persisted server-side. */
|
|
7468
|
+
async setDefaultModel(modelId) {
|
|
7469
|
+
try {
|
|
7470
|
+
const mr = this.runtime.services.modelRuntime;
|
|
7471
|
+
const slash = modelId.indexOf("/");
|
|
7472
|
+
if (slash <= 0 || slash === modelId.length - 1) {
|
|
7473
|
+
throw new Error(`无效的模型 ID:${modelId}`);
|
|
7474
|
+
}
|
|
7475
|
+
const provider = modelId.slice(0, slash);
|
|
7476
|
+
const id = modelId.slice(slash + 1);
|
|
7477
|
+
if (!mr.getModel(provider, id))
|
|
7478
|
+
throw new Error(`模型不存在:${modelId}`);
|
|
7479
|
+
this.stateStore.saveDefaultModel(modelId);
|
|
7480
|
+
const active = this.modelAdmin.getActiveKeyName(provider);
|
|
7481
|
+
if (active)
|
|
7482
|
+
this.stateStore.saveDefaultProviderKey(provider, active);
|
|
7483
|
+
this.pushDefaultModel();
|
|
7484
|
+
this.emit({
|
|
7485
|
+
type: "notice",
|
|
7486
|
+
level: "info",
|
|
7487
|
+
text: `🌍 已设全局默认模型 ${modelId}(新项目自动使用,项目记忆优先)`,
|
|
7488
|
+
textEn: `🌍 Global default model set to ${modelId} (new projects follow it; project memory wins)`,
|
|
7489
|
+
});
|
|
7490
|
+
}
|
|
7491
|
+
catch (err) {
|
|
7492
|
+
this.emit({
|
|
7493
|
+
type: "notice",
|
|
7494
|
+
level: "error",
|
|
7495
|
+
text: `设置全局默认模型失败:${err.message}`,
|
|
7496
|
+
textEn: `Failed to set global default model: ${err.message}`,
|
|
7497
|
+
});
|
|
7498
|
+
}
|
|
7499
|
+
this.flushSnapshot();
|
|
7500
|
+
}
|
|
7501
|
+
/** Clear the GLOBAL default model (new projects fall back to the SDK default). */
|
|
7502
|
+
clearDefaultModel() {
|
|
7503
|
+
this.stateStore.clearDefaultModel();
|
|
7504
|
+
this.pushDefaultModel();
|
|
7505
|
+
this.emit({
|
|
7506
|
+
type: "notice",
|
|
7507
|
+
level: "info",
|
|
7508
|
+
text: "🌍 已清除全局默认模型(新项目回到 SDK 默认)",
|
|
7509
|
+
textEn: "🌍 Global default model cleared (new projects use the SDK default)",
|
|
7510
|
+
});
|
|
7511
|
+
this.flushSnapshot();
|
|
7512
|
+
}
|
|
7513
|
+
/** Push the current global default model (attach + after every change). */
|
|
7514
|
+
pushDefaultModel() {
|
|
7515
|
+
this.emit({ type: "default_model", modelId: this.stateStore.getDefaultModel() ?? null });
|
|
7516
|
+
}
|
|
7141
7517
|
/** 切换模型,失败时抛出(无头路径专用:插件 host.chat / 定时任务)。
|
|
7142
7518
|
* setModel 为兼容 UI 把异常吞成 notice(面板要能看到原因、调用方是 fire-and-forget),
|
|
7143
7519
|
* 无头路径拿不到那个 notice,于是「模型 ID 打错/没配密钥」会变成静默按旧模型跑 ——
|
|
@@ -7302,6 +7678,8 @@ export class AgentService {
|
|
|
7302
7678
|
/** index.ts 注入:内置调度存储(attach 时拷贝到每个新会话,供 schedule_* 工具)。 */
|
|
7303
7679
|
schedulerStore = undefined;
|
|
7304
7680
|
clients = new Map();
|
|
7681
|
+
/** 全局认领表(跨浏览器标签页共享;<dataDir>/claims.json,best-effort 持久化)。 */
|
|
7682
|
+
claimStore;
|
|
7305
7683
|
/** Quiesce (draining) state — the service refuses NEW work (prompts, forks,
|
|
7306
7684
|
* session resumes, new clients) so a deploy/upgrade/backup can stop cleanly
|
|
7307
7685
|
* once existing runs finish. Controlled via the local control socket:
|
|
@@ -7322,6 +7700,7 @@ export class AgentService {
|
|
|
7322
7700
|
constructor(cwd, stateFile) {
|
|
7323
7701
|
this.cwd = cwd;
|
|
7324
7702
|
this.stateStore = new ClientStateStore(stateFile);
|
|
7703
|
+
this.claimStore = new ClaimStore(join(this.stateStore.dataDir, "claims.json"));
|
|
7325
7704
|
}
|
|
7326
7705
|
/** Get or create the session for a client, racing attach calls safely. */
|
|
7327
7706
|
/** True while the service is draining — new work is refused. */
|
|
@@ -7567,6 +7946,23 @@ export class AgentService {
|
|
|
7567
7946
|
}
|
|
7568
7947
|
return out;
|
|
7569
7948
|
}
|
|
7949
|
+
/** 按 SDK 会话(runtime 身份)找它当前归属的客户端会话与对话 id(过户后归属会变):
|
|
7950
|
+
* 桥接工具(问卷 / 页面)在调用瞬间用它投递,见 ClientSession.bridgeTarget。
|
|
7951
|
+
* 一条对话任一时刻只属于一个会话(过户先摘后插),扫一遍即可 —— 问卷/截图都是
|
|
7952
|
+
* 低频调用,不值得为此再维护一张全局索引。 */
|
|
7953
|
+
findConversationHome(sdkSession) {
|
|
7954
|
+
for (const cs of this.clients.values()) {
|
|
7955
|
+
try {
|
|
7956
|
+
const convId = cs.conversationIdOfSession(sdkSession);
|
|
7957
|
+
if (convId)
|
|
7958
|
+
return { session: cs, convId };
|
|
7959
|
+
}
|
|
7960
|
+
catch {
|
|
7961
|
+
// 单客户端坏了不影响解析
|
|
7962
|
+
}
|
|
7963
|
+
}
|
|
7964
|
+
return undefined;
|
|
7965
|
+
}
|
|
7570
7966
|
/** issue #145:别处所有正在跑的对话(左栏 elsewhere 只读感知 + 手动过户用)。
|
|
7571
7967
|
* owner/convId 标识过户目标(手动过户入口);DSH 引擎不填(不可过户)。 */
|
|
7572
7968
|
listExternalRunning(excludeClientId) {
|
|
@@ -7896,6 +8292,8 @@ export class AgentService {
|
|
|
7896
8292
|
cs.findSessionOwner = (targetPath) => this.findSessionOwner(targetPath, clientId);
|
|
7897
8293
|
cs.hasStreamingElsewhere = () => this.hasStreamingElsewhere(clientId);
|
|
7898
8294
|
cs.listProjectRunners = (cwd) => this.listProjectRunners(cwd, clientId);
|
|
8295
|
+
cs.getClaimStore = () => this.claimStore;
|
|
8296
|
+
cs.findConversationHome = (sdkSession) => this.findConversationHome(sdkSession);
|
|
7899
8297
|
cs.listExternalRunning = () => this.listExternalRunning(clientId);
|
|
7900
8298
|
cs.notifyExternalClients = (msg) => this.notifyClientsExcept(clientId, msg);
|
|
7901
8299
|
cs.onRunningChanged = () => this.pokeExternalRunning(clientId);
|