feat(dashboard): 大屏绘制智能体独立页面(按 taskId 存取,不分权)
- 新增带导航+侧边栏的独立页面 DashboardAgentPage:按 taskId 取/存会话, 有有效 report-json 直显大屏(DashboardApp),否则进生成过程(不自动跳)+输入框可改 - 后端 dashboard_sessions 存储(model/sql/__init__)+路由/by-task(GET/PUT 不分权) + deps/app/models 接线;新表靠 create_all 建,无迁移 - 扩展 useStep3Dashboard:buildInitialPrompt(输入任务JSON) + hydrateTranscript(恢复聊天记录) - 抽出共享 DashboardChatPanel;report-template 增 buildDashboardPromptFromJson - 输入 seam core/auth/dashboard-input:JSON 接口未就绪先用占位/演示数据兜底 - 路由 StrategyRoutes dashboard-agent + 侧边栏「问答管理→大屏绘制」 - 圆桌业务链卡片「关系图」改横向布局 - 顺带提交:AIWritingContext 登录切换处理、thread-access-error helper Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
500a562bb6
commit
32772b7448
20 files changed
+1385
-260
No files matched your search
@@ -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<string, unknown>).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<unknown | null> {
|
||||
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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <uuid> 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 给沙箱预览) */}
|
||||
<Route
|
||||
path="dashboard-agent"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<DashboardAgentPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 需求 0616 #3/#4:zzJC / zzXD 「按类型聚合」模块页(重做,独立路由)。 */}
|
||||
<Route path="qa/zzjc/*" element={<AgentModuleRoutes module={AGENT_MODULES.zzjc} />} />
|
||||
<Route path="qa/zzxd/*" element={<AgentModuleRoutes module={AGENT_MODULES.zzxd} />} />
|
||||
|
||||
@@ -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<DashboardSession | null> {
|
||||
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<DashboardSession | null> {
|
||||
const body: Record<string, unknown> = {};
|
||||
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);
|
||||
}
|
||||
@@ -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<ModelOption>;
|
||||
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<HTMLDivElement>(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<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const t = text.trim();
|
||||
if (!t || inputLocked) return;
|
||||
setText("");
|
||||
onAsk(t);
|
||||
};
|
||||
|
||||
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">{subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<select
|
||||
id="step3-dashboard-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="max-w-[160px] 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>
|
||||
{busy ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPause}
|
||||
title="中止当前绘制"
|
||||
className="bg-white border border-amber-200 text-amber-700 hover:bg-amber-50 text-[10.5px] font-bold px-2.5 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<Pause className="w-3.5 h-3.5" />
|
||||
中止
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRedraw}
|
||||
title="丢弃上下文,从头重新绘制"
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-2.5 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
|
||||
重新绘制
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 聊天记录 */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{displayDialogues.map((msg) => (
|
||||
<Step2Message
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
{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" />
|
||||
右侧沙箱实时显示数据抽取过程
|
||||
</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={onRedraw}
|
||||
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" />
|
||||
{doneHint}
|
||||
{hasResult && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenPreview}
|
||||
className="ml-1 inline-flex items-center gap-1 text-[11px] font-bold text-primary underline hover:opacity-80"
|
||||
>
|
||||
在沙箱打开 HTML 预览
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
|
||||
|
||||
{/* 问答修改输入框 */}
|
||||
<form onSubmit={handleSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
|
||||
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
|
||||
<AutoGrowTextarea
|
||||
maxHeight={224}
|
||||
value={text}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={inputLocked || text.trim().length === 0}
|
||||
title={inputLocked ? "正在初始化线程,请稍候" : "发送修改要求"}
|
||||
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{inputLocked ? <Loader2 className="h-4 w-4 animate-spin" /> : <SendHorizontal className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,9 +15,7 @@
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -27,31 +25,22 @@ import {
|
||||
ExternalLink,
|
||||
LayoutPanelTop,
|
||||
Loader2,
|
||||
Pause,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
SendHorizontal,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
|
||||
import { desensitizeDeep } from "@/lib/desensitize";
|
||||
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
||||
import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp";
|
||||
import { adaptReportData } from "@/roundtable-planning/dashboard/data-context";
|
||||
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
|
||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
|
||||
import { DashboardChatPanel } from "@/roundtable-planning/components/DashboardChatPanel";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
import { DASHBOARD_VIEW_STORAGE_PREFIX } from "@/roundtable-planning/pages/DashboardStandalonePage";
|
||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
|
||||
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
|
||||
import {
|
||||
parseReportJson,
|
||||
renderDashboardHtml,
|
||||
stripReportFence,
|
||||
type DashboardTheme,
|
||||
} from "@/roundtable-planning/lib/report-template";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
@@ -361,221 +350,3 @@ export function Step3DashboardMode({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 交互区聊天面板(沙箱的左 60%;右 40% 是抽取过程代码预览) ──────────────────────────
|
||||
|
||||
function DashboardChatPanel({
|
||||
phase,
|
||||
dialogues,
|
||||
busy,
|
||||
error,
|
||||
onAsk,
|
||||
onRedraw,
|
||||
onPause,
|
||||
onOpenPreview,
|
||||
hasResult,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
}: {
|
||||
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<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
setSelectedModel: (model: string) => void;
|
||||
}) {
|
||||
const inputLocked = phase === "creating";
|
||||
const scrollRef = useRef<HTMLDivElement>(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<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const t = text.trim();
|
||||
if (!t || inputLocked) return;
|
||||
setText("");
|
||||
onAsk(t);
|
||||
};
|
||||
|
||||
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">提炼研讨成果为大屏结构化数据(右侧实时显示抽取过程)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<select
|
||||
id="step3-dashboard-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="max-w-[160px] 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>
|
||||
{busy ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPause}
|
||||
title="中止当前绘制"
|
||||
className="bg-white border border-amber-200 text-amber-700 hover:bg-amber-50 text-[10.5px] font-bold px-2.5 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<Pause className="w-3.5 h-3.5" />
|
||||
中止
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRedraw}
|
||||
title="丢弃上下文,从头重新绘制"
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-2.5 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
|
||||
重新绘制
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 聊天记录 */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{displayDialogues.map((msg) => (
|
||||
<Step2Message
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
{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" />
|
||||
右侧沙箱实时显示数据抽取过程,完成后自动切到大屏页面
|
||||
</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={onRedraw}
|
||||
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" />
|
||||
数据已就绪,已切换到大屏页面。
|
||||
{hasResult && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenPreview}
|
||||
className="ml-1 inline-flex items-center gap-1 text-[11px] font-bold text-primary underline hover:opacity-80"
|
||||
>
|
||||
在沙箱打开 HTML 预览
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
|
||||
|
||||
{/* 问答修改输入框 */}
|
||||
<form onSubmit={handleSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
|
||||
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
|
||||
<AutoGrowTextarea
|
||||
maxHeight={224}
|
||||
value={text}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={inputLocked || text.trim().length === 0}
|
||||
title={inputLocked ? "正在初始化线程,请稍候" : "发送修改要求"}
|
||||
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{inputLocked ? <Loader2 className="h-4 w-4 animate-spin" /> : <SendHorizontal className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,11 @@ interface UseStep3DashboardOptions {
|
||||
themeMode: "light" | "dark";
|
||||
savedReport?: DraftStep3Snapshot | null;
|
||||
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
|
||||
/**
|
||||
* 自定义初始 prompt 构造(独立大屏页用:输入是任务原始 JSON,让智能体分析转换)。
|
||||
* 返回空/未提供时回退默认的 buildDashboardPrompt(圆桌研讨成果模式)。
|
||||
*/
|
||||
buildInitialPrompt?: () => string | null | undefined;
|
||||
}
|
||||
|
||||
export interface UseStep3DashboardReturn {
|
||||
@@ -73,6 +78,8 @@ export interface UseStep3DashboardReturn {
|
||||
hydrateSaved: (html: string) => void;
|
||||
/** 从草稿/后台作业恢复(dashboardJson 结构化数据):按**当前主题**渲染后展示。 */
|
||||
hydrateFromJson: (jsonStr: string) => void;
|
||||
/** 从持久化恢复**真实聊天记录** + 结构化数据(独立大屏页按 taskId 取回时用)。 */
|
||||
hydrateTranscript: (dialogues: Step2Dialogue[], dashboardJson: string | null) => void;
|
||||
/** 手动打开大屏沙箱预览(恢复后/用户点气泡)。 */
|
||||
openDashboardPreview: () => void;
|
||||
}
|
||||
@@ -105,6 +112,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
themeMode,
|
||||
savedReport,
|
||||
onReportSaved,
|
||||
buildInitialPrompt,
|
||||
} = options;
|
||||
|
||||
const onSavedRef = useRef(onReportSaved);
|
||||
@@ -112,6 +120,11 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
onSavedRef.current = onReportSaved;
|
||||
}, [onReportSaved]);
|
||||
|
||||
const buildInitialPromptRef = useRef(buildInitialPrompt);
|
||||
useEffect(() => {
|
||||
buildInitialPromptRef.current = buildInitialPrompt;
|
||||
}, [buildInitialPrompt]);
|
||||
|
||||
const { select: selectArtifact, setOpen: setArtifactsOpen, open: artifactsOpen } = useArtifacts();
|
||||
const artifactsOpenRef = useRef(artifactsOpen);
|
||||
useEffect(() => {
|
||||
@@ -374,7 +387,13 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const prompt = buildDashboardPrompt({ intentText, seatDeliveries, consensusText });
|
||||
// 独立大屏页:用自定义 prompt(输入任务原始 JSON,让智能体分析转换);
|
||||
// 否则回退默认的圆桌研讨成果 prompt。
|
||||
const custom = buildInitialPromptRef.current?.();
|
||||
const prompt =
|
||||
custom && custom.trim()
|
||||
? custom
|
||||
: buildDashboardPrompt({ intentText, seatDeliveries, consensusText });
|
||||
await streamOnce({ bubbleId, message: prompt, aborter });
|
||||
} catch (err) {
|
||||
if (aborter.signal.aborted) return;
|
||||
@@ -486,6 +505,38 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
[themeMode, hydrateSaved],
|
||||
);
|
||||
|
||||
/** 从持久化恢复真实聊天记录 + 结构化数据(独立大屏页按 taskId 取回时用)。
|
||||
* 与 hydrateFromJson 的区别:保留**用户真实的历史对话气泡**,而非造一条合成提示。 */
|
||||
const hydrateTranscript = useCallback(
|
||||
(savedDialogues: Step2Dialogue[], dashboardJson: string | null) => {
|
||||
abort();
|
||||
runningRef.current = false;
|
||||
const synthThreadId = "roundtable-dashboard-restored";
|
||||
liveThreadRef.current = null; // 合成线程非真线程:续问会新建线程重新生成
|
||||
setDashThreadId(synthThreadId);
|
||||
setActiveArtifactThreadId(synthThreadId);
|
||||
const parsed = dashboardJson ? parseReportJson(dashboardJson) : null;
|
||||
if (parsed) {
|
||||
const rendered = renderDashboardHtml(parsed, { generatedAt: formatNow(), theme: themeMode });
|
||||
htmlRef.current = rendered;
|
||||
setHtml(rendered);
|
||||
setData(parsed);
|
||||
dataRef.current = parsed; // 续轮修改的基线
|
||||
const { messageId, toolCallId } = upsertDashStub(synthThreadId, rendered);
|
||||
lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId };
|
||||
} else {
|
||||
htmlRef.current = null;
|
||||
setHtml(null);
|
||||
setData(null);
|
||||
dataRef.current = null;
|
||||
}
|
||||
setDialogues(Array.isArray(savedDialogues) ? savedDialogues : []);
|
||||
setError(null);
|
||||
setPhase(parsed ? "done" : "idle");
|
||||
},
|
||||
[abort, themeMode, upsertDashStub],
|
||||
);
|
||||
|
||||
// 恢复路径辅助:savedReport 由组件/页面按字段调用对应 hydrate;这里只消除未用告警。
|
||||
void savedReport;
|
||||
void isDashboardHtml;
|
||||
@@ -519,6 +570,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
setPreviewEnabled,
|
||||
hydrateSaved,
|
||||
hydrateFromJson,
|
||||
hydrateTranscript,
|
||||
openDashboardPreview,
|
||||
};
|
||||
}
|
||||
@@ -351,6 +351,22 @@ export function coerceReportData(raw: unknown): ReportData | null {
|
||||
|
||||
// ── 模型 prompt(只要 report-json,不调工具、不写文件) ───────────────────────────
|
||||
|
||||
/** ```report-json 契约说明(buildDashboardPrompt / buildDashboardPromptFromJson 共用)。 */
|
||||
const DASHBOARD_REPORT_JSON_CONTRACT = [
|
||||
"```report-json 契约(严格 JSON,全程简体中文):",
|
||||
'{"title":"大屏主标题(≤20字)","subtitle":"一句话定位",',
|
||||
' "kpis":[{"label":"指标名","value":"数值","unit":"单位","trend":"up|down|flat"}], // 3-6 个核心指标,从内容提取或合理估算',
|
||||
' "consensus":{"headline":"一句话最终结论","points":["结论要点,3-6 条"]},',
|
||||
' "charts":[{"type":"line|bar|donut|radar","title":"图表标题","items":[{"label":"维度","value":数字}]}], // 1-3 个总览图表:line=趋势(销量/渗透率随时间)、donut=占比(份额/构成)、radar=多维对比(竞争力)、bar=排名;3-8 项/图',
|
||||
' "timeline":[{"label":"阶段名","desc":"一句话说明"}], // 执行路线 3-6 阶段',
|
||||
' "seats":[{"id":"seat-1","name":"分区/角色名","summary":"该分区结论一两句",',
|
||||
' "sections":[{"heading":"小节标题","bullets":["要点,每条≤40字"]}], // 2-4 节',
|
||||
' "metrics":[{"label":"量化指标名","value":0到100}], // 0-3 个',
|
||||
' "risks":[{"label":"风险点","level":"high|mid|low"}], // 0-4 个',
|
||||
' "dependsOn":["它承接哪些分区的产物(分区id)"]}]} // 依据内容判断依赖关系',
|
||||
"硬性要求:id 用 seat-1..seat-N;dependsOn 只引用本清单内的 id;JSON 必须可被 JSON.parse 解析(无注释、无尾逗号)。",
|
||||
].join("\n");
|
||||
|
||||
export function buildDashboardPrompt(opts: {
|
||||
intentText: string;
|
||||
seatDeliveries: Array<{ sender: string; content: string }>;
|
||||
@@ -374,24 +390,45 @@ export function buildDashboardPrompt(opts: {
|
||||
parts.push((opts.consensusText || "").trim() || "(未提供)");
|
||||
parts.push("");
|
||||
parts.push(
|
||||
[
|
||||
"```report-json 契约(严格 JSON,全程简体中文):",
|
||||
'{"title":"大屏主标题(≤20字)","subtitle":"一句话定位",',
|
||||
' "kpis":[{"label":"指标名","value":"数值","unit":"单位","trend":"up|down|flat"}], // 3-6 个核心指标,从研讨内容提取或合理估算',
|
||||
' "consensus":{"headline":"一句话最终结论","points":["结论要点,3-6 条"]},',
|
||||
' "charts":[{"type":"line|bar|donut|radar","title":"图表标题","items":[{"label":"维度","value":数字}]}], // 1-3 个总览图表:line=趋势(销量/渗透率随时间)、donut=占比(份额/构成)、radar=多维对比(竞争力)、bar=排名;3-8 项/图',
|
||||
' "timeline":[{"label":"阶段名","desc":"一句话说明"}], // 执行路线 3-6 阶段',
|
||||
' "seats":[{"id":"seat-1","name":"席位名(与交付席位一致)","summary":"该席位结论一两句",',
|
||||
' "sections":[{"heading":"小节标题","bullets":["要点,每条≤40字"]}], // 2-4 节,提炼该席位交付',
|
||||
' "metrics":[{"label":"量化指标名","value":0到100}], // 0-3 个',
|
||||
' "risks":[{"label":"风险点","level":"high|mid|low"}], // 0-4 个',
|
||||
' "dependsOn":["它承接哪些席位的产物(席位id)"]}]} // 每个交付席位一项;依据交付内容判断依赖关系',
|
||||
"硬性要求:seats 覆盖**每一个**有交付的席位;id 用 seat-1..seat-N;dependsOn 只引用本清单内的 id;JSON 必须可被 JSON.parse 解析(无注释、无尾逗号)。",
|
||||
].join("\n"),
|
||||
DASHBOARD_REPORT_JSON_CONTRACT.replace(
|
||||
"硬性要求:id",
|
||||
"硬性要求:seats 覆盖**每一个**有交付的席位;id",
|
||||
),
|
||||
);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 大屏绘制智能体(独立页面)的初始 prompt:输入是外部系统给的**任意结构 JSON**,
|
||||
* 让智能体**分析/转换**成大屏所需的 report-json 结构。区别于 buildDashboardPrompt
|
||||
* (输入是圆桌研讨成果),这里输入是登录时拉取的任务原始数据。
|
||||
*
|
||||
* 注:现在 JSON 接口未就绪,调用方传的是占位数据(任务名/内容等);接口就绪后改为
|
||||
* 后台转换格式 + 智能体校验,仍由本智能体产出/校验 report-json(不后台硬抽)。
|
||||
*/
|
||||
export function buildDashboardPromptFromJson(inputJson: unknown): string {
|
||||
let inputText: string;
|
||||
try {
|
||||
inputText =
|
||||
typeof inputJson === "string" ? inputJson : JSON.stringify(inputJson, null, 2);
|
||||
} catch {
|
||||
inputText = String(inputJson ?? "");
|
||||
}
|
||||
return [
|
||||
"【结构化数据模式】本轮**不要调用任何工具、不要写任何文件、不要输出 HTML**。",
|
||||
"下面是本任务的**原始数据(JSON)**。请你**分析并转换**它,提炼成一份用于「研讨大屏」展示的结构化数据:",
|
||||
"理解原始数据的业务含义,归纳关键指标、结论、图表维度、分阶段路线,以及若干主题分区(seats,按业务维度/模块拆分,不必对应真实席位)。",
|
||||
"在回复**最末尾**输出一个完整的 ```report-json 代码块(契约见下),代码块之外只允许一两句简短说明。",
|
||||
"",
|
||||
"# 任务原始数据(JSON)",
|
||||
"```json",
|
||||
inputText.trim() || "(未提供)",
|
||||
"```",
|
||||
"",
|
||||
DASHBOARD_REPORT_JSON_CONTRACT,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// ── 主题(2026-06-13):跟随应用当前主题绘制 ─────────────────────────────────────
|
||||
// light = 《浅红色 UI 规范》(docs/浅红色UI规范):白/浅灰大底、#F5222D 品牌红强调、
|
||||
// 卡片 4px 圆角、语义色 成功#189D72/警告#FF9900/错误#F5222D/信息#0079FE;
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* 「大屏绘制智能体」独立页面(带导航 + 侧边栏,挂在 StrategyRoutes 下)。
|
||||
*
|
||||
* 按外部 `taskId`(URL `?taskId=` / 路由 state / 登录暂存的任务详情 id)存取:
|
||||
* - 进入时按 taskId 拉取已存会话(`/api/dashboard-sessions/by-task`,**不按 user 分权**)。
|
||||
* - **已有有效结果(可解析的 report-json)** → 直接展示大屏页面(应用内 `DashboardApp`)。
|
||||
* - **没有有效结果** → 进入「生成过程」视图:自动让智能体分析登录拉取的业务 JSON
|
||||
* (现为占位适配,接口就绪后改后台转换 + 智能体校验)→ 边抽边在右侧沙箱显示,
|
||||
* **完成不自动跳**;底部输入框可随时改。
|
||||
* - 聊天记录 + 最新 report-json 防抖回存(按 taskId 共享,跨用户可见)。
|
||||
*
|
||||
* 数据抽取/聊天/沙箱/渲染全部复用 `useStep3Dashboard` + `DashboardChatPanel` +
|
||||
* `DashboardApp`,仅把输入从「圆桌研讨成果」换成「任务原始 JSON」。
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Boxes,
|
||||
Code2,
|
||||
ExternalLink,
|
||||
LayoutPanelTop,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { fetchDashboardInputJson } from "@/core/auth/dashboard-input";
|
||||
import { readCopTaskDetail } from "@/core/auth/task-deeplink";
|
||||
import { desensitizeDeep } from "@/lib/desensitize";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp";
|
||||
import { adaptReportData } from "@/roundtable-planning/dashboard/data-context";
|
||||
import { DashboardChatPanel } from "@/roundtable-planning/components/DashboardChatPanel";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
import { DASHBOARD_VIEW_STORAGE_PREFIX } from "@/roundtable-planning/pages/DashboardStandalonePage";
|
||||
import { useStep3Dashboard } from "@/roundtable-planning/hooks/useStep3Dashboard";
|
||||
import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import {
|
||||
buildDashboardPromptFromJson,
|
||||
parseReportJson,
|
||||
renderDashboardHtml,
|
||||
type DashboardTheme,
|
||||
} from "@/roundtable-planning/lib/report-template";
|
||||
import {
|
||||
getDashboardSessionByTask,
|
||||
putDashboardSessionByTask,
|
||||
} from "@/roundtable-planning/api/dashboard-sessions";
|
||||
|
||||
function formatNow(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
type DashView = "chat" | "page";
|
||||
type RenderMode = "app" | "html";
|
||||
|
||||
/** 从 URL / 路由 state / 登录暂存任务详情解析 taskId。 */
|
||||
function useTaskId(): string {
|
||||
const [searchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
return useMemo(() => {
|
||||
const fromQuery = searchParams.get("taskId")?.trim();
|
||||
if (fromQuery) return fromQuery;
|
||||
const fromState = (location.state as { taskId?: string } | null)?.taskId;
|
||||
if (fromState && String(fromState).trim()) return String(fromState).trim();
|
||||
const detail = readCopTaskDetail();
|
||||
if (detail?.id != null && String(detail.id).trim()) return String(detail.id).trim();
|
||||
return "";
|
||||
}, [searchParams, location.state]);
|
||||
}
|
||||
|
||||
export default function DashboardAgentPage() {
|
||||
const taskId = useTaskId();
|
||||
const { models: availableModels, isLoading: modelsLoading } = useModels();
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const { theme } = useTheme();
|
||||
const themeMode: DashboardTheme = theme === "dark" ? "dark" : "light";
|
||||
|
||||
// 输入 JSON(登录拉取/占位)——auto-start 时由 buildInitialPrompt 闭包读取。
|
||||
const inputRef = useRef<unknown>(null);
|
||||
|
||||
const dashboard = useStep3Dashboard({
|
||||
intentReady: null,
|
||||
seatDeliveries: [],
|
||||
consensusText: "",
|
||||
selectedModel,
|
||||
themeMode,
|
||||
buildInitialPrompt: () => {
|
||||
const inp = inputRef.current;
|
||||
return inp ? buildDashboardPromptFromJson(inp) : null;
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
phase,
|
||||
dialogues,
|
||||
data,
|
||||
seatStubMessages,
|
||||
activeArtifactThreadId,
|
||||
busy,
|
||||
redraw,
|
||||
ask,
|
||||
abort,
|
||||
setPreviewEnabled,
|
||||
openDashboardPreview,
|
||||
start,
|
||||
hydrateTranscript,
|
||||
} = dashboard;
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [view, setView] = useState<DashView>("chat");
|
||||
const [renderMode, setRenderMode] = useState<RenderMode>("app");
|
||||
|
||||
// 默认选中第一个模型。
|
||||
useEffect(() => {
|
||||
if (availableModels.length === 0) return;
|
||||
if (!availableModels.some((m) => m.name === selectedModel)) {
|
||||
setSelectedModel(availableModels[0].name);
|
||||
}
|
||||
}, [availableModels, selectedModel]);
|
||||
|
||||
// ── 挂载:按 taskId 取会话 → 决定直显大屏 / 看生成过程(只跑一次) ──
|
||||
const didInitRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (didInitRef.current) return;
|
||||
if (!taskId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
didInitRef.current = true;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const session = await getDashboardSessionByTask(taskId).catch(() => null);
|
||||
if (cancelled) return;
|
||||
const savedDialogues = (session?.transcript as Step2Dialogue[] | undefined) ?? [];
|
||||
const validReport = session?.reportJson && parseReportJson(session.reportJson);
|
||||
if (validReport) {
|
||||
// 有有效结果 → 恢复并直接展示大屏页面。
|
||||
hydrateTranscript(savedDialogues, session!.reportJson);
|
||||
setView("page");
|
||||
} else if (savedDialogues.length > 0) {
|
||||
// 有历史聊天但无有效结果 → 进生成过程展示历史,不自动重跑。
|
||||
hydrateTranscript(savedDialogues, session?.reportJson ?? null);
|
||||
setView("chat");
|
||||
} else {
|
||||
// 无任何记录 → 拉输入 JSON 自动开跑,让用户看生成过程(不自动跳)。
|
||||
const input = await fetchDashboardInputJson(taskId).catch(() => null);
|
||||
if (cancelled) return;
|
||||
inputRef.current = input;
|
||||
setView("chat");
|
||||
if (input) start();
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [taskId, hydrateTranscript, start]);
|
||||
|
||||
// ── 防抖回存聊天记录 + 最新 report-json(按 taskId 共享) ──
|
||||
useEffect(() => {
|
||||
if (!taskId || loading) return;
|
||||
if (dialogues.length === 0) return;
|
||||
const t = window.setTimeout(() => {
|
||||
void putDashboardSessionByTask(taskId, {
|
||||
transcript: dialogues,
|
||||
reportJson: data ? JSON.stringify(data) : undefined,
|
||||
}).catch(() => {
|
||||
/* 回存失败不打扰用户 */
|
||||
});
|
||||
}, 800);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [taskId, loading, dialogues, data]);
|
||||
|
||||
// 仅在交互区允许本流自动接管沙箱(并行流防抢);离开/卸载时让出。
|
||||
useEffect(() => {
|
||||
setPreviewEnabled(view === "chat");
|
||||
return () => setPreviewEnabled(false);
|
||||
}, [view, setPreviewEnabled]);
|
||||
|
||||
// ── 大屏渲染数据(深度脱敏后渲染,覆盖大屏所有展示文本) ──
|
||||
const report = useMemo(() => (data ? desensitizeDeep(data) : null), [data]);
|
||||
const dashboardData = useMemo(
|
||||
() => (report ? adaptReportData(report, formatNow()) : undefined),
|
||||
[report],
|
||||
);
|
||||
const dashboardHtml = useMemo(
|
||||
() => (report ? renderDashboardHtml(report, { generatedAt: formatNow(), theme: themeMode }) : null),
|
||||
[report, themeMode],
|
||||
);
|
||||
const hasResult = !!dashboardData || !!dashboardHtml;
|
||||
|
||||
// 网页渲染:blob URL 作 iframe src(:target 导航需独立文档 URL,见 Step3DashboardMode 注释)。
|
||||
const [htmlBlobUrl, setHtmlBlobUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !dashboardHtml) {
|
||||
setHtmlBlobUrl(null);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(new Blob([dashboardHtml], { type: "text/html;charset=utf-8" }));
|
||||
setHtmlBlobUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [dashboardHtml]);
|
||||
|
||||
const isStreaming =
|
||||
phase === "creating" || phase === "streaming" || dialogues.some((d) => d.streaming === true);
|
||||
|
||||
const handleOpenNewWindow = () => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (renderMode === "html" && dashboardHtml) {
|
||||
const blobUrl = URL.createObjectURL(new Blob([dashboardHtml], { type: "text/html;charset=utf-8" }));
|
||||
window.open(blobUrl, "_blank", "noopener,noreferrer");
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
return;
|
||||
}
|
||||
const key = `${DASHBOARD_VIEW_STORAGE_PREFIX}${Date.now()}`;
|
||||
try {
|
||||
for (let i = window.localStorage.length - 1; i >= 0; i -= 1) {
|
||||
const k = window.localStorage.key(i);
|
||||
if (k && k.startsWith(DASHBOARD_VIEW_STORAGE_PREFIX)) window.localStorage.removeItem(k);
|
||||
}
|
||||
if (dashboardData) window.localStorage.setItem(key, JSON.stringify(dashboardData));
|
||||
} catch {
|
||||
/* localStorage 不可用:新窗口回落 DEMO */
|
||||
}
|
||||
const base = `${window.location.origin}${window.location.pathname}`;
|
||||
window.open(`${base}#/roundtable-dashboard?k=${encodeURIComponent(key)}`, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
if (!taskId) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center p-8 text-center text-sm text-slate-500">
|
||||
<div>
|
||||
<Sparkles className="mx-auto mb-3 h-8 w-8 text-primary/40" />
|
||||
缺少任务标识(taskId)。请通过带 <code className="px-1">?taskId=</code> 的链接进入大屏绘制页。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col text-slate-800">
|
||||
{/* 顶栏 */}
|
||||
<div className="shrink-0 flex flex-wrap items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Sparkles className="h-4.5 w-4.5 text-primary" />
|
||||
<h3 className="text-sm font-black tracking-tight text-slate-900">大屏绘制智能体</h3>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-red-200/50 bg-[var(--brand-200)]/60 px-2 py-0.5 text-[10px] font-bold text-primary">
|
||||
{(phase === "creating" || phase === "streaming") && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{view === "chat" ? "生成过程" : "大屏页面"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{view === "page" ? (
|
||||
<>
|
||||
<div className="inline-flex items-center gap-0.5 rounded-xl border border-slate-200 bg-slate-50 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenderMode("app")}
|
||||
title="用应用内 React 组件渲染(Tab 切换、动画、席位钻取)"
|
||||
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[10.5px] font-bold transition ${
|
||||
renderMode === "app"
|
||||
? "border border-slate-200 bg-white text-primary shadow-sm"
|
||||
: "text-slate-500 hover:text-slate-700"
|
||||
}`}
|
||||
>
|
||||
<Boxes className="h-3.5 w-3.5" />
|
||||
应用渲染
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenderMode("html")}
|
||||
disabled={!dashboardHtml}
|
||||
title={dashboardHtml ? "用自包含 HTML 大屏渲染(与新窗口/下载一致)" : "尚无已生成的 HTML,先完成一次绘制"}
|
||||
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[10.5px] font-bold transition disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
renderMode === "html"
|
||||
? "border border-slate-200 bg-white text-primary shadow-sm"
|
||||
: "text-slate-500 hover:text-slate-700"
|
||||
}`}
|
||||
>
|
||||
<Code2 className="h-3.5 w-3.5" />
|
||||
网页渲染
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("chat")}
|
||||
title="回到生成过程,加载聊天记录继续问答修改"
|
||||
className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white px-3 py-1.5 text-[10.5px] font-bold text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-slate-500" />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenNewWindow}
|
||||
title={renderMode === "html" ? "新标签页打开自包含 HTML 大屏" : "新标签页打开应用内 React 大屏(全屏)"}
|
||||
className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white px-3 py-1.5 text-[10.5px] font-bold text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-slate-500" />
|
||||
新窗口打开
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
hasResult && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("page")}
|
||||
title="查看已生成的大屏页面"
|
||||
className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white px-3 py-1.5 text-[10.5px] font-bold text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
<LayoutPanelTop className="h-3.5 w-3.5 text-slate-500" />
|
||||
查看大屏
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主体 */}
|
||||
{loading ? (
|
||||
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-slate-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
正在加载任务大屏…
|
||||
</div>
|
||||
) : view === "page" ? (
|
||||
renderMode === "html" && htmlBlobUrl ? (
|
||||
<iframe
|
||||
title="任务研讨大屏"
|
||||
src={htmlBlobUrl}
|
||||
className="min-h-0 w-full flex-1 border-0 bg-white"
|
||||
sandbox="allow-scripts allow-popups allow-downloads allow-same-origin"
|
||||
/>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<DashboardApp data={dashboardData} />
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 p-3">
|
||||
<section className="h-full min-h-0 overflow-hidden rounded-2xl border border-slate-200/60 bg-white">
|
||||
<Step2SandboxLayout
|
||||
activeThreadId={activeArtifactThreadId}
|
||||
isStreaming={isStreaming}
|
||||
seatMessages={activeArtifactThreadId ? seatStubMessages[activeArtifactThreadId] : undefined}
|
||||
>
|
||||
<DashboardChatPanel
|
||||
phase={phase}
|
||||
dialogues={dialogues}
|
||||
busy={busy}
|
||||
error={dashboard.error}
|
||||
onAsk={ask}
|
||||
onRedraw={redraw}
|
||||
onPause={abort}
|
||||
onOpenPreview={openDashboardPreview}
|
||||
hasResult={hasResult}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
subtitle="分析任务原始数据,提炼为大屏结构化数据(右侧实时显示抽取过程)"
|
||||
doneHint="数据已就绪,点右上「查看大屏」查看。"
|
||||
/>
|
||||
</Step2SandboxLayout>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2501,21 +2501,30 @@ function RoundtableEntryRail({
|
||||
该业务链暂无智能体席位
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
{stageGroups.map((stage, si) => (
|
||||
<div className="flex flex-row flex-wrap items-center justify-center gap-1">
|
||||
{stageGroups.flatMap((stage, si) => {
|
||||
// 横向编排:阶段从左到右排列,阶段之间用 → 串联;并行阶段的
|
||||
// 席位在该列内竖向堆叠(宽度可控,符合「同层并行·层间串行」示意)。
|
||||
const items: React.ReactNode[] = [];
|
||||
if (si > 0) {
|
||||
items.push(
|
||||
<ChevronRight
|
||||
key={`arrow-${si}`}
|
||||
className="h-3 w-3 shrink-0 text-neutral-300"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
items.push(
|
||||
<div
|
||||
key={si}
|
||||
key={`stage-${si}`}
|
||||
className="flex flex-col items-center gap-1"
|
||||
>
|
||||
{si > 0 && (
|
||||
<ChevronDown className="h-3 w-3 text-neutral-300" />
|
||||
)}
|
||||
{stage.length > 1 && (
|
||||
<span className="rounded-full bg-primary/5 px-1.5 text-[8.5px] font-semibold text-primary">
|
||||
并行 {stage.length}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-center gap-1.5">
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
{stage.map((seat, sj) => {
|
||||
const palette =
|
||||
SEAT_CHIP_PALETTE[
|
||||
@@ -2553,8 +2562,10 @@ function RoundtableEntryRail({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
);
|
||||
return items;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -254,6 +254,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
| **Light Apps** (`/api/light-apps`) | Light-app registry (????? / ????), **global/shared** (no user scoping; `created_by`/`updated_by` audit-only). Two kinds: `window_open` (launched in a new tab from the ???? card grid) and `iframe` (embedded under a parent sidebar menu via `mount_location` and dynamically injected into the sidebar). Both carry `route_params` ? a list of `{key, paramType: login\|theme, source, customValue, themeValue}` descriptors the **frontend** resolves at launch time (login params read from `localStorage.userInfo`; theme params use a literal value) and appends to `url` as query string. `GET /` - list (filters `app_type`/`mount_location`/`enabled_only`/`search`); `GET /{app_id}` - read one (both open to any authenticated user); `POST /`, `PUT /{app_id}`, `DELETE /{app_id}` - **admin only**; iframe apps must carry a `mount_location` (400 otherwise); the unique `name` collides as HTTP 409. Backed by `deerflow.persistence.light_apps` (table `light_apps`, auto-created by `create_all`); store wired in `deps.py` as `app.state.light_app_store`. Tests: `tests/test_light_apps.py`. |
|
||||
| **Menu Overrides** (`/api/menu-overrides`) | Sidebar-menu overrides (菜单管理), **global/shared** (no user scoping; `updated_by` audit-only). The static menu tree (icons/routes/`adminOnly`) lives in the **frontend** code (`core/page-layout/sidebar-menu.ts`); iframe light-apps are injected at render time. This router persists a per-node **overlay** the frontend merges onto that tree: `parent_id` (re-parent, moves the whole subtree; NULL = top level), `sort_order` (reorder among siblings), `label` (rename; NULL keeps code label), `disabled` (停用 — hides the node **and its subtree**). A node with no row renders exactly as code defines it, so new code menus / newly-registered iframe apps appear automatically and are immediately configurable. `GET /` - list all overrides (open to any authenticated user); `PUT /` - **admin only**, atomically **replaces the whole set** (`{overrides: [...]}`); rejects re-parenting that would exceed **3 levels** or form a cycle (`_validate_depth`, HTTP 400). Backed by `deerflow.persistence.menu_overrides` (table `menu_overrides`, auto-created by `create_all`); store wired in `deps.py` as `app.state.menu_override_store`. Frontend: merge logic `core/page-layout/menu-overrides.ts` (`applyMenuOverrides` in `use-sidebar-menu.ts`), client `strategy-components/api/menu-overrides.ts`, admin page `pages/MenuManagementPage.tsx` (@dnd-kit drag-reorder/re-parent + 「移动到」parent selector + ↑↓ arrows + 改名/停用), entry in the bottom-left settings dropdown (`components/page-sidebar.tsx`, admin-only). Tests: `tests/test_menu_overrides.py`, `frontend-web/src/core/page-layout/menu-overrides.test.ts`. |
|
||||
| **Sensitive Words** (`/api/sensitive-words`) | Sensitive-word / desensitize map (敏感词管理 / 脱敏词), **global/shared** (no user scoping; `updated_by` audit-only). Each row maps an original `word` → `replacement`, with `enabled` (停用 parks a word without deleting) + `sort_order`. The **frontend** ships a seed map in code (`src/lib/desensitize.ts` `SEED_DESENSITIZE_MAP`) used as the default on a fresh deployment; once an admin saves, this table is the source of truth and the frontend loads it at startup (`DesensitizeWordsLoader` → `replaceDesensitizeMap`, **enabled** rows only). `GET /` - list all words (open to any authenticated user — every page desensitizes); `PUT /` - **admin OR the `lqq` account** (`_require_manager`: `system_role == "admin"` or email `@`-prefix `== "lqq"`), atomically **replaces the whole set** (`{words: [...]}`, de-duped by `word`, last wins). Backed by `deerflow.persistence.sensitive_words` (table `sensitive_words`, PK `word` `String(191)`, auto-created by `create_all`); store wired in `deps.py` as `app.state.sensitive_word_store`. Frontend: client `strategy-components/api/sensitive-words.ts`, page `pages/SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, entry in the bottom-left settings dropdown — **gated on username `lqq`, not admin role**, present in both sidebar modes; 「导入种子词」 merges missing seed entries). Tests: `tests/test_sensitive_words.py`. |
|
||||
| **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析登录拉取的业务 JSON → report-json → 渲染 `DashboardApp`;输入 seam `core/auth/dashboard-input.ts`(JSON 接口未就绪时回退 copTaskDetail 占位)。Tests: `tests/test_dashboard_sessions.py`。 |
|
||||
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Defaults to `background=true`**: the slow per-thread extraction loop is deferred to a FastAPI background task and the endpoint returns `{queued:true, processed:N}` immediately (each thread's transcript is truncated to `max_input_chars` before the LLM call). Running the loop inline for a large batch outlives the nginx gateway timeout (504), which the frontend's `parseResponse` surfaces as a generic "Request failed"; `background=false` keeps the blocking path (full per-thread `created/skipped/failed` tally) for small batches/scripts. Shared loop: `_run_batch_ingest`. **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` ? `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "???????". The "??????" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector?keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[?]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). `GET/POST /folders` + `PUT/DELETE /folders/{id}` - user-managed directories (notes carry a `folder` path; `GET /notes?folder=` filters a dir + sub-dirs); `GET/POST /extract-templates` + `GET/PUT/DELETE /extract-templates/{id}` - editable extraction-prompt templates (thread/document ingest accept `template_id`; capture/PUT-note accept `folder`). Backed by the `deerflow.knowledge` module (see below). |
|
||||
|
||||
Proxied through nginx: `/api/langgraph/*` ? LangGraph, all other `/api/*` ? Gateway.
|
||||
|
||||
@@ -37,6 +37,7 @@ from app.gateway.routers import (
|
||||
channels,
|
||||
checkpoint_migration,
|
||||
feedback,
|
||||
dashboard_sessions,
|
||||
html_page_favorites,
|
||||
intent,
|
||||
knowledge,
|
||||
@@ -1352,6 +1353,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Sensitive-word map (敏感词管理 / 脱敏词) at /api/sensitive-words
|
||||
app.include_router(sensitive_words.router)
|
||||
|
||||
# 大屏绘制智能体独立页面会话(按 taskId 存取,不分权)at /api/dashboard-sessions
|
||||
app.include_router(dashboard_sessions.router)
|
||||
|
||||
# Standalone parallel multi-agent panel: fan-out streaming + saved tasks
|
||||
app.include_router(parallel_agents.router)
|
||||
app.include_router(parallel_agent_tasks.router)
|
||||
|
||||
@@ -247,6 +247,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.sensitive_word_store = make_sensitive_word_store(sf)
|
||||
|
||||
from deerflow.persistence.dashboard_sessions import make_dashboard_session_store
|
||||
|
||||
app.state.dashboard_session_store = make_dashboard_session_store(sf)
|
||||
|
||||
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
|
||||
|
||||
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""按 taskId 存取的「大屏绘制智能体」独立页面会话存储 API。
|
||||
|
||||
独立大屏页(前端 ``DashboardAgentPage``)按外部 ``task_id`` 存取:聊天记录
|
||||
(``transcript``)+ 最新结构化数据(``report_json``)。**按 task 共享、不按 user
|
||||
分权**(open read + 写放开),镜像 ``roundtable_drafts`` 的 by-task 语义。
|
||||
|
||||
Routes (prefix ``/api/dashboard-sessions``):
|
||||
GET /by-task/{task_id} 取该 task 的会话(无则 404)
|
||||
PUT /by-task/{task_id} upsert(body: {transcript?, report_json?})
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard-sessions", tags=["dashboard-sessions"])
|
||||
|
||||
|
||||
class DashboardSessionResponse(BaseModel):
|
||||
task_id: str
|
||||
transcript: Any | None = None
|
||||
report_json: str | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class DashboardSessionUpsertRequest(BaseModel):
|
||||
# 只传需要更新的字段:聊天记录 / 结构化数据,二者皆可单独更新。
|
||||
transcript: Any | None = None
|
||||
report_json: str | None = None
|
||||
|
||||
|
||||
def _current_user_id(request: Request) -> str:
|
||||
user = getattr(request.state, "user", None)
|
||||
if user is not None:
|
||||
return str(user.id)
|
||||
return get_effective_user_id()
|
||||
|
||||
|
||||
def _get_store(request: Request):
|
||||
store = getattr(request.app.state, "dashboard_session_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Dashboard session store not available")
|
||||
return store
|
||||
|
||||
|
||||
def _project(row: dict[str, Any]) -> DashboardSessionResponse:
|
||||
return DashboardSessionResponse(
|
||||
task_id=row.get("task_id", ""),
|
||||
transcript=row.get("transcript"),
|
||||
report_json=row.get("report_json"),
|
||||
created_at=row.get("created_at"),
|
||||
updated_at=row.get("updated_at"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/by-task/{task_id}", response_model=DashboardSessionResponse)
|
||||
async def get_dashboard_session(request: Request, task_id: str) -> DashboardSessionResponse:
|
||||
"""取该 task 的大屏会话(**不按 user 分权**);无则 404。"""
|
||||
store = _get_store(request)
|
||||
row = await store.get_by_task(task_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="No dashboard session for this task")
|
||||
return _project(row)
|
||||
|
||||
|
||||
@router.put("/by-task/{task_id}", response_model=DashboardSessionResponse)
|
||||
async def upsert_dashboard_session(
|
||||
request: Request, task_id: str, body: DashboardSessionUpsertRequest
|
||||
) -> DashboardSessionResponse:
|
||||
"""按 task upsert 大屏会话(**不按 user 分权**),返回最新快照。"""
|
||||
store = _get_store(request)
|
||||
row = await store.upsert_by_task(
|
||||
task_id,
|
||||
transcript=body.transcript,
|
||||
report_json=body.report_json,
|
||||
user_id=_current_user_id(request),
|
||||
)
|
||||
return _project(row)
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
"""大屏绘制智能体 session persistence (按 taskId 存取,不分权)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
|
||||
from deerflow.persistence.dashboard_sessions.sql import DashboardSessionRepository
|
||||
|
||||
__all__ = [
|
||||
"DashboardSessionRepository",
|
||||
"DashboardSessionRow",
|
||||
"make_dashboard_session_store",
|
||||
]
|
||||
|
||||
|
||||
def make_dashboard_session_store(session_factory):
|
||||
if session_factory is None:
|
||||
return None
|
||||
return DashboardSessionRepository(session_factory)
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
"""ORM model for the standalone 大屏绘制智能体 session (按 taskId 存取).
|
||||
|
||||
One row per external ``task_id`` — the standalone dashboard page persists its
|
||||
chat transcript + the latest structured ``report_json`` so that returning to the
|
||||
same task either shows the finished dashboard directly (valid report_json) or
|
||||
restores the generation process.
|
||||
|
||||
**Global / shared by task** — like ``roundtable_drafts`` task-bound drafts, the
|
||||
store keys on ``task_id`` and does **not** scope by ``user_id`` (open read +
|
||||
shared writes). ``user_id`` is recorded for audit only.
|
||||
|
||||
``transcript`` / ``report_json`` are large JSON blobs (chat history + report
|
||||
data) stored as serialized strings in ``PortableLongText`` (MySQL ``LONGTEXT``)
|
||||
to dodge MySQL's 64 KB ``TEXT`` cap — same approach as ``roundtable_drafts``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime, PortableLongText
|
||||
|
||||
|
||||
class DashboardSessionRow(Base):
|
||||
__tablename__ = "dashboard_sessions"
|
||||
# One session per task — keep it unique so upsert-by-task is unambiguous.
|
||||
__table_args__ = (UniqueConstraint("task_id", name="uq_dashboard_sessions_task_id"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
# External task id (deep-link). The session is shared by task, not by user.
|
||||
task_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
# Audit only — who last wrote. Never used for access control.
|
||||
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# Chat transcript JSON (the dashboard agent's dialogue bubbles).
|
||||
transcript: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
# Latest structured dashboard data (report-json string). Presence of a
|
||||
# parseable value = "有效结果" → the page shows the dashboard directly.
|
||||
report_json: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
"""SQLAlchemy-backed 大屏绘制智能体 session store (按 taskId,不分权).
|
||||
|
||||
``transcript`` / ``report_json`` are JSON serialized into ``PortableLongText``
|
||||
and round-tripped here with ``json.dumps`` / ``json.loads`` — same approach as
|
||||
``roundtable_drafts``. All access keys on ``task_id`` and ignores ``user_id``
|
||||
(open read + shared writes); ``user_id`` is recorded for audit only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
|
||||
|
||||
|
||||
def _loads(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _dumps(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return value # already a JSON string (report_json) — store verbatim
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
class DashboardSessionRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(row: DashboardSessionRow) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"task_id": row.task_id,
|
||||
"user_id": row.user_id,
|
||||
"transcript": _loads(row.transcript),
|
||||
# report_json is a structured-data JSON string; return it as a string
|
||||
# so the frontend parses it with the same robust parser it uses live.
|
||||
"report_json": row.report_json,
|
||||
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
||||
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
|
||||
}
|
||||
|
||||
async def get_by_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
"""取该 task 的会话(**忽略 user_id**,不分权)。无则 None。"""
|
||||
stmt = select(DashboardSessionRow).where(DashboardSessionRow.task_id == task_id).limit(1)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalars().first()
|
||||
return self._to_dict(row) if row is not None else None
|
||||
|
||||
async def upsert_by_task(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
transcript: Any = None,
|
||||
report_json: Any = None,
|
||||
user_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""按 task_id upsert:无则建、有则更新(**不分权**)。
|
||||
|
||||
``transcript`` / ``report_json`` 仅在传入非 None 时覆盖,便于只更新其中一项。
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
async with self._sf() as session:
|
||||
stmt = select(DashboardSessionRow).where(DashboardSessionRow.task_id == task_id).limit(1)
|
||||
row = (await session.execute(stmt)).scalars().first()
|
||||
if row is None:
|
||||
row = DashboardSessionRow(
|
||||
id=uuid4().hex,
|
||||
task_id=task_id,
|
||||
user_id=user_id,
|
||||
transcript=_dumps(transcript),
|
||||
report_json=_dumps(report_json),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(row)
|
||||
else:
|
||||
if transcript is not None:
|
||||
row.transcript = _dumps(transcript)
|
||||
if report_json is not None:
|
||||
row.report_json = _dumps(report_json)
|
||||
if user_id is not None:
|
||||
row.user_id = user_id
|
||||
row.updated_at = now
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._to_dict(row)
|
||||
+2
@@ -23,6 +23,7 @@ from deerflow.persistence.agents.model import (
|
||||
)
|
||||
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
|
||||
from deerflow.persistence.business_mapping.model import BusinessMappingRow
|
||||
from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
|
||||
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
|
||||
from deerflow.persistence.feedback.model import FeedbackRow
|
||||
from deerflow.knowledge.models import (
|
||||
@@ -81,6 +82,7 @@ __all__ = [
|
||||
"AgentSkillRow",
|
||||
"AdminLeaderboardDailyStatRow",
|
||||
"BusinessMappingRow",
|
||||
"DashboardSessionRow",
|
||||
"KnowledgeEmbeddingRow",
|
||||
"KnowledgeEntityRow",
|
||||
"KnowledgeExtractTemplateRow",
|
||||
|
||||
Reference in new issue
Block a user