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
+1396
-271
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user