chattercatcher 0.1.17 → 0.1.19

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.17",
11
+ version: "0.1.19",
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",
@@ -27,7 +27,9 @@ var package_default = {
27
27
  files: [
28
28
  "assets",
29
29
  "dist",
30
- "docs",
30
+ "docs/DEVELOPMENT_PLAN.md",
31
+ "docs/PRD.md",
32
+ "docs/TECHNICAL_ARCHITECTURE.md",
31
33
  "README.md",
32
34
  "AGENTS.md"
33
35
  ],
@@ -102,6 +104,13 @@ var appConfigSchema = z.object({
102
104
  model: z.string().default(""),
103
105
  dimension: z.number().int().positive().nullable().default(null)
104
106
  }),
107
+ multimodal: z.preprocess(
108
+ (value) => value ?? {},
109
+ z.object({
110
+ baseUrl: z.string().url().or(z.literal("")).default(""),
111
+ model: z.string().default("")
112
+ })
113
+ ),
105
114
  storage: z.object({
106
115
  dataDir: z.string().default(defaultDataDir)
107
116
  }),
@@ -126,13 +135,20 @@ var appSecretsSchema = z.object({
126
135
  }),
127
136
  embedding: z.object({
128
137
  apiKey: z.string().default("")
129
- })
138
+ }),
139
+ multimodal: z.preprocess(
140
+ (value) => value ?? {},
141
+ z.object({
142
+ apiKey: z.string().default("")
143
+ })
144
+ )
130
145
  });
