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;
@@ -271,6 +271,12 @@ Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship
**Skill-driven retrieval (检索类型).** `material_source` has exactly two values: `general` (通用检索, default) and `notebook` (我的空间); legacy `knowledge_base`/`skill` are normalized to `general` by the researcher node. General retrieval is orchestrated from the **skills configured on the `ai-writing-researcher` agent** (`config.yaml → skills`, editable from the agent management page): the built-in `knowledge-base-search` skill (知识库检索) runs the native keyword fan-out web/intranet search (the old default path), while any other configured skill searches with a `description`-targeted query; skills run in configured order with early-stop at `max_materials`, and an empty/unresolvable skills config falls back to the built-in knowledge search so retrieval always works. The skill itself is seeded from `_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` into `skills/public/` (never overwrites), and a pre-existing researcher `config.yaml` lacking the `skills` key is patched in place with the default (explicit `skills: []` is respected). **PAUSE-1 input append flow**: when the user types natural language into the material-confirm input (`re_search`/legacy `enable_skill_search` + `user_query`), the node matches the most relevant configured skill via `SkillPicker(candidates=…)` (skipped when only one skill is configured), searches with the user's words, and **appends** the new materials to the existing package (URL-dedup, original order preserved, no LLM re-rank/truncation, 100-item hard cap). **Skill-failure fault tolerance**: a configured skill that times out / raises / has all its keyword searches fail (vs. legitimately returning 0 results) no longer just silently moves on — the researcher node emits a `skill_search_step` of type `skill_error` (carrying the error `reason`), and if the configured skills collected **no** new materials at all it runs a `fallback` pass over other available skills (`_fallback_candidate_skills`: the built-in `knowledge-base-search` native path first, then other enabled catalog skills not yet tried) instead of erroring out. The frontend `SkillStepsCard` renders `skill_error` (rose) and `fallback` (amber) so the timeline shows "技能报错 → 改用其他技能搜集". Tests: `tests/test_ai_writing_material_source.py`.
**Skill-call timeout** is `AI_WRITING_SKILL_SEARCH_TIMEOUT_SECONDS` (env, default **120s**; `researcher.py` `_SKILL_SEARCH_TIMEOUT_SECONDS`); the DuckDuckGo provider's internal cap was raised 30s→120s so it doesn't undercut it.
**Agentic skill execution (`ai_writing.researcher_skill_agent`, default false).** By default a configured non-native skill is *not* run agent-style — the node just turns its `description` into a query for the generic search provider. With this flag on, the directed-skill branch instead runs `nodes/_skill_agent.py::run_skill_search_agent`: it injects the skill's **SKILL.md** (read from `skill.skill_file`) alongside the researcher SOUL as the system prompt, binds a `search` tool to the researcher model, and runs a bounded (`_MAX_TURNS=4`) tool-call loop so the model drives the retrieval per the skill's instructions; the `SearchResult`s the tool returns are harvested directly as materials (no fuzzy text extraction). Requires a tool-calling model; if the model can't tool-call (returns no `tool_calls`) or the agent yields nothing, `_execute_skill` falls back to the legacy description→query search so materials are never empty. The built-in `knowledge-base-search` native concurrent path is unchanged. Tests: `tests/test_ai_writing_skill_agent.py`.
**Background suspend (后台挂起).** A writing session can be handed off to the server to auto-drive the remaining flow to finalize, surviving page leave / refresh / account switch. `POST /api/ai-writing/sessions/{id}/background` (owner-checked, idempotent) starts `AIWritingJobExecutor` (`app/gateway/ai_writing_job_executor.py`), wired in `deps.py` as `app.state.ai_writing_job_executor`. The executor **drives the same `ai_writing` graph in-process** — it builds `make_ai_writing_graph()`, attaches the shared `app.state.checkpointer` (the very saver the runtime worker uses, so it continues the real thread state the frontend already wrote), sets the owning user via `set_current_user`, and loops `aget_state` → `ainvoke(Command(resume=…))` until `status==done`. **Auto-action per interrupt**: material/outline confirm → `confirm`; section_help → every blocked section `loose` (continue with general knowledge, never re-loop to research); draft_confirm → `to_editor` until reviewed, then `finalize` (on pass, or when `revision_count >= max_revisions`); review_confirm → `accept_review` (revise & re-review until pass or budget exhausted, then forced finalize). To avoid racing a still-running runtime worker (suspend mid-node), the driver **waits for an actionable state** (`_wait_for_actionable`): it only acts when the graph is at an interrupt / done, or when the checkpoint stops advancing (no worker → abandoned/restart). No new table — the graph checkpoint + `ai_writing_sessions` already hold all state; `status="background"` marks 挂起中 (surfaced in the history dropdown), nodes write `draft`/`review_result`/`status=done` as usual, so the user reopens the finished article from history. Per-user concurrency cap `AI_WRITING_BACKGROUND_SLOTS_PER_USER` (default 2, same SQLite-lock rationale as the roundtable executor). Startup `reconcile` (in `deps.py`) relaunches drivers for sessions left `background` after a restart (`AIWritingSessionRepository.list_by_status`). **Progress flowchart popup**: `GET /api/ai-writing/sessions/{id}/progress` (owner-checked) reads the graph checkpoint via `executor.get_progress` and derives a canonical pipeline `stage` key (`intent/research/material_confirm/outline/outline_confirm/draft/draft_confirm/review/done`, from the pending interrupt's `pause_point` or `snapshot.next` node) + `note` (素材不足求助 / 编辑打回) + review progress + `isRunning`. Frontend: 「后台挂起」 button + auto-suspend on route unmount / `beforeunload` (`AIWritingPanel.tsx`, `suspendAIWritingToBackground[Keepalive]` in `api/ai-writing-sessions.ts`); 挂起中 badge in `AIWritingHistoryDropdown.tsx`, and **clicking a 挂起中 history record opens a flowchart dialog** (`AIWritingFlowDialog.tsx` polls `getAIWritingProgress` every 2.5s, renders the fixed vertical pipeline `AIWritingFlowChart.tsx` lit by the live `stage`) so the user sees how far the background run has gotten without reopening/taking over the session. **Transcript consistency**: the rich history timeline (`AIWritingHistoryView` → `AIWritingTimeline`) replays `transcript = {progressEvents, completedInterventions}`, normally assembled+saved client-side. A background run has no client, so the executor rebuilds it at the end (`_save_transcript`/`_build_transcript_from_checkpoint`) **entirely from the final checkpoint** — both `progressEvents` and `completedInterventions` are derived from the same set of `resumed` events (every pause-resume emits exactly one), so they are always 1:1 aligned. For each `resumed`: classify its message → `(pausePoint, action)` (`_classify_resumed`, matched against `graph.py`'s resume messages), insert an `await_user` (with the pause prompt) before it, and build a **rich** intervention card enriched from final state (material_confirm→`materials`, outline_confirm→`outline`, draft_confirm→`draftTitle`/`draftWordCount`/`reviewResult`, review_confirm→`reviewResult`); `materials_ready` is inserted before the first material-confirm. (Earlier code merged the frontend's pre-suspend `completedInterventions` with auto ones and aligned by index — but the frontend's last save can predate its most recent decision, so the counts drifted and cards rendered off-by-one with empty data; deriving everything from the checkpoint root-fixes both.) Raw (snake_case) events **and** intervention data are normalized on load by the **exported** `normalizeProgressDict` / `normalize{Material,Outline,ReviewResult}` (idempotent on already-camelCase live transcripts — one normalize path for both sources), so reopening a background-completed session shows the same cards as a frontend-driven one. Tests: `tests/test_ai_writing_background.py`.
### Admin Leaderboard Snapshots (`/api/admin/users/leaderboard*`)
The admin ????? is served from **precomputed per-day snapshots** in `admin_leaderboard_daily_stats` (keyed by `(stat_date, settings_hash)`), not by aggregating raw rows on every request. A background scheduler inside the Gateway (`_leaderboard_snapshot_scheduler_loop` in `app/gateway/app.py`, singleton file-locked) ticks every `ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS` (default 300s): it prewarms recent days, then builds any `queued`/`error`/stale-`running` day. `GET /leaderboard` merges the per-day snapshots for the requested range. **Today** is rebuilt whenever its snapshot is older than `ADMIN_LEADERBOARD_TODAY_TTL_SECONDS` (default 43200s = half a day, so the leaderboard refreshes ~twice daily); a past day, once `ready`, is treated as fresh forever.
@@ -0,0 +1,546 @@
"""AI 写作「后台挂起」执行器。
把 AI 写作 LangGraph 图作为**后台 asyncio 任务**驱动到定稿,survive 发起请求的
生命周期(离开页面 / 刷新 / 切换账号都不打断)。
与圆桌后台作业(``roundtable_job_executor``)的根本区别:AI 写作的编排**就是
LangGraph 图本身**(4 个 ``interrupt()`` 干预点),所以这里不另起编排引擎,而是
直接驱动同一张图——用 app 启动时创建的**共享 checkpointer**(``app.state.checkpointer``,
即运行时 worker 跑该图用的同一个 saver,见 ``runtime/runs/worker.py`` 的
``agent.checkpointer = ctx.checkpointer``)续上前端已经写到 checkpoint 的线程状态,
在每个 ``interrupt()`` 处用「默认放行」动作自动 resume,一路推进到定稿:
素材确认 → confirm
大纲确认 → confirm
缺素材求助 → 全部「通用知识续写」(loose,不回检索、不丢章节)
草稿确认 → 未审过先 to_editor,审过(通过/用完次数)再 finalize
编辑打回 → accept_review(按编辑意见重写),直到通过或用完 max_revisions
图节点本就把进度落库(``writer_draft`` 写 draft、``editor`` 写 review_result、
``pause_*`` finalize 时 ``_mark_session_done`` 写 status=done),所以后台跑完用户
从历史里重新打开会话即可看到成稿——这里无需额外的产物表。
"""
from __future__ import annotations
import asyncio
import logging
import os
from langgraph.types import Command
from deerflow.runtime.user_context import AUTO, reset_current_user, set_current_user
logger = logging.getLogger(__name__)
def _slots_per_user() -> int:
"""每个用户同时真正在跑的后台写作上限(默认 2)。
根因与圆桌同源:checkpointer 多为单连接 SQLite(一把 asyncio.Lock 串行全进程
checkpoint 读写),后台作业无并发上限会把多张图的模型轮次压到同一把锁 + 同一
离线模型端点,拖慢前台。按用户限流后随便挂多少都能建,只是同时真跑的 ≤ N。
前台实时路径走 LangGraph 运行时 worker,不经这里,永不受限。
"""
raw = os.getenv("AI_WRITING_BACKGROUND_SLOTS_PER_USER", "").strip()
if not raw:
return 2
try:
return max(1, int(raw))
except ValueError:
return 2
# 每用户一把信号量(懒建)。键为 user_id(None → "default")。
_user_semaphores: dict[str, asyncio.Semaphore] = {}
def _user_semaphore(user_id: str | None) -> asyncio.Semaphore:
key = user_id or "default"
sem = _user_semaphores.get(key)
if sem is None:
sem = asyncio.Semaphore(_slots_per_user())
_user_semaphores[key] = sem
return sem
class _JobUser:
"""最小 CurrentUser:满足 user_context 的 ``.id`` 结构协议。"""
def __init__(self, user_id: str | None) -> None:
self.id = user_id
# 后台运行中的会话状态值(历史下拉据此显示「挂起中」徽标)。
STATUS_BACKGROUND = "background"
# 单个会话最多自动推进的步数(resume / 续跑各算一步)。安全上限,防图拓扑异常死循环。
# 正常一篇:素材→大纲→草稿→审核(最多 max_revisions 轮)→定稿,远小于这个数。
_MAX_DRIVE_STEPS = 60
# 「等待可操作态」轮询:用户在节点执行中(如素材搜集)点后台挂起时,运行时 worker
# 可能还在跑那个节点。此时**绝不能**并发 ainvoke 同一线程(会撞坏 checkpoint),先观察:
# - 出现 interrupt / 走到 done → 立刻接管;
# - 停在节点间且 checkpoint 连续 _INFLIGHT_MAX_STABLE 次不再推进 → 判定无 worker 在跑
# (被遗弃 / 进程重启续跑),可安全自驱。
_INFLIGHT_POLL_SECONDS = 1.5
_INFLIGHT_MAX_STABLE = 3
# 等待极长节点(LLM 慢)的轮询上限,≈ _INFLIGHT_MAX_WAIT_POLLS * _INFLIGHT_POLL_SECONDS 秒。
_INFLIGHT_MAX_WAIT_POLLS = 800
# 进度弹窗:把「下一个待执行节点 / 当前 interrupt 暂停点」映射成前端流程图的规范化阶段 key。
_NODE_TO_STAGE = {
"intent_parser": "intent",
"researcher": "research",
"pause_material": "material_confirm",
"writer_outline": "outline",
"pause_outline": "outline_confirm",
"writer_draft": "draft",
"pause_section_help": "draft",
"pause_draft": "draft_confirm",
"editor": "review",
"pause_review": "review",
}
_PAUSE_TO_STAGE = {
"material_confirm": "material_confirm",
"outline_confirm": "outline_confirm",
"section_help": "draft",
"draft_confirm": "draft_confirm",
"review_confirm": "review",
}
# 暂停点附带的细化说明(流水线主链之外的分支)。
_STAGE_NOTE = {
"section_help": "素材不足求助",
"review_confirm": "编辑打回",
}
# resumed 事件 message → (pause_point, action)。与 graph.py 各 pause 节点 emit 的 resumed
# 文案严格对齐(见 graph.py pause_material/outline/draft/section_help/review)。从 checkpoint
# 重建 await_user/干预卡时据此分类——await_user 与 completedInterventions 都源自同一批 resumed
# 事件,永远 1:1 对齐(不再依赖前端可能滞后的已存 interventions,根治错位 off-by-one)。
def _classify_resumed(message: str) -> tuple[str | None, str | None]:
m = message or ""
if "确认素材" in m:
return "material_confirm", "confirm"
if "匹配技能检索" in m or "重新检索" in m or "重新搜索" in m:
return "material_confirm", "re_search"
if "确认大纲" in m:
return "outline_confirm", "confirm"
if "重新规划大纲" in m:
return "outline_confirm", "re_outline"
if "提交编辑审核" in m:
return "draft_confirm", "to_editor"
if "强制定稿" in m:
return "review_confirm", "force_finalize"
if "确认定稿" in m:
return "draft_confirm", "finalize"
if "按用户意见重写" in m:
return "draft_confirm", "user_revise"
if "按编辑意见重写" in m:
return "review_confirm", "accept_review"
if "补充检索素材" in m:
return "section_help", "supplement"
if "继续写作" in m:
return "section_help", "loose"
return None, None
# 暂停提示文案(await_user 的 message)——对齐 graph.py 各 interrupt() 的 message,
# 让历史回显里暂停点显示与实时一致的提示语。
_PAUSE_MESSAGES = {
"material_confirm": "素材收集专家已完成素材搜集,请确认素材后继续。",
"outline_confirm": "作家已完成大纲规划,请确认或编辑大纲后继续。",
"draft_confirm": "草稿已完成,请确认或给出修改意见。",
"section_help": "部分章节缺少足够素材支撑写作,请决定如何处理。",
"review_confirm": "编辑认为文章需要修改,请决定下一步操作。",
}
def _build_transcript_from_checkpoint(values: dict) -> tuple[list[dict], list[dict]]:
"""从最终 checkpoint 重建 (progressEvents, completedInterventions),二者自洽 1:1 对齐。
每条 ``resumed`` 事件 = 一个已处理的暂停点:在其前插一条 ``await_user``(带暂停提示
文案),并据 resumed 文案分类出 pausePoint+action 生成**带富数据**的干预卡(素材列表/
大纲/草稿标题字数/审核结果取自最终 state;snake_case 字段由前端归一化)。素材确认前
再插一条 ``materials_ready``。其余里程碑(intent_ready/step_*/outline_ready/draft_ready/
review_ready/notice/error)节点本就 append 进 progress_events,原位即正确顺序。
时间线靠 ``await_user`` 锚点按序取 completedInterventions 渲染干预卡——这里两者都源自
同一批 resumed,数量与顺序天然一致,根治了「卡片错位 / 只显示标题没数据」的 bug。
"""
raw_events = list(values.get("progress_events") or [])
pkg = values.get("material_package") or {}
outline = values.get("current_outline")
draft = values.get("current_draft") or {}
review = values.get("review_result")
status = values.get("status")
events: list[dict] = []
interventions: list[dict] = []
materials_inserted = not pkg
for ev in raw_events:
if isinstance(ev, dict) and ev.get("type") == "resumed":
pp, action = _classify_resumed(ev.get("message") or "")
if pp == "material_confirm" and not materials_inserted:
events.append({
"type": "materials_ready",
"materials": pkg.get("materials") or [],
"keywords": pkg.get("keywords") or [],
"summary": pkg.get("summary") or "",
})
materials_inserted = True
events.append({"type": "await_user", "pause_point": pp, "message": _PAUSE_MESSAGES.get(pp, "")})
rec: dict = {"pausePoint": pp, "action": action}
if pp == "material_confirm":
rec["materials"] = pkg.get("materials") or []
elif pp == "outline_confirm":
if outline:
rec["outline"] = outline
elif pp == "draft_confirm":
rec["draftTitle"] = draft.get("title") or ""
rec["draftWordCount"] = int(draft.get("word_count") or 0)
if review:
rec["reviewResult"] = review
elif pp == "review_confirm":
if review:
rec["reviewResult"] = review
interventions.append(rec)
events.append(ev)
if not materials_inserted:
# 没有 material_confirm 暂停(罕见)但有素材包 → 插到第一条 outline_ready 前,否则附末尾。
mr = {"type": "materials_ready", "materials": pkg.get("materials") or [],
"keywords": pkg.get("keywords") or [], "summary": pkg.get("summary") or ""}
idx = next((i for i, e in enumerate(events) if isinstance(e, dict) and e.get("type") == "outline_ready"), None)
events.insert(idx, mr) if idx is not None else events.append(mr)
if status == "done" and not any(isinstance(e, dict) and e.get("type") == "done" for e in events):
events.append({"type": "done", "message": "写作完成"})
return events, interventions
def _checkpoint_id(snapshot) -> str | None:
"""取快照的 checkpoint_id,用于判断图状态是否在被(worker)推进。"""
cfg = getattr(snapshot, "config", None) or {}
try:
return (cfg.get("configurable") or {}).get("checkpoint_id")
except AttributeError:
return None
def _auto_resume_payload(pause_point: str | None, interrupt_value: dict, values: dict) -> dict:
"""给定当前 ``interrupt()`` 暂停点 + 图状态,返回「默认放行」的 resume 入参。
设计目标:一路推进到定稿且**不绕回检索 / 不无限重审**。判定逻辑见模块 docstring。
"""
if pause_point == "material_confirm":
# 不传 approvedMaterialIds → 节点默认采用全部素材。
return {"action": "confirm"}
if pause_point == "outline_confirm":
# 不传 editedOutline → 节点采用当前大纲。
return {"action": "confirm"}
if pause_point == "section_help":
# 严格模式缺素材求助:全部章节按「通用知识续写」(loose)处理——既不回检索
# (supplement 会绕回 researcher 再触发暂停),也不删章节,直接续写到底。
blocked = (interrupt_value or {}).get("blocked_sections") or values.get("blocked_sections") or []
decisions = []
for b in blocked:
title = (b or {}).get("section_title") or (b or {}).get("sectionTitle") or ""
if title:
decisions.append({"sectionTitle": title, "decision": "loose"})
return {"action": "section_help", "sectionDecisions": decisions}
if pause_point == "draft_confirm":
review = values.get("review_result")
rev = int(values.get("revision_count") or 0)
max_rev = int(values.get("max_revisions") or 3)
if not review:
# 还没经过编辑审核 → 先送审。
return {"action": "to_editor"}
if (review or {}).get("verdict") == "pass":
# 审核通过 → 定稿。
return {"action": "finalize"}
if rev >= max_rev:
# 用完修改次数仍未通过(after_review 强制落到草稿确认)→ 接受现稿定稿。
return {"action": "finalize"}
# 刚按编辑意见改过、review 还是旧的不通过结果 → 再次送审,拿新一轮审核。
return {"action": "to_editor"}
if pause_point == "review_confirm":
# 编辑打回(仍有修改额度)→ 按编辑意见重写,重写完会再次 to_editor 复审。
return {"action": "accept_review"}
# 未知暂停点:保守放行。
return {"action": "confirm"}
def _pending_interrupt(snapshot) -> dict | None:
"""从状态快照里取第一个待处理 ``interrupt()`` 的 value(dict),无则 None。"""
for task in getattr(snapshot, "tasks", None) or ():
for intr in getattr(task, "interrupts", None) or ():
value = getattr(intr, "value", None)
if isinstance(value, dict):
return value
return {"value": value}
return None
class AIWritingJobExecutor:
"""驱动 AI 写作图在后台跑到定稿。按 thread_id 幂等、按用户限流。"""
def __init__(self, app) -> None:
# 闭包持有 app,运行时再读 ``app.state.checkpointer`` / session store——它们在
# lifespan 里创建,运行时读保证拿到最新实例(与 worker 用同一个 checkpointer)。
self._app = app
self._tasks: dict[str, asyncio.Task] = {}
# ── 公开 API ──────────────────────────────────────────────────────────────
def start(self, *, thread_id: str, user_id: str | None) -> bool:
"""启动一个后台驱动任务。同一 thread 已在跑则忽略(幂等)。返回是否新启动。"""
existing = self._tasks.get(thread_id)
if existing is not None and not existing.done():
return False
task = asyncio.create_task(self._run(thread_id, user_id))
self._tasks[thread_id] = task
task.add_done_callback(lambda t, tid=thread_id: self._tasks.pop(tid, None))
return True
def is_running(self, thread_id: str) -> bool:
task = self._tasks.get(thread_id)
return task is not None and not task.done()
async def get_progress(self, thread_id: str) -> dict:
"""读取该写作线程当前所处阶段(从 graph checkpoint 推导),供「查看进度」弹窗。
返回规范化的 ``stage`` key(见 ``_NODE_TO_STAGE`` / ``_PAUSE_TO_STAGE``,与前端
流程图阶段一一对应)+ 可选 ``note`` + 审核进度。无 checkpoint / 读失败 → stage=None。
"""
empty = {"stage": None, "note": None, "revisionCount": 0, "maxRevisions": 3, "reviewVerdict": None}
checkpointer = self._checkpointer()
if checkpointer is None:
return empty
from deerflow.agents.ai_writing.graph import make_ai_writing_graph
graph = make_ai_writing_graph()
graph.checkpointer = checkpointer
try:
snapshot = await graph.aget_state({"configurable": {"thread_id": thread_id}})
except Exception: # noqa: BLE001 — 读不到 checkpoint 不致命
return empty
values = getattr(snapshot, "values", None) or {}
review = values.get("review_result") or {}
out = {
"stage": None,
"note": None,
"revisionCount": int(values.get("revision_count") or 0),
"maxRevisions": int(values.get("max_revisions") or 3),
"reviewVerdict": review.get("verdict"),
}
if not values:
return out # 还没建 checkpoint
if values.get("status") == "done":
out["stage"] = "done"
return out
interrupt_value = _pending_interrupt(snapshot)
if interrupt_value is not None:
pp = interrupt_value.get("pause_point")
out["stage"] = _PAUSE_TO_STAGE.get(pp, pp)
out["note"] = _STAGE_NOTE.get(pp)
return out
next_nodes = list(getattr(snapshot, "next", None) or ())
if next_nodes:
node = next_nodes[0]
out["stage"] = _NODE_TO_STAGE.get(node, node)
return out
# 无 interrupt、无 next → 已结束。
out["stage"] = "done"
return out
def cancel(self, thread_id: str) -> bool:
task = self._tasks.get(thread_id)
if task is None or task.done():
return False
task.cancel()
return True
async def await_job(self, thread_id: str) -> None:
"""等待某后台任务结束(测试用;生产不调用)。"""
task = self._tasks.get(thread_id)
if task is not None:
try:
await task
except (Exception, asyncio.CancelledError): # noqa: BLE001
pass
def reconcile(self, *, sessions: list[dict]) -> int:
"""启动时把仍标记为 ``background`` 的会话重新拉起驱动(进程重启续跑)。
``sessions`` 为 ``[{id, user_id}, ...]``。返回拉起的任务数。
"""
launched = 0
for s in sessions:
tid = s.get("id")
if not tid:
continue
if self.start(thread_id=tid, user_id=s.get("user_id")):
launched += 1
if launched:
logger.info("[ai-writing-bg] reconcile relaunched %d background session(s)", launched)
return launched
# ── 内部 ────────────────────────────────────────────────────────────────
def _checkpointer(self):
return getattr(self._app.state, "checkpointer", None)
def _store(self):
return getattr(self._app.state, "ai_writing_session_store", None)
async def _set_status(self, thread_id: str, status: str) -> None:
store = self._store()
if store is None:
return
try:
await store.update(thread_id, user_id=AUTO, status=status)
except Exception: # noqa: BLE001 — 落库失败不致命
logger.warning("[ai-writing-bg] set status=%s for %s failed", status, thread_id, exc_info=True)
async def _run(self, thread_id: str, user_id: str | None) -> None:
token = set_current_user(_JobUser(user_id))
try:
checkpointer = self._checkpointer()
if checkpointer is None:
logger.warning("[ai-writing-bg] no checkpointer; cannot drive %s", thread_id)
return
await self._set_status(thread_id, STATUS_BACKGROUND)
from deerflow.agents.ai_writing.graph import make_ai_writing_graph
graph = make_ai_writing_graph()
# 与运行时 worker 同款:把共享 checkpointer 挂到图上,续上同一线程的 checkpoint。
graph.checkpointer = checkpointer
config = {"configurable": {"thread_id": thread_id}, "recursion_limit": 100}
# 按用户限流:同用户最多 N 篇后台写作同时真跑(见 _slots_per_user)。
async with _user_semaphore(user_id):
done, _interventions = await self._drive(graph, config)
# 把后台跑出的对话时间线写进 transcript(与前端直接写作的存取一致)。
await self._save_transcript(thread_id, graph, config)
if not done:
# 图已停在非 done 终态(无 interrupt 也无 next):兜底标 done,避免会话
# 永久卡在「挂起中」。正常路径下 finalize 已由节点 _mark_session_done 写 done。
await self._mark_terminal(thread_id, graph, config)
except asyncio.CancelledError:
logger.info("[ai-writing-bg] job %s cancelled", thread_id)
raise
except Exception: # noqa: BLE001 — 顶层兜底
logger.exception("[ai-writing-bg] job %s crashed", thread_id)
await self._set_status(thread_id, "error")
finally:
reset_current_user(token)
async def _wait_for_actionable(self, graph, config) -> tuple[object, str]:
"""等到图处于可安全操作的状态,返回 ``(snapshot, action)``。
action ∈ {``interrupt``(有干预点待 resume), ``done``(已结束),
``push``(停在节点间且无 worker 在推进,可自驱)}。
见 ``_INFLIGHT_*`` 注释:避免与运行时 worker 并发跑同一线程。
"""
last_ckpt: object = object()
stable = 0
for _ in range(_INFLIGHT_MAX_WAIT_POLLS):
snapshot = await graph.aget_state(config)
if (getattr(snapshot, "values", None) or {}).get("status") == "done":
return snapshot, "done"
if _pending_interrupt(snapshot) is not None:
return snapshot, "interrupt"
if not getattr(snapshot, "next", None):
return snapshot, "done" # 无中断、无待执行 → 图已结束
ckpt = _checkpoint_id(snapshot)
if ckpt != last_ckpt:
# checkpoint 在变 → 有 worker 正推进该节点,继续等它到下一个干预点。
last_ckpt = ckpt
stable = 0
else:
stable += 1
if stable >= _INFLIGHT_MAX_STABLE:
return snapshot, "push" # 连续不动 → 无 worker,自己推进
await asyncio.sleep(_INFLIGHT_POLL_SECONDS)
# 等待超时(极长节点)→ 保守按可推进处理。
return await graph.aget_state(config), "push"
async def _drive(self, graph, config) -> tuple[bool, list[dict]]:
"""推进图直到定稿(status==done)或无可推进。
返回 ``(是否走到 done, 本次自动处理的 interventions)``。每个干预点记录
``{pausePoint, action}``,供重建 transcript 时与 ``await_user`` 对齐。
"""
thread_id = (config.get("configurable") or {}).get("thread_id")
interventions: list[dict] = []
for _ in range(_MAX_DRIVE_STEPS):
snapshot, action = await self._wait_for_actionable(graph, config)
values = getattr(snapshot, "values", None) or {}
if action == "done":
return values.get("status") == "done", interventions
if action == "interrupt":
interrupt_value = _pending_interrupt(snapshot) or {}
pause_point = interrupt_value.get("pause_point")
payload = _auto_resume_payload(pause_point, interrupt_value, values)
interventions.append({"pausePoint": pause_point, "action": payload.get("action")})
logger.info("[ai-writing-bg] auto-resume thread=%s pause=%s action=%s",
thread_id, pause_point, payload.get("action"))
await graph.ainvoke(Command(resume=payload), config)
continue
# action == "push":停在节点间且无 worker 在跑 → 继续推进。
await graph.ainvoke(None, config)
logger.warning("[ai-writing-bg] drive hit max steps for thread=%s", thread_id)
return False, interventions
async def _save_transcript(self, thread_id: str, graph, config) -> None:
"""把后台跑出的对话时间线写进 ``transcript`` —— 与前端直接写作的存取完全一致。
完全从最终 checkpoint 自洽重建(``_build_transcript_from_checkpoint``):progressEvents
与 completedInterventions 都源自同一批 ``resumed`` 事件,永远 1:1 对齐——不再依赖前端
可能滞后的已存 interventions(那是「卡片错位」bug 的根因)。
"""
store = self._store()
if store is None:
return
try:
snapshot = await graph.aget_state(config)
except Exception: # noqa: BLE001
return
values = getattr(snapshot, "values", None) or {}
events, interventions = _build_transcript_from_checkpoint(values)
transcript = {"progressEvents": events, "completedInterventions": interventions}
try:
await store.update(
thread_id, user_id=AUTO, transcript=transcript, completed_interventions=interventions
)
except Exception: # noqa: BLE001 — transcript 是锦上添花,存失败不影响作业
logger.warning("[ai-writing-bg] save transcript for %s failed", thread_id, exc_info=True)
async def _mark_terminal(self, thread_id: str, graph, config) -> None:
try:
snapshot = await graph.aget_state(config)
status = (getattr(snapshot, "values", None) or {}).get("status")
except Exception: # noqa: BLE001
status = None
# 走到 done 由节点写过了;否则若图已停止(无 next)兜底标 done,停在中途则保留。
if status == "done":
return
try:
snapshot = await graph.aget_state(config)
if not getattr(snapshot, "next", None):
await self._set_status(thread_id, "done")
except Exception: # noqa: BLE001
logger.warning("[ai-writing-bg] mark terminal for %s failed", thread_id, exc_info=True)
@@ -8,6 +8,7 @@ Initialization is handled directly in ``app.py`` via :class:`AsyncExitStack`.
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncGenerator, Callable
from contextlib import AsyncExitStack, asynccontextmanager
@@ -172,6 +173,24 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
# 老 /start /stream /resume 路由仍走 request.app.state,互不影响。
_register_ai_writing_default_store(app.state.ai_writing_session_store)
# AI 写作「后台挂起」执行器:把写作图作为后台任务驱动到定稿(离页/刷新/切号不打断)。
# 用 app.state.checkpointer(与运行时 worker 同一份 saver)续上线程 checkpoint。
from app.gateway.ai_writing_job_executor import AIWritingJobExecutor
app.state.ai_writing_job_executor = AIWritingJobExecutor(app)
# 进程重启续跑:把仍标记为 background 的会话重新拉起驱动(best-effort,不阻塞启动)。
if app.state.ai_writing_session_store is not None:
from app.gateway.ai_writing_job_executor import STATUS_BACKGROUND
async def _reconcile_ai_writing_background() -> None:
try:
sessions = await app.state.ai_writing_session_store.list_by_status(STATUS_BACKGROUND)
app.state.ai_writing_job_executor.reconcile(sessions=sessions)
except Exception: # noqa: BLE001
logger.warning("[ai-writing-bg] startup reconcile failed", exc_info=True)
asyncio.create_task(_reconcile_ai_writing_background())
from deerflow.persistence.article_types import make_article_type_store
app.state.article_type_store = make_article_type_store(sf)
@@ -473,6 +492,11 @@ def get_ai_writing_session_store(request: Request):
return getattr(request.app.state, "ai_writing_session_store", None)
def get_ai_writing_job_executor(request: Request):
"""Return the AI writing background-suspend executor, or None when unavailable."""
return getattr(request.app.state, "ai_writing_job_executor", None)
def get_article_type_store(request: Request):
"""Return the article type store, or None when not configured."""
return getattr(request.app.state, "article_type_store", None)
@@ -233,6 +233,98 @@ async def save_transcript(session_id: str, body: SaveTranscriptRequest, request:
# 会话不存在或不属于当前用户:同样不抛 500,返回 success=False 即可
return {"success": False, "reason": "session_not_found"}
return {"success": True}
class BackgroundResponse(BaseModel):
# 是否新拉起了后台任务(False = 已在后台跑,幂等返回)
started: bool
# 会话当前状态(background = 挂起中)
status: str
@router.post("/sessions/{session_id}/background", response_model=BackgroundResponse)
async def suspend_to_background(session_id: str, request: Request):
"""把这篇写作转入「后台挂起」:后端自动驱动剩余流程到定稿。
前端在用户点「后台挂起」或离开页面时调用。幂等:同一会话已在后台跑则直接返回。
驱动逻辑见 ``app.gateway.ai_writing_job_executor``(素材/大纲自动确认、缺素材
通用知识续写、草稿送审、审核不过自动重写直到通过或用完次数再定稿)。
"""
from app.gateway.deps import (
get_ai_writing_job_executor,
get_ai_writing_session_store,
get_current_user,
)
executor = get_ai_writing_job_executor(request)
if executor is None:
raise HTTPException(status_code=503, detail="Background executor not available")
repo = get_ai_writing_session_store(request)
if repo is None:
raise HTTPException(status_code=503, detail="Session store not available")
user_id = await get_current_user(request)
# 鉴权 + 存在性:会话必须属于当前用户(get 已带 user_id 过滤)。
record = await repo.get(session_id, user_id=user_id)
if record is None:
raise HTTPException(status_code=404, detail="Session not found")
if record.get("status") == "done":
# 已定稿,无需后台驱动。
return BackgroundResponse(started=False, status="done")
started = executor.start(thread_id=session_id, user_id=user_id)
return BackgroundResponse(started=started, status="background")
class ProgressResponse(BaseModel):
# 业务表 status(background = 挂起中 / done / error / in_progress)
sessionStatus: str
# 规范化阶段 key(intent/research/material_confirm/outline/outline_confirm/draft/draft_confirm/review/done),
# 从 graph checkpoint 推导;None = 还没建 checkpoint。
stage: Optional[str] = None
# 阶段细化说明(素材不足求助 / 编辑打回)
note: Optional[str] = None
revisionCount: int = 0
maxRevisions: int = 3
reviewVerdict: Optional[str] = None
# 后台驱动任务是否在本进程在跑(重启后可能为 False 但 status 仍 background,由 reconcile 续起)
isRunning: bool = False
@router.get("/sessions/{session_id}/progress", response_model=ProgressResponse)
async def get_session_progress(session_id: str, request: Request):
"""读取一篇写作的实际进度(当前阶段),供「查看进度」流程图弹窗轮询。
阶段从 graph checkpoint 推导,反映后端真实推进位置;鉴权要求会话属于当前用户。
"""
from app.gateway.deps import (
get_ai_writing_job_executor,
get_ai_writing_session_store,
get_current_user,
)
repo = get_ai_writing_session_store(request)
if repo is None:
raise HTTPException(status_code=503, detail="Session store not available")
user_id = await get_current_user(request)
record = await repo.get(session_id, user_id=user_id)
if record is None:
raise HTTPException(status_code=404, detail="Session not found")
executor = get_ai_writing_job_executor(request)
progress = await executor.get_progress(session_id) if executor is not None else {}
return ProgressResponse(
sessionStatus=str(record.get("status") or "in_progress"),
stage=progress.get("stage") or None,
note=progress.get("note"),
revisionCount=int(progress.get("revisionCount") or 0),
maxRevisions=int(progress.get("maxRevisions") or 3),
reviewVerdict=progress.get("reviewVerdict"),
isRunning=bool(executor.is_running(session_id)) if executor is not None else False,
)
@router.get("/article-types", response_model=list[ArticleTypeResponse])
async def list_article_types(request: Request):
"""列出所有文章类型,按 sort_order 升序排列。"""
@@ -169,7 +169,19 @@ async def pause_outline_confirm(state: AIWritingState) -> dict:
async def pause_draft_confirm(state: AIWritingState, config: RunnableConfig) -> dict:
"""PAUSE-3:草稿确认"""
draft = state["current_draft"]
draft = state.get("current_draft")
# 防御:草稿缺失(写作节点异常未产出)时不进入确认暂停,转 error 优雅结束,
# 避免 draft["title"] 在 None 上崩溃。正常路径下 after_writer_draft 已拦截
# status:error,这里是兜底第二道防线(如 editor 异常等边角情形)。
if not draft:
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": "作家",
"title": "草稿缺失", "detail": "草稿尚未生成,无法进入确认环节,请重试。",
"message": "草稿生成失败",
}],
}
user_input: dict = interrupt({
"pause_point": "draft_confirm",
"message": "草稿已完成,请确认或给出修改意见。",
@@ -308,7 +320,15 @@ def after_outline_pause(state: AIWritingState) -> str:
def after_writer_draft(state: AIWritingState) -> str:
"""写作完成后:素材不足则去求助暂停点,否则去草稿确认。"""
"""写作完成后:素材不足则去求助暂停点,否则去草稿确认。
写作节点兜底成 ``status:error``(如模型创建失败)时直接结束本次 run —— 否则会
流到 pause_draft,而此时 ``current_draft`` 为 None,``pause_draft_confirm`` 取
``draft["title"]`` 会二次崩溃('NoneType' object is not subscriptable)。错误事件
已由写作节点 emit,run 优雅结束即可,用户重试不受影响。
"""
if state["status"] == "error":
return "end"
return "pause_section_help" if state["status"] == "awaiting_section_help" else "pause_draft"
@@ -377,7 +397,7 @@ def make_ai_writing_graph(config=None):
g.add_conditional_edges("pause_outline", after_outline_pause,
{"writer_outline": "writer_outline", "writer_draft": "writer_draft"})
g.add_conditional_edges("writer_draft", after_writer_draft,
{"pause_section_help": "pause_section_help", "pause_draft": "pause_draft"})
{"pause_section_help": "pause_section_help", "pause_draft": "pause_draft", "end": END})
g.add_conditional_edges("pause_section_help", after_section_help_pause,
{"researcher": "researcher", "writer_draft": "writer_draft"})
g.add_conditional_edges("pause_draft", after_draft_pause,
@@ -126,9 +126,11 @@ def resolve_agent_model_name(
传入,如 researcher 的 keyword_model/rank_model)> ``state.model_name``
(用户写作表单选的主模型)> configurable.model_name。
strict=True(大纲/写作/审核):全部为空抛 ValueError(与 get_writing_model
口径一致——不静默回退默认模型);strict=False(researcher 辅助任务):
返回 None,由 create_chat_model 回退 config.yaml 的 models[0]。
全部为空时**不再抛错**(旧实现 strict=True 抛 ValueError 会把「未选模型」直接打成
草稿失败,且后续 pause_draft 还会因 current_draft 为空二次崩溃):统一返回 None,
由 create_chat_model 回退 config.yaml 的 ``models[0]``。``strict`` 仅用于区分日志
级别——大纲/写作/审核阶段缺模型属异常配置,记 warning,调用方据 None 向用户提示
「已用默认模型」。
"""
candidates = (
(getattr(agent_cfg, "model", None) or "") if agent_cfg else "",
@@ -140,7 +142,7 @@ def resolve_agent_model_name(
if isinstance(name, str) and name.strip():
return name.strip()
if strict:
raise ValueError("未指定写作模型(model_name 为空):请在写作表单选择模型后重试")
logger.warning("写作模型未指定(state/configurable/agent.model 均为空),回退默认模型")
return None
@@ -0,0 +1,177 @@
"""像「智能体调用技能」那样用一个技能检索素材。
背景:素材收集专家(ai-writing-researcher)是内置智能体,但此前它「用技能检索」只是
拿技能的 ``description`` 拼一个 query 丢给通用 web/intranet 检索 provider——技能自己
SKILL.md 里写的检索说明 / 策略并没有真正跑起来,和真实智能体「读 SKILL.md → 按说明
调工具检索」的逻辑不一致。
本模块把两者对齐:把技能 **SKILL.md 全文**连同素材收集专家 SOUL 注入 system prompt,
给模型绑定一个 ``search`` 工具,跑一个**有界的工具调用循环**——模型按技能说明自行决定
检索词与轮次(就像智能体读了技能后驱动检索),我们直接**收割工具返回的 SearchResult**
作为素材(无需再从自由文本里抽取,检索 provider 的结果本就是结构化素材)。
仅在 ``config.yaml → ai_writing.researcher_skill_agent`` 打开时启用(默认 false,走旧的
description→检索 直连路径)。弱模型不会工具调用 / 检索失败时,调用方回退旧路径,保证
素材不为空。
"""
from __future__ import annotations
import asyncio
import logging
from langchain_core.runnables import RunnableConfig
from ._agent_runner import (
RESEARCHER_AGENT_ID,
WritingAgentUnavailable,
load_writing_agent,
resolve_agent_model_name,
)
from ._utils import get_ai_writing_config
logger = logging.getLogger(__name__)
# 工具调用循环上限(每轮 = 一次模型调用,可含多个 search 调用)。
_MAX_TURNS = 4
def use_skill_search_agent() -> bool:
"""读 ``config.yaml → ai_writing.researcher_skill_agent`` 开关(默认 false,mtime 热加载)。"""
cfg = get_ai_writing_config()
return bool(cfg.get("researcher_skill_agent", False)) if isinstance(cfg, dict) else False
def _read_skill_body(skill) -> str:
"""读技能 SKILL.md 全文;失败回退 name+description。"""
try:
return skill.skill_file.read_text(encoding="utf-8")
except Exception: # noqa: BLE001
return f"# 技能:{getattr(skill, 'name', '')}\n{getattr(skill, 'description', '') or ''}"
async def run_skill_search_agent(
*,
skill,
intent: str,
keywords: list[str],
user_query: str,
config: RunnableConfig,
state: dict,
model_name_fallback: str | None,
max_results: int,
timeout_s: int,
emit_query=None,
) -> tuple[list, str]:
"""像智能体一样按技能 SKILL.md 检索素材。
Returns ``(results, error_detail)``:results 为收割到的 SearchResult 列表(调用方再
去全局去重/合并);error_detail 非空且 results 为空 = 该技能整体失败(触发兜底)。
模型不支持工具调用 / 始终不发起检索时返回 ``([], "")``,调用方据此回退旧检索路径。
"""
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langchain_core.tools import tool
from ..search import get_search_provider
skill_name = getattr(skill, "name", "") or "技能"
skill_body = _read_skill_body(skill)
try:
agent_cfg, soul = load_writing_agent(RESEARCHER_AGENT_ID)
except WritingAgentUnavailable:
agent_cfg, soul = None, ""
model_name = resolve_agent_model_name(
agent_cfg, state, config, explicit_fallback=model_name_fallback, strict=False
)
from deerflow.models import create_chat_model
try:
model = create_chat_model(name=model_name)
except Exception as e: # noqa: BLE001
return [], f"模型创建失败: {e}"
@tool
def search(query: str) -> str: # noqa: ARG001 — 仅用于给模型生成 schema,真正检索在循环里执行
"""根据 query 检索素材。一次只传一个检索词;可多次调用以覆盖不同角度。"""
return ""
try:
model_tools = model.bind_tools([search])
except Exception as e: # noqa: BLE001 — 模型不支持工具调用
logger.info("skill agent: model lacks tool-calling, fallback. err=%s", e)
return [], ""
provider = get_search_provider(get_ai_writing_config())
system = (
(soul + "\n\n" if soul else "")
+ "你现在挂载了下面这个检索技能,请**严格按它的说明**来检索素材。\n"
"通过调用 search 工具执行检索(一次一个检索词,可多次调用覆盖不同角度/来源);\n"
"收集到足够素材后直接回复「检索完成」即可,不要编造任何内容。\n\n"
f"=== 技能:{skill_name} ===\n{skill_body}"
)
task_lines = [f"写作意图:{intent or '(无)'}"]
if user_query:
task_lines.append(f"用户补充:{user_query}")
if keywords:
task_lines.append(f"可参考的检索方向:{', '.join(keywords[:8])}")
task_lines.append(f"请按上面技能的说明检索相关素材(目标约 {max_results} 条)。")
messages: list = [SystemMessage(content=system), HumanMessage(content="\n".join(task_lines))]
results: list = []
seen_urls: set[str] = set()
last_error = ""
for _turn in range(_MAX_TURNS):
try:
ai = await model_tools.ainvoke(messages)
except Exception as e: # noqa: BLE001
last_error = f"模型调用失败: {e}"
logger.warning("skill agent[%s] model invoke failed: %s", skill_name, e)
break
messages.append(ai)
tool_calls = getattr(ai, "tool_calls", None) or []
if not tool_calls:
break # 模型不再检索(回复「检索完成」或纯文本)
for tc in tool_calls:
tc_id = tc.get("id") or ""
query = ((tc.get("args") or {}).get("query") or "").strip()
if not query:
messages.append(ToolMessage(content="query 为空,已跳过", tool_call_id=tc_id))
continue
if emit_query:
emit_query(query)
try:
found = await asyncio.wait_for(
provider.search(query, max_results=max_results), timeout=timeout_s
)
except asyncio.TimeoutError:
last_error = f"检索超时(>{timeout_s}s)"
messages.append(ToolMessage(content=last_error, tool_call_id=tc_id))
continue
except Exception as e: # noqa: BLE001
last_error = str(e)
messages.append(ToolMessage(content=f"检索失败: {e}", tool_call_id=tc_id))
continue
added = 0
for r in found or []:
url = getattr(r, "url", None)
if url and url in seen_urls:
continue
if url:
seen_urls.add(url)
results.append(r)
added += 1
# 给模型一个简短回执,让它据此决定是否继续换角度检索。
sample = ";".join((getattr(r, "title", "") or "")[:40] for r in (found or [])[:5]) or "无结果"
messages.append(
ToolMessage(content=f"检索到 {added} 条新素材。标题样例:{sample}", tool_call_id=tc_id)
)
if len(results) >= max_results:
break
if not results and last_error:
return [], last_error
return results, ""
@@ -74,24 +74,30 @@ def get_model(config: RunnableConfig, model_name: str | None = None):
return create_chat_model(name=name)
def get_writing_model(config: RunnableConfig, state: dict):
"""大纲 / 写作 / 审核专用:严格使用用户在表单选定的模型,**不回退默认**。
def resolve_writing_model_name(config: RunnableConfig, state: dict) -> tuple[str | None, bool]:
"""解析大纲 / 写作 / 审核阶段使用的模型名,返回 ``(name, used_default)``。
用户模型在 intent_parser(首次 invocation,configurable.model_name 可用)阶段
捕获进 ``state['model_name']``,经 checkpoint 持久化。这三个节点都在
「确认素材 / 大纲」resume 之后才运行,而 resume 是新 invocation、其 config 不再
带 model_name —— 所以必须从 state 读,不能从 configurable 读(否则回退到 config
列表第一个模型,常是推理模型,既影响质量也会让 reasoning_content 走不出流式)。
优先级:``state['model_name']``(用户在表单选定,由 intent_parser 在首次
invocation 捕获并经 checkpoint 持久化)> ``configurable.model_name``(同一
invocation 内直跑、或前端在 resume 时一并回传的兜底)。
缺失视为配置错误直接抛 ValueError —— 由各节点的 try/except 兜底成 status:error,
而不是静默回退默认模型。
interrupt/resume 是新 invocation,其 config 默认不带原 model_name,所以正常情况
要从 state 读。两者皆空(历史会话 / 首轮捕获就为空 / 前端没传)时不再视为致命
错误,而是返回 ``(None, True)``:调用方据此回退 config.yaml 的 ``models[0]`` 并向
用户提示「未选模型,已用默认模型」,保证写作流程不被一个空模型名硬卡住。
"""
name = ((state or {}).get("model_name") or "").strip()
if not name:
# 兜底再看一眼 configurable(首次同 invocation 内直跑到这里的极端情况)
name = (config.get("configurable", {}).get("model_name", "") or "").strip()
if not name:
raise ValueError("未指定写作模型(model_name 为空):请在写作表单选择模型后重试")
if name:
return name, False
logger.warning("写作模型未指定(state/configurable 均为空),回退默认模型")
return None, True
def get_writing_model(config: RunnableConfig, state: dict):
"""大纲 / 写作 / 审核:解析用户选定模型,缺失时回退默认模型(见 resolve_writing_model_name)。"""
name, _ = resolve_writing_model_name(config, state)
from deerflow.models import create_chat_model
return create_chat_model(name=name)
@@ -40,6 +40,7 @@ from ._agent_runner import (
run_writing_agent,
use_builtin_agent,
)
from ._skill_agent import run_skill_search_agent, use_skill_search_agent
from ._utils import get_ai_writing_config, llm_json, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -83,10 +84,21 @@ _MAX_MAX_MATERIALS = 100
_DEFAULT_KEYWORD_COUNT = 6
_MAX_KEYWORD_COUNT = 10
# 单个技能的 web 检索超时(秒)。技能检索是顺序循环,
# DuckDuckGo 等 provider 偶发限速/挂起会把整段"技能检索过程"拖到几十秒看起来卡死;
# 超时即放弃该技能、emit 0 条结果继续下一个,保证时间线持续推进。
_SKILL_SEARCH_TIMEOUT_SECONDS = 25
# 单个技能的检索超时(秒)。技能检索是顺序循环,provider 偶发限速/挂起会把整段
# "技能检索过程"拖住;超时即放弃该技能、emit 0 条结果继续下一个,保证时间线持续推进。
# 默认 120s(≥2 分钟):内网检索 provider 本身就允许 timeout=120,之前 25s 会过早放弃
# 慢但有效的检索。可用环境变量 AI_WRITING_SKILL_SEARCH_TIMEOUT_SECONDS 覆盖。
def _env_int(name: str, default: int) -> int:
raw = (os.getenv(name) or "").strip()
if raw:
try:
return max(1, int(raw))
except ValueError:
pass
return default
_SKILL_SEARCH_TIMEOUT_SECONDS = _env_int("AI_WRITING_SKILL_SEARCH_TIMEOUT_SECONDS", 120)
# 内置「知识库检索」技能名:素材收集专家 agent 的默认技能。researcher 对它
# 特殊处理——不用 description 拼 query,而是对 Step 1 生成的全部检索词做
@@ -607,8 +619,34 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
error_detail = ";".join(dict.fromkeys(errs))[:200]
return added, error_detail
else:
# 定向技能检索:user_query 模式优先用用户原话;非知识库技能再
# 附上 description 让 query 贴合该技能的领域。
# ── 智能体式技能调用(config ai_writing.researcher_skill_agent 打开时)──
# 把技能 SKILL.md 注入 + 绑定 search 工具,让模型按技能说明驱动检索,
# 收割工具返回的 SearchResult 作为素材(与「智能体调用技能」逻辑一致)。
if use_skill_search_agent():
def _emit_q(q: str, _name=sk.name) -> None:
_emit_skill_step({
"type": "invoke_skill",
"message": f"素材收集专家用技能「{_name}」检索:{q[:60]}",
"skill_name": _name,
})
agent_results, agent_err = await run_skill_search_agent(
skill=sk,
intent=intent,
keywords=keywords,
user_query=user_query if is_user_query_search else "",
config=config,
state=state,
model_name_fallback=keyword_model,
max_results=per_skill,
timeout_s=_SKILL_SEARCH_TIMEOUT_SECONDS,
emit_query=_emit_q,
)
if agent_results:
return _collect(agent_results), ""
# 弱模型不会工具调用 / 智能体检索失败 → 回退直连检索,保证素材不空。
# 定向技能检索(默认路径 / 智能体模式回退):user_query 模式优先用用户原话;
# 非知识库技能再附上 description 让 query 贴合该技能的领域。
if is_user_query_search:
skill_query = user_query if is_knowledge else f"{user_query} {sk.description or ''}".strip()
else:
@@ -21,7 +21,7 @@ from ._agent_runner import (
stream_writing_agent,
use_builtin_agent,
)
from ._utils import get_writing_model, _content_to_text, count_words, reasoning_delta, ThinkTagParser, safe_get_stream_writer
from ._utils import resolve_writing_model_name, _content_to_text, count_words, reasoning_delta, ThinkTagParser, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -209,11 +209,18 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
use_agent = use_builtin_agent("writer")
agent_label = "作家"
model = None
# 未选模型回退默认时给用户一条非致命提示(最终随草稿一并返回)
model_default_notice: dict | None = None
if use_agent:
try:
agent_cfg, _soul = load_writing_agent(WRITER_AGENT_ID)
# 模型守卫提前到循环外:模型名缺失/配错与旧路径同样转成 status:error
resolve_agent_model_name(agent_cfg, state, config)
# 模型解析提前到循环外:返回 None 表示 state/configurable/agent.model 全空,
# 后续 create_chat_model 会回退默认模型,这里据此准备一条用户提示。
if resolve_agent_model_name(agent_cfg, state, config) is None:
model_default_notice = {
"type": "notice", "agent_name": agent_display_name(WRITER_AGENT_ID, "作家"),
"title": "模型提示", "detail": "未检测到所选写作模型,已使用默认模型继续写作。",
}
agent_label = agent_display_name(WRITER_AGENT_ID, "作家")
except WritingAgentUnavailable as e:
logger.warning("writer builtin agent unavailable, falling back to legacy prompts: %s", e)
@@ -231,7 +238,14 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError,
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。
try:
model = get_writing_model(config, state)
model_name, used_default = resolve_writing_model_name(config, state)
if used_default:
model_default_notice = {
"type": "notice", "agent_name": "作家",
"title": "模型提示", "detail": "未检测到所选写作模型,已使用默认模型继续写作。",
}
from deerflow.models import create_chat_model
model = create_chat_model(name=model_name)
except Exception as e:
logger.error("draft model creation failed: %s", e, exc_info=True)
return {
@@ -449,6 +463,8 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
# 分节写作出现模型异常时,以非致命 notice 提示用户,草稿仍照常产出
notices: list[dict] = []
if model_default_notice:
notices.append(model_default_notice)
if section_errors:
notices.append({
"type": "notice",
@@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
class DuckDuckGoSearchProvider(BaseSearchProvider):
"""使用 DuckDuckGo 进行互联网搜索,无需 API Key。"""
def __init__(self, region: str = "cn-zh", max_timeout: int = 30):
def __init__(self, region: str = "cn-zh", max_timeout: int = 120):
self.region = region
self.max_timeout = max_timeout
# 收集本次运行的检索异常,供 researcher 节点取出并提示用户
@@ -207,6 +207,21 @@ class AIWritingSessionRepository:
await session.commit()
return True
async def list_by_status(self, status: str, *, limit: int = 500) -> list[dict]:
"""列出指定 status 的会话(仅 id + user_id,用于后台驱动重启续跑 reconcile)。
不按 user_id 过滤——进程级 reconcile 要把所有用户「挂起中」的会话都拉起。
"""
stmt = (
select(AIWritingSessionRow.id, AIWritingSessionRow.user_id)
.where(AIWritingSessionRow.status == status)
.order_by(AIWritingSessionRow.updated_at.asc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [{"id": row[0], "user_id": row[1]} for row in result.all()]
async def list_older_than(
self,
cutoff: datetime,