代码提交

This commit is contained in:
董宏凯 committed 2026-08-16 19:17:45 +08:00
1 parent 70da7c5649
commit 178572bc7f
79 files changed
+13456 -798

No files matched your search

@@ -16,9 +16,11 @@ import { getStoredAuth } from "@/core/auth";
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
import { AgentCard } from "./agent-card";
import { SentimentAgentCard } from "./sentiment-agent-card";
const PAGE_SIZE = 12;
@@ -30,6 +32,7 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
export function AgentGallery() {
const { t } = useI18n();
const router = useRouter();
const sentimentAgentVisible = isSentimentAgentVisible();
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
const isAdmin = getStoredAuth()?.system_role === "admin";
const [viewMode, setViewMode] = useViewMode("agent");
@@ -41,6 +44,9 @@ export function AgentGallery() {
// Each configured publish square (发布广场) becomes its own browse tab; the
// backend always returns at least one (the built-in default).
const squareTabs = publishSquares?.squares ?? [];
// 默认广场固定排在「我的」之后;舆情分析是依附在默认广场后的前端虚拟类型,
// 其余自定义广场继续保持后端给出的顺序。
const [defaultSquare, ...additionalSquares] = squareTabs;
// Publisher filter: click a card's 发布者 to keep only that user's agents.
// {id} is the owner user_id (server filter), {label} the desensitized name.
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
@@ -70,6 +76,7 @@ export function AgentGallery() {
activeTab !== "mine" &&
activeTab !== "private" &&
activeTab !== "builtin" &&
activeTab !== "sentiment" &&
squareTabs.length > 0 &&
!squareTabs.some((sq) => sq.id === activeTab)
) {
@@ -90,9 +97,13 @@ export function AgentGallery() {
// Only the active tab fetches. "广场" maps to the server "square" scope
// (built-in/published, never private), "我的" to "mine" (owned + favorited),
// "内置" to "builtin" (admin-only, user_id IS NULL).
// "内置" to "builtin" (admin-only, user_id IS NULL). 「舆情分析」是前端虚拟
// 类型,不查后端列表。
const isSquareTab =
activeTab !== "mine" && activeTab !== "private" && activeTab !== "builtin";
activeTab !== "mine" &&
activeTab !== "private" &&
activeTab !== "builtin" &&
activeTab !== "sentiment";
const mainScope =
activeTab === "mine"
? "mine"
@@ -112,7 +123,7 @@ export function AgentGallery() {
sort,
page: mainPage,
pageSize: PAGE_SIZE,
enabled: activeTab !== "private",
enabled: activeTab !== "private" && activeTab !== "sentiment",
owner: publisher?.id,
// Each square tab fetches only that square's agents.
square: isSquareTab ? activeTab : undefined,
@@ -148,9 +159,16 @@ export function AgentGallery() {
// password has been verified. Until then we show the gate (or the lock
// prompt) and hide the keyword/tag search bar — there is no list to filter.
const privateUnlocked = privateFeatureUsable && !!privatePassword;
const showSearchBar = activeTab !== "private" || privateUnlocked;
const showSearchBar =
activeTab !== "sentiment" &&
(activeTab !== "private" || privateUnlocked);
const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
const activeTotal =
activeTab === "sentiment"
? 1
: activeTab === "private"
? privateTotal
: mainTotal;
const setTabPage = (tab: string, page: number) =>
setPages((prev) => ({ ...prev, [tab]: page }));
@@ -398,7 +416,22 @@ export function AgentGallery() {
<div className="flex items-center justify-between gap-3">
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start">
<TabsTrigger value="mine">我的</TabsTrigger>
{squareTabs.map((sq) => (
{defaultSquare && (
<TabsTrigger
key={defaultSquare.id}
value={defaultSquare.id}
title={defaultSquare.description || defaultSquare.name}
>
{defaultSquare.name}
</TabsTrigger>
)}
{sentimentAgentVisible && (
/* 舆情分析:前端虚拟类型,直连外部舆情问答接口。 */
<TabsTrigger value="sentiment" title="舆情分析智能体(外部接口对接)">
舆情分析
</TabsTrigger>
)}
{additionalSquares.map((sq) => (
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
{sq.name}
</TabsTrigger>
@@ -512,6 +545,25 @@ export function AgentGallery() {
})}
</TabsContent>
{/* 舆情分析类型:固定一张虚拟智能体卡片(名称/简介可编辑),
接口地址与鉴权在 runtime-config.js 配置。 */}
{sentimentAgentVisible && (
<TabsContent
value="sentiment"
className="flex-1 overflow-y-auto p-6 mt-0"
>
{viewMode === "list" ? (
<div className="flex flex-col gap-2">
<SentimentAgentCard layout="list" />
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<SentimentAgentCard />
</div>
)}
</TabsContent>
)}
{isAdmin && (
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({
@@ -0,0 +1,160 @@
"use client";
import { BotIcon, MessageSquareIcon, Radio, SquarePenIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { SENTIMENT_AGENT_ID } from "@/core/sentiment-agent/config";
import { readSentimentAgentProfile } from "@/core/sentiment-agent/profile";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { SentimentAgentProfileDialog } from "./sentiment-agent-profile-dialog";
/**
* 「舆情分析」类型下的虚拟智能体卡片:只存在于前端(不落库 /api/agents),
* 对话直连外部 AG-UI 网关。名称 / 简介可编辑(localStorage),
* 视觉与普通智能体卡片保持一致(网格 / 列表两种布局)。
*/
export function SentimentAgentCard({
layout = "grid",
className,
}: {
layout?: "grid" | "list";
className?: string;
}) {
const router = useRouter();
const [profile, setProfile] = useState(() => readSentimentAgentProfile());
const [editOpen, setEditOpen] = useState(false);
// 编辑弹窗也可能挂在对话页,保存后广播事件,这里即时刷新卡片。
useEffect(() => {
const onChange = () => setProfile(readSentimentAgentProfile());
window.addEventListener("sentiment-agent-profile-changed", onChange);
return () =>
window.removeEventListener("sentiment-agent-profile-changed", onChange);
}, []);
const handleChat = () => {
router.push(`/page/workspace/agents/${SENTIMENT_AGENT_ID}/chats/new`);
};
const editButton = (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setEditOpen(true)}
title="编辑名称与简介"
aria-label="编辑名称与简介"
>
<SquarePenIcon className="h-3.5 w-3.5" />
</Button>
);
const chatButton = (
<Button
size="sm"
className={cn(layout === "list" ? "shrink-0" : "w-full")}
onClick={handleChat}
>
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
对话
</Button>
);
const badge = (
<Badge variant="secondary" className="gap-1 text-xs">
<Radio className="size-3" />
舆情分析
</Badge>
);
const dialog = (
<SentimentAgentProfileDialog
open={editOpen}
onOpenChange={setEditOpen}
/>
);
if (layout === "list") {
return (
<div
className={cn(
"group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]",
className,
)}
>
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<BotIcon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex min-w-0 items-center gap-2">
<span
className="min-w-0 truncate text-sm font-medium"
title={desensitize(profile.name)}
>
{desensitize(profile.name)}
</span>
{badge}
</div>
<p className="text-muted-foreground mt-0.5 truncate text-xs">
{desensitize(profile.description)}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{editButton}
{chatButton}
</div>
{dialog}
</div>
);
}
return (
<Card
className={cn(
"group flex min-w-0 flex-col transition-shadow hover:shadow-md",
className,
)}
>
<CardHeader className="min-w-0 pb-2">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<BotIcon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<CardTitle
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
title={desensitize(profile.name)}
>
{desensitize(profile.name)}
</CardTitle>
<div className="mt-0.5">{badge}</div>
</div>
</div>
<div className="flex shrink-0 items-center justify-end gap-1">
{editButton}
</div>
</div>
<CardDescription className="mt-2 line-clamp-2 text-sm">
{desensitize(profile.description)}
</CardDescription>
</CardHeader>
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-2">
{chatButton}
</CardFooter>
{dialog}
</Card>
);
}
@@ -0,0 +1,104 @@
"use client";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
DEFAULT_SENTIMENT_AGENT_PROFILE,
type SentimentAgentProfile,
readSentimentAgentProfile,
saveSentimentAgentProfile,
} from "@/core/sentiment-agent/profile";
/**
* 舆情分析智能体的「编辑信息」弹窗:修改名称 / 简介并保存到 localStorage。
* 智能体管理页的卡片和对话页顶栏共用。
*/
export function SentimentAgentProfileDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
useEffect(() => {
if (!open) return;
const profile = readSentimentAgentProfile();
setName(profile.name);
setDescription(profile.description);
}, [open]);
const handleSave = () => {
const next: SentimentAgentProfile = {
name: name.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.name,
description:
description.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.description,
};
saveSentimentAgentProfile(next);
toast.success("舆情分析智能体信息已更新");
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>编辑舆情分析智能体</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<label htmlFor="sentiment-agent-name" className="text-sm font-medium">
名称
</label>
<Input
id="sentiment-agent-name"
value={name}
maxLength={50}
onChange={(e) => setName(e.target.value)}
placeholder="智能体名称"
/>
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="sentiment-agent-description"
className="text-sm font-medium"
>
简介
</label>
<Textarea
id="sentiment-agent-description"
value={description}
maxLength={300}
onChange={(e) => setDescription(e.target.value)}
placeholder="智能体简介(展示在卡片与对话页)"
className="min-h-[96px] resize-none"
/>
</div>
<p className="text-muted-foreground text-xs">
仅修改本浏览器展示的名称与简介;接口地址与鉴权请在部署目录的
runtime-config.js 中配置。
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleSave}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large. Load diff
@@ -28,11 +28,14 @@ export function ArtifactFileList({
files,
threadId,
onOpenArtifact,
onDownloadArtifact,
}: {
className?: string;
files: string[];
threadId: string;
onOpenArtifact?: (filepath: string) => void;
/** Optional host download action for virtual/session-owned artifacts. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
}) {
const { t } = useI18n();
const { isMock, publicShareCode } = useThread();
@@ -65,10 +68,14 @@ export function ArtifactFileList({
async (e: React.MouseEvent, filepath: string) => {
e.stopPropagation();
e.preventDefault();
if (downloadingFile) return;
setDownloadingFile(filepath);
try {
await downloadArtifactFile({ filepath, threadId, isMock, publicShareCode });
if (downloadingFile) return;
setDownloadingFile(filepath);
try {
if (onDownloadArtifact) {
await onDownloadArtifact(filepath);
} else {
await downloadArtifactFile({ filepath, threadId, isMock, publicShareCode });
}
} catch (error) {
console.error(error);
toast.error(
@@ -78,7 +85,7 @@ export function ArtifactFileList({
setDownloadingFile(null);
}
},
[threadId, isMock, publicShareCode, downloadingFile],
[threadId, isMock, publicShareCode, downloadingFile, onDownloadArtifact],
);
return (
@@ -1,22 +1,43 @@
import {
createContext,
useCallback,
useContext,
useState,
useCallback,
useContext,
useRef,
useState,
type ReactNode,
} from "react";
import { useSidebarSafe } from "@/components/ui/sidebar";
import { env } from "@/env";
import { env } from "@/env";
export function artifactPreviewKey(artifact: string): string {
if (!artifact.startsWith("write-file:")) {
return artifact.trim();
}
try {
const url = new URL(artifact);
const messageId = url.searchParams.get("message_id");
const toolCallId = url.searchParams.get("tool_call_id");
if (messageId && toolCallId) {
return `${messageId}:${toolCallId}`;
}
} catch {
// Fall back to the complete value for malformed legacy virtual URLs.
}
return artifact;
}
export interface ArtifactsContextType {
artifacts: string[];
setArtifacts: (artifacts: string[]) => void;
selectedArtifact: string | null;
autoSelect: boolean;
select: (artifact: string, autoSelect?: boolean) => void;
deselect: () => void;
selectedArtifact: string | null;
selectedArtifactKey: string | null;
autoSelect: boolean;
select: (artifact: string, autoSelect?: boolean) => void;
/** Open/update a streamed write without overriding a manual close of that call. */
openStreamedArtifact: (artifact: string, previewKey: string) => boolean;
deselect: () => void;
open: boolean;
autoOpen: boolean;
@@ -36,32 +57,55 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const setArtifacts = useCallback((next: string[]) => {
setArtifactsState(Array.isArray(next) ? next : []);
}, []);
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [open, setOpen] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const [autoOpen, setAutoOpen] = useState(true);
const sidebar = useSidebarSafe();
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [open, setOpenState] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const [autoOpen, setAutoOpen] = useState(true);
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
const sidebar = useSidebarSafe();
const select = useCallback(
(artifact: string, autoSelect = false) => {
setSelectedArtifact(artifact);
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
(artifact: string, autoSelect = false) => {
setSelectedArtifact(artifact);
setSelectedArtifactKey(artifactPreviewKey(artifact));
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false);
}
if (!autoSelect) {
setAutoSelect(false);
}
},
[sidebar, setSelectedArtifact, setAutoSelect],
);
[sidebar],
);
const openStreamedArtifact = useCallback(
(artifact: string, previewKey: string) => {
if (suppressedAutoOpenKeyRef.current === previewKey) {
return false;
}
setSelectedArtifact(artifact);
setSelectedArtifactKey(previewKey);
setAutoSelect(true);
setAutoOpen(true);
setOpenState(true);
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false);
}
return true;
},
[sidebar],
);
const deselect = useCallback(() => {
setSelectedArtifact(null);
setAutoSelect(true);
setOpen(false);
}, []);
setSelectedArtifact(null);
setSelectedArtifactKey(null);
setAutoSelect(true);
setOpenState(false);
suppressedAutoOpenKeyRef.current = null;
}, []);
const value: ArtifactsContextType = {
artifacts,
@@ -75,12 +119,17 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
setAutoOpen(false);
setAutoSelect(false);
}
setOpen(isOpen);
},
selectedArtifact,
select,
deselect,
if (!isOpen) {
suppressedAutoOpenKeyRef.current = selectedArtifactKey;
}
setOpenState(isOpen);
},
selectedArtifact,
selectedArtifactKey,
select,
openStreamedArtifact,
deselect,
};
return (
@@ -0,0 +1,157 @@
"use client";
import { SparklesIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
const STYLE_OPTIONS: { value: DocumentRewriteStyle; label: string; description: string }[] = [
{
value: "formal_analysis",
label: "正式分析",
description: "客观、严谨,适合报告与研究材料。",
},
{
value: "professional",
label: "专业正式",
description: "适合业务文档和说明材料。",
},
{
value: "concise",
label: "精炼简洁",
description: "减少重复,保留核心信息。",
},
{
value: "readable",
label: "清晰易读",
description: "优化段落衔接和阅读节奏。",
},
];
export function DocumentRewriteDialog({
open,
onOpenChange,
fileName,
charCount,
onStart,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string;
charCount: number;
onStart: (input: {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
}) => void;
}) {
const [instruction, setInstruction] = useState(
"统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。",
);
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
const [modelName, setModelName] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setStyle("formal_analysis");
setModelName(null);
}, [open]);
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
const canStart = instruction.trim().length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<SparklesIcon className="size-5 text-primary" />
AI 全文改写
</DialogTitle>
<DialogDescription>
将以当前文件快照生成临时草稿。校验通过前不会覆盖原 Markdown。
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border bg-muted/35 px-3 py-2.5 text-sm">
<div className="font-medium">{fileName}</div>
<div className="text-muted-foreground mt-1">原文约 {charCount.toLocaleString()} 字</div>
</div>
<label className="grid gap-2 text-sm font-medium">
改写要求
<Textarea
value={instruction}
onChange={(event) => setInstruction(event.target.value)}
placeholder="例如:统一为正式行业分析语气,减少重复,不新增外部事实。"
rows={4}
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm font-medium">
改写风格
<Select value={style} onValueChange={(value) => setStyle(value as DocumentRewriteStyle)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STYLE_OPTIONS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs">{selectedStyle?.description}</span>
</label>
<label className="grid gap-2 text-sm font-medium">
模型
<ModelNameSelect
value={modelName}
onChange={setModelName}
triggerClassName="h-10 text-sm"
defaultLabel="系统默认模型"
/>
<span className="text-muted-foreground text-xs">仅影响本次全文改写。</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button
disabled={!canStart}
onClick={() => {
if (!canStart) return;
onStart({ instruction: instruction.trim(), style, modelName });
}}
>
<SparklesIcon />
开始改写
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -118,6 +118,7 @@ const ChatBox: React.FC<{
select: selectArtifact,
deselect,
selectedArtifact,
selectedArtifactKey,
} = useArtifacts();
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
@@ -455,6 +456,7 @@ const ChatBox: React.FC<{
</div>
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
<ArtifactFileDetail
key={selectedArtifactKey ?? selectedArtifact}
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
@@ -470,6 +472,7 @@ const ChatBox: React.FC<{
</Tabs>
) : selectedArtifact ? (
<ArtifactFileDetail
key={selectedArtifactKey ?? selectedArtifact}
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
@@ -0,0 +1,125 @@
"use client";
import { useMemo } from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
type DiffRow = {
left: { text: string; kind: "same" | "removed" } | null;
right: { text: string; kind: "same" | "added" } | null;
};
function buildLineDiff(before: string, after: string): DiffRow[] {
const left = before.split("\n");
const right = after.split("\n");
// A large quadratic matrix would make the dialog itself a performance risk.
// Documents above this threshold still get an honest two-column comparison,
// just without expensive alignment of every unchanged line.
if (left.length * right.length > 1_000_000) {
const rows: DiffRow[] = [];
const length = Math.max(left.length, right.length);
for (let index = 0; index < length; index += 1) {
const beforeLine = left[index];
const afterLine = right[index];
const same = beforeLine === afterLine;
rows.push({
left: beforeLine === undefined ? null : { text: beforeLine, kind: same ? "same" : "removed" },
right: afterLine === undefined ? null : { text: afterLine, kind: same ? "same" : "added" },
});
}
return rows;
}
const table = Array.from({ length: left.length + 1 }, () => new Uint16Array(right.length + 1));
for (let i = left.length - 1; i >= 0; i -= 1) {
for (let j = right.length - 1; j >= 0; j -= 1) {
table[i][j] = left[i] === right[j]
? table[i + 1][j + 1] + 1
: Math.max(table[i + 1][j], table[i][j + 1]);
}
}
const rows: DiffRow[] = [];
let i = 0;
let j = 0;
while (i < left.length || j < right.length) {
if (i < left.length && j < right.length && left[i] === right[j]) {
rows.push({ left: { text: left[i], kind: "same" }, right: { text: right[j], kind: "same" } });
i += 1;
j += 1;
} else if (j < right.length && (i === left.length || table[i][j + 1] >= table[i + 1][j])) {
rows.push({ left: null, right: { text: right[j], kind: "added" } });
j += 1;
} else {
rows.push({ left: { text: left[i], kind: "removed" }, right: null });
i += 1;
}
}
return rows;
}
function DiffCell({
value,
line,
}: {
value: DiffRow["left"] | DiffRow["right"];
line: number | null;
}) {
return (
<div
className={cn(
"grid min-w-0 grid-cols-[3rem_minmax(0,1fr)] border-b font-mono text-xs leading-5",
value?.kind === "removed" && "bg-red-50 text-red-950 dark:bg-red-950/30 dark:text-red-100",
value?.kind === "added" && "bg-emerald-50 text-emerald-950 dark:bg-emerald-950/30 dark:text-emerald-100",
!value && "bg-muted/30",
)}
>
<span className="select-none border-r px-2 text-right text-muted-foreground">{line ?? ""}</span>
<pre className="min-w-0 whitespace-pre-wrap break-words px-3">{value?.text ?? ""}</pre>
</div>
);
}
export function DocumentRewriteDiffDialog({
open,
onOpenChange,
fileName,
original,
rewritten,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string;
original: string;
rewritten: string;
}) {
const rows = useMemo(() => buildLineDiff(original, rewritten), [original, rewritten]);
let leftLine = 0;
let rightLine = 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[85vh] max-w-[calc(100vw-2rem)] flex-col p-0 sm:max-w-[min(94vw,1440px)]">
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>改写前后对比 · {fileName}</DialogTitle>
<DialogDescription>红色为删除或调整前内容,绿色为新增或改写后内容。</DialogDescription>
</DialogHeader>
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x overflow-auto" role="region" aria-label="Markdown 改写前后对比">
<section aria-label="改写前原文">
<div className="sticky top-0 z-10 border-b bg-background px-4 py-2 text-sm font-medium">改写前</div>
{rows.map((row, index) => {
const line = row.left ? ++leftLine : null;
return <DiffCell key={`left-${index}`} value={row.left} line={line} />;
})}
</section>
<section aria-label="改写后内容">
<div className="sticky top-0 z-10 border-b bg-background px-4 py-2 text-sm font-medium">改写后</div>
{rows.map((row, index) => {
const line = row.right ? ++rightLine : null;
return <DiffCell key={`right-${index}`} value={row.right} line={line} />;
})}
</section>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,295 @@
"use client";
import {
CheckCircle2Icon,
FilePenLineIcon,
Loader2Icon,
Undo2Icon,
XCircleIcon,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { apiFetch } from "@/core/api/fetch-client";
import {
DOCUMENT_REWRITE_UNDONE_EVENT,
type DocumentRewritePresentation,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import { getBackendBaseURL } from "@/core/config";
import { cn } from "@/lib/utils";
import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
const STEPS = [
{ key: "snapshot", label: "固定原文版本" },
{ key: "requirements", label: "理解改写要求" },
{ key: "writing", label: "流式重写 Markdown" },
{ key: "validation", label: "校验文档" },
{ key: "comparison", label: "对比原文并生成说明" },
] as const;
function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
switch (stage) {
case "snapshot":
return 0;
case "requirements":
return 1;
case "writing":
return 2;
case "validation":
return 3;
case "committing":
case "completed":
return 5;
case "conflict":
case "error":
case "cancelled":
return 4;
default:
return -1;
}
}
function isTerminal(stage: DocumentRewritePresentation["stage"]) {
return ["completed", "conflict", "error", "cancelled"].includes(stage);
}
export function DocumentRewriteTimeline({
item,
}: {
item: DocumentRewritePresentation;
}) {
const current = stepPosition(item.stage);
const failed = ["conflict", "error", "cancelled"].includes(item.stage);
const completed = item.stage === "completed";
const presentation = useDocumentRewritePresentation();
const [undoing, setUndoing] = useState(false);
const [diffOpen, setDiffOpen] = useState(false);
const reapplying = Boolean(item.revertedAt);
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
const handleUndo = async () => {
if (!activeVersionId || undoing) return;
const baseUrl = getBackendBaseURL();
const endpoint =
item.source.kind === "artifact"
? `${baseUrl}/api/writing/document-rewrite/versions/${encodeURIComponent(activeVersionId)}/undo`
: `${baseUrl}/api/deep-research/sessions/${encodeURIComponent(item.source.id)}/document-rewrite/versions/${encodeURIComponent(activeVersionId)}/undo`;
const payload =
item.source.kind === "artifact"
? { threadId: item.source.id, path: item.source.path }
: undefined;
setUndoing(true);
try {
const response = await apiFetch(endpoint, {
method: "POST",
headers: payload ? { "Content-Type": "application/json" } : undefined,
body: payload ? JSON.stringify(payload) : undefined,
});
const result = (await response.json().catch(() => ({}))) as Record<string, unknown>;
if (!response.ok) {
throw new Error(
typeof result.detail === "string" ? result.detail : "撤销改写失败,请稍后重试。",
);
}
const content = typeof result.content === "string" ? result.content : "";
const nextInverseVersionId =
typeof result.redoVersionId === "string" ? result.redoVersionId : null;
presentation?.update(
item.id,
reapplying
? { versionId: nextInverseVersionId, redoVersionId: null, revertedAt: null }
: { redoVersionId: nextInverseVersionId, revertedAt: new Date().toISOString() },
);
window.dispatchEvent(
new CustomEvent(DOCUMENT_REWRITE_UNDONE_EVENT, {
detail: { source: item.source, content },
}),
);
toast.success(reapplying ? "已重新应用本次 AI 全文改写版本。" : "已恢复到本次 AI 全文改写前的版本。");
} catch (error) {
toast.error(error instanceof Error ? error.message : "撤销改写失败,请稍后重试。");
} finally {
setUndoing(false);
}
};
return (
<article className="w-full rounded-2xl border bg-card shadow-sm">
<header className="flex items-start gap-3 border-b px-5 py-4">
<div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-full">
<FilePenLineIcon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold">AI 全文改写 · {item.fileName}</h3>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs",
completed
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-300"
: failed
? "bg-destructive/10 text-destructive"
: "bg-primary/10 text-primary",
)}
>
{completed ? "已完成" : failed ? "未覆盖原文件" : "进行中"}
</span>
</div>
<p className="text-muted-foreground mt-1 text-sm">
{item.instruction}
</p>
</div>
</header>
<div className="space-y-3 px-5 py-4">
{STEPS.map((step, index) => {
const active = !isTerminal(item.stage) && index === current;
const done = completed || index < current;
return (
<div className="flex items-start gap-3" key={step.key}>
<div
className={cn(
"mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border",
done
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-400/30 dark:bg-emerald-400/10"
: active
? "border-primary/30 bg-primary/10 text-primary"
: "border-border text-muted-foreground",
)}
>
{done ? (
<CheckCircle2Icon className="size-4" />
) : active ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<span className="text-xs">{index + 1}</span>
)}
</div>
<div className="min-w-0 pt-0.5">
<div className="text-sm font-medium">{step.label}</div>
{step.key === "snapshot" && item.originalCharCount > 0 && (
<p className="text-muted-foreground mt-0.5 text-xs">
已冻结 {item.originalCharCount.toLocaleString()} 字原文快照。
</p>
)}
{step.key === "requirements" && (
item.requirementsPlan ? (
<div
aria-live={active ? "polite" : undefined}
className="bg-muted/45 text-muted-foreground mt-2 max-w-3xl rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap"
role={active ? "status" : undefined}
>
{item.requirementsPlan}
</div>
) : active ? (
<p className="text-muted-foreground mt-0.5 text-xs">
正在识别改写目标、保留约束与执行方式…
</p>
) : (
<p className="text-muted-foreground mt-0.5 text-xs">
已根据改写要求确定执行计划。
</p>
)
)}
{step.key === "writing" && item.writtenCharCount > 0 && (
<p className="text-muted-foreground mt-0.5 text-xs">
正在写入右侧沙箱,已生成 {item.writtenCharCount.toLocaleString()} 字。
</p>
)}
{step.key === "validation" && item.warnings.length > 0 && (
<p className="mt-0.5 text-xs text-amber-700 dark:text-amber-300">
{item.warnings.join(";")}
</p>
)}
</div>
</div>
);
})}
</div>
{item.summary && (
<div className="border-t px-5 py-4 text-sm leading-6">
<div className="mb-2 font-semibold">改写说明</div>
<p>
原文 {item.summary.originalCharCount.toLocaleString()} 字,改写后{" "}
{item.summary.rewrittenCharCount.toLocaleString()} 字;调整了约{" "}
{item.summary.changedBlockCount} 个内容块。
</p>
<p className="text-muted-foreground">
标题数量 {item.summary.headingCountBefore} → {item.summary.headingCountAfter},
使用模型:{item.summary.modelName}。
</p>
{item.summary.requestedOptimizations?.length ? (
<p className="text-muted-foreground">
本次优化:{item.summary.requestedOptimizations.join("、")}。
</p>
) : null}
{item.summary.headingChanges && (
item.summary.headingChanges.reordered ||
item.summary.headingChanges.added.length > 0 ||
item.summary.headingChanges.removed.length > 0 ||
item.summary.headingChanges.levelWarnings.length > 0
) && (
<p className="text-muted-foreground">
{item.summary.headingChanges.reordered ? "已调整章节顺序;" : ""}
{item.summary.headingChanges.added.length
? `新增标题:${item.summary.headingChanges.added.join("、")};`
: ""}
{item.summary.headingChanges.removed.length
? `移除标题:${item.summary.headingChanges.removed.join("、")};`
: ""}
{item.summary.headingChanges.levelWarnings.join(";")}
</p>
)}
{item.originalContent && item.rewrittenContent && (
<Button
className="mt-3 mr-2"
onClick={() => setDiffOpen(true)}
size="sm"
type="button"
variant="outline"
>
查看前后对比
</Button>
)}
{completed && activeVersionId && (
<Button
className="mt-3"
disabled={undoing}
onClick={() => void handleUndo()}
size="sm"
type="button"
variant="outline"
>
{undoing ? <Loader2Icon className="mr-1.5 size-3.5 animate-spin" /> : <Undo2Icon className="mr-1.5 size-3.5" />}
{reapplying ? "重新应用此次改写" : "撤销此次改写"}
</Button>
)}
{item.revertedAt && (
<p className="mt-2 text-xs text-emerald-700 dark:text-emerald-300">
已恢复为改写前版本。{item.redoVersionId ? "可重新应用本次改写。" : ""}
</p>
)}
</div>
)}
{item.error && (
<div className="flex items-center gap-2 border-t px-5 py-3 text-sm text-destructive">
<XCircleIcon className="size-4 shrink-0" />
{item.error}
</div>
)}
<DocumentRewriteDiffDialog
fileName={item.fileName}
onOpenChange={setDiffOpen}
open={diffOpen}
original={item.originalContent}
rewritten={item.rewrittenContent ?? ""}
/>
</article>
);
}
@@ -1,19 +1,21 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
BookOpenTextIcon,
ChevronDownIcon,
ChevronUp,
BookOpenTextIcon,
CheckCircle2Icon,
ChevronDownIcon,
ChevronUp,
FolderOpenIcon,
GlobeIcon,
LightbulbIcon,
ListTodoIcon,
LightbulbIcon,
ListTodoIcon,
Loader2Icon,
MessageCircleQuestionMarkIcon,
NotebookPenIcon,
SearchIcon,
SquareTerminalIcon,
WrenchIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
ChainOfThought,
@@ -29,14 +31,20 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useI18n } from "@/core/i18n/hooks";
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
import { useI18n } from "@/core/i18n/hooks";
import {
normalizeSearchResults,
resolveSearchQuery,
@@ -227,7 +235,7 @@ export function MessageGroup({
);
}
function ToolCall({
function ToolCall({
id,
messageId,
name,
@@ -249,8 +257,6 @@ function ToolCall({
ragModeEnabled?: boolean;
}) {
const { t } = useI18n();
const artifacts = useArtifactsOptional();
const displayMatch =
result !== undefined && isDisplaySamplePayload(result)
? null
@@ -265,7 +271,7 @@ function ToolCall({
mode: item.displayMode ?? displayMatch.display.mode,
}))
: [];
if (displayItems.length > 0) {
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
key={id}
@@ -279,9 +285,16 @@ function ToolCall({
<DisplayResultsList results={displayItems} />
</ChainOfThoughtStep>
);
}
const searchItems = normalizeSearchResults(result);
}
// Deep Research uses a virtual tool call for each durable writing phase.
// Rendering it here keeps its lifecycle in the very same ChainOfThought
// component as ordinary Q&A retrieval and sandbox tool calls.
if (name === "deep_research_progress") {
return <DeepResearchProgressStep id={id} args={args} />;
}
const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
@@ -424,44 +437,18 @@ function ToolCall({
if (!description) {
description = t.toolCalls.writeFile;
}
const path: string | undefined = (args as { path: string })?.path;
const artifactPath =
name === "write_file" && path
? new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString()
: path;
if (artifacts && isLoading && isLast && artifacts.autoOpen && artifacts.autoSelect && path && !result) {
setTimeout(() => {
if (!artifactPath || artifacts.selectedArtifact === artifactPath) {
return;
}
artifacts.select(artifactPath, true);
artifacts.setOpen(true);
}, 100);
}
return (
<ChainOfThoughtStep
key={id}
className="cursor-pointer"
label={description}
icon={NotebookPenIcon}
onClick={() => {
if (!artifactPath || !artifacts) {
return;
}
artifacts.select(artifactPath);
artifacts.setOpen(true);
}}
>
{path && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
return (
<ArtifactWriteToolCall
id={id}
messageId={messageId}
name={name}
args={args}
result={result}
description={description}
isLast={isLast}
isLoading={isLoading}
/>
);
} else if (name === "bash") {
const description: string | undefined = (args as { description: string })
?.description;
@@ -525,6 +512,123 @@ function ToolCall({
}
}
function DeepResearchProgressStep({
id,
args,
}: {
id?: string;
args: Record<string, unknown>;
}) {
const label = typeof args.label === "string" && args.label.trim()
? args.label
: "正在撰写研究报告";
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
const active = args.status !== "complete";
return (
<ChainOfThoughtStep
key={id}
status={active ? "active" : "complete"}
icon={active
? <Loader2Icon className="text-primary size-4 animate-spin" />
: <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />}
label={
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span>{label}</span>
<span className={active ? "text-primary text-xs" : "text-muted-foreground text-xs"}>
{active ? "进行中" : "已完成"}
</span>
</span>
}
>
{detail && (
<div className="text-muted-foreground text-xs leading-5">
{detail}
</div>
)}
</ChainOfThoughtStep>
);
}
function ArtifactWriteToolCall({
id,
messageId,
name,
args,
result,
description,
isLast,
isLoading,
}: {
id?: string;
messageId?: string;
name: "write_file" | "str_replace";
args: Record<string, unknown>;
result?: string | Record<string, unknown>;
description: string;
isLast: boolean;
isLoading: boolean;
}) {
const artifacts = useArtifactsOptional();
const openStreamedArtifact = artifacts?.openStreamedArtifact;
const rawPath = typeof args.path === "string" ? args.path : "";
const content = typeof args.content === "string" ? args.content : "";
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
const hasUsablePath =
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
const sourcePath = hasUsablePath
? normalizedPath
: name === "write_file" && content.trim()
? provisionalWriteFilePath(content)
: null;
const previewKey = messageId && id ? `${messageId}:${id}` : null;
const artifactPath =
sourcePath && name === "write_file" && messageId && id
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
: sourcePath;
useEffect(() => {
if (
!openStreamedArtifact ||
!artifactPath ||
!previewKey ||
!isLoading ||
!isLast ||
result !== undefined
) {
return;
}
openStreamedArtifact(artifactPath, previewKey);
}, [
artifactPath,
isLast,
isLoading,
openStreamedArtifact,
previewKey,
result,
]);
return (
<ChainOfThoughtStep
key={id}
className={cn(artifactPath && "cursor-pointer")}
label={description}
icon={NotebookPenIcon}
onClick={() => {
if (!artifactPath || !artifacts) return;
artifacts.select(artifactPath);
artifacts.setOpen(true);
}}
>
{(normalizedPath || sourcePath) && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{normalizedPath || sourcePath}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
}
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
@@ -11,7 +11,8 @@ import {
} from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
import { useDocumentRewritePresentation } from "@/core/document-rewrite/context";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
@@ -45,7 +46,8 @@ import { getQuestionAnchorId } from "../question-navigator";
import { StreamingIndicator } from "../streaming-indicator";
import { ReferenceBatchesContext, ThreadContext } from "./context";
import { MarkdownContent } from "./markdown-content";
import { MarkdownContent } from "./markdown-content";
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
import {
@@ -110,8 +112,10 @@ export function MessageList({
onSetupWritingApprove,
onSetupWritingReject,
onSetupWritingStart,
onOpenArtifact,
}: {
researchApprovalSlot,
onOpenArtifact,
onDownloadArtifact,
}: {
className?: string;
contentClassName?: string;
fitMessageRowsToContent?: boolean;
@@ -133,14 +137,24 @@ export function MessageList({
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onOpenArtifact?: (filepath: string) => void;
/**
* 深度研究的交互卡插槽(报告配置、计划确认、失败重试):workbench 向
* displayThread 注入带 `additional_kwargs.setup_research_approval` 的虚拟 ai
* 消息。撰写步骤和完成摘要不走此插槽,而是分别复用原生 tool-call 步骤条和
* assistant 消息行;不传该 slot 时普通对话/AI 写作零行为变化。
*/
researchApprovalSlot?: (message: Message) => React.ReactNode;
onOpenArtifact?: (filepath: string) => void;
/** Host override for downloading virtual/session-owned files in a present-files card. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
[baseRehypePlugins, extraRehypePlugins],
);
const updateSubtask = useUpdateSubtask();
const updateSubtask = useUpdateSubtask();
const documentRewritePresentation = useDocumentRewritePresentation();
const { t } = useI18n();
const threadContext = useContext(ThreadContext);
// Read-only contexts (public share, mock previews) must not offer deletion.
@@ -180,7 +194,7 @@ export function MessageList({
: ({ ...message, content: override } as Message);
});
}, [messageContentOverrides, thread.messages]);
const messages = useMemo(
const messages = useMemo(
() => {
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
return hiddenMessageIds.size === 0 && externalHidden.size === 0
@@ -191,8 +205,9 @@ export function MessageList({
(!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)),
);
},
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
);
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
);
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
const askTimes = useChatAskTimes();
@@ -428,10 +443,16 @@ export function MessageList({
)}
>
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => {
{groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
const isAssistantMessage =
return group.messages.map((msg) => {
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
const messageIsLoading =
thread.isLoading ||
msg.additional_kwargs?.deep_research_summary_streaming === true ||
msg.additional_kwargs?.deep_research_follow_up_streaming === true;
const isAssistantMessage =
referencesEnabled &&
group.type === "assistant" &&
msg.type === "ai";
@@ -464,9 +485,9 @@ export function MessageList({
msg.type === "human"
? getQuestionAnchorId(msg, messages.indexOf(msg))
: undefined
}
message={msg}
isLoading={thread.isLoading}
}
message={msg}
isLoading={messageIsLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
@@ -561,6 +582,15 @@ export function MessageList({
/>
</div>
) : null;
} else if (group.type === "assistant:research-approval") {
// 深度研究交互卡(配置/计划确认/失败重试):宿主不传渲染函数时该
// 分组不渲染。撰写步骤和完成摘要会经过普通的 processing/assistant 分组。
const message = group.messages[0];
return researchApprovalSlot && message ? (
<div key={group.id} className="w-full pb-2">
{researchApprovalSlot(message)}
</div>
) : null;
} else if (group.type === "assistant:present-files") {
const files: string[] = [];
for (const message of group.messages) {
@@ -580,10 +610,11 @@ export function MessageList({
/>
)}
<ArtifactFileList
files={files}
threadId={threadId}
onOpenArtifact={onOpenArtifact}
/>
files={files}
threadId={threadId}
onOpenArtifact={onOpenArtifact}
onDownloadArtifact={onDownloadArtifact}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
@@ -707,8 +738,11 @@ export function MessageList({
/>
</div>
);
})}
{thread.isLoading && <StreamingIndicator className="my-4" />}
})}
{documentRewriteItems.map((item) => (
<DocumentRewriteTimeline key={item.id} item={item} />
))}
{thread.isLoading && <StreamingIndicator className="my-4" />}
<div style={{ height: `${paddingBottom}px` }} />
</MessageMetricsProvider>
</ConversationContent>
@@ -0,0 +1,68 @@
export type StreamingArtifactLanguage = "markdown" | "html" | "mermaid";
export function normalizeStreamingArtifactPath(path: string): string {
let normalized = path.trim();
const first = normalized[0];
const last = normalized.at(-1);
if (
normalized.length >= 2 &&
((first === '"' && last === '"') ||
(first === "'" && last === "'") ||
(first === "`" && last === "`"))
) {
normalized = normalized.slice(1, -1).trim();
}
return normalized;
}
export function hasCompleteArtifactExtension(path: string): boolean {
const pathname = normalizeStreamingArtifactPath(path).split(/[?#]/, 1)[0] ?? "";
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
// JSON tool arguments commonly stream `report.m` before `report.md`.
// Treating the partial suffix as final makes the sandbox permanently TEXT.
return /\.[a-z0-9]{2,12}$/i.test(filename);
}
export function provisionalWriteFilePath(content: string): string {
const head = content.slice(0, 256).trimStart().toLowerCase();
if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
return "未命名文件.html";
}
return "未命名文件.md";
}
export function languageOfStreamingArtifact(
path: string,
): StreamingArtifactLanguage | null {
const pathname = normalizeStreamingArtifactPath(path)
.split(/[?#]/, 1)[0]
?.toLowerCase();
if (pathname?.endsWith(".md") || pathname?.endsWith(".markdown")) {
return "markdown";
}
if (pathname?.endsWith(".html") || pathname?.endsWith(".htm")) {
return "html";
}
if (pathname?.endsWith(".mmd") || pathname?.endsWith(".mermaid")) {
return "mermaid";
}
return null;
}
export function buildWriteFileVirtualUrl({
path,
messageId,
toolCallId,
}: {
path: string;
messageId: string;
toolCallId: string;
}): string {
const language = languageOfStreamingArtifact(path);
const languageQuery = language ? `&language=${language}` : "";
return new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}${languageQuery}`,
).toString();
}
@@ -0,0 +1,166 @@
"use client";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import type {
DocumentRewriteStage,
} from "@/open-canvas/hooks/useDocumentRewrite";
import type { DocumentRewriteSummary } from "@/open-canvas/api/rewrite";
export type DocumentRewritePresentation = {
id: string;
threadId: string;
source: {
kind: "artifact" | "deep_research_report";
id: string;
path?: string;
};
fileName: string;
instruction: string;
style: string;
modelName: string | null;
stage: DocumentRewriteStage;
originalCharCount: number;
writtenCharCount: number;
/** Short model-generated plan, streamed while it understands the request. */
requirementsPlan: string;
warnings: string[];
summary: DocumentRewriteSummary | null;
/** Kept in-memory for the line-level compare dialog; backend job hydration refills it. */
originalContent: string;
rewrittenContent: string | null;
versionId: string | null;
/** Inverse snapshot created by a safe undo; enables a one-click reapply. */
redoVersionId: string | null;
revertedAt: string | null;
error: string | null;
createdAt: string;
updatedAt: string;
};
type CreatePresentationInput = Omit<
DocumentRewritePresentation,
| "id"
| "stage"
| "originalCharCount"
| "writtenCharCount"
| "requirementsPlan"
| "warnings"
| "summary"
| "originalContent"
| "rewrittenContent"
| "versionId"
| "redoVersionId"
| "revertedAt"
| "error"
| "createdAt"
| "updatedAt"
> & {
originalContent?: string;
rewrittenContent?: string | null;
requirementsPlan?: string;
};
type DocumentRewritePresentationContextValue = {
create: (input: CreatePresentationInput) => string;
update: (
id: string,
patch: Partial<Omit<DocumentRewritePresentation, "id" | "threadId" | "createdAt">>,
) => void;
forThread: (threadId: string) => DocumentRewritePresentation[];
};
const DocumentRewritePresentationContext =
createContext<DocumentRewritePresentationContextValue | null>(null);
function createId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return "drw-ui-" + crypto.randomUUID();
}
return "drw-ui-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
}
export function DocumentRewritePresentationProvider({
children,
}: {
children: React.ReactNode;
}) {
const [presentations, setPresentations] = useState<DocumentRewritePresentation[]>([]);
const create = useCallback((input: CreatePresentationInput) => {
const id = createId();
const now = new Date().toISOString();
setPresentations((previous) => [
...previous.slice(-29),
{
...input,
id,
stage: "snapshot",
originalCharCount: 0,
writtenCharCount: 0,
requirementsPlan: input.requirementsPlan ?? "",
warnings: [],
summary: null,
originalContent: input.originalContent ?? "",
rewrittenContent: input.rewrittenContent ?? null,
versionId: null,
redoVersionId: null,
revertedAt: null,
error: null,
createdAt: now,
updatedAt: now,
},
]);
return id;
}, []);
const update = useCallback(
(
id: string,
patch: Partial<Omit<DocumentRewritePresentation, "id" | "threadId" | "createdAt">>,
) => {
setPresentations((previous) => {
const index = previous.findIndex((item) => item.id === id);
if (index < 0) return previous;
const current = previous[index];
const changed = Object.entries(patch).some(
([key, value]) => current[key as keyof DocumentRewritePresentation] !== value,
);
// Do not create a new context value for an identical update. Stream
// subscribers can legitimately receive an already-hydrated snapshot;
// treating it as a state change makes effect-based synchronisation
// vulnerable to update loops.
if (!changed) return previous;
const next = [...previous];
next[index] = { ...current, ...patch, updatedAt: new Date().toISOString() };
return next;
});
},
[],
);
const value = useMemo<DocumentRewritePresentationContextValue>(
() => ({
create,
update,
forThread: (threadId) =>
presentations.filter((item) => item.threadId === threadId),
}),
[create, presentations, update],
);
return (
<DocumentRewritePresentationContext.Provider value={value}>
{children}
</DocumentRewritePresentationContext.Provider>
);
}
export function useDocumentRewritePresentation() {
return useContext(DocumentRewritePresentationContext);
}
/** Browser event consumed by the open sandbox after an in-feed undo succeeds. */
export const DOCUMENT_REWRITE_UNDONE_EVENT = "deerflow:document-rewrite-undone";
+25 -1
View File
@@ -22,6 +22,8 @@ interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistan
interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {}
interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:research-approval"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
@@ -32,6 +34,7 @@ type MessageGroup =
| AssistantClarificationGroup
| AssistantSetupAgentApprovalGroup
| AssistantSetupWritingApprovalGroup
| AssistantResearchApprovalGroup
| AssistantSubagentGroup;
export function groupMessages<T>(
@@ -54,7 +57,8 @@ export function groupMessages<T>(
last.type !== "assistant" &&
last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval" &&
last.type !== "assistant:setup-writing-approval"
last.type !== "assistant:setup-writing-approval" &&
last.type !== "assistant:research-approval"
) {
return last;
}
@@ -114,6 +118,18 @@ export function groupMessages<T>(
}
if (message.type === "ai") {
// Deep-research interaction card: virtual ai messages injected by the
// workbench (additional_kwargs.setup_research_approval) render through
// the host-provided slot. Writing phases use ordinary virtual tool calls
// and completed summaries remain normal assistant messages.
if (hasResearchApproval(message)) {
groups.push({
id: message.id,
type: "assistant:research-approval",
messages: [message],
});
continue;
}
if (hasPresentFiles(message)) {
groups.push({
id: message.id,
@@ -386,6 +402,14 @@ export function isSetupWritingApprovalToolMessage(message: Message) {
);
}
export function hasResearchApproval(message: Message) {
return (
message.type === "ai" &&
typeof message.additional_kwargs?.setup_research_approval === "object" &&
message.additional_kwargs.setup_research_approval !== null
);
}
export function extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) {
return [];
@@ -0,0 +1,192 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
buildSentimentAgentHeaders,
readSentimentAgentEndpoint,
} from "./config";
/**
* 舆情分析智能体走的是外部 AG-UI 协议网关(POST,SSE 返回):
* 请求体为 { threadId, runId, state, tools, context, forwardedProps, messages },
* 返回为 `data: {"type": ...}` 的 SSE 事件流:
* RUN_STARTED / THINKING_TEXT_MESSAGE_START|CONTENT|END /
* TEXT_MESSAGE_START|CONTENT|END / RUN_FINISHED(另有 RUN_ERROR 等异常事件)。
*/
/** AG-UI 协议消息(请求体里的 messages 数组元素)。 */
export interface AgUiMessage {
role: "user" | "assistant";
id: string;
content: string;
}
export interface SentimentAgentRunHandlers {
/** 推理(思考)文本开始:reasoning 消息流开始。 */
onReasoningStart?: (messageId: string) => void;
onReasoningDelta?: (messageId: string, delta: string) => void;
/** 正文回答开始:answer 消息流开始。 */
onAnswerStart?: (messageId: string) => void;
onAnswerDelta?: (messageId: string, delta: string) => void;
}
function textOfMessage(message: Message): string {
if (typeof message.content === "string") return message.content;
if (!Array.isArray(message.content)) return "";
return message.content
.map((part) => {
if (typeof part === "string") return part;
if (!part || typeof part !== "object") return "";
const text = (part as { text?: unknown }).text;
return typeof text === "string" ? text : "";
})
.join("");
}
/** 把本地会话消息转成 AG-UI 请求的历史消息(过滤空内容)。 */
export function toAgUiMessages(messages: Message[]): AgUiMessage[] {
return messages
.filter((message) => message.type === "human" || message.type === "ai")
.map((message) => ({
role: message.type === "human" ? ("user" as const) : ("assistant" as const),
id: message.id ?? "",
content: textOfMessage(message),
}))
.filter((message) => message.content.trim().length > 0);
}
type SseEvent = {
event?: string;
data: string;
};
/** 按 SSE 规范切帧:事件以空行分隔,`data:` 行可能跨多行、帧可能跨 chunk。 */
function parseSseFrames(buffer: string): { frames: SseEvent[]; rest: string } {
const normalized = buffer.replace(/\r\n/g, "\n");
const parts = normalized.split("\n\n");
const rest = parts.pop() ?? "";
const frames: SseEvent[] = [];
for (const part of parts) {
if (!part.trim()) continue;
const dataLines: string[] = [];
let eventName: string | undefined;
for (const line of part.split("\n")) {
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).replace(/^ /, ""));
} else if (line.startsWith("event:")) {
eventName = line.slice(6).replace(/^ /, "");
}
}
if (dataLines.length > 0) {
frames.push({ event: eventName, data: dataLines.join("\n") });
}
}
return { frames, rest };
}
export interface RunSentimentAgentOptions {
threadId: string;
runId: string;
messages: AgUiMessage[];
signal: AbortSignal;
handlers: SentimentAgentRunHandlers;
}
/**
* 发起一次舆情分析流式问答。resolve 于流正常结束(RUN_FINISHED 或连接关闭);
* 网络错误 / 非 2xx / RUN_ERROR 事件都会 reject(AbortError 由调用方自行忽略)。
*/
export async function runSentimentAgent(
options: RunSentimentAgentOptions,
): Promise<void> {
const { threadId, runId, messages, signal, handlers } = options;
const { apiUrl } = readSentimentAgentEndpoint();
if (!apiUrl) {
throw new Error("未配置舆情分析接口地址(runtime-config.js 的 VITE_SENTIMENT_AGENT_API_URL)。");
}
const response = await fetch(apiUrl, {
method: "POST",
headers: buildSentimentAgentHeaders(),
signal,
body: JSON.stringify({
threadId,
runId,
state: {},
tools: [],
context: [],
forwardedProps: {},
messages,
}),
});
if (!response.ok) {
throw new Error(`舆情分析服务请求失败(HTTP ${response.status})。`);
}
if (!response.body) {
throw new Error("舆情分析服务未返回流式响应。");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const dispatch = (payload: Record<string, unknown>) => {
// 样例里思考事件使用 message_id,而正文事件使用 messageId;两种 AG-UI
// 字段形式都兼容,避免后端升级/不同事件类型造成流式状态断开。
const messageId = String(payload.message_id ?? payload.messageId ?? "");
const delta = typeof payload.delta === "string" ? payload.delta : "";
switch (payload.type) {
case "THINKING_TEXT_MESSAGE_START":
handlers.onReasoningStart?.(messageId);
break;
case "THINKING_TEXT_MESSAGE_CONTENT":
handlers.onReasoningDelta?.(messageId, delta);
break;
case "TEXT_MESSAGE_START":
handlers.onAnswerStart?.(messageId);
break;
case "TEXT_MESSAGE_CONTENT":
handlers.onAnswerDelta?.(messageId, delta);
break;
case "RUN_ERROR": {
const message =
typeof payload.message === "string" && payload.message
? payload.message
: "舆情分析服务返回错误。";
throw new Error(message);
}
default:
// RUN_STARTED / *_END / RUN_FINISHED / TOOL_* 等事件不需要驱动 UI。
break;
}
};
const consumeFrames = (frames: SseEvent[]) => {
for (const frame of frames) {
let payload: Record<string, unknown>;
try {
payload = JSON.parse(frame.data) as Record<string, unknown>;
} catch {
continue; // 解析不了的脏帧直接跳过,不断流。
}
// RUN_ERROR 会在 dispatch 里抛出业务错误,正常往外传播。
dispatch(payload);
}
};
while (true) {
const { done, value } = await reader.read();
if (done) {
// 某些网关会在最后一帧后直接关闭连接而不补 SSE 的空行分隔符。流已
// 结束时剩余缓冲区就是完整帧,补上分隔符后仍要消费,避免丢最后几个字。
buffer += decoder.decode();
if (buffer.trim()) {
const { frames } = parseSseFrames(`${buffer}\n\n`);
consumeFrames(frames);
}
break;
}
buffer += decoder.decode(value, { stream: true });
const { frames, rest } = parseSseFrames(buffer);
buffer = rest;
consumeFrames(frames);
}
}
@@ -0,0 +1,59 @@
import { readRuntimeEnv } from "@/env";
/**
* 舆情分析智能体(前端直连的虚拟智能体)的接入配置。
* 接口地址与 Basic Auth 凭据都来自 public/runtime-config.js,部署后可直接改
* dist/runtime-config.js 切换环境,无需重新打包。
*/
/** 该虚拟智能体在路由里的固定 id(/agents/{id}/chats/...)。 */
export const SENTIMENT_AGENT_ID = "sentiment-analysis";
export interface SentimentAgentEndpoint {
apiUrl: string;
authUsername: string;
authPassword: string;
}
export function readSentimentAgentEndpoint(): SentimentAgentEndpoint {
return {
apiUrl: readRuntimeEnv("VITE_SENTIMENT_AGENT_API_URL") ?? "",
authUsername: readRuntimeEnv("VITE_SENTIMENT_AGENT_AUTH_USERNAME") ?? "",
authPassword: readRuntimeEnv("VITE_SENTIMENT_AGENT_AUTH_PASSWORD") ?? "",
};
}
export function isSentimentAgentConfigured(): boolean {
return !!readSentimentAgentEndpoint().apiUrl;
}
/**
* 是否开放舆情分析虚拟智能体。未配置时保持展示,避免旧部署升级后入口意外消失;
* 只有运行时配置显式写为 "false" 才关闭。
*/
export function isSentimentAgentVisible(): boolean {
return readRuntimeEnv("VITE_SENTIMENT_AGENT_VISIBLE") !== "false";
}
function toBase64(value: string): string {
// 凭据按 UTF-8 字节做 Basic Auth base64,避免非 ASCII 字符导致 btoa 抛错。
const bytes = new TextEncoder().encode(value);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
/** 组装请求头:Basic Auth(用户名/密码都配置了才带 Authorization)。 */
export function buildSentimentAgentHeaders(): Record<string, string> {
const { authUsername, authPassword } = readSentimentAgentEndpoint();
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "text/event-stream",
};
if (authUsername && authPassword) {
headers.Authorization = `Basic ${toBase64(`${authUsername}:${authPassword}`)}`;
}
return headers;
}
@@ -0,0 +1,55 @@
/**
* 舆情分析智能体的展示信息(名称 / 简介)。
* 这是一个前端虚拟智能体(不落库后端 /api/agents),名称与简介保存在
* localStorage,可在智能体管理页的编辑弹窗里随时修改。
*/
export interface SentimentAgentProfile {
name: string;
description: string;
}
const STORAGE_KEY = "deerflow.sentiment-agent.profile";
/** 名称 / 简介 未自定义时的默认文案。 */
export const DEFAULT_SENTIMENT_AGENT_PROFILE: SentimentAgentProfile = {
name: "舆情分析",
description:
"面向舆情领域的态势问答助手:围绕指定话题或事件,自动梳理舆情脉络、研判发展态势、归纳各方观点与热点走向,输出结构化的分析结论,为舆情应对与决策提供参考。",
};
export function readSentimentAgentProfile(): SentimentAgentProfile {
if (typeof window === "undefined") return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") {
return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
}
const record = parsed as Record<string, unknown>;
const name =
typeof record.name === "string" && record.name.trim()
? record.name.trim()
: DEFAULT_SENTIMENT_AGENT_PROFILE.name;
const description =
typeof record.description === "string"
? record.description.trim()
: DEFAULT_SENTIMENT_AGENT_PROFILE.description;
return { name, description };
} catch {
return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
}
}
export function saveSentimentAgentProfile(profile: SentimentAgentProfile): void {
if (typeof window === "undefined") return;
const name = profile.name.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.name;
const description =
profile.description.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.description;
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ name, description } satisfies SentimentAgentProfile),
);
// 编辑弹窗可能挂在管理页 / 对话页两处,广播一下让同页签的其他挂载点即时刷新。
window.dispatchEvent(new CustomEvent("sentiment-agent-profile-changed"));
}
@@ -0,0 +1,187 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { runSentimentAgent, toAgUiMessages } from "./api";
const STORAGE_PREFIX = "workspace.sentiment-agent.";
/**
* 舆情分析智能体的前端会话状态:消息只存在当前标签页的 sessionStorage,
* 不创建后端 thread、不进 LangGraph —— 所有问答都直连外部 AG-UI 网关。
* 整体结构参照 use-single-round-rag 的「合成 LangGraph 消息 + 本地持久化」模式。
*/
export function useSentimentAgentChat(threadId: string) {
const [messages, setMessages] = useState<Message[]>(() =>
readStoredMessages(threadId),
);
const messagesRef = useRef(messages);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
const stored = readStoredMessages(threadId);
messagesRef.current = stored;
setMessages(stored);
setError(null);
}, [threadId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
// 卸载 / 切会话时中断进行中的流。
useEffect(() => () => controllerRef.current?.abort(), []);
const stop = useCallback(() => controllerRef.current?.abort(), []);
const commitMessages = useCallback(
(updater: (current: Message[]) => Message[]) => {
setMessages((current) => {
const next = updater(current);
messagesRef.current = next;
writeStoredMessages(threadId, next);
return next;
});
},
[threadId],
);
const submit = useCallback(
async ({ query }: { query: string }) => {
if (isLoading) return;
const question = query.trim();
if (!question) return;
const assistantId = `sentiment-ai-${crypto.randomUUID()}`;
const userMessage: Message = {
id: `sentiment-human-${crypto.randomUUID()}`,
type: "human",
content: [{ type: "text", text: question }],
additional_kwargs: {
client_ts: new Date().toISOString(),
sentiment_agent: true,
},
};
const initialAssistant: Message = {
id: assistantId,
type: "ai",
content: "",
additional_kwargs: { sentiment_agent: true },
};
// AG-UI 历史 = 之前已完成的轮次 + 本轮提问。
const history = [
...toAgUiMessages(messagesRef.current),
{ role: "user" as const, id: userMessage.id!, content: question },
];
commitMessages((current) => [...current, userMessage, initialAssistant]);
setError(null);
setIsLoading(true);
const controller = new AbortController();
controllerRef.current = controller;
// 思考 / 正文两个缓冲区 + rAF 合帧刷新:SSE 一轮几千个 delta,
// 直接逐条 setState 会造成大量无效渲染。
let reasoning = "";
let answer = "";
let flushScheduled = false;
const flush = () => {
flushScheduled = false;
commitMessages((current) =>
current.map((message) =>
message.id === assistantId
? ({
...message,
content: answer,
additional_kwargs: {
...message.additional_kwargs,
sentiment_agent: true,
reasoning_content: reasoning || null,
},
} as Message)
: message,
),
);
};
const scheduleFlush = () => {
if (flushScheduled) return;
flushScheduled = true;
requestAnimationFrame(flush);
};
try {
await runSentimentAgent({
threadId,
runId: `run-${crypto.randomUUID()}`,
messages: history,
signal: controller.signal,
handlers: {
onReasoningDelta: (_messageId, delta) => {
reasoning += delta;
scheduleFlush();
},
onAnswerDelta: (_messageId, delta) => {
answer += delta;
scheduleFlush();
},
},
});
flush();
} catch (caught) {
flush();
if (!isAbortError(caught)) {
const message =
caught instanceof Error ? caught.message : "舆情分析问答失败。";
setError(message);
// 一个字都没回答出来时,把错误写进气泡,用户能看到失败原因。
if (!answer) {
commitMessages((current) =>
current.map((item) =>
item.id === assistantId
? ({ ...item, content: message } as Message)
: item,
),
);
}
}
} finally {
if (controllerRef.current === controller) controllerRef.current = null;
setIsLoading(false);
}
},
[commitMessages, isLoading, threadId],
);
return { messages, isLoading, error, submit, stop };
}
function storageKey(threadId: string) {
return `${STORAGE_PREFIX}${threadId}`;
}
function readStoredMessages(threadId: string): Message[] {
if (typeof window === "undefined" || !threadId) return [];
try {
const raw = window.sessionStorage.getItem(storageKey(threadId));
const parsed: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? (parsed as Message[]) : [];
} catch {
return [];
}
}
function writeStoredMessages(threadId: string, messages: Message[]) {
if (typeof window === "undefined" || !threadId) return;
try {
window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages));
} catch {
// 配额异常不能打断进行中的回答。
}
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
+286 -1
View File
@@ -31,7 +31,7 @@ export type RewriteStreamEvent =
// ── SSE parser ────────────────────────────────────────────────────────────────
async function* parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<{ event: string; data: string }> {
export async function* parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<{ event: string; data: string }> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
@@ -106,3 +106,288 @@ export async function* streamRewrite(
}
}
}
export type DocumentRewriteStyle =
| "formal_analysis"
| "professional"
| "concise"
| "readable";
export interface DocumentRewriteRequest {
threadId: string;
path: string;
instruction: string;
style?: DocumentRewriteStyle;
modelName?: string;
expectedHash?: string;
}
export interface DocumentRewriteSummary {
sourceDisplayName: string;
instruction: string;
modelName: string;
originalCharCount: number;
rewrittenCharCount: number;
charChangeRatio: number;
changedBlockCount: number;
headingCountBefore: number;
headingCountAfter: number;
headingChanges?: {
added: string[];
removed: string[];
reordered: boolean;
levelWarnings: string[];
};
requestedOptimizations?: string[];
validationWarnings?: string[];
}
export type DocumentRewriteStreamEvent =
| { type: "job_started"; jobId: string; reused: boolean }
| { type: "snapshot_fixed"; fileName: string; originalCharCount: number; baseHash: string }
| { type: "requirements_started" }
/** Small live rewrite plan shown in the message-list requirements step. */
| { type: "requirements_delta"; delta: string }
/** Durable recovery point for a plan produced before an SSE reconnect. */
| { type: "requirements_checkpoint"; content: string }
| { type: "requirements_completed"; content: string; fallback: boolean }
| { type: "requirements_resolved"; instruction: string; style: string; modelName: string; plan?: string }
| { type: "thinking"; chunk: string }
| { type: "rewrite_delta"; delta: string; charCount: number }
/** Durable full-draft recovery point used after an SSE reconnect. */
| { type: "rewrite_checkpoint"; content: string; charCount: number }
| { type: "validation_started" }
| { type: "validation_completed"; passed: boolean; warnings: string[] }
| { type: "comparison_ready"; summary: DocumentRewriteSummary }
| { type: "commit_started" }
| { type: "committed"; content: string; committedHash: string; versionId?: string }
| { type: "done"; committedHash: string; versionId?: string }
| { type: "conflict"; message: string }
| { type: "cancelled"; message: string }
| { type: "error"; message: string; stage?: string };
export interface DurableDocumentRewriteJob {
id: string;
status: string;
phase: string;
progress: number;
createdAt?: string | null;
result: Record<string, unknown>;
request: {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
originalContent: string;
};
}
async function readDocumentRewriteEvent(
event: string,
data: string,
): Promise<DocumentRewriteStreamEvent | null> {
try {
const parsed = JSON.parse(data) as Record<string, unknown>;
switch (event) {
case "snapshot_fixed":
return {
type: "snapshot_fixed",
fileName: String(parsed.fileName ?? ""),
originalCharCount: Number(parsed.originalCharCount ?? 0),
baseHash: String(parsed.baseHash ?? ""),
};
case "requirements_resolved":
return {
type: "requirements_resolved",
instruction: String(parsed.instruction ?? ""),
style: String(parsed.style ?? ""),
modelName: String(parsed.modelName ?? ""),
plan: typeof parsed.plan === "string" ? parsed.plan : undefined,
};
case "requirements_started":
return { type: "requirements_started" };
case "requirements_delta":
return { type: "requirements_delta", delta: String(parsed.delta ?? "") };
case "requirements_checkpoint":
return { type: "requirements_checkpoint", content: String(parsed.content ?? "") };
case "requirements_completed":
return {
type: "requirements_completed",
content: String(parsed.content ?? ""),
fallback: parsed.fallback === true,
};
case "thinking":
return { type: "thinking", chunk: String(parsed.chunk ?? "") };
case "rewrite_delta":
return {
type: "rewrite_delta",
delta: String(parsed.delta ?? ""),
charCount: Number(parsed.charCount ?? 0),
};
case "rewrite_checkpoint":
return {
type: "rewrite_checkpoint",
content: String(parsed.content ?? ""),
charCount: Number(parsed.charCount ?? 0),
};
case "validation_started":
return { type: "validation_started" };
case "validation_completed":
return {
type: "validation_completed",
passed: parsed.passed === true,
warnings: Array.isArray(parsed.warnings)
? parsed.warnings.filter((item): item is string => typeof item === "string")
: [],
};
case "comparison_ready":
return { type: "comparison_ready", summary: parsed as unknown as DocumentRewriteSummary };
case "commit_started":
return { type: "commit_started" };
case "committed":
return {
type: "committed",
content: String(parsed.content ?? ""),
committedHash: String(parsed.committedHash ?? ""),
versionId: typeof parsed.versionId === "string" ? parsed.versionId : undefined,
};
case "done":
return {
type: "done",
committedHash: String(parsed.committedHash ?? ""),
versionId: typeof parsed.versionId === "string" ? parsed.versionId : undefined,
};
case "conflict":
return { type: "conflict", message: String(parsed.message ?? "文件内容已变化") };
case "cancelled":
return { type: "cancelled", message: String(parsed.message ?? "已停止全文改写") };
case "error":
return {
type: "error",
message: String(parsed.message ?? "全文改写失败"),
stage: typeof parsed.stage === "string" ? parsed.stage : undefined,
};
default:
return null;
}
} catch {
return null;
}
}
async function startDurableDocumentRewrite(
req: DocumentRewriteRequest,
signal?: AbortSignal,
): Promise<{ jobId: string; reused: boolean }> {
const response = await apiFetch(getBackendBaseURL() + "/api/writing/document-rewrite/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
signal,
});
const body = await response.json().catch(() => ({})) as Record<string, unknown>;
if (!response.ok || typeof body.jobId !== "string") {
throw new Error(
(body.detail as string | undefined) ??
(body.message as string | undefined) ??
`HTTP ${response.status}`,
);
}
return { jobId: body.jobId, reused: body.reused === true };
}
export async function* streamDurableDocumentRewriteJob(
req: Pick<DocumentRewriteRequest, "threadId" | "path">,
jobId: string,
signal?: AbortSignal,
after = 0,
): AsyncGenerator<DocumentRewriteStreamEvent> {
const query = new URLSearchParams({
thread_id: req.threadId,
path: req.path,
after: String(after),
});
const response = await apiFetch(
`${getBackendBaseURL()}/api/writing/document-rewrite/jobs/${encodeURIComponent(jobId)}/stream?${query.toString()}`,
{ method: "GET", signal },
);
if (!response.ok || !response.body) {
const body = await response.json().catch(() => ({})) as Record<string, unknown>;
throw new Error((body.detail as string | undefined) ?? `HTTP ${response.status}`);
}
for await (const { event, data } of parseSse(response.body)) {
if (signal?.aborted) return;
const parsed = await readDocumentRewriteEvent(event, data);
if (!parsed) continue;
yield parsed;
if (["done", "conflict", "cancelled", "error"].includes(parsed.type)) return;
}
}
export async function getLatestDurableDocumentRewrite(
req: Pick<DocumentRewriteRequest, "threadId" | "path">,
): Promise<DurableDocumentRewriteJob | null> {
const jobs = await listDurableDocumentRewrites(req, 1);
return jobs[0] ?? null;
}
export async function listDurableDocumentRewrites(
req: Pick<DocumentRewriteRequest, "threadId" | "path">,
limit = 20,
): Promise<DurableDocumentRewriteJob[]> {
const query = new URLSearchParams({
thread_id: req.threadId,
path: req.path,
limit: String(Math.min(Math.max(limit, 1), 20)),
});
const response = await apiFetch(
`${getBackendBaseURL()}/api/writing/document-rewrite/jobs?${query.toString()}`,
{ method: "GET" },
);
const body = await response.json().catch(() => ({})) as Record<string, unknown>;
if (!response.ok) throw new Error((body.detail as string | undefined) ?? `HTTP ${response.status}`);
const rows = Array.isArray(body.items) ? body.items : [];
return rows.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const job = item as Record<string, unknown>;
if (typeof job.id !== "string") return [];
const request = (job.request ?? {}) as Record<string, unknown>;
return [{
id: job.id,
status: String(job.status ?? "queued"),
phase: String(job.phase ?? "initializing"),
progress: Number(job.progress ?? 0),
createdAt: typeof job.createdAt === "string" ? job.createdAt : null,
result: (job.result ?? {}) as Record<string, unknown>,
request: {
instruction: String(request.instruction ?? ""),
style: (request.style ?? "professional") as DocumentRewriteStyle,
modelName: typeof request.modelName === "string" ? request.modelName : null,
originalContent: String(request.originalContent ?? ""),
},
}];
});
}
export async function cancelDurableDocumentRewrite(
req: Pick<DocumentRewriteRequest, "threadId" | "path">,
jobId: string,
): Promise<void> {
const query = new URLSearchParams({ thread_id: req.threadId, path: req.path });
const response = await apiFetch(
`${getBackendBaseURL()}/api/writing/document-rewrite/jobs/${encodeURIComponent(jobId)}/cancel?${query.toString()}`,
{ method: "POST" },
);
if (!response.ok) {
const body = await response.json().catch(() => ({})) as Record<string, unknown>;
throw new Error((body.detail as string | undefined) ?? "停止全文改写失败");
}
}
export async function* streamDocumentRewrite(
req: DocumentRewriteRequest,
signal?: AbortSignal,
): AsyncGenerator<DocumentRewriteStreamEvent> {
const started = await startDurableDocumentRewrite(req, signal);
yield { type: "job_started", jobId: started.jobId, reused: started.reused };
yield* streamDurableDocumentRewriteJob(req, started.jobId, signal);
}
@@ -5,8 +5,15 @@ import { CanvasUIProvider, useCanvasUIContext } from "../../contexts/CanvasUICon
import { TiptapEditor } from "../../editor/TiptapEditor";
import { useWritingEditor } from "../../hooks/useWritingEditor";
import { useWritingRewrite } from "../../hooks/useWritingRewrite";
import { applyInsert, applyReplace } from "../../utils/apply-rewrite";
import {
applyInsert,
applyReplace,
createRewriteSelectionSnapshot,
isRewriteSelectionSnapshotCurrent,
type RewriteSelectionSnapshot,
} from "../../utils/apply-rewrite";
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
import { toast } from "sonner";
import { WritingAiMenu } from "./WritingAiMenu";
import { WritingBubbleMenu } from "./WritingBubbleMenu";
@@ -69,6 +76,7 @@ function MarkdownAiEditorInner({
const [phase, setPhase] = useState<FlowPhase>("idle");
const [panelPos, setPanelPos] = useState<PanelPos>({ x: 0, y: 0 });
const lastActionRef = useRef<LastAction | null>(null);
const candidateSnapshotRef = useRef<RewriteSelectionSnapshot | null>(null);
const editor = useWritingEditor({
markdown,
@@ -99,10 +107,17 @@ function MarkdownAiEditorInner({
const closeAll = useCallback(() => {
editor?.commands.clearAiHighlight();
candidateSnapshotRef.current = null;
setPhase("idle");
reset();
}, [reset, editor]);
const snapshotCurrentSelection = useCallback(() => {
if (!editor || !writingSelection) return false;
candidateSnapshotRef.current = createRewriteSelectionSnapshot(editor, writingSelection);
return true;
}, [editor, writingSelection]);
const handleAction = useCallback(
(
action: RewriteAction,
@@ -110,39 +125,53 @@ function MarkdownAiEditorInner({
customInstruction?: string,
referenceText?: string,
) => {
if (!snapshotCurrentSelection()) return;
lastActionRef.current = { action, tone, customInstruction, referenceText };
rewrite(action, tone, customInstruction, referenceText);
setPhase("preview");
},
[rewrite],
[rewrite, snapshotCurrentSelection],
);
const handleRetry = useCallback(() => {
if (!lastActionRef.current) return;
if (!snapshotCurrentSelection()) return;
const { action, tone, customInstruction, referenceText } = lastActionRef.current;
rewrite(action, tone, customInstruction, referenceText);
}, [rewrite]);
}, [rewrite, snapshotCurrentSelection]);
const handleFollowUp = useCallback(
(instruction: string) => {
if (!lastActionRef.current) return;
if (!snapshotCurrentSelection()) return;
const { action, tone, referenceText } = lastActionRef.current;
rewriteFollowUp(instruction, action, tone, referenceText);
},
[rewriteFollowUp],
[rewriteFollowUp, snapshotCurrentSelection],
);
const canApplyCandidate = useCallback(() => {
if (!editor || !candidateSnapshotRef.current) return false;
if (isRewriteSelectionSnapshotCurrent(editor, candidateSnapshotRef.current)) return true;
toast.error("原文已变化,请重新生成候选稿。", {
description: "为避免将过期候选替换到错误位置,本次内容没有写入文档。",
});
return false;
}, [editor]);
const handleReplace = useCallback(() => {
if (!editor || !writingSelection || !result) return;
applyReplace(editor, writingSelection, result);
const snapshot = candidateSnapshotRef.current;
if (!editor || !snapshot || !result || !canApplyCandidate()) return;
applyReplace(editor, snapshot, result);
closeAll();
}, [editor, writingSelection, result, closeAll]);
}, [editor, result, canApplyCandidate, closeAll]);
const handleInsert = useCallback(() => {
if (!editor || !writingSelection || !result) return;
applyInsert(editor, writingSelection, result);
const snapshot = candidateSnapshotRef.current;
if (!editor || !snapshot || !result || !canApplyCandidate()) return;
applyInsert(editor, snapshot, result);
closeAll();
}, [editor, writingSelection, result, closeAll]);
}, [editor, result, canApplyCandidate, closeAll]);
return (
<div className={className ?? "h-full flex flex-col overflow-hidden"}>
@@ -0,0 +1,394 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
type DocumentRewriteRequest,
type DocumentRewriteSummary,
type DocumentRewriteStreamEvent,
cancelDurableDocumentRewrite,
streamDocumentRewrite,
} from "../api/rewrite";
export type DocumentRewriteStage =
| "idle"
| "snapshot"
| "requirements"
| "writing"
| "validation"
| "committing"
| "completed"
| "conflict"
| "error"
| "cancelled";
export interface DocumentRewriteRun {
stage: DocumentRewriteStage;
draft: string;
/** Live, compact execution plan produced before Markdown generation starts. */
requirementsPlan: string;
originalCharCount: number;
writtenCharCount: number;
fileName: string;
warnings: string[];
summary: DocumentRewriteSummary | null;
versionId: string | null;
jobId: string | null;
error: string | null;
}
const INITIAL_RUN: DocumentRewriteRun = {
stage: "idle",
draft: "",
requirementsPlan: "",
originalCharCount: 0,
writtenCharCount: 0,
fileName: "",
warnings: [],
summary: null,
versionId: null,
jobId: null,
error: null,
};
/**
* Full-document rewrite stream with requestAnimationFrame batching.
*
* The backend only persists after it emits `committed`; until then `draft`
* represents a temporary sandbox view and must never be treated as the file's
* actual content.
*/
export function useDocumentRewrite() {
const [run, setRun] = useState<DocumentRewriteRun>(INITIAL_RUN);
const controllerRef = useRef<AbortController | null>(null);
const jobRef = useRef<{ id: string; request: DocumentRewriteRequest } | null>(null);
const pendingDeltaRef = useRef("");
const pendingWrittenCharCountRef = useRef<number | null>(null);
const frameRef = useRef<number | null>(null);
const pendingRequirementsDeltaRef = useRef("");
const requirementsFrameRef = useRef<number | null>(null);
const flushDelta = useCallback(() => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
const delta = pendingDeltaRef.current;
const writtenCharCount = pendingWrittenCharCountRef.current;
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
if (!delta) return;
setRun((previous) => ({
...previous,
stage: "writing",
draft: previous.draft + delta,
...(writtenCharCount !== null ? { writtenCharCount } : {}),
}));
}, []);
const enqueueDelta = useCallback((delta: string, writtenCharCount: number) => {
if (!delta) return;
pendingDeltaRef.current += delta;
pendingWrittenCharCountRef.current = writtenCharCount;
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
const pending = pendingDeltaRef.current;
const pendingWrittenCharCount = pendingWrittenCharCountRef.current;
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
if (!pending) return;
setRun((previous) => ({
...previous,
stage: "writing",
draft: previous.draft + pending,
...(pendingWrittenCharCount !== null ? { writtenCharCount: pendingWrittenCharCount } : {}),
}));
});
}, []);
const flushRequirements = useCallback(() => {
if (requirementsFrameRef.current !== null) {
cancelAnimationFrame(requirementsFrameRef.current);
requirementsFrameRef.current = null;
}
const delta = pendingRequirementsDeltaRef.current;
pendingRequirementsDeltaRef.current = "";
if (!delta) return;
setRun((previous) => ({
...previous,
stage: ["snapshot", "requirements"].includes(previous.stage)
? "requirements"
: previous.stage,
requirementsPlan: previous.requirementsPlan + delta,
}));
}, []);
const enqueueRequirementsDelta = useCallback((delta: string) => {
if (!delta) return;
pendingRequirementsDeltaRef.current += delta;
if (requirementsFrameRef.current !== null) return;
requirementsFrameRef.current = requestAnimationFrame(() => {
requirementsFrameRef.current = null;
const pending = pendingRequirementsDeltaRef.current;
pendingRequirementsDeltaRef.current = "";
if (!pending) return;
setRun((previous) => ({
...previous,
stage: ["snapshot", "requirements"].includes(previous.stage)
? "requirements"
: previous.stage,
requirementsPlan: previous.requirementsPlan + pending,
}));
});
}, []);
const cancel = useCallback(() => {
controllerRef.current?.abort();
controllerRef.current = null;
const job = jobRef.current;
if (job) {
void cancelDurableDocumentRewrite(job.request, job.id).catch(() => {
// The browser stream is already stopped. The terminal event will be
// recovered on the next hydration if the cancel request is delayed.
});
}
flushDelta();
flushRequirements();
setRun((previous) =>
previous.stage === "completed"
? previous
: { ...previous, stage: "cancelled", error: "已停止全文改写,原文件未被修改。" },
);
}, [flushDelta, flushRequirements]);
const reset = useCallback(() => {
controllerRef.current?.abort();
controllerRef.current = null;
jobRef.current = null;
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
pendingRequirementsDeltaRef.current = "";
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
if (requirementsFrameRef.current !== null) {
cancelAnimationFrame(requirementsFrameRef.current);
requirementsFrameRef.current = null;
}
setRun(INITIAL_RUN);
}, []);
const start = useCallback(
async (
request: DocumentRewriteRequest,
options?: {
onCommitted?: (content: string) => void;
onEvent?: (event: DocumentRewriteStreamEvent) => void;
stream?: (
signal: AbortSignal,
) => AsyncIterable<DocumentRewriteStreamEvent>;
/** Resume a durable task instead of creating a new one. */
jobId?: string;
},
) => {
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
if (options?.jobId) {
jobRef.current = { id: options.jobId, request };
}
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
pendingRequirementsDeltaRef.current = "";
setRun({
...INITIAL_RUN,
stage: "snapshot",
fileName: request.path.split("/").filter(Boolean).pop() ?? request.path,
jobId: options?.jobId ?? null,
});
try {
let receivedTerminalEvent = false;
const stream = options?.stream
? options.stream(controller.signal)
: streamDocumentRewrite(request, controller.signal);
for await (const event of stream) {
if (controller.signal.aborted) return;
options?.onEvent?.(event);
switch (event.type) {
case "job_started":
jobRef.current = { id: event.jobId, request };
setRun((previous) => ({ ...previous, jobId: event.jobId }));
break;
case "snapshot_fixed":
setRun((previous) => ({
...previous,
stage: "requirements",
fileName: event.fileName || previous.fileName,
originalCharCount: event.originalCharCount,
}));
break;
case "requirements_started":
setRun((previous) => ({ ...previous, stage: "requirements", requirementsPlan: "" }));
break;
case "requirements_delta":
enqueueRequirementsDelta(event.delta);
break;
case "requirements_checkpoint":
flushRequirements();
setRun((previous) => ({
...previous,
stage: "requirements",
requirementsPlan: event.content,
}));
break;
case "requirements_completed":
flushRequirements();
setRun((previous) => ({
...previous,
stage: "requirements",
requirementsPlan: event.content,
}));
break;
case "requirements_resolved":
flushRequirements();
setRun((previous) => ({
...previous,
stage: "writing",
...(event.plan ? { requirementsPlan: event.plan } : {}),
}));
break;
case "rewrite_delta":
enqueueDelta(event.delta, event.charCount);
break;
case "rewrite_checkpoint":
// A background job may have continued while this browser was
// disconnected. Replace, rather than append, so no missed or
// duplicated live deltas corrupt the temporary sandbox draft.
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
setRun((previous) => ({
...previous,
stage: "writing",
draft: event.content,
writtenCharCount: event.charCount,
}));
break;
case "validation_started":
flushDelta();
flushRequirements();
setRun((previous) => ({ ...previous, stage: "validation" }));
break;
case "validation_completed":
setRun((previous) => ({
...previous,
warnings: event.warnings,
}));
break;
case "comparison_ready":
setRun((previous) => ({ ...previous, summary: event.summary }));
break;
case "commit_started":
setRun((previous) => ({ ...previous, stage: "committing" }));
break;
case "committed":
flushDelta();
flushRequirements();
setRun((previous) => ({
...previous,
draft: event.content,
writtenCharCount: event.content.replace(/\s/g, "").length,
versionId: event.versionId ?? previous.versionId,
}));
options?.onCommitted?.(event.content);
break;
case "done":
flushDelta();
flushRequirements();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
stage: "completed",
versionId: event.versionId ?? previous.versionId,
}));
return;
case "conflict":
flushDelta();
flushRequirements();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
stage: "conflict",
error: event.message,
}));
return;
case "cancelled":
flushDelta();
flushRequirements();
receivedTerminalEvent = true;
setRun((previous) => ({ ...previous, stage: "cancelled", error: event.message }));
return;
case "error":
flushDelta();
flushRequirements();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
stage: "error",
error: event.message,
}));
return;
default:
break;
}
}
if (!controller.signal.aborted && !receivedTerminalEvent) {
flushDelta();
flushRequirements();
setRun((previous) => ({
...previous,
stage: "error",
error: "改写任务连接已结束,但未收到完成状态;原文件未被修改。",
}));
}
} catch (error) {
if (controller.signal.aborted) return;
flushDelta();
flushRequirements();
setRun((previous) => ({
...previous,
stage: "error",
error: error instanceof Error ? error.message : "全文改写失败",
}));
} finally {
if (controllerRef.current === controller) {
controllerRef.current = null;
}
}
},
[enqueueDelta, enqueueRequirementsDelta, flushDelta, flushRequirements],
);
useEffect(
() => () => {
controllerRef.current?.abort();
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
if (requirementsFrameRef.current !== null) cancelAnimationFrame(requirementsFrameRef.current);
},
[],
);
return {
run,
isActive: ["snapshot", "requirements", "writing", "validation", "committing"].includes(run.stage),
start,
cancel,
reset,
};
}
@@ -1,6 +1,54 @@
import type { Editor } from "@tiptap/react";
import type { WritingSelection } from "../types/selection";
/**
* Immutable anchor for an AI rewrite candidate.
*
* An AI response can take seconds to arrive. During that time a user may
* keep editing the document, so a saved ProseMirror range is no longer a safe
* write target by itself. We bind the candidate to both the complete document
* and the selected text before allowing a destructive replacement.
*/
export interface RewriteSelectionSnapshot extends WritingSelection {
documentFingerprint: string;
}
/** A small deterministic fingerprint is sufficient for stale-candidate checks. */
function fingerprintDocument(editor: Editor): string {
const serialized = JSON.stringify(editor.getJSON());
let hash = 0x811c9dc5;
for (let index = 0; index < serialized.length; index += 1) {
hash ^= serialized.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16);
}
export function createRewriteSelectionSnapshot(
editor: Editor,
selection: WritingSelection,
): RewriteSelectionSnapshot {
return {
...selection,
documentFingerprint: fingerprintDocument(editor),
};
}
export function isRewriteSelectionSnapshotCurrent(
editor: Editor,
snapshot: RewriteSelectionSnapshot,
): boolean {
const documentSize = editor.state.doc.content.size;
if (snapshot.from < 0 || snapshot.to < snapshot.from || snapshot.to > documentSize) {
return false;
}
const selectedText = editor.state.doc.textBetween(snapshot.from, snapshot.to, "\n");
return (
selectedText === snapshot.text &&
fingerprintDocument(editor) === snapshot.documentFingerprint
);
}
/** Convert plain text (possibly multi-paragraph) to a list of Tiptap paragraph nodes. */
function textToNodes(schema: Editor["schema"], text: string) {
const paragraphs = text.split(/\n{2,}/);
+2 -2
View File
@@ -231,7 +231,7 @@ function LegacyDeepResearchPage() {
setQuery("");
setSelectedId(created.id);
// Auto-start the research job.
await actions.startJob.mutateAsync(created.id);
await actions.startJob.mutateAsync({ sessionId: created.id, entry: "legacy" });
toast.success("深度研究已启动");
} catch (err) {
toast.error(err instanceof Error ? err.message : "启动研究失败");
@@ -241,7 +241,7 @@ function LegacyDeepResearchPage() {
const handleStart = useCallback(
async (sessionId: string) => {
try {
await actions.startJob.mutateAsync(sessionId);
await actions.startJob.mutateAsync({ sessionId, entry: "legacy" });
toast.success("深度研究已启动");
} catch (err) {
toast.error(err instanceof Error ? err.message : "启动研究失败");
@@ -0,0 +1,250 @@
"use client";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { BotIcon, PlusSquare } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
PromptInput,
PromptInputProvider,
type PromptInputMessage,
PromptInputBody,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
} from "@/components/ai-elements/prompt-input";
import { useThreadChat } from "@/components/workspace/chats";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { Button } from "@/components/ui/button";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { SENTIMENT_AGENT_ID, isSentimentAgentConfigured } from "@/core/sentiment-agent/config";
import { readSentimentAgentProfile } from "@/core/sentiment-agent/profile";
import { useSentimentAgentChat } from "@/core/sentiment-agent/use-sentiment-agent-chat";
import { SubtasksProvider } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads";
import { useRouteBase } from "@/core/route-base";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/**
* 舆情分析智能体对话页:一个前端虚拟智能体(不在后端 /api/agents 落库),
* 问答直连外部 AG-UI 网关(地址 + Basic Auth 在 runtime-config.js 配置)。
* 页面复用普通问答的消息渲染(MessageList),输入框只保留发送按钮;
* 会话只存在当前标签页 sessionStorage,不创建后端 thread。
*/
export default function SentimentAgentChatPage() {
// 与 WorkspaceRoutes 的 ChatRuntime 相同的 Provider 组合(这里内联以避免
// 页面与路由文件互相导入形成环),使精简输入框仍具备普通问答的一致键盘行为。
return (
<SubtasksProvider>
<ArtifactsProvider>
<PromptInputProvider>
<SentimentAgentChatPageInner />
</PromptInputProvider>
</ArtifactsProvider>
</SubtasksProvider>
);
}
function SentimentAgentChatPageInner() {
const router = useRouter();
const routeBase = useRouteBase();
const chatsBase = `${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats`;
const { threadId, isNewThread, setIsNewThread } = useThreadChat();
const chat = useSentimentAgentChat(threadId);
const [profile, setProfile] = useState(() => readSentimentAgentProfile());
const configured = isSentimentAgentConfigured();
// 编辑弹窗可能挂在管理页 / 本页两处,保存后广播事件,这里即时刷新标题与简介。
useEffect(() => {
const onChange = () => setProfile(readSentimentAgentProfile());
window.addEventListener("sentiment-agent-profile-changed", onChange);
return () =>
window.removeEventListener("sentiment-agent-profile-changed", onChange);
}, []);
const displayName = desensitize(profile.name);
// 合成一个 MessageList 需要的 BaseStream:消息与加载态全部来自前端流式
// hook(参照 ChatPage 对单轮 RAG displayThread 的合成方式)。
const displayThread = useMemo(
() =>
({
messages: chat.messages,
values: { messages: chat.messages, artifacts: [] },
isLoading: chat.isLoading,
isThreadLoading: false,
error: chat.error,
stop: () => chat.stop(),
submit: async () => undefined,
joinStream: async () => undefined,
}) as unknown as BaseStream<AgentThreadState>,
[chat.error, chat.isLoading, chat.messages, chat.stop],
);
const status = chat.error
? "error"
: chat.isLoading
? "streaming"
: "ready";
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
const text = message.text.trim();
if (!text || chat.isLoading) return;
if (isNewThread) {
// 首条消息发出后把 /new 换成本地会话 id,刷新后仍能恢复当前对话。
// 这里必须经 HashRouter 导航;直接 history.replaceState 会在 URL 的
// hash 内再拼一个 `#`,导致刷新或后续导航命中错误的通用智能体路由。
setIsNewThread(false);
router.replace(`${chatsBase}/${threadId}`);
}
void chat.submit({ query: text });
},
[chat, chatsBase, isNewThread, router, setIsNewThread, threadId],
);
const handleStop = useCallback(() => chat.stop(), [chat]);
const isLandingState = chat.messages.length === 0;
const composer = (autoFocus: boolean) => (
<div className="w-full">
<SentimentComposer
status={status}
autoFocus={autoFocus}
onSubmit={handleSubmit}
onStop={handleStop}
/>
{!configured && (
<p className="text-destructive mt-2 text-center text-xs">
尚未配置舆情分析接口:请在部署目录的 runtime-config.js 里填写
VITE_SENTIMENT_AGENT_API_URL 及鉴权用户名 / 密码后刷新页面。
</p>
)}
</div>
);
return (
// isMock=true:会话完全在前端(无后端 thread),借此关掉消息上的
// 「删除」按钮 —— 它会去调后端 thread 删除接口,对虚拟会话没有意义。
<ThreadContext.Provider value={{ thread: displayThread, isMock: true }}>
<div className="workspace-chat-bg flex size-full min-h-0 overflow-hidden">
<main className="relative flex size-full min-h-0 min-w-0 flex-col">
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span
className="max-w-[200px] truncate text-xs font-medium"
title={displayName}
>
{displayName}
</span>
</div>
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
<span className="text-muted-foreground max-w-[260px] truncate text-xs font-normal">
{desensitize(profile.description)}
</span>
</div>
<div className="flex items-center">
<Button
size="sm"
variant="secondary"
onClick={() => router.push(`${chatsBase}/new`)}
>
<PlusSquare /> 新对话
</Button>
</div>
</header>
{isLandingState ? (
/* 落地态:名称 + 简介 + 输入框整体居中(普通问答落地页样式)。 */
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-6 pb-10 pt-16 sm:pt-24">
<div className="mb-7 flex flex-col items-center gap-3 text-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full dark:bg-[#022248]">
<BotIcon className="text-primary h-7 w-7" />
</div>
<h1 className="text-2xl font-semibold">{displayName}</h1>
<p className="text-muted-foreground max-w-xl text-sm leading-6">
{desensitize(profile.description)}
</p>
</div>
<div className="w-full max-w-(--container-width-md)">
{composer(true)}
</div>
</div>
) : (
/* 对话态:消息列表滚动,输入框停靠在主列底部。 */
<div className="flex min-h-0 flex-1 flex-col">
<MessageList
className="min-h-0 flex-1"
threadId={threadId}
thread={displayThread}
paddingBottom={24}
scrollButtonClassName="bottom-2"
onEditResubmit={(text) =>
handleSubmit({ text, files: [] })
}
/>
<div className="from-background pointer-events-none shrink-0 bg-gradient-to-t to-transparent px-4 pb-4 pt-2">
<div className="pointer-events-auto mx-auto w-full max-w-(--container-width-lg)">
{composer(false)}
</div>
</div>
</div>
)}
</main>
</div>
</ThreadContext.Provider>
);
}
/**
* 精简输入框:只有文本域 + 发送/停止按钮(流式中点击变为停止),
* 不带附件、模式、模型选择等普通问答输入框的其余能力。
*/
function SentimentComposer({
status,
autoFocus,
onSubmit,
onStop,
}: {
status: "error" | "streaming" | "ready";
autoFocus?: boolean;
onSubmit: (message: PromptInputMessage) => void;
onStop: () => void;
}) {
return (
<PromptInput
className={cn(
"w-full *:data-[slot='input-group']:rounded-3xl",
"rounded-3xl border border-border bg-background shadow-sm transition-shadow hover:shadow-md focus-within:border-ring/40 focus-within:shadow-md focus-within:ring-1 focus-within:ring-ring/20 dark:border-slate-700 dark:bg-slate-800",
)}
onSubmit={(message) => {
if (status === "streaming") {
onStop();
return;
}
onSubmit(message);
}}
>
<PromptInputBody className="px-2 pt-2">
<PromptInputTextarea
className="min-h-16 px-2 py-1 text-[15px] leading-7"
placeholder="请输入要分析的舆情问题,例如:分析近三个月美伊局势"
aria-label="舆情分析问题"
autoFocus={autoFocus}
/>
</PromptInputBody>
<PromptInputFooter className="flex items-center justify-end px-2 pt-1 pb-1.5">
<PromptInputSubmit
className="rounded-full border-border bg-background shadow-sm hover:bg-muted dark:border-blue-500 dark:bg-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none"
variant="outline"
status={status}
/>
</PromptInputFooter>
</PromptInput>
);
}
+23
View File
@@ -3,6 +3,7 @@ import { Toaster } from "sonner";
import { SidebarProvider } from "@/components/ui/sidebar";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
import { RouteBaseProvider } from "@/core/route-base";
import GeneralQA from "@/strategy-components/components/chat-qa/GeneralQA";
import SentimentAnalyzes from "@/strategy-components/components/task-components/SentimentAnalyzes";
@@ -29,6 +30,7 @@ import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainList
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
import AgentChatPage from "./AgentChatPage";
import SentimentAgentChatPage from "./SentimentAgentChatPage";
import { AgentModuleRoutes } from "./AgentModulePage";
import { AGENT_MODULES } from "@/components/workspace/agents/agent-module-sidebar";
import AgentsPage from "./AgentsPage";
@@ -311,6 +313,27 @@ export default function StrategyRoutes() {
<Route path="agents" element={<AgentsPage />} />
<Route path="agents/new" element={<NewAgentPage />} />
{/* 舆情分析虚拟智能体:前端直连外部 AG-UI 网关的专用对话页。 */}
<Route
path="agents/sentiment-analysis/chats"
element={
isSentimentAgentVisible() ? (
<Navigate to="/page/strategy/agents/sentiment-analysis/chats/new" replace />
) : (
<Navigate to="/page/strategy/agents" replace />
)
}
/>
<Route
path="agents/sentiment-analysis/chats/:thread_id"
element={
isSentimentAgentVisible() ? (
<SentimentAgentChatPage />
) : (
<Navigate to="/page/strategy/agents" replace />
)
}
/>
<Route
path="agents/:agent_id/chats"
element={<Navigate to="/page/strategy/agents" replace />}
+32 -3
View File
@@ -2,6 +2,7 @@ import type { CSSProperties, ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { getStoredAuth } from '@/core/auth';
import { isSentimentAgentVisible } from '@/core/sentiment-agent/config';
import {
resolveChatsIframeEntryRedirect,
resolveStandardChatFallback,
@@ -21,6 +22,7 @@ import { CommandPalette } from '@/components/workspace/command-palette';
import { Toaster } from 'sonner';
import { SubtasksProvider } from '@/core/tasks/context';
import { ArtifactsProvider } from '@/components/workspace/artifacts';
import { DocumentRewritePresentationProvider } from '@/core/document-rewrite/context';
import { RouteBaseProvider } from '@/core/route-base';
@@ -29,6 +31,7 @@ import ChatPage from './ChatPage';
import IframeChatPage from './IframeChatPage';
import AgentsPage from './AgentsPage';
import AgentChatPage from './AgentChatPage';
import SentimentAgentChatPage from './SentimentAgentChatPage';
import NewAgentPage from './NewAgentPage';
import AdminAgentsPage from './AdminAgentsPage';
import AdminScheduledTasksPage from './AdminScheduledTasksPage';
@@ -210,9 +213,11 @@ export function WorkspaceLayout({
export function ChatRuntime({ children }: { children: ReactNode }) {
return (
<SubtasksProvider>
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
<DocumentRewritePresentationProvider>
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
</DocumentRewritePresentationProvider>
</SubtasksProvider>
);
}
@@ -468,6 +473,30 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
{/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走前端直连外部
AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */}
<Route
path="agents/sentiment-analysis/chats"
element={
isSentimentAgentVisible() ? (
<Navigate to="/page/workspace/agents/sentiment-analysis/chats/new" replace />
) : (
<Navigate to="/page/workspace/agents" replace />
)
}
/>
<Route
path="agents/sentiment-analysis/chats/:thread_id"
element={
isSentimentAgentVisible() ? (
<WorkspaceLayout {...layoutProps}>
<SentimentAgentChatPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/agents" replace />
)
}
/>
<Route
path="agents/:agent_id/chats"
element={<Navigate to="/page/workspace/agents" replace />}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,358 @@
"use client";
/**
* 报告配置卡片(用户协助卡片)。
*
* 对话式收集完成后弹出:预填原模式下拉(快速/标准/详细/深度/多智能体)与
* 原高级配置对话框的全部选项(语言/语气/结构/配图/预算等),由用户确认后
* 以 chat 入口启动报告写作 job —— 保留旧输入框全部能力,改造收敛在这一张卡片。
*/
import { useEffect, useMemo, useState } from "react";
import {
ChevronRight,
ImagePlus,
Loader2,
MessageSquarePlus,
SlidersHorizontal,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
type ResearchMode,
type ResearchTone,
} from "@/strategy-components/api/deep-research";
const MODE_LABELS: Record<ResearchMode, string> = {
basic: "标准",
quick: "快速",
detailed: "详细",
deep: "深度",
multi_agent: "多智能体",
};
const MODE_HINTS: Record<ResearchMode, string> = {
basic: "完整单遍写作,结构与篇幅均衡",
quick: "短报告单遍成文,最快出结果",
detailed: "按主题分节综合,篇幅更长",
deep: "迭代深挖补充检索后再成文",
multi_agent: "分节撰写并附加修订审稿轮",
};
export type AdvancedResearchOptions = {
language: "zh-CN" | "en-US";
tone: ResearchTone;
maxSearchResults: string;
maxIterations: string;
maxConcurrency: string;
maxContextWords: string;
maxSubtopics: string;
deepBreadth: string;
deepDepth: string;
maxRevisionLoops: string;
maxGeneratedImages: string;
thinkingEnabled: boolean;
customOutline: string;
};
export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
language: "zh-CN",
tone: "objective",
maxSearchResults: "8",
maxIterations: "3",
maxConcurrency: "3",
maxContextWords: "24000",
maxSubtopics: "8",
deepBreadth: "4",
deepDepth: "2",
maxRevisionLoops: "2",
maxGeneratedImages: "2",
thinkingEnabled: false,
customOutline: "",
};
/** Seed the card's editable options from the frozen session config snapshot. */
export function advancedOptionsFromConfig(config?: Record<string, unknown>): AdvancedResearchOptions {
const numberish = (key: string, fallback: string): string => {
const value = config?.[key];
return typeof value === "number" && Number.isFinite(value) ? String(value) : fallback;
};
return {
language: config?.language === "en-US" ? "en-US" : "zh-CN",
tone: (typeof config?.tone === "string" ? config.tone : "objective") as ResearchTone,
maxSearchResults: numberish("max_search_results_per_query", DEFAULT_ADVANCED_OPTIONS.maxSearchResults),
maxIterations: numberish("max_iterations", DEFAULT_ADVANCED_OPTIONS.maxIterations),
maxConcurrency: numberish("max_concurrency", DEFAULT_ADVANCED_OPTIONS.maxConcurrency),
maxContextWords: numberish("max_context_words", DEFAULT_ADVANCED_OPTIONS.maxContextWords),
maxSubtopics: numberish("max_subtopics", DEFAULT_ADVANCED_OPTIONS.maxSubtopics),
deepBreadth: numberish("deep_breadth", DEFAULT_ADVANCED_OPTIONS.deepBreadth),
deepDepth: numberish("deep_depth", DEFAULT_ADVANCED_OPTIONS.deepDepth),
maxRevisionLoops: numberish("max_revision_loops", DEFAULT_ADVANCED_OPTIONS.maxRevisionLoops),
maxGeneratedImages: numberish("max_generated_images", DEFAULT_ADVANCED_OPTIONS.maxGeneratedImages),
thinkingEnabled: config?.thinking_enabled === true,
customOutline: typeof config?.custom_outline === "string" ? config.custom_outline : "",
};
}
function boundedInteger(value: string, fallback: number, min: number, max: number): number {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
}
/** The card's choices as the job config override sent with entry:"chat". */
export function buildReportCardConfig(
mode: ResearchMode,
generateImages: boolean,
options: AdvancedResearchOptions,
): Record<string, unknown> {
const config: Record<string, unknown> = {
mode,
generate_images: generateImages,
language: options.language,
tone: options.tone,
thinking_enabled: options.thinkingEnabled,
max_search_results_per_query: boundedInteger(options.maxSearchResults, 8, 1, 20),
max_iterations: boundedInteger(options.maxIterations, 3, 1, 10),
max_concurrency: boundedInteger(options.maxConcurrency, 3, 1, 8),
max_context_words: boundedInteger(options.maxContextWords, 24_000, 500, 120_000),
};
if (mode === "detailed" || mode === "multi_agent") {
config.max_subtopics = boundedInteger(options.maxSubtopics, 8, 1, 20);
}
if (mode === "deep") {
config.deep_breadth = boundedInteger(options.deepBreadth, 4, 1, 8);
config.deep_depth = boundedInteger(options.deepDepth, 2, 1, 4);
}
if (mode === "multi_agent") {
config.max_revision_loops = boundedInteger(options.maxRevisionLoops, 2, 0, 5);
}
if (generateImages) config.max_generated_images = boundedInteger(options.maxGeneratedImages, 2, 1, 4);
if (options.customOutline.trim()) config.custom_outline = options.customOutline.trim().slice(0, 4_000);
return config;
}
export function ReportConfigCard({
availableModes,
defaultMode,
defaultOptions,
defaultGenerateImages,
modelLabel,
materialCount,
imageGenerationAvailable,
imageLimit,
submitting,
onConfirm,
onContinueCollecting,
}: {
availableModes: ResearchMode[];
defaultMode: ResearchMode;
defaultOptions: AdvancedResearchOptions;
defaultGenerateImages: boolean;
modelLabel: string;
/** Number of unique, harvestable materials visible in the collector thread. */
materialCount: number;
imageGenerationAvailable: boolean;
imageLimit: number;
submitting: boolean;
onConfirm: (config: Record<string, unknown>) => void;
onContinueCollecting: () => void;
}) {
const [mode, setMode] = useState<ResearchMode>(
availableModes.includes(defaultMode) ? defaultMode : (availableModes[0] ?? "basic"),
);
const [options, setOptions] = useState<AdvancedResearchOptions>(defaultOptions);
const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
setOptions(defaultOptions);
setGenerateImages(defaultGenerateImages);
}, [defaultOptions, defaultGenerateImages]);
const modes = useMemo(
() => (availableModes.length ? availableModes : (["basic", "quick", "detailed", "deep", "multi_agent"] as ResearchMode[])),
[availableModes],
);
return (
<div className="rounded-2xl rounded-tl-md border border-primary/25 bg-card p-4 shadow-sm">
<div className="flex items-start gap-2.5">
<div className="mt-0.5 rounded-full bg-primary/10 p-1.5 text-primary">
<Sparkles className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">资料收集完成,请确认报告写法</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
下方选项沿用原研究配置并可调整;确认后将基于以上对话收集到的资料撰写报告。
{modelLabel ? <>写作模型:<span className="font-medium text-foreground">{modelLabel}</span>。</> : null}
</p>
<div className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-primary/15 bg-primary/[0.06] px-2 py-1 text-[11px] text-primary">
<span className="font-medium">已收集 {materialCount} 条资料</span>
<span className="text-primary/70">将作为本次报告的写作依据</span>
</div>
</div>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1.5 block text-xs font-medium">报告模式</span>
<Select value={mode} onValueChange={(value) => setMode(value as ResearchMode)}>
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
{modes.map((item) => (
<SelectItem key={item} value={item}>{MODE_LABELS[item]}</SelectItem>
))}
</SelectContent>
</Select>
<span className="mt-1 block text-[11px] text-muted-foreground">{MODE_HINTS[mode]}</span>
</label>
{imageGenerationAvailable && (
<div>
<span className="mb-1.5 block text-xs font-medium">报告配图</span>
<button
type="button"
aria-pressed={generateImages}
onClick={() => setGenerateImages((value) => !value)}
className={`inline-flex h-9 items-center gap-1.5 rounded-md border px-3 text-sm transition-colors ${generateImages ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-muted-foreground hover:bg-muted"}`}
>
<ImagePlus className="h-3.5 w-3.5" />
{generateImages ? `生成配图(最多 ${imageLimit} 张)` : "不生成配图"}
</button>
</div>
)}
</div>
<div className="mt-3 overflow-hidden rounded-xl border border-border/70">
<button
type="button"
onClick={() => setAdvancedOpen((value) => !value)}
className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-xs font-medium hover:bg-muted/40"
aria-expanded={advancedOpen}
>
<SlidersHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
高级配置
<span className="font-normal text-muted-foreground">语言、语气、自定义结构等</span>
<ChevronRight className={`ml-auto h-3.5 w-3.5 text-muted-foreground transition-transform ${advancedOpen ? "rotate-90" : ""}`} />
</button>
{advancedOpen && (
<div className="grid gap-3 border-t border-border/70 p-3 sm:grid-cols-2">
<CardSelect
label="输出语言"
value={options.language}
onChange={(value) => update("language", value as AdvancedResearchOptions["language"])}
options={[{ value: "zh-CN", label: "中文" }, { value: "en-US", label: "English" }]}
/>
<CardSelect
label="报告语气"
value={options.tone}
onChange={(value) => update("tone", value as ResearchTone)}
options={[
{ value: "objective", label: "客观" },
{ value: "formal", label: "正式" },
{ value: "analytical", label: "分析" },
{ value: "persuasive", label: "论证" },
]}
/>
<CardNumber label="单次查询结果" hint="1–20 条" value={options.maxSearchResults} onChange={(v) => update("maxSearchResults", v)} />
<CardNumber label="上下文词数" hint="500–120000" value={options.maxContextWords} onChange={(v) => update("maxContextWords", v)} />
{(mode === "detailed" || mode === "multi_agent") && (
<CardNumber label="最多子主题" hint="1–20 个" value={options.maxSubtopics} onChange={(v) => update("maxSubtopics", v)} />
)}
{mode === "deep" && (
<>
<CardNumber label="深挖广度" hint="1–8 路" value={options.deepBreadth} onChange={(v) => update("deepBreadth", v)} />
<CardNumber label="深挖层级" hint="1–4 层" value={options.deepDepth} onChange={(v) => update("deepDepth", v)} />
</>
)}
{mode === "multi_agent" && (
<CardNumber label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(v) => update("maxRevisionLoops", v)} />
)}
<div className="sm:col-span-2">
<span className="mb-1.5 block text-xs font-medium">自定义报告结构</span>
<Textarea
value={options.customOutline}
onChange={(event) => update("customOutline", event.target.value)}
maxLength={4000}
className="min-h-20 resize-y text-xs"
placeholder={"例如:\n# 执行摘要\n## 市场与背景\n## 机会与风险\n## 建议"}
/>
<p className="mt-1 text-[10px] text-muted-foreground">支持 Markdown 标题或编号列表,作为报告写作结构要求。</p>
</div>
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4 sm:col-span-2">
<input
type="checkbox"
checked={options.thinkingEnabled}
onChange={(event) => update("thinkingEnabled", event.target.checked)}
className="mt-0.5 accent-primary"
/>
<span>
<span className="block font-medium text-foreground">启用模型深度思考</span>
仅在当前模型支持时生效;会增加写作时延与模型消耗。
</span>
</label>
</div>
)}
</div>
<div className="mt-4 flex flex-wrap justify-end gap-2">
<Button size="sm" variant="outline" onClick={onContinueCollecting} disabled={submitting}>
<MessageSquarePlus className="mr-1.5 h-3.5 w-3.5" />
继续补充资料
</Button>
<Button size="sm" disabled={submitting} onClick={() => onConfirm(buildReportCardConfig(mode, generateImages, options))}>
{submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
开始撰写报告
</Button>
</div>
</div>
);
function update<K extends keyof AdvancedResearchOptions>(key: K, value: AdvancedResearchOptions[K]) {
setOptions((current) => ({ ...current, [key]: value }));
}
}
function CardSelect({ label, value, onChange, options }: {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}) {
return (
<label className="block">
<span className="mb-1.5 block text-xs font-medium">{label}</span>
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{options.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
</SelectContent>
</Select>
</label>
);
}
function CardNumber({ label, hint, value, onChange }: {
label: string;
hint: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<label className="block">
<span className="mb-1.5 block text-xs font-medium">{label}</span>
<Input value={value} inputMode="numeric" onChange={(event) => onChange(event.target.value)} className="h-8 text-xs" />
<span className="mt-0.5 block text-[10px] text-muted-foreground">{hint}</span>
</label>
);
}
@@ -0,0 +1,197 @@
"use client";
/**
* 深度研究沙箱分栏 —— 对齐主聊天 `ChatBox` / 圆桌 Step2 的 60/40 对话 | 产物预览布局。
*
* 深度研究没有 LangGraph SDK 线程承载报告(报告由写作 job 的 SSE 流出),这里用
* `useReportSandbox` 构造的最小 ThreadContext stub 供 `ArtifactFileDetail` 工作:
* 虚拟 `write-file:` URL 直接读 stub tool_call 的 args.content。右侧因此免费获得
* 真沙箱的全部能力(Streamdown Markdown 渲染、流式 code 视图自动滚底、写完自动
* 切预览 + 脉冲、复制/另存、展示层脱敏)。
*/
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { FileTextIcon, XIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
ArtifactFileDetail,
useArtifacts,
} from "@/components/workspace/artifacts";
import { DocumentRewriteDialog } from "@/components/workspace/artifacts/document-rewrite-dialog";
import { ThreadContext } from "@/components/workspace/messages/context";
import type { AgentThreadState } from "@/core/threads";
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
import { cn } from "@/lib/utils";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 60, artifacts: 40 };
interface ResearchSandboxPanelProps {
/** 左栏:对话收集区(MessageList / 卡片区 / 输入框),由宿主传入。 */
children: React.ReactNode;
/** stub 线程 id(与 useReportSandbox 的 threadKey 一致)。 */
threadId: string;
/** useReportSandbox 构造的 stub ai messages(承载 write_file(report.md))。 */
stubMessages: unknown[];
/** 报告是否正在流式写入 —— stub thread 的 isLoading。 */
writing: boolean;
/** 右栏是否展开(宿主导航状态;报告首帧到达时由 hook 自动置开)。 */
open: boolean;
onClose: () => void;
/**
* 覆盖沙箱「下载」按钮的默认行为(深度研究:弹 Markdown / Word 选择弹窗)。
* 不传时沿用 ArtifactFileDetail 内置下载。
*/
onDownload?: () => void;
/** 深度研究报告的全文改写由宿主执行;这里仅提供右侧沙箱入口和配置弹窗。 */
onDocumentRewriteStart?: (input: {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
}) => void;
documentRewriteActive?: boolean;
onCancelDocumentRewrite?: () => void;
reportCharCount?: number;
}
export function ResearchSandboxPanel({
children,
threadId,
stubMessages,
writing,
open,
onClose,
onDownload,
onDocumentRewriteStart,
documentRewriteActive = false,
onCancelDocumentRewrite,
reportCharCount = 0,
}: ResearchSandboxPanelProps) {
const layoutRef = useRef<GroupImperativeHandle>(null);
const { selectedArtifact, setOpen, deselect } = useArtifacts();
const [rewriteDialogOpen, setRewriteDialogOpen] = useState(false);
useEffect(() => {
if (!layoutRef.current) return;
layoutRef.current.setLayout(open ? OPEN_MODE : CLOSE_MODE);
}, [open]);
const threadStub = useMemo(
() =>
({
messages: stubMessages,
isLoading: writing,
isThreadLoading: false,
values: {
title: "",
messages: stubMessages,
artifacts: [],
},
}) as unknown as BaseStream<AgentThreadState>,
[stubMessages, writing],
);
const previewThreadId = threadId || "";
const handleArtifactsClose = () => {
deselect();
setOpen(false);
onClose();
};
return (
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
<ResizablePanelGroup
id="deep-research-panels"
orientation="horizontal"
defaultLayout={CLOSE_MODE}
groupRef={layoutRef}
className="h-full min-h-0 flex-1"
>
<ResizablePanel className="relative" defaultSize={100} id="chat">
{children}
</ResizablePanel>
<ResizableHandle
id="deep-research-separator"
className={cn(
"opacity-33 hover:opacity-100",
!open && "pointer-events-none opacity-0",
)}
/>
<ResizablePanel
className={cn(
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
!open && "opacity-0",
)}
id="artifacts"
>
<div className="relative h-full bg-[#fffdfa] p-4">
{selectedArtifact && previewThreadId ? (
<>
{onDocumentRewriteStart && (
<div className="absolute top-7 right-36 z-30">
<Button
size="sm"
variant="outline"
disabled={documentRewriteActive}
onClick={() => setRewriteDialogOpen(true)}
>
{documentRewriteActive ? "正在改写" : "AI 全文改写"}
</Button>
</div>
)}
{documentRewriteActive && onCancelDocumentRewrite && (
<div className="absolute right-36 bottom-7 z-30">
<Button size="sm" variant="outline" onClick={onCancelDocumentRewrite}>
停止改写
</Button>
</div>
)}
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={previewThreadId}
onClose={handleArtifactsClose}
onDownloadClick={onDownload}
/>
</>
) : (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button size="icon-sm" variant="ghost" onClick={handleArtifactsClose}>
<XIcon />
</Button>
</div>
<ConversationEmptyState
icon={<FileTextIcon />}
title="report.md"
description="报告开始撰写后将在此实时预览"
/>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
{onDocumentRewriteStart && (
<DocumentRewriteDialog
open={rewriteDialogOpen}
onOpenChange={setRewriteDialogOpen}
fileName="report.md"
charCount={reportCharCount}
onStart={(input) => {
setRewriteDialogOpen(false);
onDocumentRewriteStart(input);
}}
/>
)}
</ThreadContext.Provider>
);
}
@@ -0,0 +1,104 @@
"use client";
/**
* 深度研究「真沙箱」报告预览 hook —— 仿圆桌 `useStep3Report` 的 stub 机制。
*
* 深度研究的报告不是任何 LangGraph 线程里的真实 write_file 工具调用,而是写作
* job 通过 SSE(report_delta)流出、由 workbench 在内存里拼装的文本。要让工作区
* 的 `ArtifactFileDetail`(真沙箱)能预览它,这里构造一条**stub ai 消息**:携带
* 一个 `write_file(report.md)` 的 tool_call,args.content 就是当前报告全文。
* `ArtifactFileDetail` 通过虚拟 URL `write-file:...?message_id=...&tool_call_id=...`
* 从 ThreadContext 的 stub 消息里直接读取内容(见 `core/artifacts/loader.ts`),
* 于是免费获得沙箱的全部既有能力:
* - 流式期(stub isLoading=true):code 视图自动滚底;
* - isLoading 翻 false:自动切换 Markdown 预览(Streamdown 渲染)+ 脉冲提示;
* - 复制 / 另存 / 展示层脱敏等。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
artifactPreviewKey,
useArtifacts,
} from "@/components/workspace/artifacts";
/** 报告在沙箱中的固定虚拟路径(与 runner 实际写入的 artifacts 路径一致)。 */
export const REPORT_OUTPUT_PATH = "/mnt/user-data/outputs/report.md";
export type ReportStubAiMessage = {
id: string;
type: "ai";
content: string;
tool_calls: {
id: string;
name: string;
args: Record<string, unknown>;
}[];
};
interface UseReportSandboxOptions {
/** stub 线程标识(建议用 sessionId/jobId 派生,切换会话即切换 stub)。 */
threadKey: string;
/** 当前报告全文:流式期间为逐帧拼装的文本,完成后为已保存的 reportMarkdown。 */
report: string;
/** 报告是否正在流式写入 —— 作为 stub thread 的 isLoading。 */
writing: boolean;
}
export function useReportSandbox({ threadKey, report, writing }: UseReportSandboxOptions) {
const { select, setOpen, openStreamedArtifact } = useArtifacts();
const [stubMessage, setStubMessage] = useState<ReportStubAiMessage | null>(null);
// 每个 threadKey 只自动展开一次;刷新/切换会话后的重开由用户或 openReport 触发。
const autoOpenedRef = useRef(false);
const messageId = `dr-report-${threadKey}`;
const toolCallId = `dr-report-${threadKey}-write`;
const virtualUrl = useMemo(
() =>
new URL(
`write-file:${REPORT_OUTPUT_PATH}?message_id=${encodeURIComponent(messageId)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
).toString(),
[messageId, toolCallId],
);
useEffect(() => {
autoOpenedRef.current = false;
setStubMessage(null);
}, [threadKey]);
// 每帧 upsert stub(带防抹空:新帧为空而旧内容非空时保留旧内容)。
useEffect(() => {
if (!report) return;
setStubMessage((prev) => {
const prevArgs = prev?.tool_calls?.[0]?.args as { content?: unknown } | undefined;
const prevContent = typeof prevArgs?.content === "string" ? prevArgs.content : "";
const content = report.trim() ? report : prevContent;
return {
id: messageId,
type: "ai",
content: "",
tool_calls: [{ id: toolCallId, name: "write_file", args: { path: REPORT_OUTPUT_PATH, content } }],
};
});
}, [messageId, report, toolCallId]);
// 报告首帧到达且仍在写入 → 自动展开右栏并选中虚拟 URL。
// openStreamedArtifact 尊重用户手动关闭(suppress),不会强行弹回。
useEffect(() => {
if (!report.trim() || !writing || autoOpenedRef.current) return;
autoOpenedRef.current = true;
openStreamedArtifact(virtualUrl, artifactPreviewKey(virtualUrl));
}, [openStreamedArtifact, report, virtualUrl, writing]);
/** 手动打开(头部「报告」按钮 / 完成卡「查看报告」):始终选中虚拟 URL。 */
const openReport = useCallback(() => {
select(virtualUrl, false);
setOpen(true);
}, [select, setOpen, virtualUrl]);
return {
virtualUrl,
stubMessages: useMemo(() => (stubMessage ? [stubMessage] : []), [stubMessage]),
openReport,
};
}
@@ -17,6 +17,11 @@ import {
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type {
DocumentRewriteSummary,
DocumentRewriteStreamEvent,
DocumentRewriteStyle,
} from "@/open-canvas/api/rewrite";
// ── base URL ────────────────────────────────────────────────────────────────
@@ -26,8 +31,12 @@ const BASE = () => `${getBackendBaseURL()}/api/deep-research`;
export type ResearchMode = "basic" | "quick" | "detailed" | "deep" | "multi_agent";
export type ResearchTone = "objective" | "formal" | "analytical" | "persuasive";
/** How the materials are collected: chat = collector-agent conversation (new), legacy = runner search tiers. */
export type CollectionMode = "chat" | "legacy";
export type SessionStatus =
| "draft"
| "collecting"
| "awaiting_report"
| "running"
| "awaiting_input"
| "completed"
@@ -56,6 +65,8 @@ export interface DeepResearchSession {
usage?: Record<string, unknown>;
activeJobId?: string;
error?: string;
/** The real LangGraph thread the collector agent chats on (chat collection). */
runtimeThreadId?: string;
createdAt?: string;
updatedAt?: string;
}
@@ -95,9 +106,31 @@ export interface DeepResearchMessage {
content: string;
citationSourceIds: string[];
usage?: Record<string, unknown>;
rewriteProposal?: RewriteProposal;
createdAt?: string;
}
export interface RewriteProposal {
/** User-facing target label, for example “第 1 段” or “摘要”. */
label: string;
/** A candidate only updates the report after a user decision. */
status: "pending" | "applied" | "dismissed";
}
export interface FollowUpRewritePreview {
/** User-facing target label, for example “第 1 段” or “摘要”. */
label: string;
}
export interface FollowUpStreamEvent {
type: "start" | "delta" | "complete" | "error";
delta?: string;
detail?: string;
assistantId?: string;
assistant?: DeepResearchMessage;
rewrite?: FollowUpRewritePreview | null;
}
export interface ProgressEvent {
seq: number;
/** Local live-stream identifier. Durable replay events use their seq instead. */
@@ -119,6 +152,8 @@ export interface Capabilities {
maxImages: number;
};
maxActiveJobsPerUser: number;
/** Built-in collector agent id the chat collection flow talks to. */
collectionAgentId?: string;
}
export const TERMINAL_JOB_STATUSES: JobStatus[] = [
@@ -183,6 +218,7 @@ function normalizeSession(row: WireRecord): DeepResearchSession {
usage: (pick(row, "usage", "usage_snapshot") ?? undefined) as Record<string, unknown> | undefined,
activeJobId: pick<string>(row, "activeJobId", "active_job_id"),
error: typeof row.error === "string" ? row.error : undefined,
runtimeThreadId: pick<string>(row, "runtimeThreadId", "runtime_thread_id"),
createdAt: pick<string>(row, "createdAt", "created_at"),
updatedAt: pick<string>(row, "updatedAt", "updated_at"),
};
@@ -221,6 +257,12 @@ function normalizeSource(row: WireRecord): DeepResearchSource {
}
function normalizeMessage(row: WireRecord): DeepResearchMessage {
const rewriteRaw = pick<WireRecord>(row, "rewriteProposal", "rewrite_proposal");
const rawStatus = typeof rewriteRaw?.status === "string" ? rewriteRaw.status : "";
const rewriteProposal = rewriteRaw && typeof rewriteRaw.label === "string"
&& (rawStatus === "pending" || rawStatus === "applied" || rawStatus === "dismissed")
? { label: rewriteRaw.label, status: rawStatus as RewriteProposal["status"] }
: undefined;
return {
id: String(row.id ?? ""),
sessionId: String(pick<string>(row, "sessionId", "session_id") ?? ""),
@@ -230,6 +272,7 @@ function normalizeMessage(row: WireRecord): DeepResearchMessage {
? (pick<string[]>(row, "citationSourceIds", "citation_source_ids") ?? []).map(String)
: [],
usage: (pick(row, "usage", "usage_snapshot") ?? undefined) as Record<string, unknown> | undefined,
rewriteProposal,
createdAt: pick<string>(row, "createdAt", "created_at"),
};
}
@@ -272,7 +315,7 @@ export async function getSession(id: string): Promise<DeepResearchSession> {
export async function updateSession(
id: string,
patch: { title?: string },
patch: { title?: string; status?: "collecting" | "awaiting_report" },
): Promise<DeepResearchSession> {
const res = await apiFetch(`${BASE()}/sessions/${id}`, {
method: "PATCH",
@@ -291,14 +334,31 @@ export async function deleteSession(id: string): Promise<void> {
// ── job lifecycle ─────────────────────────────────────────────────────────────
export interface StartJobOptions {
requestId?: string;
/**
* "chat" harvests the collector-agent conversation's materials as the pool;
* "legacy" (default) keeps the runner-driven search tiers.
*/
entry?: "chat" | "legacy";
/** Report-config card choices merged over the frozen session snapshot. */
config?: Record<string, unknown>;
}
export async function startJob(
sessionId: string,
requestId?: string,
optionsOrRequestId?: StartJobOptions | string,
): Promise<{ jobId: string; sessionId: string; status: string; reused: boolean }> {
const opts: StartJobOptions =
typeof optionsOrRequestId === "string" ? { requestId: optionsOrRequestId } : optionsOrRequestId ?? {};
const res = await apiFetch(`${BASE()}/sessions/${sessionId}/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_id: requestId ?? crypto.randomUUID() }),
body: JSON.stringify({
request_id: opts.requestId ?? crypto.randomUUID(),
entry: opts.entry ?? "legacy",
config: opts.config ?? null,
}),
});
return readJson(res, "startJob");
}
@@ -487,6 +547,307 @@ export async function sendResearchMessage(
};
}
/**
* Native SSE follow-up stream. Unlike the legacy JSON `/chat` endpoint, this
* surfaces every model delta to the normal message list. Explicit rewrites
* identify the candidate target but stay out of the sandbox until the user
* confirms the candidate through the proposal endpoint.
*/
export async function streamResearchMessage(
sessionId: string,
message: string,
handlers: {
onEvent: (event: FollowUpStreamEvent) => void;
},
signal?: AbortSignal,
streamKind: "qa" | "rewrite" = "qa",
): Promise<void> {
const path = streamKind === "rewrite" ? "rewrite/stream" : "chat/stream";
const res = await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message, allow_new_research: false }),
signal,
});
if (!res.ok || !res.body) {
await readJson(res, "streamResearchMessage");
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? "";
for (const frame of frames) {
const dataLine = frame.split("\n").find((line) => line.startsWith("data:"));
if (!dataLine) continue;
try {
const raw = JSON.parse(dataLine.slice("data:".length).trim()) as WireRecord;
const type = raw.type;
if (type !== "start" && type !== "delta" && type !== "complete" && type !== "error") continue;
const rewriteRaw = raw.rewrite;
const rewrite = rewriteRaw && typeof rewriteRaw === "object" && !Array.isArray(rewriteRaw)
? {
label: String((rewriteRaw as WireRecord).label ?? "报告内容"),
}
: null;
const event: FollowUpStreamEvent = {
type,
delta: typeof raw.delta === "string" ? raw.delta : undefined,
detail: typeof raw.detail === "string" ? raw.detail : undefined,
assistantId: typeof raw.assistantId === "string" ? raw.assistantId : undefined,
assistant: raw.assistant && typeof raw.assistant === "object"
? normalizeMessage(asRecord(raw.assistant))
: undefined,
rewrite,
};
handlers.onEvent(event);
if (event.type === "error") throw new Error(event.detail || "报告追问生成失败");
} catch (error) {
if (error instanceof Error) throw error;
}
}
}
}
/**
* Dedicated completed-report writing stream. Its only successful output is a
* scoped replacement candidate; report Q&A stays on `streamResearchMessage`.
*/
export function streamResearchRewrite(
sessionId: string,
instruction: string,
handlers: { onEvent: (event: FollowUpStreamEvent) => void },
signal?: AbortSignal,
): Promise<void> {
return streamResearchMessage(sessionId, instruction, handlers, signal, "rewrite");
}
export async function cancelFullResearchReportRewrite(jobId: string): Promise<void> {
await cancelJob(jobId);
}
export interface LatestFullReportRewrite {
job: DeepResearchJob | null;
result: Record<string, unknown>;
request: { instruction: string; style: DocumentRewriteStyle; modelName?: string | null };
}
export async function getLatestFullResearchReportRewrite(
sessionId: string,
): Promise<LatestFullReportRewrite> {
const response = await apiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/document-rewrite/jobs/latest`,
{ method: "GET" },
);
const data = await readJson<WireRecord>(response, "getLatestFullResearchReportRewrite");
const jobRecord = data.job && typeof data.job === "object" ? asRecord(data.job) : null;
const request = asRecord(data.request);
return {
job: jobRecord ? normalizeJob(jobRecord) : null,
result: asRecord(data.result),
request: {
instruction: String(request.instruction ?? ""),
style: (request.style ?? "formal_analysis") as DocumentRewriteStyle,
modelName: typeof request.modelName === "string" ? request.modelName : null,
},
};
}
/**
* Join an already-created durable complete-report rewrite.
*
* The task continues on the backend if this iterator is aborted (for example,
* a page refresh). Re-opening the stream with its last durable sequence
* replays milestones/checkpoints and then receives new live deltas.
*/
export async function* joinFullResearchReportRewrite(
jobId: string,
signal?: AbortSignal,
): AsyncGenerator<DocumentRewriteStreamEvent> {
let afterSeq = 0;
let terminal = false;
let retryDelay = 250;
while (!terminal && !signal?.aborted) {
try {
const response = await apiFetch(
`${BASE()}/jobs/${encodeURIComponent(jobId)}/stream?after=${afterSeq}`,
{ method: "GET", signal },
);
if (!response.ok || !response.body) {
await readJson(response, "streamFullResearchReportRewrite");
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (!signal?.aborted) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? "";
for (const frame of frames) {
const dataLine = frame.split("\n").find((line) => line.startsWith("data:"));
if (!dataLine) continue;
let progress: ProgressEvent;
try {
progress = JSON.parse(dataLine.slice("data:".length).trim()) as ProgressEvent;
} catch {
continue;
}
if (progress.seq > 0) afterSeq = Math.max(afterSeq, progress.seq);
if (progress.type === "job_status") {
const status = String(progress.payload?.status ?? "");
if (status === "cancelled") {
yield { type: "error", message: "已停止全文改写,原报告未被修改。", stage: "cancelled" };
terminal = true;
} else if (status === "failed") {
yield { type: "error", message: "报告全文改写失败,请稍后重试。" };
terminal = true;
}
continue;
}
if (progress.type !== "document_rewrite") continue;
const raw = asRecord(progress.payload);
const type = String(raw.type ?? "");
if (type === "snapshot_fixed") {
yield {
type,
fileName: String(raw.fileName ?? "report.md"),
originalCharCount: Number(raw.originalCharCount ?? 0),
baseHash: String(raw.baseHash ?? ""),
};
} else if (type === "requirements_started") {
yield { type };
} else if (type === "requirements_delta") {
yield { type, delta: String(raw.delta ?? "") };
} else if (type === "requirements_checkpoint") {
yield { type, content: String(raw.content ?? "") };
} else if (type === "requirements_completed") {
yield {
type,
content: String(raw.content ?? ""),
fallback: raw.fallback === true,
};
} else if (type === "requirements_resolved") {
yield {
type,
instruction: String(raw.instruction ?? ""),
style: String(raw.style ?? ""),
modelName: String(raw.modelName ?? "deep_research_smart_model"),
plan: typeof raw.plan === "string" ? raw.plan : undefined,
};
} else if (type === "rewrite_delta") {
yield { type, delta: String(raw.delta ?? ""), charCount: Number(raw.charCount ?? 0) };
} else if (type === "rewrite_checkpoint") {
yield { type, content: String(raw.content ?? ""), charCount: Number(raw.charCount ?? 0) };
} else if (type === "validation_started" || type === "commit_started") {
yield { type };
} else if (type === "validation_completed") {
yield {
type,
passed: raw.passed === true,
warnings: Array.isArray(raw.warnings)
? raw.warnings.filter((item): item is string => typeof item === "string")
: [],
};
} else if (type === "comparison_ready") {
yield { type, summary: raw as unknown as DocumentRewriteSummary };
} else if (type === "committed") {
yield {
type,
content: String(raw.content ?? ""),
committedHash: String(raw.committedHash ?? ""),
versionId: typeof raw.versionId === "string" ? raw.versionId : undefined,
};
} else if (type === "done") {
yield {
type,
committedHash: String(raw.committedHash ?? ""),
versionId: typeof raw.versionId === "string" ? raw.versionId : undefined,
};
terminal = true;
} else if (type === "conflict") {
yield { type, message: String(raw.message ?? "报告内容已变化") };
terminal = true;
} else if (type === "error") {
yield {
type,
message: String(raw.message ?? "报告全文改写失败"),
stage: typeof raw.stage === "string" ? raw.stage : undefined,
};
terminal = true;
}
}
}
} finally {
reader.releaseLock();
}
retryDelay = 250;
} catch (error) {
if (signal?.aborted) return;
// Browser/proxy SSE connections can transiently end. Keep the server
// job alive and join it again from the last durable event sequence.
if (retryDelay > 4_000) {
throw error instanceof Error ? error : new Error("全文改写流连接失败");
}
await new Promise((resolve) => window.setTimeout(resolve, retryDelay));
retryDelay *= 2;
}
}
}
/** Start and join the sandbox's durable complete-report rewrite. */
export async function* streamFullResearchReportRewrite(
sessionId: string,
input: { instruction: string; style: DocumentRewriteStyle; modelName?: string },
signal?: AbortSignal,
onJobStarted?: (jobId: string) => void,
): AsyncGenerator<DocumentRewriteStreamEvent> {
const startResponse = await apiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/document-rewrite`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
signal,
},
);
const started = await readJson<WireRecord>(startResponse, "startFullResearchReportRewrite");
const jobId = String(pick<string>(started, "jobId", "job_id") ?? "");
if (!jobId) throw new Error("全文改写任务未返回任务编号");
onJobStarted?.(jobId);
yield* joinFullResearchReportRewrite(jobId, signal);
}
/** Resolve a durable rewrite candidate after the user has reviewed its stream. */
export async function resolveResearchRewriteProposal(
sessionId: string,
messageId: string,
action: "apply" | "dismiss",
): Promise<{ session: DeepResearchSession; message: DeepResearchMessage }> {
const res = await apiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/rewrite-proposal`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
},
);
const data = await readJson<WireRecord>(res, "resolveResearchRewriteProposal");
return {
session: normalizeSession(asRecord(data.session)),
message: normalizeMessage(asRecord(data.message)),
};
}
// ── capabilities ─────────────────────────────────────────────────────────────
export async function getCapabilities(): Promise<Capabilities> {
@@ -571,8 +932,29 @@ export function useResearchActions() {
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] }),
}),
startJob: useMutation({
mutationFn: (sessionId: string) => startJob(sessionId),
onSuccess: (_result, sessionId) => {
mutationFn: ({
sessionId,
entry,
config,
}: {
sessionId: string;
entry?: "chat" | "legacy";
config?: Record<string, unknown>;
}) => startJob(sessionId, { entry, config }),
onSuccess: (_result, { sessionId }) => {
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] });
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSION_KEY, sessionId] });
},
}),
updateStatus: useMutation({
mutationFn: ({
sessionId,
status,
}: {
sessionId: string;
status: "collecting" | "awaiting_report";
}) => updateSession(sessionId, { status }),
onSuccess: (_result, { sessionId }) => {
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] });
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSION_KEY, sessionId] });
},
@@ -619,6 +1001,22 @@ export function useResearchActions() {
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_MESSAGES_KEY, sessionId] });
},
}),
resolveRewriteProposal: useMutation({
mutationFn: ({
sessionId,
messageId,
action,
}: {
sessionId: string;
messageId: string;
action: "apply" | "dismiss";
}) => resolveResearchRewriteProposal(sessionId, messageId, action),
onSuccess: (_result, { sessionId }) => {
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] });
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSION_KEY, sessionId] });
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_MESSAGES_KEY, sessionId] });
},
}),
setSourceSelected: useMutation({
mutationFn: ({
sessionId,