@aalis/plugin-session-manager 0.14.0 → 0.16.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/README.md +1 -1
- package/dist/index.d.ts +25 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +936 -623
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts +23 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/en.js +23 -0
- package/dist/locales/en.js.map +1 -0
- package/dist/texts.d.ts +45 -0
- package/dist/texts.d.ts.map +1 -0
- package/dist/texts.js +31 -0
- package/dist/texts.js.map +1 -0
- package/package.json +23 -18
package/dist/index.js
CHANGED
|
@@ -1,123 +1,79 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
1
2
|
import { agent } from '@aalis/api-agent';
|
|
3
|
+
import { authority } from '@aalis/api-authority';
|
|
4
|
+
import { chatScopeOf, windowKey } from '@aalis/api-gateway';
|
|
2
5
|
import { hooks } from '@aalis/api-hooks';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
6
|
+
import { ownHostConfig } from '@aalis/api-host-config';
|
|
7
|
+
import { i18n, outputLanguageFor } from '@aalis/api-i18n';
|
|
8
|
+
import { lightestThinking, llm, resolveLLMModel } from '@aalis/api-llm';
|
|
9
|
+
import { historyCleared, memory } from '@aalis/api-memory';
|
|
10
|
+
import { claimantsOf, getPlatformAdapters, platform, resolvePlatformBySession, sessionTypesOf, } from '@aalis/api-platform';
|
|
11
|
+
import { isUnfiled, oneToOneWith, rootSessionOf, sessionKind, sessionLabel, sessionManager, TAKE_UNKNOWN, } from '@aalis/api-session-manager';
|
|
12
|
+
import { sessionFields } from '@aalis/api-session-settings';
|
|
10
13
|
import { config, definePlugin, events, lifecycle, logger, optional, provide } from '@aalis/core';
|
|
11
|
-
import { defineConfig
|
|
14
|
+
import { defineConfig } from '@aalis/schema-config';
|
|
15
|
+
import { asText, toSource, truncateParams, withText } from '@aalis/schema-i18n';
|
|
16
|
+
import { truncateChars } from '@aalis/util-text-normalize';
|
|
17
|
+
import { t } from './texts.js';
|
|
18
|
+
// ===== 配置 =====
|
|
19
|
+
// 取别的会话的东西(canTake)的范围与三个开关:都是按会话字段,按取的一方所在的会话生效。坏值原样交来,范围按本会话、开关按否
|
|
12
20
|
const configSchema = defineConfig({
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
},
|
|
21
|
+
takeScope: {
|
|
22
|
+
type: 'select',
|
|
23
|
+
label: t('takeScopeLabel'),
|
|
24
|
+
description: t('takeScopeDescription'),
|
|
25
|
+
default: 'platform',
|
|
26
|
+
onInvalid: 'error',
|
|
27
|
+
options: [
|
|
28
|
+
{ label: t('takeScopeSession'), value: 'session' },
|
|
29
|
+
{ label: t('takeScopePlatform'), value: 'platform' },
|
|
30
|
+
{ label: t('takeScopeAll'), value: 'all' },
|
|
31
|
+
],
|
|
32
|
+
session: { prominence: 'advanced' },
|
|
25
33
|
},
|
|
26
|
-
|
|
27
|
-
type: '
|
|
28
|
-
label: '
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
},
|
|
34
|
+
takeOtherGroups: {
|
|
35
|
+
type: 'boolean',
|
|
36
|
+
label: t('takeOtherGroupsLabel'),
|
|
37
|
+
description: t('takeOtherGroupsDescription'),
|
|
38
|
+
default: true,
|
|
39
|
+
onInvalid: 'error',
|
|
40
|
+
session: { prominence: 'advanced' },
|
|
41
|
+
},
|
|
42
|
+
takeOthersPrivate: {
|
|
43
|
+
type: 'boolean',
|
|
44
|
+
label: t('takeOthersPrivateLabel'),
|
|
45
|
+
description: t('takeOthersPrivateDescription'),
|
|
46
|
+
default: false,
|
|
47
|
+
onInvalid: 'error',
|
|
48
|
+
session: { prominence: 'advanced' },
|
|
49
|
+
},
|
|
50
|
+
takeCrossBot: {
|
|
51
|
+
type: 'boolean',
|
|
52
|
+
label: t('takeCrossBotLabel'),
|
|
53
|
+
description: t('takeCrossBotDescription'),
|
|
54
|
+
default: false,
|
|
55
|
+
onInvalid: 'error',
|
|
56
|
+
session: { prominence: 'advanced' },
|
|
78
57
|
},
|
|
79
58
|
});
|
|
80
59
|
// ===== 常量 =====
|
|
81
|
-
|
|
60
|
+
/** 会话表与场所记录在记忆后端元数据里的命名空间 */
|
|
61
|
+
const SESSIONS = 'sessions';
|
|
62
|
+
const PLACES = 'places';
|
|
63
|
+
/** 必填字段不对、没有载入的记录移到 `<命名空间>-rejected`:留在原处会被之后同键的建档整条覆盖 */
|
|
64
|
+
const REJECTED_SUFFIX = '-rejected';
|
|
65
|
+
/** 工作区的场所键前缀;默认工作区的 id 为 default */
|
|
66
|
+
const WORKSPACE_PREFIX = 'workspace:';
|
|
67
|
+
const DEFAULT_WORKSPACE = 'default';
|
|
68
|
+
const DEFAULT_PLACE = Object.freeze({
|
|
69
|
+
kind: 'workspace',
|
|
70
|
+
key: `${WORKSPACE_PREFIX}${DEFAULT_WORKSPACE}`,
|
|
71
|
+
});
|
|
82
72
|
/** 读会话表失败后的重试间隔(毫秒);用尽仍失败按读表失败处理 */
|
|
83
73
|
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
|
-
];
|
|
74
|
+
/** 最近一轮出错原因最多留多少字 */
|
|
75
|
+
const ERROR_MAX = 200;
|
|
103
76
|
// ===== 辅助函数 =====
|
|
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
77
|
/** 等待 ms 毫秒,signal 中止时提前返回。不用 node:timers/promises:全局定时器测试里能用假时钟推进 */
|
|
122
78
|
function wait(ms, signal) {
|
|
123
79
|
return new Promise(resolve => {
|
|
@@ -130,27 +86,20 @@ function wait(ms, signal) {
|
|
|
130
86
|
signal.addEventListener('abort', done, { once: true });
|
|
131
87
|
});
|
|
132
88
|
}
|
|
133
|
-
function formatConfigSummary(config) {
|
|
134
|
-
const parts = [];
|
|
135
|
-
if (config.llm?.model)
|
|
136
|
-
parts.push(`${config.llm.provider}/${config.llm.model}`);
|
|
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
|
-
}
|
|
143
89
|
class SessionManager {
|
|
90
|
+
take;
|
|
144
91
|
sessions = new Map();
|
|
92
|
+
/** 场所记录:键 → 记录。与会话表一同加载与落盘 */
|
|
93
|
+
places = new Map();
|
|
145
94
|
caps;
|
|
146
95
|
persistTimer = null;
|
|
147
96
|
dirty = false;
|
|
148
97
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
98
|
+
* 本进程显式删除、尚未落盘的记录(墓碑),按表分开。persist 写全量快照,但只删这些键,不按「后端有、内存无」清扫:
|
|
99
|
+
* 后端里还可能有本进程没读到的记录,按差集清扫会把它们当孤儿删掉。每条墓碑带令牌,提交成功后只清掉
|
|
151
100
|
* 提交时那一版,提交期间再删的保留到下一次。换后端时墓碑随旧表一起作废。
|
|
152
101
|
*/
|
|
153
|
-
deleted = new Map();
|
|
102
|
+
deleted = { [SESSIONS]: new Map(), [PLACES]: new Map() };
|
|
154
103
|
/**
|
|
155
104
|
* 会话表所在的 memory 实例:跟随 memory 胜者。换人时先把旧表的未落盘变更写回旧实例,再从新实例读表整体替换
|
|
156
105
|
* (换后端即换库,不跨后端合并)。新表加载完成前仍指向旧实例,落盘不会把旧表写进新后端;加载窗口里的改动
|
|
@@ -159,11 +108,29 @@ class SessionManager {
|
|
|
159
108
|
store;
|
|
160
109
|
/** 最近一次挂接的加载;apply 等它完成再对外提供服务 */
|
|
161
110
|
loading = Promise.resolve();
|
|
162
|
-
/**
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
|
|
166
|
-
|
|
111
|
+
/** 已告警过的问题(坏数据、收录判不出的认领):同一处只告警一次 */
|
|
112
|
+
warned = new Set();
|
|
113
|
+
/** 正在收录或补记的会话:同一会话并发的原生入站共用这一次 */
|
|
114
|
+
recording = new Map();
|
|
115
|
+
/** 正在删除的会话(从摘掉、删子会话之前起,到清完数据),删除做完时兑现:这期间到的同一会话的入站等它(见 recordInbound) */
|
|
116
|
+
removing = new Map();
|
|
117
|
+
/**
|
|
118
|
+
* 各会话的代号(generationOf):开始删除的次数,在 remove 里加一,没删过的为 0。只记删过的 id,一直留着(之后同一 id 再建档
|
|
119
|
+
* 也不换回去,半路上删除之前的一代才认得出来),每个删过的 id 占一项;随这次激活,会话管理重新激活时清空
|
|
120
|
+
*/
|
|
121
|
+
generations = new Map();
|
|
122
|
+
/**
|
|
123
|
+
* 在飞的回合:turnId → 所在会话与这一轮的结局(出错时带原因)。agent:turn:before 进出登记与注销,结局由
|
|
124
|
+
* agent:turn:after 记下(被拦下的一轮不发它,没有结局)。第一方 agent 同一会话一轮接一轮,契约不限别的实现在同一会话并行几轮,
|
|
125
|
+
* 所以按回合 id 记、会话在最后一轮结束时才收口;回合之外建档的会话按这里有没有回合定初始状态(见 ensureSession)
|
|
126
|
+
*/
|
|
127
|
+
turns = new Map();
|
|
128
|
+
/** 正在解析所属实例的会话记录:同一条记录不并发解析,后到的调用不等它、直接返回 */
|
|
129
|
+
resolving = new WeakSet();
|
|
130
|
+
lookup = (id) => this.sessions.get(id);
|
|
131
|
+
/** take 读取的一方所在会话此刻的取别的会话的范围与三个开关(生效值,坏值原样) */
|
|
132
|
+
constructor(caps, take) {
|
|
133
|
+
this.take = take;
|
|
167
134
|
this.caps = caps;
|
|
168
135
|
}
|
|
169
136
|
/**
|
|
@@ -171,18 +138,20 @@ class SessionManager {
|
|
|
171
138
|
* 会话表另经 {@link store} 跟随,见 {@link attach}。
|
|
172
139
|
*/
|
|
173
140
|
get memory() {
|
|
174
|
-
|
|
175
|
-
if (!m)
|
|
141
|
+
if (!this.caps.memory.present)
|
|
176
142
|
throw new Error('session-manager 需要 memory 服务');
|
|
177
|
-
return
|
|
143
|
+
return this.caps.memory.use;
|
|
178
144
|
}
|
|
179
145
|
/**
|
|
180
|
-
* memory 胜者的 follow
|
|
146
|
+
* memory 胜者的 follow 挂接:从这个实例读会话表与场所记录,整体替换内存里的这份。
|
|
181
147
|
* 返回的清理在换人或关闭时执行:中止读表重试的等待,等本次加载落定,再把未落盘的变更写回这个实例。
|
|
182
148
|
*/
|
|
183
149
|
attach(instance) {
|
|
184
150
|
const detached = new AbortController();
|
|
185
|
-
|
|
151
|
+
// 挂上的句柄只活到这次挂载的清理落定;之后的落盘(换人后的加载窗口、停机时的收尾)按条目 id 打到这一家
|
|
152
|
+
const id = this.caps.memory.list().find(p => p.selected)?.id;
|
|
153
|
+
const target = id === undefined ? instance : this.caps.memory.of(id);
|
|
154
|
+
this.loading = this.load(instance, target, AbortSignal.any([detached.signal, this.caps.lifecycle.use.signal]));
|
|
186
155
|
return async () => {
|
|
187
156
|
detached.abort();
|
|
188
157
|
await this.loading;
|
|
@@ -190,52 +159,96 @@ class SessionManager {
|
|
|
190
159
|
clearTimeout(this.persistTimer);
|
|
191
160
|
this.persistTimer = null;
|
|
192
161
|
}
|
|
193
|
-
|
|
162
|
+
// 这一家可能已经撤回:清理期间经挂上的句柄落盘
|
|
163
|
+
await this.persist(instance).catch(err => this.caps.logger.use.warn('换后端前落盘会话失败:', err));
|
|
194
164
|
};
|
|
195
165
|
}
|
|
196
166
|
/**
|
|
197
|
-
* 从 memory
|
|
198
|
-
*
|
|
167
|
+
* 从 memory 元数据加载会话表与场所记录,加载完成后以它们为准、以这个实例为落盘目标。读表失败按 {@link LOAD_RETRY_DELAYS}
|
|
168
|
+
* 有限重试,其间仍用原来的表与落盘目标;用尽或被中止(换人、停机)时两张表为空且不落盘。默认工作区的记录不在时补上
|
|
169
|
+
* (随下一次落盘写进后端)。
|
|
199
170
|
*/
|
|
200
|
-
async load(instance, signal) {
|
|
201
|
-
let
|
|
171
|
+
async load(instance, target, signal) {
|
|
172
|
+
let loaded;
|
|
202
173
|
for (let attempt = 0;; attempt++) {
|
|
203
174
|
try {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
175
|
+
loaded = {
|
|
176
|
+
sessions: await this.readTable(instance, SESSIONS, '会话记录', (key, data) => {
|
|
177
|
+
const read = storedSession(key, data);
|
|
178
|
+
return 'wrong' in read
|
|
179
|
+
? read
|
|
180
|
+
: { record: { ...read.record, ...describeSession(read.record) }, dropped: read.dropped };
|
|
181
|
+
}),
|
|
182
|
+
places: await this.readTable(instance, PLACES, '场所记录', storedPlace),
|
|
183
|
+
};
|
|
211
184
|
break;
|
|
212
185
|
}
|
|
213
186
|
catch (err) {
|
|
214
187
|
const delay = LOAD_RETRY_DELAYS[attempt];
|
|
215
188
|
if (delay !== undefined && !signal.aborted) {
|
|
216
|
-
this.caps.logger.warn(`加载会话数据失败,${delay}ms 后重试:`, err);
|
|
189
|
+
this.caps.logger.use.warn(`加载会话数据失败,${delay}ms 后重试:`, err);
|
|
217
190
|
await wait(delay, signal);
|
|
218
191
|
if (!signal.aborted)
|
|
219
192
|
continue;
|
|
220
193
|
}
|
|
221
194
|
if (signal.aborted)
|
|
222
|
-
this.caps.logger.info('已中止加载会话数据(停用、停机或换后端)');
|
|
195
|
+
this.caps.logger.use.info('已中止加载会话数据(停用、停机或换后端)');
|
|
223
196
|
else
|
|
224
|
-
this.caps.logger.error('加载会话数据失败,在这个后端上的会话改动不落盘(以免覆盖原有记录):', err);
|
|
197
|
+
this.caps.logger.use.error('加载会话数据失败,在这个后端上的会话改动不落盘(以免覆盖原有记录):', err);
|
|
225
198
|
break;
|
|
226
199
|
}
|
|
227
200
|
}
|
|
228
|
-
//
|
|
201
|
+
// 加载窗口里旧表的改动:落盘目标此前仍是旧的那一家,换表前取快照,换表后写回
|
|
229
202
|
const previous = this.store;
|
|
230
203
|
const pending = previous && this.dirty ? this.snapshot().ops : undefined;
|
|
231
|
-
this.sessions = sessions ?? new Map();
|
|
232
|
-
this.
|
|
204
|
+
this.sessions = loaded?.sessions ?? new Map();
|
|
205
|
+
this.places = loaded?.places ?? new Map();
|
|
206
|
+
if (!this.places.has(DEFAULT_PLACE.key))
|
|
207
|
+
this.places.set(DEFAULT_PLACE.key, newPlace(DEFAULT_PLACE));
|
|
208
|
+
this.deleted = { [SESSIONS]: new Map(), [PLACES]: new Map() };
|
|
233
209
|
this.dirty = false;
|
|
234
|
-
this.store =
|
|
235
|
-
if (
|
|
236
|
-
this.caps.logger.info(`已加载 ${sessions.size}
|
|
237
|
-
if (previous && pending)
|
|
238
|
-
|
|
210
|
+
this.store = loaded ? target : undefined;
|
|
211
|
+
if (loaded)
|
|
212
|
+
this.caps.logger.use.info(`已加载 ${loaded.sessions.size} 个会话、${loaded.places.size} 个场所`);
|
|
213
|
+
if (previous && pending) {
|
|
214
|
+
try {
|
|
215
|
+
await previous.commitMetadata(pending);
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
this.caps.logger.use.warn('换后端时写回旧后端失败:', err);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* 读一张表:必填字段不对的记录不载入,移到 `<命名空间>-rejected`(先写副本再删原处:后端不保证整批原子时,停在半途也
|
|
224
|
+
* 不丢记录);可选字段类型不对的去掉这些字段照常载入。两种都告警
|
|
225
|
+
*/
|
|
226
|
+
async readTable(instance, namespace, label, parse) {
|
|
227
|
+
const rows = new Map();
|
|
228
|
+
const dropped = [];
|
|
229
|
+
const rejected = [];
|
|
230
|
+
for (const { key, data } of await instance.listMetadata(namespace)) {
|
|
231
|
+
const read = parse(key, data);
|
|
232
|
+
if ('wrong' in read) {
|
|
233
|
+
rejected.push({ key, data, field: read.wrong });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (read.dropped.length > 0)
|
|
237
|
+
dropped.push(`${key}(${read.dropped.join('、')})`);
|
|
238
|
+
rows.set(key, read.record);
|
|
239
|
+
}
|
|
240
|
+
const aside = `${namespace}${REJECTED_SUFFIX}`;
|
|
241
|
+
if (rejected.length > 0) {
|
|
242
|
+
await instance.commitMetadata(rejected.flatMap(({ key, data }) => [
|
|
243
|
+
{ op: 'put', namespace: aside, key, data },
|
|
244
|
+
{ op: 'del', namespace, key },
|
|
245
|
+
]));
|
|
246
|
+
this.caps.logger.use.warn(`${label}的必填字段不对,没有载入,原记录已移到 metadata 命名空间 ${aside}:` +
|
|
247
|
+
rejected.map(({ key, field }) => `${key}(${field})`).join('、'));
|
|
248
|
+
}
|
|
249
|
+
if (dropped.length > 0)
|
|
250
|
+
this.caps.logger.use.warn(`${label}有字段类型不对,去掉这些字段后照常载入:${dropped.join('、')}`);
|
|
251
|
+
return rows;
|
|
239
252
|
}
|
|
240
253
|
/** 标记需要持久化并延迟刷盘 */
|
|
241
254
|
markDirty() {
|
|
@@ -243,7 +256,7 @@ class SessionManager {
|
|
|
243
256
|
if (!this.persistTimer) {
|
|
244
257
|
this.persistTimer = setTimeout(() => {
|
|
245
258
|
this.persistTimer = null;
|
|
246
|
-
this.persist().catch(err => this.caps.logger.warn('持久化会话失败:', err));
|
|
259
|
+
this.persist().catch(err => this.caps.logger.use.warn('持久化会话失败:', err));
|
|
247
260
|
}, 1000);
|
|
248
261
|
}
|
|
249
262
|
}
|
|
@@ -258,107 +271,363 @@ class SessionManager {
|
|
|
258
271
|
* 而是**幂等 + 可重试**:每次写的是全量快照(不是增量),删除只针对墓碑;失败时 dirty 复位、
|
|
259
272
|
* 墓碑保留,下一次 markDirty 会把完整状态重写一遍,前一次的半成品被整体覆盖。
|
|
260
273
|
*/
|
|
261
|
-
async persist() {
|
|
262
|
-
|
|
263
|
-
if (!this.dirty || !target)
|
|
274
|
+
async persist(target = this.store) {
|
|
275
|
+
if (!this.dirty || !this.store || !target)
|
|
264
276
|
return;
|
|
265
277
|
this.dirty = false;
|
|
266
278
|
const { ops, tombstones } = this.snapshot();
|
|
267
279
|
try {
|
|
268
280
|
// 写入目标取落盘开始时的会话表所在实例:换人窗口里当前胜者可能已是新后端,旧表不能写过去
|
|
269
281
|
await target.commitMetadata(ops);
|
|
270
|
-
for (const [key, token] of tombstones)
|
|
271
|
-
if (this.deleted.get(key) === token)
|
|
272
|
-
this.deleted.delete(key);
|
|
282
|
+
for (const [table, key, token] of tombstones) {
|
|
283
|
+
if (this.deleted[table].get(key) === token)
|
|
284
|
+
this.deleted[table].delete(key);
|
|
285
|
+
}
|
|
273
286
|
}
|
|
274
287
|
catch (err) {
|
|
275
288
|
this.dirty = true; // 失败要能重试,否则这批变更永远落不了盘
|
|
276
289
|
throw err;
|
|
277
290
|
}
|
|
278
291
|
}
|
|
279
|
-
/**
|
|
292
|
+
/** 两张表的全量快照:每条记录一条 put,墓碑各一条 del */
|
|
280
293
|
snapshot() {
|
|
281
|
-
const ops = [
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
294
|
+
const ops = [];
|
|
295
|
+
const tombstones = [];
|
|
296
|
+
for (const [namespace, rows] of [
|
|
297
|
+
[SESSIONS, this.sessions],
|
|
298
|
+
[PLACES, this.places],
|
|
299
|
+
]) {
|
|
300
|
+
for (const [key, data] of rows) {
|
|
301
|
+
ops.push({ op: 'put', namespace, key, data: data });
|
|
302
|
+
}
|
|
303
|
+
for (const [key, token] of this.deleted[namespace]) {
|
|
304
|
+
tombstones.push([namespace, key, token]);
|
|
305
|
+
if (!rows.has(key))
|
|
306
|
+
ops.push({ op: 'del', namespace, key });
|
|
307
|
+
}
|
|
291
308
|
}
|
|
292
309
|
return { ops, tombstones };
|
|
293
310
|
}
|
|
294
311
|
/**
|
|
295
|
-
* 按精确 id 幂等 upsert:命中走 updateSession
|
|
296
|
-
*
|
|
297
|
-
*
|
|
312
|
+
* 按精确 id 幂等 upsert:命中走 updateSession(emit `session:updated`),未命中以传入 id 建档。
|
|
313
|
+
* 创建来源在建档时写;命中按 id 兜底建档留下的空记录({@link isUnfiled})时补写,随这次更新落盘。
|
|
314
|
+
* 终端配置的会话 id、在收录不了的会话上写设置时用
|
|
315
|
+
*/
|
|
316
|
+
async ensureSession(id, patch = {}, creation) {
|
|
317
|
+
const session = this.sessions.get(id);
|
|
318
|
+
if (!session)
|
|
319
|
+
return this.insert(id, patch, creation ? { createdFrom: creation.createdFrom } : {});
|
|
320
|
+
if (creation && isUnfiled(session))
|
|
321
|
+
session.createdFrom = creation.createdFrom;
|
|
322
|
+
return this.updateSession(id, patch);
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* IM 房间收录:按原生入站记下的出生平台与聊天窗口建档,名字取出生平台给的群名或昵称(没有时不取名)。只给会话表里
|
|
326
|
+
* 还没有的 id 同步调用。场所是窗口的同时确保窗口的场所记录:insert 在第一次等待之前已同步占住 id、记下来源字段
|
|
327
|
+
*/
|
|
328
|
+
async recordRoom(id, nativeName, origin) {
|
|
329
|
+
const [created] = await Promise.all([
|
|
330
|
+
this.insert(id, { name: nativeName, status: 'waiting' }, origin),
|
|
331
|
+
this.noteWindow(id, nativeName),
|
|
332
|
+
]);
|
|
333
|
+
return created;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* IM 房间收录的补记:按 id 兜底建档的根会话(没有来源字段、创建来源与绑定)与升级前只记了出生平台的记录(没有聊天窗口,
|
|
337
|
+
* 出生平台与消息平台相同)收到原生入站时,记下与按这条消息建档时一样的来源字段,还没有名字的一并补上,场所是窗口的
|
|
338
|
+
* 确保窗口的场所记录。只给表里这条记录同步调用,由收录监听判定这个 id 归消息平台的适配器管
|
|
298
339
|
*/
|
|
299
|
-
async
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
340
|
+
async backfillRoom(session, nativeName, origin) {
|
|
341
|
+
Object.assign(session, origin, session.name === undefined && nativeName ? { name: nativeName } : {});
|
|
342
|
+
Object.assign(session, describeSession(session));
|
|
343
|
+
await this.noteWindow(session.id, nativeName);
|
|
344
|
+
this.markDirty();
|
|
345
|
+
await this.caps.events.use.emit('session:updated', session);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* 原生入站之后的窗口场所记录:会话的场所是窗口时,没有记录就建(显示名取出生平台给的名字,没有时为场所键),有记录而
|
|
349
|
+
* 名字变了就跟着改。场所是工作区的(owner 的 IM 私聊)不建。建了或改了名,给场所是这个窗口的根会话各发一次
|
|
350
|
+
* session:updated:会话的摘要在生成时取场所的显示名,不说话的群改了名也要刷新
|
|
351
|
+
*/
|
|
352
|
+
async noteWindow(sessionId, nativeName) {
|
|
353
|
+
const place = this.placeOf(sessionId);
|
|
354
|
+
if (place.kind !== 'window')
|
|
355
|
+
return;
|
|
356
|
+
const record = this.places.get(place.key);
|
|
357
|
+
if (!record) {
|
|
358
|
+
this.places.set(place.key, newPlace(place, nativeName || place.key));
|
|
359
|
+
}
|
|
360
|
+
else if (nativeName && record.name !== nativeName) {
|
|
361
|
+
record.name = nativeName;
|
|
362
|
+
record.updatedAt = Date.now();
|
|
304
363
|
}
|
|
364
|
+
else
|
|
365
|
+
return;
|
|
366
|
+
this.markDirty();
|
|
367
|
+
for (const session of [...this.sessions.values()]) {
|
|
368
|
+
if (!session.parentId && this.placeOf(session.id).key === place.key) {
|
|
369
|
+
await this.caps.events.use.emit('session:updated', session);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* 以传入 id 建档(emit `session:created`),fields 是只在建档时写的字段。状态缺省按有没有回合在跑:有为 active
|
|
375
|
+
* (回合结束时收口),没有为 waiting(指令回复等回合之外的出站不改状态,建成 active 就会一直停在进行中)
|
|
376
|
+
*/
|
|
377
|
+
async insert(id, patch, fields) {
|
|
305
378
|
const now = Date.now();
|
|
306
379
|
const session = {
|
|
307
380
|
id,
|
|
308
|
-
name: patch.name ||
|
|
309
|
-
`会话 ${new Date().toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}`,
|
|
310
|
-
title: patch.metadata?.title,
|
|
381
|
+
name: patch.name || undefined,
|
|
311
382
|
parentId: undefined,
|
|
312
383
|
children: [],
|
|
313
|
-
status: patch.status || 'active',
|
|
314
|
-
config: patch.config || {},
|
|
384
|
+
status: patch.status || (this.hasTurnInFlight(id) ? 'active' : 'waiting'),
|
|
315
385
|
createdAt: now,
|
|
316
386
|
updatedAt: now,
|
|
317
|
-
createdBy: patch.createdBy || 'user',
|
|
318
|
-
inputContext: patch.metadata?.inputContext,
|
|
319
387
|
metadata: patch.metadata,
|
|
388
|
+
...fields,
|
|
389
|
+
...describeSession(fields),
|
|
320
390
|
};
|
|
321
391
|
this.sessions.set(id, session);
|
|
322
392
|
this.markDirty();
|
|
323
|
-
await this.caps.events.emit('session:created', session);
|
|
324
|
-
this.caps.logger.info(`会话建档(ensure): ${session
|
|
393
|
+
await this.caps.events.use.emit('session:created', session);
|
|
394
|
+
this.caps.logger.use.info(`会话建档(ensure): ${toSource(sessionLabel(session))} (${id})`);
|
|
325
395
|
return session;
|
|
326
396
|
}
|
|
397
|
+
/**
|
|
398
|
+
* 原生入站的收录与补记。会话表里还没有记录的真人入站(不带 source)按这条消息建档:出生平台取消息的平台,聊天窗口按这个
|
|
399
|
+
* 平台的类型声明经 chatScopeOf 记下,之后不变。WebUI 与终端的会话在入站之前已建档、带创建来源,走快路径。带 source 的是
|
|
400
|
+
* 内部注入(工作流、定时任务、空闲开话题、宿主通知、好友申请与入群邀请的合成通知),不登记;子会话按 parentId 不登记(recorded)。
|
|
401
|
+
* 消息带的类型名不在平台的声明里时按多方记下,每个平台与类型名告警一次;消息没带类型名、按平台声明的第一个多方类型记下时,
|
|
402
|
+
* 每个平台告警一次。按 id 兜底建档的根会话(注入回合在未登记的房间开子任务)没有来源字段,升级前的记录只记了出生平台、没有
|
|
403
|
+
* 聊天窗口,两者都由第一条归它的适配器管的原生入站补记(backfillRoom;后者只认出生平台的入站)。
|
|
404
|
+
* 收录与补记之前问遍认领这个 id 的适配器(claimantsOf),第一个认领者随来源字段记作所属实例。认领它的平台不止一个
|
|
405
|
+
* (如终端配置的会话 id 配成了群的 id)时判不出它是谁的窗口,不收录、不补记,每个 id 告警一次:结果不能取决于
|
|
406
|
+
* 适配器的登记先后。问的期间别的入站可能已收录,问完按会话表此刻的记录判,再建档或补记。同一会话并发的调用共用这一次。
|
|
407
|
+
* 名字只采信出生平台自己的入站:多方取群名、一对一取对方昵称,缺省不取名(群的首条入站可能是不带群名的戳一戳,
|
|
408
|
+
* 不回退到昵称)。没有名字时由后到的原生入站补上,起过的名字不动;补记不成、仍没有聊天窗口的记录判不出该取哪个,不补。
|
|
409
|
+
* 场所是窗口的同时建窗口的场所记录,它的显示名随出生平台的入站更新(owner 的 IM 私聊在默认工作区,不建)。
|
|
410
|
+
* 已登记、还没记下所属实例的房间,每条真人入站都再解析一次,认领到就补记。不会拒绝:收录失败只告警。
|
|
411
|
+
* 这个会话正在删除时(带 source 的也一样)先等删除做完,问认领者的期间开始删除的同样等删完再从头问:删除期间到的是新的
|
|
412
|
+
* 一代,不被这次删除停下、清掉
|
|
413
|
+
*/
|
|
414
|
+
recordInbound(msg) {
|
|
415
|
+
const { sessionId } = msg;
|
|
416
|
+
const removing = this.removing.get(sessionId);
|
|
417
|
+
if (removing)
|
|
418
|
+
return removing.then(() => this.recordInbound(msg));
|
|
419
|
+
if (!sessionId || msg.source)
|
|
420
|
+
return Promise.resolve();
|
|
421
|
+
const known = this.sessions.get(sessionId);
|
|
422
|
+
if (this.recorded(known, msg)) {
|
|
423
|
+
this.noteRecorded(known, msg);
|
|
424
|
+
return Promise.resolve();
|
|
425
|
+
}
|
|
426
|
+
const pending = this.recording.get(sessionId);
|
|
427
|
+
if (pending)
|
|
428
|
+
return pending;
|
|
429
|
+
const recording = this.record(msg)
|
|
430
|
+
.catch(err => this.caps.logger.use.warn(`IM 房间登记失败 (${sessionId}):`, err))
|
|
431
|
+
.finally(() => this.recording.delete(sessionId));
|
|
432
|
+
this.recording.set(sessionId, recording);
|
|
433
|
+
return recording;
|
|
434
|
+
}
|
|
435
|
+
generationOf(sessionId) {
|
|
436
|
+
return this.generations.get(sessionId) ?? 0;
|
|
437
|
+
}
|
|
438
|
+
/** 不经这条入站收录或补记的会话:子会话(没有窗口),有聊天窗口、创建来源或绑定的,与出生平台不是消息平台的 */
|
|
439
|
+
recorded(session, msg) {
|
|
440
|
+
return (session !== undefined &&
|
|
441
|
+
(Boolean(session.parentId || session.originChat || session.createdFrom || session.work) ||
|
|
442
|
+
(session.originPlatform ?? msg.platform) !== msg.platform));
|
|
443
|
+
}
|
|
444
|
+
/** 已收录的会话:出生平台的入站补名、更新窗口的显示名,还没记下所属实例的再解析一次(都不等) */
|
|
445
|
+
noteRecorded(session, msg) {
|
|
446
|
+
const chat = session.originChat;
|
|
447
|
+
if (chat && session.originPlatform === msg.platform) {
|
|
448
|
+
const nativeName = chat.multiParty ? msg.groupName : msg.nickname;
|
|
449
|
+
if (nativeName && session.name === undefined)
|
|
450
|
+
this.updateSession(session.id, { name: nativeName }).catch(() => { });
|
|
451
|
+
void this.noteWindow(session.id, nativeName);
|
|
452
|
+
}
|
|
453
|
+
void this.recordOriginInstance(session);
|
|
454
|
+
}
|
|
455
|
+
/** 问完认领者再收录或补记,见 {@link recordInbound} */
|
|
456
|
+
async record(msg) {
|
|
457
|
+
const { sessionId } = msg;
|
|
458
|
+
const claimants = await claimantsOf(this.caps.platform, sessionId, this.caps.logger.use);
|
|
459
|
+
// 问的期间开始删除这个会话的,与入口同一条规则:等删除做完再从头问
|
|
460
|
+
const removing = this.removing.get(sessionId);
|
|
461
|
+
if (removing)
|
|
462
|
+
return removing.then(() => this.record(msg));
|
|
463
|
+
const session = this.sessions.get(sessionId);
|
|
464
|
+
if (this.recorded(session, msg)) {
|
|
465
|
+
this.noteRecorded(session, msg);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const platforms = [...new Set(claimants.map(({ use: instance }) => instance.platform))];
|
|
469
|
+
if (platforms.length > 1) {
|
|
470
|
+
this.warnOnce(`会话 ${sessionId} 同时由平台「${platforms.join('」「')}」认领:判不出它是哪个平台的聊天窗口,不收录。` +
|
|
471
|
+
'改配置,一个会话 id 只归一个平台', `claim:${sessionId}`);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
// 补记只认这个 id 归消息平台的适配器管的入站:WebUI 与终端往房间插话不算
|
|
475
|
+
if (session && platforms[0] !== msg.platform)
|
|
476
|
+
return;
|
|
477
|
+
const adapter = getPlatformAdapters(this.caps.platform).find(a => a.platform === msg.platform);
|
|
478
|
+
const scope = chatScopeOf(msg, sessionTypesOf(adapter));
|
|
479
|
+
if (scope.undeclared) {
|
|
480
|
+
this.warnOnce(`平台「${scope.platform}」没有声明类型「${scope.type}」(${sessionId}):这个聊天窗口按多方记下。` +
|
|
481
|
+
'适配器要在 sessionTypes 里声明它发出的类型名', `type:${scope.platform}:${scope.type}`);
|
|
482
|
+
}
|
|
483
|
+
else if (msg.sessionType === undefined && scope.multiParty) {
|
|
484
|
+
this.warnOnce(`平台「${scope.platform}」的消息没带类型名(${sessionId}):这个聊天窗口按类型「${scope.type}」、多方记下。` +
|
|
485
|
+
'适配器要在消息上带类型名', `untyped:${scope.platform}`);
|
|
486
|
+
}
|
|
487
|
+
const originChat = {
|
|
488
|
+
type: scope.type,
|
|
489
|
+
multiParty: scope.multiParty,
|
|
490
|
+
...(msg.selfId === undefined ? {} : { selfId: msg.selfId }),
|
|
491
|
+
targetId: scope.targetId,
|
|
492
|
+
};
|
|
493
|
+
const nativeName = scope.multiParty ? msg.groupName : msg.nickname;
|
|
494
|
+
const origin = {
|
|
495
|
+
originPlatform: msg.platform,
|
|
496
|
+
originChat,
|
|
497
|
+
...(claimants[0] ? { originInstance: claimants[0].id } : {}),
|
|
498
|
+
};
|
|
499
|
+
if (session)
|
|
500
|
+
await this.backfillRoom(session, nativeName, origin);
|
|
501
|
+
else
|
|
502
|
+
await this.recordRoom(sessionId, nativeName, origin);
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* 有出生平台的房间还没记下接管它的适配器实例时,解析并记下,会话列表按它分组;已记下的不改,子任务与 owner 面
|
|
506
|
+
* 会话不记。收录与补记时实例随来源字段一起记下(收录之前已解析);收录时没有适配器认领(适配器没装好、连接还没上来、
|
|
507
|
+
* 账号暂时不属于任何实例)的房间与读表读进来的旧房间,由之后的真人入站调它补记。解析期间会话被删除或换表的不再写回,
|
|
508
|
+
* 同一条记录的解析不并发。不会拒绝。
|
|
509
|
+
*/
|
|
510
|
+
async recordOriginInstance(session) {
|
|
511
|
+
if (session.kind !== 'room' || session.originInstance || this.resolving.has(session))
|
|
512
|
+
return;
|
|
513
|
+
this.resolving.add(session);
|
|
514
|
+
try {
|
|
515
|
+
const entry = await resolvePlatformBySession(this.caps.platform, session.id, this.caps.logger.use);
|
|
516
|
+
if (!entry || this.sessions.get(session.id) !== session)
|
|
517
|
+
return;
|
|
518
|
+
session.originInstance = entry.id;
|
|
519
|
+
this.markDirty();
|
|
520
|
+
await this.caps.events.use.emit('session:updated', session);
|
|
521
|
+
}
|
|
522
|
+
finally {
|
|
523
|
+
this.resolving.delete(session);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
// ---- 取别的会话的东西 ----
|
|
527
|
+
canTake(currentId, targetId, principal) {
|
|
528
|
+
if (currentId === targetId)
|
|
529
|
+
return { ok: true };
|
|
530
|
+
const current = rootSessionOf(this.sessions.get(currentId), this.lookup);
|
|
531
|
+
const target = rootSessionOf(this.sessions.get(targetId), this.lookup);
|
|
532
|
+
// 同一棵会话树:两个判不出的根会话(都是 undefined)不算相同
|
|
533
|
+
if (current && current === target)
|
|
534
|
+
return { ok: true };
|
|
535
|
+
const from = platformOf(current);
|
|
536
|
+
const to = platformOf(target);
|
|
537
|
+
if (!current || !target || from === undefined || to === undefined)
|
|
538
|
+
return { ok: false, reason: TAKE_UNKNOWN };
|
|
539
|
+
const values = this.take.get(currentId, { platform: from });
|
|
540
|
+
// 范围写坏了的按本会话
|
|
541
|
+
if (values.takeScope !== 'platform' && values.takeScope !== 'all')
|
|
542
|
+
return { ok: false, reason: t('scopeSession') };
|
|
543
|
+
if (values.takeScope === 'platform' && from !== to)
|
|
544
|
+
return { ok: false, reason: t('scopePlatform') };
|
|
545
|
+
// 机器人账号只在同一平台里有意义:跨平台由范围管
|
|
546
|
+
const ownBot = current.originChat?.selfId;
|
|
547
|
+
const chat = target.originChat;
|
|
548
|
+
const otherBot = from === to && ownBot !== undefined && chat?.selfId !== undefined && chat.selfId !== ownBot;
|
|
549
|
+
if (otherBot && values.takeCrossBot !== true)
|
|
550
|
+
return { ok: false, reason: t('crossBot') };
|
|
551
|
+
if (chat?.multiParty) {
|
|
552
|
+
return values.takeOtherGroups === true ? { ok: true } : { ok: false, reason: t('otherGroups') };
|
|
553
|
+
}
|
|
554
|
+
if (this.ownChat(target, principal))
|
|
555
|
+
return { ok: true };
|
|
556
|
+
return values.takeOthersPrivate === true ? { ok: true } : { ok: false, reason: t('othersPrivate') };
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* 一对一的目标(根会话)是不是授权身份自己与她的私聊。owner 的面按与 owner 的一对一,看授权身份是不是 owner;IM 窗口按
|
|
560
|
+
* oneToOneWith,出生平台的适配器(与收录时判一对一用的同一个)明确声明了它的类型为一对一才算。权限服务不在时没有人是 owner
|
|
561
|
+
*/
|
|
562
|
+
ownChat(root, principal) {
|
|
563
|
+
const authority = this.caps.authority.present ? this.caps.authority.use : undefined;
|
|
564
|
+
const isOwner = authority && ((platform, userId) => authority.isOwner(platform, userId));
|
|
565
|
+
if (root.originPlatform === undefined) {
|
|
566
|
+
return Boolean(principal?.userId && isOwner?.(principal.platform, principal.userId));
|
|
567
|
+
}
|
|
568
|
+
const adapter = getPlatformAdapters(this.caps.platform).find(a => a.platform === root.originPlatform);
|
|
569
|
+
const declared = adapter?.sessionTypes?.find(type => type.name === root.originChat?.type)?.multiParty === false;
|
|
570
|
+
return oneToOneWith(root, principal, declared, isOwner);
|
|
571
|
+
}
|
|
327
572
|
// ---- CRUD ----
|
|
328
|
-
async createSession(opts) {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
573
|
+
async createSession(opts, creation) {
|
|
574
|
+
if (creation && opts?.parentId)
|
|
575
|
+
throw new Error('建档专用参数只给根会话:子会话按根会话取创建来源、所属工作区与绑定');
|
|
576
|
+
const created = creation ? this.creationFields(creation) : {};
|
|
577
|
+
// 子会话的 id 同样不带父会话的 id:平台按会话 id 认领窗口,子会话没有窗口。撞上已有的会话就重抽
|
|
578
|
+
let id;
|
|
579
|
+
do
|
|
580
|
+
id = `session-${crypto.randomUUID().slice(0, 8)}`;
|
|
581
|
+
while (this.sessions.has(id));
|
|
582
|
+
const parent = opts?.parentId ? this.sessions.get(opts.parentId) : undefined;
|
|
332
583
|
const now = Date.now();
|
|
333
584
|
const session = {
|
|
334
585
|
id,
|
|
335
|
-
name: opts?.name ||
|
|
336
|
-
|
|
337
|
-
title: opts?.metadata?.title,
|
|
586
|
+
name: opts?.name || undefined,
|
|
587
|
+
title: opts?.title,
|
|
338
588
|
parentId: opts?.parentId,
|
|
339
589
|
children: [],
|
|
340
590
|
status: opts?.status || 'active',
|
|
341
|
-
config: opts?.config || {},
|
|
342
591
|
createdAt: now,
|
|
343
592
|
updatedAt: now,
|
|
344
|
-
|
|
345
|
-
inputContext: opts?.inputContext ?? opts?.metadata?.inputContext,
|
|
593
|
+
inputContext: opts?.inputContext,
|
|
346
594
|
metadata: opts?.metadata,
|
|
595
|
+
...created,
|
|
596
|
+
// 子会话的当前目录取父会话此刻的值,之后各改各的
|
|
597
|
+
...(parent?.currentDir === undefined ? {} : { currentDir: parent.currentDir }),
|
|
598
|
+
...describeSession({ parentId: opts?.parentId }),
|
|
347
599
|
};
|
|
348
600
|
this.sessions.set(id, session);
|
|
349
|
-
//
|
|
350
|
-
if (
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
parent.children.push(id);
|
|
354
|
-
parent.updatedAt = now;
|
|
355
|
-
}
|
|
601
|
+
// 挂到父会话的 children 下:父会话的子会话列表变了,建档之后再给它发一次 session:updated
|
|
602
|
+
if (parent) {
|
|
603
|
+
parent.children.push(id);
|
|
604
|
+
parent.updatedAt = now;
|
|
356
605
|
}
|
|
357
606
|
this.markDirty();
|
|
358
|
-
await this.caps.events.emit('session:created', session);
|
|
359
|
-
|
|
607
|
+
await this.caps.events.use.emit('session:created', session);
|
|
608
|
+
if (parent)
|
|
609
|
+
await this.caps.events.use.emit('session:updated', parent);
|
|
610
|
+
this.caps.logger.use.info(`会话创建: ${toSource(sessionLabel(session))} (${id})`);
|
|
360
611
|
return session;
|
|
361
612
|
}
|
|
613
|
+
/**
|
|
614
|
+
* 建档专用参数落成会话字段:工作区要有记录,项目工作区(有目录的)要带绑定,没有目录的不收绑定;默认工作区不记
|
|
615
|
+
* (缺省即默认工作区)。建档不写当前目录:没有值即场所的主目录
|
|
616
|
+
*/
|
|
617
|
+
creationFields({ createdFrom, workspace = DEFAULT_WORKSPACE, work, }) {
|
|
618
|
+
const record = this.places.get(`${WORKSPACE_PREFIX}${workspace}`);
|
|
619
|
+
if (!record)
|
|
620
|
+
throw new Error(`工作区不存在:${workspace}`);
|
|
621
|
+
if (record.root !== undefined && !work)
|
|
622
|
+
throw new Error(`在项目工作区里建会话要带绑定目录:${workspace}`);
|
|
623
|
+
if (record.root === undefined && work)
|
|
624
|
+
throw new Error(`绑定目录只给项目工作区里的会话:${workspace}`);
|
|
625
|
+
return {
|
|
626
|
+
createdFrom,
|
|
627
|
+
...(workspace === DEFAULT_WORKSPACE ? {} : { workspace }),
|
|
628
|
+
...(work ? { work: { root: work.root, extraRoots: [], worktrees: [] } } : {}),
|
|
629
|
+
};
|
|
630
|
+
}
|
|
362
631
|
getSession(id) {
|
|
363
632
|
return this.sessions.get(id);
|
|
364
633
|
}
|
|
@@ -389,37 +658,76 @@ class SessionManager {
|
|
|
389
658
|
session.status = updates.status;
|
|
390
659
|
if (updates.metadata !== undefined)
|
|
391
660
|
session.metadata = { ...session.metadata, ...updates.metadata };
|
|
392
|
-
if (updates.config !== undefined) {
|
|
393
|
-
// 合并配置而不是替换
|
|
394
|
-
session.config = { ...session.config, ...updates.config };
|
|
395
|
-
}
|
|
396
661
|
session.updatedAt = Date.now();
|
|
397
662
|
this.markDirty();
|
|
398
|
-
await this.caps.events.emit('session:updated', session);
|
|
663
|
+
await this.caps.events.use.emit('session:updated', session);
|
|
399
664
|
return session;
|
|
400
665
|
}
|
|
401
666
|
async deleteSession(id) {
|
|
667
|
+
await this.removeAll([id]);
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* 逐个删掉这些会话(各连同下面各级子会话),返回删掉的会话数。哪一处抛错都不拦住其余的,全部做完再交出:一处原样交出,几处
|
|
671
|
+
* 合成一个挂着给人看的文字的 AggregateError(各级的摊平成一层)
|
|
672
|
+
*/
|
|
673
|
+
async removeAll(ids) {
|
|
674
|
+
const failures = [];
|
|
675
|
+
let removed = 0;
|
|
676
|
+
for (const id of ids)
|
|
677
|
+
removed += await this.remove(id, failures);
|
|
678
|
+
if (failures.length === 1)
|
|
679
|
+
throw failures[0];
|
|
680
|
+
if (failures.length > 1) {
|
|
681
|
+
const text = t('deleteFailed', { count: failures.length });
|
|
682
|
+
throw withText(new AggregateError(failures, text.source), text);
|
|
683
|
+
}
|
|
684
|
+
return removed;
|
|
685
|
+
}
|
|
686
|
+
/** 删掉一个会话连同下面各级子会话,返回删掉的会话数(不在表里的算 0);抛错带会话 id 告警、收进 failures,自己不抛 */
|
|
687
|
+
async remove(id, failures) {
|
|
402
688
|
const session = this.sessions.get(id);
|
|
403
689
|
if (!session)
|
|
404
|
-
return;
|
|
405
|
-
//
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
if (parent) {
|
|
413
|
-
parent.children = parent.children.filter(c => c !== id);
|
|
414
|
-
parent.updatedAt = Date.now();
|
|
415
|
-
}
|
|
690
|
+
return 0;
|
|
691
|
+
// 开始删除,同一步:从表里摘掉(父会话的子会话列表变了,删除之后再给它发一次 session:updated)、换代号。这之后到的同一会话的
|
|
692
|
+
// 入站等删除做完(见 recordInbound),是新的一代;这之前已收录、还在半路上的是旧的一代,网关交给 agent 之前、agent 交给通道
|
|
693
|
+
// 之前比代号放掉、不归档
|
|
694
|
+
const parent = session.parentId ? this.sessions.get(session.parentId) : undefined;
|
|
695
|
+
if (parent) {
|
|
696
|
+
parent.children = parent.children.filter(c => c !== id);
|
|
697
|
+
parent.updatedAt = Date.now();
|
|
416
698
|
}
|
|
417
699
|
this.sessions.delete(id);
|
|
418
|
-
this.deleted.set(id, Symbol(id));
|
|
419
|
-
await this.clearDeletedSessionData(id);
|
|
700
|
+
this.deleted[SESSIONS].set(id, Symbol(id));
|
|
420
701
|
this.markDirty();
|
|
421
|
-
|
|
422
|
-
this.
|
|
702
|
+
let done;
|
|
703
|
+
this.removing.set(id, new Promise(resolve => (done = resolve)));
|
|
704
|
+
this.generations.set(id, this.generationOf(id) + 1);
|
|
705
|
+
// 整个删除交给 agent 的 clearing:它先按住这个会话、停下在跑的一轮并等它收尾,按住到清完,删子会话期间这个会话也不开轮。先删
|
|
706
|
+
// 各级子会话(含活跃子会话),再发事件、清数据:监听方在事件里停掉这个会话在跑的东西,清数据时就不会有中途写回来的。子会话的
|
|
707
|
+
// 抛错收进 failures,不拦住余下的子会话与这个会话自己的发事件、清数据、给上一级发 session:updated:这个会话已摘掉,停在半路
|
|
708
|
+
// 的话它的数据不清,余下子会话的 parentId 悬空,也没法再删一次
|
|
709
|
+
let removed = 1;
|
|
710
|
+
const finish = async () => {
|
|
711
|
+
for (const childId of [...session.children])
|
|
712
|
+
removed += await this.remove(childId, failures);
|
|
713
|
+
await this.caps.events.use.emit('session:deleted', id);
|
|
714
|
+
await this.clearDeletedSessionData(id);
|
|
715
|
+
};
|
|
716
|
+
try {
|
|
717
|
+
await (this.caps.agent.present ? this.caps.agent.use.clearing([id], finish, '会话删除') : finish());
|
|
718
|
+
}
|
|
719
|
+
catch (err) {
|
|
720
|
+
this.caps.logger.use.warn(`会话删除出错 [${id}]:`, err);
|
|
721
|
+
failures.push(err);
|
|
722
|
+
}
|
|
723
|
+
finally {
|
|
724
|
+
this.removing.delete(id);
|
|
725
|
+
done();
|
|
726
|
+
}
|
|
727
|
+
if (parent)
|
|
728
|
+
await this.caps.events.use.emit('session:updated', parent);
|
|
729
|
+
this.caps.logger.use.info(`会话删除: ${toSource(sessionLabel(session))} (${id})`);
|
|
730
|
+
return removed;
|
|
423
731
|
}
|
|
424
732
|
async clearDeletedSessionData(id) {
|
|
425
733
|
const clearData = {
|
|
@@ -427,7 +735,7 @@ class SessionManager {
|
|
|
427
735
|
sessionId: id,
|
|
428
736
|
results: [],
|
|
429
737
|
};
|
|
430
|
-
await this.caps.hooks.run('memory:clear', clearData, async () => {
|
|
738
|
+
await this.caps.hooks.use.run('memory:clear', clearData, async () => {
|
|
431
739
|
try {
|
|
432
740
|
await this.memory.clearSession(id);
|
|
433
741
|
clearData.results.push({ source: 'memory', success: true, message: '会话消息历史已清空' });
|
|
@@ -439,40 +747,148 @@ class SessionManager {
|
|
|
439
747
|
});
|
|
440
748
|
const failed = clearData.results.filter(r => !r.success);
|
|
441
749
|
if (failed.length > 0) {
|
|
442
|
-
this.caps.logger.warn(`会话数据清理存在失败项 [${id}]: ${failed.map(r => `${r.source}: ${r.message}`).join('; ')}`);
|
|
750
|
+
this.caps.logger.use.warn(`会话数据清理存在失败项 [${id}]: ${failed.map(r => `${r.source}: ${toSource(r.message)}`).join('; ')}`);
|
|
443
751
|
}
|
|
444
752
|
}
|
|
753
|
+
/**
|
|
754
|
+
* 删掉一个会话的全部子会话(连同下面各级),不给 parentId 即删掉全部子会话;会话不在表里时什么都不删。返回这次删掉的会话数
|
|
755
|
+
* (途中别处建的、删的会话不算)
|
|
756
|
+
*/
|
|
757
|
+
async deleteChildren(parentId) {
|
|
758
|
+
if (parentId !== undefined && !this.sessions.has(parentId))
|
|
759
|
+
return 0;
|
|
760
|
+
const targets = [...this.sessions.values()].filter(s => parentId === undefined ? s.parentId !== undefined : s.parentId === parentId);
|
|
761
|
+
return this.removeAll(targets.map(s => s.id));
|
|
762
|
+
}
|
|
445
763
|
// ---- 树形操作 ----
|
|
446
764
|
async createChildSession(parentId, opts) {
|
|
447
|
-
//
|
|
448
|
-
//
|
|
765
|
+
// 带 source 的注入回合(定时任务、编排)可能落在还没建档的会话上:先按 id 兜底建档再挂子会话,否则 create_subtask
|
|
766
|
+
// 在这些会话里必败。兜底建档的会话没有来源字段、创建来源与名字:是房间的由之后的原生入站补记来源字段与名字
|
|
449
767
|
if (!this.sessions.has(parentId))
|
|
450
768
|
await this.ensureSession(parentId);
|
|
451
|
-
return this.createSession({
|
|
452
|
-
...opts,
|
|
453
|
-
parentId,
|
|
454
|
-
createdBy: opts?.createdBy || 'agent',
|
|
455
|
-
});
|
|
769
|
+
return this.createSession({ ...opts, parentId });
|
|
456
770
|
}
|
|
457
771
|
getChildren(parentId) {
|
|
458
772
|
return this.listSessions({ parentId });
|
|
459
773
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
774
|
+
// ---- 当前目录与项目会话的绑定 ----
|
|
775
|
+
async setCurrentDir(sessionId, dir) {
|
|
776
|
+
const session = this.sessions.get(sessionId);
|
|
777
|
+
if (!session)
|
|
778
|
+
throw new Error(`会话不存在: ${sessionId}`);
|
|
779
|
+
session.currentDir = dir;
|
|
780
|
+
this.markDirty();
|
|
781
|
+
await this.caps.events.use.emit('session:updated', session);
|
|
782
|
+
}
|
|
783
|
+
async setExtraRoots(sessionId, extraRoots) {
|
|
784
|
+
const { root, work } = this.inWorkTree(sessionId);
|
|
785
|
+
work.extraRoots = [...extraRoots];
|
|
786
|
+
this.markDirty();
|
|
787
|
+
await this.caps.events.use.emit('session:updated', root);
|
|
788
|
+
}
|
|
789
|
+
async addWorktree(sessionId, path) {
|
|
790
|
+
const { root, work } = this.inWorkTree(sessionId);
|
|
791
|
+
if (work.worktrees.includes(path))
|
|
792
|
+
return;
|
|
793
|
+
work.worktrees.push(path);
|
|
794
|
+
this.markDirty();
|
|
795
|
+
await this.caps.events.use.emit('session:updated', root);
|
|
796
|
+
}
|
|
797
|
+
/** 项目会话树的根会话与它的绑定;会话不存在或不在项目会话树里时抛错 */
|
|
798
|
+
inWorkTree(sessionId) {
|
|
799
|
+
const session = this.sessions.get(sessionId);
|
|
800
|
+
if (!session)
|
|
801
|
+
throw new Error(`会话不存在: ${sessionId}`);
|
|
802
|
+
const root = rootSessionOf(session, this.lookup);
|
|
803
|
+
if (!root?.work)
|
|
804
|
+
throw new Error(`会话 ${sessionId} 不在项目会话里:根会话没有绑定目录`);
|
|
805
|
+
return { root, work: root.work };
|
|
806
|
+
}
|
|
807
|
+
// ---- 场所与工作区 ----
|
|
808
|
+
placeOf(sessionId) {
|
|
809
|
+
const root = rootSessionOf(this.sessions.get(sessionId), this.lookup);
|
|
810
|
+
if (root?.originPlatform !== undefined) {
|
|
811
|
+
const chat = root.originChat;
|
|
812
|
+
const targetId = chat?.targetId ?? '';
|
|
813
|
+
// owner 的 IM 私聊是默认工作区的入口;没有 authority 时认不出 owner,按窗口算(与房间类别的失败关闭同向)
|
|
814
|
+
const owner = targetId !== '' &&
|
|
815
|
+
!chat?.multiParty &&
|
|
816
|
+
(this.caps.authority.present ? this.caps.authority.use.isOwner(root.originPlatform, targetId) : undefined);
|
|
817
|
+
if (owner)
|
|
818
|
+
return DEFAULT_PLACE;
|
|
819
|
+
const key = windowKey({ platform: root.originPlatform, type: chat?.type ?? '', targetId });
|
|
820
|
+
// 聊天窗口缺失或没有目标的坏数据同样是窗口:落进默认工作区会把 owner 给工作区设的值套到房间上
|
|
821
|
+
if (targetId === '')
|
|
822
|
+
this.warnOnce(`会话 ${root.id} 有出生平台却没有聊天窗口或目标,按窗口 ${key} 算`);
|
|
823
|
+
return { kind: 'window', key };
|
|
824
|
+
}
|
|
825
|
+
if (root?.workspace === undefined)
|
|
826
|
+
return DEFAULT_PLACE;
|
|
827
|
+
const key = `${WORKSPACE_PREFIX}${root.workspace}`;
|
|
828
|
+
if (this.places.has(key))
|
|
829
|
+
return { kind: 'workspace', key };
|
|
830
|
+
this.warnOnce(`会话 ${root.id} 记下的工作区 ${root.workspace} 没有记录,按默认工作区`);
|
|
831
|
+
return DEFAULT_PLACE;
|
|
832
|
+
}
|
|
833
|
+
getPlace(key) {
|
|
834
|
+
const record = this.places.get(key);
|
|
835
|
+
return record && placeInfo(record);
|
|
836
|
+
}
|
|
837
|
+
listPlaces() {
|
|
838
|
+
return [...this.places.keys()]
|
|
839
|
+
.sort()
|
|
840
|
+
.sort(defaultFirst)
|
|
841
|
+
.map(key => placeInfo(this.places.get(key)));
|
|
842
|
+
}
|
|
843
|
+
async updatePlace(key, updates) {
|
|
844
|
+
const record = this.places.get(key);
|
|
845
|
+
if (!record)
|
|
846
|
+
throw new Error(`场所不存在:${key}`);
|
|
847
|
+
if (updates.metadata !== undefined)
|
|
848
|
+
record.metadata = { ...record.metadata, ...updates.metadata };
|
|
849
|
+
record.updatedAt = Date.now();
|
|
850
|
+
this.markDirty();
|
|
851
|
+
return placeInfo(record);
|
|
852
|
+
}
|
|
853
|
+
addWorkspace(root) {
|
|
854
|
+
const existing = [...this.places.values()].find(record => record.root === root);
|
|
855
|
+
if (existing)
|
|
856
|
+
return { workspace: workspaceInfo(existing), existed: true };
|
|
857
|
+
const record = newPlace({ kind: 'workspace', key: `${WORKSPACE_PREFIX}${crypto.randomUUID()}` }, basename(root), root);
|
|
858
|
+
this.places.set(record.key, record);
|
|
859
|
+
this.markDirty();
|
|
860
|
+
this.caps.logger.use.info(`登记工作区: ${record.name} (${record.key}) ${root}`);
|
|
861
|
+
return { workspace: workspaceInfo(record), existed: false };
|
|
862
|
+
}
|
|
863
|
+
listWorkspaces() {
|
|
864
|
+
return [...this.places.values()]
|
|
865
|
+
.filter(record => record.kind === 'workspace')
|
|
866
|
+
.sort((a, b) => defaultFirst(a.key, b.key) || a.createdAt - b.createdAt)
|
|
867
|
+
.map(workspaceInfo);
|
|
868
|
+
}
|
|
869
|
+
async removeWorkspace(id) {
|
|
870
|
+
if (id === DEFAULT_WORKSPACE)
|
|
871
|
+
throw new Error('默认工作区不能取消登记');
|
|
872
|
+
const key = `${WORKSPACE_PREFIX}${id}`;
|
|
873
|
+
const record = this.places.get(key);
|
|
874
|
+
if (!record)
|
|
875
|
+
throw new Error(`工作区不存在:${id}`);
|
|
876
|
+
// 先认出原属的会话树(按根会话记下的工作区),再一并清:根会话的所属工作区与绑定,整棵树的当前目录
|
|
877
|
+
const affected = [...this.sessions.values()].filter(s => rootSessionOf(s, this.lookup)?.workspace === id);
|
|
878
|
+
const changed = affected.filter(s => !s.parentId || s.currentDir !== undefined);
|
|
879
|
+
for (const session of affected) {
|
|
880
|
+
delete session.currentDir;
|
|
881
|
+
if (!session.parentId) {
|
|
882
|
+
delete session.workspace;
|
|
883
|
+
delete session.work;
|
|
884
|
+
}
|
|
473
885
|
}
|
|
474
|
-
|
|
475
|
-
|
|
886
|
+
this.places.delete(key);
|
|
887
|
+
this.deleted[PLACES].set(key, Symbol(key));
|
|
888
|
+
this.markDirty();
|
|
889
|
+
this.caps.logger.use.info(`取消登记工作区: ${record.name} (${key}),${affected.length} 个会话归默认工作区`);
|
|
890
|
+
for (const session of changed)
|
|
891
|
+
await this.caps.events.use.emit('session:updated', session);
|
|
476
892
|
}
|
|
477
893
|
// ---- 生命周期 ----
|
|
478
894
|
async completeSession(id, result) {
|
|
@@ -484,8 +900,8 @@ class SessionManager {
|
|
|
484
900
|
session.updatedAt = Date.now();
|
|
485
901
|
this.markDirty();
|
|
486
902
|
// 发事件通知(wait_subtasks 通过事件驱动感知完成)
|
|
487
|
-
await this.caps.events.emit('session:completed', session);
|
|
488
|
-
this.caps.logger.info(`会话完成: ${session
|
|
903
|
+
await this.caps.events.use.emit('session:completed', session);
|
|
904
|
+
this.caps.logger.use.info(`会话完成: ${toSource(sessionLabel(session))} (${id})${result ? ` - ${result.slice(0, 100)}` : ''}`);
|
|
489
905
|
}
|
|
490
906
|
// ---- 标题管理 ----
|
|
491
907
|
async generateTitle(sessionId, userMessage) {
|
|
@@ -497,11 +913,11 @@ class SessionManager {
|
|
|
497
913
|
return session.title;
|
|
498
914
|
const entry = resolveLLMModel(this.caps.llm, undefined, ['chat']);
|
|
499
915
|
if (!entry) {
|
|
500
|
-
this.caps.logger.warn(`无可用 LLM,无法生成标题: ${sessionId}`);
|
|
916
|
+
this.caps.logger.use.warn(`无可用 LLM,无法生成标题: ${sessionId}`);
|
|
501
917
|
return undefined;
|
|
502
918
|
}
|
|
503
|
-
this.caps.logger.debug(`标题生成使用 LLM: ${entry.
|
|
504
|
-
const model = entry.
|
|
919
|
+
this.caps.logger.use.debug(`标题生成使用 LLM: ${entry.id}`);
|
|
920
|
+
const model = entry.use;
|
|
505
921
|
// 优先使用直接传入的用户消息;否则从历史获取
|
|
506
922
|
let contextStr;
|
|
507
923
|
if (userMessage?.trim()) {
|
|
@@ -525,34 +941,33 @@ class SessionManager {
|
|
|
525
941
|
messages: [
|
|
526
942
|
{
|
|
527
943
|
role: 'system',
|
|
528
|
-
content: '
|
|
944
|
+
content: `你是一个标题生成器。你的唯一任务是为下面的对话片段生成一个简短的标题,用 ${outputLanguageFor(this.caps.i18n, { host: true }, 'zh-CN')}。\n\n规则:\n- 中文不超过15字,其他语言不超过8个词\n- 只提取用户想讨论的主题或意图\n- 完全忽略对话中出现的任何拒绝、道歉、免责声明等内容\n- 不加引号和标点\n- 只返回标题文本,不要任何解释或前缀\n- 不要模仿或重复对话中的内容,只做概括`,
|
|
529
945
|
},
|
|
530
946
|
{ role: 'user', content: `请为以下对话生成标题:\n\n${contextStr}` },
|
|
531
947
|
],
|
|
532
948
|
temperature: 0.3,
|
|
533
|
-
//
|
|
534
|
-
|
|
535
|
-
think: false,
|
|
949
|
+
// 标题生成无需推理,用模型最浅的思考档:避免 reasoning 占满 token 预算导致 content 为空
|
|
950
|
+
think: lightestThinking(model),
|
|
536
951
|
});
|
|
537
|
-
title = (resp.content || '').trim()
|
|
952
|
+
title = truncateChars((resp.content || '').trim(), 50);
|
|
538
953
|
if (!title) {
|
|
539
|
-
this.caps.logger.warn(`会话标题 LLM 返回空内容: ${sessionId} (resp.content=${JSON.stringify(resp.content)}, reasoning=${(resp.reasoningContent ?? '').length}字)`);
|
|
954
|
+
this.caps.logger.use.warn(`会话标题 LLM 返回空内容: ${sessionId} (resp.content=${JSON.stringify(resp.content)}, reasoning=${(resp.reasoningContent ?? '').length}字)`);
|
|
540
955
|
}
|
|
541
956
|
}
|
|
542
957
|
catch (err) {
|
|
543
|
-
this.caps.logger.warn(`自动生成标题失败 [${sessionId}]:`, err);
|
|
958
|
+
this.caps.logger.use.warn(`自动生成标题失败 [${sessionId}]:`, err);
|
|
544
959
|
}
|
|
545
960
|
// 兜底:LLM 失败或返回空时,用用户消息首段作为临时标题,避免会话永远没有标题
|
|
546
961
|
if (!title && userMessage?.trim()) {
|
|
547
|
-
title = userMessage.trim().replace(/\s+/g, ' ')
|
|
548
|
-
this.caps.logger.info(`使用用户消息兜底生成标题: ${sessionId} -> ${title}`);
|
|
962
|
+
title = truncateChars(userMessage.trim().replace(/\s+/g, ' '), 20);
|
|
963
|
+
this.caps.logger.use.info(`使用用户消息兜底生成标题: ${sessionId} -> ${title}`);
|
|
549
964
|
}
|
|
550
965
|
if (title) {
|
|
551
966
|
session.title = title;
|
|
552
967
|
session.updatedAt = Date.now();
|
|
553
968
|
this.markDirty();
|
|
554
|
-
await this.caps.events.emit('session:updated', session);
|
|
555
|
-
this.caps.logger.info(`会话标题已生成: [${sessionId}] ${title}`);
|
|
969
|
+
await this.caps.events.use.emit('session:updated', session);
|
|
970
|
+
this.caps.logger.use.info(`会话标题已生成: [${sessionId}] ${title}`);
|
|
556
971
|
return title;
|
|
557
972
|
}
|
|
558
973
|
return undefined;
|
|
@@ -564,101 +979,74 @@ class SessionManager {
|
|
|
564
979
|
session.title = title;
|
|
565
980
|
session.updatedAt = Date.now();
|
|
566
981
|
this.markDirty();
|
|
567
|
-
await this.caps.events.emit('session:updated', session);
|
|
982
|
+
await this.caps.events.use.emit('session:updated', session);
|
|
983
|
+
}
|
|
984
|
+
/** 同一处只告警一次:key 缺省为这句话本身 */
|
|
985
|
+
warnOnce(message, key = message) {
|
|
986
|
+
if (this.warned.has(key))
|
|
987
|
+
return;
|
|
988
|
+
this.warned.add(key);
|
|
989
|
+
this.caps.logger.use.warn(message);
|
|
990
|
+
}
|
|
991
|
+
hasTurnInFlight(sessionId) {
|
|
992
|
+
for (const turn of this.turns.values())
|
|
993
|
+
if (turn.sessionId === sessionId)
|
|
994
|
+
return true;
|
|
995
|
+
return false;
|
|
568
996
|
}
|
|
569
|
-
// ---- 配置解析 ----
|
|
570
997
|
/**
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
* 合并优先级(从高到低):
|
|
574
|
-
* 1. 会话自身 config
|
|
575
|
-
* 2. 父会话的 sessionDefaults
|
|
576
|
-
* 3. 平台 profile
|
|
577
|
-
* 4. 全局 defaults(最低)
|
|
998
|
+
* 回合开始:登记这一轮;已有记录且不是 active 的翻 active。归档的不翻:归档是 owner 不想在列表里看到它,
|
|
999
|
+
* 她在那里跑一轮不该让它回到列表,只有手动恢复才回来
|
|
578
1000
|
*/
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
1001
|
+
beginTurn(id, turnId) {
|
|
1002
|
+
this.turns.set(turnId, { sessionId: id });
|
|
1003
|
+
const session = this.sessions.get(id);
|
|
1004
|
+
if (session && session.status !== 'active' && session.status !== 'archived') {
|
|
1005
|
+
this.updateSession(id, { status: 'active' }).catch(() => { });
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
/** 记下这一轮的结局与出错原因(只有结局为 error 时留原因),回合结束时用 */
|
|
1009
|
+
recordOutcome(turnId, outcome, error) {
|
|
1010
|
+
const turn = this.turns.get(turnId);
|
|
1011
|
+
if (!turn)
|
|
1012
|
+
return;
|
|
1013
|
+
turn.outcome = outcome;
|
|
1014
|
+
// 一句原因:agent 给的可能带上游的整段响应(字符串本身,或文字对象的参数),会随会话落盘、进子任务提醒,截短;空串当作没给
|
|
1015
|
+
if (outcome !== 'error' || !error)
|
|
1016
|
+
turn.error = undefined;
|
|
1017
|
+
else
|
|
1018
|
+
turn.error = typeof error === 'string' ? truncateChars(error, ERROR_MAX, '…') : truncateParams(error, ERROR_MAX);
|
|
590
1019
|
}
|
|
591
1020
|
/**
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
* WebUI 「继承 (xxx)」提示应该用这个值,否则会显示用户自己的覆盖值。
|
|
1021
|
+
* 回合结束:注销这一轮,记下 lastTurn(被拦下、没有结局的一轮记为 silent),结局为 replied 的另记下她开口的时间。
|
|
1022
|
+
* 会话里的最后一轮结束时,仍为 active 的会话(根会话与子会话一样)按这一轮的结局收口:出错为 error,其余为 completed
|
|
595
1023
|
*/
|
|
596
|
-
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
const next = {};
|
|
623
|
-
if (cfg.persona)
|
|
624
|
-
next.persona = cfg.persona;
|
|
625
|
-
this.defaults = next;
|
|
626
|
-
if (Object.keys(next).length > 0) {
|
|
627
|
-
this.caps.logger.info(`已加载全局 defaults: ${Object.keys(next).join(', ')}`);
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
getPlatformProfiles() {
|
|
631
|
-
const result = {};
|
|
632
|
-
for (const [platform, profile] of this.platformProfiles) {
|
|
633
|
-
result[platform] = { ...profile };
|
|
634
|
-
}
|
|
635
|
-
return result;
|
|
636
|
-
}
|
|
637
|
-
/** 从配置加载平台 profiles(唯一入口:平台档属插件配置,无运行时写接口——写了也不落盘) */
|
|
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) };
|
|
645
|
-
}
|
|
646
|
-
if (entry.enabledToolGroups !== undefined)
|
|
647
|
-
profile.enabledToolGroups = entry.enabledToolGroups;
|
|
648
|
-
if (entry.disableOutputFormat !== undefined)
|
|
649
|
-
profile.disableOutputFormat = entry.disableOutputFormat;
|
|
650
|
-
if (entry.clientSideJsonRendering !== undefined)
|
|
651
|
-
profile.clientSideJsonRendering = entry.clientSideJsonRendering;
|
|
652
|
-
// 空串表示继承 provider 全局配置。
|
|
653
|
-
if (entry.think === 'on')
|
|
654
|
-
profile.think = true;
|
|
655
|
-
else if (entry.think === 'off')
|
|
656
|
-
profile.think = false;
|
|
657
|
-
this.platformProfiles.set(entry.platform, profile);
|
|
658
|
-
}
|
|
659
|
-
if (this.platformProfiles.size > 0) {
|
|
660
|
-
this.caps.logger.info(`已加载 ${this.platformProfiles.size} 个平台配置模板: ${[...this.platformProfiles.keys()].join(', ')}`);
|
|
661
|
-
}
|
|
1024
|
+
endTurn(turnId) {
|
|
1025
|
+
const turn = this.turns.get(turnId);
|
|
1026
|
+
if (!turn)
|
|
1027
|
+
return;
|
|
1028
|
+
this.turns.delete(turnId);
|
|
1029
|
+
const session = this.sessions.get(turn.sessionId);
|
|
1030
|
+
if (!session)
|
|
1031
|
+
return;
|
|
1032
|
+
const { outcome = 'silent', error } = turn;
|
|
1033
|
+
const endedAt = Date.now();
|
|
1034
|
+
session.lastTurn = error === undefined ? { outcome, endedAt } : { outcome, endedAt, error };
|
|
1035
|
+
if (outcome === 'replied')
|
|
1036
|
+
session.lastReplyAt = endedAt;
|
|
1037
|
+
const settle = session.status === 'active' && !this.hasTurnInFlight(session.id);
|
|
1038
|
+
const status = outcome === 'error' ? 'error' : 'completed';
|
|
1039
|
+
this.updateSession(session.id, settle ? { status } : {}).catch(() => { });
|
|
1040
|
+
}
|
|
1041
|
+
/** 按会话累加一次模型调用的 token:只记已有的会话,不改 updatedAt;记了就发 session:updated */
|
|
1042
|
+
recordUsage({ sessionId, inputTokens, outputTokens }) {
|
|
1043
|
+
const session = sessionId === undefined ? undefined : this.sessions.get(sessionId);
|
|
1044
|
+
if (!session)
|
|
1045
|
+
return;
|
|
1046
|
+
const { input, output } = session.tokens ?? { input: 0, output: 0 };
|
|
1047
|
+
session.tokens = { input: input + inputTokens, output: output + outputTokens };
|
|
1048
|
+
this.markDirty();
|
|
1049
|
+
void this.caps.events.use.emit('session:updated', session);
|
|
662
1050
|
}
|
|
663
1051
|
/**
|
|
664
1052
|
* 关停收尾:仍 `active` 的会话立即收口并落盘。
|
|
@@ -667,7 +1055,7 @@ class SessionManager {
|
|
|
667
1055
|
* 关停打断在飞回合与那条路径同义,沿用同一状态,不新造值。
|
|
668
1056
|
* waiting / completed / error / archived 不是在飞,原样保留。
|
|
669
1057
|
*
|
|
670
|
-
* 第一方栈由 core 关停编排(optional 环成员先全部 drain
|
|
1058
|
+
* 第一方栈由 core 关停编排(optional 环成员先全部 drain)与回合结束时的收口({@link endTurn})处理;
|
|
671
1059
|
* 此处兜的是未装 agent 或钩子未挂上的路径。
|
|
672
1060
|
* persist 也在这里立刻刷,不走 1s debounce——onDispose 的 shutdown 仍会再刷一次。
|
|
673
1061
|
*/
|
|
@@ -700,220 +1088,165 @@ class SessionManager {
|
|
|
700
1088
|
}
|
|
701
1089
|
// ===== 工具函数 =====
|
|
702
1090
|
/**
|
|
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 同义。
|
|
1091
|
+
* 会话的种类,只由父会话与出生平台推出。新建、建档、补记与加载入表时覆盖写入,调用方传的值与存储里的旧值都不采信
|
|
711
1092
|
*/
|
|
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;
|
|
1093
|
+
function describeSession(session) {
|
|
1094
|
+
return { kind: sessionKind(session) };
|
|
721
1095
|
}
|
|
722
1096
|
/**
|
|
723
|
-
*
|
|
724
|
-
*
|
|
1097
|
+
* 判得出的根会话的平台:IM 房间(出生平台加聊天窗口)取出生平台,owner 的面(有创建来源)取创建来源。不在会话表里的、按 id
|
|
1098
|
+
* 兜底建档的空记录与只记了出生平台的旧房间记录(等下一条原生入站补记)判不出
|
|
725
1099
|
*/
|
|
726
|
-
function
|
|
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
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
});
|
|
1100
|
+
function platformOf(root) {
|
|
1101
|
+
if (root?.originPlatform !== undefined)
|
|
1102
|
+
return root.originChat ? root.originPlatform : undefined;
|
|
1103
|
+
return root?.createdFrom;
|
|
1104
|
+
}
|
|
1105
|
+
/** 新建的场所记录:默认工作区没有名字 */
|
|
1106
|
+
function newPlace(place, name, root) {
|
|
1107
|
+
const now = Date.now();
|
|
1108
|
+
return { ...place, name, ...(root === undefined ? {} : { root }), createdAt: now, updatedAt: now };
|
|
1109
|
+
}
|
|
1110
|
+
function placeInfo({ kind, key, name, metadata }) {
|
|
1111
|
+
return { kind, key, name, ...(metadata === undefined ? {} : { metadata }) };
|
|
1112
|
+
}
|
|
1113
|
+
function workspaceInfo({ key, name, root }) {
|
|
1114
|
+
return { id: key.slice(WORKSPACE_PREFIX.length), name, ...(root === undefined ? {} : { root }) };
|
|
1115
|
+
}
|
|
1116
|
+
/** 排序时默认工作区在前,其余不动 */
|
|
1117
|
+
function defaultFirst(a, b) {
|
|
1118
|
+
return Number(b === DEFAULT_PLACE.key) - Number(a === DEFAULT_PLACE.key);
|
|
1119
|
+
}
|
|
1120
|
+
const SESSION_STATUSES = new Set([
|
|
1121
|
+
'active',
|
|
1122
|
+
'waiting',
|
|
1123
|
+
'completed',
|
|
1124
|
+
'error',
|
|
1125
|
+
'archived',
|
|
1126
|
+
]);
|
|
1127
|
+
const TURN_OUTCOMES = new Set([
|
|
1128
|
+
'replied',
|
|
1129
|
+
'silent',
|
|
1130
|
+
'aborted',
|
|
1131
|
+
'error',
|
|
1132
|
+
]);
|
|
1133
|
+
const isText = (value) => typeof value === 'string';
|
|
1134
|
+
const isTextList = (value) => Array.isArray(value) && value.every(isText);
|
|
1135
|
+
const isTime = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
1136
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1137
|
+
/**
|
|
1138
|
+
* 会话记录里会进会话列表与协议、类型不对会让读者出错的可选字段,各自的类型。种类载入时重算,不在这里
|
|
1139
|
+
*/
|
|
1140
|
+
const OPTIONAL_FIELDS = {
|
|
1141
|
+
name: isText,
|
|
1142
|
+
title: isText,
|
|
1143
|
+
parentId: isText,
|
|
1144
|
+
createdFrom: isText,
|
|
1145
|
+
originPlatform: isText,
|
|
1146
|
+
originChat: value => isRecord(value) &&
|
|
1147
|
+
isText(value.type) &&
|
|
1148
|
+
typeof value.multiParty === 'boolean' &&
|
|
1149
|
+
isText(value.targetId) &&
|
|
1150
|
+
(value.selfId === undefined || isText(value.selfId)),
|
|
1151
|
+
workspace: isText,
|
|
1152
|
+
work: value => isRecord(value) && isText(value.root) && isTextList(value.extraRoots) && isTextList(value.worktrees),
|
|
1153
|
+
currentDir: isText,
|
|
1154
|
+
originInstance: isText,
|
|
1155
|
+
lastReplyAt: isTime,
|
|
1156
|
+
lastTurn: value => isRecord(value) &&
|
|
1157
|
+
TURN_OUTCOMES.has(value.outcome) &&
|
|
1158
|
+
isTime(value.endedAt) &&
|
|
1159
|
+
(value.error === undefined || asText(value.error) !== undefined),
|
|
1160
|
+
tokens: value => isRecord(value) && isTime(value.input) && isTime(value.output),
|
|
1161
|
+
};
|
|
1162
|
+
/**
|
|
1163
|
+
* 读回的会话记录:存储把没有的可选字段存成 null 的(MongoDB 就是这样)去掉这个键。字段不对的(旧数据、手改或别的插件写坏的):
|
|
1164
|
+
* 可选字段去掉,记在 dropped 里;必填字段不对时回这个字段名,整条不载入
|
|
1165
|
+
*/
|
|
1166
|
+
function storedSession(key, data) {
|
|
1167
|
+
const record = Object.fromEntries(Object.entries(data).filter(([, value]) => value !== null));
|
|
1168
|
+
const required = {
|
|
1169
|
+
id: record.id === key,
|
|
1170
|
+
children: isTextList(record.children),
|
|
1171
|
+
status: SESSION_STATUSES.has(record.status),
|
|
1172
|
+
createdAt: isTime(record.createdAt),
|
|
1173
|
+
updatedAt: isTime(record.updatedAt),
|
|
1174
|
+
};
|
|
1175
|
+
const wrong = Object.keys(required).find(field => !required[field]);
|
|
1176
|
+
if (wrong)
|
|
1177
|
+
return { wrong };
|
|
1178
|
+
const dropped = Object.entries(OPTIONAL_FIELDS)
|
|
1179
|
+
.filter(([field, valid]) => record[field] !== undefined && !valid(record[field]))
|
|
1180
|
+
.map(([field]) => field);
|
|
1181
|
+
for (const field of dropped)
|
|
1182
|
+
delete record[field];
|
|
1183
|
+
return { record: record, dropped };
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* 读回的场所记录:必填字段不对(含类别与键对不上:工作区的键以 `workspace:` 开头,窗口的不是)时回这个字段名,整条不载入;
|
|
1187
|
+
* 可选的名字(默认工作区没有)、目录与 metadata 类型不对时去掉它
|
|
1188
|
+
*/
|
|
1189
|
+
function storedPlace(key, data) {
|
|
1190
|
+
const record = Object.fromEntries(Object.entries(data).filter(([, value]) => value !== null));
|
|
1191
|
+
const required = {
|
|
1192
|
+
key: record.key === key,
|
|
1193
|
+
kind: (record.kind === 'workspace' || record.kind === 'window') &&
|
|
1194
|
+
(record.kind === 'workspace') === key.startsWith(WORKSPACE_PREFIX),
|
|
1195
|
+
createdAt: isTime(record.createdAt),
|
|
1196
|
+
updatedAt: isTime(record.updatedAt),
|
|
1197
|
+
};
|
|
1198
|
+
const wrong = Object.keys(required).find(field => !required[field]);
|
|
1199
|
+
if (wrong)
|
|
1200
|
+
return { wrong };
|
|
1201
|
+
const dropped = [
|
|
1202
|
+
...['name', 'root'].filter(field => record[field] !== undefined && !isText(record[field])),
|
|
1203
|
+
...(record.metadata !== undefined && !isRecord(record.metadata) ? ['metadata'] : []),
|
|
1204
|
+
];
|
|
1205
|
+
for (const field of dropped)
|
|
1206
|
+
delete record[field];
|
|
1207
|
+
return { record: record, dropped };
|
|
888
1208
|
}
|
|
889
1209
|
// ===== 插件入口 =====
|
|
890
1210
|
const uses = {
|
|
891
1211
|
/** 会话档案与消息历史的唯一落点:没有 memory 就没有会话管理 */
|
|
892
1212
|
memory,
|
|
893
1213
|
/**
|
|
894
|
-
*
|
|
895
|
-
* 第一方栈由 core 关停编排(optional 环成员先全部 drain
|
|
1214
|
+
* 删除会话清数据时经它的 clearing 按住这个会话;另为 agent:* 钩子的键类型。
|
|
1215
|
+
* 第一方栈由 core 关停编排(optional 环成员先全部 drain)与回合结束时的收口处理;
|
|
896
1216
|
* 此处 onDrain 兜的是未装 agent 或钩子未挂上的路径。
|
|
897
1217
|
*/
|
|
898
1218
|
agent: optional(agent),
|
|
899
|
-
/**
|
|
1219
|
+
/** 自动标题的模型来源(没有 LLM 时退回用户消息首段) */
|
|
900
1220
|
llm: optional(llm),
|
|
901
|
-
/**
|
|
902
|
-
|
|
1221
|
+
/**
|
|
1222
|
+
* 自动标题的输出语言:取全局语言(读者是 owner),缺席时按中文。plugin-i18n 也以 optional 用会话管理,
|
|
1223
|
+
* 两边成可选环
|
|
1224
|
+
*/
|
|
1225
|
+
i18n: optional(i18n),
|
|
1226
|
+
/** 房间建档时问认领者、取平台的类型声明记下聊天窗口;解析接管房间的适配器实例(originInstance) */
|
|
903
1227
|
platform: optional(platform),
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1228
|
+
/**
|
|
1229
|
+
* owner 的 IM 私聊在默认工作区:placeOf 按它认对端是不是 owner,与房间类别同一判据。plugin-authority 也以 optional
|
|
1230
|
+
* 用会话管理,两边成可选环;缺席时认不出 owner,一对一窗口按窗口算
|
|
1231
|
+
*/
|
|
1232
|
+
authority: optional(authority),
|
|
1233
|
+
/**
|
|
1234
|
+
* 按会话读取别的会话的范围与三个开关;缺席时取插件配置里的值。plugin-session-settings 以 required 用会话管理:这边可选,
|
|
1235
|
+
* 读取器的登记随它上线补上
|
|
1236
|
+
*/
|
|
1237
|
+
sessionFields: optional(sessionFields),
|
|
1238
|
+
/** 按会话字段的全局层:宿主配置文档里本实例的那一段 */
|
|
1239
|
+
document: optional(ownHostConfig),
|
|
1240
|
+
config,
|
|
907
1241
|
events,
|
|
908
1242
|
hooks,
|
|
909
1243
|
lifecycle,
|
|
910
1244
|
logger,
|
|
911
|
-
config,
|
|
912
1245
|
provide,
|
|
913
1246
|
};
|
|
914
1247
|
export default definePlugin({
|
|
915
1248
|
name: '@aalis/plugin-session-manager',
|
|
916
|
-
displayName: '
|
|
1249
|
+
displayName: t('displayName'),
|
|
917
1250
|
subsystem: 'session',
|
|
918
1251
|
configSchema,
|
|
919
1252
|
provides: [sessionManager],
|
|
@@ -921,101 +1254,81 @@ export default definePlugin({
|
|
|
921
1254
|
apply: run,
|
|
922
1255
|
});
|
|
923
1256
|
async function run(caps) {
|
|
924
|
-
const
|
|
925
|
-
const
|
|
926
|
-
// 注册 WebUI 页面
|
|
927
|
-
for (const page of webuiPages)
|
|
928
|
-
webui.registerPage(page);
|
|
929
|
-
if (memory.current === undefined) {
|
|
930
|
-
logger.error('memory 服务不可用,会话管理无法启动');
|
|
931
|
-
return;
|
|
932
|
-
}
|
|
933
|
-
const manager = new SessionManager(caps);
|
|
1257
|
+
const { memory, events, hooks, lifecycle, logger, provide } = caps;
|
|
1258
|
+
const manager = new SessionManager(caps, caps.sessionFields.use.fields(configSchema, caps));
|
|
934
1259
|
// 会话表跟随 memory 胜者:首次挂接即加载;运行中胜者换人时先写回旧后端再读新后端
|
|
935
1260
|
memory.follow(instance => manager.attach(instance));
|
|
936
1261
|
await manager.loading;
|
|
937
1262
|
// 读表(含重试等待)期间停用或停机的,不再发布服务与登记
|
|
938
|
-
lifecycle.signal.throwIfAborted();
|
|
939
|
-
// 加载平台 profiles
|
|
940
|
-
manager.loadPlatformProfiles(cfg.platformProfiles);
|
|
941
|
-
// 加载全局 defaults
|
|
942
|
-
manager.loadDefaults(cfg.defaults);
|
|
1263
|
+
lifecycle.use.signal.throwIfAborted();
|
|
943
1264
|
// 注册服务
|
|
944
|
-
provide(sessionManager, manager, { label: '
|
|
945
|
-
|
|
946
|
-
//
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1265
|
+
provide.use(sessionManager, manager, { label: t('displayName') });
|
|
1266
|
+
// ===== IM 房间收录 =====
|
|
1267
|
+
// 原生入站的收录与补记见 recordInbound;网关在入站相位之前也等它,同一会话共用同一次
|
|
1268
|
+
events.use.on('inbound:message', msg => manager.recordInbound(msg));
|
|
1269
|
+
// ===== 清空连带删除子会话 =====
|
|
1270
|
+
// 会话的消息历史清成功(结果里 type 为 context 的行都成功了)之后,它的子会话一并删除:范围为 session 删这个会话的,为 all
|
|
1271
|
+
// 删全部子会话,删了的在结果里加一行。只清别的类型、消息历史没清成时不删;会话不在表里(删除会话自己触发的那次清理,会话已
|
|
1272
|
+
// 先从表里摘掉)时什么都不做
|
|
1273
|
+
hooks.use.middleware('memory:clear', async (data, next) => {
|
|
1274
|
+
await next();
|
|
1275
|
+
if (!historyCleared(data))
|
|
950
1276
|
return;
|
|
951
|
-
const
|
|
952
|
-
if (
|
|
953
|
-
|
|
1277
|
+
const deleted = await manager.deleteChildren(data.scope === 'all' ? undefined : data.sessionId);
|
|
1278
|
+
if (deleted > 0) {
|
|
1279
|
+
data.results.push({
|
|
1280
|
+
source: 'session-manager',
|
|
1281
|
+
type: 'context',
|
|
1282
|
+
success: true,
|
|
1283
|
+
message: t('childrenDeleted', { count: deleted }),
|
|
1284
|
+
});
|
|
954
1285
|
}
|
|
955
1286
|
});
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1287
|
+
// ===== 会话状态 =====
|
|
1288
|
+
// 回合真正开始时(agent:turn:before)才翻 active,归档的不翻:群里只有消息、她没开口的房间不显示「进行中」
|
|
1289
|
+
//(触发判定在入站相位,挡下的消息到不了这里)。同一个中间件在 finally 里记下这一轮并收口,根会话与子会话一样,这是
|
|
1290
|
+
// 唯一的收口:next() 返回时整轮已跑完、agent:turn:after 也已执行(agent 只在这个钩子的默认动作里发它),结局已经记下;
|
|
1291
|
+
// 后面的中间件不调 next() 或抛错时 agent 不发回合收尾,同样由这里兜住。按回合 id 登记在飞的回合,会话里没有别的在飞回合
|
|
1292
|
+
// 时才收口;回合中途的出站(send_attachment、paper_send 在工具执行中直接发)不收口。
|
|
1293
|
+
hooks.use.middleware('agent:turn:before', async (data, next) => {
|
|
1294
|
+
manager.beginTurn(data.sessionId, data.turnId);
|
|
1295
|
+
try {
|
|
1296
|
+
await next();
|
|
1297
|
+
}
|
|
1298
|
+
finally {
|
|
1299
|
+
manager.endTurn(data.turnId);
|
|
963
1300
|
}
|
|
964
1301
|
});
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
// 空回复(silent)时不发 outbound:message,会话会永远停在 'active'(即"进行中")。
|
|
968
|
-
// 这里订阅生命周期钩子作幂等互补,确保任何回合结束都把根会话收口为 'completed'。
|
|
969
|
-
hooks.middleware('agent:turn:after', async (data, next) => {
|
|
1302
|
+
hooks.use.middleware('agent:turn:after', async (data, next) => {
|
|
1303
|
+
manager.recordOutcome(data.turnId, data.outcome, data.error);
|
|
970
1304
|
await next();
|
|
971
|
-
if (!data.sessionId)
|
|
972
|
-
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
|
-
}
|
|
978
1305
|
});
|
|
979
|
-
//
|
|
980
|
-
|
|
981
|
-
//
|
|
982
|
-
|
|
1306
|
+
// 模型调用的 token 按会话累加:对话、摘要、识图、转写的调用都带会话 id
|
|
1307
|
+
events.use.on('llm:usage', usage => manager.recordUsage(usage));
|
|
1308
|
+
// 自动标题:只给带创建来源的根会话(WebUI 新建的、终端建的)生成,在首条消息时生成、不等回复;IM 房间不生成,
|
|
1309
|
+
// owner 从 WebUI 往房间插话也不拿来给房间起名。异步生成,不阻塞消息处理;直接传入这条消息,不依赖历史
|
|
983
1310
|
const titleGenerating = new Set();
|
|
984
|
-
events.on('inbound:message', (msg) => {
|
|
985
|
-
const { sessionId
|
|
986
|
-
if (!sessionId) {
|
|
987
|
-
logger.debug('标题生成跳过: 缺少 sessionId');
|
|
988
|
-
return;
|
|
989
|
-
}
|
|
990
|
-
if (titleGenerating.has(sessionId))
|
|
991
|
-
return;
|
|
992
|
-
// 仅对指定平台生成标题;非 webui/cli 平台(如 onebot)静默跳过,避免日志污染。
|
|
993
|
-
// platform 缺省同样跳过:白名单是正向门,来路不明的消息不该顺带建档 + 烧一次 LLM 生成标题。
|
|
994
|
-
if (!platform || !TITLE_PLATFORMS.has(platform))
|
|
995
|
-
return;
|
|
1311
|
+
events.use.on('inbound:message', (msg) => {
|
|
1312
|
+
const { sessionId } = msg;
|
|
996
1313
|
const session = manager.getSession(sessionId);
|
|
997
|
-
|
|
998
|
-
if (session && (session.title || session.parentId))
|
|
1314
|
+
if (!session?.createdFrom || session.title || titleGenerating.has(sessionId))
|
|
999
1315
|
return;
|
|
1000
1316
|
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))
|
|
1317
|
+
logger.use.info(`开始生成会话标题: ${sessionId}`);
|
|
1318
|
+
manager
|
|
1319
|
+
.generateTitle(sessionId, msg.content)
|
|
1320
|
+
.catch(err => logger.use.warn('标题生成失败:', err))
|
|
1008
1321
|
.finally(() => titleGenerating.delete(sessionId));
|
|
1009
1322
|
});
|
|
1010
1323
|
// 关停收尾:仍 active 的会话在 onDrain 收口并落盘。
|
|
1011
|
-
// 第一方栈由 core 关停编排(optional 环成员先全部 drain
|
|
1324
|
+
// 第一方栈由 core 关停编排(optional 环成员先全部 drain)与回合结束时的收口处理;
|
|
1012
1325
|
// 此处兜的是未装 agent 或钩子未挂上的路径。
|
|
1013
1326
|
// 持久化仍走 onDispose:覆盖 bounce / unload / updateConfig 等全部拆卸路径
|
|
1014
1327
|
// (只在全局停机触发的话,热重载即丢会话元数据)。
|
|
1015
1328
|
// 异步收尾由编排层的 disposeAsync 等待完成;app.stop() 的拓扑逆序保证此时 memory 提供者
|
|
1016
1329
|
// 尚未关闭,单独热重载/禁用 memory 提供者时无此保证。shutdown() 幂等:清 timer + 置 dirty + 落盘。
|
|
1017
|
-
lifecycle.onDrain(() => manager.settleActiveOnDrain(), '收口在飞会话');
|
|
1018
|
-
lifecycle.onDispose(() => manager.shutdown());
|
|
1019
|
-
logger.info('会话管理服务已启用');
|
|
1330
|
+
lifecycle.use.onDrain(() => manager.settleActiveOnDrain(), '收口在飞会话');
|
|
1331
|
+
lifecycle.use.onDispose(() => manager.shutdown());
|
|
1332
|
+
logger.use.info('会话管理服务已启用');
|
|
1020
1333
|
}
|
|
1021
1334
|
//# sourceMappingURL=index.js.map
|