代码提交
This commit is contained in:
1 parent
1eae02f782
commit
e58da3fbdf
4 files changed
+240
-57
No files matched your search
@@ -563,7 +563,12 @@ export function MessageList({
|
||||
questionMarkdown={
|
||||
msg.id ? questionMarkdownByAssistantId.get(msg.id) : undefined
|
||||
}
|
||||
isLoading={messageIsLoading}
|
||||
isLoading={
|
||||
isMultiRoundRagMessage(msg)
|
||||
? thread.isLoading &&
|
||||
msg.id === messages[messages.length - 1]?.id
|
||||
: messageIsLoading
|
||||
}
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
|
||||
@@ -181,17 +181,110 @@ function lastRound(trace: MultiRoundRagTrace): MultiRoundRagRound {
|
||||
return next;
|
||||
}
|
||||
|
||||
function parseEventLine(line: string): MultiRoundEvent | null {
|
||||
function eventsFromLine(line: string): MultiRoundEvent[] {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("data:")) return null;
|
||||
const payload = trimmed.replace(/^data:\s?/, "").trim();
|
||||
if (!payload || payload === "[DONE]") return { event: "done" };
|
||||
try {
|
||||
const parsed = parseJson(payload);
|
||||
return asRecord(parsed) as MultiRoundEvent | null;
|
||||
} catch {
|
||||
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 {
|
||||
@@ -230,6 +323,22 @@ function extractInfoText(result: unknown): string {
|
||||
);
|
||||
}
|
||||
|
||||
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(/<think>([\s\S]*?)<\/think>/i);
|
||||
if (closed?.[1] != null) return closed[1].trim();
|
||||
const open = chatText.match(/<think>([\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 渲染成卡片。
|
||||
@@ -245,9 +354,21 @@ export function applyMultiRoundToolCall(trace: MultiRoundRagTrace, toolValue: un
|
||||
switch (toolName) {
|
||||
case "show_text": {
|
||||
const chatText = asText(input.text ?? input.content);
|
||||
if (!chatText) return;
|
||||
if (chatText.includes("<think>")) {
|
||||
const thinkContent = chatText.replace("<think>", "").replace("</think>", "").trim();
|
||||
if (trace.rounds[0]?.title !== "正在准备检索") {
|
||||
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 = round.retrievals.length > 0;
|
||||
trace.rounds.push({
|
||||
@@ -259,9 +380,6 @@ export function applyMultiRoundToolCall(trace: MultiRoundRagTrace, toolValue: un
|
||||
queryShow: true,
|
||||
retrievals: [],
|
||||
});
|
||||
} else {
|
||||
trace.rounds[0]!.title = "思考过程";
|
||||
trace.rounds[0]!.thoughtMarkdown = thinkContent;
|
||||
}
|
||||
} else {
|
||||
round.queryLoading = false;
|
||||
@@ -515,8 +633,20 @@ export function applyMultiRoundToolCall(trace: MultiRoundRagTrace, toolValue: un
|
||||
|
||||
function consumeToolCallPayload(trace: MultiRoundRagTrace, payload: unknown) {
|
||||
const parsed = parseJson(payload);
|
||||
const tools = Array.isArray(parsed) ? parsed : [parsed];
|
||||
for (const tool of tools) applyMultiRoundToolCall(trace, tool);
|
||||
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 {
|
||||
@@ -532,7 +662,7 @@ function applyStreamEvent(
|
||||
onSources?: (sources: MultiRoundRagSource[]) => void,
|
||||
onError?: (message: string) => void,
|
||||
) {
|
||||
const eventType = asText(event.event);
|
||||
const eventType = normalizeToolName(asText(event.event));
|
||||
const data = event.data;
|
||||
if (eventType === "tool_call") {
|
||||
consumeToolCallPayload(trace, data);
|
||||
@@ -560,6 +690,8 @@ function applyStreamEvent(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,7 +711,10 @@ async function* readMultiRoundSse(
|
||||
): AsyncGenerator<MultiRoundEvent> {
|
||||
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream, application/json, text/plain",
|
||||
},
|
||||
signal,
|
||||
body: JSON.stringify({ prompt: question }),
|
||||
});
|
||||
@@ -596,17 +731,25 @@ async function* readMultiRoundSse(
|
||||
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) yield event;
|
||||
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();
|
||||
for (const line of buffer.split(/\r?\n/)) {
|
||||
const event = parseEventLine(line);
|
||||
if (event) yield event;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,8 +765,10 @@ export function useMultiRoundRagMessages(threadId: string) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
const inFlightThreadIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (inFlightThreadIdRef.current === threadId) return;
|
||||
const stored = readStoredMessages(threadId);
|
||||
messagesRef.current = stored;
|
||||
setMessages(stored);
|
||||
@@ -683,6 +828,7 @@ export function useMultiRoundRagMessages(threadId: string) {
|
||||
};
|
||||
|
||||
updateMessages(targetThreadId, (current) => [...current, userMessage, assistantMessage]);
|
||||
inFlightThreadIdRef.current = targetThreadId;
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -717,6 +863,7 @@ export function useMultiRoundRagMessages(threadId: string) {
|
||||
setError,
|
||||
);
|
||||
refreshAssistant();
|
||||
if (normalizeToolName(asText(event.event)) === "done") break;
|
||||
}
|
||||
finishTrace(trace);
|
||||
refreshAssistant();
|
||||
@@ -729,6 +876,9 @@ export function useMultiRoundRagMessages(threadId: string) {
|
||||
}
|
||||
refreshAssistant();
|
||||
} finally {
|
||||
if (inFlightThreadIdRef.current === targetThreadId) {
|
||||
inFlightThreadIdRef.current = null;
|
||||
}
|
||||
if (controllerRef.current === controller) controllerRef.current = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -138,8 +138,9 @@ const emptyForm = {
|
||||
html_negative_prompt: "",
|
||||
html_reference_favorite_id: "",
|
||||
html_model_name: "",
|
||||
// 参考选了内置模板(tpl:<id>)时的「数据抽取」开关:关闭=用模板默认数据,开启=检索抽取。
|
||||
html_template_extract: false,
|
||||
// 参考选了内置模板(tpl:<id>)时的「数据抽取」开关:默认开启(检索抽取+抽不到回退
|
||||
// 模板原数据),仅可转换模板生效;显式关闭=直接用模板默认数据。
|
||||
html_template_extract: true,
|
||||
};
|
||||
|
||||
/** 覆盖 Textarea 默认 field-sizing-content,避免输入过多时撑破弹窗 */
|
||||
@@ -331,7 +332,8 @@ export default function ScheduledTasksPage({
|
||||
html_negative_prompt: html.negative_prompt ?? "",
|
||||
html_reference_favorite_id: html.reference_favorite_id ?? "",
|
||||
html_model_name: html.model_name ?? "",
|
||||
html_template_extract: !!html.template_extract,
|
||||
// 与后端缺省一致:未存该键(老任务)视为开启,仅显式 false 才关闭。
|
||||
html_template_extract: html.template_extract !== false,
|
||||
});
|
||||
// Prefer the canonical cron_expr over the user-typed schedule_text: the
|
||||
// latter may be natural language ("每天9点") that parseCron can't read,
|
||||
@@ -358,6 +360,10 @@ export default function ScheduledTasksPage({
|
||||
isTemplateAgent || form.require_html,
|
||||
);
|
||||
const selectedTemplate = htmlTemplates.find((t) => t.id === form.template_id);
|
||||
// 「参考收藏页面」选中的内置模板(require_html 模式,值形如 tpl:<id>)。
|
||||
const selectedTplRef = htmlTemplates.find(
|
||||
(t) => `tpl:${t.id}` === form.html_reference_favorite_id,
|
||||
);
|
||||
// 选中的模板是「只展示」型(convertible===false)时,不需要源 JSON,隐藏上传/输入。
|
||||
const templateDisplayOnly =
|
||||
isTemplateAgent && selectedTemplate?.convertible === false;
|
||||
@@ -1396,23 +1402,28 @@ export default function ScheduledTasksPage({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 参考选了内置模板(tpl:<id>)时:数据抽取开关。关闭=用模板默认数据。 */}
|
||||
{form.html_reference_favorite_id.startsWith("tpl:") && (
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="min-w-0 pr-3">
|
||||
<p className="text-sm font-medium">执行数据抽取</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
关闭则直接用所选模板的默认数据生成(含全部子页面);开启才检索并按模板结构抽取数据填充。
|
||||
</p>
|
||||
{/* 参考选了内置模板(tpl:<id>)时:数据抽取开关(仅可转换模板;只展示模板给提示)。 */}
|
||||
{form.html_reference_favorite_id.startsWith("tpl:") &&
|
||||
(selectedTplRef?.convertible === false ? (
|
||||
<p className="text-muted-foreground rounded-md border p-3 text-xs">
|
||||
该模板暂为「直接展示已有数据」,执行时将原样输出其现有页面,不做数据抽取。
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="min-w-0 pr-3">
|
||||
<p className="text-sm font-medium">执行数据抽取</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
开启后执行时由所选智能体检索生成内容,并按模板结构抽取数据填充;抽取不到的子页面自动回退模板原有数据。关闭则直接用所选模板的默认数据生成(含全部子页面)。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.html_template_extract}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((value) => ({ ...value, html_template_extract: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.html_template_extract}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((value) => ({ ...value, html_template_extract: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-xs">生成模型</p>
|
||||
<Select
|
||||
|
||||
Reference in new issue
Block a user