Step3 总结报告支持随时插话、暂停与附件上传
生成中可打断并续问,输入框支持上传参考文件并随消息注入总结线程。
This commit is contained in:
1 parent
2d74f8883e
commit
b682ff5d08
2 files changed
+192
-46
No files matched your search
@@ -17,6 +17,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { uploadFiles } from "@/core/uploads/api";
|
||||
import type {
|
||||
DraftStep3SummarySnapshot,
|
||||
} from "@/roundtable-planning/api/drafts";
|
||||
@@ -25,8 +26,12 @@ import {
|
||||
initSummaryThread,
|
||||
listSessionArtifacts,
|
||||
streamMultiAgent,
|
||||
type StreamMultiAgentRequest,
|
||||
type StreamPhase,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
|
||||
/** 一个上传文件的 run 引用(与 streamMultiAgent 的 files 入参对齐)。 */
|
||||
type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
|
||||
import type {
|
||||
ReportIntent,
|
||||
ReportSeatDelivery,
|
||||
@@ -64,16 +69,21 @@ export interface UseStep3SummaryReturn {
|
||||
dialogues: Step2Dialogue[];
|
||||
seatStubMessages: Record<string, StubAiMessage[]>;
|
||||
activeArtifactThreadId: string | null;
|
||||
/** 是否可发送追问/修改(非流式中、且已有线程或可新建)。 */
|
||||
canAsk: boolean;
|
||||
/** 是否正在生成中(撰写/初始化/上传)—— 驱动暂停按钮与发送态。 */
|
||||
busy: boolean;
|
||||
/** 启动总结(幂等:进行中重复调用会被忽略)。 */
|
||||
start: () => void;
|
||||
/** 从草稿快照恢复展示(不重新生成;保留 threadId 以便继续问答)。 */
|
||||
hydrateSaved: (snapshot: DraftStep3SummarySnapshot) => void;
|
||||
/** 重新生成(先中断在途请求再重跑)。 */
|
||||
redraw: () => void;
|
||||
/** 用户在输入框里继续对话:续同一线程,智能体自判改报告 or 问答。 */
|
||||
ask: (message: string) => void;
|
||||
/**
|
||||
* 用户在输入框里继续对话 —— **随时可发**:生成中发送会先打断当前轮再续同一线程,
|
||||
* 智能体自判改报告 or 问答;可携带上传文件(先传到总结线程再随消息注入)。
|
||||
*/
|
||||
ask: (message: string, files?: File[]) => void;
|
||||
/** 暂停当前生成(中断在途请求并把当前气泡收尾为「已暂停」)。 */
|
||||
pause: () => void;
|
||||
/** 中断在途请求(卸载 / 切回普通视图时调用)。 */
|
||||
abort: () => void;
|
||||
openArtifactFromStep: (
|
||||
@@ -622,6 +632,13 @@ export function useStep3Summary(
|
||||
runningRef.current = false;
|
||||
}, []);
|
||||
|
||||
/** 把仍在流式的气泡冻结(停转圈),并打上状态标签(打断 / 暂停时用)。 */
|
||||
const freezeStreaming = useCallback((note: string) => {
|
||||
setDialogues((prev) =>
|
||||
prev.map((d) => (d.streaming ? { ...d, streaming: false, confidence: note } : d)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
/** 把当前报告 md 种进某线程 stub + 开沙箱(续问时给 reconcile/预览一个锚点)。 */
|
||||
const seedStubWithCurrentMd = useCallback(
|
||||
(threadId: string) => {
|
||||
@@ -648,8 +665,9 @@ export function useStep3Summary(
|
||||
aborter: AbortController;
|
||||
seedThreadId?: string | null;
|
||||
forceReconcile?: boolean;
|
||||
files?: RunFileRef[];
|
||||
}): Promise<void> => {
|
||||
const { bubbleId, prompt, aborter, seedThreadId, forceReconcile } = args;
|
||||
const { bubbleId, prompt, aborter, seedThreadId, forceReconcile, files } = args;
|
||||
|
||||
let threadId = seedThreadId ?? null;
|
||||
let agentName = SUMMARY_AGENT_NAME;
|
||||
@@ -676,6 +694,7 @@ export function useStep3Summary(
|
||||
displayName: "方案总结报告",
|
||||
newMessage: prompt,
|
||||
model: selectedModel || undefined,
|
||||
files: files && files.length > 0 ? files : undefined,
|
||||
signal: aborter.signal,
|
||||
onTextDelta: (text) => appendToDialogue(bubbleId, text),
|
||||
onPhaseChange: (p, toolName, pArgs) => {
|
||||
@@ -763,7 +782,8 @@ export function useStep3Summary(
|
||||
finalizeDialogue(bubbleId, `(总结失败) ${msg}`, "总结失败");
|
||||
setPhase("error");
|
||||
} finally {
|
||||
runningRef.current = false;
|
||||
// 只有「本轮仍是当前轮」才复位 running —— 被新一轮(干预/重新生成)抢占时不要误清。
|
||||
if (aborterRef.current === aborter) runningRef.current = false;
|
||||
}
|
||||
}, [
|
||||
intentReady,
|
||||
@@ -776,53 +796,99 @@ export function useStep3Summary(
|
||||
]);
|
||||
|
||||
const ask = useCallback(
|
||||
async (message: string) => {
|
||||
async (message: string, files?: File[]) => {
|
||||
const text = message.trim();
|
||||
if (!text || runningRef.current) return;
|
||||
const hasFiles = !!(files && files.length > 0);
|
||||
if (!text && !hasFiles) return;
|
||||
|
||||
// 「随时干预」:生成中也能发——先打断当前轮(后端 multitask=rollback 抢占旧 run),
|
||||
// 并把当前流式气泡冻结成「已打断」,再续上同一线程跑新一轮。
|
||||
const wasBusy = runningRef.current;
|
||||
abort();
|
||||
if (wasBusy) freezeStreaming("已打断");
|
||||
|
||||
runningRef.current = true;
|
||||
setError(null);
|
||||
|
||||
const aborter = new AbortController();
|
||||
aborterRef.current = aborter;
|
||||
|
||||
appendUserDialogue(text);
|
||||
const bubbleId = appendStreamingDialogue({
|
||||
sender: "方案总结报告智能体",
|
||||
avatarType: "blue",
|
||||
});
|
||||
setPhase("streaming");
|
||||
// 续问前是否已有真线程:决定 prompt 形态——有则发原始消息(上下文在线程里),
|
||||
// 没有(恢复后 / 首轮还没建好就被打断)则用带研讨原料 + 旧报告的完整 prompt。
|
||||
const hadLiveThread = !!liveThreadRef.current;
|
||||
|
||||
try {
|
||||
const live = liveThreadRef.current;
|
||||
if (live) {
|
||||
// 有附件:先确保有线程(首轮还没建好则新建),再把文件上传到该线程。
|
||||
let threadId = liveThreadRef.current;
|
||||
if (hasFiles && !threadId) {
|
||||
setPhase("creating");
|
||||
const init = await initSummaryThread({ signal: aborter.signal });
|
||||
if (aborter.signal.aborted) return;
|
||||
threadId = init.threadId;
|
||||
liveThreadRef.current = threadId;
|
||||
setSummaryThreadId(threadId);
|
||||
setActiveArtifactThreadId(threadId);
|
||||
}
|
||||
let fileRefs: RunFileRef[] | undefined;
|
||||
if (hasFiles && threadId) {
|
||||
setPhase("creating");
|
||||
const up = await uploadFiles(threadId, files!);
|
||||
if (aborter.signal.aborted) return;
|
||||
fileRefs = up.files.map((f) => ({
|
||||
filename: f.filename,
|
||||
size: f.size,
|
||||
path: f.virtual_path,
|
||||
status: "uploaded" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
const fileNote = hasFiles ? `(附 ${files!.length} 个文件)` : "";
|
||||
appendUserDialogue((text || "(上传了参考文件)") + fileNote);
|
||||
const bubbleId = appendStreamingDialogue({
|
||||
sender: "方案总结报告智能体",
|
||||
avatarType: "blue",
|
||||
});
|
||||
setPhase("streaming");
|
||||
|
||||
if (hadLiveThread) {
|
||||
// 真线程仍在 → 续问,发原始消息,靠线程上下文 + SOUL 自判改/答。
|
||||
const prompt =
|
||||
text || "我上传了参考文件,请先阅读文件内容,再据此更新报告或回答。";
|
||||
await streamOnce({
|
||||
bubbleId,
|
||||
prompt: text,
|
||||
prompt,
|
||||
aborter,
|
||||
seedThreadId: live,
|
||||
seedThreadId: liveThreadRef.current,
|
||||
forceReconcile: true,
|
||||
files: fileRefs,
|
||||
});
|
||||
} else {
|
||||
// 恢复草稿后无 live thread → 新建线程,prompt 带上旧报告做底。
|
||||
// 无 live thread(恢复草稿 / 首轮没建好就被打断)→ prompt 带研讨原料 + 旧报告做底。
|
||||
// 若上面为上传文件已建好线程(threadId)就复用它,否则交给 streamOnce 新建。
|
||||
const prompt = buildResumeAskPrompt({
|
||||
intentReady,
|
||||
seatDeliveries,
|
||||
consensusText,
|
||||
coordinatorPrompt,
|
||||
previousMd: currentMdRef.current,
|
||||
userMessage: text,
|
||||
userMessage:
|
||||
text || "请阅读我上传的参考文件后,据此更新报告或回答。",
|
||||
});
|
||||
await streamOnce({
|
||||
bubbleId,
|
||||
prompt,
|
||||
aborter,
|
||||
seedThreadId: threadId,
|
||||
forceReconcile: true,
|
||||
files: fileRefs,
|
||||
});
|
||||
await streamOnce({ bubbleId, prompt, aborter, forceReconcile: true });
|
||||
}
|
||||
} catch (err) {
|
||||
if (aborter.signal.aborted) return;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
finalizeDialogue(bubbleId, `(处理失败) ${msg}`, "处理失败");
|
||||
freezeStreaming("处理失败");
|
||||
setPhase("error");
|
||||
} finally {
|
||||
runningRef.current = false;
|
||||
if (aborterRef.current === aborter) runningRef.current = false;
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -830,13 +896,22 @@ export function useStep3Summary(
|
||||
seatDeliveries,
|
||||
consensusText,
|
||||
coordinatorPrompt,
|
||||
abort,
|
||||
freezeStreaming,
|
||||
appendUserDialogue,
|
||||
appendStreamingDialogue,
|
||||
finalizeDialogue,
|
||||
streamOnce,
|
||||
],
|
||||
);
|
||||
|
||||
/** 暂停当前生成:中断在途请求 + 把流式气泡收尾为「已暂停」+ 状态回 done。 */
|
||||
const pause = useCallback(() => {
|
||||
if (!runningRef.current) return;
|
||||
abort();
|
||||
freezeStreaming("已暂停");
|
||||
setPhase((p) => (p === "creating" || p === "streaming" ? "done" : p));
|
||||
}, [abort, freezeStreaming]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
void run();
|
||||
}, [run]);
|
||||
@@ -912,7 +987,6 @@ export function useStep3Summary(
|
||||
);
|
||||
|
||||
const busy = phase === "creating" || phase === "streaming";
|
||||
const canAsk = !busy && (phase === "done" || phase === "error");
|
||||
|
||||
return {
|
||||
phase,
|
||||
@@ -921,11 +995,12 @@ export function useStep3Summary(
|
||||
dialogues,
|
||||
seatStubMessages,
|
||||
activeArtifactThreadId,
|
||||
canAsk,
|
||||
busy,
|
||||
start,
|
||||
hydrateSaved,
|
||||
redraw,
|
||||
ask,
|
||||
pause,
|
||||
abort,
|
||||
openArtifactFromStep,
|
||||
openArtifactPreview,
|
||||
|
||||
Reference in new issue
Block a user