chattercatcher 0.2.5 → 0.2.6

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
@@ -995,6 +995,7 @@ function migrateDatabase(database) {
995
995
  answer TEXT NOT NULL,
996
996
  citations_json TEXT NOT NULL,
997
997
  retrieval_debug_json TEXT NOT NULL,
998
+ trace_json TEXT NOT NULL DEFAULT '{}',
998
999
  status TEXT NOT NULL CHECK(status IN ('answered','failed')),
999
1000
  error TEXT,
1000
1001
  created_at TEXT NOT NULL
@@ -1089,6 +1090,10 @@ function migrateDatabase(database) {
1089
1090
  ensureCronJobColumn("mention_target_name", "mention_target_name TEXT");
1090
1091
  ensureCronJobColumn("mention_open_id", "mention_open_id TEXT");
1091
1092
  ensureCronJobColumn("mention_user_id", "mention_user_id TEXT");
1093
+ const qaLogColumns = database.prepare("PRAGMA table_info(qa_logs)").all();
1094
+ if (!qaLogColumns.some((column) => column.name === "trace_json")) {
1095
+ database.prepare("ALTER TABLE qa_logs ADD COLUMN trace_json TEXT NOT NULL DEFAULT '{}'").run();
1096
+ }
1092
1097
  }
1093
1098
 
1094
1099
  // src/doctor/checks.ts
@@ -3893,10 +3898,22 @@ function createFeishuChatMembersClient(client) {
3893
3898
 
3894
3899
  // src/rag/qa-logs.ts
3895
3900
  import crypto6 from "crypto";
3901
+
3902
+ // src/rag/qa-trace.ts
3903
+ function hasQaTrace(trace) {
3904
+ return Object.keys(trace).length > 0;
3905
+ }
3906
+
3907
+ // src/rag/qa-logs.ts
3896
3908
  function clampLimit(limit) {
3897
3909
  return Math.max(1, Math.min(200, Math.trunc(limit)));
3898
3910
  }
3911
+ function parseTrace(value) {
3912
+ const parsed = JSON.parse(value);
3913
+ return parsed && typeof parsed === "object" ? parsed : {};
3914
+ }
3899
3915
  function mapQaLogRow(row) {
3916
+ const trace = parseTrace(row.trace_json);
3900
3917
  return {
3901
3918
  id: row.id,
3902
3919
  chatId: row.chat_id,
@@ -3905,6 +3922,8 @@ function mapQaLogRow(row) {
3905
3922
  answer: row.answer,
3906
3923
  citations: JSON.parse(row.citations_json),
3907
3924
  retrievalDebug: JSON.parse(row.retrieval_debug_json),
3925
+ trace,
3926
+ hasTrace: hasQaTrace(trace),
3908
3927
  status: row.status,
3909
3928
  error: row.error,
3910
3929
  createdAt: row.created_at
@@ -3916,6 +3935,7 @@ var QaLogRepository = class {
3916
3935
  }
3917
3936
  database;
3918
3937
  create(input) {
3938
+ const trace = input.trace ?? {};
3919
3939
  const record = {
3920
3940
  id: `qa_${crypto6.randomUUID()}`,
3921
3941
  chatId: input.chatId ?? null,
@@ -3924,6 +3944,8 @@ var QaLogRepository = class {
3924
3944
  answer: input.answer,
3925
3945
  citations: input.citations,
3926
3946
  retrievalDebug: input.retrievalDebug,
3947
+ trace,
3948
+ hasTrace: hasQaTrace(trace),
3927
3949
  status: input.status,
3928
3950
  error: input.error ?? null,
3929
3951
  createdAt: input.createdAt
@@ -3938,6 +3960,7 @@ var QaLogRepository = class {
3938
3960
  answer,
3939
3961
  citations_json,
3940
3962
  retrieval_debug_json,
3963
+ trace_json,
3941
3964
  status,
3942
3965
  error,
3943
3966
  created_at
@@ -3950,6 +3973,7 @@ var QaLogRepository = class {
3950
3973
  @answer,
3951
3974
  @citationsJson,
3952
3975
  @retrievalDebugJson,
3976
+ @traceJson,
3953
3977
  @status,
3954
3978
  @error,
3955
3979
  @createdAt
@@ -3963,6 +3987,7 @@ var QaLogRepository = class {
3963
3987
  answer: record.answer,
3964
3988
  citationsJson: JSON.stringify(record.citations),
3965
3989
  retrievalDebugJson: JSON.stringify(record.retrievalDebug),
3990
+ traceJson: JSON.stringify(record.trace),
3966
3991
  status: record.status,
3967
3992
  error: record.error,
3968
3993
  createdAt: record.createdAt
@@ -3980,6 +4005,7 @@ var QaLogRepository = class {
3980
4005
  answer,
3981
4006
  citations_json,
3982
4007
  retrieval_debug_json,
4008
+ trace_json,
3983
4009
  status,
3984
4010
  error,
3985
4011
  created_at
@@ -4001,6 +4027,7 @@ var QaLogRepository = class {
4001
4027
  answer,
4002
4028
  citations_json,
4003
4029
  retrieval_debug_json,
4030
+ trace_json,
4004
4031
  status,
4005
4032
  error,
4006
4033
  created_at
@@ -4012,6 +4039,27 @@ var QaLogRepository = class {
4012
4039
  ).all(chatId, clampLimit(limit));
4013
4040
  return rows.map(mapQaLogRow);
4014
4041
  }
4042
+ getById(id) {
4043
+ const row = this.database.prepare(
4044
+ `
4045
+ SELECT
4046
+ id,
4047
+ chat_id,
4048
+ question_message_id,
4049
+ question,
4050
+ answer,
4051
+ citations_json,
4052
+ retrieval_debug_json,
4053
+ trace_json,
4054
+ status,
4055
+ error,
4056
+ created_at
4057
+ FROM qa_logs
4058
+ WHERE id = ?
4059
+ `
4060
+ ).get(id);
4061
+ return row ? mapQaLogRow(row) : null;
4062
+ }
4015
4063
  getCount() {
4016
4064
  const row = this.database.prepare("SELECT COUNT(*) AS count FROM qa_logs").get();
4017
4065
  return row.count;
@@ -4069,6 +4117,18 @@ ${block.text}`;
4069
4117
  function toToolErrorContent(message) {
4070
4118
  return JSON.stringify({ ok: false, error: message });
4071
4119
  }
4120
+ function nowIso5() {
4121
+ return (/* @__PURE__ */ new Date()).toISOString();
4122
+ }
4123
+ function finalizeTrace(trace, status, finalAnswer, startedAtMs) {
4124
+ return {
4125
+ ...trace,
4126
+ completedAt: nowIso5(),
4127
+ durationMs: Date.now() - startedAtMs,
4128
+ status,
4129
+ finalAnswer
4130
+ };
4131
+ }
4072
4132
  async function executeFeishuTool(tool, input) {
4073
4133
  const result = await tool.execute(input);
4074
4134
  if (isEvidenceBlockArray(result)) {
@@ -4080,6 +4140,13 @@ async function runFeishuToolLoop(input) {
4080
4140
  if (!input.model.completeWithTools) {
4081
4141
  throw new Error("\u5F53\u524D LLM \u5BA2\u6237\u7AEF\u4E0D\u652F\u6301\u5DE5\u5177\u8C03\u7528\u3002");
4082
4142
  }
4143
+ const startedAtMs = Date.now();
4144
+ const trace = {
4145
+ startedAt: new Date(startedAtMs).toISOString(),
4146
+ modelTurns: [],
4147
+ toolResults: [],
4148
+ fallbacks: []
4149
+ };
4083
4150
  const maxModelTurns = input.maxModelTurns ?? DEFAULT_MAX_MODEL_TURNS;
4084
4151
  const maxToolCalls = input.maxToolCalls ?? DEFAULT_MAX_TOOL_CALLS;
4085
4152
  const systemPromptParts = [FEISHU_TOOL_SYSTEM_PROMPT];
@@ -4108,19 +4175,36 @@ async function runFeishuToolLoop(input) {
4108
4175
  toolCalls: assistantResult.toolCalls,
4109
4176
  reasoningContent: assistantResult.reasoningContent
4110
4177
  });
4178
+ trace.modelTurns?.push({
4179
+ index: turn,
4180
+ content: assistantResult.content,
4181
+ reasoningContent: assistantResult.reasoningContent,
4182
+ toolCalls: assistantResult.toolCalls,
4183
+ createdAt: nowIso5()
4184
+ });
4111
4185
  if (assistantResult.toolCalls.length === 0) {
4112
4186
  if (hasRawToolCallMarkup) {
4187
+ 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: nowIso5() });
4113
4188
  break;
4114
4189
  }
4115
- return assistantResult.content || FEISHU_TOOL_LOOP_FALLBACK;
4190
+ const answer = assistantResult.content || FEISHU_TOOL_LOOP_FALLBACK;
4191
+ return { answer, trace: finalizeTrace(trace, "answered", answer, startedAtMs) };
4116
4192
  }
4117
4193
  for (const toolCall of assistantResult.toolCalls) {
4118
4194
  if (toolCallsUsed >= maxToolCalls) {
4119
- return FEISHU_TOOL_LOOP_LIMIT_REACHED;
4195
+ trace.fallbacks?.push({ type: "tool_limit", message: FEISHU_TOOL_LOOP_LIMIT_REACHED, createdAt: nowIso5() });
4196
+ return { answer: FEISHU_TOOL_LOOP_LIMIT_REACHED, trace: finalizeTrace(trace, "failed", FEISHU_TOOL_LOOP_LIMIT_REACHED, startedAtMs) };
4120
4197
  }
4121
4198
  toolCallsUsed += 1;
4122
4199
  const tool = toolsByName.get(toolCall.name);
4123
4200
  if (!tool) {
4201
+ trace.toolResults?.push({
4202
+ toolCallId: toolCall.id,
4203
+ name: toolCall.name,
4204
+ input: toolCall.input,
4205
+ error: `\u672A\u77E5\u5DE5\u5177\uFF1A${toolCall.name}`,
4206
+ createdAt: nowIso5()
4207
+ });
4124
4208
  messages.push({
4125
4209
  role: "tool",
4126
4210
  toolCallId: toolCall.id,
@@ -4130,6 +4214,13 @@ async function runFeishuToolLoop(input) {
4130
4214
  }
4131
4215
  try {
4132
4216
  const result = await executeFeishuTool(tool, toolCall.input);
4217
+ trace.toolResults?.push({
4218
+ toolCallId: toolCall.id,
4219
+ name: toolCall.name,
4220
+ input: toolCall.input,
4221
+ content: result,
4222
+ createdAt: nowIso5()
4223
+ });
4133
4224
  messages.push({
4134
4225
  role: "tool",
4135
4226
  toolCallId: toolCall.id,
@@ -4137,6 +4228,13 @@ async function runFeishuToolLoop(input) {
4137
4228
  });
4138
4229
  } catch (error) {
4139
4230
  const message = error instanceof Error ? error.message : String(error);
4231
+ trace.toolResults?.push({
4232
+ toolCallId: toolCall.id,
4233
+ name: toolCall.name,
4234
+ input: toolCall.input,
4235
+ error: message,
4236
+ createdAt: nowIso5()
4237
+ });
4140
4238
  messages.push({
4141
4239
  role: "tool",
4142
4240
  toolCallId: toolCall.id,
@@ -4150,9 +4248,13 @@ async function runFeishuToolLoop(input) {
4150
4248
  ...messages,
4151
4249
  { 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
4250
  ]);
4153
- return salvageAnswer || "\u62B1\u6B49\uFF0C\u56DE\u7B54\u751F\u6210\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
4251
+ const answer = salvageAnswer || "\u62B1\u6B49\uFF0C\u56DE\u7B54\u751F\u6210\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
4252
+ 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: nowIso5() });
4253
+ return { answer, trace: finalizeTrace(trace, "answered", answer, startedAtMs) };
4154
4254
  } catch {
4155
- return "\u62B1\u6B49\uFF0C\u56DE\u7B54\u751F\u6210\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
4255
+ const answer = "\u62B1\u6B49\uFF0C\u56DE\u7B54\u751F\u6210\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
4256
+ trace.fallbacks?.push({ type: "answer_generation_failed", message: answer, createdAt: nowIso5() });
4257
+ return { answer, trace: finalizeTrace(trace, "failed", answer, startedAtMs) };
4156
4258
  }
4157
4259
  }
4158
4260
  function formatConversationContext(records) {
@@ -4272,7 +4374,7 @@ var FeishuQuestionHandler = class {
4272
4374
  const memberRepository = this.options.memberRepository ?? new FeishuMemberRepository(this.options.database);
4273
4375
  const memberPrompt = formatFeishuMemberPrompt(memberRepository.listByChat(decision.chatId));
4274
4376
  const conversationContext = formatConversationContext(qaLogs.listRecentByChat(decision.chatId, 6));
4275
- const answer = await runFeishuToolLoop({
4377
+ const result = await runFeishuToolLoop({
4276
4378
  question: decision.question,
4277
4379
  now,
4278
4380
  tools: allTools,
@@ -4284,27 +4386,38 @@ var FeishuQuestionHandler = class {
4284
4386
  chatId: decision.chatId,
4285
4387
  questionMessageId,
4286
4388
  question: decision.question,
4287
- answer,
4389
+ answer: result.answer,
4288
4390
  citations: [],
4289
4391
  retrievalDebug: {},
4392
+ trace: result.trace,
4290
4393
  status: "answered",
4291
4394
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
4292
4395
  });
4293
- await this.sendResponse(decision.chatId, questionMessageId, answer);
4396
+ await this.sendResponse(decision.chatId, questionMessageId, result.answer);
4294
4397
  } catch (error) {
4295
4398
  const message = error instanceof Error ? error.message : String(error);
4399
+ const failedAt = (/* @__PURE__ */ new Date()).toISOString();
4400
+ const failedAnswer = `\u6682\u65F6\u65E0\u6CD5\u56DE\u7B54\uFF1A${message}`;
4296
4401
  qaLogs.create({
4297
4402
  chatId: decision.chatId,
4298
4403
  questionMessageId,
4299
4404
  question: decision.question,
4300
- answer: `\u6682\u65F6\u65E0\u6CD5\u56DE\u7B54\uFF1A${message}`,
4405
+ answer: failedAnswer,
4301
4406
  citations: [],
4302
4407
  retrievalDebug: {},
4408
+ trace: {
4409
+ startedAt: now.toISOString(),
4410
+ completedAt: failedAt,
4411
+ durationMs: Math.max(0, Date.parse(failedAt) - now.getTime()),
4412
+ status: "failed",
4413
+ finalAnswer: failedAnswer,
4414
+ fallbacks: [{ type: "answer_generation_failed", message, createdAt: failedAt }]
4415
+ },
4303
4416
  status: "failed",
4304
4417
  error: message,
4305
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
4418
+ createdAt: failedAt
4306
4419
  });
4307
- await this.sendResponse(decision.chatId, questionMessageId, `\u6682\u65F6\u65E0\u6CD5\u56DE\u7B54\uFF1A${message}`);
4420
+ await this.sendResponse(decision.chatId, questionMessageId, failedAnswer);
4308
4421
  }
4309
4422
  return decision;
4310
4423
  } finally {
@@ -5917,12 +6030,15 @@ function buildHtml() {
5917
6030
  let allFileJobs = [];
5918
6031
  let allCronJobs = [];
5919
6032
  let allQaLogs = [];
6033
+ let selectedQaLogId = null;
5920
6034
  let statusData = null;
5921
6035
 
5922
6036
  function fmt(value) { return value == null || value === "" ? "-" : String(value); }
5923
6037
  function escapeHtml(value) {
5924
6038
  return fmt(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
5925
6039
  }
6040
+ function renderJson(value) { return '<pre style="white-space:pre-wrap;overflow:auto;max-height:320px;">' + escapeHtml(JSON.stringify(value, null, 2)) + '</pre>'; }
6041
+ function renderTextBlock(value) { return '<pre style="white-space:pre-wrap;overflow:auto;max-height:320px;">' + escapeHtml(value || "") + '</pre>'; }
5926
6042
  function isOpaqueId(value) { return /^(ou|oc|om|cli|on|un|uid)_?[a-z0-9]+/i.test(fmt(value)); }
5927
6043
  function formatDateTime(value) {
5928
6044
  var date = new Date(value);
@@ -6195,17 +6311,72 @@ function buildHtml() {
6195
6311
  for (var i = 0; i < allQaLogs.length; i++) {
6196
6312
  var item = allQaLogs[i];
6197
6313
  var citationCount = Array.isArray(item.citations) ? item.citations.length : 0;
6198
- var statusClass = item.status === 'success' ? 'tag-success' : 'tag-warning';
6314
+ var statusClass = item.status === 'answered' ? 'tag-success' : 'tag-warning';
6199
6315
  html += '<div class="qa-card"><div class="message-meta" style="margin-bottom:var(--space-sm);">' +
6200
6316
  '<span>' + escapeHtml(formatDateTime(item.createdAt)) + '</span>' +
6201
6317
  '<span class="tag ' + statusClass + '">' + escapeHtml(item.status) + '</span>' +
6202
- '<span>' + citationCount + ' \u6761\u5F15\u7528</span></div>' +
6318
+ '<span>' + citationCount + ' \u6761\u5F15\u7528</span>' +
6319
+ '<span class="tag ' + (item.hasTrace ? 'tag-info' : 'tag-warning') + '">' + (item.hasTrace ? '\u6709 trace' : '\u65E0 trace') + '</span></div>' +
6203
6320
  '<div class="qa-question">' + escapeHtml(item.question) + '</div>' +
6204
- '<div class="qa-answer">' + escapeHtml(item.answer) + '</div></div>';
6321
+ '<div class="qa-answer">' + escapeHtml(item.answer) + '</div>' +
6322
+ '<button class="btn btn-sm" style="margin-top:var(--space-sm);" data-view-qa-log="' + escapeHtml(item.id) + '">\u67E5\u770B\u8BE6\u60C5</button>' +
6323
+ '<div id="qa-detail-' + escapeHtml(item.id) + '" style="margin-top:var(--space-md);"></div></div>';
6205
6324
  }
6206
6325
  el.innerHTML = html;
6207
6326
  }
6208
6327
 
6328
+ async function showQaLogDetail(id) {
6329
+ selectedQaLogId = id;
6330
+ var container = document.getElementById("qa-detail-" + id);
6331
+ if (!container) return;
6332
+ container.innerHTML = '<div class="empty-state">\u6B63\u5728\u52A0\u8F7D\u95EE\u7B54\u8BE6\u60C5...</div>';
6333
+ try {
6334
+ var item = await fetchJson("/api/qa-logs/" + encodeURIComponent(id));
6335
+ renderQaLogDetail(item);
6336
+ } catch (error) {
6337
+ container.innerHTML = '<div class="empty-state">\u8BE6\u60C5\u52A0\u8F7D\u5931\u8D25\uFF1A' + escapeHtml(error instanceof Error ? error.message : String(error)) + '</div>';
6338
+ }
6339
+ }
6340
+
6341
+ function renderQaLogDetail(item) {
6342
+ var container = document.getElementById("qa-detail-" + item.id);
6343
+ if (!container) return;
6344
+ var trace = item.trace || {};
6345
+ var html = '<div class="content-panel" style="margin-top:var(--space-sm);background:rgba(255,255,255,0.03);">';
6346
+ html += '<h3 style="font-size:15px;margin-bottom:var(--space-sm);">\u95EE\u7B54\u8BE6\u60C5</h3>';
6347
+ 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>';
6348
+ if (item.error) html += '<div style="color:var(--danger);margin-bottom:var(--space-sm);">\u9519\u8BEF\uFF1A' + escapeHtml(item.error) + '</div>';
6349
+ html += '<div class="qa-question">' + escapeHtml(item.question) + '</div>';
6350
+ html += '<div class="qa-answer" style="margin-bottom:var(--space-md);">' + escapeHtml(item.answer) + '</div>';
6351
+ if (!item.hasTrace) {
6352
+ 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>';
6353
+ container.innerHTML = html;
6354
+ return;
6355
+ }
6356
+ var turns = trace.modelTurns || [];
6357
+ html += '<h4 style="margin:var(--space-md) 0 var(--space-sm);">Reasoning</h4>';
6358
+ if (turns.length === 0) html += '<div class="empty-state">\u65E0 reasoningContent</div>';
6359
+ for (var i = 0; i < turns.length; i++) {
6360
+ 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>';
6361
+ }
6362
+ html += '<h4 style="margin:var(--space-md) 0 var(--space-sm);">\u6A21\u578B\u8F6E\u6B21\u4E0E\u5DE5\u5177\u8C03\u7528</h4>';
6363
+ for (var j = 0; j < turns.length; j++) {
6364
+ 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>';
6365
+ }
6366
+ html += '<h4 style="margin:var(--space-md) 0 var(--space-sm);">\u5DE5\u5177\u7ED3\u679C</h4>';
6367
+ var toolResults = trace.toolResults || [];
6368
+ if (toolResults.length === 0) html += '<div class="empty-state">\u6CA1\u6709\u5DE5\u5177\u7ED3\u679C\u3002</div>';
6369
+ for (var k = 0; k < toolResults.length; k++) {
6370
+ 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>';
6371
+ }
6372
+ html += '<h4 style="margin:var(--space-md) 0 var(--space-sm);">\u5F15\u7528\u4E0E\u68C0\u7D22</h4>' + renderJson({ citations: item.citations || [], retrievalDebug: item.retrievalDebug || {} });
6373
+ html += '<h4 style="margin:var(--space-md) 0 var(--space-sm);">Fallback</h4>';
6374
+ var fallbacks = trace.fallbacks || [];
6375
+ html += fallbacks.length === 0 ? '<div class="empty-state">\u6CA1\u6709 fallback\u3002</div>' : renderJson(fallbacks);
6376
+ html += '</div>';
6377
+ container.innerHTML = html;
6378
+ }
6379
+
6209
6380
  function renderSettings(status) {
6210
6381
  var el = document.getElementById("settings-config");
6211
6382
  var html = '<h3 style="font-size:16px;font-weight:600;margin-bottom:var(--space-md);">\u7CFB\u7EDF\u914D\u7F6E</h3>';
@@ -6240,7 +6411,10 @@ function buildHtml() {
6240
6411
  if (currentView === "episodes") renderEpisodesView();
6241
6412
  if (currentView === "files") renderFilesView();
6242
6413
  if (currentView === "tasks") renderTasksView();
6243
- if (currentView === "qa-logs") renderQaLogsView();
6414
+ if (currentView === "qa-logs") {
6415
+ renderQaLogsView();
6416
+ if (selectedQaLogId) void showQaLogDetail(selectedQaLogId);
6417
+ }
6244
6418
  }
6245
6419
 
6246
6420
  async function processNow() {
@@ -6262,6 +6436,11 @@ function buildHtml() {
6262
6436
  document.addEventListener("click", async function(event) {
6263
6437
  var target = event.target;
6264
6438
  if (!(target instanceof HTMLElement)) return;
6439
+ var qaLogId = target.dataset.viewQaLog;
6440
+ if (qaLogId) {
6441
+ void showQaLogDetail(qaLogId);
6442
+ return;
6443
+ }
6265
6444
  var id = target.dataset.deleteCronJob;
6266
6445
  if (!id) return;
6267
6446
  target.disabled = true;
@@ -6305,6 +6484,10 @@ function parseCookies(header) {
6305
6484
  function isAuthorizedWebAction(request, token) {
6306
6485
  return parseCookies(request.headers.cookie).chattercatcher_web_token === token;
6307
6486
  }
6487
+ function toQaLogListItem(log) {
6488
+ const { trace: _trace, ...item } = log;
6489
+ return item;
6490
+ }
6308
6491
  function createWebApp(config, options = {}) {
6309
6492
  const app = Fastify({ logger: false });
6310
6493
  const database = openDatabase(config);
@@ -6383,9 +6566,18 @@ function createWebApp(config, options = {}) {
6383
6566
  app.get("/api/qa-logs", async (request) => {
6384
6567
  const limit = parseLimit(request.query.limit, 20, 100);
6385
6568
  return {
6386
- items: qaLogs.listRecent(limit)
6569
+ items: qaLogs.listRecent(limit).map(toQaLogListItem)
6387
6570
  };
6388
6571
  });
6572
+ app.get("/api/qa-logs/:id", async (request, reply) => {
6573
+ const id = request.params.id;
6574
+ const log = qaLogs.getById(id);
6575
+ if (!log) {
6576
+ reply.code(404);
6577
+ return { ok: false, message: "\u6CA1\u6709\u627E\u5230\u95EE\u7B54\u65E5\u5FD7\u3002" };
6578
+ }
6579
+ return log;
6580
+ });
6389
6581
  app.get("/api/cron-jobs", async (request) => {
6390
6582
  const limit = parseLimit(request.query.limit, 50, 200);
6391
6583
  return {