dsh-session-conductor 1.3.11 → 1.3.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -1
- package/cordis.patch.yml +0 -0
- package/lib/apply.js +28 -1243
- package/lib/client-parts/apply.js +0 -0
- package/lib/client-parts/components/compaction.js +0 -0
- package/lib/client-parts/components/conductor-settings.js +0 -0
- package/lib/client-parts/components/group.js +0 -0
- package/lib/client-parts/components/panel.js +0 -0
- package/lib/client-parts/components/processing.js +0 -0
- package/lib/client-parts/components/session-icon.js +0 -0
- package/lib/client-parts/components/settings.js +0 -0
- package/lib/client-parts/foundation/bootstrap.js +0 -0
- package/lib/client-parts/foundation/i18n.js +0 -0
- package/lib/client-parts/foundation/list-cache.js +0 -0
- package/lib/client-parts/foundation/styles.js +0 -0
- package/lib/core/config.js +0 -0
- package/lib/core/domain.js +0 -0
- package/lib/core/index-store.js +0 -0
- package/lib/core/lazy.js +0 -0
- package/lib/core/state.js +0 -0
- package/lib/core.js +0 -0
- package/lib/eio-repair.js +0 -0
- package/lib/features/base-routes.js +156 -0
- package/lib/features/context-usage/index.js +0 -0
- package/lib/features/continue/eligibility.js +0 -0
- package/lib/features/continue/scan.js +0 -0
- package/lib/features/continue/session.js +0 -0
- package/lib/features/inject/preset.js +0 -0
- package/lib/features/member-model/batch.js +97 -0
- package/lib/features/member-model/command.js +61 -7
- package/lib/features/member-model/create-teammate.js +0 -0
- package/lib/features/member-model/index.js +15 -1
- package/lib/features/member-model/lead.js +73 -0
- package/lib/features/member-model/locate.js +82 -1
- package/lib/features/member-model/override.js +0 -0
- package/lib/features/member-model/permission-command.js +138 -0
- package/lib/features/member-model/permission.js +213 -0
- package/lib/features/member-model/setup.js +0 -0
- package/lib/features/member-model/switch.js +254 -23
- package/lib/features/member-model/tools.js +324 -0
- package/lib/features/memory-recall/index.js +0 -0
- package/lib/features/rename/analysis.js +0 -0
- package/lib/features/rename/title.js +0 -0
- package/lib/features/rename/value.js +0 -0
- package/lib/features/session-action-routes.js +435 -0
- package/lib/features/session-messaging/index.js +0 -0
- package/lib/features/session-messaging/logic.js +0 -0
- package/lib/features/session-messaging/tools.js +0 -0
- package/lib/features/session-ops-routes.js +0 -0
- package/lib/features/settings-routes.js +400 -0
- package/lib/features/trash.js +0 -0
- package/lib/features/value-index.js +0 -0
- package/lib/features/value-report-routes.js +211 -0
- package/lib/group.js +0 -0
- package/lib/i18n/en.json +0 -0
- package/lib/i18n/zh.json +0 -0
- package/lib/index.js +0 -0
- package/lib/repair.js +0 -0
- package/lib/seq-gap-repair.js +0 -0
- package/lib/session-codec.js +0 -0
- package/lib/session-log.js +0 -0
- package/lib/sessions/detach.js +0 -0
- package/lib/sessions/events.js +0 -0
- package/lib/sessions/interruption.js +0 -0
- package/lib/sessions/list-cache.js +0 -0
- package/lib/sessions/list.js +0 -0
- package/lib/sessions/lookup.js +0 -0
- package/lib/sessions/route.js +0 -0
- package/lib/sessions/session-id.js +0 -0
- package/lib/sessions/tail-io.js +185 -0
- package/lib/sessions/turn.js +0 -0
- package/lib/shared/atomic-file.js +0 -0
- package/lib/shared/constants.js +0 -0
- package/lib/shared/json-store.js +0 -0
- package/lib/shared/log.js +0 -0
- package/lib/shared/timing.js +0 -0
- package/lib/shared/util.js +0 -0
- package/lib/template-inject.js +0 -0
- package/lib/value.js +0 -0
- package/lib/zstd-frames.js +0 -0
- package/package.json +1 -1
- package/scripts/check-member-model.mjs +0 -0
- package/skills/dsh-session-conductor-functions.md +0 -0
- package/skills/dsh-session-conductor.md +0 -0
- package/skills/dsh-session-official-doc.md +0 -0
- package/skills/task-completion-report.md +0 -0
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/lib/core/config.js
CHANGED
|
File without changes
|
package/lib/core/domain.js
CHANGED
|
File without changes
|
package/lib/core/index-store.js
CHANGED
|
File without changes
|
package/lib/core/lazy.js
CHANGED
|
File without changes
|
package/lib/core/state.js
CHANGED
|
File without changes
|
package/lib/core.js
CHANGED
|
File without changes
|
package/lib/eio-repair.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// dsh-session-conductor — 基础路由(语言包 / 会话列表)
|
|
2
|
+
//
|
|
3
|
+
// 【为什么单独成文件】原先内联在 lib/apply.js 的 `ctx.inject(["webServer"])` 回调里(该回调上千行,
|
|
4
|
+
// 审计 readability/max-function-length 拦截)。这里只搬「注册」这部分,handler 体逐字保留。
|
|
5
|
+
//
|
|
6
|
+
// ⚠️ 语言包路径:原先写 `./i18n/zh.json`(相对 lib/apply.js)。本文件位于 lib/features/,
|
|
7
|
+
// 必须改成 `../i18n/...`,否则运行时读不到语言包、页面文案会整块变空。
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { readJson, send } from '../http.js';
|
|
10
|
+
import { log } from '../shared/log.js';
|
|
11
|
+
import { invalidateSessionListCache } from '../sessions/list-cache.js';
|
|
12
|
+
import { readListIndex } from '../features/session-messaging/index.js';
|
|
13
|
+
import { buildSessionListCached } from '../sessions/list.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 注册基础路由(语言包 + 会话列表)。
|
|
17
|
+
* @param {object} deps
|
|
18
|
+
* @param {object} deps.webServer 宿主 webServer 服务
|
|
19
|
+
* @param {object} deps.ctx 插件 ctx
|
|
20
|
+
* @returns {void}
|
|
21
|
+
*/
|
|
22
|
+
export function registerBaseRoutes({ webServer, ctx }) {
|
|
23
|
+
// i18n 语言包:**只读一次并缓存**。原先每个请求都同步读两个 JSON(约 33 KB)并解析,
|
|
24
|
+
// 既阻塞事件循环(审计 io-risk 高风险),又纯属浪费 —— 语言包在一个进程生命周期内不会变。
|
|
25
|
+
let i18nCache = null;
|
|
26
|
+
const loadI18n = () => {
|
|
27
|
+
if (i18nCache !== null) return i18nCache;
|
|
28
|
+
i18nCache = {
|
|
29
|
+
zh: JSON.parse(readFileSync(new URL("../i18n/zh.json", import.meta.url), "utf8")),
|
|
30
|
+
en: JSON.parse(readFileSync(new URL("../i18n/en.json", import.meta.url), "utf8")),
|
|
31
|
+
};
|
|
32
|
+
return i18nCache;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// ---------- GET /api/session-conductor/i18n ----------
|
|
36
|
+
// 语言包外置 lib/i18n/{zh,en}.json(单份权威,改文案无需重打包 client bundle):
|
|
37
|
+
// 浏览器端 ModuleLoader 的 require 不支持相对路径 JSON(require("./i18n/*.json") 抛 missed-the-module-table),
|
|
38
|
+
// 故由宿主侧读外置 JSON 经 HTTP 暴露、客户端 fetch 拉取合并(语言包外置做法)。
|
|
39
|
+
webServer.register({
|
|
40
|
+
kind: "exact",
|
|
41
|
+
path: "/api/session-conductor/i18n",
|
|
42
|
+
handler: async (req, res) => {
|
|
43
|
+
try {
|
|
44
|
+
if ((req.method ?? "GET") !== "GET") {
|
|
45
|
+
return send(res, 405, { ok: false, error: { code: "METHOD", message: "Method Not Allowed" } });
|
|
46
|
+
}
|
|
47
|
+
const { zh, en } = loadI18n();
|
|
48
|
+
return send(res, 200, { ok: true, zh, en });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return send(res, 500, { ok: false, error: { code: "INTERNAL", message: String(error?.message ?? error) } });
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ---------- 预览页已移出本插件 ----------
|
|
56
|
+
// 原先这里用 /api/session-conductor/preview/<settings|panel> 同源服务 assets/preview-*.html
|
|
57
|
+
// (那两份 html 由已移除的 tools/preview-snapshot.sh 生成)。现在预览能力统一由独立插件
|
|
58
|
+
// dsh-useful-tools 的 `preview` 工具提供:gen 生成 + serve 带 backend 反代真实后端,
|
|
59
|
+
// 效果等价且本插件不必再托管静态页,故路由与产物一并移除。
|
|
60
|
+
|
|
61
|
+
// ---------- GET /api/session-conductor/list ----------
|
|
62
|
+
webServer.register({
|
|
63
|
+
kind: "exact",
|
|
64
|
+
path: "/api/session-conductor/list",
|
|
65
|
+
handler: async (req, res) => {
|
|
66
|
+
try {
|
|
67
|
+
// 非 GET 请求会改动会话列表内容,先失效列表缓存,使面板随后的 refresh 立刻看到最新(而非 5s TTL 内的旧列表)。
|
|
68
|
+
if (req.method !== "GET") invalidateSessionListCache();
|
|
69
|
+
// 索引直读(`?fromIndex=true`):与 `list_sessions` 工具的 `fromIndex` **同义** ——
|
|
70
|
+
// 直接读落盘索引、**不做全量构建**,秒回精简列表(不含 live 状态与归档态)。
|
|
71
|
+
// 原先路由只认 job / stream 两个声明,传 fromIndex 会被**静默忽略** ⇒ 悄悄退化成
|
|
72
|
+
// 226 秒级的全量构建(本机实测一次 41 秒才回),调用方以为拿到了快照、实际被钉在慢路径上。
|
|
73
|
+
if (String(req.url ?? "").includes("fromIndex=true")) {
|
|
74
|
+
const items = await readListIndex(ctx);
|
|
75
|
+
if (!items) {
|
|
76
|
+
send(res, 200, { ok: true, fromIndex: true, sessions: [], hint: "落盘索引为空或尚未建立;去掉 fromIndex 走一次刷新(会重建索引,冷缓存可能很慢)" });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
send(res, 200, { ok: true, fromIndex: true, sessions: items, hint: "来自落盘索引,不含 live 状态与归档态;要实时状态请去掉 fromIndex 走刷新" });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// job 模式(`?job=1` 或 `Accept: application/x-job`):冷缓存下全量构建很慢
|
|
83
|
+
// (本机实测 201 份日志 / 1.5 GB 时 226 秒),把构建交给宿主后台 job 并**立即**返回 jobId,
|
|
84
|
+
// 调用方改用 job_output / job_list 取结果,前台不再被钉住。
|
|
85
|
+
// 默认(无 job 声明)仍走下面的普通 JSON / NDJSON 流式分支 —— 一字不动,避免改坏面板。
|
|
86
|
+
const wantJob = /application\/x-job/i.test(req.headers?.accept ?? "")
|
|
87
|
+
|| String(req.url ?? "").includes("job=1");
|
|
88
|
+
if (wantJob) {
|
|
89
|
+
const jobs = ctx?.get?.("jobs");
|
|
90
|
+
if (!jobs || typeof jobs.start !== "function") {
|
|
91
|
+
send(res, 501, { ok: false, error: { code: "unsupported", message: "宿主未挂载 jobs 服务,无法注册后台 job;请改用默认(流式/JSON)路径" } });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// 构建只做一次;完成后顺手失效 TTL,保证随后任何一条路径读到的都是本次结果
|
|
95
|
+
const done = buildSessionListCached(ctx).then(
|
|
96
|
+
(sessions) => { invalidateSessionListCache(); return { status: "completed", output: JSON.stringify({ ok: true, sessions }) }; },
|
|
97
|
+
(error) => ({ status: "failed", detail: String(error?.message ?? error) }),
|
|
98
|
+
);
|
|
99
|
+
const spec = { kind: "session-conductor-list", label: "list(会话列表刷新)", run: () => ({ cancel: () => {}, done }) };
|
|
100
|
+
// HTTP 入口没有执行主体 ⇒ 用「无主」注册(宿主存在全局 job 控制器即可);
|
|
101
|
+
// 无主不可用时如实回 501,由调用方决定是否改用默认路径(不静默吞掉)。
|
|
102
|
+
try {
|
|
103
|
+
send(res, 200, { ok: true, async: true, jobId: jobs.start(spec), hint: "用宿主 job_output / job_list 取结果" });
|
|
104
|
+
} catch (error) {
|
|
105
|
+
// 后台 job 不可用(HTTP 入口**没有执行主体**,宿主 job 控制器会拒绝无主注册 ——
|
|
106
|
+
// 实测回的是「no job controller serves this agent」):不再直接 501 把调用方堵死;
|
|
107
|
+
// 退一步给**索引快照**(秒回、明确标注来源与「不含 live 状态」),索引也没有才如实 501。
|
|
108
|
+
const items = await readListIndex(ctx).catch(() => null);
|
|
109
|
+
if (items) {
|
|
110
|
+
send(res, 200, {
|
|
111
|
+
ok: true,
|
|
112
|
+
async: false,
|
|
113
|
+
fromIndex: true,
|
|
114
|
+
sessions: items,
|
|
115
|
+
jobUnavailable: String(error?.message ?? error),
|
|
116
|
+
hint: "后台 job 不可用,已回退为落盘索引快照(不含 live 状态);要实时状态请走默认或流式分支",
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
send(res, 501, { ok: false, error: { code: "unsupported", message: `注册后台 job 失败:${String(error?.message ?? error)}(且落盘索引不可用,无回退)` } });
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// list 流式(仅当请求声明 Accept: application/x-ndjson)——live 会话先快速
|
|
125
|
+
// 写出,冷会话串行逐个解析边写边出,前端 fetch stream 逐行追加(每获取一个立即显示,
|
|
126
|
+
// 不等全部);排序由前端按 updatedAt 倒序插入保持(流式下后端不再整体排序)。
|
|
127
|
+
// 默认(无 Accept 头/旧前端/预览页垫片)仍返回普通 JSON——兼容不匹配的调用方。
|
|
128
|
+
const wantStream =
|
|
129
|
+
/application\/x-ndjson/i.test(req.headers?.accept ?? "") || String(req.url ?? "").includes("stream=1");
|
|
130
|
+
if (!wantStream) {
|
|
131
|
+
const sessions = await buildSessionListCached(ctx);
|
|
132
|
+
send(res, 200, { ok: true, sessions });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8");
|
|
136
|
+
res.setHeader("Cache-Control", "no-store");
|
|
137
|
+
if (res.socket?.setNoDelay) res.socket.setNoDelay(true);
|
|
138
|
+
res.write(JSON.stringify({ ok: true, stream: true }) + "\n"); // 头行(前端跳过非会话行)
|
|
139
|
+
// 立即把头行推出去:冷缓存时后面要逐份解压上百份日志(实测 41~226 秒),
|
|
140
|
+
// 若头行压在缓冲里,浏览器这侧会一直「没有响应」,看起来就像接口挂了。
|
|
141
|
+
// flushHeaders/flush 老版本不一定有,取不到就跳过(写操作本身已尽量及时)。
|
|
142
|
+
try {
|
|
143
|
+
if (typeof res.flushHeaders === "function") res.flushHeaders();
|
|
144
|
+
if (typeof res.flush === "function") res.flush();
|
|
145
|
+
} catch { /* 不支持 flush 的宿主不影响主流程 */ }
|
|
146
|
+
const onItem = (item) => { try { res.write(JSON.stringify(item) + "\n"); } catch { /* 连接已断 */ } };
|
|
147
|
+
await buildSessionListCached(ctx, { onItem, serial: true });
|
|
148
|
+
try { res.end(); } catch { /* 连接已断 */ }
|
|
149
|
+
} catch (error) {
|
|
150
|
+
try {
|
|
151
|
+
res.end(JSON.stringify({ ok: false, error: { code: "internal", message: String(error?.message ?? error) } }) + "\n");
|
|
152
|
+
} catch { /* 连接已断 */ }
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// dsh-session-conductor — 批量把模型/权限应用到多个成员
|
|
2
|
+
//
|
|
3
|
+
// 【为什么单独抽一层】「对全部子代理统一模型/权限」在指令和 AI 工具两边都要用,
|
|
4
|
+
// 而且批量写会话日志有几个必须统一的纪律:
|
|
5
|
+
// ① **串行**执行:并发写多个会话日志容易互相干扰(每个都是一次读-改-写),串行最省心;
|
|
6
|
+
// ② **逐个隔离失败**:某个成员失败(日志损坏、名册状态异常)不能中断其余成员;
|
|
7
|
+
// ③ **回执必须带名单**:批量操作最怕「不知道改了谁」,所以结果逐条列出、并统计成功/失败数。
|
|
8
|
+
import { log } from "../../shared/log.js";
|
|
9
|
+
import { switchAgentModel } from "./switch.js";
|
|
10
|
+
import { switchMemberPermission } from "./permission.js";
|
|
11
|
+
|
|
12
|
+
/** 成员显示名(名字 + 短 id,便于人核对)。 */
|
|
13
|
+
export function memberDisplay(member, sessionId) {
|
|
14
|
+
const name = member?.name ?? null;
|
|
15
|
+
const shortId = sessionId ? `${sessionId.slice(0, 8)}…` : "?";
|
|
16
|
+
return name ? `${name}(${shortId})` : String(sessionId ?? "?");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 批量切换模型。
|
|
21
|
+
*
|
|
22
|
+
* @param {object} ctx 插件 context
|
|
23
|
+
* @param {Array<{sessionId: string, member?: object, agent?: object|null}>} targets 目标列表(已解析)
|
|
24
|
+
* @param {{provider: string, model: string}} route 目标路由
|
|
25
|
+
* @returns {Promise<{rows: Array<{sessionId: string, label: string, ok: boolean, detail: string}>, okCount: number, failCount: number}>}
|
|
26
|
+
*/
|
|
27
|
+
export async function applyModelToMany(ctx, targets, { provider, model }) {
|
|
28
|
+
const rows = [];
|
|
29
|
+
for (const t of targets) {
|
|
30
|
+
const label = memberDisplay(t.member, t.sessionId);
|
|
31
|
+
try {
|
|
32
|
+
const result = await switchAgentModel(ctx, t.sessionId, provider, model, t.agent ?? null);
|
|
33
|
+
if (result?.error) {
|
|
34
|
+
rows.push({ sessionId: t.sessionId, label, ok: false, detail: String(result.error) });
|
|
35
|
+
} else {
|
|
36
|
+
rows.push({ sessionId: t.sessionId, label, ok: true, detail: String(result.via ?? "") });
|
|
37
|
+
}
|
|
38
|
+
} catch (error) {
|
|
39
|
+
rows.push({ sessionId: t.sessionId, label, ok: false, detail: String(error?.message ?? error) });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const okCount = rows.filter((r) => r.ok).length;
|
|
43
|
+
log(ctx, `批量切换模型:${okCount}/${rows.length} 成功(${provider}/${model})`);
|
|
44
|
+
return { rows, okCount, failCount: rows.length - okCount };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 批量切换权限预设。
|
|
49
|
+
*
|
|
50
|
+
* @param {object} ctx 插件 context
|
|
51
|
+
* @param {Array<{sessionId: string, member?: object}>} targets 目标列表(已解析)
|
|
52
|
+
* @param {string} preset 目标预设名
|
|
53
|
+
* @returns {Promise<{rows: Array<{sessionId: string, label: string, ok: boolean, detail: string}>, okCount: number, failCount: number}>}
|
|
54
|
+
*/
|
|
55
|
+
export async function applyPermissionToMany(ctx, targets, preset) {
|
|
56
|
+
const rows = [];
|
|
57
|
+
for (const t of targets) {
|
|
58
|
+
const label = memberDisplay(t.member, t.sessionId);
|
|
59
|
+
try {
|
|
60
|
+
const result = await switchMemberPermission(ctx, t.sessionId, preset);
|
|
61
|
+
if (result?.error) {
|
|
62
|
+
rows.push({ sessionId: t.sessionId, label, ok: false, detail: String(result.error) });
|
|
63
|
+
} else {
|
|
64
|
+
const from = result.from ?? "(未记录)";
|
|
65
|
+
const note = result.note ? `;${result.note}` : "";
|
|
66
|
+
rows.push({ sessionId: t.sessionId, label, ok: true, detail: `${from} → ${result.to}${note}` });
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
rows.push({ sessionId: t.sessionId, label, ok: false, detail: String(error?.message ?? error) });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const okCount = rows.filter((r) => r.ok).length;
|
|
73
|
+
log(ctx, `批量切换权限:${okCount}/${rows.length} 成功(${preset})`);
|
|
74
|
+
return { rows, okCount, failCount: rows.length - okCount };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 渲染批量结果(先名单、后逐条结果、最后统计)。
|
|
79
|
+
*
|
|
80
|
+
* 批量操作的价值一半在「改了谁」的可核对性上,所以名单一定要完整列出,
|
|
81
|
+
* 不能只给一句「已处理 N 个」。
|
|
82
|
+
*
|
|
83
|
+
* @param {string} title 标题行(如「批量切换模型 → sa6400/deepseek-v4.1-flash」)
|
|
84
|
+
* @param {{rows: Array<{label: string, ok: boolean, detail: string}>, okCount: number, failCount: number}} report
|
|
85
|
+
* @param {string[]} [extra] 额外说明行
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
export function renderBatchReport(title, report, extra = []) {
|
|
89
|
+
const lines = [title, ""];
|
|
90
|
+
lines.push(`受影响成员(共 ${report.rows.length} 个):`);
|
|
91
|
+
for (const r of report.rows) lines.push(` ${r.ok ? "✅" : "❌"} ${r.label}`);
|
|
92
|
+
lines.push("", "逐条结果:");
|
|
93
|
+
for (const r of report.rows) lines.push(` ${r.ok ? "✅" : "❌"} ${r.label}:${r.detail || (r.ok ? "成功" : "失败")}`);
|
|
94
|
+
lines.push("", `统计:成功 ${report.okCount} 个 | 失败 ${report.failCount} 个`);
|
|
95
|
+
for (const line of extra) if (line) lines.push(line);
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
// 就抛 ERR_MODULE_NOT_FOUND,连累整个插件加载失败(本项目在 defineTool / createUserMessage
|
|
18
18
|
// 上踩过同类坑)。因此拿不到宿主能力时**跳过注册并如实留痕**,绝不影响插件其它功能。
|
|
19
19
|
import { log } from "../../shared/log.js";
|
|
20
|
-
import { findTargetAgent, collectTeamMembers } from "./locate.js";
|
|
20
|
+
import { findTargetAgent, collectTeamMembers, resolveTargets, isAllTarget } from "./locate.js";
|
|
21
|
+
import { readLeaderRoute } from "./lead.js";
|
|
22
|
+
import { applyModelToMany, renderBatchReport } from "./batch.js";
|
|
21
23
|
import { getMemberModelOverride } from "./override.js";
|
|
22
24
|
import { readLastRequestModel, registerSwitchWatch, switchAgentModel, peekLastRequestModel } from "./switch.js";
|
|
23
25
|
|
|
@@ -53,6 +55,8 @@ async function renderMemberList(ctx, invocation) {
|
|
|
53
55
|
lines.push("要查**实际**在用什么(含历史请求,不只本次进程)或切换:");
|
|
54
56
|
lines.push(` /${COMMAND_NAME} <成员名或 sessionId> ← 查实际模型`);
|
|
55
57
|
lines.push(` /${COMMAND_NAME} <成员名或 sessionId> <provider>/<模型> ← 切换并回显查证`);
|
|
58
|
+
lines.push(` /${COMMAND_NAME} * <provider>/<模型> ← 全部成员切到该模型(回执带名单)`);
|
|
59
|
+
lines.push(` /${COMMAND_NAME} <成员名|*> auto ← 跟随主代理(Lead)当前模型`);
|
|
56
60
|
return lines.join("\n");
|
|
57
61
|
}
|
|
58
62
|
|
|
@@ -151,14 +155,64 @@ async function switchMember(ctx, found, provider, model, invocation) {
|
|
|
151
155
|
export async function handleModelSwitchCommand(ctx, invocation) {
|
|
152
156
|
const raw = String(invocation?.rawInput ?? "").trim();
|
|
153
157
|
const parts = raw.split(/\s+/).filter(Boolean);
|
|
158
|
+
const caller = invocation?.agent ?? null;
|
|
154
159
|
try {
|
|
160
|
+
// 无参:列成员(保留原行为——列表和批量执行不能共用同一种输入,否则语义含糊)
|
|
155
161
|
if (parts.length === 0) return { kind: "success", text: await renderMemberList(ctx, invocation) };
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
+
|
|
163
|
+
const targetRaw = parts[0];
|
|
164
|
+
// 单参:查成员实际模型(`*` 没有「查」的意义,直接提示正确用法)
|
|
165
|
+
if (parts.length === 1) {
|
|
166
|
+
if (isAllTarget(targetRaw)) {
|
|
167
|
+
return {
|
|
168
|
+
kind: "error",
|
|
169
|
+
text: `批量操作需要指定模型:\n /${COMMAND_NAME} * <provider>/<模型> ← 全部成员切到该模型\n /${COMMAND_NAME} * auto ← 全部成员跟随主代理(Lead)当前模型`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const found = findTargetAgent(ctx, targetRaw, { caller });
|
|
173
|
+
if (found?.error) return { kind: "error", text: `找不到成员:${found.error}` };
|
|
174
|
+
return { kind: "success", text: await renderMemberDetail(found) };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 二参:切换(单个或全部)
|
|
178
|
+
const routeRaw = parts[1];
|
|
179
|
+
let provider;
|
|
180
|
+
let model;
|
|
181
|
+
let followedLead = null;
|
|
182
|
+
if (routeRaw.toLowerCase() === "auto") {
|
|
183
|
+
// auto = 跟随主代理(Lead):从请求级记录读它当前真实使用的模型
|
|
184
|
+
const lead = await readLeaderRoute(ctx, { caller });
|
|
185
|
+
if (lead.error) return { kind: "error", text: `跟随主代理失败:${lead.error}` };
|
|
186
|
+
provider = lead.provider;
|
|
187
|
+
model = lead.model;
|
|
188
|
+
followedLead = lead;
|
|
189
|
+
} else {
|
|
190
|
+
const pair = splitPair(routeRaw);
|
|
191
|
+
if (pair.error) return { kind: "error", text: pair.error };
|
|
192
|
+
provider = pair.provider;
|
|
193
|
+
model = pair.model;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const resolved = resolveTargets(ctx, targetRaw, { caller });
|
|
197
|
+
if (resolved?.error) return { kind: "error", text: `找不到成员:${resolved.error}` };
|
|
198
|
+
|
|
199
|
+
// 单个目标:沿用原有路径(带查证快照与查证任务登记)
|
|
200
|
+
if (!resolved.all && resolved.targets.length === 1) {
|
|
201
|
+
return await switchMember(ctx, resolved.targets[0], provider, model, invocation);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 全部目标:串行逐个切,回执必须带完整名单
|
|
205
|
+
const report = await applyModelToMany(ctx, resolved.targets, { provider, model });
|
|
206
|
+
const extra = [];
|
|
207
|
+
if (followedLead) {
|
|
208
|
+
extra.push(`模型来源:跟随主代理 ${followedLead.sessionId}(${followedLead.modelSource})`);
|
|
209
|
+
for (const w of followedLead.warnings ?? []) extra.push(`提示:${w}`);
|
|
210
|
+
}
|
|
211
|
+
extra.push("批量切换逐条写入各成员会话日志;单个成员失败不影响其余成员。");
|
|
212
|
+
return {
|
|
213
|
+
kind: "success",
|
|
214
|
+
text: renderBatchReport(`批量切换模型 → ${provider}/${model}`, report, extra),
|
|
215
|
+
};
|
|
162
216
|
} catch (error) {
|
|
163
217
|
return { kind: "error", text: `执行失败:${String(error?.message ?? error)}` };
|
|
164
218
|
}
|
|
File without changes
|
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
//
|
|
9
9
|
// 对外只暴露本文件——内部文件不直接被域外引用,便于后续再细分或整体提升为顶层目录。
|
|
10
10
|
|
|
11
|
-
export {
|
|
11
|
+
export {
|
|
12
|
+
pickTeamCaller, memberStatusError, validateModelPair, findTargetAgent,
|
|
13
|
+
// 批量目标解析(空 / `*` / `all` = 全部成员,排除 Lead)与 Lead 定位
|
|
14
|
+
collectTeamMembers, isAllTarget, resolveTargets, resolveLeader,
|
|
15
|
+
} from "./locate.js";
|
|
12
16
|
export { getMemberModelOverride, setMemberModelOverride, applyModelOverride, resetMemberModelCacheForTest } from "./override.js";
|
|
13
17
|
export { waitForIdle, appendSelectionEventToLog, switchAgentModel } from "./switch.js";
|
|
14
18
|
// 切换查证(事件驱动,注册 job 不阻塞):登记待查证项 → 每次请求记录实际模型 → 按事件判定 → 结果回报
|
|
@@ -19,3 +23,13 @@ export {
|
|
|
19
23
|
export { resumeSetupFor } from "./setup.js";
|
|
20
24
|
// 聊天框指令 /model-switch(用户视角入口,与 AI 工具共用同一条切换 + 查证路径)
|
|
21
25
|
export { registerModelSwitchCommand, handleModelSwitchCommand } from "./command.js";
|
|
26
|
+
// 权限预设查看/切换(子代理在界面上切不了,只能写会话日志;与模型切换同一套成员定位)
|
|
27
|
+
export {
|
|
28
|
+
readMemberPermission, readPresetCatalog, switchMemberPermission,
|
|
29
|
+
describeMemberPermission, renderMemberPermissionList,
|
|
30
|
+
} from "./permission.js";
|
|
31
|
+
export { registerPermSwitchCommand, handlePermSwitchCommand } from "./permission-command.js";
|
|
32
|
+
// 「跟随主代理」的取值来源(Lead 当前真实模型 + 当前权限预设)
|
|
33
|
+
export { readLeaderRoute } from "./lead.js";
|
|
34
|
+
// 批量应用与回执渲染(串行执行、逐个隔离失败、回执带完整名单)
|
|
35
|
+
export { applyModelToMany, applyPermissionToMany, renderBatchReport, memberDisplay } from "./batch.js";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// dsh-session-conductor — 「跟随主代理」的取值来源
|
|
2
|
+
//
|
|
3
|
+
// 【这个文件解决什么】切换模型/权限时把参数留空表示「跟主代理一致」,这样新建一批成员后
|
|
4
|
+
// 不用逐个查 Lead 当前用的什么。这里负责把「主代理当前到底是什么」查准:
|
|
5
|
+
// · 模型:优先请求级权威记录(会话日志里的 request/header,即**真实发出请求时用的值**),
|
|
6
|
+
// 退而用本进程内观察到的最近一次请求;都读不到就如实报错,绝不用名册里的静态字段冒充
|
|
7
|
+
// (名册里那个值可能是创建时的旧值,与当前实际不符)。
|
|
8
|
+
// · 权限:取会话日志里最后一条 permission/preset(宿主折叠语义:最后一条即生效值)。
|
|
9
|
+
import { peekLastRequestModel, readLastRequestModel } from "./switch.js";
|
|
10
|
+
import { readMemberPermission } from "./permission.js";
|
|
11
|
+
import { resolveLeader } from "./locate.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 读 Team Lead 当前的模型与权限预设。
|
|
15
|
+
*
|
|
16
|
+
* @param {object} ctx 插件 context
|
|
17
|
+
* @param {{caller?: object}} [opts] 显式发起方(指令/工具执行时的 agent)
|
|
18
|
+
* @returns {Promise<{sessionId: string, leadSource: string, provider: string|null, model: string|null,
|
|
19
|
+
* modelSource: string|null, preset: string|null, sandbox: string|null, approval: string|null,
|
|
20
|
+
* warnings: string[]} | {error: string}>}
|
|
21
|
+
*/
|
|
22
|
+
export async function readLeaderRoute(ctx, { caller = null } = {}) {
|
|
23
|
+
const lead = resolveLeader(ctx, { caller });
|
|
24
|
+
if (lead?.error) return { error: lead.error };
|
|
25
|
+
|
|
26
|
+
const warnings = [];
|
|
27
|
+
let provider = null;
|
|
28
|
+
let model = null;
|
|
29
|
+
let modelSource = null;
|
|
30
|
+
|
|
31
|
+
// ① 请求级权威记录(日志里的 request/header)
|
|
32
|
+
try {
|
|
33
|
+
const { lastRequest } = await readLastRequestModel(lead.sessionId);
|
|
34
|
+
if (lastRequest?.provider && lastRequest?.model) {
|
|
35
|
+
provider = lastRequest.provider;
|
|
36
|
+
model = lastRequest.model;
|
|
37
|
+
modelSource = "请求级记录(会话日志 request/header)";
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
warnings.push(`读请求级记录失败:${String(error?.message ?? error)}`);
|
|
41
|
+
}
|
|
42
|
+
// ② 本进程内观察到的最近一次请求(日志未落盘时的兜底)
|
|
43
|
+
if (!provider) {
|
|
44
|
+
const live = peekLastRequestModel(lead.sessionId);
|
|
45
|
+
if (live?.provider && live?.model) {
|
|
46
|
+
provider = live.provider;
|
|
47
|
+
model = live.model;
|
|
48
|
+
modelSource = "本进程观察(日志尚未落盘)";
|
|
49
|
+
warnings.push("模型取自进程内观察,可能比日志更新;如与界面不一致请以界面为准");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!provider) {
|
|
53
|
+
return {
|
|
54
|
+
error: `读不到主代理(${lead.sessionId})当前使用的模型:会话日志里没有 request/header,进程内也没有观察记录。请显式指定 provider/model`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ③ 权限预设(最后一条 preset 生效)
|
|
59
|
+
const perm = await readMemberPermission(lead.sessionId);
|
|
60
|
+
if (perm.error) warnings.push(`读主代理权限失败:${perm.error}`);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
sessionId: lead.sessionId,
|
|
64
|
+
leadSource: lead.source,
|
|
65
|
+
provider,
|
|
66
|
+
model,
|
|
67
|
+
modelSource,
|
|
68
|
+
preset: perm.error ? null : perm.preset,
|
|
69
|
+
sandbox: perm.error ? null : perm.sandbox,
|
|
70
|
+
approval: perm.error ? null : perm.approval,
|
|
71
|
+
warnings,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -199,7 +199,11 @@ export function collectTeamMembers(ctx, { caller: explicitCaller = null } = {})
|
|
|
199
199
|
if (teams?.listMembers && caller) {
|
|
200
200
|
try {
|
|
201
201
|
for (const m of teams.listMembers(caller) ?? []) {
|
|
202
|
-
|
|
202
|
+
// role/status 是宿主 TeamMemberView 的既有字段:批量操作要排除 Lead 本人,
|
|
203
|
+
// 也要跳过 provisioning/failed 这类不可操作的成员,所以必须带上。
|
|
204
|
+
push(m?.id ?? m?.sessionId, m?.name, {
|
|
205
|
+
provider: m?.provider, model: m?.model, role: m?.role, status: m?.status, source: "名册",
|
|
206
|
+
});
|
|
203
207
|
}
|
|
204
208
|
} catch {
|
|
205
209
|
// 名册不可用:继续用 agents 列表兜底
|
|
@@ -215,3 +219,80 @@ export function collectTeamMembers(ctx, { caller: explicitCaller = null } = {})
|
|
|
215
219
|
}
|
|
216
220
|
return out;
|
|
217
221
|
}
|
|
222
|
+
|
|
223
|
+
/** 目标是否为「全部成员」:空、`*`、`all` 都算(大小写不敏感)。 */
|
|
224
|
+
export function isAllTarget(target) {
|
|
225
|
+
const t = String(target ?? "").trim().toLowerCase();
|
|
226
|
+
return t === "" || t === "*" || t === "all";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 把一次操作的 target 解析成「一个或多个」目标。
|
|
231
|
+
*
|
|
232
|
+
* 【为什么要批量形态】批量给所有子代理统一模型/权限是高频需求(新建一批成员后统一对齐),
|
|
233
|
+
* 逐个传 sessionId 既慢又容易漏。约定:空 / `*` / `all` = 全部成员(**排除 Lead 本人**,
|
|
234
|
+
* 因为「所有子代理」的语义就是下属,不该把 Lead 自己也改掉)。
|
|
235
|
+
*
|
|
236
|
+
* @param {object} ctx 插件 context
|
|
237
|
+
* @param {string} target 原始 target
|
|
238
|
+
* @param {{caller?: object}} [opts] 显式发起方
|
|
239
|
+
* @returns {{all: boolean, targets: Array<{sessionId: string, member?: object, agent?: object|null, matched?: string}>} | {error: string}}
|
|
240
|
+
*/
|
|
241
|
+
export function resolveTargets(ctx, target, { caller: explicitCaller = null } = {}) {
|
|
242
|
+
const agents = ctx?.get?.("agents");
|
|
243
|
+
if (isAllTarget(target)) {
|
|
244
|
+
const members = collectTeamMembers(ctx, { caller: explicitCaller });
|
|
245
|
+
// 只取可操作的下属:排除 Lead,排除正在创建/已失败的成员
|
|
246
|
+
const usable = members.filter((m) => m.role !== "lead" && m.status !== "provisioning" && m.status !== "failed");
|
|
247
|
+
if (usable.length === 0) {
|
|
248
|
+
const why = members.length === 0
|
|
249
|
+
? "名册里没有任何成员"
|
|
250
|
+
: `名册里有 ${members.length} 个条目,但没有可操作的下属(都排除了 Lead / provisioning / failed)`;
|
|
251
|
+
return { error: `没有可操作的目标成员(${why})` };
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
all: true,
|
|
255
|
+
targets: usable.map((m) => ({
|
|
256
|
+
sessionId: m.id,
|
|
257
|
+
member: m,
|
|
258
|
+
agent: agents?.get?.(m.id) ?? null,
|
|
259
|
+
matched: "全部",
|
|
260
|
+
})),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
const found = findTargetAgent(ctx, target, { caller: explicitCaller });
|
|
264
|
+
if (found?.error) return { error: found.error };
|
|
265
|
+
return { all: false, targets: [found] };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 定位 Team Lead 会话(「跟随主代理」的落点)。
|
|
270
|
+
*
|
|
271
|
+
* 【为什么要专门找 Lead】「模型/权限留空 = 跟主代理一致」里的主代理指 Team Lead,
|
|
272
|
+
* 而不是发起这次调用的那个成员——由成员自己发起时,跟随它本身毫无意义。
|
|
273
|
+
* 宿主 TeamMemberView 的 role 字段能直接识别,不需要猜。
|
|
274
|
+
*
|
|
275
|
+
* @param {object} ctx 插件 context
|
|
276
|
+
* @param {{caller?: object}} [opts] 显式发起方
|
|
277
|
+
* @returns {{sessionId: string, agent: object|null, source: string} | {error: string}}
|
|
278
|
+
*/
|
|
279
|
+
export function resolveLeader(ctx, { caller: explicitCaller = null } = {}) {
|
|
280
|
+
const teams = ctx?.get?.("agentTeams");
|
|
281
|
+
const agents = ctx?.get?.("agents");
|
|
282
|
+
const caller = pickTeamCaller(ctx, explicitCaller);
|
|
283
|
+
if (!caller) return { error: "拿不到发起方,无法定位 Lead(可用 sessionId 显式指定目标)" };
|
|
284
|
+
let members = [];
|
|
285
|
+
try {
|
|
286
|
+
members = teams?.listMembers?.(caller) ?? [];
|
|
287
|
+
} catch (error) {
|
|
288
|
+
return { error: `读取名册失败:${String(error?.message ?? error)}` };
|
|
289
|
+
}
|
|
290
|
+
if (members.length === 0) return { error: "名册为空,无法定位 Lead(可用 sessionId 显式指定目标)" };
|
|
291
|
+
// 发起方自己就是 Lead:直接用(省一次 agents 查找)
|
|
292
|
+
const callerId = caller?.session?.id ?? null;
|
|
293
|
+
const selfRow = callerId ? members.find((m) => m?.id === callerId && m?.role === "lead") : null;
|
|
294
|
+
if (selfRow) return { sessionId: callerId, agent: caller, source: "发起方即 Lead" };
|
|
295
|
+
const leadRow = members.find((m) => m?.role === "lead");
|
|
296
|
+
if (!leadRow) return { error: "名册里没有 Lead 行(role=lead),无法定位主代理" };
|
|
297
|
+
return { sessionId: leadRow.id, agent: agents?.get?.(leadRow.id) ?? null, source: "名册 Lead 行" };
|
|
298
|
+
}
|
|
File without changes
|