代码提交
This commit is contained in:
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";
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,}/);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user