mioku-plugin-agent 0.1.0 → 0.2.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 +2 -2
- package/commands/index.ts +10 -9
- package/core/compaction.ts +1 -1
- package/core/download.ts +18 -48
- package/core/emotion.ts +2 -2
- package/core/identity.ts +27 -0
- package/core/loop.ts +37 -30
- package/core/model.ts +179 -0
- package/core/send.ts +1 -1
- package/core/session.ts +10 -10
- package/db.ts +55 -15
- package/handlers/message.ts +14 -8
- package/index.ts +49 -84
- package/package.json +1 -1
- package/platforms/generic.ts +11 -0
- package/platforms/icqq.ts +47 -0
- package/platforms/index.ts +49 -0
- package/platforms/onebotv11.ts +40 -0
- package/platforms/qq-official.ts +14 -0
- package/platforms/types.ts +53 -0
- package/tools/approval.ts +7 -7
- package/tools/bash.ts +1 -1
- package/tools/deliver.ts +1 -1
- package/tools/index.ts +7 -3
- package/tools/perm.ts +6 -2
- package/tools/todo.ts +6 -3
- package/types.ts +3 -3
package/README.md
CHANGED
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
- `auto` 自动:文件与命令直接执行,但**每条命令先由工作模型审查**,危险操作(删除用户文件、清库、`git push`、sudo 等)转用户审批;执行通知与审批请求都即时推送
|
|
13
13
|
- `full` 完全访问:不审批,命令与写入/编辑汇总成一条转发记录
|
|
14
14
|
- `yolo` 静默:权限最高且**不推送任何中间通知**,用户只收到最终结果
|
|
15
|
-
-
|
|
15
|
+
- **个人工作区**:每个「适配器 + 用户」独立工作区,默认 `data/agent/workspace/<适配器>_<用户id>`(openid 平台同样隔离)
|
|
16
16
|
- **聊天内审批**:非 full 权限档下 bash 命令需 `.agent approve` 批准(不带 id 时处理该用户最近一次请求);每条命令都必须带 `purpose`,审批与执行告知都会带上用途
|
|
17
17
|
- **操作合并转发**(仅 `full`):命令、写入、编辑不再逐条推送,而是攒到本轮结束、在最终回复**之前**合并成一条合并转发消息(卡片来源「Agent 执行记录」,外显小字是操作条数与用户请求摘要,总摘要是各类操作计数与失败数;首节点为简介,其余节点按时间顺序记录每条命令/文件改动,含用途、耗时与失败输出)。适配器不支持转发时自动降级为普通文本消息。`read`/`glob`/`grep`/联网/看图等只读操作不记录。`auto` 不合并,仍逐条即时推送
|
|
18
18
|
- **工具面**:read / write / edit / glob / grep / bash / view_image / send_file / send_image / web_search (SearXNG) / web_fetch / todo_write
|
|
19
|
-
- **附件自动下载**:用户发来的图片/文件/音视频统一按文件自动落到 `download/<今天日期>/`,**保留原始文件名与后缀**(`file_name` → segment 的 `file` → `path` → URL 文件名,缺后缀才用 content-type 补);user 消息里带 `message_id`、`name` 和 `[file://路径]
|
|
19
|
+
- **附件自动下载**:用户发来的图片/文件/音视频统一按文件自动落到 `download/<今天日期>/`,**保留原始文件名与后缀**(`file_name` → segment 的 `file` → `path` → URL 文件名,缺后缀才用 content-type 补);user 消息里带 `message_id`、`name` 和 `[file://路径]`,图片额外作为图片内容附加给模型。文件消息只有 `file_id` 时按平台分支解析下载地址,见下
|
|
20
20
|
- **消息引用**:模型在回复首行写 `[reply:message_id]` 即可引用(回复)指定聊天消息,标记会被移除并只作用于本轮第一条消息
|
|
21
21
|
- **运行中插话**:Agent 正在跑时用户继续发消息,不再排队等下一轮,而是并入**当前请求**的下一次迭代(DSH 式 steering),模型在同一个回复里就能看到并调整;日志里 `steer queued` 表示已入队、`steer merged into running turn` 表示已并入本轮请求
|
|
22
22
|
- **看图**:`view_image` 查看本地图片;多模态主模型直接把图片附加进对话,非多模态时交给视觉模型转成描述
|
package/commands/index.ts
CHANGED
|
@@ -4,15 +4,16 @@ import type { SessionPlanItem } from "../db";
|
|
|
4
4
|
import { maybeCompact } from "../core/compaction";
|
|
5
5
|
import { generateSessionTitle } from "../core/title";
|
|
6
6
|
import { stopAgentTurn } from "../core/loop";
|
|
7
|
+
import { identityOf } from "../core/identity";
|
|
7
8
|
import { PERMISSION_LEVELS } from "../tools/perm";
|
|
8
9
|
|
|
9
10
|
const CLEAR_CONFIRM_TTL_MS = 60_000;
|
|
10
11
|
|
|
11
12
|
const resumeListCache = new Map<
|
|
12
|
-
|
|
13
|
+
string,
|
|
13
14
|
{ generations: number[]; at: number }
|
|
14
15
|
>();
|
|
15
|
-
const pendingClear = new Map<
|
|
16
|
+
const pendingClear = new Map<string, number>();
|
|
16
17
|
|
|
17
18
|
async function reply(event: MessageEvent, text: string): Promise<void> {
|
|
18
19
|
await event.reply(text, true);
|
|
@@ -21,12 +22,12 @@ async function reply(event: MessageEvent, text: string): Promise<void> {
|
|
|
21
22
|
async function requireUser(
|
|
22
23
|
host: AgentHost,
|
|
23
24
|
event: MessageEvent,
|
|
24
|
-
): Promise<
|
|
25
|
-
const
|
|
26
|
-
if (!userId) {
|
|
27
|
-
await reply(event, "agent
|
|
25
|
+
): Promise<string> {
|
|
26
|
+
const identity = identityOf(event);
|
|
27
|
+
if (!identity.userId) {
|
|
28
|
+
await reply(event, "agent 命令需要在私聊中使用");
|
|
28
29
|
}
|
|
29
|
-
return
|
|
30
|
+
return identity.scope;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
function formatTime(ts: number): string {
|
|
@@ -56,7 +57,7 @@ async function backgroundTitle(
|
|
|
56
57
|
host.logger.info(`[agent] session ${sessionId} titled: ${title}`);
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
function cachedResumable(host: AgentHost, userId:
|
|
60
|
+
function cachedResumable(host: AgentHost, userId: string): number[] {
|
|
60
61
|
const cached = resumeListCache.get(userId);
|
|
61
62
|
if (cached && Date.now() - cached.at < 5 * 60_000) return cached.generations;
|
|
62
63
|
const current = host.sessions.sessionId(userId);
|
|
@@ -67,7 +68,7 @@ function cachedResumable(host: AgentHost, userId: number): number[] {
|
|
|
67
68
|
return generations;
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
function invalidateResumeCache(userId:
|
|
71
|
+
function invalidateResumeCache(userId: string): void {
|
|
71
72
|
resumeListCache.delete(userId);
|
|
72
73
|
}
|
|
73
74
|
|
package/core/compaction.ts
CHANGED
package/core/download.ts
CHANGED
|
@@ -3,6 +3,8 @@ import * as fsp from "node:fs/promises";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import type { Bot } from "mioku";
|
|
5
5
|
import type { MediaAttachment, MediaKind } from "./media";
|
|
6
|
+
import type { AgentPlatform } from "../platforms/types";
|
|
7
|
+
import { EMPTY_FILE_LOOKUP } from "../platforms/types";
|
|
6
8
|
|
|
7
9
|
const MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
|
|
8
10
|
const DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
@@ -212,52 +214,18 @@ function candidateSources(item: MediaAttachment): string[] {
|
|
|
212
214
|
async function platformLookup(
|
|
213
215
|
bot: Bot | undefined,
|
|
214
216
|
item: MediaAttachment,
|
|
217
|
+
platform: AgentPlatform | undefined,
|
|
215
218
|
): Promise<{ sources: string[]; names: string[] }> {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
"get_group_file_url",
|
|
226
|
-
{ group_id: Number(item.groupId), file_id: item.fileId },
|
|
227
|
-
]);
|
|
228
|
-
}
|
|
229
|
-
if (item.userId) {
|
|
230
|
-
attempts.push([
|
|
231
|
-
"get_private_file_url",
|
|
232
|
-
{ user_id: Number(item.userId), file_id: item.fileId },
|
|
233
|
-
]);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
for (const [action, params] of attempts) {
|
|
237
|
-
try {
|
|
238
|
-
const result = (await bot.sendApi(action, params)) as Record<
|
|
239
|
-
string,
|
|
240
|
-
unknown
|
|
241
|
-
> | null;
|
|
242
|
-
if (!result || typeof result !== "object") continue;
|
|
243
|
-
for (const key of ["url", "file", "path"]) {
|
|
244
|
-
const value = result[key];
|
|
245
|
-
if (typeof value === "string" && value.trim())
|
|
246
|
-
sources.push(value.trim());
|
|
247
|
-
}
|
|
248
|
-
const base64 = result.base64 ?? result.data;
|
|
249
|
-
if (typeof base64 === "string" && base64.trim()) {
|
|
250
|
-
sources.push(`base64://${base64.trim()}`);
|
|
251
|
-
}
|
|
252
|
-
for (const key of ["file_name", "name"]) {
|
|
253
|
-
const value = result[key];
|
|
254
|
-
if (typeof value === "string" && value.trim()) names.push(value.trim());
|
|
255
|
-
}
|
|
256
|
-
} catch {
|
|
257
|
-
// 平台不支持该 action 时忽略,继续尝试下一个
|
|
258
|
-
}
|
|
219
|
+
if (!bot || !item.fileId || !platform) return EMPTY_FILE_LOOKUP;
|
|
220
|
+
try {
|
|
221
|
+
return await platform.resolveFile(bot, {
|
|
222
|
+
fileId: item.fileId,
|
|
223
|
+
groupId: item.groupId,
|
|
224
|
+
userId: item.userId,
|
|
225
|
+
});
|
|
226
|
+
} catch {
|
|
227
|
+
return EMPTY_FILE_LOOKUP;
|
|
259
228
|
}
|
|
260
|
-
return { sources, names };
|
|
261
229
|
}
|
|
262
230
|
|
|
263
231
|
function describeFields(item: MediaAttachment): string {
|
|
@@ -273,13 +241,14 @@ function describeFields(item: MediaAttachment): string {
|
|
|
273
241
|
async function resolveSource(
|
|
274
242
|
item: MediaAttachment,
|
|
275
243
|
bot: Bot | undefined,
|
|
244
|
+
platform: AgentPlatform | undefined,
|
|
276
245
|
): Promise<{
|
|
277
246
|
buffer: Buffer;
|
|
278
247
|
contentType: string | null;
|
|
279
248
|
fallbackName: string;
|
|
280
249
|
}> {
|
|
281
|
-
const
|
|
282
|
-
const candidates = [...candidateSources(item), ...
|
|
250
|
+
const found = await platformLookup(bot, item, platform);
|
|
251
|
+
const candidates = [...candidateSources(item), ...found.sources];
|
|
283
252
|
if (candidates.length === 0) {
|
|
284
253
|
throw new Error(`no downloadable source (${describeFields(item)})`);
|
|
285
254
|
}
|
|
@@ -290,7 +259,7 @@ async function resolveSource(
|
|
|
290
259
|
// 平台返回的原始文件名最可信,其次才是 URL 推断出来的名字
|
|
291
260
|
return {
|
|
292
261
|
...result,
|
|
293
|
-
fallbackName:
|
|
262
|
+
fallbackName: found.names[0] ?? result.fallbackName,
|
|
294
263
|
};
|
|
295
264
|
} catch (err) {
|
|
296
265
|
lastError = String(err);
|
|
@@ -302,7 +271,7 @@ async function resolveSource(
|
|
|
302
271
|
export async function downloadMediaItems(
|
|
303
272
|
items: MediaAttachment[],
|
|
304
273
|
workspaceRoot: string,
|
|
305
|
-
options: { bot?: Bot } = {},
|
|
274
|
+
options: { bot?: Bot; platform?: AgentPlatform } = {},
|
|
306
275
|
): Promise<DownloadResult> {
|
|
307
276
|
const dir = path.join(workspaceRoot, "download", dateStamp());
|
|
308
277
|
const files: DownloadedMedia[] = [];
|
|
@@ -315,6 +284,7 @@ export async function downloadMediaItems(
|
|
|
315
284
|
const { buffer, contentType, fallbackName } = await resolveSource(
|
|
316
285
|
item,
|
|
317
286
|
options.bot,
|
|
287
|
+
options.platform,
|
|
318
288
|
);
|
|
319
289
|
if (buffer.byteLength > MAX_DOWNLOAD_BYTES) {
|
|
320
290
|
errors.push(`#${index + 1} exceeds ${MAX_DOWNLOAD_BYTES} bytes`);
|
package/core/emotion.ts
CHANGED
|
@@ -8,7 +8,7 @@ function normalizeName(value: unknown): string {
|
|
|
8
8
|
|
|
9
9
|
export class EmotionManager {
|
|
10
10
|
constructor(
|
|
11
|
-
private readonly store: (userId:
|
|
11
|
+
private readonly store: (userId: string, emotion: string) => void,
|
|
12
12
|
) {}
|
|
13
13
|
|
|
14
14
|
available(config: ChatEmotionConfig | null): string[] {
|
|
@@ -36,7 +36,7 @@ export class EmotionManager {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
setEmotion(
|
|
39
|
-
userId:
|
|
39
|
+
userId: string,
|
|
40
40
|
emotion: unknown,
|
|
41
41
|
config: ChatEmotionConfig | null,
|
|
42
42
|
): string {
|
package/core/identity.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { MessageEvent } from "mioku";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 事件的用户身份。
|
|
5
|
+
* `userId` 是平台原始 id(QQ 号或 openid),用于权限名单匹配;
|
|
6
|
+
* `scope` 额外带上适配器,用于会话/工作区/队列隔离,避免不同平台相同 id 串号。
|
|
7
|
+
*/
|
|
8
|
+
export interface AgentIdentity {
|
|
9
|
+
readonly userId: string;
|
|
10
|
+
readonly adapter: string;
|
|
11
|
+
readonly botId: string;
|
|
12
|
+
readonly scope: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const identityOf = (event: MessageEvent): AgentIdentity => {
|
|
16
|
+
const userId = String(event?.user_id ?? event?.sender?.user_id ?? "").trim();
|
|
17
|
+
const adapter = String(
|
|
18
|
+
event?.bot?.adapter ?? event?.identity?.adapter ?? "",
|
|
19
|
+
).trim();
|
|
20
|
+
const botId = String(event?.self_id ?? event?.bot?.bot_id ?? "").trim();
|
|
21
|
+
return {
|
|
22
|
+
userId,
|
|
23
|
+
adapter,
|
|
24
|
+
botId,
|
|
25
|
+
scope: `${adapter || "unknown"}:${userId}`,
|
|
26
|
+
};
|
|
27
|
+
};
|
package/core/loop.ts
CHANGED
|
@@ -12,6 +12,8 @@ import { TurnSender } from "./send";
|
|
|
12
12
|
import { buildSystemPrompt } from "./prompt";
|
|
13
13
|
import { maybeCompact } from "./compaction";
|
|
14
14
|
import { cleanEmotionMarkers, stripThinkBlocks } from "./units";
|
|
15
|
+
import { identityOf, type AgentIdentity } from "./identity";
|
|
16
|
+
import type { AgentPlatform } from "../platforms/types";
|
|
15
17
|
import { describeImageUrls, extractMedia, formatMediaNote } from "./media";
|
|
16
18
|
import { downloadMediaItems, readImageDataUrl } from "./download";
|
|
17
19
|
import { TurnActivity } from "./activity";
|
|
@@ -42,9 +44,9 @@ interface UserQueue {
|
|
|
42
44
|
controller: AbortController | null;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
const queues = new Map<
|
|
47
|
+
const queues = new Map<string, UserQueue>();
|
|
46
48
|
|
|
47
|
-
function getQueue(userId:
|
|
49
|
+
function getQueue(userId: string): UserQueue {
|
|
48
50
|
let queue = queues.get(userId);
|
|
49
51
|
if (!queue) {
|
|
50
52
|
queue = { running: false, inbox: [], controller: null };
|
|
@@ -55,7 +57,7 @@ function getQueue(userId: number): UserQueue {
|
|
|
55
57
|
|
|
56
58
|
export function stopAgentTurn(
|
|
57
59
|
host: AgentHost,
|
|
58
|
-
userId:
|
|
60
|
+
userId: string,
|
|
59
61
|
): { running: boolean; dropped: number; approvals: number } {
|
|
60
62
|
const queue = queues.get(userId);
|
|
61
63
|
const dropped = queue?.inbox.length ?? 0;
|
|
@@ -72,29 +74,31 @@ export function stopAgentTurn(
|
|
|
72
74
|
function kickQueue(
|
|
73
75
|
host: AgentHost,
|
|
74
76
|
event: MessageEvent,
|
|
75
|
-
userId:
|
|
77
|
+
userId: string,
|
|
76
78
|
queue: UserQueue,
|
|
77
79
|
): void {
|
|
78
80
|
if (queue.running) return;
|
|
79
81
|
const next = queue.inbox.shift();
|
|
80
82
|
if (!next) return;
|
|
81
83
|
queue.running = true;
|
|
82
|
-
void drainTurns(host, event,
|
|
84
|
+
void drainTurns(host, event, identityOf(event), queue, next);
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
export function runAgentTurn(
|
|
86
88
|
host: AgentHost,
|
|
87
89
|
event: MessageEvent,
|
|
90
|
+
platform: AgentPlatform,
|
|
88
91
|
): Promise<void> {
|
|
89
|
-
const
|
|
90
|
-
if (!userId) return Promise.resolve();
|
|
92
|
+
const identity = identityOf(event);
|
|
93
|
+
if (!identity.userId) return Promise.resolve();
|
|
94
|
+
const userId = identity.scope;
|
|
91
95
|
const queue = getQueue(userId);
|
|
92
96
|
|
|
93
97
|
// 先占住轮次再准备输入,保证消息按到达顺序处理
|
|
94
98
|
const ownsTurn = !queue.running;
|
|
95
99
|
if (ownsTurn) queue.running = true;
|
|
96
100
|
|
|
97
|
-
return prepareInput(host, event,
|
|
101
|
+
return prepareInput(host, event, identity, platform)
|
|
98
102
|
.then((input) => {
|
|
99
103
|
if (!input) {
|
|
100
104
|
if (ownsTurn) {
|
|
@@ -112,7 +116,7 @@ export function runAgentTurn(
|
|
|
112
116
|
kickQueue(host, event, userId, queue);
|
|
113
117
|
return;
|
|
114
118
|
}
|
|
115
|
-
return drainTurns(host, event,
|
|
119
|
+
return drainTurns(host, event, identity, queue, input);
|
|
116
120
|
})
|
|
117
121
|
.catch((err) => {
|
|
118
122
|
if (ownsTurn) {
|
|
@@ -126,7 +130,7 @@ export function runAgentTurn(
|
|
|
126
130
|
async function drainTurns(
|
|
127
131
|
host: AgentHost,
|
|
128
132
|
event: MessageEvent,
|
|
129
|
-
|
|
133
|
+
identity: AgentIdentity,
|
|
130
134
|
queue: UserQueue,
|
|
131
135
|
first: PreparedInput,
|
|
132
136
|
): Promise<void> {
|
|
@@ -135,29 +139,31 @@ async function drainTurns(
|
|
|
135
139
|
try {
|
|
136
140
|
let next: PreparedInput | undefined = first;
|
|
137
141
|
while (next) {
|
|
138
|
-
await executeTurn(host, event,
|
|
142
|
+
await executeTurn(host, event, identity, next, queue, controller.signal);
|
|
139
143
|
next = queue.inbox.shift();
|
|
140
144
|
}
|
|
141
145
|
} finally {
|
|
142
146
|
queue.controller = null;
|
|
143
147
|
queue.running = false;
|
|
144
|
-
kickQueue(host, event,
|
|
148
|
+
kickQueue(host, event, identity.scope, queue);
|
|
145
149
|
}
|
|
146
150
|
}
|
|
147
151
|
|
|
148
152
|
async function prepareInput(
|
|
149
153
|
host: AgentHost,
|
|
150
154
|
event: MessageEvent,
|
|
151
|
-
|
|
155
|
+
identity: AgentIdentity,
|
|
156
|
+
platform: AgentPlatform,
|
|
152
157
|
): Promise<PreparedInput | null> {
|
|
153
158
|
const resolved = host.resolveModel();
|
|
154
159
|
const bot = event.bot ?? host.ctx.pickBot(event.self_id);
|
|
155
160
|
const media = extractMedia(event);
|
|
156
161
|
const downloads = await downloadMediaItems(
|
|
157
162
|
media,
|
|
158
|
-
host.workspaceRoot(
|
|
163
|
+
host.workspaceRoot(identity.scope),
|
|
159
164
|
{
|
|
160
165
|
bot,
|
|
166
|
+
platform,
|
|
161
167
|
},
|
|
162
168
|
).catch((err) => {
|
|
163
169
|
host.logger.warn(`[agent] attachment download failed: ${err}`);
|
|
@@ -216,7 +222,7 @@ async function prepareInput(
|
|
|
216
222
|
|
|
217
223
|
function steeringMessages(
|
|
218
224
|
host: AgentHost,
|
|
219
|
-
userId:
|
|
225
|
+
userId: string,
|
|
220
226
|
queue: UserQueue,
|
|
221
227
|
): AgentChatMessage[] {
|
|
222
228
|
if (queue.inbox.length === 0) return [];
|
|
@@ -249,11 +255,13 @@ function steeringContent(
|
|
|
249
255
|
async function executeTurn(
|
|
250
256
|
host: AgentHost,
|
|
251
257
|
event: MessageEvent,
|
|
252
|
-
|
|
258
|
+
identity: AgentIdentity,
|
|
253
259
|
input: PreparedInput,
|
|
254
260
|
queue: UserQueue,
|
|
255
261
|
abortSignal: AbortSignal,
|
|
256
262
|
): Promise<void> {
|
|
263
|
+
const userId = identity.scope;
|
|
264
|
+
const sendUserId = identity.userId;
|
|
257
265
|
const base = host.getBase();
|
|
258
266
|
const settings = host.getSettings();
|
|
259
267
|
const resolved = host.resolveModel();
|
|
@@ -263,7 +271,7 @@ async function executeTurn(
|
|
|
263
271
|
const activity = new TurnActivity(digestMode);
|
|
264
272
|
|
|
265
273
|
if (!resolved) {
|
|
266
|
-
await replyError(host, bot,
|
|
274
|
+
await replyError(host, bot, sendUserId, "AI 服务不可用,请先在 WebUI 配置模型");
|
|
267
275
|
return;
|
|
268
276
|
}
|
|
269
277
|
|
|
@@ -286,7 +294,7 @@ async function executeTurn(
|
|
|
286
294
|
|
|
287
295
|
const sendToUser = async (text: string): Promise<void> => {
|
|
288
296
|
if (!bot) return;
|
|
289
|
-
await bot.sendMessage({ type: "private", user_id:
|
|
297
|
+
await bot.sendMessage({ type: "private", user_id: sendUserId }, [
|
|
290
298
|
host.ctx.segment.text(text),
|
|
291
299
|
]);
|
|
292
300
|
};
|
|
@@ -332,6 +340,7 @@ async function executeTurn(
|
|
|
332
340
|
|
|
333
341
|
const { tools, webSearchState } = buildTurnTools(host, {
|
|
334
342
|
userId,
|
|
343
|
+
sendUserId,
|
|
335
344
|
bot,
|
|
336
345
|
runId,
|
|
337
346
|
reporter,
|
|
@@ -390,15 +399,15 @@ async function executeTurn(
|
|
|
390
399
|
const sender = new TurnSender(
|
|
391
400
|
host,
|
|
392
401
|
bot,
|
|
393
|
-
|
|
402
|
+
sendUserId,
|
|
394
403
|
settings.enableMarkdownScreenshot && Boolean(host.screenshot),
|
|
395
404
|
);
|
|
396
405
|
const usageId = `agent:${sessionRow.sessionId}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
|
|
397
406
|
const usageContext = {
|
|
398
407
|
usageId,
|
|
399
408
|
source: "agent",
|
|
400
|
-
botId:
|
|
401
|
-
userId,
|
|
409
|
+
botId: identity.botId || undefined,
|
|
410
|
+
userId: sendUserId,
|
|
402
411
|
sessionId: sessionRow.sessionId,
|
|
403
412
|
};
|
|
404
413
|
|
|
@@ -464,7 +473,7 @@ async function executeTurn(
|
|
|
464
473
|
}
|
|
465
474
|
|
|
466
475
|
// 批量模式:把本回合的全部操作合并成一条转发记录,放在最终回复之前
|
|
467
|
-
await flushActivity(host, activity, bot,
|
|
476
|
+
await flushActivity(host, activity, bot, sendUserId, event);
|
|
468
477
|
|
|
469
478
|
if (settings.stream && !digestMode) {
|
|
470
479
|
await sender.finishStream(finalText);
|
|
@@ -483,11 +492,11 @@ async function executeTurn(
|
|
|
483
492
|
status = "error";
|
|
484
493
|
errorText = String(err);
|
|
485
494
|
host.logger.error(`[agent] turn failed: ${err}`);
|
|
486
|
-
await flushActivity(host, activity, bot,
|
|
495
|
+
await flushActivity(host, activity, bot, sendUserId, event);
|
|
487
496
|
await replyError(
|
|
488
497
|
host,
|
|
489
498
|
bot,
|
|
490
|
-
|
|
499
|
+
sendUserId,
|
|
491
500
|
`Agent 处理出错:${errorText.slice(0, 300)}`,
|
|
492
501
|
);
|
|
493
502
|
} finally {
|
|
@@ -552,15 +561,13 @@ async function flushActivity(
|
|
|
552
561
|
host: AgentHost,
|
|
553
562
|
activity: TurnActivity,
|
|
554
563
|
bot: Bot | undefined,
|
|
555
|
-
userId:
|
|
564
|
+
userId: string,
|
|
556
565
|
event: MessageEvent,
|
|
557
566
|
): Promise<void> {
|
|
558
567
|
if (!bot || activity.total === 0) return;
|
|
559
568
|
const target = { type: "private" as const, user_id: userId };
|
|
560
|
-
const selfId = String(
|
|
561
|
-
|
|
562
|
-
);
|
|
563
|
-
const nickname = host.ctx.bot?.nickname ?? "Agent";
|
|
569
|
+
const selfId = String(event.self_id || bot.bot_id || userId);
|
|
570
|
+
const nickname = bot.nickname ?? "Agent";
|
|
564
571
|
const nodes = activity.buildNodes(host.ctx, selfId, nickname);
|
|
565
572
|
try {
|
|
566
573
|
await bot.sendForward(target, nodes, activity.buildDisplay());
|
|
@@ -583,7 +590,7 @@ async function flushActivity(
|
|
|
583
590
|
async function replyError(
|
|
584
591
|
host: AgentHost,
|
|
585
592
|
bot: Bot | undefined,
|
|
586
|
-
userId:
|
|
593
|
+
userId: string,
|
|
587
594
|
text: string,
|
|
588
595
|
): Promise<void> {
|
|
589
596
|
if (!bot) return;
|
package/core/model.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { AIInstance, AIModelRole, AIService } from "mioku";
|
|
2
|
+
import type { ResolvedModel } from "../types";
|
|
3
|
+
|
|
4
|
+
const OVERRIDE_INSTANCE_PREFIX = "__agent_override_";
|
|
5
|
+
|
|
6
|
+
export interface ModelOverride {
|
|
7
|
+
/** 覆盖模型的全 id(providerId/modelId) */
|
|
8
|
+
key: string;
|
|
9
|
+
instanceName: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function splitModelFullId(
|
|
13
|
+
fullId: string,
|
|
14
|
+
): { providerId: string; modelId: string } | null {
|
|
15
|
+
const raw = String(fullId ?? "").trim();
|
|
16
|
+
const index = raw.indexOf("/");
|
|
17
|
+
if (index <= 0 || index >= raw.length - 1) return null;
|
|
18
|
+
return { providerId: raw.slice(0, index), modelId: raw.slice(index + 1) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function instanceName(instance: AIInstance | undefined): string | undefined {
|
|
22
|
+
const name = (instance as { name?: unknown } | undefined)?.name;
|
|
23
|
+
return typeof name === "string" && name ? name : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 在现有实例里找跑在指定提供商上的实例,同模型优先。 */
|
|
27
|
+
export function findInstanceByProvider(
|
|
28
|
+
aiService: AIService,
|
|
29
|
+
providerId: string,
|
|
30
|
+
modelId?: string,
|
|
31
|
+
): { name: string; instance: AIInstance } | undefined {
|
|
32
|
+
if (!providerId) return undefined;
|
|
33
|
+
const infos = aiService.listInstances?.() ?? [];
|
|
34
|
+
const info =
|
|
35
|
+
(modelId
|
|
36
|
+
? infos.find(
|
|
37
|
+
(item) => item.providerId === providerId && item.modelId === modelId,
|
|
38
|
+
)
|
|
39
|
+
: undefined) ?? infos.find((item) => item.providerId === providerId);
|
|
40
|
+
if (!info) return undefined;
|
|
41
|
+
const instance = aiService.get?.(info.name);
|
|
42
|
+
return instance ? { name: info.name, instance } : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function overrideInstanceName(providerId: string, modelId: string): string {
|
|
46
|
+
const slug = `${providerId}_${modelId}`.replace(/[^A-Za-z0-9_.-]+/g, "_");
|
|
47
|
+
return `${OVERRIDE_INSTANCE_PREFIX}${slug}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 覆盖模型必须跑在它自己的提供商上:实例固定绑定 provider,只改 model 会把别的
|
|
52
|
+
* 提供商的模型名发给主提供商的 client。优先复用现成实例,否则建一个隐藏实例。
|
|
53
|
+
*/
|
|
54
|
+
export async function prepareModelOverride(
|
|
55
|
+
aiService: AIService,
|
|
56
|
+
fullId: string,
|
|
57
|
+
warn: (message: string) => void,
|
|
58
|
+
): Promise<ModelOverride | undefined> {
|
|
59
|
+
const parsed = splitModelFullId(fullId);
|
|
60
|
+
if (!parsed) return undefined;
|
|
61
|
+
const reused = findInstanceByProvider(
|
|
62
|
+
aiService,
|
|
63
|
+
parsed.providerId,
|
|
64
|
+
parsed.modelId,
|
|
65
|
+
);
|
|
66
|
+
if (reused) return { key: fullId, instanceName: reused.name };
|
|
67
|
+
const name = overrideInstanceName(parsed.providerId, parsed.modelId);
|
|
68
|
+
if (aiService.get?.(name)) return { key: fullId, instanceName: name };
|
|
69
|
+
if (!aiService.createInstance) {
|
|
70
|
+
warn(`覆盖模型 ${fullId} 无法切换提供商:AI 服务不支持 createInstance`);
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
await aiService.createInstance({
|
|
75
|
+
name,
|
|
76
|
+
providerId: parsed.providerId,
|
|
77
|
+
modelId: parsed.modelId,
|
|
78
|
+
});
|
|
79
|
+
return { key: fullId, instanceName: name };
|
|
80
|
+
} catch (err) {
|
|
81
|
+
warn(`覆盖模型 ${fullId} 实例创建失败:${err}`);
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function resolveAgentModel(options: {
|
|
87
|
+
aiService: AIService;
|
|
88
|
+
overrideFullId: string;
|
|
89
|
+
override?: ModelOverride;
|
|
90
|
+
}): ResolvedModel | null {
|
|
91
|
+
const { aiService, overrideFullId, override } = options;
|
|
92
|
+
const getByRole = (role: AIModelRole) =>
|
|
93
|
+
aiService.getInstanceByRole?.(role) ?? aiService.get?.(role);
|
|
94
|
+
const main = getByRole("main") ?? aiService.getDefault?.();
|
|
95
|
+
if (!main) return null;
|
|
96
|
+
|
|
97
|
+
const bindings = aiService.getRoleBindings?.() ?? {
|
|
98
|
+
main: undefined,
|
|
99
|
+
working: undefined,
|
|
100
|
+
vision: undefined,
|
|
101
|
+
};
|
|
102
|
+
const instances = aiService.listInstances?.() ?? [];
|
|
103
|
+
const models = aiService.listModels?.() ?? [];
|
|
104
|
+
|
|
105
|
+
const modelIdOf = (
|
|
106
|
+
full: string | undefined,
|
|
107
|
+
instance: AIInstance,
|
|
108
|
+
): string => {
|
|
109
|
+
if (full && full.includes("/")) return full.split("/").slice(1).join("/");
|
|
110
|
+
const name = instanceName(instance);
|
|
111
|
+
const info = instances.find(
|
|
112
|
+
(item) => item.role === name || item.name === name,
|
|
113
|
+
);
|
|
114
|
+
return info?.modelId ?? "";
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const overrideDesc = overrideFullId
|
|
118
|
+
? models.find((item) => item.id === overrideFullId)
|
|
119
|
+
: undefined;
|
|
120
|
+
const overrideParsed = splitModelFullId(overrideFullId);
|
|
121
|
+
|
|
122
|
+
let instance = main;
|
|
123
|
+
let model = modelIdOf(bindings.main, main);
|
|
124
|
+
if (overrideFullId && overrideParsed) {
|
|
125
|
+
const target =
|
|
126
|
+
(override && override.key === overrideFullId
|
|
127
|
+
? aiService.get?.(override.instanceName)
|
|
128
|
+
: undefined) ??
|
|
129
|
+
findInstanceByProvider(
|
|
130
|
+
aiService,
|
|
131
|
+
overrideParsed.providerId,
|
|
132
|
+
overrideParsed.modelId,
|
|
133
|
+
)?.instance;
|
|
134
|
+
if (target) {
|
|
135
|
+
// 覆盖模型的提供商可达:用它的实例 + 它的模型
|
|
136
|
+
instance = target;
|
|
137
|
+
model = overrideDesc?.modelId ?? overrideParsed.modelId;
|
|
138
|
+
}
|
|
139
|
+
// 提供商不可达时退回主模型,绝不把别家的模型名发给主提供商的 client
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let working = getByRole("working") ?? main;
|
|
143
|
+
let workingModel = modelIdOf(bindings.working, working);
|
|
144
|
+
if (!workingModel) {
|
|
145
|
+
// 没有独立绑定的干活模型:跟随主模型,实例也要跟着换,否则模型与提供商错配
|
|
146
|
+
working = instance;
|
|
147
|
+
workingModel = model;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let vision = getByRole("vision") ?? working;
|
|
151
|
+
let visionModel = modelIdOf(bindings.vision, vision);
|
|
152
|
+
if (!visionModel) {
|
|
153
|
+
vision = working;
|
|
154
|
+
visionModel = workingModel;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const visionDesc =
|
|
158
|
+
models.find((item) => item.id === bindings.vision) ||
|
|
159
|
+
models.find((item) => item.modelId === visionModel);
|
|
160
|
+
// 有覆盖时,直接吃图片的是覆盖模型,能力要以它为准
|
|
161
|
+
const isMultimodal = overrideDesc
|
|
162
|
+
? (overrideDesc.capabilities?.includes("vision") ?? false)
|
|
163
|
+
: (visionDesc?.capabilities?.includes("vision") ?? Boolean(visionModel));
|
|
164
|
+
const mainDesc =
|
|
165
|
+
overrideDesc ||
|
|
166
|
+
models.find((item) => item.id === bindings.main) ||
|
|
167
|
+
models.find((item) => item.modelId === model);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
instance,
|
|
171
|
+
model,
|
|
172
|
+
working,
|
|
173
|
+
workingModel,
|
|
174
|
+
vision,
|
|
175
|
+
visionModel,
|
|
176
|
+
isMultimodal,
|
|
177
|
+
contextWindow: mainDesc?.contextWindow ?? 0,
|
|
178
|
+
};
|
|
179
|
+
}
|