@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.
Files changed (47) hide show
  1. package/client/bot-list.js +243 -0
  2. package/client/bot-settings.js +175 -0
  3. package/client/bot-shared-settings.js +561 -0
  4. package/client/chat-ui.js +134 -0
  5. package/client/context-enhancement.js +435 -0
  6. package/client/delivery-targets.js +334 -0
  7. package/client/diagnostics.js +160 -0
  8. package/client/i18n.js +371 -0
  9. package/client/index.js +77 -0
  10. package/client/list-order.js +144 -0
  11. package/client/rpc.js +52 -0
  12. package/client/scoped-mode-editor.js +111 -0
  13. package/client/section.js +250 -0
  14. package/client/session-badges.js +263 -0
  15. package/client/styles.js +960 -0
  16. package/client/version-panel.js +97 -0
  17. package/cordis.patch.yml +5 -0
  18. package/host/bot-model.mjs +53 -0
  19. package/host/bot-settings.mjs +247 -0
  20. package/host/channel-registry.mjs +237 -0
  21. package/host/commands.mjs +857 -0
  22. package/host/deferred.mjs +291 -0
  23. package/host/delivery.mjs +377 -0
  24. package/host/file-log.mjs +169 -0
  25. package/host/guidance.mjs +73 -0
  26. package/host/index.mjs +7 -0
  27. package/host/interactions.mjs +330 -0
  28. package/host/json-store.mjs +144 -0
  29. package/host/log-tail.mjs +63 -0
  30. package/host/panel.mjs +1012 -0
  31. package/host/paths.mjs +50 -0
  32. package/host/plugin.mjs +873 -0
  33. package/host/prompt-context.mjs +70 -0
  34. package/host/rpc.mjs +147 -0
  35. package/host/session-keys.mjs +25 -0
  36. package/host/session-store.mjs +187 -0
  37. package/host/sessions.mjs +1348 -0
  38. package/host/tools.mjs +283 -0
  39. package/lib/client.js +4431 -0
  40. package/lib/index.js +5676 -0
  41. package/package.json +63 -0
  42. package/shared/access-policy.mjs +263 -0
  43. package/shared/channel-rail.mjs +156 -0
  44. package/shared/context-enhancement.mjs +415 -0
  45. package/shared/contract.mjs +120 -0
  46. package/shared/panel-sections.mjs +76 -0
  47. package/shared/reply-reference.mjs +115 -0
