chattercatcher 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3,12 +3,12 @@
3
3
  // src/cli.ts
4
4
  import { input, password, select, confirm, number } from "@inquirer/prompts";
5
5
  import { Command } from "commander";
6
- import fs13 from "fs/promises";
6
+ import fs14 from "fs/promises";
7
7
 
8
8
  // package.json
9
9
  var package_default = {
10
10
  name: "chattercatcher",
11
- version: "0.1.16",
11
+ version: "0.1.18",
12
12
  description: "\u672C\u5730\u4F18\u5148\u7684\u98DE\u4E66/Lark \u5BB6\u5EAD\u7FA4\u77E5\u8BC6\u5E93\u673A\u5668\u4EBA",
13
13
  type: "module",
14
14
  main: "dist/index.js",
@@ -102,6 +102,13 @@ var appConfigSchema = z.object({
102
102
  model: z.string().default(""),
103
103
  dimension: z.number().int().positive().nullable().default(null)
104
104
  }),
105
+ multimodal: z.preprocess(
106
+ (value) => value ?? {},
107
+ z.object({
108
+ baseUrl: z.string().url().or(z.literal("")).default(""),
109
+ model: z.string().default("")
110
+ })
111
+ ),
105
112
  storage: z.object({
106
113
  dataDir: z.string().default(defaultDataDir)
107
114
  }),
@@ -126,13 +133,20 @@ var appSecretsSchema = z.object({
126
133
  }),
127
134
  embedding: z.object({
128
135
  apiKey: z.string().default("")
129
- })
136
+ }),
137
+ multimodal: z.preprocess(
138
+ (value) => value ?? {},
139
+ z.object({
140
+ apiKey: z.string().default("")
141
+ })
142
+ )
130
143
  });
