@morlay/dsh-session-mode 0.0.15 → 0.0.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 +20 -7
- package/dist/client.cjs +1114 -1406
- package/dist/client.d.cts +41 -43
- package/dist/client.d.mts +41 -43
- package/dist/client.mjs +952 -1233
- package/dist/index.cjs +748 -70
- package/dist/index.d.mts +26 -55
- package/dist/index.mjs +742 -65
- package/dist/mode-config-CiQfAy4G.mjs +99 -0
- package/dist/mode-config-DOQ1VjcM.cjs +225 -0
- package/dist/rows-BT6Fpq-E.cjs +99 -0
- package/dist/rows-DnHpp-Ak.mjs +82 -0
- package/dist/rows.cjs +3 -86
- package/dist/rows.d.mts +31 -1
- package/dist/rows.mjs +1 -86
- package/dist/shared-R2j0SuWy.d.mts +118 -0
- package/package.json +17 -3
- package/src/client/SessionModePanel.tsx +803 -0
- package/src/client/api.ts +125 -1
- package/src/client/hints.ts +41 -0
- package/src/client/index.ts +71 -55
- package/src/client/locales.ts +80 -0
- package/src/index.ts +581 -20
- package/src/mode-config.ts +104 -0
- package/src/mode-sources.ts +4 -4
- package/src/mode-store.ts +53 -0
- package/src/modes.ts +23 -28
- package/src/per-agent.ts +53 -0
- package/src/ptc-describe.ts +97 -0
- package/src/ptc-sdk.ts +90 -0
- package/src/rows.ts +4 -8
- package/src/scope.ts +369 -71
- package/src/session-mode-store.ts +20 -0
- package/src/shared.ts +128 -3
- package/src/system-remainder.ts +39 -0
- package/src/tool-groups.ts +19 -0
- package/dist/shared-DO1nZbJ0.cjs +0 -64
- package/dist/shared-vjlwxiMt.mjs +0 -4
- package/src/client/BundleConfigPage.tsx +0 -631
- package/src/client/bundle-config.ts +0 -701
package/src/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { Agent } from "@deepseek-ai/dsh-agent";
|
|
|
9
9
|
// 那一行可能没装(headless 部署),所以读它时按"可能为空"处理。
|
|
10
10
|
import type {} from "@deepseek-ai/dsh-api-session-controller";
|
|
11
11
|
import { ReasoningEffortId, type LlmCallConfig } from "@deepseek-ai/dsh-llm";
|
|
12
|
-
import type
|
|
12
|
+
import { type Session, type SessionEvent, type SessionId } from "@deepseek-ai/dsh-session";
|
|
13
13
|
import type {} from "@deepseek-ai/dsh-session";
|
|
14
14
|
import { agentPresetProjectionDefinition } from "@deepseek-ai/dsh-agent-preset-registry";
|
|
15
15
|
import type { ProjectionDefinition, ProjectionHandle } from "@deepseek-ai/dsh-session-projection";
|
|
@@ -18,6 +18,8 @@ import type {} from "@deepseek-ai/dsh-session-projection";
|
|
|
18
18
|
// Type-only:上游 fs 的 policy 事件词汇(`fs/write-intent` / `fs/edit-intent` 的签名与 `FsWriteIntent`)。
|
|
19
19
|
import type {} from "@deepseek-ai/dsh-fs";
|
|
20
20
|
import { z } from "zod";
|
|
21
|
+
import { MODE_SOURCES } from "./mode-sources.ts";
|
|
22
|
+
import { modeConfig } from "./rows.ts";
|
|
21
23
|
import {
|
|
22
24
|
configProblem,
|
|
23
25
|
derivedSkills,
|
|
@@ -26,14 +28,34 @@ import {
|
|
|
26
28
|
type SessionModeRole,
|
|
27
29
|
} from "./modes.ts";
|
|
28
30
|
import { SessionModeCatalog } from "./catalog.ts";
|
|
31
|
+
import { normalizeMode } from "./modes.ts";
|
|
29
32
|
import { installPersona } from "./persona.ts";
|
|
30
33
|
import { SessionScope } from "./scope.ts";
|
|
31
34
|
import {
|
|
35
|
+
asModeConfig,
|
|
36
|
+
resolveModeConfig,
|
|
37
|
+
type ModeConfig,
|
|
38
|
+
type ModeSession,
|
|
39
|
+
} from "./mode-config.ts";
|
|
40
|
+
import { loadModeOverrides, removeMode, writeMode } from "./mode-store.ts";
|
|
41
|
+
import { sessionModeDomainSpec } from "./session-mode-store.ts";
|
|
42
|
+
import {
|
|
43
|
+
POLICY_DESCRIPTIONS,
|
|
44
|
+
POLICY_NAMES,
|
|
45
|
+
toolGroupLabel,
|
|
46
|
+
toolGroupOf,
|
|
47
|
+
deniedByList,
|
|
32
48
|
SESSION_MODE_PATH,
|
|
33
49
|
type PolicyName,
|
|
50
|
+
type SessionModeActiveState,
|
|
34
51
|
type SessionModeRoster,
|
|
35
52
|
type SessionModeRow,
|
|
36
53
|
type SessionModeSelectResult,
|
|
54
|
+
type SessionModeSwitchKey,
|
|
55
|
+
type SessionModeEditable,
|
|
56
|
+
type SessionModeDirectory,
|
|
57
|
+
type SessionModeDirectoryEntry,
|
|
58
|
+
type SessionModeTriState,
|
|
37
59
|
} from "./shared.ts";
|
|
38
60
|
|
|
39
61
|
// Cordis 插件名:与行 id 一致。
|
|
@@ -52,6 +74,18 @@ export type {
|
|
|
52
74
|
SessionModeRole,
|
|
53
75
|
} from "./modes.ts";
|
|
54
76
|
export { SESSION_MODE_PATH } from "./shared.ts";
|
|
77
|
+
|
|
78
|
+
// 面板能改的那几项;`null` = 清掉这一项的会话覆盖、回到模式默认。
|
|
79
|
+
export type SwitchPatch = Partial<Record<SessionModeSwitchKey, boolean | null>>;
|
|
80
|
+
export type ModePatch = Partial<Record<keyof SessionModeEditable, unknown>>;
|
|
81
|
+
|
|
82
|
+
// TOML 不能表示 undefined:写盘前把空值字段剔掉。
|
|
83
|
+
function plainMode(value: Record<string, unknown>): Record<string, unknown> {
|
|
84
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 会话级**逐条目**覆盖:不碰模式的 allow/deny 名单,没配就是默认。
|
|
88
|
+
export type ItemKind = "tools" | "skills" | "policies";
|
|
55
89
|
export { POLICY_NAMES } from "./shared.ts";
|
|
56
90
|
export type { PolicyName, SessionModeRoster, SessionModeRow } from "./shared.ts";
|
|
57
91
|
|
|
@@ -62,6 +96,7 @@ declare module "@deepseek-ai/cordis" {
|
|
|
62
96
|
}
|
|
63
97
|
|
|
64
98
|
declare module "@deepseek-ai/dsh-session/types" {
|
|
99
|
+
// 会话级覆盖是**插件记录**:旧 harness 不认识时按 ignorable 跳过,而不是拒绝整份日志。
|
|
65
100
|
interface SessionEventMap {
|
|
66
101
|
// 会话在空白窗口里换了模式;**log-only**,恢复与 fork 据此重建(模式决定模型看到的工具与提示词)。
|
|
67
102
|
"session-mode/selected": { sessionMode: string };
|
|
@@ -111,6 +146,17 @@ export const sessionModeEditableProjection = {
|
|
|
111
146
|
export class SessionModes extends Service {
|
|
112
147
|
// 每个 agent 已经装上的那一份(persona + 默认模型兜底;模式变了就换一份)。
|
|
113
148
|
private readonly installs = new WeakMap<Agent, { mode: string; dispose: () => void }>();
|
|
149
|
+
// 会话级的注入开关覆盖(面板上改的那三个):只活在这个进程里;"保存为默认"把它写回模式配置。
|
|
150
|
+
private readonly modeOverrides = new WeakMap<Agent, ModeSession>();
|
|
151
|
+
// 会话覆盖的持久化表(storageDomain,与投影缓存同一层)。
|
|
152
|
+
private sessionsTable:
|
|
153
|
+
| {
|
|
154
|
+
get(id: SessionId): { config: Record<string, unknown> } | undefined;
|
|
155
|
+
put(id: SessionId, value: { config: Record<string, unknown> }): void;
|
|
156
|
+
delete(id: SessionId): void;
|
|
157
|
+
}
|
|
158
|
+
| undefined;
|
|
159
|
+
// 逐条目三态:没配就是"跟着模式的默认"。
|
|
114
160
|
|
|
115
161
|
// 按会话收口:工具名单、instruction / 技能目录 / 动态快照三个开关。它由本行**内部持有**(不发布服务)——收口的
|
|
116
162
|
// 输入就是模式定义、唯一消费者也是模式。
|
|
@@ -121,7 +167,10 @@ export class SessionModes extends Service {
|
|
|
121
167
|
// 只是共享同一个 unit,不改变它的行为)。
|
|
122
168
|
private projectionHandle!: ProjectionHandle<string | null>;
|
|
123
169
|
private editableHandle!: ProjectionHandle<boolean>;
|
|
124
|
-
private modelSelectionHandle!: ProjectionHandle<{
|
|
170
|
+
private modelSelectionHandle!: ProjectionHandle<{
|
|
171
|
+
readonly pending: unknown;
|
|
172
|
+
readonly lastUsed: unknown;
|
|
173
|
+
}>;
|
|
125
174
|
private agentPresetHandle!: ProjectionHandle<string | null>;
|
|
126
175
|
|
|
127
176
|
// 装配时的配置快照:`default` / `modes` 读它,改这两项靠 Loader 重挂这一行(已运行会话不自动换定义)。
|
|
@@ -136,7 +185,9 @@ export class SessionModes extends Service {
|
|
|
136
185
|
// 快照取一份**可变**副本:volatile 的快照是深度只读,而内部按普通配置对象用(写回不走这里,改配置靠重挂)。
|
|
137
186
|
this.config = {
|
|
138
187
|
default: config.default.get(),
|
|
139
|
-
|
|
188
|
+
// 模式定义不走 cordis 配置:内置默认 + `{dshHome}/modes/*.toml`(`init` 里填)。
|
|
189
|
+
// 装配期注入(测试/高级用法)单存一份,`init` 时覆盖内置默认。
|
|
190
|
+
modes: structuredClone(config.modes ?? {}) as Record<string, SessionMode>,
|
|
140
191
|
};
|
|
141
192
|
// 退役的顶层 `models` 还配着值就让装配期报错。
|
|
142
193
|
const problem = configProblem({ ...this.config, models: config.models });
|
|
@@ -165,6 +216,12 @@ export class SessionModes extends Service {
|
|
|
165
216
|
(_target, actor, next) => this.decidePolicy("fs/edit-intent", actor, next),
|
|
166
217
|
{ prepend: true },
|
|
167
218
|
);
|
|
219
|
+
// 删除那条也走同一个决策面:上游 `fs-observation-policy` 在这里同样独占决策槽,所以仍用 `prepend` 抢在它前面。
|
|
220
|
+
ctx.on(
|
|
221
|
+
"fs/delete-intent" satisfies PolicyName,
|
|
222
|
+
(_target, actor, next) => this.decidePolicy("fs/delete-intent", actor, next),
|
|
223
|
+
{ prepend: true },
|
|
224
|
+
);
|
|
168
225
|
// 会话一建立就装上:这早于它的第一次装配,persona 因此一定在装配之前注册好。
|
|
169
226
|
ctx.on("agent/created", ({ agent }) => {
|
|
170
227
|
this.installFor(agent);
|
|
@@ -184,13 +241,46 @@ export class SessionModes extends Service {
|
|
|
184
241
|
|
|
185
242
|
// 构造即注册(`init` 是异步的):入口 `apply` 走这条异步工厂。
|
|
186
243
|
static async create(ctx: Context, config: ResolvedConfig): Promise<SessionModes> {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
244
|
+
// 内置默认必须在**构造之前**就位:构造期校验要确认 `default` 落在已知的模式集合里,而行 config 现在
|
|
245
|
+
// 只带 `default`(模式定义走 `{dshHome}/modes/*.toml`)。装配期注入(测试 / 高级用法)给了 modes 就整份用,
|
|
246
|
+
// 不掺内置默认。
|
|
247
|
+
const injected = config.modes ?? {};
|
|
248
|
+
const modes =
|
|
249
|
+
Object.keys(injected).length === 0
|
|
250
|
+
? (Object.fromEntries(
|
|
251
|
+
MODE_SOURCES.map((source) => [source.id, normalizeMode(modeConfig(source))]),
|
|
252
|
+
) as Record<string, SessionMode>)
|
|
253
|
+
: injected;
|
|
254
|
+
const sessionModes = new SessionModes(ctx, { ...config, modes });
|
|
255
|
+
await sessionModes.init();
|
|
256
|
+
return sessionModes;
|
|
190
257
|
}
|
|
191
258
|
|
|
192
259
|
// 注册投影并拿读句柄。`register()` 要 await:句柄与注册同生命期(行卸载时自动释放),而读状态只能经句柄。
|
|
193
260
|
private async init(): Promise<void> {
|
|
261
|
+
// 用户自己的模式定义(`{dshHome}/modes/*.toml`)覆盖已就位的那份 modes——内置默认由 `create` 在构造前补上。
|
|
262
|
+
const modes = this.config.modes as Record<string, SessionMode>;
|
|
263
|
+
for (const [id, value] of Object.entries(await loadModeOverrides())) {
|
|
264
|
+
// `noop` 是保留关键字:用户文件不能覆盖它。
|
|
265
|
+
if (id === "noop") continue;
|
|
266
|
+
// TOML 是部分字段:合并模板后过一遍 schema,新加的模式才有齐全的字段。
|
|
267
|
+
modes[id] = normalizeMode({ ...modes[id], ...value });
|
|
268
|
+
}
|
|
269
|
+
// 会话覆盖的持久化:harness 的存储域(与投影缓存同一层),按 sessionId 存一份配置。
|
|
270
|
+
// 没有这个服务(例如单测装配)时退化为纯内存,会话关闭即丢。
|
|
271
|
+
const storage = this.ctx.get("storageDomain") as unknown as
|
|
272
|
+
| {
|
|
273
|
+
open(spec: typeof sessionModeDomainSpec): Promise<{
|
|
274
|
+
close(): Promise<void>;
|
|
275
|
+
table(name: "sessions"): NonNullable<SessionModes["sessionsTable"]>;
|
|
276
|
+
}>;
|
|
277
|
+
}
|
|
278
|
+
| undefined;
|
|
279
|
+
if (storage !== undefined) {
|
|
280
|
+
const domain = await storage.open(sessionModeDomainSpec);
|
|
281
|
+
this.ctx.effect(() => () => domain.close(), "session-mode.domainClose");
|
|
282
|
+
this.sessionsTable = domain.table("sessions");
|
|
283
|
+
}
|
|
194
284
|
this.projectionHandle = await this.ctx.sessionProjections.register(sessionModeProjection);
|
|
195
285
|
this.editableHandle = await this.ctx.sessionProjections.register(sessionModeEditableProjection);
|
|
196
286
|
// 上游那份 `modelSelection` 由 agent-loop 注册;这里拿同一个定义再注册一次只是要一个读句柄
|
|
@@ -267,11 +357,376 @@ export class SessionModes extends Service {
|
|
|
267
357
|
return this.editableHandle.stateOf(session);
|
|
268
358
|
}
|
|
269
359
|
|
|
360
|
+
// 每一条目的三态:模式默认下的判定 + 这个会话是否显式覆盖了它。
|
|
361
|
+
private buildCatalog(
|
|
362
|
+
agent: Agent | undefined,
|
|
363
|
+
base: SessionMode,
|
|
364
|
+
merged: SessionMode,
|
|
365
|
+
registered: readonly { name: string; description: string }[],
|
|
366
|
+
visible: readonly { name: string; description?: string }[],
|
|
367
|
+
): SessionModeDirectory {
|
|
368
|
+
const entry = (
|
|
369
|
+
id: string,
|
|
370
|
+
description: string,
|
|
371
|
+
group: string | undefined,
|
|
372
|
+
allowKey: "allowTools" | "allowSkills" | "allowPolicies",
|
|
373
|
+
denyKey: "denyTools" | "denySkills" | "denyPolicies",
|
|
374
|
+
): SessionModeDirectoryEntry => {
|
|
375
|
+
// `fallback` 反映**配置**:显式禁止(含组引用)→ deny;白名单内 → allow;白名单外 → deny;没配 → default。
|
|
376
|
+
const fallback: SessionModeTriState | "default" = deniedByList(base[denyKey], id)
|
|
377
|
+
? "deny"
|
|
378
|
+
: base[allowKey].length > 0
|
|
379
|
+
? base[allowKey].includes(id)
|
|
380
|
+
? "allow"
|
|
381
|
+
: "deny"
|
|
382
|
+
: "default";
|
|
383
|
+
return {
|
|
384
|
+
id,
|
|
385
|
+
description,
|
|
386
|
+
...(group === undefined ? {} : { group, groupLabel: toolGroupLabel(group) }),
|
|
387
|
+
fallback,
|
|
388
|
+
// override = 生效值与配置值不一致时的那一档(会话覆盖就是 `ModeSession` 本身)。
|
|
389
|
+
override: (() => {
|
|
390
|
+
const baseDeny = deniedByList(base[denyKey], id);
|
|
391
|
+
const effDeny = deniedByList(merged[denyKey], id);
|
|
392
|
+
if (baseDeny !== effDeny) return effDeny ? ("deny" as const) : ("allow" as const);
|
|
393
|
+
const baseAllow = base[allowKey].includes(id);
|
|
394
|
+
const effAllow = merged[allowKey].includes(id);
|
|
395
|
+
if (baseAllow !== effAllow) return effAllow ? ("allow" as const) : ("deny" as const);
|
|
396
|
+
return null;
|
|
397
|
+
})(),
|
|
398
|
+
};
|
|
399
|
+
};
|
|
400
|
+
// 收口时抓的全量目录 + 之后才注册的可见工具(agent-team 这类 scoped 工具),同一名字只留一份。
|
|
401
|
+
const known = new Map<string, { name: string; description: string }>();
|
|
402
|
+
if (agent !== undefined) {
|
|
403
|
+
for (const tool of this.scope.catalogOf(agent)) known.set(tool.name, tool);
|
|
404
|
+
for (const tool of visible) {
|
|
405
|
+
if (!known.has(tool.name)) {
|
|
406
|
+
known.set(tool.name, { name: tool.name, description: tool.description ?? "" });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const tools = [...known.values()].map((tool) =>
|
|
411
|
+
entry(tool.name, tool.description, toolGroupOf(tool.name), "allowTools", "denyTools"),
|
|
412
|
+
);
|
|
413
|
+
const toolIds = new Set(tools.map((tool) => tool.id));
|
|
414
|
+
const fsActive = ["read", "write", "edit", "apply_patch"].some((id) => toolIds.has(id));
|
|
415
|
+
return {
|
|
416
|
+
tools,
|
|
417
|
+
skills: registered.map((skill) =>
|
|
418
|
+
entry(skill.name, skill.description, undefined, "allowSkills", "denySkills"),
|
|
419
|
+
),
|
|
420
|
+
// 两条 policy 都作用于文件工具:这个会话连一件文件工具都没有时,它们"不适用",按禁止显示。
|
|
421
|
+
policies: POLICY_NAMES.map((policy) => {
|
|
422
|
+
const row = entry(
|
|
423
|
+
policy,
|
|
424
|
+
POLICY_DESCRIPTIONS[policy],
|
|
425
|
+
undefined,
|
|
426
|
+
"allowPolicies",
|
|
427
|
+
"denyPolicies",
|
|
428
|
+
);
|
|
429
|
+
return fsActive ? row : { ...row, fallback: "deny" as const, override: null };
|
|
430
|
+
}),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// 这个会话在模式名单收窄之后"程序内可用"的工具名(给 `tool-guidance` 的注入判据用)。
|
|
435
|
+
knownToolsOf(agent: Agent): readonly string[] {
|
|
436
|
+
return this.scope.knownTools(agent);
|
|
437
|
+
}
|
|
438
|
+
|
|
270
439
|
// 会话当前模式的定义。
|
|
271
440
|
modeOfSession(session: Session): SessionMode {
|
|
272
441
|
return this.definition(this.modeOf(session));
|
|
273
442
|
}
|
|
274
443
|
|
|
444
|
+
// 会话覆盖的持久化:**不写会话日志**,落在 `storageDomain` 的 `session_mode` 域(按 `SessionId` 一行)。
|
|
445
|
+
private record(agent: Agent): void {
|
|
446
|
+
const session = this.modeOverrides.get(agent);
|
|
447
|
+
if (this.sessionsTable === undefined) return;
|
|
448
|
+
if (session === undefined) this.sessionsTable.delete(agent.session.id);
|
|
449
|
+
else this.sessionsTable.put(agent.session.id, { config: { ...session } });
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// 进入会话时把覆盖读回来(异步,读到后重装一次)。
|
|
453
|
+
private hydrate(agent: Agent): void {
|
|
454
|
+
if (this.modeOverrides.has(agent) || this.sessionsTable === undefined) return;
|
|
455
|
+
const row = this.sessionsTable.get(agent.session.id);
|
|
456
|
+
if (row === undefined) return;
|
|
457
|
+
this.modeOverrides.set(agent, row.config as unknown as ModeSession);
|
|
458
|
+
const modeId = this.resolveModeId(agent);
|
|
459
|
+
this.installs.get(agent)?.dispose();
|
|
460
|
+
this.installs.delete(agent);
|
|
461
|
+
this.installFor(agent, modeId);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// 一次应用整份会话覆盖(面板的"应用到会话"):`ModeSession` 直接落,只重装一次。
|
|
465
|
+
// 一次应用整份会话覆盖(面板的"应用到会话"):参数就是一份完整的 `ModeConfig`。
|
|
466
|
+
// 把草稿写回**配置**(面板的"保存为默认"):只动模式的配置,不把会话覆盖混进来。
|
|
467
|
+
async saveConfig(
|
|
468
|
+
sessionId: SessionId,
|
|
469
|
+
config: ModeConfig,
|
|
470
|
+
): Promise<SessionModeActiveState | undefined> {
|
|
471
|
+
const agent = this.ctx.agents.get(sessionId);
|
|
472
|
+
if (agent === undefined) return undefined;
|
|
473
|
+
const modeId = this.modeOf(agent.session);
|
|
474
|
+
(this.config.modes as Record<string, SessionMode>)[modeId] = config as unknown as SessionMode;
|
|
475
|
+
await writeMode(modeId, plainMode({ ...config }));
|
|
476
|
+
this.modeOverrides.delete(agent);
|
|
477
|
+
this.record(agent);
|
|
478
|
+
this.installs.get(agent)?.dispose();
|
|
479
|
+
this.installs.delete(agent);
|
|
480
|
+
this.installFor(agent, modeId);
|
|
481
|
+
return this.activeState(sessionId);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async applyConfig(
|
|
485
|
+
sessionId: SessionId,
|
|
486
|
+
config: ModeConfig,
|
|
487
|
+
): Promise<SessionModeActiveState | undefined> {
|
|
488
|
+
const agent = this.ctx.agents.get(sessionId);
|
|
489
|
+
if (agent === undefined) return undefined;
|
|
490
|
+
this.modeOverrides.set(agent, structuredClone(config));
|
|
491
|
+
this.record(agent);
|
|
492
|
+
const modeId = this.resolveModeId(agent);
|
|
493
|
+
this.installs.get(agent)?.dispose();
|
|
494
|
+
this.installs.delete(agent);
|
|
495
|
+
this.installFor(agent, modeId);
|
|
496
|
+
return this.activeState(sessionId);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// 逐条目三态:`null` 清掉覆盖、回到模式默认。
|
|
500
|
+
// 这个会话可见的工具名全集(含被收窄的):组引用展开要用。
|
|
501
|
+
private toolNames(agent: Agent): readonly string[] {
|
|
502
|
+
const tools = this.ctx.get("tools") as unknown as
|
|
503
|
+
| { definitions(): readonly { name: string }[] }
|
|
504
|
+
| undefined;
|
|
505
|
+
const known = new Set((tools?.definitions() ?? []).map((tool) => tool.name));
|
|
506
|
+
for (const tool of this.scope.catalogOf(agent)) known.add(tool.name);
|
|
507
|
+
return [...known];
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// 模式的可编辑面(面板的默认值与"另存为"的底稿)。
|
|
511
|
+
private editableFaceOf(mode: SessionMode): SessionModeEditable {
|
|
512
|
+
return {
|
|
513
|
+
persona: { ...mode.persona },
|
|
514
|
+
allowTools: [...mode.allowTools],
|
|
515
|
+
denyTools: [...mode.denyTools],
|
|
516
|
+
allowSkills: [...mode.allowSkills],
|
|
517
|
+
denySkills: [...mode.denySkills],
|
|
518
|
+
allowPolicies: [...mode.allowPolicies],
|
|
519
|
+
denyPolicies: [...mode.denyPolicies],
|
|
520
|
+
instructions: mode.instructions,
|
|
521
|
+
skills: mode.skills ?? true,
|
|
522
|
+
runtimeContext: mode.runtimeContext,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// 这个会话实际生效的模式定义:配置 + 会话覆盖(`ModeSession`),没有第二套逐条目结构。
|
|
527
|
+
private definitionFor(agent: Agent, modeId: string): SessionMode {
|
|
528
|
+
const base = this.definition(modeId);
|
|
529
|
+
const normalized = asModeConfig(base, base.skills ?? true);
|
|
530
|
+
const session = this.modeOverrides.get(agent);
|
|
531
|
+
// 覆盖是**全量**的一份配置;旧日志里可能缺字段,按配置补齐后再用。
|
|
532
|
+
return resolveModeConfig(
|
|
533
|
+
normalized,
|
|
534
|
+
session === undefined ? undefined : { ...normalized, ...session },
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// 会话级覆盖里那几项(只列还覆盖着的)。
|
|
539
|
+
private overriddenKeys(agent: Agent | undefined): SessionModeSwitchKey[] {
|
|
540
|
+
if (agent === undefined) return [];
|
|
541
|
+
const override = this.modeOverrides.get(agent);
|
|
542
|
+
if (override === undefined) return [];
|
|
543
|
+
return (Object.keys(override) as SessionModeSwitchKey[]).filter(
|
|
544
|
+
(key) => override[key] !== undefined,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private overriddenFields(agent: Agent | undefined): string[] {
|
|
549
|
+
if (agent === undefined) return [];
|
|
550
|
+
const override = this.modeOverrides.get(agent);
|
|
551
|
+
return override === undefined ? [] : Object.keys(override);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// 改这个会话的可编辑字段(会话级覆盖);`null` 清掉该字段。
|
|
555
|
+
async setModePatch(
|
|
556
|
+
sessionId: SessionId,
|
|
557
|
+
patch: ModePatch,
|
|
558
|
+
): Promise<SessionModeActiveState | undefined> {
|
|
559
|
+
const agent = this.ctx.agents.get(sessionId);
|
|
560
|
+
if (agent === undefined) return undefined;
|
|
561
|
+
const next: Record<string, unknown> = { ...this.modeOverrides.get(agent) };
|
|
562
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
563
|
+
if (value === null) Reflect.deleteProperty(next, key);
|
|
564
|
+
else next[key] = value;
|
|
565
|
+
}
|
|
566
|
+
this.modeOverrides.set(agent, next as unknown as ModeSession);
|
|
567
|
+
const modeId = this.resolveModeId(agent);
|
|
568
|
+
this.record(agent);
|
|
569
|
+
this.installs.get(agent)?.dispose();
|
|
570
|
+
this.installs.delete(agent);
|
|
571
|
+
this.installFor(agent, modeId);
|
|
572
|
+
return this.activeState(sessionId);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// 改这个会话的三个注入开关:立刻重装这一份(工具面与注入面同时换),返回新的生效态。
|
|
576
|
+
async setSwitches(
|
|
577
|
+
sessionId: SessionId,
|
|
578
|
+
patch: SwitchPatch,
|
|
579
|
+
): Promise<SessionModeActiveState | undefined> {
|
|
580
|
+
const agent = this.ctx.agents.get(sessionId);
|
|
581
|
+
if (agent === undefined) return undefined;
|
|
582
|
+
return this.setModePatch(sessionId, patch as ModePatch);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// 把当前会话的开关覆盖写回这个模式的配置(profile 的那份 patch):对新会话生效。
|
|
586
|
+
async saveAsDefault(sessionId: SessionId): Promise<SessionModeActiveState | undefined> {
|
|
587
|
+
const agent = this.ctx.agents.get(sessionId);
|
|
588
|
+
if (agent === undefined) throw new Error("会话不存在。");
|
|
589
|
+
const overrides = this.modeOverrides.get(agent);
|
|
590
|
+
if (overrides === undefined) return;
|
|
591
|
+
const modeId = this.modeOf(agent.session);
|
|
592
|
+
// 写回的是**当下生效**的那一份:模式的 persona/开关 + 逐条目的三态(合并成名单)。
|
|
593
|
+
const editable = this.editableFaceOf(this.definitionFor(agent, modeId));
|
|
594
|
+
const next = plainMode({ ...this.config.modes[modeId], ...editable });
|
|
595
|
+
(this.config.modes as Record<string, SessionMode>)[modeId] = next as unknown as SessionMode;
|
|
596
|
+
await writeMode(modeId, next);
|
|
597
|
+
// 写回后清掉会话覆盖:`config` 已在上面更新,重装用的是新默认,面板与工具面都不会回退。
|
|
598
|
+
this.modeOverrides.delete(agent);
|
|
599
|
+
this.installs.get(agent)?.dispose();
|
|
600
|
+
this.installs.delete(agent);
|
|
601
|
+
this.installFor(agent, modeId);
|
|
602
|
+
return this.activeState(sessionId);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// 以当前会话的生效配置另存为一个新模式;返回新 id。
|
|
606
|
+
async saveAsNewMode(sessionId: SessionId, code: string, name?: string): Promise<string> {
|
|
607
|
+
const agent = this.ctx.agents.get(sessionId);
|
|
608
|
+
if (agent === undefined) throw new Error("会话不存在。");
|
|
609
|
+
const id = code.trim();
|
|
610
|
+
if (id.length === 0) throw new Error("模式 code 不能为空。");
|
|
611
|
+
if (!/^[a-zA-Z0-9_-]+$/u.test(id))
|
|
612
|
+
throw new Error("模式 code 只能包含字母、数字、下划线与连字符。");
|
|
613
|
+
if (this.config.modes[id] !== undefined) throw new Error(`模式 ${id} 已存在。`);
|
|
614
|
+
const merged = this.definitionFor(agent, this.resolveModeId(agent));
|
|
615
|
+
const next = plainMode({ ...merged, name: name?.trim() || id });
|
|
616
|
+
// 先落内存再写文件:当前进程立刻可用,不必等 Loader(cordis 只是默认模板,不再参与写回)。
|
|
617
|
+
(this.config.modes as Record<string, SessionMode>)[id] = next as unknown as SessionMode;
|
|
618
|
+
await writeMode(id, next);
|
|
619
|
+
return id;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// 删除一个"另存为"出来的模式;内置模式拒绝。会话回到部署默认。
|
|
623
|
+
async deleteMode(sessionId: SessionId): Promise<string> {
|
|
624
|
+
const agent = this.ctx.agents.get(sessionId);
|
|
625
|
+
if (agent === undefined) throw new Error("会话不存在。");
|
|
626
|
+
const modeId = this.modeOf(agent.session);
|
|
627
|
+
if (modeId === this.defaultId) throw new Error("部署默认模式不能删除。");
|
|
628
|
+
const remaining = Object.keys(this.config.modes).filter((id) => id !== modeId);
|
|
629
|
+
if (remaining.length === 0) throw new Error("至少要留一个模式。");
|
|
630
|
+
const fallback = this.defaultId === modeId ? (remaining[0] as string) : this.defaultId;
|
|
631
|
+
Reflect.deleteProperty(this.config.modes as Record<string, SessionMode>, modeId);
|
|
632
|
+
await removeMode(modeId);
|
|
633
|
+
this.modeOverrides.delete(agent);
|
|
634
|
+
if (this.projectionHandle.stateOf(agent.session) === modeId) {
|
|
635
|
+
agent.session.append("session-mode/selected", { sessionMode: fallback });
|
|
636
|
+
}
|
|
637
|
+
this.installs.get(agent)?.dispose();
|
|
638
|
+
this.installFor(agent, fallback);
|
|
639
|
+
return fallback;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// 某个会话**当下生效**的一份:工具是运行期实际可见的集合(按需加载的 MCP 组加载后才会出现),
|
|
643
|
+
// 技能与 policy 是名单合成后的结果。侧边栏面板读它,不读配置清单。
|
|
644
|
+
async activeState(sessionId: SessionId): Promise<SessionModeActiveState | undefined> {
|
|
645
|
+
const session = this.ctx.sessions.get(sessionId);
|
|
646
|
+
if (session === undefined) return undefined;
|
|
647
|
+
const agent = this.ctx.agents.get(sessionId);
|
|
648
|
+
const id = this.modeOf(session);
|
|
649
|
+
const mode = agent === undefined ? this.definition(id) : this.definitionFor(agent, id);
|
|
650
|
+
// `ctx.get` 给的是追踪代理:带 JS 私有字段的方法在代理上会抛,先解包成真服务。
|
|
651
|
+
const unwrap = <T>(value: T | undefined): T | undefined =>
|
|
652
|
+
value === undefined
|
|
653
|
+
? undefined
|
|
654
|
+
: (((value as { [key: symbol]: unknown })[Symbol.for("cordis.original")] ?? value) as T);
|
|
655
|
+
const tools = unwrap(
|
|
656
|
+
this.ctx.get("tools") as unknown as
|
|
657
|
+
| {
|
|
658
|
+
schemas(agent: Agent): readonly { name: string }[];
|
|
659
|
+
definitions(): readonly { name: string; description?: string }[];
|
|
660
|
+
}
|
|
661
|
+
| undefined,
|
|
662
|
+
);
|
|
663
|
+
const skills = unwrap(
|
|
664
|
+
this.ctx.get("skills") as unknown as
|
|
665
|
+
| {
|
|
666
|
+
list(options?: {
|
|
667
|
+
scope?: Agent;
|
|
668
|
+
cwd?: string;
|
|
669
|
+
}): Promise<readonly { name: string; description: string }[]>;
|
|
670
|
+
}
|
|
671
|
+
| undefined,
|
|
672
|
+
);
|
|
673
|
+
// 技能面按 agent 收口与工作目录扫描:不传 scope 只读全局层,本部署会读成空。
|
|
674
|
+
const cwd = (agent?.session.header as { cwd?: unknown } | undefined)?.cwd;
|
|
675
|
+
const registered =
|
|
676
|
+
skills === undefined
|
|
677
|
+
? []
|
|
678
|
+
: await skills
|
|
679
|
+
.list({
|
|
680
|
+
...(agent === undefined ? {} : { scope: agent }),
|
|
681
|
+
...(typeof cwd === "string" ? { cwd } : {}),
|
|
682
|
+
})
|
|
683
|
+
.catch((error: unknown) => {
|
|
684
|
+
this.ctx.logger.warn("session-mode: skills.list() failed %o", {
|
|
685
|
+
error: String(error),
|
|
686
|
+
});
|
|
687
|
+
return [];
|
|
688
|
+
});
|
|
689
|
+
const config = asModeConfig(this.definition(id), this.definition(id).skills ?? true);
|
|
690
|
+
const override = agent === undefined ? undefined : this.modeOverrides.get(agent);
|
|
691
|
+
// 会话覆盖是整份 `ModeConfig`:面板要按字段显示来源,得知道**哪些字段真的不同**。
|
|
692
|
+
const overriddenFields =
|
|
693
|
+
override === undefined
|
|
694
|
+
? []
|
|
695
|
+
: (
|
|
696
|
+
[
|
|
697
|
+
["persona", "persona"],
|
|
698
|
+
["instructions", "instructions"],
|
|
699
|
+
["skills", "skills"],
|
|
700
|
+
["runtimeContext", "runtimeContext"],
|
|
701
|
+
["allowTools", "allowTools"],
|
|
702
|
+
["denyTools", "denyTools"],
|
|
703
|
+
["allowSkills", "allowSkills"],
|
|
704
|
+
["denySkills", "denySkills"],
|
|
705
|
+
["allowPolicies", "allowPolicies"],
|
|
706
|
+
["denyPolicies", "denyPolicies"],
|
|
707
|
+
] as const
|
|
708
|
+
)
|
|
709
|
+
.filter(([key]) => JSON.stringify(override[key]) !== JSON.stringify(config[key]))
|
|
710
|
+
.map(([, field]) => field);
|
|
711
|
+
return {
|
|
712
|
+
mode: { id, name: mode.name, description: mode.description },
|
|
713
|
+
config,
|
|
714
|
+
session: override,
|
|
715
|
+
overriddenFields,
|
|
716
|
+
// 可删的判据:不是部署默认、且删完还留得下至少一个模式(内置模式有确认弹窗兜底)。
|
|
717
|
+
deletable: id !== this.defaultId && Object.keys(this.config.modes).length > 1,
|
|
718
|
+
catalog: this.buildCatalog(
|
|
719
|
+
agent,
|
|
720
|
+
this.definition(id),
|
|
721
|
+
mode,
|
|
722
|
+
registered,
|
|
723
|
+
tools === undefined
|
|
724
|
+
? []
|
|
725
|
+
: [...tools.definitions(), ...(agent === undefined ? [] : tools.schemas(agent))],
|
|
726
|
+
),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
275
730
|
// 这条 policy 规则在**这次调用**里还生效吗(`true` = 交给上游,`false` = 被这个模式禁用)。
|
|
276
731
|
// 判据只有一处:`actor.agent` → `agent.session` → `modeOf`。认不出 agent(没有 agent 的直接调用)、或模式 id 在
|
|
277
732
|
// config 里找不到(重挂前后的瞬间)时一律按"没配"读——拦截路径绝不抛错。
|
|
@@ -319,7 +774,10 @@ export class SessionModes extends Service {
|
|
|
319
774
|
const agent = this.ctx.agents.get(sessionId);
|
|
320
775
|
const registry = this.presetRegistry();
|
|
321
776
|
if (agent !== undefined && registry !== undefined) {
|
|
322
|
-
const target = this.presetTarget(
|
|
777
|
+
const target = this.presetTarget(
|
|
778
|
+
definition.presetsOnly ?? [],
|
|
779
|
+
this.presetOfAgent(registry, agent),
|
|
780
|
+
);
|
|
323
781
|
if (target !== undefined) {
|
|
324
782
|
// 官方那条路自己也会查空白窗口(`agent-preset/locked`);它切完会 emit `agent-preset/selected`,
|
|
325
783
|
// 监听据此落事实并重装——所以这里先比一次投影,同一个值不写第二条。
|
|
@@ -372,6 +830,9 @@ export class SessionModes extends Service {
|
|
|
372
830
|
// 把某个活着的 agent 切到某个模式(不要求空白会话:子代理创建时的继承走这里);
|
|
373
831
|
// `options.record: false` 时不写会话日志(只改当前进程)。
|
|
374
832
|
applyTo(agent: Agent, mode: string, options: { record?: boolean } = {}): void {
|
|
833
|
+
this.hydrate(agent);
|
|
834
|
+
// 只有模式真的变了才重装:同一模式重复 `applyTo`(`agent/created` 之后再调一次)不该再装配一遍,
|
|
835
|
+
// 否则每一步会多写一条 `system/message`。覆盖变化由 `hydrate` 自己重装。
|
|
375
836
|
this.definition(mode);
|
|
376
837
|
if (options.record !== false) {
|
|
377
838
|
agent.session.append("session-mode/selected", { sessionMode: mode });
|
|
@@ -410,7 +871,7 @@ export class SessionModes extends Service {
|
|
|
410
871
|
modeForPreset(preset: string | undefined): string | undefined {
|
|
411
872
|
if (preset === undefined) return undefined;
|
|
412
873
|
const owners = Object.keys(this.config.modes).filter((id) =>
|
|
413
|
-
this.config.modes[id]?.presetsOnly.includes(preset),
|
|
874
|
+
(this.config.modes[id]?.presetsOnly ?? []).includes(preset),
|
|
414
875
|
);
|
|
415
876
|
return owners.length === 1 ? owners[0] : undefined;
|
|
416
877
|
}
|
|
@@ -428,28 +889,27 @@ export class SessionModes extends Service {
|
|
|
428
889
|
const installed = this.installs.get(agent);
|
|
429
890
|
if (installed?.mode === modeId) return;
|
|
430
891
|
installed?.dispose();
|
|
431
|
-
const mode = this.
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
this.
|
|
437
|
-
|
|
438
|
-
dispose: () => {
|
|
439
|
-
for (const dispose of disposers) dispose();
|
|
440
|
-
},
|
|
441
|
-
});
|
|
892
|
+
const mode = this.definitionFor(agent, modeId);
|
|
893
|
+
// persona 与默认模型兜底同样落在 `agent.ctx` 上:收回账挂在本行 + agent 两处(见 `./per-agent.ts`),
|
|
894
|
+
// 否则行重挂之后新实例会撞上 `deployment:persona-prefix` 那条 section。
|
|
895
|
+
const account = this.scope.account(agent);
|
|
896
|
+
account.add(installPersona(agent, mode.persona));
|
|
897
|
+
account.add(this.installDefaultModel(agent, modeId));
|
|
898
|
+
this.installs.set(agent, { mode: modeId, dispose: () => account.release() });
|
|
442
899
|
// 收口要的那几项按模式定义整份推过去:`name` / 两份工具名单 / 两份技能名单 / 三个开关。`skills` 缺省时在这里
|
|
443
900
|
// 按这份定义自己的工具名单推导(见 `derivedSkills`)——收口那一侧只认解析后的布尔。
|
|
444
901
|
this.scope.apply(agent, {
|
|
445
902
|
name: mode.name,
|
|
446
903
|
allowTools: mode.allowTools,
|
|
447
904
|
denyTools: mode.denyTools,
|
|
905
|
+
allowToolsOverride: [],
|
|
906
|
+
denyToolsOverride: [],
|
|
448
907
|
allowSkills: mode.allowSkills,
|
|
449
908
|
denySkills: mode.denySkills,
|
|
450
909
|
instructions: mode.instructions,
|
|
451
910
|
skills: mode.skills ?? derivedSkills(mode),
|
|
452
911
|
runtimeContext: mode.runtimeContext,
|
|
912
|
+
dropSections: mode.dropSections ?? [],
|
|
453
913
|
});
|
|
454
914
|
}
|
|
455
915
|
|
|
@@ -532,6 +992,14 @@ function requestJson(request: HttpRequestLike): Promise<unknown> {
|
|
|
532
992
|
});
|
|
533
993
|
}
|
|
534
994
|
|
|
995
|
+
function queryValue(url: string | undefined, name: string): string | undefined {
|
|
996
|
+
if (url === undefined) return undefined;
|
|
997
|
+
const separator = url.indexOf("?");
|
|
998
|
+
if (separator < 0) return undefined;
|
|
999
|
+
const value = new URLSearchParams(url.slice(separator + 1)).get(name);
|
|
1000
|
+
return value === null || value.length === 0 ? undefined : value;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
535
1003
|
function requiredString(value: unknown, field: string): string {
|
|
536
1004
|
if (typeof value !== "string" || value.length === 0) {
|
|
537
1005
|
throw new TypeError(`${field} 必须是非空字符串。`);
|
|
@@ -539,6 +1007,20 @@ function requiredString(value: unknown, field: string): string {
|
|
|
539
1007
|
return value;
|
|
540
1008
|
}
|
|
541
1009
|
|
|
1010
|
+
// 一条 POST 是面板的写面还是"切换模式":键漏一个,请求就会掉到 `mode` 校验上,报"mode 必须是非空字符串"。
|
|
1011
|
+
export function isWriteRequest(record: Record<string, unknown>): boolean {
|
|
1012
|
+
return (
|
|
1013
|
+
typeof record["switches"] === "object" ||
|
|
1014
|
+
typeof record["modePatch"] === "object" ||
|
|
1015
|
+
typeof record["itemState"] === "object" ||
|
|
1016
|
+
typeof record["applyConfig"] === "object" ||
|
|
1017
|
+
typeof record["saveConfig"] === "object" ||
|
|
1018
|
+
(typeof record["saveAsNewMode"] === "object" && record["saveAsNewMode"] !== null) ||
|
|
1019
|
+
record["deleteMode"] === true ||
|
|
1020
|
+
record["saveAsDefault"] === true
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
542
1024
|
async function handleRoute(
|
|
543
1025
|
modes: SessionModes,
|
|
544
1026
|
request: HttpRequestLike,
|
|
@@ -546,7 +1028,18 @@ async function handleRoute(
|
|
|
546
1028
|
): Promise<void> {
|
|
547
1029
|
try {
|
|
548
1030
|
if (request.method === "GET") {
|
|
549
|
-
|
|
1031
|
+
// 带 `sessionId` 时给这个会话的生效态,否则是模式清单(页面与 composer chip 用后者)。
|
|
1032
|
+
const sessionId = queryValue(request.url, "sessionId");
|
|
1033
|
+
if (sessionId === undefined) {
|
|
1034
|
+
respondJson(response, 200, modes.roster());
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
const active = await modes.activeState(sessionId as SessionId);
|
|
1038
|
+
if (active === undefined) {
|
|
1039
|
+
respondJson(response, 404, { error: "会话不存在。" });
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
respondJson(response, 200, active);
|
|
550
1043
|
return;
|
|
551
1044
|
}
|
|
552
1045
|
if (request.method === "POST") {
|
|
@@ -555,6 +1048,74 @@ async function handleRoute(
|
|
|
555
1048
|
throw new TypeError("请求体必须是 JSON 对象。");
|
|
556
1049
|
}
|
|
557
1050
|
const record = body as Record<string, unknown>;
|
|
1051
|
+
// 面板的写面:`switches` 改这个会话的开关,`saveAsDefault` 把当前覆盖写回模式配置。
|
|
1052
|
+
if (isWriteRequest(record)) {
|
|
1053
|
+
const target = requiredString(record["sessionId"], "sessionId") as SessionId;
|
|
1054
|
+
const saved =
|
|
1055
|
+
record["saveAsDefault"] === true ? await modes.saveAsDefault(target) : undefined;
|
|
1056
|
+
if (saved !== undefined) {
|
|
1057
|
+
respondJson(response, 200, saved);
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
if (record["deleteMode"] === true) {
|
|
1061
|
+
await modes.deleteMode(target);
|
|
1062
|
+
const state = await modes.activeState(target);
|
|
1063
|
+
respondJson(
|
|
1064
|
+
response,
|
|
1065
|
+
state === undefined ? 404 : 200,
|
|
1066
|
+
state ?? { error: "会话不存在。" },
|
|
1067
|
+
);
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
const saving = record["saveAsNewMode"];
|
|
1071
|
+
if (typeof saving === "object" && saving !== null) {
|
|
1072
|
+
const value = saving as { code?: unknown; name?: unknown };
|
|
1073
|
+
await modes.saveAsNewMode(
|
|
1074
|
+
target,
|
|
1075
|
+
requiredString(value.code, "code"),
|
|
1076
|
+
typeof value.name === "string" ? value.name : undefined,
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
if (typeof record["saveConfig"] === "object" && record["saveConfig"] !== null) {
|
|
1080
|
+
const draft = record["saveConfig"] as { config?: ModeConfig };
|
|
1081
|
+
if (draft.config === undefined) {
|
|
1082
|
+
respondJson(response, 400, { error: "saveConfig 需要完整的 config。" });
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
const state = await modes.saveConfig(target, draft.config);
|
|
1086
|
+
respondJson(
|
|
1087
|
+
response,
|
|
1088
|
+
state === undefined ? 404 : 200,
|
|
1089
|
+
state ?? { error: "会话不存在。" },
|
|
1090
|
+
);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (typeof record["applyConfig"] === "object" && record["applyConfig"] !== null) {
|
|
1094
|
+
const draft = record["applyConfig"] as { config?: ModeConfig };
|
|
1095
|
+
if (draft.config === undefined) {
|
|
1096
|
+
respondJson(response, 400, { error: "applyConfig 需要完整的 config。" });
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
const state = await modes.applyConfig(target, draft.config);
|
|
1100
|
+
respondJson(
|
|
1101
|
+
response,
|
|
1102
|
+
state === undefined ? 404 : 200,
|
|
1103
|
+
state ?? { error: "会话不存在。" },
|
|
1104
|
+
);
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
const patch = record["modePatch"] ?? record["switches"];
|
|
1108
|
+
const state =
|
|
1109
|
+
typeof patch === "object" && patch !== null
|
|
1110
|
+
? await modes.setModePatch(target, patch as ModePatch)
|
|
1111
|
+
: await modes.activeState(target);
|
|
1112
|
+
if (state === undefined) {
|
|
1113
|
+
respondJson(response, 404, { error: "会话不存在。" });
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
respondJson(response, 200, state);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
558
1119
|
const sessionId = requiredString(record["sessionId"], "sessionId") as SessionId;
|
|
559
1120
|
const mode = requiredString(record["mode"], "mode");
|
|
560
1121
|
const result: SessionModeSelectResult = { mode: await modes.select(sessionId, mode) };
|