131
146
  function createDefaultConfig() {
132
147
  return appConfigSchema.parse({
133
148
  feishu: {},
134
149
  llm: {},
135
150
  embedding: {},
151
+ multimodal: {},
136
152
  storage: {},
137
153
  web: {},
138
154
  schedules: {},
@@ -143,7 +159,8 @@ function createDefaultSecrets() {
143
159
  return appSecretsSchema.parse({
144
160
  feishu: {},
145
161
  llm: {},
146
- embedding: {}
162
+ embedding: {},
163
+ multimodal: {}
147
164
  });
148
165
  }
149
166
 
@@ -463,6 +480,22 @@ function migrateDatabase(database) {
463
480
  CREATE INDEX IF NOT EXISTS message_chunk_embeddings_model_idx
464
481
  ON message_chunk_embeddings(model, dimension);
465
482
 
483
+ CREATE TABLE IF NOT EXISTS qa_logs (
484
+ id TEXT PRIMARY KEY,
485
+ chat_id TEXT,
486
+ question_message_id TEXT,
487
+ question TEXT NOT NULL,
488
+ answer TEXT NOT NULL,
489
+ citations_json TEXT NOT NULL,
490
+ retrieval_debug_json TEXT NOT NULL,
491
+ status TEXT NOT NULL CHECK(status IN ('answered','failed')),
492
+ error TEXT,
493
+ created_at TEXT NOT NULL
494
+ );
495
+
496
+ CREATE INDEX IF NOT EXISTS qa_logs_created_at_idx ON qa_logs(created_at);
497
+ CREATE INDEX IF NOT EXISTS qa_logs_chat_idx ON qa_logs(chat_id, created_at);
498
+
466
499
  CREATE TABLE IF NOT EXISTS file_jobs (
467
500
  id TEXT PRIMARY KEY,
468
501
  source_path TEXT NOT NULL,
@@ -478,6 +511,24 @@ function migrateDatabase(database) {
478
511
  created_at TEXT NOT NULL,
479
512
  updated_at TEXT NOT NULL
480
513
  );
514
+
515
+ CREATE TABLE IF NOT EXISTS image_multimodal_tasks (
516
+ id TEXT PRIMARY KEY,
517
+ source_message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
518
+ platform_message_id TEXT NOT NULL,
519
+ image_key TEXT NOT NULL,
520
+ stored_path TEXT NOT NULL,
521
+ mime_type TEXT NOT NULL,
522
+ status TEXT NOT NULL CHECK(status IN ('pending','running','succeeded','skipped','failed')),
523
+ attempts INTEGER NOT NULL DEFAULT 0,
524
+ last_error TEXT,
525
+ derived_message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
526
+ created_at TEXT NOT NULL,
527
+ updated_at TEXT NOT NULL,
528
+ UNIQUE(source_message_id, image_key)
529
+ );
530
+
531
+ CREATE INDEX IF NOT EXISTS image_multimodal_tasks_status_idx ON image_multimodal_tasks(status, updated_at);
481
532
  `);
482
533
  }
483
534
 
@@ -1144,6 +1195,7 @@ var MessageRepository = class {
1144
1195
  )
1145
1196
  ON CONFLICT(platform, platform_message_id)
1146
1197
  DO UPDATE SET
1198
+ message_type = excluded.message_type,
1147
1199
  text = excluded.text,
1148
1200
  raw_payload_json = excluded.raw_payload_json,
1149
1201
  received_at = excluded.received_at
@@ -1188,6 +1240,48 @@ var MessageRepository = class {
1188
1240
  transaction();
1189
1241
  return messageId;
1190
1242
  }
1243
+ createImageSummaryMessage(input2) {
1244
+ const source = this.database.prepare(
1245
+ `
1246
+ SELECT
1247
+ m.platform AS platform,
1248
+ m.platform_message_id AS platformMessageId,
1249
+ m.chat_id AS chatId,
1250
+ m.sender_id AS senderId,
1251
+ m.sender_name AS senderName,
1252
+ m.sent_at AS sentAt,
1253
+ c.platform_chat_id AS platformChatId,
1254
+ c.name AS chatName
1255
+ FROM messages m
1256
+ JOIN chats c ON c.id = m.chat_id
1257
+ WHERE m.id = ?
1258
+ `
1259
+ ).get(input2.sourceMessageId);
1260
+ if (!source) {
1261
+ throw new Error("\u539F\u59CB\u56FE\u7247\u6D88\u606F\u4E0D\u5B58\u5728\u3002");
1262
+ }
1263
+ const derivedPlatformMessageId = `${source.platformMessageId}:image-summary:${input2.imageKey}`;
1264
+ return this.ingest({
1265
+ platform: source.platform,
1266
+ platformChatId: source.platformChatId,
1267
+ chatName: source.chatName,
1268
+ platformMessageId: derivedPlatformMessageId,
1269
+ senderId: source.senderId,
1270
+ senderName: source.senderName,
1271
+ messageType: "image_summary",
1272
+ text: `[\u56FE\u7247\u8F6C\u8FF0] ${input2.summary.trim()}`,
1273
+ sentAt: source.sentAt,
1274
+ rawPayload: {
1275
+ derivedFromMessageId: input2.sourceMessageId,
1276
+ sourceAttachmentKind: "image",
1277
+ sourceResourceKey: input2.imageKey,
1278
+ multimodalModel: input2.multimodalModel,
1279
+ isMeaningful: true,
1280
+ ...input2.reason?.trim() ? { reason: input2.reason.trim() } : {},
1281
+ generatedAt: input2.generatedAt
1282
+ }
1283
+ });
1284
+ }
1191
1285
  listRecentMessages(limit = 20) {
1192
1286
  return this.database.prepare(
1193
1287
  `
@@ -1507,6 +1601,69 @@ var EpisodeRepository = class {
1507
1601
  messageIds: window.messages.map((message) => message.id)
1508
1602
  };
1509
1603
  }
1604
+ async refreshWindowForMessage(input2) {
1605
+ const target = this.database.prepare(
1606
+ `
1607
+ SELECT chat_id AS chatId, sent_at AS sentAt
1608
+ FROM messages
1609
+ WHERE id = ?
1610
+ `
1611
+ ).get(input2.messageId);
1612
+ if (!target) {
1613
+ return void 0;
1614
+ }
1615
+ const existingWindow = this.database.prepare(
1616
+ `
1617
+ SELECT e.started_at AS startedAt, e.ended_at AS endedAt
1618
+ FROM messages target
1619
+ JOIN messages source
1620
+ ON source.id = json_extract(target.raw_payload_json, '$.derivedFromMessageId')
1621
+ JOIN memory_episode_messages mem ON mem.message_id = source.id
1622
+ JOIN memory_episodes e ON e.id = mem.episode_id
1623
+ WHERE target.id = ?
1624
+ LIMIT 1
1625
+ `
1626
+ ).get(input2.messageId);
1627
+ if (!existingWindow) {
1628
+ return void 0;
1629
+ }
1630
+ const messageTime = toMillis(target.sentAt);
1631
+ const windowStart = toMillis(existingWindow.startedAt);
1632
+ const windowEnd = Math.max(toMillis(existingWindow.endedAt), messageTime);
1633
+ const rows = this.database.prepare(
1634
+ `
1635
+ SELECT
1636
+ m.id,
1637
+ m.chat_id AS chatId,
1638
+ c.name AS chatName,
1639
+ m.sender_name AS senderName,
1640
+ m.text,
1641
+ m.sent_at AS sentAt
1642
+ FROM messages m
1643
+ JOIN chats c ON c.id = m.chat_id
1644
+ WHERE m.chat_id = ?
1645
+ ORDER BY m.sent_at ASC
1646
+ `
1647
+ ).all(target.chatId);
1648
+ const windowMessages = rows.filter((message) => {
1649
+ const time = toMillis(message.sentAt);
1650
+ return time >= windowStart && time <= windowEnd;
1651
+ });
1652
+ const first = windowMessages[0];
1653
+ const last = windowMessages.at(-1);
1654
+ if (!first || !last) {
1655
+ return void 0;
1656
+ }
1657
+ const window = {
1658
+ chatId: first.chatId,
1659
+ chatName: first.chatName,
1660
+ startedAt: first.sentAt,
1661
+ endedAt: last.sentAt,
1662
+ messages: windowMessages
1663
+ };
1664
+ const summary = await input2.summarize(window);
1665
+ return this.insertEpisode(window, summary);
1666
+ }
1510
1667
  getEpisodeCount() {
1511
1668
  const row = this.database.prepare("SELECT count(*) AS count FROM memory_episodes").get();
1512
1669
  return row.count;
@@ -2478,6 +2635,453 @@ async function ensureFeishuBotOpenId(config, secrets, options = {}) {
2478
2635
  // src/feishu/gateway.ts
2479
2636
  import * as lark2 from "@larksuiteoapi/node-sdk";
2480
2637
 
2638
+ // src/gateway/indexing-scheduler.ts
2639
+ function createIndexingScheduler(options) {
2640
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
2641
+ const setIntervalFn = options.setIntervalFn ?? setInterval;
2642
+ const clearIntervalFn = options.clearIntervalFn ?? clearInterval;
2643
+ const logger = options.logger ?? console;
2644
+ const parsed = parseCronSchedule(options.schedule);
2645
+ let timer;
2646
+ let running = false;
2647
+ const runDueNow = async () => {
2648
+ if (!parsed || running || !matchesParsedSchedule(parsed, now())) {
2649
+ return;
2650
+ }
2651
+ running = true;
2652
+ try {
2653
+ await options.work();
2654
+ } catch (error) {
2655
+ const message = error instanceof Error ? error.message : String(error);
2656
+ logger.error(`\u5B9A\u65F6\u6D88\u606F\u7D22\u5F15\u5931\u8D25\uFF1A${message}`);
2657
+ } finally {
2658
+ running = false;
2659
+ }
2660
+ };
2661
+ return {
2662
+ start() {
2663
+ if (!parsed || timer) {
2664
+ return;
2665
+ }
2666
+ timer = setIntervalFn(() => {
2667
+ void runDueNow();
2668
+ }, 6e4);
2669
+ },
2670
+ stop() {
2671
+ if (!timer) {
2672
+ return;
2673
+ }
2674
+ clearIntervalFn(timer);
2675
+ timer = void 0;
2676
+ },
2677
+ runDueNow
2678
+ };
2679
+ }
2680
+ function matchesParsedSchedule(schedule, date) {
2681
+ return schedule.minute(date.getMinutes()) && schedule.hour(date.getHours()) && schedule.dayOfMonth(date.getDate()) && schedule.month(date.getMonth() + 1) && schedule.dayOfWeek(date.getDay());
2682
+ }
2683
+ function parseCronSchedule(schedule) {
2684
+ const fields = schedule.trim().split(/\s+/);
2685
+ if (fields.length !== 5) {
2686
+ return null;
2687
+ }
2688
+ const minute = parseMinuteField(fields[0]);
2689
+ const hour = parseExactOrWildcardField(fields[1], 0, 23);
2690
+ const dayOfMonth = parseExactOrWildcardField(fields[2], 1, 31);
2691
+ const month = parseExactOrWildcardField(fields[3], 1, 12);
2692
+ const dayOfWeek = parseExactOrWildcardField(fields[4], 0, 6);
2693
+ if (!minute || !hour || !dayOfMonth || !month || !dayOfWeek) {
2694
+ return null;
2695
+ }
2696
+ return { minute, hour, dayOfMonth, month, dayOfWeek };
2697
+ }
2698
+ function parseMinuteField(field) {
2699
+ if (field === "*") {
2700
+ return () => true;
2701
+ }
2702
+ const stepMatch = /^\*\/(\d+)$/.exec(field);
2703
+ if (stepMatch) {
2704
+ const step = Number(stepMatch[1]);
2705
+ if (!Number.isInteger(step) || step <= 0 || step > 59) {
2706
+ return null;
2707
+ }
2708
+ return (value) => value % step === 0;
2709
+ }
2710
+ if (field.includes(",")) {
2711
+ const values = field.split(",").map((part) => parseExactNumber(part, 0, 59));
2712
+ if (values.some((value) => value === null)) {
2713
+ return null;
2714
+ }
2715
+ const allowed = new Set(values);
2716
+ return (value) => allowed.has(value);
2717
+ }
2718
+ const exact = parseExactNumber(field, 0, 59);
2719
+ if (exact === null) {
2720
+ return null;
2721
+ }
2722
+ return (value) => value === exact;
2723
+ }
2724
+ function parseExactOrWildcardField(field, min, max) {
2725
+ if (field === "*") {
2726
+ return () => true;
2727
+ }
2728
+ const exact = parseExactNumber(field, min, max);
2729
+ if (exact === null) {
2730
+ return null;
2731
+ }
2732
+ return (value) => value === exact;
2733
+ }
2734
+ function parseExactNumber(field, min, max) {
2735
+ if (!/^\d+$/.test(field)) {
2736
+ return null;
2737
+ }
2738
+ const value = Number(field);
2739
+ if (!Number.isInteger(value) || value < min || value > max) {
2740
+ return null;
2741
+ }
2742
+ return value;
2743
+ }
2744
+
2745
+ // src/rag/indexer.ts
2746
+ async function indexMessageChunks(input2) {
2747
+ const chunks = input2.messageIds ? input2.messages.listMessageChunksByMessageIds(input2.messageIds, input2.limit ?? 1e4) : input2.messages.listAllMessageChunks(input2.limit ?? 1e4);
2748
+ if (chunks.length === 0) {
2749
+ return { chunks: 0, vectors: 0 };
2750
+ }
2751
+ const vectors = await input2.embedding.embedBatch(chunks.map((chunk) => chunk.text));
2752
+ const records = [];
2753
+ for (const [index2, chunk] of chunks.entries()) {
2754
+ const vector = vectors[index2];
2755
+ if (!vector || vector.length === 0) {
2756
+ continue;
2757
+ }
2758
+ records.push({
2759
+ id: chunk.chunkId,
2760
+ vector,
2761
+ evidence: {
2762
+ id: chunk.chunkId,
2763
+ text: chunk.text,
2764
+ score: 1,
2765
+ source: toEvidenceSource3(chunk)
2766
+ }
2767
+ });
2768
+ }
2769
+ await input2.store.upsert(records);
2770
+ return {
2771
+ chunks: chunks.length,
2772
+ vectors: records.length
2773
+ };
2774
+ }
2775
+ function toEvidenceSource3(chunk) {
2776
+ if (chunk.messageType === "file") {
2777
+ return {
2778
+ type: "file",
2779
+ label: chunk.senderName,
2780
+ timestamp: chunk.sentAt
2781
+ };
2782
+ }
2783
+ return {
2784
+ type: "message",
2785
+ label: chunk.chatName,
2786
+ sender: chunk.senderName,
2787
+ timestamp: chunk.sentAt
2788
+ };
2789
+ }
2790
+
2791
+ // src/rag/manual-index.ts
2792
+ async function processMessagesNow(input2) {
2793
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2794
+ if (!hasEmbeddingConfig(input2.config, input2.secrets)) {
2795
+ return {
2796
+ status: "skipped",
2797
+ reason: "Embedding \u914D\u7F6E\u4E0D\u5B8C\u6574\uFF1BSQLite FTS \u5DF2\u5728\u6D88\u606F\u5165\u5E93\u65F6\u5373\u65F6\u66F4\u65B0\u3002",
2798
+ chunks: 0,
2799
+ vectors: 0,
2800
+ startedAt,
2801
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString()
2802
+ };
2803
+ }
2804
+ const vectorStore = new SqliteVectorStore(input2.database, {
2805
+ model: input2.config.embedding.model
2806
+ });
2807
+ const embedding = input2.embedding ?? createEmbeddingModel(input2.config, input2.secrets);
2808
+ const stats = await indexMessageChunks({
2809
+ messages: new MessageRepository(input2.database),
2810
+ embedding,
2811
+ store: vectorStore,
2812
+ limit: input2.limit
2813
+ });
2814
+ return {
2815
+ status: "completed",
2816
+ chunks: stats.chunks,
2817
+ vectors: stats.vectors,
2818
+ startedAt,
2819
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString()
2820
+ };
2821
+ }
2822
+
2823
+ // src/multimodal/tasks.ts
2824
+ import crypto4 from "crypto";
2825
+ function nowIso4() {
2826
+ return (/* @__PURE__ */ new Date()).toISOString();
2827
+ }
2828
+ function stableId3(sourceMessageId, imageKey) {
2829
+ return crypto4.createHash("sha256").update(`${sourceMessageId}${imageKey}`).digest("hex").slice(0, 32);
2830
+ }
2831
+ function mapRow(row) {
2832
+ if (!row) {
2833
+ return void 0;
2834
+ }
2835
+ return {
2836
+ id: row.id,
2837
+ sourceMessageId: row.source_message_id,
2838
+ platformMessageId: row.platform_message_id,
2839
+ imageKey: row.image_key,
2840
+ storedPath: row.stored_path,
2841
+ mimeType: row.mime_type,
2842
+ status: row.status,
2843
+ attempts: row.attempts,
2844
+ ...row.last_error ? { lastError: row.last_error } : {},
2845
+ ...row.derived_message_id ? { derivedMessageId: row.derived_message_id } : {},
2846
+ createdAt: row.created_at,
2847
+ updatedAt: row.updated_at
2848
+ };
2849
+ }
2850
+ var ImageMultimodalTaskRepository = class {
2851
+ constructor(database) {
2852
+ this.database = database;
2853
+ }
2854
+ database;
2855
+ enqueue(input2) {
2856
+ const id = stableId3(input2.sourceMessageId, input2.imageKey);
2857
+ const timestamp = nowIso4();
2858
+ this.database.prepare(
2859
+ `
2860
+ INSERT INTO image_multimodal_tasks (
2861
+ id,
2862
+ source_message_id,
2863
+ platform_message_id,
2864
+ image_key,
2865
+ stored_path,
2866
+ mime_type,
2867
+ status,
2868
+ attempts,
2869
+ created_at,
2870
+ updated_at
2871
+ )
2872
+ VALUES (
2873
+ @id,
2874
+ @sourceMessageId,
2875
+ @platformMessageId,
2876
+ @imageKey,
2877
+ @storedPath,
2878
+ @mimeType,
2879
+ 'pending',
2880
+ 0,
2881
+ @createdAt,
2882
+ @updatedAt
2883
+ )
2884
+ ON CONFLICT(source_message_id, image_key)
2885
+ DO UPDATE SET
2886
+ platform_message_id = excluded.platform_message_id,
2887
+ stored_path = excluded.stored_path,
2888
+ mime_type = excluded.mime_type,
2889
+ status = 'pending',
2890
+ attempts = 0,
2891
+ last_error = NULL,
2892
+ derived_message_id = NULL,
2893
+ updated_at = excluded.updated_at
2894
+ `
2895
+ ).run({
2896
+ id,
2897
+ sourceMessageId: input2.sourceMessageId,
2898
+ platformMessageId: input2.platformMessageId,
2899
+ imageKey: input2.imageKey,
2900
+ storedPath: input2.storedPath,
2901
+ mimeType: input2.mimeType,
2902
+ createdAt: timestamp,
2903
+ updatedAt: timestamp
2904
+ });
2905
+ const record = this.getById(id);
2906
+ if (!record) {
2907
+ throw new Error(`\u56FE\u7247\u591A\u6A21\u6001\u4EFB\u52A1\u5199\u5165\u5931\u8D25\uFF1A${id}`);
2908
+ }
2909
+ return record;
2910
+ }
2911
+ listPending(limit = 10) {
2912
+ const rows = this.database.prepare(
2913
+ `
2914
+ SELECT
2915
+ id,
2916
+ source_message_id,
2917
+ platform_message_id,
2918
+ image_key,
2919
+ stored_path,
2920
+ mime_type,
2921
+ status,
2922
+ attempts,
2923
+ last_error,
2924
+ derived_message_id,
2925
+ created_at,
2926
+ updated_at
2927
+ FROM image_multimodal_tasks
2928
+ WHERE status = 'pending'
2929
+ ORDER BY updated_at ASC
2930
+ LIMIT ?
2931
+ `
2932
+ ).all(limit);
2933
+ return rows.map((row) => mapRow(row)).filter((row) => Boolean(row));
2934
+ }
2935
+ markRunning(id) {
2936
+ const result = this.database.prepare(
2937
+ `
2938
+ UPDATE image_multimodal_tasks
2939
+ SET status = 'running',
2940
+ attempts = attempts + 1,
2941
+ last_error = NULL,
2942
+ updated_at = @updatedAt
2943
+ WHERE id = @id AND status = 'pending'
2944
+ `
2945
+ ).run({ id, updatedAt: nowIso4() });
2946
+ if (result.changes === 0) {
2947
+ throw new Error(`\u56FE\u7247\u591A\u6A21\u6001\u4EFB\u52A1\u72B6\u6001\u65E0\u6CD5\u66F4\u65B0\uFF1A${id}`);
2948
+ }
2949
+ return this.requireById(id);
2950
+ }
2951
+ markSucceeded(id, derivedMessageId) {
2952
+ this.database.prepare(
2953
+ `
2954
+ UPDATE image_multimodal_tasks
2955
+ SET status = 'succeeded',
2956
+ last_error = NULL,
2957
+ derived_message_id = @derivedMessageId,
2958
+ updated_at = @updatedAt
2959
+ WHERE id = @id
2960
+ `
2961
+ ).run({ id, derivedMessageId, updatedAt: nowIso4() });
2962
+ return this.requireById(id);
2963
+ }
2964
+ markSkipped(id, reason) {
2965
+ this.database.prepare(
2966
+ `
2967
+ UPDATE image_multimodal_tasks
2968
+ SET status = 'skipped',
2969
+ last_error = @reason,
2970
+ derived_message_id = NULL,
2971
+ updated_at = @updatedAt
2972
+ WHERE id = @id
2973
+ `
2974
+ ).run({ id, reason, updatedAt: nowIso4() });
2975
+ return this.requireById(id);
2976
+ }
2977
+ markFailed(id, error, finalFailure) {
2978
+ this.database.prepare(
2979
+ `
2980
+ UPDATE image_multimodal_tasks
2981
+ SET status = @status,
2982
+ last_error = @error,
2983
+ derived_message_id = NULL,
2984
+ updated_at = @updatedAt
2985
+ WHERE id = @id
2986
+ `
2987
+ ).run({ id, status: finalFailure ? "failed" : "pending", error, updatedAt: nowIso4() });
2988
+ return this.requireById(id);
2989
+ }
2990
+ getById(id) {
2991
+ const row = this.database.prepare(
2992
+ `
2993
+ SELECT
2994
+ id,
2995
+ source_message_id,
2996
+ platform_message_id,
2997
+ image_key,
2998
+ stored_path,
2999
+ mime_type,
3000
+ status,
3001
+ attempts,
3002
+ last_error,
3003
+ derived_message_id,
3004
+ created_at,
3005
+ updated_at
3006
+ FROM image_multimodal_tasks
3007
+ WHERE id = ?
3008
+ `
3009
+ ).get(id);
3010
+ return mapRow(row);
3011
+ }
3012
+ requireById(id) {
3013
+ const record = this.getById(id);
3014
+ if (!record) {
3015
+ throw new Error(`\u56FE\u7247\u591A\u6A21\u6001\u4EFB\u52A1\u4E0D\u5B58\u5728\uFF1A${id}`);
3016
+ }
3017
+ return record;
3018
+ }
3019
+ };
3020
+
3021
+ // src/multimodal/worker.ts
3022
+ var ImageMultimodalWorker = class {
3023
+ constructor(options) {
3024
+ this.options = options;
3025
+ }
3026
+ options;
3027
+ async processPending(limit = 10) {
3028
+ const result = { processed: 0, succeeded: 0, skipped: 0, failed: 0 };
3029
+ const pending = this.options.tasks.listPending(limit);
3030
+ for (const task of pending) {
3031
+ result.processed += 1;
3032
+ await this.processTask(task, result);
3033
+ }
3034
+ return result;
3035
+ }
3036
+ async processTask(task, result) {
3037
+ let running;
3038
+ try {
3039
+ running = this.options.tasks.markRunning(task.id);
3040
+ } catch (error) {
3041
+ const message = error instanceof Error ? error.message : String(error);
3042
+ if (message.startsWith("\u56FE\u7247\u591A\u6A21\u6001\u4EFB\u52A1\u72B6\u6001\u65E0\u6CD5\u66F4\u65B0\uFF1A")) {
3043
+ return;
3044
+ }
3045
+ throw error;
3046
+ }
3047
+ try {
3048
+ const described = await this.options.model.describeImage({
3049
+ imagePath: running.storedPath,
3050
+ mimeType: running.mimeType
3051
+ });
3052
+ if (!described.isMeaningful) {
3053
+ this.options.tasks.markSkipped(running.id, described.reason || "\u591A\u6A21\u6001\u6A21\u578B\u5224\u5B9A\u56FE\u7247\u65E0\u610F\u4E49\u3002");
3054
+ result.skipped += 1;
3055
+ return;
3056
+ }
3057
+ const derivedMessageId = this.options.messages.createImageSummaryMessage({
3058
+ sourceMessageId: running.sourceMessageId,
3059
+ imageKey: running.imageKey,
3060
+ summary: described.summary,
3061
+ reason: described.reason,
3062
+ multimodalModel: this.options.multimodalModelName,
3063
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
3064
+ });
3065
+ if (this.options.vectorIndexMessage) {
3066
+ await this.options.vectorIndexMessage(derivedMessageId);
3067
+ }
3068
+ if (this.options.episodes && this.options.summarizeEpisode) {
3069
+ await this.options.episodes.refreshWindowForMessage({
3070
+ messageId: derivedMessageId,
3071
+ windowMs: this.options.config.episodes.windowMinutes * 60 * 1e3,
3072
+ summarize: this.options.summarizeEpisode
3073
+ });
3074
+ }
3075
+ this.options.tasks.markSucceeded(running.id, derivedMessageId);
3076
+ result.succeeded += 1;
3077
+ } catch (error) {
3078
+ const message = error instanceof Error ? error.message : String(error);
3079
+ this.options.tasks.markFailed(running.id, message, running.attempts >= 3);
3080
+ result.failed += 1;
3081
+ }
3082
+ }
3083
+ };
3084
+
2481
3085
  // src/rag/citations.ts
2482
3086
  function isOpaqueId(value) {
2483
3087
  return Boolean(value && /^(ou|oc|om|cli|on|un|uid)_?[a-z0-9]+/i.test(value));
@@ -2701,6 +3305,108 @@ async function askWithAgenticRag(input2) {
2701
3305
  });
2702
3306
  }
2703
3307
 
3308
+ // src/rag/qa-logs.ts
3309
+ import crypto5 from "crypto";
3310
+ function clampLimit(limit) {
3311
+ return Math.max(1, Math.min(200, Math.trunc(limit)));
3312
+ }
3313
+ var QaLogRepository = class {
3314
+ constructor(database) {
3315
+ this.database = database;
3316
+ }
3317
+ database;
3318
+ create(input2) {
3319
+ const record = {
3320
+ id: `qa_${crypto5.randomUUID()}`,
3321
+ chatId: input2.chatId ?? null,
3322
+ questionMessageId: input2.questionMessageId ?? null,
3323
+ question: input2.question,
3324
+ answer: input2.answer,
3325
+ citations: input2.citations,
3326
+ retrievalDebug: input2.retrievalDebug,
3327
+ status: input2.status,
3328
+ error: input2.error ?? null,
3329
+ createdAt: input2.createdAt
3330
+ };
3331
+ this.database.prepare(
3332
+ `
3333
+ INSERT INTO qa_logs (
3334
+ id,
3335
+ chat_id,
3336
+ question_message_id,
3337
+ question,
3338
+ answer,
3339
+ citations_json,
3340
+ retrieval_debug_json,
3341
+ status,
3342
+ error,
3343
+ created_at
3344
+ )
3345
+ VALUES (
3346
+ @id,
3347
+ @chatId,
3348
+ @questionMessageId,
3349
+ @question,
3350
+ @answer,
3351
+ @citationsJson,
3352
+ @retrievalDebugJson,
3353
+ @status,
3354
+ @error,
3355
+ @createdAt
3356
+ )
3357
+ `
3358
+ ).run({
3359
+ id: record.id,
3360
+ chatId: record.chatId,
3361
+ questionMessageId: record.questionMessageId,
3362
+ question: record.question,
3363
+ answer: record.answer,
3364
+ citationsJson: JSON.stringify(record.citations),
3365
+ retrievalDebugJson: JSON.stringify(record.retrievalDebug),
3366
+ status: record.status,
3367
+ error: record.error,
3368
+ createdAt: record.createdAt
3369
+ });
3370
+ return record;
3371
+ }
3372
+ listRecent(limit) {
3373
+ const rows = this.database.prepare(
3374
+ `
3375
+ SELECT
3376
+ id,
3377
+ chat_id,
3378
+ question_message_id,
3379
+ question,
3380
+ answer,
3381
+ citations_json,
3382
+ retrieval_debug_json,
3383
+ status,
3384
+ error,
3385
+ created_at
3386
+ FROM qa_logs
3387
+ ORDER BY created_at DESC
3388
+ LIMIT ?
3389
+ `
3390
+ ).all(clampLimit(limit));
3391
+ return rows.map((row) => ({
3392
+ id: row.id,
3393
+ chatId: row.chat_id,
3394
+ questionMessageId: row.question_message_id,
3395
+ question: row.question,
3396
+ answer: row.answer,
3397
+ citations: JSON.parse(row.citations_json),
3398
+ retrievalDebug: JSON.parse(row.retrieval_debug_json),
3399
+ status: row.status,
3400
+ error: row.error,
3401
+ createdAt: row.created_at
3402
+ }));
3403
+ }
3404
+ getCount() {
3405
+ const row = this.database.prepare("SELECT COUNT(*) AS count FROM qa_logs").get();
3406
+ return row.count;
3407
+ }
3408
+ };
3409
+
2704
3410
  // src/feishu/question.ts
2705
3411
  function parseTextContent(content) {
2706
3412
  if (!content) {
@@ -2807,6 +3513,7 @@ var FeishuQuestionHandler = class {
2807
3513
  return decision;
2808
3514
  }
2809
3515
  const questionMessageId = payload.event?.message?.message_id;
3516
+ const qaLogs = new QaLogRepository(this.options.database);
2810
3517
  await this.acknowledgeQuestion(decision.chatId, questionMessageId);
2811
3518
  const { tools, close } = await createAgenticRagSearchTools({
2812
3519
  config: this.options.config,
@@ -2822,6 +3529,16 @@ var FeishuQuestionHandler = class {
2822
3529
  tools,
2823
3530
  model: this.options.model
2824
3531
  });
3532
+ qaLogs.create({
3533
+ chatId: decision.chatId,
3534
+ questionMessageId,
3535
+ question: decision.question,
3536
+ answer: result.answer,
3537
+ citations: result.citations,
3538
+ retrievalDebug: { evidenceCount: result.citations.length },
3539
+ status: "answered",
3540
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3541
+ });
2825
3542
  const citations = formatCitations(result.citations);
2826
3543
  const text = citations ? `${result.answer}
2827
3544
 
@@ -2830,6 +3547,17 @@ ${citations}` : result.answer;
2830
3547
  await this.sendResponse(decision.chatId, questionMessageId, text);
2831
3548
  } catch (error) {
2832
3549
  const message = error instanceof Error ? error.message : String(error);
3550
+ qaLogs.create({
3551
+ chatId: decision.chatId,
3552
+ questionMessageId,
3553
+ question: decision.question,
3554
+ answer: `\u6682\u65F6\u65E0\u6CD5\u56DE\u7B54\uFF1A${message}`,
3555
+ citations: [],
3556
+ retrievalDebug: {},
3557
+ status: "failed",
3558
+ error: message,
3559
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3560
+ });
2833
3561
  await this.sendResponse(decision.chatId, questionMessageId, `\u6682\u65F6\u65E0\u6CD5\u56DE\u7B54\uFF1A${message}`);
2834
3562
  }
2835
3563
  return decision;
@@ -2955,6 +3683,7 @@ function createFeishuEventDispatcher(options) {
2955
3683
  payload,
2956
3684
  downloader: options.resourceDownloader,
2957
3685
  config: options.config,
3686
+ secrets: options.secrets,
2958
3687
  vectorIndexMessage: options.attachmentVectorIndexer
2959
3688
  }) : options.ingestor.ingestFeishuEvent(payload);
2960
3689
  if (!result.accepted) {
@@ -2980,6 +3709,23 @@ function createFeishuEventDispatcher(options) {
2980
3709
  }
2981
3710
  if (result.attachment?.downloaded) {
2982
3711
  console.log(`\u98DE\u4E66\u9644\u4EF6\u5DF2\u4E0B\u8F7D\uFF1A${result.attachment.downloaded.storedPath}`);
3712
+ if (options.imageMultimodalProcessor && result.attachment.imageTask) {
3713
+ void new ImageMultimodalWorker({
3714
+ config: options.config,
3715
+ messages: new MessageRepository(options.imageMultimodalProcessor.database),
3716
+ tasks: new ImageMultimodalTaskRepository(options.imageMultimodalProcessor.database),
3717
+ model: options.imageMultimodalProcessor.model,
3718
+ multimodalModelName: options.config.multimodal.model,
3719
+ vectorIndexMessage: options.attachmentVectorIndexer
3720
+ }).processPending().then((imageResult) => {
3721
+ console.log(
3722
+ `\u98DE\u4E66\u56FE\u7247\u591A\u6A21\u6001\u5904\u7406\u5B8C\u6210\uFF1Aprocessed=${imageResult.processed}, succeeded=${imageResult.succeeded}, skipped=${imageResult.skipped}, failed=${imageResult.failed}`
3723
+ );
3724
+ }).catch((error) => {
3725
+ const message = error instanceof Error ? error.message : String(error);
3726
+ console.error(`\u98DE\u4E66\u56FE\u7247\u591A\u6A21\u6001\u5904\u7406\u5931\u8D25\uFF1A${message}`);
3727
+ });
3728
+ }
2983
3729
  if (result.attachment.indexedMessageId) {
2984
3730
  console.log(`\u98DE\u4E66\u9644\u4EF6\u5DF2\u8FDB\u5165 RAG\uFF1A${result.attachment.indexedMessageId}`);
2985
3731
  if (result.attachment.vectorIndexed) {
@@ -3030,17 +3776,32 @@ function createFeishuGateway(options) {
3030
3776
  questionHandler: options.questionHandler,
3031
3777
  resourceDownloader: options.resourceDownloader,
3032
3778
  attachmentVectorIndexer: options.attachmentVectorIndexer,
3033
- episodeProcessor: options.episodeProcessor
3779
+ episodeProcessor: options.episodeProcessor,
3780
+ imageMultimodalProcessor: options.imageMultimodalProcessor
3034
3781
  });
3782
+ const indexingScheduler = options.indexingScheduler ?? (options.indexingProcessor ? createIndexingScheduler({
3783
+ schedule: options.config.schedules.indexing,
3784
+ work: async () => {
3785
+ await processMessagesNow({
3786
+ config: options.config,
3787
+ secrets: options.secrets,
3788
+ database: options.indexingProcessor.database,
3789
+ limit: 1e4
3790
+ });
3791
+ }
3792
+ }) : void 0);
3035
3793
  return {
3036
3794
  async start() {
3037
3795
  try {
3038
3796
  await wsClient.start({ eventDispatcher });
3797
+ indexingScheduler?.start();
3039
3798
  } catch (error) {
3799
+ indexingScheduler?.stop();
3040
3800
  throw formatGatewayStartError(error);
3041
3801
  }
3042
3802
  },
3043
3803
  stop() {
3804
+ indexingScheduler?.stop();
3044
3805
  wsClient.close({ force: true });
3045
3806
  }
3046
3807
  };
@@ -3112,7 +3873,7 @@ var FeishuResourceDownloader = class _FeishuResourceDownloader {
3112
3873
  };
3113
3874
 
3114
3875
  // src/files/ingest.ts
3115
- import crypto4 from "crypto";
3876
+ import crypto6 from "crypto";
3116
3877
  import fs11 from "fs/promises";
3117
3878
  import path13 from "path";
3118
3879
 
@@ -3176,7 +3937,7 @@ function ensureSupportedTextFile(filePath) {
3176
3937
  }
3177
3938
  }
3178
3939
  function stableStoredName(sourcePath, fileName) {
3179
- const digest = crypto4.createHash("sha256").update(sourcePath).digest("hex").slice(0, 16);
3940
+ const digest = crypto6.createHash("sha256").update(sourcePath).digest("hex").slice(0, 16);
3180
3941
  return `${digest}-${fileName}`;
3181
3942
  }
3182
3943
  async function ingestLocalFile(input2) {
@@ -3414,12 +4175,17 @@ function extractAttachment(message) {
3414
4175
  }
3415
4176
  return candidate;
3416
4177
  }
4178
+ function isMultimodalReady(config, secrets) {
4179
+ return Boolean(config.multimodal.baseUrl && config.multimodal.model && secrets.multimodal.apiKey);
4180
+ }
3417
4181
  var GatewayIngestor = class {
3418
4182
  messages;
3419
4183
  jobs;
4184
+ imageTasks;
3420
4185
  constructor(database) {
3421
4186
  this.messages = new MessageRepository(database);
3422
4187
  this.jobs = new FileJobRepository(database);
4188
+ this.imageTasks = new ImageMultimodalTaskRepository(database);
3423
4189
  }
3424
4190
  ingestFeishuEvent(payload) {
3425
4191
  const normalized = normalizeFeishuReceiveMessageEvent(payload);
@@ -3451,6 +4217,23 @@ var GatewayIngestor = class {
3451
4217
  messageId: result.message.platformMessageId,
3452
4218
  attachment
3453
4219
  });
4220
+ if (attachment.kind === "image") {
4221
+ const imageTask = isMultimodalReady(input2.config, input2.secrets) ? this.imageTasks.enqueue({
4222
+ sourceMessageId: result.messageId,
4223
+ platformMessageId: result.message.platformMessageId,
4224
+ imageKey: attachment.fileKey,
4225
+ storedPath: downloaded.storedPath,
4226
+ mimeType: attachment.mimeType || "image/jpeg"
4227
+ }) : void 0;
4228
+ return {
4229
+ ...result,
4230
+ attachment: {
4231
+ downloaded,
4232
+ ...imageTask ? { imageTask } : {},
4233
+ 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"
4234
+ }
4235
+ };
4236
+ }
3454
4237
  if (!isSupportedTextFile(downloaded.storedPath)) {
3455
4238
  return {
3456
4239
  ...result,
@@ -3586,82 +4369,94 @@ async function startDetachedGateway(input2) {
3586
4369
  }
3587
4370
  }
3588
4371
 
3589
- // src/rag/indexer.ts
3590
- async function indexMessageChunks(input2) {
3591
- const chunks = input2.messageIds ? input2.messages.listMessageChunksByMessageIds(input2.messageIds, input2.limit ?? 1e4) : input2.messages.listAllMessageChunks(input2.limit ?? 1e4);
3592
- if (chunks.length === 0) {
3593
- return { chunks: 0, vectors: 0 };
4372
+ // src/multimodal/openai-compatible.ts
4373
+ import fs13 from "fs/promises";
4374
+ function normalizeBaseUrl2(baseUrl) {
4375
+ return baseUrl.replace(/\/+$/, "");
4376
+ }
4377
+ function buildPrompt(context) {
4378
+ const contextText = context?.trim();
4379
+ return [
4380
+ "\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",
4381
+ '\u8BF7\u53EA\u8F93\u51FA JSON\uFF0C\u683C\u5F0F\u4E3A {"summary": string, "isMeaningful": boolean, "reason": string}\u3002',
4382
+ "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",
4383
+ contextText ? `\u4E0A\u4E0B\u6587\uFF1A${contextText}` : void 0
4384
+ ].filter(Boolean).join("\n");
4385
+ }
4386
+ function parseDescribeImageResult(content) {
4387
+ let data2;
4388
+ try {
4389
+ data2 = JSON.parse(content);
4390
+ } catch {
4391
+ throw new Error("\u591A\u6A21\u6001\u6A21\u578B\u8FD4\u56DE\u7684 JSON \u65E0\u6CD5\u89E3\u6790\u3002");
3594
4392
  }
3595
- const vectors = await input2.embedding.embedBatch(chunks.map((chunk) => chunk.text));
3596
- const records = [];
3597
- for (const [index2, chunk] of chunks.entries()) {
3598
- const vector = vectors[index2];
3599
- if (!vector || vector.length === 0) {
3600
- continue;
3601
- }
3602
- records.push({
3603
- id: chunk.chunkId,
3604
- vector,
3605
- evidence: {
3606
- id: chunk.chunkId,
3607
- text: chunk.text,
3608
- score: 1,
3609
- source: toEvidenceSource3(chunk)
3610
- }
3611
- });
4393
+ if (!data2 || typeof data2 !== "object") {
4394
+ throw new Error("\u591A\u6A21\u6001\u6A21\u578B\u8FD4\u56DE\u683C\u5F0F\u4E0D\u6B63\u786E\u3002");
3612
4395
  }
3613
- await input2.store.upsert(records);
3614
- return {
3615
- chunks: chunks.length,
3616
- vectors: records.length
3617
- };
3618
- }
3619
- function toEvidenceSource3(chunk) {
3620
- if (chunk.messageType === "file") {
3621
- return {
3622
- type: "file",
3623
- label: chunk.senderName,
3624
- timestamp: chunk.sentAt
3625
- };
4396
+ const result = data2;
4397
+ const summary = typeof result.summary === "string" ? result.summary.trim() : "";
4398
+ if (!summary) {
4399
+ throw new Error("\u591A\u6A21\u6001\u6A21\u578B\u8FD4\u56DE\u7684 summary \u4E3A\u7A7A\u3002");
3626
4400
  }
4401
+ if (typeof result.isMeaningful !== "boolean") {
4402
+ throw new Error("\u591A\u6A21\u6001\u6A21\u578B\u8FD4\u56DE\u7684 isMeaningful \u4E0D\u662F\u5E03\u5C14\u503C\u3002");
4403
+ }
4404
+ const reason = typeof result.reason === "string" ? result.reason.trim() : "";
3627
4405
  return {
3628
- type: "message",
3629
- label: chunk.chatName,
3630
- sender: chunk.senderName,
3631
- timestamp: chunk.sentAt
4406
+ summary,
4407
+ isMeaningful: result.isMeaningful,
4408
+ ...reason ? { reason } : {}
3632
4409
  };
3633
4410
  }
3634
-
3635
- // src/rag/manual-index.ts
3636
- async function processMessagesNow(input2) {
3637
- const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3638
- if (!hasEmbeddingConfig(input2.config, input2.secrets)) {
3639
- return {
3640
- status: "skipped",
3641
- reason: "Embedding \u914D\u7F6E\u4E0D\u5B8C\u6574\uFF1BSQLite FTS \u5DF2\u5728\u6D88\u606F\u5165\u5E93\u65F6\u5373\u65F6\u66F4\u65B0\u3002",
3642
- chunks: 0,
3643
- vectors: 0,
3644
- startedAt,
3645
- finishedAt: (/* @__PURE__ */ new Date()).toISOString()
3646
- };
4411
+ var OpenAICompatibleMultimodalModel = class {
4412
+ constructor(options) {
4413
+ this.options = options;
3647
4414
  }
3648
- const vectorStore = new SqliteVectorStore(input2.database, {
3649
- model: input2.config.embedding.model
3650
- });
3651
- const embedding = input2.embedding ?? createEmbeddingModel(input2.config, input2.secrets);
3652
- const stats = await indexMessageChunks({
3653
- messages: new MessageRepository(input2.database),
3654
- embedding,
3655
- store: vectorStore,
3656
- limit: input2.limit
4415
+ options;
4416
+ async describeImage(input2) {
4417
+ if (!this.options.baseUrl || !this.options.apiKey || !this.options.model) {
4418
+ throw new Error("\u591A\u6A21\u6001\u914D\u7F6E\u4E0D\u5B8C\u6574\u3002\u8BF7\u8FD0\u884C chattercatcher setup \u6216 chattercatcher settings\u3002");
4419
+ }
4420
+ const image = await fs13.readFile(input2.imagePath);
4421
+ const response = await fetch(`${normalizeBaseUrl2(this.options.baseUrl)}/chat/completions`, {
4422
+ method: "POST",
4423
+ headers: {
4424
+ authorization: `Bearer ${this.options.apiKey}`,
4425
+ "content-type": "application/json"
4426
+ },
4427
+ body: JSON.stringify({
4428
+ model: this.options.model,
4429
+ messages: [
4430
+ {
4431
+ role: "user",
4432
+ content: [
4433
+ { type: "text", text: buildPrompt(input2.context) },
4434
+ { type: "image_url", image_url: { url: `data:${input2.mimeType};base64,${image.toString("base64")}` } }
4435
+ ]
4436
+ }
4437
+ ],
4438
+ response_format: { type: "json_object" },
4439
+ temperature: this.options.temperature ?? 0.2
4440
+ })
4441
+ });
4442
+ if (!response.ok) {
4443
+ const body = await response.text();
4444
+ throw new Error(`\u591A\u6A21\u6001\u8BF7\u6C42\u5931\u8D25\uFF1A${response.status} ${body}`);
4445
+ }
4446
+ const data2 = await response.json();
4447
+ const content = data2.choices?.[0]?.message?.content?.trim();
4448
+ if (!content) {
4449
+ throw new Error("\u591A\u6A21\u6001\u6A21\u578B\u8FD4\u56DE\u4E3A\u7A7A\u3002");
4450
+ }
4451
+ return parseDescribeImageResult(content);
4452
+ }
4453
+ };
4454
+ function createMultimodalModel(config, secrets) {
4455
+ return new OpenAICompatibleMultimodalModel({
4456
+ baseUrl: config.multimodal.baseUrl,
4457
+ apiKey: secrets.multimodal.apiKey,
4458
+ model: config.multimodal.model
3657
4459
  });
3658
- return {
3659
- status: "completed",
3660
- chunks: stats.chunks,
3661
- vectors: stats.vectors,
3662
- startedAt,
3663
- finishedAt: (/* @__PURE__ */ new Date()).toISOString()
3664
- };
3665
4460
  }
3666
4461
 
3667
4462
  // src/rag/qa-service.ts
@@ -3912,6 +4707,10 @@ function buildHtml() {
3912
4707
  <h2>\u4F1A\u8BDD\u8BB0\u5FC6</h2>
3913
4708
  <div id="episodes" class="empty">\u6B63\u5728\u8BFB\u53D6...</div>
3914
4709
  </section>
4710
+ <section>
4711
+ <h2>\u95EE\u7B54\u65E5\u5FD7</h2>
4712
+ <div id="qa-logs" class="empty">\u6B63\u5728\u8BFB\u53D6...</div>
4713
+ </section>
3915
4714
  </div>
3916
4715
  <aside>
3917
4716
  <section>
@@ -3942,6 +4741,7 @@ function buildHtml() {
3942
4741
  const chats = document.querySelector("#chats");
3943
4742
  const files = document.querySelector("#files");
3944
4743
  const fileJobs = document.querySelector("#file-jobs");
4744
+ const qaLogs = document.querySelector("#qa-logs");
3945
4745
  const processMessages = document.querySelector("#process-messages");
3946
4746
  const actionStatus = document.querySelector("#action-status");
3947
4747
 
@@ -4133,14 +4933,45 @@ function buildHtml() {
4133
4933
  \`;
4134
4934
  }
4135
4935
 
4936
+ function renderQaLogs(items) {
4937
+ if (items.length === 0) {
4938
+ qaLogs.className = "empty";
4939
+ qaLogs.textContent = "\u8FD8\u6CA1\u6709\u95EE\u7B54\u65E5\u5FD7\u3002";
4940
+ return;
4941
+ }
4942
+ qaLogs.className = "";
4943
+ const rows = items.map((item) => {
4944
+ const citationCount = Array.isArray(item.citations) ? item.citations.length : 0;
4945
+ return [
4946
+ '<article class="message-item">',
4947
+ ' <div class="message-meta">',
4948
+ " <span>" + escapeHtml(formatDateTime(item.createdAt)) + "</span>",
4949
+ " <span>" + escapeHtml(item.status) + "</span>",
4950
+ " <span>" + escapeHtml(citationCount) + " \u6761\u5F15\u7528</span>",
4951
+ " </div>",
4952
+ " <div class="message-body"><strong>\u95EE\uFF1A</strong>" + escapeHtml(item.question) + "</div>",
4953
+ " <div class="message-body"><strong>\u7B54\uFF1A</strong>" + escapeHtml(item.answer) + "</div>",
4954
+ "</article>",
4955
+ ].join("
4956
+ ");
4957
+ });
4958
+ qaLogs.innerHTML = [
4959
+ '<div class="message-list">',
4960
+ rows.join(""),
4961
+ "</div>",
4962
+ ].join("
4963
+ ");
4964
+ }
4965
+
4136
4966
  async function load() {
4137
- const [status, recent, episodeList, chatList, fileList, jobList] = await Promise.all([
4967
+ const [status, recent, episodeList, chatList, fileList, jobList, qaLogList] = await Promise.all([
4138
4968
  fetch("/api/status").then((response) => response.json()),
4139
4969
  fetch("/api/messages/recent?limit=20").then((response) => response.json()),
4140
4970
  fetch("/api/episodes?limit=10").then((response) => response.json()),
4141
4971
  fetch("/api/chats").then((response) => response.json()),
4142
4972
  fetch("/api/files").then((response) => response.json()),
4143
4973
  fetch("/api/file-jobs").then((response) => response.json()),
4974
+ fetch("/api/qa-logs?limit=10").then((response) => response.json()),
4144
4975
  ]);
4145
4976
  renderMetrics(status);
4146
4977
  renderMessages(recent.items);
@@ -4148,6 +4979,7 @@ function buildHtml() {
4148
4979
  renderChats(chatList.items);
4149
4980
  renderFiles(fileList.items);
4150
4981
  renderFileJobs(jobList.items);
4982
+ renderQaLogs(qaLogList.items);
4151
4983
  }
4152
4984
 
4153
4985
  async function processNow() {
@@ -4195,6 +5027,7 @@ function createWebApp(config) {
4195
5027
  const messages = new MessageRepository(database);
4196
5028
  const episodes = new EpisodeRepository(database);
4197
5029
  const fileJobs = new FileJobRepository(database);
5030
+ const qaLogs = new QaLogRepository(database);
4198
5031
  app.addHook("onClose", async () => {
4199
5032
  database.close();
4200
5033
  });
@@ -4205,7 +5038,8 @@ function createWebApp(config) {
4205
5038
  chats: messages.getChatCount(),
4206
5039
  messages: messages.getMessageCount(),
4207
5040
  episodes: episodes.getEpisodeCount(),
4208
- files: messages.listFiles(1e3).length
5041
+ files: messages.listFiles(1e3).length,
5042
+ qaLogs: qaLogs.getCount()
4209
5043
  },
4210
5044
  rag: {
4211
5045
  mode: "required",
@@ -4246,6 +5080,12 @@ function createWebApp(config) {
4246
5080
  items: episodes.listRecentEpisodes(limit)
4247
5081
  };
4248
5082
  });
5083
+ app.get("/api/qa-logs", async (request) => {
5084
+ const limit = parseLimit(request.query.limit, 20, 100);
5085
+ return {
5086
+ items: qaLogs.listRecent(limit)
5087
+ };
5088
+ });
4249
5089
  app.post("/api/process/messages", async (_request, reply) => {
4250
5090
  try {
4251
5091
  return await processMessagesNow({
@@ -4312,12 +5152,31 @@ async function promptForConfiguration(config, secrets) {
4312
5152
  llmApiKey: secrets.llm.apiKey
4313
5153
  });
4314
5154
  config.embedding.model = await input({ message: "Embedding Model", default: config.embedding.model });
5155
+ const multimodalBaseUrl = await input({
5156
+ message: "Multimodal Base URL\uFF08OpenAI-compatible\uFF0C\u53EF\u7559\u7A7A\uFF09",
5157
+ default: config.multimodal.baseUrl
5158
+ });
5159
+ const multimodalApiKey = await password({
5160
+ message: "Multimodal API Key\uFF08\u53EF\u7559\u7A7A\uFF09",
5161
+ mask: "*"
5162
+ });
5163
+ const multimodalModel = await input({
5164
+ message: "Multimodal Model\uFF08\u53EF\u7559\u7A7A\uFF09",
5165
+ default: config.multimodal.model
5166
+ });
4315
5167
  const dimension = await number({
4316
5168
  message: "Embedding \u7EF4\u5EA6\uFF08\u4E0D\u77E5\u9053\u53EF\u5148\u7559\u7A7A\uFF09",
4317
5169
  default: config.embedding.dimension ?? void 0,
4318
5170
  required: false
4319
5171
  });
4320
5172
  config.embedding.dimension = dimension ?? null;
5173
+ config.multimodal = {
5174
+ baseUrl: multimodalBaseUrl,
5175
+ model: multimodalModel
5176
+ };
5177
+ secrets.multimodal = {
5178
+ apiKey: multimodalApiKey || secrets.multimodal.apiKey
5179
+ };
4321
5180
  config.web.port = await number({ message: "Web UI \u7AEF\u53E3", default: config.web.port, required: true }) ?? config.web.port;
4322
5181
  config.feishu.requireMention = await confirm({
4323
5182
  message: "\u7FA4\u804A\u56DE\u7B54\u662F\u5426\u8981\u6C42 @ \u673A\u5668\u4EBA\uFF1F",
@@ -4345,7 +5204,8 @@ function printSettings(config, secrets) {
4345
5204
  secrets: {
4346
5205
  feishu: { appSecret: maskSecret(secrets.feishu.appSecret) },
4347
5206
  llm: { apiKey: maskSecret(secrets.llm.apiKey) },
4348
- embedding: { apiKey: maskSecret(secrets.embedding.apiKey) }
5207
+ embedding: { apiKey: maskSecret(secrets.embedding.apiKey) },
5208
+ multimodal: { apiKey: maskSecret(secrets.multimodal.apiKey) }
4349
5209
  }
4350
5210
  },
4351
5211
  null,
@@ -4457,6 +5317,13 @@ async function startGatewayForegroundCommand() {
4457
5317
  database,
4458
5318
  model: createChatModel(config, secrets)
4459
5319
  },
5320
+ imageMultimodalProcessor: config.multimodal.baseUrl && config.multimodal.model && secrets.multimodal.apiKey ? {
5321
+ database,
5322
+ model: createMultimodalModel(config, secrets)
5323
+ } : void 0,
5324
+ indexingProcessor: {
5325
+ database
5326
+ },
4460
5327
  questionHandler: new FeishuQuestionHandler({
4461
5328
  config,
4462
5329
  secrets,
@@ -4824,7 +5691,7 @@ dev.command("ingest-feishu-event").description("\u4ECE JSON \u6587\u4EF6\u6A21\u
4824
5691
  const config = await loadConfig();
4825
5692
  const database = openDatabase(config);
4826
5693
  try {
4827
- const raw = await fs13.readFile(options.file, "utf8");
5694
+ const raw = await fs14.readFile(options.file, "utf8");
4828
5695
  const payload = JSON.parse(raw);
4829
5696
  const result = new GatewayIngestor(database).ingestFeishuEvent(payload);
4830
5697
  if (!result.accepted) {