131
144
  function createDefaultConfig() {
132
145
  return appConfigSchema.parse({
133
146
  feishu: {},
134
147
  llm: {},
135
148
  embedding: {},
149
+ multimodal: {},
136
150
  storage: {},
137
151
  web: {},
138
152
  schedules: {},
@@ -143,7 +157,8 @@ function createDefaultSecrets() {
143
157
  return appSecretsSchema.parse({
144
158
  feishu: {},
145
159
  llm: {},
146
- embedding: {}
160
+ embedding: {},
161
+ multimodal: {}
147
162
  });
148
163
  }
149
164
 
@@ -478,6 +493,24 @@ function migrateDatabase(database) {
478
493
  created_at TEXT NOT NULL,
479
494
  updated_at TEXT NOT NULL
480
495
  );
496
+
497
+ CREATE TABLE IF NOT EXISTS image_multimodal_tasks (
498
+ id TEXT PRIMARY KEY,
499
+ source_message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
500
+ platform_message_id TEXT NOT NULL,
501
+ image_key TEXT NOT NULL,
502
+ stored_path TEXT NOT NULL,
503
+ mime_type TEXT NOT NULL,
504
+ status TEXT NOT NULL CHECK(status IN ('pending','running','succeeded','skipped','failed')),
505
+ attempts INTEGER NOT NULL DEFAULT 0,
506
+ last_error TEXT,
507
+ derived_message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
508
+ created_at TEXT NOT NULL,
509
+ updated_at TEXT NOT NULL,
510
+ UNIQUE(source_message_id, image_key)
511
+ );
512
+
513
+ CREATE INDEX IF NOT EXISTS image_multimodal_tasks_status_idx ON image_multimodal_tasks(status, updated_at);
481
514
  `);
482
515
  }
483
516
 
@@ -894,6 +927,40 @@ function getGatewayStatus(config, secrets) {
894
927
  function normalizeBaseUrl(baseUrl) {
895
928
  return baseUrl.replace(/\/+$/, "");
896
929
  }
930
+ function toOpenAIMessage(message) {
931
+ return {
932
+ role: message.role,
933
+ content: message.content,
934
+ ...message.toolCallId ? { tool_call_id: message.toolCallId } : {},
935
+ ...message.toolCalls ? {
936
+ tool_calls: message.toolCalls.map((toolCall) => ({
937
+ id: toolCall.id,
938
+ type: "function",
939
+ function: {
940
+ name: toolCall.name,
941
+ arguments: JSON.stringify(toolCall.input)
942
+ }
943
+ }))
944
+ } : {}
945
+ };
946
+ }
947
+ function toOpenAITool(tool) {
948
+ return {
949
+ type: "function",
950
+ function: {
951
+ name: tool.name,
952
+ description: tool.description,
953
+ parameters: tool.inputSchema
954
+ }
955
+ };
956
+ }
957
+ function parseToolCalls(message) {
958
+ return message?.tool_calls?.map((toolCall) => ({
959
+ id: toolCall.id,
960
+ name: toolCall.function.name,
961
+ input: JSON.parse(toolCall.function.arguments)
962
+ })) ?? [];
963
+ }
897
964
  var OpenAICompatibleChatModel = class {
898
965
  constructor(options) {
899
966
  this.options = options;
@@ -911,7 +978,7 @@ var OpenAICompatibleChatModel = class {
911
978
  },
912
979
  body: JSON.stringify({
913
980
  model: this.options.model,
914
- messages,
981
+ messages: messages.map(toOpenAIMessage),
915
982
  temperature: this.options.temperature ?? 0.2
916
983
  })
917
984
  });
@@ -926,6 +993,35 @@ var OpenAICompatibleChatModel = class {
926
993
  }
927
994
  return content;
928
995
  }
996
+ async completeWithTools(messages, tools) {
997
+ if (!this.options.baseUrl || !this.options.apiKey || !this.options.model) {
998
+ throw new Error("LLM \u914D\u7F6E\u4E0D\u5B8C\u6574\u3002\u8BF7\u8FD0\u884C chattercatcher setup \u6216 chattercatcher settings\u3002");
999
+ }
1000
+ const response = await fetch(`${normalizeBaseUrl(this.options.baseUrl)}/chat/completions`, {
1001
+ method: "POST",
1002
+ headers: {
1003
+ authorization: `Bearer ${this.options.apiKey}`,
1004
+ "content-type": "application/json"
1005
+ },
1006
+ body: JSON.stringify({
1007
+ model: this.options.model,
1008
+ messages: messages.map(toOpenAIMessage),
1009
+ tools: tools.map(toOpenAITool),
1010
+ tool_choice: "auto",
1011
+ temperature: this.options.temperature ?? 0.2
1012
+ })
1013
+ });
1014
+ if (!response.ok) {
1015
+ const body = await response.text();
1016
+ throw new Error(`LLM \u8BF7\u6C42\u5931\u8D25\uFF1A${response.status} ${body}`);
1017
+ }
1018
+ const data2 = await response.json();
1019
+ const message = data2.choices?.[0]?.message;
1020
+ return {
1021
+ content: message?.content ?? "",
1022
+ toolCalls: parseToolCalls(message)
1023
+ };
1024
+ }
929
1025
  };
930
1026
  var OpenAICompatibleEmbeddingModel = class {
931
1027
  constructor(options) {
@@ -1081,6 +1177,7 @@ var MessageRepository = class {
1081
1177
  )
1082
1178
  ON CONFLICT(platform, platform_message_id)
1083
1179
  DO UPDATE SET
1180
+ message_type = excluded.message_type,
1084
1181
  text = excluded.text,
1085
1182
  raw_payload_json = excluded.raw_payload_json,
1086
1183
  received_at = excluded.received_at
@@ -1125,6 +1222,48 @@ var MessageRepository = class {
1125
1222
  transaction();
1126
1223
  return messageId;
1127
1224
  }
1225
+ createImageSummaryMessage(input2) {
1226
+ const source = this.database.prepare(
1227
+ `
1228
+ SELECT
1229
+ m.platform AS platform,
1230
+ m.platform_message_id AS platformMessageId,
1231
+ m.chat_id AS chatId,
1232
+ m.sender_id AS senderId,
1233
+ m.sender_name AS senderName,
1234
+ m.sent_at AS sentAt,
1235
+ c.platform_chat_id AS platformChatId,
1236
+ c.name AS chatName
1237
+ FROM messages m
1238
+ JOIN chats c ON c.id = m.chat_id
1239
+ WHERE m.id = ?
1240
+ `
1241
+ ).get(input2.sourceMessageId);
1242
+ if (!source) {
1243
+ throw new Error("\u539F\u59CB\u56FE\u7247\u6D88\u606F\u4E0D\u5B58\u5728\u3002");
1244
+ }
1245
+ const derivedPlatformMessageId = `${source.platformMessageId}:image-summary:${input2.imageKey}`;
1246
+ return this.ingest({
1247
+ platform: source.platform,
1248
+ platformChatId: source.platformChatId,
1249
+ chatName: source.chatName,
1250
+ platformMessageId: derivedPlatformMessageId,
1251
+ senderId: source.senderId,
1252
+ senderName: source.senderName,
1253
+ messageType: "image_summary",
1254
+ text: `[\u56FE\u7247\u8F6C\u8FF0] ${input2.summary.trim()}`,
1255
+ sentAt: source.sentAt,
1256
+ rawPayload: {
1257
+ derivedFromMessageId: input2.sourceMessageId,
1258
+ sourceAttachmentKind: "image",
1259
+ sourceResourceKey: input2.imageKey,
1260
+ multimodalModel: input2.multimodalModel,
1261
+ isMeaningful: true,
1262
+ ...input2.reason?.trim() ? { reason: input2.reason.trim() } : {},
1263
+ generatedAt: input2.generatedAt
1264
+ }
1265
+ });
1266
+ }
1128
1267
  listRecentMessages(limit = 20) {
1129
1268
  return this.database.prepare(
1130
1269
  `
@@ -1444,6 +1583,69 @@ var EpisodeRepository = class {
1444
1583
  messageIds: window.messages.map((message) => message.id)
1445
1584
  };
1446
1585
  }
1586
+ async refreshWindowForMessage(input2) {
1587
+ const target = this.database.prepare(
1588
+ `
1589
+ SELECT chat_id AS chatId, sent_at AS sentAt
1590
+ FROM messages
1591
+ WHERE id = ?
1592
+ `
1593
+ ).get(input2.messageId);
1594
+ if (!target) {
1595
+ return void 0;
1596
+ }
1597
+ const existingWindow = this.database.prepare(
1598
+ `
1599
+ SELECT e.started_at AS startedAt, e.ended_at AS endedAt
1600
+ FROM messages target
1601
+ JOIN messages source
1602
+ ON source.id = json_extract(target.raw_payload_json, '$.derivedFromMessageId')
1603
+ JOIN memory_episode_messages mem ON mem.message_id = source.id
1604
+ JOIN memory_episodes e ON e.id = mem.episode_id
1605
+ WHERE target.id = ?
1606
+ LIMIT 1
1607
+ `
1608
+ ).get(input2.messageId);
1609
+ if (!existingWindow) {
1610
+ return void 0;
1611
+ }
1612
+ const messageTime = toMillis(target.sentAt);
1613
+ const windowStart = toMillis(existingWindow.startedAt);
1614
+ const windowEnd = Math.max(toMillis(existingWindow.endedAt), messageTime);
1615
+ const rows = this.database.prepare(
1616
+ `
1617
+ SELECT
1618
+ m.id,
1619
+ m.chat_id AS chatId,
1620
+ c.name AS chatName,
1621
+ m.sender_name AS senderName,
1622
+ m.text,
1623
+ m.sent_at AS sentAt
1624
+ FROM messages m
1625
+ JOIN chats c ON c.id = m.chat_id
1626
+ WHERE m.chat_id = ?
1627
+ ORDER BY m.sent_at ASC
1628
+ `
1629
+ ).all(target.chatId);
1630
+ const windowMessages = rows.filter((message) => {
1631
+ const time = toMillis(message.sentAt);
1632
+ return time >= windowStart && time <= windowEnd;
1633
+ });
1634
+ const first = windowMessages[0];
1635
+ const last = windowMessages.at(-1);
1636
+ if (!first || !last) {
1637
+ return void 0;
1638
+ }
1639
+ const window = {
1640
+ chatId: first.chatId,
1641
+ chatName: first.chatName,
1642
+ startedAt: first.sentAt,
1643
+ endedAt: last.sentAt,
1644
+ messages: windowMessages
1645
+ };
1646
+ const summary = await input2.summarize(window);
1647
+ return this.insertEpisode(window, summary);
1648
+ }
1447
1649
  getEpisodeCount() {
1448
1650
  const row = this.database.prepare("SELECT count(*) AS count FROM memory_episodes").get();
1449
1651
  return row.count;
@@ -1612,6 +1814,73 @@ var MessageFtsRetriever = class {
1612
1814
  }
1613
1815
  };
1614
1816
 
1817
+ // src/rag/search-tools.ts
1818
+ var searchInputSchema = {
1819
+ type: "object",
1820
+ properties: {
1821
+ query: { type: "string", description: "Search query written by the model." },
1822
+ limit: { type: "number", description: "Maximum number of evidence blocks to return." }
1823
+ },
1824
+ required: ["query"],
1825
+ additionalProperties: false
1826
+ };
1827
+ function parseSearchInput(input2) {
1828
+ const rawQuery = typeof input2 === "object" && input2 !== null && "query" in input2 ? input2.query : void 0;
1829
+ if (typeof rawQuery !== "string") {
1830
+ throw new Error("\u641C\u7D22 query \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\u3002");
1831
+ }
1832
+ const query = rawQuery.trim();
1833
+ if (!query) {
1834
+ throw new Error("\u641C\u7D22 query \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\u3002");
1835
+ }
1836
+ const rawLimit = typeof input2 === "object" && input2 !== null && "limit" in input2 ? input2.limit : void 0;
1837
+ const numericLimit = typeof rawLimit === "number" && Number.isFinite(rawLimit) ? rawLimit : 5;
1838
+ const limit = Math.min(12, Math.max(1, Math.floor(numericLimit)));
1839
+ return { query, limit };
1840
+ }
1841
+ async function runRetriever(retriever, input2) {
1842
+ const { query, limit } = parseSearchInput(input2);
1843
+ const results = await retriever.retrieve(query);
1844
+ return results.slice(0, limit);
1845
+ }
1846
+ function createSearchTool(name, description, retriever) {
1847
+ return {
1848
+ name,
1849
+ description,
1850
+ inputSchema: searchInputSchema,
1851
+ execute: (input2) => runRetriever(retriever, input2)
1852
+ };
1853
+ }
1854
+ function createRagSearchTools(input2) {
1855
+ const tools = [
1856
+ createSearchTool(
1857
+ "hybrid_search",
1858
+ "Search across all indexed RAG evidence using the default hybrid retrieval strategy.",
1859
+ input2.hybrid
1860
+ ),
1861
+ createSearchTool(
1862
+ "search_messages",
1863
+ "Search chat messages only when the answer likely depends on message-level evidence.",
1864
+ input2.messages
1865
+ ),
1866
+ createSearchTool(
1867
+ "search_episodes",
1868
+ "Search episode summaries only when the answer likely depends on longer-running context.",
1869
+ input2.episodes
1870
+ )
1871
+ ];
1872
+ if (input2.semantic) {
1873
+ tools.push(
1874
+ createSearchTool(
1875
+ "semantic_search",
1876
+ "Search semantic vector evidence only when broader conceptual recall is needed.",
1877
+ input2.semantic
1878
+ )
1879
+ );
1880
+ }
1881
+ return tools;
1882
+ }
1883
+
1615
1884
  // src/rag/embedding.ts
1616
1885
  function cosineSimilarity(left, right) {
1617
1886
  if (left.length === 0 || right.length === 0 || left.length !== right.length) {
@@ -1766,6 +2035,20 @@ async function createHybridRetriever(input2) {
1766
2035
  }
1767
2036
  };
1768
2037
  }
2038
+ async function createAgenticRagSearchTools(input2) {
2039
+ const episodes = new EpisodeFtsRetriever(new EpisodeRepository(input2.database));
2040
+ const messages = new MessageFtsRetriever(input2.messages, { excludeMessageIds: input2.excludeMessageIds });
2041
+ const semantic = hasEmbeddingConfig(input2.config, input2.secrets) ? new VectorRetriever(
2042
+ createEmbeddingModel(input2.config, input2.secrets),
2043
+ new SqliteVectorStore(input2.database, { model: input2.config.embedding.model })
2044
+ ) : void 0;
2045
+ const hybrid = new HybridRetriever(semantic ? [episodes, messages, semantic] : [episodes, messages]);
2046
+ return {
2047
+ tools: createRagSearchTools({ hybrid, messages, episodes, semantic }),
2048
+ close: () => {
2049
+ }
2050
+ };
2051
+ }
1769
2052
 
1770
2053
  // src/doctor/checks.ts
1771
2054
  function pass(name, message) {
@@ -2334,6 +2617,268 @@ async function ensureFeishuBotOpenId(config, secrets, options = {}) {
2334
2617
  // src/feishu/gateway.ts
2335
2618
  import * as lark2 from "@larksuiteoapi/node-sdk";
2336
2619
 
2620
+ // src/multimodal/tasks.ts
2621
+ import crypto4 from "crypto";
2622
+ function nowIso4() {
2623
+ return (/* @__PURE__ */ new Date()).toISOString();
2624
+ }
2625
+ function stableId3(sourceMessageId, imageKey) {
2626
+ return crypto4.createHash("sha256").update(`${sourceMessageId}${imageKey}`).digest("hex").slice(0, 32);
2627
+ }
2628
+ function mapRow(row) {
2629
+ if (!row) {
2630
+ return void 0;
2631
+ }
2632
+ return {
2633
+ id: row.id,
2634
+ sourceMessageId: row.source_message_id,
2635
+ platformMessageId: row.platform_message_id,
2636
+ imageKey: row.image_key,
2637
+ storedPath: row.stored_path,
2638
+ mimeType: row.mime_type,
2639
+ status: row.status,
2640
+ attempts: row.attempts,
2641
+ ...row.last_error ? { lastError: row.last_error } : {},
2642
+ ...row.derived_message_id ? { derivedMessageId: row.derived_message_id } : {},
2643
+ createdAt: row.created_at,
2644
+ updatedAt: row.updated_at
2645
+ };
2646
+ }
2647
+ var ImageMultimodalTaskRepository = class {
2648
+ constructor(database) {
2649
+ this.database = database;
2650
+ }
2651
+ database;
2652
+ enqueue(input2) {
2653
+ const id = stableId3(input2.sourceMessageId, input2.imageKey);
2654
+ const timestamp = nowIso4();
2655
+ this.database.prepare(
2656
+ `
2657
+ INSERT INTO image_multimodal_tasks (
2658
+ id,
2659
+ source_message_id,
2660
+ platform_message_id,
2661
+ image_key,
2662
+ stored_path,
2663
+ mime_type,
2664
+ status,
2665
+ attempts,
2666
+ created_at,
2667
+ updated_at
2668
+ )
2669
+ VALUES (
2670
+ @id,
2671
+ @sourceMessageId,
2672
+ @platformMessageId,
2673
+ @imageKey,
2674
+ @storedPath,
2675
+ @mimeType,
2676
+ 'pending',
2677
+ 0,
2678
+ @createdAt,
2679
+ @updatedAt
2680
+ )
2681
+ ON CONFLICT(source_message_id, image_key)
2682
+ DO UPDATE SET
2683
+ platform_message_id = excluded.platform_message_id,
2684
+ stored_path = excluded.stored_path,
2685
+ mime_type = excluded.mime_type,
2686
+ status = 'pending',
2687
+ attempts = 0,
2688
+ last_error = NULL,
2689
+ derived_message_id = NULL,
2690
+ updated_at = excluded.updated_at
2691
+ `
2692
+ ).run({
2693
+ id,
2694
+ sourceMessageId: input2.sourceMessageId,
2695
+ platformMessageId: input2.platformMessageId,
2696
+ imageKey: input2.imageKey,
2697
+ storedPath: input2.storedPath,
2698
+ mimeType: input2.mimeType,
2699
+ createdAt: timestamp,
2700
+ updatedAt: timestamp
2701
+ });
2702
+ const record = this.getById(id);
2703
+ if (!record) {
2704
+ throw new Error(`\u56FE\u7247\u591A\u6A21\u6001\u4EFB\u52A1\u5199\u5165\u5931\u8D25\uFF1A${id}`);
2705
+ }
2706
+ return record;
2707
+ }
2708
+ listPending(limit = 10) {
2709
+ const rows = this.database.prepare(
2710
+ `
2711
+ SELECT
2712
+ id,
2713
+ source_message_id,
2714
+ platform_message_id,
2715
+ image_key,
2716
+ stored_path,
2717
+ mime_type,
2718
+ status,
2719
+ attempts,
2720
+ last_error,
2721
+ derived_message_id,
2722
+ created_at,
2723
+ updated_at
2724
+ FROM image_multimodal_tasks
2725
+ WHERE status = 'pending'
2726
+ ORDER BY updated_at ASC
2727
+ LIMIT ?
2728
+ `
2729
+ ).all(limit);
2730
+ return rows.map((row) => mapRow(row)).filter((row) => Boolean(row));
2731
+ }
2732
+ markRunning(id) {
2733
+ const result = this.database.prepare(
2734
+ `
2735
+ UPDATE image_multimodal_tasks
2736
+ SET status = 'running',
2737
+ attempts = attempts + 1,
2738
+ last_error = NULL,
2739
+ updated_at = @updatedAt
2740
+ WHERE id = @id AND status = 'pending'
2741
+ `
2742
+ ).run({ id, updatedAt: nowIso4() });
2743
+ if (result.changes === 0) {
2744
+ throw new Error(`\u56FE\u7247\u591A\u6A21\u6001\u4EFB\u52A1\u72B6\u6001\u65E0\u6CD5\u66F4\u65B0\uFF1A${id}`);
2745
+ }
2746
+ return this.requireById(id);
2747
+ }
2748
+ markSucceeded(id, derivedMessageId) {
2749
+ this.database.prepare(
2750
+ `
2751
+ UPDATE image_multimodal_tasks
2752
+ SET status = 'succeeded',
2753
+ last_error = NULL,
2754
+ derived_message_id = @derivedMessageId,
2755
+ updated_at = @updatedAt
2756
+ WHERE id = @id
2757
+ `
2758
+ ).run({ id, derivedMessageId, updatedAt: nowIso4() });
2759
+ return this.requireById(id);
2760
+ }
2761
+ markSkipped(id, reason) {
2762
+ this.database.prepare(
2763
+ `
2764
+ UPDATE image_multimodal_tasks
2765
+ SET status = 'skipped',
2766
+ last_error = @reason,
2767
+ derived_message_id = NULL,
2768
+ updated_at = @updatedAt
2769
+ WHERE id = @id
2770
+ `
2771
+ ).run({ id, reason, updatedAt: nowIso4() });
2772
+ return this.requireById(id);
2773
+ }
2774
+ markFailed(id, error, finalFailure) {
2775
+ this.database.prepare(
2776
+ `
2777
+ UPDATE image_multimodal_tasks
2778
+ SET status = @status,
2779
+ last_error = @error,
2780
+ derived_message_id = NULL,
2781
+ updated_at = @updatedAt
2782
+ WHERE id = @id
2783
+ `
2784
+ ).run({ id, status: finalFailure ? "failed" : "pending", error, updatedAt: nowIso4() });
2785
+ return this.requireById(id);
2786
+ }
2787
+ getById(id) {
2788
+ const row = this.database.prepare(
2789
+ `
2790
+ SELECT
2791
+ id,
2792
+ source_message_id,
2793
+ platform_message_id,
2794
+ image_key,
2795
+ stored_path,
2796
+ mime_type,
2797
+ status,
2798
+ attempts,
2799
+ last_error,
2800
+ derived_message_id,
2801
+ created_at,
2802
+ updated_at
2803
+ FROM image_multimodal_tasks
2804
+ WHERE id = ?
2805
+ `
2806
+ ).get(id);
2807
+ return mapRow(row);
2808
+ }
2809
+ requireById(id) {
2810
+ const record = this.getById(id);
2811
+ if (!record) {
2812
+ throw new Error(`\u56FE\u7247\u591A\u6A21\u6001\u4EFB\u52A1\u4E0D\u5B58\u5728\uFF1A${id}`);
2813
+ }
2814
+ return record;
2815
+ }
2816
+ };
2817
+
2818
+ // src/multimodal/worker.ts
2819
+ var ImageMultimodalWorker = class {
2820
+ constructor(options) {
2821
+ this.options = options;
2822
+ }
2823
+ options;
2824
+ async processPending(limit = 10) {
2825
+ const result = { processed: 0, succeeded: 0, skipped: 0, failed: 0 };
2826
+ const pending = this.options.tasks.listPending(limit);
2827
+ for (const task of pending) {
2828
+ result.processed += 1;
2829
+ await this.processTask(task, result);
2830
+ }
2831
+ return result;
2832
+ }
2833
+ async processTask(task, result) {
2834
+ let running;
2835
+ try {
2836
+ running = this.options.tasks.markRunning(task.id);
2837
+ } catch (error) {
2838
+ const message = error instanceof Error ? error.message : String(error);
2839
+ if (message.startsWith("\u56FE\u7247\u591A\u6A21\u6001\u4EFB\u52A1\u72B6\u6001\u65E0\u6CD5\u66F4\u65B0\uFF1A")) {
2840
+ return;
2841
+ }
2842
+ throw error;
2843
+ }
2844
+ try {
2845
+ const described = await this.options.model.describeImage({
2846
+ imagePath: running.storedPath,
2847
+ mimeType: running.mimeType
2848
+ });
2849
+ if (!described.isMeaningful) {
2850
+ this.options.tasks.markSkipped(running.id, described.reason || "\u591A\u6A21\u6001\u6A21\u578B\u5224\u5B9A\u56FE\u7247\u65E0\u610F\u4E49\u3002");
2851
+ result.skipped += 1;
2852
+ return;
2853
+ }
2854
+ const derivedMessageId = this.options.messages.createImageSummaryMessage({
2855
+ sourceMessageId: running.sourceMessageId,
2856
+ imageKey: running.imageKey,
2857
+ summary: described.summary,
2858
+ reason: described.reason,
2859
+ multimodalModel: this.options.multimodalModelName,
2860
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
2861
+ });
2862
+ if (this.options.vectorIndexMessage) {
2863
+ await this.options.vectorIndexMessage(derivedMessageId);
2864
+ }
2865
+ if (this.options.episodes && this.options.summarizeEpisode) {
2866
+ await this.options.episodes.refreshWindowForMessage({
2867
+ messageId: derivedMessageId,
2868
+ windowMs: this.options.config.episodes.windowMinutes * 60 * 1e3,
2869
+ summarize: this.options.summarizeEpisode
2870
+ });
2871
+ }
2872
+ this.options.tasks.markSucceeded(running.id, derivedMessageId);
2873
+ result.succeeded += 1;
2874
+ } catch (error) {
2875
+ const message = error instanceof Error ? error.message : String(error);
2876
+ this.options.tasks.markFailed(running.id, message, running.attempts >= 3);
2877
+ result.failed += 1;
2878
+ }
2879
+ }
2880
+ };
2881
+
2337
2882
  // src/rag/citations.ts
2338
2883
  function isOpaqueId(value) {
2339
2884
  return Boolean(value && /^(ou|oc|om|cli|on|un|uid)_?[a-z0-9]+/i.test(value));
@@ -2454,12 +2999,99 @@ async function generateGroundedAnswer(input2) {
2454
2999
  };
2455
3000
  }
2456
3001
 
2457
- // src/rag/qa-service.ts
2458
- async function askWithRag(input2) {
2459
- const evidence = await input2.retriever.retrieve(input2.question);
3002
+ // src/rag/agentic-qa-service.ts
3003
+ var DEFAULT_MAX_MODEL_TURNS = 4;
3004
+ var DEFAULT_MAX_TOOL_CALLS = 8;
3005
+ var DEFAULT_MAX_EVIDENCE = 12;
3006
+ var NO_EVIDENCE_ANSWER = "\u4E0D\u77E5\u9053\u3002\u5F53\u524D\u672C\u5730\u77E5\u8BC6\u5E93\u6CA1\u6709\u68C0\u7D22\u5230\u8DB3\u591F\u8BC1\u636E\u3002";
3007
+ var AGENTIC_SYSTEM_PROMPT = "\u4F60\u662F\u672C\u5730\u77E5\u8BC6\u4FE1\u606F\u6536\u96C6\u4EE3\u7406\u3002\u4F60\u7684\u804C\u8D23\u662F\u56F4\u7ED5\u7528\u6237\u95EE\u9898\u51B3\u5B9A\u662F\u5426\u8C03\u7528\u641C\u7D22\u5DE5\u5177\u3001\u9009\u62E9\u5408\u9002\u7684\u5DE5\u5177\u548C\u67E5\u8BE2\u8BCD\uFF0C\u5E76\u6839\u636E\u5F53\u524D\u7ED3\u679C\u51B3\u5B9A\u662F\u5426\u7EE7\u7EED\u641C\u7D22\u3002\u4E0D\u8981\u7F16\u9020\u4EFB\u4F55\u8BC1\u636E\u6216\u58F0\u79F0\u770B\u8FC7\u672A\u68C0\u7D22\u5230\u7684\u5185\u5BB9\u3002\u4F60\u7684\u8F93\u51FA\u53EA\u7528\u4E8E\u6536\u96C6\u8BC1\u636E\uFF0C\u6700\u7EC8\u7B54\u6848\u4F1A\u7531\u53E6\u4E00\u4E2A\u57FA\u4E8E\u8BC1\u636E\u7684\u6B65\u9AA4\u751F\u6210\u3002";
3008
+ function toToolResultContent(results) {
3009
+ return JSON.stringify(
3010
+ results.map((item) => ({
3011
+ id: item.id,
3012
+ text: item.text,
3013
+ score: item.score,
3014
+ source: item.source
3015
+ }))
3016
+ );
3017
+ }
3018
+ function toToolErrorContent(message) {
3019
+ return JSON.stringify({ error: message });
3020
+ }
3021
+ function dedupeEvidence(evidence, maxEvidence) {
3022
+ const deduped = [];
3023
+ const seen = /* @__PURE__ */ new Set();
3024
+ for (const item of evidence) {
3025
+ if (seen.has(item.id)) {
3026
+ continue;
3027
+ }
3028
+ seen.add(item.id);
3029
+ deduped.push(item);
3030
+ if (deduped.length >= maxEvidence) {
3031
+ break;
3032
+ }
3033
+ }
3034
+ return deduped;
3035
+ }
3036
+ async function askWithAgenticRag(input2) {
3037
+ if (!input2.model.completeWithTools) {
3038
+ throw new Error("\u5F53\u524D LLM \u5BA2\u6237\u7AEF\u4E0D\u652F\u6301\u5DE5\u5177\u8C03\u7528\u3002");
3039
+ }
3040
+ const maxModelTurns = input2.maxModelTurns ?? DEFAULT_MAX_MODEL_TURNS;
3041
+ const maxToolCalls = input2.maxToolCalls ?? DEFAULT_MAX_TOOL_CALLS;
3042
+ const maxEvidence = input2.maxEvidence ?? DEFAULT_MAX_EVIDENCE;
3043
+ const messages = [
3044
+ { role: "system", content: AGENTIC_SYSTEM_PROMPT },
3045
+ { role: "user", content: input2.question }
3046
+ ];
3047
+ const toolsByName = new Map(input2.tools.map((tool) => [tool.name, tool]));
3048
+ let evidence = [];
3049
+ let toolCallsUsed = 0;
3050
+ for (let turn = 0; turn < maxModelTurns; turn += 1) {
3051
+ const assistantResult = await input2.model.completeWithTools(messages, input2.tools);
3052
+ messages.push({
3053
+ role: "assistant",
3054
+ content: assistantResult.content,
3055
+ toolCalls: assistantResult.toolCalls
3056
+ });
3057
+ if (assistantResult.toolCalls.length === 0) {
3058
+ break;
3059
+ }
3060
+ for (const toolCall of assistantResult.toolCalls) {
3061
+ if (toolCallsUsed >= maxToolCalls) {
3062
+ break;
3063
+ }
3064
+ toolCallsUsed += 1;
3065
+ const tool = toolsByName.get(toolCall.name);
3066
+ if (!tool) {
3067
+ messages.push({
3068
+ role: "tool",
3069
+ toolCallId: toolCall.id,
3070
+ content: toToolErrorContent(`\u672A\u77E5\u5DE5\u5177\uFF1A${toolCall.name}`)
3071
+ });
3072
+ continue;
3073
+ }
3074
+ try {
3075
+ const results = await tool.execute(toolCall.input);
3076
+ evidence = dedupeEvidence([...evidence, ...results], maxEvidence);
3077
+ messages.push({
3078
+ role: "tool",
3079
+ toolCallId: toolCall.id,
3080
+ content: toToolResultContent(results)
3081
+ });
3082
+ } catch (error) {
3083
+ const message = error instanceof Error ? error.message : String(error);
3084
+ messages.push({
3085
+ role: "tool",
3086
+ toolCallId: toolCall.id,
3087
+ content: toToolErrorContent(message)
3088
+ });
3089
+ }
3090
+ }
3091
+ }
2460
3092
  if (evidence.length === 0) {
2461
3093
  return {
2462
- answer: "\u4E0D\u77E5\u9053\u3002\u5F53\u524D\u672C\u5730\u77E5\u8BC6\u5E93\u6CA1\u6709\u68C0\u7D22\u5230\u8DB3\u591F\u8BC1\u636E\u3002",
3094
+ answer: NO_EVIDENCE_ANSWER,
2463
3095
  citations: []
2464
3096
  };
2465
3097
  }
@@ -2577,7 +3209,7 @@ var FeishuQuestionHandler = class {
2577
3209
  }
2578
3210
  const questionMessageId = payload.event?.message?.message_id;
2579
3211
  await this.acknowledgeQuestion(decision.chatId, questionMessageId);
2580
- const { retriever, close } = await createHybridRetriever({
3212
+ const { tools, close } = await createAgenticRagSearchTools({
2581
3213
  config: this.options.config,
2582
3214
  secrets: this.options.secrets,
2583
3215
  database: this.options.database,
@@ -2586,9 +3218,9 @@ var FeishuQuestionHandler = class {
2586
3218
  });
2587
3219
  try {
2588
3220
  try {
2589
- const result = await askWithRag({
3221
+ const result = await askWithAgenticRag({
2590
3222
  question: decision.question,
2591
- retriever,
3223
+ tools,
2592
3224
  model: this.options.model
2593
3225
  });
2594
3226
  const citations = formatCitations(result.citations);
@@ -2724,6 +3356,7 @@ function createFeishuEventDispatcher(options) {
2724
3356
  payload,
2725
3357
  downloader: options.resourceDownloader,
2726
3358
  config: options.config,
3359
+ secrets: options.secrets,
2727
3360
  vectorIndexMessage: options.attachmentVectorIndexer
2728
3361
  }) : options.ingestor.ingestFeishuEvent(payload);
2729
3362
  if (!result.accepted) {
@@ -2749,6 +3382,23 @@ function createFeishuEventDispatcher(options) {
2749
3382
  }
2750
3383
  if (result.attachment?.downloaded) {
2751
3384
  console.log(`\u98DE\u4E66\u9644\u4EF6\u5DF2\u4E0B\u8F7D\uFF1A${result.attachment.downloaded.storedPath}`);
3385
+ if (options.imageMultimodalProcessor && result.attachment.imageTask) {
3386
+ void new ImageMultimodalWorker({
3387
+ config: options.config,
3388
+ messages: new MessageRepository(options.imageMultimodalProcessor.database),
3389
+ tasks: new ImageMultimodalTaskRepository(options.imageMultimodalProcessor.database),
3390
+ model: options.imageMultimodalProcessor.model,
3391
+ multimodalModelName: options.config.multimodal.model,
3392
+ vectorIndexMessage: options.attachmentVectorIndexer
3393
+ }).processPending().then((imageResult) => {
3394
+ console.log(
3395
+ `\u98DE\u4E66\u56FE\u7247\u591A\u6A21\u6001\u5904\u7406\u5B8C\u6210\uFF1Aprocessed=${imageResult.processed}, succeeded=${imageResult.succeeded}, skipped=${imageResult.skipped}, failed=${imageResult.failed}`
3396
+ );
3397
+ }).catch((error) => {
3398
+ const message = error instanceof Error ? error.message : String(error);
3399
+ console.error(`\u98DE\u4E66\u56FE\u7247\u591A\u6A21\u6001\u5904\u7406\u5931\u8D25\uFF1A${message}`);
3400
+ });
3401
+ }
2752
3402
  if (result.attachment.indexedMessageId) {
2753
3403
  console.log(`\u98DE\u4E66\u9644\u4EF6\u5DF2\u8FDB\u5165 RAG\uFF1A${result.attachment.indexedMessageId}`);
2754
3404
  if (result.attachment.vectorIndexed) {
@@ -2799,7 +3449,8 @@ function createFeishuGateway(options) {
2799
3449
  questionHandler: options.questionHandler,
2800
3450
  resourceDownloader: options.resourceDownloader,
2801
3451
  attachmentVectorIndexer: options.attachmentVectorIndexer,
2802
- episodeProcessor: options.episodeProcessor
3452
+ episodeProcessor: options.episodeProcessor,
3453
+ imageMultimodalProcessor: options.imageMultimodalProcessor
2803
3454
  });
2804
3455
  return {
2805
3456
  async start() {
@@ -2881,7 +3532,7 @@ var FeishuResourceDownloader = class _FeishuResourceDownloader {
2881
3532
  };
2882
3533
 
2883
3534
  // src/files/ingest.ts
2884
- import crypto4 from "crypto";
3535
+ import crypto5 from "crypto";
2885
3536
  import fs11 from "fs/promises";
2886
3537
  import path13 from "path";
2887
3538
 
@@ -2945,7 +3596,7 @@ function ensureSupportedTextFile(filePath) {
2945
3596
  }
2946
3597
  }
2947
3598
  function stableStoredName(sourcePath, fileName) {
2948
- const digest = crypto4.createHash("sha256").update(sourcePath).digest("hex").slice(0, 16);
3599
+ const digest = crypto5.createHash("sha256").update(sourcePath).digest("hex").slice(0, 16);
2949
3600
  return `${digest}-${fileName}`;
2950
3601
  }
2951
3602
  async function ingestLocalFile(input2) {
@@ -3183,12 +3834,17 @@ function extractAttachment(message) {
3183
3834
  }
3184
3835
  return candidate;
3185
3836
  }
3837
+ function isMultimodalReady(config, secrets) {
3838
+ return Boolean(config.multimodal.baseUrl && config.multimodal.model && secrets.multimodal.apiKey);
3839
+ }
3186
3840
  var GatewayIngestor = class {
3187
3841
  messages;
3188
3842
  jobs;
3843
+ imageTasks;
3189
3844
  constructor(database) {
3190
3845
  this.messages = new MessageRepository(database);
3191
3846
  this.jobs = new FileJobRepository(database);
3847
+ this.imageTasks = new ImageMultimodalTaskRepository(database);
3192
3848
  }
3193
3849
  ingestFeishuEvent(payload) {
3194
3850
  const normalized = normalizeFeishuReceiveMessageEvent(payload);
@@ -3220,6 +3876,23 @@ var GatewayIngestor = class {
3220
3876
  messageId: result.message.platformMessageId,
3221
3877
  attachment
3222
3878
  });
3879
+ if (attachment.kind === "image") {
3880
+ const imageTask = isMultimodalReady(input2.config, input2.secrets) ? this.imageTasks.enqueue({
3881
+ sourceMessageId: result.messageId,
3882
+ platformMessageId: result.message.platformMessageId,
3883
+ imageKey: attachment.fileKey,
3884
+ storedPath: downloaded.storedPath,
3885
+ mimeType: attachment.mimeType || "image/jpeg"
3886
+ }) : void 0;
3887
+ return {
3888
+ ...result,
3889
+ attachment: {
3890
+ downloaded,
3891
+ ...imageTask ? { imageTask } : {},
3892
+ skippedReason: imageTask ? "\u56FE\u7247\u5DF2\u4E0B\u8F7D\uFF0C\u7B49\u5F85\u591A\u6A21\u6001\u540E\u53F0\u5904\u7406\u3002" : "\u56FE\u7247\u5DF2\u4E0B\u8F7D\uFF0C\u4F46\u591A\u6A21\u6001\u672A\u914D\u7F6E\u3002"
3893
+ }
3894
+ };
3895
+ }
3223
3896
  if (!isSupportedTextFile(downloaded.storedPath)) {
3224
3897
  return {
3225
3898
  ...result,
@@ -3355,6 +4028,96 @@ async function startDetachedGateway(input2) {
3355
4028
  }
3356
4029
  }
3357
4030
 
4031
+ // src/multimodal/openai-compatible.ts
4032
+ import fs13 from "fs/promises";
4033
+ function normalizeBaseUrl2(baseUrl) {
4034
+ return baseUrl.replace(/\/+$/, "");
4035
+ }
4036
+ function buildPrompt(context) {
4037
+ const contextText = context?.trim();
4038
+ return [
4039
+ "\u8BF7\u7406\u89E3\u8FD9\u5F20\u56FE\u7247\uFF0C\u5224\u65AD\u5B83\u662F\u5426\u5305\u542B\u503C\u5F97\u8FDB\u5165\u77E5\u8BC6\u5E93\u548C\u4F1A\u8BDD\u8BB0\u5FC6\u7684\u6709\u610F\u4E49\u4FE1\u606F\u3002",
4040
+ '\u8BF7\u53EA\u8F93\u51FA JSON\uFF0C\u683C\u5F0F\u4E3A {"summary": string, "isMeaningful": boolean, "reason": string}\u3002',
4041
+ "summary \u4F7F\u7528\u7B80\u6D01\u4E2D\u6587\u8F6C\u8FF0\u56FE\u7247\u4E2D\u7684\u5173\u952E\u4FE1\u606F\uFF1B\u65E0\u610F\u4E49\u56FE\u7247\u4E5F\u8981\u7ED9\u51FA\u7B80\u77ED summary\u3002",
4042
+ contextText ? `\u4E0A\u4E0B\u6587\uFF1A${contextText}` : void 0
4043
+ ].filter(Boolean).join("\n");
4044
+ }
4045
+ function parseDescribeImageResult(content) {
4046
+ let data2;
4047
+ try {
4048
+ data2 = JSON.parse(content);
4049
+ } catch {
4050
+ throw new Error("\u591A\u6A21\u6001\u6A21\u578B\u8FD4\u56DE\u7684 JSON \u65E0\u6CD5\u89E3\u6790\u3002");
4051
+ }
4052
+ if (!data2 || typeof data2 !== "object") {
4053
+ throw new Error("\u591A\u6A21\u6001\u6A21\u578B\u8FD4\u56DE\u683C\u5F0F\u4E0D\u6B63\u786E\u3002");
4054
+ }
4055
+ const result = data2;
4056
+ const summary = typeof result.summary === "string" ? result.summary.trim() : "";
4057
+ if (!summary) {
4058
+ throw new Error("\u591A\u6A21\u6001\u6A21\u578B\u8FD4\u56DE\u7684 summary \u4E3A\u7A7A\u3002");
4059
+ }
4060
+ if (typeof result.isMeaningful !== "boolean") {
4061
+ throw new Error("\u591A\u6A21\u6001\u6A21\u578B\u8FD4\u56DE\u7684 isMeaningful \u4E0D\u662F\u5E03\u5C14\u503C\u3002");
4062
+ }
4063
+ const reason = typeof result.reason === "string" ? result.reason.trim() : "";
4064
+ return {
4065
+ summary,
4066
+ isMeaningful: result.isMeaningful,
4067
+ ...reason ? { reason } : {}
4068
+ };
4069
+ }
4070
+ var OpenAICompatibleMultimodalModel = class {
4071
+ constructor(options) {
4072
+ this.options = options;
4073
+ }
4074
+ options;
4075
+ async describeImage(input2) {
4076
+ if (!this.options.baseUrl || !this.options.apiKey || !this.options.model) {
4077
+ throw new Error("\u591A\u6A21\u6001\u914D\u7F6E\u4E0D\u5B8C\u6574\u3002\u8BF7\u8FD0\u884C chattercatcher setup \u6216 chattercatcher settings\u3002");
4078
+ }
4079
+ const image = await fs13.readFile(input2.imagePath);
4080
+ const response = await fetch(`${normalizeBaseUrl2(this.options.baseUrl)}/chat/completions`, {
4081
+ method: "POST",
4082
+ headers: {
4083
+ authorization: `Bearer ${this.options.apiKey}`,
4084
+ "content-type": "application/json"
4085
+ },
4086
+ body: JSON.stringify({
4087
+ model: this.options.model,
4088
+ messages: [
4089
+ {
4090
+ role: "user",
4091
+ content: [
4092
+ { type: "text", text: buildPrompt(input2.context) },
4093
+ { type: "image_url", image_url: { url: `data:${input2.mimeType};base64,${image.toString("base64")}` } }
4094
+ ]
4095
+ }
4096
+ ],
4097
+ response_format: { type: "json_object" },
4098
+ temperature: this.options.temperature ?? 0.2
4099
+ })
4100
+ });
4101
+ if (!response.ok) {
4102
+ const body = await response.text();
4103
+ throw new Error(`\u591A\u6A21\u6001\u8BF7\u6C42\u5931\u8D25\uFF1A${response.status} ${body}`);
4104
+ }
4105
+ const data2 = await response.json();
4106
+ const content = data2.choices?.[0]?.message?.content?.trim();
4107
+ if (!content) {
4108
+ throw new Error("\u591A\u6A21\u6001\u6A21\u578B\u8FD4\u56DE\u4E3A\u7A7A\u3002");
4109
+ }
4110
+ return parseDescribeImageResult(content);
4111
+ }
4112
+ };
4113
+ function createMultimodalModel(config, secrets) {
4114
+ return new OpenAICompatibleMultimodalModel({
4115
+ baseUrl: config.multimodal.baseUrl,
4116
+ apiKey: secrets.multimodal.apiKey,
4117
+ model: config.multimodal.model
4118
+ });
4119
+ }
4120
+
3358
4121
  // src/rag/indexer.ts
3359
4122
  async function indexMessageChunks(input2) {
3360
4123
  const chunks = input2.messageIds ? input2.messages.listMessageChunksByMessageIds(input2.messageIds, input2.limit ?? 1e4) : input2.messages.listAllMessageChunks(input2.limit ?? 1e4);
@@ -3433,6 +4196,22 @@ async function processMessagesNow(input2) {
3433
4196
  };
3434
4197
  }
3435
4198
 
4199
+ // src/rag/qa-service.ts
4200
+ async function askWithRag(input2) {
4201
+ const evidence = await input2.retriever.retrieve(input2.question);
4202
+ if (evidence.length === 0) {
4203
+ return {
4204
+ answer: "\u4E0D\u77E5\u9053\u3002\u5F53\u524D\u672C\u5730\u77E5\u8BC6\u5E93\u6CA1\u6709\u68C0\u7D22\u5230\u8DB3\u591F\u8BC1\u636E\u3002",
4205
+ citations: []
4206
+ };
4207
+ }
4208
+ return generateGroundedAnswer({
4209
+ question: input2.question,
4210
+ evidence,
4211
+ model: input2.model
4212
+ });
4213
+ }
4214
+
3436
4215
  // src/update/npm-updater.ts
3437
4216
  import { execFile } from "child_process";
3438
4217
  import { promisify } from "util";
@@ -4065,12 +4844,31 @@ async function promptForConfiguration(config, secrets) {
4065
4844
  llmApiKey: secrets.llm.apiKey
4066
4845
  });
4067
4846
  config.embedding.model = await input({ message: "Embedding Model", default: config.embedding.model });
4847
+ const multimodalBaseUrl = await input({
4848
+ message: "Multimodal Base URL\uFF08OpenAI-compatible\uFF0C\u53EF\u7559\u7A7A\uFF09",
4849
+ default: config.multimodal.baseUrl
4850
+ });
4851
+ const multimodalApiKey = await password({
4852
+ message: "Multimodal API Key\uFF08\u53EF\u7559\u7A7A\uFF09",
4853
+ mask: "*"
4854
+ });
4855
+ const multimodalModel = await input({
4856
+ message: "Multimodal Model\uFF08\u53EF\u7559\u7A7A\uFF09",
4857
+ default: config.multimodal.model
4858
+ });
4068
4859
  const dimension = await number({
4069
4860
  message: "Embedding \u7EF4\u5EA6\uFF08\u4E0D\u77E5\u9053\u53EF\u5148\u7559\u7A7A\uFF09",
4070
4861
  default: config.embedding.dimension ?? void 0,
4071
4862
  required: false
4072
4863
  });
4073
4864
  config.embedding.dimension = dimension ?? null;
4865
+ config.multimodal = {
4866
+ baseUrl: multimodalBaseUrl,
4867
+ model: multimodalModel
4868
+ };
4869
+ secrets.multimodal = {
4870
+ apiKey: multimodalApiKey || secrets.multimodal.apiKey
4871
+ };
4074
4872
  config.web.port = await number({ message: "Web UI \u7AEF\u53E3", default: config.web.port, required: true }) ?? config.web.port;
4075
4873
  config.feishu.requireMention = await confirm({
4076
4874
  message: "\u7FA4\u804A\u56DE\u7B54\u662F\u5426\u8981\u6C42 @ \u673A\u5668\u4EBA\uFF1F",
@@ -4098,7 +4896,8 @@ function printSettings(config, secrets) {
4098
4896
  secrets: {
4099
4897
  feishu: { appSecret: maskSecret(secrets.feishu.appSecret) },
4100
4898
  llm: { apiKey: maskSecret(secrets.llm.apiKey) },
4101
- embedding: { apiKey: maskSecret(secrets.embedding.apiKey) }
4899
+ embedding: { apiKey: maskSecret(secrets.embedding.apiKey) },
4900
+ multimodal: { apiKey: maskSecret(secrets.multimodal.apiKey) }
4102
4901
  }
4103
4902
  },
4104
4903
  null,
@@ -4210,6 +5009,10 @@ async function startGatewayForegroundCommand() {
4210
5009
  database,
4211
5010
  model: createChatModel(config, secrets)
4212
5011
  },
5012
+ imageMultimodalProcessor: config.multimodal.baseUrl && config.multimodal.model && secrets.multimodal.apiKey ? {
5013
+ database,
5014
+ model: createMultimodalModel(config, secrets)
5015
+ } : void 0,
4213
5016
  questionHandler: new FeishuQuestionHandler({
4214
5017
  config,
4215
5018
  secrets,
@@ -4577,7 +5380,7 @@ dev.command("ingest-feishu-event").description("\u4ECE JSON \u6587\u4EF6\u6A21\u
4577
5380
  const config = await loadConfig();
4578
5381
  const database = openDatabase(config);
4579
5382
  try {
4580
- const raw = await fs13.readFile(options.file, "utf8");
5383
+ const raw = await fs14.readFile(options.file, "utf8");
4581
5384
  const payload = JSON.parse(raw);
4582
5385
  const result = new GatewayIngestor(database).ingestFeishuEvent(payload);
4583
5386
  if (!result.accepted) {