chattercatcher 0.1.18 → 0.1.20

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
@@ -8,7 +8,7 @@ import fs14 from "fs/promises";
8
8
  // package.json
9
9
  var package_default = {
10
10
  name: "chattercatcher",
11
- version: "0.1.18",
11
+ version: "0.1.20",
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
  ],
@@ -139,6 +141,12 @@ var appSecretsSchema = z.object({
139
141
  z.object({
140
142
  apiKey: z.string().default("")
141
143
  })
144
+ ),
145
+ web: z.preprocess(
146
+ (value) => value ?? {},
147
+ z.object({
148
+ actionToken: z.string().default("")
149
+ })
142
150
  )
143
151
  });
144
152
  function createDefaultConfig() {
@@ -158,7 +166,8 @@ function createDefaultSecrets() {
158
166
  feishu: {},
159
167
  llm: {},
160
168
  embedding: {},
161
- multimodal: {}
169
+ multimodal: {},
170
+ web: {}
162
171
  });
163
172
  }
164
173
 
@@ -478,6 +487,22 @@ function migrateDatabase(database) {
478
487
  CREATE INDEX IF NOT EXISTS message_chunk_embeddings_model_idx
479
488
  ON message_chunk_embeddings(model, dimension);
480
489
 
490
+ CREATE TABLE IF NOT EXISTS qa_logs (
491
+ id TEXT PRIMARY KEY,
492
+ chat_id TEXT,
493
+ question_message_id TEXT,
494
+ question TEXT NOT NULL,
495
+ answer TEXT NOT NULL,
496
+ citations_json TEXT NOT NULL,
497
+ retrieval_debug_json TEXT NOT NULL,
498
+ status TEXT NOT NULL CHECK(status IN ('answered','failed')),
499
+ error TEXT,
500
+ created_at TEXT NOT NULL
501
+ );
502
+
503
+ CREATE INDEX IF NOT EXISTS qa_logs_created_at_idx ON qa_logs(created_at);
504
+ CREATE INDEX IF NOT EXISTS qa_logs_chat_idx ON qa_logs(chat_id, created_at);
505
+
481
506
  CREATE TABLE IF NOT EXISTS file_jobs (
482
507
  id TEXT PRIMARY KEY,
483
508
  source_path TEXT NOT NULL,
@@ -511,6 +536,23 @@ function migrateDatabase(database) {
511
536
  );
512
537
 
513
538
  CREATE INDEX IF NOT EXISTS image_multimodal_tasks_status_idx ON image_multimodal_tasks(status, updated_at);
539
+
540
+ CREATE TABLE IF NOT EXISTS cron_jobs (
541
+ id TEXT PRIMARY KEY,
542
+ chat_id TEXT NOT NULL,
543
+ created_by_open_id TEXT,
544
+ schedule TEXT NOT NULL,
545
+ prompt TEXT NOT NULL,
546
+ status TEXT NOT NULL CHECK(status IN ('active','deleted')),
547
+ last_run_at TEXT,
548
+ next_run_at TEXT NOT NULL,
549
+ last_error TEXT,
550
+ created_at TEXT NOT NULL,
551
+ updated_at TEXT NOT NULL
552
+ );
553
+
554
+ CREATE INDEX IF NOT EXISTS cron_jobs_chat_status_idx ON cron_jobs(chat_id, status, updated_at);
555
+ CREATE INDEX IF NOT EXISTS cron_jobs_due_idx ON cron_jobs(status, next_run_at);
514
556
  `);
515
557
  }
516
558
 
@@ -1130,6 +1172,22 @@ function buildSearchTerms(query) {
1130
1172
  }
1131
1173
  return [trimmed];
1132
1174
  }
1175
+ function buildScopeWhere(scope) {
1176
+ const clauses = [];
1177
+ const params = [];
1178
+ if (scope?.platform) {
1179
+ clauses.push("m.platform = ?");
1180
+ params.push(scope.platform);
1181
+ }
1182
+ if (scope?.platformChatId) {
1183
+ clauses.push("c.platform_chat_id = ?");
1184
+ params.push(scope.platformChatId);
1185
+ }
1186
+ return {
1187
+ where: clauses.length > 0 ? `AND ${clauses.join(" AND ")}` : "",
1188
+ params
1189
+ };
1190
+ }
1133
1191
  function parseRawPayload(value) {
1134
1192
  try {
1135
1193
  const parsed = JSON.parse(value);
@@ -1335,6 +1393,7 @@ var MessageRepository = class {
1335
1393
  const ftsQuery = escapeFtsQuery(query);
1336
1394
  const excludedIds = options.excludeMessageIds ?? [];
1337
1395
  const excludedWhere = excludedIds.length > 0 ? `AND fts.message_id NOT IN (${excludedIds.map(() => "?").join(", ")})` : "";
1396
+ const scope = buildScopeWhere(options.scope);
1338
1397
  const ftsResults = this.database.prepare(
1339
1398
  `
1340
1399
  SELECT
@@ -1353,10 +1412,11 @@ var MessageRepository = class {
1353
1412
  JOIN chats c ON c.id = m.chat_id
1354
1413
  WHERE message_chunks_fts MATCH ?
1355
1414
  ${excludedWhere}
1415
+ ${scope.where}
1356
1416
  ORDER BY bm25(message_chunks_fts)
1357
1417
  LIMIT ?
1358
1418
  `
1359
- ).all(ftsQuery, ...excludedIds, limit);
1419
+ ).all(ftsQuery, ...excludedIds, ...scope.params, limit);
1360
1420
  if (ftsResults.length > 0) {
1361
1421
  return ftsResults;
1362
1422
  }
@@ -1384,10 +1444,11 @@ var MessageRepository = class {
1384
1444
  JOIN chats c ON c.id = m.chat_id
1385
1445
  WHERE (${where})
1386
1446
  ${likeExcludedWhere}
1447
+ ${scope.where}
1387
1448
  ORDER BY m.sent_at DESC
1388
1449
  LIMIT ?
1389
1450
  `
1390
- ).all(...params, ...excludedIds, limit);
1451
+ ).all(...params, ...excludedIds, ...scope.params, limit);
1391
1452
  }
