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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「按类型聚合智能体 + 聊天记录」左栏(需求 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}
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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)排查内网接口到底通不通。生产建议关掉(会多跑一次内网)。
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+14
-5
@@ -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]
|
||||
|
||||
+3
-1
@@ -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())
|
||||
|
||||
+15
-7
@@ -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] = []
|
||||
# 解析失败时收集的原始响应体,供前端「错误返回结果」折叠展示
|
||||
|
||||
Reference in new issue
Block a user