chattercatcher 0.2.5 → 0.2.7

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/index.js CHANGED
@@ -916,6 +916,7 @@ function migrateDatabase(database) {
916
916
  chat_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
917
917
  sender_id TEXT NOT NULL,
918
918
  sender_name TEXT NOT NULL,
919
+ person_id TEXT REFERENCES persons(id) ON DELETE SET NULL,
919
920
  message_type TEXT NOT NULL,
920
921
  text TEXT NOT NULL,
921
922
  raw_payload_json TEXT NOT NULL,
@@ -953,6 +954,80 @@ function migrateDatabase(database) {
953
954
  UNIQUE(chat_id, started_at, ended_at)
954
955
  );
955
956
 
957
+ CREATE TABLE IF NOT EXISTS persons (
958
+ id TEXT PRIMARY KEY,
959
+ primary_name TEXT NOT NULL,
960
+ notes TEXT,
961
+ created_at TEXT NOT NULL,
962
+ updated_at TEXT NOT NULL
963
+ );
964
+
965
+ CREATE TABLE IF NOT EXISTS person_identities (
966
+ id TEXT PRIMARY KEY,
967
+ person_id TEXT NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
968
+ platform TEXT NOT NULL,
969
+ platform_chat_id TEXT NOT NULL,
970
+ external_user_id TEXT NOT NULL,
971
+ external_open_id TEXT,
972
+ external_union_id TEXT,
973
+ external_user_id_raw TEXT,
974
+ display_name TEXT NOT NULL,
975
+ alias TEXT,
976
+ source TEXT NOT NULL CHECK(source IN ('message','feishu_member','manual','inferred')),
977
+ first_seen_at TEXT NOT NULL,
978
+ last_seen_at TEXT NOT NULL,
979
+ UNIQUE(platform, platform_chat_id, external_user_id)
980
+ );
981
+
982
+ CREATE INDEX IF NOT EXISTS person_identities_person_idx ON person_identities(person_id);
983
+ CREATE INDEX IF NOT EXISTS person_identities_lookup_idx ON person_identities(platform, platform_chat_id, external_user_id);
984
+ CREATE INDEX IF NOT EXISTS person_identities_name_idx ON person_identities(display_name, alias);
985
+
986
+ CREATE TABLE IF NOT EXISTS person_profile_entries (
987
+ id TEXT PRIMARY KEY,
988
+ person_id TEXT NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
989
+ category TEXT NOT NULL,
990
+ content TEXT NOT NULL,
991
+ entry_type TEXT NOT NULL CHECK(entry_type IN ('fact','inferred')),
992
+ confidence REAL NOT NULL,
993
+ status TEXT NOT NULL CHECK(status IN ('active','superseded','deleted')),
994
+ source TEXT NOT NULL CHECK(source IN ('dream','explicit_user_request','manual')),
995
+ created_at TEXT NOT NULL,
996
+ updated_at TEXT NOT NULL,
997
+ last_observed_at TEXT NOT NULL
998
+ );
999
+
1000
+ CREATE INDEX IF NOT EXISTS person_profile_entries_person_status_idx ON person_profile_entries(person_id, status, updated_at);
1001
+
1002
+ CREATE TABLE IF NOT EXISTS person_profile_evidence (
1003
+ entry_id TEXT NOT NULL REFERENCES person_profile_entries(id) ON DELETE CASCADE,
1004
+ message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
1005
+ quote TEXT NOT NULL,
1006
+ reason TEXT NOT NULL,
1007
+ PRIMARY KEY (entry_id, message_id, quote)
1008
+ );
1009
+
1010
+ CREATE TABLE IF NOT EXISTS profile_dream_state (
1011
+ platform TEXT NOT NULL,
1012
+ platform_chat_id TEXT NOT NULL,
1013
+ last_message_id TEXT,
1014
+ last_message_sent_at TEXT,
1015
+ updated_at TEXT NOT NULL,
1016
+ PRIMARY KEY (platform, platform_chat_id)
1017
+ );
1018
+
1019
+ CREATE TABLE IF NOT EXISTS profile_dream_runs (
1020
+ id TEXT PRIMARY KEY,
1021
+ platform TEXT NOT NULL,
1022
+ platform_chat_id TEXT NOT NULL,
1023
+ status TEXT NOT NULL CHECK(status IN ('succeeded','failed','skipped')),
1024
+ processed_message_count INTEGER NOT NULL,
1025
+ generated_entry_count INTEGER NOT NULL,
1026
+ error TEXT,
1027
+ started_at TEXT NOT NULL,
1028
+ finished_at TEXT NOT NULL
1029
+ );
1030
+
956
1031
  CREATE TABLE IF NOT EXISTS memory_episode_messages (
957
1032
  episode_id TEXT NOT NULL REFERENCES memory_episodes(id) ON DELETE CASCADE,
958
1033
  message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
@@ -995,6 +1070,7 @@ function migrateDatabase(database) {
995
1070
  answer TEXT NOT NULL,
996
1071
  citations_json TEXT NOT NULL,
997
1072
  retrieval_debug_json TEXT NOT NULL,
1073
+ trace_json TEXT NOT NULL DEFAULT '{}',
998
1074
  status TEXT NOT NULL CHECK(status IN ('answered','failed')),
999
1075
  error TEXT,
1000
1076
  created_at TEXT NOT NULL
@@ -1079,6 +1155,11 @@ function migrateDatabase(database) {
1079
1155
  CREATE INDEX IF NOT EXISTS feishu_chat_members_chat_name_idx
1080
1156
  ON feishu_chat_members(chat_id, user_name);
1081
1157
  `);
1158
+ const messageColumns = database.prepare("PRAGMA table_info(messages)").all();
1159
+ if (!messageColumns.some((column) => column.name === "person_id")) {
1160
+ database.prepare("ALTER TABLE messages ADD COLUMN person_id TEXT REFERENCES persons(id) ON DELETE SET NULL").run();
1161
+ }
1162
+ database.prepare("CREATE INDEX IF NOT EXISTS messages_person_idx ON messages(person_id, sent_at)").run();
1082
1163
  const cronJobColumns = database.prepare("PRAGMA table_info(cron_jobs)").all();
1083
1164
  const ensureCronJobColumn = (name, definition) => {
1084
1165
  if (!cronJobColumns.some((column) => column.name === name)) {
@@ -1089,6 +1170,10 @@ function migrateDatabase(database) {
1089
1170
  ensureCronJobColumn("mention_target_name", "mention_target_name TEXT");
1090
1171
  ensureCronJobColumn("mention_open_id", "mention_open_id TEXT");
1091
1172
  ensureCronJobColumn("mention_user_id", "mention_user_id TEXT");
1173
+ const qaLogColumns = database.prepare("PRAGMA table_info(qa_logs)").all();
1174
+ if (!qaLogColumns.some((column) => column.name === "trace_json")) {
1175
+ database.prepare("ALTER TABLE qa_logs ADD COLUMN trace_json TEXT NOT NULL DEFAULT '{}'").run();
1176
+ }
1092
1177
  }
1093
1178
 
1094
1179
  // src/doctor/checks.ts
@@ -1784,6 +1869,10 @@ function buildScopeWhere(scope) {
1784
1869
  clauses.push("c.platform_chat_id = ?");
1785
1870
  params.push(scope.platformChatId);
1786
1871
  }
1872
+ if (scope?.personId) {
1873
+ clauses.push("m.person_id = ?");
1874
+ params.push(scope.personId);
1875
+ }
1787
1876
  return {
1788
1877
  where: clauses.length > 0 ? `AND ${clauses.join(" AND ")}` : "",
1789
1878
  params
@@ -1828,14 +1917,15 @@ var MessageRepository = class {
1828
1917
  `
1829
1918
  INSERT INTO messages (
1830
1919
  id, platform, platform_message_id, chat_id, sender_id, sender_name,
1831
- message_type, text, raw_payload_json, sent_at, received_at, created_at
1920
+ person_id, message_type, text, raw_payload_json, sent_at, received_at, created_at
1832
1921
  )
1833
1922
  VALUES (
1834
1923
  @id, @platform, @platformMessageId, @chatId, @senderId, @senderName,
1835
- @messageType, @text, @rawPayloadJson, @sentAt, @receivedAt, @createdAt
1924
+ @personId, @messageType, @text, @rawPayloadJson, @sentAt, @receivedAt, @createdAt
1836
1925
  )
1837
1926
  ON CONFLICT(platform, platform_message_id)
1838
1927
  DO UPDATE SET
1928
+ person_id = COALESCE(excluded.person_id, messages.person_id),
1839
1929
  message_type = excluded.message_type,
1840
1930
  text = excluded.text,
1841
1931
  raw_payload_json = excluded.raw_payload_json,
@@ -1848,6 +1938,7 @@ var MessageRepository = class {
1848
1938
  chatId,
1849
1939
  senderId: input.senderId,
1850
1940
  senderName: input.senderName,
1941
+ personId: input.personId ?? null,
1851
1942
  messageType: input.messageType,
1852
1943
  text: input.text,
1853
1944
  rawPayloadJson,
@@ -1890,6 +1981,7 @@ var MessageRepository = class {
1890
1981
  m.chat_id AS chatId,
1891
1982
  m.sender_id AS senderId,
1892
1983
  m.sender_name AS senderName,
1984
+ m.person_id AS personId,
1893
1985
  m.sent_at AS sentAt,
1894
1986
  c.platform_chat_id AS platformChatId,
1895
1987
  c.name AS chatName
@@ -1912,6 +2004,7 @@ ${input.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input.summary.trim()}`;
1912
2004
  platformMessageId: derivedPlatformMessageId,
1913
2005
  senderId: source.senderId,
1914
2006
  senderName: source.senderName,
2007
+ personId: source.personId ?? void 0,
1915
2008
  messageType: "image_summary",
1916
2009
  text: summaryText,
1917
2010
  sentAt: source.sentAt,
@@ -1938,7 +2031,9 @@ ${input.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input.summary.trim()}`;
1938
2031
  1.0 AS score,
1939
2032
  m.message_type AS messageType,
1940
2033
  c.name AS chatName,
2034
+ m.sender_id AS senderId,
1941
2035
  m.sender_name AS senderName,
2036
+ m.person_id AS personId,
1942
2037
  m.sent_at AS sentAt
1943
2038
  FROM message_chunks mc
1944
2039
  JOIN messages m ON m.id = mc.message_id
@@ -1959,7 +2054,9 @@ ${input.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input.summary.trim()}`;
1959
2054
  1.0 AS score,
1960
2055
  m.message_type AS messageType,
1961
2056
  c.name AS chatName,
2057
+ m.sender_id AS senderId,
1962
2058
  m.sender_name AS senderName,
2059
+ m.person_id AS personId,
1963
2060
  m.sent_at AS sentAt
1964
2061
  FROM message_chunks mc
1965
2062
  JOIN messages m ON m.id = mc.message_id
@@ -1983,7 +2080,9 @@ ${input.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input.summary.trim()}`;
1983
2080
  1.0 AS score,
1984
2081
  m.message_type AS messageType,
1985
2082
  c.name AS chatName,
2083
+ m.sender_id AS senderId,
1986
2084
  m.sender_name AS senderName,
2085
+ m.person_id AS personId,
1987
2086
  m.sent_at AS sentAt
1988
2087
  FROM message_chunks mc
1989
2088
  JOIN messages m ON m.id = mc.message_id
@@ -1999,7 +2098,7 @@ ${input.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input.summary.trim()}`;
1999
2098
  const excludedIds = options.excludeMessageIds ?? [];
2000
2099
  const excludedWhere = excludedIds.length > 0 ? `AND fts.message_id NOT IN (${excludedIds.map(() => "?").join(", ")})` : "";
2001
2100
  const scope = buildScopeWhere(options.scope);
2002
- const ftsResults = this.database.prepare(
2101
+ const ftsRows = this.database.prepare(
2003
2102
  `
2004
2103
  SELECT
2005
2104
  fts.chunk_id AS chunkId,
@@ -2009,8 +2108,11 @@ ${input.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input.summary.trim()}`;
2009
2108
  bm25(message_chunks_fts) * -1 AS score,
2010
2109
  m.message_type AS messageType,
2011
2110
  c.name AS chatName,
2111
+ m.sender_id AS senderId,
2012
2112
  m.sender_name AS senderName,
2013
- m.sent_at AS sentAt
2113
+ m.person_id AS personId,
2114
+ m.sent_at AS sentAt,
2115
+ mc.chunk_index AS chunkIndex
2014
2116
  FROM message_chunks_fts fts
2015
2117
  JOIN message_chunks mc ON mc.id = fts.chunk_id
2016
2118
  JOIN messages m ON m.id = fts.message_id
@@ -2018,10 +2120,23 @@ ${input.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input.summary.trim()}`;
2018
2120
  WHERE message_chunks_fts MATCH ?
2019
2121
  ${excludedWhere}
2020
2122
  ${scope.where}
2021
- ORDER BY bm25(message_chunks_fts)
2123
+ ORDER BY bm25(message_chunks_fts), m.sent_at DESC, mc.chunk_index ASC
2022
2124
  LIMIT ?
2023
2125
  `
2024
- ).all(ftsQuery, ...excludedIds, ...scope.params, limit);
2126
+ ).all(ftsQuery, ...excludedIds, ...scope.params, Math.max(limit * 8, limit));
2127
+ const ftsResults = [];
2128
+ const seenMessageIds = /* @__PURE__ */ new Set();
2129
+ for (const row of ftsRows) {
2130
+ if (seenMessageIds.has(row.messageId)) {
2131
+ continue;
2132
+ }
2133
+ seenMessageIds.add(row.messageId);
2134
+ const { chunkIndex: _chunkIndex, ...result } = row;
2135
+ ftsResults.push(result);
2136
+ if (ftsResults.length >= limit) {
2137
+ break;
2138
+ }
2139
+ }
2025
2140
  if (ftsResults.length > 0) {
2026
2141
  return ftsResults;
2027
2142
  }
@@ -2035,22 +2150,30 @@ ${input.summary.trim()}` : `[\u56FE\u7247\u8F6C\u8FF0] ${input.summary.trim()}`;
2035
2150
  return this.database.prepare(
2036
2151
  `
2037
2152
  SELECT
2038
- mc.id AS chunkId,
2039
- m.id AS messageId,
2040
- m.platform AS platform,
2041
- mc.text AS text,
2042
- 0.1 AS score,
2043
- m.message_type AS messageType,
2044
- c.name AS chatName,
2045
- m.sender_name AS senderName,
2046
- m.sent_at AS sentAt
2047
- FROM message_chunks mc
2048
- JOIN messages m ON m.id = mc.message_id
2049
- JOIN chats c ON c.id = m.chat_id
2050
- WHERE (${where})
2051
- ${likeExcludedWhere}
2052
- ${scope.where}
2053
- ORDER BY m.sent_at DESC
2153
+ *
2154
+ FROM (
2155
+ SELECT
2156
+ mc.id AS chunkId,
2157
+ m.id AS messageId,
2158
+ m.platform AS platform,
2159
+ mc.text AS text,
2160
+ 0.1 AS score,
2161
+ m.message_type AS messageType,
2162
+ c.name AS chatName,
2163
+ m.sender_id AS senderId,
2164
+ m.sender_name AS senderName,
2165
+ m.person_id AS personId,
2166
+ m.sent_at AS sentAt,
2167
+ ROW_NUMBER() OVER (PARTITION BY m.id ORDER BY mc.chunk_index ASC) AS rowNumber
2168
+ FROM message_chunks mc
2169
+ JOIN messages m ON m.id = mc.message_id
2170
+ JOIN chats c ON c.id = m.chat_id
2171
+ WHERE (${where})
2172
+ ${likeExcludedWhere}
2173
+ ${scope.where}
2174
+ ) ranked
2175
+ WHERE rowNumber = 1
2176
+ ORDER BY sentAt DESC
2054
2177
  LIMIT ?
2055
2178
  `
2056
2179
  ).all(...params, ...excludedIds, ...scope.params, limit);
@@ -2465,19 +2588,20 @@ var HybridRetriever = class {
2465
2588
 
2466
2589
  // src/rag/message-retriever.ts
2467
2590
  function toEvidenceSource(result) {
2468
- if (result.messageType === "file") {
2469
- return {
2470
- type: "file",
2471
- label: result.senderName,
2472
- timestamp: result.sentAt
2473
- };
2474
- }
2475
- return {
2476
- type: "message",
2477
- label: result.chatName,
2478
- sender: result.senderName,
2591
+ const source = {
2592
+ type: result.messageType === "file" ? "file" : "message",
2593
+ label: result.messageType === "file" ? result.senderName : result.chatName,
2479
2594
  timestamp: result.sentAt
2480
2595
  };
2596
+ if (result.messageType !== "file") {
2597
+ source.sender = result.senderName;
2598
+ }
2599
+ source.senderId = result.senderId;
2600
+ source.profileAvailable = Boolean(result.personId);
2601
+ if (result.personId) {
2602
+ source.personId = result.personId;
2603
+ }
2604
+ return source;
2481
2605
  }
2482
2606
  var MessageFtsRetriever = class {
2483
2607
  constructor(messages, options = {}) {
@@ -2606,6 +2730,9 @@ function toEvidenceSource2(row) {
2606
2730
  type: "message",
2607
2731
  label: row.chatName,
2608
2732
  sender: row.senderName,
2733
+ senderId: row.senderId,
2734
+ personId: row.personId,
2735
+ profileAvailable: Boolean(row.personId),
2609
2736
  timestamp: row.sentAt
2610
2737
  };
2611
2738
  }
@@ -2620,6 +2747,10 @@ function buildScopeWhere3(scope) {
2620
2747
  clauses.push("c.platform_chat_id = ?");
2621
2748
  params.push(scope.platformChatId);
2622
2749
  }
2750
+ if (scope?.personId) {
2751
+ clauses.push("m.person_id = ?");
2752
+ params.push(scope.personId);
2753
+ }
2623
2754
  return {
2624
2755
  where: clauses.length > 0 ? `AND ${clauses.join(" AND ")}` : "",
2625
2756
  params
@@ -2670,7 +2801,9 @@ var SqliteVectorStore = class {
2670
2801
  mc.id AS chunkId,
2671
2802
  mc.text AS text,
2672
2803
  c.name AS chatName,
2804
+ m.sender_id AS senderId,
2673
2805
  m.sender_name AS senderName,
2806
+ m.person_id AS personId,
2674
2807
  m.sent_at AS sentAt,
2675
2808
  e.embedding_json AS embeddingJson
2676
2809
  FROM message_chunk_embeddings e
@@ -2751,8 +2884,12 @@ async function createAgenticRagSearchTools(input) {
2751
2884
  new SqliteVectorStore(input.database, { model: input.config.embedding.model })
2752
2885
  ) : void 0;
2753
2886
  const hybrid = new HybridRetriever(semantic ? [episodes, messages, semantic] : [episodes, messages]);
2887
+ const tools = createRagSearchTools({ hybrid, messages, episodes, semantic, scope: input.scope });
2888
+ if (input.profileTools && input.profileTools.length > 0) {
2889
+ tools.push(...input.profileTools);
2890
+ }
2754
2891
  return {
2755
- tools: createRagSearchTools({ hybrid, messages, episodes, semantic, scope: input.scope }),
2892
+ tools,
2756
2893
  close: () => {
2757
2894
  }
2758
2895
  };
@@ -3360,6 +3497,588 @@ function parseExactNumber2(field, min, max) {
3360
3497
  return value;
3361
3498
  }
3362
3499
 
3500
+ // src/profiles/rag-tools.ts
3501
+ var getProfileInputSchema = {
3502
+ type: "object",
3503
+ properties: {
3504
+ personId: { type: "string", description: "Stable person identifier from retrieved evidence." },
3505
+ senderId: { type: "string", description: "Message sender id when personId is unavailable." },
3506
+ platformChatId: { type: "string", description: "Chat id paired with senderId for profile lookup." },
3507
+ includeEvidence: { type: "boolean", description: "Whether to include evidence snippets in the profile text." },
3508
+ includeInferred: { type: "boolean", description: "Whether to include inferred profile entries." }
3509
+ },
3510
+ additionalProperties: false
3511
+ };
3512
+ var searchMessagesInputSchema = {
3513
+ type: "object",
3514
+ properties: {
3515
+ personId: { type: "string", description: "The stable person identifier whose messages to search." },
3516
+ query: { type: "string", description: "Search query written by the model." },
3517
+ limit: { type: "number", description: "Maximum number of evidence blocks to return." }
3518
+ },
3519
+ required: ["personId", "query"],
3520
+ additionalProperties: false
3521
+ };
3522
+ function readString2(value) {
3523
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
3524
+ }
3525
+ function resolvePersonId(profiles, input) {
3526
+ const raw = typeof input === "object" && input !== null ? input : {};
3527
+ const personId = readString2(raw.personId);
3528
+ if (personId) return personId;
3529
+ const senderId = readString2(raw.senderId);
3530
+ const platformChatId = readString2(raw.platformChatId);
3531
+ if (senderId && platformChatId) {
3532
+ const resolved = profiles.resolvePersonIdForSender({ senderId, platformChatId });
3533
+ if (resolved) return resolved;
3534
+ }
3535
+ throw new Error("personId \u6216 senderId + platformChatId \u5FC5\u987B\u63D0\u4F9B\u3002");
3536
+ }
3537
+ function parseBoolean(input, key, defaultValue) {
3538
+ const value = typeof input === "object" && input !== null ? input[key] : void 0;
3539
+ return typeof value === "boolean" ? value : defaultValue;
3540
+ }
3541
+ function parseQuery(input) {
3542
+ const rawQuery = typeof input === "object" && input !== null && "query" in input ? input.query : void 0;
3543
+ if (typeof rawQuery !== "string") {
3544
+ throw new Error("\u641C\u7D22 query \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\u3002");
3545
+ }
3546
+ const query = rawQuery.trim();
3547
+ if (!query) {
3548
+ throw new Error("\u641C\u7D22 query \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\u3002");
3549
+ }
3550
+ return query;
3551
+ }
3552
+ function parseLimit(input) {
3553
+ const rawLimit = typeof input === "object" && input !== null && "limit" in input ? input.limit : void 0;
3554
+ const numericLimit = typeof rawLimit === "number" && Number.isFinite(rawLimit) ? rawLimit : 5;
3555
+ return Math.min(12, Math.max(1, Math.floor(numericLimit)));
3556
+ }
3557
+ function createGetPersonProfileTool(profiles) {
3558
+ return {
3559
+ name: "get_person_profile",
3560
+ description: "Retrieve an evidence-backed profile for a person. Use this when the question depends on who someone is, their role, preferences, personality, relationships, or recent state.",
3561
+ inputSchema: getProfileInputSchema,
3562
+ execute: async (input) => {
3563
+ const personId = resolvePersonId(profiles, input);
3564
+ const includeEvidence = parseBoolean(input, "includeEvidence", false);
3565
+ const includeInferred = parseBoolean(input, "includeInferred", true);
3566
+ const profile = profiles.getPersonProfile(personId, { includeEvidence, includeInferred });
3567
+ if (!profile) {
3568
+ return [];
3569
+ }
3570
+ const aliases = profile.identities.map((identity) => identity.displayName).filter(Boolean).join("\u3001");
3571
+ const entries = profile.entries.map((entry) => {
3572
+ const evidence = includeEvidence && entry.evidence?.length ? `
3573
+ \u8BC1\u636E\uFF1A${entry.evidence.map((item) => `${item.quote}\uFF08${item.reason}\uFF09`).join("\uFF1B")}` : "";
3574
+ return `- [${entry.entryType}] ${entry.category}\uFF1A${entry.content}\uFF08\u7F6E\u4FE1\u5EA6 ${entry.confidence}\uFF0C\u6765\u6E90 ${entry.source}\uFF09${evidence}`;
3575
+ });
3576
+ return [{
3577
+ id: `person_profile:${profile.person.id}`,
3578
+ text: [`\u4EBA\u7269\uFF1A${profile.person.primaryName}`, aliases ? `\u8EAB\u4EFD/\u6635\u79F0\uFF1A${aliases}` : void 0, ...entries].filter(Boolean).join("\n"),
3579
+ score: 1,
3580
+ source: {
3581
+ type: "person_profile",
3582
+ label: profile.person.primaryName,
3583
+ personId: profile.person.id,
3584
+ profileAvailable: true
3585
+ }
3586
+ }];
3587
+ }
3588
+ };
3589
+ }
3590
+ function createSearchPersonMessagesTool(profiles) {
3591
+ return {
3592
+ name: "search_person_messages",
3593
+ description: "Search chat messages sent by a specific person only. Use this when the question is explicitly about what a particular person said, or when you need to find messages from a specific person.",
3594
+ inputSchema: searchMessagesInputSchema,
3595
+ execute: async (input) => {
3596
+ const personId = resolvePersonId(profiles, input);
3597
+ const query = parseQuery(input);
3598
+ const limit = parseLimit(input);
3599
+ const results = profiles.searchPersonMessages(personId, query, limit);
3600
+ return results.map((result) => ({
3601
+ id: result.chunkId,
3602
+ text: result.text,
3603
+ score: result.score,
3604
+ source: {
3605
+ type: result.messageType === "file" ? "file" : "message",
3606
+ label: result.chatName,
3607
+ sender: result.senderName,
3608
+ senderId: result.senderId,
3609
+ timestamp: result.sentAt,
3610
+ personId: result.personId ?? void 0,
3611
+ profileAvailable: Boolean(result.personId)
3612
+ }
3613
+ }));
3614
+ }
3615
+ };
3616
+ }
3617
+ function createPersonProfileTools({ profiles }) {
3618
+ return [createGetPersonProfileTool(profiles), createSearchPersonMessagesTool(profiles)];
3619
+ }
3620
+
3621
+ // src/profiles/repository.ts
3622
+ import crypto5 from "crypto";
3623
+ function nowIso4() {
3624
+ return (/* @__PURE__ */ new Date()).toISOString();
3625
+ }
3626
+ function createId(prefix, parts) {
3627
+ return `${prefix}_${crypto5.createHash("sha256").update(parts.join("")).digest("hex").slice(0, 24)}`;
3628
+ }
3629
+ function mapPerson(row) {
3630
+ return {
3631
+ id: row.id,
3632
+ primaryName: row.primaryName,
3633
+ notes: row.notes ?? void 0,
3634
+ createdAt: row.createdAt,
3635
+ updatedAt: row.updatedAt
3636
+ };
3637
+ }
3638
+ var ProfileRepository = class {
3639
+ constructor(database) {
3640
+ this.database = database;
3641
+ }
3642
+ database;
3643
+ resolvePersonForSender(input) {
3644
+ const observedAt = input.observedAt ?? nowIso4();
3645
+ const personId = createId("person", [input.platform, input.platformChatId, input.senderId]);
3646
+ const identityId = createId("identity", [input.platform, input.platformChatId, input.senderId]);
3647
+ const findPerson = this.database.prepare(
3648
+ `
3649
+ SELECT id, primary_name AS primaryName, notes, created_at AS createdAt, updated_at AS updatedAt
3650
+ FROM persons
3651
+ WHERE id = ?
3652
+ `
3653
+ );
3654
+ const transaction = this.database.transaction(() => {
3655
+ this.database.prepare(
3656
+ `
3657
+ INSERT INTO persons (id, primary_name, notes, created_at, updated_at)
3658
+ VALUES (?, ?, NULL, ?, ?)
3659
+ ON CONFLICT(id) DO NOTHING
3660
+ `
3661
+ ).run(personId, input.senderName, observedAt, observedAt);
3662
+ this.database.prepare(
3663
+ `
3664
+ INSERT INTO person_identities (
3665
+ id, person_id, platform, platform_chat_id, external_user_id, external_open_id,
3666
+ external_union_id, external_user_id_raw, display_name, alias, source, first_seen_at, last_seen_at
3667
+ ) VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, ?, NULL, ?, ?, ?)
3668
+ ON CONFLICT(platform, platform_chat_id, external_user_id)
3669
+ DO UPDATE SET
3670
+ display_name = excluded.display_name,
3671
+ source = excluded.source,
3672
+ last_seen_at = excluded.last_seen_at
3673
+ `
3674
+ ).run(identityId, personId, input.platform, input.platformChatId, input.senderId, input.senderId, input.senderName, input.source, observedAt, observedAt);
3675
+ this.database.prepare(
3676
+ `
3677
+ UPDATE persons
3678
+ SET primary_name = ?, updated_at = ?
3679
+ WHERE id = ?
3680
+ `
3681
+ ).run(input.senderName, observedAt, personId);
3682
+ return findPerson.get(personId);
3683
+ });
3684
+ return transaction();
3685
+ }
3686
+ listPersons() {
3687
+ const rows = this.database.prepare(
3688
+ `
3689
+ SELECT id, primary_name AS primaryName, notes, created_at AS createdAt, updated_at AS updatedAt
3690
+ FROM persons
3691
+ ORDER BY updated_at DESC, created_at DESC
3692
+ `
3693
+ ).all();
3694
+ return rows.map(mapPerson);
3695
+ }
3696
+ getPersonProfile(personId, options = {}) {
3697
+ const personRow = this.database.prepare(
3698
+ `
3699
+ SELECT id, primary_name AS primaryName, notes, created_at AS createdAt, updated_at AS updatedAt
3700
+ FROM persons
3701
+ WHERE id = ?
3702
+ `
3703
+ ).get(personId);
3704
+ if (!personRow) {
3705
+ return void 0;
3706
+ }
3707
+ const includeInferred = options.includeInferred ?? true;
3708
+ const entryRows = this.database.prepare(
3709
+ `
3710
+ SELECT
3711
+ id,
3712
+ person_id AS personId,
3713
+ category,
3714
+ content,
3715
+ entry_type AS entryType,
3716
+ confidence,
3717
+ status,
3718
+ source,
3719
+ created_at AS createdAt,
3720
+ updated_at AS updatedAt,
3721
+ last_observed_at AS lastObservedAt
3722
+ FROM person_profile_entries
3723
+ WHERE person_id = ?
3724
+ AND status = 'active'
3725
+ ${includeInferred ? "" : "AND entry_type = 'fact'"}
3726
+ ORDER BY updated_at DESC, created_at DESC
3727
+ `
3728
+ ).all(personId);
3729
+ const entries = options.includeEvidence ? entryRows.map((entry) => ({ ...entry, evidence: this.getEvidence(entry.id) })) : entryRows;
3730
+ const identities = this.database.prepare(
3731
+ `
3732
+ SELECT
3733
+ platform,
3734
+ platform_chat_id AS platformChatId,
3735
+ external_user_id AS externalUserId,
3736
+ display_name AS displayName,
3737
+ alias,
3738
+ source,
3739
+ first_seen_at AS firstSeenAt,
3740
+ last_seen_at AS lastSeenAt
3741
+ FROM person_identities
3742
+ WHERE person_id = ?
3743
+ ORDER BY last_seen_at DESC, first_seen_at DESC
3744
+ `
3745
+ ).all(personId);
3746
+ return {
3747
+ person: mapPerson(personRow),
3748
+ identities,
3749
+ entries
3750
+ };
3751
+ }
3752
+ upsertProfileEntry(input) {
3753
+ if (input.evidence.length === 0) {
3754
+ throw new Error("Profile entry evidence is required.");
3755
+ }
3756
+ const timestamp = input.observedAt ?? nowIso4();
3757
+ const entryId = createId("profile_entry", [input.personId, input.category, input.content]);
3758
+ const transaction = this.database.transaction(() => {
3759
+ this.database.prepare(
3760
+ `
3761
+ INSERT INTO person_profile_entries (
3762
+ id, person_id, category, content, entry_type, confidence, status, source, created_at, updated_at, last_observed_at
3763
+ ) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
3764
+ ON CONFLICT(id) DO UPDATE SET
3765
+ confidence = MAX(person_profile_entries.confidence, excluded.confidence),
3766
+ status = 'active',
3767
+ source = excluded.source,
3768
+ updated_at = excluded.updated_at,
3769
+ last_observed_at = excluded.last_observed_at
3770
+ `
3771
+ ).run(entryId, input.personId, input.category, input.content, input.entryType, input.confidence, input.source, timestamp, timestamp, timestamp);
3772
+ const insertEvidence = this.database.prepare(
3773
+ `
3774
+ INSERT INTO person_profile_evidence (entry_id, message_id, quote, reason)
3775
+ VALUES (?, ?, ?, ?)
3776
+ ON CONFLICT(entry_id, message_id, quote) DO UPDATE SET reason = excluded.reason
3777
+ `
3778
+ );
3779
+ for (const evidence of input.evidence) {
3780
+ insertEvidence.run(entryId, evidence.messageId, evidence.quote, evidence.reason);
3781
+ }
3782
+ });
3783
+ transaction();
3784
+ return entryId;
3785
+ }
3786
+ backfillMessagePersons({ limit }) {
3787
+ const rows = this.database.prepare(
3788
+ `
3789
+ SELECT
3790
+ m.id AS id,
3791
+ m.platform AS platform,
3792
+ c.platform_chat_id AS platformChatId,
3793
+ m.sender_id AS senderId,
3794
+ m.sender_name AS senderName,
3795
+ m.sent_at AS sentAt
3796
+ FROM messages m
3797
+ JOIN chats c ON c.id = m.chat_id
3798
+ WHERE m.person_id IS NULL
3799
+ ORDER BY m.sent_at ASC
3800
+ LIMIT ?
3801
+ `
3802
+ ).all(limit);
3803
+ const update = this.database.prepare("UPDATE messages SET person_id = ? WHERE id = ?");
3804
+ const transaction = this.database.transaction(() => {
3805
+ for (const row of rows) {
3806
+ const person = this.resolvePersonForSender({
3807
+ platform: row.platform,
3808
+ platformChatId: row.platformChatId,
3809
+ senderId: row.senderId,
3810
+ senderName: row.senderName,
3811
+ source: "inferred",
3812
+ observedAt: row.sentAt
3813
+ });
3814
+ update.run(person.id, row.id);
3815
+ }
3816
+ });
3817
+ transaction();
3818
+ return { updatedMessages: rows.length };
3819
+ }
3820
+ getDreamState(platform, platformChatId) {
3821
+ return this.database.prepare(
3822
+ `
3823
+ SELECT
3824
+ platform,
3825
+ platform_chat_id AS platformChatId,
3826
+ last_message_id AS lastMessageId,
3827
+ last_message_sent_at AS lastMessageSentAt,
3828
+ updated_at AS updatedAt
3829
+ FROM profile_dream_state
3830
+ WHERE platform = ? AND platform_chat_id = ?
3831
+ `
3832
+ ).get(platform, platformChatId);
3833
+ }
3834
+ updateDreamState(input) {
3835
+ this.database.prepare(
3836
+ `
3837
+ INSERT INTO profile_dream_state (platform, platform_chat_id, last_message_id, last_message_sent_at, updated_at)
3838
+ VALUES (?, ?, ?, ?, ?)
3839
+ ON CONFLICT(platform, platform_chat_id)
3840
+ DO UPDATE SET
3841
+ last_message_id = excluded.last_message_id,
3842
+ last_message_sent_at = excluded.last_message_sent_at,
3843
+ updated_at = excluded.updated_at
3844
+ `
3845
+ ).run(input.platform, input.platformChatId, input.lastMessageId ?? null, input.lastMessageSentAt ?? null, input.updatedAt);
3846
+ }
3847
+ listMessagesForDream(input) {
3848
+ const afterWhere = input.afterSentAt ? "AND m.sent_at > ?" : "";
3849
+ const params = input.afterSentAt ? [input.platform, input.platformChatId, input.afterSentAt, input.limit] : [input.platform, input.platformChatId, input.limit];
3850
+ return this.database.prepare(
3851
+ `
3852
+ SELECT
3853
+ m.id AS messageId,
3854
+ m.person_id AS personId,
3855
+ m.sender_name AS senderName,
3856
+ m.sent_at AS sentAt,
3857
+ m.text AS text
3858
+ FROM messages m
3859
+ JOIN chats c ON c.id = m.chat_id
3860
+ WHERE m.platform = ?
3861
+ AND c.platform_chat_id = ?
3862
+ AND m.person_id IS NOT NULL
3863
+ ${afterWhere}
3864
+ ORDER BY m.sent_at ASC, m.created_at ASC
3865
+ LIMIT ?
3866
+ `
3867
+ ).all(...params);
3868
+ }
3869
+ listChatsWithPendingDreamMessages() {
3870
+ return this.database.prepare(
3871
+ `
3872
+ SELECT DISTINCT m.platform AS platform, c.platform_chat_id AS platformChatId
3873
+ FROM messages m
3874
+ JOIN chats c ON c.id = m.chat_id
3875
+ LEFT JOIN profile_dream_state pds ON pds.platform = m.platform AND pds.platform_chat_id = c.platform_chat_id
3876
+ WHERE m.person_id IS NOT NULL
3877
+ AND (pds.last_message_sent_at IS NULL OR m.sent_at > pds.last_message_sent_at)
3878
+ ORDER BY c.platform_chat_id ASC
3879
+ `
3880
+ ).all();
3881
+ }
3882
+ recordDreamRun(input) {
3883
+ const id = input.id ?? createId("profile_dream_run", [input.platform, input.platformChatId, input.status, input.startedAt, input.finishedAt, crypto5.randomUUID()]);
3884
+ this.database.prepare(
3885
+ `
3886
+ INSERT INTO profile_dream_runs (
3887
+ id, platform, platform_chat_id, status, processed_message_count, generated_entry_count, error, started_at, finished_at
3888
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
3889
+ `
3890
+ ).run(
3891
+ id,
3892
+ input.platform,
3893
+ input.platformChatId,
3894
+ input.status,
3895
+ input.processedMessageCount,
3896
+ input.generatedEntryCount,
3897
+ input.error ?? null,
3898
+ input.startedAt,
3899
+ input.finishedAt
3900
+ );
3901
+ return id;
3902
+ }
3903
+ resolvePersonIdForSender(input) {
3904
+ const platform = input.platform ?? "feishu";
3905
+ const row = this.database.prepare(
3906
+ `
3907
+ SELECT person_id AS personId
3908
+ FROM person_identities
3909
+ WHERE platform = ? AND platform_chat_id = ? AND external_user_id = ?
3910
+ LIMIT 1
3911
+ `
3912
+ ).get(platform, input.platformChatId, input.senderId);
3913
+ return row?.personId;
3914
+ }
3915
+ searchPersonMessages(personId, query, limit, options = {}) {
3916
+ const cleaned = query.trim().split(/\s+/).map((term) => term.replace(/"/g, '""')).filter(Boolean);
3917
+ const wrapped = cleaned.map((term) => `"${term}"`).join(" ");
3918
+ if (!wrapped) {
3919
+ return [];
3920
+ }
3921
+ const excludedIds = options.excludeMessageIds ?? [];
3922
+ const excludedWhere = excludedIds.length > 0 ? `AND fts.message_id NOT IN (${excludedIds.map(() => "?").join(", ")})` : "";
3923
+ const rows = this.database.prepare(
3924
+ `
3925
+ SELECT
3926
+ fts.chunk_id AS chunkId,
3927
+ fts.message_id AS messageId,
3928
+ m.platform AS platform,
3929
+ mc.text AS text,
3930
+ bm25(message_chunks_fts) * -1 AS score,
3931
+ m.message_type AS messageType,
3932
+ c.name AS chatName,
3933
+ m.sender_id AS senderId,
3934
+ m.sender_name AS senderName,
3935
+ m.person_id AS personId,
3936
+ m.sent_at AS sentAt,
3937
+ mc.chunk_index AS chunkIndex
3938
+ FROM message_chunks_fts fts
3939
+ JOIN message_chunks mc ON mc.id = fts.chunk_id
3940
+ JOIN messages m ON m.id = fts.message_id
3941
+ JOIN chats c ON c.id = m.chat_id
3942
+ WHERE message_chunks_fts MATCH ?
3943
+ ${excludedWhere}
3944
+ AND m.person_id = ?
3945
+ ORDER BY bm25(message_chunks_fts), m.sent_at DESC, mc.chunk_index ASC
3946
+ LIMIT ?
3947
+ `
3948
+ ).all(wrapped, ...excludedIds, personId, Math.max(limit * 8, limit));
3949
+ const results = [];
3950
+ const seenMessageIds = /* @__PURE__ */ new Set();
3951
+ for (const row of rows) {
3952
+ if (seenMessageIds.has(row.messageId)) {
3953
+ continue;
3954
+ }
3955
+ seenMessageIds.add(row.messageId);
3956
+ const { chunkIndex: _chunkIndex, ...result } = row;
3957
+ results.push(result);
3958
+ if (results.length >= limit) {
3959
+ break;
3960
+ }
3961
+ }
3962
+ if (results.length > 0) {
3963
+ return results;
3964
+ }
3965
+ const terms = query.split(/[  ]+/).map((term) => term.trim()).filter((term) => term.length > 0);
3966
+ if (terms.length === 0) {
3967
+ return [];
3968
+ }
3969
+ const where = terms.map(() => "mc.text LIKE ? ESCAPE '\\'").join(" OR ");
3970
+ const params = terms.map((term) => `%${term.replace(/[%_]/g, "\\$&")}%`);
3971
+ const likeExcludedWhere = excludedIds.length > 0 ? `AND m.id NOT IN (${excludedIds.map(() => "?").join(", ")})` : "";
3972
+ return this.database.prepare(
3973
+ `
3974
+ SELECT
3975
+ *
3976
+ FROM (
3977
+ SELECT
3978
+ mc.id AS chunkId,
3979
+ m.id AS messageId,
3980
+ m.platform AS platform,
3981
+ mc.text AS text,
3982
+ 0.1 AS score,
3983
+ m.message_type AS messageType,
3984
+ c.name AS chatName,
3985
+ m.sender_id AS senderId,
3986
+ m.sender_name AS senderName,
3987
+ m.person_id AS personId,
3988
+ m.sent_at AS sentAt,
3989
+ ROW_NUMBER() OVER (PARTITION BY m.id ORDER BY mc.chunk_index ASC) AS rowNumber
3990
+ FROM message_chunks mc
3991
+ JOIN messages m ON m.id = mc.message_id
3992
+ JOIN chats c ON c.id = m.chat_id
3993
+ WHERE (${where})
3994
+ ${likeExcludedWhere}
3995
+ AND m.person_id = ?
3996
+ ) ranked
3997
+ WHERE rowNumber = 1
3998
+ ORDER BY sentAt DESC
3999
+ LIMIT ?
4000
+ `
4001
+ ).all(...params, ...excludedIds, personId, limit);
4002
+ }
4003
+ getProfileEntry(entryId) {
4004
+ const row = this.database.prepare(
4005
+ `
4006
+ SELECT
4007
+ id,
4008
+ person_id AS personId,
4009
+ category,
4010
+ content,
4011
+ entry_type AS entryType,
4012
+ confidence,
4013
+ status,
4014
+ source,
4015
+ created_at AS createdAt,
4016
+ updated_at AS updatedAt,
4017
+ last_observed_at AS lastObservedAt
4018
+ FROM person_profile_entries
4019
+ WHERE id = ?
4020
+ `
4021
+ ).get(entryId);
4022
+ if (!row) {
4023
+ return void 0;
4024
+ }
4025
+ return { ...row, evidence: this.getEvidence(entryId) };
4026
+ }
4027
+ replaceProfileEntry(input) {
4028
+ if (input.input.evidence.length === 0) {
4029
+ throw new Error("Profile entry evidence is required.");
4030
+ }
4031
+ const timestamp = input.input.observedAt ?? nowIso4();
4032
+ const newEntryId = createId("profile_entry", [input.input.personId, input.input.category, input.input.content]);
4033
+ const transaction = this.database.transaction(() => {
4034
+ this.database.prepare(
4035
+ `
4036
+ INSERT INTO person_profile_entries (
4037
+ id, person_id, category, content, entry_type, confidence, status, source, created_at, updated_at, last_observed_at
4038
+ ) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
4039
+ `
4040
+ ).run(newEntryId, input.input.personId, input.input.category, input.input.content, input.input.entryType, input.input.confidence, input.input.source, timestamp, timestamp, timestamp);
4041
+ this.database.prepare(
4042
+ `
4043
+ UPDATE person_profile_entries
4044
+ SET status = 'superseded', updated_at = ?
4045
+ WHERE id = ? AND status = 'active'
4046
+ `
4047
+ ).run(timestamp, input.supersedeEntryId);
4048
+ const insertEvidence = this.database.prepare(
4049
+ `
4050
+ INSERT INTO person_profile_evidence (entry_id, message_id, quote, reason)
4051
+ VALUES (?, ?, ?, ?)
4052
+ ON CONFLICT(entry_id, message_id, quote) DO UPDATE SET reason = excluded.reason
4053
+ `
4054
+ );
4055
+ for (const evidence of input.input.evidence) {
4056
+ insertEvidence.run(newEntryId, evidence.messageId, evidence.quote, evidence.reason);
4057
+ }
4058
+ });
4059
+ transaction();
4060
+ return newEntryId;
4061
+ }
4062
+ markProfileEntryDeleted(entryId) {
4063
+ const timestamp = nowIso4();
4064
+ this.database.prepare("UPDATE person_profile_entries SET status = 'deleted', updated_at = ? WHERE id = ?").run(timestamp, entryId);
4065
+ }
4066
+ personExists(personId) {
4067
+ const row = this.database.prepare("SELECT 1 AS existsFlag FROM persons WHERE id = ? LIMIT 1").get(personId);
4068
+ return Boolean(row);
4069
+ }
4070
+ getEvidence(entryId) {
4071
+ return this.database.prepare(
4072
+ `
4073
+ SELECT entry_id AS entryId, message_id AS messageId, quote, reason
4074
+ FROM person_profile_evidence
4075
+ WHERE entry_id = ?
4076
+ ORDER BY message_id ASC, quote ASC
4077
+ `
4078
+ ).all(entryId);
4079
+ }
4080
+ };
4081
+
3363
4082
  // src/rag/indexer.ts
3364
4083
  var EMBEDDING_INDEX_BATCH_SIZE = 64;
3365
4084
  async function indexMessageChunks(input) {
@@ -3447,12 +4166,12 @@ async function processMessagesNow(input) {
3447
4166
  }
3448
4167
 
3449
4168
  // src/multimodal/tasks.ts
3450
- import crypto5 from "crypto";
3451
- function nowIso4() {
4169
+ import crypto6 from "crypto";
4170
+ function nowIso5() {
3452
4171
  return (/* @__PURE__ */ new Date()).toISOString();
3453
4172
  }
3454
4173
  function stableId3(sourceMessageId, imageKey) {
3455
- return crypto5.createHash("sha256").update(`${sourceMessageId}${imageKey}`).digest("hex").slice(0, 32);
4174
+ return crypto6.createHash("sha256").update(`${sourceMessageId}${imageKey}`).digest("hex").slice(0, 32);
3456
4175
  }
3457
4176
  function mapRow(row) {
3458
4177
  if (!row) {
@@ -3480,7 +4199,7 @@ var ImageMultimodalTaskRepository = class {
3480
4199
  database;
3481
4200
  enqueue(input) {
3482
4201
  const id = stableId3(input.sourceMessageId, input.imageKey);
3483
- const timestamp = nowIso4();
4202
+ const timestamp = nowIso5();
3484
4203
  this.database.prepare(
3485
4204
  `
3486
4205
  INSERT INTO image_multimodal_tasks (
@@ -3568,7 +4287,7 @@ var ImageMultimodalTaskRepository = class {
3568
4287
  updated_at = @updatedAt
3569
4288
  WHERE id = @id AND status = 'pending'
3570
4289
  `
3571
- ).run({ id, updatedAt: nowIso4() });
4290
+ ).run({ id, updatedAt: nowIso5() });
3572
4291
  if (result.changes === 0) {
3573
4292
  throw new Error(`\u56FE\u7247\u591A\u6A21\u6001\u4EFB\u52A1\u72B6\u6001\u65E0\u6CD5\u66F4\u65B0\uFF1A${id}`);
3574
4293
  }
@@ -3584,7 +4303,7 @@ var ImageMultimodalTaskRepository = class {
3584
4303
  updated_at = @updatedAt
3585
4304
  WHERE id = @id
3586
4305
  `
3587
- ).run({ id, derivedMessageId, updatedAt: nowIso4() });
4306
+ ).run({ id, derivedMessageId, updatedAt: nowIso5() });
3588
4307
  return this.requireById(id);
3589
4308
  }
3590
4309
  markSkipped(id, reason) {
@@ -3597,7 +4316,7 @@ var ImageMultimodalTaskRepository = class {
3597
4316
  updated_at = @updatedAt
3598
4317
  WHERE id = @id
3599
4318
  `
3600
- ).run({ id, reason, updatedAt: nowIso4() });
4319
+ ).run({ id, reason, updatedAt: nowIso5() });
3601
4320
  return this.requireById(id);
3602
4321
  }
3603
4322
  markFailed(id, error, finalFailure) {
@@ -3610,7 +4329,7 @@ var ImageMultimodalTaskRepository = class {
3610
4329
  updated_at = @updatedAt
3611
4330
  WHERE id = @id
3612
4331
  `
3613
- ).run({ id, status: finalFailure ? "failed" : "pending", error, updatedAt: nowIso4() });
4332
+ ).run({ id, status: finalFailure ? "failed" : "pending", error, updatedAt: nowIso5() });
3614
4333
  return this.requireById(id);
3615
4334
  }
3616
4335
  getById(id) {
@@ -3892,11 +4611,23 @@ function createFeishuChatMembersClient(client) {
3892
4611
  }
3893
4612
 
3894
4613
  // src/rag/qa-logs.ts
3895
- import crypto6 from "crypto";
4614
+ import crypto7 from "crypto";
4615
+
4616
+ // src/rag/qa-trace.ts
4617
+ function hasQaTrace(trace) {
4618
+ return Object.keys(trace).length > 0;
4619
+ }
4620
+
4621
+ // src/rag/qa-logs.ts
3896
4622
  function clampLimit(limit) {
3897
4623
  return Math.max(1, Math.min(200, Math.trunc(limit)));
3898
4624
  }
4625
+ function parseTrace(value) {
4626
+ const parsed = JSON.parse(value);
4627
+ return parsed && typeof parsed === "object" ? parsed : {};
4628
+ }
3899
4629
  function mapQaLogRow(row) {
4630
+ const trace = parseTrace(row.trace_json);
3900
4631
  return {
3901
4632
  id: row.id,
3902
4633
  chatId: row.chat_id,
@@ -3905,6 +4636,8 @@ function mapQaLogRow(row) {
3905
4636
  answer: row.answer,
3906
4637
  citations: JSON.parse(row.citations_json),
3907
4638
  retrievalDebug: JSON.parse(row.retrieval_debug_json),
4639
+ trace,
4640
+ hasTrace: hasQaTrace(trace),
3908
4641
  status: row.status,
3909
4642
  error: row.error,
3910
4643
  createdAt: row.created_at
@@ -3916,14 +4649,17 @@ var QaLogRepository = class {
3916
4649
  }
3917
4650
  database;
3918
4651
  create(input) {
4652
+ const trace = input.trace ?? {};
3919
4653
  const record = {
3920
- id: `qa_${crypto6.randomUUID()}`,
4654
+ id: `qa_${crypto7.randomUUID()}`,
3921
4655
  chatId: input.chatId ?? null,
3922
4656
  questionMessageId: input.questionMessageId ?? null,
3923
4657
  question: input.question,
3924
4658
  answer: input.answer,
3925
4659
  citations: input.citations,
3926
4660
  retrievalDebug: input.retrievalDebug,
4661
+ trace,
4662
+ hasTrace: hasQaTrace(trace),
3927
4663
  status: input.status,
3928
4664
  error: input.error ?? null,
3929
4665
  createdAt: input.createdAt
@@ -3938,6 +4674,7 @@ var QaLogRepository = class {
3938
4674
  answer,
3939
4675
  citations_json,
3940
4676
  retrieval_debug_json,
4677
+ trace_json,
3941
4678
  status,
3942
4679
  error,
3943
4680
  created_at
@@ -3950,6 +4687,7 @@ var QaLogRepository = class {
3950
4687
  @answer,
3951
4688
  @citationsJson,
3952
4689
  @retrievalDebugJson,
4690
+ @traceJson,
3953
4691
  @status,
3954
4692
  @error,
3955
4693
  @createdAt
@@ -3963,6 +4701,7 @@ var QaLogRepository = class {
3963
4701
  answer: record.answer,
3964
4702
  citationsJson: JSON.stringify(record.citations),
3965
4703
  retrievalDebugJson: JSON.stringify(record.retrievalDebug),
4704
+ traceJson: JSON.stringify(record.trace),
3966
4705
  status: record.status,
3967
4706
  error: record.error,
3968
4707
  createdAt: record.createdAt
@@ -3980,6 +4719,7 @@ var QaLogRepository = class {
3980
4719
  answer,
3981
4720
  citations_json,
3982
4721
  retrieval_debug_json,
4722
+ trace_json,
3983
4723
  status,
3984
4724
  error,
3985
4725
  created_at
@@ -4001,6 +4741,7 @@ var QaLogRepository = class {
4001
4741
  answer,
4002
4742
  citations_json,
4003
4743
  retrieval_debug_json,
4744
+ trace_json,
4004
4745
  status,
4005
4746
  error,
4006
4747
  created_at
@@ -4012,6 +4753,27 @@ var QaLogRepository = class {
4012
4753
  ).all(chatId, clampLimit(limit));
4013
4754
  return rows.map(mapQaLogRow);
4014
4755
  }
4756
+ getById(id) {
4757
+ const row = this.database.prepare(
4758
+ `
4759
+ SELECT
4760
+ id,
4761
+ chat_id,
4762
+ question_message_id,
4763
+ question,
4764
+ answer,
4765
+ citations_json,
4766
+ retrieval_debug_json,
4767
+ trace_json,
4768
+ status,
4769
+ error,
4770
+ created_at
4771
+ FROM qa_logs
4772
+ WHERE id = ?
4773
+ `
4774
+ ).get(id);
4775
+ return row ? mapQaLogRow(row) : null;
4776
+ }
4015
4777
  getCount() {
4016
4778
  const row = this.database.prepare("SELECT COUNT(*) AS count FROM qa_logs").get();
4017
4779
  return row.count;
@@ -4069,6 +4831,18 @@ ${block.text}`;
4069
4831
  function toToolErrorContent(message) {
4070
4832
  return JSON.stringify({ ok: false, error: message });
4071
4833
  }
4834
+ function nowIso6() {
4835
+ return (/* @__PURE__ */ new Date()).toISOString();
4836
+ }
4837
+ function finalizeTrace(trace, status, finalAnswer, startedAtMs) {
4838
+ return {
4839
+ ...trace,
4840
+ completedAt: nowIso6(),
4841
+ durationMs: Date.now() - startedAtMs,
4842
+ status,
4843
+ finalAnswer
4844
+ };
4845
+ }
4072
4846
  async function executeFeishuTool(tool, input) {
4073
4847
  const result = await tool.execute(input);
4074
4848
  if (isEvidenceBlockArray(result)) {
@@ -4080,6 +4854,13 @@ async function runFeishuToolLoop(input) {
4080
4854
  if (!input.model.completeWithTools) {
4081
4855
  throw new Error("\u5F53\u524D LLM \u5BA2\u6237\u7AEF\u4E0D\u652F\u6301\u5DE5\u5177\u8C03\u7528\u3002");
4082
4856
  }
4857
+ const startedAtMs = Date.now();
4858
+ const trace = {
4859
+ startedAt: new Date(startedAtMs).toISOString(),
4860
+ modelTurns: [],
4861
+ toolResults: [],
4862
+ fallbacks: []
4863
+ };
4083
4864
  const maxModelTurns = input.maxModelTurns ?? DEFAULT_MAX_MODEL_TURNS;
4084
4865
  const maxToolCalls = input.maxToolCalls ?? DEFAULT_MAX_TOOL_CALLS;
4085
4866
  const systemPromptParts = [FEISHU_TOOL_SYSTEM_PROMPT];
@@ -4108,19 +4889,36 @@ async function runFeishuToolLoop(input) {
4108
4889
  toolCalls: assistantResult.toolCalls,
4109
4890
  reasoningContent: assistantResult.reasoningContent
4110
4891
  });
4892
+ trace.modelTurns?.push({
4893
+ index: turn,
4894
+ content: assistantResult.content,
4895
+ reasoningContent: assistantResult.reasoningContent,
4896
+ toolCalls: assistantResult.toolCalls,
4897
+ createdAt: nowIso6()
4898
+ });
4111
4899
  if (assistantResult.toolCalls.length === 0) {
4112
4900
  if (hasRawToolCallMarkup) {
4901
+ trace.fallbacks?.push({ type: "raw_tool_markup", message: "\u6A21\u578B\u8F93\u51FA\u4E86\u539F\u59CB\u5DE5\u5177\u8C03\u7528\u6807\u8BB0\uFF0C\u8F6C\u5165\u6700\u7EC8\u8865\u6551\u56DE\u7B54\u3002", createdAt: nowIso6() });
4113
4902
  break;
4114
4903
  }
4115
- return assistantResult.content || FEISHU_TOOL_LOOP_FALLBACK;
4904
+ const answer = assistantResult.content || FEISHU_TOOL_LOOP_FALLBACK;
4905
+ return { answer, trace: finalizeTrace(trace, "answered", answer, startedAtMs) };
4116
4906
  }
4117
4907
  for (const toolCall of assistantResult.toolCalls) {
4118
4908
  if (toolCallsUsed >= maxToolCalls) {
4119
- return FEISHU_TOOL_LOOP_LIMIT_REACHED;
4909
+ trace.fallbacks?.push({ type: "tool_limit", message: FEISHU_TOOL_LOOP_LIMIT_REACHED, createdAt: nowIso6() });
4910
+ return { answer: FEISHU_TOOL_LOOP_LIMIT_REACHED, trace: finalizeTrace(trace, "failed", FEISHU_TOOL_LOOP_LIMIT_REACHED, startedAtMs) };
4120
4911
  }
4121
4912
  toolCallsUsed += 1;
4122
4913
  const tool = toolsByName.get(toolCall.name);
4123
4914
  if (!tool) {
4915
+ trace.toolResults?.push({
4916
+ toolCallId: toolCall.id,
4917
+ name: toolCall.name,
4918
+ input: toolCall.input,
4919
+ error: `\u672A\u77E5\u5DE5\u5177\uFF1A${toolCall.name}`,
4920
+ createdAt: nowIso6()
4921
+ });
4124
4922
  messages.push({
4125
4923
  role: "tool",
4126
4924
  toolCallId: toolCall.id,
@@ -4130,6 +4928,13 @@ async function runFeishuToolLoop(input) {
4130
4928
  }
4131
4929
  try {
4132
4930
  const result = await executeFeishuTool(tool, toolCall.input);
4931
+ trace.toolResults?.push({
4932
+ toolCallId: toolCall.id,
4933
+ name: toolCall.name,
4934
+ input: toolCall.input,
4935
+ content: result,
4936
+ createdAt: nowIso6()
4937
+ });
4133
4938
  messages.push({
4134
4939
  role: "tool",
4135
4940
  toolCallId: toolCall.id,
@@ -4137,6 +4942,13 @@ async function runFeishuToolLoop(input) {
4137
4942
  });
4138
4943
  } catch (error) {
4139
4944
  const message = error instanceof Error ? error.message : String(error);
4945
+ trace.toolResults?.push({
4946
+ toolCallId: toolCall.id,
4947
+ name: toolCall.name,
4948
+ input: toolCall.input,
4949
+ error: message,
4950
+ createdAt: nowIso6()
4951
+ });
4140
4952
  messages.push({
4141
4953
  role: "tool",
4142
4954
  toolCallId: toolCall.id,
@@ -4150,9 +4962,13 @@ async function runFeishuToolLoop(input) {
4150
4962
  ...messages,
4151
4963
  { role: "system", content: "\u8BF7\u57FA\u4E8E\u4EE5\u4E0A\u6240\u6709\u5DE5\u5177\u8FD4\u56DE\u7684\u4FE1\u606F\uFF0C\u76F4\u63A5\u7ED9\u51FA\u6700\u7EC8\u7B54\u6848\u3002\u4E0D\u8981\u518D\u8C03\u7528\u5DE5\u5177\u3002" }
4152
4964
  ]);
4153
- return salvageAnswer || "\u62B1\u6B49\uFF0C\u56DE\u7B54\u751F\u6210\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
4965
+ const answer = salvageAnswer || "\u62B1\u6B49\uFF0C\u56DE\u7B54\u751F\u6210\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
4966
+ trace.fallbacks?.push({ type: "salvage_completion", message: "\u5DE5\u5177\u5FAA\u73AF\u7ED3\u675F\u540E\u4F7F\u7528\u65E0\u5DE5\u5177\u8865\u6551\u56DE\u7B54\u3002", createdAt: nowIso6() });
4967
+ return { answer, trace: finalizeTrace(trace, "answered", answer, startedAtMs) };
4154
4968
  } catch {
4155
- return "\u62B1\u6B49\uFF0C\u56DE\u7B54\u751F\u6210\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
4969
+ const answer = "\u62B1\u6B49\uFF0C\u56DE\u7B54\u751F\u6210\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
4970
+ trace.fallbacks?.push({ type: "answer_generation_failed", message: answer, createdAt: nowIso6() });
4971
+ return { answer, trace: finalizeTrace(trace, "failed", answer, startedAtMs) };
4156
4972
  }
4157
4973
  }
4158
4974
  function formatConversationContext(records) {
@@ -4258,7 +5074,8 @@ var FeishuQuestionHandler = class {
4258
5074
  secrets: this.options.secrets,
4259
5075
  database: this.options.database,
4260
5076
  messages: new MessageRepository(this.options.database),
4261
- excludeMessageIds: options.excludeMessageIds
5077
+ excludeMessageIds: options.excludeMessageIds,
5078
+ profileTools: createPersonProfileTools({ profiles: new ProfileRepository(this.options.database) })
4262
5079
  });
4263
5080
  try {
4264
5081
  try {
@@ -4272,7 +5089,7 @@ var FeishuQuestionHandler = class {
4272
5089
  const memberRepository = this.options.memberRepository ?? new FeishuMemberRepository(this.options.database);
4273
5090
  const memberPrompt = formatFeishuMemberPrompt(memberRepository.listByChat(decision.chatId));
4274
5091
  const conversationContext = formatConversationContext(qaLogs.listRecentByChat(decision.chatId, 6));
4275
- const answer = await runFeishuToolLoop({
5092
+ const result = await runFeishuToolLoop({
4276
5093
  question: decision.question,
4277
5094
  now,
4278
5095
  tools: allTools,
@@ -4284,27 +5101,38 @@ var FeishuQuestionHandler = class {
4284
5101
  chatId: decision.chatId,
4285
5102
  questionMessageId,
4286
5103
  question: decision.question,
4287
- answer,
5104
+ answer: result.answer,
4288
5105
  citations: [],
4289
5106
  retrievalDebug: {},
5107
+ trace: result.trace,
4290
5108
  status: "answered",
4291
5109
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
4292
5110
  });
4293
- await this.sendResponse(decision.chatId, questionMessageId, answer);
5111
+ await this.sendResponse(decision.chatId, questionMessageId, result.answer);
4294
5112
  } catch (error) {
4295
5113
  const message = error instanceof Error ? error.message : String(error);
5114
+ const failedAt = (/* @__PURE__ */ new Date()).toISOString();
5115
+ const failedAnswer = `\u6682\u65F6\u65E0\u6CD5\u56DE\u7B54\uFF1A${message}`;
4296
5116
  qaLogs.create({
4297
5117
  chatId: decision.chatId,
4298
5118
  questionMessageId,
4299
5119
  question: decision.question,
4300
- answer: `\u6682\u65F6\u65E0\u6CD5\u56DE\u7B54\uFF1A${message}`,
5120
+ answer: failedAnswer,
4301
5121
  citations: [],
4302
5122
  retrievalDebug: {},
5123
+ trace: {
5124
+ startedAt: now.toISOString(),
5125
+ completedAt: failedAt,
5126
+ durationMs: Math.max(0, Date.parse(failedAt) - now.getTime()),
5127
+ status: "failed",
5128
+ finalAnswer: failedAnswer,
5129
+ fallbacks: [{ type: "answer_generation_failed", message, createdAt: failedAt }]
5130
+ },
4303
5131
  status: "failed",
4304
5132
  error: message,
4305
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
5133
+ createdAt: failedAt
4306
5134
  });
4307
- await this.sendResponse(decision.chatId, questionMessageId, `\u6682\u65F6\u65E0\u6CD5\u56DE\u7B54\uFF1A${message}`);
5135
+ await this.sendResponse(decision.chatId, questionMessageId, failedAnswer);
4308
5136
  }
4309
5137
  return decision;
4310
5138
  } finally {
@@ -4724,7 +5552,8 @@ function createFeishuGateway(options) {
4724
5552
  secrets: options.secrets,
4725
5553
  database: options.cronJobProcessor.database,
4726
5554
  messages: new MessageRepository(options.cronJobProcessor.database),
4727
- scope: { platform: "feishu", platformChatId: job.chatId }
5555
+ scope: { platform: "feishu", platformChatId: job.chatId },
5556
+ profileTools: createPersonProfileTools({ profiles: new ProfileRepository(options.cronJobProcessor.database) })
4728
5557
  });
4729
5558
  try {
4730
5559
  const memberPrompt = formatFeishuMemberPrompt(
@@ -4984,7 +5813,7 @@ var FeishuResourceDownloader = class _FeishuResourceDownloader {
4984
5813
  };
4985
5814
 
4986
5815
  // src/files/ingest.ts
4987
- import crypto7 from "crypto";
5816
+ import crypto8 from "crypto";
4988
5817
  import fs12 from "fs/promises";
4989
5818
  import path15 from "path";
4990
5819
 
@@ -5048,7 +5877,7 @@ function ensureSupportedTextFile(filePath) {
5048
5877
  }
5049
5878
  }
5050
5879
  function stableStoredName(sourcePath, fileName) {
5051
- const digest = crypto7.createHash("sha256").update(sourcePath).digest("hex").slice(0, 16);
5880
+ const digest = crypto8.createHash("sha256").update(sourcePath).digest("hex").slice(0, 16);
5052
5881
  return `${digest}-${fileName}`;
5053
5882
  }
5054
5883
  async function ingestLocalFile(input) {
@@ -5148,16 +5977,32 @@ function isMultimodalReady(config, secrets) {
5148
5977
  return Boolean(config.multimodal.baseUrl && config.multimodal.model && secrets.multimodal.apiKey);
5149
5978
  }
5150
5979
  var GatewayIngestor = class {
5151
- constructor(database) {
5980
+ constructor(database, options = {}) {
5152
5981
  this.database = database;
5982
+ this.options = options;
5153
5983
  this.messages = new MessageRepository(database);
5154
5984
  this.jobs = new FileJobRepository(database);
5155
5985
  this.imageTasks = new ImageMultimodalTaskRepository(database);
5156
5986
  }
5157
5987
  database;
5988
+ options;
5158
5989
  messages;
5159
5990
  jobs;
5160
5991
  imageTasks;
5992
+ enrichWithPerson(input) {
5993
+ const person = this.options.profiles?.resolvePersonForSender({
5994
+ platform: input.platform,
5995
+ platformChatId: input.platformChatId,
5996
+ senderId: input.senderId,
5997
+ senderName: input.senderName,
5998
+ source: "message",
5999
+ observedAt: input.sentAt
6000
+ });
6001
+ return {
6002
+ ...input,
6003
+ personId: person?.id
6004
+ };
6005
+ }
5161
6006
  ingestFeishuEvent(payload) {
5162
6007
  const normalized = normalizeFeishuReceiveMessageEvent(payload);
5163
6008
  if (!normalized) {
@@ -5166,12 +6011,13 @@ var GatewayIngestor = class {
5166
6011
  reason: "\u4E8B\u4EF6\u4E0D\u662F\u53EF\u5165\u5E93\u7684\u98DE\u4E66\u6D88\u606F\u3002"
5167
6012
  };
5168
6013
  }
5169
- const duplicate = this.messages.hasPlatformMessage(normalized.platform, normalized.platformMessageId);
5170
- const messageId = this.messages.ingest(normalized);
6014
+ const enriched = this.enrichWithPerson(normalized);
6015
+ const duplicate = this.messages.hasPlatformMessage(enriched.platform, enriched.platformMessageId);
6016
+ const messageId = this.messages.ingest(enriched);
5171
6017
  return {
5172
6018
  accepted: true,
5173
6019
  messageId,
5174
- message: normalized,
6020
+ message: enriched,
5175
6021
  duplicate
5176
6022
  };
5177
6023
  }
@@ -5185,7 +6031,7 @@ var GatewayIngestor = class {
5185
6031
  }
5186
6032
  const openId = extractFeishuSenderOpenId(normalized);
5187
6033
  const senderName = openId ? await input.memberResolver.resolveOpenIdName(normalized.platformChatId, openId) : normalized.senderName;
5188
- const enriched = { ...normalized, senderName };
6034
+ const enriched = this.enrichWithPerson({ ...normalized, senderName });
5189
6035
  const duplicate = this.messages.hasPlatformMessage(enriched.platform, enriched.platformMessageId);
5190
6036
  const messageId = this.messages.ingest(enriched);
5191
6037
  return {
@@ -5413,7 +6259,7 @@ var MemoryVectorStore = class {
5413
6259
  };
5414
6260
 
5415
6261
  // src/web/server.ts
5416
- import crypto8 from "crypto";
6262
+ import crypto9 from "crypto";
5417
6263
  import Fastify from "fastify";
5418
6264
  function buildHtml() {
5419
6265
  return `<!doctype html>
@@ -5755,6 +6601,10 @@ function buildHtml() {
5755
6601
  <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
5756
6602
  <span>\u95EE\u7B54\u65E5\u5FD7</span>
5757
6603
  </button>
6604
+ <button class="nav-item" data-view="persons">
6605
+ <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
6606
+ <span>\u4E2A\u4EBA\u6863\u6848</span>
6607
+ </button>
5758
6608
  <button class="nav-item" data-view="settings">
5759
6609
  <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
5760
6610
  <span>\u8BBE\u7F6E</span>
@@ -5786,6 +6636,10 @@ function buildHtml() {
5786
6636
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
5787
6637
  <span>\u4EFB\u52A1</span>
5788
6638
  </button>
6639
+ <button class="mobile-nav-item" data-view="persons">
6640
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
6641
+ <span>\u6863\u6848</span>
6642
+ </button>
5789
6643
  <button class="mobile-nav-item" data-view="settings">
5790
6644
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
5791
6645
  <span>\u8BBE\u7F6E</span>
@@ -5882,6 +6736,18 @@ function buildHtml() {
5882
6736
  <div class="content-panel glass"><div id="qa-logs-list"></div></div>
5883
6737
  </div>
5884
6738
 
6739
+ <div class="view" id="view-persons">
6740
+ <div class="section-header"><div><h1 class="section-title">\u4E2A\u4EBA\u6863\u6848</h1><p class="page-subtitle">\u7FA4\u6210\u5458\u6863\u6848\u4E0E\u4E8B\u5B9E\u4FE1\u606F</p></div></div>
6741
+ <div class="content-panel glass" id="persons-list-panel"><div id="persons-list"></div></div>
6742
+ <div class="content-panel glass mt-lg" id="person-profile-panel" style="display:none;">
6743
+ <div class="panel-header">
6744
+ <h2 class="panel-title" id="person-profile-name"></h2>
6745
+ <button class="btn btn-sm" onclick="navigateTo('persons')">\u8FD4\u56DE\u5217\u8868</button>
6746
+ </div>
6747
+ <div id="person-profile-detail"></div>
6748
+ </div>
6749
+ </div>
6750
+
5885
6751
  <div class="view" id="view-settings">
5886
6752
  <div class="section-header"><div><h1 class="section-title">\u8BBE\u7F6E</h1><p class="page-subtitle">\u7CFB\u7EDF\u914D\u7F6E\u4E0E\u64CD\u4F5C</p></div></div>
5887
6753
  <div class="settings-group glass" id="settings-config"></div>
@@ -5917,12 +6783,16 @@ function buildHtml() {
5917
6783
  let allFileJobs = [];
5918
6784
  let allCronJobs = [];
5919
6785
  let allQaLogs = [];
6786
+ let allPersons = [];
6787
+ let selectedPersonId = null;
5920
6788
  let statusData = null;
5921
6789
 
5922
6790
  function fmt(value) { return value == null || value === "" ? "-" : String(value); }
5923
6791
  function escapeHtml(value) {
5924
6792
  return fmt(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
5925
6793
  }
6794
+ function renderJson(value) { return '<pre style="white-space:pre-wrap;overflow:auto;max-height:320px;">' + escapeHtml(JSON.stringify(value, null, 2)) + '</pre>'; }
6795
+ function renderTextBlock(value) { return '<pre style="white-space:pre-wrap;overflow:auto;max-height:320px;">' + escapeHtml(value || "") + '</pre>'; }
5926
6796
  function isOpaqueId(value) { return /^(ou|oc|om|cli|on|un|uid)_?[a-z0-9]+/i.test(fmt(value)); }
5927
6797
  function formatDateTime(value) {
5928
6798
  var date = new Date(value);
@@ -5958,6 +6828,7 @@ function buildHtml() {
5958
6828
  if (view === "files") renderFilesView();
5959
6829
  if (view === "tasks") renderTasksView();
5960
6830
  if (view === "qa-logs") renderQaLogsView();
6831
+ if (view === "persons") renderPersonsView();
5961
6832
  }
5962
6833
 
5963
6834
  document.querySelectorAll(".nav-item, .mobile-nav-item").forEach(function(el) {
@@ -6000,6 +6871,55 @@ function buildHtml() {
6000
6871
  return result;
6001
6872
  }
6002
6873
 
6874
+ function toJsArgument(value) {
6875
+ return escapeHtml(JSON.stringify(fmt(value)));
6876
+ }
6877
+
6878
+ function findEntryEvidenceMessage(entry) {
6879
+ var evidence = entry.evidence || [];
6880
+ return evidence.length > 0 ? evidence[0].messageId : "";
6881
+ }
6882
+
6883
+ function findEntryEvidenceQuote(entry) {
6884
+ var evidence = entry.evidence || [];
6885
+ return evidence.length > 0 ? evidence[0].quote : "";
6886
+ }
6887
+
6888
+ async function correctPersonProfileEntry(personId, entryId, category, content, entryType, confidence, evidenceMessageId, quote) {
6889
+ var nextContent = prompt("\u4FEE\u6B63\u6863\u6848\u5185\u5BB9", content);
6890
+ if (!nextContent || !nextContent.trim()) return;
6891
+ var reason = prompt("\u4FEE\u6B63\u7406\u7531", "\u7528\u6237\u5728 Web UI \u663E\u5F0F\u4FEE\u6B63") || "\u7528\u6237\u5728 Web UI \u663E\u5F0F\u4FEE\u6B63";
6892
+ try {
6893
+ await postJson("/api/persons/" + encodeURIComponent(personId) + "/profile/entries/" + encodeURIComponent(entryId) + "/correct", {
6894
+ headers: { "content-type": "application/json" },
6895
+ body: JSON.stringify({
6896
+ category: category,
6897
+ content: nextContent.trim(),
6898
+ entryType: entryType,
6899
+ confidence: confidence,
6900
+ evidenceMessageId: evidenceMessageId,
6901
+ quote: quote || content,
6902
+ reason: reason
6903
+ })
6904
+ });
6905
+ showToast("\u6863\u6848\u6761\u76EE\u5DF2\u4FEE\u6B63", "success");
6906
+ await showPersonProfile(personId);
6907
+ } catch (error) {
6908
+ showToast(error instanceof Error ? error.message : String(error), "error");
6909
+ }
6910
+ }
6911
+
6912
+ async function deletePersonProfileEntry(personId, entryId) {
6913
+ if (!confirm("\u786E\u8BA4\u5220\u9664\u8FD9\u6761\u6863\u6848\u6761\u76EE\uFF1F")) return;
6914
+ try {
6915
+ await deleteJson("/api/persons/" + encodeURIComponent(personId) + "/profile/entries/" + encodeURIComponent(entryId));
6916
+ showToast("\u6863\u6848\u6761\u76EE\u5DF2\u5220\u9664", "success");
6917
+ await showPersonProfile(personId);
6918
+ } catch (error) {
6919
+ showToast(error instanceof Error ? error.message : String(error), "error");
6920
+ }
6921
+ }
6922
+
6003
6923
  function renderMetrics(status) {
6004
6924
  var gatewayClass = status.gateway.configured ? "status-dot online" : "status-dot offline";
6005
6925
  var gatewayText = status.gateway.connection === "running" ? "\u8FD0\u884C\u4E2D" : (!status.gateway.configured ? "\u672A\u914D\u7F6E" : "\u5F85\u542F\u52A8");
@@ -6195,13 +7115,158 @@ function buildHtml() {
6195
7115
  for (var i = 0; i < allQaLogs.length; i++) {
6196
7116
  var item = allQaLogs[i];
6197
7117
  var citationCount = Array.isArray(item.citations) ? item.citations.length : 0;
6198
- var statusClass = item.status === 'success' ? 'tag-success' : 'tag-warning';
7118
+ var statusClass = item.status === 'answered' ? 'tag-success' : 'tag-warning';
6199
7119
  html += '<div class="qa-card"><div class="message-meta" style="margin-bottom:var(--space-sm);">' +
6200
7120
  '<span>' + escapeHtml(formatDateTime(item.createdAt)) + '</span>' +
6201
7121
  '<span class="tag ' + statusClass + '">' + escapeHtml(item.status) + '</span>' +
6202
- '<span>' + citationCount + ' \u6761\u5F15\u7528</span></div>' +
7122
+ '<span>' + citationCount + ' \u6761\u5F15\u7528</span>' +
7123
+ '<span class="tag ' + (item.hasTrace ? 'tag-info' : 'tag-warning') + '">' + (item.hasTrace ? '\u6709 trace' : '\u65E0 trace') + '</span></div>' +
6203
7124
  '<div class="qa-question">' + escapeHtml(item.question) + '</div>' +
6204
- '<div class="qa-answer">' + escapeHtml(item.answer) + '</div></div>';
7125
+ '<div class="qa-answer">' + escapeHtml(item.answer) + '</div>' +
7126
+ '<button class="btn btn-sm" style="margin-top:var(--space-sm);" data-view-qa-log="' + escapeHtml(item.id) + '">\u67E5\u770B\u8BE6\u60C5</button>' +
7127
+ '<div id="qa-detail-' + escapeHtml(item.id) + '" style="margin-top:var(--space-md);"></div></div>';
7128
+ }
7129
+ el.innerHTML = html;
7130
+ }
7131
+
7132
+ async function showQaLogDetail(id) {
7133
+ selectedQaLogId = id;
7134
+ var container = document.getElementById("qa-detail-" + id);
7135
+ if (!container) return;
7136
+ container.innerHTML = '<div class="empty-state">\u6B63\u5728\u52A0\u8F7D\u95EE\u7B54\u8BE6\u60C5...</div>';
7137
+ try {
7138
+ var item = await fetchJson("/api/qa-logs/" + encodeURIComponent(id));
7139
+ renderQaLogDetail(item);
7140
+ } catch (error) {
7141
+ container.innerHTML = '<div class="empty-state">\u8BE6\u60C5\u52A0\u8F7D\u5931\u8D25\uFF1A' + escapeHtml(error instanceof Error ? error.message : String(error)) + '</div>';
7142
+ }
7143
+ }
7144
+
7145
+ function renderQaLogDetail(item) {
7146
+ var container = document.getElementById("qa-detail-" + item.id);
7147
+ if (!container) return;
7148
+ var trace = item.trace || {};
7149
+ var html = '<div class="content-panel" style="margin-top:var(--space-sm);background:rgba(255,255,255,0.03);">';
7150
+ html += '<h3 style="font-size:15px;margin-bottom:var(--space-sm);">\u95EE\u7B54\u8BE6\u60C5</h3>';
7151
+ html += '<div class="message-meta" style="margin-bottom:var(--space-sm);"><span>\u72B6\u6001\uFF1A' + escapeHtml(item.status) + '</span><span>\u521B\u5EFA\uFF1A' + escapeHtml(formatDateTime(item.createdAt)) + '</span><span>\u8017\u65F6\uFF1A' + escapeHtml(trace.durationMs == null ? '-' : trace.durationMs + 'ms') + '</span></div>';
7152
+ if (item.error) html += '<div style="color:var(--danger);margin-bottom:var(--space-sm);">\u9519\u8BEF\uFF1A' + escapeHtml(item.error) + '</div>';
7153
+ html += '<div class="qa-question">' + escapeHtml(item.question) + '</div>';
7154
+ html += '<div class="qa-answer" style="margin-bottom:var(--space-md);">' + escapeHtml(item.answer) + '</div>';
7155
+ if (!item.hasTrace) {
7156
+ html += '<div class="empty-state">\u8FD9\u6761\u95EE\u7B54\u6CA1\u6709 trace\uFF0C\u53EF\u80FD\u6765\u81EA\u65E7\u7248\u672C\u8BB0\u5F55\u3002</div></div>';
7157
+ container.innerHTML = html;
7158
+ return;
7159
+ }
7160
+ var turns = trace.modelTurns || [];
7161
+ html += '<h4 style="margin:var(--space-md) 0 var(--space-sm);">Reasoning</h4>';
7162
+ if (turns.length === 0) html += '<div class="empty-state">\u65E0 reasoningContent</div>';
7163
+ for (var i = 0; i < turns.length; i++) {
7164
+ html += '<div style="margin-bottom:var(--space-sm);"><div class="message-meta"><span>\u6A21\u578B\u8F6E\u6B21 ' + escapeHtml(turns[i].index) + '</span><span>' + escapeHtml(formatDateTime(turns[i].createdAt)) + '</span></div>' + renderTextBlock(turns[i].reasoningContent || '\u65E0 reasoningContent') + '</div>';
7165
+ }
7166
+ html += '<h4 style="margin:var(--space-md) 0 var(--space-sm);">\u6A21\u578B\u8F6E\u6B21\u4E0E\u5DE5\u5177\u8C03\u7528</h4>';
7167
+ for (var j = 0; j < turns.length; j++) {
7168
+ html += '<div style="margin-bottom:var(--space-sm);"><div class="message-meta"><span>\u8F6E\u6B21 ' + escapeHtml(turns[j].index) + '</span></div>' + renderTextBlock(turns[j].content || '') + renderJson(turns[j].toolCalls || []) + '</div>';
7169
+ }
7170
+ html += '<h4 style="margin:var(--space-md) 0 var(--space-sm);">\u5DE5\u5177\u7ED3\u679C</h4>';
7171
+ var toolResults = trace.toolResults || [];
7172
+ if (toolResults.length === 0) html += '<div class="empty-state">\u6CA1\u6709\u5DE5\u5177\u7ED3\u679C\u3002</div>';
7173
+ for (var k = 0; k < toolResults.length; k++) {
7174
+ html += '<div style="margin-bottom:var(--space-sm);"><div class="message-meta"><span>' + escapeHtml(toolResults[k].name) + '</span><span>' + escapeHtml(toolResults[k].toolCallId) + '</span><span>' + escapeHtml(formatDateTime(toolResults[k].createdAt)) + '</span></div>' + renderJson(toolResults[k].input) + (toolResults[k].error ? '<div style="color:var(--danger);">' + escapeHtml(toolResults[k].error) + '</div>' : renderTextBlock(toolResults[k].content || '')) + '</div>';
7175
+ }
7176
+ html += '<h4 style="margin:var(--space-md) 0 var(--space-sm);">\u5F15\u7528\u4E0E\u68C0\u7D22</h4>' + renderJson({ citations: item.citations || [], retrievalDebug: item.retrievalDebug || {} });
7177
+ html += '<h4 style="margin:var(--space-md) 0 var(--space-sm);">Fallback</h4>';
7178
+ var fallbacks = trace.fallbacks || [];
7179
+ html += fallbacks.length === 0 ? '<div class="empty-state">\u6CA1\u6709 fallback\u3002</div>' : renderJson(fallbacks);
7180
+ html += '</div>';
7181
+ container.innerHTML = html;
7182
+ }
7183
+
7184
+ function renderPersonsView() {
7185
+ var listEl = document.getElementById("persons-list");
7186
+ var profilePanel = document.getElementById("person-profile-panel");
7187
+ if (profilePanel) profilePanel.style.display = "none";
7188
+ if (!allPersons || allPersons.length === 0) {
7189
+ listEl.innerHTML = '<div class="empty-state">\u8FD8\u6CA1\u6709\u4E2A\u4EBA\u6863\u6848\u3002\u7CFB\u7EDF\u4F1A\u81EA\u52A8\u4ECE\u804A\u5929\u8BB0\u5F55\u4E2D\u8BC6\u522B\u7FA4\u6210\u5458\u3002</div>';
7190
+ return;
7191
+ }
7192
+ var html = '<table class="data-table"><thead><tr><th>\u6210\u5458</th><th>\u6863\u6848\u6761\u76EE</th><th>\u6D88\u606F\u6570</th></tr></thead><tbody>';
7193
+ for (var i = 0; i < allPersons.length; i++) {
7194
+ var p = allPersons[i];
7195
+ html += '<tr data-view-person="' + escapeHtml(p.id) + '" style="cursor:pointer;">' +
7196
+ '<td><span style="font-weight:500;">' + escapeHtml(p.primaryName) + '</span></td>' +
7197
+ '<td>' + escapeHtml(p.profileEntryCount) + '</td>' +
7198
+ '<td>' + escapeHtml(p.messageCount) + '</td></tr>';
7199
+ }
7200
+ html += '</tbody></table>';
7201
+ listEl.innerHTML = html;
7202
+ }
7203
+
7204
+ async function showPersonProfile(id) {
7205
+ selectedPersonId = id;
7206
+ var listEl = document.getElementById("persons-list-panel");
7207
+ var profilePanel = document.getElementById("person-profile-panel");
7208
+ var detailEl = document.getElementById("person-profile-detail");
7209
+ if (listEl) listEl.style.display = "none";
7210
+ if (profilePanel) profilePanel.style.display = "block";
7211
+ detailEl.innerHTML = '<div class="empty-state">\u6B63\u5728\u52A0\u8F7D\u4E2A\u4EBA\u6863\u6848...</div>';
7212
+ try {
7213
+ var profile = await fetchJson("/api/persons/" + encodeURIComponent(id) + "/profile");
7214
+ var messages = await fetchJson("/api/persons/" + encodeURIComponent(id) + "/messages?limit=50");
7215
+ renderPersonProfile(id, profile, messages.items || []);
7216
+ } catch (error) {
7217
+ detailEl.innerHTML = '<div class="empty-state">\u52A0\u8F7D\u5931\u8D25\uFF1A' + escapeHtml(error instanceof Error ? error.message : String(error)) + '</div>';
7218
+ }
7219
+ }
7220
+
7221
+ function renderPersonProfile(id, profile, messages) {
7222
+ var el = document.getElementById("person-profile-detail");
7223
+ var nameEl = document.getElementById("person-profile-name");
7224
+ if (nameEl) nameEl.textContent = profile.person.primaryName;
7225
+ var html = '';
7226
+ var identities = profile.identities || [];
7227
+ if (identities.length > 0) {
7228
+ html += '<div class="mb-md"><span style="color:var(--text-muted);font-size:13px;">\u8EAB\u4EFD\uFF1A</span> ';
7229
+ html += identities.map(function(id) { return escapeHtml(id.displayName) + ' (' + escapeHtml(id.platform) + ')'; }).join('\uFF0C');
7230
+ html += '</div>';
7231
+ }
7232
+ var entries = profile.entries || [];
7233
+ if (entries.length === 0) {
7234
+ html += '<div class="empty-state">\u8FD8\u6CA1\u6709\u6863\u6848\u6761\u76EE\u3002</div>';
7235
+ } else {
7236
+ html += '<div class="grid-2">';
7237
+ for (var i = 0; i < entries.length; i++) {
7238
+ var entry = entries[i];
7239
+ var evidence = entry.evidence || [];
7240
+ html += '<div class="content-panel" style="background:rgba(255,255,255,0.03);padding:var(--space-md);">' +
7241
+ '<div style="display:flex;gap:var(--space-sm);margin-bottom:var(--space-sm);align-items:center;flex-wrap:wrap;">' +
7242
+ '<span class="tag">' + escapeHtml(entry.category) + '</span>' +
7243
+ '<span class="tag ' + (entry.entryType === 'fact' ? 'tag-success' : 'tag-info') + '">' + escapeHtml(entry.entryType) + '</span>' +
7244
+ '<span style="font-size:12px;color:var(--text-muted);">' + escapeHtml(Math.round(entry.confidence * 100)) + '%</span>' +
7245
+ '<span style="flex:1;"></span>' +
7246
+ '<button class="btn btn-sm" onclick="correctPersonProfileEntry(' + toJsArgument(id) + ',' + toJsArgument(entry.id) + ',' + toJsArgument(entry.category) + ',' + toJsArgument(entry.content) + ',' + toJsArgument(entry.entryType) + ',' + Number(entry.confidence || 0) + ',' + toJsArgument(findEntryEvidenceMessage(entry)) + ',' + toJsArgument(findEntryEvidenceQuote(entry)) + ')">\u4FEE\u6B63</button>' +
7247
+ '<button class="btn btn-sm btn-danger" onclick="deletePersonProfileEntry(' + toJsArgument(id) + ',' + toJsArgument(entry.id) + ')">\u5220\u9664</button>' +
7248
+ '</div>' +
7249
+ '<div style="font-weight:500;margin-bottom:var(--space-xs);">' + escapeHtml(entry.content) + '</div>';
7250
+ if (evidence.length > 0) {
7251
+ html += '<div style="font-size:12px;color:var(--text-muted);">\u8BC1\u636E\uFF1A' +
7252
+ evidence.map(function(e) { return '<span style="display:block;margin-top:2px;">"' + escapeHtml(e.quote) + '" (' + escapeHtml(e.reason) + ')</span>'; }).join('') +
7253
+ '</div>';
7254
+ }
7255
+ html += '</div>';
7256
+ }
7257
+ html += '</div>';
7258
+ }
7259
+ if (messages && messages.length > 0) {
7260
+ html += '<h3 style="margin:var(--space-lg) 0 var(--space-sm);font-size:16px;">\u6700\u8FD1\u6D88\u606F</h3>';
7261
+ html += '<div class="message-list">';
7262
+ for (var j = 0; j < Math.min(messages.length, 10); j++) {
7263
+ var msg = messages[j];
7264
+ html += '<div class="message-card"><div class="message-meta">' +
7265
+ '<span>' + escapeHtml(formatDateTime(msg.sentAt)) + '</span>' +
7266
+ '<span>' + escapeHtml(displayChatName(msg.chatName, msg.platform)) + '</span>' +
7267
+ '</div><div class="message-text">' + escapeHtml(msg.text) + '</div></div>';
7268
+ }
7269
+ html += '</div>';
6205
7270
  }
6206
7271
  el.innerHTML = html;
6207
7272
  }
@@ -6236,11 +7301,19 @@ function buildHtml() {
6236
7301
  await loadSection("/api/file-jobs", function(data) { allFileJobs = data.items || []; });
6237
7302
  await loadSection("/api/qa-logs?limit=20", function(data) { allQaLogs = data.items || []; });
6238
7303
  await loadSection("/api/cron-jobs", function(data) { allCronJobs = data.items || []; });
7304
+ await loadSection("/api/persons", function(data) { allPersons = data.items || []; });
6239
7305
  if (currentView === "messages") renderMessagesView();
6240
7306
  if (currentView === "episodes") renderEpisodesView();
6241
7307
  if (currentView === "files") renderFilesView();
6242
7308
  if (currentView === "tasks") renderTasksView();
6243
- if (currentView === "qa-logs") renderQaLogsView();
7309
+ if (currentView === "qa-logs") {
7310
+ renderQaLogsView();
7311
+ if (selectedQaLogId) void showQaLogDetail(selectedQaLogId);
7312
+ }
7313
+ if (currentView === "persons") {
7314
+ renderPersonsView();
7315
+ if (selectedPersonId) void showPersonProfile(selectedPersonId);
7316
+ }
6244
7317
  }
6245
7318
 
6246
7319
  async function processNow() {
@@ -6262,6 +7335,16 @@ function buildHtml() {
6262
7335
  document.addEventListener("click", async function(event) {
6263
7336
  var target = event.target;
6264
7337
  if (!(target instanceof HTMLElement)) return;
7338
+ var qaLogId = target.dataset.viewQaLog;
7339
+ if (qaLogId) {
7340
+ void showQaLogDetail(qaLogId);
7341
+ return;
7342
+ }
7343
+ var personId = target.dataset.viewPerson || target.closest('[data-view-person]')?.dataset.viewPerson;
7344
+ if (personId) {
7345
+ void showPersonProfile(personId);
7346
+ return;
7347
+ }
6265
7348
  var id = target.dataset.deleteCronJob;
6266
7349
  if (!id) return;
6267
7350
  target.disabled = true;
@@ -6281,7 +7364,7 @@ function buildHtml() {
6281
7364
  </body>
6282
7365
  </html>`;
6283
7366
  }
6284
- function parseLimit(value, fallback, max) {
7367
+ function parseLimit2(value, fallback, max) {
6285
7368
  const rawLimit = Number(value ?? fallback);
6286
7369
  return Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), max) : fallback;
6287
7370
  }
@@ -6305,6 +7388,20 @@ function parseCookies(header) {
6305
7388
  function isAuthorizedWebAction(request, token) {
6306
7389
  return parseCookies(request.headers.cookie).chattercatcher_web_token === token;
6307
7390
  }
7391
+ function readStringField(input, key) {
7392
+ if (!input || typeof input !== "object" || Array.isArray(input)) return void 0;
7393
+ const value = input[key];
7394
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
7395
+ }
7396
+ function readNumberField(input, key, fallback) {
7397
+ if (!input || typeof input !== "object" || Array.isArray(input)) return fallback;
7398
+ const value = input[key];
7399
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
7400
+ }
7401
+ function toQaLogListItem(log) {
7402
+ const { trace: _trace, ...item } = log;
7403
+ return item;
7404
+ }
6308
7405
  function createWebApp(config, options = {}) {
6309
7406
  const app = Fastify({ logger: false });
6310
7407
  const database = openDatabase(config);
@@ -6314,11 +7411,12 @@ function createWebApp(config, options = {}) {
6314
7411
  const fileJobs = new FileJobRepository(database);
6315
7412
  const qaLogs = new QaLogRepository(database);
6316
7413
  const cronJobs = new CronJobRepository(database);
7414
+ const profiles = new ProfileRepository(database);
6317
7415
  let webActionToken = "";
6318
7416
  const tokenReady = (async () => {
6319
7417
  const secrets = await loadSecrets();
6320
7418
  if (!secrets.web.actionToken) {
6321
- secrets.web.actionToken = crypto8.randomBytes(32).toString("hex");
7419
+ secrets.web.actionToken = crypto9.randomBytes(32).toString("hex");
6322
7420
  await saveSecrets(secrets);
6323
7421
  }
6324
7422
  webActionToken = getWebActionToken(secrets);
@@ -6356,38 +7454,47 @@ function createWebApp(config, options = {}) {
6356
7454
  items: messages.listChats()
6357
7455
  }));
6358
7456
  app.get("/api/files", async (request) => {
6359
- const limit = parseLimit(request.query.limit, 50, 200);
7457
+ const limit = parseLimit2(request.query.limit, 50, 200);
6360
7458
  return {
6361
7459
  items: messages.listFiles(limit)
6362
7460
  };
6363
7461
  });
6364
7462
  app.get("/api/file-jobs", async (request) => {
6365
- const limit = parseLimit(request.query.limit, 50, 200);
7463
+ const limit = parseLimit2(request.query.limit, 50, 200);
6366
7464
  const status = request.query.status;
6367
7465
  return {
6368
7466
  items: fileJobs.list(limit, status === "processing" || status === "indexed" || status === "failed" ? { status } : {})
6369
7467
  };
6370
7468
  });
6371
7469
  app.get("/api/messages/recent", async (request) => {
6372
- const limit = parseLimit(request.query.limit, 20, 100);
7470
+ const limit = parseLimit2(request.query.limit, 20, 100);
6373
7471
  return {
6374
7472
  items: messages.listRecentMessages(limit)
6375
7473
  };
6376
7474
  });
6377
7475
  app.get("/api/episodes", async (request) => {
6378
- const limit = parseLimit(request.query.limit, 20, 100);
7476
+ const limit = parseLimit2(request.query.limit, 20, 100);
6379
7477
  return {
6380
7478
  items: episodes.listRecentEpisodes(limit)
6381
7479
  };
6382
7480
  });
6383
7481
  app.get("/api/qa-logs", async (request) => {
6384
- const limit = parseLimit(request.query.limit, 20, 100);
7482
+ const limit = parseLimit2(request.query.limit, 20, 100);
6385
7483
  return {
6386
- items: qaLogs.listRecent(limit)
7484
+ items: qaLogs.listRecent(limit).map(toQaLogListItem)
6387
7485
  };
6388
7486
  });
7487
+ app.get("/api/qa-logs/:id", async (request, reply) => {
7488
+ const id = request.params.id;
7489
+ const log = qaLogs.getById(id);
7490
+ if (!log) {
7491
+ reply.code(404);
7492
+ return { ok: false, message: "\u6CA1\u6709\u627E\u5230\u95EE\u7B54\u65E5\u5FD7\u3002" };
7493
+ }
7494
+ return log;
7495
+ });
6389
7496
  app.get("/api/cron-jobs", async (request) => {
6390
- const limit = parseLimit(request.query.limit, 50, 200);
7497
+ const limit = parseLimit2(request.query.limit, 50, 200);
6391
7498
  return {
6392
7499
  items: cronJobs.list(limit)
6393
7500
  };
@@ -6407,6 +7514,105 @@ function createWebApp(config, options = {}) {
6407
7514
  const ok = cronJobs.deleteByChat(id, job.chatId);
6408
7515
  return { ok };
6409
7516
  });
7517
+ app.get("/api/persons", async (_request) => {
7518
+ const persons = profiles.listPersons();
7519
+ const items = persons.map((person) => {
7520
+ const profile = profiles.getPersonProfile(person.id, { includeEvidence: false, includeInferred: true });
7521
+ return {
7522
+ id: person.id,
7523
+ primaryName: person.primaryName,
7524
+ profileEntryCount: profile?.entries.length ?? 0,
7525
+ messageCount: database.prepare(
7526
+ "SELECT COUNT(1) AS count FROM messages WHERE person_id = ?"
7527
+ ).get(person.id).count
7528
+ };
7529
+ });
7530
+ return { items };
7531
+ });
7532
+ app.get("/api/persons/:id/profile", async (request, reply) => {
7533
+ const id = request.params.id;
7534
+ const profile = profiles.getPersonProfile(id, { includeEvidence: true, includeInferred: true });
7535
+ if (!profile) {
7536
+ reply.code(404);
7537
+ return { ok: false, message: "\u6CA1\u6709\u627E\u5230\u8BE5\u6210\u5458\u3002" };
7538
+ }
7539
+ return profile;
7540
+ });
7541
+ app.get("/api/persons/:id/messages", async (request) => {
7542
+ const id = request.params.id;
7543
+ const limit = parseLimit2(request.query.limit, 50, 200);
7544
+ const rows = database.prepare(
7545
+ `SELECT m.id, m.platform, m.platform_message_id AS platformMessageId, m.sender_id AS senderId,
7546
+ m.sender_name AS senderName, m.message_type AS messageType, m.text, m.sent_at AS sentAt,
7547
+ c.name AS chatName, c.platform_chat_id AS platformChatId
7548
+ FROM messages m
7549
+ JOIN chats c ON c.id = m.chat_id
7550
+ WHERE m.person_id = ?
7551
+ ORDER BY m.sent_at DESC, m.created_at DESC
7552
+ LIMIT ?`
7553
+ ).all(id, limit);
7554
+ return { items: rows };
7555
+ });
7556
+ app.post("/api/persons/:id/profile/entries/:entryId/correct", async (request, reply) => {
7557
+ await tokenReady;
7558
+ if (!isAuthorizedWebAction(request, webActionToken)) {
7559
+ reply.code(403);
7560
+ return { ok: false, message: "Web \u64CD\u4F5C\u672A\u6388\u6743\u3002" };
7561
+ }
7562
+ const { id, entryId } = request.params;
7563
+ const existing = profiles.getProfileEntry(entryId);
7564
+ if (!existing || existing.personId !== id || existing.status !== "active") {
7565
+ reply.code(404);
7566
+ return { ok: false, message: "\u6CA1\u6709\u627E\u5230\u8BE5\u6863\u6848\u6761\u76EE\u3002" };
7567
+ }
7568
+ const body = request.body;
7569
+ const category = readStringField(body, "category") ?? existing.category;
7570
+ const content = readStringField(body, "content");
7571
+ const entryType = readStringField(body, "entryType") ?? existing.entryType;
7572
+ const evidenceMessageId = readStringField(body, "evidenceMessageId");
7573
+ const quote = readStringField(body, "quote");
7574
+ const reason = readStringField(body, "reason") ?? "\u7528\u6237\u5728 Web UI \u663E\u5F0F\u4FEE\u6B63";
7575
+ if (!content || !evidenceMessageId || !quote) {
7576
+ reply.code(400);
7577
+ return { ok: false, message: "\u4FEE\u6B63\u5FC5\u987B\u5305\u542B content\u3001evidenceMessageId \u548C quote\u3002" };
7578
+ }
7579
+ if (entryType !== "fact" && entryType !== "inferred") {
7580
+ reply.code(400);
7581
+ return { ok: false, message: "entryType \u5FC5\u987B\u662F fact \u6216 inferred\u3002" };
7582
+ }
7583
+ if (category === existing.category && content === existing.content) {
7584
+ reply.code(400);
7585
+ return { ok: false, message: "\u4FEE\u6B63\u5185\u5BB9\u6CA1\u6709\u53D8\u5316\u3002" };
7586
+ }
7587
+ const entryIdNew = profiles.replaceProfileEntry({
7588
+ supersedeEntryId: entryId,
7589
+ input: {
7590
+ personId: id,
7591
+ category,
7592
+ content,
7593
+ entryType,
7594
+ confidence: Math.min(1, Math.max(0, readNumberField(body, "confidence", existing.confidence))),
7595
+ source: "explicit_user_request",
7596
+ evidence: [{ messageId: evidenceMessageId, quote, reason }]
7597
+ }
7598
+ });
7599
+ return { ok: true, entryId: entryIdNew };
7600
+ });
7601
+ app.delete("/api/persons/:id/profile/entries/:entryId", async (request, reply) => {
7602
+ await tokenReady;
7603
+ if (!isAuthorizedWebAction(request, webActionToken)) {
7604
+ reply.code(403);
7605
+ return { ok: false, message: "Web \u64CD\u4F5C\u672A\u6388\u6743\u3002" };
7606
+ }
7607
+ const { id, entryId } = request.params;
7608
+ const existing = profiles.getProfileEntry(entryId);
7609
+ if (!existing || existing.personId !== id || existing.status !== "active") {
7610
+ reply.code(404);
7611
+ return { ok: false, message: "\u6CA1\u6709\u627E\u5230\u8BE5\u6863\u6848\u6761\u76EE\u3002" };
7612
+ }
7613
+ profiles.markProfileEntryDeleted(entryId);
7614
+ return { ok: true };
7615
+ });
6410
7616
  app.post("/api/process/messages", async (request, reply) => {
6411
7617
  await tokenReady;
6412
7618
  if (!isAuthorizedWebAction(request, webActionToken)) {