import type { Message } from "@langchain/langgraph-sdk"; import { useCallback, useEffect, useRef, useState } from "react"; import { readRuntimeEnv } from "@/env"; import { createClientId } from "@/lib/client-id"; const STORAGE_PREFIX = "workspace.multi-round-rag.v2."; export type MultiRoundRagRetrieval = { id?: string; title: string; content: string; source?: string; time?: string; recUuid?: string; url?: string; type?: string; keywords?: string; taskDirection?: string; platform?: string; fans?: number; comment_count?: number; message_id?: string; hot_num?: number; }; export type MultiRoundRagRound = { id: string; title: string; thoughtMarkdown: string; queryTitle?: string; query: string; queryLoading?: boolean; queryShow: boolean; textidContent?: string; extractInfo?: string; extractContent?: string; extractLoading?: boolean; retrievals: MultiRoundRagRetrieval[]; }; export type MultiRoundRagTrace = { title?: string; rounds: MultiRoundRagRound[]; finalAnswerMarkdown: string; thinkingComplete?: boolean; }; export type MultiRoundRagSource = { title: string; type: string; snippet: string; source?: string; time?: string; url?: string; content?: string; recUuid?: string; }; export type MultiRoundRagChatMessage = { id: string; role: "user" | "assistant"; content: string; timestamp: string; thinkingTrace?: MultiRoundRagTrace; sources?: MultiRoundRagSource[]; }; type MultiRoundEvent = { event?: unknown; data?: unknown; }; function storageKey(threadId: string) { return `${STORAGE_PREFIX}${threadId}`; } function readStoredMessages(threadId: string): MultiRoundRagChatMessage[] { if (typeof window === "undefined" || !threadId) return []; try { const raw = window.sessionStorage.getItem(storageKey(threadId)); const parsed: unknown = raw ? JSON.parse(raw) : []; if (!Array.isArray(parsed)) return []; const messages = (parsed as Array) .filter((message) => !message.isDemo) .map(({ isDemo: _isDemo, ...message }) => message); if (messages.length !== parsed.length) { writeStoredMessages(threadId, messages); } return messages; } catch { return []; } } function writeStoredMessages(threadId: string, messages: MultiRoundRagChatMessage[]) { if (typeof window === "undefined" || !threadId) return; try { window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages)); } catch { // Storage quota failures must not interrupt a running answer. } } function createId(prefix: string) { return createClientId(prefix); } function isAbortError(error: unknown) { return error instanceof DOMException && error.name === "AbortError"; } function asRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; } function asText(value: unknown): string { if (typeof value === "string") return value; if (typeof value === "number" && Number.isFinite(value)) return String(value); return ""; } function parseJson(value: unknown): unknown { if (typeof value !== "string") return value; try { return JSON.parse(value) as unknown; } catch { return value; } } function normalizeToolName(name: string) { return name.trim().replace(/-/g, "_"); } function platformTypeChange(platform: string) { if (platform.includes("fb")) return "facebook"; if (platform.includes("x")) return "x"; if (platform.includes("in")) return "instagram"; if (platform.includes("re")) return "reddit"; if (platform.includes("th")) return "threads"; return platform || "facebook"; } function createEmptyRound(title: string, thoughtMarkdown = ""): MultiRoundRagRound { return { id: createId("multi-rag-round"), title, thoughtMarkdown, query: "", queryLoading: false, queryShow: false, retrievals: [], }; } function createEmptyTrace(): MultiRoundRagTrace { return { title: "多轮检索思考过程", rounds: [createEmptyRound("正在准备检索")], thinkingComplete: false, finalAnswerMarkdown: "", }; } function cloneTrace(trace: MultiRoundRagTrace): MultiRoundRagTrace { return { ...trace, rounds: trace.rounds.map((round) => ({ ...round, retrievals: [...round.retrievals], })), }; } function lastRound(trace: MultiRoundRagTrace): MultiRoundRagRound { const current = trace.rounds.at(-1); if (current) return current; const next = createEmptyTrace().rounds[0]!; trace.rounds.push(next); return next; } function eventsFromLine(line: string): MultiRoundEvent[] { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith(":")) return []; let payload = trimmed; if (/^data:\s?/i.test(trimmed)) { payload = trimmed.replace(/^data:\s?/i, "").trim(); } else if (/^(id|event|retry):/i.test(trimmed)) { return []; } if (!payload) return []; if (payload === "[DONE]") return [{ event: "done" }]; if (payload.startsWith("{") || payload.startsWith("[")) { try { return expandStreamEvents(JSON.parse(payload)); } catch { return []; } } return expandStreamEvents(payload); } function asStreamEvent(value: unknown): MultiRoundEvent | null { const parsed = parseJson(value); if (Array.isArray(parsed)) { return null; } const record = asRecord(parsed); if (!record) { if (typeof parsed === "string" && parsed.trim() && !/^[{[]/.test(parsed.trim())) { return { event: "message", data: parsed }; } return null; } const eventName = asText(record.event ?? record.type ?? record.event_type); if (eventName) { return { event: eventName, data: record.data !== undefined ? record.data : (record.payload ?? record), }; } if (record.tool_name || record.name) { return { event: "tool_call", data: record }; } return record; } function expandStreamEvents(value: unknown): MultiRoundEvent[] { const parsed = parseJson(value); if (Array.isArray(parsed)) { return parsed.flatMap((item) => expandStreamEvents(item)); } const event = asStreamEvent(parsed); return event ? [event] : []; } function extractJsonObjects(raw: string): { objects: string[]; rest: string } { const objects: string[] = []; let depth = 0; let stringQuote: '"' | "'" | null = null; let escaped = false; let start = -1; for (let index = 0; index < raw.length; index += 1) { const char = raw[index]!; if (stringQuote) { if (escaped) escaped = false; else if (char === "\\") escaped = true; else if (char === stringQuote) stringQuote = null; continue; } if (char === '"' || char === "'") { stringQuote = char; continue; } if (char === "{") { if (depth === 0) start = index; depth += 1; } else if (char === "}") { depth -= 1; if (depth === 0 && start !== -1) { objects.push(raw.slice(start, index + 1)); start = -1; } } } return depth > 0 && start !== -1 ? { objects, rest: raw.slice(start) } : { objects, rest: "" }; } function pullStreamEvents(buffer: string): { events: MultiRoundEvent[]; rest: string } { const events: MultiRoundEvent[] = []; const lines = buffer.split(/\r?\n/); const restLine = lines.pop() ?? ""; for (const line of lines) { events.push(...eventsFromLine(line)); } if (events.length > 0) { return { events, rest: restLine }; } const extracted = extractJsonObjects(buffer); for (const jsonText of extracted.objects) { events.push(...expandStreamEvents(parseJson(jsonText))); } return { events, rest: extracted.rest || restLine }; } function mapReferenceToSource(item: unknown, index: number): MultiRoundRagSource { const source = asRecord(item) ?? {}; const content = asText(source.content ?? source.m_content ?? source.snippet ?? source.text); const rawUrl = asText(source.url ?? source.link ?? source.href); const recUuid = asText(source.recUuid ?? source.rec_uuid ?? source.uuid); return { title: asText(source.title ?? source.m_title) || `参考资料 ${index + 1}`, type: asText(source.type) || "参考资料", snippet: asText(source.snippet) || content, content, source: asText(source.source ?? source.source1 ?? source.m_source ?? source.platform) || undefined, time: asText(source.time ?? source.m_publish ?? source.publish_time ?? source.date) || undefined, url: /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined, recUuid: recUuid || undefined, }; } function parseResult(raw: unknown): T | null { const parsed = parseJson(raw); return parsed == null ? null : (parsed as T); } function extractInfoText(result: unknown): string { const parsed = parseJson(result); if (typeof parsed === "string") return parsed; const record = asRecord(parsed); return asText( record?.content ?? record?.text ?? record?.extracted ?? record?.info ?? record?.summary ?? record?.result, ); } function mergeThink(existing: string, incoming: string) { if (!incoming) return existing; if (!existing) return incoming; if (incoming.startsWith(existing)) return incoming; if (existing.endsWith(incoming)) return existing; return `${existing}${incoming}`; } function extractThinkContent(chatText: string) { const closed = chatText.match(/([\s\S]*?)<\/think>/i); if (closed?.[1] != null) return closed[1].trim(); const open = chatText.match(/([\s\S]*)$/i); if (open?.[1] != null) return open[1].trim(); return chatText.replace(/<\/?think>/gi, "").trim(); } /** * GeneralQA `handleToolCall` 的独立实现:按 tool_name 把 SSE 工具事件填进最近一轮 trace。 * `show_text` / `show-text` 负责思考与最终回答;检索类工具先写检索词,再把 result 渲染成卡片。 */ export function applyMultiRoundToolCall(trace: MultiRoundRagTrace, toolValue: unknown) { const tool = asRecord(toolValue); if (!tool) return; const toolName = normalizeToolName(asText(tool.tool_name ?? tool.name)); const input = asRecord(parseJson(tool.tool_input ?? tool.input ?? tool.arguments)) ?? {}; const round = lastRound(trace); switch (toolName) { case "show_text": { const chatText = asText(input.text ?? input.content); if (!chatText) return; if (chatText.includes("")) { const thinkContent = extractThinkContent(chatText); const firstRound = trace.rounds[0]; const stillOnFirstThink = firstRound?.title === "正在准备检索" || ((!round.query && round.retrievals.length === 0) && (round.title === "思考过程" || round.title === "思考中" || round.title === "正在准备检索")); if (stillOnFirstThink && firstRound) { firstRound.title = "思考过程"; firstRound.thoughtMarkdown = mergeThink(firstRound.thoughtMarkdown, thinkContent); } else if (!round.query && round.retrievals.length === 0) { round.title = round.title === "正在准备检索" ? "思考过程" : round.title; round.thoughtMarkdown = mergeThink(round.thoughtMarkdown, thinkContent); } else { round.queryLoading = false; round.queryShow = Boolean(round.query) || round.retrievals.length > 0; trace.rounds.push(createEmptyRound("思考中", thinkContent)); } } else { round.queryLoading = false; trace.finalAnswerMarkdown += chatText; trace.thinkingComplete = true; } return; } case "tavily_search": case "local_search": { if (input.result) { const result = parseResult<{ query?: string; results?: Array<{ title?: string; content?: string; url?: string }>; }>(input.result); round.query = result?.query || round.query; round.queryLoading = false; round.title = toolName === "local_search" ? "本地知识检索" : "联网检索"; round.retrievals = (result?.results || []).map((item, idx) => ({ id: `${toolName}-${idx}`, title: item.title || "未命名结果", content: item.content || "", source: item.url || "", url: item.url, })); } return; } case "google_search": { if (input.result) { const result = parseResult<{ organic?: Array<{ title?: string; content?: string; url?: string; recUuid?: string; publish_time?: string; }>; }>(input.result); round.queryLoading = false; round.title = "思考过程"; round.retrievals = (result?.organic || []).map((item) => ({ id: item.recUuid, title: item.title || "未命名结果", content: item.content || "", source: item.recUuid || "", time: item.publish_time, recUuid: item.recUuid, url: item.url, type: "more", })); } else if (asText(input.q)) { round.query = asText(input.q); round.queryTitle = "检索词"; round.queryShow = true; round.queryLoading = true; } return; } case "getTaskLikeTop": { if (input.result) { const result = parseResult<{ data?: Array<{ taskName?: string; overview?: string; id?: number; keywords?: string; taskDirection?: string; taskStartDate?: string; taskEndDate?: string; }>; }>(input.result); round.queryLoading = false; round.title = "思考过程"; round.retrievals = (result?.data || []).map((item) => ({ id: String(item.id ?? ""), title: item.taskName || "未命名结果", content: item.overview || "", keywords: item.keywords, taskDirection: item.taskDirection, time: `${item.taskStartDate ?? ""}-${item.taskEndDate ?? ""}`, type: "task", })); } else if (asText(input.keyword)) { round.query = asText(input.keyword); round.queryShow = true; round.queryLoading = true; } return; } case "search_user_profile": { round.title = "靶标平台画像库"; if (input.result) { const result = parseResult< Array<{ user_name?: string; user_id?: string; page_content?: string; 平台?: string; 粉丝数?: number; }> >(input.result); round.queryLoading = false; round.retrievals = (result || []).map((item) => ({ id: item.user_id, title: item.user_name || item.user_id || "-", content: item.page_content || "", platform: platformTypeChange(asText(item.平台)), fans: item.粉丝数 || 0, type: "hx", })); } else if (asText(input.keyword) || asText(input.query) || asText(input.user_fig)) { round.queryTitle = "检索词"; round.query = asText(input.keyword) || asText(input.query) || asText(input.user_fig); round.queryShow = true; round.queryLoading = true; } return; } case "get_taiwan_news": case "search_keyword_taiwan_news": case "get_world_news": case "get_america_news": case "get_root_messaage_comment": { const titles: Record = { get_taiwan_news: "台湾热点新闻", search_keyword_taiwan_news: "台湾热点新闻", get_world_news: "世界热点新闻", get_america_news: "美国热点新闻", get_root_messaage_comment: "获取该消息的全部评论", }; const types: Record = { get_taiwan_news: "taiwan_news", search_keyword_taiwan_news: "taiwan_news", get_world_news: "world_news", get_america_news: "america_news", get_root_messaage_comment: "messaage_comment", }; round.title = titles[toolName] ?? toolName; if (input.result) { const result = parseResult< Array<{ user_name?: string; user_id?: string; content?: string; publish_time?: string; message_id?: string; platform?: string; comment_count?: number; }> >(input.result); round.queryLoading = false; round.retrievals = (result || []).map((item) => ({ id: item.user_id, title: item.user_name || item.user_id || "-", content: item.content || "", platform: platformTypeChange(asText(item.platform)), comment_count: item.comment_count, message_id: item.message_id, time: item.publish_time, type: types[toolName], })); } else if (asText(input.keyword) || asText(input.query) || asText(input.platform)) { round.queryTitle = toolName === "get_america_news" ? "查询美国热点新闻" : toolName === "get_world_news" ? "搜索世界新闻" : toolName === "get_root_messaage_comment" ? "查找平台" : "搜索台湾新闻"; round.query = asText(input.keyword) || asText(input.query) || platformTypeChange(asText(input.platform)); round.queryShow = true; round.queryLoading = true; } return; } case "search_user_behavior": { round.title = "查询该用户的历史行为"; if (input.result) { const result = parseResult(input.result) ?? []; round.queryLoading = false; round.retrievals = result.slice(0, 10).map((item) => ({ id: asText(item), title: asText(item), content: asText(item), type: "user_behavior", })); } else if (asText(input.keyword) || asText(input.query) || asText(input.platform)) { round.queryTitle = "查找平台"; round.query = asText(input.keyword) || asText(input.query) || platformTypeChange(asText(input.platform)); round.queryShow = true; round.queryLoading = true; } return; } case "get_time_data": { round.title = "态势新闻搜索"; if (input.result) { const result = parseResult< Array<{ title?: string; textid?: string; hot_num?: number; date?: string; content?: string }> >(input.result); round.queryLoading = false; round.queryTitle = "查询态势新闻"; round.retrievals = (result || []).map((item) => ({ id: item.textid, title: item.title || "未命名结果", content: item.content || "", time: item.date, hot_num: item.hot_num, type: "get_time_data", })); } else if (asText(input.keyword) || asText(input.query) || asText(input.s) || asText(input.e)) { round.query = [asText(input.s), asText(input.e)].filter(Boolean).join(" - "); round.queryShow = true; round.queryLoading = true; } return; } case "get_textid_content": { round.title = "获取新闻详细内容"; if (input.result) { const result = parseResult<{ content?: string }>(input.result); round.queryLoading = false; round.textidContent = asText(result?.content); } return; } case "scrape_and_extract_info": { const info = asText(input.info_to_extract ?? input.infoToExtract); if (info) { round.extractInfo = info; round.extractLoading = !input.result; } if (input.result) { round.extractLoading = false; round.extractContent = extractInfoText(input.result); } return; } default: return; } } function consumeToolCallPayload(trace: MultiRoundRagTrace, payload: unknown) { const parsed = parseJson(payload); if (Array.isArray(parsed)) { for (const tool of parsed) applyMultiRoundToolCall(trace, tool); return; } const record = asRecord(parsed); if (record?.tool_name || record?.name) { applyMultiRoundToolCall(trace, record); return; } if (record?.data != null) { consumeToolCallPayload(trace, record.data); return; } applyMultiRoundToolCall(trace, parsed); } function textFromEventData(value: unknown): string { const parsed = parseJson(value); if (typeof parsed === "string") return parsed; const record = asRecord(parsed); return asText(record?.text ?? record?.content ?? record?.message ?? record?.answer ?? record?.output); } function applyStreamEvent( trace: MultiRoundRagTrace, event: MultiRoundEvent, onSources?: (sources: MultiRoundRagSource[]) => void, onError?: (message: string) => void, ) { const eventType = normalizeToolName(asText(event.event)); const data = event.data; if (eventType === "tool_call") { consumeToolCallPayload(trace, data); } else if (eventType === "message") { const text = textFromEventData(data); if (text) { lastRound(trace).queryLoading = false; trace.finalAnswerMarkdown += text; trace.thinkingComplete = true; } } else if (eventType === "references") { const record = asRecord(parseJson(data)); const refs = Array.isArray(record?.references) ? record.references : Array.isArray(parseJson(data)) ? (parseJson(data) as unknown[]) : []; onSources?.(refs.map(mapReferenceToSource)); } else if (eventType === "error") { const message = textFromEventData(data) || "多轮 RAG 服务返回错误。"; onError?.(message); if (!trace.finalAnswerMarkdown) trace.finalAnswerMarkdown = message; finishTrace(trace); } else if (eventType === "done") { const finalText = textFromEventData(data); if (finalText && !trace.finalAnswerMarkdown) trace.finalAnswerMarkdown += finalText; finishTrace(trace); } else if (asRecord(data)?.tool_name || asRecord(parseJson(data))?.tool_name) { consumeToolCallPayload(trace, data); } } function finishTrace(trace: MultiRoundRagTrace) { trace.thinkingComplete = true; for (const round of trace.rounds) { round.queryLoading = false; round.extractLoading = false; if (round.retrievals.length > 0) round.queryShow = true; } } async function* readMultiRoundSse( baseUrl: string, question: string, signal: AbortSignal, ): AsyncGenerator { const response = await fetch(`${baseUrl.replace(/\/$/, "")}/generate`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "text/event-stream, application/json, text/plain", }, signal, body: JSON.stringify({ prompt: question }), }); if (!response.ok) { throw new Error(`多轮 RAG 服务请求失败(${response.status})。`); } if (!response.body) { throw new Error("多轮 RAG 服务未返回流式响应。"); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const pulled = pullStreamEvents(buffer); buffer = pulled.rest; for (const event of pulled.events) { yield event; if (normalizeToolName(asText(event.event)) === "done") return; } } buffer += decoder.decode(); const pulled = pullStreamEvents(`${buffer}\n`); for (const event of pulled.events) { yield event; } if (pulled.rest.trim()) { for (const event of [ ...eventsFromLine(pulled.rest), ...expandStreamEvents(parseJson(pulled.rest)), ]) { yield event; } } } /** * Frontend adapter for GeneralQA's multi-round endpoint. It keeps the external * SSE protocol outside DeerFlow and stores only this mode's own chat messages. */ export function useMultiRoundRagMessages(threadId: string) { const [messages, setMessages] = useState(() => readStoredMessages(threadId), ); const messagesRef = useRef(messages); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const controllerRef = useRef(null); const inFlightThreadIdRef = useRef(null); useEffect(() => { if (inFlightThreadIdRef.current === threadId) return; const stored = readStoredMessages(threadId); messagesRef.current = stored; setMessages(stored); setError(null); }, [threadId]); useEffect(() => { messagesRef.current = messages; }, [messages]); useEffect(() => () => controllerRef.current?.abort(), []); const updateMessages = useCallback( (targetThreadId: string, updater: (current: MultiRoundRagChatMessage[]) => MultiRoundRagChatMessage[]) => { setMessages((current) => { const next = updater(current); messagesRef.current = next; writeStoredMessages(targetThreadId, next); return next; }); }, [], ); const stop = useCallback(() => controllerRef.current?.abort(), []); const runWithEvents = useCallback( async ({ targetThreadId, query, createEvents, }: { targetThreadId: string; query: string; createEvents: (signal: AbortSignal) => AsyncIterable; }) => { const question = query.trim(); if (!question) return; const humanId = createId("multi-rag-human"); const assistantId = createId("multi-rag-ai"); const trace = createEmptyTrace(); let sources: MultiRoundRagSource[] = []; const timestamp = new Date().toISOString(); const userMessage: MultiRoundRagChatMessage = { id: humanId, role: "user", content: question, timestamp, }; const assistantMessage: MultiRoundRagChatMessage = { id: assistantId, role: "assistant", content: "", timestamp, thinkingTrace: cloneTrace(trace), }; updateMessages(targetThreadId, (current) => [...current, userMessage, assistantMessage]); inFlightThreadIdRef.current = targetThreadId; setError(null); setIsLoading(true); const controller = new AbortController(); controllerRef.current = controller; const refreshAssistant = () => { const traceSnapshot = cloneTrace(trace); updateMessages(targetThreadId, (current) => current.map((message) => message.id === assistantId ? { ...message, content: traceSnapshot.finalAnswerMarkdown, thinkingTrace: traceSnapshot, sources: sources.length > 0 ? sources : message.sources, } : message, ), ); }; try { for await (const event of createEvents(controller.signal)) { if (controller.signal.aborted) throw new DOMException("Aborted", "AbortError"); applyStreamEvent( trace, event, (nextSources) => { sources = nextSources; }, setError, ); refreshAssistant(); if (normalizeToolName(asText(event.event)) === "done") break; } finishTrace(trace); refreshAssistant(); } catch (caught) { finishTrace(trace); if (!isAbortError(caught)) { const message = caught instanceof Error ? caught.message : "多轮 RAG 问答失败。"; setError(message); if (!trace.finalAnswerMarkdown) trace.finalAnswerMarkdown = message; } refreshAssistant(); } finally { if (inFlightThreadIdRef.current === targetThreadId) { inFlightThreadIdRef.current = null; } if (controllerRef.current === controller) controllerRef.current = null; setIsLoading(false); } }, [updateMessages], ); const submit = useCallback( async ({ targetThreadId, query }: { targetThreadId: string; query: string }) => { if (isLoading) return; const question = query.trim(); if (!question) return; const baseUrl = readRuntimeEnv("VITE_AIPI_GENERATE_CHAT_URL") || ""; if (!baseUrl) { throw new Error("未配置多轮 RAG 服务地址(VITE_AIPI_GENERATE_CHAT_URL)。"); } await runWithEvents({ targetThreadId, query: question, createEvents: (signal) => readMultiRoundSse(baseUrl, question, signal), }); }, [isLoading, runWithEvents], ); return { messages, isLoading, error, submit, stop }; } export function toLangGraphMessages(messages: MultiRoundRagChatMessage[]): Message[] { return messages.map((message) => { if (message.role === "user") { return { id: message.id, type: "human", content: [{ type: "text", text: message.content }], additional_kwargs: { client_ts: message.timestamp, multi_round_rag: true, }, } as Message; } return { id: message.id, type: "ai", content: message.content, additional_kwargs: { client_ts: message.timestamp, multi_round_rag: true, multi_round_rag_trace: message.thinkingTrace, }, } as Message; }); } function messageTimestamp(message: Message, fallback: number) { const raw = message.additional_kwargs?.client_ts; if (typeof raw === "number" && Number.isFinite(raw)) return raw; if (typeof raw === "string") { const parsed = Date.parse(raw); if (!Number.isNaN(parsed)) return parsed; } return fallback; } export function mergeMessagesByTime(base: Message[], extra: Message[]): Message[] { if (extra.length === 0) return base; if (base.length === 0) return extra; const stamp = (messages: Message[], src: number) => { let last = 0; return messages.map((msg, idx) => { const t = messageTimestamp(msg, last); last = t; return { msg, t, src, idx }; }); }; return [...stamp(base, 0), ...stamp(extra, 1)] .sort((a, b) => a.t - b.t || a.src - b.src || a.idx - b.idx) .map((item) => item.msg); } export function isMultiRoundRagMessage(message: { additional_kwargs?: Record }): boolean { return message.additional_kwargs?.multi_round_rag === true; } export function getMultiRoundRagTrace( message: { additional_kwargs?: Record }, ): MultiRoundRagTrace | null { const trace = message.additional_kwargs?.multi_round_rag_trace; return trace && typeof trace === "object" ? (trace as MultiRoundRagTrace) : null; }