feat(ai写作): 左栏智能体 Tab 内嵌问答并优化模块侧栏
AI 写作页默认展示智能体 Tab,在页内复用 ai-writing 模块配置与 AgentChatPage;确认类型后侧栏扁平展示类型名并支持整块收起动画。同步历史回看、流式续跑、改写菜单与 researcher 检索相关改动。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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}
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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)排查内网接口到底通不通。生产建议关掉(会多跑一次内网)。
|
||||||
|
|||||||
+3
-2
@@ -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
|
||||||
|
|||||||
+14
-5
@@ -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]
|
||||||
|
|||||||
+3
-1
@@ -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())
|
||||||
|
|||||||
+15
-7
@@ -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] = []
|
||||||
# 解析失败时收集的原始响应体,供前端「错误返回结果」折叠展示
|
# 解析失败时收集的原始响应体,供前端「错误返回结果」折叠展示
|
||||||
|
|||||||
Reference in new issue
Block a user