代码提交
This commit is contained in:
1 parent
5901246030
commit
47faa9bcdf
7 files changed
+767
-227
No files matched your search
@@ -13,6 +13,9 @@ export type MultiRoundRagRetrieval = {
|
||||
time?: string;
|
||||
recUuid?: string;
|
||||
url?: string;
|
||||
/** Original tool payload, retained so a future renderer can add more fields
|
||||
* without losing data received from the multi-round service. */
|
||||
raw?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MultiRoundRagRound = {
|
||||
@@ -87,8 +90,11 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
/** Values in the deployed tools are occasionally numeric IDs, not strings. */
|
||||
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 {
|
||||
@@ -103,8 +109,11 @@ function parseJson(value: unknown): unknown {
|
||||
function parseEventLine(line: string): MultiRoundEvent | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("data:")) return null;
|
||||
const payload = trimmed.slice("data:".length).trim();
|
||||
if (!payload || payload === "[DONE]") return { event: "done" };
|
||||
try {
|
||||
return JSON.parse(trimmed.slice("data:".length).trim()) as MultiRoundEvent;
|
||||
const parsed = parseJson(payload);
|
||||
return asRecord(parsed) as MultiRoundEvent | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -141,10 +150,10 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) {
|
||||
if (current.title === "正在准备检索" && !current.thought && !current.query) {
|
||||
current.title = "思考过程";
|
||||
current.thought = thought;
|
||||
return;
|
||||
return current;
|
||||
}
|
||||
current.isSearching = false;
|
||||
trace.rounds.push({
|
||||
const next = {
|
||||
id: createId("multi-rag-round"),
|
||||
title: "思考过程",
|
||||
thought,
|
||||
@@ -152,7 +161,9 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) {
|
||||
query: "",
|
||||
isSearching: true,
|
||||
retrievals: [],
|
||||
});
|
||||
};
|
||||
trace.rounds.push(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function appendRoundBody(trace: MultiRoundRagTrace, body: string) {
|
||||
@@ -162,49 +173,94 @@ function appendRoundBody(trace: MultiRoundRagTrace, body: string) {
|
||||
current.body = `${current.body ?? ""}${body}`;
|
||||
}
|
||||
|
||||
function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] {
|
||||
const root = asRecord(parseJson(value));
|
||||
const list =
|
||||
(Array.isArray(root?.results) && root.results) ||
|
||||
(Array.isArray(root?.organic) && root.organic) ||
|
||||
[];
|
||||
return list.flatMap((item, index) => {
|
||||
function firstArray(values: unknown[]): unknown[] {
|
||||
for (const value of values) {
|
||||
const parsed = parseJson(value);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function toRecordList(value: unknown): Record<string, unknown>[] {
|
||||
const parsed = parseJson(value);
|
||||
const root = asRecord(parsed);
|
||||
const list = Array.isArray(parsed)
|
||||
? parsed
|
||||
: firstArray([
|
||||
root?.results,
|
||||
root?.organic,
|
||||
root?.data,
|
||||
root?.items,
|
||||
root?.list,
|
||||
root?.documents,
|
||||
]);
|
||||
return list.flatMap((item) => {
|
||||
if (typeof item === "string") return [{ content: item }];
|
||||
const record = asRecord(item);
|
||||
if (!record) return [];
|
||||
const recUuid = asString(record.recUuid ?? record.rec_uuid);
|
||||
const url = asString(record.url ?? record.link ?? record.href);
|
||||
return record ? [record] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GeneralQA's service fans out to several business search tools. Normalising
|
||||
* all of their result shapes here prevents a successful tool call from being
|
||||
* silently absent just because it did not use `results` or `organic`.
|
||||
*/
|
||||
function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] {
|
||||
const list = toRecordList(value);
|
||||
return list.flatMap((item, index) => {
|
||||
const recUuid = asText(item.recUuid ?? item.rec_uuid ?? item.uuid);
|
||||
const url = asText(item.url ?? item.link ?? item.href);
|
||||
const title = asText(
|
||||
item.title ?? item.m_title ?? item.taskName ?? item.user_name ?? item.name ?? item.user_id,
|
||||
);
|
||||
const content = asText(
|
||||
item.content ??
|
||||
item.m_content ??
|
||||
item.m_conent ??
|
||||
item.snippet ??
|
||||
item.text ??
|
||||
item.overview ??
|
||||
item.page_content ??
|
||||
item.description ??
|
||||
item.summary,
|
||||
);
|
||||
return [
|
||||
{
|
||||
id: asString(record.id) || recUuid || undefined,
|
||||
title: asString(record.title) || `检索资料 ${index + 1}`,
|
||||
content: asString(record.content ?? record.snippet ?? record.text),
|
||||
source: asString(record.source ?? record.source1 ?? url) || undefined,
|
||||
time: asString(record.publish_time ?? record.m_publish ?? record.date) || undefined,
|
||||
id: asText(item.id) || recUuid || undefined,
|
||||
title: title || `检索资料 ${index + 1}`,
|
||||
content: content || (url && !title ? url : ""),
|
||||
source: asText(item.source ?? item.source1 ?? item.m_source ?? item.platform) || undefined,
|
||||
time: asText(item.publish_time ?? item.m_publish ?? item.m_publicsh ?? item.date ?? item.time) || undefined,
|
||||
recUuid: recUuid || undefined,
|
||||
url: /^https?:\/\//i.test(url) ? url : undefined,
|
||||
raw: item,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function mapReferences(value: unknown): MultiRoundRagSource[] {
|
||||
const record = asRecord(value);
|
||||
const list = Array.isArray(record?.references) ? record.references : [];
|
||||
const parsed = parseJson(value);
|
||||
const record = asRecord(parsed);
|
||||
const list = Array.isArray(parsed)
|
||||
? parsed
|
||||
: firstArray([record?.references, record?.results, record?.sources, record?.documents]);
|
||||
return list.flatMap((item, index) => {
|
||||
const source = asRecord(item);
|
||||
if (!source) return [];
|
||||
const content = asString(source.content ?? source.snippet);
|
||||
const rawUrl = asString(source.url ?? source.link ?? source.href);
|
||||
const recUuid = asString(source.recUuid ?? source.rec_uuid ?? source.uuid);
|
||||
const content = asText(source.content ?? source.m_content ?? source.m_conent ?? source.snippet ?? source.text);
|
||||
const rawUrl = asText(source.url ?? source.link ?? source.href);
|
||||
const recUuid = asText(source.recUuid ?? source.rec_uuid ?? source.uuid);
|
||||
return [
|
||||
{
|
||||
id: asString(source.id) || recUuid || undefined,
|
||||
title: asString(source.title) || `参考资料 ${index + 1}`,
|
||||
type: asString(source.type) || "参考资料",
|
||||
snippet: asString(source.snippet) || content,
|
||||
id: asText(source.id) || recUuid || undefined,
|
||||
title: asText(source.title ?? source.m_title ?? source.taskName ?? source.user_name) || `参考资料 ${index + 1}`,
|
||||
type: asText(source.type) || "参考资料",
|
||||
snippet: asText(source.snippet) || content,
|
||||
content,
|
||||
source: asString(source.source ?? source.source1) || undefined,
|
||||
time: asString(source.time ?? source.m_publish ?? source.publish_time) || undefined,
|
||||
source: asText(source.source ?? source.source1 ?? source.m_source ?? source.platform) || undefined,
|
||||
time: asText(source.time ?? source.m_publish ?? source.m_publicsh ?? source.publish_time ?? source.date) || undefined,
|
||||
url: /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined,
|
||||
raw: source,
|
||||
},
|
||||
@@ -222,6 +278,71 @@ function cloneTrace(trace: MultiRoundRagTrace): MultiRoundRagTrace {
|
||||
};
|
||||
}
|
||||
|
||||
function toolTitle(toolName: string): string {
|
||||
switch (toolName) {
|
||||
case "local_search":
|
||||
return "本地知识检索";
|
||||
case "tavily_search":
|
||||
return "联网检索";
|
||||
case "google_search":
|
||||
case "getTaskLikeTop":
|
||||
return "思考过程";
|
||||
case "search_user_profile":
|
||||
return "靶标平台画像库";
|
||||
case "get_taiwan_news":
|
||||
case "search_keyword_taiwan_news":
|
||||
return "台湾热点新闻";
|
||||
case "get_world_news":
|
||||
return "世界热点新闻";
|
||||
case "get_root_messaage_comment":
|
||||
return "获取该消息的全部评论";
|
||||
case "search_user_behavior":
|
||||
return "查询该用户的历史行为";
|
||||
case "get_america_news":
|
||||
return "美国热点新闻";
|
||||
case "get_time_data":
|
||||
return "态势新闻搜索";
|
||||
case "get_textid_content":
|
||||
return "获取新闻详细内容";
|
||||
default:
|
||||
return toolName || "检索过程";
|
||||
}
|
||||
}
|
||||
|
||||
function isSearchTool(toolName: string): boolean {
|
||||
return [
|
||||
"local_search",
|
||||
"tavily_search",
|
||||
"google_search",
|
||||
"getTaskLikeTop",
|
||||
"search_user_profile",
|
||||
"get_taiwan_news",
|
||||
"search_keyword_taiwan_news",
|
||||
"get_world_news",
|
||||
"get_root_messaage_comment",
|
||||
"search_user_behavior",
|
||||
"get_america_news",
|
||||
"get_time_data",
|
||||
].includes(toolName);
|
||||
}
|
||||
|
||||
function queryFromToolInput(input: Record<string, unknown>, result: unknown): string {
|
||||
const resultRecord = asRecord(parseJson(result));
|
||||
const direct = asText(input.q ?? input.query ?? input.keyword ?? input.user_fig);
|
||||
if (direct) return direct;
|
||||
if (input.s || input.e) {
|
||||
return [asText(input.s), asText(input.e)].filter(Boolean).join(" - ");
|
||||
}
|
||||
return asText(resultRecord?.query ?? resultRecord?.q);
|
||||
}
|
||||
|
||||
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 isSingleRoundRagMessage(message: Message) {
|
||||
return message.additional_kwargs?.single_round_rag === true;
|
||||
}
|
||||
@@ -370,92 +491,204 @@ export function useMultiRoundRagMessages(threadId: string) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let activeThoughtRound: MultiRoundRagRound | null = null;
|
||||
let activeThoughtTailIsAnswer = false;
|
||||
let answerFromShowText = false;
|
||||
|
||||
const appendAnswer = (text: string, allowCompleteSnapshot = false) => {
|
||||
if (!text) return;
|
||||
// Some gateways emit one complete `message` after a `show_text`
|
||||
// answer. Restrict snapshot detection to that case and to a
|
||||
// meaningful length: adjacent streaming chunks can legitimately
|
||||
// contain the same character, and must never be discarded.
|
||||
if (
|
||||
allowCompleteSnapshot &&
|
||||
trace.answer.length >= 16 &&
|
||||
text.startsWith(trace.answer)
|
||||
) {
|
||||
trace.answer = text;
|
||||
return;
|
||||
}
|
||||
trace.answer += text;
|
||||
};
|
||||
|
||||
const consumeShowText = (text: string, tailIsAnswer = false) => {
|
||||
if (!text) return;
|
||||
if (activeThoughtRound) {
|
||||
const endIndex = text.indexOf("</think>");
|
||||
if (endIndex < 0) {
|
||||
activeThoughtRound.thought += text;
|
||||
return;
|
||||
}
|
||||
activeThoughtRound.thought += text.slice(0, endIndex);
|
||||
const roundBody = text.slice(endIndex + "</think>".length).trim();
|
||||
if (roundBody) {
|
||||
if (activeThoughtTailIsAnswer) {
|
||||
appendAnswer(roundBody);
|
||||
answerFromShowText = false;
|
||||
trace.isComplete = true;
|
||||
} else {
|
||||
appendRoundBody(trace, roundBody);
|
||||
}
|
||||
}
|
||||
activeThoughtRound = null;
|
||||
activeThoughtTailIsAnswer = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const thinkStart = text.indexOf("<think>");
|
||||
if (thinkStart < 0) {
|
||||
const current = latestRound(trace);
|
||||
current.isSearching = false;
|
||||
appendAnswer(text);
|
||||
answerFromShowText = true;
|
||||
trace.isComplete = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const prefix = text.slice(0, thinkStart).trim();
|
||||
if (prefix) appendRoundBody(trace, prefix);
|
||||
|
||||
// A full <think>…</think> envelope is normally delivered as one
|
||||
// tool call. Keep the unfinished variant open as well: production
|
||||
// proxies are allowed to split an SSE frame at any byte boundary.
|
||||
const thinkEnd = text.indexOf("</think>", thinkStart + "<think>".length);
|
||||
const thought = text
|
||||
.slice(thinkStart + "<think>".length, thinkEnd < 0 ? undefined : thinkEnd)
|
||||
.trim();
|
||||
const thoughtRound = addThinkingRound(trace, thought);
|
||||
if (thinkEnd < 0) {
|
||||
activeThoughtRound = thoughtRound;
|
||||
activeThoughtTailIsAnswer = tailIsAnswer;
|
||||
return;
|
||||
}
|
||||
const roundBody = text.slice(thinkEnd + "</think>".length).trim();
|
||||
if (roundBody) {
|
||||
if (tailIsAnswer) {
|
||||
appendAnswer(roundBody);
|
||||
answerFromShowText = false;
|
||||
trace.isComplete = true;
|
||||
} else {
|
||||
appendRoundBody(trace, roundBody);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const consumeToolCall = (payload: unknown) => {
|
||||
const parsedPayload = parseJson(payload);
|
||||
const tools = Array.isArray(parsedPayload) ? parsedPayload : [parsedPayload];
|
||||
for (const toolValue of tools) {
|
||||
const tool = asRecord(toolValue);
|
||||
if (!tool) continue;
|
||||
const toolName = asText(tool.tool_name ?? tool.name);
|
||||
const input = asRecord(parseJson(tool.tool_input ?? tool.input ?? tool.arguments)) ?? {};
|
||||
const current = latestRound(trace);
|
||||
|
||||
if (toolName === "show_text") {
|
||||
consumeShowText(asText(input.text ?? input.content));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (toolName === "get_textid_content") {
|
||||
current.title = toolTitle(toolName);
|
||||
const article = asRecord(parseJson(input.result));
|
||||
const body = asText(article?.content ?? article?.text ?? input.content ?? input.result);
|
||||
if (body) appendRoundBody(trace, body);
|
||||
current.isSearching = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasResult = input.result !== undefined;
|
||||
const query = queryFromToolInput(input, input.result);
|
||||
if (isSearchTool(toolName) || hasResult) {
|
||||
current.title = toolTitle(toolName);
|
||||
current.query = query || current.query;
|
||||
if (!hasResult && isSearchTool(toolName)) {
|
||||
// GeneralQA can receive a model text event before the search
|
||||
// tool announces its query. The query starts a new visible
|
||||
// loading phase, so restore the round/trace state here and
|
||||
// keep the query badge spinning until its result arrives.
|
||||
current.isSearching = true;
|
||||
trace.isComplete = false;
|
||||
} else if (hasResult) {
|
||||
const retrievals = mapRetrievals(input.result);
|
||||
// An empty search response is still a completed response; it
|
||||
// must replace a previous list instead of leaving stale cards.
|
||||
current.retrievals = retrievals;
|
||||
if (retrievals.length === 0) {
|
||||
const fallbackBody = textFromEventData(input.result);
|
||||
if (fallbackBody && fallbackBody !== current.query) {
|
||||
appendRoundBody(trace, fallbackBody);
|
||||
}
|
||||
}
|
||||
current.isSearching = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const finishTrace = () => {
|
||||
trace.isComplete = true;
|
||||
activeThoughtRound = null;
|
||||
activeThoughtTailIsAnswer = false;
|
||||
for (const round of trace.rounds) round.isSearching = false;
|
||||
};
|
||||
|
||||
const consumeEvent = (event: MultiRoundEvent) => {
|
||||
const eventType = asText(event.event);
|
||||
const data = event.data;
|
||||
if (eventType === "tool_call") {
|
||||
consumeToolCall(data);
|
||||
} else if (eventType === "message") {
|
||||
const text = textFromEventData(data);
|
||||
if (text) {
|
||||
if (activeThoughtRound || /<\/?think>/i.test(text)) {
|
||||
// Most deployments put thoughts in `show_text`, but this
|
||||
// fallback also preserves a compatible gateway that emits
|
||||
// `<think>` in normal message chunks.
|
||||
consumeShowText(text, true);
|
||||
} else {
|
||||
latestRound(trace).isSearching = false;
|
||||
appendAnswer(text, answerFromShowText);
|
||||
answerFromShowText = false;
|
||||
trace.isComplete = true;
|
||||
}
|
||||
}
|
||||
} else if (eventType === "references") {
|
||||
sources = mapReferences(data);
|
||||
} else if (eventType === "error") {
|
||||
const message = textFromEventData(data) || "多轮 RAG 服务返回错误。";
|
||||
setError(message);
|
||||
if (!trace.answer) trace.answer = message;
|
||||
finishTrace();
|
||||
} else if (eventType === "done") {
|
||||
// A few compatible gateway versions include the whole final
|
||||
// answer in `done`; render it when no `message` chunk was sent.
|
||||
const finalText = textFromEventData(data);
|
||||
if (finalText && !trace.answer) appendAnswer(finalText);
|
||||
finishTrace();
|
||||
}
|
||||
refreshAssistant();
|
||||
};
|
||||
|
||||
const consumeLines = (flush = false) => {
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
buffer = flush ? "" : (lines.pop() ?? "");
|
||||
for (const line of lines) {
|
||||
const event = parseEventLine(line);
|
||||
if (event) consumeEvent(event);
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const event = parseEventLine(line);
|
||||
if (!event) continue;
|
||||
const eventType = asString(event.event);
|
||||
const data = event.data;
|
||||
if (eventType === "tool_call") {
|
||||
const tool = asRecord(data);
|
||||
const toolName = asString(tool?.tool_name);
|
||||
const input = asRecord(tool?.tool_input) ?? {};
|
||||
const current = latestRound(trace);
|
||||
if (toolName === "show_text") {
|
||||
const text = asString(input.text);
|
||||
if (text.includes("<think>")) {
|
||||
const thinkEnd = text.indexOf("</think>");
|
||||
addThinkingRound(
|
||||
trace,
|
||||
(thinkEnd >= 0
|
||||
? text.slice(0, thinkEnd + "</think>".length)
|
||||
: text
|
||||
)
|
||||
.replace(/<\/?think>/g, "")
|
||||
.trim(),
|
||||
);
|
||||
if (thinkEnd >= 0) {
|
||||
const roundBody = text
|
||||
.slice(thinkEnd + "</think>".length)
|
||||
.trim();
|
||||
if (roundBody) {
|
||||
appendRoundBody(trace, roundBody);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// A non-thinking `show_text` immediately after a thought is
|
||||
// this round's narrative; the terminal `message` event is
|
||||
// still rendered only as the final answer below all rounds.
|
||||
current.isSearching = false;
|
||||
trace.answer += text;
|
||||
trace.isComplete = true;
|
||||
}
|
||||
} else if (
|
||||
toolName === "local_search" ||
|
||||
toolName === "tavily_search" ||
|
||||
toolName === "google_search"
|
||||
) {
|
||||
const queryData = asRecord(parseJson(input.result));
|
||||
current.title =
|
||||
toolName === "local_search" ? "本地知识检索" : "联网检索";
|
||||
current.query =
|
||||
asString(input.q ?? input.query) ||
|
||||
asString(queryData?.query) ||
|
||||
current.query;
|
||||
const retrievals = mapRetrievals(input.result);
|
||||
if (retrievals.length > 0) current.retrievals = retrievals;
|
||||
current.isSearching = false;
|
||||
} else if (toolName === "get_textid_content") {
|
||||
const article = asRecord(parseJson(input.result));
|
||||
const body =
|
||||
asString(article?.content ?? article?.text) ||
|
||||
asString(input.content);
|
||||
if (body) appendRoundBody(trace, body);
|
||||
current.isSearching = false;
|
||||
}
|
||||
refreshAssistant();
|
||||
} else if (eventType === "message" && typeof data === "string") {
|
||||
latestRound(trace).isSearching = false;
|
||||
trace.answer += data;
|
||||
trace.isComplete = true;
|
||||
refreshAssistant();
|
||||
} else if (eventType === "references") {
|
||||
sources = mapReferences(data);
|
||||
refreshAssistant();
|
||||
} else if (eventType === "done") {
|
||||
trace.isComplete = true;
|
||||
for (const round of trace.rounds) round.isSearching = false;
|
||||
refreshAssistant();
|
||||
}
|
||||
}
|
||||
consumeLines();
|
||||
}
|
||||
trace.isComplete = true;
|
||||
for (const round of trace.rounds) round.isSearching = false;
|
||||
buffer += decoder.decode();
|
||||
consumeLines(true);
|
||||
finishTrace();
|
||||
refreshAssistant();
|
||||
} catch (caught) {
|
||||
trace.isComplete = true;
|
||||
|
||||
Reference in new issue
Block a user