代码提交

This commit is contained in:
fengxiangdebaozi committed 2026-06-04 18:50:31 +08:00
1 parent 47217e9158
commit 9d863f63e5
33 files changed
+2878 -163

No files matched your search

+9 -5
View File
@@ -24,10 +24,16 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 我的笔记前端
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
@@ -35,5 +41,3 @@ VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
# VITE_CANVAS_LANGGRAPH_BASE_URL=
# 我的笔记是否显示,侧边栏
VITE_STUDIO_ENABLED=false
@@ -15,10 +15,15 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
+3
View File
@@ -21,6 +21,9 @@
# VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
# VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
# VITE_STUDIO_ENABLED=true # 仅控制“工作室”入口显示,列表内容仍依赖 notebook 接口数据
# --- 生产打包(见 .env.production)---
# 部署前修改 .env.production 中的地址,再执行 pnpm build
+32 -21
View File
@@ -1,32 +1,43 @@
# 内网 / 局域网生产打包(npm run build:network 或 pnpm build:network)
# 打包前请将 192.168.1.100 改为实际服务器 IP;变量会写入 dist,运行时不可再改
# 本地开发(pnpm dev)— 与原先 .env 保持一致
# 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高)
# Gateway / DeerFlow API(与 offline-backend DEPLOY 文档一致)
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://192.168.1.100:8000
# CMZS 后端接口
VITE_API_BASE_URL=http://192.168.1.100/zncm
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# CMZS后端接口
VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=http://192.168.1.100/gpt
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=http://192.168.1.100/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=http://192.168.1.100/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=http://192.168.1.100/knowlibrary
VITE_AIPI_B1COH_URL=http://192.168.1.100/web
VITE_AIPI_B2ACTOR_URL=http://192.168.1.100
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=http://192.168.1.100/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=http://192.168.1.100/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://192.168.1.100
VITE_AIPI_CONSUMER_URL=http://192.168.1.100/consumer
# MaxKey 鉴权(getTokenByOnlineTicket,内网部署请改为实际地址)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=http://192.168.1.100
VITE_AIPI_XKLINKTO_URL=http://192.168.1.100
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# 可选:Canvas 独立 LangGraph 地址
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
# VITE_CANVAS_LANGGRAPH_BASE_URL=
+4
View File
@@ -19,13 +19,17 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -3,3 +3,6 @@
# VITE_BACKEND_BASE_URL=https://your-intranet-host/deerflow
# VITE_LANGGRAPH_BASE_URL=https://your-intranet-host/deerflow/api
# VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# VITE_AIPI_BACKENP_URL=https://your-notebook-backend-host
# VITE_AIPI_FRONTEND_URL=https://your-notebook-frontend-host
# VITE_STUDIO_ENABLED=true
+5
View File
@@ -19,12 +19,17 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.sit.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.sit.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.sit.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -85,10 +85,13 @@ export function ArtifactFileDetail({
className,
filepath: filepathFromProps,
threadId,
onClose,
}: {
className?: string;
filepath: string;
threadId: string;
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
onClose?: () => void;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
@@ -623,7 +626,7 @@ export function ArtifactFileDetail({
className="hover:bg-background"
icon={XIcon}
label={t.common.close}
onClick={() => setOpen(false)}
onClick={() => (onClose ? onClose() : setOpen(false))}
tooltip={t.common.close}
/>
</ArtifactActions>
@@ -17,6 +17,7 @@ import { getBackendBaseURL } from "@/core/config";
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import type { JobStatus } from "@/roundtable-planning/lib/job-types";
/**
* Step 1 snapshot. Shapes for the chat / timeline lists stay loose (`unknown[]`)
@@ -80,6 +81,13 @@ export interface DraftMeta {
furthestStep: 1 | 2 | 3;
createdAt: string;
updatedAt: string;
/**
* Latest background-job status for this draft (Phase 6 history-dropdown
* loading badge). Undefined/null when the draft has never been backgrounded.
*/
status?: JobStatus | null;
/** Latest background job id (for opening the dispatch-chain modal / 查看). */
jobId?: string | null;
}
/** Full record including the step snapshots. */
@@ -129,6 +137,8 @@ interface ServerDraftMeta {
furthest_step: number;
created_at: string | null;
updated_at: string | null;
status?: string | null;
job_id?: string | null;
}
interface ServerDraftFull extends ServerDraftMeta {
@@ -145,6 +155,8 @@ function toMeta(s: ServerDraftMeta): DraftMeta {
furthestStep: step,
createdAt: s.created_at ?? "",
updatedAt: s.updated_at ?? "",
status: (s.status as JobStatus | null | undefined) ?? null,
jobId: s.job_id ?? null,
};
}
@@ -0,0 +1,195 @@
/**
* 圆桌后台作业 API 客户端(Phase 4)。
*
* 对接后端 `/api/roundtable-jobs`(Phase 3a):启动「后台挂起」作业、查快照、订阅
* SSE 进度、回答澄清续跑、取消。后端响应已是 camelCase(`RoundtableJobResponse`),
* 故无需 snake_case 转换;唯一加工是给 `dispatchChain` 节点补 `avatarType`(后端不下发,
* 由前端 `getAvatarTypeFor(agentId, order)` 派生,避免把配色表移植到后端而漂移)。
*
* SSE 走「后端轮询 job 表后下发整份快照」的模型(见后端注释):每帧 `data: {event:
* "progress", ...job}`,前端拿到的就是最新整份 `RoundtableJob`,直接渲染即可,无需做
* 增量事件归并。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
import {
isTerminalJobStatus,
type DispatchChainNode,
type RoundtableJob,
type StartJobPayload,
} from "../lib/job-types";
const BASE = () => `${getBackendBaseURL()}/api/roundtable-jobs`;
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${ctx} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
}
/** 给链路节点补 avatarType(后端不下发)。 */
function withAvatar(nodes: unknown): DispatchChainNode[] {
if (!Array.isArray(nodes)) return [];
return nodes.map((n, i) => {
const node = (n ?? {}) as Partial<DispatchChainNode> & { agentId?: string; order?: number };
const agentId = String(node.agentId ?? "");
const order = typeof node.order === "number" ? node.order : i + 1;
return {
agentId,
name: String(node.name ?? agentId),
order,
state: (node.state as DispatchChainNode["state"]) ?? "pending",
avatarType: node.avatarType || getAvatarTypeFor(agentId, order - 1),
...(node.lastSpokeCycle != null ? { lastSpokeCycle: node.lastSpokeCycle } : {}),
};
});
}
/** 后端快照 → 前端 RoundtableJob(容错 + 补 avatarType)。 */
function mapJob(raw: Record<string, unknown>): RoundtableJob {
return {
id: String(raw.id ?? ""),
draftId: (raw.draftId as string | null) ?? null,
status: (raw.status as RoundtableJob["status"]) ?? "queued",
phase: (raw.phase as RoundtableJob["phase"]) ?? "initializing",
cycle: typeof raw.cycle === "number" ? raw.cycle : 0,
dispatchChain: withAvatar(raw.dispatchChain),
activeAgentId: (raw.activeAgentId as string | null) ?? null,
consensusPercentage: typeof raw.consensusPercentage === "number" ? raw.consensusPercentage : 0,
pendingClarification: (raw.pendingClarification as string | null) ?? null,
orchestrationMode: (raw.orchestrationMode as RoundtableJob["orchestrationMode"]) ?? "recommend",
chain: (raw.chain as RoundtableJob["chain"]) ?? null,
error: (raw.error as string | null) ?? null,
createdAt: (raw.createdAt as string | null) ?? null,
updatedAt: (raw.updatedAt as string | null) ?? null,
};
}
function toStartBody(p: StartJobPayload) {
return {
draftId: p.draftId ?? null,
intent: p.intent,
agents: p.agents,
model: p.model,
orchestrationMode: p.orchestrationMode ?? "recommend",
chain: p.chain ?? null,
seedMessage: p.seedMessage,
threadIds: p.threadIds ?? null,
coordinatorName: p.coordinatorName ?? "roundtable-coordinator",
};
}
// ── CRUD / 控制 ───────────────────────────────────────────────────────────────
/** 启动一个后台作业。 */
export async function startJob(payload: StartJobPayload): Promise<RoundtableJob> {
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toStartBody(payload)),
});
return mapJob(await readJson<Record<string, unknown>>(res, "start job"));
}
/** 查作业快照。404 → null。 */
export async function getJob(jobId: string): Promise<RoundtableJob | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}`, { method: "GET" });
if (res.status === 404) return null;
return mapJob(await readJson<Record<string, unknown>>(res, "get job"));
}
/** 查某草稿最近一个作业。无 → null。 */
export async function getJobByDraft(draftId: string): Promise<RoundtableJob | null> {
const res = await apiFetch(`${BASE()}?draft_id=${encodeURIComponent(draftId)}`, { method: "GET" });
const data = await readJson<Record<string, unknown> | null>(res, "get job by draft");
return data ? mapJob(data) : null;
}
/** 回答澄清后续跑。 */
export async function resumeJob(jobId: string, answer: string): Promise<RoundtableJob> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/resume`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ answer }),
});
return mapJob(await readJson<Record<string, unknown>>(res, "resume job"));
}
/** 取消作业。 */
export async function cancelJob(jobId: string): Promise<RoundtableJob> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/cancel`, { method: "POST" });
return mapJob(await readJson<Record<string, unknown>>(res, "cancel job"));
}
// ── SSE 进度订阅 ─────────────────────────────────────────────────────────────
export interface StreamJobHandlers {
/** 每收到一帧整份快照就回调(含首帧)。 */
onProgress: (job: RoundtableJob) => void;
/** 终态(done/error/cancelled)时回调一次(在对应的 onProgress 之后)。 */
onDone?: (job: RoundtableJob) => void;
/** 流错误 / 作业不存在。 */
onError?: (detail: string) => void;
}
/**
* 订阅作业 SSE 进度。返回 unsubscribe(中断连接)。
*
* 终态帧后后端会主动关闭流;本函数也在收到终态快照时调 onDone,调用方可据此 unsubscribe。
*/
export function streamJob(jobId: string, handlers: StreamJobHandlers): () => void {
const controller = new AbortController();
void (async () => {
try {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/stream`, {
method: "GET",
signal: controller.signal,
});
if (!res.ok || !res.body) {
handlers.onError?.(`stream failed (${res.status})`);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const raw of lines) {
const line = raw.trim();
if (!line.startsWith("data:")) continue;
const json = line.slice("data:".length).trim();
if (!json) continue;
let frame: Record<string, unknown>;
try {
frame = JSON.parse(json) as Record<string, unknown>;
} catch {
continue;
}
if (frame.event === "error") {
handlers.onError?.(String(frame.detail ?? "unknown stream error"));
continue;
}
const job = mapJob(frame);
handlers.onProgress(job);
if (isTerminalJobStatus(job.status)) handlers.onDone?.(job);
}
}
} catch (err) {
if (!controller.signal.aborted) {
handlers.onError?.(err instanceof Error ? err.message : String(err));
}
}
})();
return () => controller.abort();
}
@@ -0,0 +1,59 @@
import { FileText, Loader2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { downloadMarkdownDocxFromBackend } from "@/core/api/markdown-docx-export";
import { cn } from "@/lib/utils";
export function ConsensusDocxDownloadButton({
markdown,
title = "圆桌总控最终结论",
className,
size = "sm",
}: {
markdown: string;
title?: string;
className?: string;
size?: "sm" | "md";
}) {
const [busy, setBusy] = useState(false);
const canExport = Boolean(markdown?.trim());
const handleClick = async () => {
if (!canExport) {
toast.error("暂无可导出的结论内容");
return;
}
setBusy(true);
try {
await downloadMarkdownDocxFromBackend({ title, markdown });
toast.success("Word 文件已生成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "导出 Word 失败");
} finally {
setBusy(false);
}
};
return (
<button
type="button"
onClick={() => void handleClick()}
disabled={!canExport || busy}
title={canExport ? "下载为 Word" : "暂无结论内容"}
className={cn(
"inline-flex items-center gap-1 rounded-xl border border-slate-200 bg-white font-bold text-slate-700 transition",
"hover:bg-slate-50 hover:border-primary/30 disabled:cursor-not-allowed disabled:opacity-50",
size === "sm" ? "px-2.5 py-1 text-[10px]" : "px-3 py-1.5 text-[10.5px]",
className,
)}
>
{busy ? (
<Loader2 className={cn("animate-spin", size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5")} />
) : (
<FileText className={cn(size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5", "text-primary")} />
)}
下载 Word
</button>
);
}
@@ -0,0 +1,92 @@
/**
* 派活链路流程图弹窗 · 真后台作业容器(Phase 6/8)。
*
* 给定 ``jobId``:先拉一帧快照让弹窗立刻有内容,随后订阅 SSE(``streamJob``)实时刷新,
* 把最新 ``RoundtableJob`` 喂给纯展示组件 ``DispatchChainModal``。关闭/卸载时退订。
*
* ``onView`` 透传给 footer「查看」按钮(Phase 8 接「进入正在进行的任务」)。
*/
import { useEffect, useState } from "react";
import { DispatchChainModal } from "./DispatchChainModal";
import { cancelJob, getJob, resumeJob, streamJob } from "../api/roundtable-jobs";
import type { RoundtableJob } from "../lib/job-types";
export interface DispatchChainJobModalProps {
jobId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
title?: string;
onView?: (job: RoundtableJob) => void;
}
export function DispatchChainJobModal({
jobId,
open,
onOpenChange,
title,
onView,
}: DispatchChainJobModalProps) {
const [job, setJob] = useState<RoundtableJob | null>(null);
useEffect(() => {
if (!open || !jobId) {
setJob(null);
return;
}
let cancelled = false;
// 首帧快照:弹窗一打开就有内容,不必等 SSE 第一帧。
void getJob(jobId)
.then((j) => {
if (!cancelled && j) setJob(j);
})
.catch(() => {});
const unsubscribe = streamJob(jobId, {
onProgress: (j) => {
if (!cancelled) setJob(j);
},
onError: () => {
/* 流错误静默:保留已拿到的最后一帧;可后续加 toast */
},
});
return () => {
cancelled = true;
unsubscribe();
};
}, [open, jobId]);
return (
<DispatchChainModal
open={open}
onOpenChange={onOpenChange}
title={title}
status={job?.status ?? "queued"}
phase={job?.phase ?? "initializing"}
cycle={job?.cycle ?? 0}
nodes={job?.dispatchChain ?? []}
consensusPercentage={job?.consensusPercentage ?? 0}
onView={job && onView ? () => onView(job) : undefined}
error={job?.error}
pendingClarification={job?.pendingClarification}
onResume={
jobId
? (answer) => {
// 乐观更新为 running,随后 SSE 会推真状态续上。
void resumeJob(jobId, answer)
.then((j) => setJob(j))
.catch(() => {});
}
: undefined
}
onCancel={
jobId
? () => {
void cancelJob(jobId)
.then((j) => setJob(j))
.catch(() => {});
}
: undefined
}
/>
);
}
@@ -14,8 +14,8 @@
* (business-chain.ts)。连线动画 CSS 在 `styles/roundtable-planning.css` 全局段
* (Radix Dialog portal 到 body,不在 .roundtable-planning 作用域内,故用全局类 + 语义 token)。
*/
import { Fragment } from "react";
import { CheckIcon, ChevronRightIcon, EyeIcon, Loader2Icon } from "lucide-react";
import { Fragment, useState } from "react";
import { CheckIcon, ChevronRightIcon, EyeIcon, Loader2Icon, SendIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -52,10 +52,20 @@ export interface DispatchChainModalProps {
nodes: DispatchChainNode[];
/** 共识百分比(0..100)。 */
consensusPercentage: number;
/** 「查看」按钮回调 → 进入正在进行的任务。 */
/** 「查看」按钮回调 → 进入正在进行的任务 / 查看报告。 */
onView?: () => void;
/** 命中澄清时的问题文本(status==='awaiting_input')。 */
pendingClarification?: string | null;
/** 提交澄清回答 → 续跑。 */
onResume?: (answer: string) => void;
/** 停止/取消作业。 */
onCancel?: () => void;
/** 出错详情(status==='error')。 */
error?: string | null;
}
const _NON_TERMINAL: ReadonlySet<JobStatus> = new Set<JobStatus>(["queued", "running", "awaiting_input"]);
const STATUS_LABEL: Record<JobStatus, string> = {
queued: "排队中",
running: "进行中",
@@ -85,7 +95,12 @@ export function DispatchChainModal({
nodes,
consensusPercentage,
onView,
pendingClarification,
onResume,
onCancel,
error,
}: DispatchChainModalProps) {
const [answer, setAnswer] = useState("");
// 总控节点:初始化 / 决策派活阶段高亮
const coordinatorState: ChainNodeState =
phase === "initializing" || phase === "leader_thinking" ? "active" : "done";
@@ -177,6 +192,47 @@ export function DispatchChainModal({
</div>
</div>
{/* 出错详情 */}
{status === "error" && error && (
<div className="shrink-0 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{error}
</div>
)}
{/* 澄清作答(status==='awaiting_input')→ resumeJob 续跑 */}
{status === "awaiting_input" && pendingClarification && onResume && (
<div className="shrink-0 pt-3">
<div className="rounded-xl border border-amber-300/50 bg-amber-50/60 p-3 dark:border-amber-700/40 dark:bg-amber-950/20">
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold text-amber-600 dark:text-amber-400">
<span className="inline-block size-1.5 rounded-full bg-amber-400 animate-pulse" />
总控需要补充信息,请回答后继续
</div>
<p className="mb-2 whitespace-pre-wrap text-sm text-foreground/80">{pendingClarification}</p>
<textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
rows={2}
placeholder="输入你的回答…"
className="mb-2 w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary/30"
/>
<div className="flex justify-end">
<Button
size="sm"
disabled={!answer.trim()}
onClick={() => {
onResume(answer.trim());
setAnswer("");
}}
className="gap-1.5"
>
<SendIcon className="size-3.5" />
提交并继续
</Button>
</div>
</div>
</div>
)}
<DialogFooter className="shrink-0 flex-row items-center justify-between gap-2 border-t pt-3 sm:justify-between">
<span className="text-xs text-muted-foreground">
轮次 {Math.min(cycle, maxCycles)}/{maxCycles}
@@ -185,10 +241,17 @@ export function DispatchChainModal({
<span className="mx-2 text-muted-foreground/40">·</span>
{PHASE_LABEL[phase]}
</span>
<Button onClick={onView} disabled={!onView} className="gap-1.5">
<EyeIcon className="size-4" />
查看
</Button>
<div className="flex items-center gap-2">
{onCancel && _NON_TERMINAL.has(status) && (
<Button variant="outline" size="sm" onClick={onCancel} className="text-muted-foreground">
停止
</Button>
)}
<Button onClick={onView} disabled={!onView} className="gap-1.5">
<EyeIcon className="size-4" />
{status === "done" ? "查看报告" : "查看"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -21,7 +21,8 @@ import {
type SessionArtifactFile,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { Step3FileBrowseMode } from "@/roundtable-planning/components/Step3FileBrowseMode";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import {
Step3ReportMode,
@@ -30,20 +31,6 @@ import {
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
/**
* Text-ish files we render in an in-app preview modal. Other types (pdf,
* images, html) keep the original window.open behaviour so the browser's
* native viewer handles them.
*/
function shouldPreviewInModal(file: { name: string; mimeType: string | null }): boolean {
const mime = file.mimeType ?? "";
if (mime === "text/markdown" || mime === "text/plain" || mime === "text/csv" || mime === "application/json") {
return true;
}
if (mime.startsWith("text/")) return true;
return /\.(md|markdown|txt|json|csv|log|yml|yaml|xml)$/i.test(file.name);
}
interface SeatDelivery {
/** Display name of the seat (e.g. "情报收集"). */
sender: string;
@@ -78,6 +65,8 @@ interface HighFidelityReportProps {
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
/** Resets state and navigates back to Step 1. */
onReset: () => void;
openArtifactPreview: (threadId: string, path: string) => void;
seatStubMessages: Record<string, unknown[]>;
}
interface SeatArtifacts {
@@ -116,9 +105,23 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
savedReport,
onReportSaved,
onReset,
openArtifactPreview,
seatStubMessages,
}) => {
// 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 HTML。
const consensusDocTitle = useMemo(() => {
const objective = intentReady?.objective?.trim();
if (!objective) return "圆桌总控最终结论";
const short = objective.length > 40 ? `${objective.slice(0, 40)}…` : objective;
return `圆桌总控结论-${short}`;
}, [intentReady?.objective]);
const [drawMode, setDrawMode] = useState(false);
const [fileSandboxMode, setFileSandboxMode] = useState(false);
const [fileSandboxTarget, setFileSandboxTarget] = useState<{
threadId: string;
file: SessionArtifactFile;
} | null>(null);
// 进入第三步即关闭并清空第二步遗留的沙箱(artifacts 面板全局共享,第二步打开后
// 不会自动关)。点「结果绘制」后 Step3ReportMode 会按需开自己的沙箱,互不串扰。
@@ -151,10 +154,6 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
const [artifactsBySeat, setArtifactsBySeat] = useState<SeatArtifacts[]>([]);
const [expandedSeats, setExpandedSeats] = useState<Set<string>>(new Set());
const [reloadKey, setReloadKey] = useState(0);
// Active preview target — non-null means the modal is open. We carry the
// owning threadId so the modal can fetch + build download URLs without
// needing a second prop drill.
const [previewing, setPreviewing] = useState<{ threadId: string; file: SessionArtifactFile } | null>(null);
const generatedAt = useMemo(() => formatGeneratedAt(new Date()), []);
// Fetch artifact listings for every seat thread in parallel. Re-runs when
@@ -222,23 +221,16 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
});
}, []);
const handlePreview = useCallback(
(threadId: string, file: SessionArtifactFile) => {
// Text-ish artifacts (markdown / txt / json / csv / yaml / ...) open
// in our in-app modal so the user can read the content without leaving
// the planning page. Binary or active content (pdf / png / html) still
// hands off to the browser's native viewer via the original tab.
if (shouldPreviewInModal({ name: file.name, mimeType: file.mimeType })) {
setPreviewing({ threadId, file });
return;
}
if (typeof window !== "undefined") {
const url = buildArtifactDownloadUrl(threadId, file.path);
window.open(url, "_blank", "noopener,noreferrer");
}
},
[],
);
const handleViewInSandbox = useCallback((threadId: string, file: SessionArtifactFile) => {
setFileSandboxTarget({ threadId, file });
setFileSandboxMode(true);
}, []);
const handleBackFromFileSandbox = useCallback(() => {
setFileSandboxMode(false);
setFileSandboxTarget(null);
deselectArtifact();
}, [deselectArtifact]);
const handleDownload = useCallback(
(threadId: string, file: SessionArtifactFile) => {
@@ -250,6 +242,22 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
[],
);
if (fileSandboxMode && fileSandboxTarget) {
return (
<Step3FileBrowseMode
consensusText={consensusText}
hasConsensus={hasConsensus}
consensusDocTitle={consensusDocTitle}
seatDeliveries={seatDeliveries}
artifactsBySeat={artifactsBySeat}
seatStubMessages={seatStubMessages}
openArtifactPreview={openArtifactPreview}
initialFile={fileSandboxTarget}
onBack={handleBackFromFileSandbox}
/>
);
}
if (drawMode) {
return (
<Step3ReportMode
@@ -351,10 +359,17 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
<div className="p-6 space-y-6">
{/* Section 1: Coordinator consensus conclusion */}
<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">
<CheckCircle className="w-4 h-4 text-emerald-500" />
总控最终结论
</h4>
<div className="mb-3 flex items-center justify-between gap-2">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest flex items-center gap-2 min-w-0">
<CheckCircle className="w-4 h-4 text-emerald-500 shrink-0" />
<span className="truncate">总控最终结论</span>
</h4>
<ConsensusDocxDownloadButton
markdown={consensusText}
title={consensusDocTitle}
className="shrink-0"
/>
</div>
{consensusText ? (
<div className="text-xs text-slate-700 leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
@@ -420,7 +435,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
)}
</section>
{/* Section 3: Generated files per seat —— 各席位产出的 md 文件,点击预览弹窗查看内容 */}
{/* Section 3: Generated files per seat —— 点击「查看」进入沙箱分栏预览 */}
<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" />
@@ -475,9 +490,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
</span>
<button
type="button"
onClick={() => handlePreview(seat.threadId, f)}
onClick={() => handleViewInSandbox(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="预览"
title="在沙箱中查看"
>
<Eye className="w-3.5 h-3.5" />
</button>
@@ -515,15 +530,6 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
</div>
</div>
{previewing && (
<ArtifactPreviewModal
threadId={previewing.threadId}
path={previewing.file.path}
name={previewing.file.name}
mimeType={previewing.file.mimeType}
onClose={() => setPreviewing(null)}
/>
)}
</div>
);
};
@@ -23,6 +23,7 @@ import {
Loader2,
Pause,
Play,
Rocket,
Sparkles,
} from "lucide-react";
@@ -98,6 +99,8 @@ export interface Step2PanelProps {
onSubmitClarification: (answer: string) => void;
onSavePersona: (id: number) => void;
onProceedToStep3: () => void;
/** 「后台挂起」:把当前研讨转入后端自动跑到结果绘制完成。 */
onBackgroundSuspend: () => void;
onTriggerToast: (msg: string) => void;
}
@@ -133,6 +136,7 @@ export function Step2Panel(props: Step2PanelProps) {
onSubmitClarification,
onSavePersona,
onProceedToStep3,
onBackgroundSuspend,
onTriggerToast,
} = props;
@@ -365,6 +369,16 @@ export function Step2Panel(props: Step2PanelProps) {
<div className="h-6 w-[1px] bg-slate-200 shrink-0" />
<button
type="button"
onClick={onBackgroundSuspend}
title="把当前研讨转入后台自动跑到结果绘制完成;可离开页面,进度在历史记录里查看"
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-700 text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 cursor-pointer dark:bg-secondary dark:text-foreground dark:border-border"
>
<Rocket className="w-3.5 h-3.5 text-primary" />
后台挂起
</button>
<button
type="button"
onClick={onProceedToStep3}
@@ -27,11 +27,14 @@ import { cn } from "@/lib/utils";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 60, artifacts: 40 };
interface Step2SandboxLayoutProps {
children: React.ReactNode;
children?: React.ReactNode;
/** 当前预览文件所属的席位 thread id。 */
activeThreadId: string | null;
/** 为 true 时右侧区域仅展示沙箱预览,占满可用宽度(无中间说明/对话区)。 */
artifactsFill?: boolean;
/** artifactsFill 模式下用户点击沙箱关闭时的回调(退出预览、收起沙箱)。 */
onArtifactsClose?: () => void;
/** 是否有流式 run 进行中 —— 开启 artifacts API 轮询。 */
isStreaming?: boolean;
/**
@@ -43,10 +46,12 @@ interface Step2SandboxLayoutProps {
}
export function Step2SandboxLayout({
children,
children = null,
activeThreadId,
isStreaming = false,
seatMessages,
artifactsFill = false,
onArtifactsClose,
}: Step2SandboxLayoutProps) {
const layoutRef = useRef<GroupImperativeHandle>(null);
const {
@@ -54,18 +59,22 @@ export function Step2SandboxLayout({
open: artifactsOpen,
setOpen: setArtifactsOpen,
selectedArtifact,
deselect,
} = useArtifacts();
const artifacts = rawArtifacts ?? [];
const artifactPanelOpen = artifactsOpen;
useEffect(() => {
if (layoutRef.current) {
layoutRef.current.setLayout(
artifactPanelOpen ? OPEN_MODE : CLOSE_MODE,
);
}
}, [artifactPanelOpen]);
if (artifactsFill || !layoutRef.current) return;
layoutRef.current.setLayout(artifactPanelOpen ? OPEN_MODE : CLOSE_MODE);
}, [artifactPanelOpen, artifactsFill]);
const handleArtifactsClose = () => {
deselect();
setArtifactsOpen(false);
onArtifactsClose?.();
};
const threadStub = useMemo(() => {
const messages = seatMessages ?? [];
@@ -83,6 +92,70 @@ export function Step2SandboxLayout({
const previewThreadId = activeThreadId ?? "";
const artifactPanelBody = (
<div
className={cn(
"h-full bg-[#fffdfa] p-4",
!artifactsFill &&
"transition-transform duration-300 ease-in-out",
!artifactsFill && (artifactPanelOpen ? "translate-x-0" : "translate-x-full"),
)}
>
{selectedArtifact && previewThreadId ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={previewThreadId}
onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined}
/>
) : (
<div className="relative flex size-full justify-center">
{!artifactsFill && (
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => setArtifactsOpen(false)}
>
<XIcon />
</Button>
</div>
)}
{artifacts.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="未选择文件"
description="智能体写入文件后将在此预览"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">产出文件</h2>
</header>
<main className="min-h-0 grow">
{previewThreadId ? (
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={artifacts}
threadId={previewThreadId}
/>
) : null}
</main>
</div>
)}
</div>
)}
</div>
);
if (artifactsFill) {
return (
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
<div className="h-full min-h-0 flex-1 bg-[#fffdfa]">{artifactPanelBody}</div>
</ThreadContext.Provider>
);
}
return (
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
<ResizablePanelGroup
@@ -109,54 +182,7 @@ export function Step2SandboxLayout({
)}
id="artifacts"
>
<div
className={cn(
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
)}
>
{selectedArtifact && previewThreadId ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={previewThreadId}
/>
) : (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => setArtifactsOpen(false)}
>
<XIcon />
</Button>
</div>
{artifacts.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="未选择文件"
description="智能体写入文件后将在此预览"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">产出文件</h2>
</header>
<main className="min-h-0 grow">
{previewThreadId ? (
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={artifacts}
threadId={previewThreadId}
/>
) : null}
</main>
</div>
)}
</div>
)}
</div>
{artifactPanelBody}
</ResizablePanel>
</ResizablePanelGroup>
</ThreadContext.Provider>
@@ -0,0 +1,351 @@
/**
* Step 3「文件沙箱预览」模式 —— 布局对齐「结果绘制」:左栏研讨内容 + 文件清单,右栏 Step 2 沙箱。
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
ArrowLeft,
CheckCircle,
Download,
Eye,
FileText,
Loader2,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { cn } from "@/lib/utils";
import {
buildArtifactDownloadUrl,
type SessionArtifactFile,
} from "@/roundtable-planning/api/multi-agent";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
interface SeatDelivery {
sender: string;
content: string;
}
interface SeatArtifacts {
agentName: string;
displayName: string;
threadId: string;
files: SessionArtifactFile[];
loading: boolean;
error: string | null;
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export interface Step3FileBrowseModeProps {
consensusText: string;
hasConsensus: boolean;
consensusDocTitle: string;
seatDeliveries: SeatDelivery[];
artifactsBySeat: SeatArtifacts[];
seatStubMessages: Record<string, unknown[]>;
openArtifactPreview: (threadId: string, path: string) => void;
/** 进入模式时默认打开的文件(由「查看」触发)。 */
initialFile: { threadId: string; file: SessionArtifactFile };
onBack: () => void;
}
export function Step3FileBrowseMode({
consensusText,
hasConsensus,
consensusDocTitle,
seatDeliveries,
artifactsBySeat,
seatStubMessages,
openArtifactPreview,
initialFile,
onBack,
}: Step3FileBrowseModeProps) {
const [activeFile, setActiveFile] = useState(initialFile);
const [leftCollapsed, setLeftCollapsed] = useState(false);
useEffect(() => {
openArtifactPreview(activeFile.threadId, activeFile.file.path);
}, [activeFile.file.path, activeFile.threadId, openArtifactPreview]);
const handleSelectFile = useCallback(
(threadId: string, file: SessionArtifactFile) => {
setActiveFile({ threadId, file });
openArtifactPreview(threadId, file.path);
},
[openArtifactPreview],
);
const handleDownload = useCallback((threadId: string, file: SessionArtifactFile) => {
const url = buildArtifactDownloadUrl(threadId, file.path, { download: true });
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
}, []);
const activeSeatMessages = useMemo(
() => seatStubMessages[activeFile.threadId] ?? [],
[seatStubMessages, activeFile.threadId],
);
const activeDisplayName = useMemo(() => {
const seat = artifactsBySeat.find((s) => s.threadId === activeFile.threadId);
return seat?.displayName ?? "席位";
}, [artifactsBySeat, activeFile.threadId]);
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 min-w-0">
<FileText className="w-5 h-5 text-primary shrink-0" />
<h3 className="text-sm font-black text-slate-900 tracking-tight truncate">
文件预览 · 沙箱查看
</h3>
<span className="text-[10px] font-mono text-slate-500 bg-slate-100 px-2 py-0.5 rounded-lg truncate max-w-[240px]">
{activeDisplayName} / {activeFile.file.name}
</span>
</div>
<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 shrink-0"
>
<ArrowLeft className="w-3.5 h-3.5 text-slate-500" />
返回报告
</button>
</div>
<div className="flex-1 min-h-0 p-3">
{leftCollapsed ? (
<div className="h-full min-h-0 flex gap-3">
<aside className="w-[52px] shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5">
<button
type="button"
onClick={() => setLeftCollapsed(false)}
title="展开侧栏"
className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
<CollapsedIconButton
icon={<CheckCircle className="w-4 h-4 text-emerald-500" />}
title="总控最终结论"
onClick={() => setLeftCollapsed(false)}
/>
<CollapsedIconButton
icon={<FileText className="w-4 h-4 text-primary" />}
title="生成的报告文件"
onClick={() => setLeftCollapsed(false)}
/>
</aside>
<section className="flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-[#fffdfa] overflow-hidden">
<Step2SandboxLayout
artifactsFill
activeThreadId={activeFile.threadId}
seatMessages={activeSeatMessages}
onArtifactsClose={onBack}
/>
</section>
</div>
) : (
<ResizablePanelGroup
orientation="horizontal"
className="h-full min-h-0 gap-3"
autoSaveId="roundtable-step3-file-browse-layout"
>
<ResizablePanel defaultSize={42} minSize={26} className="min-w-0">
<aside className="h-full min-h-0 flex flex-col gap-3 overflow-y-auto pr-0.5">
<div className="shrink-0 flex items-center justify-between gap-2 px-1">
<span className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">
方案内容
</span>
<button
type="button"
onClick={() => setLeftCollapsed(true)}
title="收起侧栏"
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</button>
</div>
<section className="rounded-2xl border border-slate-200/60 bg-white p-3.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="mb-2 flex items-center justify-between gap-2">
<h4 className="text-xs font-bold text-slate-900 flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
总控最终结论
</h4>
{hasConsensus && (
<span className="text-[9px] font-bold text-emerald-700 bg-emerald-50 border border-emerald-200 px-1.5 py-0.5 rounded-full">
已共识
</span>
)}
</div>
<div className="mb-2 flex justify-end">
<ConsensusDocxDownloadButton
markdown={consensusText}
title={consensusDocTitle}
size="sm"
/>
</div>
{consensusText ? (
<div className="max-h-32 overflow-y-auto text-[11px] text-slate-700 leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText}
</ReactMarkdown>
</div>
) : (
<p className="text-[11px] text-slate-400 italic">暂无总控结论。</p>
)}
</section>
{seatDeliveries.length > 0 && (
<section className="rounded-2xl border border-slate-200/60 bg-white p-3.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<h4 className="text-xs font-bold text-slate-900 mb-2">
各席位交付
<span className="ml-1.5 text-[10px] font-mono text-slate-400">
{seatDeliveries.length}
</span>
</h4>
<ul className="space-y-1 max-h-24 overflow-y-auto">
{seatDeliveries.map((s, i) => (
<li key={`${s.sender}-${i}`} className="text-[11px] text-slate-600 truncate">
{s.sender}
</li>
))}
</ul>
</section>
)}
<section className="rounded-2xl border border-slate-200/60 bg-white p-3.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 min-h-0 flex flex-col">
<h4 className="text-xs font-bold text-slate-900 mb-2 flex items-center gap-1.5 shrink-0">
<Download className="w-3.5 h-3.5 text-primary" />
生成的报告文件
</h4>
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
{artifactsBySeat.map((seat) => (
<div key={seat.agentName} className="border border-slate-150 rounded-xl overflow-hidden">
<div className="px-2.5 py-1.5 bg-slate-50 border-b border-slate-100 text-[10.5px] font-bold text-slate-800 truncate">
{seat.displayName}
</div>
{seat.loading ? (
<div className="flex items-center gap-1.5 px-2.5 py-2 text-[10px] text-slate-500">
<Loader2 className="w-3 h-3 animate-spin" />
加载中…
</div>
) : seat.error ? (
<p className="px-2.5 py-2 text-[10px] text-rose-600">{seat.error}</p>
) : seat.files.length === 0 ? (
<p className="px-2.5 py-2 text-[10px] text-slate-400 italic">无文件</p>
) : (
<ul className="divide-y divide-slate-100">
{seat.files.map((f) => {
const isActive =
activeFile.threadId === seat.threadId &&
activeFile.file.path === f.path;
return (
<li key={f.path}>
<div
className={cn(
"px-2 py-1.5 flex items-center justify-between gap-1",
isActive && "bg-[var(--brand-200)]/25",
)}
>
<button
type="button"
onClick={() => handleSelectFile(seat.threadId, f)}
className="flex items-center gap-1.5 min-w-0 text-left flex-1"
title={f.name}
>
<FileText className="w-3 h-3 text-slate-400 shrink-0" />
<span className="text-[10.5px] font-medium text-slate-800 truncate">
{f.name}
</span>
</button>
<div className="flex items-center gap-0.5 shrink-0">
<button
type="button"
onClick={() => handleSelectFile(seat.threadId, f)}
className={cn(
"p-1 rounded transition",
isActive
? "text-primary bg-white/80"
: "text-slate-500 hover:text-primary hover:bg-slate-100",
)}
title="在沙箱中查看"
>
<Eye className="w-3 h-3" />
</button>
<button
type="button"
onClick={() => handleDownload(seat.threadId, f)}
className="text-slate-500 hover:text-primary p-1 rounded hover:bg-slate-100 transition"
title="下载"
>
<Download className="w-3 h-3" />
</button>
</div>
</div>
</li>
);
})}
</ul>
)}
</div>
))}
</div>
</section>
</aside>
</ResizablePanel>
<ResizableHandle className="w-2 rounded-full bg-slate-200/70 hover:bg-slate-300 transition" />
<ResizablePanel defaultSize={58} minSize={30} className="min-w-0">
<section className="h-full min-h-0 rounded-2xl border border-slate-200/60 bg-[#fffdfa] overflow-hidden">
<Step2SandboxLayout
artifactsFill
activeThreadId={activeFile.threadId}
seatMessages={activeSeatMessages}
onArtifactsClose={onBack}
/>
</section>
</ResizablePanel>
</ResizablePanelGroup>
)}
</div>
</div>
);
}
function CollapsedIconButton({
icon,
title,
onClick,
}: {
icon: React.ReactNode;
title: string;
onClick: () => void;
}) {
return (
<button
type="button"
title={title}
onClick={onClick}
className="w-full shrink-0 flex items-center justify-center rounded-2xl border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30"
>
{icon}
</button>
);
}
@@ -35,6 +35,9 @@ export interface Step3PanelProps {
savedReport?: DraftStep3Snapshot | null;
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
onReset: () => void;
/** Step 2 沙箱预览产出文件(与第二步相同)。 */
openArtifactPreview: (threadId: string, path: string) => void;
seatStubMessages: Record<string, unknown[]>;
}
export function Step3Panel(props: Step3PanelProps) {
@@ -53,6 +56,8 @@ export function Step3Panel(props: Step3PanelProps) {
savedReport,
onReportSaved,
onReset,
openArtifactPreview,
seatStubMessages,
} = props;
const seatDeliveries = step2RoundtableDialogues
@@ -83,6 +88,8 @@ export function Step3Panel(props: Step3PanelProps) {
savedReport={savedReport}
onReportSaved={onReportSaved}
onReset={onReset}
openArtifactPreview={openArtifactPreview}
seatStubMessages={seatStubMessages}
/>
);
}
@@ -36,6 +36,7 @@ import {
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
@@ -100,6 +101,13 @@ export function Step3ReportMode({
[seatDeliveries],
);
const consensusDocTitle = useMemo(() => {
const objective = intentReady?.objective?.trim();
if (!objective) return "圆桌总控最终结论";
const short = objective.length > 40 ? `${objective.slice(0, 40)}…` : objective;
return `圆桌总控结论-${short}`;
}, [intentReady?.objective]);
const {
phase,
error,
@@ -316,7 +324,16 @@ export function Step3ReportMode({
{/* 弹窗 */}
{modal === "consensus" && (
<ContentModal title="总控最终结论" onClose={() => setModal(null)}>
<ContentModal
title="总控最终结论"
onClose={() => setModal(null)}
headerAction={
<ConsensusDocxDownloadButton
markdown={consensusText}
title={consensusDocTitle}
/>
}
>
{consensusText ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText}
@@ -543,10 +560,12 @@ function ContentModal({
title,
children,
onClose,
headerAction,
}: {
title: string;
children: React.ReactNode;
onClose: () => void;
headerAction?: React.ReactNode;
}) {
return (
<div
@@ -557,15 +576,18 @@ function ContentModal({
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 className="shrink-0 flex items-center justify-between gap-2 px-5 py-3.5 border-b border-slate-100 bg-slate-50">
<h3 className="text-sm font-bold text-slate-900 min-w-0 truncate">{title}</h3>
<div className="flex items-center gap-2 shrink-0">
{headerAction}
<button
type="button"
onClick={onClose}
className="p-1 text-slate-400 hover:text-slate-600 rounded-md hover:bg-slate-100"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-5">{children}</div>
</div>
@@ -29,6 +29,7 @@ import {
updateDraft,
type DraftStep3Snapshot,
} from "@/roundtable-planning/api/drafts";
import type { JobStatus } from "@/roundtable-planning/lib/job-types";
/** 历史下拉渲染用的轻量列表项(`name` 映射自后端 `title`)。 */
export interface DraftListItem {
@@ -37,6 +38,10 @@ export interface DraftListItem {
furthestStep: 1 | 2 | 3;
createdAt: string;
updatedAt: string;
/** 最近一次后台作业状态(Phase 6 历史下拉「进行中」loading)。 */
status?: JobStatus | null;
/** 最近一次后台作业 id(点击进行中项打开流程图弹窗用)。 */
jobId?: string | null;
}
export interface UseDraftPersistenceOptions {
@@ -101,6 +106,8 @@ export interface UseDraftPersistenceReturn {
* 推荐弹窗打开前调用,确保推荐历史有 draft_id 可归属。
*/
ensureDraft: (furthestStep?: 1 | 2 | 3) => Promise<string | null>;
/** 强制刷新草稿列表。 */
refresh: () => Promise<void>;
}
export function useDraftPersistence(
@@ -165,6 +172,8 @@ export function useDraftPersistence(
furthestStep: m.furthestStep,
createdAt: m.createdAt,
updatedAt: m.updatedAt,
status: m.status ?? null,
jobId: m.jobId ?? null,
})),
);
} catch (err) {
@@ -457,5 +466,7 @@ export function useDraftPersistence(
handleCommitRename,
handleCancelRename,
ensureDraft,
/** 强制刷新草稿列表(如后台挂起后让「进行中」状态立刻出现)。 */
refresh: refreshList,
};
}
@@ -45,6 +45,48 @@ export interface DispatchChainNode {
lastSpokeCycle?: number;
}
/** 一条后台作业的完整快照(§2.5)——对齐后端 `RoundtableJobResponse`(camelCase)。 */
export interface RoundtableJob {
id: string;
draftId: string | null;
status: JobStatus;
phase: JobPhase;
cycle: number;
dispatchChain: DispatchChainNode[];
activeAgentId: string | null;
consensusPercentage: number;
pendingClarification: string | null;
orchestrationMode: "recommend" | "chain";
chain: { id: string; title: string } | null;
error: string | null;
createdAt: string | null;
updatedAt: string | null;
}
/** 启动一个后台作业的入参(对齐后端 `StartJobRequest`)。 */
export interface StartJobPayload {
draftId?: string | null;
/** 任务意图;缺省 seedMessage 时用它作第一轮 leader prompt。 */
intent: string;
/** 有序席位(recommend=选择顺序 / chain=链条顺序)。 */
agents: Array<{ agentId: string; name: string }>;
model?: string;
orchestrationMode?: "recommend" | "chain";
chain?: { id: string; title: string } | null;
/** 显式第一轮 leader prompt(覆盖 intent)。 */
seedMessage?: string;
/** 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。 */
threadIds?: Record<string, string> | null;
coordinatorName?: string;
}
/** 终态集合——SSE/轮询判定作业是否结束。 */
export const TERMINAL_JOB_STATUSES: readonly JobStatus[] = ["done", "error", "cancelled"];
export function isTerminalJobStatus(status: JobStatus): boolean {
return TERMINAL_JOB_STATUSES.includes(status);
}
/** 归一化进度事件(§2.4)——SSE 推送。Phase 7 暂未用到,先占位。 */
export type JobProgressEvent =
| { type: "status"; status: JobStatus }
@@ -56,6 +56,7 @@ import { useModels } from "@/core/models/hooks";
import { getAvatar, Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog";
import { DispatchChainJobModal } from "@/roundtable-planning/components/DispatchChainJobModal";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import { Step2Panel } from "@/roundtable-planning/components/Step2Panel";
import { Step3Panel } from "@/roundtable-planning/components/Step3Panel";
@@ -74,6 +75,7 @@ import {
type SelectedAgent,
} from "@/roundtable-planning/lib/roundtable-constants";
import type { SeatSelectionResult } from "@/roundtable-planning/lib/business-chain";
import { getJobByDraft, startJob } from "@/roundtable-planning/api/roundtable-jobs";
export default function RoundtablePlanningPage() {
const { theme } = useTheme();
@@ -88,6 +90,8 @@ export default function RoundtablePlanningPage() {
const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState<boolean>(false);
// 「业务链条」按钮 → 打开链条列表弹窗(弹窗里再决定进配置页 / 切到某条链条)。
const [isChainListOpen, setIsChainListOpen] = useState<boolean>(false);
// 历史下拉点击「进行中」任务 → 打开派活链路流程图弹窗(订阅该作业 SSE)。
const [chainModalJobId, setChainModalJobId] = useState<string | null>(null);
const [selectedModel, setSelectedModel] = useState<string>("");
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活。
// 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。
@@ -321,7 +325,11 @@ export default function RoundtablePlanningPage() {
[step2, triggerToast],
);
const handleStartNewTask = useCallback(() => {
// 把页面重置回「第一步全新任务」——新建任务 + 后台挂起后都复用。
// 注意:只清前端状态,不删后端已建的草稿 / 已挂起的后台作业(它们继续在后台跑,
// 在历史记录里可见)。
const resetToStep1 = useCallback(() => {
step2.stopOrchestration();
step2.resetStep2State();
setOrchestrationMode("recommend");
setChainMeta(null);
@@ -333,8 +341,61 @@ export default function RoundtablePlanningPage() {
// 同步触发 Step 1 init,而不是依赖 useEffect —— 已经在 Step 1 时
// currentStep 不变,useEffect 不会 re-fire。
step1.runStep1Init();
}, [drafts, step1, step2]);
const handleStartNewTask = useCallback(() => {
resetToStep1();
triggerToast("✨ 已开始新任务,请在第一步与任务理解智能体澄清需求");
}, [drafts, step1, step2, triggerToast]);
}, [resetToStep1, triggerToast]);
// 「后台挂起」:把当前研讨交给后端自动跑到 Step3 结果绘制完成;模型沿用第二步选择。
// 挂起后页面像「新建任务」一样重置回第一步(后台作业继续在历史记录里跑)。
const handleBackgroundSuspend = useCallback(async () => {
const agents = step2.selectedAgents.map((a) => ({ agentId: a.agent_id, name: a.name }));
if (agents.length === 0) {
triggerToast("⚠️ 请先选择研讨席位再后台挂起");
return;
}
try {
// 确保后端存在对应草稿(作业按 draftId 关联,历史下拉据此显示「进行中」)。
const draftId = await drafts.ensureDraft(2);
// 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。
const existing = draftId ? await getJobByDraft(draftId).catch(() => null) : null;
const alreadyRunning =
existing &&
(existing.status === "running" ||
existing.status === "awaiting_input" ||
existing.status === "queued");
if (!alreadyRunning) {
await startJob({
draftId: draftId ?? undefined,
intent: buildInitialIntent(),
agents,
model: selectedModel || undefined, // 模型沿用第二步选择
orchestrationMode,
chain: chainMeta,
threadIds: step2.threadIds ?? undefined,
coordinatorName: step2.coordinatorName ?? undefined,
});
}
} catch (err) {
triggerToast(`❌ 后台挂起失败:${(err as Error).message}`);
return;
}
// 重置回第一步(同新建任务);后台作业在历史记录里继续跑、可点开看进度。
resetToStep1();
triggerToast("🚀 已转入后台,任务会自动跑到结果绘制完成;可在「历史记录」查看进度");
void drafts.refresh();
}, [
step2,
drafts,
selectedModel,
orchestrationMode,
chainMeta,
buildInitialIntent,
resetToStep1,
triggerToast,
]);
// 本地 demo handler:继续追问 / 跳过此项 —— 不发请求,只本地 push 一条气泡。
const handleSkipQuestion = useCallback(() => {
@@ -390,6 +451,26 @@ export default function RoundtablePlanningPage() {
}}
/>
{/* 派活链路流程图弹窗(订阅进行中作业的真实 SSE 进度) */}
<DispatchChainJobModal
jobId={chainModalJobId}
open={chainModalJobId !== null}
onOpenChange={(o) => {
if (!o) setChainModalJobId(null);
}}
onView={(job) => {
// 「查看」:进入该任务。done → 加载草稿(含 step3)落到 Step3 看报告;
// 进行中 / 待澄清 → 进去后置暂停态,避免前后端「双跑」同一批 thread。
setChainModalJobId(null);
if (!job.draftId) return;
if (job.status === "running" || job.status === "awaiting_input") {
step2.setIsRoundTableRunning(false);
step2.stopOrchestration();
}
drafts.handleLoadDraft(job.draftId);
}}
/>
{showToast && (
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 bg-primary text-white text-xs px-4 py-2.5 rounded-xl shadow-lg border border-primary/40 font-medium flex items-center gap-2 animate-bounce">
<Sparkles className="h-4 w-4 shrink-0" />
@@ -483,6 +564,11 @@ export default function RoundtablePlanningPage() {
renameDraftValue={drafts.renameDraftValue}
setRenameDraftValue={drafts.setRenameDraftValue}
onLoad={drafts.handleLoadDraft}
onOpen={() => void drafts.refresh()}
onOpenJob={(jobId) => {
drafts.setIsHistoryOpen(false);
setChainModalJobId(jobId);
}}
onDelete={drafts.handleDeleteDraft}
onStartRename={drafts.handleStartRename}
onCommitRename={drafts.handleCommitRename}
@@ -593,6 +679,7 @@ export default function RoundtablePlanningPage() {
"🎉 多角色智能体博弈共识达成,最终交付全套路线成果文档及方案图!",
);
}}
onBackgroundSuspend={handleBackgroundSuspend}
onTriggerToast={triggerToast}
/>
)}
@@ -616,6 +703,8 @@ export default function RoundtablePlanningPage() {
setStep3SavedTick((t) => t + 1);
}}
onReset={handleStartNewTask}
openArtifactPreview={step2.openArtifactPreview}
seatStubMessages={step2.seatStubMessages}
/>
)}
</section>
@@ -762,6 +851,10 @@ interface HistoryDropdownProps {
renameDraftValue: string;
setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>;
onLoad: (id: string) => void;
/** 下拉展开时回调(刷新列表拿最新作业状态)。 */
onOpen?: () => void;
/** 点击「进行中 / 待澄清」的任务 → 打开派活链路流程图弹窗。 */
onOpenJob: (jobId: string) => void;
onDelete: (id: string) => void;
onStartRename: (d: ReturnType<typeof useDraftPersistence>["drafts"][number]) => void;
onCommitRename: (id: string) => void;
@@ -779,6 +872,8 @@ function HistoryDropdown(props: HistoryDropdownProps) {
renameDraftValue,
setRenameDraftValue,
onLoad,
onOpen,
onOpenJob,
onDelete,
onStartRename,
onCommitRename,
@@ -790,7 +885,13 @@ function HistoryDropdown(props: HistoryDropdownProps) {
<button
id="history-dropdown-btn"
type="button"
onClick={() => setIsOpen((open) => !open)}
onClick={() =>
setIsOpen((open) => {
const next = !open;
if (next) onOpen?.();
return next;
})
}
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<History className="w-3.5 h-3.5 text-slate-500" />
@@ -815,6 +916,11 @@ function HistoryDropdown(props: HistoryDropdownProps) {
{drafts.map((d) => {
const isCurrent = d.id === currentDraftId;
const isRenaming = d.id === renamingDraftId;
// 「进行中 / 待澄清」且有 jobId → 点击打开流程图弹窗,而非加载草稿。
const isActiveJob =
(d.status === "running" || d.status === "awaiting_input") && !!d.jobId;
const openOrLoad = () =>
isActiveJob && d.jobId ? onOpenJob(d.jobId) : onLoad(d.id);
return (
<li key={d.id} className={`px-3 py-2.5 ${isCurrent ? "bg-emerald-50/40" : ""}`}>
<div className="flex items-start gap-2">
@@ -835,15 +941,30 @@ function HistoryDropdown(props: HistoryDropdownProps) {
) : (
<button
type="button"
onClick={() => onLoad(d.id)}
onClick={openOrLoad}
className="block w-full text-left"
title="点击加载"
title={isActiveJob ? "点击查看进度" : "点击加载"}
>
<span className="text-[11.5px] font-semibold text-slate-800 line-clamp-2 dark:text-foreground">
{d.name}
</span>
<span className="text-[10px] text-slate-400 mt-0.5 block font-mono">
Step {d.furthestStep} ·{" "}
<span className="text-[10px] text-slate-400 mt-0.5 flex items-center gap-1 font-mono">
{d.status === "running" ? (
<span className="inline-flex items-center gap-1 text-primary font-semibold">
<Loader2 className="w-3 h-3 animate-spin" />
进行中
</span>
) : d.status === "awaiting_input" ? (
<span className="inline-flex items-center gap-1 text-amber-500 font-semibold">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
待澄清
</span>
) : d.status === "error" ? (
<span className="text-rose-500 font-semibold">出错</span>
) : (
<span>Step {d.furthestStep}</span>
)}
<span className="text-slate-300">·</span>
{new Date(d.updatedAt).toLocaleString("zh-CN", { hour12: false })}
</span>
</button>
@@ -854,11 +975,15 @@ function HistoryDropdown(props: HistoryDropdownProps) {
<>
<button
type="button"
onClick={() => onLoad(d.id)}
onClick={openOrLoad}
className="p-1 text-slate-500 hover:text-primary hover:bg-slate-100 rounded transition"
title="加载"
title={isActiveJob ? "查看进度" : "加载"}
>
<Upload className="w-3.5 h-3.5" />
{isActiveJob ? (
<Activity className="w-3.5 h-3.5 text-primary" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
</button>
<button
type="button"