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:
1 parent
85cca36ddb
commit
03b6fb724c
40 files changed
+2149
-101
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +495,8 @@ function Td({
|
||||
export default function LlmMetricsPage() {
|
||||
const [tab, setTab] = useState<"llm" | "tool">("llm");
|
||||
return (
|
||||
<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>
|
||||
@@ -503,5 +506,7 @@ export default function LlmMetricsPage() {
|
||||
</Tabs>
|
||||
{tab === "llm" ? <LlmMetricsPanel /> : <ToolMetricsPanel />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 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="text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
|
||||
)}
|
||||
<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,11 +467,20 @@ export function layoutFlowGraph(data: FlowGraphData): PositionedFlowNode[] {
|
||||
|
||||
const pos = new Map<string, { x: number; y: number }>();
|
||||
for (const [layer, nodes] of byLayer) {
|
||||
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;
|
||||
@@ -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 升序排列。"""
|
||||
|
||||
+23
-3
@@ -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,
|
||||
|
||||
+6
-4
@@ -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
|
||||
|
||||
|
||||
|
||||
+177
@@ -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, ""
|
||||
+18
-12
@@ -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)
|
||||
|
||||
|
||||
+44
-6
@@ -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:
|
||||
|
||||
+20
-4
@@ -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",
|
||||
|
||||
+1
-1
@@ -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 节点取出并提示用户
|
||||
|
||||
+15
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user