feat(ai写作): 左栏智能体 Tab 内嵌问答并优化模块侧栏

AI 写作页默认展示智能体 Tab,在页内复用 ai-writing 模块配置与 AgentChatPage;确认类型后侧栏扁平展示类型名并支持整块收起动画。同步历史回看、流式续跑、改写菜单与 researcher 检索相关改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-06-27 15:53:51 +08:00
1 parent c11307aa67
commit 5de74e90dc
21 files changed
+1379 -386

No files matched your search

@@ -107,33 +107,11 @@ function groupAgentsByTag(agents: Agent[], allowedTagIds: string[]): AgentGroup[
return groups; return groups;
} }
/** export function useAgentModuleSidebarData(moduleKey: string) {
* 「按类型聚合智能体 + 聊天记录」左栏(需求 0616 #3 zzJC / #4 zzXD)。 const { agents, isLoading: agentsLoading } = useAgents();
*
* - 上半:可见智能体按标签(类型)分组、可折叠;点击进入该智能体对话(保持当前路由
* 命名空间,全量模式下为 /page/strategy)。
* - 下半:聊天记录 —— 仅与本模块智能体(即左栏列出的智能体)的会话,按 metadata.agent_id 过滤。
* - 宽度与通用问答左栏一致(``--sidebar-width``)。
*/
export function AgentModuleSidebar({
moduleKey,
activeAgentId,
onOpenSettings,
}: {
moduleKey: string;
activeAgentId?: string;
/** 传入则在左栏顶部显示设置齿轮(admin)。 */
onOpenSettings?: () => void;
}) {
const routeBase = useRouteBase();
const pathname = usePathname();
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
const { theme } = useTheme();
const isDark = theme === "dark";
const { agents } = useAgents();
const { data: threads = [] } = useThreads(); const { data: threads = [] } = useThreads();
const { data: moduleConfig } = useAgentModuleConfig(moduleKey); const { data: moduleConfig, isLoading: configLoading } =
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set()); useAgentModuleConfig(moduleKey);
const allowedTagIds = useMemo( const allowedTagIds = useMemo(
() => moduleConfig?.tag_ids ?? [], () => moduleConfig?.tag_ids ?? [],
@@ -161,6 +139,56 @@ export function AgentModuleSidebar({
[threads, moduleAgentIds], [threads, moduleAgentIds],
); );
return {
groups,
moduleAgentIds,
moduleThreads,
allowedTagIds,
// admin 是否已确认该模块展示的智能体类型(配置了标签)。
hasConfiguredType: allowedTagIds.length > 0,
isLoading: agentsLoading || configLoading,
};
}
/**
* 「按类型聚合智能体 + 聊天记录」左栏(需求 0616 #3 zzJC / #4 zzXD)。
*
* - 上半:可见智能体按标签(类型)分组、可折叠;点击进入该智能体对话(保持当前路由
* 命名空间,全量模式下为 /page/strategy)。
* - 下半:聊天记录 —— 仅与本模块智能体(即左栏列出的智能体)的会话,按 metadata.agent_id 过滤。
* - 宽度与通用问答左栏一致(``--sidebar-width``)。
*/
export function AgentModuleSidebar({
moduleKey,
activeAgentId,
activeThreadId,
routeBase: routeBaseProp,
headerTitle = "智能体",
className,
onOpenSettings,
}: {
moduleKey: string;
activeAgentId?: string;
activeThreadId?: string;
routeBase?: string;
headerTitle?: string;
className?: string;
/** 传入则在左栏顶部显示设置齿轮(admin)。 */
onOpenSettings?: () => void;
}) {
const contextRouteBase = useRouteBase();
const routeBase = routeBaseProp ?? contextRouteBase;
const pathname = usePathname();
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
const { theme } = useTheme();
const isDark = theme === "dark";
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const { groups, moduleThreads, hasConfiguredType } =
useAgentModuleSidebarData(moduleKey);
// 已确认智能体类型时,允许把上半「智能体类型」整块收起,把空间让给下半聊天记录。
const [agentsSectionCollapsed, setAgentsSectionCollapsed] = useState(false);
const agentsCollapsed = hasConfiguredType && agentsSectionCollapsed;
const toggleGroup = (key: string) => const toggleGroup = (key: string) =>
setCollapsed((prev) => { setCollapsed((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -169,14 +197,68 @@ export function AgentModuleSidebar({
return next; return next;
}); });
// 已确认类型时把所有分组的智能体拍平(单选类型一般只有一个分组),
// 标题行直接显示该类型名 + 数量,去掉额外的分组行。
const flatAgents = useMemo(() => groups.flatMap((g) => g.agents), [groups]);
const configuredTypeLabel =
groups.length === 1 ? (groups[0]?.label ?? "智能体类型") : "智能体类型";
const renderAgentCard = (agent: Agent, keyPrefix: string) => {
const href = `${routeBase}/agents/${agent.id}/chats/new`;
const isActive = agent.id === activeAgentId;
const { from, to } = agentColor(agent.id, isDark);
return (
<Link
key={`${keyPrefix}:${agent.id}`}
href={href}
className={cn(
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 transition-colors",
isActive
? "border-primary/40 bg-primary/5"
: "border-transparent hover:border-border hover:bg-accent/50",
)}
>
<span
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm"
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
>
<Bot className="size-[18px]" />
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[13px] font-semibold",
isActive ? "text-primary" : "text-foreground",
)}
>
{desensitize(agent.name)}
</span>
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground">
{agent.description ? desensitize(agent.description) : "智能体助手"}
</span>
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
</Link>
);
};
return ( return (
// 原有布局不变,仅做配色适配:深色下左栏改为半透明深蓝玻璃面板(对齐 .sidebar-contents: // 原有布局不变,仅做配色适配:深色下左栏改为半透明深蓝玻璃面板(对齐 .sidebar-contents:
// bg rgba(3,38,80,.8) + 蓝色发光边框),叠 backdrop-blur 让外壳的深蓝底图朦胧透出; // bg rgba(3,38,80,.8) + 蓝色发光边框),叠 backdrop-blur 让外壳的深蓝底图朦胧透出;
// 浅色保持白底。其余颜色已走语义 token 自适配。 // 浅色保持白底。其余颜色已走语义 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"> <aside
className={cn(
"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",
className,
)}
>
{onOpenSettings && ( {onOpenSettings && (
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2"> <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> <span className="text-xs font-semibold text-foreground/80">
{headerTitle}
</span>
<button <button
type="button" type="button"
onClick={onOpenSettings} onClick={onOpenSettings}
@@ -189,89 +271,116 @@ export function AgentModuleSidebar({
</div> </div>
)} )}
{/* 上半:按类型分组的智能体(彩色卡片,与全量版看齐) */} {/* 上半:智能体列表。
<div className="flex min-h-0 flex-[3] flex-col overflow-y-auto px-2 py-3"> - 未确认类型:按类型分组、可逐组折叠(与全量版一致)。
<h2 className="mb-1 px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 select-none"> - 已确认类型:标题行直接显示该类型名 + 数量(去掉单独的分组行),
智能体类型 扁平列出该类型下的智能体,并可整块收起(grid 行高过渡动画),
</h2> 收起后只留标题、把高度让给下半聊天记录。 */}
{groups.length === 0 ? ( <div
<p className="px-3 py-1.5 text-xs text-muted-foreground/70">暂无智能体</p> className={cn(
"flex flex-col px-2 py-3",
agentsCollapsed ? "shrink-0" : "min-h-0 flex-[3]",
)}
>
{hasConfiguredType ? (
<>
<button
type="button"
onClick={() => setAgentsSectionCollapsed((prev) => !prev)}
title={agentsCollapsed ? "展开智能体" : "收起智能体"}
className="flex shrink-0 items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
>
<span className="flex items-center gap-1.5 truncate">
<span className="truncate">{configuredTypeLabel}</span>
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
{flatAgents.length}
</span>
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 transition-transform duration-300",
agentsCollapsed && "-rotate-90",
)}
/>
</button>
<div
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-in-out",
agentsCollapsed
? "grid-rows-[0fr]"
: "mt-1 min-h-0 flex-1 grid-rows-[1fr]",
)}
>
<div
className={cn(
"min-h-0",
agentsCollapsed
? "overflow-hidden"
: "overflow-y-auto overflow-x-hidden",
)}
>
{flatAgents.length === 0 ? (
<p className="px-3 py-1.5 text-xs text-muted-foreground/70">
暂无智能体
</p>
) : (
<div className="flex flex-col gap-1">
{flatAgents.map((agent) =>
renderAgentCard(agent, "configured"),
)}
</div>
)}
</div>
</div>
</>
) : ( ) : (
<div className="flex flex-col gap-1.5"> <>
{groups.map((group) => { <h2 className="mb-1 shrink-0 px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 select-none">
const isCollapsed = collapsed.has(group.key); 智能体类型
return ( </h2>
<div key={group.key}> <div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
<button {groups.length === 0 ? (
type="button" <p className="px-3 py-1.5 text-xs text-muted-foreground/70">
onClick={() => toggleGroup(group.key)} 暂无智能体
className="flex w-full items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none" </p>
> ) : (
<span className="flex items-center gap-1.5 truncate"> <div className="flex flex-col gap-1.5">
<span className="truncate">{group.label}</span> {groups.map((group) => {
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums"> const isCollapsed = collapsed.has(group.key);
{group.agents.length} return (
</span> <div key={group.key}>
</span> <button
<ChevronDown type="button"
className={cn( onClick={() => toggleGroup(group.key)}
"size-3.5 shrink-0 transition-transform duration-200", className="flex w-full items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
isCollapsed && "-rotate-90", >
)} <span className="flex items-center gap-1.5 truncate">
/> <span className="truncate">{group.label}</span>
</button> <span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
{!isCollapsed && ( {group.agents.length}
<div className="mt-1 flex flex-col gap-1"> </span>
{group.agents.map((agent) => { </span>
const href = `${routeBase}/agents/${agent.id}/chats/new`; <ChevronDown
const isActive = agent.id === activeAgentId;
const { from, to } = agentColor(agent.id, isDark);
return (
<Link
key={`${group.key}:${agent.id}`}
href={href}
className={cn( className={cn(
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 transition-colors", "size-3.5 shrink-0 transition-transform duration-200",
isActive isCollapsed && "-rotate-90",
? "border-primary/40 bg-primary/5"
: "border-transparent hover:border-border hover:bg-accent/50",
)} )}
> />
<span </button>
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm" {!isCollapsed && (
style={{ <div className="mt-1 flex flex-col gap-1">
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`, {group.agents.map((agent) =>
}} renderAgentCard(agent, group.key),
>
<Bot className="size-[18px]" />
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[13px] font-semibold",
isActive ? "text-primary" : "text-foreground",
)}
>
{desensitize(agent.name)}
</span>
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground">
{agent.description
? desensitize(agent.description)
: "智能体助手"}
</span>
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" />
)} )}
</Link> </div>
); )}
})} </div>
</div> );
)} })}
</div> </div>
); )}
})} </div>
</div> </>
)} )}
</div> </div>
@@ -286,7 +395,8 @@ export function AgentModuleSidebar({
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
{moduleThreads.map((thread) => { {moduleThreads.map((thread) => {
const href = pathOfThread(thread, undefined, routeBase); const href = pathOfThread(thread, undefined, routeBase);
const isActive = href === pathname; const isActive =
thread.thread_id === activeThreadId || href === pathname;
return ( return (
<Link <Link
key={thread.thread_id} key={thread.thread_id}
+3 -1
View File
@@ -1,7 +1,7 @@
import { apiFetch } from "@/core/api/fetch-client"; import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
export type RewriteAction = "polish" | "expand" | "shorten" | "tone"; export type RewriteAction = "polish" | "expand" | "shorten" | "tone" | "imitate";
export type RewriteTone = export type RewriteTone =
| "professional" | "professional"
| "casual" | "casual"
@@ -17,6 +17,8 @@ export interface RewriteRequest {
modelName?: string; modelName?: string;
surroundingContext?: { before?: string; after?: string }; surroundingContext?: { before?: string; after?: string };
customInstruction?: string; customInstruction?: string;
/** 仿写参考范文(action="imitate" 时使用):AI 模仿其文风/句式/语气改写所选文字。 */
referenceText?: string;
followUpInstruction?: string; followUpInstruction?: string;
previousResult?: string; previousResult?: string;
} }
@@ -1,88 +1,370 @@
import { useMemo } from 'react'; 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 type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
import { import {
normalizeProgressDict, normalizeProgressDict,
normalizeMaterial, normalizeMaterial,
normalizeOutline, normalizeOutline,
normalizeReviewResult, normalizeReviewResult,
} from '../../hooks/useAIWritingStream'; } from '../../hooks/useAIWritingStream';
import type { CompletedIntervention } from '../../types/ai-writing'; import type { CompletedIntervention, ProgressEvent, ReviewResult } from '../../types/ai-writing';
import { countArticleWords } from '../../utils/wordCount';
import { AIWritingTimeline } from './AIWritingTimeline'; import { AIWritingTimeline } from './AIWritingTimeline';
/** const PAUSE_POINTS = new Set<CompletedIntervention['pausePoint']>([
* 归一化一条 completedIntervention(后台挂起重建的干预记录里素材/大纲/审核数据是 'material_confirm',
* snake_case,前端干预卡期望 camelCase)。对前端实时存的 camelCase 记录幂等。 'outline_confirm',
*/ 'draft_confirm',
function normalizeCompletedIntervention(rec: Record<string, unknown>): CompletedIntervention { 'review_confirm',
const out: Record<string, unknown> = { ...rec }; 'section_help',
out.pausePoint = rec.pausePoint ?? rec.pause_point; ]);
const PAUSE_MESSAGES: Record<CompletedIntervention['pausePoint'], string> = {
material_confirm: '素材收集专家已完成素材搜集,请确认素材后继续。',
outline_confirm: '作家已完成大纲规划,请确认或编辑大纲后继续。',
draft_confirm: '草稿已完成,请确认或给出修改意见。',
review_confirm: '编辑认为文章需要修改,请决定下一步操作。',
section_help: '部分章节缺少足够素材支撑写作,请决定如何处理。',
};
interface TimelineSnapshot {
materials?: CompletedIntervention['materials'];
outline?: CompletedIntervention['outline'];
draftTitle?: string;
draftWordCount?: number;
reviewResult?: ReviewResult | null;
}
function asPausePoint(value: unknown): CompletedIntervention['pausePoint'] | null {
const pausePoint = String(value ?? '');
return PAUSE_POINTS.has(pausePoint as CompletedIntervention['pausePoint'])
? (pausePoint as CompletedIntervention['pausePoint'])
: null;
}
function normalizeCompletedIntervention(rec: Record<string, unknown>): CompletedIntervention | null {
const pausePoint = asPausePoint(rec.pausePoint ?? rec.pause_point);
if (!pausePoint) return null;
const out: CompletedIntervention = {
pausePoint,
action: String(rec.action ?? defaultActionForPause(pausePoint)),
};
if (Array.isArray(rec.materials)) { if (Array.isArray(rec.materials)) {
out.materials = (rec.materials as Record<string, unknown>[]).map(normalizeMaterial); 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; const outline = normalizeOutline(rec.outline);
if (rr) out.reviewResult = normalizeReviewResult(rr); if (outline) out.outline = outline;
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; const reviewResult = normalizeReviewResult(rec.reviewResult ?? rec.review_result);
if (out.userInput == null && rec.user_input != null) out.userInput = rec.user_input; if (reviewResult) out.reviewResult = reviewResult;
return out as unknown as CompletedIntervention;
const draftTitle = rec.draftTitle ?? rec.draft_title;
if (typeof draftTitle === 'string' && draftTitle.trim()) out.draftTitle = draftTitle;
const draftWordCount = Number(rec.draftWordCount ?? rec.draft_word_count);
if (Number.isFinite(draftWordCount) && draftWordCount > 0) {
out.draftWordCount = draftWordCount;
}
const userInput =
rec.userInput ??
rec.user_input ??
rec.userQuery ??
rec.user_query ??
rec.outlineFeedback ??
rec.outline_feedback ??
rec.userRevisionNotes ??
rec.user_revision_notes;
if (typeof userInput === 'string' && userInput.trim()) out.userInput = userInput;
return out;
} }
const PAUSE_LABELS: Record<string, string> = { function normalizeInterventionList(records: unknown): CompletedIntervention[] {
material_confirm: '素材确认', if (!Array.isArray(records)) return [];
outline_confirm: '大纲确认', return records
draft_confirm: '草稿确认', .map((rec) => (
review_confirm: '编辑意见处理', rec && typeof rec === 'object'
}; ? normalizeCompletedIntervention(rec as Record<string, unknown>)
: null
))
.filter((rec): rec is CompletedIntervention => rec != null);
}
const ACTION_LABELS: Record<string, string> = { function defaultActionForPause(pausePoint: CompletedIntervention['pausePoint']): string {
approve: '已批准', switch (pausePoint) {
re_search: '重新搜索', case 'draft_confirm':
re_outline: '重新规划', return 'to_editor';
finalize: '直接定稿', case 'review_confirm':
to_editor: '提交审核', return 'accept_review';
user_revise: '手动修改', case 'section_help':
accept_review: '接受意见', return 'section_help';
force_finalize: '强制定稿', default:
}; return 'confirm';
}
}
const PAUSE_ICONS: Record<string, React.ElementType> = { function classifyResumeEvent(message: string): Pick<CompletedIntervention, 'pausePoint' | 'action'> | null {
material_confirm: Search, const m = message || '';
outline_confirm: PenLine, if (m.includes('确认素材')) return { pausePoint: 'material_confirm', action: 'confirm' };
draft_confirm: FileText, if (m.includes('匹配技能检索') || m.includes('重新检索') || m.includes('重新搜索')) {
review_confirm: ClipboardEdit, return { pausePoint: 'material_confirm', action: 're_search' };
}; }
if (m.includes('确认大纲')) return { pausePoint: 'outline_confirm', action: 'confirm' };
if (m.includes('重新规划大纲')) return { pausePoint: 'outline_confirm', action: 're_outline' };
if (m.includes('提交编辑审核')) return { pausePoint: 'draft_confirm', action: 'to_editor' };
if (m.includes('强制定稿')) return { pausePoint: 'review_confirm', action: 'force_finalize' };
if (m.includes('确认定稿')) return { pausePoint: 'draft_confirm', action: 'finalize' };
if (m.includes('按用户意见重写')) return { pausePoint: 'draft_confirm', action: 'user_revise' };
if (m.includes('按编辑意见重写')) return { pausePoint: 'review_confirm', action: 'accept_review' };
if (m.includes('补充检索素材')) return { pausePoint: 'section_help', action: 'supplement' };
if (m.includes('继续写作')) return { pausePoint: 'section_help', action: 'loose' };
return null;
}
function InterventionTimelineItem({ record, isLast }: { record: CompletedInterventionRecord; isLast: boolean }) { function actionFromNextResume(
const Icon = PAUSE_ICONS[record.pausePoint] ?? CheckCircle2; pausePoint: CompletedIntervention['pausePoint'],
const ts = record.timestamp events: ProgressEvent[],
? new Date(record.timestamp).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) startIndex: number,
: ''; ): string {
for (let i = startIndex; i < events.length; i += 1) {
const event = events[i];
if (event.type === 'await_user') break;
if (event.type !== 'resumed') continue;
const classified = classifyResumeEvent(event.message);
if (classified?.pausePoint === pausePoint) return classified.action;
break;
}
return defaultActionForPause(pausePoint);
}
return ( function materialsFromEvent(event: ProgressEvent | undefined): CompletedIntervention['materials'] | undefined {
<div className="flex gap-3"> if (!event) return undefined;
<div className="flex flex-col items-center"> if (event.materials?.length) return event.materials;
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0"> const raw = event.pauseContext?.materials;
<Icon className="w-3 h-3 text-primary" /> return Array.isArray(raw)
</div> ? raw.map((item) => normalizeMaterial(item as Record<string, unknown>))
{!isLast && <div className="w-px flex-1 bg-border/60 mt-1" />} : undefined;
</div> }
<div className="pb-4 min-w-0 flex-1">
<div className="flex items-center gap-2"> function outlineFromEvent(event: ProgressEvent | undefined): CompletedIntervention['outline'] | undefined {
<span className="text-xs font-medium"> if (!event) return undefined;
{PAUSE_LABELS[record.pausePoint] ?? record.pausePoint} if (event.outline) return event.outline;
</span> const outline = normalizeOutline(event.pauseContext?.outline);
<ChevronRight className="w-3 h-3 text-muted-foreground" /> return outline ?? undefined;
<span className="text-xs text-primary font-medium"> }
{ACTION_LABELS[record.action] ?? record.action}
</span> function reviewFromEvent(event: ProgressEvent | undefined): ReviewResult | null | undefined {
</div> if (!event) return undefined;
{ts && <p className="text-[10px] text-muted-foreground mt-0.5">{ts}</p>} if (event.reviewResult) return event.reviewResult;
</div> return normalizeReviewResult(event.pauseContext?.reviewResult ?? event.pauseContext?.review_result) ?? undefined;
</div> }
);
function updateSnapshot(snapshot: TimelineSnapshot, event: ProgressEvent): void {
if (event.type === 'materials_ready' && event.materials) {
snapshot.materials = event.materials;
}
if (event.type === 'outline_ready' && event.outline) {
snapshot.outline = event.outline;
}
if (event.type === 'draft_ready') {
if (event.draftTitle) snapshot.draftTitle = event.draftTitle;
if (event.draftMarkdown) snapshot.draftWordCount = countArticleWords(event.draftMarkdown);
}
if (event.type === 'review_ready' && event.reviewResult) {
snapshot.reviewResult = event.reviewResult;
}
}
function enrichIntervention(
record: CompletedIntervention,
sourceEvent: ProgressEvent | undefined,
snapshot: TimelineSnapshot,
sessionDraftTitle: string | null,
sessionDraftMarkdown: string | null,
sessionReviewResult: ReviewResult | null,
): CompletedIntervention {
const enriched: CompletedIntervention = { ...record };
if (record.pausePoint === 'material_confirm') {
enriched.materials = record.materials?.length
? record.materials
: (materialsFromEvent(sourceEvent) ?? snapshot.materials ?? []);
}
if (record.pausePoint === 'outline_confirm') {
enriched.outline = record.outline ?? outlineFromEvent(sourceEvent) ?? snapshot.outline;
}
if (record.pausePoint === 'draft_confirm') {
enriched.draftTitle =
record.draftTitle ??
sourceEvent?.draftTitle ??
snapshot.draftTitle ??
sessionDraftTitle ??
undefined;
enriched.draftWordCount =
record.draftWordCount ??
snapshot.draftWordCount ??
(sessionDraftMarkdown ? countArticleWords(sessionDraftMarkdown) : undefined);
enriched.reviewResult =
record.reviewResult ??
reviewFromEvent(sourceEvent) ??
snapshot.reviewResult ??
sessionReviewResult ??
undefined;
}
if (record.pausePoint === 'review_confirm') {
enriched.reviewResult =
record.reviewResult ??
reviewFromEvent(sourceEvent) ??
snapshot.reviewResult ??
sessionReviewResult ??
undefined;
}
return enriched;
}
function synthesizeInterventions(
events: ProgressEvent[],
sessionDraftTitle: string | null,
sessionDraftMarkdown: string | null,
sessionReviewResult: ReviewResult | null,
): CompletedIntervention[] {
const records: CompletedIntervention[] = [];
const snapshot: TimelineSnapshot = {
draftTitle: sessionDraftTitle ?? undefined,
draftWordCount: sessionDraftMarkdown ? countArticleWords(sessionDraftMarkdown) : undefined,
reviewResult: sessionReviewResult,
};
const hasAwaitUser = events.some((event) => event.type === 'await_user');
for (let i = 0; i < events.length; i += 1) {
const event = events[i];
if (event.type === 'await_user') {
const pausePoint = asPausePoint(event.pausePoint);
if (pausePoint) {
const record: CompletedIntervention = {
pausePoint,
action: actionFromNextResume(pausePoint, events, i + 1),
};
records.push(
enrichIntervention(
record,
event,
snapshot,
sessionDraftTitle,
sessionDraftMarkdown,
sessionReviewResult,
),
);
}
} else if (!hasAwaitUser && event.type === 'resumed') {
const classified = classifyResumeEvent(event.message);
if (classified) {
records.push(
enrichIntervention(
classified,
event,
snapshot,
sessionDraftTitle,
sessionDraftMarkdown,
sessionReviewResult,
),
);
}
}
updateSnapshot(snapshot, event);
}
return records;
}
function mergeIntervention(primary: CompletedIntervention, fallback: CompletedIntervention): CompletedIntervention {
const defaultAction = defaultActionForPause(primary.pausePoint);
const action = primary.action === defaultAction && fallback.action
? fallback.action
: (primary.action || fallback.action);
return {
pausePoint: primary.pausePoint,
action,
userInput: primary.userInput ?? fallback.userInput,
materials: primary.materials?.length ? primary.materials : fallback.materials,
outline: primary.outline ?? fallback.outline,
draftTitle: primary.draftTitle ?? fallback.draftTitle,
draftWordCount: primary.draftWordCount ?? fallback.draftWordCount,
reviewResult: primary.reviewResult ?? fallback.reviewResult,
};
}
function mergeInterventionLists(
synthesized: CompletedIntervention[],
transcriptRecords: CompletedIntervention[],
sessionRecords: CompletedIntervention[],
): CompletedIntervention[] {
const max = Math.max(synthesized.length, transcriptRecords.length, sessionRecords.length);
const merged: CompletedIntervention[] = [];
for (let i = 0; i < max; i += 1) {
const anchor = synthesized[i] ?? transcriptRecords[i] ?? sessionRecords[i];
if (!anchor) continue;
let record = anchor;
const candidates = [transcriptRecords[i], sessionRecords[i]];
for (const candidate of candidates) {
if (!candidate || candidate.pausePoint !== record.pausePoint) continue;
record = mergeIntervention(record, candidate);
}
merged.push(record);
}
return merged;
}
function eventFromIntervention(record: CompletedIntervention, sessionId: string): ProgressEvent {
return {
type: 'await_user',
sessionId,
message: PAUSE_MESSAGES[record.pausePoint],
pausePoint: record.pausePoint,
materials: record.materials,
outline: record.outline,
reviewResult: record.reviewResult,
draftTitle: record.draftTitle,
};
}
function ensureInterventionAnchors(
events: ProgressEvent[],
records: CompletedIntervention[],
sessionId: string,
): ProgressEvent[] {
if (events.some((event) => event.type === 'await_user')) return events;
if (records.length === 0) return events;
if (events.length === 0) return records.map((record) => eventFromIntervention(record, sessionId));
const nextEvents: ProgressEvent[] = [];
let recordIndex = 0;
for (const event of events) {
if (event.type === 'resumed' && recordIndex < records.length) {
nextEvents.push(eventFromIntervention(records[recordIndex], sessionId));
recordIndex += 1;
}
nextEvents.push(event);
}
while (recordIndex < records.length) {
nextEvents.push(eventFromIntervention(records[recordIndex], sessionId));
recordIndex += 1;
}
return nextEvents;
} }
interface AIWritingHistoryViewProps { interface AIWritingHistoryViewProps {
@@ -90,26 +372,42 @@ interface AIWritingHistoryViewProps {
} }
export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) { export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) {
const interventions = session.completed_interventions ?? [];
const transcript = session.transcript; const transcript = session.transcript;
// 有完整时间线快照时,复用实时对话的 AIWritingTimeline 渲染,保证回显与实时一致 const sessionReviewResult = useMemo(
const hasTranscript = !!transcript && (transcript.progressEvents?.length ?? 0) > 0; () => normalizeReviewResult(session.review_result),
// 归一化 transcript 事件:后台挂起跑出的事件是 raw(snake_case),前端实时存的是 [session.review_result],
// camelCase——统一过一遍 normalizeProgressDict(幂等),两种来源回显都正确一致。 );
const normalizedEvents = useMemo( const normalizedEvents = useMemo(
() => (transcript?.progressEvents ?? []).map( () => (transcript?.progressEvents ?? []).map(
(e) => normalizeProgressDict(e as unknown as Record<string, unknown>, session.id), (event) => normalizeProgressDict(event as unknown as Record<string, unknown>, session.id),
), ),
[transcript, session.id], [transcript, session.id],
); );
// 干预记录同样归一化(后台重建的素材/大纲/审核数据为 snake_case;前端实时记录已是 const transcriptInterventions = useMemo(
// camelCase,归一化幂等),保证素材列表/大纲/编辑审核卡片正确渲染。 () => normalizeInterventionList(transcript?.completedInterventions),
const normalizedInterventions = useMemo(
() => (transcript?.completedInterventions ?? []).map(
(rec) => normalizeCompletedIntervention(rec as unknown as Record<string, unknown>),
),
[transcript], [transcript],
); );
const sessionInterventions = useMemo(
() => normalizeInterventionList(session.completed_interventions),
[session.completed_interventions],
);
const synthesizedInterventions = useMemo(
() => synthesizeInterventions(
normalizedEvents,
session.draft_title,
session.draft_markdown,
sessionReviewResult,
),
[normalizedEvents, session.draft_title, session.draft_markdown, sessionReviewResult],
);
const completedInterventions = useMemo(
() => mergeInterventionLists(synthesizedInterventions, transcriptInterventions, sessionInterventions),
[synthesizedInterventions, transcriptInterventions, sessionInterventions],
);
const timelineEvents = useMemo(
() => ensureInterventionAnchors(normalizedEvents, completedInterventions, session.id),
[normalizedEvents, completedInterventions, session.id],
);
const createdAt = session.created_at const createdAt = session.created_at
? new Date(session.created_at).toLocaleString('zh-CN', { ? new Date(session.created_at).toLocaleString('zh-CN', {
year: 'numeric', month: 'short', day: 'numeric', year: 'numeric', month: 'short', day: 'numeric',
@@ -119,7 +417,6 @@ export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) {
return ( return (
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{/* 元信息 */}
<div className="px-4 pt-3"> <div className="px-4 pt-3">
<div className="rounded-lg border bg-muted/30 px-3 py-2.5 space-y-1"> <div className="rounded-lg border bg-muted/30 px-3 py-2.5 space-y-1">
<p className="text-xs font-semibold truncate">{session.draft_title ?? session.title}</p> <p className="text-xs font-semibold truncate">{session.draft_title ?? session.title}</p>
@@ -134,39 +431,15 @@ export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) {
</div> </div>
</div> </div>
{hasTranscript ? ( {timelineEvents.length > 0 ? (
/* 完整对话时间线回显 */
<AIWritingTimeline <AIWritingTimeline
events={normalizedEvents} events={timelineEvents}
completedInterventions={normalizedInterventions} completedInterventions={completedInterventions}
currentPause={null} currentPause={null}
/> />
) : ( ) : (
/* 旧会话无时间线快照时,回退到精简阶段列表 */ <div className="px-4 py-6 text-center text-xs text-muted-foreground">
<div className="px-4 py-3 space-y-4"> 暂无阶段记录
{interventions.length > 0 ? (
<div>
<p className="text-[11px] font-medium text-muted-foreground mb-2">写作阶段</p>
<div>
{interventions.map((rec, i) => (
<InterventionTimelineItem
key={i}
record={rec}
isLast={i === interventions.length - 1}
/>
))}
</div>
</div>
) : (
<div className="text-center py-4 text-xs text-muted-foreground">无阶段记录</div>
)}
{session.status === 'done' && (
<div className="flex items-center gap-2 text-xs text-green-600 dark:text-green-400">
<Star className="w-3.5 h-3.5" />
<span>写作完成</span>
</div>
)}
</div> </div>
)} )}
</div> </div>
@@ -32,6 +32,7 @@ import {
* 已完成阶段不重来;见 runtime/runs/worker.py)。刷新页面 = 重置(不自动重连)。 * 已完成阶段不重来;见 runtime/runs/worker.py)。刷新页面 = 重置(不自动重连)。
*/ */
const ENABLE_PAUSE_RESUME_UI = true; const ENABLE_PAUSE_RESUME_UI = true;
const RESUMABLE_HISTORY_STATUSES = new Set(['in_progress', 'draft_ready', 'review_ready']);
import { countArticleWords } from '../../utils/wordCount'; import { countArticleWords } from '../../utils/wordCount';
import { AIWritingForm } from './AIWritingForm'; import { AIWritingForm } from './AIWritingForm';
import { AIWritingTimeline } from './AIWritingTimeline'; import { AIWritingTimeline } from './AIWritingTimeline';
@@ -80,7 +81,7 @@ export function AIWritingPanel({
} = {}) { } = {}) {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting(); const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, continueWriting, reset } = useAIWriting();
const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory(); const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory();
const writingForm = useWritingFormOptional(); const writingForm = useWritingFormOptional();
const { sessions, loading: sessionsLoading, refresh, rename, remove, markBackground, clearBackground } = useAIWritingSessions(); const { sessions, loading: sessionsLoading, refresh, rename, remove, markBackground, clearBackground } = useAIWritingSessions();
@@ -198,6 +199,15 @@ export function AIWritingPanel({
reset(); reset();
}, [closeHistory, reset]); }, [closeHistory, reset]);
const canRestoreHistory =
isHistoryMode && !!historySession && RESUMABLE_HISTORY_STATUSES.has(historySession.status);
const handleRestoreHistory = useCallback(() => {
if (!historySession || !RESUMABLE_HISTORY_STATUSES.has(historySession.status)) return;
closeHistory();
continueWriting(historySession.id);
}, [historySession, closeHistory, continueWriting]);
// ── 后台挂起:把当前写作转后台,后端自动驱动到定稿(离页/刷新/切号不打断) ── // ── 后台挂起:把当前写作转后台,后端自动驱动到定稿(离页/刷新/切号不打断) ──
// 与「多智能体会商」一致:点击后**立刻**回到初始页面,挂起请求 fire-and-forget; // 与「多智能体会商」一致:点击后**立刻**回到初始页面,挂起请求 fire-and-forget;
// 历史下拉靠乐观标记马上显示「挂起中」,不等后端确认。 // 历史下拉靠乐观标记马上显示「挂起中」,不等后端确认。
@@ -307,19 +317,33 @@ export function AIWritingPanel({
)} )}
{/* 头部 */} {/* 头部 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0"> <div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
<span <div className="flex min-w-0 flex-1 items-center gap-2">
className={cn( {canRestoreHistory && (
'text-sm font-semibold shrink-0 min-w-0 truncate', <Button
isHistoryMode && 'flex-1 mr-2', variant="ghost"
!isIdle && !isHistoryMode && 'cursor-pointer hover:text-primary transition-colors', size="sm"
className="h-7 px-2 text-xs gap-1 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-950/30"
onClick={handleRestoreHistory}
title="恢复写作:跳过已完成阶段,从未完成阶段重新开始"
>
<Play className="size-3 fill-current" />
恢复
</Button>
)} )}
onClick={() => { if (!isIdle && !isHistoryMode && state.request) setReqDialogOpen(true); }} <span
title={isHistoryMode ? (historySession?.draft_title ?? historySession?.title) : undefined} className={cn(
> 'text-sm font-semibold shrink-0 min-w-0 truncate',
{isHistoryMode isHistoryMode && 'flex-1 mr-2',
? (historySession?.draft_title ?? historySession?.title ?? '写作历史') !isIdle && !isHistoryMode && 'cursor-pointer hover:text-primary transition-colors',
: 'AI 智能写作'} )}
</span> onClick={() => { if (!isIdle && !isHistoryMode && state.request) setReqDialogOpen(true); }}
title={isHistoryMode ? (historySession?.draft_title ?? historySession?.title) : undefined}
>
{isHistoryMode
? (historySession?.draft_title ?? historySession?.title ?? '写作历史')
: 'AI 智能写作'}
</span>
</div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{/* ⚠️ 临时调试:admin 一键清理 —— 阶段 7 删冗余时移除 */} {/* ⚠️ 临时调试:admin 一键清理 —— 阶段 7 删冗余时移除 */}
<Button <Button
@@ -8,7 +8,6 @@ import { buildIntranetSourceUrl } from '@/core/config';
import type { AIMaterial, CompletedIntervention } from '../../types/ai-writing'; import type { AIMaterial, CompletedIntervention } from '../../types/ai-writing';
import { OutlineCard } from './OutlineCard'; import { OutlineCard } from './OutlineCard';
import { ReviewCard } from './ReviewCard'; import { ReviewCard } from './ReviewCard';
import { ScoreBar } from './ScoreBar';
import { CollapseTransition } from './CollapseTransition'; import { CollapseTransition } from './CollapseTransition';
interface Props { interface Props {
@@ -282,14 +281,8 @@ function ReviewCompleted({ record, expanded, onToggle }: { record: CompletedInte
</button> </button>
<CollapseTransition open={expanded && !!record.reviewResult}> <CollapseTransition open={expanded && !!record.reviewResult}>
<div className="px-2.5 pb-2.5 pt-1 space-y-1.5 border-t border-green-200/40 dark:border-green-900/20"> <div className="px-2.5 pb-2.5 pt-1 border-t border-green-200/40 dark:border-green-900/20">
{record.reviewResult && ( {record.reviewResult && <ReviewCard review={record.reviewResult} />}
<>
<ScoreBar label="事实准确性" score={record.reviewResult.factScore} />
<ScoreBar label="逻辑结构" score={record.reviewResult.logicScore} />
<ScoreBar label="语言文字" score={record.reviewResult.languageScore} />
</>
)}
</div> </div>
</CollapseTransition> </CollapseTransition>
</div> </div>
@@ -12,7 +12,6 @@ import {
Newspaper, Newspaper,
PanelLeftClose, PanelLeftClose,
Pencil, Pencil,
Play,
Plus, Plus,
RefreshCw, RefreshCw,
Search, Search,
@@ -110,17 +109,12 @@ const WRITING_STATUS_PILL: Record<string, { label: string; className: string; sp
error: { label: "出错", className: "text-rose-500" }, error: { label: "出错", className: "text-rose-500" },
}; };
/** 可「继续未完成」的状态:有可恢复的 checkpoint(mid-stage 或停在确认点)。done/error 终态、
* background 自有进度弹窗,都不算。 */
const RESUMABLE_STATUSES = new Set(["in_progress", "draft_ready", "review_ready"]);
/** 单条历史记录项 —— 样式参考多智能体会商的 HistoryDropdown 列表项(圆角卡片 + 状态药丸 + 操作按钮)。 */ /** 单条历史记录项 —— 样式参考多智能体会商的 HistoryDropdown 列表项(圆角卡片 + 状态药丸 + 操作按钮)。 */
function WritingHistoryItem({ function WritingHistoryItem({
session, session,
isCurrent, isCurrent,
onSelect, onSelect,
onShowProgress, onShowProgress,
onContinue,
onDelete, onDelete,
onRename, onRename,
}: { }: {
@@ -128,8 +122,6 @@ function WritingHistoryItem({
isCurrent: boolean; isCurrent: boolean;
onSelect: () => void; onSelect: () => void;
onShowProgress?: () => void; onShowProgress?: () => void;
/** 继续这条未完成的会话(从 checkpoint 续跑当前阶段 / 显示确认卡)。 */
onContinue?: () => void;
onDelete: () => void; onDelete: () => void;
onRename: (title: string) => void; onRename: (title: string) => void;
}) { }) {
@@ -137,10 +129,9 @@ function WritingHistoryItem({
const [value, setValue] = useState(session.title); const [value, setValue] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const isBackground = session.status === "background"; const isBackground = session.status === "background";
const canContinue = !!onContinue && RESUMABLE_STATUSES.has(session.status); // Row/title click only opens the history view; restore starts from the detail header.
// 行/标题主点击:未完成→继续续跑;挂起中→看进度弹窗;其余(完成/出错)→只读历史回看。 const primary = isBackground && onShowProgress ? onShowProgress : onSelect;
const primary = isBackground && onShowProgress ? onShowProgress : (canContinue ? onContinue! : onSelect); const primaryTitle = isBackground ? "查看进度" : "打开历史";
const primaryTitle = isBackground ? "查看进度" : canContinue ? "继续未完成" : "打开历史";
const pill = WRITING_STATUS_PILL[session.status] ?? { label: session.status, className: "text-slate-400" }; const pill = WRITING_STATUS_PILL[session.status] ?? { label: session.status, className: "text-slate-400" };
useEffect(() => { useEffect(() => {
@@ -200,16 +191,6 @@ function WritingHistoryItem({
{!renaming && ( {!renaming && (
<div className="flex shrink-0 items-center gap-0.5"> <div className="flex shrink-0 items-center gap-0.5">
{canContinue && (
<button
type="button"
onClick={onContinue}
title="继续未完成"
className="rounded p-1 text-emerald-500 transition hover:bg-emerald-50 hover:text-emerald-600"
>
<Play className="size-3.5 fill-current" />
</button>
)}
<button <button
type="button" type="button"
onClick={isBackground && onShowProgress ? onShowProgress : onSelect} onClick={isBackground && onShowProgress ? onShowProgress : onSelect}
@@ -258,7 +239,7 @@ export function ResearchSidebar({
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { templates, refresh } = useResearchTemplates(); const { templates, refresh } = useResearchTemplates();
const { startWriting, continueWriting } = useAIWriting(); const { startWriting } = useAIWriting();
const { const {
notebooks, notebooks,
isLoading: notebooksLoading, isLoading: notebooksLoading,
@@ -286,7 +267,7 @@ export function ResearchSidebar({
rename: renameSession, rename: renameSession,
remove: removeSession, remove: removeSession,
} = useAIWritingSessions(); } = useAIWritingSessions();
const { openHistory, closeHistory, historySession } = useAIWritingHistory(); const { openHistory, historySession } = useAIWritingHistory();
// 进入/返回写作配置页时刷新一次历史(本侧栏仅在 idle 态挂载,故等同每次回到表单刷新)。 // 进入/返回写作配置页时刷新一次历史(本侧栏仅在 idle 态挂载,故等同每次回到表单刷新)。
useEffect(() => { useEffect(() => {
@@ -296,11 +277,6 @@ export function ResearchSidebar({
const handleSelectHistory = (session: AIWritingSessionSummary) => { const handleSelectHistory = (session: AIWritingSessionSummary) => {
void openHistory(session); void openHistory(session);
}; };
// 「继续未完成」:退出只读历史态(若在),把该会话设为活跃并从 checkpoint 续跑。
const handleContinue = (session: AIWritingSessionSummary) => {
closeHistory();
continueWriting(session.id);
};
// 「挂起中」记录:交给右侧面板打开进度流程图弹窗(沿用面板已有的 window 事件解耦模式, // 「挂起中」记录:交给右侧面板打开进度流程图弹窗(沿用面板已有的 window 事件解耦模式,
// 与 ai-writing:open-session 一致),本侧栏不直接持有弹窗状态。 // 与 ai-writing:open-session 一致),本侧栏不直接持有弹窗状态。
const handleShowProgress = (session: AIWritingSessionSummary) => { const handleShowProgress = (session: AIWritingSessionSummary) => {
@@ -617,7 +593,6 @@ export function ResearchSidebar({
isCurrent={s.id === (historySession?.id ?? null)} isCurrent={s.id === (historySession?.id ?? null)}
onSelect={() => handleSelectHistory(s)} onSelect={() => handleSelectHistory(s)}
onShowProgress={() => handleShowProgress(s)} onShowProgress={() => handleShowProgress(s)}
onContinue={() => handleContinue(s)}
onDelete={() => removeSession(s.id)} onDelete={() => removeSession(s.id)}
onRename={(title) => renameSession(s.id, title)} onRename={(title) => renameSession(s.id, title)}
/> />
@@ -40,6 +40,7 @@ interface LastAction {
action: RewriteAction; action: RewriteAction;
tone?: RewriteTone; tone?: RewriteTone;
customInstruction?: string; customInstruction?: string;
referenceText?: string;
} }
export function TextArtifactRenderer({ content, isStreaming, readOnly = false, onSaveDraft }: TextArtifactRendererProps) { export function TextArtifactRenderer({ content, isStreaming, readOnly = false, onSaveDraft }: TextArtifactRendererProps) {
@@ -137,9 +138,9 @@ export function TextArtifactRenderer({ content, isStreaming, readOnly = false, o
}, [reset, editor]); }, [reset, editor]);
const handleAction = useCallback( const handleAction = useCallback(
(action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => { (action: RewriteAction, tone?: RewriteTone, customInstruction?: string, referenceText?: string) => {
lastActionRef.current = { action, tone, customInstruction }; lastActionRef.current = { action, tone, customInstruction, referenceText };
rewrite(action, tone, customInstruction); rewrite(action, tone, customInstruction, referenceText);
setPhase("preview"); setPhase("preview");
}, },
[rewrite], [rewrite],
@@ -147,15 +148,15 @@ export function TextArtifactRenderer({ content, isStreaming, readOnly = false, o
const handleRetry = useCallback(() => { const handleRetry = useCallback(() => {
if (!lastActionRef.current) return; if (!lastActionRef.current) return;
const { action, tone, customInstruction } = lastActionRef.current; const { action, tone, customInstruction, referenceText } = lastActionRef.current;
rewrite(action, tone, customInstruction); rewrite(action, tone, customInstruction, referenceText);
}, [rewrite]); }, [rewrite]);
const handleFollowUp = useCallback( const handleFollowUp = useCallback(
(instruction: string) => { (instruction: string) => {
if (!lastActionRef.current) return; if (!lastActionRef.current) return;
const { action, tone } = lastActionRef.current; const { action, tone, referenceText } = lastActionRef.current;
rewriteFollowUp(instruction, action, tone); rewriteFollowUp(instruction, action, tone, referenceText);
}, },
[rewriteFollowUp], [rewriteFollowUp],
); );
@@ -6,11 +6,13 @@ import {
Expand, Expand,
Shrink, Shrink,
Mic2, Mic2,
BookCopy,
AlertCircle, AlertCircle,
RefreshCw, RefreshCw,
ChevronRight, ChevronRight,
X, X,
Square, Square,
Send,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { RewriteAction, RewriteTone } from "../../api/rewrite"; import type { RewriteAction, RewriteTone } from "../../api/rewrite";
@@ -44,6 +46,7 @@ const ACTION_ROWS: {
{ action: "expand", label: "扩写", icon: Expand }, { action: "expand", label: "扩写", icon: Expand },
{ action: "shorten", label: "缩写", icon: Shrink }, { action: "shorten", label: "缩写", icon: Shrink },
{ action: "tone", label: "改语气", icon: Mic2 }, { action: "tone", label: "改语气", icon: Mic2 },
{ action: "imitate", label: "仿写", icon: BookCopy },
]; ];
// ── Main component ───────────────────────────────────────────────────────────── // ── Main component ─────────────────────────────────────────────────────────────
@@ -52,7 +55,7 @@ interface WritingAiMenuProps {
pos: MenuPos; pos: MenuPos;
status: RewriteStatus; status: RewriteStatus;
error: string | null; error: string | null;
onAction: (action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => void; onAction: (action: RewriteAction, tone?: RewriteTone, customInstruction?: string, referenceText?: string) => void;
onAbort: () => void; onAbort: () => void;
onRetry: () => void; onRetry: () => void;
onClose: () => void; onClose: () => void;
@@ -68,8 +71,11 @@ export function WritingAiMenu({
onClose, onClose,
}: WritingAiMenuProps) { }: WritingAiMenuProps) {
const [toneOpen, setToneOpen] = useState(false); const [toneOpen, setToneOpen] = useState(false);
const [imitateDialogOpen, setImitateDialogOpen] = useState(false);
const [referenceText, setReferenceText] = useState("");
const [instruction, setInstruction] = useState(""); const [instruction, setInstruction] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const imitateTextareaRef = useRef<HTMLTextAreaElement>(null);
// autoFocus is unreliable when the menu opens during a mousedown event sequence; // autoFocus is unreliable when the menu opens during a mousedown event sequence;
// explicit focus via setTimeout(0) runs after the browser finishes that sequence. // explicit focus via setTimeout(0) runs after the browser finishes that sequence.
@@ -78,6 +84,13 @@ export function WritingAiMenu({
return () => clearTimeout(id); return () => clearTimeout(id);
}, []); }, []);
// Focus the dialog textarea when the imitate dialog opens.
useEffect(() => {
if (!imitateDialogOpen) return;
const id = setTimeout(() => imitateTextareaRef.current?.focus(), 0);
return () => clearTimeout(id);
}, [imitateDialogOpen]);
const isLoading = status === "loading"; const isLoading = status === "loading";
const isError = status === "error"; const isError = status === "error";
@@ -110,6 +123,13 @@ export function WritingAiMenu({
onAction(action, tone, instruction.trim() || undefined); onAction(action, tone, instruction.trim() || undefined);
}; };
const fireImitate = () => {
const ref = referenceText.trim();
if (!ref) return;
setImitateDialogOpen(false);
onAction("imitate", undefined, instruction.trim() || undefined, ref);
};
const handleInputEnter = () => { const handleInputEnter = () => {
const trimmed = instruction.trim(); const trimmed = instruction.trim();
onAction("polish", undefined, trimmed || undefined); onAction("polish", undefined, trimmed || undefined);
@@ -230,7 +250,9 @@ export function WritingAiMenu({
<div className="p-1 space-y-0.5"> <div className="p-1 space-y-0.5">
{ACTION_ROWS.map(({ action, label, icon: Icon }) => { {ACTION_ROWS.map(({ action, label, icon: Icon }) => {
const isTone = action === "tone"; const isTone = action === "tone";
const isTonesOpen = isTone && toneOpen; const isImitate = action === "imitate";
const hasSub = isTone;
const isExpanded = isTone && toneOpen;
return ( return (
<div key={action}> <div key={action}>
<button <button
@@ -238,6 +260,9 @@ export function WritingAiMenu({
onClick={() => { onClick={() => {
if (isTone) { if (isTone) {
setToneOpen((v) => !v); setToneOpen((v) => !v);
} else if (isImitate) {
setToneOpen(false);
setImitateDialogOpen(true);
} else { } else {
fireAction(action); fireAction(action);
} }
@@ -245,23 +270,23 @@ export function WritingAiMenu({
className={cn( className={cn(
"flex w-full items-center gap-2 px-2.5 py-1.5 text-xs rounded-md transition-colors text-left", "flex w-full items-center gap-2 px-2.5 py-1.5 text-xs rounded-md transition-colors text-left",
"hover:bg-accent hover:text-accent-foreground", "hover:bg-accent hover:text-accent-foreground",
isTonesOpen && "bg-accent text-accent-foreground", isExpanded && "bg-accent text-accent-foreground",
)} )}
> >
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1">{label}</span> <span className="flex-1">{label}</span>
{isTone && ( {hasSub && (
<ChevronRight <ChevronRight
className={cn( className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-200", "h-3.5 w-3.5 text-muted-foreground transition-transform duration-200",
isTonesOpen && "rotate-90", isExpanded && "rotate-90",
)} )}
/> />
)} )}
</button> </button>
{/* Tone sub-list */} {/* Tone sub-list */}
{isTonesOpen && ( {isTone && toneOpen && (
<div className="ml-5 border-l pl-2 my-0.5 space-y-0.5"> <div className="ml-5 border-l pl-2 my-0.5 space-y-0.5">
{TONE_OPTIONS.map(({ value, label: toneLabel }) => ( {TONE_OPTIONS.map(({ value, label: toneLabel }) => (
<button <button
@@ -282,6 +307,85 @@ export function WritingAiMenu({
</> </>
)} )}
</div> </div>
{/* ── Imitate dialog:弹窗内多行输入范文,回车 / 发送即仿写 ───────────── */}
{imitateDialogOpen && (
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/40 animate-in fade-in duration-100"
onMouseDown={() => setImitateDialogOpen(false)}
/>
<div
className="relative w-full max-w-md rounded-xl border bg-popover shadow-2xl animate-in fade-in zoom-in-95 duration-100 overflow-hidden"
onMouseDown={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<span className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
<BookCopy className="h-4 w-4 text-amber-500" />
仿写
</span>
<button
type="button"
onClick={() => setImitateDialogOpen(false)}
className="rounded p-0.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="p-4 space-y-3">
<textarea
ref={imitateTextareaRef}
value={referenceText}
onChange={(e) => setReferenceText(e.target.value)}
onKeyDown={(e) => {
// Enter 提交,Shift+Enter 换行
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
fireImitate();
} else if (e.key === "Escape") {
e.preventDefault();
setImitateDialogOpen(false);
}
}}
rows={8}
placeholder="粘贴范文,AI 将模仿其文风、句式与语气改写所选文字…(回车提交,Shift+Enter 换行)"
className={cn(
"w-full resize-none text-sm rounded-md px-3 py-2 outline-none transition-colors leading-relaxed",
"bg-muted/50 border border-input focus:border-primary/60",
"placeholder:text-muted-foreground/50",
)}
/>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t bg-muted/20">
<button
type="button"
onClick={() => setImitateDialogOpen(false)}
className="px-3 py-1.5 text-xs font-medium rounded-md border border-input hover:bg-accent hover:text-accent-foreground transition-colors"
>
取消
</button>
<button
type="button"
onClick={fireImitate}
disabled={!referenceText.trim()}
className={cn(
"flex items-center justify-center gap-1.5 px-4 py-1.5 text-xs font-medium rounded-md transition-colors",
"bg-primary text-primary-foreground hover:bg-primary/90",
"disabled:opacity-40 disabled:cursor-not-allowed",
)}
>
<Send className="h-3.5 w-3.5" />
发送
</button>
</div>
</div>
</div>
)}
</>, </>,
document.body, document.body,
); );
@@ -14,6 +14,7 @@ const ACTION_LABEL: Record<RewriteAction, string> = {
expand: "扩写", expand: "扩写",
shorten: "缩写", shorten: "缩写",
tone: "改语气", tone: "改语气",
imitate: "仿写",
}; };
const TONE_LABEL: Record<RewriteTone, string> = { const TONE_LABEL: Record<RewriteTone, string> = {
@@ -26,6 +26,8 @@ import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
import { countArticleWords } from '../utils/wordCount'; import { countArticleWords } from '../utils/wordCount';
import { import {
INITIAL_STATE, INITIAL_STATE,
buildAwaitUserEventFromCheckpointPause,
buildCompletedInterventionFromCheckpointPause,
buildRehydrateSnapshot, buildRehydrateSnapshot,
customToProgressEvent, customToProgressEvent,
interruptToEvent, interruptToEvent,
@@ -96,14 +98,104 @@ type AIWritingStreamApi = Pick<
'submit' | 'stop' | 'interrupt' 'submit' | 'stop' | 'interrupt'
>; >;
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' ? value as Record<string, unknown> : {};
}
function pausePointFromStatus(status: string): string | null {
return status.startsWith('awaiting_') ? status.slice('awaiting_'.length) : null;
}
function defaultResumePayloadForPause(
pausePoint: string | null | undefined,
interruptValue: unknown,
values: AIWritingValues,
): Record<string, unknown> {
const intr = asRecord(interruptValue);
const valueRecord = asRecord(values);
if (pausePoint === 'material_confirm') {
return { action: 'confirm' };
}
if (pausePoint === 'outline_confirm') {
return { action: 'confirm' };
}
if (pausePoint === 'section_help') {
const blocked = Array.isArray(intr.blocked_sections)
? intr.blocked_sections
: (Array.isArray(valueRecord.blocked_sections) ? valueRecord.blocked_sections : []);
const decisions = blocked
.map((b) => asRecord(b))
.map((b) => String(b.section_title ?? b.sectionTitle ?? ''))
.filter(Boolean)
.map((sectionTitle) => ({ sectionTitle, decision: 'loose' }));
return { action: 'section_help', sectionDecisions: decisions };
}
if (pausePoint === 'draft_confirm') {
const review = asRecord(valueRecord.review_result);
const hasReview = Object.keys(review).length > 0;
const revisionCount = Number(valueRecord.revision_count ?? 0);
const maxRevisions = Number(valueRecord.max_revisions ?? 3);
if (!hasReview) return { action: 'to_editor' };
if (review.verdict === 'pass') return { action: 'finalize' };
if (revisionCount >= maxRevisions) return { action: 'finalize' };
return { action: 'to_editor' };
}
if (pausePoint === 'review_confirm') {
return { action: 'accept_review' };
}
return { action: 'confirm' };
}
function nextStatusAfterResumePayload(
pausePoint: string | null | undefined,
payload: Record<string, unknown>,
): WritingStatus {
const action = String(payload.action ?? 'confirm');
if (pausePoint === 'material_confirm') {
return (action === 're_search' || action === 'enable_skill_search')
? 'researching'
: 'writing_outline';
}
if (pausePoint === 'outline_confirm') {
return action === 're_outline' ? 'writing_outline' : 'writing_draft';
}
if (pausePoint === 'draft_confirm') {
if (action === 'finalize') return 'done';
if (action === 'to_editor') return 'reviewing';
return 'revising';
}
if (pausePoint === 'review_confirm') {
return action === 'force_finalize' ? 'done' : 'revising';
}
if (pausePoint === 'section_help') {
const decisions = Array.isArray(payload.sectionDecisions) ? payload.sectionDecisions : [];
return decisions.some((d) => asRecord(d).decision === 'supplement')
? 'researching'
: 'writing_draft';
}
return 'researching';
}
export type AIWritingApi = { export type AIWritingApi = {
state: AIWritingState; state: AIWritingState;
startWriting: (request: AIWritingRequest) => Promise<void>; startWriting: (request: AIWritingRequest) => Promise<void>;
submitIntervention: (payload: UserInterventionPayload) => Promise<void>; submitIntervention: (payload: UserInterventionPayload) => Promise<void>;
pauseWriting: () => void; pauseWriting: () => void;
resumeWriting: () => void; resumeWriting: () => void;
/** 继续一条未完成的历史会话:加载其 checkpoint 重建已完成阶段,并从断点续跑当前阶段 /** Restore an unfinished history session: rehydrate completed stages from checkpoint,
* (停在确认点则显示确认卡)。多 worker 安全(基于共享 checkpoint,同 4 个确认卡机制)。 */ * auto-advance completed awaiting_* stages, and re-run the active unfinished stage. */
continueWriting: (sessionId: string) => void; continueWriting: (sessionId: string) => void;
reset: () => void; reset: () => void;
}; };
@@ -144,7 +236,7 @@ const AIWritingStreamHost = memo(function AIWritingStreamHost({
sessionIdRef: RefObject<string | null>; sessionIdRef: RefObject<string | null>;
streamRef: MutableRefObject<AIWritingStreamApi | null>; streamRef: MutableRefObject<AIWritingStreamApi | null>;
seenInterruptRef: MutableRefObject<string | null>; seenInterruptRef: MutableRefObject<string | null>;
/** 非空 = 本次要"继续未完成的历史会话",等 checkpoint values 到位后 rehydrate + 驱动续跑。 */ /** Non-empty while an explicit history restore is waiting for checkpoint values. */
continueTargetRef: MutableRefObject<string | null>; continueTargetRef: MutableRefObject<string | null>;
}) { }) {
const eventBatcherRef = useRef<ReturnType<typeof createProgressEventBatcher> | null>(null); const eventBatcherRef = useRef<ReturnType<typeof createProgressEventBatcher> | null>(null);
@@ -240,16 +332,18 @@ const AIWritingStreamHost = memo(function AIWritingStreamHost({
} }
const key = intr.id ?? JSON.stringify(intr.value); const key = intr.id ?? JSON.stringify(intr.value);
if (seenInterruptRef.current === key) return; if (seenInterruptRef.current === key) return;
if (continueTargetRef.current && continueTargetRef.current === threadId) {
seenInterruptRef.current = key;
return;
}
seenInterruptRef.current = key; seenInterruptRef.current = key;
const ev = interruptToEvent(intr.value, sessionIdRef.current ?? ''); const ev = interruptToEvent(intr.value, sessionIdRef.current ?? '');
if (ev) emitProgressEvent(ev); if (ev) emitProgressEvent(ev);
}, [stream.interrupt, emitProgressEvent, seenInterruptRef, sessionIdRef]); }, [stream.interrupt, emitProgressEvent, seenInterruptRef, sessionIdRef, continueTargetRef, threadId]);
// ── 继续未完成的历史会话:等该 thread 的 checkpoint values 到位后 rehydrate + 驱动续跑 ── // History restore is explicit: after the history detail "restore" button is clicked,
// 仅在显式点「继续」时触发(continueTargetRef 由 continueWriting 置位),非自动重连。 // rehydrate from checkpoint, auto-advance completed awaiting_* stages with default
// 每个 thread 只做一次:rehydrate 用 checkpoint 全量重建已完成阶段(素材/大纲/草稿/审核/时间线), // decisions, and re-run the currently active unfinished stage with an empty command.
// 然后——停在确认点(awaiting_*)就显示确认卡等用户决策;停在某活跃阶段中途就发空 command:{}
// 让后端从 checkpoint 续跑该阶段(worker 空 command → astream(None))。
const rehydratedForRef = useRef<string | null>(null); const rehydratedForRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
const target = continueTargetRef.current; const target = continueTargetRef.current;
@@ -262,21 +356,35 @@ const AIWritingStreamHost = memo(function AIWritingStreamHost({
const snapshot = buildRehydrateSnapshot(values, threadId); const snapshot = buildRehydrateSnapshot(values, threadId);
dispatchRef.current?.({ type: 'rehydrate', snapshot }); dispatchRef.current?.({ type: 'rehydrate', snapshot });
const status = String(snapshot.status ?? ''); const status = String(snapshot.status ?? '');
if (status.startsWith('awaiting_')) { const checkpointModelName = typeof values.model_name === 'string' ? values.model_name : '';
// 停在确认点:补一条 await_user 设好 currentPause(与下方既有 interrupt effect 二者其一生效, const submitFromCheckpoint = (command: Record<string, unknown>) => {
// 对齐 seenInterruptRef 防重复派发);不自动驱动,等用户在确认卡上操作。
const intr = stream.interrupt;
if (intr) {
const ev = interruptToEvent(intr.value, threadId);
if (ev) dispatchRef.current?.({ type: 'handle', event: ev });
seenInterruptRef.current = intr.id ?? JSON.stringify(intr.value);
}
} else {
// 停在某活跃阶段中途:从 checkpoint 续跑该阶段。
void streamRef.current?.submit(undefined, { void streamRef.current?.submit(undefined, {
command: {}, command,
streamMode: [...WRITING_STREAM_MODE], streamMode: [...WRITING_STREAM_MODE],
...(checkpointModelName ? { config: { configurable: { model_name: checkpointModelName } } } : {}),
}).catch(() => { /* 流式错误由 onError 反映 */ }); }).catch(() => { /* 流式错误由 onError 反映 */ });
};
if (status.startsWith('awaiting_')) {
const intr = stream.interrupt;
if (intr) seenInterruptRef.current = intr.id ?? JSON.stringify(intr.value);
const pausePoint = (asRecord(intr?.value).pause_point as string | undefined) ?? pausePointFromStatus(status);
const resumePayload = defaultResumePayloadForPause(pausePoint, intr?.value, values);
const pauseEvent = buildAwaitUserEventFromCheckpointPause(values, threadId, pausePoint, intr?.value);
if (pauseEvent) {
dispatchRef.current?.({ type: 'handle', event: pauseEvent });
}
const record = buildCompletedInterventionFromCheckpointPause(values, pausePoint, resumePayload.action);
if (record) {
dispatchRef.current?.({
type: 'intervention_submitted',
record,
nextStatus: nextStatusAfterResumePayload(pausePoint, resumePayload),
pendingRevisionContext: null,
});
}
submitFromCheckpoint({ resume: resumePayload });
} else {
submitFromCheckpoint({});
} }
}, [stream.values, stream.interrupt, threadId, continueTargetRef, dispatchRef, seenInterruptRef, streamRef]); }, [stream.values, stream.interrupt, threadId, continueTargetRef, dispatchRef, seenInterruptRef, streamRef]);
@@ -294,7 +402,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
sessionIdRef.current = state.sessionId; sessionIdRef.current = state.sessionId;
const streamRef = useRef<AIWritingStreamApi | null>(null); const streamRef = useRef<AIWritingStreamApi | null>(null);
const seenInterruptRef = useRef<string | null>(null); const seenInterruptRef = useRef<string | null>(null);
// 「继续未完成历史」目标:continueWriting 置位,host 在该 thread 的 checkpoint 到位后 rehydrate。 // Explicit history restore target; the host rehydrates once this thread's checkpoint arrives.
const continueTargetRef = useRef<string | null>(null); const continueTargetRef = useRef<string | null>(null);
// 刷新页面 = 重置:不持久化、不自动重连。provider 重新挂载即回到 idle 初始态 // 刷新页面 = 重置:不持久化、不自动重连。provider 重新挂载即回到 idle 初始态
@@ -514,8 +622,8 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
})(); })();
}, [state.request]); }, [state.request]);
// 继续一条未完成的历史会话:把该会话设为活跃 sessionId → useStream 加载其 checkpoint → // Restore an unfinished history session: make it active, load its checkpoint, then let
// host 的 rehydrate effect 重建已完成阶段并驱动续跑(停在确认点则显示确认卡)。 // the host skip completed awaiting_* stages or re-run the current unfinished stage.
// 与「刷新=重置」不冲突:这是显式点击触发,不是自动重连。 // 与「刷新=重置」不冲突:这是显式点击触发,不是自动重连。
const continueWriting = useCallback((sessionId: string) => { const continueWriting = useCallback((sessionId: string) => {
if (!sessionId) return; if (!sessionId) return;
@@ -85,7 +85,7 @@ export type Action =
| { type: 'intervention_submitted'; record: CompletedIntervention; nextStatus: WritingStatus; pendingRevisionContext: AIWritingState['pendingRevisionContext'] } | { type: 'intervention_submitted'; record: CompletedIntervention; nextStatus: WritingStatus; pendingRevisionContext: AIWritingState['pendingRevisionContext'] }
| { type: 'pause' } | { type: 'pause' }
| { type: 'resume_ui' } | { type: 'resume_ui' }
// 「继续未完成的历史记录」:用 graph checkpoint 的 values 快照整体重建状态(见 buildRehydrateSnapshot)。 // Restore history: rebuild state from the graph checkpoint values snapshot.
| { type: 'rehydrate'; snapshot: Partial<AIWritingState> } | { type: 'rehydrate'; snapshot: Partial<AIWritingState> }
// 手动「继续」从检查点重跑当前阶段前,清空该阶段的流式缓冲(避免与重跑的新输出粘连)。 // 手动「继续」从检查点重跑当前阶段前,清空该阶段的流式缓冲(避免与重跑的新输出粘连)。
| { type: 'clear_streaming' } | { type: 'clear_streaming' }
@@ -137,9 +137,7 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
return { ...prev, isPaused: false }; return { ...prev, isPaused: false };
case 'rehydrate': case 'rehydrate':
// 继续未完成历史:以 INITIAL_STATE 为底叠加 checkpoint 快照(materials/outline/draft/ // Restore history: layer checkpoint snapshot over INITIAL_STATE.
// review/status/progressEvents/sessionId 等)。currentPause 由 stream.interrupt 既有 effect
// 或 host 在 rehydrate 时补设。
return { ...INITIAL_STATE, ...action.snapshot, isPaused: false }; return { ...INITIAL_STATE, ...action.snapshot, isPaused: false };
case 'clear_streaming': case 'clear_streaming':
@@ -522,6 +520,163 @@ export function normalizeReviewResult(r: unknown): ReviewResult | null {
}; };
} }
const REHYDRATE_PAUSE_MESSAGES: Record<CompletedIntervention['pausePoint'], string> = {
material_confirm: '素材收集专家已完成素材搜集,请确认素材后继续。',
outline_confirm: '作家已完成大纲规划,请确认或编辑大纲后继续。',
draft_confirm: '草稿已完成,请确认或给出修改意见。',
review_confirm: '编辑认为文章需要修改,请决定下一步操作。',
section_help: '部分章节缺少足够素材支撑写作,请决定如何处理。',
};
function normalizePausePoint(value: unknown): CompletedIntervention['pausePoint'] | null {
const pausePoint = String(value ?? '');
switch (pausePoint) {
case 'material_confirm':
case 'outline_confirm':
case 'draft_confirm':
case 'review_confirm':
case 'section_help':
return pausePoint;
default:
return null;
}
}
function classifyResumedMessage(message: string): Pick<CompletedIntervention, 'pausePoint' | 'action'> | null {
const m = message || '';
if (m.includes('确认素材')) return { pausePoint: 'material_confirm', action: 'confirm' };
if (m.includes('匹配技能检索') || m.includes('重新检索') || m.includes('重新搜索')) {
return { pausePoint: 'material_confirm', action: 're_search' };
}
if (m.includes('采用你提供的大纲')) return { pausePoint: 'material_confirm', action: 'confirm' };
if (m.includes('确认大纲')) return { pausePoint: 'outline_confirm', action: 'confirm' };
if (m.includes('重新规划大纲')) return { pausePoint: 'outline_confirm', action: 're_outline' };
if (m.includes('提交编辑审核')) return { pausePoint: 'draft_confirm', action: 'to_editor' };
if (m.includes('强制定稿')) return { pausePoint: 'review_confirm', action: 'force_finalize' };
if (m.includes('确认定稿')) return { pausePoint: 'draft_confirm', action: 'finalize' };
if (m.includes('按用户意见重写')) return { pausePoint: 'draft_confirm', action: 'user_revise' };
if (m.includes('按编辑意见重写')) return { pausePoint: 'review_confirm', action: 'accept_review' };
if (m.includes('补充检索素材')) return { pausePoint: 'section_help', action: 'supplement' };
if (m.includes('继续写作') || m.includes('按你的选择继续写作')) {
return { pausePoint: 'section_help', action: 'loose' };
}
return null;
}
function materialsFromValues(values: AIWritingValues): AIMaterial[] {
const pkg = values.material_package && typeof values.material_package === 'object'
? values.material_package as Record<string, unknown>
: null;
return pkg && Array.isArray(pkg.materials)
? (pkg.materials as Record<string, unknown>[]).map(normalizeMaterial)
: [];
}
function draftFromValues(values: AIWritingValues): Record<string, unknown> | null {
return values.current_draft && typeof values.current_draft === 'object'
? values.current_draft as Record<string, unknown>
: null;
}
export function buildCompletedInterventionFromCheckpointPause(
values: AIWritingValues,
pausePointValue: unknown,
actionValue: unknown,
): CompletedIntervention | null {
const pausePoint = normalizePausePoint(pausePointValue);
if (!pausePoint) return null;
const action = String(actionValue || 'confirm');
const record: CompletedIntervention = { pausePoint, action };
if (pausePoint === 'material_confirm') {
record.materials = materialsFromValues(values);
} else if (pausePoint === 'outline_confirm') {
record.outline = normalizeOutline(values.current_outline) ?? undefined;
} else if (pausePoint === 'draft_confirm') {
const draft = draftFromValues(values);
record.draftTitle = String(draft?.title ?? '');
const wordCount = Number(draft?.word_count ?? draft?.wordCount ?? 0);
record.draftWordCount = wordCount > 0
? wordCount
: countArticleWords(String(draft?.full_markdown ?? draft?.fullMarkdown ?? ''));
record.reviewResult = normalizeReviewResult(values.review_result) ?? undefined;
} else if (pausePoint === 'review_confirm') {
record.reviewResult = normalizeReviewResult(values.review_result) ?? undefined;
}
return record;
}
export function buildAwaitUserEventFromCheckpointPause(
values: AIWritingValues,
sessionId: string,
pausePointValue: unknown,
interruptValue?: unknown,
): ProgressEvent | null {
const pausePoint = normalizePausePoint(pausePointValue);
if (!pausePoint) return null;
const intr = interruptValue && typeof interruptValue === 'object'
? interruptValue as Record<string, unknown>
: {};
const draft = draftFromValues(values);
const event: ProgressEvent = {
type: 'await_user',
sessionId,
message: String(intr.message ?? REHYDRATE_PAUSE_MESSAGES[pausePoint]),
pausePoint,
};
if (pausePoint === 'material_confirm') {
event.materials = Array.isArray(intr.materials)
? (intr.materials as Record<string, unknown>[]).map(normalizeMaterial)
: materialsFromValues(values);
} else if (pausePoint === 'outline_confirm') {
event.outline = normalizeOutline(intr.outline ?? values.current_outline) ?? undefined;
} else if (pausePoint === 'draft_confirm') {
event.draftTitle = String(intr.draft_title ?? draft?.title ?? '');
event.revisionCount = Number(intr.revision_count ?? draft?.revision_count ?? draft?.revisionCount ?? 0);
event.reviewResult = normalizeReviewResult(intr.review_result ?? values.review_result) ?? undefined;
} else if (pausePoint === 'review_confirm') {
event.reviewResult = normalizeReviewResult(intr.review_result ?? values.review_result) ?? undefined;
} else if (pausePoint === 'section_help' && Array.isArray(intr.blocked_sections)) {
event.blockedSections = (intr.blocked_sections as Record<string, unknown>[]).map(normalizeBlockedSection);
}
return event;
}
function buildRehydrateTimeline(values: AIWritingValues, sessionId: string): {
progressEvents: ProgressEvent[];
completedInterventions: CompletedIntervention[];
} {
const rawEvents = Array.isArray(values.progress_events)
? (values.progress_events as Record<string, unknown>[]).map((e) => normalizeProgressDict(e, sessionId))
: [];
const progressEvents: ProgressEvent[] = [];
const completedInterventions: CompletedIntervention[] = [];
for (const event of rawEvents) {
if (event.type === 'resumed') {
const classified = classifyResumedMessage(event.message);
if (classified) {
const pauseEvent = buildAwaitUserEventFromCheckpointPause(values, sessionId, classified.pausePoint);
const record = buildCompletedInterventionFromCheckpointPause(
values,
classified.pausePoint,
classified.action,
);
if (pauseEvent) progressEvents.push(pauseEvent);
if (record) completedInterventions.push(record);
}
}
progressEvents.push(event);
}
return { progressEvents, completedInterventions };
}
function normalizeBlockedSection(b: Record<string, unknown>): BlockedSection { function normalizeBlockedSection(b: Record<string, unknown>): BlockedSection {
return { return {
sectionTitle: String(b.section_title ?? b.sectionTitle ?? ''), sectionTitle: String(b.section_title ?? b.sectionTitle ?? ''),
@@ -583,7 +738,7 @@ export function normalizeProgressDict(parsed: Record<string, unknown>, sessionId
return out as unknown as ProgressEvent; return out as unknown as ProgressEvent;
} }
/** 继续未完成历史:把 graph checkpoint 的 values 快照重建成前端状态快照(用于 ``rehydrate``)。 /** Restore history: rebuild a frontend state snapshot from graph checkpoint values.
* *
* ``status`` 后端与前端共用同一套字面量(state.py WritingStatus ↔ types/ai-writing.ts),可直接 * ``status`` 后端与前端共用同一套字面量(state.py WritingStatus ↔ types/ai-writing.ts),可直接
* 透传;timeline 由累积的 ``progress_events``(operator.add 全量)重放,素材/大纲/草稿/审核读各自 * 透传;timeline 由累积的 ``progress_events``(operator.add 全量)重放,素材/大纲/草稿/审核读各自
@@ -597,9 +752,7 @@ export function buildRehydrateSnapshot(values: AIWritingValues, sessionId: strin
? ((pkg.skill_search_steps ?? pkg.skillSearchSteps) as Record<string, unknown>[]).map(normalizeSkillStep) : []; ? ((pkg.skill_search_steps ?? pkg.skillSearchSteps) as Record<string, unknown>[]).map(normalizeSkillStep) : [];
const draft = (values.current_draft && typeof values.current_draft === 'object') const draft = (values.current_draft && typeof values.current_draft === 'object')
? (values.current_draft as Record<string, unknown>) : null; ? (values.current_draft as Record<string, unknown>) : null;
const progressEvents = Array.isArray(values.progress_events) const { progressEvents, completedInterventions } = buildRehydrateTimeline(values, sessionId);
? (values.progress_events as Record<string, unknown>[]).map((e) => normalizeProgressDict(e, sessionId))
: [];
return { return {
sessionId, sessionId,
@@ -612,6 +765,7 @@ export function buildRehydrateSnapshot(values: AIWritingValues, sessionId: strin
revisionCount: Number(draft?.revision_count ?? draft?.revisionCount ?? 0), revisionCount: Number(draft?.revision_count ?? draft?.revisionCount ?? 0),
reviewResult: normalizeReviewResult(values.review_result), reviewResult: normalizeReviewResult(values.review_result),
progressEvents, progressEvents,
completedInterventions,
}; };
} }
@@ -786,7 +940,11 @@ export type AIWritingValues = {
add_references?: boolean; add_references?: boolean;
/** 不收集素材,直接用通用知识写作(researcher 节点据此短路跳过检索)。 */ /** 不收集素材,直接用通用知识写作(researcher 节点据此短路跳过检索)。 */
skip_research?: boolean; skip_research?: boolean;
model_name?: string;
status?: string; status?: string;
revision_count?: number;
max_revisions?: number;
blocked_sections?: unknown[];
material_package?: unknown; material_package?: unknown;
current_outline?: unknown; current_outline?: unknown;
current_draft?: unknown; current_draft?: unknown;
@@ -121,7 +121,7 @@ export function useWritingRewrite(editor: Editor | null) {
); );
const rewrite = useCallback( const rewrite = useCallback(
async (action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => { async (action: RewriteAction, tone?: RewriteTone, customInstruction?: string, referenceText?: string) => {
if (!writingSelection || !editor) return; if (!writingSelection || !editor) return;
abortRef.current?.abort(); abortRef.current?.abort();
@@ -143,6 +143,7 @@ export function useWritingRewrite(editor: Editor | null) {
after: docText.substring(to, Math.min(docSize, to + 400)), after: docText.substring(to, Math.min(docSize, to + 400)),
}, },
customInstruction: customInstruction || undefined, customInstruction: customInstruction || undefined,
referenceText: referenceText || undefined,
}; };
setStatus("loading"); setStatus("loading");
@@ -156,7 +157,7 @@ export function useWritingRewrite(editor: Editor | null) {
); );
const rewriteFollowUp = useCallback( const rewriteFollowUp = useCallback(
async (instruction: string, action: RewriteAction, tone?: RewriteTone) => { async (instruction: string, action: RewriteAction, tone?: RewriteTone, referenceText?: string) => {
if (!writingSelection || !editor) return; if (!writingSelection || !editor) return;
const prevResult = result; const prevResult = result;
if (!prevResult) return; if (!prevResult) return;
@@ -179,6 +180,8 @@ export function useWritingRewrite(editor: Editor | null) {
before: docText.substring(Math.max(0, from - 400), from), before: docText.substring(Math.max(0, from - 400), from),
after: docText.substring(to, Math.min(docSize, to + 400)), after: docText.substring(to, Math.min(docSize, to + 400)),
}, },
customInstruction: undefined,
referenceText: referenceText || undefined,
followUpInstruction: instruction, followUpInstruction: instruction,
previousResult: prevResult, previousResult: prevResult,
}; };
@@ -81,11 +81,26 @@
* -d '{"retention_days": 0}' * -d '{"retention_days": 0}'
* ────────────────────────────────────────────────────────────────────────── * ──────────────────────────────────────────────────────────────────────────
*/ */
import { useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, History, PanelLeftOpen, PanelRightOpen } from 'lucide-react'; import {
ArrowLeft,
History,
Loader2,
PanelLeftClose,
PanelLeftOpen,
PanelRightOpen,
} from 'lucide-react';
import { TooltipProvider } from '@/components/ui/tooltip'; import { TooltipProvider } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { GroupImperativeHandle } from 'react-resizable-panels'; import type { GroupImperativeHandle } from 'react-resizable-panels';
import {
Navigate,
Route,
Routes,
useLocation,
useNavigate,
useParams,
} from 'react-router-dom';
import { CanvasThreadProvider } from '../contexts/CanvasThreadContext'; import { CanvasThreadProvider } from '../contexts/CanvasThreadContext';
import { CanvasGraphProvider } from '../contexts/CanvasGraphContext'; import { CanvasGraphProvider } from '../contexts/CanvasGraphContext';
@@ -96,7 +111,15 @@ import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
import { ResearchSidebar } from '../components/ai-writing/ResearchSidebar'; import { ResearchSidebar } from '../components/ai-writing/ResearchSidebar';
import StudioNotebookDetailPage from '@/pages/StudioNotebookDetailPage'; import StudioNotebookDetailPage from '@/pages/StudioNotebookDetailPage';
import StudioNotebooksPage from '@/pages/StudioNotebooksPage'; import StudioNotebooksPage from '@/pages/StudioNotebooksPage';
import AgentChatPage from '@/pages/AgentChatPage';
import { ChatRuntime } from '@/pages/WorkspaceRoutes'; import { ChatRuntime } from '@/pages/WorkspaceRoutes';
import {
AgentModuleSidebar,
useAgentModuleSidebarData,
} from '@/components/workspace/agents/agent-module-sidebar';
import { AgentModuleSettingsDialog } from '@/components/workspace/agents/agent-module-settings-dialog';
import { getStoredAuth } from '@/core/auth';
import { RouteBaseProvider } from '@/core/route-base';
/** 右侧内嵌视图:笔记本详情 / 所有空间列表 / null(写作) */ /** 右侧内嵌视图:笔记本详情 / 所有空间列表 / null(写作) */
type RightView = { kind: 'notebook'; id: number } | { kind: 'allSpaces' } | null; type RightView = { kind: 'notebook'; id: number } | { kind: 'allSpaces' } | null;
@@ -111,11 +134,83 @@ import {
// Panel IDs used as keys in the v4 Layout map // Panel IDs used as keys in the v4 Layout map
const WRITING_ID = 'aw-writing'; const WRITING_ID = 'aw-writing';
const DRAFT_ID = 'aw-draft'; const DRAFT_ID = 'aw-draft';
const AI_WRITING_AGENT_MODULE_KEY = 'ai-writing';
const AI_WRITING_AGENT_MODULE_TITLE = 'AI 写作智能体';
// Match the original canvas page proportions (chat=35, artifact=65) // Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 }; const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
function useAIWritingRouteBase() {
const { pathname } = useLocation();
return useMemo(() => {
const marker = '/ai-writing';
const index = pathname.indexOf(marker);
return index >= 0
? pathname.slice(0, index + marker.length)
: '/page/workspace/ai-writing';
}, [pathname]);
}
function useAIWritingAgentRouteState(base: string) {
const { pathname } = useLocation();
return useMemo(() => {
const prefix = `${base}/agents/`;
if (!pathname.startsWith(prefix)) {
return { active: false, agentId: undefined, threadId: undefined };
}
const parts = pathname.slice(prefix.length).split('/');
return {
active: true,
agentId: parts[0] ? decodeURIComponent(parts[0]) : undefined,
threadId: parts[2] ? decodeURIComponent(parts[2]) : undefined,
};
}, [base, pathname]);
}
function AIWritingAgentDefaultRedirect() {
const { groups, isLoading } = useAgentModuleSidebarData(AI_WRITING_AGENT_MODULE_KEY);
const firstAgent = groups.flatMap((group) => group.agents)[0];
if (isLoading) {
return (
<div className="flex size-full items-center justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
if (!firstAgent) {
return (
<div className="flex size-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
暂无可用智能体,请先在模块设置中配置或创建智能体。
</div>
);
}
return <Navigate to={`agents/${firstAgent.id}/chats/new`} replace />;
}
function AIWritingAgentChatRoute() {
const { agent_id } = useParams<{ agent_id: string }>();
return (
<ChatRuntime>
<AgentChatPage key={agent_id} />
</ChatRuntime>
);
}
function AIWritingAgentRoutes({ base }: { base: string }) {
return (
<RouteBaseProvider value={base}>
<Routes>
<Route index element={<AIWritingAgentDefaultRedirect />} />
<Route path="agents/:agent_id/chats/:thread_id" element={<AIWritingAgentChatRoute />} />
</Routes>
</RouteBaseProvider>
);
}
/** /**
* 写作配置表单面板(写作助手回填的目标),抽出复用:idle 时在右栏,开始写作后在左栏。 * 写作配置表单面板(写作助手回填的目标),抽出复用:idle 时在右栏,开始写作后在左栏。
*/ */
@@ -230,6 +325,11 @@ function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumen
function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) { function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
const { state } = useAIWriting(); const { state } = useAIWriting();
const { isHistoryMode } = useAIWritingHistory(); const { isHistoryMode } = useAIWritingHistory();
const location = useLocation();
const navigate = useNavigate();
const routeBase = useAIWritingRouteBase();
const agentRoute = useAIWritingAgentRouteState(routeBase);
const isAdmin = getStoredAuth()?.system_role === 'admin';
// 需求 0616 #5:左栏点「笔记本 / 所有空间」→ 在右侧内嵌打开对应页,替换右侧内容 // 需求 0616 #5:左栏点「笔记本 / 所有空间」→ 在右侧内嵌打开对应页,替换右侧内容
// (写作布局不卸载,仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程)。 // (写作布局不卸载,仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程)。
@@ -239,18 +339,38 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
// 侧栏「收起」态:由页面承载,ResearchSidebar 顶部的收起按钮触发,收起后显示一条细窄展开条。 // 侧栏「收起」态:由页面承载,ResearchSidebar 顶部的收起按钮触发,收起后显示一条细窄展开条。
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [sidebarTab, setSidebarTab] = useState<'research' | 'agents'>('agents');
const [settingsOpen, setSettingsOpen] = useState(false);
useEffect(() => {
if (agentRoute.active) {
setSidebarTab('agents');
}
}, [agentRoute.active]);
const agentsActive = sidebarTab === 'agents';
// 研究模板侧栏在「写作配置(idle)」与「历史回看」两态都展示(点历史记录后保留侧栏、 // 研究模板侧栏在「写作配置(idle)」与「历史回看」两态都展示(点历史记录后保留侧栏、
// 历史内容显示到右侧区域);写作进行中收起;嵌入态(studioToken)始终不展示。 // 历史内容显示到右侧区域);写作进行中收起;嵌入态(studioToken)始终不展示。
const showSidebar = !studioToken && (state.status === 'idle' || isHistoryMode); const showSidebar =
!studioToken && (state.status === 'idle' || isHistoryMode || agentsActive);
return ( return (
<div className="flex-1 min-h-0 overflow-hidden flex"> <div className="flex-1 min-h-0 overflow-hidden flex">
{/* 课题研究左栏(研究模板 + 笔记本 + 历史记录)。支持收起为细窄展开条。 {/* 左栏:课题研究 / 智能体。课题研究支持收起为细窄展开条。 */}
写作配置(idle) 与 历史回看 两态都显示;写作进行中整体收起。 */} {showSidebar && (
{showSidebar && <div
(sidebarCollapsed ? ( className={cn(
<div className="flex h-full w-11 shrink-0 flex-col items-center gap-2 border-r border-slate-100 bg-white py-3"> 'relative h-full shrink-0 overflow-hidden border-r border-slate-100 bg-white text-slate-700 shadow-[1px_0_18px_rgba(15,23,42,0.03)] transition-[width] duration-300 ease-in-out',
sidebarCollapsed ? 'w-11' : 'w-[320px]',
)}
>
<div
className={cn(
'absolute inset-0 flex flex-col items-center gap-2 py-3 transition-opacity duration-200 ease-out',
sidebarCollapsed ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
<button <button
type="button" type="button"
onClick={() => setSidebarCollapsed(false)} onClick={() => setSidebarCollapsed(false)}
@@ -262,21 +382,86 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
</button> </button>
<History className="size-4 text-slate-300" /> <History className="size-4 text-slate-300" />
</div> </div>
) : ( <aside
<ResearchSidebar className={cn(
viewingNotebookId={viewingNotebookId} 'absolute inset-y-0 left-0 flex h-full w-[320px] flex-col overflow-hidden transition-all duration-300 ease-in-out',
onOpenNotebook={(id) => setRightView({ kind: 'notebook', id })} sidebarCollapsed ? 'pointer-events-none -translate-x-4 opacity-0' : 'translate-x-0 opacity-100',
onOpenAllSpaces={() => setRightView({ kind: 'allSpaces' })} )}
onCollapse={() => setSidebarCollapsed(true)} >
/> <div className="flex shrink-0 items-center gap-2 border-b border-slate-100 px-3 py-2">
))} <div className="grid flex-1 grid-cols-2 rounded-lg bg-slate-100 p-1">
<button
type="button"
onClick={() => {
setSidebarTab('agents');
setRightView(null);
if (!agentRoute.active) navigate(routeBase);
}}
className={cn(
'rounded-md px-2 py-1.5 text-xs font-semibold transition-colors',
sidebarTab === 'agents'
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-800',
)}
>
课题研究
</button>
<button
type="button"
onClick={() => {
setSidebarTab('research');
setRightView(null);
if (location.pathname !== routeBase) navigate(routeBase);
}}
className={cn(
'rounded-md px-2 py-1.5 text-xs font-semibold transition-colors',
sidebarTab === 'research'
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-800',
)}
>
ai写作
</button>
</div>
<button
type="button"
onClick={() => setSidebarCollapsed(true)}
title="收起侧栏"
aria-label="收起侧栏"
className="rounded-md p-1 text-slate-400 transition-colors hover:bg-slate-50 hover:text-slate-700"
>
<PanelLeftClose className="size-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{sidebarTab === 'research' ? (
<ResearchSidebar
viewingNotebookId={viewingNotebookId}
onOpenNotebook={(id) => setRightView({ kind: 'notebook', id })}
onOpenAllSpaces={() => setRightView({ kind: 'allSpaces' })}
/>
) : (
<AgentModuleSidebar
moduleKey={AI_WRITING_AGENT_MODULE_KEY}
activeAgentId={agentRoute.agentId}
activeThreadId={agentRoute.threadId}
routeBase={routeBase}
headerTitle={AI_WRITING_AGENT_MODULE_TITLE}
className="w-full border-r-0"
onOpenSettings={isAdmin ? () => setSettingsOpen(true) : undefined}
/>
)}
</div>
</aside>
</div>
)}
{/* 右侧:写作布局常驻(仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程), {/* 右侧:写作布局常驻(仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程),
查看笔记本 / 所有空间时在其上叠加内嵌页。 */} 查看笔记本 / 所有空间时在其上叠加内嵌页。 */}
<div className="relative flex-1 min-h-0 overflow-hidden"> <div className="relative flex-1 min-h-0 overflow-hidden">
<div className={cn('absolute inset-0', overlayActive && 'hidden')}> <div className={cn('absolute inset-0', (overlayActive || agentsActive) && 'hidden')}>
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} /> <AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
</div> </div>
{overlayActive && ( {overlayActive && !agentsActive && (
<div className="absolute inset-0 flex flex-col bg-background"> <div className="absolute inset-0 flex flex-col bg-background">
<div className="flex shrink-0 items-center gap-1 border-b border-border px-4 py-2"> <div className="flex shrink-0 items-center gap-1 border-b border-border px-4 py-2">
<button <button
@@ -313,7 +498,20 @@ function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentI
</div> </div>
</div> </div>
)} )}
{agentsActive && (
<div className="absolute inset-0 bg-background">
<AIWritingAgentRoutes base={routeBase} />
</div>
)}
</div> </div>
{isAdmin && (
<AgentModuleSettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
moduleKey={AI_WRITING_AGENT_MODULE_KEY}
moduleTitle={AI_WRITING_AGENT_MODULE_TITLE}
/>
)}
</div> </div>
); );
} }
@@ -331,30 +529,30 @@ export default function AIWritingPage({
} = {}) { } = {}) {
return ( return (
<CanvasThreadProvider> <CanvasThreadProvider>
<CanvasGraphProvider> <CanvasGraphProvider>
<CanvasUIProvider> <CanvasUIProvider>
<AIWritingHistoryProvider> <AIWritingHistoryProvider>
<AIWritingProvider> <AIWritingProvider>
<WritingFormProvider <WritingFormProvider
initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined} initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}
initialNotebookId={initialNotebookId} initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds} initialDocumentIds={initialDocumentIds}
> >
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background canvas-writing-bg"> <div className="h-full flex flex-col overflow-hidden bg-background canvas-writing-bg">
<AIWritingBody <AIWritingBody
hideAdvancedFields={hideAdvancedFields} hideAdvancedFields={hideAdvancedFields}
initialNotebookId={initialNotebookId} initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds} initialDocumentIds={initialDocumentIds}
studioToken={studioToken} studioToken={studioToken}
/> />
</div> </div>
</TooltipProvider> </TooltipProvider>
</WritingFormProvider> </WritingFormProvider>
</AIWritingProvider> </AIWritingProvider>
</AIWritingHistoryProvider> </AIWritingHistoryProvider>
</CanvasUIProvider> </CanvasUIProvider>
</CanvasGraphProvider> </CanvasGraphProvider>
</CanvasThreadProvider> </CanvasThreadProvider>
); );
} }
@@ -11,7 +11,7 @@ export default function OpenCanvasRoutes() {
return ( return (
<Routes> <Routes>
<Route index element={<CanvasHomePage />} /> <Route index element={<CanvasHomePage />} />
<Route path="ai-writing" element={<AIWritingPage hideAdvancedFields />} /> <Route path="ai-writing/*" element={<AIWritingPage hideAdvancedFields />} />
<Route path="article-types" element={<ArticleTypesConfigPage />} /> <Route path="article-types" element={<ArticleTypesConfigPage />} />
<Route path="research-templates" element={<ResearchTemplatesConfigPage />} /> <Route path="research-templates" element={<ResearchTemplatesConfigPage />} />
<Route path="research-templates/new" element={<ResearchTemplateEditorPage />} /> <Route path="research-templates/new" element={<ResearchTemplateEditorPage />} />
+1 -1
View File
@@ -334,7 +334,7 @@ export default function WorkspaceRoutes({
} }
/> />
<Route <Route
path="ai-writing" path="ai-writing/*"
element={ element={
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps}>
<AIWritingPage hideAdvancedFields /> <AIWritingPage hideAdvancedFields />
@@ -42,8 +42,12 @@ _ACTION_INSTRUCTIONS: dict[str, str] = {
"expand": "请对以下文字进行扩写,补充细节和说明,使内容更丰富饱满。", "expand": "请对以下文字进行扩写,补充细节和说明,使内容更丰富饱满。",
"shorten": "请对以下文字进行缩写,保留核心意思,去除冗余表达,使其更简洁。", "shorten": "请对以下文字进行缩写,保留核心意思,去除冗余表达,使其更简洁。",
"tone": "请调整以下文字的语气风格。", "tone": "请调整以下文字的语气风格。",
"imitate": "请模仿给定范文的写作风格、句式与语气,对以下文字进行仿写改写,在保留原文核心信息的前提下尽量贴近范文的表达方式。",
} }
# 仿写参考范文的最大长度(防止超长 prompt)
_REFERENCE_MAX_CHARS = 2000
_TONE_SUFFIX: dict[str, str] = { _TONE_SUFFIX: dict[str, str] = {
"professional": "目标风格:专业、正式。", "professional": "目标风格:专业、正式。",
"casual": "目标风格:轻松、口语化。", "casual": "目标风格:轻松、口语化。",
@@ -72,11 +76,12 @@ class SurroundingContext(BaseModel):
class RewriteRequest(BaseModel): class RewriteRequest(BaseModel):
thread_id: str | None = Field(default=None, alias="threadId") thread_id: str | None = Field(default=None, alias="threadId")
selection: SelectionInfo selection: SelectionInfo
action: str = Field(..., description="polish | expand | shorten | tone") action: str = Field(..., description="polish | expand | shorten | tone | imitate")
tone: str | None = Field(default=None) tone: str | None = Field(default=None)
model_name: str | None = Field(default=None, alias="modelName") model_name: str | None = Field(default=None, alias="modelName")
surrounding_context: SurroundingContext = Field(default_factory=SurroundingContext, alias="surroundingContext") surrounding_context: SurroundingContext = Field(default_factory=SurroundingContext, alias="surroundingContext")
custom_instruction: str | None = Field(default=None, alias="customInstruction") custom_instruction: str | None = Field(default=None, alias="customInstruction")
reference_text: str | None = Field(default=None, alias="referenceText", description="仿写参考范文(action=imitate)")
follow_up_instruction: str | None = Field(default=None, alias="followUpInstruction") follow_up_instruction: str | None = Field(default=None, alias="followUpInstruction")
previous_result: str | None = Field(default=None, alias="previousResult") previous_result: str | None = Field(default=None, alias="previousResult")
@@ -121,18 +126,15 @@ def _extract_chunk_parts(chunk: object) -> tuple[str, str]:
return thinking, text return thinking, text
# ── Endpoint ────────────────────────────────────────────────────────────────── # ── Prompt building (pure, testable) ──────────────────────────────────────────
@router.post( def build_rewrite_messages(body: RewriteRequest) -> tuple[str, list]:
"/rewrite", """Build (resolved_action, messages) for the rewrite request.
summary="AI Text Rewrite (SSE stream)",
response_class=StreamingResponse, Pure function (no model / IO) so the prompt assembly — including 仿写
) (imitate) reference-sample injection — can be unit-tested directly.
async def rewrite_selection( """
body: RewriteRequest,
config: AppConfig = Depends(get_config),
) -> StreamingResponse:
action = body.action if body.action in _ACTION_INSTRUCTIONS else "polish" action = body.action if body.action in _ACTION_INSTRUCTIONS else "polish"
instruction = _ACTION_INSTRUCTIONS[action] instruction = _ACTION_INSTRUCTIONS[action]
if action == "tone" and body.tone and body.tone in _TONE_SUFFIX: if action == "tone" and body.tone and body.tone in _TONE_SUFFIX:
@@ -153,6 +155,9 @@ async def rewrite_selection(
base_instruction = instruction base_instruction = instruction
if body.custom_instruction: if body.custom_instruction:
base_instruction = f"{instruction}\n用户额外要求:{body.custom_instruction}" base_instruction = f"{instruction}\n用户额外要求:{body.custom_instruction}"
if action == "imitate" and body.reference_text and body.reference_text.strip():
reference = body.reference_text.strip()[:_REFERENCE_MAX_CHARS]
base_instruction = f"{base_instruction}\n\n【范文】(请模仿其文风、句式、语气与结构,但不要照抄其具体内容):\n{reference}"
user_message = f"{base_instruction}\n\n{context_block}需要改写的文字:\n{body.selection.text}" user_message = f"{base_instruction}\n\n{context_block}需要改写的文字:\n{body.selection.text}"
if body.follow_up_instruction and body.previous_result: if body.follow_up_instruction and body.previous_result:
@@ -165,6 +170,23 @@ async def rewrite_selection(
else: else:
messages = [SystemMessage(content=_SYSTEM_PROMPT), HumanMessage(content=user_message)] messages = [SystemMessage(content=_SYSTEM_PROMPT), HumanMessage(content=user_message)]
return action, messages
# ── Endpoint ──────────────────────────────────────────────────────────────────
@router.post(
"/rewrite",
summary="AI Text Rewrite (SSE stream)",
response_class=StreamingResponse,
)
async def rewrite_selection(
body: RewriteRequest,
config: AppConfig = Depends(get_config),
) -> StreamingResponse:
action, messages = build_rewrite_messages(body)
# Auto-enable thinking when the selected model supports it # Auto-enable thinking when the selected model supports it
resolved_name = body.model_name or (config.models[0].name if config.models else None) resolved_name = body.model_name or (config.models[0].name if config.models else None)
model_cfg = config.get_model_config(resolved_name) if resolved_name else None model_cfg = config.get_model_config(resolved_name) if resolved_name else None
@@ -440,6 +440,7 @@ ai_writing:
# 接口契约:POST {url} 体 {"query","knowledge_base_name","ts":"yes"} → [{"page_content","metadata":{m_title,content,recUuid,m_publish,source1}}] # 接口契约:POST {url} 体 {"query","knowledge_base_name","ts":"yes"} → [{"page_content","metadata":{m_title,content,recUuid,m_publish,source1}}]
intranet_search_url: "" # 例如 http://10.106.57.180:7863/knowledge_base/search_docs_es intranet_search_url: "" # 例如 http://10.106.57.180:7863/knowledge_base/search_docs_es
intranet_knowledge_base: "samples" # 兜底检索用的知识库名 intranet_knowledge_base: "samples" # 兜底检索用的知识库名
intranet_search_timeout: 300 # 内网检索超时时间(秒),代码层强制最低 300;需要更长可调大
# 内网接口诊断开关:开启后即使技能已检索成功,也照样把内网接口跑完并把执行结果 # 内网接口诊断开关:开启后即使技能已检索成功,也照样把内网接口跑完并把执行结果
# (端点/每个检索词命中数/报错/原始响应片段)记到 material_package.intranet_debug 里, # (端点/每个检索词命中数/报错/原始响应片段)记到 material_package.intranet_debug 里,
# 便于在接口返回(devtools)排查内网接口到底通不通。生产建议关掉(会多跑一次内网)。 # 便于在接口返回(devtools)排查内网接口到底通不通。生产建议关掉(会多跑一次内网)。
@@ -594,6 +594,7 @@ async def run_skill_search_agent(
results: list = [] results: list = []
seen_urls: set[str] = set() seen_urls: set[str] = set()
last_error = "" last_error = ""
search_timeout = max(timeout_s, _REAL_AGENT_TIMEOUT_SECONDS)
for _turn in range(_MAX_TURNS): for _turn in range(_MAX_TURNS):
try: try:
@@ -616,10 +617,10 @@ async def run_skill_search_agent(
emit_query(query) emit_query(query)
try: try:
found = await asyncio.wait_for( found = await asyncio.wait_for(
provider.search(query, max_results=max_results), timeout=timeout_s provider.search(query, max_results=max_results), timeout=search_timeout
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
last_error = f"检索超时(>{timeout_s}s)" last_error = f"检索超时(>{search_timeout}s)"
messages.append(ToolMessage(content=last_error, tool_call_id=tc_id)) messages.append(ToolMessage(content=last_error, tool_call_id=tc_id))
continue continue
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
@@ -102,6 +102,14 @@ _KNOWLEDGE_SKILL_DISPLAY = "知识库检索"
_KNOWLEDGE_SKILL_FALLBACK_DESC = "对写作意图提炼的检索词执行 web/intranet 知识库检索,获取相关素材" _KNOWLEDGE_SKILL_FALLBACK_DESC = "对写作意图提炼的检索词执行 web/intranet 知识库检索,获取相关素材"
def _intranet_timeout_seconds(ai_writing_config: dict) -> int:
"""内网检索超时统一入口:默认 10 分钟,任何配置都至少 5 分钟。"""
from ..search.intranet_stub import normalize_intranet_timeout
raw = (ai_writing_config or {}).get("intranet_search_timeout")
return normalize_intranet_timeout(raw)
def _fallback_knowledge_skill() -> SimpleNamespace: def _fallback_knowledge_skill() -> SimpleNamespace:
"""技能目录里找不到 knowledge-base-search 时的内存兜底(原生路径不依赖 SKILL.md)。""" """技能目录里找不到 knowledge-base-search 时的内存兜底(原生路径不依赖 SKILL.md)。"""
return SimpleNamespace(name=KNOWLEDGE_SEARCH_SKILL_NAME, description=_KNOWLEDGE_SKILL_FALLBACK_DESC) return SimpleNamespace(name=KNOWLEDGE_SEARCH_SKILL_NAME, description=_KNOWLEDGE_SKILL_FALLBACK_DESC)
@@ -581,8 +589,9 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
provider = IntranetSearchProvider( provider = IntranetSearchProvider(
base_url=intranet_url, # 空串 → provider 内部回退默认内网端点(旧逻辑) base_url=intranet_url, # 空串 → provider 内部回退默认内网端点(旧逻辑)
knowledge_base_name=ai_writing_config.get("intranet_knowledge_base", "samples"), knowledge_base_name=ai_writing_config.get("intranet_knowledge_base", "samples"),
# 内网慢检索超时可在 config.yaml 的 ai_writing.intranet_search_timeout 调大,默认 600s。 # 内网慢检索超时可在 config.yaml 的 ai_writing.intranet_search_timeout 调大,
timeout=ai_writing_config.get("intranet_search_timeout", 600), # 默认 600s,且最低 300s。
timeout=_intranet_timeout_seconds(ai_writing_config),
) )
endpoint_label = intranet_url or getattr(provider, "endpoint", "内置默认内网端点") endpoint_label = intranet_url or getattr(provider, "endpoint", "内置默认内网端点")
# 用户输入框追加(is_user_query_search)时 keywords 已是 [用户原话],全部检索; # 用户输入框追加(is_user_query_search)时 keywords 已是 [用户原话],全部检索;
@@ -833,7 +842,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
"configured": bool(intranet_url), "configured": bool(intranet_url),
"endpoint": intranet_url or "(未配置 ai_writing.intranet_search_url)", "endpoint": intranet_url or "(未配置 ai_writing.intranet_search_url)",
"knowledge_base": ai_writing_config.get("intranet_knowledge_base", "samples"), "knowledge_base": ai_writing_config.get("intranet_knowledge_base", "samples"),
"timeout": ai_writing_config.get("intranet_search_timeout", 600), "timeout": _intranet_timeout_seconds(ai_writing_config),
"debug_force": intranet_debug_force, "debug_force": intranet_debug_force,
"status": "pending" if intranet_url else "not_configured", "status": "pending" if intranet_url else "not_configured",
"queries": [], # 每个检索词:{query, count, error?} "queries": [], # 每个检索词:{query, count, error?}
@@ -853,8 +862,8 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
base_url=intranet_url, base_url=intranet_url,
knowledge_base_name=ai_writing_config.get("intranet_knowledge_base", "samples"), knowledge_base_name=ai_writing_config.get("intranet_knowledge_base", "samples"),
# 兜底接口超时可在 config.yaml 的 ai_writing.intranet_search_timeout 调大, # 兜底接口超时可在 config.yaml 的 ai_writing.intranet_search_timeout 调大,
# 默认 600s(10 分钟),给内网慢检索留足时间。 # 默认 600s(10 分钟),且最低 300s。
timeout=ai_writing_config.get("intranet_search_timeout", 600), timeout=_intranet_timeout_seconds(ai_writing_config),
) )
intranet_holder["provider"] = intranet intranet_holder["provider"] = intranet
fb_keywords = keywords[:keyword_count] or [intent] fb_keywords = keywords[:keyword_count] or [intent]
@@ -1,6 +1,6 @@
from .base import BaseSearchProvider, SearchResult from .base import BaseSearchProvider, SearchResult
from .internet_search import InternetSearchProvider from .internet_search import InternetSearchProvider
from .intranet_stub import IntranetSearchProvider from .intranet_stub import IntranetSearchProvider, normalize_intranet_timeout
from .demo_search import DemoSearchProvider from .demo_search import DemoSearchProvider
from .duckduckgo_search import DuckDuckGoSearchProvider from .duckduckgo_search import DuckDuckGoSearchProvider
from .skill_search import SkillSearchProvider from .skill_search import SkillSearchProvider
@@ -13,6 +13,7 @@ def get_search_provider(ai_writing_config: dict) -> BaseSearchProvider:
search_provider: internet # internet(默认,DDG) / intranet / tavily / demo search_provider: internet # internet(默认,DDG) / intranet / tavily / demo
intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
intranet_knowledge_base: samples intranet_knowledge_base: samples
intranet_search_timeout: 300 # 内网检索超时秒数,最少 300 秒
ddg_region: "cn-zh" # DuckDuckGo 区域,默认 cn-zh ddg_region: "cn-zh" # DuckDuckGo 区域,默认 cn-zh
internet 模式:DuckDuckGo 为主搜索(免 API Key),失败时 fallback 到 demo 演示素材。 internet 模式:DuckDuckGo 为主搜索(免 API Key),失败时 fallback 到 demo 演示素材。
@@ -24,6 +25,7 @@ def get_search_provider(ai_writing_config: dict) -> BaseSearchProvider:
return IntranetSearchProvider( return IntranetSearchProvider(
base_url=cfg.get("intranet_search_url", ""), base_url=cfg.get("intranet_search_url", ""),
knowledge_base_name=cfg.get("intranet_knowledge_base", "samples"), knowledge_base_name=cfg.get("intranet_knowledge_base", "samples"),
timeout=normalize_intranet_timeout(cfg.get("intranet_search_timeout")),
) )
if provider == "tavily": if provider == "tavily":
return _FallbackSearchProvider(InternetSearchProvider(), DemoSearchProvider()) return _FallbackSearchProvider(InternetSearchProvider(), DemoSearchProvider())
@@ -11,10 +11,22 @@ logger = logging.getLogger(__name__)
# 内网知识库默认检索接口与知识库名(可经 config.yaml 的 ai_writing 段覆盖) # 内网知识库默认检索接口与知识库名(可经 config.yaml 的 ai_writing 段覆盖)
_DEFAULT_ENDPOINT = "http://10.106.57.180:7863/knowledge_base/search_docs_es" _DEFAULT_ENDPOINT = "http://10.106.57.180:7863/knowledge_base/search_docs_es"
_DEFAULT_KB = "samples" _DEFAULT_KB = "samples"
# 内网检索最少等待 5 分钟;该接口经常慢返回,低于这个值会造成素材收集误判为空。
_MIN_TIMEOUT_SECONDS = 300
_DEFAULT_TIMEOUT_SECONDS = 600
# 单条原始响应体最大保留长度,避免把超大响应整段灌进前端时间线 # 单条原始响应体最大保留长度,避免把超大响应整段灌进前端时间线
_RAW_SNIPPET_LIMIT = 2000 _RAW_SNIPPET_LIMIT = 2000
def normalize_intranet_timeout(timeout: object, default: int = _DEFAULT_TIMEOUT_SECONDS) -> int:
"""返回内网检索超时秒数,并强制不低于 5 分钟。"""
try:
value = int(timeout)
except (TypeError, ValueError):
value = default
return max(value, _MIN_TIMEOUT_SECONDS)
def _pick(d: dict, keys: tuple) -> str: def _pick(d: dict, keys: tuple) -> str:
"""从结果项里按候选字段名取第一个非空值(两套接口字段名不同,做并集兼容)。""" """从结果项里按候选字段名取第一个非空值(两套接口字段名不同,做并集兼容)。"""
for k in keys: for k in keys:
@@ -38,17 +50,13 @@ class IntranetSearchProvider(BaseSearchProvider):
self, self,
base_url: str = "", base_url: str = "",
knowledge_base_name: str = _DEFAULT_KB, knowledge_base_name: str = _DEFAULT_KB,
timeout: int = 600, timeout: int = _DEFAULT_TIMEOUT_SECONDS,
**_ignored, **_ignored,
): ):
self.endpoint = base_url or _DEFAULT_ENDPOINT self.endpoint = base_url or _DEFAULT_ENDPOINT
self.knowledge_base_name = knowledge_base_name or _DEFAULT_KB self.knowledge_base_name = knowledge_base_name or _DEFAULT_KB
# 内网 ES 接口很慢,兜底检索超时放宽到 10 分钟(不依赖 env 配置);构造方可传更大值覆盖。 # 内网 ES 接口很慢,兜底检索超时默认 10 分钟,且无论配置如何至少 5 分钟。
# 「咱们先把自己的做好」:我方兜底超时给足,避免因我方过早超时被甩锅成「检不出」。 self.timeout = normalize_intranet_timeout(timeout)
try:
self.timeout = max(int(timeout), 1)
except (TypeError, ValueError):
self.timeout = 600
# 收集本次运行的检索异常,供 researcher 节点取出并提示用户(ES 波动等) # 收集本次运行的检索异常,供 researcher 节点取出并提示用户(ES 波动等)
self.errors: list[str] = [] self.errors: list[str] = []
# 解析失败时收集的原始响应体,供前端「错误返回结果」折叠展示 # 解析失败时收集的原始响应体,供前端「错误返回结果」折叠展示