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