1392
1453
  getChatCount() {
1393
1454
  return this.database.prepare("SELECT COUNT(*) AS count FROM chats").get().count;
@@ -1487,6 +1548,22 @@ function toMillis(value) {
1487
1548
  const time = Date.parse(value);
1488
1549
  return Number.isFinite(time) ? time : 0;
1489
1550
  }
1551
+ function buildScopeWhere2(scope) {
1552
+ const clauses = [];
1553
+ const params = [];
1554
+ if (scope?.platform) {
1555
+ clauses.push("c.platform = ?");
1556
+ params.push(scope.platform);
1557
+ }
1558
+ if (scope?.platformChatId) {
1559
+ clauses.push("c.platform_chat_id = ?");
1560
+ params.push(scope.platformChatId);
1561
+ }
1562
+ return {
1563
+ where: clauses.length > 0 ? `AND ${clauses.join(" AND ")}` : "",
1564
+ params
1565
+ };
1566
+ }
1490
1567
  var EpisodeRepository = class {
1491
1568
  constructor(database) {
1492
1569
  this.database = database;
@@ -1669,8 +1746,9 @@ var EpisodeRepository = class {
1669
1746
  `
1670
1747
  ).all(limit);
1671
1748
  }
1672
- searchEpisodes(query, limit = 8) {
1749
+ searchEpisodes(query, limit = 8, scope) {
1673
1750
  const ftsQuery = escapeFtsQuery2(query);
1751
+ const scopeWhere = buildScopeWhere2(scope);
1674
1752
  return this.database.prepare(
1675
1753
  `
1676
1754
  SELECT
@@ -1698,11 +1776,12 @@ var EpisodeRepository = class {
1698
1776
  JOIN memory_episodes e ON e.id = fts.episode_id
1699
1777
  JOIN chats c ON c.id = e.chat_id
1700
1778
  WHERE memory_episodes_fts MATCH ?
1779
+ ${scopeWhere.where}
1701
1780
  GROUP BY e.id
1702
1781
  ORDER BY e.ended_at DESC
1703
1782
  LIMIT ?
1704
1783
  `
1705
- ).all(ftsQuery, limit).map((row) => {
1784
+ ).all(ftsQuery, ...scopeWhere.params, limit).map((row) => {
1706
1785
  const item = row;
1707
1786
  return {
1708
1787
  ...item,
@@ -1732,8 +1811,8 @@ var EpisodeFtsRetriever = class {
1732
1811
  this.episodes = episodes;
1733
1812
  }
1734
1813
  episodes;
1735
- async retrieve(question) {
1736
- return this.episodes.searchEpisodes(question, 8).map(toEpisodeEvidence);
1814
+ async retrieve(question, scope) {
1815
+ return this.episodes.searchEpisodes(question, 8, scope).map(toEpisodeEvidence);
1737
1816
  }
1738
1817
  };
1739
1818
 
@@ -1759,8 +1838,9 @@ var HybridRetriever = class {
1759
1838
  }
1760
1839
  retrievers;
1761
1840
  options;
1762
- async retrieve(question) {
1763
- const results = await Promise.all(this.retrievers.map((retriever) => retriever.retrieve(question)));
1841
+ async retrieve(question, scope) {
1842
+ const effectiveScope = scope ?? this.options.scope;
1843
+ const results = await Promise.all(this.retrievers.map((retriever) => retriever.retrieve(question, effectiveScope)));
1764
1844
  const merged = /* @__PURE__ */ new Map();
1765
1845
  for (const evidenceList of results) {
1766
1846
  for (const evidence of evidenceList) {
@@ -1801,9 +1881,10 @@ var MessageFtsRetriever = class {
1801
1881
  }
1802
1882
  messages;
1803
1883
  options;
1804
- async retrieve(question) {
1884
+ async retrieve(question, scope) {
1805
1885
  const results = this.messages.searchMessages(question, 8, {
1806
- excludeMessageIds: this.options.excludeMessageIds
1886
+ excludeMessageIds: this.options.excludeMessageIds,
1887
+ scope
1807
1888
  });
1808
1889
  return results.map((result) => ({
1809
1890
  id: result.chunkId,
@@ -1838,17 +1919,17 @@ function parseSearchInput(input2) {
1838
1919
  const limit = Math.min(12, Math.max(1, Math.floor(numericLimit)));
1839
1920
  return { query, limit };
1840
1921
  }
1841
- async function runRetriever(retriever, input2) {
1922
+ async function runRetriever(retriever, input2, scope) {
1842
1923
  const { query, limit } = parseSearchInput(input2);
1843
- const results = await retriever.retrieve(query);
1924
+ const results = await retriever.retrieve(query, scope);
1844
1925
  return results.slice(0, limit);
1845
1926
  }
1846
- function createSearchTool(name, description, retriever) {
1927
+ function createSearchTool(name, description, retriever, scope) {
1847
1928
  return {
1848
1929
  name,
1849
1930
  description,
1850
1931
  inputSchema: searchInputSchema,
1851
- execute: (input2) => runRetriever(retriever, input2)
1932
+ execute: (input2) => runRetriever(retriever, input2, scope)
1852
1933
  };
1853
1934
  }
1854
1935
  function createRagSearchTools(input2) {
@@ -1856,17 +1937,20 @@ function createRagSearchTools(input2) {
1856
1937
  createSearchTool(
1857
1938
  "hybrid_search",
1858
1939
  "Search across all indexed RAG evidence using the default hybrid retrieval strategy.",
1859
- input2.hybrid
1940
+ input2.hybrid,
1941
+ input2.scope
1860
1942
  ),
1861
1943
  createSearchTool(
1862
1944
  "search_messages",
1863
1945
  "Search chat messages only when the answer likely depends on message-level evidence.",
1864
- input2.messages
1946
+ input2.messages,
1947
+ input2.scope
1865
1948
  ),
1866
1949
  createSearchTool(
1867
1950
  "search_episodes",
1868
1951
  "Search episode summaries only when the answer likely depends on longer-running context.",
1869
- input2.episodes
1952
+ input2.episodes,
1953
+ input2.scope
1870
1954
  )
1871
1955
  ];
1872
1956
  if (input2.semantic) {
@@ -1874,7 +1958,8 @@ function createRagSearchTools(input2) {
1874
1958
  createSearchTool(
1875
1959
  "semantic_search",
1876
1960
  "Search semantic vector evidence only when broader conceptual recall is needed.",
1877
- input2.semantic
1961
+ input2.semantic,
1962
+ input2.scope
1878
1963
  )
1879
1964
  );
1880
1965
  }
@@ -1919,6 +2004,22 @@ function toEvidenceSource2(row) {
1919
2004
  timestamp: row.sentAt
1920
2005
  };
1921
2006
  }
2007
+ function buildScopeWhere3(scope) {
2008
+ const clauses = [];
2009
+ const params = [];
2010
+ if (scope?.platform) {
2011
+ clauses.push("m.platform = ?");
2012
+ params.push(scope.platform);
2013
+ }
2014
+ if (scope?.platformChatId) {
2015
+ clauses.push("c.platform_chat_id = ?");
2016
+ params.push(scope.platformChatId);
2017
+ }
2018
+ return {
2019
+ where: clauses.length > 0 ? `AND ${clauses.join(" AND ")}` : "",
2020
+ params
2021
+ };
2022
+ }
1922
2023
  var SqliteVectorStore = class {
1923
2024
  constructor(database, options) {
1924
2025
  this.database = database;
@@ -1953,10 +2054,11 @@ var SqliteVectorStore = class {
1953
2054
  });
1954
2055
  transaction(records);
1955
2056
  }
1956
- async search(vector, limit) {
2057
+ async search(vector, limit, scope) {
1957
2058
  if (limit <= 0) {
1958
2059
  return [];
1959
2060
  }
2061
+ const scopeWhere = buildScopeWhere3(scope);
1960
2062
  const rows = this.database.prepare(
1961
2063
  `
1962
2064
  SELECT
@@ -1971,8 +2073,9 @@ var SqliteVectorStore = class {
1971
2073
  JOIN messages m ON m.id = mc.message_id
1972
2074
  JOIN chats c ON c.id = m.chat_id
1973
2075
  WHERE e.model = ?
2076
+ ${scopeWhere.where}
1974
2077
  `
1975
- ).all(this.options.model);
2078
+ ).all(this.options.model, ...scopeWhere.params);
1976
2079
  return rows.flatMap((row) => {
1977
2080
  const storedVector = parseEmbeddingJson(row.embeddingJson);
1978
2081
  if (storedVector.length === 0) {
@@ -2004,9 +2107,9 @@ var VectorRetriever = class {
2004
2107
  embedding;
2005
2108
  store;
2006
2109
  limit;
2007
- async retrieve(question) {
2110
+ async retrieve(question, scope) {
2008
2111
  const vector = await this.embedding.embed(question);
2009
- return this.store.search(vector, this.limit);
2112
+ return this.store.search(vector, this.limit, scope);
2010
2113
  }
2011
2114
  };
2012
2115
 
@@ -2027,7 +2130,7 @@ async function createHybridRetriever(input2) {
2027
2130
  retrievers.push(new VectorRetriever(createEmbeddingModel(input2.config, input2.secrets), vectorStore));
2028
2131
  }
2029
2132
  return {
2030
- retriever: new HybridRetriever(retrievers),
2133
+ retriever: new HybridRetriever(retrievers, { scope: input2.scope }),
2031
2134
  close: () => {
2032
2135
  for (const closer of closers) {
2033
2136
  closer();
@@ -2044,7 +2147,7 @@ async function createAgenticRagSearchTools(input2) {
2044
2147
  ) : void 0;
2045
2148
  const hybrid = new HybridRetriever(semantic ? [episodes, messages, semantic] : [episodes, messages]);
2046
2149
  return {
2047
- tools: createRagSearchTools({ hybrid, messages, episodes, semantic }),
2150
+ tools: createRagSearchTools({ hybrid, messages, episodes, semantic, scope: input2.scope }),
2048
2151
  close: () => {
2049
2152
  }
2050
2153
  };
@@ -2617,182 +2720,773 @@ async function ensureFeishuBotOpenId(config, secrets, options = {}) {
2617
2720
  // src/feishu/gateway.ts
2618
2721
  import * as lark2 from "@larksuiteoapi/node-sdk";
2619
2722
 
2620
- // src/multimodal/tasks.ts
2723
+ // src/cron/jobs.ts
2621
2724
  import crypto4 from "crypto";
2622
- function nowIso4() {
2623
- return (/* @__PURE__ */ new Date()).toISOString();
2725
+
2726
+ // src/cron/schedule.ts
2727
+ function isValidCronSchedule(schedule) {
2728
+ return parseCronSchedule(schedule) !== null;
2624
2729
  }
2625
- function stableId3(sourceMessageId, imageKey) {
2626
- return crypto4.createHash("sha256").update(`${sourceMessageId}${imageKey}`).digest("hex").slice(0, 32);
2730
+ function getNextCronRun(schedule, after) {
2731
+ const parsed = parseCronSchedule(schedule);
2732
+ if (!parsed) {
2733
+ return null;
2734
+ }
2735
+ const candidate = new Date(after);
2736
+ candidate.setSeconds(0, 0);
2737
+ candidate.setMinutes(candidate.getMinutes() + 1);
2738
+ const maxMinutes = 5 * 366 * 24 * 60;
2739
+ for (let i = 0; i < maxMinutes; i += 1) {
2740
+ if (matchesParsedSchedule(parsed, candidate)) {
2741
+ return new Date(candidate);
2742
+ }
2743
+ candidate.setMinutes(candidate.getMinutes() + 1);
2744
+ }
2745
+ return null;
2627
2746
  }
2628
- function mapRow(row) {
2629
- if (!row) {
2630
- return void 0;
2747
+ function matchesParsedSchedule(schedule, date) {
2748
+ const dayOfMonthMatches = schedule.dayOfMonth.matches(date.getDate());
2749
+ const dayOfWeekMatches = schedule.dayOfWeek.matches(date.getDay());
2750
+ const dayMatches = schedule.dayOfMonth.wildcard || schedule.dayOfWeek.wildcard ? dayOfMonthMatches && dayOfWeekMatches : dayOfMonthMatches || dayOfWeekMatches;
2751
+ return schedule.minute.matches(date.getMinutes()) && schedule.hour.matches(date.getHours()) && dayMatches && schedule.month.matches(date.getMonth() + 1);
2752
+ }
2753
+ function parseCronSchedule(schedule) {
2754
+ const fields = schedule.trim().split(/\s+/);
2755
+ if (fields.length !== 5) {
2756
+ return null;
2631
2757
  }
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
- };
2758
+ const minute = parseMinuteField(fields[0]);
2759
+ const hour = parseExactOrWildcardField(fields[1], 0, 23);
2760
+ const dayOfMonth = parseExactOrWildcardField(fields[2], 1, 31);
2761
+ const month = parseExactOrWildcardField(fields[3], 1, 12);
2762
+ const dayOfWeek = parseExactOrWildcardField(fields[4], 0, 6);
2763
+ if (!minute || !hour || !dayOfMonth || !month || !dayOfWeek) {
2764
+ return null;
2765
+ }
2766
+ return { minute, hour, dayOfMonth, month, dayOfWeek };
2646
2767
  }
2647
- var ImageMultimodalTaskRepository = class {
2648
- constructor(database) {
2768
+ function parseMinuteField(field) {
2769
+ if (field === "*") {
2770
+ return { wildcard: true, matches: () => true };
2771
+ }
2772
+ const stepMatch = /^\*\/(\d+)$/.exec(field);
2773
+ if (stepMatch) {
2774
+ const step = Number(stepMatch[1]);
2775
+ if (!Number.isInteger(step) || step <= 0 || step > 59) {
2776
+ return null;
2777
+ }
2778
+ return { wildcard: false, matches: (value) => value % step === 0 };
2779
+ }
2780
+ if (field.includes(",")) {
2781
+ const values = field.split(",").map((part) => parseExactNumber(part, 0, 59));
2782
+ if (values.some((value) => value === null)) {
2783
+ return null;
2784
+ }
2785
+ const allowed = new Set(values);
2786
+ return { wildcard: false, matches: (value) => allowed.has(value) };
2787
+ }
2788
+ const exact = parseExactNumber(field, 0, 59);
2789
+ if (exact === null) {
2790
+ return null;
2791
+ }
2792
+ return { wildcard: false, matches: (value) => value === exact };
2793
+ }
2794
+ function parseExactOrWildcardField(field, min, max) {
2795
+ if (field === "*") {
2796
+ return { wildcard: true, matches: () => true };
2797
+ }
2798
+ const exact = parseExactNumber(field, min, max);
2799
+ if (exact === null) {
2800
+ return null;
2801
+ }
2802
+ return { wildcard: false, matches: (value) => value === exact };
2803
+ }
2804
+ function parseExactNumber(field, min, max) {
2805
+ if (!/^\d+$/.test(field)) {
2806
+ return null;
2807
+ }
2808
+ const value = Number(field);
2809
+ if (!Number.isInteger(value) || value < min || value > max) {
2810
+ return null;
2811
+ }
2812
+ return value;
2813
+ }
2814
+
2815
+ // src/cron/jobs.ts
2816
+ var CronJobRepository = class {
2817
+ constructor(database, options = {}) {
2649
2818
  this.database = database;
2819
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
2650
2820
  }
2651
2821
  database;
2652
- enqueue(input2) {
2653
- const id = stableId3(input2.sourceMessageId, input2.imageKey);
2654
- const timestamp = nowIso4();
2822
+ now;
2823
+ create(input2) {
2824
+ const schedule = input2.schedule.trim();
2825
+ const prompt = input2.prompt.trim();
2826
+ if (!isValidCronSchedule(schedule)) {
2827
+ throw new Error("cron \u8868\u8FBE\u5F0F\u65E0\u6548\u3002");
2828
+ }
2829
+ if (!prompt) {
2830
+ throw new Error("\u5B9A\u65F6\u4EFB\u52A1 prompt \u4E0D\u80FD\u4E3A\u7A7A\u3002");
2831
+ }
2832
+ const now = this.now();
2833
+ const nextRunAt = getNextCronRun(schedule, now);
2834
+ if (!nextRunAt) {
2835
+ throw new Error("\u65E0\u6CD5\u8BA1\u7B97\u4E0B\u4E00\u6B21\u6267\u884C\u65F6\u95F4\u3002");
2836
+ }
2837
+ const record = {
2838
+ id: crypto4.randomUUID(),
2839
+ chatId: input2.chatId,
2840
+ createdByOpenId: input2.createdByOpenId,
2841
+ schedule,
2842
+ prompt,
2843
+ status: "active",
2844
+ nextRunAt: nextRunAt.toISOString(),
2845
+ createdAt: now.toISOString(),
2846
+ updatedAt: now.toISOString()
2847
+ };
2655
2848
  this.database.prepare(
2656
2849
  `
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
- }
2850
+ INSERT INTO cron_jobs (
2851
+ id, chat_id, created_by_open_id, schedule, prompt, status,
2852
+ last_run_at, next_run_at, last_error, created_at, updated_at
2853
+ )
2854
+ VALUES (
2855
+ @id, @chatId, @createdByOpenId, @schedule, @prompt, @status,
2856
+ NULL, @nextRunAt, NULL, @createdAt, @updatedAt
2857
+ )
2858
+ `
2859
+ ).run(record);
2706
2860
  return record;
2707
2861
  }
2708
- listPending(limit = 10) {
2862
+ get(id) {
2863
+ return this.listByWhere("WHERE id = ?", [id], 1)[0] ?? null;
2864
+ }
2865
+ list(limit = 100) {
2866
+ return this.listByWhere("", [], limit);
2867
+ }
2868
+ listByChat(chatId, limit = 50) {
2869
+ return this.listByWhere(
2870
+ "WHERE chat_id = ? AND status = 'active'",
2871
+ [chatId],
2872
+ limit
2873
+ );
2874
+ }
2875
+ listDue(now, limit = 20) {
2709
2876
  const rows = this.database.prepare(
2710
2877
  `
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));
2878
+ SELECT
2879
+ id,
2880
+ chat_id AS chatId,
2881
+ created_by_open_id AS createdByOpenId,
2882
+ schedule,
2883
+ prompt,
2884
+ status,
2885
+ last_run_at AS lastRunAt,
2886
+ next_run_at AS nextRunAt,
2887
+ last_error AS lastError,
2888
+ created_at AS createdAt,
2889
+ updated_at AS updatedAt
2890
+ FROM cron_jobs
2891
+ WHERE status = 'active' AND next_run_at <= ?
2892
+ ORDER BY next_run_at ASC, updated_at ASC
2893
+ LIMIT ?
2894
+ `
2895
+ ).all(now.toISOString(), limit);
2896
+ return rows.map((row) => ({
2897
+ id: row.id,
2898
+ chatId: row.chatId,
2899
+ createdByOpenId: row.createdByOpenId ?? void 0,
2900
+ schedule: row.schedule,
2901
+ prompt: row.prompt,
2902
+ status: row.status,
2903
+ lastRunAt: row.lastRunAt ?? void 0,
2904
+ nextRunAt: row.nextRunAt,
2905
+ lastError: row.lastError ?? void 0,
2906
+ createdAt: row.createdAt,
2907
+ updatedAt: row.updatedAt
2908
+ }));
2731
2909
  }
2732
- markRunning(id) {
2910
+ deleteByChat(id, chatId) {
2911
+ const now = this.now().toISOString();
2733
2912
  const result = this.database.prepare(
2734
2913
  `
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(
2914
+ UPDATE cron_jobs
2915
+ SET status = 'deleted', updated_at = @updatedAt
2916
+ WHERE id = @id AND chat_id = @chatId AND status = 'active'
2750
2917
  `
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);
2918
+ ).run({ id, chatId, updatedAt: now });
2919
+ return result.changes > 0;
2760
2920
  }
2761
- markSkipped(id, reason) {
2921
+ markSuccess(id, ranAt) {
2922
+ const job = this.get(id);
2923
+ if (!job) {
2924
+ return;
2925
+ }
2926
+ const nextRunAt = getNextCronRun(job.schedule, ranAt);
2927
+ if (!nextRunAt) {
2928
+ throw new Error("\u65E0\u6CD5\u8BA1\u7B97\u4E0B\u4E00\u6B21\u6267\u884C\u65F6\u95F4\u3002");
2929
+ }
2762
2930
  this.database.prepare(
2763
2931
  `
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);
2932
+ UPDATE cron_jobs
2933
+ SET last_run_at = @lastRunAt, next_run_at = @nextRunAt, last_error = NULL, updated_at = @updatedAt
2934
+ WHERE id = @id AND status = 'active'
2935
+ `
2936
+ ).run({
2937
+ id,
2938
+ lastRunAt: ranAt.toISOString(),
2939
+ nextRunAt: nextRunAt.toISOString(),
2940
+ updatedAt: ranAt.toISOString()
2941
+ });
2773
2942
  }
2774
- markFailed(id, error, finalFailure) {
2943
+ markFailure(id, error, failedAt) {
2944
+ const job = this.get(id);
2945
+ if (!job) {
2946
+ return;
2947
+ }
2948
+ const nextRunAt = getNextCronRun(job.schedule, failedAt);
2949
+ if (!nextRunAt) {
2950
+ throw new Error("\u65E0\u6CD5\u8BA1\u7B97\u4E0B\u4E00\u6B21\u6267\u884C\u65F6\u95F4\u3002");
2951
+ }
2775
2952
  this.database.prepare(
2776
2953
  `
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);
2954
+ UPDATE cron_jobs
2955
+ SET last_run_at = @lastRunAt, last_error = @lastError, next_run_at = @nextRunAt, updated_at = @updatedAt
2956
+ WHERE id = @id AND status = 'active'
2957
+ `
2958
+ ).run({
2959
+ id,
2960
+ lastRunAt: failedAt.toISOString(),
2961
+ lastError: error,
2962
+ nextRunAt: nextRunAt.toISOString(),
2963
+ updatedAt: failedAt.toISOString()
2964
+ });
2786
2965
  }
2787
- getById(id) {
2788
- const row = this.database.prepare(
2966
+ listByWhere(whereSql, params, limit) {
2967
+ const rows = this.database.prepare(
2789
2968
  `
2790
- SELECT
2791
- id,
2792
- source_message_id,
2793
- platform_message_id,
2794
- image_key,
2795
- stored_path,
2969
+ SELECT
2970
+ id,
2971
+ chat_id AS chatId,
2972
+ created_by_open_id AS createdByOpenId,
2973
+ schedule,
2974
+ prompt,
2975
+ status,
2976
+ last_run_at AS lastRunAt,
2977
+ next_run_at AS nextRunAt,
2978
+ last_error AS lastError,
2979
+ created_at AS createdAt,
2980
+ updated_at AS updatedAt
2981
+ FROM cron_jobs
2982
+ ${whereSql}
2983
+ ORDER BY updated_at DESC
2984
+ LIMIT ?
2985
+ `
2986
+ ).all(...params, limit);
2987
+ return rows.map((row) => ({
2988
+ id: row.id,
2989
+ chatId: row.chatId,
2990
+ createdByOpenId: row.createdByOpenId ?? void 0,
2991
+ schedule: row.schedule,
2992
+ prompt: row.prompt,
2993
+ status: row.status,
2994
+ lastRunAt: row.lastRunAt ?? void 0,
2995
+ nextRunAt: row.nextRunAt,
2996
+ lastError: row.lastError ?? void 0,
2997
+ createdAt: row.createdAt,
2998
+ updatedAt: row.updatedAt
2999
+ }));
3000
+ }
3001
+ };
3002
+
3003
+ // src/cron/generator.ts
3004
+ var SYSTEM_PROMPT = "\u4F60\u6B63\u5728\u4E3A\u98DE\u4E66\u7FA4\u751F\u6210\u4E00\u6761\u5B9A\u65F6\u6D88\u606F\u3002\u53EF\u4EE5\u5148\u8C03\u7528\u641C\u7D22\u5DE5\u5177\u68C0\u7D22\u672C\u5730\u7FA4\u804A\u77E5\u8BC6\u5E93\u3002\u6700\u7EC8\u8F93\u51FA\u5FC5\u987B\u662F\u53EF\u4EE5\u76F4\u63A5\u53D1\u5230\u7FA4\u91CC\u7684\u7EAF\u6587\u672C\uFF0C\u4E0D\u8981\u8F93\u51FA\u5DE5\u5177\u8C03\u7528\u8BF4\u660E\u3002";
3005
+ function evidenceToText(evidence) {
3006
+ if (evidence.length === 0) {
3007
+ return "\u65E0\u68C0\u7D22\u8BC1\u636E\u3002";
3008
+ }
3009
+ return evidence.map((item, index2) => `${index2 + 1}. ${item.text}`).join("\n");
3010
+ }
3011
+ function toolResultContent(results) {
3012
+ return JSON.stringify(results.map((item) => ({ id: item.id, text: item.text, score: item.score, source: item.source })));
3013
+ }
3014
+ async function generateCronJobMessage(input2) {
3015
+ if (!input2.model.completeWithTools) {
3016
+ throw new Error("\u5F53\u524D LLM \u5BA2\u6237\u7AEF\u4E0D\u652F\u6301\u5DE5\u5177\u8C03\u7528\u3002");
3017
+ }
3018
+ const messages = [
3019
+ { role: "system", content: SYSTEM_PROMPT },
3020
+ { role: "user", content: `\u5F53\u524D\u65F6\u95F4\uFF1A${input2.now.toISOString()}
3021
+ \u4EFB\u52A1\u63D0\u793A\u8BCD\uFF1A${input2.prompt}` }
3022
+ ];
3023
+ const toolsByName = new Map(input2.tools.map((tool) => [tool.name, tool]));
3024
+ const evidence = [];
3025
+ const maxModelTurns = input2.maxModelTurns ?? 3;
3026
+ const maxToolCalls = input2.maxToolCalls ?? 6;
3027
+ let toolCallsUsed = 0;
3028
+ for (let turn = 0; turn < maxModelTurns; turn += 1) {
3029
+ const result = await input2.model.completeWithTools(messages, input2.tools);
3030
+ messages.push({ role: "assistant", content: result.content, toolCalls: result.toolCalls });
3031
+ if (result.toolCalls.length === 0) {
3032
+ break;
3033
+ }
3034
+ for (const call of result.toolCalls) {
3035
+ if (toolCallsUsed >= maxToolCalls) {
3036
+ return input2.model.complete([
3037
+ { role: "system", content: SYSTEM_PROMPT },
3038
+ {
3039
+ role: "user",
3040
+ content: `\u5F53\u524D\u65F6\u95F4\uFF1A${input2.now.toISOString()}
3041
+ \u4EFB\u52A1\u63D0\u793A\u8BCD\uFF1A${input2.prompt}
3042
+
3043
+ \u8BC1\u636E\uFF1A
3044
+ ${evidenceToText(evidence)}`
3045
+ }
3046
+ ]);
3047
+ }
3048
+ toolCallsUsed += 1;
3049
+ const tool = toolsByName.get(call.name);
3050
+ if (!tool) {
3051
+ messages.push({ role: "tool", toolCallId: call.id, content: JSON.stringify({ error: `\u672A\u77E5\u5DE5\u5177\uFF1A${call.name}` }) });
3052
+ continue;
3053
+ }
3054
+ try {
3055
+ const results = await tool.execute(call.input);
3056
+ evidence.push(...results);
3057
+ messages.push({ role: "tool", toolCallId: call.id, content: toolResultContent(results) });
3058
+ } catch (error) {
3059
+ const message = error instanceof Error ? error.message : String(error);
3060
+ messages.push({ role: "tool", toolCallId: call.id, content: JSON.stringify({ error: message }) });
3061
+ }
3062
+ }
3063
+ }
3064
+ return input2.model.complete([
3065
+ { role: "system", content: SYSTEM_PROMPT },
3066
+ {
3067
+ role: "user",
3068
+ content: `\u5F53\u524D\u65F6\u95F4\uFF1A${input2.now.toISOString()}
3069
+ \u4EFB\u52A1\u63D0\u793A\u8BCD\uFF1A${input2.prompt}
3070
+
3071
+ \u8BC1\u636E\uFF1A
3072
+ ${evidenceToText(evidence)}`
3073
+ }
3074
+ ]);
3075
+ }
3076
+
3077
+ // src/cron/scheduler.ts
3078
+ function createCronJobScheduler(options) {
3079
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
3080
+ const setIntervalFn = options.setIntervalFn ?? setInterval;
3081
+ const clearIntervalFn = options.clearIntervalFn ?? clearInterval;
3082
+ const logger = options.logger ?? console;
3083
+ let timer;
3084
+ let running = false;
3085
+ const runDueNow = async () => {
3086
+ if (running) {
3087
+ return;
3088
+ }
3089
+ running = true;
3090
+ const startedAt = now();
3091
+ try {
3092
+ const jobs = options.repository.listDue(startedAt);
3093
+ for (const job of jobs) {
3094
+ try {
3095
+ const text = await options.generateMessage(job, startedAt);
3096
+ await options.sendTextToChat(job.chatId, text);
3097
+ options.repository.markSuccess(job.id, startedAt);
3098
+ } catch (error) {
3099
+ const message = error instanceof Error ? error.message : String(error);
3100
+ options.repository.markFailure(job.id, message, startedAt);
3101
+ logger.error(`CRONJob \u6267\u884C\u5931\u8D25\uFF1A${job.id} ${message}`);
3102
+ }
3103
+ }
3104
+ } finally {
3105
+ running = false;
3106
+ }
3107
+ };
3108
+ return {
3109
+ start() {
3110
+ if (timer) {
3111
+ return;
3112
+ }
3113
+ void runDueNow();
3114
+ timer = setIntervalFn(() => {
3115
+ void runDueNow();
3116
+ }, 6e4);
3117
+ },
3118
+ stop() {
3119
+ if (!timer) {
3120
+ return;
3121
+ }
3122
+ clearIntervalFn(timer);
3123
+ timer = void 0;
3124
+ },
3125
+ runDueNow
3126
+ };
3127
+ }
3128
+
3129
+ // src/gateway/indexing-scheduler.ts
3130
+ function createIndexingScheduler(options) {
3131
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
3132
+ const setIntervalFn = options.setIntervalFn ?? setInterval;
3133
+ const clearIntervalFn = options.clearIntervalFn ?? clearInterval;
3134
+ const logger = options.logger ?? console;
3135
+ const parsed = parseCronSchedule2(options.schedule);
3136
+ let timer;
3137
+ let running = false;
3138
+ const runDueNow = async () => {
3139
+ if (!parsed || running || !matchesParsedSchedule2(parsed, now())) {
3140
+ return;
3141
+ }
3142
+ running = true;
3143
+ try {
3144
+ await options.work();
3145
+ } catch (error) {
3146
+ const message = error instanceof Error ? error.message : String(error);
3147
+ logger.error(`\u5B9A\u65F6\u6D88\u606F\u7D22\u5F15\u5931\u8D25\uFF1A${message}`);
3148
+ } finally {
3149
+ running = false;
3150
+ }
3151
+ };
3152
+ return {
3153
+ start() {
3154
+ if (!parsed || timer) {
3155
+ return;
3156
+ }
3157
+ timer = setIntervalFn(() => {
3158
+ void runDueNow();
3159
+ }, 6e4);
3160
+ },
3161
+ stop() {
3162
+ if (!timer) {
3163
+ return;
3164
+ }
3165
+ clearIntervalFn(timer);
3166
+ timer = void 0;
3167
+ },
3168
+ runDueNow
3169
+ };
3170
+ }
3171
+ function matchesParsedSchedule2(schedule, date) {
3172
+ return schedule.minute(date.getMinutes()) && schedule.hour(date.getHours()) && schedule.dayOfMonth(date.getDate()) && schedule.month(date.getMonth() + 1) && schedule.dayOfWeek(date.getDay());
3173
+ }
3174
+ function parseCronSchedule2(schedule) {
3175
+ const fields = schedule.trim().split(/\s+/);
3176
+ if (fields.length !== 5) {
3177
+ return null;
3178
+ }
3179
+ const minute = parseMinuteField2(fields[0]);
3180
+ const hour = parseExactOrWildcardField2(fields[1], 0, 23);
3181
+ const dayOfMonth = parseExactOrWildcardField2(fields[2], 1, 31);
3182
+ const month = parseExactOrWildcardField2(fields[3], 1, 12);
3183
+ const dayOfWeek = parseExactOrWildcardField2(fields[4], 0, 6);
3184
+ if (!minute || !hour || !dayOfMonth || !month || !dayOfWeek) {
3185
+ return null;
3186
+ }
3187
+ return { minute, hour, dayOfMonth, month, dayOfWeek };
3188
+ }
3189
+ function parseMinuteField2(field) {
3190
+ if (field === "*") {
3191
+ return () => true;
3192
+ }
3193
+ const stepMatch = /^\*\/(\d+)$/.exec(field);
3194
+ if (stepMatch) {
3195
+ const step = Number(stepMatch[1]);
3196
+ if (!Number.isInteger(step) || step <= 0 || step > 59) {
3197
+ return null;
3198
+ }
3199
+ return (value) => value % step === 0;
3200
+ }
3201
+ if (field.includes(",")) {
3202
+ const values = field.split(",").map((part) => parseExactNumber2(part, 0, 59));
3203
+ if (values.some((value) => value === null)) {
3204
+ return null;
3205
+ }
3206
+ const allowed = new Set(values);
3207
+ return (value) => allowed.has(value);
3208
+ }
3209
+ const exact = parseExactNumber2(field, 0, 59);
3210
+ if (exact === null) {
3211
+ return null;
3212
+ }
3213
+ return (value) => value === exact;
3214
+ }
3215
+ function parseExactOrWildcardField2(field, min, max) {
3216
+ if (field === "*") {
3217
+ return () => true;
3218
+ }
3219
+ const exact = parseExactNumber2(field, min, max);
3220
+ if (exact === null) {
3221
+ return null;
3222
+ }
3223
+ return (value) => value === exact;
3224
+ }
3225
+ function parseExactNumber2(field, min, max) {
3226
+ if (!/^\d+$/.test(field)) {
3227
+ return null;
3228
+ }
3229
+ const value = Number(field);
3230
+ if (!Number.isInteger(value) || value < min || value > max) {
3231
+ return null;
3232
+ }
3233
+ return value;
3234
+ }
3235
+
3236
+ // src/rag/indexer.ts
3237
+ async function indexMessageChunks(input2) {
3238
+ const chunks = input2.messageIds ? input2.messages.listMessageChunksByMessageIds(input2.messageIds, input2.limit ?? 1e4) : input2.messages.listAllMessageChunks(input2.limit ?? 1e4);
3239
+ if (chunks.length === 0) {
3240
+ return { chunks: 0, vectors: 0 };
3241
+ }
3242
+ const vectors = await input2.embedding.embedBatch(chunks.map((chunk) => chunk.text));
3243
+ const records = [];
3244
+ for (const [index2, chunk] of chunks.entries()) {
3245
+ const vector = vectors[index2];
3246
+ if (!vector || vector.length === 0) {
3247
+ continue;
3248
+ }
3249
+ records.push({
3250
+ id: chunk.chunkId,
3251
+ vector,
3252
+ evidence: {
3253
+ id: chunk.chunkId,
3254
+ text: chunk.text,
3255
+ score: 1,
3256
+ source: toEvidenceSource3(chunk)
3257
+ }
3258
+ });
3259
+ }
3260
+ await input2.store.upsert(records);
3261
+ return {
3262
+ chunks: chunks.length,
3263
+ vectors: records.length
3264
+ };
3265
+ }
3266
+ function toEvidenceSource3(chunk) {
3267
+ if (chunk.messageType === "file") {
3268
+ return {
3269
+ type: "file",
3270
+ label: chunk.senderName,
3271
+ timestamp: chunk.sentAt
3272
+ };
3273
+ }
3274
+ return {
3275
+ type: "message",
3276
+ label: chunk.chatName,
3277
+ sender: chunk.senderName,
3278
+ timestamp: chunk.sentAt
3279
+ };
3280
+ }
3281
+
3282
+ // src/rag/manual-index.ts
3283
+ async function processMessagesNow(input2) {
3284
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3285
+ if (!hasEmbeddingConfig(input2.config, input2.secrets)) {
3286
+ return {
3287
+ status: "skipped",
3288
+ reason: "Embedding \u914D\u7F6E\u4E0D\u5B8C\u6574\uFF1BSQLite FTS \u5DF2\u5728\u6D88\u606F\u5165\u5E93\u65F6\u5373\u65F6\u66F4\u65B0\u3002",
3289
+ chunks: 0,
3290
+ vectors: 0,
3291
+ startedAt,
3292
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString()
3293
+ };
3294
+ }
3295
+ const vectorStore = new SqliteVectorStore(input2.database, {
3296
+ model: input2.config.embedding.model
3297
+ });
3298
+ const embedding = input2.embedding ?? createEmbeddingModel(input2.config, input2.secrets);
3299
+ const stats = await indexMessageChunks({
3300
+ messages: new MessageRepository(input2.database),
3301
+ embedding,
3302
+ store: vectorStore,
3303
+ limit: input2.limit
3304
+ });
3305
+ return {
3306
+ status: "completed",
3307
+ chunks: stats.chunks,
3308
+ vectors: stats.vectors,
3309
+ startedAt,
3310
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString()
3311
+ };
3312
+ }
3313
+
3314
+ // src/multimodal/tasks.ts
3315
+ import crypto5 from "crypto";
3316
+ function nowIso4() {
3317
+ return (/* @__PURE__ */ new Date()).toISOString();
3318
+ }
3319
+ function stableId3(sourceMessageId, imageKey) {
3320
+ return crypto5.createHash("sha256").update(`${sourceMessageId}${imageKey}`).digest("hex").slice(0, 32);
3321
+ }
3322
+ function mapRow(row) {
3323
+ if (!row) {
3324
+ return void 0;
3325
+ }
3326
+ return {
3327
+ id: row.id,
3328
+ sourceMessageId: row.source_message_id,
3329
+ platformMessageId: row.platform_message_id,
3330
+ imageKey: row.image_key,
3331
+ storedPath: row.stored_path,
3332
+ mimeType: row.mime_type,
3333
+ status: row.status,
3334
+ attempts: row.attempts,
3335
+ ...row.last_error ? { lastError: row.last_error } : {},
3336
+ ...row.derived_message_id ? { derivedMessageId: row.derived_message_id } : {},
3337
+ createdAt: row.created_at,
3338
+ updatedAt: row.updated_at
3339
+ };
3340
+ }
3341
+ var ImageMultimodalTaskRepository = class {
3342
+ constructor(database) {
3343
+ this.database = database;
3344
+ }
3345
+ database;
3346
+ enqueue(input2) {
3347
+ const id = stableId3(input2.sourceMessageId, input2.imageKey);
3348
+ const timestamp = nowIso4();
3349
+ this.database.prepare(
3350
+ `
3351
+ INSERT INTO image_multimodal_tasks (
3352
+ id,
3353
+ source_message_id,
3354
+ platform_message_id,
3355
+ image_key,
3356
+ stored_path,
3357
+ mime_type,
3358
+ status,
3359
+ attempts,
3360
+ created_at,
3361
+ updated_at
3362
+ )
3363
+ VALUES (
3364
+ @id,
3365
+ @sourceMessageId,
3366
+ @platformMessageId,
3367
+ @imageKey,
3368
+ @storedPath,
3369
+ @mimeType,
3370
+ 'pending',
3371
+ 0,
3372
+ @createdAt,
3373
+ @updatedAt
3374
+ )
3375
+ ON CONFLICT(source_message_id, image_key)
3376
+ DO UPDATE SET
3377
+ platform_message_id = excluded.platform_message_id,
3378
+ stored_path = excluded.stored_path,
3379
+ mime_type = excluded.mime_type,
3380
+ status = 'pending',
3381
+ attempts = 0,
3382
+ last_error = NULL,
3383
+ derived_message_id = NULL,
3384
+ updated_at = excluded.updated_at
3385
+ `
3386
+ ).run({
3387
+ id,
3388
+ sourceMessageId: input2.sourceMessageId,
3389
+ platformMessageId: input2.platformMessageId,
3390
+ imageKey: input2.imageKey,
3391
+ storedPath: input2.storedPath,
3392
+ mimeType: input2.mimeType,
3393
+ createdAt: timestamp,
3394
+ updatedAt: timestamp
3395
+ });
3396
+ const record = this.getById(id);
3397
+ if (!record) {
3398
+ throw new Error(`\u56FE\u7247\u591A\u6A21\u6001\u4EFB\u52A1\u5199\u5165\u5931\u8D25\uFF1A${id}`);
3399
+ }
3400
+ return record;
3401
+ }
3402
+ listPending(limit = 10) {
3403
+ const rows = this.database.prepare(
3404
+ `
3405
+ SELECT
3406
+ id,
3407
+ source_message_id,
3408
+ platform_message_id,
3409
+ image_key,
3410
+ stored_path,
3411
+ mime_type,
3412
+ status,
3413
+ attempts,
3414
+ last_error,
3415
+ derived_message_id,
3416
+ created_at,
3417
+ updated_at
3418
+ FROM image_multimodal_tasks
3419
+ WHERE status = 'pending'
3420
+ ORDER BY updated_at ASC
3421
+ LIMIT ?
3422
+ `
3423
+ ).all(limit);
3424
+ return rows.map((row) => mapRow(row)).filter((row) => Boolean(row));
3425
+ }
3426
+ markRunning(id) {
3427
+ const result = this.database.prepare(
3428
+ `
3429
+ UPDATE image_multimodal_tasks
3430
+ SET status = 'running',
3431
+ attempts = attempts + 1,
3432
+ last_error = NULL,
3433
+ updated_at = @updatedAt
3434
+ WHERE id = @id AND status = 'pending'
3435
+ `
3436
+ ).run({ id, updatedAt: nowIso4() });
3437
+ if (result.changes === 0) {
3438
+ throw new Error(`\u56FE\u7247\u591A\u6A21\u6001\u4EFB\u52A1\u72B6\u6001\u65E0\u6CD5\u66F4\u65B0\uFF1A${id}`);
3439
+ }
3440
+ return this.requireById(id);
3441
+ }
3442
+ markSucceeded(id, derivedMessageId) {
3443
+ this.database.prepare(
3444
+ `
3445
+ UPDATE image_multimodal_tasks
3446
+ SET status = 'succeeded',
3447
+ last_error = NULL,
3448
+ derived_message_id = @derivedMessageId,
3449
+ updated_at = @updatedAt
3450
+ WHERE id = @id
3451
+ `
3452
+ ).run({ id, derivedMessageId, updatedAt: nowIso4() });
3453
+ return this.requireById(id);
3454
+ }
3455
+ markSkipped(id, reason) {
3456
+ this.database.prepare(
3457
+ `
3458
+ UPDATE image_multimodal_tasks
3459
+ SET status = 'skipped',
3460
+ last_error = @reason,
3461
+ derived_message_id = NULL,
3462
+ updated_at = @updatedAt
3463
+ WHERE id = @id
3464
+ `
3465
+ ).run({ id, reason, updatedAt: nowIso4() });
3466
+ return this.requireById(id);
3467
+ }
3468
+ markFailed(id, error, finalFailure) {
3469
+ this.database.prepare(
3470
+ `
3471
+ UPDATE image_multimodal_tasks
3472
+ SET status = @status,
3473
+ last_error = @error,
3474
+ derived_message_id = NULL,
3475
+ updated_at = @updatedAt
3476
+ WHERE id = @id
3477
+ `
3478
+ ).run({ id, status: finalFailure ? "failed" : "pending", error, updatedAt: nowIso4() });
3479
+ return this.requireById(id);
3480
+ }
3481
+ getById(id) {
3482
+ const row = this.database.prepare(
3483
+ `
3484
+ SELECT
3485
+ id,
3486
+ source_message_id,
3487
+ platform_message_id,
3488
+ image_key,
3489
+ stored_path,
2796
3490
  mime_type,
2797
3491
  status,
2798
3492
  attempts,
@@ -2879,173 +3573,228 @@ var ImageMultimodalWorker = class {
2879
3573
  }
2880
3574
  };
2881
3575
 
2882
- // src/rag/citations.ts
2883
- function isOpaqueId(value) {
2884
- return Boolean(value && /^(ou|oc|om|cli|on|un|uid)_?[a-z0-9]+/i.test(value));
3576
+ // src/cron/tools.ts
3577
+ function readString(input2, key) {
3578
+ const value = typeof input2 === "object" && input2 !== null && key in input2 ? input2[key] : void 0;
3579
+ if (typeof value !== "string" || !value.trim()) {
3580
+ throw new Error(`${key} \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\u3002`);
3581
+ }
3582
+ return value.trim();
2885
3583
  }
2886
- function formatTime(value) {
2887
- if (!value) {
2888
- return "\u672A\u77E5\u65F6\u95F4";
3584
+ function createCronJobTools(input2) {
3585
+ return [
3586
+ {
3587
+ name: "create_cron_job",
3588
+ description: "Create a scheduled AI message for the current Feishu chat only. The schedule must be a five-field cron string.",
3589
+ inputSchema: {
3590
+ type: "object",
3591
+ properties: {
3592
+ schedule: {
3593
+ type: "string",
3594
+ description: "Five-field cron schedule, for example 0 9 * * *."
3595
+ },
3596
+ prompt: {
3597
+ type: "string",
3598
+ description: "Prompt used later to generate the scheduled message."
3599
+ }
3600
+ },
3601
+ required: ["schedule", "prompt"],
3602
+ additionalProperties: false
3603
+ },
3604
+ execute: async (rawInput) => {
3605
+ const job = input2.repository.create({
3606
+ chatId: input2.chatId,
3607
+ createdByOpenId: input2.createdByOpenId,
3608
+ schedule: readString(rawInput, "schedule"),
3609
+ prompt: readString(rawInput, "prompt")
3610
+ });
3611
+ return JSON.stringify({ ok: true, job });
3612
+ }
3613
+ },
3614
+ {
3615
+ name: "list_cron_jobs",
3616
+ description: "List active scheduled AI messages for the current Feishu chat only.",
3617
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
3618
+ execute: async () => JSON.stringify({ ok: true, jobs: input2.repository.listByChat(input2.chatId) })
3619
+ },
3620
+ {
3621
+ name: "delete_cron_job",
3622
+ description: "Delete a scheduled AI message by ID, only if it belongs to the current Feishu chat.",
3623
+ inputSchema: {
3624
+ type: "object",
3625
+ properties: {
3626
+ id: {
3627
+ type: "string",
3628
+ description: "Cron job ID returned by create_cron_job or list_cron_jobs."
3629
+ }
3630
+ },
3631
+ required: ["id"],
3632
+ additionalProperties: false
3633
+ },
3634
+ execute: async (rawInput) => {
3635
+ const id = readString(rawInput, "id");
3636
+ const ok = input2.repository.deleteByChat(id, input2.chatId);
3637
+ return JSON.stringify({
3638
+ ok,
3639
+ id,
3640
+ message: ok ? "\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u5220\u9664\u3002" : "\u6CA1\u6709\u627E\u5230\u5F53\u524D\u7FA4\u91CC\u7684\u8FD9\u4E2A\u5B9A\u65F6\u4EFB\u52A1\u3002"
3641
+ });
3642
+ }
3643
+ }
3644
+ ];
3645
+ }
3646
+
3647
+ // src/rag/qa-logs.ts
3648
+ import crypto6 from "crypto";
3649
+ function clampLimit(limit) {
3650
+ return Math.max(1, Math.min(200, Math.trunc(limit)));
3651
+ }
3652
+ var QaLogRepository = class {
3653
+ constructor(database) {
3654
+ this.database = database;
2889
3655
  }
2890
- const date = new Date(value);
2891
- if (Number.isNaN(date.getTime())) {
2892
- return value;
3656
+ database;
3657
+ create(input2) {
3658
+ const record = {
3659
+ id: `qa_${crypto6.randomUUID()}`,
3660
+ chatId: input2.chatId ?? null,
3661
+ questionMessageId: input2.questionMessageId ?? null,
3662
+ question: input2.question,
3663
+ answer: input2.answer,
3664
+ citations: input2.citations,
3665
+ retrievalDebug: input2.retrievalDebug,
3666
+ status: input2.status,
3667
+ error: input2.error ?? null,
3668
+ createdAt: input2.createdAt
3669
+ };
3670
+ this.database.prepare(
3671
+ `
3672
+ INSERT INTO qa_logs (
3673
+ id,
3674
+ chat_id,
3675
+ question_message_id,
3676
+ question,
3677
+ answer,
3678
+ citations_json,
3679
+ retrieval_debug_json,
3680
+ status,
3681
+ error,
3682
+ created_at
3683
+ )
3684
+ VALUES (
3685
+ @id,
3686
+ @chatId,
3687
+ @questionMessageId,
3688
+ @question,
3689
+ @answer,
3690
+ @citationsJson,
3691
+ @retrievalDebugJson,
3692
+ @status,
3693
+ @error,
3694
+ @createdAt
3695
+ )
3696
+ `
3697
+ ).run({
3698
+ id: record.id,
3699
+ chatId: record.chatId,
3700
+ questionMessageId: record.questionMessageId,
3701
+ question: record.question,
3702
+ answer: record.answer,
3703
+ citationsJson: JSON.stringify(record.citations),
3704
+ retrievalDebugJson: JSON.stringify(record.retrievalDebug),
3705
+ status: record.status,
3706
+ error: record.error,
3707
+ createdAt: record.createdAt
3708
+ });
3709
+ return record;
2893
3710
  }
2894
- const pad = (input2) => String(input2).padStart(2, "0");
2895
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
2896
- }
2897
- function formatSpeaker(source) {
2898
- if (source.type === "file") {
2899
- return isOpaqueId(source.label) ? "\u6587\u4EF6" : `\u6587\u4EF6 ${source.label}`;
3711
+ listRecent(limit) {
3712
+ const rows = this.database.prepare(
3713
+ `
3714
+ SELECT
3715
+ id,
3716
+ chat_id,
3717
+ question_message_id,
3718
+ question,
3719
+ answer,
3720
+ citations_json,
3721
+ retrieval_debug_json,
3722
+ status,
3723
+ error,
3724
+ created_at
3725
+ FROM qa_logs
3726
+ ORDER BY created_at DESC
3727
+ LIMIT ?
3728
+ `
3729
+ ).all(clampLimit(limit));
3730
+ return rows.map((row) => ({
3731
+ id: row.id,
3732
+ chatId: row.chat_id,
3733
+ questionMessageId: row.question_message_id,
3734
+ question: row.question,
3735
+ answer: row.answer,
3736
+ citations: JSON.parse(row.citations_json),
3737
+ retrievalDebug: JSON.parse(row.retrieval_debug_json),
3738
+ status: row.status,
3739
+ error: row.error,
3740
+ createdAt: row.created_at
3741
+ }));
2900
3742
  }
2901
- if (source.sender && !isOpaqueId(source.sender)) {
2902
- return source.sender;
3743
+ getCount() {
3744
+ const row = this.database.prepare("SELECT COUNT(*) AS count FROM qa_logs").get();
3745
+ return row.count;
2903
3746
  }
2904
- return "\u7FA4\u6210\u5458";
2905
- }
2906
- function clipText(value, maxLength) {
2907
- const normalized = value.replace(/\s+/g, " ").trim();
2908
- return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized;
2909
- }
2910
- function formatCitation(citation, options = {}) {
2911
- const maxTextLength = options.maxTextLength ?? 120;
2912
- const speaker = formatSpeaker(citation.source);
2913
- const time = formatTime(citation.source.timestamp);
2914
- const verb = citation.source.type === "file" ? "\u8BB0\u5F55" : "\u8BF4";
2915
- return `[${citation.marker}] ${speaker}\u5728 ${time} ${verb}\uFF1A\u201C${clipText(citation.text, maxTextLength)}\u201D`;
2916
- }
2917
- function formatCitations(citations, options = {}) {
2918
- return citations.map((citation) => formatCitation(citation, options)).join("\n");
2919
- }
3747
+ };
2920
3748
 
2921
- // src/rag/answer.ts
2922
- var DEFAULT_MAX_EVIDENCE_BLOCKS = 8;
2923
- var DEFAULT_MAX_CHARS_PER_BLOCK = 1200;
2924
- var SCORE_TIE_THRESHOLD = 0.15;
2925
- function parseTimestamp(value) {
2926
- if (!value) {
2927
- return 0;
3749
+ // src/feishu/question.ts
3750
+ function parseTextContent(content) {
3751
+ if (!content) {
3752
+ return "";
3753
+ }
3754
+ try {
3755
+ const parsed = JSON.parse(content);
3756
+ return typeof parsed.text === "string" ? parsed.text : "";
3757
+ } catch {
3758
+ return content;
2928
3759
  }
2929
- const time = Date.parse(value);
2930
- return Number.isFinite(time) ? time : 0;
2931
3760
  }
2932
- function rankEvidenceForPrompt(evidence) {
2933
- return [...evidence].sort((left, right) => {
2934
- const scoreDiff = right.score - left.score;
2935
- if (Math.abs(scoreDiff) > SCORE_TIE_THRESHOLD) {
2936
- return scoreDiff;
2937
- }
2938
- const timeDiff = parseTimestamp(right.source.timestamp) - parseTimestamp(left.source.timestamp);
2939
- if (timeDiff !== 0) {
2940
- return timeDiff;
3761
+ function stripMentions(text, mentions) {
3762
+ let result = text;
3763
+ for (const mention of mentions ?? []) {
3764
+ for (const token of [mention.key, mention.name, mention.name ? `@${mention.name}` : void 0]) {
3765
+ if (token) {
3766
+ result = result.replaceAll(token, " ");
3767
+ }
2941
3768
  }
2942
- return scoreDiff;
2943
- });
2944
- }
2945
- function buildEvidencePrompt(question, evidence, options = {}) {
2946
- if (evidence.length === 0) {
2947
- throw new Error("RAG evidence is required before answer generation.");
2948
3769
  }
2949
- const maxEvidenceBlocks = options.maxEvidenceBlocks ?? DEFAULT_MAX_EVIDENCE_BLOCKS;
2950
- const maxCharsPerBlock = options.maxCharsPerBlock ?? DEFAULT_MAX_CHARS_PER_BLOCK;
2951
- const selected = rankEvidenceForPrompt(evidence).slice(0, maxEvidenceBlocks);
2952
- const citations = selected.map((item, index2) => ({
2953
- marker: `S${index2 + 1}`,
2954
- evidenceId: item.id,
2955
- source: item.source,
2956
- text: item.text
2957
- }));
2958
- const evidenceText = selected.map((item, index2) => {
2959
- const marker = citations[index2]?.marker;
2960
- const clippedText = item.text.length > maxCharsPerBlock ? `${item.text.slice(0, maxCharsPerBlock)}...` : item.text;
2961
- const sourceParts = [
2962
- item.source.label,
2963
- item.source.sender ? `\u53D1\u9001\u4EBA\uFF1A${item.source.sender}` : void 0,
2964
- item.source.timestamp ? `\u65F6\u95F4\uFF1A${item.source.timestamp}` : void 0,
2965
- item.source.location ? `\u4F4D\u7F6E\uFF1A${item.source.location}` : void 0
2966
- ].filter(Boolean);
2967
- return `[${marker}]
2968
- \u6765\u6E90\uFF1A${sourceParts.join("\uFF1B")}
2969
- \u5185\u5BB9\uFF1A${clippedText}`;
2970
- }).join("\n\n");
2971
- return {
2972
- citations,
2973
- messages: [
2974
- {
2975
- role: "system",
2976
- content: "\u4F60\u662F ChatterCatcher \u7684\u95EE\u7B54\u6A21\u5757\u3002\u53EA\u80FD\u6839\u636E\u63D0\u4F9B\u7684\u68C0\u7D22\u8BC1\u636E\u56DE\u7B54\uFF0C\u5FC5\u987B\u7B80\u77ED\u76F4\u63A5\u3002\u4E8B\u5B9E\u6027\u7ED3\u8BBA\u5FC5\u987B\u5F15\u7528 [S1] \u8FD9\u6837\u7684\u6765\u6E90\u6807\u8BB0\u3002\u8BC1\u636E\u4E0D\u8DB3\u65F6\u8BF4\u4E0D\u77E5\u9053\uFF0C\u4E0D\u8981\u731C\u3002\u82E5\u8BC1\u636E\u4E92\u76F8\u77DB\u76FE\uFF0C\u4F18\u5148\u91C7\u7528\u65F6\u95F4\u66F4\u65B0\u4E14\u8868\u8FF0\u660E\u786E\u7684\u8BC1\u636E\uFF1B\u5982\u679C\u8F83\u65B0\u7684\u8BC1\u636E\u53EA\u662F\u8BA8\u8BBA\u3001\u731C\u6D4B\u6216\u4E0D\u786E\u5B9A\u8868\u8FBE\uFF0C\u4E0D\u8981\u628A\u5B83\u5F53\u4F5C\u786E\u5B9A\u66F4\u65B0\u3002"
2977
- },
2978
- {
2979
- role: "user",
2980
- content: `\u95EE\u9898\uFF1A${question}
2981
-
2982
- \u8BC1\u636E\u5904\u7406\u89C4\u5219\uFF1A
2983
- 1. \u5148\u5224\u65AD\u8BC1\u636E\u662F\u5426\u8DB3\u4EE5\u56DE\u7B54\u95EE\u9898\u3002
2984
- 2. \u540C\u4E00\u4E8B\u9879\u51FA\u73B0\u591A\u4E2A\u7248\u672C\u65F6\uFF0C\u9ED8\u8BA4\u8F83\u65B0\u7684\u660E\u786E\u6D88\u606F\u4F18\u5148\u3002
2985
- 3. \u56DE\u7B54\u53EA\u5F15\u7528\u5B9E\u9645\u652F\u6491\u7ED3\u8BBA\u7684\u8BC1\u636E\u3002
2986
-
2987
- \u68C0\u7D22\u8BC1\u636E\uFF1A
2988
- ${evidenceText}`
2989
- }
2990
- ]
2991
- };
2992
- }
2993
- async function generateGroundedAnswer(input2) {
2994
- const prompt = buildEvidencePrompt(input2.question, input2.evidence);
2995
- const answer = await input2.model.complete(prompt.messages);
2996
- return {
2997
- answer,
2998
- citations: prompt.citations
2999
- };
3770
+ return result.replace(/@/g, " ").replace(/\s+/g, " ").trim();
3000
3771
  }
3001
-
3002
- // src/rag/agentic-qa-service.ts
3772
+ var FEISHU_TOOL_SYSTEM_PROMPT = "\u4F60\u662F\u98DE\u4E66\u7FA4\u804A\u52A9\u624B\u3002\u4F60\u53EF\u4EE5\u5148\u641C\u7D22\u672C\u5730\u77E5\u8BC6\u6765\u56DE\u7B54\u95EE\u9898\uFF1B\u5F53\u7528\u6237\u660E\u786E\u8981\u6C42\u521B\u5EFA\u3001\u67E5\u770B\u6216\u5220\u9664\u7FA4\u6D88\u606F\u5B9A\u65F6\u4EFB\u52A1\u65F6\uFF0C\u4E5F\u53EF\u4EE5\u8C03\u7528\u5B9A\u65F6\u4EFB\u52A1\u5DE5\u5177\u3002\u5B9A\u65F6\u4EFB\u52A1\u5DE5\u5177\u53EA\u7BA1\u7406\u5F53\u524D\u7FA4\u804A\uFF0C\u4E0D\u80FD\u8DE8\u7FA4\u64CD\u4F5C\u3002\u82E5\u7528\u6237\u7528\u81EA\u7136\u8BED\u8A00\u63CF\u8FF0\u65F6\u95F4\uFF0C\u4F60\u9700\u8981\u5148\u5C06\u5176\u8F6C\u6362\u4E3A\u4E94\u5B57\u6BB5 cron \u8868\u8FBE\u5F0F\uFF08\u5206 \u65F6 \u65E5 \u6708 \u5468\uFF09\uFF0C\u518D\u8C03\u7528\u5DE5\u5177\u3002\u5BF9\u4E8E\u4E00\u822C\u95EE\u7B54\uFF0C\u5148\u6309\u9700\u8C03\u7528\u641C\u7D22\u5DE5\u5177\uFF0C\u518D\u57FA\u4E8E\u5DE5\u5177\u8FD4\u56DE\u7684\u8BC1\u636E\u76F4\u63A5\u7ED9\u51FA\u6700\u7EC8\u7B54\u6848\uFF1B\u82E5\u5F15\u7528\u4E86\u68C0\u7D22\u7ED3\u679C\uFF0C\u8981\u5728\u7B54\u6848\u91CC\u76F4\u63A5\u5199\u51FA\u5F15\u7528\u5185\u5BB9\u3002\u4E0D\u8981\u58F0\u79F0\u5B8C\u6210\u4E86\u672A\u5B9E\u9645\u8C03\u7528\u7684\u64CD\u4F5C\u3002";
3003
3773
  var DEFAULT_MAX_MODEL_TURNS = 4;
3004
3774
  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
- );
3775
+ var FEISHU_TOOL_LOOP_FALLBACK = "\u5B9A\u65F6\u4EFB\u52A1\u64CD\u4F5C\u5DF2\u63D0\u4EA4\uFF0C\u4F46\u6A21\u578B\u6CA1\u6709\u751F\u6210\u6700\u7EC8\u56DE\u590D\u3002";
3776
+ var FEISHU_TOOL_LOOP_LIMIT_REACHED = "\u5DE5\u5177\u8C03\u7528\u6B21\u6570\u5DF2\u8FBE\u5230\u4E0A\u9650\uFF0C\u8BF7\u7F29\u5C0F\u8BF7\u6C42\u540E\u91CD\u8BD5\u3002";
3777
+ function toToolResultContent(value) {
3778
+ return typeof value === "string" ? value : JSON.stringify(value);
3017
3779
  }
3018
3780
  function toToolErrorContent(message) {
3019
- return JSON.stringify({ error: message });
3781
+ return JSON.stringify({ ok: false, error: message });
3020
3782
  }
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;
3783
+ async function executeFeishuTool(tool, input2) {
3784
+ const result = await tool.execute(input2);
3785
+ return toToolResultContent(result);
3035
3786
  }
3036
- async function askWithAgenticRag(input2) {
3787
+ async function runFeishuToolLoop(input2) {
3037
3788
  if (!input2.model.completeWithTools) {
3038
3789
  throw new Error("\u5F53\u524D LLM \u5BA2\u6237\u7AEF\u4E0D\u652F\u6301\u5DE5\u5177\u8C03\u7528\u3002");
3039
3790
  }
3040
3791
  const maxModelTurns = input2.maxModelTurns ?? DEFAULT_MAX_MODEL_TURNS;
3041
3792
  const maxToolCalls = input2.maxToolCalls ?? DEFAULT_MAX_TOOL_CALLS;
3042
- const maxEvidence = input2.maxEvidence ?? DEFAULT_MAX_EVIDENCE;
3043
3793
  const messages = [
3044
- { role: "system", content: AGENTIC_SYSTEM_PROMPT },
3794
+ { role: "system", content: FEISHU_TOOL_SYSTEM_PROMPT },
3045
3795
  { role: "user", content: input2.question }
3046
3796
  ];
3047
3797
  const toolsByName = new Map(input2.tools.map((tool) => [tool.name, tool]));
3048
- let evidence = [];
3049
3798
  let toolCallsUsed = 0;
3050
3799
  for (let turn = 0; turn < maxModelTurns; turn += 1) {
3051
3800
  const assistantResult = await input2.model.completeWithTools(messages, input2.tools);
@@ -3055,11 +3804,11 @@ async function askWithAgenticRag(input2) {
3055
3804
  toolCalls: assistantResult.toolCalls
3056
3805
  });
3057
3806
  if (assistantResult.toolCalls.length === 0) {
3058
- break;
3807
+ return assistantResult.content || FEISHU_TOOL_LOOP_FALLBACK;
3059
3808
  }
3060
3809
  for (const toolCall of assistantResult.toolCalls) {
3061
3810
  if (toolCallsUsed >= maxToolCalls) {
3062
- break;
3811
+ return FEISHU_TOOL_LOOP_LIMIT_REACHED;
3063
3812
  }
3064
3813
  toolCallsUsed += 1;
3065
3814
  const tool = toolsByName.get(toolCall.name);
@@ -3072,12 +3821,11 @@ async function askWithAgenticRag(input2) {
3072
3821
  continue;
3073
3822
  }
3074
3823
  try {
3075
- const results = await tool.execute(toolCall.input);
3076
- evidence = dedupeEvidence([...evidence, ...results], maxEvidence);
3824
+ const result = await executeFeishuTool(tool, toolCall.input);
3077
3825
  messages.push({
3078
3826
  role: "tool",
3079
3827
  toolCallId: toolCall.id,
3080
- content: toToolResultContent(results)
3828
+ content: result
3081
3829
  });
3082
3830
  } catch (error) {
3083
3831
  const message = error instanceof Error ? error.message : String(error);
@@ -3089,41 +3837,7 @@ async function askWithAgenticRag(input2) {
3089
3837
  }
3090
3838
  }
3091
3839
  }
3092
- if (evidence.length === 0) {
3093
- return {
3094
- answer: NO_EVIDENCE_ANSWER,
3095
- citations: []
3096
- };
3097
- }
3098
- return generateGroundedAnswer({
3099
- question: input2.question,
3100
- evidence,
3101
- model: input2.model
3102
- });
3103
- }
3104
-
3105
- // src/feishu/question.ts
3106
- function parseTextContent(content) {
3107
- if (!content) {
3108
- return "";
3109
- }
3110
- try {
3111
- const parsed = JSON.parse(content);
3112
- return typeof parsed.text === "string" ? parsed.text : "";
3113
- } catch {
3114
- return content;
3115
- }
3116
- }
3117
- function stripMentions(text, mentions) {
3118
- let result = text;
3119
- for (const mention of mentions ?? []) {
3120
- for (const token of [mention.key, mention.name, mention.name ? `@${mention.name}` : void 0]) {
3121
- if (token) {
3122
- result = result.replaceAll(token, " ");
3123
- }
3124
- }
3125
- }
3126
- return result.replace(/@/g, " ").replace(/\s+/g, " ").trim();
3840
+ return FEISHU_TOOL_LOOP_FALLBACK;
3127
3841
  }
3128
3842
  function isMentionForBot(mention, config) {
3129
3843
  if (!config.feishu.botOpenId) {
@@ -3208,6 +3922,7 @@ var FeishuQuestionHandler = class {
3208
3922
  return decision;
3209
3923
  }
3210
3924
  const questionMessageId = payload.event?.message?.message_id;
3925
+ const qaLogs = new QaLogRepository(this.options.database);
3211
3926
  await this.acknowledgeQuestion(decision.chatId, questionMessageId);
3212
3927
  const { tools, close } = await createAgenticRagSearchTools({
3213
3928
  config: this.options.config,
@@ -3218,19 +3933,41 @@ var FeishuQuestionHandler = class {
3218
3933
  });
3219
3934
  try {
3220
3935
  try {
3221
- const result = await askWithAgenticRag({
3936
+ const cronTools = createCronJobTools({
3937
+ repository: new CronJobRepository(this.options.database),
3938
+ chatId: decision.chatId,
3939
+ createdByOpenId: payload.event?.sender?.sender_id?.open_id
3940
+ });
3941
+ const allTools = [...tools, ...cronTools];
3942
+ const answer = await runFeishuToolLoop({
3222
3943
  question: decision.question,
3223
- tools,
3944
+ tools: allTools,
3224
3945
  model: this.options.model
3225
3946
  });
3226
- const citations = formatCitations(result.citations);
3227
- const text = citations ? `${result.answer}
3228
-
3229
- \u5F15\u7528\uFF1A
3230
- ${citations}` : result.answer;
3231
- await this.sendResponse(decision.chatId, questionMessageId, text);
3947
+ qaLogs.create({
3948
+ chatId: decision.chatId,
3949
+ questionMessageId,
3950
+ question: decision.question,
3951
+ answer,
3952
+ citations: [],
3953
+ retrievalDebug: {},
3954
+ status: "answered",
3955
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3956
+ });
3957
+ await this.sendResponse(decision.chatId, questionMessageId, answer);
3232
3958
  } catch (error) {
3233
3959
  const message = error instanceof Error ? error.message : String(error);
3960
+ qaLogs.create({
3961
+ chatId: decision.chatId,
3962
+ questionMessageId,
3963
+ question: decision.question,
3964
+ answer: `\u6682\u65F6\u65E0\u6CD5\u56DE\u7B54\uFF1A${message}`,
3965
+ citations: [],
3966
+ retrievalDebug: {},
3967
+ status: "failed",
3968
+ error: message,
3969
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3970
+ });
3234
3971
  await this.sendResponse(decision.chatId, questionMessageId, `\u6682\u65F6\u65E0\u6CD5\u56DE\u7B54\uFF1A${message}`);
3235
3972
  }
3236
3973
  return decision;
@@ -3452,15 +4189,50 @@ function createFeishuGateway(options) {
3452
4189
  episodeProcessor: options.episodeProcessor,
3453
4190
  imageMultimodalProcessor: options.imageMultimodalProcessor
3454
4191
  });
4192
+ const indexingScheduler = options.indexingScheduler ?? (options.indexingProcessor ? createIndexingScheduler({
4193
+ schedule: options.config.schedules.indexing,
4194
+ work: async () => {
4195
+ await processMessagesNow({
4196
+ config: options.config,
4197
+ secrets: options.secrets,
4198
+ database: options.indexingProcessor.database,
4199
+ limit: 1e4
4200
+ });
4201
+ }
4202
+ }) : void 0);
4203
+ const cronJobScheduler = options.cronJobScheduler ?? (options.cronJobProcessor ? createCronJobScheduler({
4204
+ repository: new CronJobRepository(options.cronJobProcessor.database),
4205
+ sendTextToChat: (chatId, text) => options.cronJobProcessor.sender.sendTextToChat(chatId, text),
4206
+ generateMessage: async (job, now) => {
4207
+ const { tools, close } = await createAgenticRagSearchTools({
4208
+ config: options.config,
4209
+ secrets: options.secrets,
4210
+ database: options.cronJobProcessor.database,
4211
+ messages: new MessageRepository(options.cronJobProcessor.database),
4212
+ scope: { platform: "feishu", platformChatId: job.chatId }
4213
+ });
4214
+ try {
4215
+ return await generateCronJobMessage({ prompt: job.prompt, model: options.cronJobProcessor.model, tools, now });
4216
+ } finally {
4217
+ close();
4218
+ }
4219
+ }
4220
+ }) : void 0);
3455
4221
  return {
3456
4222
  async start() {
3457
4223
  try {
3458
4224
  await wsClient.start({ eventDispatcher });
4225
+ indexingScheduler?.start();
4226
+ cronJobScheduler?.start();
3459
4227
  } catch (error) {
4228
+ indexingScheduler?.stop();
4229
+ cronJobScheduler?.stop();
3460
4230
  throw formatGatewayStartError(error);
3461
4231
  }
3462
4232
  },
3463
4233
  stop() {
4234
+ indexingScheduler?.stop();
4235
+ cronJobScheduler?.stop();
3464
4236
  wsClient.close({ force: true });
3465
4237
  }
3466
4238
  };
@@ -3532,7 +4304,7 @@ var FeishuResourceDownloader = class _FeishuResourceDownloader {
3532
4304
  };
3533
4305
 
3534
4306
  // src/files/ingest.ts
3535
- import crypto5 from "crypto";
4307
+ import crypto7 from "crypto";
3536
4308
  import fs11 from "fs/promises";
3537
4309
  import path13 from "path";
3538
4310
 
@@ -3596,7 +4368,7 @@ function ensureSupportedTextFile(filePath) {
3596
4368
  }
3597
4369
  }
3598
4370
  function stableStoredName(sourcePath, fileName) {
3599
- const digest = crypto5.createHash("sha256").update(sourcePath).digest("hex").slice(0, 16);
4371
+ const digest = crypto7.createHash("sha256").update(sourcePath).digest("hex").slice(0, 16);
3600
4372
  return `${digest}-${fileName}`;
3601
4373
  }
3602
4374
  async function ingestLocalFile(input2) {
@@ -4118,81 +4890,84 @@ function createMultimodalModel(config, secrets) {
4118
4890
  });
4119
4891
  }
4120
4892
 
4121
- // src/rag/indexer.ts
4122
- async function indexMessageChunks(input2) {
4123
- const chunks = input2.messageIds ? input2.messages.listMessageChunksByMessageIds(input2.messageIds, input2.limit ?? 1e4) : input2.messages.listAllMessageChunks(input2.limit ?? 1e4);
4124
- if (chunks.length === 0) {
4125
- return { chunks: 0, vectors: 0 };
4893
+ // src/rag/answer.ts
4894
+ var DEFAULT_MAX_EVIDENCE_BLOCKS = 8;
4895
+ var DEFAULT_MAX_CHARS_PER_BLOCK = 1200;
4896
+ var SCORE_TIE_THRESHOLD = 0.15;
4897
+ function parseTimestamp(value) {
4898
+ if (!value) {
4899
+ return 0;
4126
4900
  }
4127
- const vectors = await input2.embedding.embedBatch(chunks.map((chunk) => chunk.text));
4128
- const records = [];
4129
- for (const [index2, chunk] of chunks.entries()) {
4130
- const vector = vectors[index2];
4131
- if (!vector || vector.length === 0) {
4132
- continue;
4901
+ const time = Date.parse(value);
4902
+ return Number.isFinite(time) ? time : 0;
4903
+ }
4904
+ function rankEvidenceForPrompt(evidence) {
4905
+ return [...evidence].sort((left, right) => {
4906
+ const scoreDiff = right.score - left.score;
4907
+ if (Math.abs(scoreDiff) > SCORE_TIE_THRESHOLD) {
4908
+ return scoreDiff;
4133
4909
  }
4134
- records.push({
4135
- id: chunk.chunkId,
4136
- vector,
4137
- evidence: {
4138
- id: chunk.chunkId,
4139
- text: chunk.text,
4140
- score: 1,
4141
- source: toEvidenceSource3(chunk)
4142
- }
4143
- });
4144
- }
4145
- await input2.store.upsert(records);
4146
- return {
4147
- chunks: chunks.length,
4148
- vectors: records.length
4149
- };
4910
+ const timeDiff = parseTimestamp(right.source.timestamp) - parseTimestamp(left.source.timestamp);
4911
+ if (timeDiff !== 0) {
4912
+ return timeDiff;
4913
+ }
4914
+ return scoreDiff;
4915
+ });
4150
4916
  }
4151
- function toEvidenceSource3(chunk) {
4152
- if (chunk.messageType === "file") {
4153
- return {
4154
- type: "file",
4155
- label: chunk.senderName,
4156
- timestamp: chunk.sentAt
4157
- };
4917
+ function buildEvidencePrompt(question, evidence, options = {}) {
4918
+ if (evidence.length === 0) {
4919
+ throw new Error("RAG evidence is required before answer generation.");
4158
4920
  }
4921
+ const maxEvidenceBlocks = options.maxEvidenceBlocks ?? DEFAULT_MAX_EVIDENCE_BLOCKS;
4922
+ const maxCharsPerBlock = options.maxCharsPerBlock ?? DEFAULT_MAX_CHARS_PER_BLOCK;
4923
+ const selected = rankEvidenceForPrompt(evidence).slice(0, maxEvidenceBlocks);
4924
+ const citations = selected.map((item, index2) => ({
4925
+ marker: `S${index2 + 1}`,
4926
+ evidenceId: item.id,
4927
+ source: item.source,
4928
+ text: item.text
4929
+ }));
4930
+ const evidenceText = selected.map((item, index2) => {
4931
+ const marker = citations[index2]?.marker;
4932
+ const clippedText = item.text.length > maxCharsPerBlock ? `${item.text.slice(0, maxCharsPerBlock)}...` : item.text;
4933
+ const sourceParts = [
4934
+ item.source.label,
4935
+ item.source.sender ? `\u53D1\u9001\u4EBA\uFF1A${item.source.sender}` : void 0,
4936
+ item.source.timestamp ? `\u65F6\u95F4\uFF1A${item.source.timestamp}` : void 0,
4937
+ item.source.location ? `\u4F4D\u7F6E\uFF1A${item.source.location}` : void 0
4938
+ ].filter(Boolean);
4939
+ return `[${marker}]
4940
+ \u6765\u6E90\uFF1A${sourceParts.join("\uFF1B")}
4941
+ \u5185\u5BB9\uFF1A${clippedText}`;
4942
+ }).join("\n\n");
4159
4943
  return {
4160
- type: "message",
4161
- label: chunk.chatName,
4162
- sender: chunk.senderName,
4163
- timestamp: chunk.sentAt
4944
+ citations,
4945
+ messages: [
4946
+ {
4947
+ role: "system",
4948
+ content: "\u4F60\u662F ChatterCatcher \u7684\u95EE\u7B54\u6A21\u5757\u3002\u53EA\u80FD\u6839\u636E\u63D0\u4F9B\u7684\u68C0\u7D22\u8BC1\u636E\u56DE\u7B54\uFF0C\u5FC5\u987B\u7B80\u77ED\u76F4\u63A5\u3002\u4E8B\u5B9E\u6027\u7ED3\u8BBA\u5FC5\u987B\u5F15\u7528 [S1] \u8FD9\u6837\u7684\u6765\u6E90\u6807\u8BB0\u3002\u8BC1\u636E\u4E0D\u8DB3\u65F6\u8BF4\u4E0D\u77E5\u9053\uFF0C\u4E0D\u8981\u731C\u3002\u82E5\u8BC1\u636E\u4E92\u76F8\u77DB\u76FE\uFF0C\u4F18\u5148\u91C7\u7528\u65F6\u95F4\u66F4\u65B0\u4E14\u8868\u8FF0\u660E\u786E\u7684\u8BC1\u636E\uFF1B\u5982\u679C\u8F83\u65B0\u7684\u8BC1\u636E\u53EA\u662F\u8BA8\u8BBA\u3001\u731C\u6D4B\u6216\u4E0D\u786E\u5B9A\u8868\u8FBE\uFF0C\u4E0D\u8981\u628A\u5B83\u5F53\u4F5C\u786E\u5B9A\u66F4\u65B0\u3002"
4949
+ },
4950
+ {
4951
+ role: "user",
4952
+ content: `\u95EE\u9898\uFF1A${question}
4953
+
4954
+ \u8BC1\u636E\u5904\u7406\u89C4\u5219\uFF1A
4955
+ 1. \u5148\u5224\u65AD\u8BC1\u636E\u662F\u5426\u8DB3\u4EE5\u56DE\u7B54\u95EE\u9898\u3002
4956
+ 2. \u540C\u4E00\u4E8B\u9879\u51FA\u73B0\u591A\u4E2A\u7248\u672C\u65F6\uFF0C\u9ED8\u8BA4\u8F83\u65B0\u7684\u660E\u786E\u6D88\u606F\u4F18\u5148\u3002
4957
+ 3. \u56DE\u7B54\u53EA\u5F15\u7528\u5B9E\u9645\u652F\u6491\u7ED3\u8BBA\u7684\u8BC1\u636E\u3002
4958
+
4959
+ \u68C0\u7D22\u8BC1\u636E\uFF1A
4960
+ ${evidenceText}`
4961
+ }
4962
+ ]
4164
4963
  };
4165
4964
  }
4166
-
4167
- // src/rag/manual-index.ts
4168
- async function processMessagesNow(input2) {
4169
- const startedAt = (/* @__PURE__ */ new Date()).toISOString();
4170
- if (!hasEmbeddingConfig(input2.config, input2.secrets)) {
4171
- return {
4172
- status: "skipped",
4173
- reason: "Embedding \u914D\u7F6E\u4E0D\u5B8C\u6574\uFF1BSQLite FTS \u5DF2\u5728\u6D88\u606F\u5165\u5E93\u65F6\u5373\u65F6\u66F4\u65B0\u3002",
4174
- chunks: 0,
4175
- vectors: 0,
4176
- startedAt,
4177
- finishedAt: (/* @__PURE__ */ new Date()).toISOString()
4178
- };
4179
- }
4180
- const vectorStore = new SqliteVectorStore(input2.database, {
4181
- model: input2.config.embedding.model
4182
- });
4183
- const embedding = input2.embedding ?? createEmbeddingModel(input2.config, input2.secrets);
4184
- const stats = await indexMessageChunks({
4185
- messages: new MessageRepository(input2.database),
4186
- embedding,
4187
- store: vectorStore,
4188
- limit: input2.limit
4189
- });
4965
+ async function generateGroundedAnswer(input2) {
4966
+ const prompt = buildEvidencePrompt(input2.question, input2.evidence);
4967
+ const answer = await input2.model.complete(prompt.messages);
4190
4968
  return {
4191
- status: "completed",
4192
- chunks: stats.chunks,
4193
- vectors: stats.vectors,
4194
- startedAt,
4195
- finishedAt: (/* @__PURE__ */ new Date()).toISOString()
4969
+ answer,
4970
+ citations: prompt.citations
4196
4971
  };
4197
4972
  }
4198
4973
 
@@ -4212,6 +4987,42 @@ async function askWithRag(input2) {
4212
4987
  });
4213
4988
  }
4214
4989
 
4990
+ // src/rag/citations.ts
4991
+ function isOpaqueId(value) {
4992
+ return Boolean(value && /^(ou|oc|om|cli|on|un|uid)_?[a-z0-9]+/i.test(value));
4993
+ }
4994
+ function formatTime(value) {
4995
+ if (!value) {
4996
+ return "\u672A\u77E5\u65F6\u95F4";
4997
+ }
4998
+ const date = new Date(value);
4999
+ if (Number.isNaN(date.getTime())) {
5000
+ return value;
5001
+ }
5002
+ const pad = (input2) => String(input2).padStart(2, "0");
5003
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
5004
+ }
5005
+ function formatSpeaker(source) {
5006
+ if (source.type === "file") {
5007
+ return isOpaqueId(source.label) ? "\u6587\u4EF6" : `\u6587\u4EF6 ${source.label}`;
5008
+ }
5009
+ if (source.sender && !isOpaqueId(source.sender)) {
5010
+ return source.sender;
5011
+ }
5012
+ return "\u7FA4\u6210\u5458";
5013
+ }
5014
+ function clipText(value, maxLength) {
5015
+ const normalized = value.replace(/\s+/g, " ").trim();
5016
+ return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized;
5017
+ }
5018
+ function formatCitation(citation, options = {}) {
5019
+ const maxTextLength = options.maxTextLength ?? 120;
5020
+ const speaker = formatSpeaker(citation.source);
5021
+ const time = formatTime(citation.source.timestamp);
5022
+ const verb = citation.source.type === "file" ? "\u8BB0\u5F55" : "\u8BF4";
5023
+ return `[${citation.marker}] ${speaker}\u5728 ${time} ${verb}\uFF1A\u201C${clipText(citation.text, maxTextLength)}\u201D`;
5024
+ }
5025
+
4215
5026
  // src/update/npm-updater.ts
4216
5027
  import { execFile } from "child_process";
4217
5028
  import { promisify } from "util";
@@ -4315,6 +5126,7 @@ async function updateChatterCatcher(options) {
4315
5126
  }
4316
5127
 
4317
5128
  // src/web/server.ts
5129
+ import crypto8 from "crypto";
4318
5130
  import Fastify from "fastify";
4319
5131
  function buildHtml() {
4320
5132
  return `<!doctype html>
@@ -4444,6 +5256,10 @@ function buildHtml() {
4444
5256
  <h2>\u4F1A\u8BDD\u8BB0\u5FC6</h2>
4445
5257
  <div id="episodes" class="empty">\u6B63\u5728\u8BFB\u53D6...</div>
4446
5258
  </section>
5259
+ <section>
5260
+ <h2>\u95EE\u7B54\u65E5\u5FD7</h2>
5261
+ <div id="qa-logs" class="empty">\u6B63\u5728\u8BFB\u53D6...</div>
5262
+ </section>
4447
5263
  </div>
4448
5264
  <aside>
4449
5265
  <section>
@@ -4458,6 +5274,10 @@ function buildHtml() {
4458
5274
  <h2>\u89E3\u6790\u4EFB\u52A1</h2>
4459
5275
  <div id="file-jobs" class="empty">\u6B63\u5728\u8BFB\u53D6...</div>
4460
5276
  </section>
5277
+ <section>
5278
+ <h2>\u5B9A\u65F6\u4EFB\u52A1</h2>
5279
+ <div id="cron-jobs" class="empty">\u6B63\u5728\u8BFB\u53D6...</div>
5280
+ </section>
4461
5281
  <section>
4462
5282
  <h2>\u672C\u5730\u64CD\u4F5C</h2>
4463
5283
  <p><code>chattercatcher settings</code> \u4FEE\u6539\u914D\u7F6E\u3002</p>
@@ -4474,9 +5294,13 @@ function buildHtml() {
4474
5294
  const chats = document.querySelector("#chats");
4475
5295
  const files = document.querySelector("#files");
4476
5296
  const fileJobs = document.querySelector("#file-jobs");
5297
+ const cronJobs = document.querySelector("#cron-jobs");
5298
+ const qaLogs = document.querySelector("#qa-logs");
4477
5299
  const processMessages = document.querySelector("#process-messages");
4478
5300
  const actionStatus = document.querySelector("#action-status");
4479
5301
 
5302
+ let webActionToken = "__WEB_ACTION_TOKEN__";
5303
+
4480
5304
  function fmt(value) {
4481
5305
  return value == null || value === "" ? "-" : String(value);
4482
5306
  }
@@ -4665,14 +5489,76 @@ function buildHtml() {
4665
5489
  \`;
4666
5490
  }
4667
5491
 
5492
+ function renderCronJobs(items) {
5493
+ if (items.length === 0) {
5494
+ cronJobs.className = "empty";
5495
+ cronJobs.textContent = "\u8FD8\u6CA1\u6709\u5B9A\u65F6\u4EFB\u52A1\u3002\u53EF\u5728\u98DE\u4E66\u7FA4\u91CC @ \u673A\u5668\u4EBA\u521B\u5EFA\u3002";
5496
+ return;
5497
+ }
5498
+ cronJobs.className = "";
5499
+ cronJobs.innerHTML = \`
5500
+ <table>
5501
+ <thead><tr><th>\u4EFB\u52A1</th><th>\u72B6\u6001</th></tr></thead>
5502
+ <tbody>
5503
+ \${items.map((item) => \`
5504
+ <tr>
5505
+ <td>
5506
+ <div>\${escapeHtml(item.schedule)}</div>
5507
+ <div class="message" title="\${escapeHtml(item.prompt)}">\${escapeHtml(item.prompt)}</div>
5508
+ <div class="path" title="\${escapeHtml(item.id)}">ID: \${escapeHtml(item.id)}</div>
5509
+ <div class="path" title="\${escapeHtml(item.chatId)}">\u7FA4: \${escapeHtml(item.chatId)}</div>
5510
+ <div class="path">\u4E0B\u6B21: \${escapeHtml(formatDateTime(item.nextRunAt))}</div>
5511
+ <div class="path" title="\${escapeHtml(item.lastError || "")}">\${escapeHtml(item.lastError || "")}</div>
5512
+ \${item.status === "active" ? \`<button type="button" data-delete-cron-job="\${escapeHtml(item.id)}">\u5220\u9664</button>\` : ""}
5513
+ </td>
5514
+ <td>\${escapeHtml(item.status)}</td>
5515
+ </tr>
5516
+ \`).join("")}
5517
+ </tbody>
5518
+ </table>
5519
+ \`;
5520
+ }
5521
+
5522
+ function renderQaLogs(items) {
5523
+ if (items.length === 0) {
5524
+ qaLogs.className = "empty";
5525
+ qaLogs.textContent = "\u8FD8\u6CA1\u6709\u95EE\u7B54\u65E5\u5FD7\u3002";
5526
+ return;
5527
+ }
5528
+ qaLogs.className = "";
5529
+ const rows = items.map((item) => {
5530
+ const citationCount = Array.isArray(item.citations) ? item.citations.length : 0;
5531
+ return [
5532
+ '<article class="message-item">',
5533
+ ' <div class="message-meta">',
5534
+ " <span>" + escapeHtml(formatDateTime(item.createdAt)) + "</span>",
5535
+ " <span>" + escapeHtml(item.status) + "</span>",
5536
+ " <span>" + escapeHtml(citationCount) + " \u6761\u5F15\u7528</span>",
5537
+ " </div>",
5538
+ " <div class="message-body"><strong>\u95EE\uFF1A</strong>" + escapeHtml(item.question) + "</div>",
5539
+ " <div class="message-body"><strong>\u7B54\uFF1A</strong>" + escapeHtml(item.answer) + "</div>",
5540
+ "</article>",
5541
+ ].join("
5542
+ ");
5543
+ });
5544
+ qaLogs.innerHTML = [
5545
+ '<div class="message-list">',
5546
+ rows.join(""),
5547
+ "</div>",
5548
+ ].join("
5549
+ ");
5550
+ }
5551
+
4668
5552
  async function load() {
4669
- const [status, recent, episodeList, chatList, fileList, jobList] = await Promise.all([
5553
+ const [status, recent, episodeList, chatList, fileList, jobList, qaLogList, cronJobList] = await Promise.all([
4670
5554
  fetch("/api/status").then((response) => response.json()),
4671
5555
  fetch("/api/messages/recent?limit=20").then((response) => response.json()),
4672
5556
  fetch("/api/episodes?limit=10").then((response) => response.json()),
4673
5557
  fetch("/api/chats").then((response) => response.json()),
4674
5558
  fetch("/api/files").then((response) => response.json()),
4675
5559
  fetch("/api/file-jobs").then((response) => response.json()),
5560
+ fetch("/api/qa-logs?limit=10").then((response) => response.json()),
5561
+ fetch("/api/cron-jobs").then((response) => response.json()),
4676
5562
  ]);
4677
5563
  renderMetrics(status);
4678
5564
  renderMessages(recent.items);
@@ -4680,13 +5566,18 @@ function buildHtml() {
4680
5566
  renderChats(chatList.items);
4681
5567
  renderFiles(fileList.items);
4682
5568
  renderFileJobs(jobList.items);
5569
+ renderQaLogs(qaLogList.items);
5570
+ renderCronJobs(cronJobList.items);
4683
5571
  }
4684
5572
 
4685
5573
  async function processNow() {
4686
5574
  processMessages.disabled = true;
4687
5575
  actionStatus.textContent = "\u6B63\u5728\u5904\u7406\u6D88\u606F\u7D22\u5F15...";
4688
5576
  try {
4689
- const response = await fetch("/api/process/messages", { method: "POST" });
5577
+ const response = await fetch("/api/process/messages", {
5578
+ method: "POST",
5579
+ headers: { "x-chattercatcher-web-token": webActionToken },
5580
+ });
4690
5581
  const result = await response.json();
4691
5582
  if (!response.ok) {
4692
5583
  actionStatus.textContent = result.message || "\u5904\u7406\u5931\u8D25\u3002";
@@ -4706,6 +5597,28 @@ function buildHtml() {
4706
5597
  }
4707
5598
  }
4708
5599
 
5600
+ document.addEventListener("click", async (event) => {
5601
+ const target = event.target;
5602
+ if (!(target instanceof HTMLElement)) return;
5603
+ const id = target.dataset.deleteCronJob;
5604
+ if (!id) return;
5605
+ target.setAttribute("disabled", "disabled");
5606
+ actionStatus.textContent = "\u6B63\u5728\u5220\u9664\u5B9A\u65F6\u4EFB\u52A1...";
5607
+ try {
5608
+ const response = await fetch(\`/api/cron-jobs/\${encodeURIComponent(id)}\`, {
5609
+ method: "DELETE",
5610
+ headers: { "x-chattercatcher-web-token": webActionToken },
5611
+ });
5612
+ const result = await response.json();
5613
+ actionStatus.textContent = result.ok ? "\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u5220\u9664\u3002" : result.message || "\u5220\u9664\u5931\u8D25\u3002";
5614
+ await load();
5615
+ } catch (error) {
5616
+ actionStatus.textContent = error instanceof Error ? error.message : String(error);
5617
+ } finally {
5618
+ target.removeAttribute("disabled");
5619
+ }
5620
+ });
5621
+
4709
5622
  processMessages.addEventListener("click", () => void processNow());
4710
5623
  void load();
4711
5624
  setInterval(() => {
@@ -4714,6 +5627,7 @@ function buildHtml() {
4714
5627
  }
4715
5628
  }, 5000);
4716
5629
  </script>
5630
+ <script src="/app.js"></script>
4717
5631
  </body>
4718
5632
  </html>`;
4719
5633
  }
@@ -4721,35 +5635,65 @@ function parseLimit(value, fallback, max) {
4721
5635
  const rawLimit = Number(value ?? fallback);
4722
5636
  return Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), max) : fallback;
4723
5637
  }
5638
+ function getWebActionToken(secrets) {
5639
+ return secrets.web.actionToken;
5640
+ }
5641
+ function readHeader(value) {
5642
+ return Array.isArray(value) ? value[0] : value;
5643
+ }
5644
+ function isAuthorizedWebAction(request, token) {
5645
+ const provided = readHeader(request.headers["x-chattercatcher-web-token"]);
5646
+ return provided === token;
5647
+ }
5648
+ function extractInlineScript(html) {
5649
+ const match = /<script>([\s\S]*)<\/script>/.exec(html);
5650
+ return match?.[1] ?? "";
5651
+ }
4724
5652
  function createWebApp(config) {
4725
5653
  const app = Fastify({ logger: false });
4726
5654
  const database = openDatabase(config);
4727
5655
  const messages = new MessageRepository(database);
4728
5656
  const episodes = new EpisodeRepository(database);
4729
5657
  const fileJobs = new FileJobRepository(database);
5658
+ const qaLogs = new QaLogRepository(database);
5659
+ const cronJobs = new CronJobRepository(database);
5660
+ let webActionToken = "";
5661
+ const tokenReady = (async () => {
5662
+ const secrets = await loadSecrets();
5663
+ if (!secrets.web.actionToken) {
5664
+ secrets.web.actionToken = crypto8.randomBytes(32).toString("hex");
5665
+ await saveSecrets(secrets);
5666
+ }
5667
+ webActionToken = getWebActionToken(secrets);
5668
+ })();
4730
5669
  app.addHook("onClose", async () => {
4731
5670
  database.close();
4732
5671
  });
4733
- app.get("/api/status", async () => ({
4734
- app: "ChatterCatcher",
4735
- gateway: getGatewayStatus(config),
4736
- data: {
4737
- chats: messages.getChatCount(),
4738
- messages: messages.getMessageCount(),
4739
- episodes: episodes.getEpisodeCount(),
4740
- files: messages.listFiles(1e3).length
4741
- },
4742
- rag: {
4743
- mode: "required",
4744
- note: "\u95EE\u7B54\u5FC5\u987B\u5148\u68C0\u7D22\u8BC1\u636E\uFF0C\u7981\u6B62\u5168\u91CF\u4E0A\u4E0B\u6587\u5806\u53E0\u3002",
4745
- retrieval: {
4746
- keyword: "SQLite FTS5",
4747
- vector: "SQLite embedding",
4748
- hybrid: true
4749
- }
4750
- },
4751
- web: config.web
4752
- }));
5672
+ app.get("/api/status", async () => {
5673
+ await tokenReady;
5674
+ return {
5675
+ app: "ChatterCatcher",
5676
+ gateway: getGatewayStatus(config),
5677
+ data: {
5678
+ chats: messages.getChatCount(),
5679
+ messages: messages.getMessageCount(),
5680
+ episodes: episodes.getEpisodeCount(),
5681
+ files: messages.listFiles(1e3).length,
5682
+ qaLogs: qaLogs.getCount(),
5683
+ cronJobs: cronJobs.list(1e3).length
5684
+ },
5685
+ rag: {
5686
+ mode: "required",
5687
+ note: "\u95EE\u7B54\u5FC5\u987B\u5148\u68C0\u7D22\u8BC1\u636E\uFF0C\u7981\u6B62\u5168\u91CF\u4E0A\u4E0B\u6587\u5806\u53E0\u3002",
5688
+ retrieval: {
5689
+ keyword: "SQLite FTS5",
5690
+ vector: "SQLite embedding",
5691
+ hybrid: true
5692
+ }
5693
+ },
5694
+ web: config.web
5695
+ };
5696
+ });
4753
5697
  app.get("/api/chats", async () => ({
4754
5698
  items: messages.listChats()
4755
5699
  }));
@@ -4778,7 +5722,39 @@ function createWebApp(config) {
4778
5722
  items: episodes.listRecentEpisodes(limit)
4779
5723
  };
4780
5724
  });
4781
- app.post("/api/process/messages", async (_request, reply) => {
5725
+ app.get("/api/qa-logs", async (request) => {
5726
+ const limit = parseLimit(request.query.limit, 20, 100);
5727
+ return {
5728
+ items: qaLogs.listRecent(limit)
5729
+ };
5730
+ });
5731
+ app.get("/api/cron-jobs", async (request) => {
5732
+ const limit = parseLimit(request.query.limit, 50, 200);
5733
+ return {
5734
+ items: cronJobs.list(limit)
5735
+ };
5736
+ });
5737
+ app.delete("/api/cron-jobs/:id", async (request, reply) => {
5738
+ await tokenReady;
5739
+ if (!isAuthorizedWebAction(request, webActionToken)) {
5740
+ reply.code(403);
5741
+ return { ok: false, message: "Web \u64CD\u4F5C\u672A\u6388\u6743\u3002" };
5742
+ }
5743
+ const id = request.params.id;
5744
+ const job = cronJobs.get(id);
5745
+ if (!job) {
5746
+ reply.code(404);
5747
+ return { ok: false, message: "\u6CA1\u6709\u627E\u5230\u5B9A\u65F6\u4EFB\u52A1\u3002" };
5748
+ }
5749
+ const ok = cronJobs.deleteByChat(id, job.chatId);
5750
+ return { ok };
5751
+ });
5752
+ app.post("/api/process/messages", async (request, reply) => {
5753
+ await tokenReady;
5754
+ if (!isAuthorizedWebAction(request, webActionToken)) {
5755
+ reply.code(403);
5756
+ return { status: "failed", message: "Web \u64CD\u4F5C\u672A\u6388\u6743\u3002" };
5757
+ }
4782
5758
  try {
4783
5759
  return await processMessagesNow({
4784
5760
  config,
@@ -4794,6 +5770,11 @@ function createWebApp(config) {
4794
5770
  };
4795
5771
  }
4796
5772
  });
5773
+ app.get("/app.js", async (_request, reply) => {
5774
+ await tokenReady;
5775
+ reply.type("application/javascript; charset=utf-8");
5776
+ return extractInlineScript(buildHtml()).replaceAll("__WEB_ACTION_TOKEN__", webActionToken);
5777
+ });
4797
5778
  app.get("/", async (_request, reply) => {
4798
5779
  reply.type("text/html; charset=utf-8");
4799
5780
  return buildHtml();
@@ -4993,6 +5974,8 @@ async function startGatewayForegroundCommand() {
4993
5974
  mode: "gateway"
4994
5975
  });
4995
5976
  const database = openDatabase(config);
5977
+ const chatModel = createChatModel(config, secrets);
5978
+ const sender = FeishuMessageSender.fromConfig(config, secrets);
4996
5979
  const vectorStore = hasEmbeddingConfig(config, secrets) ? new SqliteVectorStore(database, { model: config.embedding.model }) : null;
4997
5980
  const gatewayRuntime = createFeishuGateway({
4998
5981
  config,
@@ -5007,18 +5990,26 @@ async function startGatewayForegroundCommand() {
5007
5990
  }) : void 0,
5008
5991
  episodeProcessor: {
5009
5992
  database,
5010
- model: createChatModel(config, secrets)
5993
+ model: chatModel
5011
5994
  },
5012
5995
  imageMultimodalProcessor: config.multimodal.baseUrl && config.multimodal.model && secrets.multimodal.apiKey ? {
5013
5996
  database,
5014
5997
  model: createMultimodalModel(config, secrets)
5015
5998
  } : void 0,
5999
+ indexingProcessor: {
6000
+ database
6001
+ },
6002
+ cronJobProcessor: {
6003
+ database,
6004
+ model: chatModel,
6005
+ sender
6006
+ },
5016
6007
  questionHandler: new FeishuQuestionHandler({
5017
6008
  config,
5018
6009
  secrets,
5019
6010
  database,
5020
- sender: FeishuMessageSender.fromConfig(config, secrets),
5021
- model: createChatModel(config, secrets)
6011
+ sender,
6012
+ model: chatModel
5022
6013
  })
5023
6014
  });
5024
6015
  const cleanup = () => {