代码优化

This commit is contained in:
董宏凯 committed 2026-07-19 16:08:40 +08:00
1 parent 760b88d6f1
commit bb09c5991b
48 files changed
+8397 -6812

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
---
name: deerflow-ui-guard
description: Improve or review UI in the DeerFlow Vite/React frontend while preserving its light, dark-blue, and light-blue themes, Radix/shadcn-style primitives, Chinese enterprise density, embedded routes, and Chrome 109 compatibility. Use for visual refinements, new frontend pages/components, UI reviews, token cleanup, or design-system work under frontend-web/.
---
# DeerFlow UI Guard
Use this skill only in this repository. Preserve the product's existing visual system; do not replace it with a generic SaaS template or a new component library.
## Read the relevant sources first
1. Read `references/project-sources.md`, then the relevant theme specification.
2. Inspect the page and the nearest existing components before proposing or changing UI.
3. Read `frontend-web/src/styles/globals.css` and the affected primitives under `frontend-web/src/components/ui/` before changing tokens, shared styles, or variants.
## Design and implementation rules
- Reuse the existing Radix/shadcn-style primitives, `lucide-react`, Tailwind v4, and established CSS variables. Do not add Ant Design, another component system, or a visual-effects package unless the user explicitly asks.
- Keep all three themes correct: light/red, dark/blue, and light-blue. Prefer semantic classes and existing variables over raw color values. When a new token is necessary, define matching values for every theme.
- Preserve Chinese enterprise-product density and task completion speed. Make hierarchy, primary action, status, empty states, error recovery, and keyboard focus clearer before adding decoration.
- Keep the shell, deep links, authentication, embed/isolation behavior, sidebar menu semantics, and responsive layout unchanged unless the request explicitly targets them.
- Avoid unscoped global overrides and `!important`; place page-specific styling beside the feature and make selectors local.
- Support the Chrome 109 constraint. Do not introduce unguarded `oklch()`, `oklab()`, `lab()`, `lch()`, or `color-mix()` values.
- Preserve accessible names, keyboard interaction, visible `:focus-visible` states, sufficient contrast, and reduced-motion behavior for new or changed controls.
## Delivery workflow
1. State which existing theme and UI primitives the work will reuse.
2. Make the smallest coherent change; do not perform an unrelated visual sweep.
3. Validate with `pnpm typecheck`; when CSS or built UI changes, also run `pnpm build` and `pnpm check:chrome109-css` from `frontend-web/` when practical.
4. If a browser is available, inspect screenshots in light, dark, and light-blue modes. Otherwise say that visual verification remains outstanding.
5. For a review-only request, use `web-design-guidelines` as an additional quality gate when it is available, then distinguish confirmed issues from visual checks that still need a browser.
@@ -0,0 +1,4 @@
interface:
display_name: "DeerFlow UI Guard"
short_description: "Apply DeerFlow themes and UI safety checks."
default_prompt: "Use $deerflow-ui-guard to improve this DeerFlow UI without breaking its themes or legacy-browser support."
@@ -0,0 +1,23 @@
# DeerFlow UI sources
Read only the files relevant to the requested surface.
## Canonical themes
- Light/red: `frontend-web/docs/浅红色UI规范/README.md` and its focused section files.
- Dark/blue: `frontend-web/docs/ui规范-深蓝/README.md` and its focused section files.
- Light-blue: `frontend-web/docs/ui规范-浅蓝/浅蓝色ui规范.md`.
- Runtime tokens and browser fallbacks: `frontend-web/src/styles/globals.css`.
## Shared implementation
- UI primitives: `frontend-web/src/components/ui/`.
- Theme selection: `frontend-web/src/contexts/ThemeContext.tsx` and `frontend-web/src/App.tsx`.
- Application shell and embed behavior: `frontend-web/src/pages/PageRoutes.tsx`.
- Dynamic navigation: `frontend-web/src/core/page-layout/use-sidebar-menu.ts`.
## Required constraints
- Use `pnpm` in `frontend-web/`.
- Retain Chrome 109 compatibility; validate CSS with `pnpm check:chrome109-css` after a build.
- Do not move or replace public assets, route behavior, or authentication as part of a visual-only change.
+9 -3
View File
@@ -77,8 +77,14 @@ offline-backend-20260512/backend/.deer-flow/*
# Per-user skill_evolution overrides — runtime data written by the API
offline-backend-20260512/backend/skill_evolution.json
skills/
hermes-agent-main/
skills/
# Keep the project-local Codex UI guard under version control. The broad
# skills/ rule above still excludes runtime skill directories elsewhere.
!.codex/
!.codex/skills/
!.codex/skills/deerflow-ui-guard/
!.codex/skills/deerflow-ui-guard/**
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
deploy
@@ -112,4 +118,4 @@ offline-backend-20260512/backend/uv.lock
__pycache__/
# misc
.DS_Store
.DS_Store
+48
View File
@@ -0,0 +1,48 @@
# Design QA — skills card list
## Scope
Only the skill-card list presentation was changed. The page header, summary cards, filters, top actions, sidebar, and existing interactions were left intact.
## Visual evidence
- Source visual truth: `C:\Users\admin\AppData\Local\Temp\codex-clipboard-8ca03817-ebd4-43e1-9d7c-2119e87d331c.png`
- Implementation URL: `http://localhost:5173/#/page/workspace/skills`
- Implementation capture: `F:\react01\deeflow-code\deerflow-server\skills-card-list-implementation.png`
- Focused source/implementation comparison: `F:\react01\deeflow-code\deerflow-server\skills-card-list-comparison.png`
- Viewport and state: Edge, desktop light theme, default list mode. The supplied reference contains three sample skills; the live implementation currently exposes one skill, so the comparison focuses on the first card row rather than fabricated data.
## Findings
No actionable P0, P1, or P2 visual issue remains within the requested list-card scope.
- **Hierarchy and layout:** the card now begins with a large semantic skill icon, then groups name, existing status badges, description, and publisher before the existing tag, use-count, state, activity, and action areas. The five-column grid keeps the important skill identity visible while preserving the original live fields.
- **Typography and spacing:** the title, description, metadata, and tags use the existing product type scale with a compact 12px card radius and consistent horizontal rhythm. The row remains responsive and collapses cleanly on narrower widths.
- **Color and surfaces:** category icons use restrained solid blue/green/violet/amber surfaces, tags remain subdued, and enabled status uses a quiet green pill. Borders and hover surfaces follow the existing neutral card treatment; no gradients were introduced.
- **Image and icon fidelity:** the source uses symbolic skill artwork. The implementation uses the closest existing Lucide workflow, globe, file, and upload icons in colored circular surfaces rather than adding screenshot-derived artwork or a new custom asset.
- **Feature fidelity:** all pre-existing action controls, view modes, and list metadata stay visible. The reference's toggle was not copied as a fake interactive control because this page had no enable/disable mutation; its existing `enabled` data is shown as a read-only status pill instead.
## Comparison history
1. **[P1, fixed] The original live skill row had little identity or information hierarchy.**
- Earlier state: title, description, metadata, and the action cluster were visually detached, with no category icon or clear scanning order.
- Fix: rebuilt only the list/card row layout around existing data and handlers, added semantic colored icon surfaces, compact state/use/activity columns, and a unified action cluster.
- Evidence: `skills-card-list-comparison.png` contains the reference card row and the live implementation card row in one focused comparison.
## Interaction and engineering checks
- Edge interaction check: passed. The live row retains 7 existing action buttons; opening the details action displays its dialog.
- View-mode behavior: passed. The existing list/card view control still switches to card mode (`aria-pressed=true`); the card view uses the same restyled visual language.
- Browser runtime: two guest-bootstrap resource requests returned `401 Unauthorized` after reload. They are unrelated to the skill-card styling and did not prevent the list, details dialog, or view-mode control from working.
- `pnpm build`: passed. The project still emits the pre-existing `/images/deer.svg` runtime-resolution warning.
- `git diff --check`: passed; only existing line-ending notices were emitted for unrelated files.
## Implementation checklist
- [x] Skill-card list restyled without changing page chrome or filtering controls
- [x] Existing skill actions, metadata, and view modes retained
- [x] Live `enabled` data displayed without inventing a new mutation control
- [x] Source and implementation reviewed in a single focused comparison image
- [x] Edge interaction and production build verified
final result: passed
Binary file not shown.

After

Width:  |  Height:  |  Size: 951 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 966 KiB

@@ -20,32 +20,25 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Cpu,
FileIcon,
HelpCircle,
Loader2,
Network,
Paperclip,
Pause,
Play,
Sparkles,
X,
} from "lucide-react";
/** 人类可读的文件大小(B / KB / MB)。 */
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
/** 后台挂起入口 —— 后端 _run_dag 已支持业务链分层并行,放出该入口。 */
const SHOW_BACKGROUND_SUSPEND = true;
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
import { DialogueSpeedMeter } from "@/roundtable-planning/components/DialogueSpeedMeter";
import {
Step2ClarificationCard,
Step2InterventionBar,
} from "@/roundtable-planning/components/planning/Step2Composer";
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
@@ -93,75 +86,80 @@ interface ModelOption {
}
export interface Step2PanelProps {
// ─── State ────────────────────────────────────────────────────
step2RoundtableDialogues: Step2Dialogue[];
isInitializing: boolean;
isRoundTableRunning: boolean;
isOrchestrating: boolean;
pendingClarification: ClarificationPayload | null;
clarificationDraft: string;
customAgentInput: string;
hasConsensus: boolean;
agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null;
roles: RoleEntry[];
// ─── 按席位筛选会商记录(状态在页面层,与左侧角色卡片共用)──────────────
/** 当前筛选标识;null = 显示全部。 */
seatFilter: SeatFilter | null;
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
// ─── 并行显示(§5)────────────────────────────────────────────
parallelBatch: ParallelSeatView[];
selectedParallelSeats: string[];
isParallelRunning: boolean;
onToggleParallelSeat: (agentId: string) => void;
// ─── Phase 7 DAG 流程图执行态总览(§7)─────────────────────────
/** 当前编排模式;=== "dag" 且 plan 非空时,顶部显示 DAG 流程图缩略。 */
orchestrationMode: "recommend" | "chain" | "dag";
/** dag 模式的分层计划(流程图按 stage 分列渲染)。 */
orchestrationPlan: OrchestrationPlan | null;
/** agentId(小写) → 节点状态;驱动流程图节点 active/done。 */
dagNodeStates: Record<string, ChainNodeState>;
/** agent id(小写)→ 席位元数据,用于把派活 chip 的 id 显示成中文席位名。 */
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
selectedModel: string;
availableModels: ModelOption[];
modelsLoading: boolean;
setSelectedModel: React.Dispatch<React.SetStateAction<string>>;
activeArtifactThreadId: string | null;
seatStubMessages: Record<string, unknown[]>;
openArtifactFromStep: (
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => void;
/** 主聊天里 `present_files` 会在气泡下方渲染卡片;Step 2 沿用同一交互。 */
openArtifactPreview: (threadId: string, path: string) => void;
// ─── Setters ──────────────────────────────────────────────────
setClarificationDraft: React.Dispatch<React.SetStateAction<string>>;
setCustomAgentInput: React.Dispatch<React.SetStateAction<string>>;
setAgentConfigs: React.Dispatch<
React.SetStateAction<Record<number, AgentConfig>>
>;
setSelectedAgentId: React.Dispatch<React.SetStateAction<number | null>>;
// ─── Handlers ─────────────────────────────────────────────────
onPauseRoundtable: () => void;
onResumeRoundtable: () => void;
onHandleInjectToRoundtable: (files?: File[]) => void;
onSubmitClarification: (answer: string) => void;
onSavePersona: (id: number) => void;
onProceedToStep3: () => void;
/** 「后台挂起」:把当前研讨转入后端自动跑到结果绘制完成。 */
onBackgroundSuspend: () => void;
/** 是否在「查看历史记录」(从草稿/后台作业加载)—— 为 true 时隐藏「后台挂起」按钮。 */
isViewingHistory?: boolean;
onTriggerToast: (msg: string) => void;
session: {
dialogues: Step2Dialogue[];
isInitializing: boolean;
isRoundTableRunning: boolean;
isOrchestrating: boolean;
pendingClarification: ClarificationPayload | null;
clarificationDraft: string;
customAgentInput: string;
hasConsensus: boolean;
agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null;
roles: RoleEntry[];
};
execution: {
seatFilter: SeatFilter | null;
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
parallelBatch: ParallelSeatView[];
selectedParallelSeats: string[];
isParallelRunning: boolean;
onToggleParallelSeat: (agentId: string) => void;
orchestrationMode: "recommend" | "chain" | "dag";
orchestrationPlan: OrchestrationPlan | null;
dagNodeStates: Record<string, ChainNodeState>;
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
};
model: {
selectedModel: string;
availableModels: ModelOption[];
modelsLoading: boolean;
setSelectedModel: React.Dispatch<React.SetStateAction<string>>;
};
artifacts: {
activeArtifactThreadId: string | null;
seatStubMessages: Record<string, unknown[]>;
openArtifactFromStep: (
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => void;
openArtifactPreview: (threadId: string, path: string) => void;
};
setters: {
setClarificationDraft: React.Dispatch<React.SetStateAction<string>>;
setCustomAgentInput: React.Dispatch<React.SetStateAction<string>>;
setAgentConfigs: React.Dispatch<
React.SetStateAction<Record<number, AgentConfig>>
>;
setSelectedAgentId: React.Dispatch<React.SetStateAction<number | null>>;
};
actions: {
onPauseRoundtable: () => void;
onResumeRoundtable: () => void;
onHandleInjectToRoundtable: (files?: File[]) => void;
onSubmitClarification: (answer: string) => void;
onSavePersona: (id: number) => void;
onProceedToStep3: () => void;
onBackgroundSuspend: () => void;
isViewingHistory?: boolean;
onTriggerToast: (message: string) => void;
};
}
export function Step2Panel(props: Step2PanelProps) {
export function Step2Panel({
session,
execution,
model,
artifacts,
setters,
actions,
}: Step2PanelProps) {
const {
step2RoundtableDialogues,
dialogues: step2RoundtableDialogues,
isInitializing,
isRoundTableRunning,
isOrchestrating,
@@ -172,6 +170,8 @@ export function Step2Panel(props: Step2PanelProps) {
agentConfigs,
selectedAgentId,
roles,
} = session;
const {
seatFilter,
setSeatFilter,
parallelBatch,
@@ -182,18 +182,26 @@ export function Step2Panel(props: Step2PanelProps) {
orchestrationPlan,
dagNodeStates,
agentNameToRole,
} = execution;
const {
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
} = model;
const {
activeArtifactThreadId,
seatStubMessages,
openArtifactFromStep,
openArtifactPreview,
} = artifacts;
const {
setClarificationDraft,
setCustomAgentInput,
setAgentConfigs,
setSelectedAgentId,
} = setters;
const {
onPauseRoundtable,
onResumeRoundtable,
onHandleInjectToRoundtable,
@@ -203,7 +211,7 @@ export function Step2Panel(props: Step2PanelProps) {
onBackgroundSuspend,
isViewingHistory,
onTriggerToast,
} = props;
} = actions;
const step2ScrollRef = useRef<HTMLDivElement>(null);
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
@@ -375,34 +383,11 @@ export function Step2Panel(props: Step2PanelProps) {
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
const [pauseFlash, setPauseFlash] = useState(false);
// 干预消息携带的本地附件(点「干预并发送」时连同指令一起上传)。
const [injectFiles, setInjectFiles] = useState<File[]>([]);
const injectFileInputRef = useRef<HTMLInputElement>(null);
const handlePickInjectFiles = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(e.target.files ?? []);
if (picked.length > 0) {
setInjectFiles((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 removeInjectFile = useCallback((idx: number) => {
setInjectFiles((prev) => prev.filter((_, i) => i !== idx));
}, []);
const handleInject = useCallback(() => {
onHandleInjectToRoundtable(injectFiles.length > 0 ? injectFiles : undefined);
setInjectFiles([]);
const handleInject = useCallback((files?: File[]) => {
onHandleInjectToRoundtable(files);
setPauseFlash(true);
window.setTimeout(() => setPauseFlash(false), 1600);
}, [onHandleInjectToRoundtable, injectFiles]);
}, [onHandleInjectToRoundtable]);
const isSandboxStreaming =
isOrchestrating ||
@@ -656,166 +641,20 @@ export function Step2Panel(props: Step2PanelProps) {
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
</div>
{/* Clarification card */}
{pendingClarification && (
<div className="shrink-0 px-4 pt-3 bg-amber-50 border-t-2 border-amber-300">
<div className="flex items-start gap-3">
<HelpCircle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-extrabold text-amber-900">总控协调需要澄清</span>
{pendingClarification.clarificationType && (
<span className="text-[calc(9px*var(--ui-font-scale,1))] font-mono font-black uppercase text-amber-900 bg-amber-200 border border-amber-300 px-1.5 py-0.2 rounded">
{pendingClarification.clarificationType}
</span>
)}
</div>
{pendingClarification.context && (
<p className="roundtable-chat-message text-amber-800 italic">{pendingClarification.context}</p>
)}
<p className="roundtable-chat-message text-slate-800 font-medium whitespace-pre-wrap break-words">
{pendingClarification.question}
</p>
{Array.isArray(pendingClarification.options) &&
pendingClarification.options.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{pendingClarification.options.map((opt, idx) => {
const label =
typeof opt === "string"
? opt
: opt && typeof opt === "object" && "label" in (opt as object)
? String((opt as { label: unknown }).label ?? "")
: String(opt ?? "");
if (!label) return null;
return (
<button
key={`${idx}-${label}`}
type="button"
onClick={() => onSubmitClarification(label)}
className="roundtable-chat-message font-bold px-3 py-1.5 rounded-full bg-white border border-amber-300 text-amber-900 hover:bg-amber-100 transition active:scale-95 cursor-pointer"
>
{label}
</button>
);
})}
</div>
)}
{pendingClarification.allowCustom !== false && (
<div className="flex items-end gap-2 pt-1">
<AutoGrowTextarea
value={clarificationDraft}
onChange={(e) => setClarificationDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmitClarification(clarificationDraft);
}
}}
placeholder="输入你的回答…(Enter 发送 / Shift+Enter 换行)"
className="flex-1 bg-white border border-amber-300 rounded-xl px-3 py-2 text-xs leading-relaxed focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-200 font-medium"
maxHeight={280}
/>
<button
type="button"
onClick={() => onSubmitClarification(clarificationDraft)}
disabled={!clarificationDraft.trim()}
className="bg-amber-600 hover:bg-amber-700 disabled:bg-slate-300 disabled:cursor-not-allowed text-white text-xs font-bold px-4 py-2 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-amber-500/10"
>
回复
</button>
</div>
)}
</div>
</div>
</div>
)}
<Step2ClarificationCard
clarification={pendingClarification}
draft={clarificationDraft}
setDraft={setClarificationDraft}
onSubmit={onSubmitClarification}
/>
{/* Console + intervention bar */}
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex flex-col gap-2">
{/* 已选附件预览(点「干预并发送」时随指令上传) */}
{injectFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{injectFiles.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]"
>
<FileIcon 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">
{formatFileSize(file.size)}
</span>
<button
type="button"
onClick={() => removeInjectFile(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-3">
<AutoGrowTextarea
value={customAgentInput}
onChange={(e) => setCustomAgentInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleInject();
}
}}
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预...(Enter 发送 / Shift+Enter 换行)"
className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs leading-relaxed focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
maxHeight={280}
/>
<input
ref={injectFileInputRef}
type="file"
multiple
className="hidden"
onChange={handlePickInjectFiles}
/>
<button
type="button"
onClick={() => injectFileInputRef.current?.click()}
title="上传参考文件(席位/总控将读取文件内容辅助研讨)"
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-500 hover:text-primary p-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95"
>
<Paperclip className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleInject}
title="记录干预指令并立即恢复会商执行"
className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10"
>
干预并发送
</button>
<button
type="button"
onClick={onProceedToStep3}
disabled={!hasConsensus}
aria-disabled={!hasConsensus}
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
className={`text-xs font-black px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 shadow-md shadow-emerald-500/10 ${
hasConsensus
? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
: "bg-emerald-600/40 text-white cursor-not-allowed"
}`}
>
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
</div>
</div>
<Step2InterventionBar
value={customAgentInput}
setValue={setCustomAgentInput}
hasConsensus={hasConsensus}
onInject={handleInject}
onProceed={onProceedToStep3}
/>
</>
);
@@ -2,6 +2,7 @@
* Step 3「文件沙箱预览」模式 —— 布局对齐「结果绘制」:左栏研讨内容 + 文件清单,右栏 Step 2 沙箱。
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useDefaultLayout } from "react-resizable-panels";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
@@ -75,6 +76,10 @@ export function Step3FileBrowseMode({
}: Step3FileBrowseModeProps) {
const [activeFile, setActiveFile] = useState(initialFile);
const [leftCollapsed, setLeftCollapsed] = useState(false);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "roundtable-step3-file-browse-layout",
panelIds: ["content", "artifact"],
});
useEffect(() => {
openArtifactPreview(activeFile.threadId, activeFile.file.path);
@@ -163,9 +168,10 @@ export function Step3FileBrowseMode({
<ResizablePanelGroup
orientation="horizontal"
className="h-full min-h-0 gap-3"
autoSaveId="roundtable-step3-file-browse-layout"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel defaultSize={42} minSize={26} className="min-w-0">
<ResizablePanel id="content" defaultSize={42} minSize={26} className="min-w-0">
<aside className="h-full min-h-0 flex flex-col gap-3 overflow-y-auto pr-0.5">
<div className="shrink-0 flex items-center justify-between gap-2 px-1">
<span className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">
@@ -312,7 +318,7 @@ export function Step3FileBrowseMode({
<ResizableHandle className="w-2 rounded-full bg-slate-200/70 hover:bg-slate-300 transition" />
<ResizablePanel defaultSize={58} minSize={30} className="min-w-0">
<ResizablePanel id="artifact" defaultSize={58} minSize={30} className="min-w-0">
<section className="h-full min-h-0 rounded-2xl border border-slate-200/60 bg-[#fffdfa] overflow-hidden">
<Step2SandboxLayout
artifactsFill
@@ -9,40 +9,26 @@
* 布局对齐 Step3ReportMode(HTML 绘制):左栏研讨原料源卡 + 右栏沙箱。两者并存、
* 互不影响(report 画 HTML 看板,summary 写 Markdown 报告 + 问答)。
*/
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { createPortal } from "react-dom";
import { useEffect, useMemo, useRef, useState } from "react";
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 {
buildArtifactDownloadUrl,
listSessionArtifacts,
type SessionArtifactFile,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
import {
@@ -50,23 +36,22 @@ import {
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";
import { desensitize } from "@/lib/desensitize";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import { useStep3Summary, type UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
interface SeatDelivery {
sender: string;
avatarType: string;
content: string;
}
import {
CollapsedSourceCard,
ContentModal,
SeatDeliveriesView,
SeatFilesModal,
SourceCard,
type SeatDelivery,
} from "@/roundtable-planning/components/step3-summary/Step3SummaryWorkspaceParts";
import { AgentProgressPanel } from "@/roundtable-planning/components/step3-summary/Step3SummaryAgentProgress";
interface Step3SummaryModeProps {
threadIds: ThreadIdMap | null;
@@ -89,7 +74,6 @@ interface Step3SummaryModeProps {
/** 切到「最终方案展示」静态页(次要入口)。 */
onViewStatic: () => void;
}
type SourceModal = "consensus" | "seats" | "files" | null;
export function Step3SummaryMode({
@@ -497,761 +481,3 @@ export function Step3SummaryMode({
</div>
);
}
// ── 左栏源卡 ─────────────────────────────────────────────────────────────
function SourceCard({
icon,
title,
hint,
badge,
disabled,
onClick,
}: {
icon: React.ReactNode;
title: string;
hint: string;
badge?: string;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
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"
>
<div className="flex items-center gap-2 mb-1.5">
{icon}
<h4 className="text-xs font-bold text-slate-900 tracking-wide flex-1 truncate">{title}</h4>
{badge && (
<span className="text-[10px] font-mono font-semibold text-primary bg-[var(--brand-200)]/50 px-1.5 py-0.5 rounded">
{badge}
</span>
)}
</div>
<p className="text-[11px] text-slate-400 leading-normal">{hint}</p>
</button>
);
}
function CollapsedSourceCard({
icon,
title,
disabled,
onClick,
}: {
icon: React.ReactNode;
title: string;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
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"
>
{icon}
</button>
);
}
// ── 右栏:智能体进度面板(沙箱的 60% 区域)+ 底部对话输入框 ──────────────────
function AgentProgressPanel({
phase,
dialogues,
error,
busy,
mdAvailable,
flowPhase,
onRegenerateFlow,
pendingClarification,
onResolveClarification,
ingestAvailable,
ingestStatus,
pendingIngestClarification,
onResolveIngestChoice,
onResolveIngestClarification,
pendingFlowForIngest,
onResolveFlowForIngest,
onManualIngest,
onRetry,
onAsk,
onPause,
onArtifactStepClick,
onArtifactPreview,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
}: {
phase: UseStep3SummaryReturn["phase"];
dialogues: UseStep3SummaryReturn["dialogues"];
error: string | null;
busy: boolean;
/** 沙箱里是否已有 md 报告 —— 决定提示语(纯流程图轮没有 .md 可预览/下载)。 */
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;
onArtifactStepClick: UseStep3SummaryReturn["openArtifactFromStep"];
onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"];
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
// 新一轮气泡出现(如:md 报告完成后自动续跑的结构化输出轮、用户发问)→ 强制滚到底,
// 让用户看到新阶段(修「调用结构化输出时不自动下滑」)。仅在气泡**条数**增加时触发,
// 流式增量内容仍由 useAutoScroll 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。
const prevDialogueCountRef = useRef(dialogues.length);
useEffect(() => {
if (dialogues.length > prevDialogueCountRef.current) {
scrollToBottom();
}
prevDialogueCountRef.current = dialogues.length;
}, [dialogues.length, scrollToBottom]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
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 ?? []);
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 text = askText.trim();
const hasFiles = askFiles.length > 0;
if (!text && !hasFiles) return; // 允许「随时」发送(含生成中);空内容不发
setAskText("");
setAskFiles([]);
onAsk(text, hasFiles ? askFiles : undefined);
};
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="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
报
</div>
<div className="leading-tight min-w-0">
<div className="text-xs font-bold text-slate-800 truncate">方案总结报告智能体</div>
<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">
{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" />
正在初始化总结线程…
</div>
)}
{dialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
onArtifactStepClick={(step) => onArtifactStepClick(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
}}
/>
))}
{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"
: "报告正文实时输出;输出结构化数据时流程图画布同步绘制"}
</div>
)}
{phase === "error" && (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
<div className="font-bold mb-1">总结失败</div>
<div className="leading-relaxed break-all">{error}</div>
<button
type="button"
onClick={onRetry}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-rose-700 underline hover:text-rose-800"
>
<RefreshCw className="w-3 h-3" />
重试
</button>
</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;可在下方继续修改报告 / 流程图或提问。"
: "已生成方案总结,流程图可在画布查看;可在下方继续修改总结 / 流程图或提问。"}
</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>
)}
{/* 流程图已生成 → 询问是否入库(入库技能可用时)。 */}
{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>
</div>
);
}
// ── 弹窗外壳 ─────────────────────────────────────────────────────────────
function ContentModal({
title,
children,
onClose,
headerAction,
}: {
title: string;
children: React.ReactNode;
onClose: () => void;
headerAction?: React.ReactNode;
}) {
// 经 portal 挂到 body:页面 <main> 是 `relative z-0`(浅蓝主题层叠基线),会把
// 内部 fixed 弹窗困在 z-0 层叠上下文里,被会商页头(z-40)/全局顶栏(z-45)盖住顶部。
// 挂到 body 后脱离该上下文,z-50 才真正盖在顶栏之上,弹窗标题栏正常显示。
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[1px] p-4"
onClick={onClose}
>
<div
className="bg-white border border-slate-200 rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="shrink-0 flex items-center justify-between gap-2 px-5 py-3.5 border-b border-slate-100 bg-slate-50">
<h3 className="text-sm font-bold text-slate-900 min-w-0 truncate">{title}</h3>
<div className="flex items-center gap-2 shrink-0">
{headerAction}
<button
type="button"
onClick={onClose}
className="p-1 text-slate-400 hover:text-slate-600 rounded-md hover:bg-slate-100"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-5">{children}</div>
</div>
</div>,
document.body,
);
}
// ── 各席位交付(弹窗内的折叠列表)────────────────────────────────────────
function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[] }) {
const [openIdx, setOpenIdx] = useState<number | null>(0);
if (seatDeliveries.length === 0) {
return <p className="text-xs text-slate-400 italic">本次研讨尚未产生子智能体交付。</p>;
}
return (
<div className="space-y-2">
{seatDeliveries.map((seat, idx) => {
const open = openIdx === idx;
return (
<div key={`${seat.sender}-${idx}`} className="border border-slate-150 rounded-xl overflow-hidden">
<button
type="button"
onClick={() => setOpenIdx(open ? null : idx)}
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-slate-50 hover:bg-slate-100 transition text-left"
>
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-bold text-slate-800 truncate">{desensitize(seat.sender)}</span>
</div>
<span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span>
</button>
{open && (
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{desensitize(seat.content || "(空)")}
</ReactMarkdown>
</div>
)}
</div>
);
})}
</div>
);
}
// ── 各席位生成文件(弹窗,按需拉取 outputs 列表)──────────────────────────
interface SeatArtifacts {
agentName: string;
displayName: string;
threadId: string;
files: SessionArtifactFile[];
loading: boolean;
error: string | null;
}
function shouldPreviewInModal(file: { name: string; mimeType: string | null }): boolean {
const mime = file.mimeType ?? "";
if (mime.startsWith("text/")) return true;
if (["application/json"].includes(mime)) return true;
return /\.(md|markdown|mmd|txt|json|csv|log|yml|yaml|xml|html?)$/i.test(file.name);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function SeatFilesModal({
threadIds,
coordinatorName,
seatNameMap,
onClose,
}: {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
seatNameMap: Record<string, string>;
onClose: () => void;
}) {
const seatEntries = useMemo(() => {
if (!threadIds) return [] as Array<{ agentName: string; threadId: string; displayName: string }>;
return Object.entries(threadIds)
.filter(([name]) => !coordinatorName || name !== coordinatorName)
.map(([agentName, threadId]) => ({
agentName,
threadId,
displayName: seatNameMap[agentName.toLowerCase()] ?? agentName,
}));
}, [threadIds, coordinatorName, seatNameMap]);
const [bySeat, setBySeat] = useState<SeatArtifacts[]>([]);
const [previewing, setPreviewing] = useState<{ threadId: string; file: SessionArtifactFile } | null>(
null,
);
useEffect(() => {
if (seatEntries.length === 0) {
setBySeat([]);
return;
}
setBySeat(seatEntries.map((s) => ({ ...s, files: [], loading: true, error: null })));
const abort = new AbortController();
let cancelled = false;
(async () => {
const results = await Promise.all(
seatEntries.map(async (seat) => {
try {
const res = await listSessionArtifacts(seat.threadId, { signal: abort.signal });
return { seat, files: res.files, error: null as string | null };
} catch (err) {
if ((err as Error).name === "AbortError") {
return { seat, files: [] as SessionArtifactFile[], error: null };
}
return { seat, files: [] as SessionArtifactFile[], error: (err as Error).message };
}
}),
);
if (cancelled) return;
setBySeat(results.map(({ seat, files, error }) => ({ ...seat, files, loading: false, error })));
})();
return () => {
cancelled = true;
abort.abort();
};
}, [seatEntries]);
const handlePreview = (threadId: string, file: SessionArtifactFile) => {
if (shouldPreviewInModal({ name: file.name, mimeType: file.mimeType })) {
setPreviewing({ threadId, file });
return;
}
if (typeof window !== "undefined") {
window.open(buildArtifactDownloadUrl(threadId, file.path), "_blank", "noopener,noreferrer");
}
};
const handleDownload = (threadId: string, file: SessionArtifactFile) => {
if (typeof window !== "undefined") {
window.open(
buildArtifactDownloadUrl(threadId, file.path, { download: true }),
"_blank",
"noopener,noreferrer",
);
}
};
return (
<>
<ContentModal title="各席位生成文件" onClose={onClose}>
{bySeat.length === 0 ? (
<p className="text-xs text-slate-400 italic">尚未初始化席位线程,无法列出文件。</p>
) : (
<div className="space-y-3">
{bySeat.map((seat) => (
<div key={seat.agentName} className="border border-slate-150 rounded-xl">
<div className="flex items-center justify-between gap-2 px-3 py-2 bg-slate-50 border-b border-slate-150 rounded-t-xl">
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.displayName}</span>
<span className="text-[10px] text-slate-400 font-mono shrink-0">
{seat.loading ? "加载中…" : `${seat.files.length} 个文件`}
</span>
</div>
{seat.loading ? (
<div className="flex items-center gap-2 px-3 py-3 text-[11px] text-slate-500">
<Loader2 className="w-3 h-3 animate-spin text-primary" />
正在查询 outputs 目录…
</div>
) : seat.error ? (
<div className="px-3 py-3 text-[11px] text-rose-600">读取失败:{seat.error}</div>
) : seat.files.length === 0 ? (
<div className="px-3 py-3 text-[11px] text-slate-400 italic">该席位未产生输出文件</div>
) : (
<ul className="divide-y divide-slate-100">
{seat.files.map((f) => (
<li key={f.path} className="px-3 py-2 flex items-center justify-between gap-2 hover:bg-slate-50/60">
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-medium text-slate-800 truncate" title={f.name}>
{f.name}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-mono text-slate-400">{formatBytes(f.sizeBytes)}</span>
<button
type="button"
onClick={() => handlePreview(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="预览"
>
<Eye className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleDownload(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="下载"
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
</li>
))}
</ul>
)}
</div>
))}
</div>
)}
</ContentModal>
{previewing && (
<ArtifactPreviewModal
threadId={previewing.threadId}
path={previewing.file.path}
name={previewing.file.name}
mimeType={previewing.file.mimeType}
onClose={() => setPreviewing(null)}
/>
)}
</>
);
}
@@ -0,0 +1,220 @@
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { Activity, History, Loader2, Pencil, Trash2, Upload } from "lucide-react";
import type { useDraftPersistence } from "@/roundtable-planning/hooks/useDraftPersistence";
type Drafts = ReturnType<typeof useDraftPersistence>["drafts"];
export interface HistoryDropdownProps {
innerRef: MutableRefObject<HTMLDivElement | null>;
isDark: boolean;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
drafts: Drafts;
currentDraftId: string | null;
/** 正在「挂起中」(已点后台挂起、作业尚未建好)的草稿 id 集合。 */
pendingSuspendIds: Set<string>;
renamingDraftId: string | null;
renameDraftValue: string;
setRenameDraftValue: Dispatch<SetStateAction<string>>;
onLoad: (id: string) => void;
onOpen?: () => void;
onOpenJob: (jobId: string) => void;
onDelete: (id: string) => void;
onStartRename: (draft: Drafts[number]) => void;
onCommitRename: (id: string) => void;
onCancelRename: () => void;
}
export function HistoryDropdown(props: HistoryDropdownProps) {
const {
innerRef,
isDark,
isOpen,
setIsOpen,
drafts,
currentDraftId,
pendingSuspendIds,
renamingDraftId,
renameDraftValue,
setRenameDraftValue,
onLoad,
onOpen,
onOpenJob,
onDelete,
onStartRename,
onCommitRename,
onCancelRename,
} = props;
const panelBg = isDark ? "#122347" : "#ffffff";
const currentBg = isDark ? "#1a2f5a" : "#ecfdf5";
return (
<div className="relative" ref={innerRef}>
<button
id="history-dropdown-btn"
type="button"
onClick={() =>
setIsOpen((open) => {
const next = !open;
if (next) onOpen?.();
return next;
})
}
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<History className="w-3.5 h-3.5 text-slate-500" />
历史记录
<span className="text-[10px] font-mono text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{drafts.length}
</span>
</button>
{isOpen && (
<div
className="rt-history-dropdown-panel absolute right-0 top-full mt-2 w-[360px] max-h-[60vh] overflow-y-auto border border-slate-200 rounded-2xl shadow-xl z-[1000] dark:border-border"
style={{
background: panelBg,
backgroundColor: panelBg,
backgroundImage: "none",
opacity: 1,
backdropFilter: "none",
WebkitBackdropFilter: "none",
}}
>
<div
className="rt-history-dropdown-header px-4 py-3 border-b border-slate-100 flex items-center justify-between sticky top-0 z-10 dark:border-border"
style={{
background: panelBg,
backgroundColor: panelBg,
backgroundImage: "none",
opacity: 1,
backdropFilter: "none",
WebkitBackdropFilter: "none",
}}
>
<span className="text-xs font-black text-slate-800 tracking-wide dark:text-foreground">
历史草稿
</span>
<span className="text-[10px] font-mono text-slate-400">{drafts.length} 条</span>
</div>
{drafts.length === 0 ? (
<div className="px-4 py-6 text-[11px] text-slate-400 italic text-center">
暂无历史。点击「保存草稿」或走完一步会自动保存到这里。
</div>
) : (
<ul className="divide-y divide-slate-100 dark:divide-border">
{drafts.map((draft) => {
const isCurrent = draft.id === currentDraftId;
const isRenaming = draft.id === renamingDraftId;
const isSuspending =
pendingSuspendIds.has(draft.id) || draft.status === "queued";
const isActiveJob =
(draft.status === "running" || draft.status === "awaiting_input") &&
!!draft.jobId;
const openOrLoad = () =>
isActiveJob && draft.jobId ? onOpenJob(draft.jobId) : onLoad(draft.id);
return (
<li
key={draft.id}
className="px-3 py-2.5"
style={isCurrent ? { backgroundColor: currentBg } : undefined}
>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
{isRenaming ? (
<input
type="text"
autoFocus
value={renameDraftValue}
onChange={(event) => setRenameDraftValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") onCommitRename(draft.id);
if (event.key === "Escape") onCancelRename();
}}
onBlur={() => onCommitRename(draft.id)}
className="w-full text-[11.5px] font-semibold text-slate-800 border border-slate-300 rounded px-2 py-1 outline-none focus:border-primary dark:bg-background dark:text-foreground"
/>
) : (
<button
type="button"
onClick={openOrLoad}
className="block w-full text-left"
title={isActiveJob ? "点击查看进度" : "点击加载"}
>
<span className="text-[11.5px] font-semibold text-slate-800 line-clamp-2 dark:text-foreground">
{draft.name}
</span>
<span className="text-[10px] text-slate-400 mt-0.5 flex items-center gap-1 font-mono">
{isSuspending ? (
<span className="inline-flex items-center gap-1 text-sky-500 font-semibold">
<Loader2 className="w-3 h-3 animate-spin" />
挂起中
</span>
) : draft.status === "running" ? (
<span className="inline-flex items-center gap-1 text-primary font-semibold">
<Loader2 className="w-3 h-3 animate-spin" />
进行中
</span>
) : draft.status === "awaiting_input" ? (
<span className="inline-flex items-center gap-1 text-amber-500 font-semibold">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
待澄清
</span>
) : draft.status === "error" ? (
<span className="text-rose-500 font-semibold">出错</span>
) : (
<span>Step {draft.furthestStep}</span>
)}
<span className="text-slate-300">·</span>
{new Date(draft.updatedAt).toLocaleString("zh-CN", { hour12: false })}
</span>
</button>
)}
</div>
<div className="flex items-center gap-0.5 shrink-0">
{!isRenaming && (
<>
<button
type="button"
onClick={openOrLoad}
className="p-1 text-slate-500 hover:text-primary hover:bg-slate-100 rounded transition"
title={isActiveJob ? "查看进度" : "加载"}
>
{isActiveJob ? (
<Activity className="w-3.5 h-3.5 text-primary" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
</button>
<button
type="button"
onClick={() => onStartRename(draft)}
className="p-1 text-slate-500 hover:text-primary hover:bg-slate-100 rounded transition"
title="重命名"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => onDelete(draft.id)}
className="p-1 text-slate-500 hover:text-rose-600 hover:bg-rose-50 rounded transition"
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
</div>
</li>
);
})}
</ul>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,549 @@
import { useEffect, useMemo, useState } from "react";
import {
ChevronDown,
ChevronRight,
ChevronUp,
Clipboard,
Cpu,
Filter,
History,
Layers,
Target,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { readActionDetail, readCopTaskDetail } from "@/core/auth/task-deeplink";
import { cn } from "@/lib/utils";
import { listChains } from "@/roundtable-planning/api/chains";
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
const SEAT_CHIP_PALETTE = [
"bg-rose-50 text-rose-700 border-rose-200",
"bg-amber-50 text-amber-700 border-amber-200",
"bg-teal-50 text-teal-700 border-teal-200",
"bg-purple-50 text-purple-700 border-purple-200",
"bg-sky-50 text-sky-700 border-sky-200",
"bg-emerald-50 text-emerald-700 border-emerald-200",
];
/**
* 把业务链席位按 `stages`(分层并行编排)分组:组内并行、组间串行;无 `stages`
* (线性链)时每个席位单独成一层。仅供左栏「展开关系图」做编排示意展示。
*/
function getChainStageGroups(chain: BusinessChain): BusinessChain["seats"][] {
const byId = new Map(chain.seats.map((s) => [s.agent_id, s]));
if (chain.stages && chain.stages.length > 0) {
return chain.stages
.map((stage) =>
stage
.map((id) => byId.get(id))
.filter((s): s is BusinessChain["seats"][number] => Boolean(s)),
)
.filter((stage) => stage.length > 0);
}
return chain.seats.map((s) => [s]);
}
/** 分析记录「按业务链筛选」的两个特殊值:全部 / 无业务链(自由模式)。 */
const RECORD_FILTER_ALL = "__all__";
const RECORD_FILTER_FREE = "__free__";
/**
* 需求 0616 #2:多智能体会商 Step1(落地)左栏入口栏。
* - 业务链:仅公共链条(``listChains("public")``)前 3 个;点击以该链席位启动会商。
* - 分析记录:圆桌历史草稿(``useRoundtableDrafts``);点击打开对应历史会商分析。
* 进入研讨(Step 2/3)后左栏恢复为「参与角色 / 任务概览」工作面板。
*/
export function RoundtableEntryRail({
selectedChainId,
onSelectChain,
onOpenDraft,
activeDraftId,
taskId,
businessCode,
}: {
selectedChainId: string | null;
onSelectChain: (chain: BusinessChain) => void;
onOpenDraft: (draftId: string) => void;
activeDraftId: string | null;
/** 深链 taskId 模式:分析记录只列该任务的草稿。 */
taskId?: string | null;
/** 深链业务码:xdfx(7BF) 左上角显示「当前行动」,其余显示「当前任务」。 */
businessCode?: string | null;
}) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
const [expandedChainId, setExpandedChainId] = useState<string | null>(null);
const { drafts: records, refresh } = useRoundtableDrafts(taskId);
// 分析记录「按业务链筛选」—— 选项从已加载草稿的 chainTitle 去重得到,
// 无业务链的草稿(自由 / recommend 模式)归入「自由模式」。
const [chainFilter, setChainFilter] = useState<string>(RECORD_FILTER_ALL);
const chainFilterOptions = useMemo(() => {
const titles = new Set<string>();
let hasFree = false;
for (const rec of records) {
const title = rec.chainTitle?.trim();
if (title) titles.add(title);
else hasFree = true;
}
const opts = Array.from(titles)
.sort((a, b) => a.localeCompare(b, "zh-CN"))
.map((title) => ({ value: title, label: title }));
if (hasFree) opts.push({ value: RECORD_FILTER_FREE, label: "自由模式" });
return opts;
}, [records]);
const filteredRecords = useMemo(() => {
if (chainFilter === RECORD_FILTER_ALL) return records;
if (chainFilter === RECORD_FILTER_FREE)
return records.filter((rec) => !rec.chainTitle?.trim());
return records.filter((rec) => rec.chainTitle?.trim() === chainFilter);
}, [records, chainFilter]);
// 草稿刷新后所选业务链可能已不存在(被删/改名)→ 回落到「全部」,避免列表空白无解。
useEffect(() => {
if (
chainFilter !== RECORD_FILTER_ALL &&
!chainFilterOptions.some((opt) => opt.value === chainFilter)
) {
setChainFilter(RECORD_FILTER_ALL);
}
}, [chainFilter, chainFilterOptions]);
// 深链接(taskId)模式:左上角不显示「业务链选择」(链条已由 businessCode 自动决定),改为展示
// 当前任务(rwfx/3qfx)或当前行动(xdfx)。xdfx 的根是「行动」,故展示行动名称 + 叙事。
const deeplink = Boolean(taskId);
const isXdfx = businessCode === "7BF";
const taskDetail = deeplink && !isXdfx ? readCopTaskDetail() : null;
const actionDetail = deeplink && isXdfx ? readActionDetail() : null;
useEffect(() => {
// 深链接模式不展示业务链选择 → 不必拉公共链条。
if (deeplink) {
void refresh();
return;
}
let cancelled = false;
void listChains("public")
.then((all) => {
if (!cancelled) setChains(all);
})
.catch(() => {
/* 业务链非关键,静默忽略 */
});
void refresh();
return () => {
cancelled = true;
};
}, [refresh, deeplink]);
const formatTime = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
};
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
{/* 深链接(taskId)模式:左上角展示「当前任务」(rwfx/3qfx)或「当前行动」(xdfx),替代业务链选择。 */}
{deeplink ? (
isXdfx ? (
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)]">
<div className="flex items-center gap-2 mb-3">
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<Target className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">当前行动</h2>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">行动名称</p>
<p className="mt-0.5 text-xs font-semibold leading-relaxed text-slate-800">
{actionDetail?.actionName?.trim() || "(未获取到行动名称)"}
</p>
</div>
</div>
) : (
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)]">
<div className="flex items-center gap-2 mb-3">
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<Clipboard className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">当前任务</h2>
</div>
<div className="space-y-2">
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">任务名称</p>
<p className="mt-0.5 text-xs font-semibold leading-relaxed text-slate-800">
{taskDetail?.taskName?.trim() || "(未获取到任务名称)"}
</p>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">任务描述</p>
<p className="mt-0.5 whitespace-pre-wrap text-[11px] leading-relaxed text-slate-600">
{taskDetail?.taskContent?.trim() || "(未获取到任务描述)"}
</p>
</div>
</div>
</div>
)
) : (
/* 业务链(公共列表,内部滚动) */
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] transition-shadow duration-300 hover:shadow-[0_4px_24px_rgba(45,37,34,0.04)]">
<div className="flex items-center gap-2">
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<Layers className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">业务链</h2>
<span className="ml-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-neutral-100 text-sm font-bold tabular-nums leading-none text-neutral-500">
{chains.length}
</span>
<button
type="button"
onClick={() => setChainSectionExpanded((v) => !v)}
title={chainSectionExpanded ? "收起业务链" : "展开业务链"}
aria-expanded={chainSectionExpanded}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-[#E6C9C4] bg-white text-neutral-400 transition-colors hover:border-primary/40 hover:text-primary"
>
<ChevronDown
className={cn(
"h-3.5 w-3.5 transition-transform duration-300 ease-out",
chainSectionExpanded && "rotate-180",
)}
/>
</button>
</div>
<div
className="grid overflow-hidden transition-all duration-300 ease-out"
style={{
gridTemplateRows: chainSectionExpanded ? "1fr" : "0fr",
marginTop: chainSectionExpanded ? 12 : 0,
opacity: chainSectionExpanded ? 1 : 0,
}}
>
<div className="min-h-0 overflow-hidden">
{chains.length === 0 ? (
<p className="text-xs text-slate-400 py-1.5">暂无公共业务链</p>
) : (
<div className="max-h-[292px] overflow-y-auto pr-1 -mr-1 custom-scrollbar">
<div className="flex flex-col gap-2.5 py-0.5">
{chains.map((chain) => {
const selected = selectedChainId === chain.id;
const graphOpen = expandedChainId === chain.id;
const stageGroups = getChainStageGroups(chain);
let seatColorIndex = 0;
return (
<div
key={chain.id}
className={cn(
"min-h-[88px] rounded-xl border transition-all duration-200",
selected
? "border-primary/25 bg-primary/5 ring-1 ring-primary/20 shadow-sm"
: "border-[#ECE7E4] bg-neutral-50/50 hover:border-neutral-300 hover:bg-neutral-50",
)}
>
<button
type="button"
onClick={() => onSelectChain(chain)}
title={selected ? "再次点击取消勾选" : `勾选业务链「${chain.title}」`}
aria-pressed={selected}
className="w-full rounded-xl p-3 text-left"
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
{/* 单选圆点 */}
<span
className={cn(
"flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full border transition-all",
selected
? "border-primary bg-primary"
: "border-neutral-300 bg-white",
)}
>
{selected && <span className="h-1.5 w-1.5 rounded-full bg-white" />}
</span>
<span
className={cn(
"truncate text-xs font-semibold",
selected ? "text-primary" : "text-neutral-800",
)}
>
{chain.title}
</span>
</div>
<span
className={cn(
"shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold",
selected
? "bg-primary/10 text-primary"
: "bg-neutral-100 text-neutral-500",
)}
>
{chain.seats.length} 席
</span>
</div>
{chain.description ? (
<p className="mt-1.5 line-clamp-2 text-[10.5px] leading-relaxed text-neutral-500">
{chain.description}
</p>
) : (
<p className="mt-1.5 text-[10px] font-medium text-neutral-400">
{selected ? "已选 · 再次点击取消" : "点击勾选该业务链"}
</p>
)}
</button>
{/* 关系图示例:选中后可展开,把该链席位智能体用小图标排成编排示意 */}
{selected && (
<div className="mx-3 mb-3 border-t border-dashed border-[#E8D0CC]/60 pt-2.5">
<button
type="button"
onClick={() =>
setExpandedChainId(graphOpen ? null : chain.id)
}
className="flex w-full items-center justify-center gap-1 rounded-full border border-primary/15 bg-primary/5 px-3 py-1 text-[10.5px] font-semibold text-primary transition-colors hover:bg-primary/10"
>
<Layers className="h-3 w-3" />
<span>{graphOpen ? "收起关系图" : "展开关系图"}</span>
{graphOpen ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</button>
{graphOpen && (
<div className="mt-2 rounded-xl border border-dashed border-[#E8D0CC]/60 bg-[#FAF6F5] p-2.5">
{stageGroups.length === 0 ? (
<p className="py-2 text-center text-[10px] text-neutral-400">
该业务链暂无智能体席位
</p>
) : (
<div className="flex flex-row flex-wrap items-center justify-center gap-1">
{stageGroups.flatMap((stage, si) => {
// 横向编排:阶段从左到右排列,阶段之间用 → 串联;并行阶段的
// 席位在该列内竖向堆叠(宽度可控,符合「同层并行·层间串行」示意)。
const items: React.ReactNode[] = [];
if (si > 0) {
items.push(
<ChevronRight
key={`arrow-${si}`}
className="h-3 w-3 shrink-0 text-neutral-300"
/>,
);
}
items.push(
<div
key={`stage-${si}`}
className="flex flex-col items-center gap-1"
>
{stage.length > 1 && (
<span className="rounded-full bg-primary/5 px-1.5 text-[8.5px] font-semibold text-primary">
并行 {stage.length}
</span>
)}
<div className="flex flex-col items-center gap-1.5">
{stage.map((seat, sj) => {
const palette =
SEAT_CHIP_PALETTE[
seatColorIndex++ %
SEAT_CHIP_PALETTE.length
];
return (
<div
key={`${si}-${sj}-${seat.agent_id}`}
className="group/seat relative"
>
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full border",
palette,
)}
>
<Cpu className="h-3 w-3" />
</span>
{/* 悬停浮层显示名称 / 描述 */}
<div className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 hidden -translate-x-1/2 group-hover/seat:block">
<div className="max-w-[180px] rounded-lg bg-neutral-900 px-2 py-1 text-white shadow-lg">
<div className="whitespace-nowrap text-[10px] font-semibold">
{seat.name}
</div>
{seat.description && (
<div className="mt-0.5 whitespace-normal text-[9px] leading-snug text-neutral-300">
{seat.description}
</div>
)}
</div>
<div className="absolute left-1/2 top-full h-0 w-0 -translate-x-1/2 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-neutral-900" />
</div>
</div>
);
})}
</div>
</div>,
);
return items;
})}
</div>
)}
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* 分析记录(圆桌历史草稿) */}
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] flex-1 flex flex-col min-h-[200px] overflow-hidden">
<div className="flex items-center gap-2 mb-3">
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<History className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">分析记录</h2>
{/* 按业务链筛选 —— 仅当存在多于 1 个可选业务链时才显示图标按钮。 */}
{chainFilterOptions.length > 1 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title="按业务链筛选"
aria-label="按业务链筛选"
className={cn(
"ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded-md border transition-colors",
chainFilter === RECORD_FILTER_ALL
? "border-[#ECE7E4] text-neutral-400 hover:border-primary/30 hover:text-primary"
: "border-primary/30 bg-primary/5 text-primary",
)}
>
<Filter className="h-3 w-3" strokeWidth={2.5} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[12rem]">
<DropdownMenuLabel className="text-[16px] text-neutral-500">
按业务链筛选
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={chainFilter}
onValueChange={setChainFilter}
>
<DropdownMenuRadioItem value={RECORD_FILTER_ALL} className="text-[16px]">
全部
</DropdownMenuRadioItem>
{chainFilterOptions.map((opt) => (
<DropdownMenuRadioItem
key={opt.value}
value={opt.value}
className="text-[16px]"
>
<span className="truncate">{opt.label}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{records.length === 0 ? (
<p className="flex-1 text-xs text-neutral-400 text-center py-8">暂无分析记录</p>
) : filteredRecords.length === 0 ? (
<p className="flex-1 text-xs text-neutral-400 text-center py-8">
该业务链下暂无分析记录
</p>
) : (
<>
<div className="space-y-2 overflow-y-auto flex-1 px-1 py-0.5 custom-scrollbar">
{filteredRecords.map((rec) => {
// 已完成:进行到第 3 步(执行过方案总结报告)
const completed = rec.furthestStep === 3;
// 选中:当前已加载的草稿(正在查看的那条)
const active = activeDraftId === rec.id;
return (
<button
key={rec.id}
type="button"
onClick={() => onOpenDraft(rec.id)}
title={rec.title}
aria-current={active}
className={cn(
"group/rec w-full rounded-[5px] border px-3 py-2.5 text-left transition-all duration-200",
active
? "border-primary/40 bg-primary/5 ring-1 ring-primary/20 shadow-[0_2px_10px_rgba(45,37,34,0.05)]"
: "border-[#ECE7E4] bg-white hover:border-primary/30 hover:bg-neutral-50 hover:shadow-[0_2px_10px_rgba(45,37,34,0.05)]",
)}
>
{/* 上:标题(左) + 状态标签(右上、放大) */}
<div className="flex items-start justify-between gap-2">
<span
className={cn(
"truncate text-[13px] font-semibold",
active
? "text-primary"
: "text-neutral-800 group-hover/rec:text-primary",
)}
>
{rec.title}
</span>
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-[5px] px-2 py-1 text-[11px] font-semibold",
completed
? "bg-emerald-50 text-emerald-600"
: "bg-amber-50 text-amber-600",
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
completed ? "bg-emerald-500" : "bg-amber-500",
)}
/>
{completed ? "已完成" : "未完成"}
</span>
</div>
{/* 下:时间(左下) + 业务链(右下,无链条显示「自由模式」) */}
<div className="mt-2 flex items-center justify-between gap-2">
<span className="font-mono text-[10px] text-neutral-400 shrink-0">
{formatTime(rec.updatedAt)}
</span>
<span
className="max-w-[60%] truncate rounded-[5px] bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500"
title={rec.chainTitle || "自由模式"}
>
{rec.chainTitle || "自由模式"}
</span>
</div>
</button>
);
})}
</div>
<div className="mt-2 pt-2 border-t border-neutral-100 text-center text-[11px] text-neutral-400">
{chainFilter === RECORD_FILTER_ALL
? `共 ${records.length} 条分析记录`
: `筛选出 ${filteredRecords.length} / ${records.length} 条分析记录`}
</div>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,256 @@
import { useEffect, useRef } from "react";
import {
Clipboard,
PanelLeftOpen,
Users,
} from "lucide-react";
import { useArtifacts } from "@/components/workspace/artifacts";
import { cn } from "@/lib/utils";
import type { DraftStep2RunSnapshot } from "@/roundtable-planning/api/drafts";
import { Man1Avatar } from "@/roundtable-planning/components/Avatars";
import {
IntentSectionIcon,
IntentSummaryCard,
} from "@/roundtable-planning/components/planning/RoundtableRightSidebar";
import { useStep1Intent } from "@/roundtable-planning/hooks/useStep1Intent";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
import type { RoleEntry } from "@/roundtable-planning/lib/roundtable-constants";
import {
seatFilterForRole,
USER_SEAT_FILTER,
type SeatFilter,
} from "@/roundtable-planning/lib/seat-filter";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { RoundtableEntryRail } from "@/roundtable-planning/components/planning/RoundtableEntryRail";
import {
CollapsedSidebarSection,
HumanParticipationCard,
RoleAvatarBadge,
RoleRow,
Step2RunSwitcher,
TaskOverviewCard,
} from "@/roundtable-planning/components/planning/RoundtableSidebarCards";
export interface LeftSidebarProps {
currentStep: number;
roles: RoleEntry[];
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
budgetLimit: number;
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
sidebarExpanded: boolean;
setSidebarExpanded: React.Dispatch<React.SetStateAction<boolean>>;
step2Runs: DraftStep2RunSnapshot[];
activeStep2RunId: string | null;
onSelectStep2Run: (runId: string) => void;
/** 第二步「按席位筛选会商记录」—— 点角色卡片筛选,与中间消息头像共用。 */
seatFilter: SeatFilter | null;
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
/** 需求 0616 #2:当前已勾选的业务链 id(高亮用),来自 autoChainResult.chain.id。 */
selectedChainId: string | null;
/** 需求 0616 #2:Step1 落地左栏「业务链」点击 → 勾选该链(不直接进研讨)。 */
onSelectChain: (chain: BusinessChain) => void;
/** 需求 0616 #2:Step1 落地左栏「分析记录」点击 → 打开历史会商分析(加载草稿)。 */
onOpenDraft: (draftId: string) => void;
/** 当前已加载草稿 id(分析记录选中高亮用),来自 drafts.currentDraftId。 */
activeDraftId: string | null;
/** 深链 taskId 模式:Step1 落地左栏「分析记录」只列该任务的草稿。 */
entryRailTaskId?: string | null;
/** 深链业务码:左上角卡片据此决定显示「当前任务」(rwfx/3qfx) 还是「当前行动」(xdfx)。 */
entryRailBusinessCode?: string | null;
}
export function Step2SandboxSidebarSync({
currentStep,
sidebarExpanded,
setSidebarExpanded,
}: {
currentStep: number;
sidebarExpanded: boolean;
setSidebarExpanded: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const { open } = useArtifacts();
const prevOpenRef = useRef(open);
useEffect(() => {
if (currentStep !== 2) {
prevOpenRef.current = open;
return;
}
if (open && !prevOpenRef.current && sidebarExpanded) {
setSidebarExpanded(false);
}
prevOpenRef.current = open;
}, [currentStep, open, setSidebarExpanded, sidebarExpanded]);
return null;
}
export function LeftSidebar({
currentStep,
roles,
agentNameToRole,
budgetLimit: _budgetLimit,
intentReady,
sidebarExpanded,
setSidebarExpanded,
step2Runs,
activeStep2RunId,
onSelectStep2Run,
seatFilter,
setSeatFilter,
selectedChainId,
onSelectChain,
onOpenDraft,
activeDraftId,
entryRailTaskId,
entryRailBusinessCode,
}: LeftSidebarProps) {
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
const dzRef = useDesensitizeSubtree<HTMLElement>();
const isStep2 = currentStep === 2;
// 仅第二步可按席位筛选会商记录;点中已选则取消(回到显示全部)。
const toggleSeatFilter = (filter: SeatFilter) =>
setSeatFilter((cur) => (cur === filter ? null : filter));
// Step 3 隐藏左侧栏(原「参与与结论贡献」列),让中间「最终方案展示」自适应
// 撑满整行宽度(右侧栏在 Step !==1 时也已 return null)。
if (currentStep === 3) return null;
return (
<section
ref={dzRef}
id="left-sidebar"
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 && (
<button
type="button"
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)]"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
)}
{currentStep === 2 ? (
sidebarExpanded ? (
<>
<IntentSummaryCard
intentReady={intentReady}
title="意图理解"
collapsible
sidebarToggle={{
expanded: sidebarExpanded,
onToggle: () => setSidebarExpanded((v) => !v),
}}
/>
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[240px]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
<Users className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">参与角色</h2>
</div>
<div className="space-y-2.5 overflow-y-auto flex-1 pr-1 custom-scrollbar">
{roles.map((role) => {
const filter = seatFilterForRole(role);
return (
<RoleRow
key={role.id}
role={role}
agentNameToRole={agentNameToRole}
active={seatFilter === filter}
onSelect={() => toggleSeatFilter(filter)}
/>
);
})}
</div>
</div>
<HumanParticipationCard
active={seatFilter === USER_SEAT_FILTER}
onSelect={() => toggleSeatFilter(USER_SEAT_FILTER)}
/>
<Step2RunSwitcher
runs={step2Runs}
activeRunId={activeStep2RunId}
onSelect={onSelectStep2Run}
/>
<TaskOverviewCard currentStep={currentStep} />
</>
) : (
<>
<CollapsedSidebarSection title="意图理解">
<IntentSectionIcon className="h-5 w-5" />
</CollapsedSidebarSection>
<CollapsedSidebarSection title="参与角色">
<div className="flex flex-col items-center gap-2">
{roles.map((role) => {
const filter = seatFilterForRole(role);
return (
<RoleAvatarBadge
key={role.id}
role={role}
agentNameToRole={agentNameToRole}
size="h-8 w-8"
showSpeakingAnimation
active={seatFilter === filter}
onSelect={() => toggleSeatFilter(filter)}
/>
);
})}
</div>
</CollapsedSidebarSection>
<CollapsedSidebarSection title="人工参与">
<button
type="button"
onClick={() => toggleSeatFilter(USER_SEAT_FILTER)}
aria-pressed={seatFilter === USER_SEAT_FILTER}
title={
seatFilter === USER_SEAT_FILTER
? "取消筛选,显示全部发言"
: "只看 用户 的发言"
}
className={cn(
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
seatFilter === USER_SEAT_FILTER &&
"ring-2 ring-primary ring-offset-2 ring-offset-white",
)}
>
<Man1Avatar size="h-8 w-8" />
</button>
</CollapsedSidebarSection>
<CollapsedSidebarSection title="任务概览">
<Clipboard className="h-5 w-5 text-primary" strokeWidth={2.5} />
</CollapsedSidebarSection>
</>
)
) : (
// Step 1(落地,未开始研讨):左栏改为业务链 + 分析记录入口(需求 0616 #2)。
<RoundtableEntryRail
selectedChainId={selectedChainId}
onSelectChain={onSelectChain}
onOpenDraft={onOpenDraft}
activeDraftId={activeDraftId}
taskId={entryRailTaskId ?? null}
businessCode={entryRailBusinessCode ?? null}
/>
)}
</section>
);
}
/** 「展开关系图」示例芯片的轮换配色(仅作视觉区分,无业务语义)。 */
@@ -0,0 +1,73 @@
import {
Activity,
BookOpen,
ChevronRight,
Layers,
Target,
Users,
Zap,
} from "lucide-react";
import type { PurposeLevel } from "@/core/auth/task-purpose";
import { flowNodeMeta, type FlowNodeType } from "@/roundtable-planning/lib/flow-graph";
/**
* 深链 taskId 模式 · Step 1 消息列表头部左侧的「策划流程」小图。
*
* 取代默认标题(任务意图理解),按目的树层级(目的 → 行为体 → … → 叙事)横向排列
* 彩色小图标、图标间用箭头串联,配色与流程图画布节点一致(FLOW_NODE_TYPE_META)。
* 点击整条 → 打开任务目的流程图弹窗看完整大图(不再单独放「查看」按钮)。
* 无层级数据时由调用方退回默认标题(此处 levels 必非空)。
*/
/** 各层级(流程图节点类型)对应的小图标;未列出的类型回退 Layers。 */
const LEVEL_ICON: Partial<Record<FlowNodeType, React.ComponentType<{ className?: string }>>> = {
stage: Target, // 目的
role: Users, // 行为体
task: Activity, // 预期行为
decision: Zap, // 驱动因素
deliverable: BookOpen, // 叙事
};
export function PurposeFlowPreview({
levels,
onOpen,
dark = false,
}: {
levels: PurposeLevel[];
onOpen: () => void;
dark?: boolean;
}) {
if (levels.length === 0) return null;
const meta = flowNodeMeta(dark);
return (
<button
type="button"
onClick={onOpen}
title="点击查看完整策划流程图"
className="group/flow custom-scrollbar flex min-w-0 cursor-pointer items-center gap-2 overflow-x-auto rounded-lg text-left transition-opacity hover:opacity-90"
>
<span className="inline-flex shrink-0 items-center gap-1.5 text-[11px] font-bold text-slate-500">
<Layers className="h-3.5 w-3.5 text-primary" />
原任务已有结果
</span>
{levels.map((lv, i) => {
const m = meta[lv.type];
const Icon = LEVEL_ICON[lv.type] ?? Layers;
return (
<span key={`${lv.label}-${i}`} className="flex shrink-0 items-center gap-1">
{i > 0 && <ChevronRight className="h-3.5 w-3.5 shrink-0 text-slate-300" />}
<span
title={lv.label}
style={{ background: m.bg, borderColor: m.border, color: m.text }}
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10.5px] font-semibold"
>
<Icon className="size-3 shrink-0" />
{lv.label}
</span>
</span>
);
})}
</button>
);
}
@@ -0,0 +1,448 @@
import { useState } from "react";
import type React from "react";
import {
Check,
ChevronDown,
ChevronUp,
Clock,
Cpu,
PanelLeftClose,
PanelLeftOpen,
Pencil,
Plus,
Trash2,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import { useStep1Intent } from "@/roundtable-planning/hooks/useStep1Intent";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
interface ModelSelectorCardProps {
selectedModel: string;
setSelectedModel: React.Dispatch<React.SetStateAction<string>>;
availableModels: ReadonlyArray<{ name: string; display_name?: string }>;
modelsLoading: boolean;
}
function ModelSelectorCard({
selectedModel,
setSelectedModel,
availableModels,
modelsLoading,
}: ModelSelectorCardProps) {
return (
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<Cpu className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">推理模型</h2>
</div>
<select
id="global-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: "切换全局推理模型;不影响 Persona 抽屉里已设置的 per-seat 覆盖"
}
className="w-full bg-slate-50/50 border border-slate-100 rounded-xl px-3 py-2 text-xs 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((model) => (
<option key={model.name} value={model.name}>
{model.display_name && model.display_name !== model.name
? `${model.display_name} (${model.name})`
: model.name}
</option>
))}
</select>
</div>
);
}
function IntentSummaryEditor({
initial,
title,
onSave,
onCancel,
}: {
initial: IntentSummary;
title: string;
onSave: (next: IntentSummary) => void;
onCancel: () => void;
}) {
const [objective, setObjective] = useState(initial.objective ?? "");
const [constraints, setConstraints] = useState<string[]>(
initial.constraints?.length ? [...initial.constraints] : [""],
);
const [assumptions, setAssumptions] = useState<string[]>(
initial.assumptions?.length ? [...initial.assumptions] : [],
);
const objectiveInvalid = objective.trim().length === 0;
const updateRow = (
setter: React.Dispatch<React.SetStateAction<string[]>>,
index: number,
value: string,
) => setter((previous) => previous.map((item, i) => (i === index ? value : item)));
const removeRow = (
setter: React.Dispatch<React.SetStateAction<string[]>>,
index: number,
) => setter((previous) => previous.filter((_, i) => i !== index));
const addRow = (setter: React.Dispatch<React.SetStateAction<string[]>>) =>
setter((previous) => [...previous, ""]);
const handleSave = () => {
if (objectiveInvalid) return;
onSave({
objective: objective.trim(),
constraints: constraints.map((constraint) => constraint.trim()).filter(Boolean),
assumptions: assumptions.map((assumption) => assumption.trim()).filter(Boolean),
});
};
const rowList = (
label: string,
rows: string[],
setter: React.Dispatch<React.SetStateAction<string[]>>,
placeholder: string,
) => (
<div className="space-y-1.5">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">{label}</span>
<div className="space-y-1.5">
{rows.map((row, index) => (
<div key={index} className="flex items-center gap-1.5">
<input
value={row}
onChange={(event) => updateRow(setter, index, event.target.value)}
placeholder={placeholder}
maxLength={200}
className="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-[11.5px] text-slate-700 focus:border-primary focus:outline-none focus:ring-1 focus:ring-[var(--brand-200)]/50"
/>
<button
type="button"
onClick={() => removeRow(setter, index)}
title="删除该项"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-slate-400 hover:bg-rose-50 hover:text-rose-500 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
<button
type="button"
onClick={() => addRow(setter)}
className="inline-flex items-center gap-1 text-[11px] font-bold text-slate-500 hover:text-primary transition-colors"
>
<Plus className="h-3.5 w-3.5" />
添加{label}
</button>
</div>
</div>
);
return (
<div className="bg-white border border-primary/30 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center justify-between gap-2 border-b border-slate-100 pb-2.5 mb-3">
<div className="flex items-center gap-2 min-w-0">
<IntentSectionIcon className="w-4 h-4" />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest truncate">
编辑{title}
</h2>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={handleSave}
disabled={objectiveInvalid}
title="保存"
className="flex h-7 items-center gap-1 rounded-lg bg-primary px-2.5 text-[11px] font-bold text-white hover:bg-[var(--brand-900)] disabled:bg-slate-300 disabled:cursor-not-allowed transition-colors"
>
<Check className="h-3.5 w-3.5" />
保存
</button>
<button
type="button"
onClick={onCancel}
title="取消"
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-700 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">核心目标</span>
<textarea
value={objective}
onChange={(event) => setObjective(event.target.value)}
rows={3}
maxLength={500}
placeholder="一句话说明本次要达成什么"
className={cn(
"w-full resize-y rounded-lg border bg-white px-2.5 py-1.5 text-[11.5px] leading-relaxed text-slate-700 focus:outline-none focus:ring-1",
objectiveInvalid
? "border-rose-300 focus:border-rose-400 focus:ring-rose-200/50"
: "border-slate-200 focus:border-primary focus:ring-[var(--brand-200)]/50",
)}
/>
{objectiveInvalid && <p className="text-[10px] text-rose-500">核心目标不能为空</p>}
</div>
{rowList("约束", constraints, setConstraints, "如:预算 / 时间 / 合规 / 范围")}
{rowList("假设", assumptions, setAssumptions, "智能体为你做的假设(可选)")}
</div>
</div>
);
}
interface IntentSummaryCardProps {
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
title?: string;
collapsible?: boolean;
sidebarToggle?: {
expanded: boolean;
onToggle: () => void;
};
onSave?: (next: IntentSummary) => void;
}
export function IntentSummaryCard({
intentReady,
title = "当前意图摘要",
collapsible = false,
sidebarToggle,
onSave,
}: IntentSummaryCardProps) {
const [expanded, setExpanded] = useState(!collapsible);
const showFullDetail = !collapsible || expanded;
const [editing, setEditing] = useState(false);
if (editing && intentReady) {
return (
<IntentSummaryEditor
initial={intentReady}
title={title}
onCancel={() => setEditing(false)}
onSave={(next) => {
onSave?.(next);
setEditing(false);
}}
/>
);
}
return (
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center justify-between gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<div className="flex items-center gap-2 min-w-0">
<IntentSectionIcon className="w-4 h-4" />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest truncate">{title}</h2>
</div>
<div className="flex items-center gap-1 shrink-0">
{onSave && intentReady && (
<button
type="button"
onClick={() => setEditing(true)}
title="编辑意图"
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:text-primary hover:bg-slate-50 transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
{sidebarToggle && (
<button
type="button"
onClick={sidebarToggle.onToggle}
title={sidebarToggle.expanded ? "收起侧栏" : "展开侧栏"}
aria-expanded={sidebarToggle.expanded}
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:text-primary hover:bg-slate-50 transition-colors"
>
{sidebarToggle.expanded ? (
<PanelLeftClose className="h-4 w-4" />
) : (
<PanelLeftOpen className="h-4 w-4" />
)}
</button>
)}
</div>
</div>
<div className="p-3 bg-slate-50/50 border border-slate-100 rounded-xl space-y-3 mr-0.5">
{intentReady ? (
<>
<ul className="text-xs text-slate-700 space-y-2.5 font-normal">
<li className="flex items-start gap-2 leading-relaxed">
<span className="text-primary text-base leading-none pt-0.5">•</span>
<span
className={cn(
"min-w-0 leading-relaxed",
collapsible && !expanded && "line-clamp-3",
)}
>
<b className="text-slate-900 font-bold">目标</b>: {intentReady.objective}
</span>
</li>
</ul>
{collapsible && (
<div className="flex justify-end">
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="inline-flex items-center gap-0.5 text-xs font-bold text-slate-500 hover:text-primary transition-colors"
aria-expanded={expanded}
>
{expanded ? (
<>
收起
<ChevronUp className="w-3.5 h-3.5" />
</>
) : (
<>
展开
<ChevronDown className="w-3.5 h-3.5" />
</>
)}
</button>
</div>
)}
{showFullDetail && (
<ul className="text-xs text-slate-700 space-y-2.5 font-normal">
{intentReady.constraints.map((constraint, index) => (
<li key={`c-${index}`} className="flex items-baseline gap-2 leading-relaxed">
<span className="text-primary text-base leading-none">•</span>
<span>
<b className="text-slate-900 font-bold">约束</b>: {constraint}
</span>
</li>
))}
{(intentReady.assumptions ?? []).map((assumption, index) => (
<li key={`a-${index}`} className="flex items-baseline gap-2 leading-relaxed">
<span className="text-slate-400 text-base leading-none">•</span>
<span className="text-slate-500">
<b className="text-slate-700 font-bold">假设</b>: {assumption}
</span>
</li>
))}
</ul>
)}
</>
) : (
<p className="text-xs text-slate-400 italic leading-relaxed">
等待任务理解智能体澄清完成...
</p>
)}
</div>
</div>
);
}
export function IntentSectionIcon({ className }: { className?: string }) {
return (
<svg
className={cn("text-primary shrink-0", className)}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</svg>
);
}
interface RightSidebarProps extends ModelSelectorCardProps {
currentStep: number;
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
timelineEntries: ReturnType<typeof useStep1Intent>["timelineEntries"];
triggerToast: (message: string) => void;
onSaveIntent: (next: IntentSummary) => void;
}
export function RightSidebar({
currentStep,
selectedModel,
setSelectedModel,
availableModels,
modelsLoading,
intentReady,
timelineEntries,
triggerToast,
onSaveIntent,
}: RightSidebarProps) {
const desensitizedRef = useDesensitizeSubtree<HTMLElement>();
if (currentStep !== 1) return null;
return (
<section
ref={desensitizedRef}
id="right-sidebar"
className="w-[300px] shrink-0 flex flex-col gap-4 overflow-y-auto pl-1"
>
<ModelSelectorCard
selectedModel={selectedModel}
setSelectedModel={setSelectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
/>
<IntentSummaryCard intentReady={intentReady} onSave={onSaveIntent} />
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[300px]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3 shrink-0">
<Clock className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">澄清记录</h2>
</div>
<div className="flex-1 overflow-y-auto pr-1 font-normal custom-scrollbar">
<div className="relative ml-2 space-y-4 border-l border-red-50 pb-2 pl-4 pt-1.5">
{timelineEntries.map((entry) => (
<div
key={entry.id}
className="relative rounded-lg px-1.5 py-1 transition-colors hover:bg-slate-50/80"
>
<div className="absolute -left-[20.5px] top-2 h-3 w-3 rounded-full bg-primary border-2 border-white ring-2 ring-[var(--brand-200)]" />
<div className="grid grid-cols-[68px_minmax(0,1fr)] gap-x-2">
<span className="font-mono text-xs leading-5 tracking-tight text-slate-400">
{entry.time}
</span>
<div className="min-w-0">
<h4 className="break-words text-sm font-bold leading-5 tracking-tight text-slate-800">
{entry.title}
</h4>
<p className="mt-1 break-words text-xs leading-5 text-slate-500">
{entry.desc}
</p>
</div>
</div>
</div>
))}
</div>
</div>
<div className="shrink-0 pt-2.5 border-t border-slate-100 flex items-center justify-between text-xs mt-2.5">
<span className="text-slate-400 font-medium">共 {timelineEntries.length} 条记录</span>
<button
onClick={() =>
triggerToast(`正在渲染全部 ${timelineEntries.length} 个历史事件归档`)
}
className="text-primary hover:text-[var(--brand-900)] font-bold hover:underline"
>
查看全部 &gt;
</button>
</div>
</div>
</section>
);
}
@@ -0,0 +1,411 @@
import {
Activity,
Check,
CheckCircle,
Clipboard,
ListOrdered,
Loader2,
} from "lucide-react";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import type { DraftStep2RunSnapshot } from "@/roundtable-planning/api/drafts";
import { getAvatar, Man1Avatar } from "@/roundtable-planning/components/Avatars";
import type { RoleEntry } from "@/roundtable-planning/lib/roundtable-constants";
export function Step2RunSwitcher({
runs,
activeRunId,
onSelect,
}: {
runs: DraftStep2RunSnapshot[];
activeRunId: string | null;
onSelect: (runId: string) => void;
}) {
if (runs.length === 0) return null;
const formatTime = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
};
const sourceLabel = (run: DraftStep2RunSnapshot) => {
if (run.source === "chain") return "业务链条";
if (run.source === "recommend") return "智能体推荐";
return "历史结果";
};
const statusLabel = (run: DraftStep2RunSnapshot) => {
if (run.step3) return "已生成报告";
if (run.hasConsensus) return "已达成共识";
if ((run.step2RoundtableDialogues?.length ?? 0) > 0) return "研讨记录";
return "待开始";
};
return (
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
<ListOrdered className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">生成结果</h2>
</div>
<div className="space-y-2">
{runs.map((run) => {
const active = run.id === activeRunId;
return (
<button
key={run.id}
type="button"
onClick={() => onSelect(run.id)}
className={cn(
"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",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs font-extrabold">{run.title}</span>
{active ? (
<CheckCircle className="h-3.5 w-3.5 shrink-0 text-primary" />
) : null}
</div>
<div className="mt-1.5 flex items-center justify-between gap-2 text-[10px] font-semibold">
<span className="truncate text-slate-400">{sourceLabel(run)}</span>
<span className="shrink-0 text-slate-400">{formatTime(run.createdAt)}</span>
</div>
<div className="mt-2 flex items-center gap-1.5">
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
run.step3
? "bg-emerald-500"
: run.hasConsensus
? "bg-primary"
: "bg-slate-300",
)}
/>
<span className="text-[10px] font-bold text-slate-500">{statusLabel(run)}</span>
</div>
</button>
);
})}
</div>
</div>
);
}
export function TaskOverviewCard({ currentStep }: { currentStep: number }) {
return (
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<Clipboard className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">任务概览</h2>
</div>
<div className="space-y-3">
<div>
<span className="text-[10px] uppercase tracking-wider text-slate-400 font-bold block mb-1">
当前节点
</span>
<div className="flex items-center justify-between gap-2.5">
<span className="text-sm font-bold text-slate-800">初始任务</span>
<span
className={`text-[10px] font-bold px-2 py-0.5 rounded-md border shrink-0 ${
currentStep === 1
? "text-[#ff9500] bg-orange-50 border-orange-200/50"
: currentStep === 2
? "text-[var(--brand-900)] bg-[var(--brand-200)] border-red-200/50"
: "text-emerald-600 bg-emerald-50 border-emerald-200/50"
}`}
>
{currentStep === 1 ? "待澄清" : currentStep === 2 ? "研讨中" : "已规划交付"}
</span>
</div>
<p className="text-xs text-slate-500 mt-2.5 leading-relaxed bg-slate-50 border border-slate-100 p-2 rounded-lg">
围绕某复杂任务制定规划方案
</p>
</div>
</div>
</div>
);
}
export function HumanParticipationCard({
active = false,
onSelect,
}: {
/** 当前筛选是否命中「用户」(命中则高亮)。 */
active?: boolean;
/** 传入则可点击,用于筛选出用户的插话 / 澄清回复。 */
onSelect?: () => void;
} = {}) {
const clickable = Boolean(onSelect);
return (
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<Activity className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">人工参与</h2>
</div>
<div
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
aria-pressed={clickable ? active : undefined}
title={
clickable
? active
? "取消筛选,显示全部发言"
: "只看 用户 的发言"
: undefined
}
onClick={onSelect}
onKeyDown={
clickable
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect?.();
}
}
: 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",
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",
)}
>
<div className="flex min-w-0 items-center gap-3">
<Man1Avatar size="h-10 w-10" />
<span className="truncate text-sm font-bold leading-5 text-slate-800">用户</span>
</div>
<div className="flex shrink-0 items-center gap-1.5 rounded-full border border-white/70 bg-white/70 px-2 py-1">
<span className="flex h-1.5 w-1.5 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500"></span>
</span>
<span className="whitespace-nowrap text-xs font-bold leading-none text-emerald-600">在线</span>
</div>
</div>
</div>
);
}
export function CollapsedSidebarSection({
title,
children,
className,
}: {
title: string;
children: React.ReactNode;
className?: string;
}) {
return (
<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",
className,
)}
>
{children}
</div>
);
}
function renderRoleAvatar(
role: RoleEntry,
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>,
size: string,
) {
if (role.type === "human") {
return <Man1Avatar size={size} />;
}
return getAvatar(
role.agentId ? agentNameToRole[role.agentId]?.avatarType ?? "blue" : "blue",
size,
);
}
export function RoleAvatarBadge({
role,
agentNameToRole,
size = "h-7 w-7",
showSpeakingAnimation = false,
active = false,
onSelect,
}: {
role: RoleEntry;
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
size?: string;
showSpeakingAnimation?: boolean;
/** 当前筛选是否命中本席位(命中则头像高亮)。 */
active?: boolean;
/** 传入则头像可点击,用于「按席位筛选会商记录」(收起态)。 */
onSelect?: () => void;
}) {
const isOngoing = role.status === "研讨中";
const isDone = role.status === "已完成";
const animate = showSpeakingAnimation && isOngoing;
const clickable = Boolean(onSelect);
const inner = (
<div
className={cn(
"relative shrink-0 rounded-full",
animate && "ring-2 ring-primary/50 ring-offset-2 ring-offset-white",
active && "ring-2 ring-primary ring-offset-2 ring-offset-white",
)}
>
{animate && (
<span className="pointer-events-none absolute inset-0 rounded-full border-2 border-primary/35 animate-ping" />
)}
<div className={cn("relative rounded-full", animate && "animate-pulse")}>
{renderRoleAvatar(role, agentNameToRole, size)}
</div>
{/* 收起态:输出完成的席位在头像右下角叠加「已完成」勾选角标。 */}
{isDone && (
<span className="pointer-events-none absolute -bottom-0.5 -right-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-emerald-500 ring-2 ring-white">
<Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />
</span>
)}
</div>
);
if (!clickable) {
return <div title={`${desensitize(role.name)} · ${role.status}`}>{inner}</div>;
}
return (
<button
type="button"
onClick={onSelect}
aria-pressed={active}
title={active ? "取消筛选,显示全部发言" : `只看 ${desensitize(role.name)} 的发言`}
className="shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
>
{inner}
</button>
);
}
export function RoleRow({
role,
agentNameToRole,
active = false,
onSelect,
}: {
role: RoleEntry;
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
/** 当前筛选是否命中本席位(命中则整卡高亮)。 */
active?: boolean;
/** 传入则整卡可点击,用于「按席位筛选会商记录」。 */
onSelect?: () => void;
}) {
const isActive =
role.status === "在线" || role.status === "研讨中" || role.status === "已完成";
const isOngoing = role.status === "研讨中";
const isBootingUp = role.status === "正在上线";
const clickable = Boolean(onSelect);
return (
<div
id={`role-item-${role.id}`}
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
aria-pressed={clickable ? active : undefined}
title={
clickable
? active
? "取消筛选,显示全部发言"
: `只看 ${desensitize(role.name)} 的发言`
: undefined
}
onClick={onSelect}
onKeyDown={
clickable
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect?.();
}
}
: undefined
}
className={cn(
"flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition",
isActive
? "bg-slate-50 border border-slate-100"
: isBootingUp
? "bg-amber-50/60 border border-amber-100"
: "opacity-60",
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 !opacity-100",
)}
>
<div className="flex min-w-0 items-center gap-3">
<RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} size="h-10 w-10" />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-bold leading-5 tracking-tight text-slate-800">
{desensitize(role.name)}
</span>
{role.type === "agent" && (
<span className="text-[11px] text-primary font-medium leading-4">AI 智能体</span>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5 rounded-full border border-white/70 bg-white/70 px-2 py-1">
{isOngoing ? (
<span className="flex h-1.5 w-1.5 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary/60 opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-primary"></span>
</span>
) : role.status === "在线" ? (
<span className="flex h-1.5 w-1.5 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500"></span>
</span>
) : role.status === "已完成" ? (
<Check className="h-3.5 w-3.5 text-emerald-500 font-bold" />
) : isBootingUp ? (
<Loader2 className="h-3 w-3 text-amber-500 animate-spin" />
) : (
<span className="h-1.5 w-1.5 rounded-full bg-slate-300" />
)}
<span
className={`whitespace-nowrap text-xs font-semibold leading-none ${
isOngoing
? "text-[var(--brand-900)]"
: role.status === "在线"
? "text-emerald-600 font-bold"
: role.status === "已完成"
? "text-emerald-600"
: isBootingUp
? "text-amber-600"
: "text-slate-400"
}`}
>
{role.status}
</span>
</div>
</div>
);
}
@@ -0,0 +1,225 @@
import { useCallback, useRef, useState } from "react";
import type React from "react";
import { FileIcon, HelpCircle, Paperclip, Sparkles, X } from "lucide-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export function Step2ClarificationCard({
clarification,
draft,
setDraft,
onSubmit,
}: {
clarification: ClarificationPayload | null;
draft: string;
setDraft: React.Dispatch<React.SetStateAction<string>>;
onSubmit: (answer: string) => void;
}) {
if (!clarification) return null;
return (
<div className="shrink-0 px-4 pt-3 bg-amber-50 border-t-2 border-amber-300">
<div className="flex items-start gap-3">
<HelpCircle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-extrabold text-amber-900">总控协调需要澄清</span>
{clarification.clarificationType && (
<span className="text-[calc(9px*var(--ui-font-scale,1))] font-mono font-black uppercase text-amber-900 bg-amber-200 border border-amber-300 px-1.5 py-0.2 rounded">
{clarification.clarificationType}
</span>
)}
</div>
{clarification.context && (
<p className="roundtable-chat-message text-amber-800 italic">
{clarification.context}
</p>
)}
<p className="roundtable-chat-message text-slate-800 font-medium whitespace-pre-wrap break-words">
{clarification.question}
</p>
{Array.isArray(clarification.options) && clarification.options.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{clarification.options.map((option, index) => {
const label =
typeof option === "string"
? option
: option && typeof option === "object" && "label" in option
? String((option as { label: unknown }).label ?? "")
: String(option ?? "");
if (!label) return null;
return (
<button
key={`${index}-${label}`}
type="button"
onClick={() => onSubmit(label)}
className="roundtable-chat-message font-bold px-3 py-1.5 rounded-full bg-white border border-amber-300 text-amber-900 hover:bg-amber-100 transition active:scale-95 cursor-pointer"
>
{label}
</button>
);
})}
</div>
)}
{clarification.allowCustom !== false && (
<div className="flex items-end gap-2 pt-1">
<AutoGrowTextarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSubmit(draft);
}
}}
placeholder="输入你的回答…(Enter 发送 / Shift+Enter 换行)"
className="flex-1 bg-white border border-amber-300 rounded-xl px-3 py-2 text-xs leading-relaxed focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-200 font-medium"
maxHeight={280}
/>
<button
type="button"
onClick={() => onSubmit(draft)}
disabled={!draft.trim()}
className="bg-amber-600 hover:bg-amber-700 disabled:bg-slate-300 disabled:cursor-not-allowed text-white text-xs font-bold px-4 py-2 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-amber-500/10"
>
回复
</button>
</div>
)}
</div>
</div>
</div>
);
}
export function Step2InterventionBar({
value,
setValue,
hasConsensus,
onInject,
onProceed,
}: {
value: string;
setValue: React.Dispatch<React.SetStateAction<string>>;
hasConsensus: boolean;
onInject: (files?: File[]) => void;
onProceed: () => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const handlePickFiles = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(event.target.files ?? []);
if (picked.length > 0) {
setFiles((previous) => {
const seen = new Set(previous.map((file) => `${file.name}:${file.size}`));
return [
...previous,
...picked.filter((file) => !seen.has(`${file.name}:${file.size}`)),
];
});
}
event.target.value = "";
}, []);
const removeFile = useCallback((index: number) => {
setFiles((previous) => previous.filter((_, i) => i !== index));
}, []);
const handleInject = useCallback(() => {
onInject(files.length > 0 ? files : undefined);
setFiles([]);
}, [files, onInject]);
return (
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex flex-col gap-2">
{files.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{files.map((file, index) => (
<span
key={`${file.name}-${file.size}-${index}`}
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]"
>
<FileIcon 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">{formatFileSize(file.size)}</span>
<button
type="button"
onClick={() => removeFile(index)}
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-3">
<AutoGrowTextarea
value={value}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleInject();
}
}}
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预...(Enter 发送 / Shift+Enter 换行)"
className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs leading-relaxed focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
maxHeight={280}
/>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handlePickFiles}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="上传参考文件(席位/总控将读取文件内容辅助研讨)"
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-500 hover:text-primary p-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95"
>
<Paperclip className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleInject}
title="记录干预指令并立即恢复会商执行"
className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10"
>
干预并发送
</button>
<button
type="button"
onClick={onProceed}
disabled={!hasConsensus}
aria-disabled={!hasConsensus}
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
className={`text-xs font-black px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 shadow-md shadow-emerald-500/10 ${
hasConsensus
? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
: "bg-emerald-600/40 text-white cursor-not-allowed"
}`}
>
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
</div>
</div>
);
}
@@ -0,0 +1,109 @@
import { ChevronRight } from "lucide-react";
export interface StepNavigationProps {
currentStep: number;
intentReady: object | null;
hasConsensus: boolean;
onStepClick: (step: number) => void;
triggerToast: (msg: string) => void;
}
export function StepNavigation({
currentStep,
intentReady,
hasConsensus,
onStepClick,
triggerToast,
}: StepNavigationProps) {
const step2Locked = !intentReady;
const step3Locked = !hasConsensus;
return (
<div className="flex items-center gap-1.5 rounded-2xl border border-slate-200/50 bg-slate-50 p-1.5 dark:border-border dark:bg-muted">
<StepButton
index={1}
label="任务意图理解"
active={currentStep === 1}
locked={false}
onClick={() => onStepClick(1)}
/>
<ChevronRight className="h-3 w-3 text-slate-300 dark:text-muted-foreground" />
<StepButton
index={2}
label="多智能体研讨"
active={currentStep === 2}
locked={step2Locked}
onClick={() => {
if (step2Locked) {
triggerToast("请先在第一步完成任务理解,确认[INTENT_READY]信号后再进入");
return;
}
onStepClick(2);
}}
/>
<ChevronRight className="h-3 w-3 text-slate-300 dark:text-muted-foreground" />
<StepButton
index={3}
label="方案结果展示"
active={currentStep === 3}
locked={step3Locked}
onClick={() => {
if (step3Locked) {
triggerToast(
intentReady
? "请先在第二步达成共识(总控自然收敛)再进入"
: "请先依次完成第一步和第二步",
);
return;
}
onStepClick(3);
}}
/>
</div>
);
}
interface StepButtonProps {
index: number;
label: string;
active: boolean;
locked: boolean;
onClick: () => void;
}
function StepButton({ index, label, active, locked, onClick }: StepButtonProps) {
return (
<button
id={`step-${index}-btn`}
type="button"
onClick={onClick}
disabled={locked}
aria-disabled={locked}
title={locked ? "请先完成上一步" : undefined}
className={`flex items-center gap-2 px-4 py-1.5 rounded-xl text-xs font-semibold tracking-wide transition-all relative ${
active
? "bg-white text-primary shadow-sm font-bold scale-[1.02] dark:bg-background"
: locked
? "text-slate-300 cursor-not-allowed dark:text-slate-600"
: "text-slate-500 hover:text-slate-800 dark:text-muted-foreground dark:hover:text-foreground"
}`}
>
<span
className={`w-5 h-5 flex items-center justify-center rounded-full text-[10px] font-bold ${
active
? "bg-primary text-white"
: locked
? "border border-slate-200 text-slate-300 dark:border-slate-700 dark:text-slate-600"
: "border-2 border-slate-400 text-slate-500 dark:border-muted-foreground dark:text-muted-foreground"
}`}
>
{index}
</span>
<span>{label}</span>
{locked && <span className="ml-0.5 text-[9px]">锁</span>}
{active && (
<span className="absolute bottom-0 left-1/2 -translate-x-1/2 w-8 h-1 bg-primary rounded-full" />
)}
</button>
);
}
@@ -0,0 +1,465 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import {
CheckCircle,
Database,
FileText,
Loader2,
Paperclip,
Pause,
RefreshCw,
SendHorizontal,
X,
} from "lucide-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { useLocalSettings } from "@/core/settings";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
// ── 右栏:智能体进度面板(沙箱的 60% 区域)+ 底部对话输入框 ──────────────────
export function AgentProgressPanel({
phase,
dialogues,
error,
busy,
mdAvailable,
flowPhase,
onRegenerateFlow,
pendingClarification,
onResolveClarification,
ingestAvailable,
ingestStatus,
pendingIngestClarification,
onResolveIngestChoice,
onResolveIngestClarification,
pendingFlowForIngest,
onResolveFlowForIngest,
onManualIngest,
onRetry,
onAsk,
onPause,
onArtifactStepClick,
onArtifactPreview,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
}: {
phase: UseStep3SummaryReturn["phase"];
dialogues: UseStep3SummaryReturn["dialogues"];
error: string | null;
busy: boolean;
/** 沙箱里是否已有 md 报告 —— 决定提示语(纯流程图轮没有 .md 可预览/下载)。 */
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;
onArtifactStepClick: UseStep3SummaryReturn["openArtifactFromStep"];
onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"];
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
// 新一轮气泡出现(如:md 报告完成后自动续跑的结构化输出轮、用户发问)→ 强制滚到底,
// 让用户看到新阶段(修「调用结构化输出时不自动下滑」)。仅在气泡**条数**增加时触发,
// 流式增量内容仍由 useAutoScroll 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。
const prevDialogueCountRef = useRef(dialogues.length);
useEffect(() => {
if (dialogues.length > prevDialogueCountRef.current) {
scrollToBottom();
}
prevDialogueCountRef.current = dialogues.length;
}, [dialogues.length, scrollToBottom]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
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 ?? []);
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 text = askText.trim();
const hasFiles = askFiles.length > 0;
if (!text && !hasFiles) return; // 允许「随时」发送(含生成中);空内容不发
setAskText("");
setAskFiles([]);
onAsk(text, hasFiles ? askFiles : undefined);
};
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="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
报
</div>
<div className="leading-tight min-w-0">
<div className="text-xs font-bold text-slate-800 truncate">方案总结报告智能体</div>
<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">
{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" />
正在初始化总结线程…
</div>
)}
{dialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
onArtifactStepClick={(step) => onArtifactStepClick(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
}}
/>
))}
{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"
: "报告正文实时输出;输出结构化数据时流程图画布同步绘制"}
</div>
)}
{phase === "error" && (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
<div className="font-bold mb-1">总结失败</div>
<div className="leading-relaxed break-all">{error}</div>
<button
type="button"
onClick={onRetry}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-rose-700 underline hover:text-rose-800"
>
<RefreshCw className="w-3 h-3" />
重试
</button>
</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;可在下方继续修改报告 / 流程图或提问。"
: "已生成方案总结,流程图可在画布查看;可在下方继续修改总结 / 流程图或提问。"}
</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>
)}
{/* 流程图已生成 → 询问是否入库(入库技能可用时)。 */}
{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>
</div>
);
}
// ── 弹窗外壳 ─────────────────────────────────────────────────────────────
@@ -0,0 +1,342 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Download, Eye, FileText, Loader2, X } from "lucide-react";
import {
buildArtifactDownloadUrl,
listSessionArtifacts,
type SessionArtifactFile,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { desensitize } from "@/lib/desensitize";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
export interface SeatDelivery {
sender: string;
avatarType: string;
content: string;
}
// ── 左栏源卡 ─────────────────────────────────────────────────────────────
export function SourceCard({
icon,
title,
hint,
badge,
disabled,
onClick,
}: {
icon: React.ReactNode;
title: string;
hint: string;
badge?: string;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
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"
>
<div className="flex items-center gap-2 mb-1.5">
{icon}
<h4 className="text-xs font-bold text-slate-900 tracking-wide flex-1 truncate">{title}</h4>
{badge && (
<span className="text-[10px] font-mono font-semibold text-primary bg-[var(--brand-200)]/50 px-1.5 py-0.5 rounded">
{badge}
</span>
)}
</div>
<p className="text-[11px] text-slate-400 leading-normal">{hint}</p>
</button>
);
}
export function CollapsedSourceCard({
icon,
title,
disabled,
onClick,
}: {
icon: React.ReactNode;
title: string;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
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"
>
{icon}
</button>
);
}
export function ContentModal({
title,
children,
onClose,
headerAction,
}: {
title: string;
children: React.ReactNode;
onClose: () => void;
headerAction?: React.ReactNode;
}) {
// 经 portal 挂到 body:页面 <main> 是 `relative z-0`(浅蓝主题层叠基线),会把
// 内部 fixed 弹窗困在 z-0 层叠上下文里,被会商页头(z-40)/全局顶栏(z-45)盖住顶部。
// 挂到 body 后脱离该上下文,z-50 才真正盖在顶栏之上,弹窗标题栏正常显示。
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[1px] p-4"
onClick={onClose}
>
<div
className="bg-white border border-slate-200 rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="shrink-0 flex items-center justify-between gap-2 px-5 py-3.5 border-b border-slate-100 bg-slate-50">
<h3 className="text-sm font-bold text-slate-900 min-w-0 truncate">{title}</h3>
<div className="flex items-center gap-2 shrink-0">
{headerAction}
<button
type="button"
onClick={onClose}
className="p-1 text-slate-400 hover:text-slate-600 rounded-md hover:bg-slate-100"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-5">{children}</div>
</div>
</div>,
document.body,
);
}
// ── 各席位交付(弹窗内的折叠列表)────────────────────────────────────────
export function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[] }) {
const [openIdx, setOpenIdx] = useState<number | null>(0);
if (seatDeliveries.length === 0) {
return <p className="text-xs text-slate-400 italic">本次研讨尚未产生子智能体交付。</p>;
}
return (
<div className="space-y-2">
{seatDeliveries.map((seat, idx) => {
const open = openIdx === idx;
return (
<div key={`${seat.sender}-${idx}`} className="border border-slate-150 rounded-xl overflow-hidden">
<button
type="button"
onClick={() => setOpenIdx(open ? null : idx)}
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-slate-50 hover:bg-slate-100 transition text-left"
>
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-bold text-slate-800 truncate">{desensitize(seat.sender)}</span>
</div>
<span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span>
</button>
{open && (
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{desensitize(seat.content || "(空)")}
</ReactMarkdown>
</div>
)}
</div>
);
})}
</div>
);
}
// ── 各席位生成文件(弹窗,按需拉取 outputs 列表)──────────────────────────
interface SeatArtifacts {
agentName: string;
displayName: string;
threadId: string;
files: SessionArtifactFile[];
loading: boolean;
error: string | null;
}
function shouldPreviewInModal(file: { name: string; mimeType: string | null }): boolean {
const mime = file.mimeType ?? "";
if (mime.startsWith("text/")) return true;
if (["application/json"].includes(mime)) return true;
return /\.(md|markdown|mmd|txt|json|csv|log|yml|yaml|xml|html?)$/i.test(file.name);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export function SeatFilesModal({
threadIds,
coordinatorName,
seatNameMap,
onClose,
}: {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
seatNameMap: Record<string, string>;
onClose: () => void;
}) {
const seatEntries = useMemo(() => {
if (!threadIds) return [] as Array<{ agentName: string; threadId: string; displayName: string }>;
return Object.entries(threadIds)
.filter(([name]) => !coordinatorName || name !== coordinatorName)
.map(([agentName, threadId]) => ({
agentName,
threadId,
displayName: seatNameMap[agentName.toLowerCase()] ?? agentName,
}));
}, [threadIds, coordinatorName, seatNameMap]);
const [bySeat, setBySeat] = useState<SeatArtifacts[]>([]);
const [previewing, setPreviewing] = useState<{ threadId: string; file: SessionArtifactFile } | null>(
null,
);
useEffect(() => {
if (seatEntries.length === 0) {
setBySeat([]);
return;
}
setBySeat(seatEntries.map((s) => ({ ...s, files: [], loading: true, error: null })));
const abort = new AbortController();
let cancelled = false;
(async () => {
const results = await Promise.all(
seatEntries.map(async (seat) => {
try {
const res = await listSessionArtifacts(seat.threadId, { signal: abort.signal });
return { seat, files: res.files, error: null as string | null };
} catch (err) {
if ((err as Error).name === "AbortError") {
return { seat, files: [] as SessionArtifactFile[], error: null };
}
return { seat, files: [] as SessionArtifactFile[], error: (err as Error).message };
}
}),
);
if (cancelled) return;
setBySeat(results.map(({ seat, files, error }) => ({ ...seat, files, loading: false, error })));
})();
return () => {
cancelled = true;
abort.abort();
};
}, [seatEntries]);
const handlePreview = (threadId: string, file: SessionArtifactFile) => {
if (shouldPreviewInModal({ name: file.name, mimeType: file.mimeType })) {
setPreviewing({ threadId, file });
return;
}
if (typeof window !== "undefined") {
window.open(buildArtifactDownloadUrl(threadId, file.path), "_blank", "noopener,noreferrer");
}
};
const handleDownload = (threadId: string, file: SessionArtifactFile) => {
if (typeof window !== "undefined") {
window.open(
buildArtifactDownloadUrl(threadId, file.path, { download: true }),
"_blank",
"noopener,noreferrer",
);
}
};
return (
<>
<ContentModal title="各席位生成文件" onClose={onClose}>
{bySeat.length === 0 ? (
<p className="text-xs text-slate-400 italic">尚未初始化席位线程,无法列出文件。</p>
) : (
<div className="space-y-3">
{bySeat.map((seat) => (
<div key={seat.agentName} className="border border-slate-150 rounded-xl">
<div className="flex items-center justify-between gap-2 px-3 py-2 bg-slate-50 border-b border-slate-150 rounded-t-xl">
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.displayName}</span>
<span className="text-[10px] text-slate-400 font-mono shrink-0">
{seat.loading ? "加载中…" : `${seat.files.length} 个文件`}
</span>
</div>
{seat.loading ? (
<div className="flex items-center gap-2 px-3 py-3 text-[11px] text-slate-500">
<Loader2 className="w-3 h-3 animate-spin text-primary" />
正在查询 outputs 目录…
</div>
) : seat.error ? (
<div className="px-3 py-3 text-[11px] text-rose-600">读取失败:{seat.error}</div>
) : seat.files.length === 0 ? (
<div className="px-3 py-3 text-[11px] text-slate-400 italic">该席位未产生输出文件</div>
) : (
<ul className="divide-y divide-slate-100">
{seat.files.map((f) => (
<li key={f.path} className="px-3 py-2 flex items-center justify-between gap-2 hover:bg-slate-50/60">
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-medium text-slate-800 truncate" title={f.name}>
{f.name}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-mono text-slate-400">{formatBytes(f.sizeBytes)}</span>
<button
type="button"
onClick={() => handlePreview(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="预览"
>
<Eye className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleDownload(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="下载"
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
</li>
))}
</ul>
)}
</div>
))}
</div>
)}
</ContentModal>
{previewing && (
<ArtifactPreviewModal
threadId={previewing.threadId}
path={previewing.file.path}
name={previewing.file.name}
mimeType={previewing.file.mimeType}
onClose={() => setPreviewing(null)}
/>
)}
</>
);
}
@@ -0,0 +1,123 @@
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import type {
ClarificationPayload,
ModelSwitchInfo,
StreamPhase,
ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import type { Step2ExecutionRefs } from "@/roundtable-planning/hooks/useStep2ExecutionRefs";
import type { ParallelSeatView } from "@/roundtable-planning/hooks/step2-types";
import type { ChainNodeState } from "@/roundtable-planning/lib/job-types";
type EngineExecutionRefName =
| "isOrchestratingRef"
| "orchestrationCancelRef"
| "orchestrationEpochRef"
| "selectedAgentsRef"
| "agentNameToRoleRef"
| "activeAbortersRef"
| "pendingInjectFilesRef"
| "selectedModelRef"
| "agentConfigsRef"
| "gatheredDeliveriesRef"
| "seatModesRef"
| "selectedModeRef"
| "seatSkillDirectivesRef"
| "orchestrationPlanRef"
| "dagExecModeRef"
| "dagPlanExecutedRef";
export interface Step2EngineRuntimePort {
refs: Pick<Step2ExecutionRefs, EngineExecutionRefName>;
resolveSeatThreadId: (ids: ThreadIdMap, agentName: string) => string | undefined;
}
export interface Step2EngineDialoguePort {
appendStreamingDialogue: (card: {
sender: string;
avatarType: string;
initialContent?: string;
confidence?: string;
agentThreadId?: string;
}) => number;
appendToDialogue: (id: number, deltaText: string) => void;
setDialogueContent: (id: number, fullText: string) => void;
appendStepIfNew: (
bubbleId: number,
label: string,
key: string,
args?: Record<string, unknown>,
) => void;
setDialogueConfidence: (id: number, confidence: string) => void;
phaseLabel: (
phase: StreamPhase,
agentType: "leader" | "special",
toolName?: string,
) => string;
finalizeDialogue: (id: number, finalContent: string, confidence?: string) => void;
makeModelSwitchHandler: (
bubbleId: number,
isParallel: boolean,
) => (info: ModelSwitchInfo) => void;
}
export interface Step2EngineSpeakerPort {
highlightSpeaker: (speakerId: number | null) => void;
markSpeakerStreaming: (speakerId: number) => void;
markSpeakerIdle: (speakerId: number) => void;
markParallelSpeakers: (speakerIds: number[]) => void;
clearAllStreamingSpeakers: () => void;
}
export interface Step2EngineParallelPort {
recommendParallelRef: MutableRefObject<boolean>;
parallelStreamRef: MutableRefObject<
Map<number, { text: string; charCount: number }>
>;
ingestParallelDelta: (bubbleId: number, deltaText: string) => void;
resetParallelStreams: () => void;
setParallelBatch: Dispatch<SetStateAction<ParallelSeatView[]>>;
setSelectedParallelSeats: Dispatch<SetStateAction<string[]>>;
}
export interface Step2EngineDagPort {
setDagNodeStates: Dispatch<
SetStateAction<Record<string, ChainNodeState>>
>;
}
export interface Step2EngineArtifactPort {
handleArtifactToolPhase: (
phase: StreamPhase,
toolName: string | undefined,
args: Record<string, unknown> | undefined,
seatThreadId: string | undefined,
) => void;
handleUpstreamFrame: (frame: unknown, seatThreadId: string | undefined) => void;
ingestSubagentTaskFrame: (frame: unknown, bubbleId: number) => void;
}
export interface Step2EngineControlPort {
setIsOrchestrating: Dispatch<SetStateAction<boolean>>;
setPendingClarification: Dispatch<SetStateAction<ClarificationPayload | null>>;
setClarificationDraft: Dispatch<SetStateAction<string>>;
setLastLeaderContent: Dispatch<SetStateAction<string>>;
setConsensusPercentage: Dispatch<SetStateAction<number>>;
setHasConsensus: Dispatch<SetStateAction<boolean>>;
triggerToast: (message: string) => void;
}
/**
* Shared boundary for Step 2 orchestration engines.
* Engines depend on grouped capabilities instead of closing over the whole page hook.
*/
export interface Step2EngineContext {
runtime: Step2EngineRuntimePort;
dialogue: Step2EngineDialoguePort;
speakers: Step2EngineSpeakerPort;
parallel: Step2EngineParallelPort;
dag: Step2EngineDagPort;
artifacts: Step2EngineArtifactPort;
control: Step2EngineControlPort;
}
@@ -0,0 +1,70 @@
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import type { SelectedAgent } from "@/roundtable-planning/lib/roundtable-constants";
/** Step 2 对话流里的一条气泡(总控派活 / 子智能体交付 / 系统播报 / 用户插话)。 */
export interface Step2Dialogue {
id: number;
sender: string;
avatarType: string;
content: string;
time: string;
confidence: string;
streaming?: boolean;
agentThreadId?: string;
subtaskIds?: string[];
steps?: Array<{
id: string;
key: string;
label: string;
ts: string;
args?: Record<string, unknown>;
}>;
}
/** One seat in a parallel execution batch. */
export interface ParallelSeatView {
agentId: string;
displayName: string;
avatarType: string;
status: "running" | "done" | "error";
bubbleId: number;
}
export interface StubToolCall {
id: string;
name: string;
args: Record<string, unknown>;
}
/** AI message stub injected into the artifact sandbox ThreadContext. */
export interface StubAiMessage {
id: string;
type: "ai";
content: string;
tool_calls: StubToolCall[];
}
/** Per-seat Persona overrides. Empty model means follow the global model. */
export interface AgentConfig {
model: string;
weight: number;
prompt: string;
temp: number;
}
/** Persisted Step 2 snapshot used by draft restore and live-job takeover. */
export interface Step2Snapshot {
selectedAgents?: SelectedAgent[];
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: Step2Dialogue[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
seatStubMessages: Record<string, StubAiMessage[]>;
orchestrationMode?: "recommend" | "chain" | "dag";
chain?: { id: string; title: string } | null;
orchestrationPlan?: OrchestrationPlan | null;
}
@@ -0,0 +1,376 @@
import { useCallback } from "react";
import {
streamMultiAgent,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import type { Step2EngineContext } from "@/roundtable-planning/hooks/step2-engine-context";
import { seatModeToRequestFields } from "@/roundtable-planning/lib/seat-mode";
import {
buildChainLeaderTask,
pickChainTask,
takeOnce,
} from "@/roundtable-planning/lib/step2-orchestration-helpers";
/**
* Chain mode: the coordinator dispatches each configured seat in order, then closes out.
* Special-agent runs intentionally stay serial to avoid concurrent token-level UI updates.
*/
export function useChainOrchestrationEngine(context: Step2EngineContext) {
const {
runtime: { refs, resolveSeatThreadId },
dialogue: {
appendStreamingDialogue,
appendToDialogue,
appendStepIfNew,
setDialogueConfidence,
phaseLabel,
finalizeDialogue,
makeModelSwitchHandler,
},
speakers: {
highlightSpeaker,
markSpeakerStreaming,
markSpeakerIdle,
clearAllStreamingSpeakers,
},
artifacts: {
handleArtifactToolPhase,
handleUpstreamFrame,
ingestSubagentTaskFrame,
},
control: {
setIsOrchestrating,
setPendingClarification,
setClarificationDraft,
setLastLeaderContent,
setConsensusPercentage,
setHasConsensus,
triggerToast,
},
} = context;
const {
isOrchestratingRef,
orchestrationCancelRef,
orchestrationEpochRef,
selectedAgentsRef,
agentNameToRoleRef,
activeAbortersRef,
pendingInjectFilesRef,
selectedModelRef,
agentConfigsRef,
gatheredDeliveriesRef,
seatModesRef,
selectedModeRef,
seatSkillDirectivesRef,
} = refs;
return useCallback(
async (seedMessage: string, ids: ThreadIdMap, coord: string) => {
if (isOrchestratingRef.current) return;
isOrchestratingRef.current = true;
setIsOrchestrating(true);
orchestrationCancelRef.current = false;
const myEpoch = (orchestrationEpochRef.current += 1);
const isStale = () =>
orchestrationCancelRef.current || orchestrationEpochRef.current !== myEpoch;
const chainSeats = selectedAgentsRef.current;
const total = chainSeats.length;
const leaderThreadId = resolveSeatThreadId(ids, coord);
let finishedAllSeats = true;
try {
for (let i = 0; i < total; i += 1) {
if (isStale()) {
finishedAllSeats = false;
break;
}
const seat = chainSeats[i];
const meta = agentNameToRoleRef.current[seat.agent_id];
const seatName = meta?.displayName ?? seat.name ?? seat.agent_id;
highlightSpeaker(null);
const leaderBubbleId = appendStreamingDialogue({
sender: "总控协调",
avatarType: "blue",
initialContent: "",
confidence: "链条派活中",
agentThreadId: leaderThreadId,
});
const leaderTask = buildChainLeaderTask({
seedMessage,
position: i,
total,
seatAgentId: seat.agent_id,
seatName,
isFirst: i === 0,
});
const leaderAborter = new AbortController();
activeAbortersRef.current.add(leaderAborter);
let leaderRes;
try {
leaderRes = await streamMultiAgent({
agentType: "leader",
dAgentThreadId: ids,
agentName: coord,
newMessage: leaderTask,
suppressPreBroadcast: i > 0,
files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined,
signal: leaderAborter.signal,
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName);
setDialogueConfidence(leaderBubbleId, label);
appendStepIfNew(
leaderBubbleId,
label,
`${phase}:${toolName ?? ""}`,
args,
);
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
});
} catch (error) {
activeAbortersRef.current.delete(leaderAborter);
if (
leaderAborter.signal.aborted ||
(error as Error).name === "AbortError"
) {
finalizeDialogue(leaderBubbleId, "(已停止)", "已停止");
finishedAllSeats = false;
break;
}
throw error;
}
activeAbortersRef.current.delete(leaderAborter);
if (isStale()) {
finalizeDialogue(
leaderBubbleId,
leaderRes.content || "(已停止)",
"已停止",
);
finishedAllSeats = false;
break;
}
if (leaderRes.clarification) {
finalizeDialogue(leaderBubbleId, leaderRes.content, "等待用户澄清");
highlightSpeaker(null);
setPendingClarification(leaderRes.clarification);
setClarificationDraft("");
triggerToast("总控需要补充信息,请回答下方问题后继续");
finishedAllSeats = false;
break;
}
finalizeDialogue(leaderBubbleId, leaderRes.content, "派活说明");
if (leaderRes.content) setLastLeaderContent(leaderRes.content);
const task = pickChainTask(
leaderRes.dispatched,
leaderRes.content,
seat.agent_id,
seatName,
);
if (meta) markSpeakerStreaming(meta.speakerId);
const seatThreadId = resolveSeatThreadId(ids, seat.agent_id);
const perSeatModel = meta
? agentConfigsRef.current[meta.speakerId]?.model
: undefined;
const subBubbleId = appendStreamingDialogue({
sender: seatName,
avatarType: meta?.avatarType ?? "blue",
initialContent: "",
confidence: "调度中",
agentThreadId: seatThreadId,
});
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
let aborted = false;
try {
const specRes = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: seat.agent_id,
displayName: seatName,
newMessage: task,
peerDeliveries: gatheredDeliveriesRef.current,
model: perSeatModel || selectedModelRef.current || undefined,
...seatModeToRequestFields(
seatModesRef.current[seat.agent_id] ?? selectedModeRef.current,
),
seatSkillDirective:
seatSkillDirectivesRef.current[seat.agent_id] ?? false,
signal: subAborter.signal,
onModelSwitch: makeModelSwitchHandler(subBubbleId, false),
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(subBubbleId, label);
appendStepIfNew(
subBubbleId,
label,
`${phase}:${toolName ?? ""}`,
args,
);
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
},
onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, seatThreadId);
ingestSubagentTaskFrame(frame, subBubbleId);
},
});
if (isStale()) {
finalizeDialogue(
subBubbleId,
specRes.content || "(已停止)",
"已停止",
);
aborted = true;
} else {
finalizeDialogue(subBubbleId, specRes.content, "子智能体交付");
}
} catch (error) {
if (
subAborter.signal.aborted ||
(error as Error).name === "AbortError"
) {
finalizeDialogue(subBubbleId, "(已停止)", "已停止");
aborted = true;
} else {
console.error(`[chain-special:${seat.agent_id}] error:`, error);
finalizeDialogue(
subBubbleId,
`(错误) ${(error as Error).message}`,
"调用失败",
);
}
} finally {
activeAbortersRef.current.delete(subAborter);
if (meta) markSpeakerIdle(meta.speakerId);
}
if (aborted) {
finishedAllSeats = false;
break;
}
setConsensusPercentage(
Math.min(95, Math.round(((i + 1) / total) * 90)),
);
}
if (!isStale() && finishedAllSeats && total > 0) {
highlightSpeaker(null);
const closeoutBubbleId = appendStreamingDialogue({
sender: "总控协调",
avatarType: "blue",
initialContent: "",
confidence: "汇总链条结论",
agentThreadId: leaderThreadId,
});
const closeoutAborter = new AbortController();
activeAbortersRef.current.add(closeoutAborter);
try {
const closeoutRes = await streamMultiAgent({
agentType: "leader",
dAgentThreadId: ids,
agentName: coord,
newMessage:
"各链条席位已按顺序完成发言,请你综合所有席位的交付,输出最终方案与结论。不要再派活、不要再向任何席位分配任务。",
synthesisMode: true,
suppressPreBroadcast: true,
model: selectedModelRef.current || undefined,
signal: closeoutAborter.signal,
onModelSwitch: makeModelSwitchHandler(closeoutBubbleId, false),
onTextDelta: (text) => appendToDialogue(closeoutBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName);
setDialogueConfidence(closeoutBubbleId, label);
appendStepIfNew(
closeoutBubbleId,
label,
`${phase}:${toolName ?? ""}`,
args,
);
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
});
finalizeDialogue(closeoutBubbleId, closeoutRes.content, "全案盖印");
if (closeoutRes.content) setLastLeaderContent(closeoutRes.content);
setConsensusPercentage(100);
setHasConsensus(true);
triggerToast("业务链条研讨完成!已生成全案结论");
} catch (error) {
if (
closeoutAborter.signal.aborted ||
(error as Error).name === "AbortError"
) {
finalizeDialogue(closeoutBubbleId, "(已停止)", "已停止");
} else {
console.error("chain closeout error:", error);
finalizeDialogue(
closeoutBubbleId,
`(错误) ${(error as Error).message}`,
"调用失败",
);
}
} finally {
activeAbortersRef.current.delete(closeoutAborter);
}
}
} catch (error) {
console.error("chain orchestration error:", error);
triggerToast(`链条研讨异常: ${(error as Error).message}`);
} finally {
highlightSpeaker(null);
clearAllStreamingSpeakers();
setIsOrchestrating(false);
}
},
[
activeAbortersRef,
agentConfigsRef,
agentNameToRoleRef,
appendStepIfNew,
appendStreamingDialogue,
appendToDialogue,
clearAllStreamingSpeakers,
finalizeDialogue,
gatheredDeliveriesRef,
handleArtifactToolPhase,
handleUpstreamFrame,
highlightSpeaker,
ingestSubagentTaskFrame,
isOrchestratingRef,
makeModelSwitchHandler,
markSpeakerIdle,
markSpeakerStreaming,
orchestrationCancelRef,
orchestrationEpochRef,
pendingInjectFilesRef,
phaseLabel,
resolveSeatThreadId,
seatModesRef,
seatSkillDirectivesRef,
selectedAgentsRef,
selectedModeRef,
selectedModelRef,
setClarificationDraft,
setConsensusPercentage,
setDialogueConfidence,
setHasConsensus,
setIsOrchestrating,
setLastLeaderContent,
setPendingClarification,
triggerToast,
],
);
}
@@ -0,0 +1,485 @@
import { useCallback } from "react";
import {
streamMultiAgent,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import type { Step2EngineContext } from "@/roundtable-planning/hooks/step2-engine-context";
import type { ParallelSeatView } from "@/roundtable-planning/hooks/step2-types";
import {
buildDagStageLeaderTask,
pickDagSeatTask,
} from "@/roundtable-planning/lib/dag-types";
import type { ChainNodeState } from "@/roundtable-planning/lib/job-types";
import { seatModeToRequestFields } from "@/roundtable-planning/lib/seat-mode";
import { takeOnce } from "@/roundtable-planning/lib/step2-orchestration-helpers";
export interface DagOrchestrationEngineContext extends Step2EngineContext {
continueWithRecommend: (
seedMessage: string,
ids: ThreadIdMap,
coordinator: string,
) => Promise<void>;
}
/**
* DAG mode: stages execute serially, seats inside a stage execute in parallel, and the
* recommend engine takes over after the plan when final synthesis is enabled.
*/
export function useDagOrchestrationEngine(
context: DagOrchestrationEngineContext,
) {
const {
runtime: { refs, resolveSeatThreadId },
dialogue: {
appendStreamingDialogue,
appendToDialogue,
appendStepIfNew,
setDialogueConfidence,
phaseLabel,
finalizeDialogue,
makeModelSwitchHandler,
},
speakers: {
highlightSpeaker,
markSpeakerIdle,
markParallelSpeakers,
clearAllStreamingSpeakers,
},
parallel: {
parallelStreamRef,
ingestParallelDelta,
resetParallelStreams,
setParallelBatch,
setSelectedParallelSeats,
},
dag: { setDagNodeStates },
artifacts: {
handleArtifactToolPhase,
handleUpstreamFrame,
ingestSubagentTaskFrame,
},
control: {
setIsOrchestrating,
setPendingClarification,
setClarificationDraft,
setLastLeaderContent,
setConsensusPercentage,
setHasConsensus,
triggerToast,
},
continueWithRecommend,
} = context;
const {
orchestrationPlanRef,
isOrchestratingRef,
orchestrationCancelRef,
orchestrationEpochRef,
dagPlanExecutedRef,
gatheredDeliveriesRef,
agentNameToRoleRef,
activeAbortersRef,
pendingInjectFilesRef,
selectedModelRef,
dagExecModeRef,
agentConfigsRef,
seatModesRef,
selectedModeRef,
seatSkillDirectivesRef,
} = refs;
return useCallback(
async (seedMessage: string, ids: ThreadIdMap, coord: string) => {
const plan = orchestrationPlanRef.current;
if (!plan || plan.stages.length === 0) {
triggerToast("没有可执行的 DAG 编排计划");
return;
}
if (isOrchestratingRef.current) return;
isOrchestratingRef.current = true;
setIsOrchestrating(true);
orchestrationCancelRef.current = false;
const myEpoch = (orchestrationEpochRef.current += 1);
const isStale = () =>
orchestrationCancelRef.current || orchestrationEpochRef.current !== myEpoch;
dagPlanExecutedRef.current = true;
const leaderThreadId = resolveSeatThreadId(ids, coord);
let finishedAllStages = true;
const priorDeliveries: Array<{ name: string; content: string }> = [
...gatheredDeliveriesRef.current,
];
const markDagNode = (agentId: string, state: ChainNodeState) =>
setDagNodeStates((previous) => ({
...previous,
[agentId.toLowerCase()]: state,
}));
setDagNodeStates(
Object.fromEntries(
plan.stages.flatMap((stage) =>
stage.agentIds.map((agentId) => [
agentId.toLowerCase(),
"pending" as ChainNodeState,
]),
),
),
);
try {
for (let stageIndex = 0; stageIndex < plan.stages.length; stageIndex += 1) {
if (isStale()) {
finishedAllStages = false;
break;
}
const stage = plan.stages[stageIndex];
const isParallel = stage.agentIds.length > 1;
const stageSeats = stage.agentIds.map((agentId) => {
const meta = agentNameToRoleRef.current[agentId.toLowerCase()];
return { agentId, meta, name: meta?.displayName ?? agentId };
});
highlightSpeaker(null);
const leaderBubbleId = appendStreamingDialogue({
sender: "总控协调",
avatarType: "blue",
initialContent: "",
confidence: stageIndex === 0 ? "派活中" : "承上派活中",
agentThreadId: leaderThreadId,
});
const leaderTask = buildDagStageLeaderTask({
seedMessage,
stageIndex,
totalStages: plan.stages.length,
seats: stageSeats.map((seat) => ({
agentId: seat.agentId,
name: seat.name,
})),
isFirst: stageIndex === 0,
goal: stage.goal,
coordinatorPrompt: plan.coordinatorPrompt,
});
const leaderAborter = new AbortController();
activeAbortersRef.current.add(leaderAborter);
let leaderResponse;
try {
leaderResponse = await streamMultiAgent({
agentType: "leader",
dAgentThreadId: ids,
agentName: coord,
newMessage: leaderTask,
synthesisMode: false,
suppressPreBroadcast: true,
files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined,
signal: leaderAborter.signal,
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName);
setDialogueConfidence(leaderBubbleId, label);
appendStepIfNew(
leaderBubbleId,
label,
`${phase}:${toolName ?? ""}`,
args,
);
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
},
onUpstreamFrame: (frame) =>
handleUpstreamFrame(frame, leaderThreadId),
});
} catch (error) {
activeAbortersRef.current.delete(leaderAborter);
if (
leaderAborter.signal.aborted ||
(error as Error).name === "AbortError"
) {
finalizeDialogue(leaderBubbleId, "(已停止)", "已停止");
finishedAllStages = false;
break;
}
throw error;
}
activeAbortersRef.current.delete(leaderAborter);
if (isStale()) {
finalizeDialogue(
leaderBubbleId,
leaderResponse.content || "(已停止)",
"已停止",
);
finishedAllStages = false;
break;
}
if (leaderResponse.clarification) {
finalizeDialogue(
leaderBubbleId,
leaderResponse.content,
"等待用户澄清",
);
setPendingClarification(leaderResponse.clarification);
setClarificationDraft("");
triggerToast("总控需要补充信息,请回答下方问题后继续");
finishedAllStages = false;
break;
}
finalizeDialogue(leaderBubbleId, leaderResponse.content, "派活说明");
if (leaderResponse.content) {
setLastLeaderContent(leaderResponse.content);
}
const seatPrep = stageSeats.map(({ agentId, meta, name }) => {
const avatarType = meta?.avatarType ?? "blue";
const seatThreadId = resolveSeatThreadId(ids, agentId);
const bubbleId = appendStreamingDialogue({
sender: name,
avatarType,
initialContent: "",
confidence: "调度中",
agentThreadId: seatThreadId,
});
return {
agentId,
meta,
seatName: name,
avatarType,
seatThreadId,
bubbleId,
};
});
markParallelSpeakers(
seatPrep
.map((seat) => seat.meta?.speakerId)
.filter((value): value is number => typeof value === "number"),
);
for (const seat of seatPrep) {
markDagNode(seat.agentId, "active");
}
if (isParallel) {
setParallelBatch(
seatPrep.map((seat) => ({
agentId: seat.agentId,
displayName: seat.seatName,
avatarType: seat.avatarType,
status: "running" as const,
bubbleId: seat.bubbleId,
})),
);
const columns = dagExecModeRef.current === "sandbox" ? 1 : 2;
setSelectedParallelSeats(
seatPrep.slice(0, columns).map((seat) => seat.agentId),
);
}
const markBatchStatus = (
agentId: string,
status: ParallelSeatView["status"],
) =>
setParallelBatch((previous) =>
previous.map((seat) =>
seat.agentId === agentId ? { ...seat, status } : seat,
),
);
const stageResults = await Promise.all(
seatPrep.map(
async ({
agentId,
meta,
seatName,
seatThreadId,
bubbleId,
}): Promise<{ name: string; content: string } | null> => {
const perSeatModel = meta
? agentConfigsRef.current[meta.speakerId]?.model
: undefined;
const task = pickDagSeatTask(
leaderResponse.dispatched,
agentId,
seatName,
seedMessage,
priorDeliveries.length > 0,
dagExecModeRef.current === "sandbox",
stage.goal,
);
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
try {
const response = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: agentId,
displayName: seatName,
newMessage: task,
parallelNoFile: dagExecModeRef.current !== "sandbox",
peerDeliveries: priorDeliveries,
model: perSeatModel || selectedModelRef.current || undefined,
...seatModeToRequestFields(
seatModesRef.current[agentId] ?? selectedModeRef.current,
),
seatSkillDirective:
seatSkillDirectivesRef.current[agentId] ?? false,
signal: subAborter.signal,
onModelSwitch: makeModelSwitchHandler(bubbleId, isParallel),
onTextDelta: (text) =>
isParallel
? ingestParallelDelta(bubbleId, text)
: appendToDialogue(bubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(bubbleId, label);
appendStepIfNew(
bubbleId,
label,
`${phase}:${toolName ?? ""}`,
args,
);
handleArtifactToolPhase(
phase,
toolName,
args,
seatThreadId,
);
},
onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, seatThreadId);
ingestSubagentTaskFrame(frame, bubbleId);
},
});
if (isStale()) {
finalizeDialogue(
bubbleId,
response.content || "(已停止)",
"已停止",
);
markBatchStatus(agentId, "done");
return null;
}
finalizeDialogue(bubbleId, response.content, "子智能体交付");
markBatchStatus(agentId, "done");
return response.content?.trim()
? { name: seatName, content: response.content }
: null;
} catch (error) {
if (
subAborter.signal.aborted ||
(error as Error).name === "AbortError"
) {
finalizeDialogue(bubbleId, "(已停止)", "已停止");
markBatchStatus(agentId, "done");
} else {
console.error(`[dag-special:${agentId}] error:`, error);
finalizeDialogue(
bubbleId,
`(错误) ${(error as Error).message}`,
"调用失败",
);
markBatchStatus(agentId, "error");
}
return null;
} finally {
activeAbortersRef.current.delete(subAborter);
if (meta) markSpeakerIdle(meta.speakerId);
markDagNode(agentId, "done");
parallelStreamRef.current.delete(bubbleId);
}
},
),
);
if (isParallel) {
setParallelBatch([]);
setSelectedParallelSeats([]);
resetParallelStreams();
}
if (isStale()) {
finishedAllStages = false;
break;
}
for (const result of stageResults) {
if (result?.content.trim()) priorDeliveries.push(result);
}
setConsensusPercentage(
Math.min(
95,
Math.round(((stageIndex + 1) / plan.stages.length) * 90),
),
);
}
if (!isStale() && finishedAllStages && plan.finalSynthesis) {
highlightSpeaker(null);
isOrchestratingRef.current = false;
setIsOrchestrating(false);
await continueWithRecommend(
"各阶段席位已按依赖编排完成交付。请你作为总控,综合各席位的交付:若仍需补充则继续派活,否则直接输出最终方案并收口。",
ids,
coord,
);
return;
}
if (!isStale() && finishedAllStages) {
setConsensusPercentage(100);
setHasConsensus(true);
triggerToast("依赖编排各阶段已全部完成");
}
} catch (error) {
console.error("dag orchestration error:", error);
triggerToast(`依赖编排研讨异常: ${(error as Error).message}`);
} finally {
highlightSpeaker(null);
clearAllStreamingSpeakers();
setParallelBatch([]);
setSelectedParallelSeats([]);
resetParallelStreams();
setIsOrchestrating(false);
}
},
[
activeAbortersRef,
agentConfigsRef,
agentNameToRoleRef,
appendStepIfNew,
appendStreamingDialogue,
appendToDialogue,
clearAllStreamingSpeakers,
continueWithRecommend,
dagExecModeRef,
dagPlanExecutedRef,
finalizeDialogue,
gatheredDeliveriesRef,
handleArtifactToolPhase,
handleUpstreamFrame,
highlightSpeaker,
ingestParallelDelta,
ingestSubagentTaskFrame,
isOrchestratingRef,
makeModelSwitchHandler,
markParallelSpeakers,
markSpeakerIdle,
orchestrationCancelRef,
orchestrationEpochRef,
orchestrationPlanRef,
parallelStreamRef,
pendingInjectFilesRef,
phaseLabel,
resetParallelStreams,
resolveSeatThreadId,
seatModesRef,
seatSkillDirectivesRef,
selectedModeRef,
selectedModelRef,
setClarificationDraft,
setConsensusPercentage,
setDagNodeStates,
setDialogueConfidence,
setHasConsensus,
setIsOrchestrating,
setLastLeaderContent,
setParallelBatch,
setPendingClarification,
setSelectedParallelSeats,
triggerToast,
],
);
}
@@ -0,0 +1,215 @@
import { useCallback } from "react";
import {
streamMultiAgent,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import type { Step2EngineContext } from "@/roundtable-planning/hooks/step2-engine-context";
import type { ParallelSeatView } from "@/roundtable-planning/hooks/step2-types";
import { seatModeToRequestFields } from "@/roundtable-planning/lib/seat-mode";
const GATHER_TASK =
"【资料收集阶段——只取数、不研讨】\n现在是正式研讨**之前**的并行资料收集阶段。请你**只负责把与本次任务、" +
"与你这个席位职责相关的真实信息/数据收集、整理出来**(优先用你配置的技能去检索/获取真实信息,没有技能可凭专业知识梳理)," +
"为后续正式研讨打底。\n**这一步不要做分析评估、不要给方案、不要下结论**——把判断与方案留到研讨阶段。" +
"请把你收集到的关键信息整理成条理清晰的事实/要点清单输出,**不要向用户提问或等待确认**。";
/** Runs the optional pre-discussion parallel data-gathering phase once per round. */
export function useGatherPhaseEngine(context: Step2EngineContext) {
const {
runtime: { refs, resolveSeatThreadId },
dialogue: {
appendStreamingDialogue,
appendStepIfNew,
setDialogueConfidence,
phaseLabel,
finalizeDialogue,
makeModelSwitchHandler,
},
speakers: {
markSpeakerIdle,
markParallelSpeakers,
},
parallel: {
parallelStreamRef,
ingestParallelDelta,
resetParallelStreams,
setParallelBatch,
setSelectedParallelSeats,
},
artifacts: {
handleArtifactToolPhase,
handleUpstreamFrame,
ingestSubagentTaskFrame,
},
} = context;
const {
selectedAgentsRef,
agentNameToRoleRef,
agentConfigsRef,
activeAbortersRef,
selectedModelRef,
seatModesRef,
selectedModeRef,
seatSkillDirectivesRef,
gatheredDeliveriesRef,
} = refs;
return useCallback(
async (ids: ThreadIdMap): Promise<void> => {
const agents = selectedAgentsRef.current;
if (agents.length === 0) return;
const seatPrep = agents.map((agent) => {
const meta = agentNameToRoleRef.current[agent.agent_id.toLowerCase()];
const seatName = meta?.displayName ?? agent.name;
const avatarType = meta?.avatarType ?? "blue";
const seatThreadId = resolveSeatThreadId(ids, agent.agent_id);
const bubbleId = appendStreamingDialogue({
sender: seatName,
avatarType,
initialContent: "",
confidence: "并行取数中",
agentThreadId: seatThreadId,
});
return {
agentId: agent.agent_id,
seatName,
avatarType,
seatThreadId,
bubbleId,
meta,
};
});
markParallelSpeakers(
seatPrep
.map((seat) => seat.meta?.speakerId)
.filter((value): value is number => typeof value === "number"),
);
setParallelBatch(
seatPrep.map((seat) => ({
agentId: seat.agentId,
displayName: seat.seatName,
avatarType: seat.avatarType,
status: "running" as const,
bubbleId: seat.bubbleId,
})),
);
setSelectedParallelSeats(
seatPrep.slice(0, 1).map((seat) => seat.agentId),
);
const markBatchStatus = (
agentId: string,
status: ParallelSeatView["status"],
) =>
setParallelBatch((previous) =>
previous.map((seat) =>
seat.agentId === agentId ? { ...seat, status } : seat,
),
);
const results = await Promise.all(
seatPrep.map(
async ({
agentId,
seatName,
seatThreadId,
bubbleId,
meta,
}) => {
const perSeatModel = meta
? agentConfigsRef.current[meta.speakerId]?.model
: undefined;
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
try {
const response = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: agentId,
displayName: seatName,
newMessage: GATHER_TASK,
model: perSeatModel || selectedModelRef.current || undefined,
...seatModeToRequestFields(
seatModesRef.current[agentId] ?? selectedModeRef.current,
),
seatSkillDirective:
seatSkillDirectivesRef.current[agentId] ?? false,
signal: subAborter.signal,
onModelSwitch: makeModelSwitchHandler(bubbleId, true),
onTextDelta: (text) => ingestParallelDelta(bubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(bubbleId, label);
appendStepIfNew(
bubbleId,
label,
`${phase}:${toolName ?? ""}`,
args,
);
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
},
onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, seatThreadId);
ingestSubagentTaskFrame(frame, bubbleId);
},
});
activeAbortersRef.current.delete(subAborter);
finalizeDialogue(bubbleId, response.content, "资料收集");
markBatchStatus(agentId, "done");
if (meta) markSpeakerIdle(meta.speakerId);
parallelStreamRef.current.delete(bubbleId);
return response.content?.trim()
? { name: seatName, content: response.content }
: null;
} catch (error) {
activeAbortersRef.current.delete(subAborter);
finalizeDialogue(
bubbleId,
`(取数失败) ${(error as Error).message}`,
"取数失败",
);
markBatchStatus(agentId, "done");
parallelStreamRef.current.delete(bubbleId);
return null;
}
},
),
);
setParallelBatch([]);
setSelectedParallelSeats([]);
resetParallelStreams();
gatheredDeliveriesRef.current = results.filter(
(result): result is { name: string; content: string } => Boolean(result),
);
},
[
activeAbortersRef,
agentConfigsRef,
agentNameToRoleRef,
appendStepIfNew,
appendStreamingDialogue,
finalizeDialogue,
gatheredDeliveriesRef,
handleArtifactToolPhase,
handleUpstreamFrame,
ingestParallelDelta,
ingestSubagentTaskFrame,
makeModelSwitchHandler,
markParallelSpeakers,
markSpeakerIdle,
parallelStreamRef,
phaseLabel,
resetParallelStreams,
resolveSeatThreadId,
seatModesRef,
seatSkillDirectivesRef,
selectedAgentsRef,
selectedModeRef,
selectedModelRef,
setDialogueConfidence,
setParallelBatch,
setSelectedParallelSeats,
],
);
}
@@ -0,0 +1,487 @@
import { useCallback } from "react";
import {
streamMultiAgent,
type LeaderDispatch,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import type { Step2EngineContext } from "@/roundtable-planning/hooks/step2-engine-context";
import type { ParallelSeatView } from "@/roundtable-planning/hooks/step2-types";
import { MAX_CYCLES } from "@/roundtable-planning/lib/roundtable-constants";
import { seatModeToRequestFields } from "@/roundtable-planning/lib/seat-mode";
import { takeOnce } from "@/roundtable-planning/lib/step2-orchestration-helpers";
const PARALLEL_JUDGE_NOTE =
"\n\n【派活方式】派活前请先判断各席位子任务之间的依赖关系:相互独立的子任务请在" +
"**同一轮**通过 agent_orchestration 一次性派给多个席位(系统会并行执行、显著提速);" +
"有先后依赖的子任务则每轮只派一个席位,等其交付后再派下一个。";
const SEAT_DELIVERY_GUIDE =
"\n\n【交付要求】请直接基于已知信息做出合理假设、给出完整交付,**不要向用户提问或等待确认**;" +
"承接此前各席位的交付(需要其它席位产物全文时用 read_peer_delivery 获取),不要重复或推翻已有结论。" +
"交付请按以下结构组织:① 一句话核心结论(先行);② 关键要点(分条列出);" +
"③ 风险 / 注意事项(如有);④ 与其它席位的依赖 / 承接关系(如有)。";
/**
* Recommend mode: the coordinator freely decides which seats to dispatch and whether a
* round can run in parallel. It stops on consensus or at the MAX_CYCLES safety limit.
*/
export function useRecommendOrchestrationEngine(context: Step2EngineContext) {
const {
runtime: { refs, resolveSeatThreadId },
dialogue: {
appendStreamingDialogue,
appendToDialogue,
setDialogueContent,
appendStepIfNew,
setDialogueConfidence,
phaseLabel,
finalizeDialogue,
makeModelSwitchHandler,
},
speakers: {
highlightSpeaker,
markSpeakerStreaming,
markSpeakerIdle,
markParallelSpeakers,
clearAllStreamingSpeakers,
},
parallel: {
recommendParallelRef,
parallelStreamRef,
ingestParallelDelta,
resetParallelStreams,
setParallelBatch,
setSelectedParallelSeats,
},
artifacts: {
handleArtifactToolPhase,
handleUpstreamFrame,
ingestSubagentTaskFrame,
},
control: {
setIsOrchestrating,
setPendingClarification,
setClarificationDraft,
setLastLeaderContent,
setConsensusPercentage,
setHasConsensus,
triggerToast,
},
} = context;
const {
isOrchestratingRef,
orchestrationCancelRef,
orchestrationEpochRef,
agentNameToRoleRef,
activeAbortersRef,
pendingInjectFilesRef,
selectedModelRef,
agentConfigsRef,
gatheredDeliveriesRef,
seatModesRef,
selectedModeRef,
seatSkillDirectivesRef,
} = refs;
return useCallback(
async (seedMessage: string, ids: ThreadIdMap, coord: string) => {
if (isOrchestratingRef.current) return;
isOrchestratingRef.current = true;
setIsOrchestrating(true);
orchestrationCancelRef.current = false;
const myEpoch = (orchestrationEpochRef.current += 1);
const isStale = () =>
orchestrationCancelRef.current || orchestrationEpochRef.current !== myEpoch;
const priorDeliveries: Array<{ name: string; content: string }> = [
...gatheredDeliveriesRef.current,
];
const runSeatDelivery = async (input: {
agentId: string;
displayName: string;
task: string;
bubbleId: number;
seatThreadId: string | undefined;
isParallel: boolean;
}): Promise<{ content: string; aborted: boolean }> => {
const meta = agentNameToRoleRef.current[input.agentId.toLowerCase()];
const perSeatModel = meta
? agentConfigsRef.current[meta.speakerId]?.model
: undefined;
const fullTask = input.task + SEAT_DELIVERY_GUIDE;
for (let attempt = 0; attempt < 2; attempt += 1) {
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
try {
const response = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: input.agentId,
displayName: input.displayName,
newMessage: fullTask,
peerDeliveries: priorDeliveries,
model: perSeatModel || selectedModelRef.current || undefined,
...seatModeToRequestFields(
seatModesRef.current[input.agentId] ?? selectedModeRef.current,
),
seatSkillDirective:
seatSkillDirectivesRef.current[input.agentId] ?? false,
signal: subAborter.signal,
onModelSwitch: makeModelSwitchHandler(
input.bubbleId,
input.isParallel,
),
onTextDelta: (text) =>
input.isParallel
? ingestParallelDelta(input.bubbleId, text)
: appendToDialogue(input.bubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(input.bubbleId, label);
appendStepIfNew(
input.bubbleId,
label,
`${phase}:${toolName ?? ""}`,
args,
);
handleArtifactToolPhase(
phase,
toolName,
args,
input.seatThreadId,
);
},
onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, input.seatThreadId);
ingestSubagentTaskFrame(frame, input.bubbleId);
},
});
activeAbortersRef.current.delete(subAborter);
if (isStale()) {
finalizeDialogue(
input.bubbleId,
response.content || "(已停止)",
"已停止",
);
return { content: "", aborted: true };
}
finalizeDialogue(input.bubbleId, response.content, "子智能体交付");
return { content: response.content ?? "", aborted: false };
} catch (error) {
activeAbortersRef.current.delete(subAborter);
if (
subAborter.signal.aborted ||
(error as Error).name === "AbortError"
) {
finalizeDialogue(input.bubbleId, "(已停止)", "已停止");
return { content: "", aborted: true };
}
if (attempt === 0) {
console.warn(
`[special:${input.agentId}] 第一次失败,自动重试:`,
error,
);
if (input.isParallel) {
parallelStreamRef.current.delete(input.bubbleId);
} else {
setDialogueContent(input.bubbleId, "");
}
setDialogueConfidence(input.bubbleId, "失败重试中…");
continue;
}
console.error(`[special:${input.agentId}] error:`, error);
finalizeDialogue(
input.bubbleId,
`(错误) ${(error as Error).message}`,
"调用失败",
);
return { content: "", aborted: false };
}
}
return { content: "", aborted: false };
};
let nextLeaderTask = seedMessage + PARALLEL_JUDGE_NOTE;
let safetyCounter = 0;
try {
while (!isStale() && safetyCounter < MAX_CYCLES) {
safetyCounter += 1;
highlightSpeaker(null);
const leaderThreadId = resolveSeatThreadId(ids, coord);
const leaderBubbleId = appendStreamingDialogue({
sender: "总控协调",
avatarType: "blue",
initialContent: "",
confidence: "调度中",
agentThreadId: leaderThreadId,
});
const leaderAborter = new AbortController();
activeAbortersRef.current.add(leaderAborter);
let leaderResponse;
try {
leaderResponse = await streamMultiAgent({
agentType: "leader",
dAgentThreadId: ids,
agentName: coord,
newMessage: nextLeaderTask,
synthesisMode: safetyCounter > 1,
suppressPreBroadcast: safetyCounter > 1,
files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined,
signal: leaderAborter.signal,
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName);
setDialogueConfidence(leaderBubbleId, label);
appendStepIfNew(
leaderBubbleId,
label,
`${phase}:${toolName ?? ""}`,
args,
);
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
},
onUpstreamFrame: (frame) =>
handleUpstreamFrame(frame, leaderThreadId),
});
} catch (error) {
activeAbortersRef.current.delete(leaderAborter);
if (
leaderAborter.signal.aborted ||
(error as Error).name === "AbortError"
) {
finalizeDialogue(leaderBubbleId, "(已停止)", "已停止");
break;
}
throw error;
}
activeAbortersRef.current.delete(leaderAborter);
if (isStale()) break;
if (leaderResponse.clarification) {
finalizeDialogue(
leaderBubbleId,
leaderResponse.content,
"等待用户澄清",
);
highlightSpeaker(null);
setPendingClarification(leaderResponse.clarification);
setClarificationDraft("");
triggerToast("总控需要补充信息,请回答下方问题后继续");
break;
}
finalizeDialogue(
leaderBubbleId,
leaderResponse.content,
leaderResponse.dispatched.length === 0 ? "全案盖印" : "派活说明",
);
if (leaderResponse.content) {
setLastLeaderContent(leaderResponse.content);
}
if (leaderResponse.dispatched.length === 0) {
highlightSpeaker(null);
setConsensusPercentage(100);
setHasConsensus(true);
triggerToast("圆桌研讨论证会圆满落幕!已取得 100% 专家组共识");
break;
}
const dispatched = leaderResponse.dispatched as LeaderDispatch[];
if (dispatched.length > 1) {
recommendParallelRef.current = true;
const seatPrep = dispatched.map(([subName, task]) => {
const meta = agentNameToRoleRef.current[subName.toLowerCase()];
const seatName = meta?.displayName ?? subName;
const avatarType = meta?.avatarType ?? "blue";
const seatThreadId = resolveSeatThreadId(ids, subName);
const bubbleId = appendStreamingDialogue({
sender: seatName,
avatarType,
initialContent: "",
confidence: "调度中",
agentThreadId: seatThreadId,
});
return {
agentId: subName,
task,
meta,
seatName,
avatarType,
seatThreadId,
bubbleId,
};
});
markParallelSpeakers(
seatPrep
.map((seat) => seat.meta?.speakerId)
.filter((value): value is number => typeof value === "number"),
);
setParallelBatch(
seatPrep.map((seat) => ({
agentId: seat.agentId,
displayName: seat.seatName,
avatarType: seat.avatarType,
status: "running" as const,
bubbleId: seat.bubbleId,
})),
);
setSelectedParallelSeats(
seatPrep.slice(0, 1).map((seat) => seat.agentId),
);
const markBatchStatus = (
agentId: string,
status: ParallelSeatView["status"],
) =>
setParallelBatch((previous) =>
previous.map((seat) =>
seat.agentId === agentId ? { ...seat, status } : seat,
),
);
const batchResults = await Promise.all(
seatPrep.map(
async ({
agentId,
task,
meta,
seatName,
seatThreadId,
bubbleId,
}) => {
const result = await runSeatDelivery({
agentId,
displayName: seatName,
task,
bubbleId,
seatThreadId,
isParallel: true,
});
markBatchStatus(agentId, "done");
if (meta) markSpeakerIdle(meta.speakerId);
parallelStreamRef.current.delete(bubbleId);
return result.content.trim()
? { name: seatName, content: result.content }
: null;
},
),
);
setParallelBatch([]);
setSelectedParallelSeats([]);
resetParallelStreams();
recommendParallelRef.current = false;
for (const result of batchResults) {
if (result) priorDeliveries.push(result);
}
} else {
for (const [subName, task] of dispatched) {
if (isStale()) break;
const meta = agentNameToRoleRef.current[subName.toLowerCase()];
if (meta) markSpeakerStreaming(meta.speakerId);
const seatThreadId = resolveSeatThreadId(ids, subName);
const subBubbleId = appendStreamingDialogue({
sender: meta?.displayName ?? subName,
avatarType: meta?.avatarType ?? "blue",
initialContent: "",
confidence: "调度中",
agentThreadId: seatThreadId,
});
const result = await runSeatDelivery({
agentId: subName,
displayName: meta?.displayName ?? subName,
task,
bubbleId: subBubbleId,
seatThreadId,
isParallel: false,
});
if (meta) markSpeakerIdle(meta.speakerId);
if (result.content.trim()) {
priorDeliveries.push({
name: meta?.displayName ?? subName,
content: result.content,
});
}
if (result.aborted) break;
}
}
if (isStale()) break;
setConsensusPercentage((previous) =>
Math.min(95, parseFloat((previous + 8).toFixed(1))),
);
nextLeaderTask =
"各子智能体已就上一轮派活完成交付,请你结合最新结论判断:" +
"① 若仍有关键信息缺口或席位职责未覆盖 → 继续派活(按上述派活方式判断串/并行);" +
"② 若信息已足够 → **停止派活**,直接综合各席位交付输出最终方案,并在结论最开头用一行标注" +
"「【已达成共识】(置信度:高/中/低)」。不要在信息不足时草率收口,也不要在已足够时反复派活。" +
PARALLEL_JUDGE_NOTE;
}
if (safetyCounter >= MAX_CYCLES) {
triggerToast("⏱️ 已达到最大研讨轮次,自动停止以避免循环");
}
} catch (error) {
console.error("orchestration error:", error);
triggerToast(`研讨异常: ${(error as Error).message}`);
} finally {
highlightSpeaker(null);
clearAllStreamingSpeakers();
setIsOrchestrating(false);
}
},
[
activeAbortersRef,
agentConfigsRef,
agentNameToRoleRef,
appendStepIfNew,
appendStreamingDialogue,
appendToDialogue,
clearAllStreamingSpeakers,
finalizeDialogue,
gatheredDeliveriesRef,
handleArtifactToolPhase,
handleUpstreamFrame,
highlightSpeaker,
ingestParallelDelta,
ingestSubagentTaskFrame,
isOrchestratingRef,
makeModelSwitchHandler,
markParallelSpeakers,
markSpeakerIdle,
markSpeakerStreaming,
orchestrationCancelRef,
orchestrationEpochRef,
parallelStreamRef,
pendingInjectFilesRef,
phaseLabel,
recommendParallelRef,
resetParallelStreams,
resolveSeatThreadId,
seatModesRef,
seatSkillDirectivesRef,
selectedModeRef,
selectedModelRef,
setClarificationDraft,
setConsensusPercentage,
setDialogueConfidence,
setDialogueContent,
setHasConsensus,
setIsOrchestrating,
setLastLeaderContent,
setParallelBatch,
setPendingClarification,
setSelectedParallelSeats,
triggerToast,
],
);
}
@@ -0,0 +1,110 @@
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { isWujieEnv, readWujieProps } from "@/core/embed/wujie";
export interface RoundtablePrefill {
taskTitle?: string;
taskGoal?: string;
chainId?: string;
businessCode?: string;
taskId?: string;
actionId?: string;
actionName?: string;
autoStart?: boolean;
}
const ROUNDTABLE_DEEPLINK_PREFILL_KEY = "roundtable:deeplink-prefill";
/** xdfx-A 按行动 id 隔离记录,其它业务按任务 id 隔离。 */
export function recordTaskIdOf(prefill: RoundtablePrefill | null): string | null {
return prefill?.actionId ?? prefill?.taskId ?? null;
}
function readEmbedHashQueryPrefill(): RoundtablePrefill | null {
if (typeof window === "undefined") return null;
const hash = window.location.hash || "";
if (!hash.includes("/embed/roundtable")) return null;
const queryIndex = hash.indexOf("?");
if (queryIndex < 0) return null;
const params = new URLSearchParams(hash.slice(queryIndex + 1));
const taskTitle = params.get("taskTitle") ?? undefined;
const taskGoal = params.get("taskGoal") ?? undefined;
const chainId = params.get("chainId") ?? undefined;
const businessCode = params.get("businessCode") ?? undefined;
const taskId = params.get("taskId") ?? undefined;
if (!taskTitle && !taskGoal && !chainId && !businessCode && !taskId) return null;
return { taskTitle, taskGoal, chainId, businessCode, taskId, autoStart: true };
}
function readWujiePrefill(): RoundtablePrefill | null {
if (!isWujieEnv()) return null;
const props = readWujieProps();
const taskTitle = typeof props.taskTitle === "string" ? props.taskTitle : undefined;
const taskGoal = typeof props.taskGoal === "string" ? props.taskGoal : undefined;
const chainId = typeof props.chainId === "string" ? props.chainId : undefined;
const businessCode =
typeof props.businessCode === "string" ? props.businessCode : undefined;
const taskId = typeof props.taskId === "string" ? props.taskId : undefined;
if (!taskTitle && !taskGoal && !chainId && !businessCode && !taskId) {
return readEmbedHashQueryPrefill();
}
return { taskTitle, taskGoal, chainId, businessCode, taskId, autoStart: true };
}
function readPersistedDeeplinkPrefill(path: string): RoundtablePrefill | null {
try {
const raw = sessionStorage.getItem(ROUNDTABLE_DEEPLINK_PREFILL_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { path?: string; prefill?: RoundtablePrefill };
if (!parsed.prefill || parsed.path !== path) return null;
return parsed.prefill;
} catch {
return null;
}
}
function persistDeeplinkPrefill(path: string, prefill: RoundtablePrefill): void {
try {
sessionStorage.setItem(
ROUNDTABLE_DEEPLINK_PREFILL_KEY,
JSON.stringify({ path, prefill }),
);
} catch {
// sessionStorage can be unavailable in privacy mode or when quota is exhausted.
}
}
/**
* Resolves the one-shot roundtable entry context and persists task deep links for refreshes.
* Priority: router navigation state → Wujie props/hash → session storage.
*/
export function useRoundtablePrefill() {
const location = useLocation();
const inWujieEmbed = isWujieEnv();
const prefillRef = useRef<RoundtablePrefill | null>(
((location.state as { roundtablePrefill?: RoundtablePrefill } | null)
?.roundtablePrefill ??
readWujiePrefill() ??
readPersistedDeeplinkPrefill(location.pathname)),
);
const consumedRef = useRef(false);
useEffect(() => {
if (consumedRef.current || !prefillRef.current) return;
consumedRef.current = true;
if (prefillRef.current.taskId) {
persistDeeplinkPrefill(location.pathname, prefillRef.current);
}
try {
window.history.replaceState({ ...window.history.state, usr: null }, "");
} catch {
// History state cleanup is best-effort in embedded browsers.
}
}, [location.pathname]);
return { prefill: prefillRef.current, inWujieEmbed };
}
@@ -0,0 +1,267 @@
import { useCallback, useEffect, useMemo, useRef, type MutableRefObject } from "react";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { useStep3Dashboard } from "@/roundtable-planning/hooks/useStep3Dashboard";
import { useStep3Report, type ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary";
import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import { isDashboardHtml } from "@/roundtable-planning/lib/report-template";
import { stripReasoning } from "@/roundtable-planning/lib/reasoning";
const STEP3_FLOW_STAGGER_MS = 300;
export interface Step3ConversationSnapshot {
dialogues: unknown[];
seatStubMessages: Record<string, unknown[]>;
ingestStatus?: string;
}
export interface UseRoundtableStep3FlowsOptions {
intentReady: ReportIntent | null;
step2RoundtableDialogues: Step2Dialogue[];
consensusText: string;
selectedModel: string;
orchestrationPlan: OrchestrationPlan | null;
step3Report: DraftStep3Snapshot | null;
onReportSaved: (snapshot: DraftStep3Snapshot) => void;
onSummarySaved: (snapshot: NonNullable<DraftStep3Snapshot["summaryReport"]>) => void;
ingestAvailable: boolean;
ingestSkill: string;
ingestBusinessCode: string | null;
taskId: string | null;
chainTitle: string | null;
isDark: boolean;
currentStep: number;
draftId: string | null;
summaryConvoRef: MutableRefObject<Step3ConversationSnapshot>;
reportConvoRef: MutableRefObject<Step3ConversationSnapshot>;
dashboardConvoRef: MutableRefObject<Step3ConversationSnapshot>;
}
export function useRoundtableStep3Flows({
intentReady,
step2RoundtableDialogues,
consensusText,
selectedModel,
orchestrationPlan,
step3Report,
onReportSaved: handleReportSaved,
onSummarySaved: handleSummarySaved,
ingestAvailable,
ingestSkill,
ingestBusinessCode,
taskId,
chainTitle,
isDark,
currentStep,
draftId,
summaryConvoRef: step3SummaryConvoRef,
reportConvoRef: step3ReportConvoRef,
dashboardConvoRef: step3DashConvoRef,
}: UseRoundtableStep3FlowsOptions) {
// ── Step3 三个生成流提升到页面层(2026-06-13)────────────────────────────
// 总结报告 / 结果绘制(经典) / 大屏多页的流式 run **不随 Step3 子视图卸载而中断**:
// 用户切回第二步、在总结/绘制/最终方案视图间往返,流都在页面层继续;
// 回头进入对应视图即可续看进行中的过程或最终结果。
const step3SeatDeliveries = useMemo(
() =>
step2RoundtableDialogues
.filter((d) => d.confidence === "子智能体交付")
.map((d) => ({
sender: String(d.sender ?? ""),
content: stripReasoning(String(d.content ?? "")),
})),
[step2RoundtableDialogues],
);
// 业务链条层级(取 stages 的「本阶段目标」goal,有序)——taskId 深链模式自动结构化输出时
// 作流程图层级骨架(任务 → 层级1 → …)。memo 稳定数组身份,避免 run useCallback 频繁失效。
const chainLevels = useMemo(
() =>
(orchestrationPlan?.stages ?? [])
.map((s) => s.goal?.trim() ?? "")
.filter((g) => g.length > 0),
[orchestrationPlan],
);
// 第三步首次生成报告后即**自动**续跑结构化输出(流程图)——不再限 taskId 深链,
// 任何会商进第三步首次出报告都自动出流程图(用户 2026-06-17 要求)。
const autoSummaryFlow = true;
const summaryFlow = useStep3Summary({
intentReady: intentReady,
seatDeliveries: step3SeatDeliveries,
consensusText: consensusText,
selectedModel,
coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null,
chainLevels,
autoFlow: autoSummaryFlow,
savedSummary: step3Report?.summaryReport ?? null,
onSummarySaved: handleSummarySaved,
ingestAvailable,
// 入库仅深链(taskId)模式(会商 rwfx-A 等)才有;taskId 入库时必传给技能。
taskId: taskId,
// 入库场景线索:适用性判定选中的技能 + 业务码/链名,入库时引导智能体挑对入库技能。
ingestSkill,
ingestBusinessCode,
ingestChainName: chainTitle,
});
// render 期镜像总结报告的实时对话(文字 + 步骤条) + stub + 入库状态,供 getStep3Snapshot 落库。
step3SummaryConvoRef.current = {
dialogues: summaryFlow.dialogues,
seatStubMessages: summaryFlow.seatStubMessages,
ingestStatus: summaryFlow.ingestStatus,
};
const reportFlow = useStep3Report({
intentReady: intentReady,
seatDeliveries: step3SeatDeliveries,
consensusText: consensusText,
selectedModel,
savedReport: step3Report,
onReportSaved: handleReportSaved,
});
// render 期镜像经典看板的实时对话(文字 + 步骤条) + stub,供 getStep3Snapshot 落库。
step3ReportConvoRef.current = {
dialogues: reportFlow.dialogues,
seatStubMessages: reportFlow.seatStubMessages,
};
const dashboardFlow = useStep3Dashboard({
intentReady: intentReady,
seatDeliveries: step3SeatDeliveries,
consensusText: consensusText,
selectedModel,
themeMode: isDark ? "dark" : "light",
savedReport: step3Report,
onReportSaved: handleReportSaved,
});
// render 期镜像大屏的实时对话(文字 + 步骤条) + stub,供 getStep3Snapshot 落库。
step3DashConvoRef.current = {
dialogues: dashboardFlow.dialogues,
seatStubMessages: dashboardFlow.seatStubMessages,
};
// 进入第三步:总结报告 + 结果绘制(经典) + 大屏多页 **三流并行**自动启动(互无依赖);
// 已有存稿则恢复展示。只在「本次会话首次进入第三步」时编排一次 —— 之后进出第三步
// 全是续看(流在页面层不中断);切换草稿 / 新建任务时复位入口闸门。
const step3EnteredRef = useRef(false);
// 进第三步「错峰起跑」定时器。三流虽互无依赖、要并行展示进度,但若**同一刻**一起
// 调 start()(各自 init thread + run/stream),会把单 worker 网关 + 单连接 sqlite
// checkpoint 锁的瞬时压力顶满,回环 /runs/stream 概率性返回非 200 → 前端冒 502。
// 这里把后两个流的**起跑**各往后挪一点点(仅错开 start_run 的写尖峰,不是串行等前一个
// 跑完),削峰即可。定时器存 ref,切草稿 / 重新 hydrate / 新建任务时统一清掉,避免迟发的
// start() 打到已被复位/中断的上下文。
const step3StartTimersRef = useRef<number[]>([]);
const clearStep3StartTimers = useCallback(() => {
for (const t of step3StartTimersRef.current) {
if (typeof window !== "undefined") window.clearTimeout(t);
}
step3StartTimersRef.current = [];
}, []);
// step3Report 用 ref 持最新值,供 hydrate 同步读取(避免闭包取到旧草稿快照)。
const step3ReportRef = useRef(step3Report);
step3ReportRef.current = step3Report;
// 切草稿即在**渲染期**同步复位入口闸门:保证随后的 hydrate effect 能重新跑一次
// (此前闸门只在 currentStep 变化时复位,切历史记录若仍停在第三步 currentStep 不变,
// 大屏/报告就留着上一条记录的数据不刷新 —— 即本次修复的 bug 根因)。
const prevDraftIdRef = useRef(draftId);
if (prevDraftIdRef.current !== draftId) {
const wasRealDraft = prevDraftIdRef.current != null;
prevDraftIdRef.current = draftId;
// 仅「真正切到另一份草稿」才复位入口闸门重新 hydrate。草稿**首次创建**(null→id,
// 多由本会话第一次自动保存触发)不算切换——若这时复位,正在并行跑的 Step3 三流(尤其
// 大屏)会被下方 hydrate 当成「未启动」重新 start(),既丢了在途产物又反复重画。
if (wasRealDraft) step3EnteredRef.current = false;
}
// 三流 hydrate/start(按当前草稿快照恢复或新建)。进第三步、切草稿后停留第三步都复用它,
// 保证总结/经典/大屏的展示数据跟随当前草稿实时切换。
const hydrateStep3Flows = useCallback(() => {
step3EnteredRef.current = true;
// 重新编排前先清掉上一轮可能还没触发的错峰定时器(切草稿重 hydrate 时)。
clearStep3StartTimers();
const rep = step3ReportRef.current;
// 错峰起跑助手:把一个尚未起跑的流延迟 delayMs 后再 start();触发前再校验
// step3EnteredRef(被新建任务 / 切草稿复位为 false 则跳过),避免迟发的 start()
// 打到已被中断的上下文。delay=0 表示立即(同步)起跑。
const staggerStart = (start: () => void, delayMs: number) => {
if (delayMs <= 0 || typeof window === "undefined") {
start();
return;
}
const id = window.setTimeout(() => {
if (!step3EnteredRef.current) return;
start();
}, delayMs);
step3StartTimersRef.current.push(id);
};
// ① 方案总结报告(主页,立即起跑)
const savedSum = rep?.summaryReport;
if (savedSum && (savedSum.md.trim() || (savedSum.closing ?? "").trim())) {
summaryFlow.hydrateSaved(savedSum);
} else {
summaryFlow.start();
}
// ② 结果绘制(经典模式一)。旧版草稿可能把大屏 HTML 存在 html 槽位(带 marker),
// 那不是经典产物 → 经典照常启动。错峰 ~300ms,削掉与总结的同刻 start 尖峰。
if (rep?.html && !isDashboardHtml(rep.html)) {
reportFlow.hydrateSaved(rep);
} else {
staggerStart(() => reportFlow.start(), STEP3_FLOW_STAGGER_MS); // 仍并行绘制,只是起跑错开
}
// ③ 大屏多页(模式二):优先 dashboardJson(按当前主题渲染);旧版 marker html 兜底。
// 带上大屏生成过程的完整对话(文字 + 步骤条),恢复时整段回显。错峰 ~600ms。
const dashSaved = {
dialogues: rep?.dashboardDialogues,
seatStubMessages: rep?.dashboardSeatStubMessages,
};
if (rep?.dashboardJson) {
dashboardFlow.hydrateFromJson(rep.dashboardJson, dashSaved);
} else if (rep?.html && isDashboardHtml(rep.html)) {
dashboardFlow.hydrateSaved(rep.html, dashSaved);
} else {
staggerStart(() => dashboardFlow.start(), STEP3_FLOW_STAGGER_MS * 2);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [summaryFlow, reportFlow, dashboardFlow, clearStep3StartTimers]);
// 切换草稿:先停掉旧草稿的在途 Step3 流(防串台)。声明在 hydrate effect **之前**,
// 保证同一提交内先 abort 旧流、再由下方 effect hydrate 新草稿,顺序不颠倒。
// **仅在真正切到另一份草稿时中断**——草稿首次创建(null→id)不是切换,那时三流可能正
// 跑着(尤其大屏绘制),误 abort 会让产物没存上、随后还被重新 hydrate 重画。
const abortPrevDraftIdRef = useRef(draftId);
useEffect(() => {
const prev = abortPrevDraftIdRef.current;
abortPrevDraftIdRef.current = draftId;
if (prev && draftId && prev !== draftId) {
clearStep3StartTimers(); // 切草稿:取消尚未触发的错峰 start,防打到新草稿上下文
summaryFlow.abort();
reportFlow.abort();
dashboardFlow.abort();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draftId]);
// 进第三步 / 切草稿后仍停留第三步:按当前草稿快照恢复或新建三流(闸门已在渲染期复位)。
useEffect(() => {
if (currentStep !== 3 || step3EnteredRef.current) return;
hydrateStep3Flows();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep, draftId]);
const reset = useCallback(() => {
clearStep3StartTimers();
summaryFlow.abort();
reportFlow.abort();
dashboardFlow.abort();
step3EnteredRef.current = false;
}, [clearStep3StartTimers, summaryFlow, reportFlow, dashboardFlow]);
return {
summaryFlow,
reportFlow,
dashboardFlow,
reset,
};
}
@@ -0,0 +1,73 @@
import { useEffect, useState } from "react";
import type { TaskBusiness } from "@/core/auth/task-buttons";
import {
derivePurposeLevels,
fetchTaskPurposeDetail,
type PurposeLevel,
} from "@/core/auth/task-purpose";
import {
recordTaskIdOf,
type RoundtablePrefill,
} from "@/roundtable-planning/hooks/useRoundtablePrefill";
const BUSINESS_CODE_TO_TASK_BUSINESS: Readonly<Record<string, TaskBusiness>> = {
"3Q": "3qfx",
"6BF": "rwfx",
"7BF": "xdfx",
};
export interface RoundtableTaskContext {
taskId: string | null;
recordTaskId: string | null;
actionId: string | null;
businessCode: string | null;
taskBusiness: TaskBusiness | null;
purposeLevels: PurposeLevel[];
}
/**
* Derives task-scoped roundtable identifiers and loads the purpose hierarchy used by Step 1.
* xdfx records are partitioned by action id, while other businesses use the task id.
*/
export function useRoundtableTaskContext(
prefill: RoundtablePrefill | null,
): RoundtableTaskContext {
const taskId = prefill?.taskId ?? null;
const recordTaskId = recordTaskIdOf(prefill);
const actionId = prefill?.actionId ?? null;
const businessCode = prefill?.businessCode ?? null;
const taskBusiness = businessCode
? (BUSINESS_CODE_TO_TASK_BUSINESS[businessCode] ?? null)
: null;
const [purposeLevels, setPurposeLevels] = useState<PurposeLevel[]>([]);
useEffect(() => {
if (!taskId) {
setPurposeLevels([]);
return;
}
let cancelled = false;
void fetchTaskPurposeDetail(taskId)
.then((response) => {
if (!cancelled) setPurposeLevels(derivePurposeLevels(response.tree));
})
.catch(() => {
if (!cancelled) setPurposeLevels([]);
});
return () => {
cancelled = true;
};
}, [taskId]);
return {
taskId,
recordTaskId,
actionId,
businessCode,
taskBusiness,
purposeLevels,
};
}
@@ -0,0 +1,507 @@
import {
useCallback,
useEffect,
useRef,
type MutableRefObject,
} from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import type {
StreamPhase,
ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import type { Step2ExecutionRefs } from "@/roundtable-planning/hooks/useStep2ExecutionRefs";
import { useStep2ArtifactState } from "@/roundtable-planning/hooks/useStep2StateDomains";
import type {
ParallelSeatView,
Step2Dialogue,
StubAiMessage,
StubToolCall,
} from "@/roundtable-planning/hooks/step2-types";
type ArtifactExecutionRefs = Pick<
Step2ExecutionRefs,
| "orchestrationModeRef"
| "dagExecModeRef"
| "sandboxFollowThreadIdRef"
| "threadIdsRef"
| "lastReconciledSeatRef"
>;
export interface UseStep2ArtifactBridgeOptions {
selectedParallelSeats: string[];
dagExecMode: "fast" | "sandbox";
parallelBatchRef: MutableRefObject<ParallelSeatView[]>;
executionRefs: ArtifactExecutionRefs;
resolveSeatThreadId: (ids: ThreadIdMap, agentName: string) => string | undefined;
}
/** Owns the Step 2 sandbox/file-preview bridge and its in-memory tool-call stubs. */
export function useStep2ArtifactBridge(options: UseStep2ArtifactBridgeOptions) {
const {
selectedParallelSeats,
dagExecMode,
parallelBatchRef,
executionRefs: {
orchestrationModeRef,
dagExecModeRef,
sandboxFollowThreadIdRef,
threadIdsRef,
lastReconciledSeatRef,
},
resolveSeatThreadId,
} = options;
const {
artifacts,
setArtifacts,
select: selectArtifact,
setOpen: setArtifactsOpen,
deselect: deselectArtifact,
autoOpen,
autoSelect,
} = useArtifacts();
const {
activeArtifactThreadId,
setActiveArtifactThreadId,
seatStubMessages,
setSeatStubMessages,
seatStubMessagesRef,
} = useStep2ArtifactState();
const artifactPathsRef = useRef<string[]>([]);
const autoOpenFlagsRef = useRef({ autoOpen, autoSelect });
const hasAutoOpenedRef = useRef<Set<string>>(new Set());
const recommendParallelRef = useRef(false);
useEffect(() => {
artifactPathsRef.current = artifacts ?? [];
}, [artifacts]);
useEffect(() => {
autoOpenFlagsRef.current = { autoOpen, autoSelect };
}, [autoOpen, autoSelect]);
const replaceSeatStubMessages = useCallback(
(
messages: Record<string, StubAiMessage[]>,
settings?: { resetAutoOpen?: boolean },
) => {
seatStubMessagesRef.current = messages;
setSeatStubMessages(messages);
if (settings?.resetAutoOpen) {
hasAutoOpenedRef.current = new Set();
}
},
[seatStubMessagesRef, setSeatStubMessages],
);
const upsertSeatToolCall = useCallback(
(
seatThreadId: string,
toolName: string,
args: Record<string, unknown>,
): { messageId: string; toolCallId: string } => {
const path = typeof args.path === "string" ? args.path : "";
const current = seatStubMessagesRef.current[seatThreadId] ?? [];
const existing = current[0];
const baseMessage: StubAiMessage = existing ?? {
id: `roundtable-stub-${seatThreadId}`,
type: "ai",
content: "",
tool_calls: [],
};
const index = baseMessage.tool_calls.findIndex(
(toolCall) =>
toolCall.name === toolName &&
String((toolCall.args as { path?: unknown })?.path ?? "") === path,
);
const toolCallId =
index >= 0
? baseMessage.tool_calls[index].id
: `tc-${seatThreadId}-${baseMessage.tool_calls.length}`;
const nextToolCall: StubToolCall = {
id: toolCallId,
name: toolName,
args,
};
const nextToolCalls =
index >= 0
? baseMessage.tool_calls.map((toolCall, currentIndex) =>
currentIndex === index ? nextToolCall : toolCall,
)
: [...baseMessage.tool_calls, nextToolCall];
const nextMessage: StubAiMessage = {
...baseMessage,
tool_calls: nextToolCalls,
};
const nextMap = {
...seatStubMessagesRef.current,
[seatThreadId]: [nextMessage],
};
replaceSeatStubMessages(nextMap);
return { messageId: nextMessage.id, toolCallId };
},
[replaceSeatStubMessages, seatStubMessagesRef],
);
const openSeatVirtualArtifact = useCallback(
(
seatThreadId: string,
virtualUrl: string,
auto: boolean,
settings?: { manual?: boolean },
) => {
const manual = settings?.manual ?? !auto;
if (auto) {
if (hasAutoOpenedRef.current.has(virtualUrl)) return;
const flags = autoOpenFlagsRef.current;
if (!flags.autoOpen || !flags.autoSelect) return;
if (
orchestrationModeRef.current === "dag" &&
dagExecModeRef.current === "fast" &&
parallelBatchRef.current.length > 0
) {
return;
}
if (
dagExecModeRef.current === "sandbox" &&
sandboxFollowThreadIdRef.current &&
sandboxFollowThreadIdRef.current !== seatThreadId
) {
return;
}
hasAutoOpenedRef.current.add(virtualUrl);
}
setActiveArtifactThreadId(seatThreadId);
const open = () => {
selectArtifact(virtualUrl, !manual);
setArtifactsOpen(true);
};
if (auto) window.setTimeout(open, 100);
else open();
},
[
dagExecModeRef,
orchestrationModeRef,
parallelBatchRef,
sandboxFollowThreadIdRef,
selectArtifact,
setActiveArtifactThreadId,
setArtifactsOpen,
],
);
const buildWriteFileVirtualUrl = useCallback(
(path: string, messageId: string, toolCallId: string) =>
new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
).toString(),
[],
);
const openSeatLatestArtifact = useCallback(
(seatThreadId: string): boolean => {
const stub = seatStubMessagesRef.current[seatThreadId]?.[0];
if (!stub) return false;
for (let index = stub.tool_calls.length - 1; index >= 0; index -= 1) {
const toolCall = stub.tool_calls[index];
if (
toolCall.name !== "write_file" &&
toolCall.name !== "str_replace"
) {
continue;
}
const path =
typeof (toolCall.args as { path?: unknown })?.path === "string"
? ((toolCall.args as { path?: string }).path as string)
: "";
if (!path) continue;
const virtualUrl = buildWriteFileVirtualUrl(
path,
stub.id,
toolCall.id,
);
openSeatVirtualArtifact(seatThreadId, virtualUrl, false, {
manual: false,
});
return true;
}
return false;
},
[
buildWriteFileVirtualUrl,
openSeatVirtualArtifact,
seatStubMessagesRef,
],
);
const findSeatArtifactSource = useCallback(
(
rawPath: string,
): {
seatThreadId: string;
messageId: string;
toolCallId: string;
content?: string;
} | null => {
const target = rawPath.trim();
if (!target) return null;
const targetBase = target.split("/").pop() ?? target;
const readContent = (toolCall: { args?: unknown }) => {
const content = (toolCall.args as { content?: unknown })?.content;
return typeof content === "string" ? content : undefined;
};
let basenameMatch: {
seatThreadId: string;
messageId: string;
toolCallId: string;
content?: string;
} | null = null;
for (const [seatThreadId, messages] of Object.entries(
seatStubMessagesRef.current,
)) {
const message = messages?.[0];
if (!message) continue;
for (const toolCall of message.tool_calls) {
if (
toolCall.name !== "write_file" &&
toolCall.name !== "str_replace"
) {
continue;
}
const path =
typeof (toolCall.args as { path?: unknown })?.path === "string"
? ((toolCall.args as { path?: string }).path as string)
: "";
if (!path) continue;
const match = {
seatThreadId,
messageId: message.id,
toolCallId: toolCall.id,
content: readContent(toolCall),
};
if (path === target) return match;
if (!basenameMatch && (path.split("/").pop() ?? path) === targetBase) {
basenameMatch = match;
}
}
}
return basenameMatch;
},
[seatStubMessagesRef],
);
const openArtifactPreview = useCallback(
(threadId: string, path: string, settings?: { auto?: boolean }) => {
const normalized = path.trim();
if (!normalized) return;
const seatSource = findSeatArtifactSource(normalized);
if (
seatSource &&
typeof seatSource.content === "string" &&
seatSource.content.length > 0
) {
const virtualUrl = buildWriteFileVirtualUrl(
normalized,
seatSource.messageId,
seatSource.toolCallId,
);
openSeatVirtualArtifact(
seatSource.seatThreadId,
virtualUrl,
settings?.auto ?? false,
);
return;
}
const effectiveThreadId = seatSource?.seatThreadId || threadId;
if (!effectiveThreadId) return;
setActiveArtifactThreadId(effectiveThreadId);
const previous = artifactPathsRef.current;
if (!previous.includes(normalized)) {
const next = [...previous, normalized];
artifactPathsRef.current = next;
setArtifacts(next);
}
const open = () => {
selectArtifact(normalized, settings?.auto ?? false);
setArtifactsOpen(true);
};
if (settings?.auto) window.setTimeout(open, 100);
else open();
},
[
buildWriteFileVirtualUrl,
findSeatArtifactSource,
openSeatVirtualArtifact,
selectArtifact,
setActiveArtifactThreadId,
setArtifacts,
setArtifactsOpen,
],
);
const openArtifactFromStep = useCallback(
(
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => {
const seatThreadId = dialogue.agentThreadId;
if (!seatThreadId) return;
const [, toolName] = step.key.split(":");
if (toolName === "write_file" || toolName === "str_replace") {
const path = step.args?.path;
if (typeof path !== "string" || !step.args) return;
const { messageId, toolCallId } = upsertSeatToolCall(
seatThreadId,
toolName,
step.args,
);
const virtualUrl = buildWriteFileVirtualUrl(
path,
messageId,
toolCallId,
);
openSeatVirtualArtifact(seatThreadId, virtualUrl, false);
return;
}
if (toolName === "present_files") {
const filepaths = step.args?.filepaths;
if (Array.isArray(filepaths)) {
const first = filepaths.find(
(candidate): candidate is string =>
typeof candidate === "string" && candidate.length > 0,
);
if (first) openArtifactPreview(seatThreadId, first);
}
}
},
[
buildWriteFileVirtualUrl,
openArtifactPreview,
openSeatVirtualArtifact,
upsertSeatToolCall,
],
);
const handleArtifactToolPhase = useCallback(
(
phase: StreamPhase,
toolName: string | undefined,
args: Record<string, unknown> | undefined,
seatThreadId: string | undefined,
) => {
if (phase !== "tool_calling" || !seatThreadId) return;
if (toolName !== "write_file" && toolName !== "str_replace") return;
const path = args?.path;
if (typeof path !== "string" || !path || !args) {
if (typeof window !== "undefined") {
console.debug(
"[roundtable-sandbox] tool_calling without path yet",
{ toolName, args },
);
}
return;
}
const { messageId, toolCallId } = upsertSeatToolCall(
seatThreadId,
toolName,
args,
);
const virtualUrl = buildWriteFileVirtualUrl(
path,
messageId,
toolCallId,
);
openSeatVirtualArtifact(seatThreadId, virtualUrl, true);
},
[
buildWriteFileVirtualUrl,
openSeatVirtualArtifact,
upsertSeatToolCall,
],
);
const handleUpstreamFrame = useCallback(
(frame: unknown, seatThreadId: string | undefined) => {
if (!seatThreadId || !Array.isArray(frame) || frame.length === 0) return;
const chunk = frame[0];
if (!chunk || typeof chunk !== "object") return;
const toolCalls = (chunk as Record<string, unknown>).tool_calls;
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return;
for (const toolCall of toolCalls) {
if (!toolCall || typeof toolCall !== "object") continue;
const object = toolCall as Record<string, unknown>;
const name = typeof object.name === "string" ? object.name : "";
if (name !== "write_file" && name !== "str_replace") continue;
const args = object.args;
if (!args || typeof args !== "object") continue;
const argsObject = args as Record<string, unknown>;
if (typeof argsObject.path !== "string" || !argsObject.path) continue;
upsertSeatToolCall(seatThreadId, name, argsObject);
}
},
[upsertSeatToolCall],
);
useEffect(() => {
const singleColumn =
dagExecMode === "sandbox" || recommendParallelRef.current;
const seat = singleColumn ? selectedParallelSeats[0] ?? "" : "";
const key = `${dagExecMode}:${
recommendParallelRef.current ? "rp" : ""
}:${seat}`;
if (lastReconciledSeatRef.current === key) return;
lastReconciledSeatRef.current = key;
if (!singleColumn || !seat) {
sandboxFollowThreadIdRef.current = null;
return;
}
const ids = threadIdsRef.current;
if (!ids) {
sandboxFollowThreadIdRef.current = null;
return;
}
const threadId = resolveSeatThreadId(ids, seat);
sandboxFollowThreadIdRef.current = threadId ?? null;
if (!threadId) return;
if (!openSeatLatestArtifact(threadId)) {
setActiveArtifactThreadId(null);
deselectArtifact();
}
}, [
dagExecMode,
deselectArtifact,
lastReconciledSeatRef,
openSeatLatestArtifact,
resolveSeatThreadId,
sandboxFollowThreadIdRef,
selectedParallelSeats,
setActiveArtifactThreadId,
threadIdsRef,
]);
const resetArtifactState = useCallback(() => {
setActiveArtifactThreadId(null);
artifactPathsRef.current = [];
setArtifacts([]);
replaceSeatStubMessages({}, { resetAutoOpen: true });
deselectArtifact();
}, [
deselectArtifact,
replaceSeatStubMessages,
setActiveArtifactThreadId,
setArtifacts,
]);
return {
activeArtifactThreadId,
seatStubMessages,
recommendParallelRef,
replaceSeatStubMessages,
resetArtifactState,
openArtifactPreview,
openArtifactFromStep,
handleArtifactToolPhase,
handleUpstreamFrame,
};
}
@@ -0,0 +1,433 @@
import {
useCallback,
useEffect,
type Dispatch,
type MutableRefObject,
type SetStateAction,
} from "react";
import type {
ModelSwitchInfo,
StreamPhase,
} from "@/roundtable-planning/api/multi-agent";
import type {
ParallelSeatView,
Step2Dialogue,
} from "@/roundtable-planning/hooks/step2-types";
import {
nowTime,
type RoleEntry,
} from "@/roundtable-planning/lib/roundtable-constants";
export interface UseStep2DialogueRuntimeOptions {
setDialogues: Dispatch<SetStateAction<Step2Dialogue[]>>;
triggerToast: (message: string) => void;
setRoles: Dispatch<SetStateAction<RoleEntry[]>>;
setActiveSpeakerId: Dispatch<SetStateAction<number | null>>;
setActiveSpeakerSet: Dispatch<SetStateAction<Set<number>>>;
selectedParallelSeats: string[];
setSelectedParallelSeats: Dispatch<SetStateAction<string[]>>;
parallelStreamRef: MutableRefObject<
Map<number, { text: string; charCount: number }>
>;
parallelFlushTimerRef: MutableRefObject<number | null>;
selectedParallelSeatsRef: MutableRefObject<string[]>;
parallelBatchRef: MutableRefObject<ParallelSeatView[]>;
dagExecModeRef: MutableRefObject<"fast" | "sandbox">;
}
/** Dialogue mutations, throttled parallel rendering, and speaker highlighting for Step 2. */
export function useStep2DialogueRuntime(
options: UseStep2DialogueRuntimeOptions,
) {
const {
setDialogues,
triggerToast,
setRoles,
setActiveSpeakerId,
setActiveSpeakerSet,
selectedParallelSeats,
setSelectedParallelSeats,
parallelStreamRef,
parallelFlushTimerRef,
selectedParallelSeatsRef,
parallelBatchRef,
dagExecModeRef,
} = options;
const appendDialogue = useCallback(
(card: {
sender: string;
avatarType: string;
content: string;
confidence?: string;
}) => {
setDialogues((previous) => [
...previous,
{
id: Date.now() + Math.random(),
sender: card.sender,
avatarType: card.avatarType,
content: card.content,
time: nowTime(),
confidence: card.confidence ?? "实时输出",
},
]);
},
[setDialogues],
);
const phaseLabel = useCallback(
(
phase: StreamPhase,
agentType: "leader" | "special",
toolName?: string,
) => {
switch (phase) {
case "connecting":
return "调度中";
case "connected":
return "已连接,等待响应";
case "tool_calling":
if (toolName === "agent_orchestration") return "正在派活";
if (toolName === "ask_clarification") return "正在提问";
if (toolName === "present_files") return "正在交付文件";
return toolName ? `调用工具:${toolName}` : "调用工具";
case "tool_result":
return "处理工具结果";
case "streaming":
return agentType === "leader" ? "正在派活说明" : "正在输出";
default:
return agentType === "leader" ? "调度中" : "正在输出…";
}
},
[],
);
const setDialogueConfidence = useCallback(
(id: number, confidence: string) => {
setDialogues((previous) =>
previous.map((dialogue) =>
dialogue.id === id ? { ...dialogue, confidence } : dialogue,
),
);
},
[setDialogues],
);
const appendStepIfNew = useCallback(
(
bubbleId: number,
label: string,
key: string,
args?: Record<string, unknown>,
) => {
setDialogues((previous) =>
previous.map((dialogue) => {
if (dialogue.id !== bubbleId) return dialogue;
const steps = dialogue.steps ?? [];
const last = steps[steps.length - 1];
if (last?.key === key) {
if (args && args !== last.args) {
const next = [...steps];
next[next.length - 1] = { ...last, args };
return { ...dialogue, steps: next };
}
return dialogue;
}
return {
...dialogue,
steps: [
...steps,
{
id: `${bubbleId}-${steps.length}`,
key,
label,
ts: nowTime(),
args,
},
],
};
}),
);
},
[setDialogues],
);
const appendStreamingDialogue = useCallback(
(card: {
sender: string;
avatarType: string;
initialContent?: string;
confidence?: string;
agentThreadId?: string;
}) => {
const id = Date.now() + Math.random();
setDialogues((previous) => [
...previous,
{
id,
sender: card.sender,
avatarType: card.avatarType,
content: card.initialContent ?? "",
time: nowTime(),
confidence: card.confidence ?? "正在输出…",
streaming: true,
steps: [],
agentThreadId: card.agentThreadId,
},
]);
return id;
},
[setDialogues],
);
const appendToDialogue = useCallback(
(id: number, deltaText: string) => {
if (!deltaText) return;
setDialogues((previous) =>
previous.map((dialogue) =>
dialogue.id === id
? { ...dialogue, content: (dialogue.content ?? "") + deltaText }
: dialogue,
),
);
},
[setDialogues],
);
const setDialogueContent = useCallback(
(id: number, fullText: string) => {
setDialogues((previous) =>
previous.map((dialogue) =>
dialogue.id === id ? { ...dialogue, content: fullText } : dialogue,
),
);
},
[setDialogues],
);
const makeModelSwitchHandler = useCallback(
(bubbleId: number, isParallel: boolean) => (info: ModelSwitchInfo) => {
const name = info.agentName || "智能体";
triggerToast(
`「${info.failedModel}」模型${info.reason},已自动切换「${info.nextModel}」继续`,
);
if (isParallel) parallelStreamRef.current.delete(bubbleId);
const systemBubble: Step2Dialogue = {
id: Date.now() + Math.random(),
sender: "系统提示",
avatarType: "amber",
content: `模型容错:「${name}」所用模型「${info.failedModel}」${info.reason},已自动切换到「${info.nextModel}」继续。`,
time: nowTime(),
confidence: "模型容错",
};
setDialogues((previous) => {
const index = previous.findIndex((dialogue) => dialogue.id === bubbleId);
if (index < 0) return [...previous, systemBubble];
const cleared = {
...previous[index],
content: "",
confidence: "模型切换中…",
};
const next = [...previous];
next.splice(index, 1, systemBubble, cleared);
return next;
});
},
[parallelStreamRef, setDialogues, triggerToast],
);
const flushParallelStreams = useCallback(() => {
parallelFlushTimerRef.current = null;
const store = parallelStreamRef.current;
if (store.size === 0) return;
const selected = new Set(selectedParallelSeatsRef.current);
for (const seat of parallelBatchRef.current) {
if (!selected.has(seat.agentId)) continue;
const entry = store.get(seat.bubbleId);
if (entry) setDialogueContent(seat.bubbleId, entry.text);
}
}, [
parallelBatchRef,
parallelFlushTimerRef,
parallelStreamRef,
selectedParallelSeatsRef,
setDialogueContent,
]);
const scheduleParallelFlush = useCallback(() => {
if (parallelFlushTimerRef.current !== null) return;
parallelFlushTimerRef.current = window.setTimeout(flushParallelStreams, 80);
}, [flushParallelStreams, parallelFlushTimerRef]);
const ingestParallelDelta = useCallback(
(bubbleId: number, text: string) => {
if (!text) return;
const store = parallelStreamRef.current;
const current = store.get(bubbleId) ?? { text: "", charCount: 0 };
current.text += text;
current.charCount = current.text.length;
store.set(bubbleId, current);
scheduleParallelFlush();
},
[parallelStreamRef, scheduleParallelFlush],
);
const resetParallelStreams = useCallback(() => {
parallelStreamRef.current = new Map();
if (parallelFlushTimerRef.current !== null) {
window.clearTimeout(parallelFlushTimerRef.current);
parallelFlushTimerRef.current = null;
}
}, [parallelFlushTimerRef, parallelStreamRef]);
useEffect(() => resetParallelStreams, [resetParallelStreams]);
useEffect(() => {
const store = parallelStreamRef.current;
if (store.size === 0) return;
const selected = new Set(selectedParallelSeats);
for (const seat of parallelBatchRef.current) {
if (!selected.has(seat.agentId)) continue;
const entry = store.get(seat.bubbleId);
if (entry) setDialogueContent(seat.bubbleId, entry.text);
}
}, [
parallelBatchRef,
parallelStreamRef,
selectedParallelSeats,
setDialogueContent,
]);
const finalizeDialogue = useCallback(
(id: number, finalContent: string, confidence?: string) => {
setDialogues((previous) =>
previous.map((dialogue) => {
if (dialogue.id !== id) return dialogue;
const content =
finalContent && finalContent.trim().length > 0
? finalContent
: dialogue.content || "(无文本输出)";
return {
...dialogue,
content,
confidence: confidence ?? dialogue.confidence,
streaming: false,
};
}),
);
},
[setDialogues],
);
const highlightSpeaker = useCallback(
(speakerId: number | null) => {
setActiveSpeakerId(speakerId);
setRoles((previous) =>
previous.map((role) => {
if (speakerId !== null && role.id === speakerId) {
return { ...role, status: "研讨中" };
}
if (role.status === "研讨中") return { ...role, status: "已完成" };
return role;
}),
);
},
[setActiveSpeakerId, setRoles],
);
const markSpeakerStreaming = useCallback(
(speakerId: number) => {
setActiveSpeakerSet(new Set([speakerId]));
setRoles((previous) =>
previous.map((role) => {
if (role.id === speakerId) return { ...role, status: "研讨中" };
if (role.status === "研讨中") return { ...role, status: "已完成" };
return role;
}),
);
},
[setActiveSpeakerSet, setRoles],
);
const markSpeakerIdle = useCallback(
(speakerId: number) => {
setActiveSpeakerSet((previous) => {
if (!previous.has(speakerId)) return previous;
const next = new Set(previous);
next.delete(speakerId);
return next;
});
setRoles((previous) =>
previous.map((role) =>
role.id === speakerId && role.status === "研讨中"
? { ...role, status: "已完成" }
: role,
),
);
},
[setActiveSpeakerSet, setRoles],
);
const clearAllStreamingSpeakers = useCallback(() => {
setActiveSpeakerSet(new Set());
setRoles((previous) =>
previous.map((role) =>
role.status === "研讨中" ? { ...role, status: "已完成" } : role,
),
);
}, [setActiveSpeakerSet, setRoles]);
const markParallelSpeakers = useCallback(
(speakerIds: number[]) => {
const wanted = new Set(speakerIds);
setActiveSpeakerSet(new Set(speakerIds));
setRoles((previous) =>
previous.map((role) => {
if (wanted.has(role.id)) return { ...role, status: "研讨中" };
if (role.status === "研讨中") return { ...role, status: "已完成" };
return role;
}),
);
},
[setActiveSpeakerSet, setRoles],
);
const toggleParallelSeat = useCallback(
(agentId: string) => {
setSelectedParallelSeats((previous) => {
if (dagExecModeRef.current === "sandbox") {
return previous.length === 1 && previous[0] === agentId
? previous
: [agentId];
}
if (previous.includes(agentId)) {
return previous.filter((id) => id !== agentId);
}
const next = [...previous, agentId];
return next.length > 2 ? next.slice(next.length - 2) : next;
});
},
[dagExecModeRef, setSelectedParallelSeats],
);
return {
appendDialogue,
phaseLabel,
setDialogueConfidence,
appendStepIfNew,
appendStreamingDialogue,
appendToDialogue,
setDialogueContent,
makeModelSwitchHandler,
ingestParallelDelta,
resetParallelStreams,
finalizeDialogue,
highlightSpeaker,
markSpeakerStreaming,
markSpeakerIdle,
clearAllStreamingSpeakers,
markParallelSpeakers,
toggleParallelSeat,
};
}
@@ -0,0 +1,155 @@
import { useCallback, useEffect, useRef } from "react";
import type {
StreamMultiAgentRequest,
ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import type { AgentConfig } from "@/roundtable-planning/hooks/step2-types";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import type { SeatMode } from "@/roundtable-planning/lib/seat-mode";
import type { SelectedAgent } from "@/roundtable-planning/lib/roundtable-constants";
type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
export interface Step2ExecutionRefInputs {
selectedModel: string;
selectedMode: SeatMode;
seatSkillDirectives: Record<string, boolean>;
seatModes: Record<string, SeatMode | null>;
gatherFirst: boolean;
agentConfigs: Record<number, AgentConfig>;
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
isOrchestrating: boolean;
orchestrationMode: "recommend" | "chain" | "dag";
orchestrationPlan: OrchestrationPlan | null;
dagExecMode: "fast" | "sandbox";
selectedAgents: SelectedAgent[];
}
/**
* Mutable execution controls and latest-value mirrors consumed by long-running SSE loops.
* Keeping them together documents the concurrency boundary without changing loop behavior.
*/
export function useStep2ExecutionRefs(inputs: Step2ExecutionRefInputs) {
const orchestrationCancelRef = useRef(false);
const orchestrationEpochRef = useRef(0);
const step2InitRef = useRef(false);
const activeAbortersRef = useRef<Set<AbortController>>(new Set());
const pendingInjectPromptRef = useRef<string | null>(null);
const pendingInjectFilesRef = useRef<RunFileRef[] | null>(null);
const selectedModelRef = useRef(inputs.selectedModel);
const selectedModeRef = useRef(inputs.selectedMode);
const seatSkillDirectivesRef = useRef(inputs.seatSkillDirectives);
const seatModesRef = useRef(inputs.seatModes);
const gatherFirstRef = useRef(inputs.gatherFirst);
const gatheredDeliveriesRef = useRef<Array<{ name: string; content: string }>>([]);
const gatherPhaseDoneRef = useRef(false);
const agentConfigsRef = useRef(inputs.agentConfigs);
const agentNameToRoleRef = useRef(inputs.agentNameToRole);
const threadIdsRef = useRef(inputs.threadIds);
const coordinatorNameRef = useRef(inputs.coordinatorName);
const isOrchestratingRef = useRef(inputs.isOrchestrating);
const orchestrationModeRef = useRef(inputs.orchestrationMode);
const orchestrationPlanRef = useRef(inputs.orchestrationPlan);
const dagExecModeRef = useRef(inputs.dagExecMode);
const sandboxFollowThreadIdRef = useRef<string | null>(null);
const lastReconciledSeatRef = useRef("");
const dagPlanExecutedRef = useRef(false);
const selectedAgentsRef = useRef(inputs.selectedAgents);
const abortAllInFlight = useCallback(() => {
activeAbortersRef.current.forEach((controller) => {
try {
controller.abort();
} catch {
// Abort is best-effort; another path may already have completed the request.
}
});
activeAbortersRef.current.clear();
}, []);
const stopOrchestration = useCallback(() => {
orchestrationCancelRef.current = true;
abortAllInFlight();
}, [abortAllInFlight]);
useEffect(() => {
dagExecModeRef.current = inputs.dagExecMode;
}, [inputs.dagExecMode]);
useEffect(() => {
orchestrationModeRef.current = inputs.orchestrationMode;
}, [inputs.orchestrationMode]);
useEffect(() => {
orchestrationPlanRef.current = inputs.orchestrationPlan;
}, [inputs.orchestrationPlan]);
useEffect(() => {
selectedAgentsRef.current = inputs.selectedAgents;
}, [inputs.selectedAgents]);
useEffect(() => {
selectedModelRef.current = inputs.selectedModel;
}, [inputs.selectedModel]);
useEffect(() => {
selectedModeRef.current = inputs.selectedMode;
}, [inputs.selectedMode]);
useEffect(() => {
seatSkillDirectivesRef.current = inputs.seatSkillDirectives;
}, [inputs.seatSkillDirectives]);
useEffect(() => {
seatModesRef.current = inputs.seatModes;
}, [inputs.seatModes]);
useEffect(() => {
gatherFirstRef.current = inputs.gatherFirst;
}, [inputs.gatherFirst]);
useEffect(() => {
agentConfigsRef.current = inputs.agentConfigs;
}, [inputs.agentConfigs]);
useEffect(() => {
agentNameToRoleRef.current = inputs.agentNameToRole;
}, [inputs.agentNameToRole]);
useEffect(() => {
threadIdsRef.current = inputs.threadIds;
}, [inputs.threadIds]);
useEffect(() => {
coordinatorNameRef.current = inputs.coordinatorName;
}, [inputs.coordinatorName]);
useEffect(() => {
isOrchestratingRef.current = inputs.isOrchestrating;
}, [inputs.isOrchestrating]);
return {
orchestrationCancelRef,
orchestrationEpochRef,
step2InitRef,
activeAbortersRef,
pendingInjectPromptRef,
pendingInjectFilesRef,
selectedModelRef,
selectedModeRef,
seatSkillDirectivesRef,
seatModesRef,
gatherFirstRef,
gatheredDeliveriesRef,
gatherPhaseDoneRef,
agentConfigsRef,
agentNameToRoleRef,
threadIdsRef,
coordinatorNameRef,
isOrchestratingRef,
orchestrationModeRef,
orchestrationPlanRef,
dagExecModeRef,
sandboxFollowThreadIdRef,
lastReconciledSeatRef,
dagPlanExecutedRef,
selectedAgentsRef,
abortAllInFlight,
stopOrchestration,
};
}
export type Step2ExecutionRefs = ReturnType<typeof useStep2ExecutionRefs>;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,185 @@
import { useEffect, useRef, useState } from "react";
import type {
ClarificationPayload,
ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import type {
AgentConfig,
ParallelSeatView,
Step2Dialogue,
StubAiMessage,
} from "@/roundtable-planning/hooks/step2-types";
import type { ChainNodeState } from "@/roundtable-planning/lib/job-types";
import {
DEFAULT_SELECTED_AGENTS,
type SelectedAgent,
} from "@/roundtable-planning/lib/roundtable-constants";
const DEFAULT_AGENT_CONFIGS: Record<number, AgentConfig> = {
2: {
model: "",
weight: 90,
temp: 0.2,
prompt: "你负责解析核心任务、资源极限和用户目标偏好,监督全局研商收敛。",
},
3: {
model: "",
weight: 85,
temp: 0.3,
prompt: "你负责搜集电力、通信系统、历史极端部署条件情报,支持备灾架构体系。",
},
4: {
model: "",
weight: 88,
temp: 0.1,
prompt: "你评估服务器机房热载荷、算力并发承载极限,预报运行瓶颈与风险时钟。",
},
5: {
model: "",
weight: 92,
temp: 0.4,
prompt: "你负责双全栈主备建模设计,高内聚低耦合,规避不可预知停机崩溃。",
},
6: {
model: "",
weight: 95,
temp: 0.1,
prompt: "你作为安全主管,严厉把控预算开销与合规通过率防线,识别计划漏洞。",
},
7: {
model: "",
weight: 80,
temp: 0.4,
prompt: "你负责对72小时里程碑执行路径规划、重排和资源调配演练序列。",
},
};
/** React state owned by the Step 2 session, separate from execution refs and stream loops. */
export function useStep2SessionState() {
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(
DEFAULT_SELECTED_AGENTS,
);
const [initNonce, setInitNonce] = useState(0);
const [threadIds, setThreadIds] = useState<ThreadIdMap | null>(null);
const [coordinatorName, setCoordinatorName] = useState<string | null>(null);
const [isInitializing, setIsInitializing] = useState(false);
const [initError, setInitError] = useState<string | null>(null);
const [isOrchestrating, setIsOrchestrating] = useState(false);
const [lastLeaderContent, setLastLeaderContent] = useState("");
const [hasConsensus, setHasConsensus] = useState(false);
const [consensusPercentage, setConsensusPercentage] = useState(89.4);
const [step2RoundtableDialogues, setStep2RoundtableDialogues] = useState<
Step2Dialogue[]
>([]);
const [pendingClarification, setPendingClarification] =
useState<ClarificationPayload | null>(null);
const [clarificationDraft, setClarificationDraft] = useState("");
const [isRoundTableRunning, setIsRoundTableRunning] = useState(true);
const [activeSpeakerId, setActiveSpeakerId] = useState<number | null>(null);
const [activeSpeakerSet, setActiveSpeakerSet] = useState<Set<number>>(new Set());
const [customAgentInput, setCustomAgentInput] = useState("");
const [isUpperPanelCollapsed, setIsUpperPanelCollapsed] = useState(false);
const [budgetLimit, setBudgetLimit] = useState(500);
const [agentConfigs, setAgentConfigs] = useState<Record<number, AgentConfig>>(
DEFAULT_AGENT_CONFIGS,
);
const [selectedAgentId, setSelectedAgentId] = useState<number | null>(null);
return {
selectedAgents,
setSelectedAgents,
initNonce,
setInitNonce,
threadIds,
setThreadIds,
coordinatorName,
setCoordinatorName,
isInitializing,
setIsInitializing,
initError,
setInitError,
isOrchestrating,
setIsOrchestrating,
lastLeaderContent,
setLastLeaderContent,
hasConsensus,
setHasConsensus,
consensusPercentage,
setConsensusPercentage,
step2RoundtableDialogues,
setStep2RoundtableDialogues,
pendingClarification,
setPendingClarification,
clarificationDraft,
setClarificationDraft,
isRoundTableRunning,
setIsRoundTableRunning,
activeSpeakerId,
setActiveSpeakerId,
activeSpeakerSet,
setActiveSpeakerSet,
customAgentInput,
setCustomAgentInput,
isUpperPanelCollapsed,
setIsUpperPanelCollapsed,
budgetLimit,
setBudgetLimit,
agentConfigs,
setAgentConfigs,
selectedAgentId,
setSelectedAgentId,
};
}
/** Artifact selection and stub-message state for sandbox previews. */
export function useStep2ArtifactState() {
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<string | null>(
null,
);
const [seatStubMessages, setSeatStubMessages] = useState<
Record<string, StubAiMessage[]>
>({});
const seatStubMessagesRef = useRef<Record<string, StubAiMessage[]>>({});
return {
activeArtifactThreadId,
setActiveArtifactThreadId,
seatStubMessages,
setSeatStubMessages,
seatStubMessagesRef,
};
}
/** Parallel/DAG visualization state plus refs consumed by throttled stream flushes. */
export function useStep2ParallelState() {
const [parallelBatch, setParallelBatch] = useState<ParallelSeatView[]>([]);
const [dagNodeStates, setDagNodeStates] = useState<Record<string, ChainNodeState>>({});
const [selectedParallelSeats, setSelectedParallelSeats] = useState<string[]>([]);
const parallelStreamRef = useRef<Map<number, { text: string; charCount: number }>>(
new Map(),
);
const parallelFlushTimerRef = useRef<number | null>(null);
const selectedParallelSeatsRef = useRef<string[]>([]);
const parallelBatchRef = useRef<ParallelSeatView[]>([]);
useEffect(() => {
selectedParallelSeatsRef.current = selectedParallelSeats;
}, [selectedParallelSeats]);
useEffect(() => {
parallelBatchRef.current = parallelBatch;
}, [parallelBatch]);
return {
parallelBatch,
setParallelBatch,
dagNodeStates,
setDagNodeStates,
selectedParallelSeats,
setSelectedParallelSeats,
parallelStreamRef,
parallelFlushTimerRef,
selectedParallelSeatsRef,
parallelBatchRef,
};
}
@@ -0,0 +1,160 @@
import {
useCallback,
useRef,
type Dispatch,
type SetStateAction,
} from "react";
import { useUpdateSubtask } from "@/core/tasks/context";
import type { Step2Dialogue } from "@/roundtable-planning/hooks/step2-types";
import {
asSubagentTaskEvent,
extractFrameText,
} from "@/roundtable-planning/lib/step2-orchestration-helpers";
/** Converts ultra-seat task frames into shared Subtask cards attached to a dialogue. */
export function useStep2SubtaskFrames(
setDialogues: Dispatch<SetStateAction<Step2Dialogue[]>>,
) {
const updateSubtask = useUpdateSubtask();
const knownTaskIdsRef = useRef<Set<string>>(new Set());
const touchBubbleSubtaskId = useCallback(
(bubbleId: number, taskId: string) => {
setDialogues((previous) =>
previous.map((dialogue) => {
if (dialogue.id !== bubbleId) return dialogue;
const ids = dialogue.subtaskIds ?? [];
return {
...dialogue,
subtaskIds: ids.includes(taskId) ? [...ids] : [...ids, taskId],
};
}),
);
},
[setDialogues],
);
const ingestSubagentMessage = useCallback(
(message: Record<string, unknown>, bubbleId: number) => {
const toolCalls = message.tool_calls;
if (Array.isArray(toolCalls)) {
for (const toolCall of toolCalls) {
if (!toolCall || typeof toolCall !== "object") continue;
const object = toolCall as Record<string, unknown>;
if (object.name !== "task") continue;
const taskId = typeof object.id === "string" ? object.id : "";
if (!taskId) continue;
const args = (
object.args && typeof object.args === "object" ? object.args : {}
) as Record<string, unknown>;
updateSubtask({
id: taskId,
status: "in_progress",
subagent_type:
typeof args.subagent_type === "string" ? args.subagent_type : "",
description:
typeof args.description === "string" ? args.description : "",
prompt: typeof args.prompt === "string" ? args.prompt : "",
});
knownTaskIdsRef.current.add(taskId);
touchBubbleSubtaskId(bubbleId, taskId);
}
}
const type =
typeof message.type === "string" ? message.type.toLowerCase() : "";
if (type !== "tool") return;
const taskId =
typeof message.tool_call_id === "string" ? message.tool_call_id : "";
if (!taskId || !knownTaskIdsRef.current.has(taskId)) return;
const result = extractFrameText(message.content);
if (result.startsWith("Task Succeeded. Result:")) {
updateSubtask({
id: taskId,
status: "completed",
result: result.split("Task Succeeded. Result:")[1]?.trim(),
});
} else if (result.startsWith("Task failed.")) {
updateSubtask({
id: taskId,
status: "failed",
error: result.split("Task failed.")[1]?.trim(),
});
} else if (result.startsWith("Task timed out")) {
updateSubtask({ id: taskId, status: "failed", error: result });
}
touchBubbleSubtaskId(bubbleId, taskId);
},
[touchBubbleSubtaskId, updateSubtask],
);
return useCallback(
(frame: unknown, bubbleId: number) => {
const event = asSubagentTaskEvent(frame);
if (event) {
knownTaskIdsRef.current.add(event.task_id);
if (event.type === "task_running") {
updateSubtask({
id: event.task_id,
status: "in_progress",
latestMessage: event.message as never,
});
} else if (event.type === "task_started") {
updateSubtask({
id: event.task_id,
status: "in_progress",
description:
typeof event.description === "string"
? event.description
: undefined,
});
} else if (event.type === "task_completed") {
updateSubtask({
id: event.task_id,
status: "completed",
result:
typeof event.result === "string"
? event.result
: extractFrameText(event.result),
});
} else if (
event.type === "task_failed" ||
event.type === "task_cancelled" ||
event.type === "task_timed_out"
) {
updateSubtask({
id: event.task_id,
status: "failed",
error:
typeof event.error === "string"
? event.error
: "子智能体任务失败",
});
}
touchBubbleSubtaskId(bubbleId, event.task_id);
return;
}
if (frame && typeof frame === "object" && !Array.isArray(frame)) {
const messages = (frame as Record<string, unknown>).messages;
if (Array.isArray(messages)) {
for (const message of messages) {
if (message && typeof message === "object") {
ingestSubagentMessage(
message as Record<string, unknown>,
bubbleId,
);
}
}
}
return;
}
if (!Array.isArray(frame) || frame.length === 0) return;
const chunk = frame[0];
if (!chunk || typeof chunk !== "object") return;
ingestSubagentMessage(chunk as Record<string, unknown>, bubbleId);
},
[ingestSubagentMessage, touchBubbleSubtaskId, updateSubtask],
);
}
@@ -262,6 +262,7 @@ export function useStep3Report(
// 页面主题:浅色沿用浅红效果,深色让报告智能体按「深蓝 UI 规范」绘制。
const { theme } = useTheme();
const reportThemeMode: "light" | "dark" = theme === "dark" ? "dark" : "light";
// onReportSaved 身份可能每次 render 变 —— 打到 ref,避免进 run() 的 deps 反复重建。
const onReportSavedRef = useRef(onReportSaved);
@@ -826,7 +827,7 @@ export function useStep3Report(
if (aborter.signal.aborted) return;
finalizeDialogue(bubbleId, closing, "绘制完成");
setPhase("done");
setReportTheme(theme);
setReportTheme(reportThemeMode);
// 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3),连同绘制主题。
if (html.trim()) {
onReportSavedRef.current?.({
@@ -834,12 +835,12 @@ export function useStep3Report(
generatedAt: new Date().toISOString(),
model: selectedModel || "",
summary: closing || "",
themeMode: theme,
themeMode: reportThemeMode,
});
}
},
[
theme,
reportThemeMode,
selectedModel,
openRealArtifactFallback,
reconcileStubFromRealFile,
@@ -885,7 +886,7 @@ export function useStep3Report(
intentReady,
seatDeliveries,
consensusText,
themeMode: theme,
themeMode: reportThemeMode,
revisionInstruction,
previousHtml: savedReport?.html,
});
@@ -906,7 +907,7 @@ export function useStep3Report(
intentReady,
seatDeliveries,
consensusText,
theme,
reportThemeMode,
savedReport?.html,
appendStreamingDialogue,
streamRound,
@@ -953,7 +954,7 @@ export function useStep3Report(
let threadId = liveThreadRef.current;
let prompt: string;
if (threadId) {
prompt = buildInterjectPrompt(text, theme);
prompt = buildInterjectPrompt(text, reportThemeMode);
} else {
setPhase("creating");
const init = await initReportThread({ signal: aborter.signal });
@@ -967,7 +968,7 @@ export function useStep3Report(
intentReady,
seatDeliveries,
consensusText,
themeMode: theme,
themeMode: reportThemeMode,
revisionInstruction: text,
previousHtml: savedReport?.html,
});
@@ -1000,7 +1001,7 @@ export function useStep3Report(
freezeStreaming,
appendUserDialogue,
appendStreamingDialogue,
theme,
reportThemeMode,
intentReady,
seatDeliveries,
consensusText,
@@ -1112,7 +1113,7 @@ export function useStep3Report(
// 报告已展示(done)且记录过绘制主题、而界面主题已切换 → 提示重新绘制。
const themeMismatch =
phase === "done" && reportTheme !== null && reportTheme !== theme;
phase === "done" && reportTheme !== null && reportTheme !== reportThemeMode;
return {
phase,
@@ -15,20 +15,15 @@
*/
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,
} from "@/roundtable-planning/api/multi-agent";
/** 一个上传文件的 run 引用(与 streamMultiAgent 的 files 入参对齐)。 */
@@ -50,13 +45,19 @@ import {
} from "@/roundtable-planning/api/flow-extract";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
import { readActionDetail } from "@/core/auth/task-deeplink";
import {
buildResumeAskPrompt,
buildSummaryPrompt,
ingestPhaseLabel,
SEMANTIC_ASK_INSTRUCTION,
summaryPhaseLabel,
} from "@/roundtable-planning/lib/step3-summary-prompts";
import { useStep3SummaryRuntime } from "@/roundtable-planning/hooks/useStep3SummaryRuntime";
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
const SUMMARY_OUTPUT_PATH = "/mnt/user-data/outputs/方案总结报告.md";
/** 内置单例 id —— 恢复草稿后续问时无 init 返回值,用它兜底拼 dAgentThreadId。 */
const SUMMARY_AGENT_NAME = "roundtable-summary";
/** 结构化抽取智能体 id —— 入库轮由它(走 ingest run policy)真正调用入库技能。 */
const STRUCTURE_AGENT_NAME = "roundtable-structure";
export type SummaryPhase = "idle" | "creating" | "streaming" | "done" | "error";
@@ -174,305 +175,6 @@ export interface UseStep3SummaryReturn {
runIngest: (opts?: { note?: string }) => void;
}
/** 把研讨原料拼成一段(首轮 prompt 和「恢复后无线程续问」都用得到)。 */
function buildMaterials(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
/** xdfx:本次「行动名称」,作为材料抬头喂进总结(报告围绕该行动展开)。 */
actionName?: string | null;
}): string {
const { intentReady, seatDeliveries, consensusText, coordinatorPrompt, actionName } = opts;
const parts: string[] = [];
const an = actionName?.trim();
if (an) {
parts.push("# 本次行动");
parts.push(an);
parts.push("");
}
const cp = coordinatorPrompt?.trim();
if (cp) {
parts.push("# 业务链总控编排提示(最高优先级,按它的关注点/结构/口径来组织报告)");
parts.push(cp);
parts.push("");
// 编排提示里常藏着用户对"最终总结"的明确要求——让模型先提取再动笔,
// 否则结构化输出/重点席位这类要求容易被默认章节结构盖掉。
parts.push(
"> 动笔前请先从上面这段编排提示里提取用户对最终总结报告的明确要求(输出结构/格式、重点总结哪些席位、口径风格、详略取舍),并逐条满足:" +
"用户指定了输出结构(章节、表格、编号清单、固定模板等)就完全按用户结构输出、放弃默认章节结构;" +
"点名了重点席位就把它们前置并显著加大篇幅,未点名席位一两句带过;要求了详略取舍就照办,未被要求的章节可直接省略。",
);
parts.push("");
}
if (intentReady?.objective) {
parts.push("# 一、任务意图");
parts.push(`目标:${intentReady.objective}`);
if (intentReady.constraints?.length) {
parts.push("约束:");
for (const c of intentReady.constraints) parts.push(`- ${c}`);
}
if (intentReady.assumptions?.length) {
parts.push("关键假设:");
for (const a of intentReady.assumptions) parts.push(`- ${a}`);
}
parts.push("");
}
if (seatDeliveries.length) {
parts.push("# 二、各席位最终交付");
seatDeliveries.forEach((s, i) => {
parts.push(`## ${i + 1}. ${s.sender}`);
parts.push(s.content?.trim() || "(无内容)");
parts.push("");
});
}
if (consensusText?.trim()) {
parts.push("# 三、总控最终共识结论");
parts.push(consensusText.trim());
parts.push("");
}
return parts.join("\n");
}
/**
* flow-json 形状契约(内联)。roundtable-summary 的 runtime SOUL 为空,模型拿不到契约,
* 所有要画流程图的 prompt 都得带上它,否则模型产出的 JSON 可能解析不出节点。
*/
const FLOW_JSON_SHAPE =
"flow-json 严格用下面这个形状(一个 JSON 对象,`nodes` 是节点数组;示例仅示意结构、勿照抄内容):\n" +
"```flow-json\n" +
'{"title":"流程图标题","nodes":[\n' +
' {"id":"n1","label":"任务简短名","type":"start","group":"任务","detail":"任务目标的完整描述"},\n' +
' {"id":"n2","label":"目的核心短语","type":"task","group":"目的","detail":"该目的的完整内容:名称 + 理由全文","from":["n1"]},\n' +
' {"id":"n3","label":"行为体核心短语","type":"role","group":"行为体","detail":"该行为体的完整内容:名称 + 理由全文","from":["n2"]}\n' +
"]}\n" +
"```\n" +
"字段:`id`(n1/n2… 唯一短串)、`label`(≤12 字,节点标题,取条目核心短语)、" +
"`type`(取 start/end/stage/task/decision/risk/deliverable/role 之一,仅决定配色)、" +
"**`group`(该节点所在的层级名称,如 任务 / 目的 / 行为体 / 预期行为 / 驱动因素 / 叙事——画布按它给节点打标签,必须填)**、" +
"**`detail`(该条目的名称 + 理由 / 说明,点击节点时展示——要把要点表达完整,但控制在约 2–4 句,**不必**逐字照抄报告整段)**、" +
"`from`(前驱节点 id 列表,只引用**前面已出现**的节点)。" +
"节点按先后排列、输出**全量**数据、全程简体中文。";
/**
* 「本轮只画流程图、不写报告」的强约束。关键:有的业务链 coordinatorPrompt 自身写着
* 「输出结构化结论 / 按阶段产出报告 / 最终方案总结报告按层级结构输出」,模型在同一线程里
* 会被它锚定,把用户的「结构化输出」当成「继续写结构化的文字报告」→ 产出 Markdown 而非
* flow-json(实测 deepseek-chat 在这类链上必现)。所以必须显式声明本轮压过那些报告措辞。
*/
const FLOW_ONLY_RULE =
"【硬性·本轮只画流程图】本轮唯一产物是一张**流程图**(由节点 + 连线组成的图),不是文字、不是报告、" +
"不是章节、不是结论清单、不是 Markdown:①**不要**调用 write_file、**不要**写或改任何 .md 文件;" +
"②回复里**不要**再出现任何报告正文 / 章节标题(如「## 四…」)/ 编号结论,正文最多一句话说明;" +
"③**忽略**此前关于「方案总结报告」「按层级结构输出报告」「输出结构化结论」「分阶段产出」的一切措辞——" +
"那些是给上一轮文字报告用的,本轮一律不适用;④在回复**最末尾**输出一个**完整**的 ```flow-json 代码块。";
/**
* 流程图的层级画法(修「结构画错」):直接从已有报告/席位交付里**提取实体**,
* 根=任务名称,每层取具体条目当节点,按报告里的对应关系串成分支,排除汇总/阶段名等无关段落。
*/
const FLOW_STRUCTURE_RULE =
"【流程图画法·严格遵守】\n" +
"① 根节点(n1) = 本次任务本身(即报告「任务总览」里的任务名称 / 目标),label 用任务的简短名称(≤12 字)," +
"**不要**用「任务」「目标」这种泛称当 label;其 `group` 填「任务」;\n" +
"② 从第二层起按层级顺序逐层展开:每个层级在报告/交付里通常列了**多条具体条目**" +
"(如『目的』层有 目的1 / 目的2 / 目的3,『行为体』层有各自对应的行为体,后面还有 预期行为·驱动因素、叙事…)," +
"把**每一条具体条目各画成一个节点**,label 取该条目核心短语(≤12 字);**不要**把一整层合并成一个笼统节点;\n" +
"③ **每个节点的 `group` 必须填它所在层级的名称**(任务 / 目的 / 行为体 / 预期行为 / 驱动因素 / 叙事…,取自报告里该层级的真实名称)——画布据此给节点打分类标签;\n" +
"④ **每个节点的 `detail`** 放该条目的名称 + 理由 / 说明,把要点表达完整、约 2–4 句即可(不必逐字照抄报告整段);" +
"**`detail` 必须写成一行**——内部不要出现真实换行,需要分隔就用「;」或「/」,避免 JSON 里出现裸换行;\n" +
"⑤ 连边严格按报告里的**对应关系**串成分支:根任务 → 各『目的』节点;每个『目的』→ 它对应的『行为体』节点;" +
"每个『行为体』→ 它对应的『预期行为·驱动因素』节点 → 对应的『叙事』节点……每一组各成一条自上而下的分支" +
"(报告里有几组就有几条分支,组与组之间不要混连);\n" +
"⑦ **务必把每个层级的每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停**——" +
"典型规模约 13–16 个节点(如 任务1 + 目的3 + 行为体3 + 预期行为·驱动因素3 + 叙事3);\n" +
"⑥ **只画**这些层级实体节点;**不要**把『综合分析』『核心洞察』『分层传播策略 / 执行路线 / 建议』『结论』" +
"以及『阶段①②③④』等章节名 / 阶段名 / 汇总段落画成节点。";
// 注:原 buildAutoFlowPrompt(在总结线程里续问吐 flow-json)已废弃 —— Phase 1 起自动结构化
// 输出改走后端独立端点 /api/flow-extract/stream(streamFlowExtract),抽取+校验在后端做。
// FLOW_ONLY_RULE / FLOW_STRUCTURE_RULE / FLOW_JSON_SHAPE 仍由手动 FLOW_ASK_INSTRUCTION 引用。
/** 首轮总结 prompt。`flowMode`=编排提示里明确要求了结构化输出/流程图 → 跳过 md 文件,直出流程图。 */
function buildSummaryPrompt(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
actionName?: string | null;
flowMode?: boolean;
}): string {
const parts: string[] = [];
if (opts.flowMode) {
parts.push(
"请基于以下圆桌多智能体研讨的成果,产出一份**结构化的方案总结**。" +
"用户已明确要求结构化输出/流程图,因此本轮:" +
"①**不要**调用 write_file,**不要**生成任何 .md 文件;" +
"②把精炼的文字版总结报告直接写在对话回复里(供用户直接阅读,结构遵循编排提示的要求;" +
"编排提示未指定结构时,默认对各席位交付内容逐席位格式化输出:席位名→核心结论→关键交付要点→风险/建议);" +
"③在回复最末尾**必须**输出一个完整的 ```flow-json 代码块(前端据此实时绘制可交互流程图)。",
);
} else {
parts.push(
"请基于以下圆桌多智能体研讨的成果,撰写一份以文字叙述为主线的 Markdown「方案总结报告」," +
"用 write_file 工具写入 /mnt/user-data/outputs/方案总结报告.md," +
"并把报告完整正文同时输出到对话回复里供用户直接阅读。",
);
}
parts.push("");
parts.push(buildMaterials(opts));
// 意图对齐 + 缺口核对 + 防幻觉护栏(弱席位场景的质量兜底)——两种模式通用,与后端 build_summary_prompt 同义。
parts.push("");
parts.push(
"【动笔前必做:意图对齐与缺口核对】各席位可能由能力较弱的模型产出,未必扣住用户意图、未必充分。" +
"你是这份总结的质量兜底,不能只把各席位交付简单拼接。请先把上面的「任务意图」(目标/约束/关键假设)拆成" +
"用户核心诉求清单(编排提示对最终产出的要求也并入),逐条核对各席位交付+共识是否覆盖、够不够、有无跑题或矛盾;" +
"再据此组织:① 能从已有原料重组/推理补上的→直接补全讲透;② 缺真实信息补不了的→绝不凭空编。" +
"**写进 .md 报告文件的必须是一份完整、干净、可直接使用、无需用户修改的报告——不要在报告文件里写「待补充/不足/⚠️」之类提示或占位**。" +
"**但在对话回复正文、报告之后,必须追加一段简短「核对说明」**(只在对话里、**不写进 .md 文件**):逐条说明产出是否覆盖了用户意图、哪些充分/哪些不足/有哪些可补充(可在对话里让我补全);" +
"**即便都已覆盖、无明显缺口,也要明确写一句「已核对:各诉求均已覆盖,无明显缺口」,这段核对说明任何时候都不要省略。**" +
"最终产出要明显比各席位交付的简单拼接更扣用户意图。",
);
if (opts.flowMode) {
parts.push(
"请严格遵循你的职责设定:全程简体中文。" +
"flow-json 按你职责设定的「结构化流程数据」契约输出:节点按流程先后排列、" +
"每个节点的 from 只引用前面已出现的节点、6–20 个节点为宜、类型用 start/end/stage/task/decision/risk/deliverable/role。" +
"正文文字报告以「业务链总控编排提示」的要求为骨架,简明、重点突出。",
);
} else {
parts.push(
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、全程简体中文。" +
"报告结构:默认用叙事型结构(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线);" +
"但若上面的「业务链总控编排提示」对最终总结提出了要求(输出结构/格式、重点席位、口径、详略)," +
"那些要求优先于默认结构——用户指定的结构可以完全替换默认章节。" +
"若用户要求了结构化输出/流程图,按你职责设定在对话回复末尾追加 ```flow-json 代码块(前端据此实时绘制流程图)。",
);
}
return parts.join("\n");
}
/**
* 用户消息命中结构化/流程图意图时追加给智能体的系统指令(live 线程续问轮直接拼在消息后)。
* 强调「全量 flow-json + 不写文件」——画图数据必须完整自洽,否则前端增量解析会画出半张图。
*/
const FLOW_ASK_INSTRUCTION =
"(系统指令:用户要求结构化输出 / 流程图——这里的「结构化输出」指的是画一张**流程图**," +
"不是写结构化的文字报告。**有报告就直接基于报告提取层级条目**(报告已总结过各席位交付,不要重复总结);" +
"没有报告则基于各席位交付。\n" +
FLOW_ONLY_RULE +
"\n" +
FLOW_STRUCTURE_RULE +
"\n" +
FLOW_JSON_SHAPE +
")";
/**
* 上一轮主产物是流程图、但本条消息没显式提「结构化/流程图」时的意图引导指令。
* 历史痛点:用户画完流程图后说「把验收环节加上」「改一下」,关键词不命中 →
* 智能体把它当改报告/问答处理,用户被迫说「重新结构化输出」才能改图。
* 这里把「上一轮主产物」上下文显式告诉智能体,让它按语义判断、太模糊就反问。
*/
const FLOW_CONTEXT_ASK_INSTRUCTION =
"(系统指令:上一轮的主产物是结构化流程图。请按语义理解用户这条消息、不要求任何关键词:" +
"若是修改/调整类意图且没明确说要改文字报告,默认视为**修改流程图**——不要写 .md 文件," +
"在回复末尾输出**完整修订版** ```flow-json 代码块(全量节点+连线,非增量);" +
"若明确要改文字报告才用 write_file 整文件重写;若只是提问就只用文字回答;" +
"若意图太模糊、无法判断要改什么,不要修改任何产物,在正文向用户提一个简短的澄清问题(给出可选方向)。)";
/**
* 普通报告语境下的意图引导指令(每轮续问都附上):
* 按语义理解修改/提问/结构化意图,太模糊就在正文反问,不要乱改。
*/
const SEMANTIC_ASK_INSTRUCTION =
"(系统指令:请按语义理解用户这条消息的意图、不要求任何关键词:" +
"修改类意见(哪怕表述笼统)就在原报告基础上增量修改并用 write_file 整文件重写;" +
"要求结构化/格式化/可视化(或语义等价说法)就进入流程图模式——不写 .md 文件," +
"正文默认对各席位交付内容逐席位格式化输出,末尾输出完整 ```flow-json 块;" +
"只是提问就只用文字回答、不改文件;" +
"若意图太模糊、无法判断要改什么,不要修改任何产物,在正文向用户提一个简短的澄清问题(给出可选方向)。)";
/** 恢复草稿后线程已不在内存(无 live thread)时,新建线程续问用的 prompt:带上旧报告做底。 */
function buildResumeAskPrompt(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
previousMd: string;
userMessage: string;
/** 按本轮语境选好的意图引导指令(流程图显式 / 流程图上下文 / 普通语义),追加在消息后。 */
askInstruction?: string;
}): string {
const { previousMd, userMessage, askInstruction, ...materials } = opts;
const parts: string[] = [];
parts.push(
"这是一次对已有「方案总结报告」的继续对话。下面先给出研讨原料与你之前已生成的报告," +
"然后是用户的新消息——请自行判断:若是修改意见,就在旧报告基础上增量修改并用 write_file " +
"整文件重写 /mnt/user-data/outputs/方案总结报告.md,再把更新后的报告正文输出到对话;" +
"若只是提问,就只用文字回答、不要改写文件。",
);
parts.push("");
parts.push(buildMaterials(materials));
if (previousMd.trim()) {
parts.push("# 你之前已生成的报告(方案总结报告.md 当前内容)");
parts.push("```markdown");
parts.push(previousMd.trim());
parts.push("```");
parts.push("");
}
parts.push("# 用户的新消息");
parts.push(userMessage.trim());
if (askInstruction) {
parts.push("");
parts.push(askInstruction);
}
return parts.join("\n");
}
/** 把 SSE 阶段映射成中文状态标签。 */
function summaryPhaseLabel(phase: StreamPhase, toolName?: string): string {
switch (phase) {
case "connecting":
return "调度中";
case "connected":
return "已连接,等待响应";
case "tool_calling":
if (toolName === "write_file" || toolName === "str_replace")
return "正在撰写报告";
return toolName ? `调用工具:${toolName}` : "调用工具";
case "tool_result":
return "处理工具结果";
case "streaming":
return "正在输出";
default:
return "正在输出…";
}
}
/** 入库轮的工具阶段 → 中文步骤标签(让前端步骤条体现「读技能说明 → 执行入库」真实过程)。 */
function ingestPhaseLabel(phase: StreamPhase, toolName?: string): string {
switch (phase) {
case "connecting":
return "调度入库技能";
case "connected":
return "已连接,等待响应";
case "tool_calling":
if (toolName === "read_file") return "读取入库技能说明(SKILL.md)";
if (toolName === "bash" || toolName === "write_file") return "调用入库技能执行入库";
return toolName ? `调用工具:${toolName}` : "调用入库技能";
case "tool_result":
return "处理入库结果";
case "streaming":
return "汇报入库结果";
default:
return "入库中…";
}
}
export function useStep3Summary(
options: UseStep3SummaryOptions,
@@ -485,7 +187,6 @@ export function useStep3Summary(
coordinatorPrompt,
chainLevels,
autoFlow,
savedSummary,
onSummarySaved,
ingestAvailable,
taskId: ingestTaskId,
@@ -540,16 +241,6 @@ export function useStep3Summary(
ingestChainNameRef.current = ingestChainName ?? null;
}, [ingestSkill, ingestBusinessCode, ingestChainName]);
const {
setArtifacts,
select: selectArtifact,
setOpen: setArtifactsOpen,
open: artifactsOpen,
} = useArtifacts();
const artifactsOpenRef = useRef(artifactsOpen);
useEffect(() => {
artifactsOpenRef.current = artifactsOpen;
}, [artifactsOpen]);
const [phase, setPhase] = useState<SummaryPhase>("idle");
const [error, setError] = useState<string | null>(null);
@@ -564,483 +255,48 @@ export function useStep3Summary(
// 意图模糊时挂起待澄清的原始消息(组件渲染选项让用户自己定)。
const [pendingFlowClarification, setPendingFlowClarification] = useState<string | null>(null);
const [summaryThreadId, setSummaryThreadId] = useState<string | null>(null);
const [dialogues, setDialogues] = useState<Step2Dialogue[]>([]);
const [seatStubMessages, setSeatStubMessages] = useState<
Record<string, StubAiMessage[]>
>({});
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<
string | null
>(null);
const stubRef = useRef<Record<string, StubAiMessage[]>>({});
const runningRef = useRef(false);
const aborterRef = useRef<AbortController | null>(null);
// 真线程(init 后或恢复时已知的后端 thread id)—— ask() 续问时复用它,保留报告上下文。
const liveThreadRef = useRef<string | null>(null);
// 当前报告 md 全文(续问写底 / 存草稿用)。
const currentMdRef = useRef<string>("");
// 上一轮主产物是否为流程图(flow-json)——续问意图判断的上下文锚点:
// 为 true 时用户的笼统修改意见(「加个环节」「改一下」)默认按改流程图引导,
// 不再要求用户说出「结构化输出/流程图」等关键词。
const flowActiveRef = useRef(false);
const lastAutoOpenedUrlRef = useRef<string | null>(null);
const {
dialogues,
setDialogues,
seatStubMessages,
setSeatStubMessages,
activeArtifactThreadId,
setActiveArtifactThreadId,
stubRef,
runningRef,
aborterRef,
liveThreadRef,
currentMdRef,
flowActiveRef,
lastAutoOpenedUrlRef,
lastAutoThreadRef,
appendStreamingDialogue,
appendUserDialogue,
appendToDialogue,
setDialogueConfidence,
relabelDialogue,
resetDialogueForReuse,
removeDialogue,
appendStepIfNew,
finalizeDialogue,
buildWriteFileVirtualUrl,
setPreviewEnabled,
handleArtifactToolPhase,
handleUpstreamFrame,
openArtifactPreview,
openArtifactFromStep,
openRealArtifactFallback,
reconcileStubFromRealFile,
abort,
freezeStreaming,
seedStubWithCurrentMd,
} = useStep3SummaryRuntime({ outputPath: SUMMARY_OUTPUT_PATH });
// 流程图抽取串行化:在途抽取的 aborter + 当前承接抽取/校验的气泡 id。新一轮抽取会先中止
// 上一轮、并复用同一张「调度中」气泡——保证任何时刻只有一张流程图卡片。修复:报告刚结束时
// run() 的「自动出图」与用户「跳过报告直接生成流程图」的手动出图竞态,各自建卡片 → 双「调度中」。
const flowAborterRef = useRef<AbortController | null>(null);
const activeFlowBubbleRef = useRef<number | null>(null);
// ── 对话气泡三段式(append → delta → finalize)+ 工具步骤(与第 2 步同款)──
const appendStreamingDialogue = useCallback(
(card: { sender: string; avatarType: string; agentThreadId?: string }): number => {
const id = Date.now() + Math.random();
setDialogues((prev) => [
...prev,
{
id,
sender: card.sender,
avatarType: card.avatarType,
content: "",
time: nowTime(),
confidence: "调度中",
streaming: true,
steps: [],
agentThreadId: card.agentThreadId,
},
]);
return id;
},
[],
);
/** 追加一条用户(指挥官)气泡 —— ask() 时先回显用户输入。 */
const appendUserDialogue = useCallback((text: string) => {
const id = Date.now() + Math.random();
setDialogues((prev) => [
...prev,
{
id,
sender: "指挥官(追问)",
avatarType: "human",
content: text,
time: nowTime(),
confidence: "已发送",
streaming: false,
steps: [],
},
]);
}, []);
const appendToDialogue = useCallback((id: number, deltaText: string) => {
if (!deltaText) return;
setDialogues((prev) =>
prev.map((d) =>
d.id === id ? { ...d, content: (d.content ?? "") + deltaText } : d,
),
);
}, []);
const setDialogueConfidence = useCallback((id: number, confidence: string) => {
setDialogues((prev) =>
prev.map((d) => (d.id === id ? { ...d, confidence } : d)),
);
}, []);
/** 改某气泡的发送方名称(意图分类期间先建中性「调度中」气泡,定了意图再改成对应智能体名复用)。 */
const relabelDialogue = useCallback((id: number, sender: string) => {
setDialogues((prev) => prev.map((d) => (d.id === id ? { ...d, sender } : d)));
}, []);
/**
* 复用一条旧气泡承接新一轮(改名 + 清空内容/步骤 + 重新转圈)。流程图抽取被新一轮抢占时
* 复用上一轮那条气泡,避免遗留一张卡死的「调度中」卡片。
*/
const resetDialogueForReuse = useCallback((id: number, sender: string) => {
setDialogues((prev) =>
prev.map((d) =>
d.id === id
? { ...d, sender, content: "", steps: [], streaming: true, confidence: "调度中", time: nowTime() }
: d,
),
);
}, []);
/** 移除一条气泡(流程图抽取抢占时清掉被取代的那张孤儿「调度中」卡片)。 */
const removeDialogue = useCallback((id: number) => {
setDialogues((prev) => prev.filter((d) => d.id !== id));
}, []);
const appendStepIfNew = useCallback(
(
bubbleId: number,
label: string,
key: string,
args?: Record<string, unknown>,
) => {
setDialogues((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) {
if (args && args !== last.args) {
const next = [...steps];
next[next.length - 1] = { ...last, args };
return { ...d, steps: next };
}
return d;
}
return {
...d,
steps: [
...steps,
{ id: `${bubbleId}-${steps.length}`, key, label, ts: nowTime(), args },
],
};
}),
);
},
[],
);
const finalizeDialogue = useCallback(
(id: number, finalContent: string, confidence?: string) => {
setDialogues((prev) =>
prev.map((d) => {
if (d.id !== id) return d;
const next =
finalContent && finalContent.trim().length > 0
? finalContent
: d.content || "(已写入报告文件,无额外正文)";
return {
...d,
content: next,
confidence: confidence ?? d.confidence,
streaming: false,
};
}),
);
},
[],
);
// ── stub 写入(幂等,按 (toolName, path) 去重,后续帧覆盖 args)—— 仅 write_file ──
const upsertStub = useCallback(
(threadId: string, toolName: string, args: Record<string, unknown>) => {
const path = typeof args.path === "string" ? args.path : "";
const current = stubRef.current[threadId] ?? [];
const base: StubAiMessage = current[0] ?? {
id: `roundtable-summary-stub-${threadId}`,
type: "ai",
content: "",
tool_calls: [],
};
const idx = base.tool_calls.findIndex(
(tc) =>
tc.name === toolName &&
String((tc.args as { path?: unknown })?.path ?? "") === path,
);
const toolCallId =
idx >= 0 ? base.tool_calls[idx].id : `tc-${threadId}-${base.tool_calls.length}`;
// 防抹空:重写早期帧 content 可能为空,保留旧非空内容直到吐出新非空内容。
let storeArgs = args;
if (idx >= 0) {
const newContent = typeof args.content === "string" ? args.content : "";
const prevContent =
typeof (base.tool_calls[idx].args as { content?: unknown })?.content === "string"
? ((base.tool_calls[idx].args as { content?: string }).content as string)
: "";
if (!newContent.trim() && prevContent.trim()) {
storeArgs = { ...args, content: prevContent };
}
}
const nextTc = { id: toolCallId, name: toolName, args: storeArgs };
const nextToolCalls =
idx >= 0
? base.tool_calls.map((tc, i) => (i === idx ? nextTc : tc))
: [...base.tool_calls, nextTc];
const nextMsg: StubAiMessage = { ...base, tool_calls: nextToolCalls };
const nextMap = { ...stubRef.current, [threadId]: [nextMsg] };
stubRef.current = nextMap;
setSeatStubMessages(nextMap);
// 记住当前报告全文(用于续问写底 / 存草稿)。
if (typeof storeArgs.content === "string" && storeArgs.content.trim()) {
currentMdRef.current = storeArgs.content;
}
return { messageId: nextMsg.id, toolCallId };
},
[],
);
const buildWriteFileVirtualUrl = useCallback(
(path: string, messageId: string, toolCallId: string) =>
new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
).toString(),
[],
);
const openArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
lastAutoOpenedUrlRef.current = virtualUrl;
setActiveArtifactThreadId(threadId);
selectArtifact(virtualUrl, false);
setArtifactsOpen(true);
},
[selectArtifact, setArtifactsOpen],
);
// 预览开关(2026-06-13,hook 提升页面层后并行流共用全局沙箱):只有用户当前所在
// 视图的流允许**自动**打开沙箱,否则总结/绘制并行时互相抢预览(选中产物与当前视图
// 线程对不上 → 空沙箱)。显式用户点击(openArtifactFromStep 等)不受限。
const previewEnabledRef = useRef(false);
const lastAutoThreadRef = useRef<string | null>(null);
const setPreviewEnabled = useCallback(
(enabled: boolean) => {
previewEnabledRef.current = enabled;
// 进入视图接管:**仅当沙箱面板已打开**时把选中产物换成本流的(修串台);
// 面板关着保持关闭(尊重"进第三步默认关沙箱")。
if (
enabled &&
lastAutoOpenedUrlRef.current &&
lastAutoThreadRef.current &&
artifactsOpenRef.current
) {
openArtifact(lastAutoThreadRef.current, lastAutoOpenedUrlRef.current);
}
},
[openArtifact],
);
const autoOpenArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
lastAutoThreadRef.current = threadId;
if (!previewEnabledRef.current) {
// 视图不在前台:只记录,不抢沙箱(回到视图时 setPreviewEnabled 再接管)。
lastAutoOpenedUrlRef.current = virtualUrl;
return;
}
if (lastAutoOpenedUrlRef.current === virtualUrl) return;
openArtifact(threadId, virtualUrl);
},
[openArtifact],
);
const findStubSource = useCallback(
(
rawPath: string,
): { threadId: string; messageId: string; toolCallId: string } | null => {
const target = rawPath.trim();
if (!target) return null;
const targetBase = target.split("/").pop() ?? target;
let basenameMatch:
| { threadId: string; messageId: string; toolCallId: string }
| null = null;
for (const [threadId, msgs] of Object.entries(stubRef.current)) {
const msg = msgs?.[0];
if (!msg) continue;
for (const tc of msg.tool_calls) {
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
const p =
typeof (tc.args as { path?: unknown })?.path === "string"
? ((tc.args as { path?: string }).path as string)
: "";
if (!p) continue;
if (p === target) return { threadId, messageId: msg.id, toolCallId: tc.id };
if (!basenameMatch && (p.split("/").pop() ?? p) === targetBase) {
basenameMatch = { threadId, messageId: msg.id, toolCallId: tc.id };
}
}
}
return basenameMatch;
},
[],
);
// 流式期间:phase==="tool_calling" 且 write_file 有可读 path → 写 stub + 自动开沙箱。
const handleArtifactToolPhase = useCallback(
(
streamPhase: StreamPhase,
toolName: string | undefined,
args: Record<string, unknown> | undefined,
threadId: string,
) => {
if (streamPhase !== "tool_calling") return;
if (toolName !== "write_file") return; // str_replace 无 content,不驱动预览
const path = args?.path;
if (typeof path !== "string" || !path || !args) return;
const basename = path.split("/").pop() ?? path;
if (!basename.includes(".")) return; // 半截路径(无扩展名)忽略
const { messageId, toolCallId } = upsertStub(threadId, toolName, args);
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
autoOpenArtifact(threadId, virtualUrl);
},
[upsertStub, buildWriteFileVirtualUrl, autoOpenArtifact],
);
const handleUpstreamFrame = useCallback(
(frame: unknown, threadId: string) => {
if (!Array.isArray(frame) || frame.length === 0) return;
const chunk = frame[0] as Record<string, unknown> | undefined;
const toolCalls = chunk?.tool_calls;
if (!Array.isArray(toolCalls)) return;
for (const tc of toolCalls) {
if (!tc || typeof tc !== "object") continue;
const o = tc as Record<string, unknown>;
const name = typeof o.name === "string" ? o.name : "";
if (name !== "write_file") continue;
const a = o.args;
if (!a || typeof a !== "object") continue;
const p = (a as Record<string, unknown>).path;
if (typeof p !== "string") continue;
if (!(p.split("/").pop() ?? p).includes(".")) continue;
upsertStub(threadId, name, a as Record<string, unknown>);
}
},
[upsertStub],
);
const openArtifactPreview = useCallback(
(threadId: string, path: string) => {
const normalized = path.trim();
if (!normalized) return;
const source = findStubSource(normalized);
if (source) {
const virtualUrl = buildWriteFileVirtualUrl(
normalized,
source.messageId,
source.toolCallId,
);
openArtifact(source.threadId, virtualUrl);
return;
}
if (!threadId) return;
setActiveArtifactThreadId(threadId);
setArtifacts([normalized]);
selectArtifact(normalized, false);
setArtifactsOpen(true);
},
[
findStubSource,
buildWriteFileVirtualUrl,
openArtifact,
setArtifacts,
selectArtifact,
setArtifactsOpen,
],
);
const openArtifactFromStep = useCallback(
(
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => {
const threadId = dialogue.agentThreadId;
if (!threadId) return;
const [, toolName] = step.key.split(":");
const path = step.args?.path;
if (typeof path !== "string" || !path) return;
if (toolName === "write_file") {
const { messageId, toolCallId } = upsertStub(threadId, toolName, step.args!);
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
openArtifact(threadId, virtualUrl);
} else if (toolName === "str_replace") {
openArtifactPreview(threadId, path);
}
},
[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():增量改后文件可能等长/更短,长度守卫会挡掉改动,故强制以真实文件为准。
const reconcileStubFromRealFile = useCallback(
async (
threadId: string,
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;
},
[upsertStub],
);
const abort = useCallback(() => {
aborterRef.current?.abort();
aborterRef.current = null;
runningRef.current = false;
}, []);
/** 把仍在流式的气泡冻结(停转圈),并打上状态标签(打断 / 暂停时用)。 */
const freezeStreaming = useCallback((note: string) => {
setDialogues((prev) =>
prev.map((d) => (d.streaming ? { ...d, streaming: false, confidence: note } : d)),
);
}, []);
/**
* 把当前报告 md 种进某线程 stub(续问时给 reconcile/预览一个锚点)。
* `open`=false 时只种不开沙箱——流程图轮用户要看的是画布,不要弹 md 预览抢占。
*/
const seedStubWithCurrentMd = useCallback(
(threadId: string, opts?: { open?: boolean }) => {
const md = currentMdRef.current;
if (!md.trim()) return;
const { messageId, toolCallId } = upsertStub(threadId, "write_file", {
path: SUMMARY_OUTPUT_PATH,
content: md,
});
if (opts?.open === false || !previewEnabledRef.current) return;
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
openArtifact(threadId, virtualUrl);
},
[upsertStub, buildWriteFileVirtualUrl, openArtifact],
);
/**
* 公共流式过程:在 `bubbleId` 气泡上跑一轮 special run 并对账落档。
@@ -0,0 +1,548 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import { apiFetch } from "@/core/api";
import {
buildArtifactDownloadUrl,
listSessionArtifacts,
type StreamPhase,
} from "@/roundtable-planning/api/multi-agent";
import type {
Step2Dialogue,
StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
export interface Step3SummaryRuntimeOptions {
outputPath: string;
}
export function useStep3SummaryRuntime({
outputPath,
}: Step3SummaryRuntimeOptions) {
const {
setArtifacts,
select: selectArtifact,
setOpen: setArtifactsOpen,
open: artifactsOpen,
} = useArtifacts();
const artifactsOpenRef = useRef(artifactsOpen);
useEffect(() => {
artifactsOpenRef.current = artifactsOpen;
}, [artifactsOpen]);
const [dialogues, setDialogues] = useState<Step2Dialogue[]>([]);
const [seatStubMessages, setSeatStubMessages] = useState<
Record<string, StubAiMessage[]>
>({});
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<
string | null
>(null);
const stubRef = useRef<Record<string, StubAiMessage[]>>({});
const runningRef = useRef(false);
const aborterRef = useRef<AbortController | null>(null);
// 真线程(init 后或恢复时已知的后端 thread id)—— ask() 续问时复用它,保留报告上下文。
const liveThreadRef = useRef<string | null>(null);
// 当前报告 md 全文(续问写底 / 存草稿用)。
const currentMdRef = useRef<string>("");
// 上一轮主产物是否为流程图(flow-json)——续问意图判断的上下文锚点:
// 为 true 时用户的笼统修改意见(「加个环节」「改一下」)默认按改流程图引导,
// 不再要求用户说出「结构化输出/流程图」等关键词。
const flowActiveRef = useRef(false);
const lastAutoOpenedUrlRef = useRef<string | null>(null);
// ── 对话气泡三段式(append → delta → finalize)+ 工具步骤(与第 2 步同款)──
const appendStreamingDialogue = useCallback(
(card: { sender: string; avatarType: string; agentThreadId?: string; }): number => {
const id = Date.now() + Math.random();
setDialogues((prev) => [
...prev,
{
id,
sender: card.sender,
avatarType: card.avatarType,
content: "",
time: nowTime(),
confidence: "调度中",
streaming: true,
steps: [],
agentThreadId: card.agentThreadId,
},
]);
return id;
},
[],
);
/** 追加一条用户(指挥官)气泡 —— ask() 时先回显用户输入。 */
const appendUserDialogue = useCallback((text: string) => {
const id = Date.now() + Math.random();
setDialogues((prev) => [
...prev,
{
id,
sender: "指挥官(追问)",
avatarType: "human",
content: text,
time: nowTime(),
confidence: "已发送",
streaming: false,
steps: [],
},
]);
}, []);
const appendToDialogue = useCallback((id: number, deltaText: string) => {
if (!deltaText) return;
setDialogues((prev) =>
prev.map((d) =>
d.id === id ? { ...d, content: (d.content ?? "") + deltaText } : d,
),
);
}, []);
const setDialogueConfidence = useCallback((id: number, confidence: string) => {
setDialogues((prev) =>
prev.map((d) => (d.id === id ? { ...d, confidence } : d)),
);
}, []);
/** 改某气泡的发送方名称(意图分类期间先建中性「调度中」气泡,定了意图再改成对应智能体名复用)。 */
const relabelDialogue = useCallback((id: number, sender: string) => {
setDialogues((prev) => prev.map((d) => (d.id === id ? { ...d, sender } : d)));
}, []);
/**
* 复用一条旧气泡承接新一轮(改名 + 清空内容/步骤 + 重新转圈)。流程图抽取被新一轮抢占时
* 复用上一轮那条气泡,避免遗留一张卡死的「调度中」卡片。
*/
const resetDialogueForReuse = useCallback((id: number, sender: string) => {
setDialogues((prev) =>
prev.map((d) =>
d.id === id
? { ...d, sender, content: "", steps: [], streaming: true, confidence: "调度中", time: nowTime() }
: d,
),
);
}, []);
/** 移除一条气泡(流程图抽取抢占时清掉被取代的那张孤儿「调度中」卡片)。 */
const removeDialogue = useCallback((id: number) => {
setDialogues((prev) => prev.filter((d) => d.id !== id));
}, []);
const appendStepIfNew = useCallback(
(
bubbleId: number,
label: string,
key: string,
args?: Record<string, unknown>,
) => {
setDialogues((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) {
if (args && args !== last.args) {
const next = [...steps];
next[next.length - 1] = { ...last, args };
return { ...d, steps: next };
}
return d;
}
return {
...d,
steps: [
...steps,
{ id: `${bubbleId}-${steps.length}`, key, label, ts: nowTime(), args },
],
};
}),
);
},
[],
);
const finalizeDialogue = useCallback(
(id: number, finalContent: string, confidence?: string) => {
setDialogues((prev) =>
prev.map((d) => {
if (d.id !== id) return d;
const next =
finalContent && finalContent.trim().length > 0
? finalContent
: d.content || "(已写入报告文件,无额外正文)";
return {
...d,
content: next,
confidence: confidence ?? d.confidence,
streaming: false,
};
}),
);
},
[],
);
// ── stub 写入(幂等,按 (toolName, path) 去重,后续帧覆盖 args)—— 仅 write_file ──
const upsertStub = useCallback(
(threadId: string, toolName: string, args: Record<string, unknown>) => {
const path = typeof args.path === "string" ? args.path : "";
const current = stubRef.current[threadId] ?? [];
const base: StubAiMessage = current[0] ?? {
id: `roundtable-summary-stub-${threadId}`,
type: "ai",
content: "",
tool_calls: [],
};
const idx = base.tool_calls.findIndex(
(tc) =>
tc.name === toolName &&
String((tc.args as { path?: unknown; })?.path ?? "") === path,
);
const toolCallId =
idx >= 0 ? base.tool_calls[idx].id : `tc-${threadId}-${base.tool_calls.length}`;
// 防抹空:重写早期帧 content 可能为空,保留旧非空内容直到吐出新非空内容。
let storeArgs = args;
if (idx >= 0) {
const newContent = typeof args.content === "string" ? args.content : "";
const prevContent =
typeof (base.tool_calls[idx].args as { content?: unknown; })?.content === "string"
? ((base.tool_calls[idx].args as { content?: string; }).content as string)
: "";
if (!newContent.trim() && prevContent.trim()) {
storeArgs = { ...args, content: prevContent };
}
}
const nextTc = { id: toolCallId, name: toolName, args: storeArgs };
const nextToolCalls =
idx >= 0
? base.tool_calls.map((tc, i) => (i === idx ? nextTc : tc))
: [...base.tool_calls, nextTc];
const nextMsg: StubAiMessage = { ...base, tool_calls: nextToolCalls };
const nextMap = { ...stubRef.current, [threadId]: [nextMsg] };
stubRef.current = nextMap;
setSeatStubMessages(nextMap);
// 记住当前报告全文(用于续问写底 / 存草稿)。
if (typeof storeArgs.content === "string" && storeArgs.content.trim()) {
currentMdRef.current = storeArgs.content;
}
return { messageId: nextMsg.id, toolCallId };
},
[],
);
const buildWriteFileVirtualUrl = useCallback(
(path: string, messageId: string, toolCallId: string) =>
new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
).toString(),
[],
);
const openArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
lastAutoOpenedUrlRef.current = virtualUrl;
setActiveArtifactThreadId(threadId);
selectArtifact(virtualUrl, false);
setArtifactsOpen(true);
},
[selectArtifact, setArtifactsOpen],
);
// 预览开关(2026-06-13,hook 提升页面层后并行流共用全局沙箱):只有用户当前所在
// 视图的流允许**自动**打开沙箱,否则总结/绘制并行时互相抢预览(选中产物与当前视图
// 线程对不上 → 空沙箱)。显式用户点击(openArtifactFromStep 等)不受限。
const previewEnabledRef = useRef(false);
const lastAutoThreadRef = useRef<string | null>(null);
const setPreviewEnabled = useCallback(
(enabled: boolean) => {
previewEnabledRef.current = enabled;
// 进入视图接管:**仅当沙箱面板已打开**时把选中产物换成本流的(修串台);
// 面板关着保持关闭(尊重"进第三步默认关沙箱")。
if (
enabled &&
lastAutoOpenedUrlRef.current &&
lastAutoThreadRef.current &&
artifactsOpenRef.current
) {
openArtifact(lastAutoThreadRef.current, lastAutoOpenedUrlRef.current);
}
},
[openArtifact],
);
const autoOpenArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
lastAutoThreadRef.current = threadId;
if (!previewEnabledRef.current) {
// 视图不在前台:只记录,不抢沙箱(回到视图时 setPreviewEnabled 再接管)。
lastAutoOpenedUrlRef.current = virtualUrl;
return;
}
if (lastAutoOpenedUrlRef.current === virtualUrl) return;
openArtifact(threadId, virtualUrl);
},
[openArtifact],
);
const findStubSource = useCallback(
(
rawPath: string,
): { threadId: string; messageId: string; toolCallId: string; } | null => {
const target = rawPath.trim();
if (!target) return null;
const targetBase = target.split("/").pop() ?? target;
let basenameMatch:
| { threadId: string; messageId: string; toolCallId: string; }
| null = null;
for (const [threadId, msgs] of Object.entries(stubRef.current)) {
const msg = msgs?.[0];
if (!msg) continue;
for (const tc of msg.tool_calls) {
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
const p =
typeof (tc.args as { path?: unknown; })?.path === "string"
? ((tc.args as { path?: string; }).path as string)
: "";
if (!p) continue;
if (p === target) return { threadId, messageId: msg.id, toolCallId: tc.id };
if (!basenameMatch && (p.split("/").pop() ?? p) === targetBase) {
basenameMatch = { threadId, messageId: msg.id, toolCallId: tc.id };
}
}
}
return basenameMatch;
},
[],
);
// 流式期间:phase==="tool_calling" 且 write_file 有可读 path → 写 stub + 自动开沙箱。
const handleArtifactToolPhase = useCallback(
(
streamPhase: StreamPhase,
toolName: string | undefined,
args: Record<string, unknown> | undefined,
threadId: string,
) => {
if (streamPhase !== "tool_calling") return;
if (toolName !== "write_file") return; // str_replace 无 content,不驱动预览
const path = args?.path;
if (typeof path !== "string" || !path || !args) return;
const basename = path.split("/").pop() ?? path;
if (!basename.includes(".")) return; // 半截路径(无扩展名)忽略
const { messageId, toolCallId } = upsertStub(threadId, toolName, args);
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
autoOpenArtifact(threadId, virtualUrl);
},
[upsertStub, buildWriteFileVirtualUrl, autoOpenArtifact],
);
const handleUpstreamFrame = useCallback(
(frame: unknown, threadId: string) => {
if (!Array.isArray(frame) || frame.length === 0) return;
const chunk = frame[0] as Record<string, unknown> | undefined;
const toolCalls = chunk?.tool_calls;
if (!Array.isArray(toolCalls)) return;
for (const tc of toolCalls) {
if (!tc || typeof tc !== "object") continue;
const o = tc as Record<string, unknown>;
const name = typeof o.name === "string" ? o.name : "";
if (name !== "write_file") continue;
const a = o.args;
if (!a || typeof a !== "object") continue;
const p = (a as Record<string, unknown>).path;
if (typeof p !== "string") continue;
if (!(p.split("/").pop() ?? p).includes(".")) continue;
upsertStub(threadId, name, a as Record<string, unknown>);
}
},
[upsertStub],
);
const openArtifactPreview = useCallback(
(threadId: string, path: string) => {
const normalized = path.trim();
if (!normalized) return;
const source = findStubSource(normalized);
if (source) {
const virtualUrl = buildWriteFileVirtualUrl(
normalized,
source.messageId,
source.toolCallId,
);
openArtifact(source.threadId, virtualUrl);
return;
}
if (!threadId) return;
setActiveArtifactThreadId(threadId);
setArtifacts([normalized]);
selectArtifact(normalized, false);
setArtifactsOpen(true);
},
[
findStubSource,
buildWriteFileVirtualUrl,
openArtifact,
setArtifacts,
selectArtifact,
setArtifactsOpen,
],
);
const openArtifactFromStep = useCallback(
(
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => {
const threadId = dialogue.agentThreadId;
if (!threadId) return;
const [, toolName] = step.key.split(":");
const path = step.args?.path;
if (typeof path !== "string" || !path) return;
if (toolName === "write_file") {
const { messageId, toolCallId } = upsertStub(threadId, toolName, step.args!);
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
openArtifact(threadId, virtualUrl);
} else if (toolName === "str_replace") {
openArtifactPreview(threadId, path);
}
},
[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():增量改后文件可能等长/更短,长度守卫会挡掉改动,故强制以真实文件为准。
const reconcileStubFromRealFile = useCallback(
async (
threadId: string,
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)
: outputPath;
const stubContent =
typeof (writeTc?.args as { content?: unknown; })?.content === "string"
? ((writeTc!.args as { content?: string; }).content as string)
: "";
try {
const list = await listSessionArtifacts(threadId, { signal: opts?.signal });
const mdFile = list.files.find((f) => /\.md$/i.test(f.name)) ?? null;
if (!mdFile) return stubContent;
const res = await apiFetch(buildArtifactDownloadUrl(threadId, mdFile.path), {
signal: opts?.signal,
});
if (!res.ok) return stubContent;
const real = await res.text();
if (real.trim() && (opts?.force || real.length > stubContent.length)) {
upsertStub(threadId, "write_file", { path: stubPath, content: real });
return real;
}
} catch {
/* 拉取失败退回 stub 内容 */
}
return stubContent;
},
[upsertStub],
);
const abort = useCallback(() => {
aborterRef.current?.abort();
aborterRef.current = null;
runningRef.current = false;
}, []);
/** 把仍在流式的气泡冻结(停转圈),并打上状态标签(打断 / 暂停时用)。 */
const freezeStreaming = useCallback((note: string) => {
setDialogues((prev) =>
prev.map((d) => (d.streaming ? { ...d, streaming: false, confidence: note } : d)),
);
}, []);
/**
* 把当前报告 md 种进某线程 stub(续问时给 reconcile/预览一个锚点)。
* `open`=false 时只种不开沙箱——流程图轮用户要看的是画布,不要弹 md 预览抢占。
*/
const seedStubWithCurrentMd = useCallback(
(threadId: string, opts?: { open?: boolean; }) => {
const md = currentMdRef.current;
if (!md.trim()) return;
const { messageId, toolCallId } = upsertStub(threadId, "write_file", {
path: outputPath,
content: md,
});
if (opts?.open === false || !previewEnabledRef.current) return;
const virtualUrl = buildWriteFileVirtualUrl(outputPath, messageId, toolCallId);
openArtifact(threadId, virtualUrl);
},
[upsertStub, buildWriteFileVirtualUrl, openArtifact],
);
return {
dialogues,
setDialogues,
seatStubMessages,
setSeatStubMessages,
activeArtifactThreadId,
setActiveArtifactThreadId,
stubRef,
runningRef,
aborterRef,
liveThreadRef,
currentMdRef,
flowActiveRef,
lastAutoOpenedUrlRef,
lastAutoThreadRef,
appendStreamingDialogue,
appendUserDialogue,
appendToDialogue,
setDialogueConfidence,
relabelDialogue,
resetDialogueForReuse,
removeDialogue,
appendStepIfNew,
finalizeDialogue,
upsertStub,
buildWriteFileVirtualUrl,
openArtifact,
setPreviewEnabled,
autoOpenArtifact,
findStubSource,
handleArtifactToolPhase,
handleUpstreamFrame,
openArtifactPreview,
openArtifactFromStep,
openRealArtifactFallback,
reconcileStubFromRealFile,
abort,
freezeStreaming,
seedStubWithCurrentMd,
};
}
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
asSubagentTaskEvent,
buildChainLeaderTask,
extractFrameText,
pickChainTask,
takeOnce,
} from "./step2-orchestration-helpers";
test("takeOnce returns the value and clears the ref", () => {
const ref = { current: ["file-a"] as string[] | null };
assert.deepEqual(takeOnce(ref), ["file-a"]);
assert.equal(ref.current, null);
assert.equal(takeOnce(ref), undefined);
});
test("asSubagentTaskEvent accepts bare and namespaced task frames", () => {
assert.deepEqual(
asSubagentTaskEvent({ type: "task_started", task_id: "t-1", message: "go" }),
{
type: "task_started",
task_id: "t-1",
message: "go",
result: undefined,
error: undefined,
description: undefined,
},
);
assert.equal(
asSubagentTaskEvent(["subgraph", { type: "task_completed", task_id: "t-2" }])
?.task_id,
"t-2",
);
assert.equal(asSubagentTaskEvent({ type: "message", task_id: "t-3" }), null);
});
test("extractFrameText joins strings and text blocks while ignoring other blocks", () => {
assert.equal(
extractFrameText(["A", { type: "text", text: "B" }, { type: "image", url: "x" }]),
"AB",
);
assert.equal(extractFrameText({ text: "not-an-array" }), "");
});
test("buildChainLeaderTask pins the correct seat and position", () => {
const first = buildChainLeaderTask({
seedMessage: "任务背景",
position: 0,
total: 3,
seatAgentId: "agent-a",
seatName: "情报席",
isFirst: true,
});
assert.match(first, /任务背景/);
assert.match(first, /第 1\/3 个席位/);
assert.match(first, /agent_name: agent-a/);
const later = buildChainLeaderTask({
seedMessage: "不应再次出现",
position: 1,
total: 3,
seatAgentId: "agent-b",
seatName: "研判席",
isFirst: false,
});
assert.match(later, /第 2\/3 个席位/);
assert.doesNotMatch(later, /不应再次出现/);
});
test("pickChainTask prefers exact dispatch then corrects a mismatched id", () => {
assert.equal(
pickChainTask(
[
["other", "其它任务"],
["AGENT-A", "目标任务"],
],
"",
"agent-a",
"情报席",
),
"目标任务",
);
assert.equal(
pickChainTask([["wrong", "仍使用这条派活"]], "", "agent-a", "情报席"),
"仍使用这条派活",
);
});
test("pickChainTask falls back to leader content or the seat template", () => {
assert.match(pickChainTask([], " 总控说明 ", "agent-a", "情报席"), /总控说明/);
assert.match(pickChainTask([], "", "agent-a", "情报席"), /情报席/);
});
@@ -0,0 +1,110 @@
import type { LeaderDispatch } from "@/roundtable-planning/api/multi-agent";
export interface SubagentTaskEvent {
type: string;
task_id: string;
message?: unknown;
result?: unknown;
error?: unknown;
description?: unknown;
}
/** Reads a consume-once ref and clears it before returning the previous value. */
export function takeOnce<T>(ref: { current: T | null }): T | undefined {
const value = ref.current ?? undefined;
ref.current = null;
return value;
}
/** Normalizes bare and namespaced subagent task events from LangGraph custom frames. */
export function asSubagentTaskEvent(frame: unknown): SubagentTaskEvent | null {
const candidate =
frame && typeof frame === "object" && !Array.isArray(frame)
? (frame as Record<string, unknown>)
: Array.isArray(frame) &&
frame.length >= 2 &&
frame[1] &&
typeof frame[1] === "object"
? (frame[1] as Record<string, unknown>)
: null;
if (!candidate) return null;
const type = typeof candidate.type === "string" ? candidate.type : "";
const taskId = typeof candidate.task_id === "string" ? candidate.task_id : "";
if (!type.startsWith("task_") || !taskId) return null;
return {
type,
task_id: taskId,
message: candidate.message,
result: candidate.result,
error: candidate.error,
description: candidate.description,
};
}
/** Extracts plain text from string or structured LangGraph content blocks. */
export function extractFrameText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
let output = "";
for (const item of content) {
if (typeof item === "string") {
output += item;
} else if (item && typeof item === "object") {
const block = item as Record<string, unknown>;
if (typeof block.text === "string") output += block.text;
}
}
return output;
}
/** Builds the leader instruction for one position in a strictly ordered business chain. */
export function buildChainLeaderTask(args: {
seedMessage: string;
position: number;
total: number;
seatAgentId: string;
seatName: string;
isFirst: boolean;
}): string {
const { seedMessage, position, total, seatAgentId, seatName, isFirst } = args;
const ordinal = `${position + 1}/${total}`;
if (isFirst) {
return [
"这是一次【按业务链条顺序】进行的多智能体圆桌研讨。任务背景如下:",
seedMessage,
"",
`本次研讨严格按预设链条顺序进行,共 ${total} 个席位,现在是第 ${ordinal} 个席位。`,
`请你作为总控,通过 agent_orchestration 技能**仅**向智能体「${seatName}」(agent_name: ${seatAgentId})派活,结合任务背景为它分配聚焦其职责的本轮任务。`,
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。",
].join("\n");
}
return [
`链条研讨继续,现在轮到第 ${ordinal} 个席位。`,
`请你作为总控,结合此前各席位已完成的交付,通过 agent_orchestration 技能**仅**向智能体「${seatName}」(agent_name: ${seatAgentId})派活,为它分配承接前序结论、聚焦其职责的本轮任务。`,
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。",
].join("\n");
}
/** Selects the task for the current chain seat while correcting a misaddressed dispatch. */
export function pickChainTask(
dispatched: LeaderDispatch[],
leaderContent: string,
seatAgentId: string,
seatName: string,
): string {
const wanted = seatAgentId.toLowerCase();
const exact = dispatched.find(([name]) => name.toLowerCase() === wanted);
if (exact && exact[1]?.trim()) return exact[1];
const any = dispatched.find(([, task]) => task?.trim());
if (any && any[1]?.trim()) return any[1];
if (leaderContent?.trim()) {
return `请你作为「${seatName}」,基于以下总控说明与此前各席位的交付,从你的专业职责出发给出你的分析与交付:\n${leaderContent.trim()}`;
}
return `请你作为「${seatName}」,基于任务背景与此前各席位的交付,从你的专业职责出发给出你的分析与交付。`;
}
@@ -0,0 +1,218 @@
import type { StreamPhase } from "@/roundtable-planning/api/multi-agent";
import type {
ReportIntent,
ReportSeatDelivery,
} from "@/roundtable-planning/hooks/useStep3Report";
/** 把研讨原料拼成一段(首轮 prompt 和「恢复后无线程续问」都用得到)。 */
function buildMaterials(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
/** xdfx:本次「行动名称」,作为材料抬头喂进总结(报告围绕该行动展开)。 */
actionName?: string | null;
}): string {
const { intentReady, seatDeliveries, consensusText, coordinatorPrompt, actionName } = opts;
const parts: string[] = [];
const an = actionName?.trim();
if (an) {
parts.push("# 本次行动");
parts.push(an);
parts.push("");
}
const cp = coordinatorPrompt?.trim();
if (cp) {
parts.push("# 业务链总控编排提示(最高优先级,按它的关注点/结构/口径来组织报告)");
parts.push(cp);
parts.push("");
// 编排提示里常藏着用户对"最终总结"的明确要求——让模型先提取再动笔,
// 否则结构化输出/重点席位这类要求容易被默认章节结构盖掉。
parts.push(
"> 动笔前请先从上面这段编排提示里提取用户对最终总结报告的明确要求(输出结构/格式、重点总结哪些席位、口径风格、详略取舍),并逐条满足:" +
"用户指定了输出结构(章节、表格、编号清单、固定模板等)就完全按用户结构输出、放弃默认章节结构;" +
"点名了重点席位就把它们前置并显著加大篇幅,未点名席位一两句带过;要求了详略取舍就照办,未被要求的章节可直接省略。",
);
parts.push("");
}
if (intentReady?.objective) {
parts.push("# 一、任务意图");
parts.push(`目标:${intentReady.objective}`);
if (intentReady.constraints?.length) {
parts.push("约束:");
for (const c of intentReady.constraints) parts.push(`- ${c}`);
}
if (intentReady.assumptions?.length) {
parts.push("关键假设:");
for (const a of intentReady.assumptions) parts.push(`- ${a}`);
}
parts.push("");
}
if (seatDeliveries.length) {
parts.push("# 二、各席位最终交付");
seatDeliveries.forEach((s, i) => {
parts.push(`## ${i + 1}. ${s.sender}`);
parts.push(s.content?.trim() || "(无内容)");
parts.push("");
});
}
if (consensusText?.trim()) {
parts.push("# 三、总控最终共识结论");
parts.push(consensusText.trim());
parts.push("");
}
return parts.join("\n");
}
/** 首轮总结 prompt。`flowMode`=编排提示里明确要求了结构化输出/流程图 → 跳过 md 文件,直出流程图。 */
export function buildSummaryPrompt(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
actionName?: string | null;
flowMode?: boolean;
}): string {
const parts: string[] = [];
if (opts.flowMode) {
parts.push(
"请基于以下圆桌多智能体研讨的成果,产出一份**结构化的方案总结**。" +
"用户已明确要求结构化输出/流程图,因此本轮:" +
"①**不要**调用 write_file,**不要**生成任何 .md 文件;" +
"②把精炼的文字版总结报告直接写在对话回复里(供用户直接阅读,结构遵循编排提示的要求;" +
"编排提示未指定结构时,默认对各席位交付内容逐席位格式化输出:席位名→核心结论→关键交付要点→风险/建议);" +
"③在回复最末尾**必须**输出一个完整的 ```flow-json 代码块(前端据此实时绘制可交互流程图)。",
);
} else {
parts.push(
"请基于以下圆桌多智能体研讨的成果,撰写一份以文字叙述为主线的 Markdown「方案总结报告」," +
"用 write_file 工具写入 /mnt/user-data/outputs/方案总结报告.md," +
"并把报告完整正文同时输出到对话回复里供用户直接阅读。",
);
}
parts.push("");
parts.push(buildMaterials(opts));
// 意图对齐 + 缺口核对 + 防幻觉护栏(弱席位场景的质量兜底)——两种模式通用,与后端 build_summary_prompt 同义。
parts.push("");
parts.push(
"【动笔前必做:意图对齐与缺口核对】各席位可能由能力较弱的模型产出,未必扣住用户意图、未必充分。" +
"你是这份总结的质量兜底,不能只把各席位交付简单拼接。请先把上面的「任务意图」(目标/约束/关键假设)拆成" +
"用户核心诉求清单(编排提示对最终产出的要求也并入),逐条核对各席位交付+共识是否覆盖、够不够、有无跑题或矛盾;" +
"再据此组织:① 能从已有原料重组/推理补上的→直接补全讲透;② 缺真实信息补不了的→绝不凭空编。" +
"**写进 .md 报告文件的必须是一份完整、干净、可直接使用、无需用户修改的报告——不要在报告文件里写「待补充/不足/⚠️」之类提示或占位**。" +
"**但在对话回复正文、报告之后,必须追加一段简短「核对说明」**(只在对话里、**不写进 .md 文件**):逐条说明产出是否覆盖了用户意图、哪些充分/哪些不足/有哪些可补充(可在对话里让我补全);" +
"**即便都已覆盖、无明显缺口,也要明确写一句「已核对:各诉求均已覆盖,无明显缺口」,这段核对说明任何时候都不要省略。**" +
"最终产出要明显比各席位交付的简单拼接更扣用户意图。",
);
if (opts.flowMode) {
parts.push(
"请严格遵循你的职责设定:全程简体中文。" +
"flow-json 按你职责设定的「结构化流程数据」契约输出:节点按流程先后排列、" +
"每个节点的 from 只引用前面已出现的节点、6–20 个节点为宜、类型用 start/end/stage/task/decision/risk/deliverable/role。" +
"正文文字报告以「业务链总控编排提示」的要求为骨架,简明、重点突出。",
);
} else {
parts.push(
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、全程简体中文。" +
"报告结构:默认用叙事型结构(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线);" +
"但若上面的「业务链总控编排提示」对最终总结提出了要求(输出结构/格式、重点席位、口径、详略)," +
"那些要求优先于默认结构——用户指定的结构可以完全替换默认章节。" +
"若用户要求了结构化输出/流程图,按你职责设定在对话回复末尾追加 ```flow-json 代码块(前端据此实时绘制流程图)。",
);
}
return parts.join("\n");
}
/**
* 普通报告语境下的意图引导指令(每轮续问都附上):
* 按语义理解修改/提问/结构化意图,太模糊就在正文反问,不要乱改。
*/
export const SEMANTIC_ASK_INSTRUCTION =
"(系统指令:请按语义理解用户这条消息的意图、不要求任何关键词:" +
"修改类意见(哪怕表述笼统)就在原报告基础上增量修改并用 write_file 整文件重写;" +
"要求结构化/格式化/可视化(或语义等价说法)就进入流程图模式——不写 .md 文件," +
"正文默认对各席位交付内容逐席位格式化输出,末尾输出完整 ```flow-json 块;" +
"只是提问就只用文字回答、不改文件;" +
"若意图太模糊、无法判断要改什么,不要修改任何产物,在正文向用户提一个简短的澄清问题(给出可选方向)。)";
/** 恢复草稿后线程已不在内存(无 live thread)时,新建线程续问用的 prompt:带上旧报告做底。 */
export function buildResumeAskPrompt(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
previousMd: string;
userMessage: string;
/** 按本轮语境选好的意图引导指令(流程图显式 / 流程图上下文 / 普通语义),追加在消息后。 */
askInstruction?: string;
}): string {
const { previousMd, userMessage, askInstruction, ...materials } = opts;
const parts: string[] = [];
parts.push(
"这是一次对已有「方案总结报告」的继续对话。下面先给出研讨原料与你之前已生成的报告," +
"然后是用户的新消息——请自行判断:若是修改意见,就在旧报告基础上增量修改并用 write_file " +
"整文件重写 /mnt/user-data/outputs/方案总结报告.md,再把更新后的报告正文输出到对话;" +
"若只是提问,就只用文字回答、不要改写文件。",
);
parts.push("");
parts.push(buildMaterials(materials));
if (previousMd.trim()) {
parts.push("# 你之前已生成的报告(方案总结报告.md 当前内容)");
parts.push("```markdown");
parts.push(previousMd.trim());
parts.push("```");
parts.push("");
}
parts.push("# 用户的新消息");
parts.push(userMessage.trim());
if (askInstruction) {
parts.push("");
parts.push(askInstruction);
}
return parts.join("\n");
}
/** 把 SSE 阶段映射成中文状态标签。 */
export function summaryPhaseLabel(phase: StreamPhase, toolName?: string): string {
switch (phase) {
case "connecting":
return "调度中";
case "connected":
return "已连接,等待响应";
case "tool_calling":
if (toolName === "write_file" || toolName === "str_replace")
return "正在撰写报告";
return toolName ? `调用工具:${toolName}` : "调用工具";
case "tool_result":
return "处理工具结果";
case "streaming":
return "正在输出";
default:
return "正在输出…";
}
}
/** 入库轮的工具阶段 → 中文步骤标签(让前端步骤条体现「读技能说明 → 执行入库」真实过程)。 */
export function ingestPhaseLabel(phase: StreamPhase, toolName?: string): string {
switch (phase) {
case "connecting":
return "调度入库技能";
case "connected":
return "已连接,等待响应";
case "tool_calling":
if (toolName === "read_file") return "读取入库技能说明(SKILL.md)";
if (toolName === "bash" || toolName === "write_file") return "调用入库技能执行入库";
return toolName ? `调用工具:${toolName}` : "调用入库技能";
case "tool_result":
return "处理入库结果";
case "streaming":
return "汇报入库结果";
default:
return "入库中…";
}
}
File diff suppressed because it is too large. Load diff