@aalis/plugin-session-manager 0.15.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 +15 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +398 -549
- 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 +24 -19
- package/dist/settings.d.ts +0 -66
- package/dist/settings.d.ts.map +0 -1
- package/dist/settings.js +0 -219
- package/dist/settings.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,17 +1,61 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
|
-
import { isDeepStrictEqual } from 'node:util';
|
|
3
2
|
import { agent } from '@aalis/api-agent';
|
|
4
3
|
import { authority } from '@aalis/api-authority';
|
|
5
|
-
import { chatScopeOf,
|
|
4
|
+
import { chatScopeOf, windowKey } from '@aalis/api-gateway';
|
|
6
5
|
import { hooks } from '@aalis/api-hooks';
|
|
7
|
-
import {
|
|
6
|
+
import { ownHostConfig } from '@aalis/api-host-config';
|
|
7
|
+
import { i18n, outputLanguageFor } from '@aalis/api-i18n';
|
|
8
8
|
import { lightestThinking, llm, resolveLLMModel } from '@aalis/api-llm';
|
|
9
|
-
import { memory } from '@aalis/api-memory';
|
|
9
|
+
import { historyCleared, memory } from '@aalis/api-memory';
|
|
10
10
|
import { claimantsOf, getPlatformAdapters, platform, resolvePlatformBySession, sessionTypesOf, } from '@aalis/api-platform';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import { isUnfiled, oneToOneWith, rootSessionOf, sessionKind, sessionLabel, sessionManager, TAKE_UNKNOWN, } from '@aalis/api-session-manager';
|
|
12
|
+
import { sessionFields } from '@aalis/api-session-settings';
|
|
13
|
+
import { config, definePlugin, events, lifecycle, logger, optional, provide } from '@aalis/core';
|
|
14
|
+
import { defineConfig } from '@aalis/schema-config';
|
|
15
|
+
import { asText, toSource, truncateParams, withText } from '@aalis/schema-i18n';
|
|
13
16
|
import { truncateChars } from '@aalis/util-text-normalize';
|
|
14
|
-
import {
|
|
17
|
+
import { t } from './texts.js';
|
|
18
|
+
// ===== 配置 =====
|
|
19
|
+
// 取别的会话的东西(canTake)的范围与三个开关:都是按会话字段,按取的一方所在的会话生效。坏值原样交来,范围按本会话、开关按否
|
|
20
|
+
const configSchema = defineConfig({
|
|
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' },
|
|
33
|
+
},
|
|
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' },
|
|
57
|
+
},
|
|
58
|
+
});
|
|
15
59
|
// ===== 常量 =====
|
|
16
60
|
/** 会话表与场所记录在记忆后端元数据里的命名空间 */
|
|
17
61
|
const SESSIONS = 'sessions';
|
|
@@ -42,11 +86,8 @@ function wait(ms, signal) {
|
|
|
42
86
|
signal.addEventListener('abort', done, { once: true });
|
|
43
87
|
});
|
|
44
88
|
}
|
|
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';
|
|
49
89
|
class SessionManager {
|
|
90
|
+
take;
|
|
50
91
|
sessions = new Map();
|
|
51
92
|
/** 场所记录:键 → 记录。与会话表一同加载与落盘 */
|
|
52
93
|
places = new Map();
|
|
@@ -67,78 +108,29 @@ class SessionManager {
|
|
|
67
108
|
store;
|
|
68
109
|
/** 最近一次挂接的加载;apply 等它完成再对外提供服务 */
|
|
69
110
|
loading = Promise.resolve();
|
|
70
|
-
/**
|
|
71
|
-
settings = new SettingRegistry(() => void this.caps.events.use.emit('session:config:changed'));
|
|
72
|
-
/** 已告警过的设置问题:取值无效、文档形状不对。解析每轮都跑,同一处只告警一次 */
|
|
111
|
+
/** 已告警过的问题(坏数据、收录判不出的认领):同一处只告警一次 */
|
|
73
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();
|
|
74
122
|
/**
|
|
75
|
-
* 在飞的回合:turnId → 所在会话与这一轮的结局(出错时带原因)。agent:
|
|
76
|
-
* agent:turn:after
|
|
77
|
-
*
|
|
123
|
+
* 在飞的回合:turnId → 所在会话与这一轮的结局(出错时带原因)。agent:turn:before 进出登记与注销,结局由
|
|
124
|
+
* agent:turn:after 记下(被拦下的一轮不发它,没有结局)。第一方 agent 同一会话一轮接一轮,契约不限别的实现在同一会话并行几轮,
|
|
125
|
+
* 所以按回合 id 记、会话在最后一轮结束时才收口;回合之外建档的会话按这里有没有回合定初始状态(见 ensureSession)
|
|
78
126
|
*/
|
|
79
127
|
turns = new Map();
|
|
80
128
|
/** 正在解析所属实例的会话记录:同一条记录不并发解析,后到的调用不等它、直接返回 */
|
|
81
129
|
resolving = new WeakSet();
|
|
82
130
|
lookup = (id) => this.sessions.get(id);
|
|
83
|
-
/**
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
};
|
|
141
|
-
constructor(caps) {
|
|
131
|
+
/** take 读取的一方所在会话此刻的取别的会话的范围与三个开关(生效值,坏值原样) */
|
|
132
|
+
constructor(caps, take) {
|
|
133
|
+
this.take = take;
|
|
142
134
|
this.caps = caps;
|
|
143
135
|
}
|
|
144
136
|
/**
|
|
@@ -212,7 +204,7 @@ class SessionManager {
|
|
|
212
204
|
this.sessions = loaded?.sessions ?? new Map();
|
|
213
205
|
this.places = loaded?.places ?? new Map();
|
|
214
206
|
if (!this.places.has(DEFAULT_PLACE.key))
|
|
215
|
-
this.places.set(DEFAULT_PLACE.key, newPlace(DEFAULT_PLACE
|
|
207
|
+
this.places.set(DEFAULT_PLACE.key, newPlace(DEFAULT_PLACE));
|
|
216
208
|
this.deleted = { [SESSIONS]: new Map(), [PLACES]: new Map() };
|
|
217
209
|
this.dirty = false;
|
|
218
210
|
this.store = loaded ? target : undefined;
|
|
@@ -317,37 +309,36 @@ class SessionManager {
|
|
|
317
309
|
return { ops, tombstones };
|
|
318
310
|
}
|
|
319
311
|
/**
|
|
320
|
-
* 按精确 id 幂等 upsert:命中走 updateSession
|
|
321
|
-
* 创建来源在建档时写;命中按 id
|
|
322
|
-
* 终端配置的会话 id
|
|
312
|
+
* 按精确 id 幂等 upsert:命中走 updateSession(emit `session:updated`),未命中以传入 id 建档。
|
|
313
|
+
* 创建来源在建档时写;命中按 id 兜底建档留下的空记录({@link isUnfiled})时补写,随这次更新落盘。
|
|
314
|
+
* 终端配置的会话 id、在收录不了的会话上写设置时用
|
|
323
315
|
*/
|
|
324
316
|
async ensureSession(id, patch = {}, creation) {
|
|
325
317
|
const session = this.sessions.get(id);
|
|
326
318
|
if (!session)
|
|
327
319
|
return this.insert(id, patch, creation ? { createdFrom: creation.createdFrom } : {});
|
|
328
|
-
if (creation &&
|
|
320
|
+
if (creation && isUnfiled(session))
|
|
329
321
|
session.createdFrom = creation.createdFrom;
|
|
330
|
-
}
|
|
331
322
|
return this.updateSession(id, patch);
|
|
332
323
|
}
|
|
333
324
|
/**
|
|
334
|
-
* IM
|
|
325
|
+
* IM 房间收录:按原生入站记下的出生平台与聊天窗口建档,名字取出生平台给的群名或昵称(没有时不取名)。只给会话表里
|
|
335
326
|
* 还没有的 id 同步调用。场所是窗口的同时确保窗口的场所记录:insert 在第一次等待之前已同步占住 id、记下来源字段
|
|
336
327
|
*/
|
|
337
328
|
async recordRoom(id, nativeName, origin) {
|
|
338
329
|
const [created] = await Promise.all([
|
|
339
|
-
this.insert(id, { name: nativeName
|
|
330
|
+
this.insert(id, { name: nativeName, status: 'waiting' }, origin),
|
|
340
331
|
this.noteWindow(id, nativeName),
|
|
341
332
|
]);
|
|
342
333
|
return created;
|
|
343
334
|
}
|
|
344
335
|
/**
|
|
345
336
|
* IM 房间收录的补记:按 id 兜底建档的根会话(没有来源字段、创建来源与绑定)与升级前只记了出生平台的记录(没有聊天窗口,
|
|
346
|
-
*
|
|
337
|
+
* 出生平台与消息平台相同)收到原生入站时,记下与按这条消息建档时一样的来源字段,还没有名字的一并补上,场所是窗口的
|
|
347
338
|
* 确保窗口的场所记录。只给表里这条记录同步调用,由收录监听判定这个 id 归消息平台的适配器管
|
|
348
339
|
*/
|
|
349
340
|
async backfillRoom(session, nativeName, origin) {
|
|
350
|
-
Object.assign(session, origin, session.name ===
|
|
341
|
+
Object.assign(session, origin, session.name === undefined && nativeName ? { name: nativeName } : {});
|
|
351
342
|
Object.assign(session, describeSession(session));
|
|
352
343
|
await this.noteWindow(session.id, nativeName);
|
|
353
344
|
this.markDirty();
|
|
@@ -384,20 +375,15 @@ class SessionManager {
|
|
|
384
375
|
* (回合结束时收口),没有为 waiting(指令回复等回合之外的出站不改状态,建成 active 就会一直停在进行中)
|
|
385
376
|
*/
|
|
386
377
|
async insert(id, patch, fields) {
|
|
387
|
-
const config = this.checkedConfig(patch.config);
|
|
388
378
|
const now = Date.now();
|
|
389
379
|
const session = {
|
|
390
380
|
id,
|
|
391
|
-
name: patch.name ||
|
|
392
|
-
`会话 ${new Date().toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}`,
|
|
393
|
-
title: patch.metadata?.title,
|
|
381
|
+
name: patch.name || undefined,
|
|
394
382
|
parentId: undefined,
|
|
395
383
|
children: [],
|
|
396
384
|
status: patch.status || (this.hasTurnInFlight(id) ? 'active' : 'waiting'),
|
|
397
|
-
config,
|
|
398
385
|
createdAt: now,
|
|
399
386
|
updatedAt: now,
|
|
400
|
-
inputContext: patch.metadata?.inputContext,
|
|
401
387
|
metadata: patch.metadata,
|
|
402
388
|
...fields,
|
|
403
389
|
...describeSession(fields),
|
|
@@ -405,9 +391,116 @@ class SessionManager {
|
|
|
405
391
|
this.sessions.set(id, session);
|
|
406
392
|
this.markDirty();
|
|
407
393
|
await this.caps.events.use.emit('session:created', session);
|
|
408
|
-
this.caps.logger.use.info(`会话建档(ensure): ${session
|
|
394
|
+
this.caps.logger.use.info(`会话建档(ensure): ${toSource(sessionLabel(session))} (${id})`);
|
|
409
395
|
return session;
|
|
410
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
|
+
}
|
|
411
504
|
/**
|
|
412
505
|
* 有出生平台的房间还没记下接管它的适配器实例时,解析并记下,会话列表按它分组;已记下的不改,子任务与 owner 面
|
|
413
506
|
* 会话不记。收录与补记时实例随来源字段一起记下(收录之前已解析);收录时没有适配器认领(适配器没装好、连接还没上来、
|
|
@@ -430,29 +523,74 @@ class SessionManager {
|
|
|
430
523
|
this.resolving.delete(session);
|
|
431
524
|
}
|
|
432
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
|
+
}
|
|
433
572
|
// ---- CRUD ----
|
|
434
573
|
async createSession(opts, creation) {
|
|
435
574
|
if (creation && opts?.parentId)
|
|
436
575
|
throw new Error('建档专用参数只给根会话:子会话按根会话取创建来源、所属工作区与绑定');
|
|
437
576
|
const created = creation ? this.creationFields(creation) : {};
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
577
|
+
// 子会话的 id 同样不带父会话的 id:平台按会话 id 认领窗口,子会话没有窗口。撞上已有的会话就重抽
|
|
578
|
+
let id;
|
|
579
|
+
do
|
|
580
|
+
id = `session-${crypto.randomUUID().slice(0, 8)}`;
|
|
581
|
+
while (this.sessions.has(id));
|
|
442
582
|
const parent = opts?.parentId ? this.sessions.get(opts.parentId) : undefined;
|
|
443
583
|
const now = Date.now();
|
|
444
584
|
const session = {
|
|
445
585
|
id,
|
|
446
|
-
name: opts?.name ||
|
|
447
|
-
|
|
448
|
-
title: opts?.metadata?.title,
|
|
586
|
+
name: opts?.name || undefined,
|
|
587
|
+
title: opts?.title,
|
|
449
588
|
parentId: opts?.parentId,
|
|
450
589
|
children: [],
|
|
451
590
|
status: opts?.status || 'active',
|
|
452
|
-
config,
|
|
453
591
|
createdAt: now,
|
|
454
592
|
updatedAt: now,
|
|
455
|
-
inputContext: opts?.inputContext
|
|
593
|
+
inputContext: opts?.inputContext,
|
|
456
594
|
metadata: opts?.metadata,
|
|
457
595
|
...created,
|
|
458
596
|
// 子会话的当前目录取父会话此刻的值,之后各改各的
|
|
@@ -469,7 +607,7 @@ class SessionManager {
|
|
|
469
607
|
await this.caps.events.use.emit('session:created', session);
|
|
470
608
|
if (parent)
|
|
471
609
|
await this.caps.events.use.emit('session:updated', parent);
|
|
472
|
-
this.caps.logger.use.info(`会话创建: ${session
|
|
610
|
+
this.caps.logger.use.info(`会话创建: ${toSource(sessionLabel(session))} (${id})`);
|
|
473
611
|
return session;
|
|
474
612
|
}
|
|
475
613
|
/**
|
|
@@ -514,32 +652,45 @@ class SessionManager {
|
|
|
514
652
|
const session = this.sessions.get(id);
|
|
515
653
|
if (!session)
|
|
516
654
|
throw new Error(`会话不存在: ${id}`);
|
|
517
|
-
// 先校验再动记录:配置被拒时什么都不改
|
|
518
|
-
const config = updates.config && this.checkedPatch(updates.config);
|
|
519
655
|
if (updates.name !== undefined)
|
|
520
656
|
session.name = updates.name;
|
|
521
657
|
if (updates.status !== undefined)
|
|
522
658
|
session.status = updates.status;
|
|
523
659
|
if (updates.metadata !== undefined)
|
|
524
660
|
session.metadata = { ...session.metadata, ...updates.metadata };
|
|
525
|
-
if (config)
|
|
526
|
-
session.config = applyPatch(session.config, config);
|
|
527
661
|
session.updatedAt = Date.now();
|
|
528
662
|
this.markDirty();
|
|
529
663
|
await this.caps.events.use.emit('session:updated', session);
|
|
530
|
-
if (config)
|
|
531
|
-
await this.caps.events.use.emit('session:config:changed');
|
|
532
664
|
return session;
|
|
533
665
|
}
|
|
534
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) {
|
|
535
688
|
const session = this.sessions.get(id);
|
|
536
689
|
if (!session)
|
|
537
|
-
return;
|
|
538
|
-
//
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
// 从父会话的 children 中移除:父会话的子会话列表变了,删除之后再给它发一次 session:updated
|
|
690
|
+
return 0;
|
|
691
|
+
// 开始删除,同一步:从表里摘掉(父会话的子会话列表变了,删除之后再给它发一次 session:updated)、换代号。这之后到的同一会话的
|
|
692
|
+
// 入站等删除做完(见 recordInbound),是新的一代;这之前已收录、还在半路上的是旧的一代,网关交给 agent 之前、agent 交给通道
|
|
693
|
+
// 之前比代号放掉、不归档
|
|
543
694
|
const parent = session.parentId ? this.sessions.get(session.parentId) : undefined;
|
|
544
695
|
if (parent) {
|
|
545
696
|
parent.children = parent.children.filter(c => c !== id);
|
|
@@ -547,12 +698,36 @@ class SessionManager {
|
|
|
547
698
|
}
|
|
548
699
|
this.sessions.delete(id);
|
|
549
700
|
this.deleted[SESSIONS].set(id, Symbol(id));
|
|
550
|
-
await this.clearDeletedSessionData(id);
|
|
551
701
|
this.markDirty();
|
|
552
|
-
|
|
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
|
+
}
|
|
553
727
|
if (parent)
|
|
554
728
|
await this.caps.events.use.emit('session:updated', parent);
|
|
555
|
-
this.caps.logger.use.info(`会话删除: ${session
|
|
729
|
+
this.caps.logger.use.info(`会话删除: ${toSource(sessionLabel(session))} (${id})`);
|
|
730
|
+
return removed;
|
|
556
731
|
}
|
|
557
732
|
async clearDeletedSessionData(id) {
|
|
558
733
|
const clearData = {
|
|
@@ -572,15 +747,25 @@ class SessionManager {
|
|
|
572
747
|
});
|
|
573
748
|
const failed = clearData.results.filter(r => !r.success);
|
|
574
749
|
if (failed.length > 0) {
|
|
575
|
-
this.caps.logger.use.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('; ')}`);
|
|
576
751
|
}
|
|
577
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
|
+
}
|
|
578
763
|
// ---- 树形操作 ----
|
|
579
764
|
async createChildSession(parentId, opts) {
|
|
580
765
|
// 带 source 的注入回合(定时任务、编排)可能落在还没建档的会话上:先按 id 兜底建档再挂子会话,否则 create_subtask
|
|
581
|
-
//
|
|
766
|
+
// 在这些会话里必败。兜底建档的会话没有来源字段、创建来源与名字:是房间的由之后的原生入站补记来源字段与名字
|
|
582
767
|
if (!this.sessions.has(parentId))
|
|
583
|
-
await this.ensureSession(parentId
|
|
768
|
+
await this.ensureSession(parentId);
|
|
584
769
|
return this.createSession({ ...opts, parentId });
|
|
585
770
|
}
|
|
586
771
|
getChildren(parentId) {
|
|
@@ -649,6 +834,22 @@ class SessionManager {
|
|
|
649
834
|
const record = this.places.get(key);
|
|
650
835
|
return record && placeInfo(record);
|
|
651
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
|
+
}
|
|
652
853
|
addWorkspace(root) {
|
|
653
854
|
const existing = [...this.places.values()].find(record => record.root === root);
|
|
654
855
|
if (existing)
|
|
@@ -688,7 +889,6 @@ class SessionManager {
|
|
|
688
889
|
this.caps.logger.use.info(`取消登记工作区: ${record.name} (${key}),${affected.length} 个会话归默认工作区`);
|
|
689
890
|
for (const session of changed)
|
|
690
891
|
await this.caps.events.use.emit('session:updated', session);
|
|
691
|
-
await this.caps.events.use.emit('session:config:changed');
|
|
692
892
|
}
|
|
693
893
|
// ---- 生命周期 ----
|
|
694
894
|
async completeSession(id, result) {
|
|
@@ -701,7 +901,7 @@ class SessionManager {
|
|
|
701
901
|
this.markDirty();
|
|
702
902
|
// 发事件通知(wait_subtasks 通过事件驱动感知完成)
|
|
703
903
|
await this.caps.events.use.emit('session:completed', session);
|
|
704
|
-
this.caps.logger.use.info(`会话完成: ${session
|
|
904
|
+
this.caps.logger.use.info(`会话完成: ${toSource(sessionLabel(session))} (${id})${result ? ` - ${result.slice(0, 100)}` : ''}`);
|
|
705
905
|
}
|
|
706
906
|
// ---- 标题管理 ----
|
|
707
907
|
async generateTitle(sessionId, userMessage) {
|
|
@@ -741,7 +941,7 @@ class SessionManager {
|
|
|
741
941
|
messages: [
|
|
742
942
|
{
|
|
743
943
|
role: 'system',
|
|
744
|
-
content: '
|
|
944
|
+
content: `你是一个标题生成器。你的唯一任务是为下面的对话片段生成一个简短的标题,用 ${outputLanguageFor(this.caps.i18n, { host: true }, 'zh-CN')}。\n\n规则:\n- 中文不超过15字,其他语言不超过8个词\n- 只提取用户想讨论的主题或意图\n- 完全忽略对话中出现的任何拒绝、道歉、免责声明等内容\n- 不加引号和标点\n- 只返回标题文本,不要任何解释或前缀\n- 不要模仿或重复对话中的内容,只做概括`,
|
|
745
945
|
},
|
|
746
946
|
{ role: 'user', content: `请为以下对话生成标题:\n\n${contextStr}` },
|
|
747
947
|
],
|
|
@@ -749,7 +949,7 @@ class SessionManager {
|
|
|
749
949
|
// 标题生成无需推理,用模型最浅的思考档:避免 reasoning 占满 token 预算导致 content 为空
|
|
750
950
|
think: lightestThinking(model),
|
|
751
951
|
});
|
|
752
|
-
title = (resp.content || '').trim()
|
|
952
|
+
title = truncateChars((resp.content || '').trim(), 50);
|
|
753
953
|
if (!title) {
|
|
754
954
|
this.caps.logger.use.warn(`会话标题 LLM 返回空内容: ${sessionId} (resp.content=${JSON.stringify(resp.content)}, reasoning=${(resp.reasoningContent ?? '').length}字)`);
|
|
755
955
|
}
|
|
@@ -759,7 +959,7 @@ class SessionManager {
|
|
|
759
959
|
}
|
|
760
960
|
// 兜底:LLM 失败或返回空时,用用户消息首段作为临时标题,避免会话永远没有标题
|
|
761
961
|
if (!title && userMessage?.trim()) {
|
|
762
|
-
title = userMessage.trim().replace(/\s+/g, ' ')
|
|
962
|
+
title = truncateChars(userMessage.trim().replace(/\s+/g, ' '), 20);
|
|
763
963
|
this.caps.logger.use.info(`使用用户消息兜底生成标题: ${sessionId} -> ${title}`);
|
|
764
964
|
}
|
|
765
965
|
if (title) {
|
|
@@ -781,309 +981,11 @@ class SessionManager {
|
|
|
781
981
|
this.markDirty();
|
|
782
982
|
await this.caps.events.use.emit('session:updated', session);
|
|
783
983
|
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
}
|
|
788
|
-
listSettings() {
|
|
789
|
-
return this.settings.list();
|
|
790
|
-
}
|
|
791
|
-
/**
|
|
792
|
-
* 解析指定会话的最终生效配置:继承链之上叠会话自己的覆盖。由服务偏好承担的项在全局默认之上各层都没设时不给值:
|
|
793
|
-
* 取用方交给 resolveLLMModel,偏好指向的模型不在时也落得到服务此刻解析到的那一个
|
|
794
|
-
*/
|
|
795
|
-
resolveConfig(sessionId, platform) {
|
|
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) };
|
|
803
|
-
}
|
|
804
|
-
/**
|
|
805
|
-
* 继承链解析:不含会话自身 config,按层表的先后叠加,另记下每个键来自哪一层。子会话按根会话取:有出生平台的会话不论从
|
|
806
|
-
* 哪个入口驱动都按出生平台选平台档,按聊天窗口的类型名选平台类型档;传入的入口平台只对没有出生平台的会话起作用
|
|
807
|
-
*/
|
|
808
|
-
resolveInheritance(sessionId, platform) {
|
|
809
|
-
const session = this.sessions.get(sessionId);
|
|
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} 给子会话的默认值`]);
|
|
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);
|
|
1037
|
-
}
|
|
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;
|
|
1062
|
-
}
|
|
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))
|
|
984
|
+
/** 同一处只告警一次:key 缺省为这句话本身 */
|
|
985
|
+
warnOnce(message, key = message) {
|
|
986
|
+
if (this.warned.has(key))
|
|
1085
987
|
return;
|
|
1086
|
-
this.warned.add(
|
|
988
|
+
this.warned.add(key);
|
|
1087
989
|
this.caps.logger.use.warn(message);
|
|
1088
990
|
}
|
|
1089
991
|
hasTurnInFlight(sessionId) {
|
|
@@ -1109,8 +1011,11 @@ class SessionManager {
|
|
|
1109
1011
|
if (!turn)
|
|
1110
1012
|
return;
|
|
1111
1013
|
turn.outcome = outcome;
|
|
1112
|
-
// 一句原因:agent
|
|
1113
|
-
|
|
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);
|
|
1114
1019
|
}
|
|
1115
1020
|
/**
|
|
1116
1021
|
* 回合结束:注销这一轮,记下 lastTurn(被拦下、没有结局的一轮记为 silent),结局为 replied 的另记下她开口的时间。
|
|
@@ -1188,13 +1093,22 @@ class SessionManager {
|
|
|
1188
1093
|
function describeSession(session) {
|
|
1189
1094
|
return { kind: sessionKind(session) };
|
|
1190
1095
|
}
|
|
1191
|
-
/**
|
|
1096
|
+
/**
|
|
1097
|
+
* 判得出的根会话的平台:IM 房间(出生平台加聊天窗口)取出生平台,owner 的面(有创建来源)取创建来源。不在会话表里的、按 id
|
|
1098
|
+
* 兜底建档的空记录与只记了出生平台的旧房间记录(等下一条原生入站补记)判不出
|
|
1099
|
+
*/
|
|
1100
|
+
function platformOf(root) {
|
|
1101
|
+
if (root?.originPlatform !== undefined)
|
|
1102
|
+
return root.originChat ? root.originPlatform : undefined;
|
|
1103
|
+
return root?.createdFrom;
|
|
1104
|
+
}
|
|
1105
|
+
/** 新建的场所记录:默认工作区没有名字 */
|
|
1192
1106
|
function newPlace(place, name, root) {
|
|
1193
1107
|
const now = Date.now();
|
|
1194
|
-
return { ...place, name, ...(root === undefined ? {} : { root }),
|
|
1108
|
+
return { ...place, name, ...(root === undefined ? {} : { root }), createdAt: now, updatedAt: now };
|
|
1195
1109
|
}
|
|
1196
|
-
function placeInfo({ kind, key, name }) {
|
|
1197
|
-
return { kind, key, name };
|
|
1110
|
+
function placeInfo({ kind, key, name, metadata }) {
|
|
1111
|
+
return { kind, key, name, ...(metadata === undefined ? {} : { metadata }) };
|
|
1198
1112
|
}
|
|
1199
1113
|
function workspaceInfo({ key, name, root }) {
|
|
1200
1114
|
return { id: key.slice(WORKSPACE_PREFIX.length), name, ...(root === undefined ? {} : { root }) };
|
|
@@ -1224,6 +1138,7 @@ const isRecord = (value) => typeof value === 'object' && value !== null && !Arra
|
|
|
1224
1138
|
* 会话记录里会进会话列表与协议、类型不对会让读者出错的可选字段,各自的类型。种类载入时重算,不在这里
|
|
1225
1139
|
*/
|
|
1226
1140
|
const OPTIONAL_FIELDS = {
|
|
1141
|
+
name: isText,
|
|
1227
1142
|
title: isText,
|
|
1228
1143
|
parentId: isText,
|
|
1229
1144
|
createdFrom: isText,
|
|
@@ -1241,7 +1156,7 @@ const OPTIONAL_FIELDS = {
|
|
|
1241
1156
|
lastTurn: value => isRecord(value) &&
|
|
1242
1157
|
TURN_OUTCOMES.has(value.outcome) &&
|
|
1243
1158
|
isTime(value.endedAt) &&
|
|
1244
|
-
(value.error === undefined ||
|
|
1159
|
+
(value.error === undefined || asText(value.error) !== undefined),
|
|
1245
1160
|
tokens: value => isRecord(value) && isTime(value.input) && isTime(value.output),
|
|
1246
1161
|
};
|
|
1247
1162
|
/**
|
|
@@ -1252,10 +1167,8 @@ function storedSession(key, data) {
|
|
|
1252
1167
|
const record = Object.fromEntries(Object.entries(data).filter(([, value]) => value !== null));
|
|
1253
1168
|
const required = {
|
|
1254
1169
|
id: record.id === key,
|
|
1255
|
-
name: isText(record.name),
|
|
1256
1170
|
children: isTextList(record.children),
|
|
1257
1171
|
status: SESSION_STATUSES.has(record.status),
|
|
1258
|
-
config: isRecord(record.config),
|
|
1259
1172
|
createdAt: isTime(record.createdAt),
|
|
1260
1173
|
updatedAt: isTime(record.updatedAt),
|
|
1261
1174
|
};
|
|
@@ -1271,7 +1184,7 @@ function storedSession(key, data) {
|
|
|
1271
1184
|
}
|
|
1272
1185
|
/**
|
|
1273
1186
|
* 读回的场所记录:必填字段不对(含类别与键对不上:工作区的键以 `workspace:` 开头,窗口的不是)时回这个字段名,整条不载入;
|
|
1274
|
-
*
|
|
1187
|
+
* 可选的名字(默认工作区没有)、目录与 metadata 类型不对时去掉它
|
|
1275
1188
|
*/
|
|
1276
1189
|
function storedPlace(key, data) {
|
|
1277
1190
|
const record = Object.fromEntries(Object.entries(data).filter(([, value]) => value !== null));
|
|
@@ -1279,17 +1192,18 @@ function storedPlace(key, data) {
|
|
|
1279
1192
|
key: record.key === key,
|
|
1280
1193
|
kind: (record.kind === 'workspace' || record.kind === 'window') &&
|
|
1281
1194
|
(record.kind === 'workspace') === key.startsWith(WORKSPACE_PREFIX),
|
|
1282
|
-
name: isText(record.name),
|
|
1283
|
-
config: isRecord(record.config),
|
|
1284
1195
|
createdAt: isTime(record.createdAt),
|
|
1285
1196
|
updatedAt: isTime(record.updatedAt),
|
|
1286
1197
|
};
|
|
1287
1198
|
const wrong = Object.keys(required).find(field => !required[field]);
|
|
1288
1199
|
if (wrong)
|
|
1289
1200
|
return { wrong };
|
|
1290
|
-
const dropped =
|
|
1291
|
-
|
|
1292
|
-
|
|
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];
|
|
1293
1207
|
return { record: record, dropped };
|
|
1294
1208
|
}
|
|
1295
1209
|
// ===== 插件入口 =====
|
|
@@ -1297,27 +1211,33 @@ const uses = {
|
|
|
1297
1211
|
/** 会话档案与消息历史的唯一落点:没有 memory 就没有会话管理 */
|
|
1298
1212
|
memory,
|
|
1299
1213
|
/**
|
|
1300
|
-
*
|
|
1214
|
+
* 删除会话清数据时经它的 clearing 按住这个会话;另为 agent:* 钩子的键类型。
|
|
1301
1215
|
* 第一方栈由 core 关停编排(optional 环成员先全部 drain)与回合结束时的收口处理;
|
|
1302
1216
|
* 此处 onDrain 兜的是未装 agent 或钩子未挂上的路径。
|
|
1303
1217
|
*/
|
|
1304
1218
|
agent: optional(agent),
|
|
1305
|
-
/** 自动标题的模型来源(没有 LLM
|
|
1219
|
+
/** 自动标题的模型来源(没有 LLM 时退回用户消息首段) */
|
|
1306
1220
|
llm: optional(llm),
|
|
1307
|
-
/** 房间建档时取平台的类型声明记下聊天窗口;解析接管房间的适配器实例(originInstance);可设的平台与类型 */
|
|
1308
|
-
platform: optional(platform),
|
|
1309
1221
|
/**
|
|
1310
|
-
*
|
|
1311
|
-
*
|
|
1222
|
+
* 自动标题的输出语言:取全局语言(读者是 owner),缺席时按中文。plugin-i18n 也以 optional 用会话管理,
|
|
1223
|
+
* 两边成可选环
|
|
1312
1224
|
*/
|
|
1313
|
-
|
|
1225
|
+
i18n: optional(i18n),
|
|
1226
|
+
/** 房间建档时问认领者、取平台的类型声明记下聊天窗口;解析接管房间的适配器实例(originInstance) */
|
|
1227
|
+
platform: optional(platform),
|
|
1314
1228
|
/**
|
|
1315
1229
|
* owner 的 IM 私聊在默认工作区:placeOf 按它认对端是不是 owner,与房间类别同一判据。plugin-authority 也以 optional
|
|
1316
1230
|
* 用会话管理,两边成可选环;缺席时认不出 owner,一对一窗口按窗口算
|
|
1317
1231
|
*/
|
|
1318
1232
|
authority: optional(authority),
|
|
1319
|
-
/**
|
|
1320
|
-
|
|
1233
|
+
/**
|
|
1234
|
+
* 按会话读取别的会话的范围与三个开关;缺席时取插件配置里的值。plugin-session-settings 以 required 用会话管理:这边可选,
|
|
1235
|
+
* 读取器的登记随它上线补上
|
|
1236
|
+
*/
|
|
1237
|
+
sessionFields: optional(sessionFields),
|
|
1238
|
+
/** 按会话字段的全局层:宿主配置文档里本实例的那一段 */
|
|
1239
|
+
document: optional(ownHostConfig),
|
|
1240
|
+
config,
|
|
1321
1241
|
events,
|
|
1322
1242
|
hooks,
|
|
1323
1243
|
lifecycle,
|
|
@@ -1326,123 +1246,52 @@ const uses = {
|
|
|
1326
1246
|
};
|
|
1327
1247
|
export default definePlugin({
|
|
1328
1248
|
name: '@aalis/plugin-session-manager',
|
|
1329
|
-
displayName: '
|
|
1249
|
+
displayName: t('displayName'),
|
|
1330
1250
|
subsystem: 'session',
|
|
1251
|
+
configSchema,
|
|
1331
1252
|
provides: [sessionManager],
|
|
1332
1253
|
uses,
|
|
1333
1254
|
apply: run,
|
|
1334
1255
|
});
|
|
1335
1256
|
async function run(caps) {
|
|
1336
1257
|
const { memory, events, hooks, lifecycle, logger, provide } = caps;
|
|
1337
|
-
const manager = new SessionManager(caps);
|
|
1258
|
+
const manager = new SessionManager(caps, caps.sessionFields.use.fields(configSchema, caps));
|
|
1338
1259
|
// 会话表跟随 memory 胜者:首次挂接即加载;运行中胜者换人时先写回旧后端再读新后端
|
|
1339
1260
|
memory.follow(instance => manager.attach(instance));
|
|
1340
1261
|
await manager.loading;
|
|
1341
1262
|
// 读表(含重试等待)期间停用或停机的,不再发布服务与登记
|
|
1342
1263
|
lifecycle.use.signal.throwIfAborted();
|
|
1343
1264
|
// 注册服务
|
|
1344
|
-
provide.use(sessionManager, manager, { label: '
|
|
1265
|
+
provide.use(sessionManager, manager, { label: t('displayName') });
|
|
1345
1266
|
// ===== IM 房间收录 =====
|
|
1346
|
-
//
|
|
1347
|
-
|
|
1348
|
-
//
|
|
1349
|
-
//
|
|
1350
|
-
//
|
|
1351
|
-
//
|
|
1352
|
-
|
|
1353
|
-
|
|
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))
|
|
1363
|
-
return;
|
|
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);
|
|
1379
|
-
}
|
|
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('::'))
|
|
1385
|
-
return;
|
|
1386
|
-
const known = manager.getSession(sessionId);
|
|
1387
|
-
if (recorded(known, msg)) {
|
|
1388
|
-
noteRecorded(known, msg);
|
|
1389
|
-
return;
|
|
1390
|
-
}
|
|
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);
|
|
1395
|
-
return;
|
|
1396
|
-
}
|
|
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 只归一个平台');
|
|
1401
|
-
return;
|
|
1402
|
-
}
|
|
1403
|
-
// 补记只认这个 id 归消息平台的适配器管的入站:WebUI 与终端往房间插话不算
|
|
1404
|
-
if (session && platforms[0] !== msg.platform)
|
|
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))
|
|
1405
1276
|
return;
|
|
1406
|
-
const
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
'适配器要在消息上带类型名');
|
|
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
|
+
});
|
|
1415
1285
|
}
|
|
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
1286
|
});
|
|
1438
1287
|
// ===== 会话状态 =====
|
|
1439
|
-
// 回合真正开始时(agent:
|
|
1288
|
+
// 回合真正开始时(agent:turn:before)才翻 active,归档的不翻:群里只有消息、她没开口的房间不显示「进行中」
|
|
1440
1289
|
//(触发判定在入站相位,挡下的消息到不了这里)。同一个中间件在 finally 里记下这一轮并收口,根会话与子会话一样,这是
|
|
1441
1290
|
// 唯一的收口:next() 返回时整轮已跑完、agent:turn:after 也已执行(agent 只在这个钩子的默认动作里发它),结局已经记下;
|
|
1442
|
-
// 后面的中间件不调 next() 或抛错时 agent 不发回合收尾,同样由这里兜住。按回合 id
|
|
1443
|
-
//
|
|
1444
|
-
hooks.use.middleware('agent:
|
|
1445
|
-
manager.beginTurn(data.
|
|
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);
|
|
1446
1295
|
try {
|
|
1447
1296
|
await next();
|
|
1448
1297
|
}
|