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/cli.js CHANGED
@@ -8,7 +8,7 @@ import fs15 from "fs/promises";
8
8
  // package.json
9
9
  var package_default = {
10
10
  name: "chattercatcher",
11
- version: "0.2.5",
11
+ version: "0.2.6",
12
12
  description: "\u672C\u5730\u4F18\u5148\u7684\u98DE\u4E66/Lark \u5BB6\u5EAD\u7FA4\u77E5\u8BC6\u5E93\u673A\u5668\u4EBA",
13
13
  type: "module",
14
14
  main: "dist/index.js",
@@ -495,6 +495,7 @@ function migrateDatabase(database) {
495
495
  answer TEXT NOT NULL,
496
496
  citations_json TEXT NOT NULL,
497
497
  retrieval_debug_json TEXT NOT NULL,
498
+ trace_json TEXT NOT NULL DEFAULT '{}',
498
499
  status TEXT NOT NULL CHECK(status IN ('answered','failed')),
499
500
  error TEXT,
500
501
  created_at TEXT NOT NULL
@@ -589,6 +590,10 @@ function migrateDatabase(database) {
589
590
  ensureCronJobColumn("mention_target_name", "mention_target_name TEXT");
590
591
  ensureCronJobColumn("mention_open_id", "mention_open_id TEXT");
591
592
  ensureCronJobColumn("mention_user_id", "mention_user_id TEXT");
593
+ const qaLogColumns = database.prepare("PRAGMA table_info(qa_logs)").all();
594
+ if (!qaLogColumns.some((column) => column.name === "trace_json")) {
595
+ database.prepare("ALTER TABLE qa_logs ADD COLUMN trace_json TEXT NOT NULL DEFAULT '{}'").run();
596
+ }
592
597
  }
593
598
 
594
599
  // src/doctor/checks.ts
@@ -4038,10 +4043,22 @@ function createCronJobTools(input2) {
4038
4043
 
4039
4044
  // src/rag/qa-logs.ts
4040
4045
  import crypto6 from "crypto";
4046
+
4047
+ // src/rag/qa-trace.ts
4048
+ function hasQaTrace(trace) {
4049
+ return Object.keys(trace).length > 0;
4050
+ }
4051
+
4052
+ // src/rag/qa-logs.ts
4041
4053
  function clampLimit(limit) {
4042
4054
  return Math.max(1, Math.min(200, Math.trunc(limit)));
4043
4055
  }
4056
+ function parseTrace(value) {
4057
+ const parsed = JSON.parse(value);
4058
+ return parsed && typeof parsed === "object" ? parsed : {};
4059
+ }
4044
4060
  function mapQaLogRow(row) {
4061
+ const trace = parseTrace(row.trace_json);
4045
4062
  return {
4046
4063
  id: row.id,
4047
4064
  chatId: row.chat_id,
@@ -4050,6 +4067,8 @@ function mapQaLogRow(row) {
4050
4067
  answer: row.answer,
4051
4068
  citations: JSON.parse(row.citations_json),
4052
4069
  retrievalDebug: JSON.parse(row.retrieval_debug_json),
4070
+ trace,
4071
+ hasTrace: hasQaTrace(trace),
4053
4072
  status: row.status,
4054
4073
  error: row.error,
4055
4074
  createdAt: row.created_at
@@ -4061,6 +4080,7 @@ var QaLogRepository = class {
4061
4080
  }
4062
4081
  database;
4063
4082
  create(input2) {
4083
+ const trace = input2.trace ?? {};
4064
4084
  const record = {
4065
4085
  id: `qa_${crypto6.randomUUID()}`,
4066
4086
  chatId: input2.chatId ?? null,
@@ -4069,6 +4089,8 @@ var QaLogRepository = class {
4069
4089
  answer: input2.answer,
4070
4090
  citations: input2.citations,
4071
4091
  retrievalDebug: input2.retrievalDebug,
4092
+ trace,
4093
+ hasTrace: hasQaTrace(trace),
4072
4094
  status: input2.status,
4073
4095
  error: input2.error ?? null,
4074
4096
  createdAt: input2.createdAt
@@ -4083,6 +4105,7 @@ var QaLogRepository = class {
4083
4105
  answer,
4084
4106
  citations_json,
4085
4107
  retrieval_debug_json,
4108
+ trace_json,
4086
4109
  status,
4087
4110
  error,
4088
4111
  created_at
@@ -4095,6 +4118,7 @@ var QaLogRepository = class {
4095
4118
  @answer,
4096
4119
  @citationsJson,
4097
4120
  @retrievalDebugJson,
4121
+ @traceJson,
4098
4122
  @status,
4099
4123
  @error,
4100
4124
  @createdAt
@@ -4108,6 +4132,7 @@ var QaLogRepository = class {
4108
4132
  answer: record.answer,
4109
4133
  citationsJson: JSON.stringify(record.citations),
4110
4134
  retrievalDebugJson: JSON.stringify(record.retrievalDebug),
4135
+ traceJson: JSON.stringify(record.trace),
4111
4136
  status: record.status,
4112
4137
  error: record.error,
4113
4138
  createdAt: record.createdAt
@@ -4125,6 +4150,7 @@ var QaLogRepository = class {
4125
4150
  answer,
4126
4151
  citations_json,
4127
4152
  retrieval_debug_json,
4153
+ trace_json,
4128
4154
  status,
4129
4155
  error,
4130
4156
  created_at
@@ -4146,6 +4172,7 @@ var QaLogRepository = class {
4146
4172
  answer,
4147
4173
  citations_json,
4148
4174
  retrieval_debug_json,
4175
+ trace_json,
4149
4176
  status,
4150
4177
  error,
4151
4178
  created_at
@@ -4157,6 +4184,27 @@ var QaLogRepository = class {
4157
4184
  ).all(chatId, clampLimit(limit));
4158
4185
  return rows.map(mapQaLogRow);
4159
4186
  }
4187
+ getById(id) {
4188
+ const row = this.database.prepare(
4189
+ `
4190
+ SELECT
4191
+ id,
4192
+ chat_id,
4193
+ question_message_id,
4194
+ question,
4195
+ answer,
4196
+ citations_json,
4197
+ retrieval_debug_json,
4198
+ trace_json,
4199
+ status,
4200
+ error,
4201
+ created_at
4202
+ FROM qa_logs
4203
+ WHERE id = ?
4204
+ `
4205
+ ).get(id);
4206
+ return row ? mapQaLogRow(row) : null;
4207
+ }
4160
4208
  getCount() {
4161
4209
  const row = this.database.prepare("SELECT COUNT(*) AS count FROM qa_logs").get();
4162
4210
  return row.count;
@@ -4214,6 +4262,18 @@ ${block.text}`;
4214
4262
  function toToolErrorContent(message) {
4215
4263
  return JSON.stringify({ ok: false, error: message });
4216
4264
  }
4265
+ function nowIso5() {
4266
+ return (/* @__PURE__ */ new Date()).toISOString();
4267
+ }
4268
+ function finalizeTrace(trace, status, finalAnswer, startedAtMs) {
4269
+ return {
4270
+ ...trace,
4271
+ completedAt: nowIso5(),
4272
+ durationMs: Date.now() - startedAtMs,
4273
+ status,
4274
+ finalAnswer
4275
+ };
4276
+ }
4217
4277
  async function executeFeishuTool(tool, input2) {
4218
4278
  const result = await tool.execute(input2);
4219
4279
  if (isEvidenceBlockArray(result)) {
@@ -4225,6 +4285,13 @@ async function runFeishuToolLoop(input2) {
4225
4285
  if (!input2.model.completeWithTools) {
4226
4286
  throw new Error("\u5F53\u524D LLM \u5BA2\u6237\u7AEF\u4E0D\u652F\u6301\u5DE5\u5177\u8C03\u7528\u3002");
4227
4287
  }
4288
+ const startedAtMs = Date.now();
4289
+ const trace = {
4290
+ startedAt: new Date(startedAtMs).toISOString(),
4291
+ modelTurns: [],
4292
+ toolResults: [],
4293
+ fallbacks: []
4294
+ };
4228
4295
  const maxModelTurns = input2.maxModelTurns ?? DEFAULT_MAX_MODEL_TURNS;
4229
4296
  const maxToolCalls = input2.maxToolCalls ?? DEFAULT_MAX_TOOL_CALLS;
4230
4297
  const systemPromptParts = [FEISHU_TOOL_SYSTEM_PROMPT];
@@ -4253,19 +4320,36 @@ async function runFeishuToolLoop(input2) {
4253
4320
  toolCalls: assistantResult.toolCalls,
4254
4321
  reasoningContent: assistantResult.reasoningContent
4255
4322
  });
4323
+ trace.modelTurns?.push({
4324
+ index: turn,
4325
+ content: assistantResult.content,
4326
+ reasoningContent: assistantResult.reasoningContent,
4327
+ toolCalls: assistantResult.toolCalls,
4328
+ createdAt: nowIso5()
4329
+ });
4256
4330
  if (assistantResult.toolCalls.length === 0) {
4257
4331
  if (hasRawToolCallMarkup) {
4332
+ 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() });
4258
4333
  break;
4259
4334
  }
4260
- return assistantResult.content || FEISHU_TOOL_LOOP_FALLBACK;
4335
+ const answer = assistantResult.content || FEISHU_TOOL_LOOP_FALLBACK;
4336
+ return { answer, trace: finalizeTrace(trace, "answered", answer, startedAtMs) };
4261
4337
  }
4262
4338
  for (const toolCall of assistantResult.toolCalls) {
4263
4339
  if (toolCallsUsed >= maxToolCalls) {
4264
- return FEISHU_TOOL_LOOP_LIMIT_REACHED;
4340
+ trace.fallbacks?.push({ type: "tool_limit", message: FEISHU_TOOL_LOOP_LIMIT_REACHED, createdAt: nowIso5() });
4341
+ return { answer: FEISHU_TOOL_LOOP_LIMIT_REACHED, trace: finalizeTrace(trace, "failed", FEISHU_TOOL_LOOP_LIMIT_REACHED, startedAtMs) };
4265
4342
  }
4266
4343
  toolCallsUsed += 1;
4267
4344
  const tool = toolsByName.get(toolCall.name);
4268
4345
  if (!tool) {
4346
+ trace.toolResults?.push({
4347
+ toolCallId: toolCall.id,
4348
+ name: toolCall.name,
4349
+ input: toolCall.input,
4350
+ error: `\u672A\u77E5\u5DE5\u5177\uFF1A${toolCall.name}`,
4351
+ createdAt: nowIso5()
4352
+ });
4269
4353
  messages.push({
4270
4354
  role: "tool",
4271
4355
  toolCallId: toolCall.id,
@@ -4275,6 +4359,13 @@ async function runFeishuToolLoop(input2) {
4275
4359
  }
4276
4360
  try {
4277
4361
  const result = await executeFeishuTool(tool, toolCall.input);
4362
+ trace.toolResults?.push({
4363
+ toolCallId: toolCall.id,
4364
+ name: toolCall.name,
4365
+ input: toolCall.input,
4366
+ content: result,
4367
+ createdAt: nowIso5()
4368
+ });
4278
4369
  messages.push({
4279
4370
  role: "tool",
4280
4371
  toolCallId: toolCall.id,
@@ -4282,6 +4373,13 @@ async function runFeishuToolLoop(input2) {
4282
4373
  });
4283
4374
  } catch (error) {
4284
4375
  const message = error instanceof Error ? error.message : String(error);
4376
+ trace.toolResults?.push({
4377
+ toolCallId: toolCall.id,
4378
+ name: toolCall.name,
4379
+ input: toolCall.input,
4380
+ error: message,
4381
+ createdAt: nowIso5()
4382
+ });
4285
4383
  messages.push({
4286
4384
  role: "tool",
4287
4385
  toolCallId: toolCall.id,
@@ -4295,9 +4393,13 @@ async function runFeishuToolLoop(input2) {
4295
4393
  ...messages,
4296
4394
  { 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" }
4297
4395
  ]);
4298
- return salvageAnswer || "\u62B1\u6B49\uFF0C\u56DE\u7B54\u751F\u6210\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
4396
+ const answer = salvageAnswer || "\u62B1\u6B49\uFF0C\u56DE\u7B54\u751F\u6210\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
4397
+ 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() });
4398
+ return { answer, trace: finalizeTrace(trace, "answered", answer, startedAtMs) };
4299
4399
  } catch {
4300
- return "\u62B1\u6B49\uFF0C\u56DE\u7B54\u751F\u6210\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
4400
+ const answer = "\u62B1\u6B49\uFF0C\u56DE\u7B54\u751F\u6210\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
4401
+ trace.fallbacks?.push({ type: "answer_generation_failed", message: answer, createdAt: nowIso5() });
4402
+ return { answer, trace: finalizeTrace(trace, "failed", answer, startedAtMs) };
4301
4403
  }
4302
4404
  }
4303
4405
  function formatConversationContext(records) {
@@ -4417,7 +4519,7 @@ var FeishuQuestionHandler = class {
4417
4519
  const memberRepository = this.options.memberRepository ?? new FeishuMemberRepository(this.options.database);
4418
4520
  const memberPrompt = formatFeishuMemberPrompt(memberRepository.listByChat(decision.chatId));
4419
4521
  const conversationContext = formatConversationContext(qaLogs.listRecentByChat(decision.chatId, 6));
4420
- const answer = await runFeishuToolLoop({
4522
+ const result = await runFeishuToolLoop({
4421
4523
  question: decision.question,
4422
4524
  now,
4423
4525
  tools: allTools,
@@ -4429,27 +4531,38 @@ var FeishuQuestionHandler = class {
4429
4531
  chatId: decision.chatId,
4430
4532
  questionMessageId,
4431
4533
  question: decision.question,
4432
- answer,
4534
+ answer: result.answer,
4433
4535
  citations: [],
4434
4536
  retrievalDebug: {},
4537
+ trace: result.trace,
4435
4538
  status: "answered",
4436
4539
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
4437
4540
  });
4438
- await this.sendResponse(decision.chatId, questionMessageId, answer);
4541
+ await this.sendResponse(decision.chatId, questionMessageId, result.answer);
4439
4542
  } catch (error) {
4440
4543
  const message = error instanceof Error ? error.message : String(error);
4544
+ const failedAt = (/* @__PURE__ */ new Date()).toISOString();
4545
+ const failedAnswer = `\u6682\u65F6\u65E0\u6CD5\u56DE\u7B54\uFF1A${message}`;
4441
4546
  qaLogs.create({
4442
4547
  chatId: decision.chatId,
4443
4548
  questionMessageId,
4444
4549
  question: decision.question,
4445
- answer: `\u6682\u65F6\u65E0\u6CD5\u56DE\u7B54\uFF1A${message}`,
4550
+ answer: failedAnswer,
4446
4551
  citations: [],
4447
4552
  retrievalDebug: {},
4553
+ trace: {
4554
+ startedAt: now.toISOString(),
4555
+ completedAt: failedAt,
4556
+ durationMs: Math.max(0, Date.parse(failedAt) - now.getTime()),
4557
+ status: "failed",
4558
+ finalAnswer: failedAnswer,
4559
+ fallbacks: [{ type: "answer_generation_failed", message, createdAt: failedAt }]
4560
+ },
4448
4561
  status: "failed",
4449
4562
  error: message,
4450
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
4563
+ createdAt: failedAt
4451
4564
  });
4452
- await this.sendResponse(decision.chatId, questionMessageId, `\u6682\u65F6\u65E0\u6CD5\u56DE\u7B54\uFF1A${message}`);
4565
+ await this.sendResponse(decision.chatId, questionMessageId, failedAnswer);
4453
4566
  }
4454
4567
  return decision;
4455
4568
  } finally {
@@ -6343,12 +6456,15 @@ function buildHtml() {
6343
6456
  let allFileJobs = [];
6344
6457
  let allCronJobs = [];
6345
6458
  let allQaLogs = [];
6459
+ let selectedQaLogId = null;
6346
6460
  let statusData = null;
6347
6461
 
6348
6462
  function fmt(value) { return value == null || value === "" ? "-" : String(value); }
6349
6463
  function escapeHtml(value) {
6350
6464
  return fmt(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
6351
6465
  }
6466
+ function renderJson(value) { return '<pre style="white-space:pre-wrap;overflow:auto;max-height:320px;">' + escapeHtml(JSON.stringify(value, null, 2)) + '</pre>'; }
6467
+ function renderTextBlock(value) { return '<pre style="white-space:pre-wrap;overflow:auto;max-height:320px;">' + escapeHtml(value || "") + '</pre>'; }
6352
6468
  function isOpaqueId(value) { return /^(ou|oc|om|cli|on|un|uid)_?[a-z0-9]+/i.test(fmt(value)); }
6353
6469
  function formatDateTime(value) {
6354
6470
  var date = new Date(value);
@@ -6621,17 +6737,72 @@ function buildHtml() {
6621
6737
  for (var i = 0; i < allQaLogs.length; i++) {
6622
6738
  var item = allQaLogs[i];
6623
6739
  var citationCount = Array.isArray(item.citations) ? item.citations.length : 0;
6624
- var statusClass = item.status === 'success' ? 'tag-success' : 'tag-warning';
6740
+ var statusClass = item.status === 'answered' ? 'tag-success' : 'tag-warning';
6625
6741
  html += '<div class="qa-card"><div class="message-meta" style="margin-bottom:var(--space-sm);">' +
6626
6742
  '<span>' + escapeHtml(formatDateTime(item.createdAt)) + '</span>' +
6627
6743
  '<span class="tag ' + statusClass + '">' + escapeHtml(item.status) + '</span>' +
6628
- '<span>' + citationCount + ' \u6761\u5F15\u7528</span></div>' +
6744
+ '<span>' + citationCount + ' \u6761\u5F15\u7528</span>' +
6745
+ '<span class="tag ' + (item.hasTrace ? 'tag-info' : 'tag-warning') + '">' + (item.hasTrace ? '\u6709 trace' : '\u65E0 trace') + '</span></div>' +
6629
6746
  '<div class="qa-question">' + escapeHtml(item.question) + '</div>' +
6630
- '<div class="qa-answer">' + escapeHtml(item.answer) + '</div></div>';
6747
+ '<div class="qa-answer">' + escapeHtml(item.answer) + '</div>' +
6748
+ '<button class="btn btn-sm" style="margin-top:var(--space-sm);" data-view-qa-log="' + escapeHtml(item.id) + '">\u67E5\u770B\u8BE6\u60C5</button>' +
6749
+ '<div id="qa-detail-' + escapeHtml(item.id) + '" style="margin-top:var(--space-md);"></div></div>';
6631
6750
  }
6632
6751
  el.innerHTML = html;
6633
6752
  }
6634
6753
 
6754
+ async function showQaLogDetail(id) {
6755
+ selectedQaLogId = id;
6756
+ var container = document.getElementById("qa-detail-" + id);
6757
+ if (!container) return;
6758
+ container.innerHTML = '<div class="empty-state">\u6B63\u5728\u52A0\u8F7D\u95EE\u7B54\u8BE6\u60C5...</div>';
6759
+ try {
6760
+ var item = await fetchJson("/api/qa-logs/" + encodeURIComponent(id));
6761
+ renderQaLogDetail(item);
6762
+ } catch (error) {
6763
+ container.innerHTML = '<div class="empty-state">\u8BE6\u60C5\u52A0\u8F7D\u5931\u8D25\uFF1A' + escapeHtml(error instanceof Error ? error.message : String(error)) + '</div>';
6764
+ }
6765
+ }
6766
+
6767
+ function renderQaLogDetail(item) {
6768
+ var container = document.getElementById("qa-detail-" + item.id);
6769
+ if (!container) return;
6770
+ var trace = item.trace || {};
6771
+ var html = '<div class="content-panel" style="margin-top:var(--space-sm);background:rgba(255,255,255,0.03);">';
6772
+ html += '<h3 style="font-size:15px;margin-bottom:var(--space-sm);">\u95EE\u7B54\u8BE6\u60C5</h3>';
6773
+ 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>';
6774
+ if (item.error) html += '<div style="color:var(--danger);margin-bottom:var(--space-sm);">\u9519\u8BEF\uFF1A' + escapeHtml(item.error) + '</div>';
6775
+ html += '<div class="qa-question">' + escapeHtml(item.question) + '</div>';
6776
+ html += '<div class="qa-answer" style="margin-bottom:var(--space-md);">' + escapeHtml(item.answer) + '</div>';
6777
+ if (!item.hasTrace) {
6778
+ 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>';
6779
+ container.innerHTML = html;
6780
+ return;
6781
+ }
6782
+ var turns = trace.modelTurns || [];
6783
+ html += '<h4 style="margin:var(--space-md) 0 var(--space-sm);">Reasoning</h4>';
6784
+ if (turns.length === 0) html += '<div class="empty-state">\u65E0 reasoningContent</div>';
6785
+ for (var i = 0; i < turns.length; i++) {
6786
+ 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>';
6787
+ }
6788
+ html += '<h4 style="margin:var(--space-md) 0 var(--space-sm);">\u6A21\u578B\u8F6E\u6B21\u4E0E\u5DE5\u5177\u8C03\u7528</h4>';
6789
+ for (var j = 0; j < turns.length; j++) {
6790
+ 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>';
6791
+ }
6792
+ html += '<h4 style="margin:var(--space-md) 0 var(--space-sm);">\u5DE5\u5177\u7ED3\u679C</h4>';
6793
+ var toolResults = trace.toolResults || [];
6794
+ if (toolResults.length === 0) html += '<div class="empty-state">\u6CA1\u6709\u5DE5\u5177\u7ED3\u679C\u3002</div>';
6795
+ for (var k = 0; k < toolResults.length; k++) {
6796
+ 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>';
6797
+ }
6798
+ html += '<h4 style="margin:var(--space-md) 0 var(--space-sm);">\u5F15\u7528\u4E0E\u68C0\u7D22</h4>' + renderJson({ citations: item.citations || [], retrievalDebug: item.retrievalDebug || {} });
6799
+ html += '<h4 style="margin:var(--space-md) 0 var(--space-sm);">Fallback</h4>';
6800
+ var fallbacks = trace.fallbacks || [];
6801
+ html += fallbacks.length === 0 ? '<div class="empty-state">\u6CA1\u6709 fallback\u3002</div>' : renderJson(fallbacks);
6802
+ html += '</div>';
6803
+ container.innerHTML = html;
6804
+ }
6805
+
6635
6806
  function renderSettings(status) {
6636
6807
  var el = document.getElementById("settings-config");
6637
6808
  var html = '<h3 style="font-size:16px;font-weight:600;margin-bottom:var(--space-md);">\u7CFB\u7EDF\u914D\u7F6E</h3>';
@@ -6666,7 +6837,10 @@ function buildHtml() {
6666
6837
  if (currentView === "episodes") renderEpisodesView();
6667
6838
  if (currentView === "files") renderFilesView();
6668
6839
  if (currentView === "tasks") renderTasksView();
6669
- if (currentView === "qa-logs") renderQaLogsView();
6840
+ if (currentView === "qa-logs") {
6841
+ renderQaLogsView();
6842
+ if (selectedQaLogId) void showQaLogDetail(selectedQaLogId);
6843
+ }
6670
6844
  }
6671
6845
 
6672
6846
  async function processNow() {
@@ -6688,6 +6862,11 @@ function buildHtml() {
6688
6862
  document.addEventListener("click", async function(event) {
6689
6863
  var target = event.target;
6690
6864
  if (!(target instanceof HTMLElement)) return;
6865
+ var qaLogId = target.dataset.viewQaLog;
6866
+ if (qaLogId) {
6867
+ void showQaLogDetail(qaLogId);
6868
+ return;
6869
+ }
6691
6870
  var id = target.dataset.deleteCronJob;
6692
6871
  if (!id) return;
6693
6872
  target.disabled = true;
@@ -6731,6 +6910,10 @@ function parseCookies(header) {
6731
6910
  function isAuthorizedWebAction(request, token) {
6732
6911
  return parseCookies(request.headers.cookie).chattercatcher_web_token === token;
6733
6912
  }
6913
+ function toQaLogListItem(log) {
6914
+ const { trace: _trace, ...item } = log;
6915
+ return item;
6916
+ }
6734
6917
  function createWebApp(config, options = {}) {
6735
6918
  const app = Fastify({ logger: false });
6736
6919
  const database = openDatabase(config);
@@ -6809,9 +6992,18 @@ function createWebApp(config, options = {}) {
6809
6992
  app.get("/api/qa-logs", async (request) => {
6810
6993
  const limit = parseLimit(request.query.limit, 20, 100);
6811
6994
  return {
6812
- items: qaLogs.listRecent(limit)
6995
+ items: qaLogs.listRecent(limit).map(toQaLogListItem)
6813
6996
  };
6814
6997
  });
6998
+ app.get("/api/qa-logs/:id", async (request, reply) => {
6999
+ const id = request.params.id;
7000
+ const log = qaLogs.getById(id);
7001
+ if (!log) {
7002
+ reply.code(404);
7003
+ return { ok: false, message: "\u6CA1\u6709\u627E\u5230\u95EE\u7B54\u65E5\u5FD7\u3002" };
7004
+ }
7005
+ return log;
7006
+ });
6815
7007
  app.get("/api/cron-jobs", async (request) => {
6816
7008
  const limit = parseLimit(request.query.limit, 50, 200);
6817
7009
  return {