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