mioku-plugin-agent 0.1.1 → 0.2.1
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 +4 -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/send.ts +1 -1
- package/core/session.ts +10 -10
- package/db.ts +55 -15
- package/handlers/message.ts +24 -8
- package/index.ts +15 -14
- 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` 查看本地图片;多模态主模型直接把图片附加进对话,非多模态时交给视觉模型转成描述
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
|
|
35
35
|
默认仅 bot 主人私聊可用;可在配置中允许管理员或指定用户。
|
|
36
36
|
|
|
37
|
+
私聊中以 `.` / `/` / `~` 开头的消息视为命令,agent 直接忽略不处理
|
|
38
|
+
|
|
37
39
|
## 命令
|
|
38
40
|
|
|
39
41
|
| 命令 | 说明 | 权限 |
|
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/send.ts
CHANGED
package/core/session.ts
CHANGED
|
@@ -3,44 +3,44 @@ import type { AgentDatabase, AgentSessionRow } from "../db";
|
|
|
3
3
|
export class SessionManager {
|
|
4
4
|
constructor(private db: AgentDatabase) {}
|
|
5
5
|
|
|
6
|
-
generation(userId:
|
|
6
|
+
generation(userId: string): number {
|
|
7
7
|
return this.db.getUserGeneration(userId);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
sessionId(userId:
|
|
10
|
+
sessionId(userId: string): string {
|
|
11
11
|
return `agent:${userId}:g${this.generation(userId)}`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
get(userId:
|
|
14
|
+
get(userId: string): AgentSessionRow {
|
|
15
15
|
return this.db.getOrCreateSession(this.sessionId(userId), userId);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
newSession(userId:
|
|
18
|
+
newSession(userId: string): AgentSessionRow {
|
|
19
19
|
this.db.bumpUserGenerationTo(userId, this.db.maxGeneration(userId) + 1);
|
|
20
20
|
return this.get(userId);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
resume(userId:
|
|
23
|
+
resume(userId: string, generation: number): AgentSessionRow | undefined {
|
|
24
24
|
const target = this.db.getSessionByGeneration(userId, generation);
|
|
25
25
|
if (!target) return undefined;
|
|
26
26
|
this.db.bumpUserGenerationTo(userId, generation);
|
|
27
27
|
return this.db.getOrCreateSession(target.sessionId, userId);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
resumableSessions(userId:
|
|
30
|
+
resumableSessions(userId: string, excludeSessionId: string): AgentSessionRow[] {
|
|
31
31
|
return this.db
|
|
32
32
|
.listSessions(userId, { archived: false })
|
|
33
33
|
.filter((session) => session.sessionId !== excludeSessionId);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
archive(userId:
|
|
36
|
+
archive(userId: string, generation: number): AgentSessionRow | undefined {
|
|
37
37
|
const target = this.db.getSessionByGeneration(userId, generation);
|
|
38
38
|
if (!target) return undefined;
|
|
39
39
|
this.db.setSessionMeta(target.sessionId, { archived: true });
|
|
40
40
|
return this.db.getSessionByGeneration(userId, generation);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
reset(userId:
|
|
43
|
+
reset(userId: string): void {
|
|
44
44
|
this.db.resetSession(this.sessionId(userId));
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -48,11 +48,11 @@ export class SessionManager {
|
|
|
48
48
|
return this.db.getMessagesAfter(session.sessionId, session.summaryUpTo);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
append(userId:
|
|
51
|
+
append(userId: string, role: "user" | "assistant", content: string): number {
|
|
52
52
|
return this.db.appendMessage(this.sessionId(userId), role, content);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
setEmotion(userId:
|
|
55
|
+
setEmotion(userId: string, emotion: string): void {
|
|
56
56
|
this.db.setSessionMeta(this.sessionId(userId), { emotion });
|
|
57
57
|
}
|
|
58
58
|
}
|
package/db.ts
CHANGED
|
@@ -18,6 +18,14 @@ function rowString(row: SqlRow, key: string, fallback = ""): string {
|
|
|
18
18
|
return typeof value === "string" ? value : fallback;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** id 列可能是 TEXT(openid)也可能是 INTEGER 亲和存下的数字,统一转字符串 */
|
|
22
|
+
function rowId(row: SqlRow | null | undefined, key: string, fallback = ""): string {
|
|
23
|
+
const value = row?.[key];
|
|
24
|
+
if (value == null) return fallback;
|
|
25
|
+
const text = String(value);
|
|
26
|
+
return text.length > 0 ? text : fallback;
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
export type SessionPlanStatus = "pending" | "in_progress" | "completed";
|
|
22
30
|
|
|
23
31
|
export interface SessionPlanItem {
|
|
@@ -27,7 +35,7 @@ export interface SessionPlanItem {
|
|
|
27
35
|
|
|
28
36
|
export interface AgentSessionRow {
|
|
29
37
|
sessionId: string;
|
|
30
|
-
userId:
|
|
38
|
+
userId: string;
|
|
31
39
|
generation: number;
|
|
32
40
|
emotion: string;
|
|
33
41
|
summary: string;
|
|
@@ -51,7 +59,7 @@ export interface AgentMessageRow {
|
|
|
51
59
|
export interface AgentRunRow {
|
|
52
60
|
id: number;
|
|
53
61
|
sessionId: string;
|
|
54
|
-
userId:
|
|
62
|
+
userId: string;
|
|
55
63
|
model: string;
|
|
56
64
|
status: "ok" | "error";
|
|
57
65
|
iterations: number;
|
|
@@ -93,7 +101,7 @@ export class AgentDatabase {
|
|
|
93
101
|
this.db.run(`
|
|
94
102
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
95
103
|
session_id TEXT PRIMARY KEY,
|
|
96
|
-
user_id
|
|
104
|
+
user_id TEXT NOT NULL,
|
|
97
105
|
generation INTEGER NOT NULL DEFAULT 0,
|
|
98
106
|
emotion TEXT NOT NULL DEFAULT '',
|
|
99
107
|
summary TEXT NOT NULL DEFAULT '',
|
|
@@ -106,7 +114,7 @@ export class AgentDatabase {
|
|
|
106
114
|
updated_at INTEGER NOT NULL
|
|
107
115
|
);
|
|
108
116
|
CREATE TABLE IF NOT EXISTS user_state (
|
|
109
|
-
user_id
|
|
117
|
+
user_id TEXT PRIMARY KEY,
|
|
110
118
|
generation INTEGER NOT NULL DEFAULT 0,
|
|
111
119
|
updated_at INTEGER NOT NULL
|
|
112
120
|
);
|
|
@@ -121,7 +129,7 @@ export class AgentDatabase {
|
|
|
121
129
|
CREATE TABLE IF NOT EXISTS runs (
|
|
122
130
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
123
131
|
session_id TEXT NOT NULL,
|
|
124
|
-
user_id
|
|
132
|
+
user_id TEXT NOT NULL,
|
|
125
133
|
model TEXT NOT NULL DEFAULT '',
|
|
126
134
|
status TEXT NOT NULL DEFAULT 'ok',
|
|
127
135
|
iterations INTEGER NOT NULL DEFAULT 0,
|
|
@@ -142,23 +150,55 @@ export class AgentDatabase {
|
|
|
142
150
|
created_at INTEGER NOT NULL
|
|
143
151
|
);
|
|
144
152
|
`);
|
|
153
|
+
this.#widenLegacyIdColumns();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 旧库的 user_state.user_id 是 INTEGER PRIMARY KEY(rowid 别名),
|
|
158
|
+
* 写入 openid 会直接抛 SQLiteError: datatype mismatch,这里重建成 TEXT。
|
|
159
|
+
*/
|
|
160
|
+
#widenLegacyIdColumns(): void {
|
|
161
|
+
const info = this.db
|
|
162
|
+
.query("PRAGMA table_info(user_state)")
|
|
163
|
+
.all() as Array<{ name?: string; type?: string }>;
|
|
164
|
+
const column = info.find((item) => item.name === "user_id");
|
|
165
|
+
if (!column || String(column.type ?? "").toUpperCase() === "TEXT") return;
|
|
166
|
+
|
|
167
|
+
this.db.run("BEGIN");
|
|
168
|
+
try {
|
|
169
|
+
this.db.run("ALTER TABLE user_state RENAME TO user_state_legacy");
|
|
170
|
+
this.db.run(`
|
|
171
|
+
CREATE TABLE user_state (
|
|
172
|
+
user_id TEXT PRIMARY KEY,
|
|
173
|
+
generation INTEGER NOT NULL DEFAULT 0,
|
|
174
|
+
updated_at INTEGER NOT NULL
|
|
175
|
+
);
|
|
176
|
+
INSERT INTO user_state (user_id, generation, updated_at)
|
|
177
|
+
SELECT CAST(user_id AS TEXT), generation, updated_at FROM user_state_legacy;
|
|
178
|
+
DROP TABLE user_state_legacy;
|
|
179
|
+
`);
|
|
180
|
+
this.db.run("COMMIT");
|
|
181
|
+
} catch (err) {
|
|
182
|
+
this.db.run("ROLLBACK");
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
145
185
|
}
|
|
146
186
|
|
|
147
|
-
getUserGeneration(userId:
|
|
187
|
+
getUserGeneration(userId: string): number {
|
|
148
188
|
const row = this.db
|
|
149
189
|
.query("SELECT generation FROM user_state WHERE user_id = ?")
|
|
150
190
|
.get(userId) as SqlRow | null;
|
|
151
191
|
return rowNumber(row, "generation", 0);
|
|
152
192
|
}
|
|
153
193
|
|
|
154
|
-
maxGeneration(userId:
|
|
194
|
+
maxGeneration(userId: string): number {
|
|
155
195
|
const row = this.db
|
|
156
196
|
.query("SELECT MAX(generation) AS max FROM sessions WHERE user_id = ?")
|
|
157
197
|
.get(userId) as SqlRow | null;
|
|
158
198
|
return rowNumber(row, "max", -1);
|
|
159
199
|
}
|
|
160
200
|
|
|
161
|
-
bumpUserGenerationTo(userId:
|
|
201
|
+
bumpUserGenerationTo(userId: string, generation: number): void {
|
|
162
202
|
this.db.run(
|
|
163
203
|
`INSERT INTO user_state (user_id, generation, updated_at) VALUES (?, ?, ?)
|
|
164
204
|
ON CONFLICT(user_id) DO UPDATE SET generation = ?, updated_at = ?`,
|
|
@@ -166,7 +206,7 @@ export class AgentDatabase {
|
|
|
166
206
|
);
|
|
167
207
|
}
|
|
168
208
|
|
|
169
|
-
getOrCreateSession(sessionId: string, userId:
|
|
209
|
+
getOrCreateSession(sessionId: string, userId: string): AgentSessionRow {
|
|
170
210
|
const now = Date.now();
|
|
171
211
|
this.db.run(
|
|
172
212
|
`INSERT INTO sessions (session_id, user_id, generation, created_at, updated_at)
|
|
@@ -181,7 +221,7 @@ export class AgentDatabase {
|
|
|
181
221
|
}
|
|
182
222
|
|
|
183
223
|
getSessionByGeneration(
|
|
184
|
-
userId:
|
|
224
|
+
userId: string,
|
|
185
225
|
generation: number,
|
|
186
226
|
): AgentSessionRow | undefined {
|
|
187
227
|
const row = this.db
|
|
@@ -191,7 +231,7 @@ export class AgentDatabase {
|
|
|
191
231
|
}
|
|
192
232
|
|
|
193
233
|
listSessions(
|
|
194
|
-
userId:
|
|
234
|
+
userId: string,
|
|
195
235
|
options: { archived?: boolean } = {},
|
|
196
236
|
): AgentSessionRow[] {
|
|
197
237
|
const rows = options.archived === undefined
|
|
@@ -314,7 +354,7 @@ export class AgentDatabase {
|
|
|
314
354
|
}
|
|
315
355
|
|
|
316
356
|
/** 删除该用户的全部会话(含归档)及其消息、运行记录与工具调用明细。 */
|
|
317
|
-
clearUserSessions(userId:
|
|
357
|
+
clearUserSessions(userId: string): ClearSessionsResult {
|
|
318
358
|
const ids = this.listSessions(userId).map((session) => session.sessionId);
|
|
319
359
|
const result: ClearSessionsResult = {
|
|
320
360
|
sessions: ids.length,
|
|
@@ -360,7 +400,7 @@ export class AgentDatabase {
|
|
|
360
400
|
return rowNumber(row, "count", 0);
|
|
361
401
|
}
|
|
362
402
|
|
|
363
|
-
startRun(sessionId: string, userId:
|
|
403
|
+
startRun(sessionId: string, userId: string, model: string): number {
|
|
364
404
|
const result = this.db
|
|
365
405
|
.query(
|
|
366
406
|
"INSERT INTO runs (session_id, user_id, model, started_at) VALUES (?, ?, ?, ?)",
|
|
@@ -408,7 +448,7 @@ export class AgentDatabase {
|
|
|
408
448
|
);
|
|
409
449
|
}
|
|
410
450
|
|
|
411
|
-
getRunStats(userId:
|
|
451
|
+
getRunStats(userId: string): { runs: number; toolCalls: number } {
|
|
412
452
|
const runRow = this.db
|
|
413
453
|
.query("SELECT COUNT(*) AS count FROM runs WHERE user_id = ?")
|
|
414
454
|
.get(userId) as SqlRow | null;
|
|
@@ -449,7 +489,7 @@ function toSessionRow(row: SqlRow): AgentSessionRow {
|
|
|
449
489
|
const sessionId = rowString(row, "session_id");
|
|
450
490
|
return {
|
|
451
491
|
sessionId,
|
|
452
|
-
userId:
|
|
492
|
+
userId: rowId(row, "user_id"),
|
|
453
493
|
generation: rowNumber(row, "generation", parseGeneration(sessionId)),
|
|
454
494
|
emotion: rowString(row, "emotion"),
|
|
455
495
|
summary: rowString(row, "summary"),
|
package/handlers/message.ts
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
import type { MessageEvent } from "mioku";
|
|
2
2
|
import type { AgentHost } from "../types";
|
|
3
|
+
import { identityOf } from "../core/identity";
|
|
3
4
|
import { runAgentTurn } from "../core/loop";
|
|
5
|
+
import { genericPlatform } from "../platforms/generic";
|
|
6
|
+
import type { AgentPlatform } from "../platforms/types";
|
|
4
7
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
/** 私聊里以命令前缀开头的消息交给命令系统,agent 不处理 */
|
|
9
|
+
const COMMAND_PREFIX = /^[./~]/;
|
|
10
|
+
|
|
11
|
+
/** 各平台分支共用的入口:权限/自消息过滤后进入 agent 轮次 */
|
|
12
|
+
export async function handleAgentMessage(
|
|
13
|
+
host: AgentHost,
|
|
14
|
+
event: MessageEvent,
|
|
15
|
+
platform: AgentPlatform = genericPlatform,
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
if (event.message_type === "group") return;
|
|
18
|
+
const identity = identityOf(event);
|
|
19
|
+
if (!identity.userId || identity.userId === identity.botId) return;
|
|
20
|
+
const text = host.ctx.text(event);
|
|
21
|
+
if (COMMAND_PREFIX.test(text)) {
|
|
22
|
+
host.logger.debug(
|
|
23
|
+
`[agent] command ignored | user=${identity.userId} text=${text.slice(0, 60)}`,
|
|
24
|
+
);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (!(await host.isAllowed(identity.userId))) return;
|
|
28
|
+
await runAgentTurn(host, event, platform);
|
|
13
29
|
}
|
package/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { SessionManager } from "./core/session";
|
|
|
5
5
|
import { EmotionManager } from "./core/emotion";
|
|
6
6
|
import { ApprovalManager } from "./tools/approval";
|
|
7
7
|
import { readChatSharedConfig } from "./core/chat-config";
|
|
8
|
-
import {
|
|
8
|
+
import { registerAgentPlatforms } from "./platforms";
|
|
9
9
|
import { registerCommands } from "./commands";
|
|
10
10
|
import { mergeAgentConfig } from "./utils/config";
|
|
11
11
|
import { workspaceRootFor } from "./tools/perm";
|
|
@@ -23,13 +23,13 @@ import type {
|
|
|
23
23
|
ResolvedModel,
|
|
24
24
|
} from "./types";
|
|
25
25
|
|
|
26
|
-
function normalizeIdList(input: unknown):
|
|
26
|
+
function normalizeIdList(input: unknown): string[] {
|
|
27
27
|
if (!Array.isArray(input)) return [];
|
|
28
28
|
return Array.from(
|
|
29
29
|
new Set(
|
|
30
30
|
input
|
|
31
|
-
.map((item) =>
|
|
32
|
-
.filter((id) =>
|
|
31
|
+
.map((item) => String(item ?? "").trim())
|
|
32
|
+
.filter((id) => id.length > 0),
|
|
33
33
|
),
|
|
34
34
|
);
|
|
35
35
|
}
|
|
@@ -136,18 +136,18 @@ export default definePlugin({
|
|
|
136
136
|
getSettings: () => cachedSettings,
|
|
137
137
|
getChatShared: () => readChatSharedConfig(configService),
|
|
138
138
|
resolveModel,
|
|
139
|
-
workspaceRoot: (userId:
|
|
139
|
+
workspaceRoot: (userId: string) =>
|
|
140
140
|
workspaceRootFor(cachedBase.workspaceDir, userId),
|
|
141
|
-
isAllowed: async (userId:
|
|
142
|
-
const
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
) {
|
|
141
|
+
isAllowed: async (userId: string) => {
|
|
142
|
+
const target = String(userId ?? "").trim();
|
|
143
|
+
if (!target) return false;
|
|
144
|
+
const matches = (list: readonly unknown[] | undefined): boolean =>
|
|
145
|
+
(list ?? []).some((item) => String(item ?? "").trim() === target);
|
|
146
|
+
if (matches(ctx.config.owners)) return true;
|
|
147
|
+
if (cachedBase.access.allowAdmins && matches(ctx.config.admins)) {
|
|
148
148
|
return true;
|
|
149
149
|
}
|
|
150
|
-
return normalizeIdList(cachedBase.access.users).includes(
|
|
150
|
+
return normalizeIdList(cachedBase.access.users).includes(target);
|
|
151
151
|
},
|
|
152
152
|
updateBase: async (patch) => {
|
|
153
153
|
if (configService) {
|
|
@@ -160,7 +160,7 @@ export default definePlugin({
|
|
|
160
160
|
};
|
|
161
161
|
|
|
162
162
|
registerCommands(host);
|
|
163
|
-
ctx
|
|
163
|
+
const disposePlatforms = registerAgentPlatforms(ctx, host);
|
|
164
164
|
|
|
165
165
|
const resolved = resolveModel();
|
|
166
166
|
ctx.logger.info(
|
|
@@ -168,6 +168,7 @@ export default definePlugin({
|
|
|
168
168
|
);
|
|
169
169
|
|
|
170
170
|
return () => {
|
|
171
|
+
disposePlatforms();
|
|
171
172
|
approvals.dispose();
|
|
172
173
|
db.close();
|
|
173
174
|
ctx.logger.info("agent 插件已卸载");
|
package/package.json
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AgentPlatform } from "./types";
|
|
2
|
+
import { EMPTY_FILE_LOOKUP } from "./types";
|
|
3
|
+
|
|
4
|
+
/** 未单独登记的适配器(如 stdin、未来的新平台)走的兜底分支 */
|
|
5
|
+
export const genericPlatform: AgentPlatform = {
|
|
6
|
+
adapter: "",
|
|
7
|
+
route: "message",
|
|
8
|
+
async resolveFile() {
|
|
9
|
+
return EMPTY_FILE_LOOKUP;
|
|
10
|
+
},
|
|
11
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { AgentPlatform, PlatformFileLookup } from "./types";
|
|
2
|
+
import { EMPTY_FILE_LOOKUP, mergeFileLookup } from "./types";
|
|
3
|
+
|
|
4
|
+
interface IcqqFileHolder {
|
|
5
|
+
getFileUrl?(fileId: string): Promise<string>;
|
|
6
|
+
getFileInfo?(fileId: string): Promise<{ url?: string; name?: string } | null>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface IcqqFileClient {
|
|
10
|
+
pickGroup?(groupId: string): IcqqFileHolder;
|
|
11
|
+
pickFriend?(userId: string): IcqqFileHolder;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** icqq:pickGroup / pickFriend 上的 getFileInfo / getFileUrl */
|
|
15
|
+
export const icqqPlatform: AgentPlatform = {
|
|
16
|
+
adapter: "icqq",
|
|
17
|
+
route: "icqq:message",
|
|
18
|
+
async resolveFile(bot, ref): Promise<PlatformFileLookup> {
|
|
19
|
+
const client = bot.as<IcqqFileClient>();
|
|
20
|
+
const holder = ref.groupId
|
|
21
|
+
? client.pickGroup?.(ref.groupId)
|
|
22
|
+
: ref.userId
|
|
23
|
+
? client.pickFriend?.(ref.userId)
|
|
24
|
+
: undefined;
|
|
25
|
+
if (!holder) return EMPTY_FILE_LOOKUP;
|
|
26
|
+
|
|
27
|
+
const found: PlatformFileLookup = { sources: [], names: [] };
|
|
28
|
+
if (holder.getFileInfo) {
|
|
29
|
+
try {
|
|
30
|
+
mergeFileLookup(found, await holder.getFileInfo(ref.fileId));
|
|
31
|
+
} catch {
|
|
32
|
+
// 文件不存在或无权限时继续尝试 getFileUrl
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (found.sources.length === 0 && holder.getFileUrl) {
|
|
36
|
+
try {
|
|
37
|
+
const url = await holder.getFileUrl(ref.fileId);
|
|
38
|
+
if (typeof url === "string" && url.trim()) {
|
|
39
|
+
found.sources.push(url.trim());
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// 调用方会回退到消息段里自带的 url/file
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return found;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { MessageEvent, MiokuContext } from "mioku";
|
|
2
|
+
import type { AgentHost } from "../types";
|
|
3
|
+
import { handleAgentMessage } from "../handlers/message";
|
|
4
|
+
import { genericPlatform } from "./generic";
|
|
5
|
+
import { icqqPlatform } from "./icqq";
|
|
6
|
+
import { onebotv11Platform } from "./onebotv11";
|
|
7
|
+
import { qqOfficialPlatform } from "./qq-official";
|
|
8
|
+
import type { AgentPlatform } from "./types";
|
|
9
|
+
|
|
10
|
+
/** 每个平台一个分支文件,新增平台在这里登记即可 */
|
|
11
|
+
export const AGENT_PLATFORMS: readonly AgentPlatform[] = [
|
|
12
|
+
onebotv11Platform,
|
|
13
|
+
icqqPlatform,
|
|
14
|
+
qqOfficialPlatform,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 按适配器前缀路由注册平台分支:
|
|
19
|
+
* `onebotv11:message` / `icqq:message` / `qq-official:message` 各管自己的差异,
|
|
20
|
+
* 未登记的适配器由通用 `message` 分支兜底。
|
|
21
|
+
*/
|
|
22
|
+
export function registerAgentPlatforms(
|
|
23
|
+
ctx: MiokuContext,
|
|
24
|
+
host: AgentHost,
|
|
25
|
+
): () => void {
|
|
26
|
+
const disposers: Array<() => void> = [];
|
|
27
|
+
|
|
28
|
+
for (const platform of AGENT_PLATFORMS) {
|
|
29
|
+
disposers.push(
|
|
30
|
+
ctx.handle(platform.route, (event) =>
|
|
31
|
+
handleAgentMessage(host, event as unknown as MessageEvent, platform),
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const owned = new Set(AGENT_PLATFORMS.map((platform) => platform.adapter));
|
|
37
|
+
disposers.push(
|
|
38
|
+
ctx.handle("message", (event) => {
|
|
39
|
+
const messageEvent = event as unknown as MessageEvent;
|
|
40
|
+
const adapter = String(messageEvent?.bot?.adapter ?? "");
|
|
41
|
+
if (owned.has(adapter)) return;
|
|
42
|
+
return handleAgentMessage(host, messageEvent, genericPlatform);
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return () => {
|
|
47
|
+
for (const dispose of disposers) dispose();
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AgentPlatform, PlatformFileLookup } from "./types";
|
|
2
|
+
import { EMPTY_FILE_LOOKUP, mergeFileLookup } from "./types";
|
|
3
|
+
|
|
4
|
+
/** onebotv11:get_file / get_group_file_url / get_private_file_url */
|
|
5
|
+
export const onebotv11Platform: AgentPlatform = {
|
|
6
|
+
adapter: "onebotv11",
|
|
7
|
+
route: "onebotv11:message",
|
|
8
|
+
async resolveFile(bot, ref): Promise<PlatformFileLookup> {
|
|
9
|
+
const attempts: Array<[string, Record<string, unknown>]> = [
|
|
10
|
+
["get_file", { file_id: ref.fileId }],
|
|
11
|
+
];
|
|
12
|
+
if (ref.groupId) {
|
|
13
|
+
attempts.push([
|
|
14
|
+
"get_group_file_url",
|
|
15
|
+
{ group_id: ref.groupId, file_id: ref.fileId },
|
|
16
|
+
]);
|
|
17
|
+
}
|
|
18
|
+
if (ref.userId) {
|
|
19
|
+
attempts.push([
|
|
20
|
+
"get_private_file_url",
|
|
21
|
+
{ user_id: ref.userId, file_id: ref.fileId },
|
|
22
|
+
]);
|
|
23
|
+
}
|
|
24
|
+
const found: PlatformFileLookup = { sources: [], names: [] };
|
|
25
|
+
for (const [action, params] of attempts) {
|
|
26
|
+
try {
|
|
27
|
+
mergeFileLookup(
|
|
28
|
+
found,
|
|
29
|
+
await bot.sendApi<Record<string, unknown>>(action, params),
|
|
30
|
+
);
|
|
31
|
+
} catch {
|
|
32
|
+
// 协议端不支持该 action 时继续尝试下一个
|
|
33
|
+
}
|
|
34
|
+
if (found.sources.length > 0) break;
|
|
35
|
+
}
|
|
36
|
+
return found;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export { EMPTY_FILE_LOOKUP };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AgentPlatform } from "./types";
|
|
2
|
+
import { EMPTY_FILE_LOOKUP } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* QQ 官方通道:附件事件自带公网 URL(见 adapter 的 segments.ts),
|
|
6
|
+
* 官方没有 file_id 换下载地址的公开接口,所以这里不做事。
|
|
7
|
+
*/
|
|
8
|
+
export const qqOfficialPlatform: AgentPlatform = {
|
|
9
|
+
adapter: "qq-official",
|
|
10
|
+
route: "qq-official:message",
|
|
11
|
+
async resolveFile() {
|
|
12
|
+
return EMPTY_FILE_LOOKUP;
|
|
13
|
+
},
|
|
14
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Bot } from "mioku";
|
|
2
|
+
|
|
3
|
+
/** 需要按平台换取下载地址的附件引用 */
|
|
4
|
+
export interface PlatformFileRef {
|
|
5
|
+
fileId: string;
|
|
6
|
+
groupId?: string;
|
|
7
|
+
userId?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PlatformFileLookup {
|
|
11
|
+
sources: string[];
|
|
12
|
+
names: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const EMPTY_FILE_LOOKUP: PlatformFileLookup = { sources: [], names: [] };
|
|
16
|
+
|
|
17
|
+
/** 把平台返回的原始结果合并成下载来源(各平台实现共用) */
|
|
18
|
+
export const mergeFileLookup = (
|
|
19
|
+
target: PlatformFileLookup,
|
|
20
|
+
result: unknown,
|
|
21
|
+
): void => {
|
|
22
|
+
if (!result || typeof result !== "object") return;
|
|
23
|
+
const record = result as Record<string, unknown>;
|
|
24
|
+
for (const key of ["url", "file", "path"]) {
|
|
25
|
+
const value = record[key];
|
|
26
|
+
if (typeof value === "string" && value.trim()) {
|
|
27
|
+
target.sources.push(value.trim());
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const base64 = record.base64 ?? record.data;
|
|
31
|
+
if (typeof base64 === "string" && base64.trim()) {
|
|
32
|
+
target.sources.push(`base64://${base64.trim()}`);
|
|
33
|
+
}
|
|
34
|
+
for (const key of ["file_name", "name"]) {
|
|
35
|
+
const value = record[key];
|
|
36
|
+
if (typeof value === "string" && value.trim()) {
|
|
37
|
+
target.names.push(value.trim());
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 一个平台分支:自己订阅 `adapter:message` 路由,自己处理平台专有差异。
|
|
44
|
+
* 新增平台只要加一个文件并登记到 `platforms/index.ts`。
|
|
45
|
+
*/
|
|
46
|
+
export interface AgentPlatform {
|
|
47
|
+
/** 适配器名,与 `bot.adapter` 对应 */
|
|
48
|
+
readonly adapter: string;
|
|
49
|
+
/** 该分支订阅的事件路由,如 `onebotv11:message` */
|
|
50
|
+
readonly route: string;
|
|
51
|
+
/** 平台专有的 file_id → 下载来源;没有该能力时返回空 */
|
|
52
|
+
resolveFile(bot: Bot, ref: PlatformFileRef): Promise<PlatformFileLookup>;
|
|
53
|
+
}
|
package/tools/approval.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { AgentPermissionLevel } from "../types";
|
|
|
2
2
|
|
|
3
3
|
export interface PendingApproval {
|
|
4
4
|
id: string;
|
|
5
|
-
userId:
|
|
5
|
+
userId: string;
|
|
6
6
|
command: string;
|
|
7
7
|
cwd: string;
|
|
8
8
|
level: AgentPermissionLevel;
|
|
@@ -18,7 +18,7 @@ interface PendingEntry extends PendingApproval {
|
|
|
18
18
|
|
|
19
19
|
export class ApprovalManager {
|
|
20
20
|
private pending = new Map<string, PendingEntry>();
|
|
21
|
-
private latestByUser = new Map<
|
|
21
|
+
private latestByUser = new Map<string, string>();
|
|
22
22
|
private seq = 0;
|
|
23
23
|
|
|
24
24
|
create(
|
|
@@ -55,7 +55,7 @@ export class ApprovalManager {
|
|
|
55
55
|
return true;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
resolveLatest(userId:
|
|
58
|
+
resolveLatest(userId: string, approved: boolean): PendingApproval | null {
|
|
59
59
|
const id = this.latestByUser.get(userId) ?? this.latestFor(userId);
|
|
60
60
|
if (!id) return null;
|
|
61
61
|
const entry = this.pending.get(id);
|
|
@@ -65,13 +65,13 @@ export class ApprovalManager {
|
|
|
65
65
|
return approval;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
latestByUserId(userId:
|
|
68
|
+
latestByUserId(userId: string): PendingApproval | null {
|
|
69
69
|
const id = this.latestByUser.get(userId) ?? this.latestFor(userId);
|
|
70
70
|
const entry = id ? this.pending.get(id) : undefined;
|
|
71
71
|
return entry ? this.toPending(entry) : null;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
cancelByUser(userId:
|
|
74
|
+
cancelByUser(userId: string): number {
|
|
75
75
|
const ids = [...this.pending.values()]
|
|
76
76
|
.filter((entry) => entry.userId === userId)
|
|
77
77
|
.map((entry) => entry.id);
|
|
@@ -79,7 +79,7 @@ export class ApprovalManager {
|
|
|
79
79
|
return ids.length;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
listByUser(userId:
|
|
82
|
+
listByUser(userId: string): PendingApproval[] {
|
|
83
83
|
return [...this.pending.values()]
|
|
84
84
|
.filter((entry) => entry.userId === userId)
|
|
85
85
|
.sort((a, b) => a.createdAt - b.createdAt)
|
|
@@ -95,7 +95,7 @@ export class ApprovalManager {
|
|
|
95
95
|
this.latestByUser.clear();
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
private latestFor(userId:
|
|
98
|
+
private latestFor(userId: string): string | undefined {
|
|
99
99
|
let latest: PendingEntry | undefined;
|
|
100
100
|
for (const entry of this.pending.values()) {
|
|
101
101
|
if (entry.userId !== userId) continue;
|
package/tools/bash.ts
CHANGED
package/tools/deliver.ts
CHANGED
package/tools/index.ts
CHANGED
|
@@ -25,7 +25,10 @@ import { assessCommandRisk } from "../core/risk";
|
|
|
25
25
|
import type { TurnActivity } from "../core/activity";
|
|
26
26
|
|
|
27
27
|
export interface TurnToolOptions {
|
|
28
|
-
|
|
28
|
+
/** 会话/工作区隔离键(适配器 + 平台用户 id) */
|
|
29
|
+
userId: string;
|
|
30
|
+
/** 平台原始用户 id,用于发送消息 */
|
|
31
|
+
sendUserId: string;
|
|
29
32
|
bot: Bot | undefined;
|
|
30
33
|
runId: number;
|
|
31
34
|
reporter: BashReporter;
|
|
@@ -120,13 +123,13 @@ export function buildTurnTools(
|
|
|
120
123
|
createSendFileTool({
|
|
121
124
|
ctx: host.ctx,
|
|
122
125
|
bot: options.bot,
|
|
123
|
-
userId: options.
|
|
126
|
+
userId: options.sendUserId,
|
|
124
127
|
policy,
|
|
125
128
|
}),
|
|
126
129
|
createSendImageTool({
|
|
127
130
|
ctx: host.ctx,
|
|
128
131
|
bot: options.bot,
|
|
129
|
-
userId: options.
|
|
132
|
+
userId: options.sendUserId,
|
|
130
133
|
policy,
|
|
131
134
|
}),
|
|
132
135
|
];
|
|
@@ -175,6 +178,7 @@ export function buildTurnTools(
|
|
|
175
178
|
createTodoTool({
|
|
176
179
|
host,
|
|
177
180
|
userId: options.userId,
|
|
181
|
+
sendUserId: options.sendUserId,
|
|
178
182
|
bot: options.bot,
|
|
179
183
|
quiet: isQuietMode(policy.level),
|
|
180
184
|
}),
|
package/tools/perm.ts
CHANGED
|
@@ -19,12 +19,16 @@ export function normalizePermissionLevel(value: unknown): AgentPermissionLevel {
|
|
|
19
19
|
return "workspace-write";
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** 作用域键里可能含 `:`(适配器前缀),统一替换成目录安全字符 */
|
|
23
|
+
const sanitizeSegment = (value: string): string =>
|
|
24
|
+
String(value ?? "").replace(/[^A-Za-z0-9._-]+/g, "_") || "unknown";
|
|
25
|
+
|
|
22
26
|
export interface FsPolicy {
|
|
23
27
|
level: AgentPermissionLevel;
|
|
24
28
|
workspaceRoot: string;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
export function workspaceRootFor(baseDir: string, userId:
|
|
31
|
+
export function workspaceRootFor(baseDir: string, userId: string): string {
|
|
28
32
|
const raw = String(baseDir ?? "").trim();
|
|
29
33
|
const base =
|
|
30
34
|
raw && path.isAbsolute(raw)
|
|
@@ -33,7 +37,7 @@ export function workspaceRootFor(baseDir: string, userId: number): string {
|
|
|
33
37
|
process.cwd(),
|
|
34
38
|
raw || path.join("data", "agent", "workspace"),
|
|
35
39
|
);
|
|
36
|
-
return path.resolve(base,
|
|
40
|
+
return path.resolve(base, sanitizeSegment(userId));
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
export function resolveWorkspacePath(policy: FsPolicy, target: string): string {
|
package/tools/todo.ts
CHANGED
|
@@ -38,12 +38,15 @@ export function formatPlanText(items: SessionPlanItem[]): string {
|
|
|
38
38
|
|
|
39
39
|
export function createTodoTool(options: {
|
|
40
40
|
host: AgentHost;
|
|
41
|
-
|
|
41
|
+
/** 会话隔离键 */
|
|
42
|
+
userId: string;
|
|
43
|
+
/** 平台原始用户 id,用于推送 */
|
|
44
|
+
sendUserId: string;
|
|
42
45
|
bot: Bot | undefined;
|
|
43
46
|
/** yolo 模式:不推送清单,用户只看最终回复 */
|
|
44
47
|
quiet?: boolean;
|
|
45
48
|
}): AITool {
|
|
46
|
-
const { host, userId, bot, quiet } = options;
|
|
49
|
+
const { host, userId, sendUserId, bot, quiet } = options;
|
|
47
50
|
return {
|
|
48
51
|
name: "todo_write",
|
|
49
52
|
description: DESCRIPTION,
|
|
@@ -114,7 +117,7 @@ export function createTodoTool(options: {
|
|
|
114
117
|
if (bot && !quiet) {
|
|
115
118
|
await bot
|
|
116
119
|
.sendMessage(
|
|
117
|
-
{ type: "private", user_id:
|
|
120
|
+
{ type: "private", user_id: sendUserId },
|
|
118
121
|
[host.ctx.segment.text(formatPlanText(items))],
|
|
119
122
|
)
|
|
120
123
|
.catch((err) =>
|
package/types.ts
CHANGED
|
@@ -19,7 +19,7 @@ export type AgentPermissionLevel =
|
|
|
19
19
|
|
|
20
20
|
export interface AgentAccessConfig {
|
|
21
21
|
allowAdmins: boolean;
|
|
22
|
-
users:
|
|
22
|
+
users: string[];
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export interface AgentBaseConfig {
|
|
@@ -104,8 +104,8 @@ export interface AgentHost {
|
|
|
104
104
|
getSettings(): AgentSettingsConfig;
|
|
105
105
|
getChatShared(): Promise<ChatSharedConfig>;
|
|
106
106
|
resolveModel(): ResolvedModel | null;
|
|
107
|
-
workspaceRoot(userId:
|
|
108
|
-
isAllowed(userId:
|
|
107
|
+
workspaceRoot(userId: string): string;
|
|
108
|
+
isAllowed(userId: string): Promise<boolean>;
|
|
109
109
|
updateBase(patch: Partial<AgentBaseConfig>): Promise<void>;
|
|
110
110
|
logger: MiokuContext["logger"];
|
|
111
111
|
}
|