diff --git a/frontend-web/.env.production b/frontend-web/.env.production index d1fc2180..97415742 100644 --- a/frontend-web/.env.production +++ b/frontend-web/.env.production @@ -5,7 +5,7 @@ VITE_USE_REAL_DATA=true # 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。 -VITE_MOCK_COP_TASK_DETAIL=true +VITE_MOCK_COP_TASK_DETAIL=false # Gateway / DeerFlow API VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow diff --git a/frontend-web/src/core/auth/dashboard-input.ts b/frontend-web/src/core/auth/dashboard-input.ts new file mode 100644 index 00000000..c04e59de --- /dev/null +++ b/frontend-web/src/core/auth/dashboard-input.ts @@ -0,0 +1,155 @@ +/** + * 大屏绘制智能体「独立页面」的**输入 JSON** 适配 seam。 + * + * 设计意图:外部系统登录时**后续会**提供一个接口返回一份业务 JSON,本页让 + * 智能体分析这份 JSON → 产出 report-json → 绘制大屏。 + * + * ⚠️ 该接口现在还没开发好。本模块先做**占位适配**让页面尽快可用: + * - 配了 `VITE_DASHBOARD_INPUT_URL` → 按 taskId 拉取真实 JSON; + * - 没配 → 回退用登录深链already 暂存的任务详情(`login.copTaskDetail`)拼一份占位输入, + * 让智能体至少能基于任务名/内容产出一版可看的大屏。 + * + * 接口就绪后:把 `fetchDashboardInputJson` 换成真实拉取;再按规划迁到「后台转换格式 + * + 智能体校验」(校验仍由智能体做,不在后台硬抽)。 + */ + +import { requestCrudService } from "@/service/request-axios-service"; +import { readCopTaskDetail } from "@/core/auth/task-deeplink"; + +/** 本页输入 JSON 暂存的 sessionStorage key(拉取一次后复用,避免重复请求)。 */ +export const DASHBOARD_INPUT_STORAGE_KEY = "dashboard.inputJson"; + +/** + * 🧪 演示用模拟业务 JSON —— JSON 接口未就绪、也没有登录任务详情时的兜底输入。 + * 让大屏页可立即跑通:智能体分析这份数据 → report-json → 渲染大屏。 + * ⚠️ 接口就绪后删除本常量及其兜底引用。 + */ +export const DEMO_DASHBOARD_INPUT = { + taskId: "DEMO-涉台舆情", + taskName: "2026 第一季度涉台舆情态势研判", + taskContent: + "汇总境内外多平台涉台舆情数据,研判热点叙事走势、关键行为体立场与传播渠道分布,识别风险并给出处置建议。", + overview: + "本季涉台舆情整体平稳但局部波动,'两岸交流''和平发展'类正向叙事占比上升,少数账号借军事议题制造紧张;境外智库与媒体联动放大特定框架。", + period: "2026-01-01 ~ 2026-03-31", + kpis: { + 总信息量: 1284000, + 日均增速: "12%", + 正向占比: "58%", + 负向占比: "19%", + 识别风险点: 7, + 覆盖平台: 12, + }, + channels: [ + { platform: "微博", share: 34 }, + { platform: "境外社媒(X/FB)", share: 27 }, + { platform: "短视频", share: 18 }, + { platform: "新闻门户", share: 13 }, + { platform: "论坛/贴吧", share: 8 }, + ], + narratives: [ + { theme: "两岸交流与融合发展", sentiment: "正向", volume: 42, trend: "up" }, + { theme: "和平稳定·避战", sentiment: "正向", volume: 26, trend: "up" }, + { theme: "军事演训与安全", sentiment: "中性偏负", volume: 21, trend: "flat" }, + { theme: "外部势力介入", sentiment: "负向", volume: 11, trend: "down" }, + ], + actors: [ + { name: "主流官媒", stance: "正面引导", influence: 88, note: "议程设置与权威发布" }, + { name: "岛内民间团体", stance: "中性多元", influence: 64, note: "交流合作类内容活跃" }, + { name: "境外智库", stance: "负面框架", influence: 57, note: "联动媒体放大紧张叙事" }, + { name: "自媒体大V", stance: "立场分化", influence: 49, note: "情绪化传播、易被借势" }, + ], + risks: [ + { label: "军事议题被恶意剪辑放大", level: "high" }, + { label: "境外智库联动制造紧张框架", level: "high" }, + { label: "个别大V情绪化带节奏", level: "mid" }, + { label: "短视频谣言二次传播", level: "mid" }, + { label: "节假日期间信息量骤增", level: "low" }, + ], + timeline: [ + { phase: "数据汇聚", desc: "12 平台多源采集与清洗去重" }, + { phase: "叙事识别", desc: "聚类提炼核心叙事与情感倾向" }, + { phase: "行为体画像", desc: "关键账号立场与影响力建模" }, + { phase: "风险研判", desc: "识别高危议题与传播链路" }, + { phase: "处置建议", desc: "分级响应与正向引导预案" }, + ], + weeklyVolume: [ + { week: "W1", value: 86 }, + { week: "W2", value: 92 }, + { week: "W3", value: 110 }, + { week: "W4", value: 134 }, + { week: "W5", value: 121 }, + { week: "W6", value: 158 }, + ], +}; + +function inputBaseUrl(): string { + // TODO: 接口就绪后确认环境变量名/路径。占位:未配则走 copTaskDetail 回退。 + const value = (import.meta.env as Record).VITE_DASHBOARD_INPUT_URL; + return typeof value === "string" ? value.replace(/\/+$/, "") : ""; +} + +/** 读取暂存的输入 JSON。 */ +export function readDashboardInputJson(): unknown | null { + try { + const raw = sessionStorage.getItem(DASHBOARD_INPUT_STORAGE_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +/** 暂存输入 JSON(隐私模式/quota 异常静默失败)。 */ +export function persistDashboardInputJson(data: unknown): void { + try { + sessionStorage.setItem(DASHBOARD_INPUT_STORAGE_KEY, JSON.stringify(data)); + } catch { + /* noop */ + } +} + +/** 用登录暂存的任务详情拼一份占位输入(接口未就绪时的回退)。 */ +function placeholderFromCopTask(taskId: string): unknown | null { + const detail = readCopTaskDetail(); + if (!detail) return taskId ? { taskId } : null; + return { + taskId, + taskName: detail.taskName, + taskContent: detail.taskContent, + overview: detail.overview, + taskDirection: detail.taskDirection, + _note: "占位输入(真实业务 JSON 接口未就绪)", + }; +} + +/** + * 取本页的输入 JSON:优先暂存 → 真实接口(配了地址)→ copTaskDetail 占位。 + * 任一步失败都静默退化,最终可能返回 null(调用方据此提示用户/不自动开跑)。 + */ +export async function fetchDashboardInputJson(taskId: string): Promise { + const cached = readDashboardInputJson(); + if (cached) return cached; + + const base = inputBaseUrl(); + if (base && taskId) { + try { + // service 拦截器统一注入鉴权并返回 response.data(整包 body)。 + const body = await requestCrudService.get(`${base}`, { taskId }); + const data = body && typeof body === "object" ? (body as { data?: unknown }).data ?? body : body; + if (data) { + persistDashboardInputJson(data); + return data; + } + } catch { + /* 拉取失败 → 落占位 */ + } + } + + const placeholder = placeholderFromCopTask(taskId); + // 占位里有真实任务内容(来自登录详情)才用它;否则回退演示数据,保证页面能跑通看效果。 + // ⚠️ 接口就绪后删除 DEMO 兜底。 + const detail = readCopTaskDetail(); + const resolved = detail ? placeholder : { ...DEMO_DASHBOARD_INPUT, taskId: taskId || DEMO_DASHBOARD_INPUT.taskId }; + if (resolved) persistDashboardInputJson(resolved); + return resolved; +} diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index 3c953561..247bdb07 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -65,6 +65,12 @@ export const sidebarMenuItems: MenuItem[] = [ icon: Users, path: "/page/strategy/qa/multi-agent-roundtable", }, + { + id: "dashboard-agent", + label: "大屏绘制", + icon: Gauge, + path: "/page/strategy/dashboard-agent", + }, // { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" }, { id: "qa-zzjc", diff --git a/frontend-web/src/core/threads/thread-access-error.ts b/frontend-web/src/core/threads/thread-access-error.ts new file mode 100644 index 00000000..dd803081 --- /dev/null +++ b/frontend-web/src/core/threads/thread-access-error.ts @@ -0,0 +1,98 @@ +import { toast } from "sonner"; + +import { getStoredAuth } from "@/core/auth"; + +/** + * Shared helpers for re-framing LangGraph "thread not accessible" 404s around + * the current login identity. + * + * The most common cause in practice is a login session change: a thread is + * owned by the previously logged-in account, so after switching accounts the + * backend (``app/gateway/authz.py``) correctly returns + * ``HTTP 404: {"detail":"Thread not accessible"}``. Raw-toasting that + * 404 just leaves the user staring at a scary error. Both the chat stream + * (``core/threads/hooks.ts``) and the AI-writing stream + * (``open-canvas/contexts/AIWritingContext.tsx``) funnel their stream errors + * through here so the UX is identical: detect the login-change 404 and prompt + * the user to refresh against the current identity instead of erroring out. + */ + +/** Normalize an unknown stream error into a human-readable message string. */ +export function getStreamErrorMessage(error: unknown): string { + if (typeof error === "string" && error.trim()) { + return error; + } + if (error instanceof Error && error.message.trim()) { + return error.message; + } + if (typeof error === "object" && error !== null) { + const message = Reflect.get(error, "message"); + if (typeof message === "string" && message.trim()) { + return message; + } + const nestedError = Reflect.get(error, "error"); + if (nestedError instanceof Error && nestedError.message.trim()) { + return nestedError.message; + } + if (typeof nestedError === "string" && nestedError.trim()) { + return nestedError; + } + } + return "Request failed."; +} + +/** + * Detect "thread not found" / forbidden errors from the LangGraph SDK. + * + * Backend returns 404 both when a thread truly doesn't exist and when the + * currently authenticated user doesn't own it ("not found" = no row + no + * checkpoint, "not accessible" = row owned by another user / stale login + * session). Both mean the current user can't use this thread. + */ +export function isThreadNotAccessibleError(message: string): boolean { + const lowered = message.toLowerCase(); + if (/thread\s+[\w-]{8,}\s+not\s+(found|accessible)/i.test(message)) { + return true; + } + // Fallback: a bare 404/403 from the threads endpoint when the SDK has + // wrapped the detail differently. + return ( + (lowered.includes("http 404") || lowered.includes("http 403")) && + lowered.includes("thread") + ); +} + +/** Build a label for the *current* logged-in identity (email → user_id → 未知). */ +export function buildCurrentUserLabel(): string { + const auth = getStoredAuth(); + if (!auth) { + return "未登录"; + } + const email = auth.email?.trim(); + if (email) { + return email; + } + const userId = auth.user_id?.trim(); + return userId ? userId : "未知用户"; +} + +/** + * Re-frame a login-change 404 around the current identity and reload the page. + * + * Shows a friendly toast naming the current login, then full-reloads after a + * short delay so the toast is visible and so TanStack Query cache / context + * providers / any state captured under the previous user get rebuilt against + * the current login. Used by streams whose session id lives in component state + * (not the URL) — a plain reload lands them back on a fresh, valid page. + */ +export function notifyThreadAccessChangedAndReload(): void { + const userLabel = buildCurrentUserLabel(); + toast.info( + `当前登录用户为 ${userLabel},原会话不存在或无权访问,正在为你刷新页面…`, + ); + if (typeof window !== "undefined") { + window.setTimeout(() => { + window.location.reload(); + }, 900); + } +} diff --git a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx index 11c0ffcb..31c8cdd4 100644 --- a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx +++ b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx @@ -15,6 +15,11 @@ import { import { useStream } from '@langchain/langgraph-sdk/react'; import { getAPIClient } from '@/core/api'; +import { + getStreamErrorMessage, + isThreadNotAccessibleError, + notifyThreadAccessChangedAndReload, +} from '@/core/threads/thread-access-error'; import { useCanvasGraphContext } from '../contexts/CanvasGraphContext'; import { saveAIWritingTranscript } from '../api/ai-writing-sessions'; @@ -186,10 +191,18 @@ const AIWritingStreamHost = memo(function AIWritingStreamHost({ }, onError(err: unknown) { eventBatcherRef.current?.cancel(); - dispatchRef.current?.({ - type: 'set_error', - message: err instanceof Error ? err.message : String(err), - }); + const message = getStreamErrorMessage(err); + // A "thread not accessible/not found" 404 almost always means the writing + // thread belongs to a previous login session — the user switched accounts + // (or this tab outlived a re-login), so the current account legitimately + // can't access the old thread. Instead of dumping the raw HTTP 404 into + // the panel, re-frame it around the current identity and reload — same UX + // the chat stream gives (see core/threads/hooks.ts). + if (isThreadNotAccessibleError(message)) { + notifyThreadAccessChangedAndReload(); + return; + } + dispatchRef.current?.({ type: 'set_error', message }); }, onFinish(finishState) { eventBatcherRef.current?.cancel(); @@ -291,7 +304,12 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { }, ); } catch (err) { - dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) }); + const message = getStreamErrorMessage(err); + if (isThreadNotAccessibleError(message)) { + notifyThreadAccessChangedAndReload(); + return; + } + dispatch({ type: 'set_error', message }); } }, [state.status]); @@ -372,7 +390,12 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { streamMode: ['values', 'updates', 'custom', 'messages-tuple'], }); } catch (err) { - dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) }); + const message = getStreamErrorMessage(err); + if (isThreadNotAccessibleError(message)) { + notifyThreadAccessChangedAndReload(); + return; + } + dispatch({ type: 'set_error', message }); } }, [state.sessionId, state.currentPause, state.materials, state.outline, state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult, diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 27816fa0..306cc25a 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -20,6 +20,7 @@ import { ActionAnalyzes } from "@/strategy-components/components/task-components import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement"; import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement"; import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage"; +import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage"; import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage"; import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage"; import "@/roundtable-planning/styles/roundtable-planning.css"; @@ -99,6 +100,16 @@ export default function StrategyRoutes() { } /> + {/* 大屏绘制智能体独立页面(按 taskId 存取;ChatRuntime 提供 ArtifactsProvider 给沙箱预览) */} + + + + } + /> + {/* 需求 0616 #3/#4:zzJC / zzXD 「按类型聚合」模块页(重做,独立路由)。 */} } /> } /> diff --git a/frontend-web/src/roundtable-planning/api/dashboard-sessions.ts b/frontend-web/src/roundtable-planning/api/dashboard-sessions.ts new file mode 100644 index 00000000..d7ddd688 --- /dev/null +++ b/frontend-web/src/roundtable-planning/api/dashboard-sessions.ts @@ -0,0 +1,68 @@ +/** + * 大屏绘制智能体「独立页面」会话持久化 —— 按外部 taskId 存取(后端不按 user 分权)。 + * + * 一个 taskId 一条会话:聊天记录(transcript)+ 最新结构化数据(reportJson)。 + * 后端 `/api/dashboard-sessions/by-task/{taskId}`(GET 取 / PUT upsert)。 + * 详见后端 `app/gateway/routers/dashboard_sessions.py`。 + */ +import { apiFetch } from "@/core/api/fetch-client"; +import { getBackendBaseURL } from "@/core/config"; + +export interface DashboardSession { + taskId: string; + /** 聊天记录(大屏绘制智能体对话气泡数组;形状与 Step2Dialogue 一致,宽松存取)。 */ + transcript: unknown[] | null; + /** 最新结构化数据(report-json 字符串);可被 parseReportJson 解析则视为「有效结果」。 */ + reportJson: string | null; + updatedAt: string | null; +} + +interface ServerDashboardSession { + task_id: string; + transcript: unknown[] | null; + report_json: string | null; + created_at?: string | null; + updated_at?: string | null; +} + +const BASE = () => `${getBackendBaseURL()}/api/dashboard-sessions`; + +function toClient(s: ServerDashboardSession): DashboardSession { + return { + taskId: s.task_id, + transcript: Array.isArray(s.transcript) ? s.transcript : null, + reportJson: s.report_json ?? null, + updatedAt: s.updated_at ?? null, + }; +} + +/** 取该 taskId 的会话;无(404)返回 null。 */ +export async function getDashboardSessionByTask(taskId: string): Promise { + const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}`, { method: "GET" }); + if (res.status === 404) return null; + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`get dashboard session failed (${res.status}): ${text}`); + } + return toClient((await res.json()) as ServerDashboardSession); +} + +/** upsert 该 taskId 的会话(只传需要更新的字段)。 */ +export async function putDashboardSessionByTask( + taskId: string, + patch: { transcript?: unknown[]; reportJson?: string | null }, +): Promise { + const body: Record = {}; + if (patch.transcript !== undefined) body.transcript = patch.transcript; + if (patch.reportJson !== undefined) body.report_json = patch.reportJson; + const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`put dashboard session failed (${res.status}): ${text}`); + } + return toClient((await res.json()) as ServerDashboardSession); +} diff --git a/frontend-web/src/roundtable-planning/api/flow-extract.ts b/frontend-web/src/roundtable-planning/api/flow-extract.ts new file mode 100644 index 00000000..503832d3 --- /dev/null +++ b/frontend-web/src/roundtable-planning/api/flow-extract.ts @@ -0,0 +1,141 @@ +/** + * Step3 流程图「后端抽取 + 校验」SSE 客户端。 + * + * 对应后端 `POST /api/flow-extract/stream`:后端独立调模型(不走总结线程,避开 coordinatorPrompt + * 的报告锚定)两段式产图——①流式抽取 flow-json(实时吐回,前端原样显示给用户看)→ ②模型校验/修复 + * → 返回校验后的结构化图数据 `{title, nodes, edges}`。 + * + * 这样把不稳定的「文本→结构」抽取 + 校验放后端,前端只负责实时显示抽取过程 + 拿最终图绘制。 + */ +import { apiFetch } from "@/core/api/fetch-client"; +import { getBackendBaseURL } from "@/core/config"; +import type { FlowGraphData } from "@/roundtable-planning/lib/flow-graph"; + +/** 第三步输入框意图分类结果(与后端 /intent 对应)。 */ +export type FlowIntent = "regenerate_report" | "modify_flow" | "regenerate_flow" | "qa" | "unclear"; + +/** + * 识别第三步输入框消息的意图(一次模型调用)。失败回退 "unclear"(前端发起澄清)。 + * 参考 ai_writing 的一次性 JSON 意图识别范式。 + */ +export async function classifyFlowIntent(req: { + message: string; + hasReport: boolean; + hasFlow: boolean; + model?: string; + signal?: AbortSignal; +}): Promise<{ intent: FlowIntent; reason: string }> { + try { + const res = await apiFetch(`${getBackendBaseURL()}/api/flow-extract/intent`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: req.message, + has_report: req.hasReport, + has_flow: req.hasFlow, + model: req.model, + }), + signal: req.signal, + }); + if (!res.ok) return { intent: "unclear", reason: "" }; + const data = (await res.json()) as { intent?: string; reason?: string }; + const intent = (data.intent ?? "unclear") as FlowIntent; + return { intent, reason: data.reason ?? "" }; + } catch { + return { intent: "unclear", reason: "" }; + } +} + +export interface FlowExtractRequest { + /** 报告 Markdown(优先数据来源)。 */ + reportMd?: string | null; + /** 各席位交付(无报告时的数据来源)。 */ + seatDeliveries?: Array<{ sender: string; content: string }>; + /** 业务链层级(stages 的 goal)。 */ + chainLevels?: string[]; + /** 业务链总控编排提示(写明层级时优先用它)。 */ + coordinatorPrompt?: string | null; + /** 推理模型名(沿用第三步所选)。 */ + model?: string; + /** Phase 2 问答修改:当前流程图 + 指令 → 按指令改图(其余不变)。 */ + currentGraph?: FlowGraphData | null; + instruction?: string; + signal?: AbortSignal; + /** 抽取流式增量(含 `` 思考 + 正文 + JSON,前端原样渲染:思考块 + 代码块)。 */ + onExtractDelta?: (text: string) => void; + /** 抽取结束、进入校验阶段。 */ + onValidating?: () => void; + /** 校验/修复流式增量(同样含思考 + 正文,前端实时显示校验过程)。 */ + onValidateDelta?: (text: string) => void; +} + +/** + * 消费 SSE,返回校验后的最终图。抽取阶段通过 `onExtractDelta` 实时回吐; + * 出错(后端 error 帧 / 网络)抛异常。 + */ +export async function streamFlowExtract(req: FlowExtractRequest): Promise { + const res = await apiFetch(`${getBackendBaseURL()}/api/flow-extract/stream`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + report_md: req.reportMd ?? null, + seat_deliveries: (req.seatDeliveries ?? []).map((s) => ({ sender: s.sender, content: s.content })), + chain_levels: req.chainLevels ?? [], + coordinator_prompt: req.coordinatorPrompt ?? null, + model: req.model, + current_graph: req.currentGraph ?? null, + instruction: req.instruction ?? null, + }), + signal: req.signal, + }); + if (!res.ok || !res.body) { + const text = await res.text().catch(() => ""); + throw new Error(`flow-extract failed (${res.status}): ${text}`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let graph: FlowGraphData | null = null; + let errored: string | null = null; + + 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 rawLine of lines) { + const line = rawLine.trimEnd(); + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (!data) continue; + let frame: Record; + try { + frame = JSON.parse(data) as Record; + } catch { + continue; + } + const event = typeof frame.event === "string" ? frame.event : ""; + if (event === "extract_delta") { + const c = typeof frame.content === "string" ? frame.content : ""; + if (c) req.onExtractDelta?.(c); + } else if (event === "validating") { + req.onValidating?.(); + } else if (event === "validate_delta") { + const c = typeof frame.content === "string" ? frame.content : ""; + if (c) req.onValidateDelta?.(c); + } else if (event === "final") { + graph = (frame.graph as FlowGraphData) ?? null; + } else if (event === "error") { + errored = typeof frame.detail === "string" ? frame.detail : "未知错误"; + } + } + } + + if (errored) throw new Error(errored); + if (!graph || !Array.isArray(graph.nodes) || graph.nodes.length === 0) { + throw new Error("flow-extract 未返回有效流程图"); + } + return graph; +} diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts index 91ef55a3..9d2c011b 100644 --- a/frontend-web/src/roundtable-planning/api/multi-agent.ts +++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts @@ -835,6 +835,32 @@ export async function initReportThread( return { agentName: json.agent_name, threadId: json.thread_id }; } +/** + * Create a thread for the Step 3「大屏多页」dashboard-data agent + * (`roundtable-dashboard`, a built-in singleton — distinct from `roundtable-report`; + * its run policy disables ALL tools so it only emits a ```report-json block). Run it + * with {@link streamMultiAgent} (`agentType: "special"`, `agentName: "roundtable-dashboard"`, + * `dAgentThreadId: { [agentName]: threadId }`); the frontend then `extractReportData`s + * the streamed text. Follow-up Q&A / refinements reuse the SAME thread (do not re-init) + * so the agent keeps the prior dashboard context. + */ +export async function initDashboardThread( + options: { signal?: AbortSignal } = {}, +): Promise { + const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/dashboard/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 dashboard 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 }; +} + /** * Create a thread for the Step 3「总结报告」summary agent (`roundtable-summary`, * a built-in singleton — distinct from `roundtable-report`; this only spins up a diff --git a/frontend-web/src/roundtable-planning/components/DashboardChatPanel.tsx b/frontend-web/src/roundtable-planning/components/DashboardChatPanel.tsx new file mode 100644 index 00000000..b590c90e --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/DashboardChatPanel.tsx @@ -0,0 +1,250 @@ +/** + * 大屏绘制智能体「交互区」聊天面板(沙箱左 60%;右 40% 是抽取过程代码预览)。 + * + * 从 `Step3DashboardMode` 抽出共享:圆桌 Step3「结果绘制·大屏」与独立大屏页 + * (`DashboardAgentPage`)都复用同一套聊天 UI(流式气泡 + 模型选择 + 暂停/重画 + + * 修改输入框)。消息里不展示 report-json 本体(已在右侧沙箱实时渲染),用 + * `stripReportFence` 替换成一句提示。 + */ +import { useMemo, useRef, useState, type FormEvent } from "react"; +import { + CheckCircle, + Loader2, + Pause, + RefreshCw, + SendHorizontal, +} from "lucide-react"; + +import { useLocalSettings } from "@/core/settings"; +import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea"; +import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode"; +import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; +import { Step2Message } from "@/roundtable-planning/components/Step2Panel"; +import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; +import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics"; +import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard"; +import { stripReportFence } from "@/roundtable-planning/lib/report-template"; + +export interface DashboardChatPanelProps { + phase: UseStep3DashboardReturn["phase"]; + dialogues: UseStep3DashboardReturn["dialogues"]; + busy: boolean; + error: string | null; + onAsk: (message: string) => void; + onRedraw: () => void; + onPause: () => void; + onOpenPreview: () => void; + hasResult: boolean; + selectedModel: string; + availableModels: ReadonlyArray; + modelsLoading: boolean; + setSelectedModel: (model: string) => void; + /** 面板副标题(默认对齐圆桌 Step3;独立大屏页可自定义说明)。 */ + subtitle?: string; + /** 完成提示语(默认「已切换到大屏页面」;独立页不自动跳,可改文案)。 */ + doneHint?: string; +} + +export function DashboardChatPanel({ + phase, + dialogues, + busy, + error, + onAsk, + onRedraw, + onPause, + onOpenPreview, + hasResult, + selectedModel, + availableModels, + modelsLoading, + setSelectedModel, + subtitle = "提炼研讨成果为大屏结构化数据(右侧实时显示抽取过程)", + doneHint = "数据已就绪,已切换到大屏页面。", +}: DashboardChatPanelProps) { + const inputLocked = phase === "creating"; + const scrollRef = useRef(null); + const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); + const [localSettings] = useLocalSettings(); + const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; + const dialogueMetrics = useDialogueSpeedMetrics(dialogues); + const [text, setText] = useState(""); + + // 消息列表里不展示 report-json 结构化数据本体(它已在右侧沙箱实时渲染), + // 把数据块替换成一句「看右侧沙箱」的提示,避免左右两边重复刷整段 JSON。 + const displayDialogues = useMemo( + () => + dialogues.map((d) => { + const content = d.content ?? ""; + if (!content) return d; + const stripped = stripReportFence(content, d.streaming === true); + return stripped === content ? d : { ...d, content: stripped }; + }), + [dialogues], + ); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + const t = text.trim(); + if (!t || inputLocked) return; + setText(""); + onAsk(t); + }; + + return ( +
+ {/* 面板头:智能体名 + 模型选择 + 暂停/重画 */} +
+
+
+ 📊 +
+
+
大屏绘制智能体
+
{subtitle}
+
+
+
+ + {busy ? ( + + ) : ( + + )} +
+
+ + {/* 聊天记录 */} +
+
+ {phase === "creating" && dialogues.length === 0 && ( +
+ + 正在初始化绘制线程… +
+ )} + + {displayDialogues.map((msg) => ( + + ))} + + {busy && ( +
+ + 右侧沙箱实时显示数据抽取过程 +
+ )} + + {phase === "error" && ( +
+
绘制失败
+
{error}
+ +
+ )} + + {phase === "done" && ( +
+ + {doneHint} + {hasResult && ( + + )} +
+ )} +
+ {!atBottom && } + + {/* 问答修改输入框 */} +
+
+ setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + e.currentTarget.form?.requestSubmit(); + } + }} + disabled={inputLocked} + rows={1} + placeholder={ + busy + ? "随时输入修改要求——发送会打断当前绘制并按新要求重出数据" + : "输入修改要求,例如:把风险分布换成柱状图,新增一个执行路线席位" + } + className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60" + /> + +
+
+
+
+ ); +} diff --git a/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx b/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx index f19cf47b..f49e1425 100644 --- a/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx +++ b/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx @@ -15,6 +15,7 @@ import { useRef, useState, } from "react"; +import { createPortal } from "react-dom"; import { applyNodeChanges, Background, @@ -32,11 +33,14 @@ import { } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { + Check, FileText, Loader2, LocateFixed, Maximize2, Minimize2, + Pencil, + Trash2, Workflow, X, ZoomIn, @@ -45,6 +49,8 @@ import { import { cn } from "@/lib/utils"; import { + applyNodeEdit, + deleteNodeFromGraph, FLOW_NODE_TYPE_META, FLOW_NODE_W, layoutFlowGraph, @@ -59,6 +65,8 @@ interface FlowGraphPanelProps { onClose: () => void; /** 沙箱里有 md 报告时提供:工具栏显示「MD 报告」按钮,点击切回 Markdown 预览。 */ onSwitchToReport?: () => void; + /** Phase 3:画布手动编辑——传入则节点详情弹窗出现「编辑 / 删除」,回写整张新图。 */ + onChange?: (next: FlowGraphData) => void; } // ── 自定义节点(按 type 着色的卡片)──────────────────────────────────────── @@ -106,6 +114,7 @@ function FlowCanvasInner({ onToggleFullscreen, onClose, onSwitchToReport, + onChange, }: FlowGraphPanelProps & { fullscreen: boolean; onToggleFullscreen: () => void }) { const rf = useReactFlow(); const [selectedId, setSelectedId] = useState(null); @@ -276,13 +285,14 @@ function FlowCanvasInner({ - {/* 节点详情弹窗 */} + {/* 节点详情弹窗(传入 onChange 时可编辑 / 删除节点) */} {selectedNode && ( setSelectedId(null)} onJump={(id) => setSelectedId(id)} + onChange={onChange} /> )} @@ -317,17 +327,38 @@ function NodeDetailModal({ data, onClose, onJump, + onChange, }: { node: FlowGraphNode; data: FlowGraphData; onClose: () => void; onJump: (id: string) => void; + /** Phase 3:传入则可编辑 / 删除该节点(回写整张新图)。 */ + onChange?: (next: FlowGraphData) => void; }) { const meta = FLOW_NODE_TYPE_META[node.type]; const labelOf = (id: string) => data.nodes.find((n) => n.id === id)?.label ?? id; const incoming = data.edges.filter((e) => e.to === node.id); const outgoing = data.edges.filter((e) => e.from === node.id); + const [editing, setEditing] = useState(false); + const [label, setLabel] = useState(node.label); + const [group, setGroup] = useState(node.group ?? ""); + const [detail, setDetail] = useState(node.detail ?? ""); + + const handleSave = () => { + onChange?.(applyNodeEdit(data, node.id, { label, group, detail })); + setEditing(false); + onClose(); + }; + const handleDelete = () => { + if (typeof window !== "undefined" && !window.confirm(`删除节点「${node.label}」及其连线?此操作不可撤销。`)) { + return; + } + onChange?.(deleteNodeFromGraph(data, node.id)); + onClose(); + }; + return (
-
-
-
- 详细说明 -
-

- {node.detail?.trim() || "(该节点未提供详细说明)"} -

+ + {editing ? ( +
+ + +
- {incoming.length > 0 && ( - ({ id: e.from, label: labelOf(e.from), edgeLabel: e.label }))} onJump={onJump} /> - )} - {outgoing.length > 0 && ( - ({ id: e.to, label: labelOf(e.to), edgeLabel: e.label }))} onJump={onJump} /> - )} -
+ ) : ( +
+
+
详细说明
+

+ {node.detail?.trim() || "(该节点未提供详细说明)"} +

+
+ {incoming.length > 0 && ( + ({ id: e.from, label: labelOf(e.from), edgeLabel: e.label }))} onJump={onJump} /> + )} + {outgoing.length > 0 && ( + ({ id: e.to, label: labelOf(e.to), edgeLabel: e.label }))} onJump={onJump} /> + )} +
+ )} + + {/* 编辑 / 删除 操作栏(仅传入 onChange 时显示) */} + {onChange && ( +
+ {editing ? ( + <> + + + + ) : ( + <> + + + + )} +
+ )}
); } +function EditField({ + label, + value, + onChange, + multiline, +}: { + label: string; + value: string; + onChange: (v: string) => void; + multiline?: boolean; +}) { + return ( +
+
{label}
+ {multiline ? ( +