@aalis/plugin-session-manager 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +20 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1063 -599
- package/dist/index.js.map +1 -1
- package/dist/settings.d.ts +66 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +219 -0
- package/dist/settings.js.map +1 -0
- package/package.json +20 -20
package/dist/index.js
CHANGED
|
@@ -1,123 +1,35 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
1
3
|
import { agent } from '@aalis/api-agent';
|
|
4
|
+
import { authority } from '@aalis/api-authority';
|
|
5
|
+
import { chatScopeOf, parseWindowKey, windowKey } from '@aalis/api-gateway';
|
|
2
6
|
import { hooks } from '@aalis/api-hooks';
|
|
3
|
-
import {
|
|
7
|
+
import { hostConfig } from '@aalis/api-host-config';
|
|
8
|
+
import { lightestThinking, llm, resolveLLMModel } from '@aalis/api-llm';
|
|
4
9
|
import { memory } from '@aalis/api-memory';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { config, definePlugin, events, lifecycle, logger, optional, provide } from '@aalis/core';
|
|
11
|
-
import { defineConfig, parseConfig } from '@aalis/schema-config';
|
|
12
|
-
const configSchema = defineConfig({
|
|
13
|
-
defaults: {
|
|
14
|
-
label: '全局默认配置',
|
|
15
|
-
description: '所有平台共享的最低层默认配置(platform profile 之下的 fallback)。LLM 默认模型由各 agent 插件通过 ServicePreference 锁定,不再在此配置;本节仅保留 persona 等通用默认。',
|
|
16
|
-
fields: {
|
|
17
|
-
persona: {
|
|
18
|
-
type: 'select',
|
|
19
|
-
label: '默认人设',
|
|
20
|
-
dynamicOptions: 'persona',
|
|
21
|
-
allowCustom: true,
|
|
22
|
-
description: '所有平台未单独指定时使用的默认人设',
|
|
23
|
-
},
|
|
24
|
-
},
|
|
25
|
-
},
|
|
26
|
-
platformProfiles: {
|
|
27
|
-
type: 'array',
|
|
28
|
-
label: '平台默认配置',
|
|
29
|
-
default: [],
|
|
30
|
-
description: '为每个平台设置默认的会话配置模板。新会话创建时自动应用对应平台的模板。',
|
|
31
|
-
items: {
|
|
32
|
-
platform: {
|
|
33
|
-
type: 'string',
|
|
34
|
-
label: '平台标识',
|
|
35
|
-
required: true,
|
|
36
|
-
description: '平台名(如 onebot、webui、cli)',
|
|
37
|
-
},
|
|
38
|
-
persona: {
|
|
39
|
-
type: 'select',
|
|
40
|
-
label: '人设文件',
|
|
41
|
-
dynamicOptions: 'persona',
|
|
42
|
-
allowCustom: true,
|
|
43
|
-
description: '该平台默认使用的人设文件名(不含后缀)',
|
|
44
|
-
},
|
|
45
|
-
llm: {
|
|
46
|
-
type: 'llm-ref',
|
|
47
|
-
label: '默认模型',
|
|
48
|
-
description: '该平台新会话的默认 LLM (provider + model)。留空则沿用 ServicePreference 锁定的全局默认。',
|
|
49
|
-
},
|
|
50
|
-
enabledToolGroups: {
|
|
51
|
-
type: 'multiselect',
|
|
52
|
-
label: '工具分组',
|
|
53
|
-
dynamicOptions: 'toolGroups',
|
|
54
|
-
allowCustom: true,
|
|
55
|
-
description: '该平台启用的工具分组',
|
|
56
|
-
},
|
|
57
|
-
disableOutputFormat: {
|
|
58
|
-
type: 'boolean',
|
|
59
|
-
label: '禁用结构化输出',
|
|
60
|
-
description: '禁用 JSON 结构化输出,回复纯文本',
|
|
61
|
-
},
|
|
62
|
-
clientSideJsonRendering: {
|
|
63
|
-
type: 'boolean',
|
|
64
|
-
label: '客户端渲染 JSON',
|
|
65
|
-
description: '保留完整 JSON 给前端渲染,不提取回复字段',
|
|
66
|
-
},
|
|
67
|
-
think: {
|
|
68
|
-
type: 'select',
|
|
69
|
-
label: 'thinking 默认',
|
|
70
|
-
options: [
|
|
71
|
-
{ label: '继承 provider 全局配置', value: '' },
|
|
72
|
-
{ label: '强制开启', value: 'on' },
|
|
73
|
-
{ label: '强制关闭', value: 'off' },
|
|
74
|
-
],
|
|
75
|
-
description: '该平台会话的深度思考默认档。留空=各 LLM provider 自行决定;会话可用 /session.set -t 进一步覆盖。',
|
|
76
|
-
},
|
|
77
|
-
},
|
|
78
|
-
},
|
|
79
|
-
});
|
|
10
|
+
import { claimantsOf, getPlatformAdapters, platform, resolvePlatformBySession, sessionTypesOf, } from '@aalis/api-platform';
|
|
11
|
+
import { rootSessionOf, SESSION_LAYERS, SessionConfigError, sessionKind, sessionManager, } from '@aalis/api-session-manager';
|
|
12
|
+
import { definePlugin, events, lifecycle, logger, optional, provide, services } from '@aalis/core';
|
|
13
|
+
import { truncateChars } from '@aalis/util-text-normalize';
|
|
14
|
+
import { applyPatch, assertName, assertSessionPatch, checkPatch, isLayerName, readLayers, readSetting, SettingRegistry, writeLayers, } from './settings.js';
|
|
80
15
|
// ===== 常量 =====
|
|
81
|
-
|
|
16
|
+
/** 会话表与场所记录在记忆后端元数据里的命名空间 */
|
|
17
|
+
const SESSIONS = 'sessions';
|
|
18
|
+
const PLACES = 'places';
|
|
19
|
+
/** 必填字段不对、没有载入的记录移到 `<命名空间>-rejected`:留在原处会被之后同键的建档整条覆盖 */
|
|
20
|
+
const REJECTED_SUFFIX = '-rejected';
|
|
21
|
+
/** 工作区的场所键前缀;默认工作区的 id 为 default */
|
|
22
|
+
const WORKSPACE_PREFIX = 'workspace:';
|
|
23
|
+
const DEFAULT_WORKSPACE = 'default';
|
|
24
|
+
const DEFAULT_PLACE = Object.freeze({
|
|
25
|
+
kind: 'workspace',
|
|
26
|
+
key: `${WORKSPACE_PREFIX}${DEFAULT_WORKSPACE}`,
|
|
27
|
+
});
|
|
82
28
|
/** 读会话表失败后的重试间隔(毫秒);用尽仍失败按读表失败处理 */
|
|
83
29
|
const LOAD_RETRY_DELAYS = [1000, 3000, 10_000];
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
* WebUI 传来的会话配置补丁:JSON 带不了 undefined,前端用 null 表示「删除该键、恢复继承」。
|
|
87
|
-
* updateSession 是合并语义,键置为 undefined 即从生效配置里消失(resolveConfig 会 strip)。
|
|
88
|
-
*/
|
|
89
|
-
export function normalizeSessionConfigPatch(patch) {
|
|
90
|
-
if (!patch || typeof patch !== 'object' || Array.isArray(patch))
|
|
91
|
-
throw new Error('缺少配置');
|
|
92
|
-
return Object.fromEntries(Object.entries(patch).map(([k, v]) => [k, v === null ? undefined : v]));
|
|
93
|
-
}
|
|
94
|
-
const webuiPages = [
|
|
95
|
-
{
|
|
96
|
-
key: 'sessions',
|
|
97
|
-
label: '会话管理',
|
|
98
|
-
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M8 10h8M8 14h4"/></svg>',
|
|
99
|
-
order: 6,
|
|
100
|
-
renderer: 'sessions',
|
|
101
|
-
},
|
|
102
|
-
];
|
|
30
|
+
/** 最近一轮出错原因最多留多少字 */
|
|
31
|
+
const ERROR_MAX = 200;
|
|
103
32
|
// ===== 辅助函数 =====
|
|
104
|
-
/**
|
|
105
|
-
* 递归归档:先归档所有子会话再归档自己。
|
|
106
|
-
*
|
|
107
|
-
* 写成自由函数而非方法:页面动作是被单独取出调用的(没有 receiver),递归不能依赖 this。
|
|
108
|
-
*/
|
|
109
|
-
async function archiveRecursively(sm, id) {
|
|
110
|
-
const session = sm.getSession(id);
|
|
111
|
-
if (session) {
|
|
112
|
-
for (const childId of session.children) {
|
|
113
|
-
const child = sm.getSession(childId);
|
|
114
|
-
if (child && child.status !== 'archived') {
|
|
115
|
-
await archiveRecursively(sm, childId);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
await sm.updateSession(id, { status: 'archived' });
|
|
120
|
-
}
|
|
121
33
|
/** 等待 ms 毫秒,signal 中止时提前返回。不用 node:timers/promises:全局定时器测试里能用假时钟推进 */
|
|
122
34
|
function wait(ms, signal) {
|
|
123
35
|
return new Promise(resolve => {
|
|
@@ -130,27 +42,23 @@ function wait(ms, signal) {
|
|
|
130
42
|
signal.addEventListener('abort', done, { once: true });
|
|
131
43
|
});
|
|
132
44
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (config.enabledToolGroups?.length)
|
|
138
|
-
parts.push(config.enabledToolGroups.includes('*') ? 'tools:全部' : `tools:${config.enabledToolGroups.length}组`);
|
|
139
|
-
if (config.persona)
|
|
140
|
-
parts.push(`persona:${config.persona}`);
|
|
141
|
-
return parts.join(', ') || '(默认)';
|
|
142
|
-
}
|
|
45
|
+
/** 层表里这一层的称呼,日志里点名层时用 */
|
|
46
|
+
const layerLabel = (name) => SESSION_LAYERS.find(layer => layer.name === name).label;
|
|
47
|
+
/** 全局默认层由服务偏好承担的项:字段类型为 llm-ref(选 llm 服务的提供者)的设置项,读写 llm 服务的偏好 */
|
|
48
|
+
const preferenceBacked = (setting) => setting.field.type === 'llm-ref';
|
|
143
49
|
class SessionManager {
|
|
144
50
|
sessions = new Map();
|
|
51
|
+
/** 场所记录:键 → 记录。与会话表一同加载与落盘 */
|
|
52
|
+
places = new Map();
|
|
145
53
|
caps;
|
|
146
54
|
persistTimer = null;
|
|
147
55
|
dirty = false;
|
|
148
56
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
57
|
+
* 本进程显式删除、尚未落盘的记录(墓碑),按表分开。persist 写全量快照,但只删这些键,不按「后端有、内存无」清扫:
|
|
58
|
+
* 后端里还可能有本进程没读到的记录,按差集清扫会把它们当孤儿删掉。每条墓碑带令牌,提交成功后只清掉
|
|
151
59
|
* 提交时那一版,提交期间再删的保留到下一次。换后端时墓碑随旧表一起作废。
|
|
152
60
|
*/
|
|
153
|
-
deleted = new Map();
|
|
61
|
+
deleted = { [SESSIONS]: new Map(), [PLACES]: new Map() };
|
|
154
62
|
/**
|
|
155
63
|
* 会话表所在的 memory 实例:跟随 memory 胜者。换人时先把旧表的未落盘变更写回旧实例,再从新实例读表整体替换
|
|
156
64
|
* (换后端即换库,不跨后端合并)。新表加载完成前仍指向旧实例,落盘不会把旧表写进新后端;加载窗口里的改动
|
|
@@ -159,10 +67,77 @@ class SessionManager {
|
|
|
159
67
|
store;
|
|
160
68
|
/** 最近一次挂接的加载;apply 等它完成再对外提供服务 */
|
|
161
69
|
loading = Promise.resolve();
|
|
162
|
-
/**
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
|
|
70
|
+
/** 插件登记的设置项:登记与撤回都发 session:config:changed */
|
|
71
|
+
settings = new SettingRegistry(() => void this.caps.events.use.emit('session:config:changed'));
|
|
72
|
+
/** 已告警过的设置问题:取值无效、文档形状不对。解析每轮都跑,同一处只告警一次 */
|
|
73
|
+
warned = new Set();
|
|
74
|
+
/**
|
|
75
|
+
* 在飞的回合:turnId → 所在会话与这一轮的结局(出错时带原因)。agent:input:before 进出登记与注销,结局由
|
|
76
|
+
* agent:turn:after 记下(消息被拦下的一轮不发它,没有结局)。同一会话按 lane 可以并行多轮(后台任务结束通知、白纸通知
|
|
77
|
+
* 与真人回合),会话在最后一轮结束时才收口;回合之外建档的会话按这里有没有回合定初始状态(见 ensureSession)
|
|
78
|
+
*/
|
|
79
|
+
turns = new Map();
|
|
80
|
+
/** 正在解析所属实例的会话记录:同一条记录不并发解析,后到的调用不等它、直接返回 */
|
|
81
|
+
resolving = new WeakSet();
|
|
82
|
+
lookup = (id) => this.sessions.get(id);
|
|
83
|
+
/** 层表的实现:一层一条,继承链、按层读写与可设的层都遍历它(先后取自 SESSION_LAYERS) */
|
|
84
|
+
layerTable = {
|
|
85
|
+
defaults: {
|
|
86
|
+
select: () => ({ scope: 'defaults' }),
|
|
87
|
+
assert: () => { },
|
|
88
|
+
read: () => this.readDefaults(),
|
|
89
|
+
write: (_layer, values) => this.writeDefaults(values),
|
|
90
|
+
list: () => [{ scope: 'defaults' }],
|
|
91
|
+
describe: () => layerLabel('defaults'),
|
|
92
|
+
context: () => ({}),
|
|
93
|
+
},
|
|
94
|
+
platform: {
|
|
95
|
+
select: ({ platform }) => (platform === undefined ? undefined : { scope: 'platform', platform }),
|
|
96
|
+
assert: layer => assertName('平台名', layer.platform),
|
|
97
|
+
read: layer => this.docLayers().platforms.get(layer.platform),
|
|
98
|
+
write: (layer, values) => this.writeDoc(doc => doc.platforms.set(layer.platform, values)),
|
|
99
|
+
list: () => this.platformNames().map(platform => ({ scope: 'platform', platform })),
|
|
100
|
+
describe: layer => `${layerLabel('platform')} ${layer.platform}`,
|
|
101
|
+
context: ({ platform }) => ({ platform }),
|
|
102
|
+
},
|
|
103
|
+
type: {
|
|
104
|
+
select: ({ platform, type }) => platform === undefined || type === undefined ? undefined : { scope: 'type', platform, type },
|
|
105
|
+
assert: layer => {
|
|
106
|
+
assertName('平台名', layer.platform);
|
|
107
|
+
assertName('类型名', layer.type);
|
|
108
|
+
},
|
|
109
|
+
read: layer => this.docLayers().types.get(layer.platform)?.get(layer.type),
|
|
110
|
+
write: (layer, values) => this.writeDoc(doc => doc.types.set(layer.platform, new Map(doc.types.get(layer.platform)).set(layer.type, values))),
|
|
111
|
+
list: () => this.platformNames().flatMap(platform => this.typeNames(platform).map(type => ({ scope: 'type', platform, type }))),
|
|
112
|
+
describe: layer => `${layerLabel('type')} ${layer.platform} · ${layer.type}`,
|
|
113
|
+
context: ({ platform, type }) => ({ platform, type }),
|
|
114
|
+
},
|
|
115
|
+
place: {
|
|
116
|
+
select: ({ place }) => place && { scope: 'place', key: place.key },
|
|
117
|
+
assert: layer => {
|
|
118
|
+
if (typeof layer.key !== 'string' || !this.places.has(layer.key)) {
|
|
119
|
+
throw new SessionConfigError(`场所不存在:${String(layer.key)}`);
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
read: layer => this.places.get(layer.key)?.config,
|
|
123
|
+
write: async (layer, values) => {
|
|
124
|
+
const record = this.places.get(layer.key);
|
|
125
|
+
record.config = values;
|
|
126
|
+
record.updatedAt = Date.now();
|
|
127
|
+
this.markDirty();
|
|
128
|
+
},
|
|
129
|
+
list: () => [...this.places.keys()]
|
|
130
|
+
.sort()
|
|
131
|
+
.sort(defaultFirst)
|
|
132
|
+
.map(key => ({ scope: 'place', key })),
|
|
133
|
+
describe: layer => `${layerLabel('place')} ${layer.key}`,
|
|
134
|
+
context: layer => {
|
|
135
|
+
const { kind, key } = this.places.get(layer.key);
|
|
136
|
+
const window = kind === 'window' ? parseWindowKey(key) : undefined;
|
|
137
|
+
return { platform: window?.platform, type: window?.type, place: { kind, key } };
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
};
|
|
166
141
|
constructor(caps) {
|
|
167
142
|
this.caps = caps;
|
|
168
143
|
}
|
|
@@ -171,18 +146,20 @@ class SessionManager {
|
|
|
171
146
|
* 会话表另经 {@link store} 跟随,见 {@link attach}。
|
|
172
147
|
*/
|
|
173
148
|
get memory() {
|
|
174
|
-
|
|
175
|
-
if (!m)
|
|
149
|
+
if (!this.caps.memory.present)
|
|
176
150
|
throw new Error('session-manager 需要 memory 服务');
|
|
177
|
-
return
|
|
151
|
+
return this.caps.memory.use;
|
|
178
152
|
}
|
|
179
153
|
/**
|
|
180
|
-
* memory 胜者的 follow
|
|
154
|
+
* memory 胜者的 follow 挂接:从这个实例读会话表与场所记录,整体替换内存里的这份。
|
|
181
155
|
* 返回的清理在换人或关闭时执行:中止读表重试的等待,等本次加载落定,再把未落盘的变更写回这个实例。
|
|
182
156
|
*/
|
|
183
157
|
attach(instance) {
|
|
184
158
|
const detached = new AbortController();
|
|
185
|
-
|
|
159
|
+
// 挂上的句柄只活到这次挂载的清理落定;之后的落盘(换人后的加载窗口、停机时的收尾)按条目 id 打到这一家
|
|
160
|
+
const id = this.caps.memory.list().find(p => p.selected)?.id;
|
|
161
|
+
const target = id === undefined ? instance : this.caps.memory.of(id);
|
|
162
|
+
this.loading = this.load(instance, target, AbortSignal.any([detached.signal, this.caps.lifecycle.use.signal]));
|
|
186
163
|
return async () => {
|
|
187
164
|
detached.abort();
|
|
188
165
|
await this.loading;
|
|
@@ -190,52 +167,96 @@ class SessionManager {
|
|
|
190
167
|
clearTimeout(this.persistTimer);
|
|
191
168
|
this.persistTimer = null;
|
|
192
169
|
}
|
|
193
|
-
|
|
170
|
+
// 这一家可能已经撤回:清理期间经挂上的句柄落盘
|
|
171
|
+
await this.persist(instance).catch(err => this.caps.logger.use.warn('换后端前落盘会话失败:', err));
|
|
194
172
|
};
|
|
195
173
|
}
|
|
196
174
|
/**
|
|
197
|
-
* 从 memory
|
|
198
|
-
*
|
|
175
|
+
* 从 memory 元数据加载会话表与场所记录,加载完成后以它们为准、以这个实例为落盘目标。读表失败按 {@link LOAD_RETRY_DELAYS}
|
|
176
|
+
* 有限重试,其间仍用原来的表与落盘目标;用尽或被中止(换人、停机)时两张表为空且不落盘。默认工作区的记录不在时补上
|
|
177
|
+
* (随下一次落盘写进后端)。
|
|
199
178
|
*/
|
|
200
|
-
async load(instance, signal) {
|
|
201
|
-
let
|
|
179
|
+
async load(instance, target, signal) {
|
|
180
|
+
let loaded;
|
|
202
181
|
for (let attempt = 0;; attempt++) {
|
|
203
182
|
try {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
183
|
+
loaded = {
|
|
184
|
+
sessions: await this.readTable(instance, SESSIONS, '会话记录', (key, data) => {
|
|
185
|
+
const read = storedSession(key, data);
|
|
186
|
+
return 'wrong' in read
|
|
187
|
+
? read
|
|
188
|
+
: { record: { ...read.record, ...describeSession(read.record) }, dropped: read.dropped };
|
|
189
|
+
}),
|
|
190
|
+
places: await this.readTable(instance, PLACES, '场所记录', storedPlace),
|
|
191
|
+
};
|
|
211
192
|
break;
|
|
212
193
|
}
|
|
213
194
|
catch (err) {
|
|
214
195
|
const delay = LOAD_RETRY_DELAYS[attempt];
|
|
215
196
|
if (delay !== undefined && !signal.aborted) {
|
|
216
|
-
this.caps.logger.warn(`加载会话数据失败,${delay}ms 后重试:`, err);
|
|
197
|
+
this.caps.logger.use.warn(`加载会话数据失败,${delay}ms 后重试:`, err);
|
|
217
198
|
await wait(delay, signal);
|
|
218
199
|
if (!signal.aborted)
|
|
219
200
|
continue;
|
|
220
201
|
}
|
|
221
202
|
if (signal.aborted)
|
|
222
|
-
this.caps.logger.info('已中止加载会话数据(停用、停机或换后端)');
|
|
203
|
+
this.caps.logger.use.info('已中止加载会话数据(停用、停机或换后端)');
|
|
223
204
|
else
|
|
224
|
-
this.caps.logger.error('加载会话数据失败,在这个后端上的会话改动不落盘(以免覆盖原有记录):', err);
|
|
205
|
+
this.caps.logger.use.error('加载会话数据失败,在这个后端上的会话改动不落盘(以免覆盖原有记录):', err);
|
|
225
206
|
break;
|
|
226
207
|
}
|
|
227
208
|
}
|
|
228
|
-
//
|
|
209
|
+
// 加载窗口里旧表的改动:落盘目标此前仍是旧的那一家,换表前取快照,换表后写回
|
|
229
210
|
const previous = this.store;
|
|
230
211
|
const pending = previous && this.dirty ? this.snapshot().ops : undefined;
|
|
231
|
-
this.sessions = sessions ?? new Map();
|
|
232
|
-
this.
|
|
212
|
+
this.sessions = loaded?.sessions ?? new Map();
|
|
213
|
+
this.places = loaded?.places ?? new Map();
|
|
214
|
+
if (!this.places.has(DEFAULT_PLACE.key))
|
|
215
|
+
this.places.set(DEFAULT_PLACE.key, newPlace(DEFAULT_PLACE, '默认工作区'));
|
|
216
|
+
this.deleted = { [SESSIONS]: new Map(), [PLACES]: new Map() };
|
|
233
217
|
this.dirty = false;
|
|
234
|
-
this.store =
|
|
235
|
-
if (
|
|
236
|
-
this.caps.logger.info(`已加载 ${sessions.size}
|
|
237
|
-
if (previous && pending)
|
|
238
|
-
|
|
218
|
+
this.store = loaded ? target : undefined;
|
|
219
|
+
if (loaded)
|
|
220
|
+
this.caps.logger.use.info(`已加载 ${loaded.sessions.size} 个会话、${loaded.places.size} 个场所`);
|
|
221
|
+
if (previous && pending) {
|
|
222
|
+
try {
|
|
223
|
+
await previous.commitMetadata(pending);
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
this.caps.logger.use.warn('换后端时写回旧后端失败:', err);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* 读一张表:必填字段不对的记录不载入,移到 `<命名空间>-rejected`(先写副本再删原处:后端不保证整批原子时,停在半途也
|
|
232
|
+
* 不丢记录);可选字段类型不对的去掉这些字段照常载入。两种都告警
|
|
233
|
+
*/
|
|
234
|
+
async readTable(instance, namespace, label, parse) {
|
|
235
|
+
const rows = new Map();
|
|
236
|
+
const dropped = [];
|
|
237
|
+
const rejected = [];
|
|
238
|
+
for (const { key, data } of await instance.listMetadata(namespace)) {
|
|
239
|
+
const read = parse(key, data);
|
|
240
|
+
if ('wrong' in read) {
|
|
241
|
+
rejected.push({ key, data, field: read.wrong });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (read.dropped.length > 0)
|
|
245
|
+
dropped.push(`${key}(${read.dropped.join('、')})`);
|
|
246
|
+
rows.set(key, read.record);
|
|
247
|
+
}
|
|
248
|
+
const aside = `${namespace}${REJECTED_SUFFIX}`;
|
|
249
|
+
if (rejected.length > 0) {
|
|
250
|
+
await instance.commitMetadata(rejected.flatMap(({ key, data }) => [
|
|
251
|
+
{ op: 'put', namespace: aside, key, data },
|
|
252
|
+
{ op: 'del', namespace, key },
|
|
253
|
+
]));
|
|
254
|
+
this.caps.logger.use.warn(`${label}的必填字段不对,没有载入,原记录已移到 metadata 命名空间 ${aside}:` +
|
|
255
|
+
rejected.map(({ key, field }) => `${key}(${field})`).join('、'));
|
|
256
|
+
}
|
|
257
|
+
if (dropped.length > 0)
|
|
258
|
+
this.caps.logger.use.warn(`${label}有字段类型不对,去掉这些字段后照常载入:${dropped.join('、')}`);
|
|
259
|
+
return rows;
|
|
239
260
|
}
|
|
240
261
|
/** 标记需要持久化并延迟刷盘 */
|
|
241
262
|
markDirty() {
|
|
@@ -243,7 +264,7 @@ class SessionManager {
|
|
|
243
264
|
if (!this.persistTimer) {
|
|
244
265
|
this.persistTimer = setTimeout(() => {
|
|
245
266
|
this.persistTimer = null;
|
|
246
|
-
this.persist().catch(err => this.caps.logger.warn('持久化会话失败:', err));
|
|
267
|
+
this.persist().catch(err => this.caps.logger.use.warn('持久化会话失败:', err));
|
|
247
268
|
}, 1000);
|
|
248
269
|
}
|
|
249
270
|
}
|
|
@@ -258,50 +279,112 @@ class SessionManager {
|
|
|
258
279
|
* 而是**幂等 + 可重试**:每次写的是全量快照(不是增量),删除只针对墓碑;失败时 dirty 复位、
|
|
259
280
|
* 墓碑保留,下一次 markDirty 会把完整状态重写一遍,前一次的半成品被整体覆盖。
|
|
260
281
|
*/
|
|
261
|
-
async persist() {
|
|
262
|
-
|
|
263
|
-
if (!this.dirty || !target)
|
|
282
|
+
async persist(target = this.store) {
|
|
283
|
+
if (!this.dirty || !this.store || !target)
|
|
264
284
|
return;
|
|
265
285
|
this.dirty = false;
|
|
266
286
|
const { ops, tombstones } = this.snapshot();
|
|
267
287
|
try {
|
|
268
288
|
// 写入目标取落盘开始时的会话表所在实例:换人窗口里当前胜者可能已是新后端,旧表不能写过去
|
|
269
289
|
await target.commitMetadata(ops);
|
|
270
|
-
for (const [key, token] of tombstones)
|
|
271
|
-
if (this.deleted.get(key) === token)
|
|
272
|
-
this.deleted.delete(key);
|
|
290
|
+
for (const [table, key, token] of tombstones) {
|
|
291
|
+
if (this.deleted[table].get(key) === token)
|
|
292
|
+
this.deleted[table].delete(key);
|
|
293
|
+
}
|
|
273
294
|
}
|
|
274
295
|
catch (err) {
|
|
275
296
|
this.dirty = true; // 失败要能重试,否则这批变更永远落不了盘
|
|
276
297
|
throw err;
|
|
277
298
|
}
|
|
278
299
|
}
|
|
279
|
-
/**
|
|
300
|
+
/** 两张表的全量快照:每条记录一条 put,墓碑各一条 del */
|
|
280
301
|
snapshot() {
|
|
281
|
-
const ops = [
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
302
|
+
const ops = [];
|
|
303
|
+
const tombstones = [];
|
|
304
|
+
for (const [namespace, rows] of [
|
|
305
|
+
[SESSIONS, this.sessions],
|
|
306
|
+
[PLACES, this.places],
|
|
307
|
+
]) {
|
|
308
|
+
for (const [key, data] of rows) {
|
|
309
|
+
ops.push({ op: 'put', namespace, key, data: data });
|
|
310
|
+
}
|
|
311
|
+
for (const [key, token] of this.deleted[namespace]) {
|
|
312
|
+
tombstones.push([namespace, key, token]);
|
|
313
|
+
if (!rows.has(key))
|
|
314
|
+
ops.push({ op: 'del', namespace, key });
|
|
315
|
+
}
|
|
291
316
|
}
|
|
292
317
|
return { ops, tombstones };
|
|
293
318
|
}
|
|
294
319
|
/**
|
|
295
|
-
* 按精确 id 幂等 upsert:命中走 updateSession(合并 config + emit `session:updated
|
|
296
|
-
*
|
|
297
|
-
*
|
|
320
|
+
* 按精确 id 幂等 upsert:命中走 updateSession(合并 config + emit `session:updated`),未命中以传入 id 建档。
|
|
321
|
+
* 创建来源在建档时写;命中按 id 兜底建档留下的空记录(没有创建来源、出生平台与绑定)时补写,随这次更新落盘。
|
|
322
|
+
* 终端配置的会话 id、/session.set 落配置覆盖时用
|
|
323
|
+
*/
|
|
324
|
+
async ensureSession(id, patch = {}, creation) {
|
|
325
|
+
const session = this.sessions.get(id);
|
|
326
|
+
if (!session)
|
|
327
|
+
return this.insert(id, patch, creation ? { createdFrom: creation.createdFrom } : {});
|
|
328
|
+
if (creation && !session.createdFrom && !session.originPlatform && !session.work) {
|
|
329
|
+
session.createdFrom = creation.createdFrom;
|
|
330
|
+
}
|
|
331
|
+
return this.updateSession(id, patch);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* IM 房间收录:按原生入站记下的出生平台与聊天窗口建档,名字取出生平台给的群名或昵称(没有时为 id)。只给会话表里
|
|
335
|
+
* 还没有的 id 同步调用。场所是窗口的同时确保窗口的场所记录:insert 在第一次等待之前已同步占住 id、记下来源字段
|
|
298
336
|
*/
|
|
299
|
-
async
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
337
|
+
async recordRoom(id, nativeName, origin) {
|
|
338
|
+
const [created] = await Promise.all([
|
|
339
|
+
this.insert(id, { name: nativeName || id, status: 'waiting' }, origin),
|
|
340
|
+
this.noteWindow(id, nativeName),
|
|
341
|
+
]);
|
|
342
|
+
return created;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* IM 房间收录的补记:按 id 兜底建档的根会话(没有来源字段、创建来源与绑定)与升级前只记了出生平台的记录(没有聊天窗口,
|
|
346
|
+
* 出生平台与消息平台相同)收到原生入站时,记下与按这条消息建档时一样的来源字段,名字还是 id 的一并补上,场所是窗口的
|
|
347
|
+
* 确保窗口的场所记录。只给表里这条记录同步调用,由收录监听判定这个 id 归消息平台的适配器管
|
|
348
|
+
*/
|
|
349
|
+
async backfillRoom(session, nativeName, origin) {
|
|
350
|
+
Object.assign(session, origin, session.name === session.id && nativeName ? { name: nativeName } : {});
|
|
351
|
+
Object.assign(session, describeSession(session));
|
|
352
|
+
await this.noteWindow(session.id, nativeName);
|
|
353
|
+
this.markDirty();
|
|
354
|
+
await this.caps.events.use.emit('session:updated', session);
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* 原生入站之后的窗口场所记录:会话的场所是窗口时,没有记录就建(显示名取出生平台给的名字,没有时为场所键),有记录而
|
|
358
|
+
* 名字变了就跟着改。场所是工作区的(owner 的 IM 私聊)不建。建了或改了名,给场所是这个窗口的根会话各发一次
|
|
359
|
+
* session:updated:会话的摘要在生成时取场所的显示名,不说话的群改了名也要刷新
|
|
360
|
+
*/
|
|
361
|
+
async noteWindow(sessionId, nativeName) {
|
|
362
|
+
const place = this.placeOf(sessionId);
|
|
363
|
+
if (place.kind !== 'window')
|
|
364
|
+
return;
|
|
365
|
+
const record = this.places.get(place.key);
|
|
366
|
+
if (!record) {
|
|
367
|
+
this.places.set(place.key, newPlace(place, nativeName || place.key));
|
|
304
368
|
}
|
|
369
|
+
else if (nativeName && record.name !== nativeName) {
|
|
370
|
+
record.name = nativeName;
|
|
371
|
+
record.updatedAt = Date.now();
|
|
372
|
+
}
|
|
373
|
+
else
|
|
374
|
+
return;
|
|
375
|
+
this.markDirty();
|
|
376
|
+
for (const session of [...this.sessions.values()]) {
|
|
377
|
+
if (!session.parentId && this.placeOf(session.id).key === place.key) {
|
|
378
|
+
await this.caps.events.use.emit('session:updated', session);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* 以传入 id 建档(emit `session:created`),fields 是只在建档时写的字段。状态缺省按有没有回合在跑:有为 active
|
|
384
|
+
* (回合结束时收口),没有为 waiting(指令回复等回合之外的出站不改状态,建成 active 就会一直停在进行中)
|
|
385
|
+
*/
|
|
386
|
+
async insert(id, patch, fields) {
|
|
387
|
+
const config = this.checkedConfig(patch.config);
|
|
305
388
|
const now = Date.now();
|
|
306
389
|
const session = {
|
|
307
390
|
id,
|
|
@@ -310,25 +393,53 @@ class SessionManager {
|
|
|
310
393
|
title: patch.metadata?.title,
|
|
311
394
|
parentId: undefined,
|
|
312
395
|
children: [],
|
|
313
|
-
status: patch.status || 'active',
|
|
314
|
-
config
|
|
396
|
+
status: patch.status || (this.hasTurnInFlight(id) ? 'active' : 'waiting'),
|
|
397
|
+
config,
|
|
315
398
|
createdAt: now,
|
|
316
399
|
updatedAt: now,
|
|
317
|
-
createdBy: patch.createdBy || 'user',
|
|
318
400
|
inputContext: patch.metadata?.inputContext,
|
|
319
401
|
metadata: patch.metadata,
|
|
402
|
+
...fields,
|
|
403
|
+
...describeSession(fields),
|
|
320
404
|
};
|
|
321
405
|
this.sessions.set(id, session);
|
|
322
406
|
this.markDirty();
|
|
323
|
-
await this.caps.events.emit('session:created', session);
|
|
324
|
-
this.caps.logger.info(`会话建档(ensure): ${session.name} (${id})`);
|
|
407
|
+
await this.caps.events.use.emit('session:created', session);
|
|
408
|
+
this.caps.logger.use.info(`会话建档(ensure): ${session.name} (${id})`);
|
|
325
409
|
return session;
|
|
326
410
|
}
|
|
411
|
+
/**
|
|
412
|
+
* 有出生平台的房间还没记下接管它的适配器实例时,解析并记下,会话列表按它分组;已记下的不改,子任务与 owner 面
|
|
413
|
+
* 会话不记。收录与补记时实例随来源字段一起记下(收录之前已解析);收录时没有适配器认领(适配器没装好、连接还没上来、
|
|
414
|
+
* 账号暂时不属于任何实例)的房间与读表读进来的旧房间,由之后的真人入站调它补记。解析期间会话被删除或换表的不再写回,
|
|
415
|
+
* 同一条记录的解析不并发。不会拒绝。
|
|
416
|
+
*/
|
|
417
|
+
async recordOriginInstance(session) {
|
|
418
|
+
if (session.kind !== 'room' || session.originInstance || this.resolving.has(session))
|
|
419
|
+
return;
|
|
420
|
+
this.resolving.add(session);
|
|
421
|
+
try {
|
|
422
|
+
const entry = await resolvePlatformBySession(this.caps.platform, session.id, this.caps.logger.use);
|
|
423
|
+
if (!entry || this.sessions.get(session.id) !== session)
|
|
424
|
+
return;
|
|
425
|
+
session.originInstance = entry.id;
|
|
426
|
+
this.markDirty();
|
|
427
|
+
await this.caps.events.use.emit('session:updated', session);
|
|
428
|
+
}
|
|
429
|
+
finally {
|
|
430
|
+
this.resolving.delete(session);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
327
433
|
// ---- CRUD ----
|
|
328
|
-
async createSession(opts) {
|
|
434
|
+
async createSession(opts, creation) {
|
|
435
|
+
if (creation && opts?.parentId)
|
|
436
|
+
throw new Error('建档专用参数只给根会话:子会话按根会话取创建来源、所属工作区与绑定');
|
|
437
|
+
const created = creation ? this.creationFields(creation) : {};
|
|
438
|
+
const config = this.checkedConfig(opts?.config);
|
|
329
439
|
const id = opts?.parentId
|
|
330
440
|
? `${opts.parentId}::${crypto.randomUUID().slice(0, 8)}`
|
|
331
441
|
: `session-${crypto.randomUUID().slice(0, 8)}`;
|
|
442
|
+
const parent = opts?.parentId ? this.sessions.get(opts.parentId) : undefined;
|
|
332
443
|
const now = Date.now();
|
|
333
444
|
const session = {
|
|
334
445
|
id,
|
|
@@ -338,27 +449,47 @@ class SessionManager {
|
|
|
338
449
|
parentId: opts?.parentId,
|
|
339
450
|
children: [],
|
|
340
451
|
status: opts?.status || 'active',
|
|
341
|
-
config
|
|
452
|
+
config,
|
|
342
453
|
createdAt: now,
|
|
343
454
|
updatedAt: now,
|
|
344
|
-
createdBy: opts?.createdBy || 'user',
|
|
345
455
|
inputContext: opts?.inputContext ?? opts?.metadata?.inputContext,
|
|
346
456
|
metadata: opts?.metadata,
|
|
457
|
+
...created,
|
|
458
|
+
// 子会话的当前目录取父会话此刻的值,之后各改各的
|
|
459
|
+
...(parent?.currentDir === undefined ? {} : { currentDir: parent.currentDir }),
|
|
460
|
+
...describeSession({ parentId: opts?.parentId }),
|
|
347
461
|
};
|
|
348
462
|
this.sessions.set(id, session);
|
|
349
|
-
//
|
|
350
|
-
if (
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
parent.children.push(id);
|
|
354
|
-
parent.updatedAt = now;
|
|
355
|
-
}
|
|
463
|
+
// 挂到父会话的 children 下:父会话的子会话列表变了,建档之后再给它发一次 session:updated
|
|
464
|
+
if (parent) {
|
|
465
|
+
parent.children.push(id);
|
|
466
|
+
parent.updatedAt = now;
|
|
356
467
|
}
|
|
357
468
|
this.markDirty();
|
|
358
|
-
await this.caps.events.emit('session:created', session);
|
|
359
|
-
|
|
469
|
+
await this.caps.events.use.emit('session:created', session);
|
|
470
|
+
if (parent)
|
|
471
|
+
await this.caps.events.use.emit('session:updated', parent);
|
|
472
|
+
this.caps.logger.use.info(`会话创建: ${session.name} (${id})`);
|
|
360
473
|
return session;
|
|
361
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* 建档专用参数落成会话字段:工作区要有记录,项目工作区(有目录的)要带绑定,没有目录的不收绑定;默认工作区不记
|
|
477
|
+
* (缺省即默认工作区)。建档不写当前目录:没有值即场所的主目录
|
|
478
|
+
*/
|
|
479
|
+
creationFields({ createdFrom, workspace = DEFAULT_WORKSPACE, work, }) {
|
|
480
|
+
const record = this.places.get(`${WORKSPACE_PREFIX}${workspace}`);
|
|
481
|
+
if (!record)
|
|
482
|
+
throw new Error(`工作区不存在:${workspace}`);
|
|
483
|
+
if (record.root !== undefined && !work)
|
|
484
|
+
throw new Error(`在项目工作区里建会话要带绑定目录:${workspace}`);
|
|
485
|
+
if (record.root === undefined && work)
|
|
486
|
+
throw new Error(`绑定目录只给项目工作区里的会话:${workspace}`);
|
|
487
|
+
return {
|
|
488
|
+
createdFrom,
|
|
489
|
+
...(workspace === DEFAULT_WORKSPACE ? {} : { workspace }),
|
|
490
|
+
...(work ? { work: { root: work.root, extraRoots: [], worktrees: [] } } : {}),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
362
493
|
getSession(id) {
|
|
363
494
|
return this.sessions.get(id);
|
|
364
495
|
}
|
|
@@ -383,19 +514,21 @@ class SessionManager {
|
|
|
383
514
|
const session = this.sessions.get(id);
|
|
384
515
|
if (!session)
|
|
385
516
|
throw new Error(`会话不存在: ${id}`);
|
|
517
|
+
// 先校验再动记录:配置被拒时什么都不改
|
|
518
|
+
const config = updates.config && this.checkedPatch(updates.config);
|
|
386
519
|
if (updates.name !== undefined)
|
|
387
520
|
session.name = updates.name;
|
|
388
521
|
if (updates.status !== undefined)
|
|
389
522
|
session.status = updates.status;
|
|
390
523
|
if (updates.metadata !== undefined)
|
|
391
524
|
session.metadata = { ...session.metadata, ...updates.metadata };
|
|
392
|
-
if (
|
|
393
|
-
|
|
394
|
-
session.config = { ...session.config, ...updates.config };
|
|
395
|
-
}
|
|
525
|
+
if (config)
|
|
526
|
+
session.config = applyPatch(session.config, config);
|
|
396
527
|
session.updatedAt = Date.now();
|
|
397
528
|
this.markDirty();
|
|
398
|
-
await this.caps.events.emit('session:updated', session);
|
|
529
|
+
await this.caps.events.use.emit('session:updated', session);
|
|
530
|
+
if (config)
|
|
531
|
+
await this.caps.events.use.emit('session:config:changed');
|
|
399
532
|
return session;
|
|
400
533
|
}
|
|
401
534
|
async deleteSession(id) {
|
|
@@ -406,20 +539,20 @@ class SessionManager {
|
|
|
406
539
|
for (const childId of [...session.children]) {
|
|
407
540
|
await this.deleteSession(childId);
|
|
408
541
|
}
|
|
409
|
-
// 从父会话的 children
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
parent.updatedAt = Date.now();
|
|
415
|
-
}
|
|
542
|
+
// 从父会话的 children 中移除:父会话的子会话列表变了,删除之后再给它发一次 session:updated
|
|
543
|
+
const parent = session.parentId ? this.sessions.get(session.parentId) : undefined;
|
|
544
|
+
if (parent) {
|
|
545
|
+
parent.children = parent.children.filter(c => c !== id);
|
|
546
|
+
parent.updatedAt = Date.now();
|
|
416
547
|
}
|
|
417
548
|
this.sessions.delete(id);
|
|
418
|
-
this.deleted.set(id, Symbol(id));
|
|
549
|
+
this.deleted[SESSIONS].set(id, Symbol(id));
|
|
419
550
|
await this.clearDeletedSessionData(id);
|
|
420
551
|
this.markDirty();
|
|
421
|
-
await this.caps.events.emit('session:deleted', id);
|
|
422
|
-
|
|
552
|
+
await this.caps.events.use.emit('session:deleted', id);
|
|
553
|
+
if (parent)
|
|
554
|
+
await this.caps.events.use.emit('session:updated', parent);
|
|
555
|
+
this.caps.logger.use.info(`会话删除: ${session.name} (${id})`);
|
|
423
556
|
}
|
|
424
557
|
async clearDeletedSessionData(id) {
|
|
425
558
|
const clearData = {
|
|
@@ -427,7 +560,7 @@ class SessionManager {
|
|
|
427
560
|
sessionId: id,
|
|
428
561
|
results: [],
|
|
429
562
|
};
|
|
430
|
-
await this.caps.hooks.run('memory:clear', clearData, async () => {
|
|
563
|
+
await this.caps.hooks.use.run('memory:clear', clearData, async () => {
|
|
431
564
|
try {
|
|
432
565
|
await this.memory.clearSession(id);
|
|
433
566
|
clearData.results.push({ source: 'memory', success: true, message: '会话消息历史已清空' });
|
|
@@ -439,40 +572,123 @@ class SessionManager {
|
|
|
439
572
|
});
|
|
440
573
|
const failed = clearData.results.filter(r => !r.success);
|
|
441
574
|
if (failed.length > 0) {
|
|
442
|
-
this.caps.logger.warn(`会话数据清理存在失败项 [${id}]: ${failed.map(r => `${r.source}: ${r.message}`).join('; ')}`);
|
|
575
|
+
this.caps.logger.use.warn(`会话数据清理存在失败项 [${id}]: ${failed.map(r => `${r.source}: ${r.message}`).join('; ')}`);
|
|
443
576
|
}
|
|
444
577
|
}
|
|
445
578
|
// ---- 树形操作 ----
|
|
446
579
|
async createChildSession(parentId, opts) {
|
|
447
|
-
//
|
|
448
|
-
//
|
|
580
|
+
// 带 source 的注入回合(定时任务、编排)可能落在还没建档的会话上:先按 id 兜底建档再挂子会话,否则 create_subtask
|
|
581
|
+
// 在这些会话里必败。兜底建档的会话没有来源字段与创建来源,名字为 id:是房间的由之后的原生入站补记来源字段与名字
|
|
449
582
|
if (!this.sessions.has(parentId))
|
|
450
|
-
await this.ensureSession(parentId);
|
|
451
|
-
return this.createSession({
|
|
452
|
-
...opts,
|
|
453
|
-
parentId,
|
|
454
|
-
createdBy: opts?.createdBy || 'agent',
|
|
455
|
-
});
|
|
583
|
+
await this.ensureSession(parentId, { name: parentId });
|
|
584
|
+
return this.createSession({ ...opts, parentId });
|
|
456
585
|
}
|
|
457
586
|
getChildren(parentId) {
|
|
458
587
|
return this.listSessions({ parentId });
|
|
459
588
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
589
|
+
// ---- 当前目录与项目会话的绑定 ----
|
|
590
|
+
async setCurrentDir(sessionId, dir) {
|
|
591
|
+
const session = this.sessions.get(sessionId);
|
|
592
|
+
if (!session)
|
|
593
|
+
throw new Error(`会话不存在: ${sessionId}`);
|
|
594
|
+
session.currentDir = dir;
|
|
595
|
+
this.markDirty();
|
|
596
|
+
await this.caps.events.use.emit('session:updated', session);
|
|
597
|
+
}
|
|
598
|
+
async setExtraRoots(sessionId, extraRoots) {
|
|
599
|
+
const { root, work } = this.inWorkTree(sessionId);
|
|
600
|
+
work.extraRoots = [...extraRoots];
|
|
601
|
+
this.markDirty();
|
|
602
|
+
await this.caps.events.use.emit('session:updated', root);
|
|
603
|
+
}
|
|
604
|
+
async addWorktree(sessionId, path) {
|
|
605
|
+
const { root, work } = this.inWorkTree(sessionId);
|
|
606
|
+
if (work.worktrees.includes(path))
|
|
607
|
+
return;
|
|
608
|
+
work.worktrees.push(path);
|
|
609
|
+
this.markDirty();
|
|
610
|
+
await this.caps.events.use.emit('session:updated', root);
|
|
611
|
+
}
|
|
612
|
+
/** 项目会话树的根会话与它的绑定;会话不存在或不在项目会话树里时抛错 */
|
|
613
|
+
inWorkTree(sessionId) {
|
|
614
|
+
const session = this.sessions.get(sessionId);
|
|
615
|
+
if (!session)
|
|
616
|
+
throw new Error(`会话不存在: ${sessionId}`);
|
|
617
|
+
const root = rootSessionOf(session, this.lookup);
|
|
618
|
+
if (!root?.work)
|
|
619
|
+
throw new Error(`会话 ${sessionId} 不在项目会话里:根会话没有绑定目录`);
|
|
620
|
+
return { root, work: root.work };
|
|
621
|
+
}
|
|
622
|
+
// ---- 场所与工作区 ----
|
|
623
|
+
placeOf(sessionId) {
|
|
624
|
+
const root = rootSessionOf(this.sessions.get(sessionId), this.lookup);
|
|
625
|
+
if (root?.originPlatform !== undefined) {
|
|
626
|
+
const chat = root.originChat;
|
|
627
|
+
const targetId = chat?.targetId ?? '';
|
|
628
|
+
// owner 的 IM 私聊是默认工作区的入口;没有 authority 时认不出 owner,按窗口算(与房间类别的失败关闭同向)
|
|
629
|
+
const owner = targetId !== '' &&
|
|
630
|
+
!chat?.multiParty &&
|
|
631
|
+
(this.caps.authority.present ? this.caps.authority.use.isOwner(root.originPlatform, targetId) : undefined);
|
|
632
|
+
if (owner)
|
|
633
|
+
return DEFAULT_PLACE;
|
|
634
|
+
const key = windowKey({ platform: root.originPlatform, type: chat?.type ?? '', targetId });
|
|
635
|
+
// 聊天窗口缺失或没有目标的坏数据同样是窗口:落进默认工作区会把 owner 给工作区设的值套到房间上
|
|
636
|
+
if (targetId === '')
|
|
637
|
+
this.warnOnce(`会话 ${root.id} 有出生平台却没有聊天窗口或目标,按窗口 ${key} 算`);
|
|
638
|
+
return { kind: 'window', key };
|
|
473
639
|
}
|
|
474
|
-
|
|
475
|
-
|
|
640
|
+
if (root?.workspace === undefined)
|
|
641
|
+
return DEFAULT_PLACE;
|
|
642
|
+
const key = `${WORKSPACE_PREFIX}${root.workspace}`;
|
|
643
|
+
if (this.places.has(key))
|
|
644
|
+
return { kind: 'workspace', key };
|
|
645
|
+
this.warnOnce(`会话 ${root.id} 记下的工作区 ${root.workspace} 没有记录,按默认工作区`);
|
|
646
|
+
return DEFAULT_PLACE;
|
|
647
|
+
}
|
|
648
|
+
getPlace(key) {
|
|
649
|
+
const record = this.places.get(key);
|
|
650
|
+
return record && placeInfo(record);
|
|
651
|
+
}
|
|
652
|
+
addWorkspace(root) {
|
|
653
|
+
const existing = [...this.places.values()].find(record => record.root === root);
|
|
654
|
+
if (existing)
|
|
655
|
+
return { workspace: workspaceInfo(existing), existed: true };
|
|
656
|
+
const record = newPlace({ kind: 'workspace', key: `${WORKSPACE_PREFIX}${crypto.randomUUID()}` }, basename(root), root);
|
|
657
|
+
this.places.set(record.key, record);
|
|
658
|
+
this.markDirty();
|
|
659
|
+
this.caps.logger.use.info(`登记工作区: ${record.name} (${record.key}) ${root}`);
|
|
660
|
+
return { workspace: workspaceInfo(record), existed: false };
|
|
661
|
+
}
|
|
662
|
+
listWorkspaces() {
|
|
663
|
+
return [...this.places.values()]
|
|
664
|
+
.filter(record => record.kind === 'workspace')
|
|
665
|
+
.sort((a, b) => defaultFirst(a.key, b.key) || a.createdAt - b.createdAt)
|
|
666
|
+
.map(workspaceInfo);
|
|
667
|
+
}
|
|
668
|
+
async removeWorkspace(id) {
|
|
669
|
+
if (id === DEFAULT_WORKSPACE)
|
|
670
|
+
throw new Error('默认工作区不能取消登记');
|
|
671
|
+
const key = `${WORKSPACE_PREFIX}${id}`;
|
|
672
|
+
const record = this.places.get(key);
|
|
673
|
+
if (!record)
|
|
674
|
+
throw new Error(`工作区不存在:${id}`);
|
|
675
|
+
// 先认出原属的会话树(按根会话记下的工作区),再一并清:根会话的所属工作区与绑定,整棵树的当前目录
|
|
676
|
+
const affected = [...this.sessions.values()].filter(s => rootSessionOf(s, this.lookup)?.workspace === id);
|
|
677
|
+
const changed = affected.filter(s => !s.parentId || s.currentDir !== undefined);
|
|
678
|
+
for (const session of affected) {
|
|
679
|
+
delete session.currentDir;
|
|
680
|
+
if (!session.parentId) {
|
|
681
|
+
delete session.workspace;
|
|
682
|
+
delete session.work;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
this.places.delete(key);
|
|
686
|
+
this.deleted[PLACES].set(key, Symbol(key));
|
|
687
|
+
this.markDirty();
|
|
688
|
+
this.caps.logger.use.info(`取消登记工作区: ${record.name} (${key}),${affected.length} 个会话归默认工作区`);
|
|
689
|
+
for (const session of changed)
|
|
690
|
+
await this.caps.events.use.emit('session:updated', session);
|
|
691
|
+
await this.caps.events.use.emit('session:config:changed');
|
|
476
692
|
}
|
|
477
693
|
// ---- 生命周期 ----
|
|
478
694
|
async completeSession(id, result) {
|
|
@@ -484,8 +700,8 @@ class SessionManager {
|
|
|
484
700
|
session.updatedAt = Date.now();
|
|
485
701
|
this.markDirty();
|
|
486
702
|
// 发事件通知(wait_subtasks 通过事件驱动感知完成)
|
|
487
|
-
await this.caps.events.emit('session:completed', session);
|
|
488
|
-
this.caps.logger.info(`会话完成: ${session.name} (${id})${result ? ` - ${result.slice(0, 100)}` : ''}`);
|
|
703
|
+
await this.caps.events.use.emit('session:completed', session);
|
|
704
|
+
this.caps.logger.use.info(`会话完成: ${session.name} (${id})${result ? ` - ${result.slice(0, 100)}` : ''}`);
|
|
489
705
|
}
|
|
490
706
|
// ---- 标题管理 ----
|
|
491
707
|
async generateTitle(sessionId, userMessage) {
|
|
@@ -497,11 +713,11 @@ class SessionManager {
|
|
|
497
713
|
return session.title;
|
|
498
714
|
const entry = resolveLLMModel(this.caps.llm, undefined, ['chat']);
|
|
499
715
|
if (!entry) {
|
|
500
|
-
this.caps.logger.warn(`无可用 LLM,无法生成标题: ${sessionId}`);
|
|
716
|
+
this.caps.logger.use.warn(`无可用 LLM,无法生成标题: ${sessionId}`);
|
|
501
717
|
return undefined;
|
|
502
718
|
}
|
|
503
|
-
this.caps.logger.debug(`标题生成使用 LLM: ${entry.
|
|
504
|
-
const model = entry.
|
|
719
|
+
this.caps.logger.use.debug(`标题生成使用 LLM: ${entry.id}`);
|
|
720
|
+
const model = entry.use;
|
|
505
721
|
// 优先使用直接传入的用户消息;否则从历史获取
|
|
506
722
|
let contextStr;
|
|
507
723
|
if (userMessage?.trim()) {
|
|
@@ -530,29 +746,28 @@ class SessionManager {
|
|
|
530
746
|
{ role: 'user', content: `请为以下对话生成标题:\n\n${contextStr}` },
|
|
531
747
|
],
|
|
532
748
|
temperature: 0.3,
|
|
533
|
-
//
|
|
534
|
-
|
|
535
|
-
think: false,
|
|
749
|
+
// 标题生成无需推理,用模型最浅的思考档:避免 reasoning 占满 token 预算导致 content 为空
|
|
750
|
+
think: lightestThinking(model),
|
|
536
751
|
});
|
|
537
752
|
title = (resp.content || '').trim().slice(0, 50);
|
|
538
753
|
if (!title) {
|
|
539
|
-
this.caps.logger.warn(`会话标题 LLM 返回空内容: ${sessionId} (resp.content=${JSON.stringify(resp.content)}, reasoning=${(resp.reasoningContent ?? '').length}字)`);
|
|
754
|
+
this.caps.logger.use.warn(`会话标题 LLM 返回空内容: ${sessionId} (resp.content=${JSON.stringify(resp.content)}, reasoning=${(resp.reasoningContent ?? '').length}字)`);
|
|
540
755
|
}
|
|
541
756
|
}
|
|
542
757
|
catch (err) {
|
|
543
|
-
this.caps.logger.warn(`自动生成标题失败 [${sessionId}]:`, err);
|
|
758
|
+
this.caps.logger.use.warn(`自动生成标题失败 [${sessionId}]:`, err);
|
|
544
759
|
}
|
|
545
760
|
// 兜底:LLM 失败或返回空时,用用户消息首段作为临时标题,避免会话永远没有标题
|
|
546
761
|
if (!title && userMessage?.trim()) {
|
|
547
762
|
title = userMessage.trim().replace(/\s+/g, ' ').slice(0, 20);
|
|
548
|
-
this.caps.logger.info(`使用用户消息兜底生成标题: ${sessionId} -> ${title}`);
|
|
763
|
+
this.caps.logger.use.info(`使用用户消息兜底生成标题: ${sessionId} -> ${title}`);
|
|
549
764
|
}
|
|
550
765
|
if (title) {
|
|
551
766
|
session.title = title;
|
|
552
767
|
session.updatedAt = Date.now();
|
|
553
768
|
this.markDirty();
|
|
554
|
-
await this.caps.events.emit('session:updated', session);
|
|
555
|
-
this.caps.logger.info(`会话标题已生成: [${sessionId}] ${title}`);
|
|
769
|
+
await this.caps.events.use.emit('session:updated', session);
|
|
770
|
+
this.caps.logger.use.info(`会话标题已生成: [${sessionId}] ${title}`);
|
|
556
771
|
return title;
|
|
557
772
|
}
|
|
558
773
|
return undefined;
|
|
@@ -564,102 +779,370 @@ class SessionManager {
|
|
|
564
779
|
session.title = title;
|
|
565
780
|
session.updatedAt = Date.now();
|
|
566
781
|
this.markDirty();
|
|
567
|
-
await this.caps.events.emit('session:updated', session);
|
|
782
|
+
await this.caps.events.use.emit('session:updated', session);
|
|
783
|
+
}
|
|
784
|
+
// ---- 会话设置 ----
|
|
785
|
+
registerSetting(setting, plugin) {
|
|
786
|
+
return this.settings.register(setting, plugin);
|
|
787
|
+
}
|
|
788
|
+
listSettings() {
|
|
789
|
+
return this.settings.list();
|
|
568
790
|
}
|
|
569
|
-
// ---- 配置解析 ----
|
|
570
791
|
/**
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
* 合并优先级(从高到低):
|
|
574
|
-
* 1. 会话自身 config
|
|
575
|
-
* 2. 父会话的 sessionDefaults
|
|
576
|
-
* 3. 平台 profile
|
|
577
|
-
* 4. 全局 defaults(最低)
|
|
792
|
+
* 解析指定会话的最终生效配置:继承链之上叠会话自己的覆盖。由服务偏好承担的项在全局默认之上各层都没设时不给值:
|
|
793
|
+
* 取用方交给 resolveLLMModel,偏好指向的模型不在时也落得到服务此刻解析到的那一个
|
|
578
794
|
*/
|
|
579
795
|
resolveConfig(sessionId, platform) {
|
|
580
|
-
|
|
581
|
-
const
|
|
582
|
-
const
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
// 移除 sessionDefaults(不传递到消费方)
|
|
588
|
-
delete result.sessionDefaults;
|
|
589
|
-
return result;
|
|
796
|
+
const { values, sources } = this.resolveInheritance(sessionId, platform);
|
|
797
|
+
const inherited = { ...values };
|
|
798
|
+
for (const setting of this.settings.list()) {
|
|
799
|
+
if (preferenceBacked(setting) && sources[setting.key] === 'defaults')
|
|
800
|
+
delete inherited[setting.key];
|
|
801
|
+
}
|
|
802
|
+
return { ...inherited, ...this.getOwnConfig(sessionId) };
|
|
590
803
|
}
|
|
591
804
|
/**
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
* WebUI 「继承 (xxx)」提示应该用这个值,否则会显示用户自己的覆盖值。
|
|
805
|
+
* 继承链解析:不含会话自身 config,按层表的先后叠加,另记下每个键来自哪一层。子会话按根会话取:有出生平台的会话不论从
|
|
806
|
+
* 哪个入口驱动都按出生平台选平台档,按聊天窗口的类型名选平台类型档;传入的入口平台只对没有出生平台的会话起作用
|
|
595
807
|
*/
|
|
596
|
-
|
|
597
|
-
const result = {};
|
|
598
|
-
// 3. 全局 defaults(最低)
|
|
599
|
-
Object.assign(result, stripUndefined(this.defaults));
|
|
600
|
-
// 2. 平台 profile
|
|
601
|
-
if (platform) {
|
|
602
|
-
const profile = this.platformProfiles.get(platform);
|
|
603
|
-
if (profile)
|
|
604
|
-
Object.assign(result, stripUndefined(profile));
|
|
605
|
-
}
|
|
606
|
-
// 1. 父会话 sessionDefaults(最高,覆盖 profile/defaults)
|
|
808
|
+
resolveInheritance(sessionId, platform) {
|
|
607
809
|
const session = this.sessions.get(sessionId);
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
810
|
+
const root = rootSessionOf(session, this.lookup);
|
|
811
|
+
const ctx = {
|
|
812
|
+
platform: root?.originPlatform ?? platform,
|
|
813
|
+
type: root?.originPlatform === undefined ? undefined : root.originChat?.type,
|
|
814
|
+
place: this.placeOf(sessionId),
|
|
815
|
+
parent: session?.parentId ? this.sessions.get(session.parentId) : undefined,
|
|
816
|
+
};
|
|
817
|
+
return { platform: ctx.platform, type: ctx.type, ...this.stack(this.chain(ctx)) };
|
|
818
|
+
}
|
|
819
|
+
getOwnConfig(sessionId) {
|
|
820
|
+
return this.valid(this.sessions.get(sessionId)?.config, `会话 ${sessionId}`, true);
|
|
821
|
+
}
|
|
822
|
+
childConfig(parentId, platform) {
|
|
823
|
+
const config = this.resolveConfig(parentId, platform);
|
|
824
|
+
for (const setting of this.settings.list()) {
|
|
825
|
+
if (setting.liveOnly || setting.narrowest)
|
|
826
|
+
delete config[setting.key];
|
|
827
|
+
}
|
|
828
|
+
return config;
|
|
829
|
+
}
|
|
830
|
+
resolvedDefaults() {
|
|
831
|
+
// 按真正调模型的 agent 插件的解析顺序取(它可能设了本插件偏好);没装 agent 时按本插件的
|
|
832
|
+
const agentId = this.caps.agent.list().find(p => p.selected)?.id;
|
|
833
|
+
const entries = this.caps.llm.list();
|
|
834
|
+
const ordered = agentId === undefined
|
|
835
|
+
? entries
|
|
836
|
+
: this.caps.services.use
|
|
837
|
+
.inspect(llm, { for: agentId })
|
|
838
|
+
.flatMap(info => entries.filter(e => e.id === info.contextId));
|
|
839
|
+
const entry = resolveLLMModel({ list: () => ordered }, undefined, ['chat']);
|
|
840
|
+
if (!entry)
|
|
841
|
+
return {};
|
|
842
|
+
const model = { provider: entry.use.providerId, model: entry.use.id };
|
|
843
|
+
return Object.fromEntries(this.settings.list().flatMap(s => (preferenceBacked(s) ? [[s.key, model]] : [])));
|
|
844
|
+
}
|
|
845
|
+
listLayers() {
|
|
846
|
+
return SESSION_LAYERS.flatMap(({ name }) => Object.hasOwn(this.layerTable, name) ? this.layerTable[name].list() : []);
|
|
847
|
+
}
|
|
848
|
+
getLayer(layer) {
|
|
849
|
+
const impl = this.implOf(layer);
|
|
850
|
+
impl.assert(layer);
|
|
851
|
+
return this.valid(impl.read(layer), impl.describe(layer));
|
|
852
|
+
}
|
|
853
|
+
resolveLayer(layer) {
|
|
854
|
+
const impl = this.implOf(layer);
|
|
855
|
+
impl.assert(layer);
|
|
856
|
+
return this.stack(this.chain(impl.context(layer), layer.scope));
|
|
857
|
+
}
|
|
858
|
+
async updateLayer(layer, patch) {
|
|
859
|
+
const impl = this.implOf(layer);
|
|
860
|
+
impl.assert(layer);
|
|
861
|
+
const checked = checkPatch(this.settings, patch);
|
|
862
|
+
await impl.write(layer, applyPatch(impl.read(layer), checked));
|
|
863
|
+
this.caps.logger.use.info(`会话设置已改:${impl.describe(layer)}(${[...checked.keys()].join('、')})`);
|
|
864
|
+
void this.caps.events.use.emit('session:config:changed');
|
|
865
|
+
}
|
|
866
|
+
/** 这一层的实现;不认识的层抛 {@link SessionConfigError} */
|
|
867
|
+
implOf(layer) {
|
|
868
|
+
const scope = layer?.scope;
|
|
869
|
+
if (typeof scope !== 'string' || !Object.hasOwn(this.layerTable, scope)) {
|
|
870
|
+
throw new SessionConfigError(`会话设置的层不认识:${String(scope)}`);
|
|
871
|
+
}
|
|
872
|
+
return this.layerTable[scope];
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* 按层表的先后取一个会话(或一层的视图)上各层的值,到 until 为止:会话之上的层各按自己的实现选那一份,父会话层是
|
|
876
|
+
* 父会话的 sessionDefaults(旧数据)
|
|
877
|
+
*/
|
|
878
|
+
chain(ctx, until = 'parent') {
|
|
879
|
+
const parts = [];
|
|
880
|
+
for (const { name } of SESSION_LAYERS) {
|
|
881
|
+
if (name === 'session')
|
|
882
|
+
break;
|
|
883
|
+
if (name === 'parent') {
|
|
884
|
+
if (ctx.parent)
|
|
885
|
+
parts.push([ctx.parent.config.sessionDefaults, name, `会话 ${ctx.parent.id} 给子会话的默认值`]);
|
|
612
886
|
}
|
|
887
|
+
else {
|
|
888
|
+
const impl = this.layerTable[name];
|
|
889
|
+
const layer = impl.select(ctx);
|
|
890
|
+
if (layer)
|
|
891
|
+
parts.push([impl.read(layer), name, impl.describe(layer)]);
|
|
892
|
+
}
|
|
893
|
+
if (name === until)
|
|
894
|
+
break;
|
|
895
|
+
}
|
|
896
|
+
return parts;
|
|
897
|
+
}
|
|
898
|
+
/** 按顺序叠加几处设置:后面的覆盖前面的,逐键记下来源 */
|
|
899
|
+
stack(parts) {
|
|
900
|
+
const values = {};
|
|
901
|
+
const sources = {};
|
|
902
|
+
for (const [raw, source, where] of parts) {
|
|
903
|
+
for (const [key, value] of Object.entries(this.valid(raw, where, source === 'parent'))) {
|
|
904
|
+
values[key] = value;
|
|
905
|
+
sources[key] = source;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
return { values, sources };
|
|
909
|
+
}
|
|
910
|
+
/** 宿主配置文档里的三层;没有文档时为空 */
|
|
911
|
+
docLayers() {
|
|
912
|
+
return readLayers(this.caps.hostConfig.present ? this.caps.hostConfig.use.get('sessionConfig') : undefined, message => this.warnOnce(message));
|
|
913
|
+
}
|
|
914
|
+
/** 改宿主配置文档并落盘:change 改文档并返回撤回它的函数。没有文档时抛错;落盘失败时撤回、以拒绝传出 */
|
|
915
|
+
async saveHost(change) {
|
|
916
|
+
if (!this.caps.hostConfig.present)
|
|
917
|
+
throw new Error('宿主没有提供配置文档(host-config),会话设置的各层无处保存');
|
|
918
|
+
const host = this.caps.hostConfig.use;
|
|
919
|
+
const revert = change(host);
|
|
920
|
+
try {
|
|
921
|
+
await host.save();
|
|
922
|
+
}
|
|
923
|
+
catch (err) {
|
|
924
|
+
// 拒写与写入失败都撤回:免得下一次任意保存把这次没存下的修改写进文件
|
|
925
|
+
revert();
|
|
926
|
+
throw err;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
/** 改文档里的三层:在现读的一份上改,整份写回 */
|
|
930
|
+
writeDoc(update) {
|
|
931
|
+
return this.saveHost(host => {
|
|
932
|
+
const previous = host.get('sessionConfig');
|
|
933
|
+
const layers = this.docLayers();
|
|
934
|
+
update(layers);
|
|
935
|
+
host.set('sessionConfig', writeLayers(layers));
|
|
936
|
+
return () => host.set('sessionConfig', previous);
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* 全局默认层:文档里的 defaults,由服务偏好承担的项换成 llm 服务的偏好。文档里写了这些项的告警后忽略(按层写时不再写回)
|
|
941
|
+
*/
|
|
942
|
+
readDefaults() {
|
|
943
|
+
const values = { ...this.docLayers().defaults };
|
|
944
|
+
const preferred = this.preferredModel();
|
|
945
|
+
for (const setting of this.settings.list()) {
|
|
946
|
+
if (!preferenceBacked(setting))
|
|
947
|
+
continue;
|
|
948
|
+
if (setting.key in values) {
|
|
949
|
+
this.warnOnce(`宿主配置 sessionConfig.defaults.${setting.key} 不再生效,已忽略:默认模型只在 llm 服务的偏好里,` +
|
|
950
|
+
'在「会话默认」的全局默认层改');
|
|
951
|
+
}
|
|
952
|
+
delete values[setting.key];
|
|
953
|
+
if (preferred)
|
|
954
|
+
values[setting.key] = preferred;
|
|
955
|
+
}
|
|
956
|
+
return values;
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* llm 服务的偏好写成模型引用:条目在时按它的提供者与模型 id;不在时按还装着的提供者拆(取能作为 `<提供者>/` 前缀的最长的
|
|
960
|
+
* 一个,模型 id 里的斜杠留在模型里),提供者也不在时按最后一个斜杠拆。拆不出、或拆出的提供者只剩 npm scope(偏好只写了
|
|
961
|
+
* 提供者,如 `@scope/llm-x`)的读不成模型引用,偏好没有生效由 doctor 报出
|
|
962
|
+
*/
|
|
963
|
+
preferredModel() {
|
|
964
|
+
const contextId = this.caps.services.use.preferred(llm);
|
|
965
|
+
if (contextId === undefined)
|
|
966
|
+
return undefined;
|
|
967
|
+
const entries = this.caps.llm.list();
|
|
968
|
+
const entry = entries.find(e => e.id === contextId);
|
|
969
|
+
if (entry)
|
|
970
|
+
return { provider: entry.use.providerId, model: entry.use.id };
|
|
971
|
+
const provider = entries
|
|
972
|
+
.map(e => e.use.providerId)
|
|
973
|
+
.filter(id => contextId.startsWith(`${id}/`))
|
|
974
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
975
|
+
const cut = provider === undefined ? contextId.lastIndexOf('/') : provider.length;
|
|
976
|
+
const ref = { provider: contextId.slice(0, cut), model: contextId.slice(cut + 1) };
|
|
977
|
+
return cut > 0 && !/^@[^/]*$/.test(ref.provider) ? ref : undefined;
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* 写全局默认层:由服务偏好承担的项写 llm 服务的偏好(运行态与文档的 servicePreferences,没有值即取消偏好),其余写文档的
|
|
981
|
+
* defaults。这一层读出的是偏好({@link readDefaults}),按读出的值比:只有这一项改了才动偏好,几项都由偏好承担时取改了的
|
|
982
|
+
* 那一项。读不成模型引用的偏好(手写的)读出来没有这一项,改别的项不碰它。落盘失败时偏好与文档一并撤回
|
|
983
|
+
*/
|
|
984
|
+
writeDefaults(values) {
|
|
985
|
+
const backed = this.settings.list().filter(preferenceBacked);
|
|
986
|
+
const preferred = this.preferredModel();
|
|
987
|
+
const changed = backed.find(setting => !isDeepStrictEqual(values[setting.key], preferred));
|
|
988
|
+
const ref = changed && values[changed.key];
|
|
989
|
+
const rest = { ...values };
|
|
990
|
+
for (const { key } of backed)
|
|
991
|
+
delete rest[key];
|
|
992
|
+
return this.saveHost(host => {
|
|
993
|
+
const revertPreference = changed ? this.setPreference(host, ref && `${ref.provider}/${ref.model}`) : () => { };
|
|
994
|
+
const previous = host.get('sessionConfig');
|
|
995
|
+
host.set('sessionConfig', writeLayers({ ...this.docLayers(), defaults: rest }));
|
|
996
|
+
return () => {
|
|
997
|
+
host.set('sessionConfig', previous);
|
|
998
|
+
revertPreference();
|
|
999
|
+
};
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
/** 改 llm 服务的偏好(运行态与文档),返回撤回它的函数 */
|
|
1003
|
+
setPreference(host, contextId) {
|
|
1004
|
+
const runtime = this.caps.services.use.preferred(llm);
|
|
1005
|
+
const documented = host.getServicePreferences()[llm.name];
|
|
1006
|
+
const apply = (live, doc) => {
|
|
1007
|
+
if (live === undefined)
|
|
1008
|
+
this.caps.services.use.unprefer(llm);
|
|
1009
|
+
else
|
|
1010
|
+
this.caps.services.use.prefer(llm, live);
|
|
1011
|
+
if (doc === undefined)
|
|
1012
|
+
host.removeServicePreference(llm.name);
|
|
1013
|
+
else
|
|
1014
|
+
host.setServicePreference(llm.name, doc);
|
|
1015
|
+
};
|
|
1016
|
+
apply(contextId, contextId);
|
|
1017
|
+
return () => apply(runtime, documented);
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* 平台档可设的平台:已装适配器的平台加文档里写过的(两段里的都算),按名字排。名字不合层标识写法的不列(文档里的读时已滤掉):
|
|
1021
|
+
* 列出的层都要能按层读写
|
|
1022
|
+
*/
|
|
1023
|
+
platformNames() {
|
|
1024
|
+
const doc = this.docLayers();
|
|
1025
|
+
const names = new Set([...doc.platforms.keys(), ...doc.types.keys()]);
|
|
1026
|
+
for (const adapter of getPlatformAdapters(this.caps.platform))
|
|
1027
|
+
names.add(adapter.platform);
|
|
1028
|
+
return [...names].filter(isLayerName).sort();
|
|
1029
|
+
}
|
|
1030
|
+
/** 平台类型档可设的类型:适配器声明的类型加文档里写过的,按名字排;没声明类型的适配器不单列,名字不合写法的不列 */
|
|
1031
|
+
typeNames(platform) {
|
|
1032
|
+
const names = new Set(this.docLayers().types.get(platform)?.keys());
|
|
1033
|
+
for (const adapter of getPlatformAdapters(this.caps.platform)) {
|
|
1034
|
+
if (adapter.platform === platform)
|
|
1035
|
+
for (const type of adapter.sessionTypes ?? [])
|
|
1036
|
+
names.add(type.name);
|
|
613
1037
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
loadPlatformProfiles(profiles) {
|
|
639
|
-
for (const entry of profiles) {
|
|
640
|
-
const profile = {};
|
|
641
|
-
if (entry.persona)
|
|
642
|
-
profile.persona = entry.persona;
|
|
643
|
-
if (entry.llm && typeof entry.llm === 'object' && entry.llm.provider && entry.llm.model) {
|
|
644
|
-
profile.llm = { provider: String(entry.llm.provider), model: String(entry.llm.model) };
|
|
1038
|
+
return [...names].filter(isLayerName).sort();
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* 一处设置里已登记的项:没登记的不参与解析;取值不合登记字段的当作没设置,字段标了 onInvalid: 'error' 的原样交出、
|
|
1042
|
+
* 由登记它的插件按拒绝处理。会话上的设置(会话自己的覆盖、父会话给子会话的默认值)里只能设到场所为止的项不参与解析:
|
|
1043
|
+
* 写入不收它,旧数据里留着的不能压过场所。这两种问题同一处同一项只告警一次
|
|
1044
|
+
*/
|
|
1045
|
+
valid(values, where, onSession = false) {
|
|
1046
|
+
const out = {};
|
|
1047
|
+
for (const [key, value] of Object.entries(values ?? {})) {
|
|
1048
|
+
// 清掉的覆盖经 BSON 落盘读回来是 null,与没有同义
|
|
1049
|
+
if (value === undefined || value === null)
|
|
1050
|
+
continue;
|
|
1051
|
+
const setting = this.settings.get(key);
|
|
1052
|
+
if (!setting)
|
|
1053
|
+
continue;
|
|
1054
|
+
if (onSession && setting.narrowest) {
|
|
1055
|
+
this.warnOnce(`设置项 ${key}(${where})只能设到场所为止,会话上的值不生效:改到场所或以上的层,再清掉它`);
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
const read = readSetting(setting, value);
|
|
1059
|
+
if (!('invalid' in read)) {
|
|
1060
|
+
if (read.value !== undefined)
|
|
1061
|
+
out[key] = read.value;
|
|
645
1062
|
}
|
|
646
|
-
if (
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
1063
|
+
else if (setting.field.onInvalid === 'error') {
|
|
1064
|
+
this.warnOnce(`设置项 ${key}(${where})取值无效(${read.invalid}),原样交给登记它的插件按拒绝处理`);
|
|
1065
|
+
out[key] = value;
|
|
1066
|
+
}
|
|
1067
|
+
else {
|
|
1068
|
+
this.warnOnce(`设置项 ${key}(${where})取值无效(${read.invalid}),按没设置处理`);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return out;
|
|
1072
|
+
}
|
|
1073
|
+
/** 写会话自己的覆盖:按登记的设置项校验,只能设到场所为止的项不收 */
|
|
1074
|
+
checkedPatch(patch) {
|
|
1075
|
+
const checked = checkPatch(this.settings, patch);
|
|
1076
|
+
assertSessionPatch(this.settings, checked);
|
|
1077
|
+
return checked;
|
|
1078
|
+
}
|
|
1079
|
+
/** 建会话时给的配置:校验后作为会话自己的覆盖 */
|
|
1080
|
+
checkedConfig(config) {
|
|
1081
|
+
return config ? applyPatch({}, this.checkedPatch(config)) : {};
|
|
1082
|
+
}
|
|
1083
|
+
warnOnce(message) {
|
|
1084
|
+
if (this.warned.has(message))
|
|
1085
|
+
return;
|
|
1086
|
+
this.warned.add(message);
|
|
1087
|
+
this.caps.logger.use.warn(message);
|
|
1088
|
+
}
|
|
1089
|
+
hasTurnInFlight(sessionId) {
|
|
1090
|
+
for (const turn of this.turns.values())
|
|
1091
|
+
if (turn.sessionId === sessionId)
|
|
1092
|
+
return true;
|
|
1093
|
+
return false;
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* 回合开始:登记这一轮;已有记录且不是 active 的翻 active。归档的不翻:归档是 owner 不想在列表里看到它,
|
|
1097
|
+
* 她在那里跑一轮不该让它回到列表,只有手动恢复才回来
|
|
1098
|
+
*/
|
|
1099
|
+
beginTurn(id, turnId) {
|
|
1100
|
+
this.turns.set(turnId, { sessionId: id });
|
|
1101
|
+
const session = this.sessions.get(id);
|
|
1102
|
+
if (session && session.status !== 'active' && session.status !== 'archived') {
|
|
1103
|
+
this.updateSession(id, { status: 'active' }).catch(() => { });
|
|
661
1104
|
}
|
|
662
1105
|
}
|
|
1106
|
+
/** 记下这一轮的结局与出错原因(只有结局为 error 时留原因),回合结束时用 */
|
|
1107
|
+
recordOutcome(turnId, outcome, error) {
|
|
1108
|
+
const turn = this.turns.get(turnId);
|
|
1109
|
+
if (!turn)
|
|
1110
|
+
return;
|
|
1111
|
+
turn.outcome = outcome;
|
|
1112
|
+
// 一句原因:agent 给的可能带上游的整段响应,会随会话落盘、进子任务提醒;空串当作没给
|
|
1113
|
+
turn.error = outcome === 'error' && error ? truncateChars(error, ERROR_MAX, '…') : undefined;
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* 回合结束:注销这一轮,记下 lastTurn(被拦下、没有结局的一轮记为 silent),结局为 replied 的另记下她开口的时间。
|
|
1117
|
+
* 会话里的最后一轮结束时,仍为 active 的会话(根会话与子会话一样)按这一轮的结局收口:出错为 error,其余为 completed
|
|
1118
|
+
*/
|
|
1119
|
+
endTurn(turnId) {
|
|
1120
|
+
const turn = this.turns.get(turnId);
|
|
1121
|
+
if (!turn)
|
|
1122
|
+
return;
|
|
1123
|
+
this.turns.delete(turnId);
|
|
1124
|
+
const session = this.sessions.get(turn.sessionId);
|
|
1125
|
+
if (!session)
|
|
1126
|
+
return;
|
|
1127
|
+
const { outcome = 'silent', error } = turn;
|
|
1128
|
+
const endedAt = Date.now();
|
|
1129
|
+
session.lastTurn = error === undefined ? { outcome, endedAt } : { outcome, endedAt, error };
|
|
1130
|
+
if (outcome === 'replied')
|
|
1131
|
+
session.lastReplyAt = endedAt;
|
|
1132
|
+
const settle = session.status === 'active' && !this.hasTurnInFlight(session.id);
|
|
1133
|
+
const status = outcome === 'error' ? 'error' : 'completed';
|
|
1134
|
+
this.updateSession(session.id, settle ? { status } : {}).catch(() => { });
|
|
1135
|
+
}
|
|
1136
|
+
/** 按会话累加一次模型调用的 token:只记已有的会话,不改 updatedAt;记了就发 session:updated */
|
|
1137
|
+
recordUsage({ sessionId, inputTokens, outputTokens }) {
|
|
1138
|
+
const session = sessionId === undefined ? undefined : this.sessions.get(sessionId);
|
|
1139
|
+
if (!session)
|
|
1140
|
+
return;
|
|
1141
|
+
const { input, output } = session.tokens ?? { input: 0, output: 0 };
|
|
1142
|
+
session.tokens = { input: input + inputTokens, output: output + outputTokens };
|
|
1143
|
+
this.markDirty();
|
|
1144
|
+
void this.caps.events.use.emit('session:updated', session);
|
|
1145
|
+
}
|
|
663
1146
|
/**
|
|
664
1147
|
* 关停收尾:仍 `active` 的会话立即收口并落盘。
|
|
665
1148
|
*
|
|
@@ -667,7 +1150,7 @@ class SessionManager {
|
|
|
667
1150
|
* 关停打断在飞回合与那条路径同义,沿用同一状态,不新造值。
|
|
668
1151
|
* waiting / completed / error / archived 不是在飞,原样保留。
|
|
669
1152
|
*
|
|
670
|
-
* 第一方栈由 core 关停编排(optional 环成员先全部 drain
|
|
1153
|
+
* 第一方栈由 core 关停编排(optional 环成员先全部 drain)与回合结束时的收口({@link endTurn})处理;
|
|
671
1154
|
* 此处兜的是未装 agent 或钩子未挂上的路径。
|
|
672
1155
|
* persist 也在这里立刻刷,不走 1s debounce——onDispose 的 shutdown 仍会再刷一次。
|
|
673
1156
|
*/
|
|
@@ -700,322 +1183,303 @@ class SessionManager {
|
|
|
700
1183
|
}
|
|
701
1184
|
// ===== 工具函数 =====
|
|
702
1185
|
/**
|
|
703
|
-
*
|
|
704
|
-
*
|
|
705
|
-
* null 必须一起剥:清除会话覆盖(`/model 复位`)写入的是 undefined,
|
|
706
|
-
* 而它经 BSON 持久化后读回来是 **null**。不剥的话这个 null 会在 Object.assign 里
|
|
707
|
-
* 把平台 profile 的同名字段盖成空——agent 侧判 `llm?.provider && llm?.model` 不成立、
|
|
708
|
-
* 退回 `resolveLLMModel(undefined)`,静默落到首个注册的 entry。表现就是
|
|
709
|
-
* 「配置里写着 A,实际跑的是 B」,且没有任何告警。persona 同理。
|
|
710
|
-
* 本模型里「没有值」一律等于「继承上层」,null 与 undefined 同义。
|
|
1186
|
+
* 会话的种类,只由父会话与出生平台推出。新建、建档、补记与加载入表时覆盖写入,调用方传的值与存储里的旧值都不采信
|
|
711
1187
|
*/
|
|
712
|
-
function
|
|
713
|
-
|
|
714
|
-
return {};
|
|
715
|
-
const result = {};
|
|
716
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
717
|
-
if (value !== undefined && value !== null)
|
|
718
|
-
result[key] = value;
|
|
719
|
-
}
|
|
720
|
-
return result;
|
|
1188
|
+
function describeSession(session) {
|
|
1189
|
+
return { kind: sessionKind(session) };
|
|
721
1190
|
}
|
|
1191
|
+
/** 新建的场所记录:设置为空 */
|
|
1192
|
+
function newPlace(place, name, root) {
|
|
1193
|
+
const now = Date.now();
|
|
1194
|
+
return { ...place, name, ...(root === undefined ? {} : { root }), config: {}, createdAt: now, updatedAt: now };
|
|
1195
|
+
}
|
|
1196
|
+
function placeInfo({ kind, key, name }) {
|
|
1197
|
+
return { kind, key, name };
|
|
1198
|
+
}
|
|
1199
|
+
function workspaceInfo({ key, name, root }) {
|
|
1200
|
+
return { id: key.slice(WORKSPACE_PREFIX.length), name, ...(root === undefined ? {} : { root }) };
|
|
1201
|
+
}
|
|
1202
|
+
/** 排序时默认工作区在前,其余不动 */
|
|
1203
|
+
function defaultFirst(a, b) {
|
|
1204
|
+
return Number(b === DEFAULT_PLACE.key) - Number(a === DEFAULT_PLACE.key);
|
|
1205
|
+
}
|
|
1206
|
+
const SESSION_STATUSES = new Set([
|
|
1207
|
+
'active',
|
|
1208
|
+
'waiting',
|
|
1209
|
+
'completed',
|
|
1210
|
+
'error',
|
|
1211
|
+
'archived',
|
|
1212
|
+
]);
|
|
1213
|
+
const TURN_OUTCOMES = new Set([
|
|
1214
|
+
'replied',
|
|
1215
|
+
'silent',
|
|
1216
|
+
'aborted',
|
|
1217
|
+
'error',
|
|
1218
|
+
]);
|
|
1219
|
+
const isText = (value) => typeof value === 'string';
|
|
1220
|
+
const isTextList = (value) => Array.isArray(value) && value.every(isText);
|
|
1221
|
+
const isTime = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
1222
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
722
1223
|
/**
|
|
723
|
-
*
|
|
724
|
-
* 登记随激活存亡(插件不在,WebUI 就调不到这些方法)。
|
|
1224
|
+
* 会话记录里会进会话列表与协议、类型不对会让读者出错的可选字段,各自的类型。种类载入时重算,不在这里
|
|
725
1225
|
*/
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
count++;
|
|
795
|
-
}
|
|
796
|
-
catch {
|
|
797
|
-
/* skip */
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
return { success: true, count };
|
|
801
|
-
});
|
|
802
|
-
/** 批量删除会话 */
|
|
803
|
-
webui.registerAction('batchDelete', async (args) => {
|
|
804
|
-
const ids = args.ids;
|
|
805
|
-
if (!Array.isArray(ids) || ids.length === 0)
|
|
806
|
-
throw new Error('缺少会话 ID 列表');
|
|
807
|
-
let count = 0;
|
|
808
|
-
for (const id of ids) {
|
|
809
|
-
try {
|
|
810
|
-
await manager.deleteSession(id);
|
|
811
|
-
count++;
|
|
812
|
-
}
|
|
813
|
-
catch {
|
|
814
|
-
/* skip */
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
return { success: true, count };
|
|
818
|
-
});
|
|
819
|
-
webui.registerAction('getSessionTree', async () => manager.getTree());
|
|
820
|
-
/** 获取可选项列表(供前端下拉框使用) */
|
|
821
|
-
webui.registerAction('getConfigOptions', async () => {
|
|
822
|
-
// 可用人设列表
|
|
823
|
-
const personaService = persona.current;
|
|
824
|
-
const personas = personaService?.listModels ? await personaService.listModels() : [];
|
|
825
|
-
// 可用 LLM 模型列表(枚举所有 chat-capable entry)
|
|
826
|
-
const models = listLLMModels(llm, { caps: ['chat'] }).map(e => ({
|
|
827
|
-
id: e.instance.id,
|
|
828
|
-
capabilities: [...e.instance.capabilities],
|
|
829
|
-
provider: e.instance.providerId,
|
|
830
|
-
contextId: e.contextId,
|
|
831
|
-
}));
|
|
832
|
-
// 工具分组列表
|
|
833
|
-
const toolGroups = tools.current?.getGroups().map(g => ({ name: g.name, label: g.label })) ?? [];
|
|
834
|
-
// 已注册平台列表
|
|
835
|
-
const platforms = getPlatformNames(platform);
|
|
836
|
-
return { personas, models, toolGroups, platforms, profiles: manager.getPlatformProfiles() };
|
|
837
|
-
});
|
|
838
|
-
/** 获取指定会话的最终生效配置(合并所有层级后的结果) */
|
|
839
|
-
webui.registerAction('getResolvedConfig', async (args) => {
|
|
840
|
-
const sessionId = args.sessionId;
|
|
841
|
-
if (!sessionId)
|
|
842
|
-
throw new Error('缺少 sessionId');
|
|
843
|
-
return manager.resolveConfig(sessionId, args.platform);
|
|
844
|
-
});
|
|
845
|
-
/**
|
|
846
|
-
* 获取「继承默认」——不含 session 自身 config,只算 platform profile + 父 sessionDefaults。
|
|
847
|
-
* WebUI 「继承 (xxx)」提示用这个,避免显示用户自己的覆盖值。
|
|
848
|
-
*/
|
|
849
|
-
webui.registerAction('getInheritedDefaults', async (args) => {
|
|
850
|
-
const sessionId = args.sessionId;
|
|
851
|
-
if (!sessionId)
|
|
852
|
-
throw new Error('缺少 sessionId');
|
|
853
|
-
return manager.resolveInheritedDefaults(sessionId, args.platform);
|
|
854
|
-
});
|
|
855
|
-
/** 获取会话详情(含完整消息历史,包括已归档消息) */
|
|
856
|
-
webui.registerAction('getSessionDetail', async (args) => {
|
|
857
|
-
const store = memory.current;
|
|
858
|
-
if (!store)
|
|
859
|
-
throw new Error('memory 服务不可用');
|
|
860
|
-
const id = args.id;
|
|
861
|
-
if (!id)
|
|
862
|
-
throw new Error('缺少会话 ID');
|
|
863
|
-
const session = manager.getSession(id);
|
|
864
|
-
if (!session)
|
|
865
|
-
throw new Error(`会话不存在: ${id}`);
|
|
866
|
-
const limit = args.limit || 200;
|
|
867
|
-
// 优先使用 getFullHistory(含已归档消息),确保 UI 能看到完整对话
|
|
868
|
-
const messages = store.getFullHistory ? await store.getFullHistory(id, limit) : await store.getHistory(id, limit);
|
|
869
|
-
return { session, messages };
|
|
870
|
-
});
|
|
871
|
-
/** 手动重命名会话标题 */
|
|
872
|
-
webui.registerAction('renameSession', async (args) => {
|
|
873
|
-
const id = args.id;
|
|
874
|
-
const title = args.title;
|
|
875
|
-
if (!id || !title)
|
|
876
|
-
throw new Error('缺少 id 或 title');
|
|
877
|
-
await manager.updateSessionTitle(id, title);
|
|
878
|
-
return { success: true };
|
|
879
|
-
});
|
|
880
|
-
/** 归档子会话(父已不再需要) */
|
|
881
|
-
webui.registerAction('archiveSession', async (args) => {
|
|
882
|
-
const id = args.id;
|
|
883
|
-
if (!id)
|
|
884
|
-
throw new Error('缺少会话 ID');
|
|
885
|
-
await archiveRecursively(manager, id);
|
|
886
|
-
return { success: true };
|
|
887
|
-
});
|
|
1226
|
+
const OPTIONAL_FIELDS = {
|
|
1227
|
+
title: isText,
|
|
1228
|
+
parentId: isText,
|
|
1229
|
+
createdFrom: isText,
|
|
1230
|
+
originPlatform: isText,
|
|
1231
|
+
originChat: value => isRecord(value) &&
|
|
1232
|
+
isText(value.type) &&
|
|
1233
|
+
typeof value.multiParty === 'boolean' &&
|
|
1234
|
+
isText(value.targetId) &&
|
|
1235
|
+
(value.selfId === undefined || isText(value.selfId)),
|
|
1236
|
+
workspace: isText,
|
|
1237
|
+
work: value => isRecord(value) && isText(value.root) && isTextList(value.extraRoots) && isTextList(value.worktrees),
|
|
1238
|
+
currentDir: isText,
|
|
1239
|
+
originInstance: isText,
|
|
1240
|
+
lastReplyAt: isTime,
|
|
1241
|
+
lastTurn: value => isRecord(value) &&
|
|
1242
|
+
TURN_OUTCOMES.has(value.outcome) &&
|
|
1243
|
+
isTime(value.endedAt) &&
|
|
1244
|
+
(value.error === undefined || isText(value.error)),
|
|
1245
|
+
tokens: value => isRecord(value) && isTime(value.input) && isTime(value.output),
|
|
1246
|
+
};
|
|
1247
|
+
/**
|
|
1248
|
+
* 读回的会话记录:存储把没有的可选字段存成 null 的(MongoDB 就是这样)去掉这个键。字段不对的(旧数据、手改或别的插件写坏的):
|
|
1249
|
+
* 可选字段去掉,记在 dropped 里;必填字段不对时回这个字段名,整条不载入
|
|
1250
|
+
*/
|
|
1251
|
+
function storedSession(key, data) {
|
|
1252
|
+
const record = Object.fromEntries(Object.entries(data).filter(([, value]) => value !== null));
|
|
1253
|
+
const required = {
|
|
1254
|
+
id: record.id === key,
|
|
1255
|
+
name: isText(record.name),
|
|
1256
|
+
children: isTextList(record.children),
|
|
1257
|
+
status: SESSION_STATUSES.has(record.status),
|
|
1258
|
+
config: isRecord(record.config),
|
|
1259
|
+
createdAt: isTime(record.createdAt),
|
|
1260
|
+
updatedAt: isTime(record.updatedAt),
|
|
1261
|
+
};
|
|
1262
|
+
const wrong = Object.keys(required).find(field => !required[field]);
|
|
1263
|
+
if (wrong)
|
|
1264
|
+
return { wrong };
|
|
1265
|
+
const dropped = Object.entries(OPTIONAL_FIELDS)
|
|
1266
|
+
.filter(([field, valid]) => record[field] !== undefined && !valid(record[field]))
|
|
1267
|
+
.map(([field]) => field);
|
|
1268
|
+
for (const field of dropped)
|
|
1269
|
+
delete record[field];
|
|
1270
|
+
return { record: record, dropped };
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* 读回的场所记录:必填字段不对(含类别与键对不上:工作区的键以 `workspace:` 开头,窗口的不是)时回这个字段名,整条不载入;
|
|
1274
|
+
* 目录的类型不对时去掉它
|
|
1275
|
+
*/
|
|
1276
|
+
function storedPlace(key, data) {
|
|
1277
|
+
const record = Object.fromEntries(Object.entries(data).filter(([, value]) => value !== null));
|
|
1278
|
+
const required = {
|
|
1279
|
+
key: record.key === key,
|
|
1280
|
+
kind: (record.kind === 'workspace' || record.kind === 'window') &&
|
|
1281
|
+
(record.kind === 'workspace') === key.startsWith(WORKSPACE_PREFIX),
|
|
1282
|
+
name: isText(record.name),
|
|
1283
|
+
config: isRecord(record.config),
|
|
1284
|
+
createdAt: isTime(record.createdAt),
|
|
1285
|
+
updatedAt: isTime(record.updatedAt),
|
|
1286
|
+
};
|
|
1287
|
+
const wrong = Object.keys(required).find(field => !required[field]);
|
|
1288
|
+
if (wrong)
|
|
1289
|
+
return { wrong };
|
|
1290
|
+
const dropped = record.root !== undefined && !isText(record.root) ? ['root'] : [];
|
|
1291
|
+
if (dropped.length > 0)
|
|
1292
|
+
delete record.root;
|
|
1293
|
+
return { record: record, dropped };
|
|
888
1294
|
}
|
|
889
1295
|
// ===== 插件入口 =====
|
|
890
1296
|
const uses = {
|
|
891
1297
|
/** 会话档案与消息历史的唯一落点:没有 memory 就没有会话管理 */
|
|
892
1298
|
memory,
|
|
893
1299
|
/**
|
|
894
|
-
* 本插件不调用 agent 的方法;声明它是为了 agent:*
|
|
895
|
-
* 第一方栈由 core 关停编排(optional 环成员先全部 drain
|
|
1300
|
+
* 本插件不调用 agent 的方法;声明它是为了 agent:* 钩子的键类型,并据它认出真正调模型的那个插件(默认模型按它的选择写)。
|
|
1301
|
+
* 第一方栈由 core 关停编排(optional 环成员先全部 drain)与回合结束时的收口处理;
|
|
896
1302
|
* 此处 onDrain 兜的是未装 agent 或钩子未挂上的路径。
|
|
897
1303
|
*/
|
|
898
1304
|
agent: optional(agent),
|
|
899
|
-
/**
|
|
1305
|
+
/** 自动标题的模型来源(没有 LLM 时退回用户消息首段);全局默认层的「模型」按偏好指向的条目写出提供者与模型 */
|
|
900
1306
|
llm: optional(llm),
|
|
901
|
-
/**
|
|
902
|
-
persona: optional(persona),
|
|
1307
|
+
/** 房间建档时取平台的类型声明记下聊天窗口;解析接管房间的适配器实例(originInstance);可设的平台与类型 */
|
|
903
1308
|
platform: optional(platform),
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1309
|
+
/**
|
|
1310
|
+
* 会话设置的全局默认、平台档与平台类型档存放在宿主配置文档的 sessionConfig 字段里。由宿主在根上提供、没有插件
|
|
1311
|
+
* provide 它,写进激活闸只会挂一条解析不到提供者的告警;缺席时这三层为空、按层写被拒
|
|
1312
|
+
*/
|
|
1313
|
+
hostConfig: optional(hostConfig),
|
|
1314
|
+
/**
|
|
1315
|
+
* owner 的 IM 私聊在默认工作区:placeOf 按它认对端是不是 owner,与房间类别同一判据。plugin-authority 也以 optional
|
|
1316
|
+
* 用会话管理,两边成可选环;缺席时认不出 owner,一对一窗口按窗口算
|
|
1317
|
+
*/
|
|
1318
|
+
authority: optional(authority),
|
|
1319
|
+
/** 全局默认层的「模型」读写 llm 服务的偏好 */
|
|
1320
|
+
services,
|
|
907
1321
|
events,
|
|
908
1322
|
hooks,
|
|
909
1323
|
lifecycle,
|
|
910
1324
|
logger,
|
|
911
|
-
config,
|
|
912
1325
|
provide,
|
|
913
1326
|
};
|
|
914
1327
|
export default definePlugin({
|
|
915
1328
|
name: '@aalis/plugin-session-manager',
|
|
916
1329
|
displayName: '会话管理',
|
|
917
1330
|
subsystem: 'session',
|
|
918
|
-
configSchema,
|
|
919
1331
|
provides: [sessionManager],
|
|
920
1332
|
uses,
|
|
921
1333
|
apply: run,
|
|
922
1334
|
});
|
|
923
1335
|
async function run(caps) {
|
|
924
|
-
const
|
|
925
|
-
const { memory, webui, events, hooks, lifecycle, logger, provide } = caps;
|
|
926
|
-
// 注册 WebUI 页面
|
|
927
|
-
for (const page of webuiPages)
|
|
928
|
-
webui.registerPage(page);
|
|
929
|
-
if (memory.current === undefined) {
|
|
930
|
-
logger.error('memory 服务不可用,会话管理无法启动');
|
|
931
|
-
return;
|
|
932
|
-
}
|
|
1336
|
+
const { memory, events, hooks, lifecycle, logger, provide } = caps;
|
|
933
1337
|
const manager = new SessionManager(caps);
|
|
934
1338
|
// 会话表跟随 memory 胜者:首次挂接即加载;运行中胜者换人时先写回旧后端再读新后端
|
|
935
1339
|
memory.follow(instance => manager.attach(instance));
|
|
936
1340
|
await manager.loading;
|
|
937
1341
|
// 读表(含重试等待)期间停用或停机的,不再发布服务与登记
|
|
938
|
-
lifecycle.signal.throwIfAborted();
|
|
939
|
-
// 加载平台 profiles
|
|
940
|
-
manager.loadPlatformProfiles(cfg.platformProfiles);
|
|
941
|
-
// 加载全局 defaults
|
|
942
|
-
manager.loadDefaults(cfg.defaults);
|
|
1342
|
+
lifecycle.use.signal.throwIfAborted();
|
|
943
1343
|
// 注册服务
|
|
944
|
-
provide(sessionManager, manager, { label: '会话管理' });
|
|
945
|
-
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
|
|
949
|
-
|
|
1344
|
+
provide.use(sessionManager, manager, { label: '会话管理' });
|
|
1345
|
+
// ===== IM 房间收录 =====
|
|
1346
|
+
// 会话表里还没有记录的真人入站(不带 source、不是子任务)按这条消息建档:出生平台取消息的平台,聊天窗口按这个平台的
|
|
1347
|
+
// 类型声明经 chatScopeOf 记下,之后不变。WebUI 与终端的会话在入站之前已建档、带创建来源,不经这里。带 source 的是
|
|
1348
|
+
// 内部注入(工作流、定时任务、空闲开话题、宿主通知、好友申请与入群邀请的合成通知),不登记。消息带的类型名不在平台的
|
|
1349
|
+
// 声明里时按多方记下,每个平台与类型名告警一次;消息没带类型名、按平台声明的第一个多方类型记下时,每个平台告警一次。
|
|
1350
|
+
// 按 id 兜底建档的根会话(注入回合在未登记的房间开子任务)没有来源字段,升级前的记录只记了出生平台、没有聊天窗口,
|
|
1351
|
+
// 两者都由第一条归它的适配器管的原生入站补记(backfillRoom;后者只认出生平台的入站)。
|
|
1352
|
+
// 收录与补记之前问遍认领这个 id 的适配器(claimantsOf),第一个认领者随来源字段记作所属实例。认领它的平台不止一个
|
|
1353
|
+
// (如终端配置的会话 id 配成了群的 id)时判不出它是谁的窗口,不收录、不补记,每个 id 告警一次:结果不能取决于
|
|
1354
|
+
// 适配器的登记先后。问的期间别的入站可能已收录,问完按会话表此刻的记录判,再同步建档或补记。
|
|
1355
|
+
// 名字只采信出生平台自己的入站:多方取群名、一对一取对方昵称,缺省用 id(群的首条入站可能是不带群名的戳一戳,
|
|
1356
|
+
// 不回退到昵称)。现名还是 id 时由后到的原生入站补上,起过的名字不动;补记不成、仍没有聊天窗口的记录判不出该取哪个,
|
|
1357
|
+
// 不补。
|
|
1358
|
+
// 场所是窗口的同时建窗口的场所记录,它的显示名随出生平台的入站更新(owner 的 IM 私聊在默认工作区,不建)。
|
|
1359
|
+
// 已登记、还没记下所属实例的房间,每条真人入站都再解析一次,认领到就补记。
|
|
1360
|
+
const warned = new Set();
|
|
1361
|
+
const warnOnce = (key, message) => {
|
|
1362
|
+
if (warned.has(key))
|
|
950
1363
|
return;
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1364
|
+
warned.add(key);
|
|
1365
|
+
logger.use.warn(message);
|
|
1366
|
+
};
|
|
1367
|
+
/** 不经这条入站收录或补记的会话:有聊天窗口、创建来源或绑定的,与出生平台不是消息平台的 */
|
|
1368
|
+
const recorded = (session, msg) => session !== undefined &&
|
|
1369
|
+
(Boolean(session.originChat || session.createdFrom || session.work) ||
|
|
1370
|
+
(session.originPlatform ?? msg.platform) !== msg.platform);
|
|
1371
|
+
/** 已收录的会话:出生平台的入站补名、更新窗口的显示名,还没记下所属实例的再解析一次 */
|
|
1372
|
+
const noteRecorded = (session, msg) => {
|
|
1373
|
+
const chat = session.originChat;
|
|
1374
|
+
if (chat && session.originPlatform === msg.platform) {
|
|
1375
|
+
const nativeName = chat.multiParty ? msg.groupName : msg.nickname;
|
|
1376
|
+
if (nativeName && session.name === session.id)
|
|
1377
|
+
manager.updateSession(session.id, { name: nativeName }).catch(() => { });
|
|
1378
|
+
void manager.noteWindow(session.id, nativeName);
|
|
954
1379
|
}
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1380
|
+
void manager.recordOriginInstance(session);
|
|
1381
|
+
};
|
|
1382
|
+
events.use.on('inbound:message', async (msg) => {
|
|
1383
|
+
const { sessionId } = msg;
|
|
1384
|
+
if (!sessionId || msg.source || sessionId.includes('::'))
|
|
958
1385
|
return;
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
manager.updateSession(msg.sessionId, { status: 'completed' }).catch(() => { });
|
|
963
|
-
}
|
|
964
|
-
});
|
|
965
|
-
// 回合终态收口:agent 在 replied/silent/aborted/error 四条路径都会发 agent:turn:after。
|
|
966
|
-
// 上面的 outbound:message 只覆盖"产生了回复"的情形——用户中途停止生成(aborted)或
|
|
967
|
-
// 空回复(silent)时不发 outbound:message,会话会永远停在 'active'(即"进行中")。
|
|
968
|
-
// 这里订阅生命周期钩子作幂等互补,确保任何回合结束都把根会话收口为 'completed'。
|
|
969
|
-
hooks.middleware('agent:turn:after', async (data, next) => {
|
|
970
|
-
await next();
|
|
971
|
-
if (!data.sessionId)
|
|
1386
|
+
const known = manager.getSession(sessionId);
|
|
1387
|
+
if (recorded(known, msg)) {
|
|
1388
|
+
noteRecorded(known, msg);
|
|
972
1389
|
return;
|
|
973
|
-
const session = manager.getSession(data.sessionId);
|
|
974
|
-
// 子会话由 plugin-subtask 负责完成并回传 result,这里只收口根会话。
|
|
975
|
-
if (session && session.status === 'active' && !session.parentId) {
|
|
976
|
-
manager.updateSession(data.sessionId, { status: 'completed' }).catch(() => { });
|
|
977
1390
|
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
const TITLE_PLATFORMS = new Set(['webui', 'cli']);
|
|
983
|
-
const titleGenerating = new Set();
|
|
984
|
-
events.on('inbound:message', (msg) => {
|
|
985
|
-
const { sessionId, platform } = msg;
|
|
986
|
-
if (!sessionId) {
|
|
987
|
-
logger.debug('标题生成跳过: 缺少 sessionId');
|
|
1391
|
+
const claimants = await claimantsOf(caps.platform, sessionId, logger.use);
|
|
1392
|
+
const session = manager.getSession(sessionId);
|
|
1393
|
+
if (recorded(session, msg)) {
|
|
1394
|
+
noteRecorded(session, msg);
|
|
988
1395
|
return;
|
|
989
1396
|
}
|
|
990
|
-
|
|
1397
|
+
const platforms = [...new Set(claimants.map(({ use: instance }) => instance.platform))];
|
|
1398
|
+
if (platforms.length > 1) {
|
|
1399
|
+
warnOnce(`claim:${sessionId}`, `会话 ${sessionId} 同时由平台「${platforms.join('」「')}」认领:判不出它是哪个平台的聊天窗口,不收录。` +
|
|
1400
|
+
'改配置,一个会话 id 只归一个平台');
|
|
991
1401
|
return;
|
|
992
|
-
|
|
993
|
-
//
|
|
994
|
-
if (
|
|
1402
|
+
}
|
|
1403
|
+
// 补记只认这个 id 归消息平台的适配器管的入站:WebUI 与终端往房间插话不算
|
|
1404
|
+
if (session && platforms[0] !== msg.platform)
|
|
995
1405
|
return;
|
|
1406
|
+
const adapter = getPlatformAdapters(caps.platform).find(a => a.platform === msg.platform);
|
|
1407
|
+
const scope = chatScopeOf(msg, sessionTypesOf(adapter));
|
|
1408
|
+
if (scope.undeclared) {
|
|
1409
|
+
warnOnce(`type:${scope.platform}:${scope.type}`, `平台「${scope.platform}」没有声明类型「${scope.type}」(${sessionId}):这个聊天窗口按多方记下。` +
|
|
1410
|
+
'适配器要在 sessionTypes 里声明它发出的类型名');
|
|
1411
|
+
}
|
|
1412
|
+
else if (msg.sessionType === undefined && scope.multiParty) {
|
|
1413
|
+
warnOnce(`untyped:${scope.platform}`, `平台「${scope.platform}」的消息没带类型名(${sessionId}):这个聊天窗口按类型「${scope.type}」、多方记下。` +
|
|
1414
|
+
'适配器要在消息上带类型名');
|
|
1415
|
+
}
|
|
1416
|
+
const originChat = {
|
|
1417
|
+
type: scope.type,
|
|
1418
|
+
multiParty: scope.multiParty,
|
|
1419
|
+
...(msg.selfId === undefined ? {} : { selfId: msg.selfId }),
|
|
1420
|
+
targetId: scope.targetId,
|
|
1421
|
+
};
|
|
1422
|
+
const nativeName = scope.multiParty ? msg.groupName : msg.nickname;
|
|
1423
|
+
const origin = {
|
|
1424
|
+
originPlatform: msg.platform,
|
|
1425
|
+
originChat,
|
|
1426
|
+
...(claimants[0] ? { originInstance: claimants[0].id } : {}),
|
|
1427
|
+
};
|
|
1428
|
+
(session
|
|
1429
|
+
? manager.backfillRoom(session, nativeName, origin)
|
|
1430
|
+
: manager.recordRoom(sessionId, nativeName, origin)).catch(err => logger.use.warn(`IM 房间登记失败 (${sessionId}):`, err));
|
|
1431
|
+
});
|
|
1432
|
+
// 全局默认层的「模型」就是 llm 服务的偏好:偏好从别处改了(宿主启动时应用)、llm 的提供者上下线(偏好指向的模型在不在、
|
|
1433
|
+
// 服务实际用的那一个随之变)同样算会话设置变了
|
|
1434
|
+
events.use.on('service:changed', name => {
|
|
1435
|
+
if (name === llm.name)
|
|
1436
|
+
void events.use.emit('session:config:changed');
|
|
1437
|
+
});
|
|
1438
|
+
// ===== 会话状态 =====
|
|
1439
|
+
// 回合真正开始时(agent:input:before)才翻 active,归档的不翻:群里只有消息、她没开口的房间不显示「进行中」
|
|
1440
|
+
//(触发判定在入站相位,挡下的消息到不了这里)。同一个中间件在 finally 里记下这一轮并收口,根会话与子会话一样,这是
|
|
1441
|
+
// 唯一的收口:next() 返回时整轮已跑完、agent:turn:after 也已执行(agent 只在这个钩子的默认动作里发它),结局已经记下;
|
|
1442
|
+
// 后面的中间件不调 next() 或抛错时 agent 不发回合收尾,同样由这里兜住。按回合 id 登记在飞的回合,会话里最后一轮结束
|
|
1443
|
+
// 才收口,别的 lane 还在跑时不显示已完成;回合中途的出站(send_attachment、paper_send 在工具执行中直接发)不收口。
|
|
1444
|
+
hooks.use.middleware('agent:input:before', async (data, next) => {
|
|
1445
|
+
manager.beginTurn(data.message.sessionId, data.turnId);
|
|
1446
|
+
try {
|
|
1447
|
+
await next();
|
|
1448
|
+
}
|
|
1449
|
+
finally {
|
|
1450
|
+
manager.endTurn(data.turnId);
|
|
1451
|
+
}
|
|
1452
|
+
});
|
|
1453
|
+
hooks.use.middleware('agent:turn:after', async (data, next) => {
|
|
1454
|
+
manager.recordOutcome(data.turnId, data.outcome, data.error);
|
|
1455
|
+
await next();
|
|
1456
|
+
});
|
|
1457
|
+
// 模型调用的 token 按会话累加:对话、摘要、识图、转写的调用都带会话 id
|
|
1458
|
+
events.use.on('llm:usage', usage => manager.recordUsage(usage));
|
|
1459
|
+
// 自动标题:只给带创建来源的根会话(WebUI 新建的、终端建的)生成,在首条消息时生成、不等回复;IM 房间不生成,
|
|
1460
|
+
// owner 从 WebUI 往房间插话也不拿来给房间起名。异步生成,不阻塞消息处理;直接传入这条消息,不依赖历史
|
|
1461
|
+
const titleGenerating = new Set();
|
|
1462
|
+
events.use.on('inbound:message', (msg) => {
|
|
1463
|
+
const { sessionId } = msg;
|
|
996
1464
|
const session = manager.getSession(sessionId);
|
|
997
|
-
|
|
998
|
-
if (session && (session.title || session.parentId))
|
|
1465
|
+
if (!session?.createdFrom || session.title || titleGenerating.has(sessionId))
|
|
999
1466
|
return;
|
|
1000
1467
|
titleGenerating.add(sessionId);
|
|
1001
|
-
logger.info(`开始生成会话标题: ${sessionId}
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
(session ? Promise.resolve() : manager.ensureSession(sessionId).then(() => undefined))
|
|
1006
|
-
.then(() => manager.generateTitle(sessionId, msg.content))
|
|
1007
|
-
.catch(err => logger.warn('标题生成失败:', err))
|
|
1468
|
+
logger.use.info(`开始生成会话标题: ${sessionId}`);
|
|
1469
|
+
manager
|
|
1470
|
+
.generateTitle(sessionId, msg.content)
|
|
1471
|
+
.catch(err => logger.use.warn('标题生成失败:', err))
|
|
1008
1472
|
.finally(() => titleGenerating.delete(sessionId));
|
|
1009
1473
|
});
|
|
1010
1474
|
// 关停收尾:仍 active 的会话在 onDrain 收口并落盘。
|
|
1011
|
-
// 第一方栈由 core 关停编排(optional 环成员先全部 drain
|
|
1475
|
+
// 第一方栈由 core 关停编排(optional 环成员先全部 drain)与回合结束时的收口处理;
|
|
1012
1476
|
// 此处兜的是未装 agent 或钩子未挂上的路径。
|
|
1013
1477
|
// 持久化仍走 onDispose:覆盖 bounce / unload / updateConfig 等全部拆卸路径
|
|
1014
1478
|
// (只在全局停机触发的话,热重载即丢会话元数据)。
|
|
1015
1479
|
// 异步收尾由编排层的 disposeAsync 等待完成;app.stop() 的拓扑逆序保证此时 memory 提供者
|
|
1016
1480
|
// 尚未关闭,单独热重载/禁用 memory 提供者时无此保证。shutdown() 幂等:清 timer + 置 dirty + 落盘。
|
|
1017
|
-
lifecycle.onDrain(() => manager.settleActiveOnDrain(), '收口在飞会话');
|
|
1018
|
-
lifecycle.onDispose(() => manager.shutdown());
|
|
1019
|
-
logger.info('会话管理服务已启用');
|
|
1481
|
+
lifecycle.use.onDrain(() => manager.settleActiveOnDrain(), '收口在飞会话');
|
|
1482
|
+
lifecycle.use.onDispose(() => manager.shutdown());
|
|
1483
|
+
logger.use.info('会话管理服务已启用');
|
|
1020
1484
|
}
|
|
1021
1485
|
//# sourceMappingURL=index.js.map
|