feat(y-log): 问答按 LLM 调用各记一条 + 全量补齐各 API 操作埋点

- 问答日志从「每轮 onFinish 一条」改为 useThreadStream onLangChainEvent
  监听 on_chat_model_end,每次大模型调用结束各上报一条(含子代理)
- 广覆盖各 API client 成功路径:查看(view)/查询(search)/新增(create)/
  编辑(edit)/删除(delete)/启停(enabled·disabled)/导入导出,补齐此前
  整模块零埋点与漏掉的单条详情 GET;跳过高频 typeahead/轮询/bootstrap

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AdministratorandClaude Opus 4.8 committed 2026-06-23 20:42:54 +08:00
1 parent 320f2ef814
commit df9c59e21c
35 files changed
+1569 -224

No files matched your search

+101
View File
@@ -15,6 +15,7 @@ import { getStoredAuth } from "../auth";
import { rememberAskTime } from "../chat-timestamps";
import { getBackendBaseURL, getLangGraphBaseURL } from "../config";
import { useI18n } from "../i18n/hooks";
import { OptType, writeYLog } from "../log/y-log";
import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings";
import { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings } from "../settings";
@@ -231,6 +232,84 @@ function buildCurrentUserLabel(): string {
return userId ? userId : "未知用户";
}
/**
* 从 `on_chat_model_end` 事件的 output 里尽力取出模型输出文本。
*
* output 形态可能是 AIMessage(`{ content: string | parts[] }`),也可能是
* LangChain 的 `{ generations: [[{ text, message }]] }`。全部容错,取不到返回空串。
*/
function textFromChatModelOutput(output: unknown): string {
if (output == null) return "";
if (typeof output === "string") return output;
if (typeof output !== "object") return String(output);
const o = output as Record<string, unknown>;
if ("content" in o) {
const c = o.content;
if (typeof c === "string") return c;
if (Array.isArray(c)) {
const parts = c
.map((p) =>
p && typeof p === "object" && "text" in p
? String((p as { text?: unknown }).text ?? "")
: "",
)
.filter(Boolean);
if (parts.length) return parts.join("");
}
}
if (Array.isArray(o.generations)) {
const texts: string[] = [];
const collect = (gen: unknown) => {
if (Array.isArray(gen)) {
gen.forEach(collect);
return;
}
if (gen && typeof gen === "object") {
const gg = gen as Record<string, unknown>;
if (typeof gg.text === "string" && gg.text) texts.push(gg.text);
else if (gg.message) {
const t = textFromChatModelOutput(gg.message);
if (t) texts.push(t);
}
}
};
collect(o.generations);
if (texts.length) return texts.join("");
}
return "";
}
/**
* 从 `on_chat_model_*` 事件的 input 里尽力取出最后一条 user/human 文本,
* 作为本次大模型调用的「问题」。input 可能是 `{ messages: BaseMessage[] | BaseMessage[][] }`。
*/
function lastHumanFromChatModelInput(input: unknown): string {
try {
const raw =
input && typeof input === "object" && "messages" in input
? (input as { messages?: unknown }).messages
: input;
const flat: unknown[] = [];
const push = (m: unknown) => {
if (Array.isArray(m)) m.forEach(push);
else if (m != null) flat.push(m);
};
push(raw);
for (let i = flat.length - 1; i >= 0; i--) {
const m = flat[i];
if (!m || typeof m !== "object") continue;
const type = (m as Record<string, unknown>).type ?? (m as Record<string, unknown>).role;
if (type === "human" || type === "user") {
const text = textFromChatModelOutput(m);
if (text) return text;
}
}
} catch {
/* 取不到忽略 */
}
return "";
}
export function useThreadStream({
threadId,
context,
@@ -333,6 +412,28 @@ export function useThreadStream({
data: event.data,
});
}
// 操作日志(Y-Log):每一次大模型调用结束都各上报一条(一次 LLM 调用 = 一条),
// 让问答日志尽量「多、全」——一个问答轮次里 lead agent / 子代理的每次模型调用
// 都会落一条。门控与失败静默都在 writeYLog 内部处理,绝不影响问答。
if (event.event === "on_chat_model_end") {
try {
const data = (event.data ?? {}) as {
input?: unknown;
output?: unknown;
};
const answer = textFromChatModelOutput(data.output);
const question = lastHumanFromChatModelInput(data.input);
writeYLog({
optType: OptType.other,
optDetail: "B1-CM助手-智能问答",
url: `/api/threads/${threadIdRef.current ?? ""}/runs/stream`,
reqParam: { model: event.name ?? "", question },
returnParam: { data: answer },
});
} catch {
/* 上报绝不影响问答 */
}
}
},
onUpdateEvent(data) {
const updates: Array<Partial<AgentThreadState> | null> = Object.values(