@morlay/dsh-session-mode 0.0.1-alpha.4 → 0.0.1-alpha.5
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 +80 -67
- package/dist/client.cjs +165 -802
- package/dist/client.d.cts +22 -187
- package/dist/client.d.mts +22 -187
- package/dist/index.d.cts +183 -9
- package/dist/index.d.mts +183 -9
- package/dist/index.mjs +183 -31
- package/dist/rows.d.cts +38 -0
- package/dist/rows.d.mts +38 -0
- package/dist/rows.mjs +503 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +46 -53
- package/src/client/SessionModeSeat.tsx +38 -19
- package/src/client/index.ts +143 -92
- package/src/client/locales.ts +18 -72
- package/src/index.ts +145 -18
- package/{tool/modes.ts → src/mode-sources.ts} +32 -14
- package/src/modes.ts +197 -36
- package/src/rows.ts +57 -0
- package/cordis.patch.yml +0 -69
- package/dist/shared-FNWe-_rS.d.cts +0 -106
- package/dist/shared-FNWe-_rS.d.mts +0 -106
- package/src/client/ModelDefaultsCard.styles.ts +0 -134
- package/src/client/ModelDefaultsCard.tsx +0 -233
- package/src/client/SessionModeLabel.module.css +0 -24
- package/src/client/SessionModeLabel.tsx +0 -44
- package/src/client/model-defaults-card-controller.ts +0 -408
- package/tool/patch.ts +0 -91
package/src/index.ts
CHANGED
|
@@ -32,6 +32,9 @@ import type { Session, SessionEvent, SessionId } from "@deepseek-ai/dsh-session"
|
|
|
32
32
|
import type {} from "@deepseek-ai/dsh-session";
|
|
33
33
|
import type { ProjectionDefinition } from "@deepseek-ai/dsh-session-projection";
|
|
34
34
|
import type {} from "@deepseek-ai/dsh-session-projection";
|
|
35
|
+
// Type-only:官方 roster 的会话投影(`agentPreset`)与它的选择事件——选择面归官方 preset,
|
|
36
|
+
// 我们读它的选择、落成自己的会话事实。
|
|
37
|
+
import type {} from "@deepseek-ai/dsh-agent-preset-registry";
|
|
35
38
|
import type { SessionToolScope } from "@morlay/dsh-context-assembler/scope";
|
|
36
39
|
import { z } from "zod";
|
|
37
40
|
import {
|
|
@@ -65,7 +68,6 @@ export type {
|
|
|
65
68
|
ResolvedConfig,
|
|
66
69
|
SessionMode,
|
|
67
70
|
SessionModeModel,
|
|
68
|
-
SessionModeModels,
|
|
69
71
|
SessionModePersona,
|
|
70
72
|
SessionModeRole,
|
|
71
73
|
} from "./modes.ts";
|
|
@@ -91,10 +93,13 @@ declare module "@deepseek-ai/dsh-session/types" {
|
|
|
91
93
|
declare module "@deepseek-ai/dsh-session-projection/types" {
|
|
92
94
|
interface SessionProjectionStateMap {
|
|
93
95
|
sessionMode: string | null;
|
|
96
|
+
sessionModeEditable: boolean;
|
|
94
97
|
}
|
|
95
98
|
interface SessionProjectionMap {
|
|
96
99
|
/** 会话当前模式;`null` 表示没选过(用部署默认)。 */
|
|
97
100
|
sessionMode: string | null;
|
|
101
|
+
/** 会话还能不能换模式:`true` 是选择器,`false` 是只读标签(client 那个 chip 据此变形)。 */
|
|
102
|
+
sessionModeEditable: boolean;
|
|
98
103
|
}
|
|
99
104
|
}
|
|
100
105
|
|
|
@@ -111,24 +116,65 @@ export const sessionModeProjection = {
|
|
|
111
116
|
stateVersion: 1,
|
|
112
117
|
} satisfies ProjectionDefinition<"sessionMode", string | null>;
|
|
113
118
|
|
|
119
|
+
const sessionModeEditableSchema: z.ZodType<boolean> = z.boolean();
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 这个会话能不能换模式的投影:空白会话为 `true`,一旦 `turn/start` 落库就永远 `false`(换模式要的是**整段
|
|
123
|
+
* 历史**的模式一致,所以"开过 turn"之后连正在跑的那个 turn 也算)。
|
|
124
|
+
*
|
|
125
|
+
* **判据只有这一处**:服务端 {@link SessionModes.select} 的拒绝与 client chip 的只读形态都读它——以前
|
|
126
|
+
* client 只能等服务端报错,现在连入口都不给,而两边的结论来自同一份会话事实。
|
|
127
|
+
*/
|
|
128
|
+
export const sessionModeEditableProjection = {
|
|
129
|
+
key: "sessionModeEditable",
|
|
130
|
+
stateSchema: sessionModeEditableSchema,
|
|
131
|
+
init: () => true,
|
|
132
|
+
apply: (state: boolean, event: SessionEvent) => state && event.type !== "turn/start",
|
|
133
|
+
wire: { viewSchema: sessionModeEditableSchema, view: (state: boolean) => state },
|
|
134
|
+
stateVersion: 1,
|
|
135
|
+
} satisfies ProjectionDefinition<"sessionModeEditable", boolean>;
|
|
136
|
+
|
|
114
137
|
/** 模式清单、默认模式、按会话读取与切换。 */
|
|
115
138
|
export class SessionModes extends Service {
|
|
116
139
|
/** 每个 agent 已经装上的那一份(persona + 默认模型兜底;模式变了就换一份)。 */
|
|
117
140
|
private readonly installs = new WeakMap<Agent, { mode: string; dispose: () => void }>();
|
|
118
141
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
142
|
+
/** 装配时的配置快照:`default` / `modes` 读它,改这两项靠 Loader 重挂这一行(已运行会话不自动换定义)。 */
|
|
143
|
+
readonly config: {
|
|
144
|
+
default: string;
|
|
145
|
+
modes: Record<string, SessionMode>;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
constructor(ctx: Context, config: ResolvedConfig) {
|
|
123
149
|
super(ctx, "sessionModes");
|
|
124
|
-
//
|
|
125
|
-
|
|
150
|
+
// 校验看的是**值**:字段解析后都是稳定引用,`configProblem` 只认普通对象。
|
|
151
|
+
// 快照取一份**可变**副本:volatile 的快照是深度只读,而内部按普通配置对象用(写回不走这里,改配置靠重挂)。
|
|
152
|
+
this.config = {
|
|
153
|
+
default: config.default.get(),
|
|
154
|
+
modes: structuredClone(config.modes.get()) as Record<string, SessionMode>,
|
|
155
|
+
};
|
|
156
|
+
// 退役的顶层 `models` 还配着值就让装配期报错。
|
|
157
|
+
const problem = configProblem({ ...this.config, models: config.models });
|
|
126
158
|
if (problem !== undefined) throw new Error(problem);
|
|
127
159
|
ctx.sessionProjections.register(sessionModeProjection);
|
|
160
|
+
ctx.sessionProjections.register(sessionModeEditableProjection);
|
|
128
161
|
// 会话一建立就装上:这早于它的第一次装配,persona 因此一定在装配之前注册好。
|
|
129
162
|
ctx.on("agent/created", ({ agent }) => {
|
|
130
163
|
this.installFor(agent);
|
|
131
164
|
});
|
|
165
|
+
// 官方 roster 在空白窗口换 preset 时,把该会话的扩展换成新 preset 那一份:先把它写成我们的会话事实
|
|
166
|
+
// (`installFor` 读的就是这份事实),再按新模式装一遍。
|
|
167
|
+
// 只在 preset → 模式的映射唯一时动手:本部署两个模式共享同一份 preset,反查无意义(`modeForPreset`
|
|
168
|
+
// 返回 `undefined`),选模式不会经过这条监听,模式事实由 `select` 自己落。
|
|
169
|
+
ctx.on("agent-preset/selected", (sessionId: SessionId, preset: string) => {
|
|
170
|
+
const mapped = this.modeForPreset(preset);
|
|
171
|
+
const agent = ctx.agents.get(sessionId);
|
|
172
|
+
if (mapped === undefined || agent === undefined) return;
|
|
173
|
+
if (this.ctx.sessionProjections.stateOf(agent.session, "sessionMode") !== mapped) {
|
|
174
|
+
agent.session.append("session-mode/selected", { sessionMode: mapped });
|
|
175
|
+
}
|
|
176
|
+
this.installFor(agent, mapped);
|
|
177
|
+
});
|
|
132
178
|
}
|
|
133
179
|
|
|
134
180
|
/** 新会话用它:config 里的 `default`。 */
|
|
@@ -198,6 +244,13 @@ export class SessionModes extends Service {
|
|
|
198
244
|
|
|
199
245
|
/**
|
|
200
246
|
* 把某个空白会话切到某个模式。
|
|
247
|
+
*
|
|
248
|
+
* 模式带着它的 preset(`preset` 决定行清单),所以这里**先把 agent preset 换成模式声明的那个**,再落我们的
|
|
249
|
+
* 会话事实:否则 preset realm 还是旧那一套的行,而它的注入在我们的开关之外(旧形态 `chat` 挂 `minimal`、
|
|
250
|
+
* 会话的 preset 却还是 `standard` 时,上游 `agent-instructions` 会照旧把工作区指令注进这个"不要注入"的会话)。
|
|
251
|
+
*
|
|
252
|
+
* 目标 preset 与当前挂着的**相同时不切**(本部署两个模式共享同一份 preset,所以切模式通常走不到这一步):
|
|
253
|
+
* 换 preset 是一次重挂(卸旧行、装新行),没有变化就没有理由付出这个代价。
|
|
201
254
|
* @param sessionId - 目标会话(必须还没有开过 turn)。
|
|
202
255
|
* @param mode - 目标模式 id。
|
|
203
256
|
* @returns 提交后的模式 id。
|
|
@@ -209,17 +262,57 @@ export class SessionModes extends Service {
|
|
|
209
262
|
}
|
|
210
263
|
const session = this.ctx.sessions.get(sessionId);
|
|
211
264
|
if (session === undefined) throw new Error(`未知的会话 ${sessionId}`);
|
|
212
|
-
|
|
213
|
-
|
|
265
|
+
// 空白窗口的判据只有一处:{@link sessionModeEditableProjection}。client 那个 chip 读的是同一个投影,
|
|
266
|
+
// 所以"看起来能选"与"服务端接受"不会脱节。
|
|
267
|
+
if (this.ctx.sessionProjections.stateOf(session, "sessionModeEditable") === false) {
|
|
214
268
|
throw new Error("这个会话已经开始,模式不能再改;要换模式请新开一个会话。");
|
|
215
269
|
}
|
|
216
|
-
session.append("session-mode/selected", { sessionMode: mode });
|
|
217
|
-
// 会话可能已经建好了 agent(空白会话也有):立刻按新模式重新应用一遍。
|
|
218
270
|
const agent = this.ctx.agents.get(sessionId);
|
|
271
|
+
const registry = this.presetRegistry();
|
|
272
|
+
if (agent !== undefined && registry !== undefined && definition.preset.length > 0) {
|
|
273
|
+
// 官方那条路自己也会查空白窗口(`agent-preset/locked`)。它切完会 emit `agent-preset/selected`,
|
|
274
|
+
// 下面那个监听据此把模式落成会话事实并装一遍——所以写事实前先比一次投影,同一个值不写第二条。
|
|
275
|
+
if (this.presetOfAgent(registry, agent) !== definition.preset) {
|
|
276
|
+
await registry.select(agent, definition.preset);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (this.ctx.sessionProjections.stateOf(session, "sessionMode") !== mode) {
|
|
280
|
+
session.append("session-mode/selected", { sessionMode: mode });
|
|
281
|
+
}
|
|
282
|
+
// 会话可能已经建好了 agent(空白会话也有):立刻按新模式重新应用一遍(与监听那次重复也无妨,幂等)。
|
|
219
283
|
if (agent !== undefined) this.installFor(agent);
|
|
220
284
|
return mode;
|
|
221
285
|
}
|
|
222
286
|
|
|
287
|
+
/**
|
|
288
|
+
* 某个 agent 当前挂着的 preset(registry 的 `composedPreset`)。
|
|
289
|
+
*
|
|
290
|
+
* 读不到时返回 `undefined`(按"未知"处理 → 该切就切):`composedPreset` 是 registry 较新的读面,替身与老
|
|
291
|
+
* 版本可能没有它;而"没挂任何 preset"(返回值 `undefined`)与"读不到"在这里是同一个结论。
|
|
292
|
+
*/
|
|
293
|
+
private presetOfAgent(registry: Context["agentPresets"], agent: Agent): string | undefined {
|
|
294
|
+
const read = (registry as { composedPreset?: (ctx: Context) => string | undefined })
|
|
295
|
+
.composedPreset;
|
|
296
|
+
if (typeof read !== "function") return undefined;
|
|
297
|
+
try {
|
|
298
|
+
return read.call(registry, agent.ctx);
|
|
299
|
+
} catch {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 官方 preset registry:行清单与它的选择面住在那一行。不在 `inject` 里点名(headless 部署没有它,
|
|
306
|
+
* 点名会让本行永不激活),所以按"可能拿不到"读——`ctx.get` 在当前 ctx 没声明那个服务时会抛。
|
|
307
|
+
*/
|
|
308
|
+
private presetRegistry(): Context["agentPresets"] | undefined {
|
|
309
|
+
try {
|
|
310
|
+
return this.ctx.get("agentPresets");
|
|
311
|
+
} catch {
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
223
316
|
/**
|
|
224
317
|
* 把某个活着的 agent 切到某个模式。与 {@link select} 的差别:它不要求空白会话——子代理创建时的继承
|
|
225
318
|
* 走这里,**未来的"指定 mode"入口(模型侧或配置侧)也走这里**(那条接缝还没做)。
|
|
@@ -241,15 +334,51 @@ export class SessionModes extends Service {
|
|
|
241
334
|
* 继承要**写进子会话日志**:它是一条会话事实,冷恢复与 fork 都要靠它重建({@link modeOf} 只读投影)。
|
|
242
335
|
*/
|
|
243
336
|
private resolveModeId(agent: Agent): string {
|
|
244
|
-
// `undefined` = 这个投影没注册(或还没初始化),与"没选过"(`null
|
|
337
|
+
// `undefined` = 这个投影没注册(或还没初始化),与"没选过"(`null`)一样落到下一层。
|
|
245
338
|
const selected = this.ctx.sessionProjections.stateOf(agent.session, "sessionMode");
|
|
246
339
|
if (typeof selected === "string") return selected;
|
|
340
|
+
// 会话级选择归官方 roster:它的 preset 决定这个会话用哪份扩展,我们把结论落成自己的会话事实
|
|
341
|
+
// (`sessionMode` 是会话级事实的 home:恢复、子代理继承、服务端读取都读它)。
|
|
342
|
+
const mapped = this.modeForPreset(this.presetOf(agent.session));
|
|
343
|
+
if (mapped !== undefined) {
|
|
344
|
+
agent.session.append("session-mode/selected", { sessionMode: mapped });
|
|
345
|
+
return mapped;
|
|
346
|
+
}
|
|
247
347
|
const inherited = this.inheritedModeId(agent);
|
|
248
348
|
if (inherited === undefined) return this.defaultId;
|
|
249
349
|
agent.session.append("session-mode/selected", { sessionMode: inherited });
|
|
250
350
|
return inherited;
|
|
251
351
|
}
|
|
252
352
|
|
|
353
|
+
/** 官方 roster 选定的 preset(没选过、或 registry 没装时为 `undefined`)。 */
|
|
354
|
+
private presetOf(session: Session): string | undefined {
|
|
355
|
+
try {
|
|
356
|
+
const state = this.ctx.sessionProjections.stateOf(session, "agentPreset");
|
|
357
|
+
return typeof state === "string" ? state : undefined;
|
|
358
|
+
} catch {
|
|
359
|
+
// registry 没装(headless 部署)时这个投影不存在:按"没有选择"处理。
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* 某个 agent preset 对应的模式 id——**只在映射唯一时**回答(几个模式挂同一份 preset 时返回
|
|
366
|
+
* `undefined`,不反查)。
|
|
367
|
+
*
|
|
368
|
+
* 本部署的两个模式共享同一个 preset(`MODE_PRESET_ID`),差异全在会话级收口,所以 preset → 模式的反查在
|
|
369
|
+
* 这里无意义:模式由**会话事实**决定(`session-mode/selected` 投影 → 子代理继承 → 部署默认),
|
|
370
|
+
* 官方 roster 选了什么 preset 不改变这个会话是哪个模式。这条反查留给"一对一映射"的部署形态。
|
|
371
|
+
* @param preset - preset id(`undefined` 表示没选过、或 registry 没装)。
|
|
372
|
+
* @returns 该 preset 唯一对应的模式 id;没配扩展、或由多个模式共享时 `undefined`。
|
|
373
|
+
*/
|
|
374
|
+
modeForPreset(preset: string | undefined): string | undefined {
|
|
375
|
+
if (preset === undefined) return undefined;
|
|
376
|
+
const owners = Object.keys(this.config.modes).filter(
|
|
377
|
+
(id) => this.config.modes[id]?.preset === preset,
|
|
378
|
+
);
|
|
379
|
+
return owners.length === 1 ? owners[0] : undefined;
|
|
380
|
+
}
|
|
381
|
+
|
|
253
382
|
/** 子代理(有 durable 父会话)继承父当前模式;父不在场、或不是子代理时没有可继承的。 */
|
|
254
383
|
private inheritedModeId(agent: Agent): string | undefined {
|
|
255
384
|
const parentId = agent.session.header.parentSession;
|
|
@@ -278,18 +407,16 @@ export class SessionModes extends Service {
|
|
|
278
407
|
}
|
|
279
408
|
|
|
280
409
|
/**
|
|
281
|
-
*
|
|
410
|
+
* 模式的默认模型(`modes.<模式 id>.defaultModel`)兜底:只在会话**尚无任何模型事实**(没选过模型、也
|
|
282
411
|
* 还没跑过请求)时接管这一请求的路由;一旦用户选过(投影 `pending`)或会话已经落过 header,就不再插手。
|
|
283
412
|
*
|
|
284
|
-
* 它是**配置事实**,不写会话事件——重启后仍由 config
|
|
285
|
-
*
|
|
286
|
-
* 模型取自 `config.models`:那是个 volatile 引用,设置页保存时只有**引用里的值**变,这一行不重挂,
|
|
287
|
-
* 所以每次请求都现场 `.get()`(与 `llm-openai-compatible` 读 `config.providers` 同一种读法)。
|
|
413
|
+
* 它是**配置事实**,不写会话事件——重启后仍由 config 决定;设置页里那条会话级选择才是会话事实。读的是构造
|
|
414
|
+
* 时那份模式清单快照:设置页保存会让这一行重挂(`reconcileProfilePatches`),新定义随重挂生效。
|
|
288
415
|
*/
|
|
289
416
|
private installDefaultModel(agent: Agent, modeId: string): () => void {
|
|
290
417
|
return agent.ctx.on("agent/request", async (_payload, next): Promise<LlmCallConfig> => {
|
|
291
418
|
const resolved = await next();
|
|
292
|
-
const model = this.config.
|
|
419
|
+
const model = this.config.modes[modeId]?.defaultModel;
|
|
293
420
|
if (model === undefined) return resolved;
|
|
294
421
|
// 上游 session-controller 没装(headless)时这个投影不存在,按"没有选择"处理。
|
|
295
422
|
const pending = this.ctx.sessionProjections.stateOf(agent.session, "modelSelection")?.pending;
|
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 模式定义的真源:`
|
|
2
|
+
* 模式定义的真源:`session-mode` 行的 `config` 由 `./rows.ts` 渲染(装配入口在 `packages/bundles/session-mode-profile`)。
|
|
3
3
|
*
|
|
4
4
|
* "自定义"就落在这份数据上——装配层(profile 的用户 patch 层)可以整体改写 `config.modes`,也可以只给
|
|
5
5
|
* 某个模式换提示词或工具白名单,不需要任何插件行。
|
|
6
6
|
*
|
|
7
7
|
* `allowTools` 从 [`@morlay/dsh-agent-toolkit/rows`](../../../dsh-agent-toolkit/src/rows.ts) 的
|
|
8
|
-
* `TOOLKIT_TOOL_NAMES`
|
|
9
|
-
*
|
|
8
|
+
* `TOOLKIT_TOOL_NAMES` 派生(工具名与汉化同源):**行清单由本部署自己注册的那份 preset 提供**
|
|
9
|
+
* ({@link MODE_PRESET_ID},两个模式共享它),白名单里那份 preset 没有的工具自动跳过。想改某一个模式的名单,
|
|
10
|
+
* 直接在这条源数据里加/减。
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { TOOLKIT_TOOL_NAMES } from "@morlay/dsh-agent-toolkit/rows";
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* 本部署自己注册的那份 agent preset 的 id:两个模式共享它(行清单见
|
|
17
|
+
* `packages/bundles/session-mode-profile`,`config.plugins` 引用 `@morlay/dsh-agent-toolkit/rows` 的
|
|
18
|
+
* `TOOLKIT_PRESET_ROWS`)。
|
|
19
|
+
*
|
|
20
|
+
* 为什么自己注册而不是复用官方 preset:官方 `minimal` 没有 `tool-web`,`chat` 的白名单(提问 + 联网三件)
|
|
21
|
+
* 收口后一件都不剩;而且官方 preset 自带的上游注入要在我们的开关之外做"让位",抢 `skill` 面还得把目录 kind
|
|
22
|
+
* 换掉。行清单归我们之后,两个模式的**差异全在会话级收口**(persona / `allowTools` / 两个开关)。
|
|
23
|
+
*/
|
|
24
|
+
export const MODE_PRESET_ID = "mode-switch";
|
|
25
|
+
|
|
14
26
|
/** 一个模式的源定义:就是 `session-mode` 行 `config.modes` 里的一项。 */
|
|
15
27
|
export interface ModeSource {
|
|
16
28
|
readonly id: string;
|
|
29
|
+
/**
|
|
30
|
+
* 挂哪个 agent preset(它的 `id`):行清单由那个 preset 提供,这里只写扩展。
|
|
31
|
+
*
|
|
32
|
+
* 官方四个 shipped preset 照旧可选(官方 roster 选择面没动),但我们的模式都挂
|
|
33
|
+
* {@link MODE_PRESET_ID};两个模式共享同一个 preset 是**有意**的(差异靠会话级收口表达),所以
|
|
34
|
+
* preset → 模式的反查在这种共享下无意义(见 `SessionModes.modeForPreset`)。
|
|
35
|
+
*/
|
|
36
|
+
readonly preset: string;
|
|
17
37
|
readonly name: string;
|
|
18
38
|
readonly description: string;
|
|
19
39
|
/** 归谁用:`main`(用户选择器,缺省)、`subagent`(可作子代理 mode 的候选)。 */
|
|
@@ -22,9 +42,11 @@ export interface ModeSource {
|
|
|
22
42
|
readonly allowTools: readonly string[];
|
|
23
43
|
readonly instructions?: boolean;
|
|
24
44
|
readonly runtimeContext?: boolean;
|
|
45
|
+
/** 这个模式的默认模型(省略就跟全局 `agent-default-model`)。 */
|
|
46
|
+
readonly defaultModel?: ModeModelSource;
|
|
25
47
|
}
|
|
26
48
|
|
|
27
|
-
/**
|
|
49
|
+
/** 一个模式的默认模型源定义:`modes.<模式 id>.defaultModel`。 */
|
|
28
50
|
export interface ModeModelSource {
|
|
29
51
|
readonly provider: string;
|
|
30
52
|
readonly model: string;
|
|
@@ -49,18 +71,12 @@ const CHAT_PERSONA = {
|
|
|
49
71
|
/** 新会话用哪个模式(`session-mode` 行的 `config.default`)。 */
|
|
50
72
|
export const DEFAULT_MODE = "coding";
|
|
51
73
|
|
|
52
|
-
/**
|
|
53
|
-
* 各模式的默认模型(`session-mode` 行 `config.models`:模式 id → 模型)。
|
|
54
|
-
*
|
|
55
|
-
* 当前为空——两个模式都跟着全局 `agent-default-model`。要改某个模式的默认模型就写在这里(键必须是
|
|
56
|
-
* `MODE_SOURCES` 里的 id,装配期校验会拒绝孤儿键);用户在设置页写的那份由 settings 的用户层叠在它上面。
|
|
57
|
-
*/
|
|
58
|
-
export const MODE_MODELS: Readonly<Record<string, ModeModelSource>> = {};
|
|
59
|
-
|
|
60
|
-
/** 两个模式:编码与对话。 */
|
|
74
|
+
/** 两个模式:编码与对话。**同一个 preset,差异全在会话级收口**。 */
|
|
61
75
|
export const MODE_SOURCES: readonly ModeSource[] = [
|
|
62
76
|
{
|
|
63
77
|
id: "coding",
|
|
78
|
+
// 完整工具集由我们自己的 preset(`TOOLKIT_PRESET_ROWS`)提供。
|
|
79
|
+
preset: MODE_PRESET_ID,
|
|
64
80
|
name: "编码模式",
|
|
65
81
|
description: "功能完整的编码 Agent:文件、Shell、检索、联网等工具常驻,其余用法说明按需加载。",
|
|
66
82
|
persona: CODING_PERSONA,
|
|
@@ -70,13 +86,15 @@ export const MODE_SOURCES: readonly ModeSource[] = [
|
|
|
70
86
|
},
|
|
71
87
|
{
|
|
72
88
|
id: "chat",
|
|
89
|
+
// 同一个 preset:行清单里有联网工具,收口才收得成"提问 + 联网"。
|
|
90
|
+
preset: MODE_PRESET_ID,
|
|
73
91
|
name: "对话模式",
|
|
74
92
|
description:
|
|
75
93
|
"只做对话:提问与联网(搜索、抓取)三件工具,不注入系统提示词、工作区指令与技能目录。",
|
|
76
94
|
persona: CHAT_PERSONA,
|
|
77
95
|
// 只做用户侧对话:不做子代理的候选(父在 chat 里派发的子代理仍继承 chat,见 README 的"角色"一节)。
|
|
78
96
|
role: ["main"],
|
|
79
|
-
//
|
|
97
|
+
// 提问与联网三件:行由 preset 提供,这里只收口(preset 没有的自动跳过)。
|
|
80
98
|
allowTools: ["ask_user_question", "web_search", "web_fetch"],
|
|
81
99
|
// 没有文件与 shell 工具,"能改工作区哪些文件、要不要走审批"对它全是噪音。
|
|
82
100
|
instructions: false,
|
package/src/modes.ts
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* 模式不再是 Cordis 子树,而是这份纯数据——本包的插件行只在 `config.modes` 里声明它们,运行期按会话读取
|
|
6
6
|
* 并应用(persona 注册到该 agent 的 scope,工具收口交给 `@morlay/dsh-context-assembler/scope`)。
|
|
7
7
|
*
|
|
8
|
+
* 行清单(工具 / 命令 / 压缩 / 委派…)来自 `preset` 指的那份 agent preset:本部署自己注册了一份
|
|
9
|
+
* (`packages/bundles/session-mode-profile` 的 `preset-mode-switch`),两个模式共享它,差异全在会话级收口。
|
|
10
|
+
*
|
|
8
11
|
* "支持自定义"就是指这份 config:装配层(`cordis.patch.yml` / profile 的用户层)能整体改写 `modes`,
|
|
9
12
|
* 也可以只给某几个模式换提示词或白名单——不需要任何插件行。
|
|
10
13
|
*
|
|
@@ -40,7 +43,7 @@ export interface SessionModeModel {
|
|
|
40
43
|
readonly reasoningEffort?: string;
|
|
41
44
|
}
|
|
42
45
|
|
|
43
|
-
/**
|
|
46
|
+
/** 退役的顶层形状:模式 id → 模型(默认模型现在住在各自的模式里)。 */
|
|
44
47
|
export type SessionModeModels = Readonly<Record<string, SessionModeModel>>;
|
|
45
48
|
|
|
46
49
|
/**
|
|
@@ -51,7 +54,17 @@ export type SessionModeModels = Readonly<Record<string, SessionModeModel>>;
|
|
|
51
54
|
* 不看这里。
|
|
52
55
|
*/
|
|
53
56
|
export interface SessionMode {
|
|
54
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* 这个模式挂在哪个 **agent preset** 上(官方 `standard` / `ptc` / `minimal` / `cordis`,或本部署自己
|
|
59
|
+
* 注册的那一份的 `id`,见 `mode-sources.ts` 的 `MODE_PRESET_ID`)。
|
|
60
|
+
*
|
|
61
|
+
* preset 决定这个 agent 有哪些行(工具 / 命令 / 压缩 / 委派…),模式只决定"这些行怎么被用":
|
|
62
|
+
* `allowTools` 里 preset 没有的工具自动跳过,其余扩展(persona / 注入开关 / 默认模型)照常应用。
|
|
63
|
+
* **可以共享**(本部署就是两个模式挂同一份 preset,差异全在会话级收口);共享时 preset → 模式的反查无从下手,
|
|
64
|
+
* 见 `SessionModes.modeForPreset`。空串表示不挂(只能由 applyTo / 继承使用)。
|
|
65
|
+
*/
|
|
66
|
+
readonly preset: string;
|
|
67
|
+
/** 模式的展示名(选择面归官方 roster;这里留着做事实文案)。 */
|
|
55
68
|
readonly name: string;
|
|
56
69
|
/** 一句话说明这个模式干什么;空串表示没写。 */
|
|
57
70
|
readonly description: string;
|
|
@@ -74,10 +87,21 @@ export interface SessionMode {
|
|
|
74
87
|
* ——对话模式没有文件与 shell 工具,"能改工作区哪些文件、要不要走审批"对它全是噪音。
|
|
75
88
|
*/
|
|
76
89
|
readonly runtimeContext: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* 这个模式的默认模型;省略就跟全局 `agent-default-model`。**可选**:没配的模式在页面上不出现在这一行
|
|
92
|
+
* (`defaultModel` 是它所在模式的一个可加字段)。
|
|
93
|
+
*/
|
|
94
|
+
readonly defaultModel?: SessionModeModel;
|
|
77
95
|
}
|
|
78
96
|
|
|
79
97
|
// 每个字段都带 default:schema 的产物因此没有 `undefined` 键(`exactOptionalPropertyTypes` 下"缺省的键"
|
|
80
98
|
// 会与可选属性对不上),而默认值就是"不遮蔽"的那个语义——persona 的默认是两段空文本。
|
|
99
|
+
/**
|
|
100
|
+
* 本地化说明:`description()` 的类型签名只声明 `string`,而 meta 本身接受 `Dict<string>`
|
|
101
|
+
* (`vendor/schemastery/src/index.ts` 的 `mergeDesc` 就是按字典合并的),所以这里只做一次类型放行。
|
|
102
|
+
*/
|
|
103
|
+
const localized = (text: { zh: string; en: string }): string => text as unknown as string;
|
|
104
|
+
|
|
81
105
|
const personaSchema = z.object({
|
|
82
106
|
prefix: z.string().default(""),
|
|
83
107
|
suffix: z.string().default(""),
|
|
@@ -88,65 +112,190 @@ const roleSchema = z.union([z.const("main"), z.const("subagent")]);
|
|
|
88
112
|
|
|
89
113
|
/** 默认模型的形状与全局 `agent-default-model` 一致;省略 effort 就跟 provider 默认。 */
|
|
90
114
|
const modelSchema = z.object({
|
|
91
|
-
provider: z
|
|
92
|
-
|
|
93
|
-
|
|
115
|
+
provider: z
|
|
116
|
+
.string()
|
|
117
|
+
// 候选来自客户端注册的具名源 `llm-providers`:字段只说"这是选一个",不关心行 id 与路径。
|
|
118
|
+
.role("select", { source: "llm-providers" })
|
|
119
|
+
.description(
|
|
120
|
+
localized({
|
|
121
|
+
zh: "服务商 id(部署里注册的任意路由,不论哪个适配器插件注册的)。",
|
|
122
|
+
en: "Provider id (any route registered in this deployment, whichever adapter plugin supplies it).",
|
|
123
|
+
}),
|
|
124
|
+
),
|
|
125
|
+
model: z
|
|
126
|
+
.string()
|
|
127
|
+
// 具名源 `llm-models` 自己声明依赖 `provider`(换服务商就换清单)。
|
|
128
|
+
.role("select", { source: "llm-models" })
|
|
129
|
+
.description(
|
|
130
|
+
localized({
|
|
131
|
+
zh: "模型 id。",
|
|
132
|
+
en: "Model id.",
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
135
|
+
reasoningEffort: z.string().description(
|
|
136
|
+
localized({
|
|
137
|
+
zh: "思考档位;省略就跟服务商自己的默认。",
|
|
138
|
+
en: "Reasoning effort; unset keeps the provider's own default.",
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
94
141
|
});
|
|
95
142
|
|
|
96
|
-
/** 本包 config
|
|
143
|
+
/** 本包 config 的**源码形状**:装配层与设置页写的那个形状。 */
|
|
97
144
|
export interface Config {
|
|
98
145
|
/** 新会话(还没选过模式的会话)用哪个模式。必须是 `modes` 里的一个 id。 */
|
|
99
146
|
readonly default: string;
|
|
100
|
-
/** 模式清单:id →
|
|
147
|
+
/** 模式清单:id → 定义(含各自的 `defaultModel`)。顺序即选择器里的顺序(`Object.entries` 的插入序)。 */
|
|
101
148
|
readonly modes: Record<string, SessionMode>;
|
|
102
149
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* 它是 config 的**顶层 volatile 字段**:设置面(`ctx.configForms` → 我们那张卡片)编辑的就是它。挪进
|
|
106
|
-
* `modes.<id>` 会让设置面看不见它——`schemastery` 的 `validateVolatileSchema` 把 dict 内部一律当成
|
|
107
|
-
* blocked,而 settings 只挑得出固定路径上的 volatile 字段。
|
|
150
|
+
* 各模式默认模型曾经住在这里(模式 id → 模型)。现在住在**每个模式自己的 `defaultModel`** 里,这个字段
|
|
151
|
+
* 只剩一件事:装配期看见它还配着值就报错,提醒把它挪进对应的模式——否则它会静静地失效。
|
|
108
152
|
*/
|
|
109
153
|
readonly models?: SessionModeModels;
|
|
110
154
|
}
|
|
111
155
|
|
|
112
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* schema 解析之后的形状:volatile 字段被换成**稳定引用**,读它要过 `.get()`(设置页改的就是同一份)。
|
|
158
|
+
*
|
|
159
|
+
* `default` 与 `modes` 都是 volatile:默认模式与整份模式清单(含各自的 `defaultModel`)都在行配置页上。
|
|
160
|
+
*/
|
|
113
161
|
export interface ResolvedConfig {
|
|
114
|
-
readonly default: string
|
|
115
|
-
readonly modes: Record<string, SessionMode
|
|
116
|
-
/**
|
|
117
|
-
readonly models:
|
|
162
|
+
readonly default: Volatile<string>;
|
|
163
|
+
readonly modes: Volatile<Record<string, SessionMode>>;
|
|
164
|
+
/** 退役的顶层字段:解析后仍在这儿(普通值,不 volatile),装配期据此发现"还配着值"并报错。 */
|
|
165
|
+
readonly models: SessionModeModels;
|
|
118
166
|
}
|
|
119
167
|
|
|
120
168
|
const modeSchema: z<SessionMode> = z.object({
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
169
|
+
preset: z
|
|
170
|
+
.string()
|
|
171
|
+
.default("")
|
|
172
|
+
.description(
|
|
173
|
+
localized({
|
|
174
|
+
zh: "这个模式挂哪个 agent preset(它的 id,官方或本部署自建的):行清单由那个 preset 提供,几个模式可以共享同一个;空串表示不挂(只能由 applyTo / 继承使用)。",
|
|
175
|
+
en: "Which agent preset this mode rides on (its id, shipped or deployment-owned): that preset supplies the row list and several modes may share it; empty means none (usable only through applyTo / inheritance).",
|
|
176
|
+
}),
|
|
177
|
+
),
|
|
178
|
+
name: z
|
|
179
|
+
.string()
|
|
180
|
+
.required()
|
|
181
|
+
.description(
|
|
182
|
+
localized({
|
|
183
|
+
zh: "模式名(事实文案;选择面归官方 roster)。",
|
|
184
|
+
en: "Mode name (fact copy; the picker belongs to the official roster).",
|
|
185
|
+
}),
|
|
186
|
+
),
|
|
187
|
+
description: z
|
|
188
|
+
.string()
|
|
189
|
+
.default("")
|
|
190
|
+
.description(
|
|
191
|
+
localized({
|
|
192
|
+
zh: "一句话说明。",
|
|
193
|
+
en: "One-line description.",
|
|
194
|
+
}),
|
|
195
|
+
),
|
|
196
|
+
role: z
|
|
197
|
+
.array(roleSchema)
|
|
198
|
+
.default(["main"])
|
|
199
|
+
.description(
|
|
200
|
+
localized({
|
|
201
|
+
zh: "谁可以用这个模式:`main`(会话选择器里可选)或 `subagent`(子代理继承)。",
|
|
202
|
+
en: "Who may use this mode: `main` (selectable in sessions) or `subagent` (inherited by subagents).",
|
|
203
|
+
}),
|
|
204
|
+
),
|
|
124
205
|
persona: personaSchema.default({}),
|
|
125
|
-
allowTools: z
|
|
126
|
-
|
|
127
|
-
|
|
206
|
+
allowTools: z
|
|
207
|
+
.array(z.string())
|
|
208
|
+
.default([])
|
|
209
|
+
.description(
|
|
210
|
+
localized({
|
|
211
|
+
zh: "这个会话能用的工具;其余既不进目录,调用也被拒。",
|
|
212
|
+
en: "Tools this session may use; everything else leaves the catalog and calls are refused.",
|
|
213
|
+
}),
|
|
214
|
+
),
|
|
215
|
+
instructions: z
|
|
216
|
+
.boolean()
|
|
217
|
+
.default(true)
|
|
218
|
+
.description(
|
|
219
|
+
localized({
|
|
220
|
+
zh: "是否要 instruction 类注入(工作区指令、技能目录、用法正文)。",
|
|
221
|
+
en: "Whether instruction-class injections apply (workspace instructions, skill catalog, guidance).",
|
|
222
|
+
}),
|
|
223
|
+
),
|
|
224
|
+
runtimeContext: z
|
|
225
|
+
.boolean()
|
|
226
|
+
.default(true)
|
|
227
|
+
.description(
|
|
228
|
+
localized({
|
|
229
|
+
zh: "是否要动态快照(文件沙箱策略、审批策略)。",
|
|
230
|
+
en: "Whether the runtime snapshot applies (sandbox and approval policy).",
|
|
231
|
+
}),
|
|
232
|
+
),
|
|
233
|
+
/**
|
|
234
|
+
* 这个模式的默认模型。不标 `volatile`:`modes` 本身就是 volatile,整棵子树都在页面上——再标一层会被
|
|
235
|
+
* schemastery 拒(`validateVolatileSchema` 不许 volatile 套 volatile)。
|
|
236
|
+
*/
|
|
237
|
+
defaultModel: modelSchema
|
|
238
|
+
// `default(null)` 是"没配就没有这个键":schemastery 对缺省的对象字段会造一个空对象,那样每个模式都会
|
|
239
|
+
// 凭空多出一行;给了 null 反而让它保持缺失(页面按非必填处理,从候选加成)。
|
|
240
|
+
// 类型上放行一次:`null` 在这里只是"没有这个键"的写法,schema 的输入形状不接受它。
|
|
241
|
+
.default(null as unknown as SessionModeModel)
|
|
242
|
+
.description(
|
|
243
|
+
localized({
|
|
244
|
+
zh: "这个模式的默认模型;省略就跟全局默认模型。只在会话还没有模型事实时接管。",
|
|
245
|
+
en: "Default model for this mode; unset follows the global default. Applies only while a session has no model fact yet.",
|
|
246
|
+
}),
|
|
247
|
+
),
|
|
128
248
|
});
|
|
129
249
|
|
|
130
250
|
export const Config: z<Config, ResolvedConfig> = z.object({
|
|
131
|
-
default: z
|
|
132
|
-
|
|
133
|
-
|
|
251
|
+
default: z
|
|
252
|
+
.string()
|
|
253
|
+
.required()
|
|
254
|
+
.description(
|
|
255
|
+
localized({
|
|
256
|
+
zh: "新会话(还没选过模式的会话)用哪个模式;必须是下面 `modes` 里的一个 id。",
|
|
257
|
+
en: "Mode a session starts in before anyone picks one; must be an id in `modes`.",
|
|
258
|
+
}),
|
|
259
|
+
)
|
|
260
|
+
.volatile(),
|
|
261
|
+
modes: z
|
|
262
|
+
.dict(modeSchema)
|
|
263
|
+
.required()
|
|
264
|
+
.description(
|
|
265
|
+
localized({
|
|
266
|
+
zh:
|
|
267
|
+
"模式清单:id → 定义(persona / 允许的工具 / 角色)。改它对**已运行会话**不自动生效——重挂后新建的会话、" +
|
|
268
|
+
"或重新应用模式的会话才用新定义。",
|
|
269
|
+
en:
|
|
270
|
+
"Mode roster: id to definition (persona, allowed tools, role). Edits do not follow into already-running " +
|
|
271
|
+
"sessions; sessions created after the row is remounted use the new definition.",
|
|
272
|
+
}),
|
|
273
|
+
)
|
|
274
|
+
.volatile(),
|
|
275
|
+
/**
|
|
276
|
+
* 退役字段:默认模型住在每个模式自己的 `defaultModel` 里。这里留着是为了**报错**(见 `configProblem`),
|
|
277
|
+
* 页面上不出现(`hidden()`)。
|
|
278
|
+
*/
|
|
279
|
+
models: z.dict(modelSchema).default({}).hidden(),
|
|
134
280
|
});
|
|
135
281
|
|
|
136
|
-
/**
|
|
282
|
+
/** 校验只需要看的那几件事:默认模式、每个模式的工具名单与角色、每个模式自己的默认模型。 */
|
|
137
283
|
interface Validated {
|
|
138
284
|
readonly default: string;
|
|
139
285
|
readonly modes: Readonly<
|
|
140
286
|
Record<
|
|
141
287
|
string,
|
|
142
288
|
{
|
|
289
|
+
/** 空串合法的"不挂";共享合法(差异由会话级收口表达),所以这里不做任何映射唯一性校验。 */
|
|
290
|
+
readonly preset?: string;
|
|
143
291
|
readonly allowTools?: readonly string[];
|
|
144
292
|
readonly role?: readonly string[];
|
|
293
|
+
readonly defaultModel?: { readonly provider?: string; readonly model?: string };
|
|
145
294
|
}
|
|
146
295
|
>
|
|
147
296
|
>;
|
|
148
|
-
/**
|
|
149
|
-
readonly models?: Readonly<Record<string,
|
|
297
|
+
/** 退役的顶层字段:还配着值就报错(它已经不再生效)。 */
|
|
298
|
+
readonly models?: Readonly<Record<string, unknown>>;
|
|
150
299
|
}
|
|
151
300
|
|
|
152
301
|
/** 模式定义里不合法的地方(装配期 fail loud,而不是等到某个会话装配提示词时才发现)。 */
|
|
@@ -169,16 +318,28 @@ export function configProblem(config: Validated): string | undefined {
|
|
|
169
318
|
if (empty.length > 0) {
|
|
170
319
|
return `session-mode: mode(s) ${empty.join(", ")} declare no \`allowTools\`; list the tools instead of leaving it empty`;
|
|
171
320
|
}
|
|
172
|
-
// `
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
321
|
+
// `preset` **允许共享**(本部署两个模式挂同一份 preset,差异由会话级收口表达):共享时 preset → 模式
|
|
322
|
+
// 的反查无从下手,那件事交给 `SessionModes.modeForPreset` 处理(共享时它返回 `undefined`,不反查)。
|
|
323
|
+
// 空串是"不挂",合法;除此之外没有可校验的东西——preset 是否存在由 registry 自己回答。
|
|
324
|
+
// 退役的顶层字段还配着值:它已经不再生效,别让一份"看着像配过"的配置静静地失效。
|
|
325
|
+
if (Object.keys(config.models ?? {}).length > 0) {
|
|
326
|
+
return "session-mode: `models` has moved into each mode's `defaultModel`; move the entries there and drop the top-level `models`";
|
|
176
327
|
}
|
|
177
|
-
|
|
178
|
-
|
|
328
|
+
// 每个模式自己的默认模型:成对给全(`provider` 与 `model` 都要)。
|
|
329
|
+
const partial = Object.entries(config.modes)
|
|
330
|
+
.filter(([, mode]) => {
|
|
331
|
+
const model = mode.defaultModel;
|
|
332
|
+
if (model === undefined) return false;
|
|
333
|
+
return (
|
|
334
|
+
model.provider === undefined ||
|
|
335
|
+
model.model === undefined ||
|
|
336
|
+
model.provider.length === 0 ||
|
|
337
|
+
model.model.length === 0
|
|
338
|
+
);
|
|
339
|
+
})
|
|
179
340
|
.map(([id]) => id);
|
|
180
341
|
if (partial.length > 0) {
|
|
181
|
-
return `session-mode: mode(s) ${partial.join(", ")} declare \`
|
|
342
|
+
return `session-mode: mode(s) ${partial.join(", ")} declare \`defaultModel\` without both \`provider\` and \`model\``;
|
|
182
343
|
}
|
|
183
344
|
return undefined;
|
|
184
345
|
}
|