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 CHANGED
@@ -12,11 +12,11 @@
12
12
  - `auto` 自动:文件与命令直接执行,但**每条命令先由工作模型审查**,危险操作(删除用户文件、清库、`git push`、sudo 等)转用户审批;执行通知与审批请求都即时推送
13
13
  - `full` 完全访问:不审批,命令与写入/编辑汇总成一条转发记录
14
14
  - `yolo` 静默:权限最高且**不推送任何中间通知**,用户只收到最终结果
15
- - **个人工作区**:每个 QQ 号独立工作区,默认 `data/agent/workspace/<qq号>`
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://路径]`,图片额外作为图片内容附加给模型。QQ 文件消息只有 `file_id` 时按平台 API(`get_file` / `get_group_file_url` / `get_private_file_url`)换取下载地址与原始文件名
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
- number,
13
+ string,
13
14
  { generations: number[]; at: number }
14
15
  >();
15
- const pendingClear = new Map<number, number>();
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<number> {
25
- const userId = Number(event.user_id || event.sender?.user_id || 0);
26
- if (!userId) {
27
- await reply(event, "agent 命令需要在 QQ 私聊中使用");
25
+ ): Promise<string> {
26
+ const identity = identityOf(event);
27
+ if (!identity.userId) {
28
+ await reply(event, "agent 命令需要在私聊中使用");
28
29
  }
29
- return userId;
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: number): number[] {
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: number): void {
71
+ function invalidateResumeCache(userId: string): void {
71
72
  resumeListCache.delete(userId);
72
73
  }
73
74
 
@@ -34,7 +34,7 @@ export interface CompactionResult {
34
34
 
35
35
  export async function maybeCompact(
36
36
  host: AgentHost,
37
- userId: number,
37
+ userId: string,
38
38
  options: { force?: boolean } = {},
39
39
  ): Promise<CompactionResult> {
40
40
  const settings = host.getSettings();
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
- const sources: string[] = [];
217
- const names: string[] = [];
218
- if (!bot || !item.fileId) return { sources, names };
219
-
220
- const attempts: Array<[string, Record<string, unknown>]> = [
221
- ["get_file", { file_id: item.fileId }],
222
- ];
223
- if (item.groupId) {
224
- attempts.push([
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 platform = await platformLookup(bot, item);
282
- const candidates = [...candidateSources(item), ...platform.sources];
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: platform.names[0] ?? result.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: number, emotion: string) => void,
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: number,
39
+ userId: string,
40
40
  emotion: unknown,
41
41
  config: ChatEmotionConfig | null,
42
42
  ): string {
@@ -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<number, UserQueue>();
47
+ const queues = new Map<string, UserQueue>();
46
48
 
47
- function getQueue(userId: number): UserQueue {
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: number,
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: number,
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, userId, queue, next);
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 userId = Number(event.user_id || event.sender?.user_id || 0);
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, userId)
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, userId, queue, input);
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
- userId: number,
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, userId, next, queue, controller.signal);
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, userId, queue);
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
- userId: number,
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(userId),
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: number,
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
- userId: number,
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, userId, "AI 服务不可用,请先在 WebUI 配置模型");
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: userId }, [
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
- userId,
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: Number(event?.self_id) || undefined,
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, userId, event);
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, userId, event);
495
+ await flushActivity(host, activity, bot, sendUserId, event);
487
496
  await replyError(
488
497
  host,
489
498
  bot,
490
- userId,
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: number,
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
- event.self_id || host.ctx.bot?.bot_id || bot.bot_id || userId,
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: number,
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
+ }
package/core/send.ts CHANGED
@@ -36,7 +36,7 @@ export class TurnSender {
36
36
  constructor(
37
37
  private host: AgentHost,
38
38
  private bot: Bot | undefined,
39
- private userId: number,
39
+ private userId: string,
40
40
  private enableScreenshot: boolean,
41
41
  ) {}
42
42