hyacinth-ai 0.9.20 → 0.9.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channels/dispatcher.d.ts +44 -0
- package/dist/channels/dispatcher.js +135 -0
- package/dist/channels/interface.d.ts +28 -0
- package/dist/channels/plugins/feishu/feishu-channel.d.ts +3 -1
- package/dist/channels/plugins/feishu/feishu-channel.js +104 -1
- package/dist/channels/plugins/feishu/feishu-send.d.ts +8 -0
- package/dist/channels/plugins/feishu/feishu-send.js +40 -0
- package/dist/gateway/factory.js +7 -7
- package/dist/gateway/tui.js +10 -0
- package/dist/index.js +0 -0
- package/dist/multimodal/index.js +0 -1
- package/dist/orchestrator/loop.js +0 -1
- package/dist/prompts/tools/framework-capabilities.md +18 -2
- package/dist/runtime/defaults.js +1 -1
- package/dist/setup/config.js +2 -2
- package/dist/setup/wizard.js +2 -2
- package/package.json +12 -11
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Tool } from '../tools/interface.js';
|
|
2
|
+
import type { ChannelManager } from './manager.js';
|
|
3
|
+
/**
|
|
4
|
+
* MessageDispatcher — 跨渠道统一分发层。
|
|
5
|
+
*
|
|
6
|
+
* 持有 ChannelManager 引用,按渠道 ID 查找目标渠道并调用其 send() 方法。
|
|
7
|
+
* 本身无状态——每次调用都是独立的、一次性的。
|
|
8
|
+
*/
|
|
9
|
+
export declare class MessageDispatcher {
|
|
10
|
+
private channelManager;
|
|
11
|
+
constructor(channelManager: ChannelManager);
|
|
12
|
+
/**
|
|
13
|
+
* 借用指定渠道发送消息。
|
|
14
|
+
*
|
|
15
|
+
* @returns 发送结果描述字符串,失败时包含错误原因(不抛异常,始终返回字符串)
|
|
16
|
+
*/
|
|
17
|
+
send(args: {
|
|
18
|
+
/** 目标渠道 ID(如 'feishu'、'clawbot') */
|
|
19
|
+
channel: string;
|
|
20
|
+
/** 目标用户 ID 或群聊 ID */
|
|
21
|
+
to: string;
|
|
22
|
+
/** 目标类型,默认 user */
|
|
23
|
+
targetType?: 'user' | 'chat';
|
|
24
|
+
/** 文本内容(可选,与 images 至少有一项有意义) */
|
|
25
|
+
text?: string;
|
|
26
|
+
/** 图片列表(base64 + MIME 类型) */
|
|
27
|
+
images?: Array<{
|
|
28
|
+
data: string;
|
|
29
|
+
media_type: string;
|
|
30
|
+
}>;
|
|
31
|
+
}): Promise<string>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* send_channel_message 工具 —— 暴露给 Agent 的跨渠道发送入口。
|
|
35
|
+
*
|
|
36
|
+
* Agent 在任何渠道的任意会话中都可以调用此工具,借用其他已连接渠道
|
|
37
|
+
* 的发送能力。典型场景:TUI 会话中的 Agent 通过飞书发图片给用户,
|
|
38
|
+
* 或者定时任务触发后通过微信推送通知。
|
|
39
|
+
*
|
|
40
|
+
* 此工具注册在 TUI/Server 的网关层(gateway/tui.ts、gateway/server.ts),
|
|
41
|
+
* 在 channelManager.startAll() 之后注册,确保所有渠道已启动。
|
|
42
|
+
*/
|
|
43
|
+
export declare function createSendChannelMessageTool(dispatcher: MessageDispatcher): Tool;
|
|
44
|
+
//# sourceMappingURL=dispatcher.d.ts.map
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// MessageDispatcher — 跨渠道消息分发层
|
|
3
|
+
// ============================================================
|
|
4
|
+
//
|
|
5
|
+
// 设计意图:
|
|
6
|
+
// Hyacinth 的渠道系统原本是"请求-回复"模式——用户在某个渠道发消息,
|
|
7
|
+
// Agent 通过同一个渠道的 reply() 回复。但这限制了 Agent 的能力:
|
|
8
|
+
// TUI 会话中的 Agent 无法通过飞书发图片,飞书会话中的 Agent 也无法
|
|
9
|
+
// 通过微信传文件。每个渠道的发送能力被封闭在自己的生命周期里。
|
|
10
|
+
//
|
|
11
|
+
// MessageDispatcher 打破了这个封闭——它让 Agent 可以从任意会话
|
|
12
|
+
// 借用任意已连接渠道的发送能力。这是"跨渠道"而非"跨会话"——
|
|
13
|
+
// 借用的是渠道的 API 能力,不影响任何渠道的对话状态。
|
|
14
|
+
//
|
|
15
|
+
// 核心语义("纯借用"):
|
|
16
|
+
// 1. 不创建 session —— 消息是一次性的,不关联 AgentLoop
|
|
17
|
+
// 2. 不写 conversation.jsonl —— 不污染任何渠道的对话历史
|
|
18
|
+
// 3. 不影响 ChannelHandler 内部状态 —— sessionMap、消息队列等完全不变
|
|
19
|
+
// 4. 被借用渠道对"谁借的"完全无感 —— 不记录调用来源
|
|
20
|
+
//
|
|
21
|
+
// 调用链:
|
|
22
|
+
// Agent → send_channel_message({channel:'feishu', to:'ou_xxx', text:'...'})
|
|
23
|
+
// → MessageDispatcher.send()
|
|
24
|
+
// → ChannelManager.get('feishu') → handler.send(target, content)
|
|
25
|
+
// → 渠道 API → 用户收到消息
|
|
26
|
+
//
|
|
27
|
+
// 扩展新渠道时:
|
|
28
|
+
// 只需实现 ChannelHandler.send?() 方法(可选),MessageDispatcher 自动发现。
|
|
29
|
+
// 不实现 send() 的渠道返回 "不支持主动发送",不影响其他功能。
|
|
30
|
+
// ============================================================
|
|
31
|
+
/**
|
|
32
|
+
* MessageDispatcher — 跨渠道统一分发层。
|
|
33
|
+
*
|
|
34
|
+
* 持有 ChannelManager 引用,按渠道 ID 查找目标渠道并调用其 send() 方法。
|
|
35
|
+
* 本身无状态——每次调用都是独立的、一次性的。
|
|
36
|
+
*/
|
|
37
|
+
export class MessageDispatcher {
|
|
38
|
+
channelManager;
|
|
39
|
+
constructor(channelManager) {
|
|
40
|
+
this.channelManager = channelManager;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 借用指定渠道发送消息。
|
|
44
|
+
*
|
|
45
|
+
* @returns 发送结果描述字符串,失败时包含错误原因(不抛异常,始终返回字符串)
|
|
46
|
+
*/
|
|
47
|
+
async send(args) {
|
|
48
|
+
// ① 查找目标渠道
|
|
49
|
+
const state = this.channelManager.get(args.channel);
|
|
50
|
+
if (!state || state.status !== 'active') {
|
|
51
|
+
return `渠道 "${args.channel}" 未连接或未启动。可用渠道: ${this.channelManager.getStatusSummary().map(s => s.id).join(', ') || '无'}`;
|
|
52
|
+
}
|
|
53
|
+
const handler = state.handler;
|
|
54
|
+
// ② 检查渠道是否支持主动发送
|
|
55
|
+
if (!handler.send) {
|
|
56
|
+
return `渠道 "${args.channel}" (${handler.name}) 不支持主动发送。`;
|
|
57
|
+
}
|
|
58
|
+
// ③ 构造标准目标并委托给渠道的 send()
|
|
59
|
+
const target = {
|
|
60
|
+
type: args.targetType ?? 'user',
|
|
61
|
+
id: args.to,
|
|
62
|
+
};
|
|
63
|
+
try {
|
|
64
|
+
return await handler.send(target, {
|
|
65
|
+
content: args.text ?? '',
|
|
66
|
+
images: args.images,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
// 异常兜底——渠道 send() 应该自己 catch,这里防止未预期的异常泄漏
|
|
71
|
+
return `发送失败: ${err instanceof Error ? err.message : String(err)}`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* send_channel_message 工具 —— 暴露给 Agent 的跨渠道发送入口。
|
|
77
|
+
*
|
|
78
|
+
* Agent 在任何渠道的任意会话中都可以调用此工具,借用其他已连接渠道
|
|
79
|
+
* 的发送能力。典型场景:TUI 会话中的 Agent 通过飞书发图片给用户,
|
|
80
|
+
* 或者定时任务触发后通过微信推送通知。
|
|
81
|
+
*
|
|
82
|
+
* 此工具注册在 TUI/Server 的网关层(gateway/tui.ts、gateway/server.ts),
|
|
83
|
+
* 在 channelManager.startAll() 之后注册,确保所有渠道已启动。
|
|
84
|
+
*/
|
|
85
|
+
export function createSendChannelMessageTool(dispatcher) {
|
|
86
|
+
return {
|
|
87
|
+
name: 'send_channel_message',
|
|
88
|
+
description: '借用指定渠道发送消息到目标用户或群聊。纯借用渠道能力——不创建会话、不写入对话记录、不影响其他渠道。' +
|
|
89
|
+
'适用于从当前渠道(如 TUI)通过飞书/微信等渠道向用户发送通知、图片或文件。',
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
channel: {
|
|
94
|
+
type: 'string',
|
|
95
|
+
description: '目标渠道 ID。可用 channel_info 查看已连接渠道。如 "feishu"、"clawbot"。',
|
|
96
|
+
},
|
|
97
|
+
to: {
|
|
98
|
+
type: 'string',
|
|
99
|
+
description: '目标用户 ID 或群聊 ID。飞书为 open_id 或 chat_id,微信为 wxid。',
|
|
100
|
+
},
|
|
101
|
+
target_type: {
|
|
102
|
+
type: 'string',
|
|
103
|
+
enum: ['user', 'chat'],
|
|
104
|
+
description: '目标类型。user=私聊,chat=群聊。默认 user。',
|
|
105
|
+
},
|
|
106
|
+
text: {
|
|
107
|
+
type: 'string',
|
|
108
|
+
description: '要发送的文本内容。',
|
|
109
|
+
},
|
|
110
|
+
images: {
|
|
111
|
+
type: 'array',
|
|
112
|
+
items: {
|
|
113
|
+
type: 'object',
|
|
114
|
+
properties: {
|
|
115
|
+
data: { type: 'string', description: '图片的 base64 数据' },
|
|
116
|
+
media_type: { type: 'string', description: 'MIME 类型,如 image/png' },
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
description: '要发送的图片列表(base64 + MIME 类型)。',
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
required: ['channel', 'to'],
|
|
123
|
+
},
|
|
124
|
+
async execute(args) {
|
|
125
|
+
return dispatcher.send({
|
|
126
|
+
channel: args.channel,
|
|
127
|
+
to: args.to,
|
|
128
|
+
targetType: args.target_type ?? 'user',
|
|
129
|
+
text: args.text || undefined,
|
|
130
|
+
images: args.images,
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=dispatcher.js.map
|
|
@@ -45,6 +45,13 @@ export interface ChannelReply {
|
|
|
45
45
|
}>;
|
|
46
46
|
metadata?: Record<string, unknown>;
|
|
47
47
|
}
|
|
48
|
+
/** 跨渠道主动发送的目标接收方 */
|
|
49
|
+
export interface ChannelTarget {
|
|
50
|
+
/** 目标类型:user=私聊用户,chat=群聊 */
|
|
51
|
+
type: 'user' | 'chat';
|
|
52
|
+
/** 目标 ID(格式由各渠道自行定义:飞书 ou_/oc_,微信 wxid 等) */
|
|
53
|
+
id: string;
|
|
54
|
+
}
|
|
48
55
|
/** 输出处理器(渠道消息收集用) */
|
|
49
56
|
export interface ChannelOutputHandler {
|
|
50
57
|
onText?(content: string): void;
|
|
@@ -137,6 +144,27 @@ export interface ChannelHandler {
|
|
|
137
144
|
* 获取渠道状态
|
|
138
145
|
*/
|
|
139
146
|
getStatus(): ChannelStatus;
|
|
147
|
+
/**
|
|
148
|
+
* 主动发送消息(跨渠道借用能力入口)。
|
|
149
|
+
*
|
|
150
|
+
* 设计意图:reply() 是"回复"——在 handleMessage 生命周期内,将 Agent
|
|
151
|
+
* 的响应发回给当前会话的用户。send() 是"借用"——任何渠道的 Agent
|
|
152
|
+
* 都可以调用其他渠道的 send() 来借用其发送能力,不依赖 handleMessage 生命周期。
|
|
153
|
+
*
|
|
154
|
+
* 行为保证("纯借用"语义):
|
|
155
|
+
* - 不创建 session —— 消息是一次性的,不关联 AgentLoop
|
|
156
|
+
* - 不写 conversation —— 不污染对话历史
|
|
157
|
+
* - 不影响渠道内部状态 —— sessionMap、消息队列等完全不变
|
|
158
|
+
*
|
|
159
|
+
* 调用来源:MessageDispatcher → send_channel_message 工具 → Agent
|
|
160
|
+
*
|
|
161
|
+
* 未实现 = 该渠道不支持被外部借用(返回 undefined,MessageDispatcher 会给出友好提示)。
|
|
162
|
+
*
|
|
163
|
+
* @param target 目标接收方(用户或群聊),各渠道自行解析 ID 格式
|
|
164
|
+
* @param content 消息内容(文本 + 可选图片),复用 ChannelReply 避免定义新类型
|
|
165
|
+
* @returns 发送结果描述(成功或失败原因),不抛异常
|
|
166
|
+
*/
|
|
167
|
+
send?(target: ChannelTarget, content: ChannelReply): Promise<string>;
|
|
140
168
|
}
|
|
141
169
|
export type ChannelStatus = 'registered' | 'starting' | 'active' | 'stopped' | 'error';
|
|
142
170
|
export interface ChannelState {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ChannelHandler, ChannelEvent, ChannelReply, ChannelConfig, ChannelStatus, ChannelMessageEvent, AgentFactory, ReplyFn } from '../../interface.js';
|
|
1
|
+
import type { ChannelHandler, ChannelEvent, ChannelReply, ChannelTarget, ChannelConfig, ChannelStatus, ChannelMessageEvent, AgentFactory, ReplyFn } from '../../interface.js';
|
|
2
2
|
export declare class FeishuChannel implements ChannelHandler {
|
|
3
3
|
readonly id = "feishu";
|
|
4
4
|
readonly name = "Feishu (\u98DE\u4E66)";
|
|
@@ -32,6 +32,8 @@ export declare class FeishuChannel implements ChannelHandler {
|
|
|
32
32
|
onEvent(handler: (event: ChannelEvent) => Promise<void>): void;
|
|
33
33
|
reply(sessionId: string, reply: ChannelReply): Promise<void>;
|
|
34
34
|
getStatus(): ChannelStatus;
|
|
35
|
+
send(target: ChannelTarget, content: ChannelReply): Promise<string>;
|
|
36
|
+
private uploadImage;
|
|
35
37
|
/** 获取 tenant access token(缓存,提前 60s 刷新,token 有效期 2h) */
|
|
36
38
|
private getTenantAccessToken;
|
|
37
39
|
/**
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { resolveFeishuConfig, validateFeishuConfig, } from './feishu-config.js';
|
|
15
15
|
import { FeishuTransport } from './feishu-transport.js';
|
|
16
16
|
import { parseFeishuMessageEvent, FeishuDedupeStore, checkDmAccess, checkGroupAccess, } from './feishu-event.js';
|
|
17
|
-
import { sendText, sendCard, getSenderInfo } from './feishu-send.js';
|
|
17
|
+
import { sendText, sendCard, sendImage, getSenderInfo } from './feishu-send.js';
|
|
18
18
|
import { ChannelSessionPool, createCollectHandler } from './feishu-session.js';
|
|
19
19
|
import { FeishuMessageQueue } from './feishu-message-queue.js';
|
|
20
20
|
import { generateSessionId } from '../../../memory/session.js';
|
|
@@ -176,6 +176,109 @@ export class FeishuChannel {
|
|
|
176
176
|
getStatus() {
|
|
177
177
|
return this.status;
|
|
178
178
|
}
|
|
179
|
+
// ================================================================
|
|
180
|
+
// send() — 跨渠道借用能力入口
|
|
181
|
+
// ================================================================
|
|
182
|
+
//
|
|
183
|
+
// 设计意图:
|
|
184
|
+
// 每个渠道有自己独特的发送能力(飞书能发卡片和图片、微信能发文件、
|
|
185
|
+
// TUI 只能显示文本)。send() 将这些能力暴露给外部,使得其他渠道
|
|
186
|
+
// 的 Agent 可以借用本渠道的能力发送消息。
|
|
187
|
+
//
|
|
188
|
+
// 行为保证("纯借用"语义):
|
|
189
|
+
// - 不创建 session —— 消息是一次性的,没有对应的 AgentLoop
|
|
190
|
+
// - 不写 conversation.jsonl —— 不污染任何渠道的对话历史
|
|
191
|
+
// - 不影响 ChannelHandler 内部状态 —— sessionMap、消息队列等完全不变
|
|
192
|
+
// - 被借用方对"谁借的"完全无感 —— 不需要知道调用方是哪个渠道
|
|
193
|
+
//
|
|
194
|
+
// 调用链:
|
|
195
|
+
// Agent → send_channel_message 工具 → MessageDispatcher
|
|
196
|
+
// → ChannelManager.get('feishu') → FeishuChannel.send()
|
|
197
|
+
//
|
|
198
|
+
// 支持的消息类型(按优先级):
|
|
199
|
+
// 1. 卡片消息(content.metadata.card 存在时)—— 飞书交互式卡片
|
|
200
|
+
// 2. 文本消息(默认)—— Post 格式,支持 Markdown 子集
|
|
201
|
+
// 3. 图片消息(content.images 存在时)—— 先上传获取 image_key,再发送
|
|
202
|
+
// ↑ 图片在文本/卡片之后独立发送,失败不影响前面的消息
|
|
203
|
+
//
|
|
204
|
+
// 注意:
|
|
205
|
+
// - 图片上传需要 tenant_access_token(通过 getTenantAccessToken 获取)
|
|
206
|
+
// - 每条图片独立上传 + 发送,某张失败不阻塞其他图片
|
|
207
|
+
// - 图片 base64 直接传给飞书 API,不在本地落盘
|
|
208
|
+
// ================================================================
|
|
209
|
+
async send(target, content) {
|
|
210
|
+
if (this.status !== 'active')
|
|
211
|
+
return '飞书渠道未连接,无法发送';
|
|
212
|
+
const config = this.config;
|
|
213
|
+
// 飞书 SDK 的 receive_id_type 由 ID 前缀推断(ou_→open_id, oc_→chat_id)
|
|
214
|
+
// sendText/sendCard/sendImage 内部调用 resolveSendTarget 自动处理
|
|
215
|
+
const to = target.type === 'chat' ? `chat:${target.id}` : `user:${target.id}`;
|
|
216
|
+
try {
|
|
217
|
+
// ① 主消息:卡片优先,否则文本
|
|
218
|
+
if (content.metadata?.card) {
|
|
219
|
+
await sendCard(config, { to, card: content.metadata.card });
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
await sendText(config, { to, text: content.content });
|
|
223
|
+
}
|
|
224
|
+
// ② 图片:在文本/卡片之后独立发送
|
|
225
|
+
// 飞书的图片消息和文本消息是两条独立的消息,不支持图文混排
|
|
226
|
+
if (content.images && content.images.length > 0) {
|
|
227
|
+
const token = await this.getTenantAccessToken();
|
|
228
|
+
if (token) {
|
|
229
|
+
for (const img of content.images) {
|
|
230
|
+
try {
|
|
231
|
+
// 飞书图片发送两步:上传拿 image_key → 调用 im.message.create 发送
|
|
232
|
+
const imageKey = await this.uploadImage(token, img.data);
|
|
233
|
+
await sendImage(config, { to, imageKey });
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// 某张图片失败不阻塞其他图片和整体流程
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return `已通过飞书发送到 ${to}`;
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
return `飞书发送失败: ${err instanceof Error ? err.message : String(err)}`;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// ================================================================
|
|
248
|
+
// uploadImage() — 飞书图片上传
|
|
249
|
+
// ================================================================
|
|
250
|
+
//
|
|
251
|
+
// 飞书发送图片必须先上传到飞书服务器获取 image_key,
|
|
252
|
+
// 再用 image_key 调用 im.message.create(msg_type='image')发送。
|
|
253
|
+
//
|
|
254
|
+
// API: POST https://open.feishu.cn/open-apis/im/v1/images
|
|
255
|
+
// 参数: image_type='message'(消息用图,非头像),image=base64(不含 data:xxx;base64, 前缀)
|
|
256
|
+
// 返回: { code: 0, data: { image_key: 'img_xxx' } }
|
|
257
|
+
//
|
|
258
|
+
// 注意:
|
|
259
|
+
// - token 由调用方传入(来自 getTenantAccessToken 的缓存结果)
|
|
260
|
+
// - mediaType 参数保留供未来扩展(如格式校验),当前仅透传 base64
|
|
261
|
+
// - 飞书 image_key 有时效性(约 2 小时),不持久化缓存
|
|
262
|
+
// ================================================================
|
|
263
|
+
async uploadImage(token, base64Data, _mediaType) {
|
|
264
|
+
const domain = this.config?.domain ?? 'https://open.feishu.cn';
|
|
265
|
+
const resp = await fetch(`${domain}/open-apis/im/v1/images`, {
|
|
266
|
+
method: 'POST',
|
|
267
|
+
headers: {
|
|
268
|
+
'Authorization': `Bearer ${token}`,
|
|
269
|
+
'Content-Type': 'application/json',
|
|
270
|
+
},
|
|
271
|
+
body: JSON.stringify({
|
|
272
|
+
image_type: 'message',
|
|
273
|
+
image: base64Data,
|
|
274
|
+
}),
|
|
275
|
+
});
|
|
276
|
+
const json = await resp.json();
|
|
277
|
+
if (json.code !== 0 || !json.data?.image_key) {
|
|
278
|
+
throw new Error(`飞书图片上传失败: code=${json.code}`);
|
|
279
|
+
}
|
|
280
|
+
return json.data.image_key;
|
|
281
|
+
}
|
|
179
282
|
/** 获取 tenant access token(缓存,提前 60s 刷新,token 有效期 2h) */
|
|
180
283
|
async getTenantAccessToken() {
|
|
181
284
|
if (this.cachedToken && Date.now() < this.tokenExpiresAt - 60_000) {
|
|
@@ -41,4 +41,12 @@ export declare function getSenderInfo(config: FeishuChannelConfig, userId: strin
|
|
|
41
41
|
name?: string;
|
|
42
42
|
avatar?: string;
|
|
43
43
|
} | null>;
|
|
44
|
+
/**
|
|
45
|
+
* 发送图片消息。
|
|
46
|
+
* @param imageKey 飞书上传 API 返回的 image_key
|
|
47
|
+
*/
|
|
48
|
+
export declare function sendImage(config: FeishuChannelConfig, params: {
|
|
49
|
+
to: string;
|
|
50
|
+
imageKey: string;
|
|
51
|
+
}): Promise<FeishuSendResult>;
|
|
44
52
|
//# sourceMappingURL=feishu-send.d.ts.map
|
|
@@ -229,4 +229,44 @@ export async function getSenderInfo(config, userId, userIdType = 'open_id') {
|
|
|
229
229
|
return null;
|
|
230
230
|
}
|
|
231
231
|
}
|
|
232
|
+
// ================================================================
|
|
233
|
+
// 图片消息发送
|
|
234
|
+
// ================================================================
|
|
235
|
+
//
|
|
236
|
+
// 飞书发送图片是两步操作:
|
|
237
|
+
// 1. 上传图片到飞书服务器 → 获取 image_key
|
|
238
|
+
// POST /open-apis/im/v1/images { image_type: 'message', image: base64 }
|
|
239
|
+
// 2. 用 image_key 发送图片消息
|
|
240
|
+
// POST /open-apis/im/v1/messages { msg_type: 'image', content: '{"image_key":"img_xxx"}' }
|
|
241
|
+
//
|
|
242
|
+
// 注意:
|
|
243
|
+
// - 图片上传走二进制 base64,不走 multipart/form-data
|
|
244
|
+
// - image_key 有时效性(约 2 小时),不持久化
|
|
245
|
+
// - 图片消息和文本消息是两条独立的飞书消息,不支持单条图文混排
|
|
246
|
+
// - 调用方(FeishuChannel.send())负责先上传拿到 image_key 再调用本函数
|
|
247
|
+
// ================================================================
|
|
248
|
+
/**
|
|
249
|
+
* 发送图片消息。
|
|
250
|
+
* @param imageKey 飞书上传 API 返回的 image_key
|
|
251
|
+
*/
|
|
252
|
+
export async function sendImage(config, params) {
|
|
253
|
+
const client = await createFeishuClient(config);
|
|
254
|
+
const { receiveId, receiveIdType } = resolveSendTarget(params.to);
|
|
255
|
+
const content = JSON.stringify({ image_key: params.imageKey });
|
|
256
|
+
const res = await client.im.message.create({
|
|
257
|
+
params: { receive_id_type: receiveIdType },
|
|
258
|
+
data: {
|
|
259
|
+
receive_id: receiveId,
|
|
260
|
+
content,
|
|
261
|
+
msg_type: 'image',
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
if (res.code !== 0) {
|
|
265
|
+
throw new Error(`飞书图片发送失败: ${res.msg || `code ${res.code}`}`);
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
messageId: res.data?.message_id ?? '',
|
|
269
|
+
chatId: receiveId,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
232
272
|
//# sourceMappingURL=feishu-send.js.map
|
package/dist/gateway/factory.js
CHANGED
|
@@ -570,13 +570,13 @@ export async function createAgent(options, supervisor) {
|
|
|
570
570
|
if (sessionType === 'companion' && companionCharName) {
|
|
571
571
|
bypassManager.activateForMode('companion').catch(() => { });
|
|
572
572
|
}
|
|
573
|
-
//
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
573
|
+
// 普通模式:按配置决定是否激活 orchestrator
|
|
574
|
+
if (sessionType === 'normal') {
|
|
575
|
+
const orchestratorEnabled = configCenter.get('bypass.orchestratorEnabled') ?? false;
|
|
576
|
+
if (orchestratorEnabled) {
|
|
577
|
+
bypassManager.activateAgent('orchestrator').catch(() => { });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
580
|
// ── Read 工具的图片处理器 — 将读到的图片注入 ImageStore ──
|
|
581
581
|
const readTool = toolRegistry.get('read');
|
|
582
582
|
if (readTool && typeof readTool.setImageHandler === 'function') {
|
package/dist/gateway/tui.js
CHANGED
|
@@ -970,6 +970,16 @@ export async function runTui(provider, sessionId, shouldContinue, maxTurns, maxC
|
|
|
970
970
|
}
|
|
971
971
|
// 启动所有渠道(每个渠道自行处理消息)
|
|
972
972
|
await channelManager.startAll(agentFactory);
|
|
973
|
+
// ── 跨渠道消息发送工具 ────────────────────────────────────
|
|
974
|
+
//
|
|
975
|
+
// 在 channelManager.startAll() 之后注册,此时所有渠道已启动。
|
|
976
|
+
// 工具内通过 ChannelManager.get() 查找目标渠道并调用其 send()。
|
|
977
|
+
// 仅在本地 TUI 模式下注册——远程模式(remoteWs)下工具由服务端提供。
|
|
978
|
+
if (agent) {
|
|
979
|
+
const { MessageDispatcher, createSendChannelMessageTool } = await import('../channels/dispatcher.js');
|
|
980
|
+
const dispatcher = new MessageDispatcher(channelManager);
|
|
981
|
+
agent.toolRegistry.register(createSendChannelMessageTool(dispatcher));
|
|
982
|
+
}
|
|
973
983
|
// ── ASCII Art loader ──────────────────────────────────────────────
|
|
974
984
|
async function loadAsciiArt(maxWidth = 54) {
|
|
975
985
|
const asciiDir = path.join(os.homedir(), '.agent', 'ascii');
|
package/dist/index.js
CHANGED
|
File without changes
|
package/dist/multimodal/index.js
CHANGED
|
@@ -78,7 +78,6 @@ export async function compressImageIfLarge(buf, mime) {
|
|
|
78
78
|
return { buffer: buf, mime, compressed: false };
|
|
79
79
|
let sharp;
|
|
80
80
|
try {
|
|
81
|
-
// @ts-expect-error — sharp 0.35 types incompatible with pnpm exports
|
|
82
81
|
sharp = (await import('sharp')).default;
|
|
83
82
|
}
|
|
84
83
|
catch {
|
|
@@ -93,7 +93,6 @@ function ansiBg(r, g, b) { return `\x1b[48;2;${r};${g};${b}m`; }
|
|
|
93
93
|
export async function imageToAscii(imagePath, maxWidth = 60) {
|
|
94
94
|
let sharp;
|
|
95
95
|
try {
|
|
96
|
-
// @ts-expect-error — sharp 0.35 的 types 与 pnpm exports 解析不兼容
|
|
97
96
|
sharp = (await import('sharp')).default;
|
|
98
97
|
}
|
|
99
98
|
catch {
|
|
@@ -144,7 +144,7 @@ tools: tool1,tool2
|
|
|
144
144
|
|
|
145
145
|
| 需求 | 文件 | 写法 | 生效方式 |
|
|
146
146
|
|------|------|------|----------|
|
|
147
|
-
| 渠道(飞书/Lark 等) | `.agent/config.json` | 修改根对象的 `channels` 字段 | **需重启**:调用 `restart` 工具 |
|
|
147
|
+
| 渠道(飞书/Lark/微信 ClawBot 等) | `.agent/config.json` | 修改根对象的 `channels` 字段 | **需重启**:调用 `restart` 工具 |
|
|
148
148
|
| MCP Server | `.agent/mcp.json` | 添加或更新 server 定义 | **热加载**:保存后自动检测,无需重启 |
|
|
149
149
|
| 工具包 | `.agent/tool-bundles.json` | 优先用 `list_bundles` / `activate_bundle` / `create_bundle` 等工具 | **热加载**:即时生效 |
|
|
150
150
|
| Provider 元数据 | `.agent/providers.json` | 修改 provider 的 baseUrl、defaultModel、envKey、maxTokens | **热加载**:即时生效 |
|
|
@@ -159,7 +159,9 @@ tools: tool1,tool2
|
|
|
159
159
|
|
|
160
160
|
### 渠道配置
|
|
161
161
|
|
|
162
|
-
渠道配置写在项目级 `.agent/config.json` 的 `channels`
|
|
162
|
+
渠道配置写在项目级 `.agent/config.json` 的 `channels` 字段中。配置飞书、Lark 或微信 ClawBot 时,读取并合并完整 JSON。
|
|
163
|
+
|
|
164
|
+
**飞书/Lark 示例:**
|
|
163
165
|
|
|
164
166
|
```json
|
|
165
167
|
{
|
|
@@ -176,6 +178,20 @@ tools: tool1,tool2
|
|
|
176
178
|
}
|
|
177
179
|
```
|
|
178
180
|
|
|
181
|
+
**微信 ClawBot 示例:**
|
|
182
|
+
|
|
183
|
+
```json
|
|
184
|
+
{
|
|
185
|
+
"channels": {
|
|
186
|
+
"clawbot": {
|
|
187
|
+
"enabled": true
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
ClawBot 是微信官方 AI 助手连接插件,支持扫码授权。最简单的配置只需 `{ "enabled": true }`,首次启动自动生成二维码,扫码后即可使用。不需要 appId/appSecret。
|
|
194
|
+
|
|
179
195
|
配置渠道时:
|
|
180
196
|
1. 优先修改当前工作区的 `.agent/config.json`
|
|
181
197
|
2. 先读取现有 JSON,保留 provider、session、safety 等已有配置
|
package/dist/runtime/defaults.js
CHANGED
package/dist/setup/config.js
CHANGED
|
@@ -21,8 +21,8 @@ import { getLocalProviderConfigLoader } from '../provider/local-config.js';
|
|
|
21
21
|
import { DEFAULT_PROVIDERS } from '../provider/config.js';
|
|
22
22
|
import { getDefaultConfig } from '../runtime/defaults.js';
|
|
23
23
|
const DEFAULT_CONFIG = {
|
|
24
|
-
provider: '
|
|
25
|
-
model:
|
|
24
|
+
provider: '',
|
|
25
|
+
model: '',
|
|
26
26
|
maxTurns: getDefaultConfig().session.maxTurns,
|
|
27
27
|
maxContext: 200000,
|
|
28
28
|
safety: {
|
package/dist/setup/wizard.js
CHANGED
|
@@ -143,12 +143,12 @@ export class SetupWizard {
|
|
|
143
143
|
}
|
|
144
144
|
/** Step 1: Provider */
|
|
145
145
|
async stepProvider(defaultProvider) {
|
|
146
|
-
const initialValue = defaultProvider
|
|
146
|
+
const initialValue = defaultProvider || PROVIDERS[0].value;
|
|
147
147
|
return p.select({
|
|
148
148
|
message: '选择 AI 提供商',
|
|
149
149
|
options: PROVIDERS.map(p => ({
|
|
150
150
|
...p,
|
|
151
|
-
hint: p.value ===
|
|
151
|
+
hint: p.value === defaultProvider ? `${p.hint} (当前)` : p.hint,
|
|
152
152
|
})),
|
|
153
153
|
initialValue,
|
|
154
154
|
});
|
package/package.json
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hyacinth-ai",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.22",
|
|
4
4
|
"description": "Hyacinth 🪻 — multi-provider AI agent with tool system and context management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|
|
8
8
|
"hyacinth": "dist/index.js"
|
|
9
9
|
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc && node scripts/copy-prompts.cjs && node scripts/clean-tests.cjs && node scripts/clean-maps.cjs",
|
|
12
|
+
"dev": "tsc --watch",
|
|
13
|
+
"start": "node dist/index.js",
|
|
14
|
+
"start:win": "chcp 65001 > nul && node dist/index.js",
|
|
15
|
+
"test": "vitest run",
|
|
16
|
+
"test:watch": "vitest",
|
|
17
|
+
"setup:llamacpp": "powershell -ExecutionPolicy Bypass -File scripts/setup-llamacpp.ps1"
|
|
18
|
+
},
|
|
10
19
|
"keywords": [
|
|
11
20
|
"ai",
|
|
12
21
|
"agent",
|
|
@@ -19,6 +28,7 @@
|
|
|
19
28
|
],
|
|
20
29
|
"author": "孑遗",
|
|
21
30
|
"license": "MIT",
|
|
31
|
+
"packageManager": "pnpm@10.25.0",
|
|
22
32
|
"dependencies": {
|
|
23
33
|
"@anthropic-ai/sdk": "^0.98.0",
|
|
24
34
|
"@clack/prompts": "^1.4.0",
|
|
@@ -52,14 +62,5 @@
|
|
|
52
62
|
"typescript": "^6.0.3",
|
|
53
63
|
"undici-types": "^7.24.6",
|
|
54
64
|
"vitest": "^4.1.7"
|
|
55
|
-
},
|
|
56
|
-
"scripts": {
|
|
57
|
-
"build": "tsc && node scripts/copy-prompts.cjs && node scripts/clean-tests.cjs && node scripts/clean-maps.cjs",
|
|
58
|
-
"dev": "tsc --watch",
|
|
59
|
-
"start": "node dist/index.js",
|
|
60
|
-
"start:win": "chcp 65001 > nul && node dist/index.js",
|
|
61
|
-
"test": "vitest run",
|
|
62
|
-
"test:watch": "vitest",
|
|
63
|
-
"setup:llamacpp": "powershell -ExecutionPolicy Bypass -File scripts/setup-llamacpp.ps1"
|
|
64
65
|
}
|
|
65
|
-
}
|
|
66
|
+
}
|