fix: 节流会商流式写文件预览,避免 Maximum update depth

write_file 的 content 在每个 SSE chunk 中持续增长,原先会反复触发 React 状态更新并打断整页;现改为按身份入参去重、节流发布 stub,并收口第 3 步为 Markdown 报告工作台。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-18 23:23:46 +08:00
1 parent dcfaa199bf
commit 30799f17be
14 files changed
+636 -695

No files matched your search

@@ -155,6 +155,7 @@ export function ArtifactFileDetail({
onDownloadClick, onDownloadClick,
toolbarActions, toolbarActions,
headerLeadingAction, headerLeadingAction,
headerLeadingActionBare = false,
researchSessionId, researchSessionId,
}: { }: {
className?: string; className?: string;
@@ -192,6 +193,11 @@ export function ArtifactFileDetail({
toolbarActions?: ReactNode; toolbarActions?: ReactNode;
/** Replaces the left file-type badge for an embedded workspace action. */ /** Replaces the left file-type badge for an embedded workspace action. */
headerLeadingAction?: ReactNode; headerLeadingAction?: ReactNode;
/**
* 嵌入式工作区的标题动作可直接放在标题栏中,避免再被文件类型徽标的胶囊容器包一层。
* 默认保留原有徽标容器,避免影响普通聊天和产物预览。
*/
headerLeadingActionBare?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts(); const { artifacts, setOpen, select } = useArtifacts();
@@ -1205,7 +1211,13 @@ export function ArtifactFileDetail({
<Artifact className={cn(className)}> <Artifact className={cn(className)}>
<ArtifactHeader className="flex flex-wrap items-center gap-3 px-4 py-3"> <ArtifactHeader className="flex flex-wrap items-center gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<div className="bg-muted/65 inline-flex min-w-0 items-center rounded-full border border-black/6 p-1 shadow-sm dark:border-white/8"> <div
className={cn(
"inline-flex min-w-0 items-center gap-2",
!headerLeadingActionBare &&
"bg-muted/65 rounded-full border border-black/6 p-1 shadow-sm dark:border-white/8",
)}
>
{headerLeadingAction ?? ( {headerLeadingAction ?? (
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8"> <span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
{fileTypeLabel} {fileTypeLabel}
@@ -55,7 +55,13 @@ interface ArtifactsProviderProps {
export function ArtifactsProvider({ children }: ArtifactsProviderProps) { export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifactsState] = useState<string[]>([]); const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => { const setArtifacts = useCallback((next: string[]) => {
setArtifactsState(Array.isArray(next) ? next : []); const normalized = Array.isArray(next) ? next : [];
setArtifactsState((previous) =>
previous.length === normalized.length &&
previous.every((artifact, index) => artifact === normalized[index])
? previous
: normalized,
);
}, []); }, []);
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null); const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null); const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null);
@@ -65,17 +71,23 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
); );
const [autoOpen, setAutoOpen] = useState(true); const [autoOpen, setAutoOpen] = useState(true);
const suppressedAutoOpenKeyRef = useRef<string | null>(null); const suppressedAutoOpenKeyRef = useRef<string | null>(null);
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
const autoOpenRef = useRef(autoOpen);
const sidebar = useSidebarSafe(); const sidebar = useSidebarSafe();
const select = useCallback( const select = useCallback(
(artifact: string, autoSelect = false) => { (artifact: string, autoSelect = false) => {
setSelectedArtifact(artifact); const previewKey = artifactPreviewKey(artifact);
setSelectedArtifactKey(artifactPreviewKey(artifact)); selectedArtifactKeyRef.current = previewKey;
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") { if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false); sidebar?.setOpen(false);
} }
if (!autoSelect) { if (!autoSelect) {
setAutoSelect(false); setAutoSelect((previous) => (previous ? false : previous));
} }
}, },
[sidebar], [sidebar],
@@ -86,11 +98,13 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
if (suppressedAutoOpenKeyRef.current === previewKey) { if (suppressedAutoOpenKeyRef.current === previewKey) {
return false; return false;
} }
setSelectedArtifact(artifact); selectedArtifactKeyRef.current = previewKey;
setSelectedArtifactKey(previewKey); autoOpenRef.current = true;
setAutoSelect(true); setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
setAutoOpen(true); setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
setOpenState(true); setAutoSelect((previous) => (previous ? previous : true));
setAutoOpen((previous) => (previous ? previous : true));
setOpenState((previous) => (previous ? previous : true));
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") { if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false); sidebar?.setOpen(false);
} }
@@ -100,13 +114,26 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
); );
const deselect = useCallback(() => { const deselect = useCallback(() => {
setSelectedArtifact(null); selectedArtifactKeyRef.current = null;
setSelectedArtifactKey(null); setSelectedArtifact((previous) => (previous === null ? previous : null));
setAutoSelect(true); setSelectedArtifactKey((previous) => (previous === null ? previous : null));
setOpenState(false); setAutoSelect((previous) => (previous ? previous : true));
setOpenState((previous) => (previous ? false : previous));
suppressedAutoOpenKeyRef.current = null; suppressedAutoOpenKeyRef.current = null;
}, []); }, []);
const setOpen = useCallback((isOpen: boolean) => {
if (!isOpen && autoOpenRef.current) {
autoOpenRef.current = false;
setAutoOpen((previous) => (previous ? false : previous));
setAutoSelect((previous) => (previous ? false : previous));
}
if (!isOpen) {
suppressedAutoOpenKeyRef.current = selectedArtifactKeyRef.current;
}
setOpenState((previous) => (previous === isOpen ? previous : isOpen));
}, []);
const value: ArtifactsContextType = { const value: ArtifactsContextType = {
artifacts, artifacts,
setArtifacts, setArtifacts,
@@ -114,16 +141,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
open, open,
autoOpen, autoOpen,
autoSelect, autoSelect,
setOpen: (isOpen: boolean) => { setOpen,
if (!isOpen && autoOpen) {
setAutoOpen(false);
setAutoSelect(false);
}
if (!isOpen) {
suppressedAutoOpenKeyRef.current = selectedArtifactKey;
}
setOpenState(isOpen);
},
selectedArtifact, selectedArtifact,
selectedArtifactKey, selectedArtifactKey,
@@ -65,6 +65,22 @@
padding: 0 12px; padding: 0 12px;
} }
/* 收起态也要预留与展开态一致的左右边距。此前 56px 宽度叠加内边距后,图标卡只剩
约 36px 可用空间,导致图标与边框被挤变形。80px 壳体保留 56px 的完整点击区域。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="false"] {
width: 80px;
padding: 0 12px;
}
/* 第二步左侧各级内容卡沿用 8BF 的 4px 圆角;头像、状态徽标仍保持圆形。 */
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .rounded-2xl,
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .eightbf-roundtable__step2-sidebar-header > .rounded-2xl,
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .roundtable-step2-role-row,
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .roundtable-step2-human-row,
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .roundtable-step2-run-card {
border-radius: 4px !important;
}
/* 第二步输入区直接复用第一步 PositionIntentInput;共识动作也使用其 /* 第二步输入区直接复用第一步 PositionIntentInput;共识动作也使用其
#enter-roundtable-btn 规则,保证和「确认意图并启动业务链」完全同款。 */ #enter-roundtable-btn 规则,保证和「确认意图并启动业务链」完全同款。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-input { .dark .eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-input {
@@ -76,4 +76,22 @@
background: #21958a; background: #21958a;
} }
/* 可在此继续覆盖第 3 步报告、流程图与放大弹窗的样式。 */ /* 第三步只保留「总结报告 + 沙箱」。沿用第一、二步的 4px 面板语言。 */
.dark .eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable__step3-source-panel {
border: 1px solid rgb(43 143 254 / 50%);
border-radius: 4px;
background: #032650;
padding: 12px;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable__step3-report-panel {
border-color: rgb(43 143 254 / 50%);
border-radius: 4px !important;
background: #032650;
box-shadow: none;
}
/* PositionIntentInput is the shared Step 1/2 composer; Step 3 uses the exact same frame. */
.dark .eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-input {
flex: 0 0 auto;
}
@@ -8,7 +8,6 @@ import {
Sparkles, Sparkles,
X, X,
} from "lucide-react"; } from "lucide-react";
import { Select as TSelect } from "tdesign-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea"; import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { normalizeAgentQuestions, type AgentQuestion } from "@/core/agents"; import { normalizeAgentQuestions, type AgentQuestion } from "@/core/agents";
@@ -27,6 +26,8 @@ interface PositionIntentInputProps extends Step1InputAreaProps {
selectedModel?: string; selectedModel?: string;
onSelectedModelChange?: (model: string) => void; onSelectedModelChange?: (model: string) => void;
commonQuestions?: (string | AgentQuestion)[] | null; commonQuestions?: (string | AgentQuestion)[] | null;
/** 仅保留上传、模型选择和发送的紧凑输入区(用于会商第 3 步)。 */
hideUtilityActions?: boolean;
} }
/** Keep attachment metadata presentation local to this position-only composer. */ /** Keep attachment metadata presentation local to this position-only composer. */
@@ -67,6 +68,7 @@ export function PositionIntentInput({
selectedModel, selectedModel,
onSelectedModelChange, onSelectedModelChange,
commonQuestions, commonQuestions,
hideUtilityActions = false,
}: PositionIntentInputProps) { }: PositionIntentInputProps) {
// Files are owned by Step1Panel. This ref only opens the native picker and // Files are owned by Step1Panel. This ref only opens the native picker and
// forwards its selection through onAddFiles, so replacement layouts do not // forwards its selection through onAddFiles, so replacement layouts do not
@@ -76,6 +78,12 @@ export function PositionIntentInput({
const normalizedCommonQuestions = normalizeAgentQuestions(commonQuestions); const normalizedCommonQuestions = normalizeAgentQuestions(commonQuestions);
const visibleCommonQuestions = normalizedCommonQuestions.slice(0, 2); const visibleCommonQuestions = normalizedCommonQuestions.slice(0, 2);
const extraCommonQuestions = normalizedCommonQuestions.slice(2); const extraCommonQuestions = normalizedCommonQuestions.slice(2);
// 这里的模型列表会随着会商流的状态频繁刷新。TDesign Select 会在 options 的引用
// 每次变化时执行一次内部同步;流式收尾阶段恰好会形成 effect 更新循环并打断整页。
// 原生 select 是受控且无内部同步状态,三个步骤共用这个输入框后也能保持稳定。
const modelValue = modelOptions.some((option) => option.value === selectedModel)
? selectedModel
: (modelOptions[0]?.value ?? "");
const pickCommonQuestion = (question: AgentQuestion) => { const pickCommonQuestion = (question: AgentQuestion) => {
onValueChange(question.prompt); onValueChange(question.prompt);
@@ -242,16 +250,19 @@ export function PositionIntentInput({
title="鍒囨崲鎰忓浘鐞嗚В妯″瀷" title="鍒囨崲鎰忓浘鐞嗚В妯″瀷"
> >
<Sparkles className="size-3.5 shrink-0 text-[#73c8ff]" /> <Sparkles className="size-3.5 shrink-0 text-[#73c8ff]" />
<TSelect <select
size="small" aria-label="切换模型"
aria-label="鍒囨崲鎰忓浘鐞嗚В妯″瀷" value={modelValue}
value={selectedModel}
options={modelOptions}
disabled={isSending} disabled={isSending}
onChange={(value) => onSelectedModelChange?.(String(value))} onChange={(event) => onSelectedModelChange?.(event.target.value)}
className="w-[150px] shrink-0 [&_.t-input]:!h-7 [&_.t-input]:!rounded-[4px] [&_.t-input]:!border-0 [&_.t-input]:!bg-transparent [&_.t-input]:!px-1 [&_.t-input]:!text-xs [&_.t-input]:!font-bold [&_.t-input]:!text-[#dceaff] [&_.t-input]:!shadow-none" className="h-7 w-[150px] shrink-0 cursor-pointer appearance-none bg-transparent px-1 pr-5 text-xs font-bold text-[#dceaff] outline-none disabled:cursor-not-allowed disabled:opacity-60"
popupProps={{ overlayClassName: "z-[80]" }} >
/> {modelOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-[#0b3168] text-[#dceaff]">
{option.label}
</option>
))}
</select>
</div> </div>
)} )}
<button <button
@@ -271,25 +282,29 @@ export function PositionIntentInput({
<span className="sr-only">{isSending ? "暂停生成" : "发送"}</span> <span className="sr-only">{isSending ? "暂停生成" : "发送"}</span>
</button> </button>
<button {!hideUtilityActions && (
id="suggest-followup-btn" <>
type="button" <button
onClick={onFollowUp} id="suggest-followup-btn"
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]" type="button"
> onClick={onFollowUp}
<Repeat className="size-3 text-slate-500" /> className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
继续追问 >
</button> <Repeat className="size-3 text-slate-500" />
继续追问
</button>
<button <button
id="skip-question-btn" id="skip-question-btn"
type="button" type="button"
onClick={onSkip} onClick={onSkip}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]" className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
> >
<X className="size-3 text-slate-500" strokeWidth={2.5} /> <X className="size-3 text-slate-500" strokeWidth={2.5} />
跳过此项 跳过此项
</button> </button>
</>
)}
{footerAction ?? {footerAction ??
(!hideEnterRoundtable && ( (!hideEnterRoundtable && (
@@ -309,15 +324,17 @@ export function PositionIntentInput({
{enterButtonLabel} {enterButtonLabel}
</button> </button>
))} ))}
<button {!hideUtilityActions && (
id="save-draft-btn" <button
type="button" id="save-draft-btn"
onClick={onManualSave} type="button"
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]" onClick={onManualSave}
> className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
<Save className="size-3 text-slate-500" /> >
保存草稿 <Save className="size-3 text-slate-500" />
</button> 保存草稿
</button>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -584,18 +584,55 @@ export async function streamMultiAgent(
// Inferred SSE phase + a tiny helper so we only fire onPhaseChange when the // Inferred SSE phase + a tiny helper so we only fire onPhaseChange when the
// value actually changes (the reader loop sees many frames per phase). // value actually changes (the reader loop sees many frames per phase).
//
// `write_file` 的 `content`、`str_replace` 的 old_str/new_str 会在每个
// tool_call chunk 中持续增长。它们属于实时预览数据,已经由 onUpstreamFrame
// 写入 stub;若用它们判断 phase 变化,会在单次工具调用里反复触发 React 状态
// 更新,严重时会形成 Maximum update depth loop。因此这里只比较工具的身份入参。
const phaseArgsSignature = (args?: Record<string, unknown>) => {
if (!args) return "";
const streamedPayloadKeys = new Set([
"content",
"old_str",
"new_str",
"patch",
"diff",
]);
try {
return JSON.stringify(
Object.fromEntries(
Object.entries(args)
.filter(([key]) => !streamedPayloadKeys.has(key))
.sort(([left], [right]) => left.localeCompare(right)),
),
);
} catch {
return "[unserializable]";
}
};
let currentPhase: StreamPhase = "connecting"; let currentPhase: StreamPhase = "connecting";
let currentPhaseName = "";
let currentPhaseArgsSignature = "";
req.onPhaseChange?.("connecting"); req.onPhaseChange?.("connecting");
const setPhase = ( const setPhase = (
next: StreamPhase, next: StreamPhase,
name?: string, name?: string,
args?: Record<string, unknown>, args?: Record<string, unknown>,
) => { ) => {
// 同一个 phase + 同一个 toolName 才认为是"重复",此时不再回调上层; const nextName = name ?? "";
// 但 args 单独是新的(如同一工具被多次 call 携带不同入参)时也不回调 —— 上层 const nextArgsSignature = phaseArgsSignature(args);
// appendStepIfNew 用 `phase:toolName` 做 key 去重,本来也不会因 args 变化产生新步骤。 // 同 phase、同工具、同身份入参的帧只是流式重复帧,不通知 UI。保留 path/filepaths
if (next === currentPhase && !name) return; // 等身份变化,确保工具参数从不完整变完整时仍可自动打开沙箱。
if (
next === currentPhase &&
nextName === currentPhaseName &&
nextArgsSignature === currentPhaseArgsSignature
) {
return;
}
currentPhase = next; currentPhase = next;
currentPhaseName = nextName;
currentPhaseArgsSignature = nextArgsSignature;
req.onPhaseChange?.(next, name, args); req.onPhaseChange?.(next, name, args);
}; };
@@ -152,8 +152,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
return `圆桌总控结论-${short}`; return `圆桌总控结论-${short}`;
}, [intentReady?.objective]); }, [intentReady?.objective]);
// 第 3 步**主页** = 总结报告(进第三步默认自动跑「方案总结报告智能体」)。 // 第 3 步只保留「总结报告」工作台;旧的最终方案展示 / HTML 绘制入口不再开放。
// 用户点「最终方案展示」切到静态页(summaryMode=false),再从静态页可进「结果绘制」HTML。
const [summaryMode, setSummaryMode] = useState(true); const [summaryMode, setSummaryMode] = useState(true);
const [drawMode, setDrawMode] = useState(false); const [drawMode, setDrawMode] = useState(false);
// 文件沙箱(2026-06-13 改内联):点文件卡片在右侧滑入沙箱面板、左侧席位栏收起 // 文件沙箱(2026-06-13 改内联):点文件卡片在右侧滑入沙箱面板、左侧席位栏收起
@@ -443,12 +442,10 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
intentReady={intentReady} intentReady={intentReady}
selectedModel={selectedModel} selectedModel={selectedModel}
availableModels={availableModels} availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel} setSelectedModel={setSelectedModel}
coordinatorPrompt={coordinatorPrompt} coordinatorPrompt={coordinatorPrompt}
savedSummary={savedSummary} savedSummary={savedSummary}
summary={summaryFlow} summary={summaryFlow}
onViewStatic={() => setSummaryMode(false)}
taskArtifactSubmission={taskArtifactSubmission} taskArtifactSubmission={taskArtifactSubmission}
/> />
); );
@@ -1038,6 +1035,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
threadId={fileSandboxTarget.threadId} threadId={fileSandboxTarget.threadId}
onClose={handleCloseFileSandbox} onClose={handleCloseFileSandbox}
allowVirtualMarkdownEdit allowVirtualMarkdownEdit
headerLeadingActionBare
headerLeadingAction={( headerLeadingAction={(
<TaskArtifactSubmitButton <TaskArtifactSubmitButton
compact compact
@@ -24,8 +24,6 @@ import {
Loader2, Loader2,
Network, Network,
Paperclip, Paperclip,
Pause,
Play,
Sparkles, Sparkles,
X, X,
} from "lucide-react"; } from "lucide-react";
@@ -101,7 +99,6 @@ export interface Step2PanelProps {
// ─── State ──────────────────────────────────────────────────── // ─── State ────────────────────────────────────────────────────
step2RoundtableDialogues: Step2Dialogue[]; step2RoundtableDialogues: Step2Dialogue[];
isInitializing: boolean; isInitializing: boolean;
isRoundTableRunning: boolean;
isOrchestrating: boolean; isOrchestrating: boolean;
pendingClarification: ClarificationPayload | null; pendingClarification: ClarificationPayload | null;
clarificationDraft: string; clarificationDraft: string;
@@ -152,7 +149,6 @@ export interface Step2PanelProps {
setSelectedAgentId: React.Dispatch<React.SetStateAction<number | null>>; setSelectedAgentId: React.Dispatch<React.SetStateAction<number | null>>;
// ─── Handlers ───────────────────────────────────────────────── // ─── Handlers ─────────────────────────────────────────────────
onPauseRoundtable: () => void; onPauseRoundtable: () => void;
onResumeRoundtable: () => void;
onHandleInjectToRoundtable: (files?: File[]) => void; onHandleInjectToRoundtable: (files?: File[]) => void;
onSubmitClarification: (answer: string) => void; onSubmitClarification: (answer: string) => void;
onSavePersona: (id: number) => void; onSavePersona: (id: number) => void;
@@ -171,7 +167,6 @@ export function Step2Panel(props: Step2PanelProps) {
useStep1InputAppearance = false, useStep1InputAppearance = false,
step2RoundtableDialogues, step2RoundtableDialogues,
isInitializing, isInitializing,
isRoundTableRunning,
isOrchestrating, isOrchestrating,
pendingClarification, pendingClarification,
clarificationDraft, clarificationDraft,
@@ -203,7 +198,6 @@ export function Step2Panel(props: Step2PanelProps) {
setAgentConfigs, setAgentConfigs,
setSelectedAgentId, setSelectedAgentId,
onPauseRoundtable, onPauseRoundtable,
onResumeRoundtable,
onHandleInjectToRoundtable, onHandleInjectToRoundtable,
onSubmitClarification, onSubmitClarification,
onSavePersona, onSavePersona,
@@ -381,9 +375,6 @@ export function Step2Panel(props: Step2PanelProps) {
prevArtifactsOpenRef.current = artifactsOpenForFlow; prevArtifactsOpenRef.current = artifactsOpenForFlow;
}, [artifactsOpenForFlow]); }, [artifactsOpenForFlow]);
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
const [pauseFlash, setPauseFlash] = useState(false);
// 干预消息携带的本地附件(点「干预并发送」时连同指令一起上传)。 // 干预消息携带的本地附件(点「干预并发送」时连同指令一起上传)。
const [injectFiles, setInjectFiles] = useState<File[]>([]); const [injectFiles, setInjectFiles] = useState<File[]>([]);
@@ -406,13 +397,14 @@ export function Step2Panel(props: Step2PanelProps) {
const handleInject = useCallback(() => { const handleInject = useCallback(() => {
onHandleInjectToRoundtable(injectFiles.length > 0 ? injectFiles : undefined); onHandleInjectToRoundtable(injectFiles.length > 0 ? injectFiles : undefined);
setInjectFiles([]); setInjectFiles([]);
setPauseFlash(true);
window.setTimeout(() => setPauseFlash(false), 1600);
}, [onHandleInjectToRoundtable, injectFiles]); }, [onHandleInjectToRoundtable, injectFiles]);
const isSandboxStreaming = const isSandboxStreaming =
isOrchestrating || isOrchestrating ||
step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true); step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true);
// 顶部「会商运行」状态在等待用户输入时仍可能保持 true,不能据此把发送键误显示成暂停。
// 仅在初始化或实际流式输出时,沿用第一步输入框的暂停态。
const isStep2Responding = isInitializing || isSandboxStreaming;
// 顶部标题栏整片脱敏(消息列表正文不在此容器,保持原文)。 // 顶部标题栏整片脱敏(消息列表正文不在此容器,保持原文)。
const headerDzRef = useDesensitizeSubtree<HTMLDivElement>(); const headerDzRef = useDesensitizeSubtree<HTMLDivElement>();
@@ -456,30 +448,7 @@ export function Step2Panel(props: Step2PanelProps) {
</option> </option>
))} ))}
</select> </select>
{!isRoundTableRunning ? ( {/* 后台挂起:查看历史记录时隐藏(isViewingHistory),
<button
type="button"
onClick={onResumeRoundtable}
className={`bg-emerald-100 hover:bg-emerald-200 text-emerald-800 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-emerald-200/40 shadow-sm ${
pauseFlash
? "ring-2 ring-emerald-400 ring-offset-1 animate-pulse"
: ""
}`}
>
<Play className="w-3.5 h-3.5 fill-emerald-800" />
恢复会商
</button>
) : (
<button
type="button"
onClick={onPauseRoundtable}
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-amber-200/40 shadow-sm"
>
<Pause className="w-3.5 h-3.5" />
暂停会商
</button>
)}
{/* 后台挂起:放在暂停/恢复按钮右侧。查看历史记录时隐藏(isViewingHistory),
重新从第一步进入第二步研讨时(isViewingHistory 为假)正常显示。 */} 重新从第一步进入第二步研讨时(isViewingHistory 为假)正常显示。 */}
{SHOW_BACKGROUND_SUSPEND && !isViewingHistory && ( {SHOW_BACKGROUND_SUSPEND && !isViewingHistory && (
<button <button
@@ -661,7 +630,12 @@ export function Step2Panel(props: Step2PanelProps) {
</> </>
)} )}
</div> </div>
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />} {!step2AtBottom && (
<ScrollToBottomButton
onClick={scrollStep2ToBottom}
className="!bottom-1/2 translate-y-1/2"
/>
)}
</div> </div>
{/* Clarification card */} {/* Clarification card */}
@@ -748,11 +722,11 @@ export function Step2Panel(props: Step2PanelProps) {
onAddFiles={addInjectFiles} onAddFiles={addInjectFiles}
onRemoveFile={removeInjectFile} onRemoveFile={removeInjectFile}
charactersLeft={Math.max(0, 500 - customAgentInput.length)} charactersLeft={Math.max(0, 500 - customAgentInput.length)}
isSending={false} isSending={isStep2Responding}
canSend canSend={customAgentInput.trim().length > 0 || injectFiles.length > 0}
pendingClarification={false} pendingClarification={false}
onSend={handleInject} onSend={handleInject}
onStop={() => undefined} onStop={onPauseRoundtable}
onFollowUp={() => undefined} onFollowUp={() => undefined}
onSkip={() => undefined} onSkip={() => undefined}
onEnterRoundtable={() => undefined} onEnterRoundtable={() => undefined}
@@ -1199,14 +1173,13 @@ function PresentFileCard({
{filename} {filename}
</span> </span>
</div> </div>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="roundtable-chat-meta rounded bg-slate-100 px-1.5 py-0.5 font-mono font-semibold text-slate-500">
{typeLabel}
</span>
<span className="roundtable-chat-meta text-slate-400">点击预览</span>
</div>
</button> </button>
{submitButton && <div className="mt-1 flex justify-end">{submitButton}</div>} <div className="flex min-w-0 items-center justify-between gap-2">
<span className="roundtable-chat-meta rounded bg-slate-100 px-1.5 py-0.5 font-mono font-semibold text-slate-500">
{typeLabel}
</span>
{submitButton}
</div>
</div> </div>
); );
} }
@@ -29,6 +29,42 @@ import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib
const CLOSE_MODE = { chat: 100, artifacts: 0 }; const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 60, artifacts: 40 }; const OPEN_MODE = { chat: 60, artifacts: 40 };
/** 从 write_file 虚拟地址反查流式工具调用中的正文,供任务产物提交直接使用。 */
function virtualMarkdownContent(
artifact: string | null,
messages: unknown[] | undefined,
): string | undefined {
if (!artifact?.startsWith("write-file:") || !messages?.length) return undefined;
try {
const url = new URL(artifact);
const toolCallId = url.searchParams.get("tool_call_id");
const path = decodeURIComponent(url.pathname).trim();
for (const message of messages) {
if (!message || typeof message !== "object") continue;
const toolCalls = (message as { tool_calls?: unknown }).tool_calls;
if (!Array.isArray(toolCalls)) continue;
for (const call of toolCalls) {
if (!call || typeof call !== "object") continue;
const toolCall = call as {
id?: unknown;
name?: unknown;
args?: { path?: unknown; content?: unknown };
};
if (toolCall.name !== "write_file") continue;
if (toolCallId && toolCall.id !== toolCallId) continue;
if (!toolCallId && toolCall.args?.path !== path) continue;
return typeof toolCall.args?.content === "string"
? toolCall.args.content
: undefined;
}
}
} catch {
// 虚拟路径解析失败时交给提交层走它原有的真实文件读取兜底。
}
return undefined;
}
interface Step2SandboxLayoutProps { interface Step2SandboxLayoutProps {
children?: React.ReactNode; children?: React.ReactNode;
/** 当前预览文件所属的席位 thread id。 */ /** 当前预览文件所属的席位 thread id。 */
@@ -73,6 +109,10 @@ export function Step2SandboxLayout({
deselect, deselect,
} = useArtifacts(); } = useArtifacts();
const artifacts = rawArtifacts ?? []; const artifacts = rawArtifacts ?? [];
const selectedArtifactContent = useMemo(
() => virtualMarkdownContent(selectedArtifact, seatMessages),
[selectedArtifact, seatMessages],
);
const artifactPanelOpen = artifactsOpen; const artifactPanelOpen = artifactsOpen;
// sidePanel(流程图等)非空时即使沙箱没开也要把右栏撑开。 // sidePanel(流程图等)非空时即使沙箱没开也要把右栏撑开。
@@ -120,9 +160,9 @@ export function Step2SandboxLayout({
filepath={selectedArtifact} filepath={selectedArtifact}
threadId={previewThreadId} threadId={previewThreadId}
onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined} onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined}
allowVirtualMarkdownEdit
// 会商「消息列表沙箱」按需求不脱敏产物内容(用户要拿原始文件)。 // 会商「消息列表沙箱」按需求不脱敏产物内容(用户要拿原始文件)。
disableDesensitize disableDesensitize
headerLeadingActionBare
headerLeadingAction={( headerLeadingAction={(
<TaskArtifactSubmitButton <TaskArtifactSubmitButton
compact compact
@@ -130,6 +170,7 @@ export function Step2SandboxLayout({
target={taskArtifactSubmission?.targetFor({ target={taskArtifactSubmission?.targetFor({
threadId: previewThreadId, threadId: previewThreadId,
path: selectedArtifact, path: selectedArtifact,
content: selectedArtifactContent,
}) ?? { threadId: previewThreadId, path: selectedArtifact }} }) ?? { threadId: previewThreadId, path: selectedArtifact }}
/> />
)} )}
@@ -9,33 +9,27 @@
* 布局对齐 Step3ReportMode(HTML 绘制):左栏研讨原料源卡 + 右栏沙箱。两者并存、 * 布局对齐 Step3ReportMode(HTML 绘制):左栏研讨原料源卡 + 右栏沙箱。两者并存、
* 互不影响(report 画 HTML 看板,summary 写 Markdown 报告 + 问答)。 * 互不影响(report 画 HTML 看板,summary 写 Markdown 报告 + 问答)。
*/ */
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { import {
ArrowRight,
CheckCircle, CheckCircle,
Database,
Download, Download,
Eye, Eye,
FileText, FileText,
Loader2, Loader2,
PanelLeftClose, PanelLeftClose,
PanelLeftOpen, PanelLeftOpen,
Paperclip,
Pause,
RefreshCw, RefreshCw,
SendHorizontal,
Sparkles, Sparkles,
Workflow,
X, X,
} from "lucide-react"; } from "lucide-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { useArtifacts } from "@/components/workspace/artifacts"; import { useArtifacts } from "@/components/workspace/artifacts";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
import { import {
buildArtifactDownloadUrl, buildArtifactDownloadUrl,
listSessionArtifacts, listSessionArtifacts,
@@ -44,12 +38,6 @@ import {
} from "@/roundtable-planning/api/multi-agent"; } from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal"; import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton"; import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
import {
detectFlowIntent,
extractFlowGraph,
FLOW_FENCE_OPEN,
} from "@/roundtable-planning/lib/flow-graph";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel"; import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
@@ -80,7 +68,6 @@ interface Step3SummaryModeProps {
intentReady: ReportIntent | null; intentReady: ReportIntent | null;
selectedModel: string; selectedModel: string;
availableModels: ReadonlyArray<ModelOption>; availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void; setSelectedModel: (model: string) => void;
/** 业务链写给总控的编排提示 —— 注入总结智能体作为最高优先级口径。 */ /** 业务链写给总控的编排提示 —— 注入总结智能体作为最高优先级口径。 */
coordinatorPrompt?: string | null; coordinatorPrompt?: string | null;
@@ -88,8 +75,6 @@ interface Step3SummaryModeProps {
savedSummary?: DraftStep3SummarySnapshot | null; savedSummary?: DraftStep3SummarySnapshot | null;
/** 页面层常驻的总结生成流(2026-06-13 提升:切走不断流,进入本视图续看)。 */ /** 页面层常驻的总结生成流(2026-06-13 提升:切走不断流,进入本视图续看)。 */
summary: UseStep3SummaryReturn; summary: UseStep3SummaryReturn;
/** 切到「最终方案展示」静态页(次要入口)。 */
onViewStatic: () => void;
taskArtifactSubmission?: TaskArtifactSubmissionController; taskArtifactSubmission?: TaskArtifactSubmissionController;
} }
@@ -105,12 +90,10 @@ export function Step3SummaryMode({
intentReady, intentReady,
selectedModel, selectedModel,
availableModels, availableModels,
modelsLoading,
setSelectedModel, setSelectedModel,
coordinatorPrompt, coordinatorPrompt,
savedSummary, savedSummary,
summary, summary,
onViewStatic,
taskArtifactSubmission, taskArtifactSubmission,
}: Step3SummaryModeProps) { }: Step3SummaryModeProps) {
void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容 void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容
@@ -144,42 +127,7 @@ export function Step3SummaryMode({
const { open: artifactsOpen } = useArtifacts(); const { open: artifactsOpen } = useArtifacts();
// ── 结构化流程图(Phase 1:后端「抽取+校验」)──────────────────────────── // 沙箱里已写出的 Markdown 总结报告。
// 数据来源优先级:
// · flowPhase==="done" → 用后端校验后的结构化图 summary.flowGraph 绘制;
// · 抽取/校验进行中 → 不画(用户看对话气泡里实时滚动的 JSON + 状态条),等校验完成再绘制;
// · idle/error → 回退到对话气泡里解析 flow-json(手动 flow 续问路径 / 旧逻辑兜底)。
const flowGraph = useMemo(() => {
if (summary.flowPhase === "done" && summary.flowGraph) {
return { graph: summary.flowGraph, streaming: false };
}
if (summary.flowPhase === "extracting" || summary.flowPhase === "validating") {
return null;
}
for (let i = dialogues.length - 1; i >= 0; i--) {
const d = dialogues[i];
if (d.avatarType === "human") continue;
const content = d.content ?? "";
if (!content.includes(FLOW_FENCE_OPEN)) continue;
const graph = extractFlowGraph(content);
if (graph) return { graph, streaming: d.streaming === true };
}
return null;
}, [dialogues, summary.flowPhase, summary.flowGraph]);
const [flowView, setFlowView] = useState(false);
const flowAutoOpenedRef = useRef(false);
useEffect(() => {
if (!flowGraph || flowGraph.graph.nodes.length === 0) return;
if (flowAutoOpenedRef.current) return;
flowAutoOpenedRef.current = true;
// taskId/深链模式:结构化输出是本模式的主交付 → 流程图一出现就自动聚焦画布
// (即便此前 md 沙箱已自动打开,也切到流程图)。
// 普通模式:没开沙箱才自动打开;沙箱开着不抢占,只露出切换按钮。
if (summary.autoFlow || !artifactsOpen) setFlowView(true);
}, [flowGraph, artifactsOpen, summary.autoFlow]);
// 沙箱里已写出的 md 报告(流程图画布上提供「MD 报告」切回按钮的锚点)。
const mdArtifact = useMemo(() => { const mdArtifact = useMemo(() => {
for (const [threadId, msgs] of Object.entries(seatStubMessages)) { for (const [threadId, msgs] of Object.entries(seatStubMessages)) {
const msg = msgs?.[0]; const msg = msgs?.[0];
@@ -200,25 +148,16 @@ export function Step3SummaryMode({
return null; return null;
}, [seatStubMessages]); }, [seatStubMessages]);
// 用户在输入框里明确要求结构化输出/流程图 → 立即切到流程图画布(旧图先顶着, const handleAsk = (message: string, files?: File[]) => ask(message, files);
// 新 flow-json 流出来就增量重绘;首次还没有图时画布在数据到达后自动渲染)。
const handleAsk = (message: string, files?: File[]) => {
if (detectFlowIntent(message)) {
flowAutoOpenedRef.current = true;
setFlowView(true);
}
ask(message, files);
};
// 流程图抽取/校验的过程(思考 `<think>` + 文字说明 + flow-json 代码块)全部原样交给 // 报告生成过程(思考、文字说明、文件写入步骤)交由 Step2Message 原样渲染,
// Step2Message 渲染:它用 splitInlineReasoning 把思考折叠成思考块、其余按 markdown 渲染 // 保持与第二步一致的流式过程可见性。
// (flow-json 显示为代码块)。用户要求显示完整过程、不只显示代码块,故不再 strip。
const displayDialogues = dialogues; const displayDialogues = dialogues;
const [leftCollapsed, setLeftCollapsed] = useState(false); const [leftCollapsed, setLeftCollapsed] = useState(false);
// 右侧面板(沙箱预览或流程图画布)露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。 // 右侧沙箱露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
// 初值固定为 false:这样即使「进入/续看时沙箱已是打开态」(rightPanelVisible 一上来就为 true), // 初值固定为 false:这样即使「进入/续看时沙箱已是打开态」(rightPanelVisible 一上来就为 true),
// 首个 effect 也能识别 false→true 的边沿并收起左栏(修此前已打开时左栏不收起的问题)。 // 首个 effect 也能识别 false→true 的边沿并收起左栏(修此前已打开时左栏不收起的问题)。
const rightPanelVisible = artifactsOpen || (!!flowGraph && flowView); const rightPanelVisible = artifactsOpen;
const prevRightPanelVisibleRef = useRef(false); const prevRightPanelVisibleRef = useRef(false);
useEffect(() => { useEffect(() => {
if (rightPanelVisible !== prevRightPanelVisibleRef.current) { if (rightPanelVisible !== prevRightPanelVisibleRef.current) {
@@ -292,23 +231,16 @@ export function Step3SummaryMode({
<RefreshCw className="w-3.5 h-3.5 text-slate-500" /> <RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新生成 重新生成
</button> </button>
<button
type="button"
onClick={onViewStatic}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
最终方案展示
<ArrowRight className="w-3.5 h-3.5 text-slate-500" />
</button>
</div> </div>
</div> </div>
{/* 主体:左源卡 + 右沙箱 */} {/* 主体:左源卡 + 右沙箱 */}
<div className="flex-1 min-h-0 flex gap-3 p-3"> <div className="flex-1 min-h-0 flex gap-3 p-3">
<aside <aside
data-step3-source-panel
className={cn( className={cn(
"shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5 transition-[width] duration-200 ease-out", "eightbf-roundtable__step3-source-panel shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5 transition-[width] duration-200 ease-out",
leftCollapsed ? "w-[52px]" : "w-[280px]", leftCollapsed ? "w-[80px]" : "w-[280px]",
)} )}
> >
{leftCollapsed ? ( {leftCollapsed ? (
@@ -318,7 +250,7 @@ export function Step3SummaryMode({
onClick={() => setLeftCollapsed(false)} onClick={() => setLeftCollapsed(false)}
title="展开研讨原料" title="展开研讨原料"
aria-expanded={false} aria-expanded={false}
className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]" className="w-full shrink-0 flex h-10 items-center justify-center rounded-[4px] border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
> >
<PanelLeftOpen className="h-4 w-4" /> <PanelLeftOpen className="h-4 w-4" />
</button> </button>
@@ -351,7 +283,7 @@ export function Step3SummaryMode({
onClick={() => setLeftCollapsed(true)} onClick={() => setLeftCollapsed(true)}
title="收起研讨原料" title="收起研讨原料"
aria-expanded aria-expanded
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors" className="shrink-0 flex h-7 w-7 items-center justify-center rounded-[4px] border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors"
> >
<PanelLeftClose className="h-3.5 w-3.5" /> <PanelLeftClose className="h-3.5 w-3.5" />
</button> </button>
@@ -379,39 +311,23 @@ export function Step3SummaryMode({
onClick={() => setModal("files")} onClick={() => setModal("files")}
/> />
<div className="mt-1 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-3 text-[11px] leading-relaxed text-slate-400"> <div className="mt-1 rounded-[4px] border border-dashed border-slate-200 bg-slate-50/60 p-3 text-[11px] leading-relaxed text-slate-400">
右侧由「方案总结报告智能体」据这些原料撰写 Markdown 总结报告,可在下方输入框继续提修改意见或提问。 右侧由「方案总结报告智能体」据这些原料撰写 Markdown 总结报告,可在下方输入框继续提修改意见或提问。
</div> </div>
</> </>
)} )}
</aside> </aside>
{/* 右栏:智能体进度 + 沙箱(复用第 2 步)。流程图画布放在右侧沙箱位 {/* 右栏:智能体进度 + Markdown 沙箱(复用第 2 步)。 */}
(sidePanel 插槽),消息列表保持可见,可与 md 沙箱预览互相切换。 */} <section
<section className="relative flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden"> data-step3-report-panel
className="eightbf-roundtable__step3-report-panel relative flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden"
>
<Step2SandboxLayout <Step2SandboxLayout
activeThreadId={activeArtifactThreadId ?? summaryThreadId} activeThreadId={activeArtifactThreadId ?? summaryThreadId}
isStreaming={isStreaming} isStreaming={isStreaming}
seatMessages={activeSeatMessages} seatMessages={activeSeatMessages}
taskArtifactSubmission={taskArtifactSubmission} taskArtifactSubmission={taskArtifactSubmission}
sidePanel={
flowGraph && flowView ? (
<FlowGraphPanel
data={flowGraph.graph}
streaming={flowGraph.streaming}
onClose={() => setFlowView(false)}
onSwitchToReport={
mdArtifact
? () => {
setFlowView(false);
openArtifactPreview(mdArtifact.threadId, mdArtifact.path);
}
: undefined
}
onChange={summary.setFlowGraph}
/>
) : undefined
}
> >
<AgentProgressPanel <AgentProgressPanel
phase={phase} phase={phase}
@@ -419,18 +335,6 @@ export function Step3SummaryMode({
error={error} error={error}
busy={busy} busy={busy}
mdAvailable={!!mdArtifact} mdAvailable={!!mdArtifact}
flowPhase={summary.flowPhase}
onRegenerateFlow={summary.regenerateFlow}
pendingClarification={summary.pendingFlowClarification}
onResolveClarification={summary.resolveFlowClarification}
ingestAvailable={summary.ingestAvailable}
ingestStatus={summary.ingestStatus}
pendingIngestClarification={summary.pendingIngestClarification}
onResolveIngestChoice={summary.resolveIngestChoice}
onResolveIngestClarification={summary.resolveIngestClarification}
pendingFlowForIngest={summary.pendingFlowForIngest}
onResolveFlowForIngest={summary.resolveFlowForIngest}
onManualIngest={summary.runIngest}
onRetry={redraw} onRetry={redraw}
onAsk={handleAsk} onAsk={handleAsk}
onPause={pause} onPause={pause}
@@ -438,32 +342,11 @@ export function Step3SummaryMode({
onArtifactPreview={openArtifactPreview} onArtifactPreview={openArtifactPreview}
selectedModel={selectedModel} selectedModel={selectedModel}
availableModels={availableModels} availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel} setSelectedModel={setSelectedModel}
taskArtifactSubmission={taskArtifactSubmission} taskArtifactSubmission={taskArtifactSubmission}
/> />
</Step2SandboxLayout> </Step2SandboxLayout>
{/* 流程图入口按钮(有结构化数据且画布未开时显示;沙箱开着时即「切换」按钮) */}
{flowGraph && !flowView && (
<button
type="button"
onClick={() => setFlowView(true)}
title={artifactsOpen ? "切换到流程图画布" : "打开流程图画布"}
className="absolute bottom-4 right-4 z-20 inline-flex items-center gap-1.5 rounded-full border border-primary/30 bg-white px-3.5 py-2 text-[11px] font-bold text-primary shadow-lg transition hover:bg-primary/5"
>
{flowGraph.streaming ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Workflow className="h-3.5 w-3.5" />
)}
流程图
<span className="rounded-full bg-primary/10 px-1.5 font-mono text-[10px]">
{flowGraph.graph.nodes.length}
</span>
</button>
)}
</section> </section>
</div> </div>
@@ -527,7 +410,7 @@ function SourceCard({
type="button" type="button"
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className="text-left bg-white border border-slate-200/60 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:border-primary/40 hover:shadow-[0_4px_14px_rgba(0,0,0,0.04)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-slate-200/60" className="text-left bg-white border border-slate-200/60 rounded-[4px] p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:border-primary/40 hover:shadow-[0_4px_14px_rgba(0,0,0,0.04)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-slate-200/60"
> >
<div className="flex items-center gap-2 mb-1.5"> <div className="flex items-center gap-2 mb-1.5">
{icon} {icon}
@@ -560,7 +443,7 @@ function CollapsedSourceCard({
title={title} title={title}
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
className="w-full shrink-0 flex items-center justify-center rounded-2xl border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30 disabled:opacity-40 disabled:cursor-not-allowed" className="w-full shrink-0 flex items-center justify-center rounded-[4px] border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30 disabled:opacity-40 disabled:cursor-not-allowed"
> >
{icon} {icon}
</button> </button>
@@ -575,18 +458,6 @@ function AgentProgressPanel({
error, error,
busy, busy,
mdAvailable, mdAvailable,
flowPhase,
onRegenerateFlow,
pendingClarification,
onResolveClarification,
ingestAvailable,
ingestStatus,
pendingIngestClarification,
onResolveIngestChoice,
onResolveIngestClarification,
pendingFlowForIngest,
onResolveFlowForIngest,
onManualIngest,
onRetry, onRetry,
onAsk, onAsk,
onPause, onPause,
@@ -594,7 +465,6 @@ function AgentProgressPanel({
onArtifactPreview, onArtifactPreview,
selectedModel, selectedModel,
availableModels, availableModels,
modelsLoading,
setSelectedModel, setSelectedModel,
taskArtifactSubmission, taskArtifactSubmission,
}: { }: {
@@ -602,25 +472,8 @@ function AgentProgressPanel({
dialogues: UseStep3SummaryReturn["dialogues"]; dialogues: UseStep3SummaryReturn["dialogues"];
error: string | null; error: string | null;
busy: boolean; busy: boolean;
/** 沙箱里是否已有 md 报告 —— 决定提示语(纯流程图轮没有 .md 可预览/下载)。 */ /** 沙箱里是否已有 Markdown 总结报告。 */
mdAvailable: boolean; mdAvailable: boolean;
/** 后端流程图抽取阶段(Phase 1)。 */
flowPhase: UseStep3SummaryReturn["flowPhase"];
/** 仅重新生成流程图(模型挂了切模型后重试)。 */
onRegenerateFlow: () => void;
/** 意图模糊待澄清的原始消息(非空 → 展示选项让用户定)。 */
pendingClarification: string | null;
onResolveClarification: UseStep3SummaryReturn["resolveFlowClarification"];
/** 入库技能是否可用(决定入库询问卡 + 手动入库按钮是否出现)。 */
ingestAvailable: boolean;
ingestStatus: UseStep3SummaryReturn["ingestStatus"];
pendingIngestClarification: string | null;
onResolveIngestChoice: UseStep3SummaryReturn["resolveIngestChoice"];
onResolveIngestClarification: UseStep3SummaryReturn["resolveIngestClarification"];
/** 入库时尚无流程图 → 展示「是否先生成流程图」协助。 */
pendingFlowForIngest: boolean;
onResolveFlowForIngest: UseStep3SummaryReturn["resolveFlowForIngest"];
onManualIngest: UseStep3SummaryReturn["runIngest"];
onRetry: () => void; onRetry: () => void;
onAsk: (message: string, files?: File[]) => void; onAsk: (message: string, files?: File[]) => void;
onPause: () => void; onPause: () => void;
@@ -628,14 +481,13 @@ function AgentProgressPanel({
onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"]; onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"];
selectedModel: string; selectedModel: string;
availableModels: ReadonlyArray<ModelOption>; availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void; setSelectedModel: (model: string) => void;
taskArtifactSubmission?: TaskArtifactSubmissionController; taskArtifactSubmission?: TaskArtifactSubmissionController;
}) { }) {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
// 新一轮气泡出现(如:md 报告完成后自动续跑的结构化输出轮、用户发问)→ 强制滚到底, // 新一轮气泡出现(报告生成或用户续问)→ 强制滚到底,
// 让用户看到新阶段(修「调用结构化输出时不自动下滑」)。仅在气泡**条数**增加时触发, // 让用户看到新阶段。仅在气泡**条数**增加时触发,
// 流式增量内容仍由 useAutoScroll 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。 // 流式增量内容仍由 useAutoScroll 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。
const prevDialogueCountRef = useRef(dialogues.length); const prevDialogueCountRef = useRef(dialogues.length);
useEffect(() => { useEffect(() => {
@@ -651,22 +503,18 @@ function AgentProgressPanel({
const [askText, setAskText] = useState(""); const [askText, setAskText] = useState("");
// 输入框携带的本地附件(点发送时连同指令一起上传到总结线程)。 // 输入框携带的本地附件(点发送时连同指令一起上传到总结线程)。
const [askFiles, setAskFiles] = useState<File[]>([]); const [askFiles, setAskFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const pickFiles = (e: React.ChangeEvent<HTMLInputElement>) => { const addAskFiles = (picked: File[]) => {
const picked = Array.from(e.target.files ?? []);
if (picked.length > 0) { if (picked.length > 0) {
setAskFiles((prev) => { setAskFiles((prev) => {
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`)); const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))]; return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
}); });
} }
e.target.value = "";
}; };
const removeFile = (idx: number) => setAskFiles((prev) => prev.filter((_, i) => i !== idx)); const removeFile = (idx: number) => setAskFiles((prev) => prev.filter((_, i) => i !== idx));
const handleAskSubmit = (e: FormEvent<HTMLFormElement>) => { const submitAsk = () => {
e.preventDefault();
const text = askText.trim(); const text = askText.trim();
const hasFiles = askFiles.length > 0; const hasFiles = askFiles.length > 0;
if (!text && !hasFiles) return; // 允许「随时」发送(含生成中);空内容不发 if (!text && !hasFiles) return; // 允许「随时」发送(含生成中);空内容不发
@@ -677,7 +525,7 @@ function AgentProgressPanel({
return ( return (
<div className="flex h-full min-h-0 flex-col bg-slate-50/20"> <div className="flex h-full min-h-0 flex-col bg-slate-50/20">
<div className="shrink-0 flex items-center justify-between gap-2 border-b border-slate-100 bg-white px-4 py-2.5"> <div className="shrink-0 flex items-center gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm"> <div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
报 报
@@ -687,38 +535,11 @@ function AgentProgressPanel({
<div className="text-[10px] text-slate-400 truncate">据各席位成果撰写 Markdown 总结报告</div> <div className="text-[10px] text-slate-400 truncate">据各席位成果撰写 Markdown 总结报告</div>
</div> </div>
</div> </div>
<select
id="step3-summary-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={busy || modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: busy
? "撰写中不可切换;完成后或重新生成时生效"
: "切换撰写使用的推理模型(下次生成生效)"
}
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
</div> </div>
{/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */} {/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */}
<div className="relative flex-1 min-h-0 flex flex-col"> <div className="relative flex-1 min-h-0">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-4 space-y-4 bg-white"> <div ref={scrollRef} className="size-full overflow-y-auto overflow-x-hidden p-4 space-y-4 bg-white">
{phase === "creating" && dialogues.length === 0 && ( {phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500"> <div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" /> <Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
@@ -742,9 +563,7 @@ function AgentProgressPanel({
{busy && ( {busy && (
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500"> <div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
<span className="bg-primary size-1.5 animate-pulse rounded-full" /> <span className="bg-primary size-1.5 animate-pulse rounded-full" />
{mdAvailable {mdAvailable ? "报告正文实时输出,右侧沙箱同步预览 Markdown" : "正在撰写 Markdown 总结报告"}
? "报告正文实时输出,右侧沙箱同步预览 Markdown"
: "报告正文实时输出;输出结构化数据时流程图画布同步绘制"}
</div> </div>
)} )}
@@ -763,243 +582,54 @@ function AgentProgressPanel({
</div> </div>
)} )}
{/* 后端流程图「抽取+校验」状态条(Phase 1)。 */}
{(flowPhase === "extracting" || flowPhase === "validating") && (
<div className="inline-flex items-center gap-2 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-bold text-blue-600">
<Loader2 className="w-3 h-3 animate-spin" />
{flowPhase === "extracting" ? "结构化流程图抽取中(JSON 实时输出)…" : "流程图校验 / 修复中…"}
</div>
)}
{flowPhase === "error" && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<div className="font-bold mb-1">流程图生成失败</div>
<div className="leading-relaxed">模型可能不稳定。可在上方切换模型后重试。</div>
<button
type="button"
onClick={onRegenerateFlow}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-amber-800 underline hover:text-amber-900"
>
<RefreshCw className="w-3 h-3" />
重新生成流程图
</button>
</div>
)}
{phase === "done" && ( {phase === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600"> <div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" /> <CheckCircle className="w-4 h-4" />
{mdAvailable {mdAvailable
? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告 / 流程图或提问。" ? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告或提问。"
: "已生成方案总结,流程图可在画布查看;可在下方继续修改总结 / 流程图或提问。"} : "已生成方案总结,可在下方继续修改报告或提问。"}
</div> </div>
)} )}
</div> </div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />} {!atBottom && (
<ScrollToBottomButton
{/* 意图模糊 → 让用户自己定(不自作主张) */} onClick={scrollToBottom}
{pendingClarification && ( className="!bottom-1/2 translate-y-1/2"
<div className="shrink-0 border-t border-amber-100 bg-amber-50/60 px-3 py-2.5"> />
<div className="mb-1.5 text-[11px] font-bold text-amber-800">
没太理解你的意思,你是想——
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "regenerate_report", t: "重新生成报告" },
{ k: "modify_flow", t: "修改流程图" },
{ k: "regenerate_flow", t: "重新生成流程图" },
{ k: "qa", t: "只是提问" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveClarification(o.k)}
className="rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-amber-800 transition hover:bg-amber-100"
>
{o.t}
</button>
))}
</div>
</div>
)} )}
</div>
{/* 流程图已生成 → 询问是否入库(入库技能可用时)。 */} <div className="position-roundtable-intent-page eightbf-roundtable-step3-input shrink-0">
{ingestAvailable && ingestStatus === "asking" && ( <PositionIntentInput
<div className="shrink-0 border-t border-emerald-100 bg-emerald-50/60 px-3 py-2.5"> value={askText}
<div className="mb-1.5 text-[11px] font-bold text-emerald-800"> onValueChange={setAskText}
流程图已生成,是否进行数据入库? attachedFiles={askFiles}
</div> onAddFiles={addAskFiles}
<div className="flex flex-wrap gap-1.5"> onRemoveFile={removeFile}
{( charactersLeft={Math.max(0, 500 - askText.length)}
[ isSending={busy}
{ k: "now", t: "现在入库" }, canSend={askText.trim().length > 0 || askFiles.length > 0}
{ k: "later", t: "稍后入库" }, pendingClarification={false}
{ k: "never", t: "不入库" }, onSend={submitAsk}
] as const onStop={onPause}
).map((o) => ( onFollowUp={() => undefined}
<button onSkip={() => undefined}
key={o.k} hideUtilityActions
type="button" onEnterRoundtable={() => undefined}
onClick={() => onResolveIngestChoice(o.k)} enterButtonLabel=""
className="rounded-lg border border-emerald-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-emerald-800 transition hover:bg-emerald-100" intentReady={null}
> hideEnterRoundtable
{o.t} onManualSave={() => undefined}
</button> onTriggerToast={() => undefined}
))} modelOptions={availableModels.map((model) => ({
</div> label: model.display_name && model.display_name !== model.name
</div> ? `${model.display_name} (${model.name})`
)} : model.name,
value: model.name,
{/* 入库时尚无流程图 → 协助:数据入库以流程图为核心内容,先问是否生成流程图。 */} }))}
{ingestAvailable && pendingFlowForIngest && ( selectedModel={selectedModel}
<div className="shrink-0 border-t border-amber-100 bg-amber-50/70 px-3 py-2.5"> onSelectedModelChange={setSelectedModel}
<div className="mb-1.5 text-[11px] font-bold text-amber-800"> />
当前还没有生成流程图,数据入库需要流程图。是否先生成流程图?
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "generate", t: "生成流程图" },
{ k: "cancel", t: "暂不入库" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveFlowForIngest(o.k)}
className="rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-amber-800 transition hover:bg-amber-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
{/* 「稍后入库」自由文字意图模糊 → 让用户在入库语境下自己定。 */}
{ingestAvailable && pendingIngestClarification && (
<div className="shrink-0 border-t border-emerald-100 bg-emerald-50/60 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-emerald-800">
没太理解你的入库意思,你是想——
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "start_ingest", t: "开始入库" },
{ k: "add_data", t: "补充内容后再入库" },
{ k: "other", t: "只是提问 / 改报告" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveIngestClarification(o.k)}
className="rounded-lg border border-emerald-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-emerald-800 transition hover:bg-emerald-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
<form onSubmit={handleAskSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
{/* 已选附件预览(发送时随指令上传到总结线程,供智能体读取) */}
{askFiles.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{askFiles.map((file, idx) => (
<span
key={`${file.name}-${file.size}-${idx}`}
className="inline-flex items-center gap-1.5 bg-white border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
>
<FileText className="w-3 h-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-slate-400">{formatBytes(file.size)}</span>
<button
type="button"
onClick={() => removeFile(idx)}
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
title="移除"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
<AutoGrowTextarea
maxHeight={224}
value={askText}
onChange={(e) => setAskText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
rows={1}
placeholder={
busy
? "生成中也可随时插话干预:发送将打断当前轮并据此继续…"
: "继续对话:改报告(如「执行路线写细些」)/ 改流程图(如「总结成结构化输出」「流程图加上验收环节」)/ 提问"
}
className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none"
/>
{/* 手动入库(入库技能可用、且已过流程图询问阶段时显示):直接在总结线程跑入库技能。 */}
{ingestAvailable &&
ingestStatus !== "asking" &&
ingestStatus !== "running" &&
ingestStatus !== "idle" && (
<button
type="button"
onClick={() => onManualIngest()}
title="进行数据入库(由结构化输出智能体调用入库技能,过程见消息列表步骤条)"
className="mb-0.5 flex h-8 shrink-0 items-center gap-1 rounded-xl border border-emerald-200 bg-emerald-50 px-2.5 text-[11px] font-bold text-emerald-700 transition hover:bg-emerald-100 active:scale-95"
>
<Database className="h-3.5 w-3.5" />
数据入库
</button>
)}
{/* 上传文件(参考第一/二步:本地选取,发送时上传到总结线程) */}
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={pickFiles} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="上传参考文件(智能体将读取文件内容辅助总结/修改)"
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white border border-slate-200 text-slate-500 hover:text-primary hover:bg-slate-50 transition active:scale-95"
>
<Paperclip className="h-4 w-4" />
</button>
{/* 暂停(仅生成中显示) */}
{busy && (
<button
type="button"
onClick={onPause}
title="暂停当前生成"
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-amber-100 border border-amber-200/60 text-amber-700 hover:bg-amber-200 transition active:scale-95"
>
<Pause className="h-4 w-4" />
</button>
)}
{/* 发送(随时可发:生成中发送=打断并续问) */}
<button
type="submit"
disabled={askText.trim().length === 0 && askFiles.length === 0}
title={busy ? "打断当前轮并发送" : "发送(智能体自行判断改报告 or 回答)"}
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
>
<SendHorizontal className="h-4 w-4" />
</button>
</div>
</form>
</div> </div>
</div> </div>
); );
@@ -39,7 +39,8 @@ export function TaskArtifactSubmitButton({
: error : error
? "border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-300" ? "border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-300"
: "border-primary/35 bg-primary/10 text-primary hover:bg-primary/15", : "border-primary/35 bg-primary/10 text-primary hover:bg-primary/15",
compact && "h-7 px-2", // 沙箱头部的其他分段切换器外框为 40px 高,提交入口与之对齐。
compact && "h-10 px-3 text-xs",
className, className,
)} )}
> >
@@ -507,6 +507,14 @@ export function useStep2Orchestration(
Record<string, StubAiMessage[]> Record<string, StubAiMessage[]>
>({}); >({});
const seatStubMessagesRef = useRef<Record<string, StubAiMessage[]>>({}); const seatStubMessagesRef = useRef<Record<string, StubAiMessage[]>>({});
// write_file 的 tool args 会在一个 SSE response 里高频增长。ref 始终保留最新全文,
// 供自动打开 / 最终收口等同步逻辑立即读取;但不要逐帧把整份 stub 写进 React state。
// 后者会重建沙箱 ThreadContext 并让 ArtifactFileDetail 的预览 effect 连锁执行,
// 在「继续会商 + 正在写文件」的高密度帧下曾触发 Maximum update depth exceeded。
const pendingSeatStubMessagesRef = useRef<
Record<string, StubAiMessage[]> | null
>(null);
const seatStubFlushTimerRef = useRef<number | null>(null);
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>( const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(
DEFAULT_SELECTED_AGENTS, DEFAULT_SELECTED_AGENTS,
); );
@@ -552,6 +560,11 @@ export function useStep2Orchestration(
new Map(), new Map(),
); );
const parallelFlushTimerRef = useRef<number | null>(null); const parallelFlushTimerRef = useRef<number | null>(null);
// 非并行的 leader / 单席位流同样不能逐 token 直接 setState。SSE reader 可能在一次
// 浏览器任务里吐出大量已缓冲帧;逐帧 setState 会与消息区的 effect 连锁,最终触发
// React 的 Maximum update depth 保护。先累积到 ref,再以固定节流频率提交到界面。
const directStreamDeltaRef = useRef<Map<number, string>>(new Map());
const directStreamFlushTimerRef = useRef<number | null>(null);
// flush 时要读「当前选中席位」与「批次 bubbleId↔agentId 映射」—— 打到 ref 避免 // flush 时要读「当前选中席位」与「批次 bubbleId↔agentId 映射」—— 打到 ref 避免
// 把 flush 函数绑进会频繁变化的 state 依赖。 // 把 flush 函数绑进会频繁变化的 state 依赖。
const selectedParallelSeatsRef = useRef<string[]>([]); const selectedParallelSeatsRef = useRef<string[]>([]);
@@ -684,6 +697,57 @@ export function useStep2Orchestration(
return ids[agentName] ?? ids[agentName.toLowerCase()]; return ids[agentName] ?? ids[agentName.toLowerCase()];
}, []); }, []);
/**
* 把写文件预览的 stub 以约 12fps 合并交给 React。
*
* `seatStubMessagesRef` 已在调用处同步更新,故这里的延迟只影响右侧 Markdown
* 重绘频率,不影响随后读取最新 path/content、也不影响自动打开沙箱。
*/
const flushSeatStubMessages = useCallback(() => {
seatStubFlushTimerRef.current = null;
const pending = pendingSeatStubMessagesRef.current;
if (!pending) return;
pendingSeatStubMessagesRef.current = null;
setSeatStubMessages((previous) => (previous === pending ? previous : pending));
}, []);
const scheduleSeatStubMessagesFlush = useCallback(() => {
if (seatStubFlushTimerRef.current !== null) return;
seatStubFlushTimerRef.current = window.setTimeout(
flushSeatStubMessages,
80,
);
}, [flushSeatStubMessages]);
/** 清掉未提交的流式预览,并立即发布一个确定的快照(草稿恢复 / 重置时使用)。 */
const replaceSeatStubMessages = useCallback(
(next: Record<string, StubAiMessage[]>) => {
pendingSeatStubMessagesRef.current = null;
if (seatStubFlushTimerRef.current !== null) {
window.clearTimeout(seatStubFlushTimerRef.current);
seatStubFlushTimerRef.current = null;
}
seatStubMessagesRef.current = next;
setSeatStubMessages((previous) => (previous === next ? previous : next));
},
[],
);
const resetSeatStubMessages = useCallback(() => {
replaceSeatStubMessages({});
}, [replaceSeatStubMessages]);
// 卸载时清理挂起的预览 flush,避免已离开的页面仍收到一次 setState。
useEffect(
() => () => {
if (seatStubFlushTimerRef.current !== null) {
window.clearTimeout(seatStubFlushTimerRef.current);
seatStubFlushTimerRef.current = null;
}
},
[],
);
/** /**
* 把工具调用幂等地写进 seat 的 stub ai message —— **同 (toolName, path)** 视为同一次 * 把工具调用幂等地写进 seat 的 stub ai message —— **同 (toolName, path)** 视为同一次
* 调用,后续帧只更新 args(LangGraph 流式期间会多次发送完整 tool_calls 覆盖 args)。 * 调用,后续帧只更新 args(LangGraph 流式期间会多次发送完整 tool_calls 覆盖 args)。
@@ -734,10 +798,11 @@ export function useStep2Orchestration(
[seatThreadId]: [nextAiMsg], [seatThreadId]: [nextAiMsg],
}; };
seatStubMessagesRef.current = nextMap; seatStubMessagesRef.current = nextMap;
setSeatStubMessages(nextMap); pendingSeatStubMessagesRef.current = nextMap;
scheduleSeatStubMessagesFlush();
return { messageId: nextAiMsg.id, toolCallId }; return { messageId: nextAiMsg.id, toolCallId };
}, },
[], [scheduleSeatStubMessagesFlush],
); );
/** /**
@@ -1026,12 +1091,6 @@ export function useStep2Orchestration(
args, args,
); );
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId); const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
if (typeof window !== "undefined") {
console.debug(
"[roundtable-sandbox] auto-open from phase",
{ toolName, path, seatThreadId, virtualUrl },
);
}
openSeatVirtualArtifact(seatThreadId, virtualUrl, true); openSeatVirtualArtifact(seatThreadId, virtualUrl, true);
}, },
[ [
@@ -1272,9 +1331,15 @@ export function useStep2Orchestration(
const setDialogueConfidence = useCallback( const setDialogueConfidence = useCallback(
(id: number, confidence: string) => { (id: number, confidence: string) => {
setStep2RoundtableDialogues((prev) => setStep2RoundtableDialogues((prev) => {
prev.map((d) => (d.id === id ? { ...d, confidence } : d)), const index = prev.findIndex((dialogue) => dialogue.id === id);
); // onPhaseChange 可在一个 SSE read 内收到多帧相同的 phase。状态文字没有变化
// 时必须返回原数组;否则 React 会把无意义的新数组当成一次嵌套更新。
if (index < 0 || prev[index].confidence === confidence) return prev;
const next = [...prev];
next[index] = { ...prev[index], confidence };
return next;
});
}, },
[], [],
); );
@@ -1286,30 +1351,39 @@ export function useStep2Orchestration(
key: string, key: string,
args?: Record<string, unknown>, args?: Record<string, unknown>,
) => { ) => {
setStep2RoundtableDialogues((prev) => setStep2RoundtableDialogues((prev) => {
prev.map((d) => { const dialogueIndex = prev.findIndex((dialogue) => dialogue.id === bubbleId);
if (d.id !== bubbleId) return d; if (dialogueIndex < 0) return prev;
const steps: Step2Dialogue["steps"] = d.steps ?? [];
const last = steps[steps.length - 1]; const dialogue = prev[dialogueIndex];
if (last && last.key === key) { const steps: Step2Dialogue["steps"] = dialogue.steps ?? [];
// 同 phase:tool 重复回调 —— 流式累积期 args 会持续长大(present_files const last = steps[steps.length - 1];
// 多文件分批、write_file 内容增长),拿最新 args 覆盖,不要早早定死。 let nextDialogue: Step2Dialogue;
if (args && args !== last.args) {
const next = [...steps]; if (last && last.key === key) {
next[next.length - 1] = { ...last, args }; // 同一个工具调用在流式期间会反复带来增量入参。步骤卡只需在「首次拿到
return { ...d, steps: next }; // 可见参数」时补一次;文件正文由 upstream frame 同步给沙箱,不能随每个
} // content chunk 更新对话 state。
return d; const hasVisibleArgs = (value?: Record<string, unknown>) =>
} !!value && Object.keys(value).some((argKey) => argKey !== "content");
return { if (hasVisibleArgs(last.args) || !hasVisibleArgs(args)) return prev;
...d, const nextSteps = [...steps];
nextSteps[nextSteps.length - 1] = { ...last, args };
nextDialogue = { ...dialogue, steps: nextSteps };
} else {
nextDialogue = {
...dialogue,
steps: [ steps: [
...steps, ...steps,
{ id: `${bubbleId}-${steps.length}`, key, label, ts: nowTime(), args }, { id: `${bubbleId}-${steps.length}`, key, label, ts: nowTime(), args },
], ],
}; };
}), }
);
const next = [...prev];
next[dialogueIndex] = nextDialogue;
return next;
});
}, },
[], [],
); );
@@ -1342,20 +1416,45 @@ export function useStep2Orchestration(
[], [],
); );
/** 将非并行流的已积累增量一次性写入 React state(约 20fps)。 */
const flushDirectStreamDeltas = useCallback(() => {
directStreamFlushTimerRef.current = null;
const pending = directStreamDeltaRef.current;
if (pending.size === 0) return;
directStreamDeltaRef.current = new Map();
setStep2RoundtableDialogues((prev) => {
let changed = false;
const next = prev.map((dialogue) => {
const delta = pending.get(dialogue.id);
if (!delta) return dialogue;
changed = true;
return { ...dialogue, content: (dialogue.content ?? "") + delta };
});
return changed ? next : prev;
});
}, []);
const scheduleDirectStreamFlush = useCallback(() => {
if (directStreamFlushTimerRef.current !== null) return;
directStreamFlushTimerRef.current = window.setTimeout(flushDirectStreamDeltas, 50);
}, [flushDirectStreamDeltas]);
const appendToDialogue = useCallback((id: number, deltaText: string) => { const appendToDialogue = useCallback((id: number, deltaText: string) => {
if (!deltaText) return; if (!deltaText) return;
setStep2RoundtableDialogues((prev) => const pending = directStreamDeltaRef.current;
prev.map((d) => pending.set(id, (pending.get(id) ?? "") + deltaText);
d.id === id ? { ...d, content: (d.content ?? "") + deltaText } : d, scheduleDirectStreamFlush();
), }, [scheduleDirectStreamFlush]);
);
}, []);
/** 整段替换某气泡正文(§6 flush:把 ref 里累积的全文一次性挂上去)。 */ /** 整段替换某气泡正文(§6 flush:把 ref 里累积的全文一次性挂上去)。 */
const setDialogueContent = useCallback((id: number, fullText: string) => { const setDialogueContent = useCallback((id: number, fullText: string) => {
setStep2RoundtableDialogues((prev) => setStep2RoundtableDialogues((prev) => {
prev.map((d) => (d.id === id ? { ...d, content: fullText } : d)), const index = prev.findIndex((dialogue) => dialogue.id === id);
); if (index < 0 || prev[index].content === fullText) return prev;
const next = [...prev];
next[index] = { ...prev[index], content: fullText };
return next;
});
}, []); }, []);
/** /**
@@ -1372,6 +1471,7 @@ export function useStep2Orchestration(
const who = info.agentName || "智能体"; const who = info.agentName || "智能体";
triggerToast(`「${info.failedModel}」模型${info.reason},已自动切换「${info.nextModel}」继续`); triggerToast(`「${info.failedModel}」模型${info.reason},已自动切换「${info.nextModel}」继续`);
if (isParallel) parallelStreamRef.current.delete(bubbleId); if (isParallel) parallelStreamRef.current.delete(bubbleId);
directStreamDeltaRef.current.delete(bubbleId);
const systemBubble: Step2Dialogue = { const systemBubble: Step2Dialogue = {
id: Date.now() + Math.random(), id: Date.now() + Math.random(),
sender: "系统提示", sender: "系统提示",
@@ -1440,8 +1540,17 @@ export function useStep2Orchestration(
} }
}, []); }, []);
const resetDirectStreamDeltas = useCallback(() => {
directStreamDeltaRef.current = new Map();
if (directStreamFlushTimerRef.current !== null) {
window.clearTimeout(directStreamFlushTimerRef.current);
directStreamFlushTimerRef.current = null;
}
}, []);
// 卸载兜底:清掉可能挂起的 flush 定时器。 // 卸载兜底:清掉可能挂起的 flush 定时器。
useEffect(() => resetParallelStreams, [resetParallelStreams]); useEffect(() => resetParallelStreams, [resetParallelStreams]);
useEffect(() => resetDirectStreamDeltas, [resetDirectStreamDeltas]);
// 选中变化 → 立刻把 ref 里已累积的全文挂给**新选中**的席位(切头像即显全部输出, // 选中变化 → 立刻把 ref 里已累积的全文挂给**新选中**的席位(切头像即显全部输出,
// 证明流没丢、只是之前没渲染,§6.5)。 // 证明流没丢、只是之前没渲染,§6.5)。
@@ -1508,21 +1617,40 @@ export function useStep2Orchestration(
const finalizeDialogue = useCallback( const finalizeDialogue = useCallback(
(id: number, finalContent: string, confidence?: string) => { (id: number, finalContent: string, confidence?: string) => {
setStep2RoundtableDialogues((prev) => // 最终结果已经是完整文本,丢弃尚未 flush 的 token 缓冲,避免收尾后旧增量反写。
prev.map((d) => { directStreamDeltaRef.current.delete(id);
if (
directStreamDeltaRef.current.size === 0 &&
directStreamFlushTimerRef.current !== null
) {
window.clearTimeout(directStreamFlushTimerRef.current);
directStreamFlushTimerRef.current = null;
}
setStep2RoundtableDialogues((prev) => {
let changed = false;
const next = prev.map((d) => {
if (d.id !== id) return d; if (d.id !== id) return d;
const next = const next =
finalContent && finalContent.trim().length > 0 finalContent && finalContent.trim().length > 0
? finalContent ? finalContent
: d.content || "(无文本输出)"; : d.content || "(无文本输出)";
if (
d.content === next &&
d.confidence === (confidence ?? d.confidence) &&
d.streaming === false
) {
return d;
}
changed = true;
return { return {
...d, ...d,
content: next, content: next,
confidence: confidence ?? d.confidence, confidence: confidence ?? d.confidence,
streaming: false, streaming: false,
}; };
}), });
); return changed ? next : prev;
});
}, },
[], [],
); );
@@ -2552,7 +2680,7 @@ export function useStep2Orchestration(
seatSkillDirective: seatSkillDirectivesRef.current[agentId] ?? false, seatSkillDirective: seatSkillDirectivesRef.current[agentId] ?? false,
signal: subAborter.signal, signal: subAborter.signal,
// §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点 // §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点
// stage 实际串行,1 路流直接 setState 不卡,沿用 appendToDialogue。 // stage 也经 appendToDialogue 的节流缓冲,避免高频 SSE 帧嵌套更新。
onModelSwitch: makeModelSwitchHandler(bubbleId, isParallel), onModelSwitch: makeModelSwitchHandler(bubbleId, isParallel),
onTextDelta: (text) => onTextDelta: (text) =>
isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text), isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text),
@@ -3182,6 +3310,7 @@ export function useStep2Orchestration(
// 需要 ref 立刻可读到作业的真实线程,否则读到旧值导致接管失败。 // 需要 ref 立刻可读到作业的真实线程,否则读到旧值导致接管失败。
threadIdsRef.current = snapshot.threadIds; threadIdsRef.current = snapshot.threadIds;
coordinatorNameRef.current = snapshot.coordinatorName; coordinatorNameRef.current = snapshot.coordinatorName;
resetDirectStreamDeltas();
// 历史快照是**静止记录**:任何残留「流式/加载中」的气泡(用户中途点了「停止」, // 历史快照是**静止记录**:任何残留「流式/加载中」的气泡(用户中途点了「停止」,
// 或草稿在流式期间被自动保存)都强制收尾为静止态。否则用户点聊天记录回看时,这些 // 或草稿在流式期间被自动保存)都强制收尾为静止态。否则用户点聊天记录回看时,这些
// 气泡仍显示「调度中 / 等待响应 / 正在输出…」+ 加载光标,让人误以为还在进行(其实早已结束)。 // 气泡仍显示「调度中 / 等待响应 / 正在输出…」+ 加载光标,让人误以为还在进行(其实早已结束)。
@@ -3203,15 +3332,14 @@ export function useStep2Orchestration(
setConsensusPercentage(snapshot.consensusPercentage); setConsensusPercentage(snapshot.consensusPercentage);
setBudgetLimit(snapshot.budgetLimit); setBudgetLimit(snapshot.budgetLimit);
const restoredStubs = snapshot.seatStubMessages ?? {}; const restoredStubs = snapshot.seatStubMessages ?? {};
seatStubMessagesRef.current = restoredStubs; replaceSeatStubMessages(restoredStubs);
setSeatStubMessages(restoredStubs);
// 草稿里历史 write_file 的虚拟 URL 与当前会话无关 —— 清空守卫,允许新 run 触发自动打开。 // 草稿里历史 write_file 的虚拟 URL 与当前会话无关 —— 清空守卫,允许新 run 触发自动打开。
hasAutoOpenedRef.current = new Set(); hasAutoOpenedRef.current = new Set();
// 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上, // 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上,
// 调用方需要的话再 resetInitGate()。 // 调用方需要的话再 resetInitGate()。
step2InitRef.current = true; step2InitRef.current = true;
}, },
[], [replaceSeatStubMessages, resetDirectStreamDeltas],
); );
// 「查看=恢复流程」:后台作业 SSE 帧 → 第二步实时画面(只更新展示数据,不动 // 「查看=恢复流程」:后台作业 SSE 帧 → 第二步实时画面(只更新展示数据,不动
@@ -3225,13 +3353,12 @@ export function useStep2Orchestration(
lastLeaderContent: string; lastLeaderContent: string;
}) => { }) => {
setStep2RoundtableDialogues(frame.dialogues); setStep2RoundtableDialogues(frame.dialogues);
seatStubMessagesRef.current = frame.seatStubMessages; replaceSeatStubMessages(frame.seatStubMessages);
setSeatStubMessages(frame.seatStubMessages);
setConsensusPercentage(frame.consensusPercentage); setConsensusPercentage(frame.consensusPercentage);
setHasConsensus(frame.hasConsensus); setHasConsensus(frame.hasConsensus);
if (frame.lastLeaderContent) setLastLeaderContent(frame.lastLeaderContent); if (frame.lastLeaderContent) setLastLeaderContent(frame.lastLeaderContent);
}, },
[], [replaceSeatStubMessages],
); );
// 「后台挂起接管」:取消后端作业 + hydrateFromDraft(恢复作业真实线程/协调名/对话)后, // 「后台挂起接管」:取消后端作业 + hydrateFromDraft(恢复作业真实线程/协调名/对话)后,
@@ -3279,11 +3406,11 @@ export function useStep2Orchestration(
setSelectedParallelSeats([]); setSelectedParallelSeats([]);
setDagNodeStates({}); setDagNodeStates({});
resetParallelStreams(); resetParallelStreams();
resetDirectStreamDeltas();
setActiveArtifactThreadId(null); setActiveArtifactThreadId(null);
artifactPathsRef.current = []; artifactPathsRef.current = [];
setArtifacts([]); setArtifacts([]);
seatStubMessagesRef.current = {}; resetSeatStubMessages();
setSeatStubMessages({});
hasAutoOpenedRef.current = new Set(); hasAutoOpenedRef.current = new Set();
pendingInjectPromptRef.current = null; pendingInjectPromptRef.current = null;
dagPlanExecutedRef.current = false; dagPlanExecutedRef.current = false;
@@ -3292,7 +3419,14 @@ export function useStep2Orchestration(
gatheredDeliveriesRef.current = []; gatheredDeliveriesRef.current = [];
deselectArtifact(); deselectArtifact();
step2InitRef.current = false; step2InitRef.current = false;
}, [abortAllInFlight, deselectArtifact, setArtifacts, resetParallelStreams]); }, [
abortAllInFlight,
deselectArtifact,
setArtifacts,
resetParallelStreams,
resetDirectStreamDeltas,
resetSeatStubMessages,
]);
return { return {
selectedAgents, selectedAgents,
@@ -16,16 +16,13 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts"; import { useArtifacts } from "@/components/workspace/artifacts";
import { apiFetch } from "@/core/api";
import { uploadFiles } from "@/core/uploads/api"; import { uploadFiles } from "@/core/uploads/api";
import type { import type {
DraftStep3SummarySnapshot, DraftStep3SummarySnapshot,
} from "@/roundtable-planning/api/drafts"; } from "@/roundtable-planning/api/drafts";
import { import {
buildArtifactDownloadUrl,
initStructureThread, initStructureThread,
initSummaryThread, initSummaryThread,
listSessionArtifacts,
streamMultiAgent, streamMultiAgent,
type StreamMultiAgentRequest, type StreamMultiAgentRequest,
type StreamPhase, type StreamPhase,
@@ -78,6 +75,8 @@ interface UseStep3SummaryOptions {
* 普通手动会商为 false —— 结构化输出仍按需(用户输入框打「流程图」才出)。 * 普通手动会商为 false —— 结构化输出仍按需(用户输入框打「流程图」才出)。
*/ */
autoFlow?: boolean; autoFlow?: boolean;
/** 第三步是否允许流程图分支。当前报告工作台只交付 Markdown,默认关闭。 */
flowEnabled?: boolean;
/** 草稿里已存的上次总结结果 —— 有则进入时恢复展示,不重新生成。 */ /** 草稿里已存的上次总结结果 —— 有则进入时恢复展示,不重新生成。 */
savedSummary?: DraftStep3SummarySnapshot | null; savedSummary?: DraftStep3SummarySnapshot | null;
/** 一次总结成功完成时回调(传出 md/thread/model/时间/收尾语),供上层存草稿。 */ /** 一次总结成功完成时回调(传出 md/thread/model/时间/收尾语),供上层存草稿。 */
@@ -485,6 +484,7 @@ export function useStep3Summary(
coordinatorPrompt, coordinatorPrompt,
chainLevels, chainLevels,
autoFlow, autoFlow,
flowEnabled = false,
savedSummary, savedSummary,
onSummarySaved, onSummarySaved,
ingestAvailable, ingestAvailable,
@@ -573,6 +573,10 @@ export function useStep3Summary(
>(null); >(null);
const stubRef = useRef<Record<string, StubAiMessage[]>>({}); const stubRef = useRef<Record<string, StubAiMessage[]>>({});
// write_file 会在流式过程中多次带回完整 tool_calls。stub 保持同步更新供预览读取,
// React 状态则节流发布,避免每一个工具帧都触发沙箱和消息列表的同步重渲染。
const pendingStubMessagesRef = useRef<Record<string, StubAiMessage[]> | null>(null);
const stubFlushTimerRef = useRef<number | null>(null);
const runningRef = useRef(false); const runningRef = useRef(false);
const aborterRef = useRef<AbortController | null>(null); const aborterRef = useRef<AbortController | null>(null);
// 真线程(init 后或恢复时已知的后端 thread id)—— ask() 续问时复用它,保留报告上下文。 // 真线程(init 后或恢复时已知的后端 thread id)—— ask() 续问时复用它,保留报告上下文。
@@ -589,6 +593,47 @@ export function useStep3Summary(
// run() 的「自动出图」与用户「跳过报告直接生成流程图」的手动出图竞态,各自建卡片 → 双「调度中」。 // run() 的「自动出图」与用户「跳过报告直接生成流程图」的手动出图竞态,各自建卡片 → 双「调度中」。
const flowAborterRef = useRef<AbortController | null>(null); const flowAborterRef = useRef<AbortController | null>(null);
const activeFlowBubbleRef = useRef<number | null>(null); const activeFlowBubbleRef = useRef<number | null>(null);
const autoArtifactTimerRef = useRef<number | null>(null);
/** 将写文件预览以约 12fps 合并发布给 React,读取 stub 的流逻辑始终使用最新 ref。 */
const flushStubMessages = useCallback(() => {
stubFlushTimerRef.current = null;
const pending = pendingStubMessagesRef.current;
if (!pending) return;
pendingStubMessagesRef.current = null;
setSeatStubMessages((previous) => (previous === pending ? previous : pending));
}, []);
const scheduleStubMessagesFlush = useCallback(() => {
if (stubFlushTimerRef.current !== null) return;
stubFlushTimerRef.current = window.setTimeout(flushStubMessages, 80);
}, [flushStubMessages]);
/** 重置/恢复草稿时立即发布确定快照,同时取消未提交的流式预览。 */
const replaceStubMessages = useCallback(
(next: Record<string, StubAiMessage[]>) => {
pendingStubMessagesRef.current = null;
if (stubFlushTimerRef.current !== null) {
window.clearTimeout(stubFlushTimerRef.current);
stubFlushTimerRef.current = null;
}
stubRef.current = next;
setSeatStubMessages((previous) => (previous === next ? previous : next));
},
[],
);
useEffect(
() => () => {
if (stubFlushTimerRef.current !== null) {
window.clearTimeout(stubFlushTimerRef.current);
}
if (autoArtifactTimerRef.current !== null) {
window.clearTimeout(autoArtifactTimerRef.current);
}
},
[],
);
// ── 对话气泡三段式(append → delta → finalize)+ 工具步骤(与第 2 步同款)── // ── 对话气泡三段式(append → delta → finalize)+ 工具步骤(与第 2 步同款)──
const appendStreamingDialogue = useCallback( const appendStreamingDialogue = useCallback(
@@ -683,12 +728,15 @@ export function useStep3Summary(
const steps: Step2Dialogue["steps"] = d.steps ?? []; const steps: Step2Dialogue["steps"] = d.steps ?? [];
const last = steps[steps.length - 1]; const last = steps[steps.length - 1];
if (last && last.key === key) { if (last && last.key === key) {
if (args && args !== last.args) { // write_file 的流式工具帧会反复带着新的 content 对象。步骤卡只需要
const next = [...steps]; // 首次拿到可见参数(如 path)时补充一次,正文已经由 stub 节流同步给沙箱。
next[next.length - 1] = { ...last, args }; // 不能为每个 content chunk 更新对话 state,否则会造成嵌套更新风暴。
return { ...d, steps: next }; const hasVisibleArgs = (value?: Record<string, unknown>) =>
} !!value && Object.keys(value).some((argKey) => argKey !== "content");
return d; if (hasVisibleArgs(last.args) || !hasVisibleArgs(args)) return d;
const next = [...steps];
next[next.length - 1] = { ...last, args };
return { ...d, steps: next };
} }
return { return {
...d, ...d,
@@ -762,14 +810,15 @@ export function useStep3Summary(
const nextMsg: StubAiMessage = { ...base, tool_calls: nextToolCalls }; const nextMsg: StubAiMessage = { ...base, tool_calls: nextToolCalls };
const nextMap = { ...stubRef.current, [threadId]: [nextMsg] }; const nextMap = { ...stubRef.current, [threadId]: [nextMsg] };
stubRef.current = nextMap; stubRef.current = nextMap;
setSeatStubMessages(nextMap); pendingStubMessagesRef.current = nextMap;
scheduleStubMessagesFlush();
// 记住当前报告全文(用于续问写底 / 存草稿)。 // 记住当前报告全文(用于续问写底 / 存草稿)。
if (typeof storeArgs.content === "string" && storeArgs.content.trim()) { if (typeof storeArgs.content === "string" && storeArgs.content.trim()) {
currentMdRef.current = storeArgs.content; currentMdRef.current = storeArgs.content;
} }
return { messageId: nextMsg.id, toolCallId }; return { messageId: nextMsg.id, toolCallId };
}, },
[], [scheduleStubMessagesFlush],
); );
const buildWriteFileVirtualUrl = useCallback( const buildWriteFileVirtualUrl = useCallback(
@@ -816,14 +865,36 @@ export function useStep3Summary(
const autoOpenArtifact = useCallback( const autoOpenArtifact = useCallback(
(threadId: string, virtualUrl: string) => { (threadId: string, virtualUrl: string) => {
lastAutoThreadRef.current = threadId;
if (!previewEnabledRef.current) { if (!previewEnabledRef.current) {
// 视图不在前台:只记录,不抢沙箱(回到视图时 setPreviewEnabled 再接管)。 // 视图不在前台:只记录,不抢沙箱(回到视图时 setPreviewEnabled 再接管)。
lastAutoOpenedUrlRef.current = virtualUrl; lastAutoOpenedUrlRef.current = virtualUrl;
lastAutoThreadRef.current = threadId;
return; return;
} }
if (lastAutoOpenedUrlRef.current === virtualUrl) return; if (
openArtifact(threadId, virtualUrl); lastAutoOpenedUrlRef.current === virtualUrl &&
lastAutoThreadRef.current === threadId
) {
return;
}
// onPhaseChange 在 SSE 回调栈中触发。延后一帧再打开沙箱,避免工具帧与
// ArtifactProvider/Markdown 预览的状态更新嵌套在同一次 React 更新里。
lastAutoOpenedUrlRef.current = virtualUrl;
lastAutoThreadRef.current = threadId;
if (autoArtifactTimerRef.current !== null) {
window.clearTimeout(autoArtifactTimerRef.current);
}
autoArtifactTimerRef.current = window.setTimeout(() => {
autoArtifactTimerRef.current = null;
if (
!previewEnabledRef.current ||
lastAutoOpenedUrlRef.current !== virtualUrl ||
lastAutoThreadRef.current !== threadId
) {
return;
}
openArtifact(threadId, virtualUrl);
}, 80);
}, },
[openArtifact], [openArtifact],
); );
@@ -953,61 +1024,24 @@ export function useStep3Summary(
[upsertStub, buildWriteFileVirtualUrl, openArtifact, openArtifactPreview], [upsertStub, buildWriteFileVirtualUrl, openArtifact, openArtifactPreview],
); );
const openRealArtifactFallback = useCallback( // 收尾时直接使用流式 write_file 捕获的 Markdown。当前任务沙箱的文件列表与下载
async (threadId: string) => { // 端点不是同一套存储:列表返回的 /mnt/... 路径会被预览器拿去请求 /artifacts/mnt/...
if (!previewEnabledRef.current) return; // 不在前台不抢沙箱 // 并稳定返回 404。此处若再尝试“真实文件对账”,既拿不到更完整的内容,还会触发
try { // 预览器的失败重试和状态更新循环。write_file 的 stub 在每个上游帧同步更新,因此它
const res = await listSessionArtifacts(threadId); // 是本步骤报告的唯一预览/保存来源。
const file = res.files.find((f) => /\.md$/i.test(f.name)) ?? res.files[0];
if (!file) return;
setActiveArtifactThreadId(threadId);
setArtifacts([file.path]);
selectArtifact(file.path, false);
setArtifactsOpen(true);
} catch {
/* 列文件失败就保持当前状态 */
}
},
[setArtifacts, selectArtifact, setArtifactsOpen],
);
// 收尾对账:以沙箱真实 md 文件为准回灌 stub,拿到完整 Markdown。
// `force` 用于 ask():增量改后文件可能等长/更短,长度守卫会挡掉改动,故强制以真实文件为准。
const reconcileStubFromRealFile = useCallback( const reconcileStubFromRealFile = useCallback(
async ( async (
threadId: string, threadId: string,
opts?: { signal?: AbortSignal; force?: boolean }, _opts?: { signal?: AbortSignal; force?: boolean },
): Promise<string> => { ): Promise<string> => {
const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find( const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find(
(tc) => tc.name === "write_file", (tc) => tc.name === "write_file",
); );
const stubPath = return typeof (writeTc?.args as { content?: unknown })?.content === "string"
typeof (writeTc?.args as { path?: unknown })?.path === "string" ? ((writeTc!.args as { content?: string }).content as string)
? ((writeTc!.args as { path?: string }).path as string) : "";
: SUMMARY_OUTPUT_PATH;
const stubContent =
typeof (writeTc?.args as { content?: unknown })?.content === "string"
? ((writeTc!.args as { content?: string }).content as string)
: "";
try {
const list = await listSessionArtifacts(threadId, { signal: opts?.signal });
const mdFile = list.files.find((f) => /\.md$/i.test(f.name)) ?? null;
if (!mdFile) return stubContent;
const res = await apiFetch(buildArtifactDownloadUrl(threadId, mdFile.path), {
signal: opts?.signal,
});
if (!res.ok) return stubContent;
const real = await res.text();
if (real.trim() && (opts?.force || real.length > stubContent.length)) {
upsertStub(threadId, "write_file", { path: stubPath, content: real });
return real;
}
} catch {
/* 拉取失败退回 stub 内容 */
}
return stubContent;
}, },
[upsertStub], [],
); );
const abort = useCallback(() => { const abort = useCallback(() => {
@@ -1112,11 +1146,8 @@ export function useStep3Summary(
}); });
if (aborter.signal.aborted) return; if (aborter.signal.aborted) return;
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0; // 以流式 write_file stub 收尾。不要把沙箱文件列表返回的真实路径塞给全局预览:
// 流程图轮本就不产文件,不要去沙箱兜底翻文件(也避免把别的文件弹出来)。 // 该路径在 artifacts 下载端点不存在,会导致 404 与预览循环。
if (!hasStubFile && !seedThreadId && !flowRound) await openRealArtifactFallback(threadId);
// 以真实 md 回灌 stub,拿到完整报告(ask 强制对账,防增量改后被长度守卫挡掉)。
const md = await reconcileStubFromRealFile(threadId, { const md = await reconcileStubFromRealFile(threadId, {
signal: aborter.signal, signal: aborter.signal,
force: forceReconcile, force: forceReconcile,
@@ -1155,7 +1186,6 @@ export function useStep3Summary(
appendStepIfNew, appendStepIfNew,
handleArtifactToolPhase, handleArtifactToolPhase,
handleUpstreamFrame, handleUpstreamFrame,
openRealArtifactFallback,
reconcileStubFromRealFile, reconcileStubFromRealFile,
finalizeDialogue, finalizeDialogue,
seedStubWithCurrentMd, seedStubWithCurrentMd,
@@ -1301,6 +1331,9 @@ export function useStep3Summary(
/** 仅重新生成流程图(不重跑 md 报告):模型挂了切模型后重试 / 手动重画。 */ /** 仅重新生成流程图(不重跑 md 报告):模型挂了切模型后重试 / 手动重画。 */
const regenerateFlow = useCallback(() => { const regenerateFlow = useCallback(() => {
// 第三步报告工作台已关闭流程图交付。历史草稿/旧按钮即使仍持有这个回调,
// 也不能在总结收尾后意外启动结构化抽取。
if (!flowEnabled) return;
if (!currentMdRef.current.trim() && seatDeliveries.length === 0) return; if (!currentMdRef.current.trim() && seatDeliveries.length === 0) return;
aborterRef.current?.abort(); aborterRef.current?.abort();
// 重画流程图 = 重新分析 → 先复位入库状态(隐藏旧图的入库询问/按钮),新图生成完会重新询问。 // 重画流程图 = 重新分析 → 先复位入库状态(隐藏旧图的入库询问/按钮),新图生成完会重新询问。
@@ -1308,7 +1341,7 @@ export function useStep3Summary(
const aborter = new AbortController(); const aborter = new AbortController();
aborterRef.current = aborter; aborterRef.current = aborter;
void runFlowExtract(aborter); void runFlowExtract(aborter);
}, [runFlowExtract, seatDeliveries, resetIngestState]); }, [flowEnabled, runFlowExtract, seatDeliveries, resetIngestState]);
/** Phase 3:画布手动改/删节点后回写图 + 持久化(序列化进草稿快照)。 */ /** Phase 3:画布手动改/删节点后回写图 + 持久化(序列化进草稿快照)。 */
const updateFlowGraph = useCallback( const updateFlowGraph = useCallback(
@@ -1335,10 +1368,10 @@ export function useStep3Summary(
setPhase("creating"); setPhase("creating");
setDialogues([]); setDialogues([]);
lastAutoOpenedUrlRef.current = null; lastAutoOpenedUrlRef.current = null;
stubRef.current = {}; lastAutoThreadRef.current = null;
replaceStubMessages({});
currentMdRef.current = ""; currentMdRef.current = "";
liveThreadRef.current = null; liveThreadRef.current = null;
setSeatStubMessages({});
setFlowGraphState(null); setFlowGraphState(null);
setFlowPhase("idle"); setFlowPhase("idle");
// 清空对话同时复位流程图串行化句柄(旧气泡已随 setDialogues([]) 移除,不能再被复用)。 // 清空对话同时复位流程图串行化句柄(旧气泡已随 setDialogues([]) 移除,不能再被复用)。
@@ -1359,7 +1392,7 @@ export function useStep3Summary(
try { try {
// 编排提示里明确要求了结构化输出/流程图 → 首轮就走流程图模式(不写 md,直出 flow-json)。 // 编排提示里明确要求了结构化输出/流程图 → 首轮就走流程图模式(不写 md,直出 flow-json)。
// 但 autoFlow(taskId/深链)模式要「先 md 再结构化输出」,故首轮强制走 md(忽略 detectFlowIntent)。 // 但 autoFlow(taskId/深链)模式要「先 md 再结构化输出」,故首轮强制走 md(忽略 detectFlowIntent)。
const flowMode = !autoFlow && detectFlowIntent(coordinatorPrompt); const flowMode = flowEnabled && !autoFlow && detectFlowIntent(coordinatorPrompt);
flowActiveRef.current = flowMode; // 流式结束后会按真实产物校正 flowActiveRef.current = flowMode; // 流式结束后会按真实产物校正
const prompt = buildSummaryPrompt({ const prompt = buildSummaryPrompt({
intentReady, intentReady,
@@ -1381,6 +1414,7 @@ export function useStep3Summary(
// ask() 会先 abort() 本轮(aborterRef 被置空/换成新 aborter)→ 这里不再自动出图, // ask() 会先 abort() 本轮(aborterRef 被置空/换成新 aborter)→ 这里不再自动出图,
// 由 ask 的手动出图独占,避免 auto + manual 双卡片。 // 由 ask 的手动出图独占,避免 auto + manual 双卡片。
if ( if (
flowEnabled &&
autoFlow && autoFlow &&
!aborter.signal.aborted && !aborter.signal.aborted &&
aborterRef.current === aborter && aborterRef.current === aborter &&
@@ -1404,11 +1438,13 @@ export function useStep3Summary(
consensusText, consensusText,
coordinatorPrompt, coordinatorPrompt,
autoFlow, autoFlow,
flowEnabled,
appendStreamingDialogue, appendStreamingDialogue,
finalizeDialogue, finalizeDialogue,
streamOnce, streamOnce,
runFlowExtract, runFlowExtract,
resetIngestState, resetIngestState,
replaceStubMessages,
]); ]);
/** /**
@@ -1566,6 +1602,12 @@ export function useStep3Summary(
(choice: "generate" | "cancel") => { (choice: "generate" | "cancel") => {
setPendingFlowForIngest(false); setPendingFlowForIngest(false);
if (choice === "generate") { if (choice === "generate") {
if (!flowEnabled) {
// 老草稿可能遗留「先生成流程图再入库」的协助卡;报告模式不再提供这条链路。
setIngestStatus("later");
persistSummary();
return;
}
// 重置为 idle —— 流程图生成完成后 runFlowExtract 在 idle 时会自动询问入库。 // 重置为 idle —— 流程图生成完成后 runFlowExtract 在 idle 时会自动询问入库。
setIngestStatus("idle"); setIngestStatus("idle");
regenerateFlow(); regenerateFlow();
@@ -1660,6 +1702,11 @@ export function useStep3Summary(
await runIngest(); await runIngest();
return; return;
} }
// 当前第三步只维护 Markdown 报告。即使意图分类命中旧的流程图关键词,
// 也按“重写报告”处理,绝不启动流程图抽取请求。
if (!flowEnabled && (intent === "regenerate_flow" || intent === "modify_flow")) {
intent = "regenerate_report";
}
if (intent === "regenerate_flow" || intent === "modify_flow") { if (intent === "regenerate_flow" || intent === "modify_flow") {
// 重新/修改流程图 = 重新分析 → 先复位入库状态(隐藏旧图入库询问/按钮), // 重新/修改流程图 = 重新分析 → 先复位入库状态(隐藏旧图入库询问/按钮),
// runFlowExtract 出新图后会在 idle 时重新询问是否入库。 // runFlowExtract 出新图后会在 idle 时重新询问是否入库。
@@ -1809,6 +1856,7 @@ export function useStep3Summary(
runFlowExtract, runFlowExtract,
runIngest, runIngest,
resetIngestState, resetIngestState,
flowEnabled,
], ],
); );
@@ -1902,7 +1950,7 @@ export function useStep3Summary(
// 合并:合成 stub 保住「恢复后预览」锚点,历史 stub 保住各气泡步骤卡的真线程预览。 // 合并:合成 stub 保住「恢复后预览」锚点,历史 stub 保住各气泡步骤卡的真线程预览。
const effectiveStub = const effectiveStub =
savedStubs && Object.keys(savedStubs).length ? { ...stub, ...savedStubs } : stub; savedStubs && Object.keys(savedStubs).length ? { ...stub, ...savedStubs } : stub;
stubRef.current = effectiveStub; replaceStubMessages(effectiveStub);
currentMdRef.current = snap.md; currentMdRef.current = snap.md;
liveThreadRef.current = realThreadId; liveThreadRef.current = realThreadId;
// 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。 // 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。
@@ -1919,7 +1967,6 @@ export function useStep3Summary(
lastAutoOpenedUrlRef.current = null; lastAutoOpenedUrlRef.current = null;
lastAutoThreadRef.current = null; lastAutoThreadRef.current = null;
} }
setSeatStubMessages(effectiveStub);
setSummaryThreadId(synthThreadId); setSummaryThreadId(synthThreadId);
setActiveArtifactThreadId(synthThreadId); setActiveArtifactThreadId(synthThreadId);
@@ -1962,7 +2009,7 @@ export function useStep3Summary(
// 写文件(live write_file 流式)时才自动打开;恢复历史时用户点气泡里的 // 写文件(live write_file 流式)时才自动打开;恢复历史时用户点气泡里的
// 「点击预览」步骤卡随时可开(stub 已种好)。 // 「点击预览」步骤卡随时可开(stub 已种好)。
}, },
[abort, buildWriteFileVirtualUrl], [abort, buildWriteFileVirtualUrl, replaceStubMessages],
); );
const busy = phase === "creating" || phase === "streaming"; const busy = phase === "creating" || phase === "streaming";
@@ -1534,9 +1534,9 @@ export default function RoundtablePlanningPage({
.filter((g) => g.length > 0), .filter((g) => g.length > 0),
[orchestrationPlan], [orchestrationPlan],
); );
// 第三步首次生成报告后即**自动**续跑结构化输出(流程图)——不再限 taskId 深链, // 第三步的交付统一为 Markdown 总结报告。流程图不再作为本页的后续自动产物,
// 任何会商进第三步首次出报告都自动出流程图(用户 2026-06-17 要求)。 // 避免报告写完后额外触发一次结构化抽取请求。
const autoSummaryFlow = true; const autoSummaryFlow = false;
const summaryFlow = useStep3Summary({ const summaryFlow = useStep3Summary({
intentReady: step1.intentReady, intentReady: step1.intentReady,
@@ -1546,6 +1546,7 @@ export default function RoundtablePlanningPage({
coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null, coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null,
chainLevels, chainLevels,
autoFlow: autoSummaryFlow, autoFlow: autoSummaryFlow,
flowEnabled: false,
savedSummary: step3Report?.summaryReport ?? null, savedSummary: step3Report?.summaryReport ?? null,
onSummarySaved: handleSummarySaved, onSummarySaved: handleSummarySaved,
ingestAvailable, ingestAvailable,
@@ -2422,7 +2423,6 @@ export default function RoundtablePlanningPage({
useStep1InputAppearance={rootClassName?.includes("eightbf-roundtable") ?? false} useStep1InputAppearance={rootClassName?.includes("eightbf-roundtable") ?? false}
step2RoundtableDialogues={step2.step2RoundtableDialogues} step2RoundtableDialogues={step2.step2RoundtableDialogues}
isInitializing={step2.isInitializing} isInitializing={step2.isInitializing}
isRoundTableRunning={step2.isRoundTableRunning}
isOrchestrating={step2.isOrchestrating} isOrchestrating={step2.isOrchestrating}
pendingClarification={step2.pendingClarification} pendingClarification={step2.pendingClarification}
clarificationDraft={step2.clarificationDraft} clarificationDraft={step2.clarificationDraft}
@@ -2450,7 +2450,6 @@ export default function RoundtablePlanningPage({
openArtifactFromStep={step2.openArtifactFromStep} openArtifactFromStep={step2.openArtifactFromStep}
openArtifactPreview={step2.openArtifactPreview} openArtifactPreview={step2.openArtifactPreview}
onPauseRoundtable={step2.pauseRoundtable} onPauseRoundtable={step2.pauseRoundtable}
onResumeRoundtable={step2.resumeRoundtable}
onHandleInjectToRoundtable={step2.handleInjectToRoundtable} onHandleInjectToRoundtable={step2.handleInjectToRoundtable}
setClarificationDraft={step2.setClarificationDraft} setClarificationDraft={step2.setClarificationDraft}
setCustomAgentInput={step2.setCustomAgentInput} setCustomAgentInput={step2.setCustomAgentInput}
@@ -2995,7 +2994,7 @@ function LeftSidebar({
className={cn( className={cn(
"min-h-0 shrink-0 flex flex-col gap-3 pr-1 transition-[width] duration-200 ease-out", "min-h-0 shrink-0 flex flex-col gap-3 pr-1 transition-[width] duration-200 ease-out",
isStep2 ? "overflow-y-auto" : "overflow-hidden", isStep2 ? "overflow-y-auto" : "overflow-hidden",
isStep2 && !sidebarExpanded ? "w-[56px]" : "w-[280px]", isStep2 && !sidebarExpanded ? "w-[80px]" : "w-[280px]",
)} )}
> >
{isStep2 && !sidebarExpanded && ( {isStep2 && !sidebarExpanded && (
@@ -3004,7 +3003,7 @@ function LeftSidebar({
onClick={() => setSidebarExpanded(true)} onClick={() => setSidebarExpanded(true)}
title="展开侧栏" title="展开侧栏"
aria-expanded={false} aria-expanded={false}
className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]" className="w-full shrink-0 flex h-10 items-center justify-center rounded-[4px] border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
> >
<PanelLeftOpen className="h-4 w-4" /> <PanelLeftOpen className="h-4 w-4" />
</button> </button>
@@ -3731,7 +3730,7 @@ function Step2RunSwitcher({
type="button" type="button"
onClick={() => onSelect(run.id)} onClick={() => onSelect(run.id)}
className={cn( className={cn(
"w-full rounded-xl border p-2.5 text-left transition-colors", "roundtable-step2-run-card w-full rounded-xl border p-2.5 text-left transition-colors",
active active
? "border-primary/40 bg-primary/5 text-slate-900" ? "border-primary/40 bg-primary/5 text-slate-900"
: "border-slate-100 bg-slate-50/60 text-slate-600 hover:border-primary/20 hover:bg-white", : "border-slate-100 bg-slate-50/60 text-slate-600 hover:border-primary/20 hover:bg-white",
@@ -3842,7 +3841,7 @@ function HumanParticipationCard({
: undefined : undefined
} }
className={cn( className={cn(
"flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3 transition", "roundtable-step2-human-row flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3 transition",
clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5", clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5",
active && "ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40", active && "ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40",
)} )}
@@ -4229,7 +4228,7 @@ function CollapsedSidebarSection({
<div <div
title={title} title={title}
className={cn( className={cn(
"w-full shrink-0 bg-white border border-slate-200/50 rounded-2xl p-2 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex flex-col items-center justify-center", "w-full shrink-0 bg-white border border-slate-200/50 rounded-[4px] p-2 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex flex-col items-center justify-center",
className, className,
)} )}
> >
@@ -4366,7 +4365,7 @@ function RoleRow({
: undefined : undefined
} }
className={cn( className={cn(
"flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition", "roundtable-step2-role-row flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition",
isActive isActive
? "bg-slate-50 border border-slate-100" ? "bg-slate-50 border border-slate-100"
: isBootingUp : isBootingUp