chattercatcher 0.2.4 → 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 {
@@ -4327,146 +4440,13 @@ function formatTextWithMentions(text, options) {
4327
4440
  const prefix = mentions.map((mention) => `<at user_id="${escapeAtText(mention.openId)}">${escapeAtText(mention.name)}</at>`).join(" ");
4328
4441
  return `${prefix} ${text}`.trim();
4329
4442
  }
4330
- function findMarkdownLinkEnd(text, start) {
4331
- let depth = 0;
4332
- for (let index = start; index < text.length; index += 1) {
4333
- const char = text[index];
4334
- if (char === "(") {
4335
- depth += 1;
4336
- } else if (char === ")") {
4337
- if (depth === 0) return index;
4338
- depth -= 1;
4339
- }
4340
- }
4341
- return -1;
4342
- }
4343
- function stripInlineMarkdown(text) {
4344
- let output = "";
4345
- let index = 0;
4346
- while (index < text.length) {
4347
- const linkStart = text.indexOf("[", index);
4348
- const boldStarStart = text.indexOf("**", index);
4349
- const boldUnderscoreStart = text.indexOf("__", index);
4350
- const candidates = [linkStart, boldStarStart, boldUnderscoreStart].filter((value) => value >= 0);
4351
- const next = candidates.length ? Math.min(...candidates) : -1;
4352
- if (next < 0) {
4353
- output += text.slice(index);
4354
- break;
4355
- }
4356
- output += text.slice(index, next);
4357
- if (next === linkStart) {
4358
- const labelEnd = text.indexOf("](", next);
4359
- if (labelEnd > next) {
4360
- const hrefStart = labelEnd + 2;
4361
- const hrefEnd = findMarkdownLinkEnd(text, hrefStart);
4362
- const href = hrefEnd >= 0 ? text.slice(hrefStart, hrefEnd) : "";
4363
- if (hrefEnd >= 0 && /^https?:\/\/\S+$/.test(href)) {
4364
- output += `${text.slice(next + 1, labelEnd)} ${href}`;
4365
- index = hrefEnd + 1;
4366
- continue;
4367
- }
4368
- }
4369
- output += text[next];
4370
- index = next + 1;
4371
- continue;
4372
- }
4373
- const marker = next === boldStarStart ? "**" : "__";
4374
- const close = text.indexOf(marker, next + marker.length);
4375
- if (close > next + marker.length) {
4376
- output += text.slice(next + marker.length, close);
4377
- index = close + marker.length;
4378
- continue;
4379
- }
4380
- output += marker;
4381
- index = next + marker.length;
4382
- }
4383
- return output;
4384
- }
4385
- function parseInline(text) {
4386
- return [{ tag: "text", text: stripInlineMarkdown(text) || " " }];
4387
- }
4388
- function pushParagraph(content, lines) {
4389
- if (lines.length === 0) return;
4390
- content.push(parseInline(lines.join("\n")));
4391
- lines.length = 0;
4392
- }
4393
- function parseMarkdownBlocks(markdown) {
4394
- if (!markdown.trim()) {
4395
- return [[{ tag: "text", text: " " }]];
4396
- }
4397
- const content = [];
4398
- const paragraph = [];
4399
- const code = [];
4400
- let inCodeBlock = false;
4401
- for (const rawLine of markdown.replace(/\r\n/g, "\n").split("\n")) {
4402
- const line = rawLine.trimEnd();
4403
- if (line.startsWith("```")) {
4404
- if (inCodeBlock) {
4405
- content.push([{ tag: "text", text: `\`\`\`
4406
- ${code.join("\n")}
4407
- \`\`\`` }]);
4408
- code.length = 0;
4409
- inCodeBlock = false;
4410
- } else {
4411
- pushParagraph(content, paragraph);
4412
- inCodeBlock = true;
4413
- }
4414
- continue;
4415
- }
4416
- if (inCodeBlock) {
4417
- code.push(rawLine);
4418
- continue;
4419
- }
4420
- if (!line.trim()) {
4421
- pushParagraph(content, paragraph);
4422
- continue;
4423
- }
4424
- const heading = line.match(/^#{1,6}\s+(.+)$/);
4425
- if (heading) {
4426
- pushParagraph(content, paragraph);
4427
- content.push([{ tag: "text", text: stripInlineMarkdown(heading[1]) || " " }]);
4428
- continue;
4429
- }
4430
- const unordered = line.match(/^[-*]\s+(.+)$/);
4431
- if (unordered) {
4432
- pushParagraph(content, paragraph);
4433
- content.push(parseInline(`\u2022 ${unordered[1]}`));
4434
- continue;
4435
- }
4436
- const ordered = line.match(/^(\d+)\.\s+(.+)$/);
4437
- if (ordered) {
4438
- pushParagraph(content, paragraph);
4439
- content.push(parseInline(`${ordered[1]}. ${ordered[2]}`));
4440
- continue;
4441
- }
4442
- paragraph.push(line);
4443
- }
4444
- if (inCodeBlock) {
4445
- content.push([{ tag: "text", text: `\`\`\`
4446
- ${code.join("\n")}` }]);
4447
- }
4448
- pushParagraph(content, paragraph);
4449
- return content.length ? content : [[{ tag: "text", text: markdown }]];
4443
+ function buildMarkdownText(markdown, options) {
4444
+ return formatTextWithMentions(markdown.trim() || " ", options);
4450
4445
  }
4451
4446
  function buildFeishuPostContent(markdown, options) {
4452
- const content = parseMarkdownBlocks(markdown);
4453
- const mentions = options?.mentions ?? [];
4454
- if (mentions.length) {
4455
- const firstLine = content[0] ?? [];
4456
- const firstText = firstLine[0];
4457
- const prefix = mentions.map((mention) => `@${mention.name}`).join(" ");
4458
- if (firstText?.tag === "text") {
4459
- content[0] = [{ tag: "text", text: `${prefix} ${firstText.text}` }, ...firstLine.slice(1)];
4460
- } else {
4461
- content[0] = [{ tag: "text", text: `${prefix} ` }, ...firstLine];
4462
- }
4463
- }
4464
4447
  return {
4465
- post: {
4466
- zh_cn: {
4467
- title: "",
4468
- content
4469
- }
4448
+ zh_cn: {
4449
+ content: [[{ tag: "md", text: buildMarkdownText(markdown, options) }]]
4470
4450
  }
4471
4451
  };
4472
4452
  }
@@ -6050,12 +6030,15 @@ function buildHtml() {
6050
6030
  let allFileJobs = [];
6051
6031
  let allCronJobs = [];
6052
6032
  let allQaLogs = [];
6033
+ let selectedQaLogId = null;
6053
6034
  let statusData = null;
6054
6035
 
6055
6036
  function fmt(value) { return value == null || value === "" ? "-" : String(value); }
6056
6037
  function escapeHtml(value) {
6057
6038
  return fmt(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
6058
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>'; }
6059
6042
  function isOpaqueId(value) { return /^(ou|oc|om|cli|on|un|uid)_?[a-z0-9]+/i.test(fmt(value)); }
6060
6043
  function formatDateTime(value) {
6061
6044
  var date = new Date(value);
@@ -6328,17 +6311,72 @@ function buildHtml() {
6328
6311
  for (var i = 0; i < allQaLogs.length; i++) {
6329
6312
  var item = allQaLogs[i];
6330
6313
  var citationCount = Array.isArray(item.citations) ? item.citations.length : 0;
6331
- var statusClass = item.status === 'success' ? 'tag-success' : 'tag-warning';
6314
+ var statusClass = item.status === 'answered' ? 'tag-success' : 'tag-warning';
6332
6315
  html += '<div class="qa-card"><div class="message-meta" style="margin-bottom:var(--space-sm);">' +
6333
6316
  '<span>' + escapeHtml(formatDateTime(item.createdAt)) + '</span>' +
6334
6317
  '<span class="tag ' + statusClass + '">' + escapeHtml(item.status) + '</span>' +
6335
- '<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>' +
6336
6320
  '<div class="qa-question">' + escapeHtml(item.question) + '</div>' +
6337
- '<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>';
6338
6324
  }
6339
6325
  el.innerHTML = html;
6340
6326
  }
6341
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
+
6342
6380
  function renderSettings(status) {
6343
6381
  var el = document.getElementById("settings-config");
6344
6382
  var html = '<h3 style="font-size:16px;font-weight:600;margin-bottom:var(--space-md);">\u7CFB\u7EDF\u914D\u7F6E</h3>';
@@ -6373,7 +6411,10 @@ function buildHtml() {
6373
6411
  if (currentView === "episodes") renderEpisodesView();
6374
6412
  if (currentView === "files") renderFilesView();
6375
6413
  if (currentView === "tasks") renderTasksView();
6376
- if (currentView === "qa-logs") renderQaLogsView();
6414
+ if (currentView === "qa-logs") {
6415
+ renderQaLogsView();
6416
+ if (selectedQaLogId) void showQaLogDetail(selectedQaLogId);
6417
+ }
6377
6418
  }
6378
6419
 
6379
6420
  async function processNow() {
@@ -6395,6 +6436,11 @@ function buildHtml() {
6395
6436
  document.addEventListener("click", async function(event) {
6396
6437
  var target = event.target;
6397
6438
  if (!(target instanceof HTMLElement)) return;
6439
+ var qaLogId = target.dataset.viewQaLog;
6440
+ if (qaLogId) {
6441
+ void showQaLogDetail(qaLogId);
6442
+ return;
6443
+ }
6398
6444
  var id = target.dataset.deleteCronJob;
6399
6445
  if (!id) return;
6400
6446
  target.disabled = true;
@@ -6438,6 +6484,10 @@ function parseCookies(header) {
6438
6484
  function isAuthorizedWebAction(request, token) {
6439
6485
  return parseCookies(request.headers.cookie).chattercatcher_web_token === token;
6440
6486
  }
6487
+ function toQaLogListItem(log) {
6488
+ const { trace: _trace, ...item } = log;
6489
+ return item;
6490
+ }
6441
6491
  function createWebApp(config, options = {}) {
6442
6492
  const app = Fastify({ logger: false });
6443
6493
  const database = openDatabase(config);
@@ -6516,9 +6566,18 @@ function createWebApp(config, options = {}) {
6516
6566
  app.get("/api/qa-logs", async (request) => {
6517
6567
  const limit = parseLimit(request.query.limit, 20, 100);
6518
6568
  return {
6519
- items: qaLogs.listRecent(limit)
6569
+ items: qaLogs.listRecent(limit).map(toQaLogListItem)
6520
6570
  };
6521
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
+ });
6522
6581
  app.get("/api/cron-jobs", async (request) => {
6523
6582
  const limit = parseLimit(request.query.limit, 50, 200);
6524
6583
  return {