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

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

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

No files matched your search

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