@sidleo3/dsh-chat 0.0.4
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/client/bot-list.js +243 -0
- package/client/bot-settings.js +175 -0
- package/client/bot-shared-settings.js +561 -0
- package/client/chat-ui.js +134 -0
- package/client/context-enhancement.js +435 -0
- package/client/delivery-targets.js +334 -0
- package/client/diagnostics.js +160 -0
- package/client/i18n.js +371 -0
- package/client/index.js +77 -0
- package/client/list-order.js +144 -0
- package/client/rpc.js +52 -0
- package/client/scoped-mode-editor.js +111 -0
- package/client/section.js +250 -0
- package/client/session-badges.js +263 -0
- package/client/styles.js +960 -0
- package/client/version-panel.js +97 -0
- package/cordis.patch.yml +5 -0
- package/host/bot-model.mjs +53 -0
- package/host/bot-settings.mjs +247 -0
- package/host/channel-registry.mjs +237 -0
- package/host/commands.mjs +857 -0
- package/host/deferred.mjs +291 -0
- package/host/delivery.mjs +377 -0
- package/host/file-log.mjs +169 -0
- package/host/guidance.mjs +73 -0
- package/host/index.mjs +7 -0
- package/host/interactions.mjs +330 -0
- package/host/json-store.mjs +144 -0
- package/host/log-tail.mjs +63 -0
- package/host/panel.mjs +1012 -0
- package/host/paths.mjs +50 -0
- package/host/plugin.mjs +873 -0
- package/host/prompt-context.mjs +70 -0
- package/host/rpc.mjs +147 -0
- package/host/session-keys.mjs +25 -0
- package/host/session-store.mjs +187 -0
- package/host/sessions.mjs +1348 -0
- package/host/tools.mjs +283 -0
- package/lib/client.js +4431 -0
- package/lib/index.js +5676 -0
- package/package.json +63 -0
- package/shared/access-policy.mjs +263 -0
- package/shared/channel-rail.mjs +156 -0
- package/shared/context-enhancement.mjs +415 -0
- package/shared/contract.mjs +120 -0
- package/shared/panel-sections.mjs +76 -0
- package/shared/reply-reference.mjs +115 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「版本与更新」面板:hub / 契约 / 各渠道包版本 + 连接状态 + 更新做法。
|
|
3
|
+
*
|
|
4
|
+
* 数据来自 hub 控制端点的 `channel.list`(`{ hubVersion, hubPackage, contractVersion,
|
|
5
|
+
* dataDir, logDir, channels }`)——版本一律由 host 侧给,浏览器不猜。
|
|
6
|
+
* 放在设置页底部:一眼能看到"现在跑的是哪个版本、有没有渠道启动失败、怎么升级"。
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-chat/client/version-panel
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as React from 'react';
|
|
12
|
+
|
|
13
|
+
const h = React.createElement;
|
|
14
|
+
|
|
15
|
+
/** 升级命令里的包名(本地 link 安装时指向仓库路径)。 */
|
|
16
|
+
const CHANNEL_PACKAGE_HINTS = Object.freeze({
|
|
17
|
+
feishu: 'dsh-chat-feishu',
|
|
18
|
+
weixin: 'dsh-chat-weixin',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 版本与更新面板。
|
|
23
|
+
*
|
|
24
|
+
* @param props - { connection, chatUi, translate, t }。
|
|
25
|
+
* @returns React 元素。
|
|
26
|
+
*/
|
|
27
|
+
export function VersionPanel(props) {
|
|
28
|
+
const { connection, chatUi, translate, t: frameworkT } = props;
|
|
29
|
+
const t = typeof translate === 'function' ? translate
|
|
30
|
+
: (typeof frameworkT === 'function' ? frameworkT : (key) => key);
|
|
31
|
+
const [state, setState] = React.useState({ loading: true, error: null, info: null });
|
|
32
|
+
|
|
33
|
+
const load = React.useCallback(() => {
|
|
34
|
+
setState((current) => ({ ...current, loading: true, error: null }));
|
|
35
|
+
chatUi.callControlRpc(connection, 'channel.list', {})
|
|
36
|
+
.then((result) => {
|
|
37
|
+
setState({ loading: false, error: null, info: chatUi.unwrapRpc(result) });
|
|
38
|
+
})
|
|
39
|
+
.catch((error) => {
|
|
40
|
+
setState({ loading: false, error: error?.message ?? String(error), info: null });
|
|
41
|
+
});
|
|
42
|
+
}, [chatUi, connection]);
|
|
43
|
+
|
|
44
|
+
React.useEffect(() => {
|
|
45
|
+
load();
|
|
46
|
+
}, [load]);
|
|
47
|
+
|
|
48
|
+
const Panel = chatUi.components.Panel;
|
|
49
|
+
const StatusPill = chatUi.components.StatusPill;
|
|
50
|
+
const info = state.info;
|
|
51
|
+
|
|
52
|
+
return h(Panel, {
|
|
53
|
+
title: t('版本与更新'),
|
|
54
|
+
description: t('升级插件后需要重启 dsh;只改设置页代码则刷新页面即可。'),
|
|
55
|
+
actions: h('button', {
|
|
56
|
+
type: 'button',
|
|
57
|
+
className: 'dchat-button',
|
|
58
|
+
onClick: load,
|
|
59
|
+
disabled: state.loading,
|
|
60
|
+
}, state.loading ? t('读取中…') : t('重新读取')),
|
|
61
|
+
},
|
|
62
|
+
state.error
|
|
63
|
+
? h('p', { className: 'dchat-error' }, `${t('读取失败')}:${state.error}`)
|
|
64
|
+
: null,
|
|
65
|
+
h('ul', { className: 'dchat-list' },
|
|
66
|
+
h('li', { className: 'dchat-listItem' },
|
|
67
|
+
h('span', null, t('Chat机器人内核')),
|
|
68
|
+
h('code', { className: 'dchat-code' },
|
|
69
|
+
`${info?.hubPackage ?? 'dsh-chat'} ${info?.hubVersion ?? '…'}`)),
|
|
70
|
+
h('li', { className: 'dchat-listItem' },
|
|
71
|
+
h('span', null, t('渠道契约版本')),
|
|
72
|
+
h('code', { className: 'dchat-code' }, `v${info?.contractVersion ?? '…'}`)),
|
|
73
|
+
...(info?.channels ?? []).map((channel) => h('li', {
|
|
74
|
+
key: channel.id, className: 'dchat-listItem',
|
|
75
|
+
},
|
|
76
|
+
h('span', null, `${channel.label} · ${CHANNEL_PACKAGE_HINTS[channel.id] ?? channel.id}`),
|
|
77
|
+
h('span', { className: 'dchat-versionMeta' },
|
|
78
|
+
h('code', { className: 'dchat-code' }, channel.version ?? '—'),
|
|
79
|
+
h(StatusPill, {
|
|
80
|
+
status: channel.status,
|
|
81
|
+
label: channel.status === 'running' ? t('渠道已就绪') : (channel.error ? t('渠道启动失败') : t('渠道正在启动')),
|
|
82
|
+
}))))),
|
|
83
|
+
info?.dataDir
|
|
84
|
+
? h('ul', { className: 'dchat-list' },
|
|
85
|
+
h('li', { className: 'dchat-listItem' },
|
|
86
|
+
h('span', null, t('数据目录')),
|
|
87
|
+
h('code', { className: 'dchat-code' }, info.dataDir)),
|
|
88
|
+
h('li', { className: 'dchat-listItem' },
|
|
89
|
+
h('span', null, t('日志目录')),
|
|
90
|
+
h('code', { className: 'dchat-code' }, info.logDir)))
|
|
91
|
+
: null,
|
|
92
|
+
h('div', { className: 'dchat-updateHint' },
|
|
93
|
+
h('p', { className: 'dchat-cardDescription' }, t('更新方式:在仓库里拉取新代码后重新打包,再让 DSH 重新加载插件。')),
|
|
94
|
+
h('code', { className: 'dchat-code dchat-codeBlock' }, 'npm run check'),
|
|
95
|
+
h('code', { className: 'dchat-code dchat-codeBlock' },
|
|
96
|
+
'dsh plugin --profile web add <本仓库 packages/dsh-chat 的绝对路径>')));
|
|
97
|
+
}
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 机器人级默认模型(每机器人设置里的 `model` 字段)。
|
|
3
|
+
*
|
|
4
|
+
* **为什么需要这一层**:DSH 的模型选择是**会话级**的——
|
|
5
|
+
* `session/selectModel` 必须带 `sessionId`,而 `session/create` 的参数里没有模型
|
|
6
|
+
* (`{ workspaceId?, cwd?, sessionId?, agentPreset? }`)。会话又是"用户发第一条消息"时才建的,
|
|
7
|
+
* 所以"还没有会话时想先挑好模型"原本无处可写,只能让用户先随便发一条消息。
|
|
8
|
+
*
|
|
9
|
+
* 于是把它存成**机器人默认模型**,由 `sessions.ensure()` 在新建会话后立刻 `selectModel` 应用
|
|
10
|
+
* ——与 Agent 预设、工作区同一条口径:**只对新会话生效**。
|
|
11
|
+
*
|
|
12
|
+
* 形状统一为 `{ provider, model, reasoningEffort }`;兼容旧 dsh-im `models.json` 的
|
|
13
|
+
* `{ providerId, modelId }`(历史上被原样导入到 `record.model`,从来没被读过)。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** @returns `{ provider, model, reasoningEffort }` 或 null(残缺的旧数据一律当作没配)。 */
|
|
17
|
+
export function normalizeBotModel(raw) {
|
|
18
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
19
|
+
const pick = (...values) => {
|
|
20
|
+
for (const value of values) {
|
|
21
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
};
|
|
25
|
+
const provider = pick(raw.provider, raw.providerId);
|
|
26
|
+
const model = pick(raw.model, raw.modelId);
|
|
27
|
+
// 两者缺一不可:只有一半的历史配置用起来只会在建会话时炸,按"没配"处理更保守。
|
|
28
|
+
if (!provider || !model) return null;
|
|
29
|
+
return { provider, model, reasoningEffort: pick(raw.reasoningEffort, raw.effort) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 把一次模型选择写成机器人默认。
|
|
34
|
+
*
|
|
35
|
+
* 同一个模型时保留原来的推理等级(改模型不该顺手把等级清掉);换模型时重置为"模型默认",
|
|
36
|
+
* 因为等级是模型自己的能力,跨模型沿用会给出一个当前模型不支持的等级。
|
|
37
|
+
*/
|
|
38
|
+
export function botModelForSelection(previous, { provider, model, reasoningEffort = null }) {
|
|
39
|
+
const same = previous?.provider === provider && previous?.model === model;
|
|
40
|
+
return {
|
|
41
|
+
provider,
|
|
42
|
+
model,
|
|
43
|
+
reasoningEffort: same ? (previous?.reasoningEffort ?? null) : (reasoningEffort ?? null),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 给用户看的一行描述(`P/M` 或 `P/M · 推理 x`)。 */
|
|
48
|
+
export function describeBotModel(value) {
|
|
49
|
+
const normalized = normalizeBotModel(value);
|
|
50
|
+
if (!normalized) return null;
|
|
51
|
+
return `${normalized.provider}/${normalized.model}`
|
|
52
|
+
+ `${normalized.reasoningEffort ? ` · 推理 ${normalized.reasoningEffort}` : ''}`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 每机器人共享设置的持久化(hub 所有)。
|
|
3
|
+
*
|
|
4
|
+
* 渠道无关的那些设置——工作区、模型、思考强度、Agent Preset、上下文增强、访问策略、
|
|
5
|
+
* 投递目标——由 hub 统一持有,文件为 `<dataDir>/bots.json`。渠道自己的协议配置
|
|
6
|
+
* (凭据、机器人列表、会话状态、过程展示等平台概念)留在渠道包自己的目录里。
|
|
7
|
+
*
|
|
8
|
+
* 写盘纪律(原子写、首次覆盖备份、串行队列)由 `json-store` 提供。
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-chat/host/bot-settings
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFile } from 'node:fs/promises';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { normalizeContextConfig } from '../shared/context-enhancement.mjs';
|
|
17
|
+
import { normalizePanelSections } from '../shared/panel-sections.mjs';
|
|
18
|
+
import { createJsonStore } from './json-store.mjs';
|
|
19
|
+
|
|
20
|
+
const DOCUMENT_VERSION = 1;
|
|
21
|
+
|
|
22
|
+
/** 一个机器人记录的默认值。 */
|
|
23
|
+
const EMPTY_RECORD = Object.freeze({
|
|
24
|
+
workspace: null,
|
|
25
|
+
model: null,
|
|
26
|
+
agentPreset: null,
|
|
27
|
+
contextEnhancement: null,
|
|
28
|
+
accessPolicy: null,
|
|
29
|
+
deliveryTargets: null,
|
|
30
|
+
/**
|
|
31
|
+
* 控制面板卡片的显示项(私聊/群聊各一份):`{ direct: { model: true, … }, group: {…} }`。
|
|
32
|
+
* `null` = 全显示(见 `shared/panel-sections.mjs`)。
|
|
33
|
+
*/
|
|
34
|
+
panelSections: null,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const RECORD_KEYS = Object.freeze(Object.keys(EMPTY_RECORD));
|
|
38
|
+
|
|
39
|
+
/** 旧 workspaces.json 里与每机器人设置对应的键。 */
|
|
40
|
+
const LEGACY_SOURCES = Object.freeze({
|
|
41
|
+
workspaces: 'workspace',
|
|
42
|
+
models: 'model',
|
|
43
|
+
agentPresets: 'agentPreset',
|
|
44
|
+
contextEnhancement: 'contextEnhancement',
|
|
45
|
+
accessPolicies: 'accessPolicy',
|
|
46
|
+
deliveryTargets: 'deliveryTargets',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function isPlainObject(value) {
|
|
50
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function cloneRecord(record) {
|
|
54
|
+
return {
|
|
55
|
+
...EMPTY_RECORD,
|
|
56
|
+
...(isPlainObject(record) ? record : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeDocument(value) {
|
|
61
|
+
const source = isPlainObject(value) && value.version === DOCUMENT_VERSION ? value : {};
|
|
62
|
+
const imports = isPlainObject(source.imports) ? { ...source.imports } : {};
|
|
63
|
+
const channels = {};
|
|
64
|
+
if (isPlainObject(source.channels)) {
|
|
65
|
+
for (const [channelId, bots] of Object.entries(source.channels)) {
|
|
66
|
+
if (!isPlainObject(bots)) continue;
|
|
67
|
+
const entries = {};
|
|
68
|
+
for (const [botId, record] of Object.entries(bots)) {
|
|
69
|
+
if (!isPlainObject(record)) continue;
|
|
70
|
+
entries[botId] = cloneRecord(record);
|
|
71
|
+
}
|
|
72
|
+
channels[channelId] = entries;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { version: DOCUMENT_VERSION, imports, channels };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 创建每机器人共享设置存储。
|
|
80
|
+
*
|
|
81
|
+
* @param options - { dataDir, logger }。
|
|
82
|
+
* @returns 存储:ready / read / write / list / subscribe / importLegacy / imports / path。
|
|
83
|
+
*/
|
|
84
|
+
export function createBotSettingsStore({ dataDir, logger = console } = {}) {
|
|
85
|
+
if (typeof dataDir !== 'string' || !dataDir.trim()) {
|
|
86
|
+
throw new TypeError('bot settings 需要 dataDir。');
|
|
87
|
+
}
|
|
88
|
+
const store = createJsonStore({
|
|
89
|
+
path: join(dataDir, 'bots.json'),
|
|
90
|
+
normalize: normalizeDocument,
|
|
91
|
+
empty: () => ({ version: DOCUMENT_VERSION, imports: {}, channels: {} }),
|
|
92
|
+
logger,
|
|
93
|
+
label: '每机器人设置',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
function readRecord(channelId, botId) {
|
|
97
|
+
const stored = store.snapshot().channels?.[channelId]?.[botId];
|
|
98
|
+
const record = cloneRecord(stored);
|
|
99
|
+
// 未配置(undefined)与显式清空(null)都表示"没有上下文增强"。
|
|
100
|
+
record.contextEnhancement = stored?.contextEnhancement === undefined
|
|
101
|
+
|| stored?.contextEnhancement === null
|
|
102
|
+
? null
|
|
103
|
+
: normalizeContextConfig(stored.contextEnhancement);
|
|
104
|
+
// 显示项:残缺/写错的配置按"显示"补齐(把卡片变空是最难查的那类静默失效)。
|
|
105
|
+
record.panelSections = stored?.panelSections === undefined || stored?.panelSections === null
|
|
106
|
+
? null
|
|
107
|
+
: normalizePanelSections(stored.panelSections);
|
|
108
|
+
return Object.freeze(record);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
path: store.path,
|
|
113
|
+
|
|
114
|
+
/** 等待磁盘文档就绪;渠道读取设置前应 await 它。 */
|
|
115
|
+
ready: () => store.ready(),
|
|
116
|
+
|
|
117
|
+
/** @returns 冻结的机器人记录(缺失时为默认值)。 */
|
|
118
|
+
read: readRecord,
|
|
119
|
+
|
|
120
|
+
/** 合并写入若干字段(未知键一律拒绝)。 */
|
|
121
|
+
async write(channelId, botId, patch) {
|
|
122
|
+
if (typeof channelId !== 'string' || !channelId) throw new TypeError('channelId 必填。');
|
|
123
|
+
if (typeof botId !== 'string' || !botId) throw new TypeError('botId 必填。');
|
|
124
|
+
if (!isPlainObject(patch)) throw new TypeError('patch 必须是对象。');
|
|
125
|
+
const unknown = Object.keys(patch).filter((key) => !RECORD_KEYS.includes(key));
|
|
126
|
+
if (unknown.length > 0) throw new TypeError(`未知的设置字段:${unknown.join('、')}`);
|
|
127
|
+
const withContext = Object.hasOwn(patch, 'contextEnhancement') && patch.contextEnhancement !== null
|
|
128
|
+
? { ...patch, contextEnhancement: normalizeContextConfig(patch.contextEnhancement) }
|
|
129
|
+
: patch;
|
|
130
|
+
const normalized = Object.hasOwn(withContext, 'panelSections') && withContext.panelSections !== null
|
|
131
|
+
? { ...withContext, panelSections: normalizePanelSections(withContext.panelSections) }
|
|
132
|
+
: withContext;
|
|
133
|
+
await store.update((current) => {
|
|
134
|
+
const channels = { ...current.channels };
|
|
135
|
+
const bots = { ...(channels[channelId] ?? {}) };
|
|
136
|
+
bots[botId] = { ...cloneRecord(bots[botId]), ...normalized };
|
|
137
|
+
channels[channelId] = bots;
|
|
138
|
+
return { ...current, channels };
|
|
139
|
+
});
|
|
140
|
+
return readRecord(channelId, botId);
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/** @returns 该渠道下的全部记录(含 botId)。 */
|
|
144
|
+
list(channelId) {
|
|
145
|
+
const bots = store.snapshot().channels?.[channelId] ?? {};
|
|
146
|
+
return Object.freeze(Object.keys(bots).map((botId) => Object.freeze({
|
|
147
|
+
botId,
|
|
148
|
+
...readRecord(channelId, botId),
|
|
149
|
+
})));
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
/** 订阅变更。 */
|
|
153
|
+
subscribe: (listener) => store.subscribe(listener),
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 一次性把旧渠道的 `workspaces.json` 导入为每机器人设置。
|
|
157
|
+
*
|
|
158
|
+
* 只读旧文件,绝不改写;导入过后记 `imports[channelId]`,因此用户在 dsh-chat 里
|
|
159
|
+
* 清空某条设置后不会在下次启动被"复活"。`force: true` 时忽略标记并以旧文件为准刷新。
|
|
160
|
+
*
|
|
161
|
+
* @param channelId - 渠道 id。
|
|
162
|
+
* @param legacyDir - 旧数据目录(绝对路径)。
|
|
163
|
+
* @param options - { force }。
|
|
164
|
+
* @returns { imported, bots } 或 { skipped }。
|
|
165
|
+
*/
|
|
166
|
+
async importLegacy(channelId, legacyDir, { force = false } = {}) {
|
|
167
|
+
if (!force) {
|
|
168
|
+
await store.ready();
|
|
169
|
+
if (store.snapshot().imports[channelId]) {
|
|
170
|
+
return { skipped: '已导入过', imported: 0, bots: [] };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const source = join(legacyDir, 'workspaces.json');
|
|
174
|
+
let legacy = null;
|
|
175
|
+
try {
|
|
176
|
+
legacy = JSON.parse(await readFile(source, 'utf8'));
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (error?.code === 'ENOENT') {
|
|
179
|
+
// 没有旧文件:记为已导入,避免每次启动都去探测。
|
|
180
|
+
await store.update((current) => ({
|
|
181
|
+
...current,
|
|
182
|
+
imports: {
|
|
183
|
+
...current.imports,
|
|
184
|
+
[channelId]: { path: source, importedAt: new Date().toISOString(), bots: [] },
|
|
185
|
+
},
|
|
186
|
+
}));
|
|
187
|
+
return { imported: 0, bots: [] };
|
|
188
|
+
}
|
|
189
|
+
// 读得到但解析失败:不记导入,保留下次启动重试的机会。
|
|
190
|
+
logger.warn?.(`[dsh-chat] 旧设置 ${source} 无法解析,稍后重试:${error?.message ?? error}`);
|
|
191
|
+
return { skipped: '旧设置无法解析', imported: 0, bots: [] };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const perBot = new Map();
|
|
195
|
+
for (const [legacyKey, recordKey] of Object.entries(LEGACY_SOURCES)) {
|
|
196
|
+
const table = legacy?.[legacyKey];
|
|
197
|
+
if (!isPlainObject(table)) continue;
|
|
198
|
+
for (const [botId, value] of Object.entries(table)) {
|
|
199
|
+
if (value === null || value === undefined) continue;
|
|
200
|
+
const entry = perBot.get(botId) ?? {};
|
|
201
|
+
entry[recordKey] = recordKey === 'contextEnhancement'
|
|
202
|
+
? normalizeContextConfig(value)
|
|
203
|
+
: value;
|
|
204
|
+
perBot.set(botId, entry);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
await store.update((current) => {
|
|
209
|
+
const channels = { ...current.channels };
|
|
210
|
+
if (perBot.size > 0) {
|
|
211
|
+
const bots = { ...(channels[channelId] ?? {}) };
|
|
212
|
+
for (const [botId, patch] of perBot) {
|
|
213
|
+
// 首次导入只补空缺;force 重跑则以旧文件为准刷新。
|
|
214
|
+
const merged = cloneRecord(bots[botId]);
|
|
215
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
216
|
+
if (force || merged[key] === null) merged[key] = value;
|
|
217
|
+
}
|
|
218
|
+
bots[botId] = merged;
|
|
219
|
+
}
|
|
220
|
+
channels[channelId] = bots;
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
...current,
|
|
224
|
+
imports: {
|
|
225
|
+
...current.imports,
|
|
226
|
+
[channelId]: {
|
|
227
|
+
path: source,
|
|
228
|
+
importedAt: new Date().toISOString(),
|
|
229
|
+
bots: [...perBot.keys()],
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
channels,
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
if (perBot.size > 0) {
|
|
237
|
+
logger.info?.(`[dsh-chat] 已从 ${source} 导入 ${perBot.size} 个机器人的设置`);
|
|
238
|
+
}
|
|
239
|
+
return { imported: perBot.size, bots: [...perBot.keys()] };
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
/** @returns 已导入来源的快照(调试与测试用)。 */
|
|
243
|
+
imports() {
|
|
244
|
+
return Object.freeze({ ...store.snapshot().imports });
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 渠道注册表(host 侧):渠道包提交定义,hub 负责创建实例、挂 RPC、跟踪状态。
|
|
3
|
+
*
|
|
4
|
+
* 生命周期要点:
|
|
5
|
+
* - `register()` 同步返回注销函数(Cordis `ctx.effect` 需要同步 disposer),
|
|
6
|
+
* 实例创建与启动在后台进行,失败记入状态而不是抛出;
|
|
7
|
+
* - RPC 路由在实例就绪前就已挂上,因此设置页在"启动中/启动失败"时也能读到状态;
|
|
8
|
+
* - 注销后先摘路由、再停实例,重复注销是幂等的。
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-chat/host/channel-registry
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { channelLabel, validateChannelDefinition } from '../shared/contract.mjs';
|
|
14
|
+
import { fail } from './rpc.mjs';
|
|
15
|
+
|
|
16
|
+
const STATUSES = new Set(['starting', 'running', 'failed', 'stopped']);
|
|
17
|
+
|
|
18
|
+
function describeError(error) {
|
|
19
|
+
if (!error) return null;
|
|
20
|
+
return Object.freeze({
|
|
21
|
+
code: typeof error.code === 'string' ? error.code : 'chat/channel-error',
|
|
22
|
+
message: typeof error.message === 'string' && error.message ? error.message : String(error),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 创建渠道注册表。
|
|
28
|
+
*
|
|
29
|
+
* @param options - { logger, rpc, createDeps, onRegistered }。
|
|
30
|
+
* `createDeps(channelId, definition)` 返回交给渠道 `createChannel(deps)` 的依赖包;
|
|
31
|
+
* `onRegistered(channelId, legacy)` 在定义通过校验后同步调用一次(用于旧设置导入),
|
|
32
|
+
* 其异常不会影响渠道注册。
|
|
33
|
+
* @returns { register, list, get, subscribe, handleRpc, disposeAll }。
|
|
34
|
+
*/
|
|
35
|
+
export function createChannelRegistry({
|
|
36
|
+
logger = console,
|
|
37
|
+
rpc,
|
|
38
|
+
createDeps,
|
|
39
|
+
onRegistered,
|
|
40
|
+
onDelivery,
|
|
41
|
+
}) {
|
|
42
|
+
if (typeof rpc?.register !== 'function') throw new TypeError('渠道注册表需要 rpc 载体。');
|
|
43
|
+
if (typeof createDeps !== 'function') throw new TypeError('渠道注册表需要 createDeps。');
|
|
44
|
+
|
|
45
|
+
/** @type {Map<string, object>} */
|
|
46
|
+
const channels = new Map();
|
|
47
|
+
const listeners = new Set();
|
|
48
|
+
let snapshot = Object.freeze([]);
|
|
49
|
+
|
|
50
|
+
function publish() {
|
|
51
|
+
snapshot = Object.freeze([...channels.values()].map((record) => Object.freeze({
|
|
52
|
+
id: record.definition.id,
|
|
53
|
+
label: channelLabel(record.definition),
|
|
54
|
+
order: record.definition.order,
|
|
55
|
+
version: record.definition.version ?? null,
|
|
56
|
+
status: record.status,
|
|
57
|
+
error: record.error,
|
|
58
|
+
startedAt: record.startedAt,
|
|
59
|
+
})).sort((left, right) => (
|
|
60
|
+
left.order === right.order ? left.id.localeCompare(right.id) : left.order - right.order
|
|
61
|
+
)));
|
|
62
|
+
for (const listener of [...listeners]) {
|
|
63
|
+
try {
|
|
64
|
+
listener();
|
|
65
|
+
} catch {
|
|
66
|
+
// 单个订阅者出错不影响其他订阅者。
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function setStatus(record, status, error = null) {
|
|
72
|
+
if (!STATUSES.has(status)) throw new TypeError(`未知渠道状态:${status}`);
|
|
73
|
+
record.status = status;
|
|
74
|
+
record.error = describeError(error);
|
|
75
|
+
publish();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function start(record) {
|
|
79
|
+
const { definition } = record;
|
|
80
|
+
try {
|
|
81
|
+
const instance = await definition.createChannel(record.deps);
|
|
82
|
+
if (record.disposed) {
|
|
83
|
+
await instance?.stop?.();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (instance === null || typeof instance !== 'object') {
|
|
87
|
+
throw new TypeError(`渠道 ${definition.id} 的 createChannel 必须返回实例对象。`);
|
|
88
|
+
}
|
|
89
|
+
if (instance.endpoints !== undefined && (typeof instance.endpoints !== 'object'
|
|
90
|
+
|| instance.endpoints === null || Array.isArray(instance.endpoints))) {
|
|
91
|
+
throw new TypeError(`渠道 ${definition.id} 的 endpoints 必须是方法表。`);
|
|
92
|
+
}
|
|
93
|
+
record.instance = instance;
|
|
94
|
+
// 渠道声明了投递能力就挂到 hub 的投递服务上(注销时自动摘掉)。
|
|
95
|
+
if (instance.delivery !== undefined && typeof onDelivery === 'function') {
|
|
96
|
+
record.releaseDelivery = onDelivery(definition.id, instance.delivery);
|
|
97
|
+
}
|
|
98
|
+
await instance.start?.();
|
|
99
|
+
if (record.disposed) {
|
|
100
|
+
await instance.stop?.();
|
|
101
|
+
record.releaseDelivery?.();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (record.status === 'starting') setStatus(record, 'running');
|
|
105
|
+
} catch (error) {
|
|
106
|
+
setStatus(record, 'failed', error);
|
|
107
|
+
logger.error?.(`[dsh-chat] 渠道 ${definition.id} 启动失败:${error?.message ?? error}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function stop(record) {
|
|
112
|
+
record.disposed = true;
|
|
113
|
+
record.releaseRoutes?.();
|
|
114
|
+
record.releaseRoutes = null;
|
|
115
|
+
record.releaseDelivery?.();
|
|
116
|
+
record.releaseDelivery = null;
|
|
117
|
+
try {
|
|
118
|
+
await record.instance?.stop?.();
|
|
119
|
+
} catch (error) {
|
|
120
|
+
logger.warn?.(`[dsh-chat] 渠道 ${record.definition.id} 停止时报错:${error?.message ?? error}`);
|
|
121
|
+
}
|
|
122
|
+
record.instance = null;
|
|
123
|
+
record.status = 'stopped';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 注册一个渠道。
|
|
128
|
+
*
|
|
129
|
+
* @param definition - 渠道定义(见 CONTRACT.md)。
|
|
130
|
+
* @returns 同步注销函数。
|
|
131
|
+
*/
|
|
132
|
+
function register(definition) {
|
|
133
|
+
const validated = validateChannelDefinition(definition);
|
|
134
|
+
if (channels.has(validated.id)) {
|
|
135
|
+
throw new Error(`渠道 ${validated.id} 已注册,不能重复注册。`);
|
|
136
|
+
}
|
|
137
|
+
const record = {
|
|
138
|
+
definition: validated,
|
|
139
|
+
deps: null,
|
|
140
|
+
instance: null,
|
|
141
|
+
status: 'starting',
|
|
142
|
+
error: null,
|
|
143
|
+
startedAt: new Date().toISOString(),
|
|
144
|
+
disposed: false,
|
|
145
|
+
releaseRoutes: null,
|
|
146
|
+
};
|
|
147
|
+
record.deps = Object.freeze({
|
|
148
|
+
...createDeps(validated.id, validated),
|
|
149
|
+
reportStatus: (status, error) => {
|
|
150
|
+
if (record.disposed) return;
|
|
151
|
+
setStatus(record, status, error);
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
channels.set(validated.id, record);
|
|
155
|
+
// 旧设置导入等"注册后动作"不能影响渠道本身。
|
|
156
|
+
if (typeof onRegistered === 'function') {
|
|
157
|
+
try {
|
|
158
|
+
onRegistered(validated.id, validated.legacy);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
logger.warn?.(`[dsh-chat] 渠道 ${validated.id} 注册后动作失败:${error?.message ?? error}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// 路由先挂上:状态页在渠道启动完成前就能读到 starting / failed。
|
|
164
|
+
record.releaseRoutes = rpc.register(validated.id, (method, payload, signal) => (
|
|
165
|
+
handleRpc(validated.id, method, payload, signal)
|
|
166
|
+
));
|
|
167
|
+
publish();
|
|
168
|
+
void start(record);
|
|
169
|
+
|
|
170
|
+
let released = false;
|
|
171
|
+
return () => {
|
|
172
|
+
if (released) return;
|
|
173
|
+
released = true;
|
|
174
|
+
if (!channels.delete(validated.id)) return;
|
|
175
|
+
void stop(record);
|
|
176
|
+
publish();
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** @returns 渠道状态快照(按 order 排序)。 */
|
|
181
|
+
function list() {
|
|
182
|
+
return snapshot;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param id - 渠道 id。
|
|
187
|
+
* @returns 渠道状态记录,未注册时为 undefined。
|
|
188
|
+
*/
|
|
189
|
+
function get(id) {
|
|
190
|
+
return snapshot.find((entry) => entry.id === id);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 订阅渠道状态变化。
|
|
195
|
+
*
|
|
196
|
+
* @param listener - 无参回调。
|
|
197
|
+
* @returns 取消订阅函数。
|
|
198
|
+
*/
|
|
199
|
+
function subscribe(listener) {
|
|
200
|
+
listeners.add(listener);
|
|
201
|
+
return () => listeners.delete(listener);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 把一次 RPC 调用分派给渠道实例的 endpoints。
|
|
206
|
+
*
|
|
207
|
+
* @param channelId - 渠道 id。
|
|
208
|
+
* @param method - 渠道自定义方法名。
|
|
209
|
+
* @param payload - 载荷。
|
|
210
|
+
* @param signal - AbortSignal。
|
|
211
|
+
* @returns RPC 结果对象。
|
|
212
|
+
*/
|
|
213
|
+
async function handleRpc(channelId, method, payload, signal) {
|
|
214
|
+
const record = channels.get(channelId);
|
|
215
|
+
if (!record) return fail('chat/unknown-channel', `渠道 ${channelId} 未安装。`);
|
|
216
|
+
if (record.status === 'failed') {
|
|
217
|
+
return fail('chat/channel-failed', record.error?.message ?? `渠道 ${channelId} 启动失败。`,
|
|
218
|
+
record.error?.code ? { channelCode: record.error.code } : {});
|
|
219
|
+
}
|
|
220
|
+
const endpoint = record.instance?.endpoints?.[method];
|
|
221
|
+
if (typeof endpoint !== 'function') {
|
|
222
|
+
return fail('chat/unknown-method', `渠道 ${channelId} 不支持 ${method}。`);
|
|
223
|
+
}
|
|
224
|
+
return endpoint(payload, { signal, channelId });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 注销全部渠道(插件卸载时)。 */
|
|
228
|
+
function disposeAll() {
|
|
229
|
+
for (const [id, record] of [...channels.entries()]) {
|
|
230
|
+
channels.delete(id);
|
|
231
|
+
void stop(record);
|
|
232
|
+
}
|
|
233
|
+
publish();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { register, list, get, subscribe, handleRpc, disposeAll };
|
|
237
|
+
}
|