Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
79d87ad661
29 files changed
+3220
-215
No files matched your search
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 |
|
||||
@@ -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 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
|
||||
|
||||
+57
@@ -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 种图表
|
||||
- ❌ 编造与原料明显矛盾的数据
|
||||
+3
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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`
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "业务链条配置",
|
||||
"description": "编排有序的智能体研讨链路配置系统。",
|
||||
"requestFramePermissions": [],
|
||||
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -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: "把枯燥空洞的日常工作记录,精炼美化为重点突出、职业素养爆表的职场汇报"
|
||||
}
|
||||
];
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 : {},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in new issue
Block a user