feat(roundtable): 深链策划流程小图 + 流程图弹窗横向/缩略图/深蓝适配

- Step1 消息列表头部:深链 taskId 模式取到目的树后,左侧标题换成「策划流程」
  彩色层级小图(任务→目的→…→叙事,每级配色对齐流程图节点 + 类型小图标),
  点击打开任务目的流程图弹窗;仅第一步、有数据时出现。
- TaskPurposeDialog 流程图:横向(LR)布局 + 右下角缩略图(MiniMap)。
- FlowGraphPanel 深蓝主题适配(随 ThemeContext):容器/头部/画布点阵/连线/图例/
  小地图/节点详情弹窗 + 节点配色(FLOW_NODE_TYPE_META_DARK),浅色红主题不受影响。
- 圆桌侧栏深色兜底:neutral 家族 + 米色 hex(#FAF6F5/#ECE7E4/#E8D0CC) 映射到深蓝
  token(关系图背板、第N步 tag 等不再泛白)。

附带提交工作区其余暂存改动(AI 写作等),不含 config.yaml。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-18 17:39:19 +08:00
1 parent 85cca36ddb
commit 03b6fb724c
40 files changed
+2167 -119

No files matched your search

@@ -401,7 +401,7 @@ export function PositionManagementPage() {
};
return (
<div className="flex flex-col gap-4 p-6">
<div className="flex size-full min-h-0 flex-col gap-4 overflow-auto p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold">岗位管理</h1>
@@ -12,7 +12,7 @@ export function UserManagementPage() {
const { data: users, isLoading, error } = useAllUsers(true);
return (
<div className="flex flex-col gap-6 p-6">
<div className="flex size-full min-h-0 flex-col gap-6 overflow-auto p-6">
<SettingsSection
title={t.adminUsers.title}
description={t.adminUsers.description}
@@ -364,7 +364,7 @@ export function AgentGallery() {
};
return (
<div className="flex size-full flex-col">
<div className="flex size-full min-h-0 flex-col">
{/* Page header */}
<div className="flex items-center justify-between border-b px-6 py-4">
<div>
@@ -392,7 +392,7 @@ export function AgentGallery() {
setSort("time_desc");
setPublisher(null);
}}
className="flex flex-1 flex-col overflow-hidden"
className="flex min-h-0 flex-1 flex-col overflow-hidden"
>
<div className="mx-6 mt-4 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
@@ -170,8 +170,10 @@ export function AgentModuleSidebar({
});
return (
// 背景按需求固定白色(不走 sidebar token),与全量版 GeneralQaSidebar 的卡片视觉看齐。
<aside className="flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground">
// 原有布局不变,仅做配色适配:深色下左栏改为半透明深蓝玻璃面板(对齐 .sidebar-contents:
// bg rgba(3,38,80,.8) + 蓝色发光边框),叠 backdrop-blur 让外壳的深蓝底图朦胧透出;
// 浅色保持白底。其余颜色已走语义 token 自适配。
<aside className="flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80">
{onOpenSettings && (
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2">
<span className="text-xs font-semibold text-foreground/80">智能体</span>
@@ -15,6 +15,12 @@ import {
getCachedTaskDeeplinkConfig,
} from "@/core/auth/task-deeplink-config";
import { requestCrudService } from "@/service/request-axios-service";
import type {
FlowGraphData,
FlowGraphEdge,
FlowGraphNode,
FlowNodeType,
} from "@/roundtable-planning/lib/flow-graph";
/** 归一化后的目的树节点(用于展示,逐层:目的→行为体→预期行为→驱动因素→叙事)。 */
export interface PurposeTreeNode {
@@ -112,6 +118,83 @@ export function flattenPurposeTree(nodes: PurposeTreeNode[]): PurposeFlatCell[][
return rows;
}
/* --------------------- 目的树 → 流程图(flow-graph) --------------------- */
/**
* 目的树各层级标签 → 流程图节点类型(仅决定配色,见 FLOW_NODE_TYPE_META)。
* 未知层级回退 task。层级名本身仍原样写进节点 group,画布上当小标签显示。
*/
const PURPOSE_LABEL_TO_FLOW_TYPE: Record<string, FlowNodeType> = {
目的: "stage",
行为体: "role",
预期行为: "task",
驱动因素: "decision",
叙事: "deliverable",
};
/** 目的树的一个层级(深度):层级中文名 + 对应流程图节点类型(决定配色)。 */
export interface PurposeLevel {
/** 层级中文名(目的 / 行为体 / 预期行为 / 驱动因素 / 叙事…)。 */
label: string;
/** 流程图节点类型(用于取 FLOW_NODE_TYPE_META 配色,与画布节点一致)。 */
type: FlowNodeType;
}
/**
* 按**深度**抽出目的树的有序层级(每层取该深度第一个非空层级名作代表)。
*
* 目的树每一深度的层级是一致的(深度 0 = 目的、1 = 行为体、… 直到叶子叙事),
* 故逐层 BFS、每层取一个代表标签即可得到「任务策划共几级、分别是什么」,
* 供消息列表上方的「流程示例图」按层级画彩色小图标。
*/
export function derivePurposeLevels(tree: PurposeTreeNode[]): PurposeLevel[] {
const levels: PurposeLevel[] = [];
let frontier = tree;
let depth = 0;
while (frontier.length > 0 && depth < 12) {
const label = frontier.find((n) => n.label?.trim())?.label?.trim() || `层级 ${depth + 1}`;
levels.push({ label, type: PURPOSE_LABEL_TO_FLOW_TYPE[label] ?? "task" });
frontier = frontier.flatMap((n) => n.children ?? []);
depth += 1;
}
return levels;
}
/**
* 把归一化后的目的树转换成 Step 3 流程图组件(FlowGraphPanel)认识的 FlowGraphData。
*
* - 逐层深度优先遍历,给每个节点分配稳定 id(`p1/p2/…`,按遍历顺序);
* - 父→子建一条边(from=父 id);多个根目的各自成一棵子树(画布支持多根);
* - 节点主文本(`text`)作 label,层级名(`label`)作 group(画布顶部小标签),
* 推荐理由并进 detail(点节点弹窗里看)。
* 容错沿用 PurposeTreeNode(normalizeNode 已兜底字段/层级变动),这里不再二次判断。
*/
export function purposeTreeToFlowGraph(
tree: PurposeTreeNode[],
taskId?: string,
): FlowGraphData {
const nodes: FlowGraphNode[] = [];
const edges: FlowGraphEdge[] = [];
let counter = 0;
const walk = (node: PurposeTreeNode, parentId: string | null) => {
const id = `p${++counter}`;
const type = PURPOSE_LABEL_TO_FLOW_TYPE[node.label] ?? "task";
const text = node.text?.trim() || node.label || "(空)";
const detail = node.reason?.trim()
? `${text}\n理由:${node.reason.trim()}`
: node.text?.trim() || undefined;
nodes.push({ id, label: text, type, group: node.label || undefined, detail });
if (parentId) edges.push({ from: parentId, to: id });
node.children?.forEach((child) => walk(child, id));
};
tree.forEach((root) => walk(root, null));
return {
title: taskId ? `任务目的(任务 ${taskId})` : "任务目的",
nodes,
edges,
};
}
/** 从接口/假数据的整包 body 里取出 data 数组并归一化成树。 */
function bodyToTree(body: unknown): PurposeTreeNode[] {
const data =
@@ -87,6 +87,50 @@ export async function deleteAIWritingSession(id: string): Promise<void> {
if (!resp.ok) throw new Error(`删除失败: ${resp.status}`);
}
/**
* 把一篇写作转入「后台挂起」:后端自动驱动剩余流程到定稿(离页/刷新/切号不打断)。
*
* 幂等——同一会话已在后台跑则后端直接返回 ``started:false``。
*/
export async function suspendAIWritingToBackground(
id: string,
): Promise<{ started: boolean; status: string }> {
const resp = await apiFetch(`${BASE()}/${id}/background`, { method: 'POST' });
if (!resp.ok) throw new Error(`后台挂起失败: ${resp.status}`);
return resp.json();
}
/** 一篇写作的实际进度(供「查看进度」流程图弹窗轮询)。 */
export interface AIWritingProgress {
sessionStatus: string;
/** 规范化阶段 key(与流程图阶段一一对应);null = 还没建 checkpoint。 */
stage: string | null;
note: string | null;
revisionCount: number;
maxRevisions: number;
reviewVerdict: string | null;
isRunning: boolean;
}
export async function getAIWritingProgress(id: string): Promise<AIWritingProgress> {
const resp = await apiFetch(`${BASE()}/${id}/progress`);
if (!resp.ok) throw new Error(`获取进度失败: ${resp.status}`);
return resp.json();
}
/**
* beforeunload 兜底:离开/刷新页面时用 keepalive 发起后台挂起,
* 保证浏览器卸载页面后请求仍能发完。fire-and-forget,不读响应;
* 重复触发由后端「同会话已在后台跑」幂等兜住。
*/
export function suspendAIWritingToBackgroundKeepalive(id: string): void {
try {
void apiFetch(`${BASE()}/${id}/background`, { method: 'POST', keepalive: true }).catch(() => {});
} catch {
/* 卸载阶段尽力而为,忽略一切异常 */
}
}
/**
* 保存会话的完整对话时间线,供历史回显出与实时对话一致的富时间线。
*
@@ -0,0 +1,138 @@
/**
* AI 写作流程图(竖向固定流水线)。
*
* 写作流程是固定线性流水线:意图理解 → 素材搜集 → 素材确认 → 大纲规划 → 大纲确认 →
* 写作 → 草稿确认 → 编辑审核 → 定稿。按传入的 ``activeKey``(后端从 graph checkpoint
* 推导的当前阶段 key)点亮:已过=done 打钩、当前=active 转圈、未到=灰。
*
* 纯展示组件,无弹窗/无窗口 chrome —— 由 ``AIWritingFlowDialog`` 套在弹窗里使用。
*/
import { CheckIcon, Loader2Icon } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface FlowStage {
key: string;
label: string;
emoji: string;
}
/** 固定流水线阶段(key 与后端 ai_writing_job_executor 的 stage key 一致)。 */
export const FLOW_STAGES: readonly FlowStage[] = [
{ key: 'intent', label: '意图理解', emoji: '🧭' },
{ key: 'research', label: '素材搜集', emoji: '🔍' },
{ key: 'material_confirm', label: '素材确认', emoji: '📚' },
{ key: 'outline', label: '大纲规划', emoji: '🗂️' },
{ key: 'outline_confirm', label: '大纲确认', emoji: '🧩' },
{ key: 'draft', label: '写作', emoji: '✍️' },
{ key: 'draft_confirm', label: '草稿确认', emoji: '📝' },
{ key: 'review', label: '编辑审核', emoji: '🧐' },
{ key: 'done', label: '定稿', emoji: '🏁' },
];
export const FLOW_STAGE_LABEL: Record<string, string> = Object.fromEntries(
FLOW_STAGES.map((s) => [s.key, s.label]),
);
type StageState = 'pending' | 'active' | 'done' | 'error';
const LINE = 'bg-slate-300 dark:bg-border';
function StageNode({
label,
emoji,
state,
note,
}: {
label: string;
emoji: string;
state: StageState;
note?: string | null;
}) {
return (
<div className="flex shrink-0 flex-col items-center gap-1 px-0.5 text-center">
<div className="relative flex size-11 items-center justify-center">
{state === 'active' && <span className="absolute inset-0 animate-ping rounded-full bg-primary/25" />}
<span
className={cn(
'relative flex size-11 items-center justify-center rounded-full border-2 bg-white text-lg transition-all dark:bg-card',
state === 'active' && 'border-primary shadow-[0_0_0_3px_rgba(22,119,255,0.12)]',
state === 'done' && 'border-emerald-400/70',
state === 'error' && 'border-destructive',
state === 'pending' && 'border-border opacity-45 grayscale',
)}
>
{emoji}
{state === 'done' && (
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-emerald-500 text-white shadow">
<CheckIcon className="size-2.5" />
</span>
)}
{state === 'active' && (
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-primary text-primary-foreground shadow">
<Loader2Icon className="size-2.5 animate-spin" />
</span>
)}
</span>
</div>
<span
className={cn(
'whitespace-nowrap rounded border px-1 py-px text-[10px] font-medium leading-tight',
state === 'active' && 'border-primary/40 bg-primary/10 text-primary',
state === 'done' && 'border-emerald-400/40 bg-emerald-50 text-emerald-600 dark:bg-emerald-950/30',
state === 'error' && 'border-destructive/40 bg-destructive/10 text-destructive',
state === 'pending' && 'border-border bg-muted text-muted-foreground',
)}
title={label}
>
{label}
</span>
{note && state === 'active' && (
<span className="rounded bg-amber-100 px-1 py-px text-[9px] font-medium text-amber-600 dark:bg-amber-950/30">
{note}
</span>
)}
</div>
);
}
export function AIWritingFlowChart({
activeKey,
isError = false,
note,
className,
}: {
/** 当前阶段 key(null = 还没开始 / 准备中,全部置灰)。 */
activeKey: string | null;
isError?: boolean;
note?: string | null;
className?: string;
}) {
const allDone = activeKey === 'done';
const activeIdx = activeKey ? FLOW_STAGES.findIndex((s) => s.key === activeKey) : -1;
const stateOf = (idx: number): StageState => {
if (allDone) return 'done';
if (activeIdx < 0) return 'pending';
if (idx < activeIdx) return 'done';
if (idx === activeIdx) return isError ? 'error' : 'active';
return 'pending';
};
return (
// 横向流水线:左→右排列,节点间用横向连线(对齐到圆形头像竖直中心 ≈ 22px)。
// w-max 保持内容固有宽度,由外层 overflow-x-auto 在放不下时横向滚动。
<div className={cn('flex w-max flex-row items-start', className)}>
{FLOW_STAGES.map((stage, idx) => (
<div className="contents" key={stage.key}>
{idx > 0 && <div className={cn('mt-[22px] h-px w-5 shrink-0', LINE)} />}
<StageNode
label={stage.label}
emoji={stage.emoji}
state={stateOf(idx)}
note={stage.key === activeKey ? note : undefined}
/>
</div>
))}
</div>
);
}
@@ -0,0 +1,124 @@
/**
* 「查看进度」流程图弹窗。
*
* 在历史记录里点击一条**后台挂起中**的写作 → 弹出本弹窗,里面是写作流程图,
* 实时(轮询)反映该会话后端的实际推进位置,让用户看到「跑到哪一步了」。
*/
import { useEffect, useRef, useState } from 'react';
import { Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
getAIWritingProgress,
type AIWritingProgress,
type AIWritingSessionSummary,
} from '../../api/ai-writing-sessions';
import { AIWritingFlowChart, FLOW_STAGE_LABEL } from './AIWritingFlowChart';
const POLL_MS = 2500;
function isTerminal(p: AIWritingProgress | null): boolean {
if (!p) return false;
return p.sessionStatus === 'done' || p.sessionStatus === 'error' || p.stage === 'done';
}
export function AIWritingFlowDialog({
session,
open,
onOpenChange,
}: {
session: AIWritingSessionSummary | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [progress, setProgress] = useState<AIWritingProgress | null>(null);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!open || !session) return;
let cancelled = false;
setProgress(null);
setFetchError(false);
setLoading(true);
const tick = async () => {
try {
const p = await getAIWritingProgress(session.id);
if (cancelled) return;
setProgress(p);
setFetchError(false);
setLoading(false);
// 终态停止轮询,否则继续。
if (!isTerminal(p)) {
timerRef.current = setTimeout(tick, POLL_MS);
}
} catch {
if (cancelled) return;
setFetchError(true);
setLoading(false);
timerRef.current = setTimeout(tick, POLL_MS);
}
};
void tick();
return () => {
cancelled = true;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
};
}, [open, session]);
const terminal = isTerminal(progress);
const isError = progress?.sessionStatus === 'error';
const stage = progress?.stage ?? null;
let caption: string;
if (loading && !progress) {
caption = '加载进度中…';
} else if (isError) {
caption = '后台执行出错,请重新发起写作';
} else if (progress?.sessionStatus === 'done' || stage === 'done') {
caption = '✓ 写作已完成,点开这条记录即可查看成稿';
} else {
const stageLabel = stage ? (FLOW_STAGE_LABEL[stage] ?? '准备中') : '准备中';
let text = `后台运行中 · 当前:${stageLabel}`;
if (progress?.note) text += `(${progress.note})`;
if (stage === 'review' && (progress?.revisionCount ?? 0) > 0) {
text += ` · 第 ${progress!.revisionCount}/${progress!.maxRevisions} 轮修改`;
}
caption = text;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="truncate pr-6 text-sm" title={session?.title}>
写作进度 · {session?.draft_title || session?.title || '后台写作'}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-3 py-1">
<div className="w-full overflow-x-auto py-2">
<AIWritingFlowChart className="mx-auto" activeKey={stage} isError={isError} note={progress?.note} />
</div>
<div className="flex items-center justify-center gap-1.5 text-center text-xs text-muted-foreground">
{!terminal && !isError && <Loader2 className="size-3 shrink-0 animate-spin text-sky-500" />}
<span className={fetchError ? 'text-amber-500' : undefined}>
{fetchError ? '获取进度失败,重试中…' : caption}
</span>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { History, Trash2, Check, X, RefreshCw } from 'lucide-react';
import { History, Trash2, Check, X, RefreshCw, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -7,6 +7,8 @@ import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
export const STATUS_LABELS: Record<string, { label: string; color: string }> = {
in_progress: { label: '进行中', color: 'text-blue-500' },
// 后台挂起中:后端正自动驱动到定稿(离页/刷新/切号不打断)。
background: { label: '挂起中', color: 'text-sky-500' },
draft_ready: { label: '草稿', color: 'text-amber-500' },
review_ready: { label: '审核', color: 'text-purple-500' },
done: { label: '完成', color: 'text-green-500' },
@@ -29,11 +31,15 @@ export interface SessionItemProps {
session: AIWritingSessionSummary;
isSelected: boolean;
onSelect: () => void;
/** 「挂起中」记录点击时改为查看进度(流程图弹窗),不直接打开会话。 */
onShowProgress?: () => void;
onDelete: () => void;
onRename: (title: string) => void;
}
export function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
export function SessionItem({ session, isSelected, onSelect, onShowProgress, onDelete, onRename }: SessionItemProps) {
const isBackground = session.status === 'background';
const handleRowClick = isBackground && onShowProgress ? onShowProgress : onSelect;
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(null);
@@ -60,7 +66,7 @@ export function SessionItem({ session, isSelected, onSelect, onDelete, onRename
'group relative flex items-start gap-2 px-3 py-2.5 cursor-pointer transition-colors',
isSelected ? 'bg-accent' : 'hover:bg-accent/60',
)}
onClick={!editing ? onSelect : undefined}
onClick={!editing ? handleRowClick : undefined}
>
<div className="flex-1 min-w-0">
{editing ? (
@@ -89,7 +95,10 @@ export function SessionItem({ session, isSelected, onSelect, onDelete, onRename
</p>
)}
<div className="flex items-center gap-1.5 mt-0.5">
<span className={cn('text-[10px] font-medium', color)}>{label}</span>
<span className={cn('flex items-center gap-0.5 text-[10px] font-medium', color)}>
{session.status === 'background' && <Loader2 className="w-2.5 h-2.5 animate-spin" />}
{label}
</span>
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">{formatRelative(session.updated_at)}</span>
</div>
@@ -114,6 +123,8 @@ interface AIWritingHistoryDropdownProps {
selectedId: string | null;
loading: boolean;
onSelect: (session: AIWritingSessionSummary) => void;
/** 点击「挂起中」记录时查看进度(流程图弹窗),不直接打开会话。 */
onShowProgress?: (session: AIWritingSessionSummary) => void;
onDelete: (id: string) => void;
onRename: (id: string, title: string) => void;
onRefresh: () => void;
@@ -124,6 +135,7 @@ export function AIWritingHistoryDropdown({
selectedId,
loading,
onSelect,
onShowProgress,
onDelete,
onRename,
onRefresh,
@@ -189,6 +201,7 @@ export function AIWritingHistoryDropdown({
session={s}
isSelected={s.id === selectedId}
onSelect={() => { onSelect(s); setOpen(false); }}
onShowProgress={onShowProgress ? () => { onShowProgress(s); setOpen(false); } : undefined}
onDelete={() => onDelete(s.id)}
onRename={(title) => onRename(s.id, title)}
/>
@@ -1,8 +1,34 @@
import { useMemo } from 'react';
import { CheckCircle2, ChevronRight, FileText, Search, PenLine, ClipboardEdit, Star } from 'lucide-react';
import type { AIWritingSessionSummary, CompletedInterventionRecord } from '../../api/ai-writing-sessions';
import {
normalizeProgressDict,
normalizeMaterial,
normalizeOutline,
normalizeReviewResult,
} from '../../hooks/useAIWritingStream';
import type { CompletedIntervention } from '../../types/ai-writing';
import { AIWritingTimeline } from './AIWritingTimeline';
/**
* 归一化一条 completedIntervention(后台挂起重建的干预记录里素材/大纲/审核数据是
* snake_case,前端干预卡期望 camelCase)。对前端实时存的 camelCase 记录幂等。
*/
function normalizeCompletedIntervention(rec: Record<string, unknown>): CompletedIntervention {
const out: Record<string, unknown> = { ...rec };
out.pausePoint = rec.pausePoint ?? rec.pause_point;
if (Array.isArray(rec.materials)) {
out.materials = (rec.materials as Record<string, unknown>[]).map(normalizeMaterial);
}
if (rec.outline) out.outline = normalizeOutline(rec.outline);
const rr = rec.reviewResult ?? rec.review_result;
if (rr) out.reviewResult = normalizeReviewResult(rr);
if (out.draftTitle == null && rec.draft_title != null) out.draftTitle = rec.draft_title;
if (out.draftWordCount == null && rec.draft_word_count != null) out.draftWordCount = rec.draft_word_count;
return out as unknown as CompletedIntervention;
}
const PAUSE_LABELS: Record<string, string> = {
material_confirm: '素材确认',
outline_confirm: '大纲确认',
@@ -67,6 +93,22 @@ export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) {
const transcript = session.transcript;
// 有完整时间线快照时,复用实时对话的 AIWritingTimeline 渲染,保证回显与实时一致
const hasTranscript = !!transcript && (transcript.progressEvents?.length ?? 0) > 0;
// 归一化 transcript 事件:后台挂起跑出的事件是 raw(snake_case),前端实时存的是
// camelCase——统一过一遍 normalizeProgressDict(幂等),两种来源回显都正确一致。
const normalizedEvents = useMemo(
() => (transcript?.progressEvents ?? []).map(
(e) => normalizeProgressDict(e as unknown as Record<string, unknown>, session.id),
),
[transcript, session.id],
);
// 干预记录同样归一化(后台重建的素材/大纲/审核数据为 snake_case;前端实时记录已是
// camelCase,归一化幂等),保证素材列表/大纲/编辑审核卡片正确渲染。
const normalizedInterventions = useMemo(
() => (transcript?.completedInterventions ?? []).map(
(rec) => normalizeCompletedIntervention(rec as unknown as Record<string, unknown>),
),
[transcript],
);
const createdAt = session.created_at
? new Date(session.created_at).toLocaleString('zh-CN', {
year: 'numeric', month: 'short', day: 'numeric',
@@ -94,8 +136,8 @@ export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) {
{hasTranscript ? (
/* 完整对话时间线回显 */
<AIWritingTimeline
events={transcript!.progressEvents}
completedInterventions={transcript!.completedInterventions ?? []}
events={normalizedEvents}
completedInterventions={normalizedInterventions}
currentPause={null}
/>
) : (
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ChevronsDown, PanelRightClose, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
import { ChevronsDown, CloudUpload, PanelRightClose, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -17,7 +17,12 @@ import { useAIWriting } from '../../contexts/AIWritingContext';
import { useAIWritingHistory } from '../../contexts/AIWritingHistoryContext';
import { useWritingFormOptional } from '../../contexts/WritingFormContext';
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
import { adminCleanupAIWritingSessions, getAIWritingSession } from '../../api/ai-writing-sessions';
import {
adminCleanupAIWritingSessions,
getAIWritingSession,
suspendAIWritingToBackground,
suspendAIWritingToBackgroundKeepalive,
} from '../../api/ai-writing-sessions';
/**
* 暂停 / 继续功能开关(默认 false = 关)。
@@ -51,6 +56,7 @@ import { ReviewStreamingView } from './ReviewStreamingView';
import { RevisionSummaryCard } from './RevisionSummaryCard';
import { AIWritingHistoryDropdown } from './AIWritingHistoryDropdown';
import { AIWritingHistoryView } from './AIWritingHistoryView';
import { AIWritingFlowDialog } from './AIWritingFlowDialog';
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
const STATUS_LABEL: Record<string, string> = {
@@ -91,6 +97,8 @@ export function AIWritingPanel({
useEffect(() => { refreshArticleTypes(); }, [refreshArticleTypes]);
const [reqDialogOpen, setReqDialogOpen] = useState(false);
// 「查看进度」流程图弹窗当前展示的会话(点击历史里的「挂起中」记录时打开)。
const [progressSession, setProgressSession] = useState<AIWritingSessionSummary | null>(null);
const isIdle = state.status === 'idle';
const isDone = state.status === 'done';
@@ -188,11 +196,57 @@ export function AIWritingPanel({
void openHistory(session);
}, [openHistory]);
// 点击历史里的「挂起中」记录 → 打开流程图弹窗看实际进度(而非直接打开会话)。
const handleShowProgress = useCallback((session: AIWritingSessionSummary) => {
setProgressSession(session);
}, []);
const handleNewSession = useCallback(() => {
closeHistory();
reset();
}, [closeHistory, reset]);
// ── 后台挂起:把当前写作转后台,后端自动驱动到定稿(离页/刷新/切号不打断) ──
const handleBackgroundSuspend = useCallback(async () => {
const sid = state.sessionId;
if (!sid) {
toast.error('当前没有可挂起的写作会话');
return;
}
try {
await suspendAIWritingToBackground(sid);
} catch (e) {
toast.error(e instanceof Error ? e.message : '后台挂起失败');
return;
}
toast.success('已转入后台自动完成,稍后可在「历史记录」查看进度与结果');
// reset() 会 stop 客户端 SSE 订阅并回到 idle —— 后端驱动器接管续跑,不冲突。
reset();
// 刷新历史列表,让「挂起中」徽标立刻出现。
refresh();
}, [state.sessionId, reset, refresh]);
// 离页 / 刷新 / 关标签页时,若写作仍在进行中则自动转后台(与圆桌「自动挂起」一致)。
// 用 ref 持有最新的「是否在跑 + 会话 id」,让卸载/卸载前回调读到即时值。
const suspendRef = useRef<{ running: boolean; sid: string | null }>({ running: false, sid: null });
useEffect(() => {
suspendRef.current = { running: isRunning, sid: state.sessionId ?? null };
}, [isRunning, state.sessionId]);
useEffect(() => {
const onBeforeUnload = () => {
const { running, sid } = suspendRef.current;
if (running && sid) suspendAIWritingToBackgroundKeepalive(sid);
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
// 组件卸载(路由离开本页 / 切换工作区标签):写作未完成则自动转后台。
const { running, sid } = suspendRef.current;
if (running && sid) suspendAIWritingToBackgroundKeepalive(sid);
};
}, []);
// ── 侧边栏"最近写作"通信:两种路径 ────────────────────────────────────────
// 用 ref 包住 handler,保证事件监听器始终能拿到最新版本
const handleSelectHistoryRef = useRef(handleSelectHistory);
@@ -299,6 +353,19 @@ export function AIWritingPanel({
新建
</Button>
)}
{/* 后台挂起:写作进行中(含等待干预)时出现,点击后由后端自动跑完剩余流程 */}
{isRunning && !isHistoryMode && state.sessionId && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1 text-sky-500 hover:text-sky-600 hover:bg-sky-50 dark:hover:bg-sky-950/30"
onClick={handleBackgroundSuspend}
title="转入后台:自动确认素材/大纲、写作、编辑审核直至定稿,离开页面也不中断"
>
<CloudUpload className="size-3" />
后台挂起
</Button>
)}
{/* ⏸️/▶️ "停止 / 继续" 由 ENABLE_PAUSE_RESUME_UI 控制(默认 false 隐藏)。
后期把 flag 翻成 true 即可恢复,UI / hook 全套代码都已就位。 */}
{ENABLE_PAUSE_RESUME_UI && isRunning && !state.isPaused && (
@@ -340,6 +407,7 @@ export function AIWritingPanel({
selectedId={historySession?.id ?? null}
loading={sessionsLoading}
onSelect={handleSelectHistory}
onShowProgress={handleShowProgress}
onDelete={remove}
onRename={rename}
onRefresh={refresh}
@@ -533,6 +601,13 @@ export function AIWritingPanel({
</div>
)}
{/* 查看进度流程图弹窗:点击历史里的「挂起中」记录时打开 */}
<AIWritingFlowDialog
session={progressSession}
open={progressSession !== null}
onOpenChange={(o) => { if (!o) setProgressSession(null); }}
/>
{/* 写作参数查看弹窗 */}
{state.request && (
<Dialog open={reqDialogOpen} onOpenChange={setReqDialogOpen}>
@@ -190,7 +190,7 @@ export function ResearchSidebar({
};
return (
<aside className="flex h-full w-[320px] shrink-0 flex-col overflow-hidden border-r border-slate-100 bg-white text-slate-700 shadow-[1px_0_18px_rgba(15,23,42,0.03)]">
<aside className="ai-research-sidebar flex h-full w-[320px] shrink-0 flex-col overflow-hidden border-r border-slate-100 bg-white text-slate-700 shadow-[1px_0_18px_rgba(15,23,42,0.03)]">
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden px-3 py-4">
<section className="mb-5">
<div className="mb-3 flex items-center justify-between gap-3 px-1">
@@ -385,9 +385,16 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
dispatch({ type: 'intervention_submitted', record, nextStatus, pendingRevisionContext });
try {
// resume 是新 invocation,其 config 默认不带最初的 model_name;而写作/审核节点
// 在 resume 后才运行,若首轮 intent 未把模型存进 state(历史会话 / 捕获为空),
// 后端就只能回退默认模型。这里把表单选定的模型随每次 resume 一并回传,
// 让后端(get_writing_model / resolve_agent_model_name 的 configurable 兜底)
// 始终用上用户真正选的模型。
const resumeModelName = state.request?.modelName;
await stream.submit(undefined, {
command: { resume: resumePayload },
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
...(resumeModelName ? { config: { configurable: { model_name: resumeModelName } } } : {}),
});
} catch (err) {
const message = getStreamErrorMessage(err);
@@ -395,7 +395,7 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
// ── snake_case ↔ camelCase 归一化(custom 通道事件 + interrupt payload) ──────
function normalizeMaterial(m: Record<string, unknown>): AIMaterial {
export function normalizeMaterial(m: Record<string, unknown>): AIMaterial {
const extra = (m.extra_metadata ?? m.extraMetadata) as Record<string, unknown> | undefined;
return {
id: String(m.id ?? ''),
@@ -430,7 +430,7 @@ function normalizeOutlineSection(s: Record<string, unknown>) {
};
}
function normalizeOutline(o: unknown): AIOutline | null {
export function normalizeOutline(o: unknown): AIOutline | null {
if (!o || typeof o !== 'object') return null;
const obj = o as Record<string, unknown>;
return {
@@ -449,7 +449,7 @@ function normalizeReviewIssue(i: Record<string, unknown>): ReviewIssue {
};
}
function normalizeReviewResult(r: unknown): ReviewResult | null {
export function normalizeReviewResult(r: unknown): ReviewResult | null {
if (!r || typeof r !== 'object') return null;
const o = r as Record<string, unknown>;
return {
@@ -472,8 +472,12 @@ function normalizeBlockedSection(b: Record<string, unknown>): BlockedSection {
};
}
/** 把后端节点 ``progress_events`` 数组里的一条 dict 翻译成前端 ProgressEvent。 */
function normalizeProgressDict(parsed: Record<string, unknown>, sessionId: string): ProgressEvent {
/** 把后端节点 ``progress_events`` 数组里的一条 dict 翻译成前端 ProgressEvent。
*
* 幂等:对已是 camelCase 的前端事件再跑一遍基本不变(snake_case 键不存在即跳过),
* 所以历史回显时可无脑对**整条** transcript 跑一遍——既归一后台存的 raw 事件,又
* 不破坏前端实时存的事件。 */
export function normalizeProgressDict(parsed: Record<string, unknown>, sessionId: string): ProgressEvent {
const out: Record<string, unknown> = { ...parsed, sessionId };
if ('agent_name' in out) out.agentName = out.agent_name;
+3 -1
View File
@@ -75,7 +75,9 @@ function ModuleChatShell({ module }: { module: AgentModuleConfig }) {
const [settingsOpen, setSettingsOpen] = useState(false);
return (
<div className="flex size-full min-h-0 overflow-hidden">
// 外壳挂 workspace-chat-bg:深色下整片用同一张深蓝主题底图(fixed),作为左栏半透明
// 玻璃面板透出的「底色」;浅色透明无影响。右侧对话区自带同款底图,左右底色一致。
<div className="workspace-chat-bg flex size-full min-h-0 overflow-hidden">
<AgentModuleSidebar
moduleKey={module.key}
activeAgentId={agent_id}
@@ -145,8 +145,8 @@ export default function BusinessChainMappingPage() {
/* ── 样式 ── */
const shellClass = isDark
? "h-full overflow-y-auto text-slate-100 workspace-chat-bg"
: "h-full overflow-y-auto bg-white text-[#222]";
? "size-full min-h-0 overflow-y-auto text-slate-100 workspace-chat-bg"
: "size-full min-h-0 overflow-y-auto bg-white text-[#222]";
// 卡片:无边框,纯阴影
const cardClass = isDark
+13 -8
View File
@@ -14,6 +14,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ToolMetricsPanel } from "@/components/workspace/admin/tool-metrics-panel";
import {
@@ -494,14 +495,18 @@ function Td({
export default function LlmMetricsPage() {
const [tab, setTab] = useState<"llm" | "tool">("llm");
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-8">
<Tabs value={tab} onValueChange={(v) => setTab(v as "llm" | "tool")}>
<TabsList>
<TabsTrigger value="llm">大模型调用</TabsTrigger>
<TabsTrigger value="tool">工具调用</TabsTrigger>
</TabsList>
</Tabs>
{tab === "llm" ? <LlmMetricsPanel /> : <ToolMetricsPanel />}
<div className="flex size-full min-h-0 w-full">
<ScrollArea className="min-h-0 flex-1">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-8">
<Tabs value={tab} onValueChange={(v) => setTab(v as "llm" | "tool")}>
<TabsList>
<TabsTrigger value="llm">大模型调用</TabsTrigger>
<TabsTrigger value="tool">工具调用</TabsTrigger>
</TabsList>
</Tabs>
{tab === "llm" ? <LlmMetricsPanel /> : <ToolMetricsPanel />}
</div>
</ScrollArea>
</div>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { MemorySettingsPage } from "@/components/workspace/settings/memory-setti
export default function MemoryPage() {
return (
<div className="flex flex-col h-full overflow-auto p-6">
<div className="flex size-full min-h-0 flex-col overflow-auto p-6">
<MemorySettingsPage />
</div>
);
@@ -34,6 +34,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
AdminLeaderboardDimension,
@@ -225,7 +226,11 @@ export default function PublicLeaderboardPage() {
}, [userPageCount]);
return (
<div className="min-h-screen w-full bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white" style={pageBg}>
<div
className="flex size-full min-h-0 w-full bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white"
style={pageBg}
>
<ScrollArea className="min-h-0 flex-1">
<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
{/* 标题区:仅展示,不含任何操作按钮 */}
<header className="mb-6">
@@ -372,6 +377,7 @@ export default function PublicLeaderboardPage() {
{data?.generated_at ? `数据生成于 ${fmtDate(data.generated_at)}` : "仅供查看"}
</footer>
</div>
</ScrollArea>
</div>
);
}
@@ -2,7 +2,7 @@ import { SkillEvolutionPanel } from "@/components/workspace/skills/skill-evoluti
export default function SkillEvolutionPage() {
return (
<div className="flex h-full flex-col overflow-auto p-6">
<div className="flex size-full min-h-0 flex-col overflow-auto p-6">
<SkillEvolutionPanel />
</div>
);
+1 -1
View File
@@ -2,7 +2,7 @@ import { SkillSettingsPage } from "@/components/workspace/settings/skill-setting
export default function SkillsPage() {
return (
<div className="flex flex-col h-full overflow-auto p-6">
<div className="flex size-full min-h-0 flex-col overflow-auto p-6">
<SkillSettingsPage />
</div>
);
@@ -22,6 +22,7 @@ import {
BackgroundVariant,
Handle,
MarkerType,
MiniMap,
Position,
ReactFlow,
ReactFlowProvider,
@@ -48,16 +49,36 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useTheme } from "@/contexts/ThemeContext";
import {
applyNodeEdit,
deleteNodeFromGraph,
FLOW_NODE_TYPE_META,
flowNodeMeta,
FLOW_NODE_W,
layoutFlowGraph,
type FlowGraphData,
type FlowGraphNode,
type FlowOrientation,
} from "@/roundtable-planning/lib/flow-graph";
/**
* 深蓝主题色板(《深蓝色 UI 规范》ZS-004 / ZS-002)。浅色保持原 slate/white。
* 用于流程图弹窗在深色下的容器 / 头部 / 画布 / 连线 / 图例 / 小地图 / 详情弹窗。
*/
const DARK = {
pageBg: "#0C1836", // ZS-004-09 画布底
cardBg: "#122347", // ZS-004-08 头部 / 详情卡底
border: "#1A2F5A", // ZS-004-07 卡片边框
borderStrong: "#2D4473", // ZS-004-06 弱分割
text: "#C5CFE0", // ZS-004-02 正文
textMuted: "#6E88B0", // ZS-004-04 次级文字
primary: "#1677FF", // ZS-002-06 主色
link: "#4096FF", // ZS-002-05 链接
dots: "#2D4473", // 画布点阵
edge: "#4A6491", // ZS-004-05 静态连线
edgeActive: "#4096FF", // 活动连线
} as const;
interface FlowGraphPanelProps {
data: FlowGraphData;
/** 流式绘制中(决定蚂蚁线 + 自动 fitView 跟随)。 */
@@ -67,17 +88,32 @@ interface FlowGraphPanelProps {
onSwitchToReport?: () => void;
/** Phase 3:画布手动编辑——传入则节点详情弹窗出现「编辑 / 删除」,回写整张新图。 */
onChange?: (next: FlowGraphData) => void;
/** 布局方向:默认 "vertical"(自上而下);弹窗里用 "horizontal" 横向铺开。 */
orientation?: FlowOrientation;
/** 右下角缩略图(小地图)—— 弹窗大画布里便于纵览定位。 */
miniMap?: boolean;
}
// ── 自定义节点(按 type 着色的卡片)────────────────────────────────────────
function FlowCardNode({ data }: NodeProps) {
const node = (data as { node?: FlowGraphNode; scale?: number }).node;
const scale = (data as { node?: FlowGraphNode; scale?: number }).scale ?? 1;
const d = data as {
node?: FlowGraphNode;
scale?: number;
orientation?: FlowOrientation;
dark?: boolean;
};
const node = d.node;
const scale = d.scale ?? 1;
if (!node) return null;
const meta = FLOW_NODE_TYPE_META[node.type];
const meta = flowNodeMeta(d.dark ?? false)[node.type];
// 节点宽度随容器宽度缩放(宽时放大、窄时缩小)。
const scaledWidth = FLOW_NODE_W * scale;
// 横向布局连入/连出走左右两侧,纵向走上下。
const horizontal = d.orientation === "horizontal";
const targetPos = horizontal ? Position.Left : Position.Top;
const sourcePos = horizontal ? Position.Right : Position.Bottom;
const handleColor = d.dark ? "!bg-[#4A6491]" : "!bg-slate-400";
return (
<div
style={{
@@ -89,7 +125,7 @@ function FlowCardNode({ data }: NodeProps) {
className="cursor-pointer rounded-xl border-2 px-3 py-2 shadow-sm transition hover:shadow-md"
title="点击查看详情"
>
<Handle type="target" position={Position.Top} className="!h-1.5 !w-1.5 !bg-slate-400" />
<Handle type="target" position={targetPos} className={cn("!h-1.5 !w-1.5", handleColor)} />
<div className="mb-0.5 flex items-center gap-1.5">
<span style={{ background: meta.dot }} className="size-2 shrink-0 rounded-full" />
<span className="text-[9px] font-bold uppercase tracking-wider opacity-70" style={{ fontSize: `${9 * scale}px` }}>
@@ -97,7 +133,7 @@ function FlowCardNode({ data }: NodeProps) {
</span>
</div>
<div className="break-words font-bold leading-snug" style={{ fontSize: `${11.5 * scale}px` }}>{node.label}</div>
<Handle type="source" position={Position.Bottom} className="!h-1.5 !w-1.5 !bg-slate-400" />
<Handle type="source" position={sourcePos} className={cn("!h-1.5 !w-1.5", handleColor)} />
</div>
);
}
@@ -115,8 +151,12 @@ function FlowCanvasInner({
onClose,
onSwitchToReport,
onChange,
orientation = "vertical",
miniMap = false,
}: FlowGraphPanelProps & { fullscreen: boolean; onToggleFullscreen: () => void }) {
const rf = useReactFlow();
const { theme } = useTheme();
const isDark = theme === "dark";
const [selectedId, setSelectedId] = useState<string | null>(null);
const [scale, setScale] = useState(1);
const containerRef = useRef<HTMLDivElement>(null);
@@ -145,7 +185,7 @@ function FlowCanvasInner({
};
}, [rf]);
const layoutNodes = useMemo(() => layoutFlowGraph(data), [data]);
const layoutNodes = useMemo(() => layoutFlowGraph(data, orientation), [data, orientation]);
const derivedNodes: Node[] = useMemo(
() =>
layoutNodes.map((n) => ({
@@ -153,9 +193,9 @@ function FlowCanvasInner({
type: "flowCard",
// 节点随 scale 放大 → 布局坐标同步乘 scale,间距一起拉开,避免同层重叠。
position: manualPosRef.current.get(n.id) ?? { x: n.x * scale, y: n.y * scale },
data: { node: n, scale },
data: { node: n, scale, orientation, dark: isDark },
})),
[layoutNodes, scale],
[layoutNodes, scale, orientation, isDark],
);
const [nodes, setNodes] = useState<Node[]>(derivedNodes);
@@ -174,7 +214,13 @@ function FlowCanvasInner({
() =>
data.edges.map((e) => {
const active = streaming && (e.to === newestNodeId || e.from === newestNodeId);
const color = active ? "#3b82f6" : "#94a3b8";
const color = active
? isDark
? DARK.edgeActive
: "#3b82f6"
: isDark
? DARK.edge
: "#94a3b8";
return {
id: `e-${e.from}->${e.to}`,
source: e.from,
@@ -183,11 +229,11 @@ function FlowCanvasInner({
animated: active,
style: { stroke: color, strokeWidth: active ? 2 : 1.5 },
markerEnd: { type: MarkerType.ArrowClosed, color, width: 16, height: 16 },
labelStyle: { fontSize: 10, fill: "#64748b", fontWeight: 700 },
labelBgStyle: { fill: "#ffffff", fillOpacity: 0.85 },
labelStyle: { fontSize: 10, fill: isDark ? DARK.text : "#64748b", fontWeight: 700 },
labelBgStyle: { fill: isDark ? DARK.cardBg : "#ffffff", fillOpacity: isDark ? 0.9 : 0.85 },
} satisfies Edge;
}),
[data.edges, streaming, newestNodeId],
[data.edges, streaming, newestNodeId, isDark],
);
// 流式中节点增加 → 画布平滑 fitView 跟随生长(用户暂停流后不再打扰)。
@@ -202,15 +248,30 @@ function FlowCanvasInner({
const selectedNode = selectedId ? data.nodes.find((n) => n.id === selectedId) ?? null : null;
return (
<div className="flex h-full min-h-0 flex-col">
<div
className="flex h-full min-h-0 flex-col"
style={isDark ? { background: DARK.pageBg } : undefined}
>
{/* 头部:标题 + 工具栏 */}
<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={cn(
"shrink-0 flex items-center justify-between gap-2 border-b px-4 py-2.5",
isDark ? "" : "border-slate-100 bg-white",
)}
style={isDark ? { background: DARK.cardBg, borderColor: DARK.border } : undefined}
>
<div className="flex min-w-0 items-center gap-2">
<Workflow className="h-4 w-4 shrink-0 text-primary" />
<span className="truncate text-xs font-bold text-slate-800">
<span
className="truncate text-xs font-bold text-slate-800"
style={isDark ? { color: DARK.text } : undefined}
>
{data.title || "方案流程图"}
</span>
<span className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[10px] text-slate-500">
<span
className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[10px] text-slate-500"
style={isDark ? { background: "rgba(45,68,115,0.5)", color: DARK.textMuted } : undefined}
>
{data.nodes.length} 节点 · {data.edges.length} 连线
</span>
{streaming && (
@@ -226,7 +287,8 @@ function FlowCanvasInner({
type="button"
title="切回 Markdown 报告预览(流程图可随时再打开)"
onClick={onSwitchToReport}
className="mr-1 inline-flex h-7 items-center gap-1 rounded-lg border border-slate-200/70 bg-white px-2 text-[10.5px] font-bold text-slate-600 transition hover:border-primary/40 hover:text-primary"
className="mr-1 inline-flex h-7 items-center gap-1 rounded-lg border border-slate-200/70 bg-white px-2 text-[10.5px] font-bold text-slate-600 transition hover:border-primary/40 hover:text-primary dark:hover:text-primary"
style={isDark ? { background: "transparent", borderColor: DARK.border, color: DARK.text } : undefined}
>
<FileText className="h-3.5 w-3.5" />
MD 报告
@@ -271,13 +333,50 @@ function FlowCanvasInner({
deleteKeyCode={null}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={18} size={1} color="#cbd5e1" />
<Background
variant={BackgroundVariant.Dots}
gap={18}
size={1}
color={isDark ? DARK.dots : "#cbd5e1"}
/>
{miniMap && (
<MiniMap
position="bottom-right"
pannable
zoomable
nodeColor={(n) => {
const t = (n.data as { node?: FlowGraphNode }).node?.type;
return (t && flowNodeMeta(isDark)[t]?.dot) || (isDark ? DARK.textMuted : "#94a3b8");
}}
nodeStrokeWidth={2}
maskColor={isDark ? "rgba(6,14,36,0.6)" : "rgba(240,240,240,0.6)"}
className={cn(
"!bottom-2 !right-2 !m-0 overflow-hidden rounded-lg border shadow-sm backdrop-blur-sm",
isDark ? "" : "border-slate-200/70 bg-white/90",
)}
style={
isDark
? { width: 168, height: 112, background: "rgba(18,35,71,0.9)", borderColor: DARK.border }
: { width: 168, height: 112 }
}
/>
)}
</ReactFlow>
{/* 图例 */}
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-wrap items-center gap-x-2.5 gap-y-1 rounded-lg border border-slate-200/70 bg-white/85 px-2.5 py-1.5 backdrop-blur-sm">
{Object.entries(FLOW_NODE_TYPE_META).map(([type, meta]) => (
<span key={type} className="inline-flex items-center gap-1 text-[9.5px] font-semibold text-slate-500">
<div
className={cn(
"pointer-events-none absolute bottom-2 left-2 flex flex-wrap items-center gap-x-2.5 gap-y-1 rounded-lg border px-2.5 py-1.5 backdrop-blur-sm",
isDark ? "" : "border-slate-200/70 bg-white/85",
)}
style={isDark ? { background: "rgba(18,35,71,0.85)", borderColor: DARK.border } : undefined}
>
{Object.entries(flowNodeMeta(isDark)).map(([type, meta]) => (
<span
key={type}
className="inline-flex items-center gap-1 text-[9.5px] font-semibold text-slate-500"
style={isDark ? { color: DARK.textMuted } : undefined}
>
<span style={{ background: meta.dot }} className="size-1.5 rounded-full" />
{meta.label}
</span>
@@ -290,6 +389,7 @@ function FlowCanvasInner({
<NodeDetailModal
node={selectedNode}
data={data}
dark={isDark}
onClose={() => setSelectedId(null)}
onJump={(id) => setSelectedId(id)}
onChange={onChange}
@@ -308,12 +408,15 @@ function ToolButton({
onClick: () => void;
children: React.ReactNode;
}) {
const { theme } = useTheme();
const isDark = theme === "dark";
return (
<button
type="button"
title={title}
onClick={onClick}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200/70 bg-white text-slate-500 transition hover:border-primary/40 hover:text-primary"
style={isDark ? { background: "transparent", borderColor: DARK.border, color: DARK.textMuted } : undefined}
>
{children}
</button>
@@ -325,18 +428,20 @@ function ToolButton({
function NodeDetailModal({
node,
data,
dark = false,
onClose,
onJump,
onChange,
}: {
node: FlowGraphNode;
data: FlowGraphData;
dark?: boolean;
onClose: () => void;
onJump: (id: string) => void;
/** Phase 3:传入则可编辑 / 删除该节点(回写整张新图)。 */
onChange?: (next: FlowGraphData) => void;
}) {
const meta = FLOW_NODE_TYPE_META[node.type];
const meta = flowNodeMeta(dark)[node.type];
const labelOf = (id: string) => data.nodes.find((n) => n.id === id)?.label ?? id;
const incoming = data.edges.filter((e) => e.to === node.id);
const outgoing = data.edges.filter((e) => e.from === node.id);
@@ -366,6 +471,7 @@ function NodeDetailModal({
>
<div
className="w-full max-w-md overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl"
style={dark ? { background: DARK.cardBg, borderColor: DARK.border } : undefined}
onClick={(e) => e.stopPropagation()}
>
<div
@@ -376,7 +482,7 @@ function NodeDetailModal({
<span style={{ background: meta.dot }} className="size-2.5 shrink-0 rounded-full" />
<span
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold"
style={{ background: "#ffffffaa", color: meta.text }}
style={{ background: dark ? "rgba(6,14,36,0.45)" : "#ffffffaa", color: meta.text }}
>
{node.group?.trim() || meta.label}
</span>
@@ -388,6 +494,7 @@ function NodeDetailModal({
type="button"
onClick={onClose}
className="shrink-0 rounded-md p-1 text-slate-500 hover:bg-white/60 hover:text-slate-700"
style={dark ? { color: DARK.textMuted } : undefined}
>
<X className="h-4 w-4" />
</button>
@@ -395,36 +502,48 @@ function NodeDetailModal({
{editing ? (
<div className="max-h-[55vh] space-y-3 overflow-y-auto p-4">
<EditField label="节点名称(label)" value={label} onChange={setLabel} />
<EditField label="所属层级(group,如 目的 / 行为体)" value={group} onChange={setGroup} />
<EditField label="详细内容(detail,名称+理由)" value={detail} onChange={setDetail} multiline />
<EditField label="节点名称(label)" value={label} onChange={setLabel} dark={dark} />
<EditField label="所属层级(group,如 目的 / 行为体)" value={group} onChange={setGroup} dark={dark} />
<EditField label="详细内容(detail,名称+理由)" value={detail} onChange={setDetail} multiline dark={dark} />
</div>
) : (
<div className="max-h-[50vh] space-y-3 overflow-y-auto p-4">
<div>
<div className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400">详细说明</div>
<p className="text-xs leading-relaxed text-slate-700 whitespace-pre-wrap">
<div
className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400"
style={dark ? { color: DARK.textMuted } : undefined}
>
详细说明
</div>
<p
className="text-xs leading-relaxed text-slate-700 whitespace-pre-wrap"
style={dark ? { color: DARK.text } : undefined}
>
{node.detail?.trim() || "(该节点未提供详细说明)"}
</p>
</div>
{incoming.length > 0 && (
<ConnectionList title="上游(来自)" entries={incoming.map((e) => ({ id: e.from, label: labelOf(e.from), edgeLabel: e.label }))} onJump={onJump} />
<ConnectionList title="上游(来自)" entries={incoming.map((e) => ({ id: e.from, label: labelOf(e.from), edgeLabel: e.label }))} onJump={onJump} dark={dark} />
)}
{outgoing.length > 0 && (
<ConnectionList title="下游(去往)" entries={outgoing.map((e) => ({ id: e.to, label: labelOf(e.to), edgeLabel: e.label }))} onJump={onJump} />
<ConnectionList title="下游(去往)" entries={outgoing.map((e) => ({ id: e.to, label: labelOf(e.to), edgeLabel: e.label }))} onJump={onJump} dark={dark} />
)}
</div>
)}
{/* 编辑 / 删除 操作栏(仅传入 onChange 时显示) */}
{onChange && (
<div className="flex items-center justify-between gap-2 border-t border-slate-100 bg-slate-50/60 px-4 py-2.5">
<div
className="flex items-center justify-between gap-2 border-t border-slate-100 bg-slate-50/60 px-4 py-2.5"
style={dark ? { borderColor: DARK.border, background: "rgba(12,24,54,0.6)" } : undefined}
>
{editing ? (
<>
<button
type="button"
onClick={() => setEditing(false)}
className="rounded-lg px-2.5 py-1.5 text-[11px] font-bold text-slate-500 hover:bg-slate-200/60"
style={dark ? { color: DARK.textMuted } : undefined}
>
取消
</button>
@@ -451,6 +570,7 @@ function NodeDetailModal({
type="button"
onClick={() => setEditing(true)}
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 text-[11px] font-bold text-slate-700 hover:border-primary/40 hover:text-primary"
style={dark ? { borderColor: DARK.border, color: DARK.text } : undefined}
>
<Pencil className="h-3.5 w-3.5" />
编辑
@@ -469,27 +589,39 @@ function EditField({
value,
onChange,
multiline,
dark = false,
}: {
label: string;
value: string;
onChange: (v: string) => void;
multiline?: boolean;
dark?: boolean;
}) {
const fieldStyle = dark
? { background: "rgba(6,14,36,0.45)", borderColor: DARK.border, color: DARK.text }
: undefined;
return (
<div>
<div className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400">{label}</div>
<div
className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400"
style={dark ? { color: DARK.textMuted } : undefined}
>
{label}
</div>
{multiline ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={4}
className="w-full resize-none rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs leading-relaxed text-slate-800 focus:border-primary focus:outline-none focus:ring-1 focus:ring-[var(--brand-200)]/50"
style={fieldStyle}
/>
) : (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs text-slate-800 focus:border-primary focus:outline-none focus:ring-1 focus:ring-[var(--brand-200)]/50"
style={fieldStyle}
/>
)}
</div>
@@ -500,14 +632,19 @@ function ConnectionList({
title,
entries,
onJump,
dark = false,
}: {
title: string;
entries: Array<{ id: string; label: string; edgeLabel?: string }>;
onJump: (id: string) => void;
dark?: boolean;
}) {
return (
<div>
<div className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400">
<div
className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400"
style={dark ? { color: DARK.textMuted } : undefined}
>
{title}
</div>
<div className="flex flex-wrap gap-1.5">
@@ -517,11 +654,15 @@ function ConnectionList({
type="button"
onClick={() => onJump(entry.id)}
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 bg-slate-50 px-2 py-1 text-[11px] font-semibold text-slate-700 transition hover:border-primary/40 hover:text-primary"
style={dark ? { borderColor: DARK.border, background: "rgba(6,14,36,0.4)", color: DARK.text } : undefined}
title="查看该节点详情"
>
{entry.label}
{entry.edgeLabel && (
<span className="rounded bg-slate-200/80 px-1 text-[9px] text-slate-500">
<span
className="rounded bg-slate-200/80 px-1 text-[9px] text-slate-500"
style={dark ? { background: "rgba(45,68,115,0.6)", color: DARK.textMuted } : undefined}
>
{entry.edgeLabel}
</span>
)}
@@ -534,8 +675,10 @@ function ConnectionList({
// ── 外壳:覆盖右栏 / 全屏切换 ───────────────────────────────────────────────
export function FlowGraphPanel({ data, streaming, onClose, onSwitchToReport, onChange }: FlowGraphPanelProps) {
export function FlowGraphPanel({ data, streaming, onClose, onSwitchToReport, onChange, orientation, miniMap }: FlowGraphPanelProps) {
const [fullscreen, setFullscreen] = useState(false);
const { theme } = useTheme();
const isDark = theme === "dark";
const inner = (
<div
className={cn(
@@ -544,6 +687,7 @@ export function FlowGraphPanel({ data, streaming, onClose, onSwitchToReport, onC
// 全屏=覆盖整个视口(z 高于 app 顶部导航,保证顶部工具栏可见、可点「退出全屏」)。
fullscreen ? "fixed inset-0 z-[9999]" : "relative h-full min-h-0 w-full",
)}
style={isDark ? { background: DARK.pageBg } : undefined}
>
<ReactFlowProvider>
<FlowCanvasInner
@@ -554,6 +698,8 @@ export function FlowGraphPanel({ data, streaming, onClose, onSwitchToReport, onC
onClose={onClose}
onSwitchToReport={onSwitchToReport}
onChange={onChange}
orientation={orientation}
miniMap={miniMap}
/>
</ReactFlowProvider>
</div>
@@ -67,6 +67,11 @@ import type { IntentSummary } from "@/roundtable-planning/api/intent";
export interface Step1PanelProps {
// ─── State ────────────────────────────────────────────────────
/**
* 头部左侧自定义内容(深链 taskId 模式:用「策划流程」小图取代默认标题)。
* 不传 → 显示默认「任务意图理解 (人机问答)」标题。右侧「问答轮次」始终保留。
*/
headerLeftSlot?: React.ReactNode;
interactiveMessages: InteractiveMessage[];
intentThreadId: string | null;
intentReady: object | null;
@@ -93,6 +98,7 @@ export interface Step1PanelProps {
export function Step1Panel(props: Step1PanelProps) {
const {
headerLeftSlot,
interactiveMessages,
intentThreadId,
intentReady,
@@ -157,14 +163,16 @@ export function Step1Panel(props: Step1PanelProps) {
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Dialogue Header */}
<div ref={headerDzRef} className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<div className="flex items-center gap-2">
<MessageSquare className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} />
<h3 className="text-sm font-extrabold text-slate-900">
任务意图理解 (人机问答)
</h3>
</div>
<span className="text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
<div ref={headerDzRef} className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between gap-3 bg-slate-50/50">
{headerLeftSlot ?? (
<div className="flex items-center gap-2">
<MessageSquare className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} />
<h3 className="text-sm font-extrabold text-slate-900">
任务意图理解 (人机问答)
</h3>
</div>
)}
<span className="shrink-0 text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
</span>
</div>
@@ -53,11 +53,16 @@ export interface FlowGraphData {
edges: FlowGraphEdge[];
}
export interface FlowNodeColors {
label: string;
bg: string;
border: string;
text: string;
dot: string;
}
/** 各类型节点的配色(浅底 + 深字 + 强调点),前端按 type 区分颜色。 */
export const FLOW_NODE_TYPE_META: Record<
FlowNodeType,
{ label: string; bg: string; border: string; text: string; dot: string }
> = {
export const FLOW_NODE_TYPE_META: Record<FlowNodeType, FlowNodeColors> = {
start: { label: "开始", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" },
end: { label: "结束", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" },
stage: { label: "阶段", bg: "#eff6ff", border: "#60a5fa", text: "#1d4ed8", dot: "#3b82f6" },
@@ -68,6 +73,27 @@ export const FLOW_NODE_TYPE_META: Record<
role: { label: "角色", bg: "#faf5ff", border: "#c084fc", text: "#7e22ce", dot: "#a855f7" },
};
/**
* 深蓝主题下的节点配色(对齐《深蓝色 UI 规范》ZS-004 深底):保留各类型色相作识别,
* 底色取该色相在深底上的低透明度叠色(dot @ ~16–18%),描边取色相高透明度,
* 文字用同色相浅色调(深底可读),强调点沿用亮色。供流程图弹窗 + 头部小节点用。
*/
export const FLOW_NODE_TYPE_META_DARK: Record<FlowNodeType, FlowNodeColors> = {
start: { label: "开始", bg: "rgba(100,116,139,0.20)", border: "rgba(148,163,184,0.55)", text: "#C5CFE0", dot: "#94a3b8" },
end: { label: "结束", bg: "rgba(100,116,139,0.20)", border: "rgba(148,163,184,0.55)", text: "#C5CFE0", dot: "#94a3b8" },
stage: { label: "阶段", bg: "rgba(59,130,246,0.20)", border: "rgba(96,165,250,0.60)", text: "#bfdbfe", dot: "#60a5fa" },
task: { label: "任务", bg: "rgba(6,182,212,0.20)", border: "rgba(34,211,238,0.60)", text: "#a5f3fc", dot: "#22d3ee" },
decision: { label: "判断", bg: "rgba(245,158,11,0.18)", border: "rgba(251,191,36,0.55)", text: "#fcd34d", dot: "#fbbf24" },
risk: { label: "风险", bg: "rgba(239,68,68,0.18)", border: "rgba(248,113,113,0.55)", text: "#fecaca", dot: "#f87171" },
deliverable: { label: "交付物", bg: "rgba(34,197,94,0.18)", border: "rgba(74,222,128,0.55)", text: "#bbf7d0", dot: "#4ade80" },
role: { label: "角色", bg: "rgba(168,85,247,0.20)", border: "rgba(192,132,252,0.58)", text: "#e9d5ff", dot: "#c084fc" },
};
/** 按明暗取节点配色表。 */
export function flowNodeMeta(dark: boolean): Record<FlowNodeType, FlowNodeColors> {
return dark ? FLOW_NODE_TYPE_META_DARK : FLOW_NODE_TYPE_META;
}
const VALID_TYPES = new Set<string>(Object.keys(FLOW_NODE_TYPE_META));
export const FLOW_FENCE_OPEN = "```flow-json";
@@ -395,9 +421,22 @@ export interface PositionedFlowNode extends FlowGraphNode {
export const FLOW_NODE_W = 176;
const X_GAP = 224;
const Y_GAP = 132;
// 横向布局:层间走 x(节点宽 176 + 连线/标签留白),层内同层节点沿 y 堆叠。
const LAYER_GAP_X = 248;
const NODE_GAP_Y = 104;
/** 最长路径分层(环保护:访问中的节点按 0 层处理,保证终止)。 */
export function layoutFlowGraph(data: FlowGraphData): PositionedFlowNode[] {
export type FlowOrientation = "vertical" | "horizontal";
/**
* 最长路径分层(环保护:访问中的节点按 0 层处理,保证终止)。
*
* orientation="vertical"(默认):层自上而下、同层横向铺开。
* orientation="horizontal":层自左而右、同层纵向铺开(弹窗里宽屏看着更舒展)。
*/
export function layoutFlowGraph(
data: FlowGraphData,
orientation: FlowOrientation = "vertical",
): PositionedFlowNode[] {
const preds = new Map<string, string[]>();
for (const e of data.edges) {
const list = preds.get(e.to);
@@ -428,10 +467,19 @@ export function layoutFlowGraph(data: FlowGraphData): PositionedFlowNode[] {
const pos = new Map<string, { x: number; y: number }>();
for (const [layer, nodes] of byLayer) {
const rowWidth = (nodes.length - 1) * X_GAP;
nodes.forEach((n, i) => {
pos.set(n.id, { x: i * X_GAP - rowWidth / 2, y: layer * Y_GAP });
});
if (orientation === "horizontal") {
// 层 → x,同层节点沿 y 居中堆叠。
const colHeight = (nodes.length - 1) * NODE_GAP_Y;
nodes.forEach((n, i) => {
pos.set(n.id, { x: layer * LAYER_GAP_X, y: i * NODE_GAP_Y - colHeight / 2 });
});
} else {
// 层 → y,同层节点沿 x 居中铺开(原默认)。
const rowWidth = (nodes.length - 1) * X_GAP;
nodes.forEach((n, i) => {
pos.set(n.id, { x: i * X_GAP - rowWidth / 2, y: layer * Y_GAP });
});
}
}
// 保持原始(流式)顺序输出,方便调用方做「最新节点」判断。
return data.nodes.map((n) => ({ ...n, ...pos.get(n.id)! }));
@@ -26,6 +26,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
Activity,
BookOpen,
Check,
CheckCircle,
ChevronRight,
@@ -46,10 +47,12 @@ import {
Save,
Settings,
Sparkles,
Target,
Trash2,
Upload,
Users,
X,
Zap,
} from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
@@ -62,6 +65,13 @@ import { useModels } from "@/core/models/hooks";
import { getAvatar, Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
import { ImportRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
import { TaskPurposeDialog } from "@/strategy-components/components/task-components/TaskPurposeDialog";
import {
derivePurposeLevels,
fetchTaskPurposeDetail,
type PurposeLevel,
} from "@/core/auth/task-purpose";
import { flowNodeMeta, type FlowNodeType } from "@/roundtable-planning/lib/flow-graph";
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog";
import { DispatchChainJobModal } from "@/roundtable-planning/components/DispatchChainJobModal";
@@ -231,6 +241,11 @@ export default function RoundtablePlanningPage() {
const [chainModalJobId, setChainModalJobId] = useState<string | null>(null);
// 「导入会商」弹窗(输入分享码把别人分享的会商复制到本账号)。
const [isImportOpen, setIsImportOpen] = useState<boolean>(false);
// 任务目的弹窗(多智能体会商深链 taskId 模式:查看线上当前策划进度/目的树)。
const [isTaskPurposeOpen, setIsTaskPurposeOpen] = useState<boolean>(false);
// 深链 taskId 模式:消息列表上方的「策划流程示例图」——按目的树层级画彩色小图标
// (任务→目的→…→叙事),点击打开任务目的流程图弹窗。空 = 无数据/未取到,不显示。
const [purposeLevels, setPurposeLevels] = useState<PurposeLevel[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
// Step 2 席位执行模式(快速/思考/专业问答/多智能体问答)——决定每个子智能体席位的
// 推理深度。由「选择参会智能体」弹窗确认时的 SeatSelectionResult.seatMode 决定。
@@ -897,6 +912,25 @@ export default function RoundtablePlanningPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅挂载时执行一次
// 深链 taskId 模式:挂载即取目的树,抽出有序层级供消息列表上方的「策划流程示例图」。
// 与任务目的弹窗同一条接口(弹窗打开时再各自取一帧,互不影响);取不到则保持空、不显示。
useEffect(() => {
const tid = prefillRef.current?.taskId;
if (!tid) return;
let cancelled = false;
void fetchTaskPurposeDetail(tid)
.then((r) => {
if (!cancelled) setPurposeLevels(derivePurposeLevels(r.tree));
})
.catch(() => {
if (!cancelled) setPurposeLevels([]);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅挂载时执行一次
// C2:带 autoStart 时,等 Step 1 意图线程就绪后自动把「任务名称/描述」作为首条用户消息发出,
// 触发任务理解智能体自动分析(只发一次)。
useEffect(() => {
@@ -1551,6 +1585,15 @@ export default function RoundtablePlanningPage() {
}}
/>
{/* 任务目的弹窗(深链 taskId 模式:查看线上当前策划进度/目的树,只读) */}
{prefillRef.current?.taskId && (
<TaskPurposeDialog
taskId={prefillRef.current.taskId}
open={isTaskPurposeOpen}
onOpenChange={setIsTaskPurposeOpen}
/>
)}
{/* 派活链路流程图弹窗(订阅进行中作业的真实 SSE 进度) */}
<DispatchChainJobModal
jobId={chainModalJobId}
@@ -1591,6 +1634,19 @@ export default function RoundtablePlanningPage() {
/>
<div className="flex items-center gap-2">
{/* 深链 taskId 模式:查看线上当前策划进度(目的树)。与单智能体闸门同一条接口。 */}
{prefillRef.current?.taskId && (
<button
id="task-purpose-btn"
onClick={() => setIsTaskPurposeOpen(true)}
title="查看该任务在线上的当前策划进度(目的树)"
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<Layers className="w-3.5 h-3.5 text-primary" />
任务目的
</button>
)}
{!inWujieEmbed && (
<button
id="business-chain-btn"
@@ -1706,6 +1762,15 @@ export default function RoundtablePlanningPage() {
>
{currentStep === 1 && (
<Step1Panel
headerLeftSlot={
prefillRef.current?.taskId && purposeLevels.length > 0 ? (
<PurposeFlowPreview
levels={purposeLevels}
onOpen={() => setIsTaskPurposeOpen(true)}
dark={isDark}
/>
) : undefined
}
interactiveMessages={step1.interactiveMessages}
intentThreadId={step1.intentThreadId}
intentReady={step1.intentReady}
@@ -3502,6 +3567,66 @@ interface RightSidebarProps {
onSaveIntent: (next: IntentSummary) => void;
}
/**
* 深链 taskId 模式 · Step 1 消息列表头部左侧的「策划流程」小图。
*
* 取代默认标题(任务意图理解),按目的树层级(目的 → 行为体 → … → 叙事)横向排列
* 彩色小图标、图标间用箭头串联,配色与流程图画布节点一致(FLOW_NODE_TYPE_META)。
* 点击整条 → 打开任务目的流程图弹窗看完整大图(不再单独放「查看」按钮)。
* 无层级数据时由调用方退回默认标题(此处 levels 必非空)。
*/
/** 各层级(流程图节点类型)对应的小图标;未列出的类型回退 Layers。 */
const LEVEL_ICON: Partial<Record<FlowNodeType, React.ComponentType<{ className?: string }>>> = {
stage: Target, // 目的
role: Users, // 行为体
task: Activity, // 预期行为
decision: Zap, // 驱动因素
deliverable: BookOpen, // 叙事
};
function PurposeFlowPreview({
levels,
onOpen,
dark = false,
}: {
levels: PurposeLevel[];
onOpen: () => void;
dark?: boolean;
}) {
if (levels.length === 0) return null;
const meta = flowNodeMeta(dark);
return (
<button
type="button"
onClick={onOpen}
title="点击查看完整策划流程图"
className="group/flow custom-scrollbar flex min-w-0 cursor-pointer items-center gap-2 overflow-x-auto rounded-lg text-left transition-opacity hover:opacity-90"
>
<span className="inline-flex shrink-0 items-center gap-1.5 text-[11px] font-bold text-slate-500">
<Layers className="h-3.5 w-3.5 text-primary" />
策划流程
</span>
{levels.map((lv, i) => {
const m = meta[lv.type];
const Icon = LEVEL_ICON[lv.type] ?? Layers;
return (
<span key={`${lv.label}-${i}`} className="flex shrink-0 items-center gap-1">
{i > 0 && <ChevronRight className="h-3.5 w-3.5 shrink-0 text-slate-300" />}
<span
title={lv.label}
style={{ background: m.bg, borderColor: m.border, color: m.text }}
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10.5px] font-semibold"
>
<Icon className="size-3 shrink-0" />
{lv.label}
</span>
</span>
);
})}
</button>
);
}
function RightSidebar({
currentStep,
selectedModel,
@@ -268,6 +268,54 @@
border-color: var(--border) !important;
}
/* =============================================================================
* neutral 家族 + Step1 左栏写死的米色/浅底(业务链关系图背板、分析记录卡)。
*
* 这些是 RoundtableEntryRail / 分析记录 用 neutral-* 与米色 arbitrary hex
* (#FAF6F5 / #ECE7E4 / #E8D0CC)写死的浅色,上面 slate 表只覆盖 slate-* 与
* #e2e8f0,未含 neutral 与这些米色,导致深色下「关系图背景」「第N步 tag 底」
* 等仍泛白。这里按 slate 同一套语义 token 收口;neutral-900 浮层底(深色 tooltip)
* 保留不动。
* ========================================================================== */
/* ---- 表面:米白底 / neutral-50·100(含 tag 底)→ muted ---- */
:is(.dark .roundtable-planning, .roundtable-planning--dark)
:is(
.bg-\[\#FAF6F5\], .bg-\[\#faf6f5\],
.bg-neutral-50, .bg-neutral-50\/50, .bg-neutral-100
) {
background-color: var(--muted) !important;
}
/* ---- 次级表面 hover:neutral-50 → accent ---- */
:is(.dark .roundtable-planning, .roundtable-planning--dark)
.hover\:bg-neutral-50:hover {
background-color: var(--accent) !important;
}
/* ---- neutral / 米色边框(含 #ECE7E4 / #E8D0CC 虚线,透明度档)→ border ---- */
:is(.dark .roundtable-planning, .roundtable-planning--dark)
:is(
.border-neutral-100, .border-neutral-200, .border-neutral-300,
.hover\:border-neutral-300:hover,
.border-\[\#ECE7E4\], .border-\[\#ece7e4\],
.border-\[\#E8D0CC\], .border-\[\#E8D0CC\]\/60
) {
border-color: var(--border) !important;
}
/* ---- neutral 文字:深 → 正文色 ---- */
:is(.dark .roundtable-planning, .roundtable-planning--dark)
:is(.text-neutral-900, .text-neutral-800, .text-neutral-700) {
color: var(--foreground) !important;
}
/* ---- neutral 文字:中 / 浅 → 次级文字色(深色 tooltip 内的 neutral-300 同样可读) ---- */
:is(.dark .roundtable-planning, .roundtable-planning--dark)
:is(.text-neutral-500, .text-neutral-400, .text-neutral-300) {
color: var(--muted-foreground) !important;
}
/* ---- focus ring:brand-200 → 主色淡环 ---- */
:is(.dark .roundtable-planning, .roundtable-planning--dark)
:is(
@@ -0,0 +1,93 @@
/**
* 任务目的树「查看弹窗」——多智能体会商深链(goPath=*-A)等只读场景共用。
*
* 打开时按 taskId 拉取目的树(fetchTaskPurposeDetail,走后端 task_deeplink 配置 /
* 测试假数据,与单智能体闸门面板同一条接口路径),把目的树转换成 Step 3 流程图数据
* (purposeTreeToFlowGraph)后,直接复用第三步的流程图画布组件 FlowGraphPanel 展示
* (节点可点开看详情、拖拽、缩放、全屏)。**纯查看**:不传 onChange,不可编辑。
*/
import { useEffect, useMemo, useState } from "react";
import { Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import {
fetchTaskPurposeDetail,
purposeTreeToFlowGraph,
type TaskPurposeResult,
} from "@/core/auth/task-purpose";
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
export function TaskPurposeDialog({
taskId,
open,
onOpenChange,
}: {
taskId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<TaskPurposeResult | null>(null);
useEffect(() => {
if (!open || !taskId) return;
let cancelled = false;
setLoading(true);
fetchTaskPurposeDetail(taskId)
.then((r) => {
if (!cancelled) setResult(r);
})
.catch(() => {
if (!cancelled) setResult({ tree: [], hasData: false });
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, taskId]);
const flowData = useMemo(
() => purposeTreeToFlowGraph(result?.tree ?? [], taskId),
[result, taskId],
);
const hasNodes = flowData.nodes.length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{/* 流程图画布自带工具栏(含关闭),关掉 Dialog 默认右上角关闭按钮避免重叠;去内边距让画布铺满。 */}
<DialogContent
showCloseButton={false}
className="flex h-[85vh] w-[92vw] !max-w-[92vw] flex-col gap-0 overflow-hidden p-0"
>
{/* a11y:Radix 要求 DialogTitle,画布已有可见标题,这里隐藏。 */}
<DialogTitle className="sr-only">任务目的(任务 {taskId})</DialogTitle>
{loading ? (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" /> 正在查询任务目的…
</div>
) : hasNodes ? (
<FlowGraphPanel
data={flowData}
streaming={false}
onClose={() => onOpenChange(false)}
orientation="horizontal"
miniMap
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
暂无目的数据
</div>
)}
</DialogContent>
</Dialog>
);
}
export default TaskPurposeDialog;
+93
View File
@@ -516,6 +516,99 @@
background-attachment: fixed;
}
/* AI 写作「课题研究」左栏(ResearchSidebar,class=ai-research-sidebar)的深蓝适配。
* 该组件只为浅色写死了 slate/white,这里仅在 .dark 下统一改写为半透明深蓝玻璃面板 +
* 可读文字(对齐 .sidebar-contents:bg rgba(3,38,80,.8) + 蓝色发光边框),叠 backdrop
* 模糊让外壳 canvas-writing-bg 的深蓝底图朦胧透出。浅色模式不命中,原样式完全不变;
* 彩色图标 chip / rose 选中态作语义高亮,刻意不收进此表。 */
.dark .ai-research-sidebar {
background-color: rgba(3, 38, 80, 0.8) !important;
border-color: rgba(43, 143, 254, 0.45) !important;
color: #c5cfe0 !important;
box-shadow: none !important;
backdrop-filter: blur(12px);
}
.dark .ai-research-sidebar .bg-white {
background-color: rgba(255, 255, 255, 0.05) !important;
}
.dark .ai-research-sidebar
:is(.bg-slate-50, .bg-slate-50\/80, .bg-slate-50\/70, .bg-slate-100) {
background-color: rgba(255, 255, 255, 0.06) !important;
}
.dark .ai-research-sidebar
:is(.hover\:bg-slate-50:hover, .hover\:bg-slate-50\/70:hover) {
background-color: rgba(255, 255, 255, 0.09) !important;
}
.dark .ai-research-sidebar .focus\:bg-white:focus {
background-color: rgba(255, 255, 255, 0.1) !important;
}
.dark .ai-research-sidebar
:is(.border-slate-100, .border-slate-100\/80, .border-slate-200) {
border-color: rgba(43, 143, 254, 0.22) !important;
}
.dark .ai-research-sidebar .hover\:border-slate-200:hover {
border-color: rgba(43, 143, 254, 0.42) !important;
}
.dark .ai-research-sidebar :is(.text-slate-900, .text-slate-800, .text-slate-700) {
color: #e8edf5 !important;
}
.dark .ai-research-sidebar
:is(.text-slate-600, .text-slate-500, .text-slate-400, .text-slate-300) {
color: #8aa0c4 !important;
}
.dark .ai-research-sidebar
:is(
.hover\:text-slate-800:hover,
.hover\:text-slate-900:hover,
.hover\:text-slate-950:hover
) {
color: #e8edf5 !important;
}
/* 浅色投影在深底上发灰,去掉子元素的阴影 */
.dark .ai-research-sidebar [class*="shadow-"] {
box-shadow: none !important;
}
/* 模板图标 chip:浅彩底(*-50)深色下发白 → 改主题语义色 16% 透明软底 + 提亮图标
* (色值取自《深蓝色 UI 规范》§1.1.9 功能语义色 / §1.1.7 装饰色)。 */
.dark .ai-research-sidebar .bg-blue-50 {
background-color: rgba(22, 119, 255, 0.16) !important;
}
.dark .ai-research-sidebar .text-blue-600 {
color: #4096ff !important;
}
.dark .ai-research-sidebar .bg-violet-50 {
background-color: rgba(99, 136, 255, 0.16) !important;
}
.dark .ai-research-sidebar .text-violet-600 {
color: #6388ff !important;
}
.dark .ai-research-sidebar .bg-emerald-50 {
background-color: rgba(82, 196, 26, 0.16) !important;
}
.dark .ai-research-sidebar .text-emerald-600 {
color: #52c41a !important;
}
.dark .ai-research-sidebar .bg-cyan-50 {
background-color: rgba(19, 194, 194, 0.16) !important;
}
.dark .ai-research-sidebar .text-cyan-600 {
color: #36cfc9 !important;
}
.dark .ai-research-sidebar .bg-rose-50 {
background-color: rgba(245, 34, 45, 0.16) !important;
}
.dark .ai-research-sidebar .text-rose-600 {
color: #ff7875 !important;
}
/* 警告橙(amber):模板「周报」chip + 「笔记本服务暂不可用」提示,统一警告软底 +
* 警告色文字(§1.1.9:警告 #FAAD14 / 软底 rgba(250,173,20,0.16))。 */
.dark .ai-research-sidebar .bg-amber-50 {
background-color: rgba(250, 173, 20, 0.16) !important;
}
.dark .ai-research-sidebar :is(.text-amber-700, .text-amber-600) {
color: #faad14 !important;
}
.workspace-panel {
border: 1px solid #f0d7d0;
background: #ffffff;