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

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

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

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

No files matched your search

@@ -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;