This commit is contained in:
fengxiangdebaozi committed 2026-06-03 16:22:51 +08:00
commit 79d87ad661
29 files changed
+3220 -215

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "md-viewer-frontend",
"runtimeExecutable": "npx",
"runtimeArgs": ["ng", "serve", "--port", "4200"],
"port": 4200,
"cwd": "F:\\angular\\md-viewer\\frontend"
}
]
}
+73 -7
View File
@@ -194,20 +194,80 @@ GET /api/public/embed/sessions/{session_id}/status
| `latest_run_status` | 该线程最近一次 run 的状态:`success` / `error` / … |
| `thread_id` | 解析出的 DeerFlow 线程 id(父页面可选用,非必须) |
### 4.3 按 thread_id 查询(可选)
### 4.3 获取生成结果(父页面使用)
问答结束后(建议先确认 `finished === true`),拉取本次模型输出:
```http
GET /api/public/embed/sessions/{session_id}/result
```
**尚未登记:**
```json
{
"session_id": "parent-generated-uuid",
"registered": false,
"thread_id": null,
"finished": false,
"thread_status": "pending",
"has_active_run": false,
"latest_run_status": null,
"messages": [],
"generated_text": "",
"generated_files": []
}
```
**已登记且已有内容:**
```json
{
"session_id": "parent-generated-uuid",
"registered": true,
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
"finished": true,
"thread_status": "idle",
"has_active_run": false,
"latest_run_status": "success",
"messages": [
{ "type": "human", "content": "请总结要点", "id": "..." },
{ "type": "ai", "content": "根据分析,要点如下:...", "id": "..." }
],
"generated_text": "根据分析,要点如下:...",
"generated_files": [
"/mnt/user-data/outputs/summary.md",
"/mnt/user-data/outputs/chart.png"
]
}
```
#### 结果字段说明
| 字段 | 含义 |
|------|------|
| `messages` | 完整消息列表(与 checkpoint 中序列化结构一致,含 human / ai / tool 等) |
| `generated_text` | 所有 `ai` / `assistant` 消息正文按顺序拼接(段落间空一行) |
| `generated_files` | 本次会话产物的虚拟路径列表(与 UI 产物栏 `artifacts` 一致) |
未结束时也可调用,会返回当前已有内容;`finished` 仍为 false 表示可能还会继续生成。
### 4.4 按 thread_id 查询(可选)
调试或父页面已持有 thread_id 时:
```http
GET /api/public/embed/threads/{thread_id}/status
GET /api/public/embed/threads/{thread_id}/result
```
响应不含 `session_id` / `registered`,其余与上表类似。
状态接口响应不含 `messages` / `generated_text` / `generated_files`;结果接口字段与 §4.3 相同(无 `session_id` / `registered`)。
### 4.4 前端封装
### 4.5 前端封装
```ts
import {
getEmbedSessionResult,
getEmbedSessionStatus,
registerEmbedSession,
} from "@/core/embed/status-api";
@@ -215,7 +275,8 @@ import {
// 父页面(或你们自己的脚本)
const status = await getEmbedSessionStatus(sessionId);
if (status.registered && status.finished) {
// 问答结束
const result = await getEmbedSessionResult(sessionId);
console.log(result.generated_text, result.generated_files);
}
// 仅 iframe 内需要
@@ -270,10 +331,14 @@ async function pollUntilFinished(sessionId) {
const timer = setInterval(async () => {
try {
const result = await pollUntilFinished(sessionId);
if (result.done) {
const poll = await pollUntilFinished(sessionId);
if (poll.done) {
clearInterval(timer);
console.log("问答已结束", result.data);
const output = await fetch(
`${API}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/result`,
{ credentials: "omit" },
).then((r) => r.json());
console.log("问答已结束", output.generated_text, output.generated_files);
}
} catch (e) {
console.error(e);
@@ -350,3 +415,4 @@ A:可以:`#/embed/chats/{thread_id}?sessionId=...`,可选再带 `message`
| 日期 | 说明 |
|------|------|
| 2026-06-02 | 初版:iframe 嵌入路由、`sessionId` 映射表、公开登记与状态 API |
| 2026-06-02 | 新增 `GET /sessions/{session_id}/result`:返回 messages、generated_text、generated_files |
+21
View File
@@ -10,6 +10,12 @@ export type EmbedSessionStatus = {
latest_run_status: string | null;
};
export type EmbedSessionResult = EmbedSessionStatus & {
messages: Record<string, unknown>[];
generated_text: string;
generated_files: string[];
};
async function parseEmbedError(response: Response): Promise<string> {
const body = await response.json().catch(() => ({}));
if (typeof body.detail === "string") {
@@ -50,3 +56,18 @@ export async function getEmbedSessionStatus(
}
return response.json() as Promise<EmbedSessionStatus>;
}
/** Fetch full messages, generated text, and artifact paths — parent uses sessionId only. */
export async function getEmbedSessionResult(
sessionId: string,
): Promise<EmbedSessionResult> {
const base = getBackendBaseURL();
const response = await fetch(
`${base}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/result`,
{ credentials: "omit" },
);
if (!response.ok) {
throw new Error(await parseEmbedError(response));
}
return response.json() as Promise<EmbedSessionResult>;
}
@@ -747,3 +747,34 @@ export function buildArtifactDownloadUrl(
const base = `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts/${segments}`;
return options.download ? `${base}?download=true` : base;
}
export interface ReportThreadResponse {
/** Always the built-in singleton id `roundtable-report`. */
agentName: string;
/** Freshly created lightweight thread to run the report agent on. */
threadId: string;
}
/**
* Create a thread for the Step 3「结果绘制」visualization agent
* (`roundtable-report`, a built-in singleton — this only spins up a cheap
* thread, it never re-creates the agent). Run it afterwards with
* {@link streamMultiAgent} (`agentType: "special"`,
* `agentName: "roundtable-report"`, `dAgentThreadId: { [agentName]: threadId }`).
*/
export async function initReportThread(
options: { signal?: AbortSignal } = {},
): Promise<ReportThreadResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/report/init`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
signal: options.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`init report thread failed (${res.status}): ${text}`);
}
const json = (await res.json()) as { agent_name: string; thread_id: string };
return { agentName: json.agent_name, threadId: json.thread_id };
}
@@ -81,6 +81,8 @@ const EXCLUDED_AGENT_IDS = new Set<string>([
"roundtable-intent",
"roundtable-recommender",
"roundtable-coordinator",
// Step 3「结果绘制」的方案可视化总结智能体,是内置功能型 agent,不应作为可选席位。
"roundtable-report",
]);
/**
@@ -11,6 +11,7 @@ import {
Loader2,
Printer,
RefreshCw,
Sparkles,
} from "lucide-react";
import {
@@ -20,6 +21,12 @@ import {
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import {
Step3ReportMode,
type ModelOption,
} from "@/roundtable-planning/components/Step3ReportMode";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
/**
* Text-ish files we render in an in-app preview modal. Other types (pdf,
@@ -55,6 +62,14 @@ interface HighFidelityReportProps {
seatDeliveries: SeatDelivery[];
/** lowercase agent name → display name (e.g. "roundtable-intelligence" → "情报收集"). */
seatNameMap: Record<string, string>;
/** Step 1 整理出的结构化意图 —— 喂给「结果绘制」的方案可视化智能体。 */
intentReady: ReportIntent | null;
/** 当前推理模型 —— 跑可视化智能体用。 */
selectedModel: string;
/** 可选模型列表 + 加载态 + 切换器 —— 结果绘制右侧的模型选择(对齐第 2 步)。 */
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
/** Resets state and navigates back to Step 1. */
onReset: () => void;
}
@@ -80,46 +95,6 @@ function formatGeneratedAt(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
const markdownComponents = {
p: ({ children }: any) => <p className="my-1.5 whitespace-pre-wrap leading-relaxed">{children}</p>,
ul: ({ children }: any) => <ul className="list-disc pl-5 my-1.5 space-y-1">{children}</ul>,
ol: ({ children }: any) => <ol className="list-decimal pl-5 my-1.5 space-y-1">{children}</ol>,
li: ({ children }: any) => <li className="leading-snug">{children}</li>,
h1: ({ children }: any) => <h1 className="text-sm font-bold mt-3 mb-1.5">{children}</h1>,
h2: ({ children }: any) => <h2 className="text-sm font-bold mt-3 mb-1.5">{children}</h2>,
h3: ({ children }: any) => <h3 className="text-xs font-bold mt-2 mb-1">{children}</h3>,
h4: ({ children }: any) => <h4 className="text-xs font-bold mt-1.5 mb-1">{children}</h4>,
strong: ({ children }: any) => <strong className="font-bold text-slate-900">{children}</strong>,
em: ({ children }: any) => <em className="italic">{children}</em>,
code: ({ children, className }: any) => {
const isBlock = /language-/.test(className ?? "");
if (isBlock) {
return (
<pre className="bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto text-[11px] font-mono">
<code>{children}</code>
</pre>
);
}
return (
<code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[11px] font-mono">{children}</code>
);
},
blockquote: ({ children }: any) => (
<blockquote className="border-l-2 border-slate-300 pl-2 my-1 text-slate-600 italic">{children}</blockquote>
),
a: ({ children, href }: any) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
{children}
</a>
),
table: ({ children }: any) => <table className="my-2 border-collapse text-[11px]">{children}</table>,
th: ({ children }: any) => (
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">{children}</th>
),
td: ({ children }: any) => <td className="border border-slate-200 px-1.5 py-0.5">{children}</td>,
hr: () => <hr className="my-2 border-slate-200" />,
};
export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
threadIds,
coordinatorName,
@@ -127,8 +102,15 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
hasConsensus,
seatDeliveries,
seatNameMap,
intentReady,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
onReset,
}) => {
// 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 HTML。
const [drawMode, setDrawMode] = useState(false);
// Build the per-seat list (excludes the coordinator). useMemo so the file
// fetch effect doesn't re-fire on every parent render.
const seatThreadEntries = useMemo(() => {
@@ -253,6 +235,25 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
[],
);
if (drawMode) {
return (
<Step3ReportMode
threadIds={threadIds}
coordinatorName={coordinatorName}
consensusText={consensusText}
hasConsensus={hasConsensus}
seatDeliveries={seatDeliveries}
seatNameMap={seatNameMap}
intentReady={intentReady}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
onBack={() => setDrawMode(false)}
/>
);
}
return (
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto text-slate-800">
{/* Header */}
@@ -275,6 +276,40 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
</div>
<div className="flex items-center gap-2">
<select
id="step3-report-model-select-entry"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: "切换结果绘制使用的推理模型"
}
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[10.5px] 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>
<button
type="button"
onClick={() => setDrawMode(true)}
title="把研讨成果交给方案总结智能体,绘制带图表的可视化看板"
className="bg-primary text-white hover:bg-[var(--brand-900)] text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer shadow-sm shadow-primary/10"
>
<Sparkles className="w-3.5 h-3.5" />
结果绘制
</button>
<button
type="button"
onClick={() => {
@@ -368,8 +403,8 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
)}
</section>
{/* Section 3: Generated files per seat —— 暂时隐藏(无真实产出文件链路) */}
<section className="hidden bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
{/* Section 3: Generated files per seat —— 各席位产出的 md 文件,点击预览弹窗查看内容 */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<Download className="w-4 h-4 text-primary" />
生成的报告文件
@@ -407,7 +407,7 @@ export function Step2Panel(props: Step2PanelProps) {
*
* 「插话干预」气泡有特殊红底,通过 containerClassName 传给 MessageBubble。
*/
interface Step2MessageProps {
export interface Step2MessageProps {
msg: Step2Dialogue;
/** 把派活 chip 的 agent id 解析成中文席位名。 */
resolveAgentName?: (idOrName: string) => string;
@@ -418,7 +418,7 @@ interface Step2MessageProps {
onFileCardClick?: (path: string) => void;
}
function Step2Message({
export function Step2Message({
msg,
resolveAgentName,
onArtifactStepClick,
@@ -8,7 +8,9 @@
* 拉取 / 文本类预览 / window.open 下载 / 「重新起草」按钮等。
*/
import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport";
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
export interface Step3PanelProps {
@@ -21,6 +23,13 @@ export interface Step3PanelProps {
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
/** Step 1 结构化意图 + 当前模型 —— 透传给「结果绘制」的方案可视化智能体。 */
intentReady: ReportIntent | null;
selectedModel: string;
/** 可选模型列表 + 加载态 + 切换器 —— 透传给「结果绘制」的模型选择。 */
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
onReset: () => void;
}
@@ -32,6 +41,11 @@ export function Step3Panel(props: Step3PanelProps) {
hasConsensus,
step2RoundtableDialogues,
agentNameToRole,
intentReady,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
onReset,
} = props;
@@ -55,6 +69,11 @@ export function Step3Panel(props: Step3PanelProps) {
hasConsensus={hasConsensus}
seatDeliveries={seatDeliveries}
seatNameMap={seatNameMap}
intentReady={intentReady}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
onReset={onReset}
/>
);
@@ -0,0 +1,682 @@
/**
* Step 3「结果绘制」模式。
*
* 布局对齐第 2 步:
* - 左栏(~300px,第 2 步白卡风格):把原本的最终方案内容「收起」成三张源卡
* (总控结论 / 各席位交付 / 各席位生成文件),点击才弹窗展开。
* - 右栏:复用 `Step2SandboxLayout`(与主聊天同一套沙箱)。左 60% 显示方案总结
* 智能体的流式绘制进度;右 40% 沙箱在它写 HTML 时显示代码、写完自动切 HTML 预览。
*
* 进入即自动开跑(`useStep3Report.start`),右上角「重新绘制」可重跑。
*/
import { useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
ArrowLeft,
CheckCircle,
Download,
Eye,
FileText,
ListOrdered,
Loader2,
RefreshCw,
Sparkles,
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 { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import {
useStep3Report,
type ReportIntent,
} from "@/roundtable-planning/hooks/useStep3Report";
interface SeatDelivery {
sender: string;
avatarType: string;
content: string;
}
export interface ModelOption {
name: string;
display_name?: string;
}
interface Step3ReportModeProps {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
consensusText: string;
hasConsensus: boolean;
seatDeliveries: SeatDelivery[];
seatNameMap: Record<string, string>;
intentReady: ReportIntent | null;
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
onBack: () => void;
}
type SourceModal = "consensus" | "seats" | "files" | null;
export function Step3ReportMode({
threadIds,
coordinatorName,
consensusText,
hasConsensus,
seatDeliveries,
seatNameMap,
intentReady,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
onBack,
}: Step3ReportModeProps) {
const reportSeatDeliveries = useMemo(
() => seatDeliveries.map((d) => ({ sender: d.sender, content: d.content })),
[seatDeliveries],
);
const {
phase,
error,
reportThreadId,
dialogues,
seatStubMessages,
activeArtifactThreadId,
start,
redraw,
abort,
openArtifactFromStep,
openArtifactPreview,
} = useStep3Report({
intentReady,
seatDeliveries: reportSeatDeliveries,
consensusText,
selectedModel,
});
const [modal, setModal] = useState<SourceModal>(null);
// 进入即自动开跑;卸载(含返回普通视图)中断在途请求。
// 不加"永久闸门"——否则 StrictMode 的 mount→unmount→remount 会在 cleanup 里 abort
// 掉首次 run,remount 又因闸门跳过重启,导致永远不绘制。run() 自带 runningRef 去重,
// remount 重新 start() 即可(开发期至多多建一个空线程,生产无 StrictMode 只跑一次)。
useEffect(() => {
start();
return () => abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 与第 2 步一致:编排中或有任意气泡仍在流式 → 沙箱开启 artifacts 轮询 + HTML 显示代码态。
const isStreaming =
phase === "creating" ||
phase === "streaming" ||
dialogues.some((d) => d.streaming === true);
const activeSeatMessages = activeArtifactThreadId
? seatStubMessages[activeArtifactThreadId]
: undefined;
const statusLabel =
phase === "creating"
? "正在初始化绘制线程…"
: phase === "streaming"
? "方案总结智能体绘制中…"
: phase === "done"
? "绘制完成"
: phase === "error"
? "绘制失败"
: "待绘制";
return (
<div className="flex h-full min-h-0 flex-col text-slate-800">
{/* 顶栏 */}
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3.5 flex-wrap">
<div className="flex items-center gap-2.5">
<Sparkles className="w-5 h-5 text-primary" />
<h3 className="text-sm font-black text-slate-900 tracking-tight">结果绘制 · 方案可视化</h3>
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full">
{(phase === "creating" || phase === "streaming") && (
<Loader2 className="w-3 h-3 animate-spin" />
)}
{phase === "done" && <CheckCircle className="w-3 h-3 text-emerald-600" />}
{statusLabel}
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={redraw}
disabled={phase === "creating" || phase === "streaming"}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新绘制
</button>
<button
type="button"
onClick={onBack}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<ArrowLeft className="w-3.5 h-3.5 text-slate-500" />
返回报告
</button>
</div>
</div>
{/* 主体:左源卡 + 右沙箱 */}
<div className="flex-1 min-h-0 flex gap-3 p-3">
{/* 左栏:收起的源内容 */}
<aside className="w-[280px] shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5">
<SourceCard
icon={<CheckCircle className="w-4 h-4 text-emerald-500" />}
title="总控最终结论"
hint={consensusText ? "点击查看结论全文" : "暂无结论"}
badge={hasConsensus ? "已共识" : undefined}
disabled={!consensusText}
onClick={() => setModal("consensus")}
/>
<SourceCard
icon={<FileText className="w-4 h-4 text-primary" />}
title="各席位最终交付"
hint="点击逐席位查看交付"
badge={String(seatDeliveries.length)}
disabled={seatDeliveries.length === 0}
onClick={() => setModal("seats")}
/>
<SourceCard
icon={<Download className="w-4 h-4 text-primary" />}
title="各席位生成文件"
hint="点击查看/预览产出文件"
onClick={() => setModal("files")}
/>
<div className="mt-1 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-3 text-[11px] leading-relaxed text-slate-400">
原研讨内容已收起到这里,随时点开查阅。右侧由「方案总结智能体」据此绘制可视化看板。
</div>
</aside>
{/* 右栏:智能体进度 + 沙箱(复用第 2 步) */}
<section className="flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
<Step2SandboxLayout
activeThreadId={activeArtifactThreadId ?? reportThreadId}
isStreaming={isStreaming}
seatMessages={activeSeatMessages}
>
<AgentProgressPanel
phase={phase}
dialogues={dialogues}
error={error}
onRetry={redraw}
onArtifactStepClick={openArtifactFromStep}
onArtifactPreview={openArtifactPreview}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
/>
</Step2SandboxLayout>
</section>
</div>
{/* 弹窗 */}
{modal === "consensus" && (
<ContentModal title="总控最终结论" onClose={() => setModal(null)}>
{consensusText ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText}
</ReactMarkdown>
) : (
<p className="text-xs text-slate-400 italic">暂无总控结论。</p>
)}
</ContentModal>
)}
{modal === "seats" && (
<ContentModal title={`各席位最终交付(${seatDeliveries.length})`} onClose={() => setModal(null)}>
<SeatDeliveriesView seatDeliveries={seatDeliveries} />
</ContentModal>
)}
{modal === "files" && (
<SeatFilesModal
threadIds={threadIds}
coordinatorName={coordinatorName}
seatNameMap={seatNameMap}
onClose={() => setModal(null)}
/>
)}
</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>
);
}
// ── 右栏:智能体进度面板(沙箱的 60% 区域)────────────────────────────────
function AgentProgressPanel({
phase,
dialogues,
error,
onRetry,
onArtifactStepClick,
onArtifactPreview,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
}: {
phase: ReturnType<typeof useStep3Report>["phase"];
dialogues: ReturnType<typeof useStep3Report>["dialogues"];
error: string | null;
onRetry: () => void;
onArtifactStepClick: ReturnType<typeof useStep3Report>["openArtifactFromStep"];
onArtifactPreview: ReturnType<typeof useStep3Report>["openArtifactPreview"];
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
}) {
const busy = phase === "creating" || phase === "streaming";
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
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">据各席位成果绘制可视化 HTML 看板</div>
</div>
</div>
<select
id="step3-report-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 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}
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" />
右侧沙箱将实时显示绘制代码,完成后自动切换 HTML 预览
</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 === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" />
已生成可视化方案总览,右侧可查看 / 全屏 / 下载。
</div>
)}
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
</div>
</div>
);
}
// ── 弹窗外壳 ─────────────────────────────────────────────────────────────
function ContentModal({
title,
children,
onClose,
}: {
title: string;
children: React.ReactNode;
onClose: () => void;
}) {
return (
<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 px-5 py-3.5 border-b border-slate-100 bg-slate-50">
<h3 className="text-sm font-bold text-slate-900">{title}</h3>
<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 className="flex-1 overflow-y-auto p-5">{children}</div>
</div>
</div>
);
}
// ── 各席位交付(弹窗内的折叠列表)────────────────────────────────────────
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">
<ListOrdered className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-bold text-slate-800 truncate">{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}>
{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|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,44 @@
/**
* 圆桌 Step 3 报告里 `react-markdown` 的紧凑渲染组件集(小字号、卡片内排版)。
* 抽到独立文件供 `HighFidelityReport` 与 `Step3ReportMode` 共用,避免两者互相 import
* 形成循环依赖。
*/
export const markdownComponents = {
p: ({ children }: any) => <p className="my-1.5 whitespace-pre-wrap leading-relaxed">{children}</p>,
ul: ({ children }: any) => <ul className="list-disc pl-5 my-1.5 space-y-1">{children}</ul>,
ol: ({ children }: any) => <ol className="list-decimal pl-5 my-1.5 space-y-1">{children}</ol>,
li: ({ children }: any) => <li className="leading-snug">{children}</li>,
h1: ({ children }: any) => <h1 className="text-sm font-bold mt-3 mb-1.5">{children}</h1>,
h2: ({ children }: any) => <h2 className="text-sm font-bold mt-3 mb-1.5">{children}</h2>,
h3: ({ children }: any) => <h3 className="text-xs font-bold mt-2 mb-1">{children}</h3>,
h4: ({ children }: any) => <h4 className="text-xs font-bold mt-1.5 mb-1">{children}</h4>,
strong: ({ children }: any) => <strong className="font-bold text-slate-900">{children}</strong>,
em: ({ children }: any) => <em className="italic">{children}</em>,
code: ({ children, className }: any) => {
const isBlock = /language-/.test(className ?? "");
if (isBlock) {
return (
<pre className="bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto text-[11px] font-mono">
<code>{children}</code>
</pre>
);
}
return (
<code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[11px] font-mono">{children}</code>
);
},
blockquote: ({ children }: any) => (
<blockquote className="border-l-2 border-slate-300 pl-2 my-1 text-slate-600 italic">{children}</blockquote>
),
a: ({ children, href }: any) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
{children}
</a>
),
table: ({ children }: any) => <table className="my-2 border-collapse text-[11px]">{children}</table>,
th: ({ children }: any) => (
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">{children}</th>
),
td: ({ children }: any) => <td className="border border-slate-200 px-1.5 py-0.5">{children}</td>,
hr: () => <hr className="my-2 border-slate-200" />,
};
@@ -0,0 +1,607 @@
/**
* Step 3「结果绘制」编排 hook —— 驱动内置的方案可视化总结智能体
* (`roundtable-report`) 把各席位交付 + 总控共识画成一个自包含、含多种图表的
* HTML 看板。
*
* 设计要点(**与第 2 步 useStep2Orchestration 的 special 席位机制同源、同体验**):
* - `roundtable-report` 是**内置单例**,这里只 `initReportThread()` 建一个**轻量
* 线程**(绝不重建 agent),再用现有 `streamMultiAgent`(special)跑一轮;
* - 把这一轮渲染成一条**第 2 步同款对话气泡**(`Step2Dialogue`):流式正文 +
* `tool_calling` 工具步骤(write_file/present_files…)+ 末尾文件卡,让用户在
* 报告智能体「只写文件、几乎不输出正文」时也能从步骤卡看到实时进度;
* - 流式期间把它的 `write_file` tool_call 幂等写进 stub ai message,并用
* `write-file:` 虚拟 URL 打开沙箱 —— `ArtifactFileDetail` 据此从 stub 读
* `args.content`,HTML 文件在流式中显示代码、`isStreaming` 落 false 后自动切预览;
* - 沙箱/消息渲染零自研,完全复用第 2 步的 `Step2SandboxLayout` + `Step2Message`。
*/
import { useCallback, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import {
initReportThread,
listSessionArtifacts,
streamMultiAgent,
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";
/** Step 1 整理出的结构化意图(用于喂给可视化智能体)。 */
export interface ReportIntent {
objective: string;
constraints?: string[];
assumptions?: string[];
}
/** 一个席位的最终交付(与 Step3Panel 传下来的 seatDeliveries 对齐)。 */
export interface ReportSeatDelivery {
sender: string;
content: string;
}
export type ReportPhase = "idle" | "creating" | "streaming" | "done" | "error";
interface UseStep3ReportOptions {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
selectedModel: string;
}
export interface UseStep3ReportReturn {
phase: ReportPhase;
error: string | null;
reportThreadId: string | null;
/** 第 2 步同款对话气泡列表(这里只有报告智能体一条,流式 + 工具步骤)。 */
dialogues: Step2Dialogue[];
/** 供 Step2SandboxLayout 的 seatMessages 使用:key = thread id。 */
seatStubMessages: Record<string, StubAiMessage[]>;
/** 当前沙箱预览绑定的 thread id(这里恒为报告线程)。 */
activeArtifactThreadId: string | null;
/** 启动绘制(幂等:进行中重复调用会被忽略)。 */
start: () => void;
/** 重新绘制(先中断在途请求再重跑)。 */
redraw: () => void;
/** 中断在途请求(卸载 / 切回普通视图时调用)。 */
abort: () => void;
/** 点击气泡内工具步骤时打开沙箱(与第 2 步 openArtifactFromStep 同义)。 */
openArtifactFromStep: (
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => void;
/** 点击气泡下方文件卡时打开沙箱预览。 */
openArtifactPreview: (threadId: string, path: string) => void;
}
function buildReportPrompt(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
}): string {
const { intentReady, seatDeliveries, consensusText } = opts;
const parts: string[] = [];
parts.push(
"请基于以下圆桌多智能体研讨的成果,绘制一个以图表为主、图文并茂的 HTML 可视化「方案总览看板」," +
"并用 write_file 工具写入 /mnt/user-data/outputs/方案总览.html。",
);
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("");
}
parts.push(
"请严格遵循你的职责设定:自包含离线(纯 inline SVG 图表、禁止任何外链 CDN)、" +
"至少 4 种图表、浅红主题(主色 #c25555);写完文件后只用一两句话总结,不要把 HTML 源码贴进对话。",
);
return parts.join("\n");
}
/** 把 SSE 阶段映射成报告智能体的中文状态标签(对齐第 2 步 phaseLabel)。 */
function reportPhaseLabel(phase: StreamPhase, toolName?: string): string {
switch (phase) {
case "connecting":
return "调度中";
case "connected":
return "已连接,等待响应";
case "tool_calling":
if (toolName === "present_files") return "正在交付文件";
if (toolName === "write_file" || toolName === "str_replace")
return "正在绘制图表 HTML";
return toolName ? `调用工具:${toolName}` : "调用工具";
case "tool_result":
return "处理工具结果";
case "streaming":
return "正在输出";
default:
return "正在输出…";
}
}
export function useStep3Report(
options: UseStep3ReportOptions,
): UseStep3ReportReturn {
const { intentReady, seatDeliveries, consensusText, selectedModel } = options;
const {
setArtifacts,
select: selectArtifact,
setOpen: setArtifactsOpen,
} = useArtifacts();
const [phase, setPhase] = useState<ReportPhase>("idle");
const [error, setError] = useState<string | null>(null);
const [reportThreadId, setReportThreadId] = 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);
// 当前报告气泡 id —— 流式 delta / 工具步骤 / finalize 都按它定位(绝不用数组下标)。
const bubbleIdRef = useRef<number | null>(null);
// 上一次「自动」打开沙箱的 virtualUrl —— 流式一秒回调几十次,url 没变就跳过;
// url 变了(路径在流式中逐步长全:/m→/mnt→…→方案总览.html)就用更完整的取代旧的。
// **不锁定首帧**:早期 parsePartialJson 会给出半截路径,必须能被后续完整路径覆盖
//(这正是之前「沙箱卡在 /mnt、TEXT、空内容」的根因)。对齐第 2 步 / 主页行为。
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;
},
[],
);
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 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) {
// 同 phase 工具重复回调:args 在流式期间持续长大,拿最新覆盖。
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)──
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-report-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}`;
const nextTc = { id: toolCallId, name: toolName, args };
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);
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(),
[],
);
/**
* 显式打开沙箱(用户点步骤 / 文件卡,或绘制收尾兜底)—— 始终打开、选中目标。
* 用户点了「结果绘制」意图明确,这里不走 autoOpen 闸门。
*/
const openArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
lastAutoOpenedUrlRef.current = virtualUrl;
setActiveArtifactThreadId(threadId);
selectArtifact(virtualUrl, false);
setArtifactsOpen(true);
},
[selectArtifact, setArtifactsOpen],
);
/**
* 流式自动打开沙箱 —— url 与上次相同(仅内容在长大)就跳过,避免每秒重复 select;
* url 变了(路径逐步长全)就用更完整的取代。沙箱内容随 stub 更新自动刷新,无需重开。
*/
const autoOpenArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
if (lastAutoOpenedUrlRef.current === virtualUrl) return;
openArtifact(threadId, virtualUrl);
},
[openArtifact],
);
// 反查某 path 是哪条 stub 写的 —— 关闭沙箱后再点文件卡也能复用内存内容做虚拟预览。
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/str_replace 有可读 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" && toolName !== "str_replace") return;
const path = args?.path;
if (typeof path !== "string" || !path || !args) return;
// 守卫:流式早期 parsePartialJson 会给出「半截路径」(如 /mnt、/mnt/user-data/out),
// 它们 basename 没有扩展名 → 既会污染 stub,也会让沙箱卡在错的空文件。只有 basename
// 带扩展名(含个 `.`)的路径才认为「足够完整、可写 stub / 开沙箱」。完整 .html
// 出现后每帧 url 不变(仅内容长大),autoOpenArtifact 自动去重。
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],
);
// 兜底:收口阶段上游可能再发完整 tool_calls,持续把 args.content 灌进 stub(不自动开)。
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" && name !== "str_replace") continue;
const a = o.args;
if (!a || typeof a !== "object") continue;
const p = (a as Record<string, unknown>).path;
if (typeof p !== "string") continue;
// 同 handleArtifactToolPhase:只接「basename 带扩展名」的完整路径,过滤半截路径。
if (!(p.split("/").pop() ?? p).includes(".")) continue;
upsertStub(threadId, name, a as Record<string, unknown>);
}
},
[upsertStub],
);
// 点击工具步骤打开沙箱(write_file/str_replace 走虚拟预览)。
const openArtifactFromStep = useCallback(
(
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => {
const threadId = dialogue.agentThreadId;
if (!threadId) 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 } = upsertStub(threadId, toolName, step.args);
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
openArtifact(threadId, virtualUrl);
}
},
[upsertStub, buildWriteFileVirtualUrl, openArtifact],
);
// 点击文件卡打开沙箱:优先反查 stub 走虚拟预览,未命中再走真实 outputs 路径。
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,
],
);
// 兜底:流式期间没捕捉到 write_file(极少见),收尾后从 outputs 列表直接选真实文件。
const openRealArtifactFallback = useCallback(
async (threadId: string) => {
try {
const res = await listSessionArtifacts(threadId);
const file =
res.files.find((f) => /\.html?$/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],
);
const abort = useCallback(() => {
aborterRef.current?.abort();
aborterRef.current = null;
runningRef.current = false;
}, []);
const run = useCallback(async () => {
if (runningRef.current) return;
runningRef.current = true;
setError(null);
setPhase("creating");
setDialogues([]);
bubbleIdRef.current = null;
lastAutoOpenedUrlRef.current = null;
stubRef.current = {};
setSeatStubMessages({});
const aborter = new AbortController();
aborterRef.current = aborter;
// 先建一条「调度中」气泡,让用户立刻看到报告智能体已启动(即便还没首 token)。
const bubbleId = appendStreamingDialogue({
sender: "方案总结智能体",
avatarType: "blue",
});
bubbleIdRef.current = bubbleId;
try {
const { agentName, threadId } = await initReportThread({ signal: aborter.signal });
if (aborter.signal.aborted) return;
setReportThreadId(threadId);
setActiveArtifactThreadId(threadId);
setDialogues((prev) =>
prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId } : d)),
);
setPhase("streaming");
const prompt = buildReportPrompt({ intentReady, seatDeliveries, consensusText });
const res = await streamMultiAgent({
agentType: "special",
agentName,
dAgentThreadId: { [agentName]: threadId },
displayName: "方案总结",
newMessage: prompt,
model: selectedModel || undefined,
signal: aborter.signal,
onTextDelta: (text) => appendToDialogue(bubbleId, text),
onPhaseChange: (p, toolName, args) => {
const label = reportPhaseLabel(p, toolName);
setDialogueConfidence(bubbleId, label);
appendStepIfNew(bubbleId, label, `${p}:${toolName ?? ""}`, args);
handleArtifactToolPhase(p, toolName, args, threadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, threadId),
});
if (aborter.signal.aborted) return;
// stub 里有 write_file 就说明流式期间已抓到产物;否则收尾从 outputs 兜底打开。
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
if (!hasStubFile) await openRealArtifactFallback(threadId);
finalizeDialogue(bubbleId, res.content, "绘制完成");
setPhase("done");
} catch (err) {
if (aborter.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
finalizeDialogue(bubbleId, `(绘制失败) ${msg}`, "绘制失败");
setPhase("error");
} finally {
runningRef.current = false;
}
}, [
intentReady,
seatDeliveries,
consensusText,
selectedModel,
appendStreamingDialogue,
appendToDialogue,
setDialogueConfidence,
appendStepIfNew,
finalizeDialogue,
handleArtifactToolPhase,
handleUpstreamFrame,
openRealArtifactFallback,
]);
const start = useCallback(() => {
void run();
}, [run]);
const redraw = useCallback(() => {
abort();
void run();
}, [abort, run]);
return {
phase,
error,
reportThreadId,
dialogues,
seatStubMessages,
activeArtifactThreadId,
start,
redraw,
abort,
openArtifactFromStep,
openArtifactPreview,
};
}
@@ -5,15 +5,16 @@
* - 左栏:候选智能体列表(`listAgents()` + `filterRecommendCandidates` 复用,剔除
* intent/recommender/coordinator),支持**名称检索 + 标签筛选**(`TagSearchBar`,
* 服务端过滤),竖排卡片,可拖到右栏、也可点击快捷加入。
* - 右栏:当前链条的**有序**长条卡片(序号 + 名称 + 描述),可拖拽排序、删除单项;
* 左栏拖入时按落点插入到对应位置。
* - 右栏:当前链条的**有序**长条卡片(序号 + 名称 + 描述),可拖拽排序、上/下移、
* 删除单项;左栏拖入时按落点插入到对应位置。
*
* 拖拽用 `@dnd-kit`:右栏是 `SortableContext`(席位互相排序),左栏卡片是
* `useDraggable`(pool→右栏插入)。落点计算在 `onDragEnd`。
*
* 视觉:直接遵循《浅红色 UI 规范》/《深蓝色 UI 规范》—— 用 app 语义 token(光红暗蓝
* 已内置)+ 规范小圆角(输入/按钮/tag 2px、卡片/分栏 4px)+ 克制阴影。**不**套圆桌页
* 的 `roundtable-planning` scope。
* 视觉:对齐设计稿 `yewuliantiao`(浅红 / 绛红风格)。统一走 app 语义 token
* (`primary` 在浅色主题即设计稿的绛红 #c25555,深色主题自动切蓝),配规范小圆角
* (输入/按钮/tag 2px、卡片/分栏 4px)+ 克制阴影。**不**套圆桌页的
* `roundtable-planning` scope。
*
* 持久化走 `api/chains.ts`(后端 MySQL `/api/roundtable-chains`)。席位数约束
* 2 ≤ N ≤ 8、agent 去重,保存时校验(后端 `_validate_seats` 也兜底)。
@@ -26,14 +27,16 @@ import { toast } from "sonner";
import { nanoid } from "nanoid";
import {
ArrowLeft,
Bot,
ChevronDown,
ChevronUp,
GripVertical,
Layers,
ListOrdered,
Loader2,
Plus,
Save,
Trash2,
Users,
UserPlus,
X,
} from "lucide-react";
import {
@@ -57,7 +60,6 @@ import {
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { listAgents } from "@/core/agents";
@@ -116,7 +118,7 @@ export default function BusinessChainEditorPage() {
const [saving, setSaving] = useState(false);
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
const selectValue = currentChainId ?? "";
const [dropdownOpen, setDropdownOpen] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
@@ -214,6 +216,14 @@ export default function BusinessChainEditorPage() {
setSeats((prev) => prev.filter((s) => s.agent_id !== agentId));
}, []);
const moveSeat = useCallback((index: number, dir: -1 | 1) => {
setSeats((prev) => {
const target = index + dir;
if (target < 0 || target >= prev.length) return prev;
return arrayMove(prev, index, target);
});
}, []);
// ─── DnD ─────────────────────────────────────────────────────────────
const onDragStart = useCallback(
(e: DragStartEvent) => {
@@ -344,108 +354,196 @@ export default function BusinessChainEditorPage() {
// ─── Render ──────────────────────────────────────────────────────────
const seatItemIds = seats.map((s) => SEAT_PREFIX + s.agent_id);
const triggerLabel = currentChainId ? title || "未命名链条" : "+ 新建链条…";
return (
<div className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground">
{/* 顶栏 */}
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border bg-card px-5 py-2.5">
<Button
variant="ghost"
size="sm"
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border bg-card px-6 py-3">
<button
type="button"
onClick={() => navigate("/page/workspace/roundtable/planning")}
className={cn("h-8 gap-1.5 px-2 text-muted-foreground hover:text-foreground", R_CTRL)}
className={cn(
"flex items-center gap-1.5 border border-border bg-card px-3 py-1.5 text-sm font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground active:scale-[0.98]",
R_CTRL,
)}
>
<ArrowLeft className="size-4" />
返回圆桌
</Button>
<div className="h-4 w-px bg-border" />
</button>
{/* 品牌标识 */}
<div className="flex items-center gap-2.5">
<span className={cn("flex size-8 items-center justify-center bg-primary/10 text-primary", R_CARD)}>
<Layers className="size-4" />
</span>
<div
className={cn(
"flex size-8 select-none items-center justify-center bg-gradient-to-tr from-primary to-primary/40 text-sm font-bold text-primary-foreground shadow-sm",
R_CARD,
)}
>
🪶
</div>
<div className="leading-tight">
<div className="text-sm font-semibold">业务链条配置</div>
<div className="text-xs text-muted-foreground">编排有序的智能体研讨链路</div>
<div className="text-base font-bold text-foreground">业务链条配置</div>
<div className="text-[11px] font-medium tracking-wide text-muted-foreground">
编排有序的智能体研讨链路
</div>
</div>
</div>
{/* 链条选择 + 新建 */}
<div className="ml-2 flex items-center gap-2">
<select
value={selectValue}
onChange={(e) => {
const id = e.target.value;
if (!id) {
startNewChain();
return;
}
const chain = chains.find((c) => c.id === id);
if (chain) loadChain(chain);
}}
{/* 链条选择下拉 */}
<div className="relative ml-2">
<button
type="button"
onClick={() => setDropdownOpen((o) => !o)}
className={cn(
"h-8 border border-input bg-card px-2.5 text-sm text-foreground outline-none transition-colors focus:border-primary focus:ring-1 focus:ring-primary/30",
"flex min-w-[150px] items-center justify-between gap-3 border border-border bg-card px-3 py-1.5 text-sm text-foreground transition hover:border-primary/50 focus:outline-none focus:ring-1 focus:ring-primary/30",
R_CTRL,
)}
>
<option value="">+ 新建链条…</option>
{chains.map((c) => (
<option key={c.id} value={c.id}>
{c.title || "未命名"}({c.seats.length} 席)
</option>
))}
</select>
<Button variant="outline" size="sm" onClick={startNewChain} className={cn("h-8 gap-1.5", R_CTRL)}>
<Plus className="size-4" />
新建
</Button>
<span className="truncate font-medium">{triggerLabel}</span>
<ChevronDown className="size-4 text-muted-foreground" />
</button>
{dropdownOpen && (
<>
<div className="fixed inset-0 z-30" onClick={() => setDropdownOpen(false)} />
<div
className={cn(
"absolute left-0 z-40 mt-1 max-h-[280px] w-[260px] overflow-y-auto border border-border bg-card py-1 shadow-lg",
R_CARD,
)}
>
<div className="border-b border-border px-3 py-1 text-xs font-semibold text-muted-foreground">
切换已有链条
</div>
{chains.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">暂无已保存链条</div>
) : (
chains.map((c) => (
<button
key={c.id}
type="button"
onClick={() => {
loadChain(c);
setDropdownOpen(false);
}}
className={cn(
"flex w-full items-center justify-between px-3 py-2 text-xs transition hover:bg-primary/5",
c.id === currentChainId
? "bg-primary/5 font-bold text-primary"
: "text-muted-foreground",
)}
>
<span className="truncate">
{c.title || "未命名链条"}({c.seats.length} 席)
</span>
{c.id === currentChainId && (
<span className={cn("ml-2 shrink-0 bg-primary/15 px-1.5 py-0.5 text-[10px] text-primary", R_CTRL)}>
当前
</span>
)}
</button>
))
)}
<div className="mt-1 border-t border-border pt-1">
<button
type="button"
onClick={() => {
startNewChain();
setDropdownOpen(false);
}}
className="flex w-full items-center gap-1.5 px-3 py-2 text-xs font-medium text-primary transition hover:bg-primary/5"
>
<Plus className="size-3.5" />
新建另一条链路
</button>
</div>
</div>
</>
)}
</div>
{/* 快捷新建 */}
<button
type="button"
onClick={startNewChain}
className={cn(
"flex items-center gap-1.5 border border-border bg-card px-3 py-1.5 text-sm text-muted-foreground transition hover:bg-muted hover:text-foreground active:scale-[0.98]",
R_CTRL,
)}
>
<Plus className="size-4" />
新建
</button>
{/* 右侧操作 */}
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
<button
type="button"
onClick={handleDelete}
disabled={!currentChainId}
className={cn("h-8 gap-1.5 text-destructive hover:text-destructive", R_CTRL)}
className={cn(
"flex items-center gap-1.5 border border-primary/30 bg-card px-3 py-1.5 text-sm font-medium text-primary transition hover:bg-primary/5 active:scale-[0.98]",
R_CTRL,
)}
>
<Trash2 className="size-4" />
删除
</Button>
<Button
size="sm"
</button>
<button
type="button"
onClick={handleSave}
disabled={!canSave}
className={cn("h-8 gap-1.5", R_CTRL)}
className={cn(
"flex items-center gap-1.5 bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground shadow-sm transition hover:bg-primary/90 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50",
R_CTRL,
)}
>
{saving ? <Loader2 className="size-4 animate-spin" /> : <Save className="size-4" />}
保存
</Button>
</button>
</div>
</header>
{/* 标题 + 描述 */}
<div className="flex shrink-0 flex-col gap-2.5 border-b border-border bg-card px-5 py-3 sm:flex-row sm:items-center">
<div className="flex items-center gap-2 sm:w-80">
<span className="shrink-0 text-xs font-medium text-muted-foreground">标题</span>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:应急方案三段链"
className={cn("h-8 bg-background", R_CTRL)}
maxLength={120}
/>
<section className="shrink-0 border-b border-border bg-card px-6 py-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
className={cn(
"flex items-stretch overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
R_CTRL,
)}
>
<span className="flex min-w-[64px] select-none items-center justify-center border-r border-border bg-muted/50 px-4 text-sm font-medium text-muted-foreground">
标题
</span>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:应急方案三段链"
maxLength={120}
className="h-auto rounded-none border-0 bg-transparent px-4 py-2.5 text-sm font-medium shadow-none focus-visible:ring-0 dark:border-0 dark:bg-transparent"
/>
</div>
<div
className={cn(
"flex items-stretch overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
R_CTRL,
)}
>
<span className="flex min-w-[64px] select-none items-center justify-center border-r border-border bg-muted/50 px-4 text-sm font-medium text-muted-foreground">
描述
</span>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="链条用途说明(可选)"
maxLength={500}
className="h-auto rounded-none border-0 bg-transparent px-4 py-2.5 text-sm font-medium shadow-none focus-visible:ring-0 dark:border-0 dark:bg-transparent"
/>
</div>
</div>
<div className="flex flex-1 items-center gap-2">
<span className="shrink-0 text-xs font-medium text-muted-foreground">描述</span>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="链条用途说明(可选)"
className={cn("h-8 bg-background", R_CTRL)}
maxLength={500}
/>
</div>
</div>
</section>
{/* 双栏拖拽编辑器 */}
<DndContext
@@ -454,20 +552,34 @@ export default function BusinessChainEditorPage() {
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 overflow-hidden p-4 md:grid-cols-[360px_1fr]">
<main className="grid min-h-0 flex-1 grid-cols-1 gap-6 overflow-hidden px-6 py-6 lg:grid-cols-12">
{/* 左栏:候选智能体 */}
<section className={cn("flex min-h-0 flex-col overflow-hidden border border-border bg-card", R_CARD, PANEL_SHADOW)}>
<div className="shrink-0 space-y-2.5 border-b border-border px-3.5 py-3">
<div className="flex items-center gap-2 text-xs font-semibold text-foreground">
<Users className="size-3.5 text-primary" />
候选智能体
<span className={cn("bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary tabular-nums", R_CTRL)}>
<section
className={cn(
"flex min-h-0 flex-col overflow-hidden border border-border bg-card lg:col-span-4",
R_CARD,
PANEL_SHADOW,
)}
>
<div className="flex shrink-0 items-center justify-between border-b border-border bg-muted/40 px-5 py-4">
<div className="flex items-center gap-2">
<span className={cn("flex size-5 items-center justify-center bg-primary/10", R_CTRL)}>
<UserPlus className="size-3.5 text-primary" />
</span>
<h2 className="text-sm font-bold tracking-wide text-foreground">候选智能体</h2>
<span
className={cn(
"min-w-[20px] border border-primary/30 bg-primary/10 px-2 py-0.5 text-center text-xs font-bold text-primary tabular-nums",
"rounded-full",
)}
>
{pool.length}
</span>
<span className="ml-auto text-[11px] font-normal text-muted-foreground">
拖到右侧 / 点击加入
</span>
</div>
<span className="text-[11px] font-medium text-muted-foreground">拖到右侧 / 点击加入</span>
</div>
<div className="shrink-0 border-b border-border px-4 py-3">
<TagSearchBar
targetType="agent"
onSearchChange={setSearch}
@@ -475,14 +587,15 @@ export default function BusinessChainEditorPage() {
onTagIdsChange={setFilterTagIds}
/>
</div>
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto p-2.5">
<div className="min-h-0 flex-1 space-y-2.5 overflow-y-auto p-4">
{poolLoading ? (
<div className="flex items-center gap-2 px-2 py-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载候选智能体…
</div>
) : pool.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
<div className="px-2 py-12 text-center text-sm text-muted-foreground">
{search || filterTagIds.length
? "没有匹配的智能体,换个关键词或标签试试。"
: `候选池为空,请先在「智能体」里创建至少 ${CHAIN_MIN_SEATS} 个智能体。`}
@@ -506,18 +619,29 @@ export default function BusinessChainEditorPage() {
seatItemIds={seatItemIds}
seatCountInvalid={seatCountInvalid}
onRemove={removeSeat}
onMove={moveSeat}
/>
</div>
</main>
<DragOverlay>
{activeDrag ? (
<div className={cn("flex items-center gap-2 border border-primary/50 bg-card px-3 py-2 text-sm font-medium text-foreground shadow-[0_4px_12px_rgba(15,23,42,0.12)]", R_CTRL)}>
<div
className={cn(
"flex items-center gap-2 border border-primary/50 bg-card px-3 py-2 text-sm font-medium text-foreground shadow-[0_4px_12px_rgba(15,23,42,0.12)]",
R_CTRL,
)}
>
<GripVertical className="size-4 text-primary" />
{activeDrag.name}
</div>
) : null}
</DragOverlay>
</DndContext>
{/* 页脚 */}
<footer className="shrink-0 select-none border-t border-border bg-card px-6 py-3 text-center text-[11px] text-muted-foreground">
编排有序的业务链条,让智能体决策更有深度。保存后,配置将自动同步至圆桌。
</footer>
</div>
);
}
@@ -540,43 +664,57 @@ function PoolAgentCard({
return (
<div
ref={setNodeRef}
onClick={inChain ? undefined : onAdd}
className={cn(
"group flex items-start gap-2 border p-2.5 transition-colors",
R_CTRL,
"group relative flex items-start gap-3 border p-3.5 transition-all duration-150",
R_CARD,
inChain
? "cursor-not-allowed border-dashed border-border bg-muted/40 opacity-60"
: "cursor-grab border-border bg-card hover:border-primary/50 hover:bg-primary/[0.03] active:cursor-grabbing",
? "cursor-default border-primary/30 bg-primary/[0.03]"
: "cursor-pointer border-border bg-card hover:border-primary hover:shadow-[0_2px_8px_rgba(0,0,0,0.04)]",
isDragging && "opacity-30",
)}
{...(inChain ? {} : listeners)}
{...attributes}
>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{agent.name || agent.agent_id}</div>
<div
className={cn(
"mt-0.5 flex size-6 shrink-0 items-center justify-center transition-colors",
R_CARD,
inChain
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary",
)}
>
<Bot className="size-3.5" />
</div>
<div className="min-w-0 flex-1 pr-16">
<div className="truncate text-xs font-bold leading-tight text-foreground transition-colors group-hover:text-primary">
{agent.name || agent.agent_id}
</div>
{agent.description && (
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
<div className="mt-1 line-clamp-2 text-[11px] font-medium leading-normal text-muted-foreground">
{agent.description}
</div>
)}
</div>
{inChain ? (
<span className={cn("shrink-0 self-center bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary", R_CTRL)}>
已加入
</span>
) : (
<button
type="button"
onClick={onAdd}
onPointerDown={(e) => e.stopPropagation()}
className={cn(
"shrink-0 self-center p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-primary hover:text-primary-foreground group-hover:opacity-100",
R_CTRL,
)}
title="加入链条"
>
<Plus className="size-4" />
</button>
)}
<div className="absolute right-3.5 top-3.5 shrink-0 select-none">
{inChain ? (
<span className={cn("border border-primary/30 bg-primary/10 px-2 py-1 text-[10px] font-bold text-primary", R_CTRL)}>
已加入
</span>
) : (
<span
className={cn(
"border border-transparent bg-muted px-2 py-1 text-[10px] font-semibold text-muted-foreground transition-all group-hover:border-primary/30 group-hover:bg-primary/10 group-hover:text-primary",
R_CTRL,
)}
>
点击加入
</span>
)}
</div>
</div>
);
}
@@ -588,45 +726,72 @@ function SeatsColumn({
seatItemIds,
seatCountInvalid,
onRemove,
onMove,
}: {
seats: ChainSeat[];
seatItemIds: string[];
seatCountInvalid: boolean;
onRemove: (agentId: string) => void;
onMove: (index: number, dir: -1 | 1) => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: SEATS_DROPPABLE_ID });
return (
<section className={cn("flex min-h-0 flex-col overflow-hidden border border-border bg-card", R_CARD, PANEL_SHADOW)}>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-3 text-xs font-semibold text-foreground">
<ListOrdered className="size-3.5 text-primary" />
链条席位
<span className={cn("bg-muted px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground", R_CTRL)}>
按顺序发言
</span>
<span
className={cn(
"ml-auto px-1.5 py-0.5 text-[11px] font-medium tabular-nums",
R_CTRL,
seatCountInvalid
? "bg-destructive/10 text-destructive"
: "bg-primary/10 text-primary",
)}
>
{seats.length} / {CHAIN_MAX_SEATS}
{seats.length < CHAIN_MIN_SEATS && ` · 至少 ${CHAIN_MIN_SEATS}`}
</span>
<section
className={cn(
"flex min-h-0 flex-col overflow-hidden border border-border bg-card lg:col-span-8",
R_CARD,
PANEL_SHADOW,
)}
>
<div className="flex shrink-0 items-center justify-between border-b border-border bg-muted/40 px-5 py-4">
<div className="flex items-center gap-2">
<span className={cn("flex size-5 items-center justify-center bg-primary/10", R_CTRL)}>
<ListOrdered className="size-3.5 text-primary" />
</span>
<h2 className="text-sm font-bold tracking-wide text-foreground">链条席位</h2>
<span className={cn("border border-border bg-muted px-2 py-0.5 text-[10px] font-bold text-muted-foreground", R_CTRL)}>
按顺序发言
</span>
</div>
<div className="flex select-none items-center gap-1 text-xs">
<span className="font-medium text-muted-foreground">配置席位</span>
<span
className={cn(
"border px-2 py-0.5 font-bold tabular-nums",
R_CTRL,
seatCountInvalid
? "border-destructive/30 bg-destructive/10 text-destructive"
: "border-primary/30 bg-primary/10 text-primary",
)}
>
{seats.length} / {CHAIN_MAX_SEATS}
{seats.length < CHAIN_MIN_SEATS && ` · 至少 ${CHAIN_MIN_SEATS}`}
</span>
</div>
</div>
<div
ref={setNodeRef}
className={cn(
"min-h-0 flex-1 space-y-2 overflow-y-auto p-3.5 transition-colors",
"min-h-0 flex-1 space-y-3 overflow-y-auto p-5 transition-colors",
isOver && "bg-primary/[0.03]",
)}
>
{seats.length === 0 ? (
<div className={cn("flex h-full flex-col items-center justify-center gap-2 border border-dashed border-border text-sm text-muted-foreground", R_CARD)}>
<Layers className="size-7 opacity-40" />
从左侧把智能体拖到这里,组成有序业务链条
<div
className={cn(
"m-1 flex h-full flex-col items-center justify-center border border-dashed border-border bg-card/50 px-8 py-16 text-center",
R_CARD,
)}
>
<div className="mb-3 flex size-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<ListOrdered className="size-6" />
</div>
<h3 className="mb-1 text-sm font-bold text-foreground">暂无席位</h3>
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
该业务链条当前为空。请从左侧「候选智能体」点击或拖拽,将其按顺序编排加入此处。
</p>
</div>
) : (
<SortableContext items={seatItemIds} strategy={verticalListSortingStrategy}>
@@ -635,7 +800,9 @@ function SeatsColumn({
key={seat.agent_id}
seat={seat}
index={index}
total={seats.length}
onRemove={() => onRemove(seat.agent_id)}
onMove={(dir) => onMove(index, dir)}
/>
))}
</SortableContext>
@@ -650,59 +817,110 @@ function SeatsColumn({
function SeatStrip({
seat,
index,
total,
onRemove,
onMove,
}: {
seat: ChainSeat;
index: number;
total: number;
onRemove: () => void;
onMove: (dir: -1 | 1) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: SEAT_PREFIX + seat.agent_id });
const avatarType = getAvatarTypeFor(seat.agent_id, index);
const isFirst = index === 0;
const isLast = index === total - 1;
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(
"flex items-center gap-3 border border-border bg-card p-2.5",
R_CTRL,
"flex items-stretch overflow-hidden border border-border bg-card",
R_CARD,
PANEL_SHADOW,
isDragging && "z-10 opacity-70 shadow-[0_4px_12px_rgba(15,23,42,0.12)]",
)}
>
{/* 拖拽手柄 */}
<button
type="button"
className="shrink-0 cursor-grab touch-none text-muted-foreground/60 hover:text-foreground active:cursor-grabbing"
className="flex w-7 shrink-0 cursor-grab touch-none items-center justify-center border-r border-border bg-muted/40 text-muted-foreground/60 transition-colors hover:text-foreground active:cursor-grabbing"
{...attributes}
{...listeners}
title="拖动排序"
>
<GripVertical className="size-4" />
<GripVertical className="size-3.5" />
</button>
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center border text-[12px] font-semibold tabular-nums",
R_CTRL,
chainTagClasses(avatarType),
)}
>
{index + 1}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{seat.name || seat.agent_id}</div>
{/* 序号 */}
<div className="flex shrink-0 select-none items-center pl-2.5">
<span
className={cn(
"flex size-6 items-center justify-center border text-xs font-bold tabular-nums",
R_CTRL,
chainTagClasses(avatarType),
)}
>
{index + 1}
</span>
</div>
{/* 详情 */}
<div className="flex min-w-0 flex-1 flex-col justify-center px-2.5 py-2">
<div className="truncate text-xs font-bold text-foreground">{seat.name || seat.agent_id}</div>
{seat.description && (
<div className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">
<div className="mt-0.5 line-clamp-1 text-[11px] font-medium text-muted-foreground">
{seat.description}
</div>
)}
</div>
<button
type="button"
onClick={onRemove}
className={cn("shrink-0 p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive", R_CTRL)}
title="移除"
>
<X className="size-4" />
</button>
{/* 控制:上移 / 下移 / 移除 */}
<div className="flex shrink-0 items-center gap-0.5 border-l border-border px-2">
<button
type="button"
onClick={() => onMove(-1)}
disabled={isFirst}
title="上移席位"
className={cn(
"p-1 transition",
R_CTRL,
isFirst
? "pointer-events-none text-muted-foreground/30"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<ChevronUp className="size-3.5" />
</button>
<button
type="button"
onClick={() => onMove(1)}
disabled={isLast}
title="下移席位"
className={cn(
"p-1 transition",
R_CTRL,
isLast
? "pointer-events-none text-muted-foreground/30"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<ChevronDown className="size-3.5" />
</button>
<button
type="button"
onClick={onRemove}
title="移出此席位"
className={cn(
"ml-0.5 p-1 text-muted-foreground/60 transition-colors hover:bg-primary/10 hover:text-primary",
R_CTRL,
)}
>
<X className="size-3.5" />
</button>
</div>
</div>
);
}
@@ -579,6 +579,11 @@ export default function RoundtablePlanningPage() {
hasConsensus={step2.hasConsensus}
step2RoundtableDialogues={step2.step2RoundtableDialogues}
agentNameToRole={step2.agentNameToRole}
intentReady={step1.intentReady}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
onReset={handleStartNewTask}
/>
)}
@@ -60,16 +60,21 @@ logger = logging.getLogger(__name__)
INTENT_AGENT_ID = "roundtable-intent"
RECOMMENDER_AGENT_ID = "roundtable-recommender"
COORDINATOR_AGENT_ID = "roundtable-coordinator"
# Step 3「结果绘制」用的方案可视化总结智能体:读各席位交付 + 共识,画自包含图表 HTML。
# 与上面三个一样是内置单例,种子化一次常驻磁盘+DB,绝不每次调用重建。
REPORT_AGENT_ID = "roundtable-report"
# 验证版 SOUL.md + config.yaml 的源码资源目录(随 git 发布)。每个子目录名即 agent id,
# 内含该 agent 的 config.yaml 与 SOUL.md,是 seeder 落盘时字节级复制的来源。
_ASSETS_DIR = Path(__file__).parent / "_roundtable_seed_assets"
# 这三个功能型 agent 任何部署都必须存在;顺序无关紧要(逐个独立补建)。
# 这几个功能型/内置 agent 任何部署都必须存在;顺序无关紧要(逐个独立补建)。
# 前三个支撑 Step 1 澄清 / 推荐 / Step 2 总控;roundtable-report 支撑 Step 3 结果绘制。
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
INTENT_AGENT_ID,
RECOMMENDER_AGENT_ID,
COORDINATOR_AGENT_ID,
REPORT_AGENT_ID,
)
# seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
@@ -0,0 +1,57 @@
# 圆桌方案可视化总结智能体
## 核心定位
你是多智能体圆桌研讨 **Step 3** 的「方案可视化总结智能体」。各席位(情报收集 / 环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述等)已经完成研讨并产出交付,总控也给出了最终共识。
你的**唯一职责**:把这些文字成果归纳、提炼,**绘制成一个图文并茂、以图表为主的 HTML 可视化看板**,用 `write_file` 工具写入到 `/mnt/user-data/outputs/方案总览.html`。
你**不**调研、**不**联网搜索、**不**派活、**不**追问用户——只做归纳与可视化。
## 输入说明
用户消息里会给你三部分原料(可能有缺省):
1. **任务意图摘要**:目标、约束、关键假设(来自 Step 1);
2. **各席位最终交付**:每个席位的名称 + 它产出的完整 markdown 正文;
3. **总控最终共识结论**。
请通读全部原料,从中**抽取可量化、可结构化的信息**(指标、阶段、里程碑、风险项、预算/资源占比、优劣势等)来驱动图表。原料里没有明确数字时,可基于文字内容**合理估算并标注**,但不要凭空编造与原料矛盾的数据。
## 产出要求(最重要)
### 1. 必须是「自包含、离线可用」的单文件 HTML
- 所有 CSS 内联在 `<style>`,所有图表用**纯 inline SVG** 直接画(坐标、路径、文字都由你计算好写死);
- **绝对禁止**引用任何外部资源:不要 `<script src=...>` 引 CDN(Chart.js/ECharts/D3 等一律不行)、不要外链字体/图片/CSS。本系统离线部署,外链一律加载失败;
- 如确需交互/动画,只能用**内联的原生 JS**(`<script>...</script>` 里自己写),但默认优先纯 SVG 静态图,稳定优先;
- 字体用系统字体栈(`system-ui, -apple-system, "Microsoft YaHei", sans-serif`)。
### 2. 内容与图表(**至少包含 4 种图表**,按原料有无自适应取舍)
顶部先放**标题 + 一句话结论 + 一行关键指标卡(KPI)**,然后是图表区。优先实现下面这些,能画几个画几个,缺数据的模块可省略:
| 模块 | 图表形式 |
|------|----------|
| 关键指标卡 | 4–6 个数字卡片(如:共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模) |
| 共识达成度 | **环形进度仪表(donut/gauge)** |
| 各席位贡献画像 | **雷达图**(维度如:完整性 / 可行性 / 风险覆盖 / 创新性 / 落地性) |
| 风险评估 | **概率 × 影响 气泡矩阵**(气泡大小=影响范围,颜色=风险等级,象限分色) |
| 执行路线 | **甘特图 / 阶段时间线**(阶段条 + 里程碑点) |
| 资源 / 预算分配 | **饼图 / 环形图** |
| 方案权衡 | **SWOT 四象限** 或候选方案**对比柱状图** |
| 决策链路 | **流程图**(席位发言/依赖顺序,链条模式尤其适用) |
每个图表都要有**标题、图例、坐标轴或数值标注**,让人不看原文也能读懂。
### 3. 视觉风格
- 主色对齐圆桌的**浅红 / 绛红**主题:主色 `#c25555`,浅底 `#fef4f3`,描边 `#ede9e9`,文字 `#1f2937`,辅助灰 `#717b88`;图表配色可在主色基础上取同色系/邻近色,保持克制专业;
- 整体是**仪表盘/看板**布局:卡片化分区、圆角、留白充足、响应式(`max-width` 容器 + 网格自适应),打印友好;
- 顶部一个简洁的报告头(标题 + 生成时间占位 + 「AI 自动生成」标签)。
## 工作流程
1. 通读原料,列一个内部要点清单(指标、阶段、风险、优劣势等)——这一步在思考里做即可;
2. **一次性** 用 `write_file` 把完整 HTML 写入 `/mnt/user-data/outputs/方案总览.html`(`description` 写「方案可视化总览看板」);
3. 写完后,用**一两句话**向用户说明「已生成可视化方案总览,包含 X 个图表(列出图表名)」即可——**不要**把 HTML 源码贴进对话。
## 禁止事项
- ❌ 引用任何外部 CDN / 外链资源(离线必然失败)
- ❌ 把大段 HTML 源码输出到对话正文(只写文件 + 一句话总结)
- ❌ 联网搜索、派活给其他智能体、反问用户
- ❌ 只画 1 个图表草草交差——尽量丰富,至少 4 种图表
- ❌ 编造与原料明显矛盾的数据
@@ -0,0 +1,3 @@
description: 圆桌方案可视化总结智能体,读取各席位的最终交付与总控共识,绘制一个自包含、含多种图表的 HTML 可视化看板(写入 outputs/方案总览.html)。本身不调研、不联网、不派活,只做归纳与可视化。
id: roundtable-report
name: 方案总结
@@ -59,6 +59,7 @@ from typing import Any, AsyncIterator
import httpx
from app.gateway.routers._roundtable_seed import (
COORDINATOR_AGENT_ID,
REPORT_AGENT_ID,
ensure_roundtable_functional_agents,
)
from deerflow.config import get_app_config
@@ -822,6 +823,25 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
)
@router.post("/report/init")
async def init_report_thread(request: Request) -> dict[str, str]:
"""为 Step 3「结果绘制」创建一个供方案可视化总结智能体(``roundtable-report``)
运行的线程,返回 ``{"agent_name", "thread_id"}``。
设计要点:
- ``roundtable-report`` 是**内置单例**(``_roundtable_seed`` 种子化,启动即同步进
DB),这里**只建一个轻量 thread**,绝不重建 agent;
- 线程打 ``_ROUNDTABLE_THREAD_METADATA`` 标记成系统会话,从「最近对话」里隐藏;
- 入口先 ``ensure_roundtable_functional_agents()`` 自愈(磁盘目录被手删也能补建);
- 建好后前端用现有 ``POST /run/stream``(``agent_type="special"``、
``agent_name="roundtable-report"``、``d_agent_thread_id={REPORT_AGENT_ID: thread_id}``)
跑这一轮,智能体把图表 HTML 写进该线程的 ``outputs/``,再由沙箱预览。
"""
ensure_roundtable_functional_agents()
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
return {"agent_name": REPORT_AGENT_ID, "thread_id": thread_id}
# ---------------------------------------------------------------------------
# 上游 LangGraph SSE 透传(intent / recommend / leader / special 共用)
# ---------------------------------------------------------------------------
@@ -15,6 +15,7 @@ from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field, field_validator
from app.gateway.deps import get_checkpointer, get_embed_session_store, get_run_manager, get_thread_store
from app.gateway.routers.thread_shares import _load_thread_snapshot
from app.gateway.routers.threads import _derive_thread_status
from deerflow.persistence.embed_sessions.base import EmbedSessionStore
@@ -93,6 +94,80 @@ class EmbedSessionStatusResponse(EmbedThreadStatusFields):
thread_id: str | None = Field(default=None, description="Resolved thread id when registered")
class EmbedSessionResultResponse(EmbedThreadStatusFields):
session_id: str
registered: bool = Field(description="False until the iframe has registered this session_id")
thread_id: str | None = Field(default=None, description="Resolved thread id when registered")
messages: list[dict] = Field(default_factory=list, description="Full serialized conversation messages")
generated_text: str = Field(default="", description="Concatenated assistant/AI message text")
generated_files: list[str] = Field(
default_factory=list,
description="Virtual artifact paths produced during the conversation (e.g. /mnt/user-data/outputs/...)",
)
class EmbedThreadResultResponse(EmbedThreadStatusFields):
thread_id: str
messages: list[dict] = Field(default_factory=list)
generated_text: str = Field(default="")
generated_files: list[str] = Field(default_factory=list)
def _message_text(message: dict) -> str:
content = message.get("content")
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts: list[str] = []
for part in content:
if isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str):
text = part["text"].strip()
if text:
parts.append(text)
return "\n".join(parts)
return ""
def extract_generated_text(messages: list[dict]) -> str:
"""Join all assistant/AI message bodies in conversation order."""
parts: list[str] = []
for message in messages:
if not isinstance(message, dict):
continue
message_type = message.get("type") or message.get("role")
if message_type not in {"ai", "assistant"}:
continue
text = _message_text(message)
if text:
parts.append(text)
return "\n\n".join(parts)
def build_embed_result_payload(
*,
messages: list[dict],
artifacts: list[str],
) -> tuple[list[dict], str, list[str]]:
return messages, extract_generated_text(messages), artifacts
async def _result_for_thread_id(thread_id: str, request: Request) -> EmbedThreadResultResponse:
checkpointer = get_checkpointer(request)
messages, artifacts = await _load_thread_snapshot(checkpointer, thread_id)
serialized_messages, generated_text, generated_files = build_embed_result_payload(
messages=messages,
artifacts=artifacts,
)
status = await _status_for_thread_id(thread_id, request)
return EmbedThreadResultResponse(
thread_id=thread_id,
messages=serialized_messages,
generated_text=generated_text,
generated_files=generated_files,
**status.model_dump(),
)
async def _status_for_thread_id(thread_id: str, request: Request) -> EmbedThreadStatusFields:
thread_store = get_thread_store(request)
run_mgr = get_run_manager(request)
@@ -192,3 +267,52 @@ async def get_embed_thread_status(thread_id: str, request: Request) -> EmbedThre
"""Direct thread lookup when the parent already knows thread_id."""
status = await _status_for_thread_id(thread_id, request)
return EmbedThreadStatusResponse(thread_id=thread_id, **status.model_dump())
@router.get(
"/sessions/{session_id}/result",
response_model=EmbedSessionResultResponse,
summary="Fetch embed Q&A messages, generated text, and files by session_id",
)
async def get_embed_session_result(session_id: str, request: Request) -> EmbedSessionResultResponse:
"""Return model output for iframe parents once they know ``session_id`` only."""
session_id = _validate_session_id(session_id)
store: EmbedSessionStore = get_embed_session_store(request)
record = await store.get(session_id)
if record is None:
return EmbedSessionResultResponse(
session_id=session_id,
registered=False,
thread_id=None,
finished=False,
thread_status="pending",
has_active_run=False,
latest_run_status=None,
messages=[],
generated_text="",
generated_files=[],
)
thread_result = await _result_for_thread_id(record["thread_id"], request)
return EmbedSessionResultResponse(
session_id=session_id,
registered=True,
thread_id=thread_result.thread_id,
finished=thread_result.finished,
thread_status=thread_result.thread_status,
has_active_run=thread_result.has_active_run,
latest_run_status=thread_result.latest_run_status,
messages=thread_result.messages,
generated_text=thread_result.generated_text,
generated_files=thread_result.generated_files,
)
@router.get(
"/threads/{thread_id}/result",
response_model=EmbedThreadResultResponse,
summary="Fetch embed Q&A messages, generated text, and files by thread_id",
)
async def get_embed_thread_result(thread_id: str, request: Request) -> EmbedThreadResultResponse:
"""Direct thread lookup when the parent already knows thread_id."""
return await _result_for_thread_id(thread_id, request)
+9
View File
@@ -0,0 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"
+20
View File
@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/afcf08ae-640d-4fb2-9eab-4d3094ffaa28
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>业务链条配置</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
{
"name": "业务链条配置",
"description": "编排有序的智能体研讨链路配置系统。",
"requestFramePermissions": [],
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist server.js",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^2.4.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"lucide-react": "^0.546.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3",
"express": "^4.21.2",
"dotenv": "^17.2.3",
"motion": "^12.23.24"
},
"devDependencies": {
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3",
"@types/express": "^4.17.21"
}
}
+833
View File
@@ -0,0 +1,833 @@
import React, { useState, useEffect, useMemo } from "react";
import {
ArrowLeft,
Trash2,
Save,
Plus,
ChevronDown,
Search,
GripVertical,
X,
ListOrdered,
Languages,
Sparkles,
ChevronUp,
UserPlus,
Bot
} from "lucide-react";
import { INITIAL_AGENTS, Agent } from "./initialAgents";
interface ChainConfig {
id: string;
title: string;
description: string;
seats: string[]; // Holds reference to Agent IDs in order
}
const STORAGE_CHANNELS_KEY = "business_decision_chains";
const STORAGE_CURRENT_KEY = "business_decision_current_id";
const STORAGE_CUSTOM_AGENTS_KEY = "business_decision_custom_agents";
// Preset colors for the seat index badges
const SEAT_COLORS = [
{ bg: "bg-blue-50 border border-blue-200 text-blue-600", label: "蓝色" },
{ bg: "bg-purple-50 border border-purple-200 text-purple-600", label: "紫色" },
{ bg: "bg-emerald-50 border border-emerald-200 text-emerald-600", label: "绿色" },
{ bg: "bg-orange-50 border border-orange-200 text-orange-600", label: "橙色" },
{ bg: "bg-indigo-50 border border-indigo-200 text-indigo-600", label: "靛蓝" },
{ bg: "bg-pink-50 border border-pink-200 text-pink-600", label: "粉色" },
{ bg: "bg-amber-50 border border-amber-200 text-amber-600", label: "琥珀" },
{ bg: "bg-sky-50 border border-sky-200 text-sky-600", label: "天空蓝" },
];
export default function App() {
// 1. Storage & Config states
const [chains, setChains] = useState<ChainConfig[]>([]);
const [currentChainId, setCurrentChainId] = useState<string>("");
const [customAgents, setCustomAgents] = useState<Agent[]>([]);
// 2. Search & UI states
const [searchTerm, setSearchTerm] = useState("");
const [showDropdown, setShowDropdown] = useState(false);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
// Custom Agent Form Modal
const [showAgentModal, setShowAgentModal] = useState(false);
const [newAgentName, setNewAgentName] = useState("");
const [newAgentDesc, setNewAgentDesc] = useState("");
// Toast feedback
const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null);
// Initialize data on load
useEffect(() => {
// Load custom agents
const savedCustom = localStorage.getItem(STORAGE_CUSTOM_AGENTS_KEY);
let loadedCustom: Agent[] = [];
if (savedCustom) {
try {
loadedCustom = JSON.parse(savedCustom);
setCustomAgents(loadedCustom);
} catch (e) {
console.error("Failed to parse custom agents", e);
}
}
// Load chains
const savedChains = localStorage.getItem(STORAGE_CHANNELS_KEY);
const savedCurrentId = localStorage.getItem(STORAGE_CURRENT_KEY);
let loadedChains: ChainConfig[] = [];
if (savedChains) {
try {
loadedChains = JSON.parse(savedChains);
} catch (e) {
console.error("Failed to parse chains from localStorage", e);
}
}
// Default configuration if empty
if (loadedChains.length === 0) {
loadedChains = [
{
id: "chain-1",
title: "应急方案三段链",
description: "链条用途说明(可选)",
seats: ["agent-1", "agent-2", "agent-3"],
}
];
}
setChains(loadedChains);
// Set current active id
if (savedCurrentId && loadedChains.some(c => c.id === savedCurrentId)) {
setCurrentChainId(savedCurrentId);
} else {
setCurrentChainId(loadedChains[0].id);
}
}, []);
// Save chains list to storage when it updates
const saveAllToLocalStorage = (newChainsList: ChainConfig[]) => {
localStorage.setItem(STORAGE_CHANNELS_KEY, JSON.stringify(newChainsList));
};
// Toast helper
const triggerToast = (message: string, type: "success" | "error" | "info" = "success") => {
setToast({ message, type });
setTimeout(() => {
setToast(null);
}, 2500);
};
// Complete list of agents (built-in + custom)
const allAgents = useMemo(() => {
return [...INITIAL_AGENTS, ...customAgents];
}, [customAgents]);
const activeChain = useMemo(() => {
return chains.find(c => c.id === currentChainId) || {
id: "fallback",
title: "",
description: "",
seats: []
} as ChainConfig;
}, [chains, currentChainId]);
// Handle text field updates
const handleTitleChange = (val: string) => {
setChains(prev => {
const updated = prev.map(c => c.id === currentChainId ? { ...c, title: val } : c);
saveAllToLocalStorage(updated);
return updated;
});
};
const handleDescChange = (val: string) => {
setChains(prev => {
const updated = prev.map(c => c.id === currentChainId ? { ...c, description: val } : c);
saveAllToLocalStorage(updated);
return updated;
});
};
// Add / Toggle seat inclusion
const handleToggleAgent = (agentId: string) => {
const isAlreadyIn = activeChain.seats.includes(agentId);
if (isAlreadyIn) {
// Remove it
const newSeats = activeChain.seats.filter(id => id !== agentId);
updateActiveSeats(newSeats);
triggerToast("已将智能体移出席位");
} else {
// Add it
if (activeChain.seats.length >= 8) {
triggerToast("席位已满,每个链条最多配置 8 个席位!", "error");
return;
}
const newSeats = [...activeChain.seats, agentId];
updateActiveSeats(newSeats);
triggerToast("成功添加到链条席位");
}
};
const updateActiveSeats = (newSeats: string[]) => {
setChains(prev => {
const updated = prev.map(c => c.id === currentChainId ? { ...c, seats: newSeats } : c);
saveAllToLocalStorage(updated);
return updated;
});
};
// Multi-chain operations
const createNewChain = () => {
const nextId = `chain-${Date.now()}`;
const newChainItem: ChainConfig = {
id: nextId,
title: `空白新链条 ${chains.length + 1}`,
description: "",
seats: []
};
const updated = [...chains, newChainItem];
setChains(updated);
setCurrentChainId(nextId);
localStorage.setItem(STORAGE_CURRENT_KEY, nextId);
saveAllToLocalStorage(updated);
triggerToast("已创建新的空白链条", "success");
setShowDropdown(false);
};
const handleSelectChain = (id: string) => {
setCurrentChainId(id);
localStorage.setItem(STORAGE_CURRENT_KEY, id);
setShowDropdown(false);
triggerToast(`切换到: ${chains.find(c => c.id === id)?.title || "新链条"}`);
};
const handleSaveChain = () => {
saveAllToLocalStorage(chains);
triggerToast("保存配置成功", "success");
};
const handleDeleteChain = () => {
if (chains.length <= 1) {
// Create a blank placeholder instead of locking
const nextId = `chain-${Date.now()}`;
const placeholder: ChainConfig = {
id: nextId,
title: "新配置链条",
description: "",
seats: []
};
setChains([placeholder]);
setCurrentChainId(nextId);
localStorage.setItem(STORAGE_CURRENT_KEY, nextId);
saveAllToLocalStorage([placeholder]);
triggerToast("重置当前链条");
return;
}
const filtered = chains.filter(c => c.id !== currentChainId);
setChains(filtered);
const nextId = filtered[0].id;
setCurrentChainId(nextId);
localStorage.setItem(STORAGE_CURRENT_KEY, nextId);
saveAllToLocalStorage(filtered);
triggerToast("删除成功", "success");
};
// Reorder commands: Drag & Drop
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
setDragOverIndex(index);
};
const handleDrop = (targetIndex: number) => {
if (draggedIndex === null) return;
const reordered = [...activeChain.seats];
const [movedItem] = reordered.splice(draggedIndex, 1);
reordered.splice(targetIndex, 0, movedItem);
updateActiveSeats(reordered);
setDraggedIndex(null);
setDragOverIndex(null);
};
// Fallback reordering commands: Move Up/Down button click
const moveDirection = (index: number, direction: "up" | "down") => {
const targetIdx = direction === "up" ? index - 1 : index + 1;
if (targetIdx < 0 || targetIdx >= activeChain.seats.length) return;
const items = [...activeChain.seats];
const temp = items[index];
items[index] = items[targetIdx];
items[targetIdx] = temp;
updateActiveSeats(items);
};
// Create customized user agents
const handleAddCustomAgent = (e: React.FormEvent) => {
e.preventDefault();
if (!newAgentName.trim()) {
triggerToast("请输入智能体名称", "error");
return;
}
const newAgent: Agent = {
id: `custom-agent-${Date.now()}`,
name: newAgentName,
description: newAgentDesc || "自定义创建的专属智能体角色。",
isCustom: true
};
const updatedCustom = [...customAgents, newAgent];
setCustomAgents(updatedCustom);
localStorage.setItem(STORAGE_CUSTOM_AGENTS_KEY, JSON.stringify(updatedCustom));
setNewAgentName("");
setNewAgentDesc("");
setShowAgentModal(false);
triggerToast(`智能体 '${newAgent.name}' 创建成功!`);
// Optionally auto-add to the chain seats if space available
if (activeChain.seats.length < 8) {
updateActiveSeats([...activeChain.seats, newAgent.id]);
}
};
// Clear a custom agent
const handleDeleteCustomAgent = (e: React.MouseEvent, agentId: string) => {
e.stopPropagation(); // Avoid triggering seat addition
const updatedCustom = customAgents.filter(a => a.id !== agentId);
setCustomAgents(updatedCustom);
localStorage.setItem(STORAGE_CUSTOM_AGENTS_KEY, JSON.stringify(updatedCustom));
// Also remove from any seats as well
if (activeChain.seats.includes(agentId)) {
updateActiveSeats(activeChain.seats.filter(id => id !== agentId));
}
triggerToast("已彻底删除该自定义智能体");
};
// Filter conditions
const filteredCandidates = allAgents.filter(agent => {
const text = (agent.name + " " + agent.description).toLowerCase();
return text.includes(searchTerm.toLowerCase());
});
return (
<div className="min-h-screen bg-[#faf9f9] text-[#1f2937] flex flex-col font-sans transition-all duration-150 antialiased" id="main-container">
{/* 1. Header Toolbar */}
<header className="bg-white border-b border-[#ede9e9] sticky top-0 z-20 px-6 py-3 shrink-0" id="header">
<div className="max-w-[1600px] mx-auto flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
{/* Left Controls */}
<div className="flex flex-wrap items-center gap-4">
{/* Back Button */}
<button
id="back-roundtable-btn"
onClick={() => triggerToast("页面状态已保持。")}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-[#4b5563] border border-[#ede9e9] rounded-md bg-white hover:bg-[#f6f4f4] active:scale-[0.98] transition"
>
<ArrowLeft className="w-4 h-4" />
<span>返回圆桌</span>
</button>
{/* Custom Brand Icon */}
<div className="flex items-center gap-2" id="brand-indicator">
<div className="w-8 h-8 rounded-lg bg-gradient-to-tr from-[#c25555] to-[#fcdbda] flex items-center justify-center text-white font-bold text-sm shadow-sm select-none">
🪶
</div>
<div className="flex flex-col">
<h1 className="text-base font-bold text-[#1f2937] leading-tight">业务链条配置</h1>
<p className="text-[11px] text-[#9ca3af] tracking-wide font-medium">编排有序的智能体研讨链路</p>
</div>
</div>
{/* Dropdown config selection */}
<div className="relative ml-2" id="chain-selector-dropdown">
<button
onClick={() => setShowDropdown(!showDropdown)}
className="flex items-center justify-between gap-3 min-w-[140px] px-3 py-1.5 text-sm bg-white border border-[#ede9e9] rounded-md text-[#374151] hover:border-[#dbcbcb] focus:outline-none focus:ring-1 focus:ring-[#c25555] transition"
>
<span className="font-medium truncate">{activeChain.title || "选择一个配置"}</span>
<ChevronDown className="w-4 h-4 text-[#9ca3af]" />
</button>
{showDropdown && (
<>
<div className="fixed inset-0 z-30" onClick={() => setShowDropdown(false)} />
<div className="absolute left-0 mt-1 w-[240px] bg-white border border-[#ede9e9] rounded-md shadow-lg py-1 z-40 max-h-[280px] overflow-y-auto animate-in fade-in slide-in-from-top-2 duration-150">
<div className="px-3 py-1 text-xs text-slate-400 font-semibold border-b border-[#f5f2f2]">
切换已有链条
</div>
{chains.map((c) => (
<button
key={c.id}
onClick={() => handleSelectChain(c.id)}
className={`w-full text-left px-3 py-2 text-xs hover:bg-[#fef4f3] flex items-center justify-between transition ${
c.id === currentChainId ? "text-[#c25555] font-bold bg-[#fef4f3]" : "text-[#4b5563]"
}`}
>
<span className="truncate">{c.title || "未命名链条"}</span>
{c.id === currentChainId && <span className="text-[10px] bg-[#fcdbda] px-1.5 py-0.5 rounded text-[#c25555]">当前</span>}
</button>
))}
<div className="border-t border-[#f5f2f2] mt-1 pt-1">
<button
onClick={createNewChain}
className="w-full text-left px-3 py-2 text-xs text-[#c25555] hover:bg-[#fef4f3] font-medium flex items-center gap-1.5 transition"
>
<Plus className="w-3.5 h-3.5" />
<span>新建另一条链路</span>
</button>
</div>
</div>
</>
)}
</div>
{/* Quick New Button */}
<button
id="new-chain-btn"
onClick={createNewChain}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-[#4b5563] border border-[#ede9e9] rounded-md bg-white hover:bg-[#f6f4f4] active:scale-[0.98] transition"
>
<Plus className="w-4 h-4 text-[#9ca3af]" />
<span>新建</span>
</button>
</div>
{/* Right Action buttons */}
<div className="flex items-center gap-2 ml-auto sm:ml-0">
<button
id="delete-chain-btn"
onClick={handleDeleteChain}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-[#c25555] font-medium border border-[#fcdbda] rounded-md bg-white hover:bg-[#fef4f3] active:scale-[0.98] transition"
>
<Trash2 className="w-4 h-4" />
<span>删除</span>
</button>
<button
id="save-chain-btn"
onClick={handleSaveChain}
className="flex items-center gap-1.5 px-4 py-1.5 text-sm text-white font-medium bg-[#c25555] hover:bg-[#b54545] border border-[#b24848] rounded-md shadow-sm active:scale-[0.98] transition"
>
<Save className="w-4 h-4" />
<span>保存</span>
</button>
</div>
</div>
</header>
{/* 2. Top Config Form Cards (Title, Description) */}
<section className="bg-white border-b border-[#ede9e9] px-6 py-4 shrink-0 shadow-[0_1px_2px_0_rgba(0,0,0,0.02)]" id="form-container">
<div className="max-w-[1600px] mx-auto grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Title input field */}
<div className="flex items-stretch border border-[#ede9e9] rounded-md overflow-hidden bg-white group focus-within:ring-1 focus-within:ring-[#c25555] focus-within:border-[#c25555] transition" id="title-field">
<span className="bg-[#faf9f9] text-[#767e89] font-medium text-sm px-4 flex items-center justify-center border-r border-[#ede9e9] select-none min-w-[70px]">
标题
</span>
<input
type="text"
value={activeChain.title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="如:应急方案三段链"
className="w-full px-4 py-2.5 text-sm text-[#1f2937] focus:outline-none placeholder-[#9ca3af] font-medium"
/>
</div>
{/* Description input field */}
<div className="flex items-stretch border border-[#ede9e9] rounded-md overflow-hidden bg-white group focus-within:ring-1 focus-within:ring-[#c25555] focus-within:border-[#c25555] transition" id="desc-field">
<span className="bg-[#faf9f9] text-[#767e89] font-medium text-sm px-4 flex items-center justify-center border-r border-[#ede9e9] select-none min-w-[70px]">
描述
</span>
<input
type="text"
value={activeChain.description}
onChange={(e) => handleDescChange(e.target.value)}
placeholder="链条用途说明(可选)"
className="w-full px-4 py-2.5 text-sm text-[#1f2937] focus:outline-none placeholder-[#9ca3af] font-medium"
/>
</div>
</div>
</section>
{/* 3. Main Split Workspace Grid */}
<main className="flex-1 max-w-[1600px] w-full mx-auto px-6 py-6 grid grid-cols-1 lg:grid-cols-12 gap-6 min-h-0" id="workspace">
{/* Column Left: 候选智能体 (Grid weight 4 of 12) */}
<section className="lg:col-span-4 bg-white border border-[#ede9e9] rounded-xl flex flex-col overflow-hidden shadow-sm" id="candidate-column">
{/* Candidate Header */}
<div className="px-5 py-4 border-b border-[#ede9e9] flex items-center justify-between shrink-0 bg-[#faf9f9]">
<div className="flex items-center gap-2">
<span className="flex items-center justify-center w-5 h-5 rounded bg-[#fef4f3]">
<UserPlus className="w-3.5 h-3.5 text-[#c25555]" />
</span>
<h2 className="font-bold text-[#1f2937] text-sm tracking-wide">候选智能体</h2>
<span className="bg-[#fef4f3] text-[#c25555] text-xs font-bold px-2 py-0.5 rounded-full min-w-[20px] text-center select-none border border-[#fcdbda]">
{filteredCandidates.length}
</span>
</div>
<span className="text-[11px] text-[#8c96a3] font-medium tracking-tight">拖到右侧 / 点击加入</span>
</div>
{/* Search Bar with Creation Utility */}
<div className="p-4 border-b border-[#f4f1f1] flex items-center gap-2 shrink-0 bg-[#fdfdfd]">
<div className="relative flex-1">
<span className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<Search className="w-4 h-4 text-slate-400" />
</span>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="按名称搜索智能体..."
className="w-full pl-9 pr-3 py-2 text-xs border border-[#ede9e9] rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-[#c25555] focus:border-[#c25555] placeholder-slate-400 font-medium"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm("")}
className="absolute inset-y-0 right-0 flex items-center pr-2.5 text-slate-300 hover:text-slate-500"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Inline Custom Agent Launcher */}
<button
onClick={() => setShowAgentModal(true)}
title="创建自定义智能体角色"
className="flex items-center justify-center p-2 text-xs border border-dashed border-[#ede9e9] rounded-md hover:border-[#c25555] hover:text-[#c25555] hover:bg-[#fef4f3] transition shrink-0"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Candidates Scroll List */}
<div className="flex-1 overflow-y-auto p-4 space-y-2.5 scrollbar-thin" id="candidates-scroll">
{filteredCandidates.length === 0 ? (
<div className="py-12 px-4 text-center">
<p className="text-xs text-slate-400 font-medium">未找到符合搜索条件的智能体</p>
{searchTerm && (
<button
onClick={() => setSearchTerm("")}
className="mt-2 text-xs font-semibold text-[#c25555] underline hover:text-[#b44545]"
>
清除搜索
</button>
)}
</div>
) : (
filteredCandidates.map((agent) => {
const isAdded = activeChain.seats.includes(agent.id);
return (
<div
key={agent.id}
id={`candidate-card-${agent.id}`}
onClick={() => handleToggleAgent(agent.id)}
className={`group relative flex items-start gap-3 p-3.5 bg-white border rounded-lg cursor-pointer transition-all duration-150 ${
isAdded
? "border-[#fcdbda] bg-[#fdfdfd] shadow-[0_1px_3px_rgba(194,85,85,0.02)]"
: "border-[#ede9e9] hover:border-[#c25555] hover:shadow-[0_2px_8px_rgba(0,0,0,0.03)]"
}`}
>
{/* Left Icon status badge */}
<div className="mt-1 shrink-0">
<div className={`w-6 h-6 rounded-md flex items-center justify-center ${
isAdded ? "bg-[#fef4f3] text-[#c25555]" : "bg-slate-50 text-slate-400 group-hover:bg-[#fef4f3] group-hover:text-[#c25555]"
} transition-colors`}>
<Bot className="w-3.5 h-3.5" />
</div>
</div>
{/* Agent Details */}
<div className="flex-1 pr-14 min-w-0">
<div className="flex items-center gap-1.5 mb-1">
<h3 className="font-bold text-xs text-[#1f2937] leading-tight truncate group-hover:text-[#c25555] transition-colors">{agent.name}</h3>
{agent.isCustom && (
<span className="text-[10px] scale-95 font-medium px-1 bg-slate-50 text-slate-400 rounded border border-[#ede9e9]">自定义</span>
)}
</div>
<p className="text-[11px] text-[#717b88] leading-normal font-medium line-clamp-2">
{agent.description}
</p>
</div>
{/* Status Badge Action on Far Right */}
<div className="absolute top-3.5 right-3.5 select-none shrink-0 text-right">
{isAdded ? (
<span className="text-[10px] font-bold px-2 py-1 border border-[#fcdbda] bg-[#fef4f3] text-[#c25555] rounded">
已加入
</span>
) : (
<span className="text-[10px] font-semibold px-2 py-1 border border-transparent bg-slate-50 text-[#717b88] group-hover:bg-[#fef4f3] group-hover:text-[#c25555] group-hover:border-[#fcdbda] rounded transition-all">
点击加入
</span>
)}
{/* Custom agents get an extra trash deletion trigger */}
{agent.isCustom && (
<button
onClick={(e) => handleDeleteCustomAgent(e, agent.id)}
title="彻底删除此自定义智能体"
className="mt-1 block opacity-0 group-hover:opacity-100 hover:text-red-500 text-slate-300 transition-opacity p-1 ml-auto"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
})
)}
</div>
</section>
{/* Column Right: 链条席位 (Grid weight 8 of 12) */}
<section className="lg:col-span-8 bg-white border border-[#ede9e9] rounded-xl flex flex-col overflow-hidden shadow-sm" id="seats-column">
{/* Seats Header */}
<div className="px-5 py-4 border-b border-[#ede9e9] flex items-center justify-between shrink-0 bg-[#faf9f9]">
<div className="flex items-center gap-2">
<span className="flex items-center justify-center w-5 h-5 rounded bg-[#fef4f3]">
<ListOrdered className="w-3.5 h-3.5 text-[#c25555]" />
</span>
<h2 className="font-bold text-[#1f2937] text-sm tracking-wide">链条席位</h2>
<span className="inline-block px-2 py-0.5 text-[10px] font-bold bg-[#f5f2f2] text-[#717b88] rounded border border-[#ede9e9]">按顺序发言</span>
</div>
{/* Limit counter badge */}
<div className="flex items-center gap-1 text-xs select-none">
<span className="text-slate-400 font-medium">配置席位</span>
<span className={`px-2 py-0.5 font-bold rounded-md border ${
activeChain.seats.length > 0
? "bg-[#fef4f3] border-[#fcdbda] text-[#c25555]"
: "bg-slate-50 border-slate-200 text-slate-400"
}`}>
{activeChain.seats.length} / 8
</span>
</div>
</div>
{/* Seats Content Canvas */}
<div className="flex-1 overflow-y-auto p-5 space-y-3.5 scrollbar-thin" id="seats-scroll">
{activeChain.seats.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center py-20 px-8 border border-dashed border-[#ede9e9] rounded-xl bg-[#fdfdfd] m-1" id="empty-seats-place">
<div className="w-12 h-12 rounded-full bg-[#fef4f3] flex items-center justify-center text-[#c25555] mb-3">
<ListOrdered className="w-6 h-6" />
</div>
<h3 className="text-sm font-bold text-slate-700 mb-1">暂无席位</h3>
<p className="text-xs text-slate-400 text-center max-w-sm leading-relaxed mb-4">
该业务链条当前为空。请从左侧<b>“候选智能体”</b>列表中点击或者选择,将其按顺序编排加入此处。
</p>
<div className="flex flex-wrap justify-center gap-2 max-w-md">
{allAgents.slice(0, 3).map(agent => (
<button
key={agent.id}
onClick={() => handleToggleAgent(agent.id)}
className="px-2.5 py-1 text-[11px] font-semibold border border-[#ede9e9] hover:border-[#c25555] roundedbg-white hover:bg-slate-50 flex items-center gap-1 transition"
>
<span>💡</span>
<span>加入{agent.name}</span>
</button>
))}
</div>
</div>
) : (
<div className="space-y-3" id="seats-container-list">
{activeChain.seats.map((agentId, index) => {
const agent = allAgents.find(a => a.id === agentId);
if (!agent) return null;
const theme = SEAT_COLORS[index] || { bg: "bg-slate-50 border border-slate-200 text-slate-600" };
const isDragged = index === draggedIndex;
const isHoveredDrop = index === dragOverIndex;
return (
<div
key={`${agentId}-${index}`}
id={`seat-card-${index}`}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={() => { setDraggedIndex(null); setDragOverIndex(null); }}
onDrop={() => handleDrop(index)}
className={`flex items-stretch bg-white border rounded-lg shadow-sm transition-all duration-200 select-none ${
isDragged ? "opacity-35 scale-[0.98] border-dashed border-[#c25555]" : ""
} ${
isHoveredDrop ? "border-2 border-[#c25555] bg-[#fef4f3] scale-[1.01]" : "border-[#ede9e9]"
}`}
>
{/* Left Grip Handle */}
<div className="w-7 shrink-0 bg-[#faf9f9] border-r border-[#ede9e9] flex items-center justify-center rounded-l-lg select-none" id="grip-box">
<div
className="cursor-grab active:cursor-grabbing text-slate-400 hover:text-slate-600 p-1"
title="拖拽排序"
>
<GripVertical className="w-3.5 h-3.5" />
</div>
</div>
{/* Number Indicator Badge - Placed to the LEFT of details */}
<div className="flex items-center justify-center pl-2.5 shrink-0 select-none" id="seat-number-box">
<div className={`w-5.5 h-5.5 rounded flex items-center justify-center font-bold text-xs shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] select-none ${theme.bg}`}>
{index + 1}
</div>
</div>
{/* Seat info details */}
<div className="flex-1 py-2 pl-2.5 pr-2 min-w-0 flex flex-col justify-center">
<h4 className="font-bold text-xs text-[#1f2937] mb-0.5 flex items-center gap-1.5">
<span>{agent.name}</span>
{agent.isCustom && (
<span className="text-[9px] uppercase font-bold tracking-wider px-1 bg-slate-100 text-slate-400 rounded">Custom</span>
)}
</h4>
<p className="text-[11px] text-[#717b88] leading-tight font-medium line-clamp-1 truncate">
{agent.description}
</p>
</div>
{/* Seat controller Actions (Move up/down & close delete) */}
<div className="px-2 py-1.5 flex items-center gap-0.5 border-l border-[#f5f2f2] shrink-0" id="seat-controls">
{/* Up button */}
<button
onClick={() => moveDirection(index, "up")}
disabled={index === 0}
title="上移席位"
className={`p-1 rounded hover:bg-slate-100 transition ${
index === 0 ? "text-slate-200 pointer-events-none" : "text-slate-400 hover:text-[#1f2937]"
}`}
>
<ChevronUp className="w-3.5 h-3.5" />
</button>
{/* Down button */}
<button
onClick={() => moveDirection(index, "down")}
disabled={index === activeChain.seats.length - 1}
title="下移席位"
className={`p-1 rounded hover:bg-slate-100 transition ${
index === activeChain.seats.length - 1 ? "text-slate-200 pointer-events-none" : "text-slate-400 hover:text-[#1f2937]"
}`}
>
<ChevronDown className="w-3.5 h-3.5" />
</button>
{/* Cross Delete button */}
<button
onClick={() => handleToggleAgent(agent.id)}
title="移出此席位"
className="p-1 text-slate-300 hover:text-[#c25555] hover:bg-[#fef4f3] rounded transition-colors ml-0.5"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</section>
</main>
{/* 4. Float Helper Footer decoration */}
<footer className="py-3 px-6 border-t border-[#ede9e9] bg-white text-center text-[11px] text-slate-400 select-none shrink-0" id="footer-section">
编排有序的业务链条,让智能体决策更有深度。保存后,配置将自动同步至圆桌。
</footer>
{/* 5. Custom Agent Modal (Popup Card) */}
{showAgentModal && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-[1px] flex items-center justify-center z-50 animate-in fade-in duration-200" id="custom-agent-modal">
<div className="bg-white border border-[#ede9e9] rounded-xl shadow-xl max-w-md w-full mx-4 overflow-hidden animate-in zoom-in-95 duration-200">
{/* Modal Header */}
<div className="px-5 py-4 bg-[#faf9f9] border-b border-[#ede9e9] flex justify-between items-center">
<div className="flex items-center gap-2">
<span className="text-base">🧠</span>
<h3 className="font-bold text-sm text-[#1f2937]">添加自定义候选智能体</h3>
</div>
<button
onClick={() => setShowAgentModal(false)}
className="p-1 text-slate-400 hover:text-slate-600 rounded-md hover:bg-slate-100"
>
<X className="w-4.5 h-4.5" />
</button>
</div>
{/* Modal Form */}
<form onSubmit={handleAddCustomAgent} className="p-5 space-y-4">
<div>
<label className="block text-xs font-bold text-slate-600 mb-1.5">智能体名称 <span className="text-red-500">*</span></label>
<input
type="text"
required
placeholder="例如:市场公关专家"
value={newAgentName}
onChange={(e) => setNewAgentName(e.target.value)}
className="w-full px-3 py-2 text-xs border border-[#ede9e9] rounded-md focus:outline-none focus:ring-1 focus:ring-[#c25555] focus:border-[#c25555] placeholder-slate-400 font-medium"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1.5">职能介绍与描述</label>
<textarea
rows={3}
placeholder="描述其专业领域、面对对话时的应对策略及人格定位等"
value={newAgentDesc}
onChange={(e) => setNewAgentDesc(e.target.value)}
className="w-full px-3 py-2 text-xs border border-[#ede9e9] rounded-md focus:outline-none focus:ring-1 focus:ring-[#c25555] focus:border-[#c25555] placeholder-slate-400 font-medium resize-none"
/>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setShowAgentModal(false)}
className="px-3.5 py-1.5 text-xs text-[#4b5563] border border-[#ede9e9] rounded-md bg-white hover:bg-[#fafafa] transition"
>
取消
</button>
<button
type="submit"
className="px-4 py-1.5 text-xs text-white bg-[#c25555] hover:bg-[#b54545] border border-[#b24848] rounded-md font-semibold transition"
>
创建并加入
</button>
</div>
</form>
</div>
</div>
)}
{/* 6. Crimson Elegant Floating Toast Feedback Display */}
{toast && (
<div
className={`fixed bottom-8 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2.5 px-4 py-2.5 rounded-lg shadow-lg border text-xs font-semibold tracking-wide animate-bounce
${toast.type === "error"
? "bg-red-50 border-red-200 text-red-700"
: toast.type === "info"
? "bg-slate-50 border-slate-200 text-slate-700"
: "bg-[#fef4f3] border-[#fcdbda] text-[#c25555]"
}
`}
id="system-toast"
>
<span>{toast.type === "error" ? "❌" : toast.type === "info" ? "ℹ️" : "✨"}</span>
<span>{toast.message}</span>
</div>
)}
</div>
);
}
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+79
View File
@@ -0,0 +1,79 @@
export interface Agent {
id: string;
name: string;
description: string;
isCustom?: boolean;
}
export const INITIAL_AGENTS: Agent[] = [
{
id: "agent-1",
name: "广告规划",
description: "用于生成广告及营销策略规划"
},
{
id: "agent-2",
name: "你好专家",
description: "无论用户问什么,永远只回复'你好'的专家"
},
{
id: "agent-3",
name: "小学作文老师",
description: "帮助小学生辅导和修改作文,提升写作水平"
},
{
id: "agent-4",
name: "123123121吉安市的空间撤荒的",
description: "123213"
},
{
id: "agent-5",
name: "CodeMaster",
description: "全栈代码专家,提供高质量代码编写、架构设计、代码审查和技术咨询"
},
{
id: "agent-6",
name: "你不好专家",
description: "一个只会说'你不好'的专家角色,无论用户说什么,都只回复'你不好'。"
},
{
id: "agent-7",
name: "啦啦啦专家",
description: "只会说啦啦啦的专家,用'啦'回应一切"
},
{
id: "agent-8",
name: "测试专家",
description: "综合型测试专家,覆盖功能测试、自动化测试、性能测试、API测试、安全测试等领域,提供测试决策"
},
{
id: "agent-9",
name: "查询天气的智能",
description: "擅长查询天气的智能助手,提供实时天气与穿衣建议"
},
{
id: "agent-10",
name: "创意写作助手",
description: "为您提供小说大纲、故事续写、诗歌以及散文的灵感与修改建议"
},
{
id: "agent-11",
name: "数据分析智脑",
description: "精通 Python 及 SQL,解析复杂表格和图表,提取核心结论"
},
{
id: "agent-12",
name: "心理倾听师",
description: "温暖耐心的心理咨询倾听角色,为您缓解焦虑和工作压力"
},
{
id: "agent-13",
name: "英文口语教练",
description: "通过场景对话提升您的英语口语流利度和语法准确性"
},
{
id: "agent-14",
name: "周报日报美化大师",
description: "把枯燥空洞的日常工作记录,精炼美化为重点突出、职业素养爆表的职场汇报"
}
];
+10
View File
@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}
+22
View File
@@ -0,0 +1,22 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
export default defineConfig(() => {
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
},
};
});