package/host/tools.mjs ADDED
@@ -0,0 +1,283 @@
1
+ /**
2
+ * 聊天工具(模型可调用):让 agent 会话自己把结果发到 IM。
3
+ *
4
+ * 为什么放在 hub:投递、目标清单、渠道能力都是 hub 的东西;工具只是它们的
5
+ * 一层模型可见外壳。渠道不需要(也不应该)各自注册一遍。
6
+ *
7
+ * 安全边界(重要):
8
+ * - `chat_targets` 只读;
9
+ * - `chat_send` **只能发给用户已保存的目标**,不能临时指定任意会话;
10
+ * - `chat_save_target` 只接受渠道自己发现的候选(即该机器人历史上真实对话过的
11
+ * 会话),agent 无法凭空捏造一个投递目标。
12
+ *
13
+ * @module dsh-chat/host/tools
14
+ */
15
+
16
+ const OUTPUT_TEXT = Object.freeze({
17
+ schema: { type: 'string' },
18
+ render: (_args, value) => [{ type: 'text', text: typeof value === 'string' ? value : String(value) }],
19
+ });
20
+
21
+ const CHANNEL_FIELD = {
22
+ type: 'string',
23
+ description: '渠道 id,例如 feishu(飞书)或 weixin(微信)。省略时先列出已安装的渠道。',
24
+ };
25
+
26
+ const BOT_FIELD = {
27
+ type: 'string',
28
+ description: '机器人/账号 id(在设置页的机器人卡片上可见,例如 bot_1f4c… / wx_0f2d…)。'
29
+ + '省略时列出该渠道下的机器人及其可投递目标。',
30
+ };
31
+
32
+ function formatBytes(bytes) {
33
+ if (!Number.isFinite(bytes)) return '';
34
+ if (bytes < 1024) return `${bytes}B`;
35
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
36
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
37
+ }
38
+
39
+ /** 发文件失败时给出"下一步能做什么",而不是甩一个错误码。 */
40
+ function describeFileFailure(error, args) {
41
+ const code = error?.code ?? '';
42
+ const message = error?.message ?? String(error);
43
+ if (code === 'chat/unknown-target') {
44
+ return `目标 ${args.target_id} 还没有保存,无法发送。`
45
+ + '先用 chat_targets 查看候选,再用 chat_save_target 保存,或请用户到设置页保存。';
46
+ }
47
+ if (code === 'chat/file-not-found') {
48
+ return `${message}。请确认路径(相对路径按该机器人的工作区解析),或先自己生成这个文件。`;
49
+ }
50
+ if (code === 'chat/file-too-large') {
51
+ return `${message}。可以把内容拆小、压缩,或改成生成后分多次发送。`;
52
+ }
53
+ if (code === 'chat/delivery-unsupported') {
54
+ return `${message}(该渠道还没实现发送文件,可以先把结果作为文本发出去)。`;
55
+ }
56
+ return `发送文件失败:${code} ${message}`.trim();
57
+ }
58
+
59
+ /** 渲染一行目标。 */
60
+ function targetLine(target) {
61
+ const route = Object.entries(target.route).map(([key, value]) => `${key}=${value}`).join(', ');
62
+ return `${target.id}\t${target.kind === 'group' ? '群聊' : '私聊'}\t${target.name || '(未命名)'}\t${route}`
63
+ + `${target.discovered ? '\t候选(需先保存才能发)' : ''}`;
64
+ }
65
+
66
+ function describeTargets(result) {
67
+ if (!result.canSend) return `该渠道不支持主动投递,无法向它发送消息。`;
68
+ if (result.targets.length === 0) {
69
+ return '该机器人还没有可投递的目标:先在设置页里保存一个,或先与它对话过(对话过的会话会被发现为候选)。';
70
+ }
71
+ return [
72
+ `可投递目标(共 ${result.targets.length} 个):`,
73
+ 'id\t类型\t名称\t路由\t状态',
74
+ ...result.targets.map(targetLine),
75
+ ].join('\n');
76
+ }
77
+
78
+ /** 没给 channel_id 时:列出已安装渠道与各自的机器人数,让调用方决定下一步。 */
79
+ function describeChannels(entries, botsOf) {
80
+ if (entries.length === 0) return '当前没有安装任何聊天渠道。';
81
+ const lines = entries.map((entry) => {
82
+ const bots = botsOf(entry.id);
83
+ const status = entry.status === 'running'
84
+ ? '运行中'
85
+ : `${entry.status}${entry.error?.code ? `(${entry.error.code})` : ''}`;
86
+ return `${entry.id}\t${entry.label}\t${status}\t机器人 ${bots.length} 个`;
87
+ });
88
+ return [
89
+ `已安装渠道(共 ${entries.length} 个):`,
90
+ 'id\t名称\t状态\t机器人',
91
+ ...lines,
92
+ '下一步:带上 channel_id 再调一次 chat_targets,即可看到该渠道下的机器人与可投递目标。',
93
+ ].join('\n');
94
+ }
95
+
96
+ /** 没给 bot_id 时:逐个机器人列出可投递目标(机器人数量很少,一次给全)。 */
97
+ async function describeBots(channelId, records, delivery) {
98
+ if (records.length === 0) {
99
+ return `${channelId} 下还没有机器人:请先在设置页里添加或登录一个,再来查询可投递目标。`;
100
+ }
101
+ const blocks = [`${channelId} 下的机器人(共 ${records.length} 个):`];
102
+ for (const record of records) {
103
+ const listed = await delivery.list({ channelId, botId: record.botId });
104
+ blocks.push('', `[${record.botId}]`, describeTargets(listed));
105
+ }
106
+ return blocks.join('\n');
107
+ }
108
+
109
+ /**
110
+ * 注册聊天工具。
111
+ *
112
+ * @param toolCtx - 已注入 `tools` 的 Cordis 上下文。
113
+ * @param options - { delivery, channels, bots, logger }。
114
+ * `channels.list()` 返回已安装渠道状态;`bots.list(channelId)` 返回该渠道的机器人记录。
115
+ * @returns 注销函数(释放全部工具)。
116
+ */
117
+ export function registerChatTools(toolCtx, { delivery, channels, bots, logger = console } = {}) {
118
+ if (typeof toolCtx?.tools?.register !== 'function') {
119
+ throw new TypeError('注册聊天工具需要 Cordis 的 tools 服务。');
120
+ }
121
+ if (!delivery?.send) throw new TypeError('注册聊天工具需要投递服务。');
122
+ const listChannels = () => (typeof channels?.list === 'function' ? channels.list() : []);
123
+ const listBots = (channelId) => (typeof bots?.list === 'function' ? bots.list(channelId) : []);
124
+ const disposers = [];
125
+
126
+ disposers.push(toolCtx.tools.register({
127
+ name: 'chat_targets',
128
+ description: '查询某个聊天机器人可以主动投递的会话(已保存的目标 + 从历史会话发现的候选)。'
129
+ + '省略 channel_id 先列出已安装渠道;省略 bot_id 列出该渠道的机器人与目标。'
130
+ + '需要把结果发到飞书/微信时,先用它确认目标 id。',
131
+ parameters: {
132
+ type: 'object',
133
+ properties: { channel_id: CHANNEL_FIELD, bot_id: BOT_FIELD },
134
+ additionalProperties: false,
135
+ },
136
+ output: OUTPUT_TEXT,
137
+ async execute(args) {
138
+ const channelId = typeof args.channel_id === 'string' && args.channel_id.trim()
139
+ ? args.channel_id.trim() : null;
140
+ const botId = typeof args.bot_id === 'string' && args.bot_id.trim() ? args.bot_id.trim() : null;
141
+ if (!channelId) return describeChannels(listChannels(), listBots);
142
+ if (!listChannels().some((entry) => entry.id === channelId)) {
143
+ return `没有安装名为 ${channelId} 的渠道。已安装:`
144
+ + `${listChannels().map((entry) => entry.id).join('、') || '(无)'}。`;
145
+ }
146
+ if (!botId) return describeBots(channelId, listBots(channelId), delivery);
147
+ return describeTargets(await delivery.list({ channelId, botId }));
148
+ },
149
+ }));
150
+
151
+ disposers.push(toolCtx.tools.register({
152
+ name: 'chat_send',
153
+ description: '把一段文本主动发送到指定聊天机器人的指定会话(目标必须已在设置里保存)。'
154
+ + '适合把报表、任务结果推给用户。返回发送结果;失败会说明原因。',
155
+ parameters: {
156
+ type: 'object',
157
+ properties: {
158
+ channel_id: CHANNEL_FIELD,
159
+ bot_id: BOT_FIELD,
160
+ target_id: {
161
+ type: 'string',
162
+ description: 'chat_targets 列出的目标 id(只能是已保存的目标,不能是候选)。',
163
+ },
164
+ text: { type: 'string', description: '要发送的正文(纯文本)。' },
165
+ },
166
+ required: ['channel_id', 'bot_id', 'target_id', 'text'],
167
+ additionalProperties: false,
168
+ },
169
+ output: OUTPUT_TEXT,
170
+ async execute(args) {
171
+ try {
172
+ const result = await delivery.send({
173
+ channelId: args.channel_id,
174
+ botId: args.bot_id,
175
+ targetId: args.target_id,
176
+ text: args.text,
177
+ });
178
+ const messageId = result?.messageId ?? result?.providerMessageIds?.[0] ?? null;
179
+ return `已发送到 ${args.target_id}${messageId ? `(消息 id ${messageId})` : ''}。`;
180
+ } catch (error) {
181
+ // 工具报错要能指导下一步动作,而不是只抛一个码。
182
+ if (error?.code === 'chat/unknown-target') {
183
+ return `目标 ${args.target_id} 还没有保存,无法发送。`
184
+ + '先用 chat_targets 查看候选,再用 chat_save_target 保存,或请用户到设置页保存。';
185
+ }
186
+ return `发送失败:${error?.code ?? ''} ${error?.message ?? String(error)}`.trim();
187
+ }
188
+ },
189
+ }));
190
+
191
+ disposers.push(toolCtx.tools.register({
192
+ name: 'chat_send_file',
193
+ description: '把一个本地文件(报表、Excel、图片等,≤30MB)发到指定聊天机器人的指定会话。'
194
+ + '目标必须已在设置里保存;相对路径按该机器人的工作区解析。'
195
+ + '适合把生成好的结果文件直接推给用户。',
196
+ parameters: {
197
+ type: 'object',
198
+ properties: {
199
+ channel_id: CHANNEL_FIELD,
200
+ bot_id: BOT_FIELD,
201
+ target_id: {
202
+ type: 'string',
203
+ description: 'chat_targets 列出的目标 id(只能是已保存的目标,不能是候选)。',
204
+ },
205
+ path: {
206
+ type: 'string',
207
+ description: '要发送的文件路径(绝对路径,或相对该机器人工作区的路径)。',
208
+ },
209
+ name: { type: 'string', description: '对方看到的文件名(可选,默认取文件名)。' },
210
+ },
211
+ required: ['channel_id', 'bot_id', 'target_id', 'path'],
212
+ additionalProperties: false,
213
+ },
214
+ output: OUTPUT_TEXT,
215
+ async execute(args) {
216
+ try {
217
+ const result = await delivery.sendFile({
218
+ channelId: args.channel_id,
219
+ botId: args.bot_id,
220
+ targetId: args.target_id,
221
+ path: args.path,
222
+ name: args.name,
223
+ });
224
+ const size = result?.size ? `(${formatBytes(result.size)})` : '';
225
+ const messageId = result?.messageId ?? null;
226
+ return `已发送文件 ${result?.name ?? args.path}${size} 到 ${args.target_id}`
227
+ + `${messageId ? `(消息 id ${messageId})` : ''}。`;
228
+ } catch (error) {
229
+ return describeFileFailure(error, args);
230
+ }
231
+ },
232
+ }));
233
+
234
+ disposers.push(toolCtx.tools.register({
235
+ name: 'chat_save_target',
236
+ description: '把一个"候选"会话保存为可投递目标(只能保存 chat_targets 里标记为候选的目标,'
237
+ + '即该机器人历史上真实对话过的会话)。保存后即可用 chat_send 发送。',
238
+ parameters: {
239
+ type: 'object',
240
+ properties: {
241
+ channel_id: CHANNEL_FIELD,
242
+ bot_id: BOT_FIELD,
243
+ target_id: { type: 'string', description: 'chat_targets 里标记为候选的目标 id。' },
244
+ name: { type: 'string', description: '给这个目标起的名字(可选)。' },
245
+ },
246
+ required: ['channel_id', 'bot_id', 'target_id'],
247
+ additionalProperties: false,
248
+ },
249
+ output: OUTPUT_TEXT,
250
+ async execute(args) {
251
+ const listed = await delivery.list({ channelId: args.channel_id, botId: args.bot_id });
252
+ const candidate = listed.targets.find((t) => t.id === args.target_id);
253
+ if (!candidate) {
254
+ return `没有找到候选 ${args.target_id}(已保存的目标无需重复保存)。`;
255
+ }
256
+ if (!candidate.discovered) {
257
+ return `目标 ${args.target_id} 已经保存过了。`;
258
+ }
259
+ const saved = await delivery.save({
260
+ channelId: args.channel_id,
261
+ botId: args.bot_id,
262
+ target: {
263
+ id: candidate.id,
264
+ name: args.name ?? candidate.name,
265
+ kind: candidate.kind,
266
+ route: candidate.route,
267
+ },
268
+ });
269
+ logger.info?.(`[dsh-chat] agent 保存了投递目标 ${saved.id}(${args.channel_id}/${args.bot_id})`);
270
+ return `已保存目标 ${saved.id}(${saved.kind === 'group' ? '群聊' : '私聊'} · ${saved.name || '未命名'}),现在可以用 chat_send 发送。`;
271
+ },
272
+ }));
273
+
274
+ return () => {
275
+ for (const dispose of disposers.reverse()) {
276
+ try {
277
+ dispose?.();
278
+ } catch (error) {
279
+ logger.warn?.(`[dsh-chat] 注销聊天工具失败:${error?.message ?? error}`);
280
+ }
281
+ }
282
+ };
283
+ }