Merge remote-tracking branch 'origin/master' into ji

This commit is contained in:
吉鑫升 committed 2026-08-17 15:53:36 +08:00
commit 5bf08c83a6
176 files changed
+33341 -1896

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>
);
}
@@ -16,6 +16,7 @@ import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
import { useSkills } from "@/core/skills/hooks";
import type { SkillDisplayConfig } from "@/core/skills/type";
import {
@@ -93,13 +94,20 @@ const ChatBox: React.FC<{
const [settings] = useLocalSettings();
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
// Frontend single-round RAG writes its own source tool message, so it should
// use the same reference panel without changing the user's global RAG mode.
const hasSingleRoundRag = useMemo(
() => (thread.values.messages ?? []).some(isSingleRoundRagMessage),
// Frontend RAG modes write their own hidden source tool messages. They use
// the existing reference panel, but must not request or write reference
// batches through DeerFlow's backend APIs.
const hasFrontendRag = useMemo(
() =>
(thread.values.messages ?? []).some(
(message) =>
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
),
[thread.values.messages],
);
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
const referencesEnabled = ragModeEnabled || hasFrontendRag;
const shouldFetchBackendReferenceBatches =
referencesEnabled && !hasFrontendRag;
const { skills } = useSkills();
const {
@@ -110,6 +118,7 @@ const ChatBox: React.FC<{
select: selectArtifact,
deselect,
selectedArtifact,
selectedArtifactKey,
} = useArtifacts();
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
@@ -128,7 +137,7 @@ const ChatBox: React.FC<{
);
}, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
useEffect(() => {
if (!referencesEnabled) {
if (!shouldFetchBackendReferenceBatches) {
setBackendReferenceBatches([]);
return;
}
@@ -164,7 +173,12 @@ const ChatBox: React.FC<{
}
});
return () => controller.abort();
}, [referencesEnabled, thread.isLoading, threadId, thread.values.messages]);
}, [
shouldFetchBackendReferenceBatches,
thread.isLoading,
threadId,
thread.values.messages,
]);
const adaptedBackendReferenceBatches = useMemo(
() =>
adaptBackendReferenceBatches(
@@ -175,15 +189,22 @@ const ChatBox: React.FC<{
);
const referenceBatches = useMemo(
() => {
if (adaptedBackendReferenceBatches.length > 0) {
if (
shouldFetchBackendReferenceBatches &&
adaptedBackendReferenceBatches.length > 0
) {
return adaptedBackendReferenceBatches;
}
return fallbackReferenceBatches;
},
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
[
adaptedBackendReferenceBatches,
fallbackReferenceBatches,
shouldFetchBackendReferenceBatches,
],
);
useEffect(() => {
if (!referencesEnabled) {
if (!shouldFetchBackendReferenceBatches) {
return;
}
const signature = JSON.stringify({
@@ -210,7 +231,14 @@ const ChatBox: React.FC<{
fallbackBatches: debugReferenceBatches(fallbackReferenceBatches),
finalBatches: debugReferenceBatches(referenceBatches),
});
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, referencesEnabled, referenceBatches, threadId]);
}, [
adaptedBackendReferenceBatches,
backendReferenceBatches,
fallbackReferenceBatches,
referenceBatches,
shouldFetchBackendReferenceBatches,
threadId,
]);
const latestReferenceBatch =
referenceBatches.length > 0
? referenceBatches[referenceBatches.length - 1]!
@@ -221,7 +249,7 @@ const ChatBox: React.FC<{
const referenceSources = activeReferenceBatch?.sources ?? [];
const hasCitationReferences = referenceSources.length > 0;
useEffect(() => {
if (!referencesEnabled || !activeReferenceBatch) {
if (!shouldFetchBackendReferenceBatches || !activeReferenceBatch) {
return;
}
logReferenceDebug("frontend-active-reference-batch-payload", threadId, {
@@ -230,7 +258,7 @@ const ChatBox: React.FC<{
sourceCount: activeReferenceBatch.sources.length,
sources: activeReferenceBatch.sources.map(debugReferenceSource),
});
}, [activeReferenceBatch, referencesEnabled, threadId]);
}, [activeReferenceBatch, shouldFetchBackendReferenceBatches, threadId]);
useEffect(() => {
if (hasCitationReferences) {
setReferencesOpen(true);
@@ -428,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}
@@ -443,6 +472,7 @@ const ChatBox: React.FC<{
</Tabs>
) : selectedArtifact ? (
<ArtifactFileDetail
key={selectedArtifactKey ?? selectedArtifact}
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
@@ -8,8 +8,13 @@ export function CitationLink({
href,
children,
className,
onReferenceClick,
onClick,
...props
}: ComponentProps<"a">) {
}: ComponentProps<"a"> & {
/** Lets a message renderer show an in-place source preview instead of scrolling. */
onReferenceClick?: (referenceId: string) => void;
}) {
const childrenText = normalizeCitationText(children);
const displayText = childrenText ?? "";
const referenceId = href?.startsWith("#reference-")
@@ -28,6 +33,11 @@ export function CitationLink({
)}
onClick={(event) => {
event.preventDefault();
if (onReferenceClick) {
onReferenceClick(referenceId);
onClick?.(event);
return;
}
const batchId =
event.currentTarget.closest<HTMLElement>("[data-reference-batch-id]")
?.dataset.referenceBatchId;
@@ -41,6 +51,7 @@ export function CitationLink({
target?.scrollIntoView({ behavior: "smooth", block: "center" });
};
window.setTimeout(scroll, 240);
onClick?.(event);
}}
{...props}
>
@@ -58,6 +69,7 @@ export function CitationLink({
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
className,
)}
onClick={onClick}
{...props}
>
{displayText || children}
@@ -22,10 +22,10 @@ import {
useEffect,
useMemo,
useRef,
useState,
type ComponentProps,
type ReactNode,
} from "react";
useState,
type ComponentProps,
type ReactNode,
} from "react";
import {
PromptInput,
@@ -62,10 +62,10 @@ import {
} from "@/components/ui/dropdown-menu";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import type { Model } from "@/core/models/types";
import { useI18n } from "@/core/i18n/hooks";
import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import type { Model } from "@/core/models/types";
import type { AgentThreadContext } from "@/core/threads";
import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
@@ -134,11 +134,11 @@ export function InputBox({
onFollowupsVisibilityChange,
onSubmit,
onStop,
promptPrefix,
agentSelector,
knowledgeSelector,
extraModels = [],
assistantId,
promptPrefix,
agentSelector,
knowledgeSelector,
extraModels = [],
assistantId,
hideAdminRecommendedQuestions,
compact,
...props
@@ -194,25 +194,28 @@ export function InputBox({
* homepage (``ChatPage``) passes this; ``AgentChatPage`` omits it because the
* thread is already bound to one agent.
*/
agentSelector?: {
selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void;
};
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
knowledgeSelector?: ReactNode;
/** Page-scoped model choices implemented outside the DeerFlow model API. */
extraModels?: Model[];
}) {
agentSelector?: {
selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void;
};
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
knowledgeSelector?: ReactNode;
/** Page-scoped model choices implemented outside the DeerFlow model API. */
extraModels?: Model[];
}) {
const { t } = useI18n();
const [localSettings, setLocalSettings] = useLocalSettings();
const searchParams = useSearchParams();
const [modelDialogOpen, setModelDialogOpen] = useState(false);
const { models } = useModels();
const singleRoundRagActive =
context.execution_profile === "single_round_rag";
const modelOptions = useMemo(() => {
return singleRoundRagActive ? extraModels : models;
}, [extraModels, models, singleRoundRagActive]);
const [modelDialogOpen, setModelDialogOpen] = useState(false);
const { models } = useModels();
const singleRoundRagActive =
context.execution_profile === "single_round_rag";
const multiRoundRagActive =
context.execution_profile === "multi_round_rag";
const frontendRagActive = singleRoundRagActive || multiRoundRagActive;
const modelOptions = useMemo(() => {
return singleRoundRagActive ? extraModels : models;
}, [extraModels, models, singleRoundRagActive]);
const { thread, isMock } = useThread();
const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null);
@@ -236,40 +239,45 @@ export function InputBox({
recommendedQuestions.length > 0;
useEffect(() => {
if (modelOptions.length === 0) {
return;
}
const currentModel = modelOptions.find((m) => m.name === context.model_name);
const fallbackModel = currentModel ?? modelOptions[0]!;
// Multi-round RAG is a frontend execution profile, not a model option.
// Do not let the normal model fallback clear it on every render.
if (multiRoundRagActive) {
return;
}
if (modelOptions.length === 0) {
return;
}
const currentModel = modelOptions.find((m) => m.name === context.model_name);
const fallbackModel = currentModel ?? modelOptions[0]!;
const supportsThinking = fallbackModel.supports_thinking ?? false;
const nextModelName = fallbackModel.name;
const nextMode = getResolvedMode(context.mode, supportsThinking);
if (
context.model_name === nextModelName &&
context.mode === nextMode &&
context.execution_profile === fallbackModel.execution_profile
) {
return;
}
if (
context.model_name === nextModelName &&
context.mode === nextMode &&
context.execution_profile === fallbackModel.execution_profile
) {
return;
}
onContextChange?.({
...context,
model_name: nextModelName,
mode: nextMode,
execution_profile: fallbackModel.execution_profile,
});
}, [context, modelOptions, onContextChange]);
const selectedModel = useMemo(() => {
if (modelOptions.length === 0) {
return undefined;
}
return modelOptions.find((m) => m.name === context.model_name) ?? modelOptions[0];
}, [context.model_name, modelOptions]);
...context,
model_name: nextModelName,
mode: nextMode,
execution_profile: fallbackModel.execution_profile,
});
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
const resolvedModelName = selectedModel?.name;
const selectedSingleRoundRag = singleRoundRagActive;
const selectedModel = useMemo(() => {
if (modelOptions.length === 0) {
return undefined;
}
return modelOptions.find((m) => m.name === context.model_name) ?? modelOptions[0];
}, [context.model_name, modelOptions]);
const resolvedModelName = selectedModel?.name;
const selectedSingleRoundRag = singleRoundRagActive;
const supportThinking = useMemo(
() => selectedModel?.supports_thinking ?? false,
@@ -283,65 +291,72 @@ export function InputBox({
const handleModelSelect = useCallback(
(model_name: string) => {
const model = modelOptions.find((m) => m.name === model_name);
const model = modelOptions.find((m) => m.name === model_name);
if (!model) {
return;
}
onContextChange?.({
...context,
model_name,
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: context.reasoning_effort,
execution_profile: model.execution_profile,
});
setModelDialogOpen(false);
},
[onContextChange, context, modelOptions],
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: context.reasoning_effort,
execution_profile: model.execution_profile,
});
setModelDialogOpen(false);
},
[onContextChange, context, modelOptions],
);
const handleModeSelect = useCallback(
(mode: InputMode) => {
const ordinaryModel =
models.find((model) => model.name === context.model_name) ?? models[0];
onContextChange?.({
...context,
model_name: ordinaryModel?.name ?? context.model_name,
mode: getResolvedMode(
mode,
ordinaryModel?.supports_thinking ?? supportThinking,
),
reasoning_effort:
mode === "ultra"
? "high"
const handleModeSelect = useCallback(
(mode: InputMode) => {
const ordinaryModel =
models.find((model) => model.name === context.model_name) ?? models[0];
onContextChange?.({
...context,
model_name: ordinaryModel?.name ?? context.model_name,
mode: getResolvedMode(
mode,
ordinaryModel?.supports_thinking ?? supportThinking,
),
reasoning_effort:
mode === "ultra"
? "high"
: mode === "pro"
? "medium"
: mode === "thinking"
? "low"
: "minimal",
execution_profile: undefined,
});
},
[onContextChange, context, models, supportThinking],
);
const handleSingleRoundRagSelect = useCallback(() => {
const ragModel =
extraModels.find((model) => model.name === context.model_name) ??
extraModels[0];
if (!ragModel) {
return;
}
onContextChange?.({
...context,
model_name: ragModel.name,
mode: getResolvedMode(
context.mode,
ragModel.supports_thinking ?? false,
),
execution_profile: "single_round_rag",
});
}, [context, extraModels, onContextChange]);
? "low"
: "minimal",
execution_profile: undefined,
});
},
[onContextChange, context, models, supportThinking],
);
const handleSingleRoundRagSelect = useCallback(() => {
const ragModel =
extraModels.find((model) => model.name === context.model_name) ??
extraModels[0];
if (!ragModel) {
return;
}
onContextChange?.({
...context,
model_name: ragModel.name,
mode: getResolvedMode(
context.mode,
ragModel.supports_thinking ?? false,
),
execution_profile: "single_round_rag",
});
}, [context, extraModels, onContextChange]);
const handleMultiRoundRagSelect = useCallback(() => {
onContextChange?.({
...context,
execution_profile: "multi_round_rag",
});
}, [context, onContextChange]);
const handleReasoningEffortSelect = useCallback(
(effort: "minimal" | "low" | "medium" | "high") => {
@@ -407,11 +422,11 @@ export function InputBox({
onContextChange?.({
...context,
model_name: resolvedModelName,
mode: getResolvedMode(
context.mode,
selectedModel?.supports_thinking ?? false,
),
execution_profile: selectedModel?.execution_profile,
mode: getResolvedMode(
context.mode,
selectedModel?.supports_thinking ?? false,
),
execution_profile: selectedModel?.execution_profile,
});
setTimeout(() => onSubmit?.(message), 0);
return;
@@ -426,8 +441,8 @@ export function InputBox({
onStop,
resolvedModelName,
ragModeEnabled,
selectedModel?.supports_thinking,
selectedModel?.execution_profile,
selectedModel?.supports_thinking,
selectedModel?.execution_profile,
status,
threadId,
],
@@ -485,9 +500,9 @@ export function InputBox({
}, [pendingSuggestion, requestFormSubmit, textInput]);
const showFollowups =
!disabled &&
!isNewThread &&
!selectedSingleRoundRag &&
!disabled &&
!isNewThread &&
!selectedSingleRoundRag &&
!followupsHidden &&
(followupsLoading || followups.length > 0);
@@ -513,19 +528,19 @@ export function InputBox({
return;
}
if (disabled || isMock || selectedSingleRoundRag) {
if (disabled || isMock || selectedSingleRoundRag) {
return;
}
const visibleMessages = thread.messages.filter((m) => !isHiddenFromUIMessage(m));
const lastAi = [...visibleMessages].reverse().find((m) => m.type === "ai");
const visibleMessages = thread.messages.filter((m) => !isHiddenFromUIMessage(m));
const lastAi = [...visibleMessages].reverse().find((m) => m.type === "ai");
const lastAiId = lastAi?.id ?? null;
if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) {
return;
}
lastGeneratedForAiIdRef.current = lastAiId;
const recent = visibleMessages
const recent = visibleMessages
.filter((m) => m.type === "human" || m.type === "ai")
.map((m) => {
const role = m.type === "human" ? "user" : "assistant";
@@ -575,7 +590,7 @@ export function InputBox({
});
return () => controller.abort();
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
return (
<div ref={promptRootRef} className="relative flex flex-col gap-6">
@@ -662,7 +677,7 @@ export function InputBox({
: "*:data-[slot='input-group']:rounded-3xl",
// 紧凑落地态(CM 参考图):白卡 + 细边 + 轻阴影 + 主色聚焦环(无旋转彩边)
compact && isNewThread
? "rounded-2xl border border-gray-200/70 bg-white overflow-hidden shadow-[0_4.5px_22px_rgba(0,0,0,0.025)] transition-all duration-300 focus-within:border-[rgb(var(--legacy-primary-rgb)/35%)] focus-within:ring-4 focus-within:ring-[rgb(var(--legacy-primary-rgb)/5%)] focus-within:shadow-[0_10px_35px_rgb(var(--legacy-primary-rgb)/4%)] dark:border-slate-700 dark:bg-slate-800"
? "rounded-2xl border border-gray-200/70 bg-white overflow-hidden shadow-[0_4.5px_22px_rgba(0,0,0,0.025)] transition-all duration-300 focus-within:border-[rgb(var(--legacy-primary-rgb)/35%)] focus-within:ring-4 focus-within:ring-[rgb(var(--legacy-primary-rgb)/5%)] focus-within:shadow-[0_10px_35px_rgb(var(--legacy-primary-rgb)/4%)] dark:border-slate-700 dark:bg-slate-800"
: // landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边
// 对话态:常规白卡 + 中性边框 + 轻阴影
isNewThread
@@ -712,14 +727,16 @@ export function InputBox({
/>
</PromptInputActionMenuContent>
</PromptInputActionMenu> */}
<AddAttachmentsButton
className={cn(
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact &&
"gap-1.5! rounded-lg px-2.5! py-1! text-[11px] font-medium",
)}
/>
<Tooltip
{!frontendRagActive && (
<AddAttachmentsButton
className={cn(
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact &&
"gap-1.5! rounded-lg px-2.5! py-1! text-[11px] font-medium",
)}
/>
)}
{!frontendRagActive && <Tooltip
content={
context.writing_mode
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
@@ -729,23 +746,23 @@ export function InputBox({
<PromptInputButton
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
context.writing_mode
? "border border-amber-300 bg-amber-50 text-amber-700 ring-1 ring-amber-300/35 hover:bg-amber-100 dark:border-amber-300/70 dark:bg-amber-400/20 dark:text-amber-100 dark:ring-amber-300/35 dark:shadow-[0_0_0_1px_rgba(251,191,36,0.18),0_0_18px_rgba(251,191,36,0.18)] dark:hover:bg-amber-400/25"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleWritingModeToggle}
>
{context.writing_mode && (
<CheckIcon className="size-3 text-amber-700 dark:text-amber-100" />
)}
<FileTextIcon className="size-3" />
<span className="text-xs font-normal">写作</span>
</PromptInputButton>
</Tooltip>
<Tooltip
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
context.writing_mode
? "border border-amber-300 bg-amber-50 text-amber-700 ring-1 ring-amber-300/35 hover:bg-amber-100 dark:border-amber-300/70 dark:bg-amber-400/20 dark:text-amber-100 dark:ring-amber-300/35 dark:shadow-[0_0_0_1px_rgba(251,191,36,0.18),0_0_18px_rgba(251,191,36,0.18)] dark:hover:bg-amber-400/25"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleWritingModeToggle}
>
{context.writing_mode && (
<CheckIcon className="size-3 text-amber-700 dark:text-amber-100" />
)}
<FileTextIcon className="size-3" />
<span className="text-xs font-normal">写作</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && <Tooltip
content={
memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆"
@@ -754,24 +771,24 @@ export function InputBox({
>
<PromptInputButton
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
memoryInjectionEnabled
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleMemoryInjectionToggle}
>
{memoryInjectionEnabled && (
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
)}
<BrainCircuitIcon className="size-3" />
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>
<Tooltip
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
memoryInjectionEnabled
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleMemoryInjectionToggle}
>
{memoryInjectionEnabled && (
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
)}
<BrainCircuitIcon className="size-3" />
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && <Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
@@ -800,8 +817,8 @@ export function InputBox({
<BookMarkedIcon className="size-3" />
<span className="text-xs font-normal">参考文献</span>
</PromptInputButton>
</Tooltip>
<Tooltip
</Tooltip>}
{!frontendRagActive && <Tooltip
content={
contextCompressionEnabled
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
@@ -829,66 +846,70 @@ export function InputBox({
<ShrinkIcon className="size-3" />
<span className="text-xs font-normal">上下文压缩</span>
</PromptInputButton>
</Tooltip>
</Tooltip>}
<PromptInputActionMenu>
<ModeHoverGuide
mode={
<ModeHoverGuide
mode={
context.mode === "flash" ||
context.mode === "thinking" ||
context.mode === "pro" ||
context.mode === "ultra"
? context.mode
: "flash"
}
tooltipContent={
selectedSingleRoundRag
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
: undefined
}
>
? context.mode
: "flash"
}
tooltipContent={
singleRoundRagActive
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
: multiRoundRagActive
? "多轮 RAG 问答:多次检索并展示检索过程与参考资料"
: undefined
}
>
<PromptInputActionMenuTrigger
className={cn(
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
compact &&
(selectedSingleRoundRag
? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: context.mode === "thinking"
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
compact &&
(frontendRagActive
? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: context.mode === "thinking"
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
: context.mode === "pro"
? "border-violet-200 bg-violet-50/60 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200"
: context.mode === "ultra"
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
: "border-transparent bg-muted/40 hover:bg-muted hover:text-foreground"),
)}
>
<div>
{selectedSingleRoundRag ? (
<BookMarkedIcon className="size-3" />
) : context.mode === "flash" ? (
<ZapIcon className="size-3" />
) : context.mode === "thinking" ? (
<LightbulbIcon className="size-3" />
) : context.mode === "pro" ? (
<GraduationCapIcon className="size-3" />
) : context.mode === "ultra" ? (
<RocketIcon className="size-3 text-[#dabb5e]" />
) : null}
</div>
>
<div>
{frontendRagActive ? (
<BookMarkedIcon className="size-3" />
) : context.mode === "flash" ? (
<ZapIcon className="size-3" />
) : context.mode === "thinking" ? (
<LightbulbIcon className="size-3" />
) : context.mode === "pro" ? (
<GraduationCapIcon className="size-3" />
) : context.mode === "ultra" ? (
<RocketIcon className="size-3 text-[#dabb5e]" />
) : null}
</div>
<div
className={cn(
"text-xs font-normal",
context.mode === "ultra" ? "golden-text" : "",
)}
>
{selectedSingleRoundRag
? "单轮 RAG"
: (context.mode === "flash" && t.inputBox.flashMode) ||
(context.mode === "thinking" &&
t.inputBox.reasoningMode) ||
(context.mode === "pro" && t.inputBox.proMode) ||
(context.mode === "ultra" && t.inputBox.ultraMode)}
</div>
{frontendRagActive
? multiRoundRagActive
? "多轮 RAG"
: "单轮 RAG"
: (context.mode === "flash" && t.inputBox.flashMode) ||
(context.mode === "thinking" &&
t.inputBox.reasoningMode) ||
(context.mode === "pro" && t.inputBox.proMode) ||
(context.mode === "ultra" && t.inputBox.ultraMode)}
</div>
</PromptInputActionMenuTrigger>
</ModeHoverGuide>
<PromptInputActionMenuContent className="w-80">
@@ -897,11 +918,11 @@ export function InputBox({
{t.inputBox.mode}
</DropdownMenuLabel>
<PromptInputActionMenu>
<PromptInputActionMenuItem
className={cn(
!selectedSingleRoundRag && context.mode === "flash"
? "text-accent-foreground"
: "text-muted-foreground/65",
<PromptInputActionMenuItem
className={cn(
!frontendRagActive && context.mode === "flash"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleModeSelect("flash")}
>
@@ -909,9 +930,9 @@ export function InputBox({
<div className="flex items-center gap-1 font-bold">
<ZapIcon
className={cn(
"mr-2 size-4",
!selectedSingleRoundRag && context.mode === "flash" &&
"text-accent-foreground",
"mr-2 size-4",
!frontendRagActive && context.mode === "flash" &&
"text-accent-foreground",
)}
/>
{t.inputBox.flashMode}
@@ -920,7 +941,7 @@ export function InputBox({
{t.inputBox.flashModeDescription}
</div>
</div>
{!selectedSingleRoundRag && context.mode === "flash" ? (
{!frontendRagActive && context.mode === "flash" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -928,9 +949,9 @@ export function InputBox({
</PromptInputActionMenuItem>
{supportThinking && (
<PromptInputActionMenuItem
className={cn(
!selectedSingleRoundRag && context.mode === "thinking"
? "text-accent-foreground"
className={cn(
!frontendRagActive && context.mode === "thinking"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleModeSelect("thinking")}
@@ -939,9 +960,9 @@ export function InputBox({
<div className="flex items-center gap-1 font-bold">
<LightbulbIcon
className={cn(
"mr-2 size-4",
!selectedSingleRoundRag && context.mode === "thinking" &&
"text-accent-foreground",
"mr-2 size-4",
!frontendRagActive && context.mode === "thinking" &&
"text-accent-foreground",
)}
/>
{t.inputBox.reasoningMode}
@@ -950,7 +971,7 @@ export function InputBox({
{t.inputBox.reasoningModeDescription}
</div>
</div>
{!selectedSingleRoundRag && context.mode === "thinking" ? (
{!frontendRagActive && context.mode === "thinking" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -958,8 +979,8 @@ export function InputBox({
</PromptInputActionMenuItem>
)}
<PromptInputActionMenuItem
className={cn(
!selectedSingleRoundRag && context.mode === "pro"
className={cn(
!frontendRagActive && context.mode === "pro"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -969,8 +990,8 @@ export function InputBox({
<div className="flex items-center gap-1 font-bold">
<GraduationCapIcon
className={cn(
"mr-2 size-4",
!selectedSingleRoundRag && context.mode === "pro" &&
"mr-2 size-4",
!frontendRagActive && context.mode === "pro" &&
"text-accent-foreground",
)}
/>
@@ -980,15 +1001,15 @@ export function InputBox({
{t.inputBox.proModeDescription}
</div>
</div>
{!selectedSingleRoundRag && context.mode === "pro" ? (
{!frontendRagActive && context.mode === "pro" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
!selectedSingleRoundRag && context.mode === "ultra"
className={cn(
!frontendRagActive && context.mode === "ultra"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -998,17 +1019,17 @@ export function InputBox({
<div className="flex items-center gap-1 font-bold">
<RocketIcon
className={cn(
"mr-2 size-4",
!selectedSingleRoundRag &&
context.mode === "ultra" &&
"text-[#dabb5e]",
"mr-2 size-4",
!frontendRagActive &&
context.mode === "ultra" &&
"text-[#dabb5e]",
)}
/>
<div
className={cn(
!selectedSingleRoundRag &&
context.mode === "ultra" &&
"golden-text",
!frontendRagActive &&
context.mode === "ultra" &&
"golden-text",
)}
>
{t.inputBox.ultraMode}
@@ -1018,50 +1039,78 @@ export function InputBox({
{t.inputBox.ultraModeDescription}
</div>
</div>
{!selectedSingleRoundRag && context.mode === "ultra" ? (
{!frontendRagActive && context.mode === "ultra" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
{extraModels.length > 0 && (
<>
<DropdownMenuSeparator className="my-2" />
<PromptInputActionMenuItem
className={cn(
selectedSingleRoundRag
? "bg-emerald-50 text-emerald-800 hover:bg-emerald-100 focus:bg-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-200 dark:hover:bg-emerald-950/60 dark:focus:bg-emerald-950/60"
: "text-muted-foreground/65",
)}
onSelect={handleSingleRoundRagSelect}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<BookMarkedIcon
className={cn(
"mr-2 size-4",
selectedSingleRoundRag && "text-emerald-700 dark:text-emerald-200",
)}
/>
单轮 RAG 问答
</div>
<div className="pl-7 text-xs">
基于知识库检索生成答案,并展示思考内容与参考资料
</div>
</div>
{selectedSingleRoundRag ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
</>
)}
</PromptInputActionMenu>
</PromptInputActionMenuItem>
{extraModels.length > 0 && (
<>
<DropdownMenuSeparator className="my-2" />
<PromptInputActionMenuItem
className={cn(
selectedSingleRoundRag
? "bg-emerald-50 text-emerald-800 hover:bg-emerald-100 focus:bg-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-200 dark:hover:bg-emerald-950/60 dark:focus:bg-emerald-950/60"
: "text-muted-foreground/65",
)}
onSelect={handleSingleRoundRagSelect}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<BookMarkedIcon
className={cn(
"mr-2 size-4",
selectedSingleRoundRag && "text-emerald-700 dark:text-emerald-200",
)}
/>
单轮 RAG 问答
</div>
<div className="pl-7 text-xs">
基于知识库检索生成答案,并展示思考内容与参考资料
</div>
</div>
{selectedSingleRoundRag ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
</>
)}
<PromptInputActionMenuItem
className={cn(
multiRoundRagActive
? "bg-emerald-50 text-emerald-800 hover:bg-emerald-100 focus:bg-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-200 dark:hover:bg-emerald-950/60 dark:focus:bg-emerald-950/60"
: "text-muted-foreground/65",
)}
onSelect={handleMultiRoundRagSelect}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<BookMarkedIcon
className={cn(
"mr-2 size-4",
multiRoundRagActive && "text-emerald-700 dark:text-emerald-200",
)}
/>
多轮 RAG 问答
</div>
<div className="pl-7 text-xs">
多次检索、展示过程与参考资料,适合复杂问题
</div>
</div>
{multiRoundRagActive ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
</PromptInputActionMenu>
</DropdownMenuGroup>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
{supportReasoningEffort && context.mode !== "flash" && (
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
<div className="text-xs font-normal">
@@ -1177,10 +1226,9 @@ export function InputBox({
</PromptInputActionMenuContent>
</PromptInputActionMenu>
)}
</PromptInputTools>
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
{knowledgeSelector}
{agentSelector && (
</PromptInputTools>
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
{!frontendRagActive && agentSelector && (
<AgentSelector
selectedAgentId={agentSelector.selectedAgentId}
onAgentChange={agentSelector.onAgentChange}
@@ -1188,7 +1236,8 @@ export function InputBox({
compact={compact}
/>
)}
{promptPrefix && (
{!frontendRagActive && knowledgeSelector}
{!frontendRagActive && promptPrefix && (
<Tooltip
content={
promptPrefix.on
@@ -1216,7 +1265,7 @@ export function InputBox({
</PromptInputButton>
</Tooltip>
)}
<ModelSelector
{!multiRoundRagActive && <ModelSelector
open={modelDialogOpen}
onOpenChange={setModelDialogOpen}
>
@@ -1243,7 +1292,7 @@ export function InputBox({
<ModelSelectorContent>
<ModelSelectorInput placeholder={t.inputBox.searchModels} />
<ModelSelectorList>
{modelOptions.map((m) => (
{modelOptions.map((m) => (
<ModelSelectorItem
key={m.name}
value={m.name}
@@ -1251,11 +1300,11 @@ export function InputBox({
>
<div className="flex min-w-0 flex-1 flex-col">
<ModelSelectorName>{m.display_name}</ModelSelectorName>
<span className="text-muted-foreground truncate text-[10px]">
{selectedSingleRoundRag
? `单轮知识库问答 · ${m.name}`
: m.model}
</span>
<span className="text-muted-foreground truncate text-[10px]">
{selectedSingleRoundRag
? `单轮知识库问答 · ${m.name}`
: m.model}
</span>
</div>
{m.name === context.model_name ? (
<CheckIcon className="ml-auto size-4" />
@@ -1263,15 +1312,15 @@ export function InputBox({
<div className="ml-auto size-4" />
)}
</ModelSelectorItem>
))}
</ModelSelectorList>
))}
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
</ModelSelector>}
<PromptInputSubmit
className={cn(
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
compact &&
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
)}
disabled={disabled}
variant="outline"
@@ -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;
File diff suppressed because it is too large. Load diff
@@ -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,
@@ -30,11 +31,13 @@ import { useSkills } from "@/core/skills/hooks";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import type { AgentThreadState } from "@/core/threads";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
import { cn } from "@/lib/utils";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
@@ -43,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 {
@@ -98,17 +102,19 @@ export function MessageList({
tokenUsageEnabled = false,
extraRehypePlugins,
scrollButtonClassName,
onClarificationSubmit,
onEditResubmit,
onEditSave,
messageContentOverrides,
externallyHiddenMessageIds,
onSetupAgentApprove,
onSetupAgentReject,
onSetupWritingApprove,
onSetupWritingReject,
onSetupWritingStart,
onClarificationSubmit,
onEditResubmit,
onEditSave,
messageContentOverrides,
externallyHiddenMessageIds,
onSetupAgentApprove,
onSetupAgentReject,
onSetupWritingApprove,
onSetupWritingReject,
onSetupWritingStart,
researchApprovalSlot,
onOpenArtifact,
onDownloadArtifact,
}: {
className?: string;
contentClassName?: string;
@@ -120,25 +126,35 @@ export function MessageList({
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
scrollButtonClassName?: string;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void;
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
messageContentOverrides?: Record<string, string>;
externallyHiddenMessageIds?: Set<string>;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void;
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
messageContentOverrides?: Record<string, string>;
externallyHiddenMessageIds?: Set<string>;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<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.
@@ -164,33 +180,34 @@ export function MessageList({
});
}, []);
const allMessages = useMemo(() => {
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
return thread.messages
.filter((m) => !isHiddenFromUIMessage(m))
.map((message) => {
const override =
hasOverrides && message.id
? messageContentOverrides?.[message.id]
: undefined;
return override === undefined
? message
: ({ ...message, content: override } as Message);
});
}, [messageContentOverrides, thread.messages]);
const allMessages = useMemo(() => {
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
return thread.messages
.filter((m) => !isHiddenFromUIMessage(m))
.map((message) => {
const override =
hasOverrides && message.id
? messageContentOverrides?.[message.id]
: undefined;
return override === undefined
? message
: ({ ...message, content: override } as Message);
});
}, [messageContentOverrides, thread.messages]);
const messages = useMemo(
() => {
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
return hiddenMessageIds.size === 0 && externalHidden.size === 0
? allMessages
: allMessages.filter(
(m) =>
!m.id ||
(!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)),
);
},
() => {
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
return hiddenMessageIds.size === 0 && externalHidden.size === 0
? allMessages
: allMessages.filter(
(m) =>
!m.id ||
(!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)),
);
},
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
);
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
const askTimes = useChatAskTimes();
@@ -236,17 +253,21 @@ export function MessageList({
}, [messages, askTimes]);
const [settings] = useLocalSettings();
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
const hasSingleRoundRag = useMemo(
() => messages.some(isSingleRoundRagMessage),
[messages],
);
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
[referencesEnabled, skills],
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
const hasSingleRoundRag = useMemo(
() =>
messages.some(
(message) =>
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
),
[messages],
);
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
[referencesEnabled, skills],
);
// Same per-turn reference batches the right-side panel renders (backend
// batches preferred). Inline [n] tokens must resolve against these so badge
@@ -254,6 +275,53 @@ export function MessageList({
// frontend tool-message scan below comes up empty (e.g. after a reload).
const referenceBatches = useContext(ReferenceBatchesContext);
// 单轮/多轮 RAG 的参考资料存放在一条 hide_from_ui 的 tool 消息里(紧挨在
// assistant 消息之前)。allMessages 会把 hide_from_ui 消息过滤掉,导致通用
// 的 collectRuntimeDisplaySources 扫描不到它们。这里直接在原始消息流里按
// assistant 消息提取其对应的参考资料,保证 `[1]` 等编号能解析成可点击引用。
const frontendRagSourcesByAiId = useMemo(() => {
const map = new Map<string, RuntimeDisplaySource[]>();
const raw = thread.messages;
for (let i = 0; i < raw.length; i++) {
const msg = raw[i];
if (!msg || msg.type !== "ai" || !msg.id) continue;
const kwargs = msg.additional_kwargs;
if (!kwargs || (kwargs.single_round_rag !== true && kwargs.multi_round_rag !== true)) {
continue;
}
let sources: RuntimeDisplaySource[] = [];
for (let j = i - 1; j >= 0; j--) {
const prev = raw[j];
if (!prev) break;
// 前一条 human/ai 消息即为本轮边界,跨边界即停止。
if (prev.type === "human" || prev.type === "ai") break;
if (prev.type !== "tool") continue;
const prevKwargs = prev.additional_kwargs;
if (
!prevKwargs ||
(prevKwargs.single_round_rag !== true && prevKwargs.multi_round_rag !== true)
) {
continue;
}
const results = extractFrontendRagResults(prev.content);
if (results && results.length > 0) {
const toolName = String(
(prev as { name?: unknown }).name ??
(prevKwargs.multi_round_rag === true
? "multi_round_rag_references"
: "knowledge_base_search"),
);
sources = results.map((item, index) =>
toRuntimeDisplaySource(item, index + 1, toolName),
);
}
break;
}
map.set(msg.id, sources);
}
return map;
}, [thread.messages]);
// Track which clarification messages are still awaiting an answer.
// Pending = clarification tool messages that appear after the last human message
// (no user reply has been sent for them yet).
@@ -375,11 +443,17 @@ 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 =
referencesEnabled &&
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";
const batchSources = isAssistantMessage
@@ -387,14 +461,21 @@ export function MessageList({
(batch) => batch.assistant_message_id === msg.id,
)?.sources ?? []
: [];
// 单轮/多轮 RAG 的参考资料来自隐藏的 tool 消息,优先于通用扫描。
const frontendRagSources =
isAssistantMessage && msg.id
? frontendRagSourcesByAiId.get(msg.id) ?? []
: [];
const displaySources = isAssistantMessage
? batchSources.length > 0
? batchSources
: collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
})
: frontendRagSources.length > 0
? frontendRagSources
: collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
})
: [];
return (
<MessageListItem
@@ -404,17 +485,17 @@ 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}
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
onEditSave={onEditSave}
onMessageDeleted={
allowDelete ? handleMessageDeleted : undefined
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
onEditSave={onEditSave}
onMessageDeleted={
allowDelete ? handleMessageDeleted : undefined
}
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
/>
@@ -501,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) {
@@ -519,10 +609,11 @@ export function MessageList({
className="mb-4"
/>
)}
<ArtifactFileList
<ArtifactFileList
files={files}
threadId={threadId}
onOpenArtifact={onOpenArtifact}
onDownloadArtifact={onDownloadArtifact}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
@@ -647,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>
@@ -723,6 +817,46 @@ function findPreviousHumanMessageId(
return undefined;
}
/** 从单轮/多轮 RAG 的 tool 消息体里取出 `content.results`。 */
function extractFrontendRagResults(content: unknown): unknown[] | null {
if (content && typeof content === "object" && !Array.isArray(content)) {
const results = (content as { results?: unknown }).results;
if (Array.isArray(results)) return results;
}
return null;
}
/** 把单轮/多轮 RAG 的一条参考资料归一化为展示用的 RuntimeDisplaySource。 */
function toRuntimeDisplaySource(
item: unknown,
index: number,
skillName: string,
): RuntimeDisplaySource {
const record =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: {};
const raw =
(record.raw as Record<string, unknown> | undefined) ?? record;
const content = String(record.content ?? record.snippet ?? "");
return {
id: typeof record.id === "string" && record.id ? record.id : undefined,
title: String(record.title ?? `参考资料 ${index}`),
type: String(record.type ?? "result"),
snippet: String(record.snippet ?? content),
content,
source:
typeof record.source === "string" && record.source ? record.source : undefined,
author: undefined,
time: typeof record.time === "string" && record.time ? record.time : undefined,
url: typeof record.url === "string" && record.url ? record.url : undefined,
raw,
index,
skillName,
mode: "citation",
};
}
function getSetupAgentApprovalPayload(
message: unknown,
): SetupAgentApprovalPayload | null {
@@ -14,7 +14,7 @@ import {
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -172,6 +172,18 @@ export function WorkspaceNavChatList({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/deep-research")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/deep-research">
<Telescope />
<span>深度研究</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/roundtable/planning")}
@@ -0,0 +1,68 @@
export type StreamingArtifactLanguage = "markdown" | "html" | "mermaid";
export function normalizeStreamingArtifactPath(path: string): string {
let normalized = path.trim();
const first = normalized[0];
const last = normalized.at(-1);
if (
normalized.length >= 2 &&
((first === '"' && last === '"') ||
(first === "'" && last === "'") ||
(first === "`" && last === "`"))
) {
normalized = normalized.slice(1, -1).trim();
}
return normalized;
}
export function hasCompleteArtifactExtension(path: string): boolean {
const pathname = normalizeStreamingArtifactPath(path).split(/[?#]/, 1)[0] ?? "";
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
// JSON tool arguments commonly stream `report.m` before `report.md`.
// Treating the partial suffix as final makes the sandbox permanently TEXT.
return /\.[a-z0-9]{2,12}$/i.test(filename);
}
export function provisionalWriteFilePath(content: string): string {
const head = content.slice(0, 256).trimStart().toLowerCase();
if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
return "未命名文件.html";
}
return "未命名文件.md";
}
export function languageOfStreamingArtifact(
path: string,
): StreamingArtifactLanguage | null {
const pathname = normalizeStreamingArtifactPath(path)
.split(/[?#]/, 1)[0]
?.toLowerCase();
if (pathname?.endsWith(".md") || pathname?.endsWith(".markdown")) {
return "markdown";
}
if (pathname?.endsWith(".html") || pathname?.endsWith(".htm")) {
return "html";
}
if (pathname?.endsWith(".mmd") || pathname?.endsWith(".mermaid")) {
return "mermaid";
}
return null;
}
export function buildWriteFileVirtualUrl({
path,
messageId,
toolCallId,
}: {
path: string;
messageId: string;
toolCallId: string;
}): string {
const language = languageOfStreamingArtifact(path);
const languageQuery = language ? `&language=${language}` : "";
return new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}${languageQuery}`,
).toString();
}
@@ -0,0 +1,166 @@
"use client";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import type {
DocumentRewriteStage,
} from "@/open-canvas/hooks/useDocumentRewrite";
import type { DocumentRewriteSummary } from "@/open-canvas/api/rewrite";
export type DocumentRewritePresentation = {
id: string;
threadId: string;
source: {
kind: "artifact" | "deep_research_report";
id: string;
path?: string;
};
fileName: string;
instruction: string;
style: string;
modelName: string | null;
stage: DocumentRewriteStage;
originalCharCount: number;
writtenCharCount: number;
/** Short model-generated plan, streamed while it understands the request. */
requirementsPlan: string;
warnings: string[];
summary: DocumentRewriteSummary | null;
/** Kept in-memory for the line-level compare dialog; backend job hydration refills it. */
originalContent: string;
rewrittenContent: string | null;
versionId: string | null;
/** Inverse snapshot created by a safe undo; enables a one-click reapply. */
redoVersionId: string | null;
revertedAt: string | null;
error: string | null;
createdAt: string;
updatedAt: string;
};
type CreatePresentationInput = Omit<
DocumentRewritePresentation,
| "id"
| "stage"
| "originalCharCount"
| "writtenCharCount"
| "requirementsPlan"
| "warnings"
| "summary"
| "originalContent"
| "rewrittenContent"
| "versionId"
| "redoVersionId"
| "revertedAt"
| "error"
| "createdAt"
| "updatedAt"
> & {
originalContent?: string;
rewrittenContent?: string | null;
requirementsPlan?: string;
};
type DocumentRewritePresentationContextValue = {
create: (input: CreatePresentationInput) => string;
update: (
id: string,
patch: Partial<Omit<DocumentRewritePresentation, "id" | "threadId" | "createdAt">>,
) => void;
forThread: (threadId: string) => DocumentRewritePresentation[];
};
const DocumentRewritePresentationContext =
createContext<DocumentRewritePresentationContextValue | null>(null);
function createId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return "drw-ui-" + crypto.randomUUID();
}
return "drw-ui-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
}
export function DocumentRewritePresentationProvider({
children,
}: {
children: React.ReactNode;
}) {
const [presentations, setPresentations] = useState<DocumentRewritePresentation[]>([]);
const create = useCallback((input: CreatePresentationInput) => {
const id = createId();
const now = new Date().toISOString();
setPresentations((previous) => [
...previous.slice(-29),
{
...input,
id,
stage: "snapshot",
originalCharCount: 0,
writtenCharCount: 0,
requirementsPlan: input.requirementsPlan ?? "",
warnings: [],
summary: null,
originalContent: input.originalContent ?? "",
rewrittenContent: input.rewrittenContent ?? null,
versionId: null,
redoVersionId: null,
revertedAt: null,
error: null,
createdAt: now,
updatedAt: now,
},
]);
return id;
}, []);
const update = useCallback(
(
id: string,
patch: Partial<Omit<DocumentRewritePresentation, "id" | "threadId" | "createdAt">>,
) => {
setPresentations((previous) => {
const index = previous.findIndex((item) => item.id === id);
if (index < 0) return previous;
const current = previous[index];
const changed = Object.entries(patch).some(
([key, value]) => current[key as keyof DocumentRewritePresentation] !== value,
);
// Do not create a new context value for an identical update. Stream
// subscribers can legitimately receive an already-hydrated snapshot;
// treating it as a state change makes effect-based synchronisation
// vulnerable to update loops.
if (!changed) return previous;
const next = [...previous];
next[index] = { ...current, ...patch, updatedAt: new Date().toISOString() };
return next;
});
},
[],
);
const value = useMemo<DocumentRewritePresentationContextValue>(
() => ({
create,
update,
forThread: (threadId) =>
presentations.filter((item) => item.threadId === threadId),
}),
[create, presentations, update],
);
return (
<DocumentRewritePresentationContext.Provider value={value}>
{children}
</DocumentRewritePresentationContext.Provider>
);
}
export function useDocumentRewritePresentation() {
return useContext(DocumentRewritePresentationContext);
}
/** Browser event consumed by the open sandbox after an in-feed undo succeeds. */
export const DOCUMENT_REWRITE_UNDONE_EVENT = "deerflow:document-rewrite-undone";
+25 -1
View File
@@ -22,6 +22,8 @@ interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistan
interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {}
interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:research-approval"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
@@ -32,6 +34,7 @@ type MessageGroup =
| AssistantClarificationGroup
| AssistantSetupAgentApprovalGroup
| AssistantSetupWritingApprovalGroup
| AssistantResearchApprovalGroup
| AssistantSubagentGroup;
export function groupMessages<T>(
@@ -54,7 +57,8 @@ export function groupMessages<T>(
last.type !== "assistant" &&
last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval" &&
last.type !== "assistant:setup-writing-approval"
last.type !== "assistant:setup-writing-approval" &&
last.type !== "assistant:research-approval"
) {
return last;
}
@@ -114,6 +118,18 @@ export function groupMessages<T>(
}
if (message.type === "ai") {
// Deep-research interaction card: virtual ai messages injected by the
// workbench (additional_kwargs.setup_research_approval) render through
// the host-provided slot. Writing phases use ordinary virtual tool calls
// and completed summaries remain normal assistant messages.
if (hasResearchApproval(message)) {
groups.push({
id: message.id,
type: "assistant:research-approval",
messages: [message],
});
continue;
}
if (hasPresentFiles(message)) {
groups.push({
id: message.id,
@@ -386,6 +402,14 @@ export function isSetupWritingApprovalToolMessage(message: Message) {
);
}
export function hasResearchApproval(message: Message) {
return (
message.type === "ai" &&
typeof message.additional_kwargs?.setup_research_approval === "object" &&
message.additional_kwargs.setup_research_approval !== null
);
}
export function extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) {
return [];
@@ -19,3 +19,18 @@ export function isSingleRoundRagProfile(
"single_round_rag"
);
}
export function isMultiRoundRagProfile(
context: unknown,
): boolean {
return (
typeof context === "object" &&
context !== null &&
(context as { execution_profile?: unknown }).execution_profile ===
"multi_round_rag"
);
}
export function isFrontendRagProfile(context: unknown): boolean {
return isSingleRoundRagProfile(context) || isMultiRoundRagProfile(context);
}
+1 -1
View File
@@ -7,7 +7,7 @@ export interface Model {
supports_thinking?: boolean;
supports_reasoning_effort?: boolean;
/** Execution profiles handled by the frontend instead of DeerFlow. */
execution_profile?: "single_round_rag";
execution_profile?: "single_round_rag" | "multi_round_rag";
}
export interface TokenUsageSettings {
@@ -35,6 +35,7 @@ import {
Settings,
Sparkles,
Target,
Telescope,
Trophy,
Users,
} from "lucide-react";
@@ -92,6 +93,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: FileText,
path: "/page/canvas/ai-writing-chat",
},
{
id: "qa-deep-research",
label: "深度研究",
icon: Telescope,
path: "/page/strategy/qa/deep-research",
},
],
},
{
@@ -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";
}
@@ -71,6 +71,12 @@ test("keeps half-width syntax working", () => {
assert.equal(out.includes(citation(3)), true);
});
test("converts double-bracket RAG references", () => {
const out = applyInlineCitations("RAG answer [[1]] and [[2]].", makeSources(2));
assert.equal(out.includes(citation(1)), true);
assert.equal(out.includes(citation(2)), true);
});
test("accepts spaced and full-width square brackets", () => {
const out = applyInlineCitations("参考[ 1 ]和[2]。", makeSources(2));
assert.equal(out.includes(citation(1)), true);
+64 -12
View File
@@ -244,6 +244,11 @@ const TERMINAL_RUN_STATUSES = new Set([
// for a while, then reconcile it from the authoritative run/state endpoints.
const STREAM_RECONCILIATION_IDLE_MS = 12_000;
const STREAM_RECONCILIATION_POLL_MS = 5_000;
// A run can legitimately be quiet while a long tool call is in progress, so
// reconnect more conservatively than the terminal-status check above. The
// reconnection only replaces the local SSE reader; the backend run keeps going.
const STREAM_RECONNECT_IDLE_MS = 30_000;
const STREAM_RECONNECT_MAX_ATTEMPTS = 3;
async function fetchThreadTitleRefreshState(
threadId: string,
@@ -466,9 +471,15 @@ export function useThreadStream({
const startedRef = useRef(false);
const lastStreamActivityAtRef = useRef(0);
const streamReconciliationInFlightRef = useRef(false);
const streamReconnectAttemptsRef = useRef(0);
const streamReconnectToastShownRef = useRef(false);
const streamReconnectFailureToastShownRef = useRef(false);
const markStreamActivity = useCallback(() => {
lastStreamActivityAtRef.current = Date.now();
streamReconnectAttemptsRef.current = 0;
streamReconnectToastShownRef.current = false;
streamReconnectFailureToastShownRef.current = false;
}, []);
const listeners = useRef({
@@ -761,13 +772,6 @@ export function useThreadStream({
threadStreamRef.current = thread;
useEffect(() => {
if (!thread.isLoading) {
// A normal terminal event reached the browser. A later run may use the
// same thread, so it must be eligible for recovery again.
streamReconciliationInFlightRef.current = false;
return;
}
const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
if (!activeThreadId || typeof window === "undefined") {
return;
@@ -776,7 +780,7 @@ export function useThreadStream({
let disposed = false;
let refreshTimer: number | null = null;
const reconcileCompletedRun = async () => {
const reconcileRun = async () => {
if (
disposed ||
streamReconciliationInFlightRef.current ||
@@ -793,7 +797,51 @@ export function useThreadStream({
try {
const client = getAPIClient(isMock);
const run = await client.runs.get(activeThreadId, runId);
if (disposed || !TERMINAL_RUN_STATUSES.has(run.status)) {
if (disposed) {
return;
}
if (!TERMINAL_RUN_STATUSES.has(run.status)) {
// ``useStream`` retries one failed connection on its own. If that
// reader quietly stalls, however, its internal queue never gets a
// terminal error and the page remains frozen despite the backend
// continuing the run. Abort only the local reader, then join the
// same run so no work or tokens are lost server-side.
if (Date.now() - lastStreamActivityAtRef.current < STREAM_RECONNECT_IDLE_MS) {
return;
}
if (streamReconnectAttemptsRef.current >= STREAM_RECONNECT_MAX_ATTEMPTS) {
if (!streamReconnectFailureToastShownRef.current) {
streamReconnectFailureToastShownRef.current = true;
toast.error("回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果。");
}
return;
}
streamReconnectAttemptsRef.current += 1;
// Avoid enqueueing another reconnect before this new reader has had
// enough time to receive its first event. A genuine event resets
// the attempt counter through ``markStreamActivity``.
lastStreamActivityAtRef.current = Date.now();
if (!streamReconnectToastShownRef.current) {
streamReconnectToastShownRef.current = true;
toast.info("回答连接出现波动,正在自动恢复…");
}
const stream = threadStreamRef.current;
void Promise.resolve(stream.stop())
.catch(() => {
// Stopping a stale local reader is best-effort; joining is still
// useful if the SDK already considers the stream stopped.
})
.finally(() => {
if (disposed || threadIdRef.current !== activeThreadId) return;
void stream.joinStream(runId).catch(() => {
// The status poll will either reconcile a terminal run or
// schedule the next bounded reconnect attempt.
});
});
return;
}
@@ -801,13 +849,17 @@ export function useThreadStream({
// receive any more useful events. Load the checkpoint directly before
// resetting the SDK stream; this preserves the normal onFinish side
// effects (e.g. marking a position delivery complete) even when the
// browser missed the terminal event.
// browser missed the terminal event or already reported a transport
// error and dropped ``isLoading``.
const finalState = await client.threads.getState(activeThreadId);
if (disposed || !threadStreamRef.current.isLoading) {
if (disposed) {
return;
}
storage?.removeItem(`lg:stream:${activeThreadId}`);
streamReconnectAttemptsRef.current = 0;
streamReconnectToastShownRef.current = false;
streamReconnectFailureToastShownRef.current = false;
if (run.status === "success") {
listeners.current.onFinish?.(finalState.values as AgentThreadState);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
@@ -836,7 +888,7 @@ export function useThreadStream({
};
const interval = window.setInterval(
() => void reconcileCompletedRun(),
() => void reconcileRun(),
STREAM_RECONCILIATION_POLL_MS,
);
+2 -2
View File
@@ -27,8 +27,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
knowledge_rag_enabled?: boolean;
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
llmwiki_knowledge_base_ids?: string[];
/** Direct knowledge-base RAG mode; this request never enters Lead Agent. */
execution_profile?: "single_round_rag";
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */
execution_profile?: "single_round_rag" | "multi_round_rag";
agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string;
@@ -0,0 +1,483 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { readRuntimeEnv } from "@/env";
const STORAGE_PREFIX = "workspace.multi-round-rag.";
export type MultiRoundRagRetrieval = {
id?: string;
title: string;
content: string;
source?: string;
time?: string;
recUuid?: string;
url?: string;
};
export type MultiRoundRagRound = {
id: string;
title: string;
thought: string;
/** Model text emitted for this round, before its retrieval results. */
body: string;
query: string;
isSearching: boolean;
retrievals: MultiRoundRagRetrieval[];
};
export type MultiRoundRagTrace = {
rounds: MultiRoundRagRound[];
answer: string;
isComplete: boolean;
};
type MultiRoundRagSource = {
id?: string;
title: string;
type: string;
snippet: string;
content: string;
source?: string;
time?: string;
url?: string;
raw: Record<string, unknown>;
};
type MultiRoundEvent = {
event?: unknown;
data?: unknown;
};
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 {
// Storage quota failures must not interrupt a running answer.
}
}
function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`;
}
function isAbortError(error: unknown) {
return error instanceof DOMException && error.name === "AbortError";
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function parseJson(value: unknown): unknown {
if (typeof value !== "string") return value;
try {
return JSON.parse(value) as unknown;
} catch {
return value;
}
}
function parseEventLine(line: string): MultiRoundEvent | null {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) return null;
try {
return JSON.parse(trimmed.slice("data:".length).trim()) as MultiRoundEvent;
} catch {
return null;
}
}
function initialTrace(): MultiRoundRagTrace {
return {
rounds: [
{
id: createId("multi-rag-round"),
title: "正在准备检索",
thought: "",
body: "",
query: "",
isSearching: true,
retrievals: [],
},
],
answer: "",
isComplete: false,
};
}
function latestRound(trace: MultiRoundRagTrace): MultiRoundRagRound {
const current = trace.rounds.at(-1);
if (current) return current;
const next = initialTrace().rounds[0]!;
trace.rounds.push(next);
return next;
}
function addThinkingRound(trace: MultiRoundRagTrace, thought: string) {
const current = latestRound(trace);
if (current.title === "正在准备检索" && !current.thought && !current.query) {
current.title = "思考过程";
current.thought = thought;
return;
}
current.isSearching = false;
trace.rounds.push({
id: createId("multi-rag-round"),
title: "思考过程",
thought,
body: "",
query: "",
isSearching: true,
retrievals: [],
});
}
function appendRoundBody(trace: MultiRoundRagTrace, body: string) {
if (!body) return;
const current = latestRound(trace);
// Stored traces created before `body` was introduced remain readable.
current.body = `${current.body ?? ""}${body}`;
}
function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] {
const root = asRecord(parseJson(value));
const list =
(Array.isArray(root?.results) && root.results) ||
(Array.isArray(root?.organic) && root.organic) ||
[];
return list.flatMap((item, index) => {
const record = asRecord(item);
if (!record) return [];
const recUuid = asString(record.recUuid ?? record.rec_uuid);
const url = asString(record.url ?? record.link ?? record.href);
return [
{
id: asString(record.id) || recUuid || undefined,
title: asString(record.title) || `检索资料 ${index + 1}`,
content: asString(record.content ?? record.snippet ?? record.text),
source: asString(record.source ?? record.source1 ?? url) || undefined,
time: asString(record.publish_time ?? record.m_publish ?? record.date) || undefined,
recUuid: recUuid || undefined,
url: /^https?:\/\//i.test(url) ? url : undefined,
},
];
});
}
function mapReferences(value: unknown): MultiRoundRagSource[] {
const record = asRecord(value);
const list = Array.isArray(record?.references) ? record.references : [];
return list.flatMap((item, index) => {
const source = asRecord(item);
if (!source) return [];
const content = asString(source.content ?? source.snippet);
const rawUrl = asString(source.url ?? source.link ?? source.href);
const recUuid = asString(source.recUuid ?? source.rec_uuid ?? source.uuid);
return [
{
id: asString(source.id) || recUuid || undefined,
title: asString(source.title) || `参考资料 ${index + 1}`,
type: asString(source.type) || "参考资料",
snippet: asString(source.snippet) || content,
content,
source: asString(source.source ?? source.source1) || undefined,
time: asString(source.time ?? source.m_publish ?? source.publish_time) || undefined,
url: /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined,
raw: source,
},
];
});
}
function cloneTrace(trace: MultiRoundRagTrace): MultiRoundRagTrace {
return {
...trace,
rounds: trace.rounds.map((round) => ({
...round,
retrievals: [...round.retrievals],
})),
};
}
function isSingleRoundRagMessage(message: Message) {
return message.additional_kwargs?.single_round_rag === true;
}
export function isMultiRoundRagMessage(message: Message): boolean {
return message.additional_kwargs?.multi_round_rag === true;
}
export function getMultiRoundRagTrace(
message: Message,
): MultiRoundRagTrace | null {
const trace = message.additional_kwargs?.multi_round_rag_trace;
return asRecord(trace)
? (trace as unknown as MultiRoundRagTrace)
: null;
}
/**
* Frontend adapter for GeneralQA's multi-round endpoint. It keeps the external
* SSE protocol outside DeerFlow and stores only its UI messages in sessionStorage.
*/
export function useMultiRoundRagMessages(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 updateMessages = useCallback(
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
setMessages((current) => {
const next = updater(current);
messagesRef.current = next;
writeStoredMessages(targetThreadId, next);
return next;
});
},
[],
);
const stop = useCallback(() => controllerRef.current?.abort(), []);
const submit = useCallback(
async ({ targetThreadId, query }: { targetThreadId: string; query: string }) => {
if (isLoading) return;
const question = query.trim();
if (!question) return;
const humanId = createId("multi-rag-human");
const assistantId = createId("multi-rag-ai");
const sourceId = createId("multi-rag-sources");
const trace = initialTrace();
let sources: MultiRoundRagSource[] = [];
const clientTs = new Date().toISOString();
const userMessage: Message = {
id: humanId,
type: "human",
content: [{ type: "text", text: question }],
additional_kwargs: { client_ts: clientTs, multi_round_rag: true },
};
const assistantMessage: Message = {
id: assistantId,
type: "ai",
content: "",
additional_kwargs: {
multi_round_rag: true,
multi_round_rag_trace: trace,
},
};
updateMessages(targetThreadId, (current) => [
...current,
userMessage,
assistantMessage,
]);
setError(null);
setIsLoading(true);
const controller = new AbortController();
controllerRef.current = controller;
const refreshAssistant = () => {
const traceSnapshot = cloneTrace(trace);
updateMessages(targetThreadId, (current) => {
const next = current.filter((message) => message.id !== sourceId);
const assistantIndex = next.findIndex((message) => message.id === assistantId);
if (assistantIndex < 0) return current;
next[assistantIndex] = {
...next[assistantIndex],
content: traceSnapshot.answer,
additional_kwargs: {
...next[assistantIndex]!.additional_kwargs,
multi_round_rag: true,
multi_round_rag_trace: traceSnapshot,
reasoning_content: traceSnapshot.rounds
.map((round) => round.thought)
.filter(Boolean)
.join("\n\n") || null,
},
} as Message;
if (sources.length > 0) {
next.splice(assistantIndex, 0, {
id: sourceId,
type: "tool",
name: "multi_round_rag_references",
content: { results: sources },
additional_kwargs: {
hide_from_ui: true,
multi_round_rag: true,
},
} as unknown as Message);
}
return next;
});
};
try {
const baseUrl = readRuntimeEnv("VITE_AIPI_GENERATE_CHAT_URL") || "";
if (!baseUrl) {
throw new Error("未配置 VITE_AIPI_GENERATE_CHAT_URL,无法发起多轮 RAG 问答。");
}
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({ prompt: question }),
});
if (!response.ok) {
throw new Error(`多轮 RAG 服务请求失败(${response.status})。`);
}
if (!response.body) {
throw new Error("多轮 RAG 服务未返回流式响应。");
}
const reader = response.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 });
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? "";
for (const line of lines) {
const event = parseEventLine(line);
if (!event) continue;
const eventType = asString(event.event);
const data = event.data;
if (eventType === "tool_call") {
const tool = asRecord(data);
const toolName = asString(tool?.tool_name);
const input = asRecord(tool?.tool_input) ?? {};
const current = latestRound(trace);
if (toolName === "show_text") {
const text = asString(input.text);
if (text.includes("<think>")) {
const thinkEnd = text.indexOf("</think>");
addThinkingRound(
trace,
(thinkEnd >= 0
? text.slice(0, thinkEnd + "</think>".length)
: text
)
.replace(/<\/?think>/g, "")
.trim(),
);
if (thinkEnd >= 0) {
const roundBody = text
.slice(thinkEnd + "</think>".length)
.trim();
if (roundBody) {
appendRoundBody(trace, roundBody);
}
}
} else {
// A non-thinking `show_text` immediately after a thought is
// this round's narrative; the terminal `message` event is
// still rendered only as the final answer below all rounds.
current.isSearching = false;
trace.answer += text;
trace.isComplete = true;
}
} else if (
toolName === "local_search" ||
toolName === "tavily_search" ||
toolName === "google_search"
) {
const queryData = asRecord(parseJson(input.result));
current.title =
toolName === "local_search" ? "本地知识检索" : "联网检索";
current.query =
asString(input.q ?? input.query) ||
asString(queryData?.query) ||
current.query;
const retrievals = mapRetrievals(input.result);
if (retrievals.length > 0) current.retrievals = retrievals;
current.isSearching = false;
} else if (toolName === "get_textid_content") {
const article = asRecord(parseJson(input.result));
const body =
asString(article?.content ?? article?.text) ||
asString(input.content);
if (body) appendRoundBody(trace, body);
current.isSearching = false;
}
refreshAssistant();
} else if (eventType === "message" && typeof data === "string") {
latestRound(trace).isSearching = false;
trace.answer += data;
trace.isComplete = true;
refreshAssistant();
} else if (eventType === "references") {
sources = mapReferences(data);
refreshAssistant();
} else if (eventType === "done") {
trace.isComplete = true;
for (const round of trace.rounds) round.isSearching = false;
refreshAssistant();
}
}
}
trace.isComplete = true;
for (const round of trace.rounds) round.isSearching = false;
refreshAssistant();
} catch (caught) {
trace.isComplete = true;
for (const round of trace.rounds) round.isSearching = false;
if (!isAbortError(caught)) {
const message =
caught instanceof Error ? caught.message : "多轮 RAG 问答失败。";
setError(message);
if (!trace.answer) trace.answer = message;
}
refreshAssistant();
} finally {
if (controllerRef.current === controller) controllerRef.current = null;
setIsLoading(false);
}
},
[isLoading, updateMessages],
);
return { messages, isLoading, error, submit, stop };
}
export function isFrontendRagMessage(message: Message): boolean {
return isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message);
}
@@ -22,6 +22,13 @@ type RagSource = {
raw: Record<string, unknown>;
};
type RagHistoryEntry = {
role: "user" | "assistant";
content: string;
};
const MAX_HISTORY_MESSAGES = 12;
/** Same JSON-object framing used by the established GeneralQA single-RAG flow. */
function extractJsonObjects(raw: string): { objects: string[]; rest: string } {
const objects: string[] = [];
@@ -199,6 +206,44 @@ function isAbortError(error: unknown) {
return error instanceof DOMException && error.name === "AbortError";
}
function textFromMessage(message: Message): string {
if (typeof message.content === "string") {
return message.content.trim();
}
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("\n")
.trim();
}
/**
* The general-QA endpoint accepts the usual `{ role, content }` history
* format. Keep it scoped to completed single-round-RAG turns so a normal
* DeerFlow conversation never leaks into this external endpoint.
*/
function buildRagHistory(messages: Message[]): RagHistoryEntry[] {
return messages
.filter(
(message) =>
message.additional_kwargs?.single_round_rag === true &&
(message.type === "human" || message.type === "ai"),
)
.map((message): RagHistoryEntry => ({
role: message.type === "human" ? "user" : "assistant",
content: textFromMessage(message),
}))
.filter((entry) => entry.content.length > 0)
.slice(-MAX_HISTORY_MESSAGES);
}
export function isSingleRoundRagMessage(message: Message): boolean {
return message.additional_kwargs?.single_round_rag === true;
}
@@ -209,21 +254,29 @@ export function isSingleRoundRagMessage(message: Message): boolean {
*/
export function useSingleRoundRagMessages(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(() => {
setMessages(readStoredMessages(threadId));
const stored = readStoredMessages(threadId);
messagesRef.current = stored;
setMessages(stored);
setError(null);
}, [threadId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
useEffect(() => () => controllerRef.current?.abort(), []);
const updateMessages = useCallback(
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
setMessages((current) => {
const next = updater(current);
messagesRef.current = next;
writeStoredMessages(targetThreadId, next);
return next;
});
@@ -251,6 +304,7 @@ export function useSingleRoundRagMessages(threadId: string) {
const assistantId = createId("single-rag-ai");
const sourceId = createId("single-rag-sources");
const clientTs = new Date().toISOString();
const history = buildRagHistory(messagesRef.current);
const userMessage: Message = {
id: humanId,
type: "human",
@@ -319,7 +373,7 @@ export function useSingleRoundRagMessages(threadId: string) {
knowledge_base_name: [],
top_k: 5,
score_threshold: 0.5,
history: [],
history,
stream: true,
model_name: modelName,
temperature: 0.3,
+286 -1
View File
@@ -31,7 +31,7 @@ export type RewriteStreamEvent =
// ── SSE parser ────────────────────────────────────────────────────────────────
async function* parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<{ event: string; data: string }> {
export async function* parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<{ event: string; data: string }> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
@@ -106,3 +106,288 @@ export async function* streamRewrite(
}
}
}
export type DocumentRewriteStyle =
| "formal_analysis"
| "professional"
| "concise"
| "readable";
export interface DocumentRewriteRequest {
threadId: string;
path: string;
instruction: string;
style?: DocumentRewriteStyle;
modelName?: string;
expectedHash?: string;
}
export interface DocumentRewriteSummary {
sourceDisplayName: string;
instruction: string;
modelName: string;
originalCharCount: number;
rewrittenCharCount: number;
charChangeRatio: number;
changedBlockCount: number;
headingCountBefore: number;
headingCountAfter: number;
headingChanges?: {
added: string[];
removed: string[];
reordered: boolean;
levelWarnings: string[];
};
requestedOptimizations?: string[];
validationWarnings?: string[];
}
export type DocumentRewriteStreamEvent =
| { type: "job_started"; jobId: string; reused: boolean }
| { type: "snapshot_fixed"; fileName: string; originalCharCount: number; baseHash: string }
| { type: "requirements_started" }
/** Small live rewrite plan shown in the message-list requirements step. */
| { type: "requirements_delta"; delta: string }
/** Durable recovery point for a plan produced before an SSE reconnect. */
| { type: "requirements_checkpoint"; content: string }
| { type: "requirements_completed"; content: string; fallback: boolean }
| { type: "requirements_resolved"; instruction: string; style: string; modelName: string; plan?: string }
| { type: "thinking"; chunk: string }
| { type: "rewrite_delta"; delta: string; charCount: number }
/** Durable full-draft recovery point used after an SSE reconnect. */
| { type: "rewrite_checkpoint"; content: string; charCount: number }
| { type: "validation_started" }
| { type: "validation_completed"; passed: boolean; warnings: string[] }
| { type: "comparison_ready"; summary: DocumentRewriteSummary }
| { type: "commit_started" }
| { type: "committed"; content: string; committedHash: string; versionId?: string }
| { type: "done"; committedHash: string; versionId?: string }
| { type: "conflict"; message: string }
| { type: "cancelled"; message: string }
| { type: "error"; message: string; stage?: string };
export interface DurableDocumentRewriteJob {
id: string;
status: string;
phase: string;
progress: number;
createdAt?: string | null;
result: Record<string, unknown>;
request: {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
originalContent: string;
};
}
async function readDocumentRewriteEvent(
event: string,
data: string,
): Promise<DocumentRewriteStreamEvent | null> {
try {
const parsed = JSON.parse(data) as Record<string, unknown>;
switch (event) {
case "snapshot_fixed":
return {
type: "snapshot_fixed",
fileName: String(parsed.fileName ?? ""),
originalCharCount: Number(parsed.originalCharCount ?? 0),
baseHash: String(parsed.baseHash ?? ""),
};
case "requirements_resolved":
return {
type: "requirements_resolved",
instruction: String(parsed.instruction ?? ""),
style: String(parsed.style ?? ""),
modelName: String(parsed.modelName ?? ""),
plan: typeof parsed.plan === "string" ? parsed.plan : undefined,
};
case "requirements_started":
return { type: "requirements_started" };
case "requirements_delta":
return { type: "requirements_delta", delta: String(parsed.delta ?? "") };
case "requirements_checkpoint":
return { type: "requirements_checkpoint", content: String(parsed.content ?? "") };
case "requirements_completed":
return {
type: "requirements_completed",
content: String(parsed.content ?? ""),
fallback: parsed.fallback === true,
};
case "thinking":
return { type: "thinking", chunk: String(parsed.chunk ?? "") };
case "rewrite_delta":
return {
type: "rewrite_delta",
delta: String(parsed.delta ?? ""),
charCount: Number(parsed.charCount ?? 0),
};
case "rewrite_checkpoint":
return {
type: "rewrite_checkpoint",
content: String(parsed.content ?? ""),
charCount: Number(parsed.charCount ?? 0),
};
case "validation_started":
return { type: "validation_started" };
case "validation_completed":
return {
type: "validation_completed",
passed: parsed.passed === true,
warnings: Array.isArray(parsed.warnings)
? parsed.warnings.filter((item): item is string => typeof item === "string")
: [],
};
case "comparison_ready":
return { type: "comparison_ready", summary: parsed as unknown as DocumentRewriteSummary };
case "commit_started":
return { type: "commit_started" };
case "committed":
return {
type: "committed",
content: String(parsed.content ?? ""),
committedHash: String(parsed.committedHash ?? ""),
versionId: typeof parsed.versionId === "string" ? parsed.versionId : undefined,
};
case "done":
return {
type: "done",
committedHash: String(parsed.committedHash ?? ""),
versionId: typeof parsed.versionId === "string" ? parsed.versionId : undefined,
};
case "conflict":
return { type: "conflict", message: String(parsed.message ?? "文件内容已变化") };
case "cancelled":
return { type: "cancelled", message: String(parsed.message ?? "已停止全文改写") };
case "error":
return {
type: "error",
message: String(parsed.message ?? "全文改写失败"),
stage: typeof parsed.stage === "string" ? parsed.stage : undefined,
};
default:
return null;
}
} catch {
return null;
}
}
async function startDurableDocumentRewrite(
req: DocumentRewriteRequest,
signal?: AbortSignal,
): Promise<{ jobId: string; reused: boolean }> {
const response = await apiFetch(getBackendBaseURL() + "/api/writing/document-rewrite/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
signal,
});
const body = await response.json().catch(() => ({})) as Record<string, unknown>;
if (!response.ok || typeof body.jobId !== "string") {
throw new Error(
(body.detail as string | undefined) ??
(body.message as string | undefined) ??
`HTTP ${response.status}`,
);
}
return { jobId: body.jobId, reused: body.reused === true };
}
export async function* streamDurableDocumentRewriteJob(
req: Pick<DocumentRewriteRequest, "threadId" | "path">,
jobId: string,
signal?: AbortSignal,
after = 0,
): AsyncGenerator<DocumentRewriteStreamEvent> {
const query = new URLSearchParams({
thread_id: req.threadId,
path: req.path,
after: String(after),
});
const response = await apiFetch(
`${getBackendBaseURL()}/api/writing/document-rewrite/jobs/${encodeURIComponent(jobId)}/stream?${query.toString()}`,
{ method: "GET", signal },
);
if (!response.ok || !response.body) {
const body = await response.json().catch(() => ({})) as Record<string, unknown>;
throw new Error((body.detail as string | undefined) ?? `HTTP ${response.status}`);
}
for await (const { event, data } of parseSse(response.body)) {
if (signal?.aborted) return;
const parsed = await readDocumentRewriteEvent(event, data);
if (!parsed) continue;
yield parsed;
if (["done", "conflict", "cancelled", "error"].includes(parsed.type)) return;
}
}
export async function getLatestDurableDocumentRewrite(
req: Pick<DocumentRewriteRequest, "threadId" | "path">,
): Promise<DurableDocumentRewriteJob | null> {
const jobs = await listDurableDocumentRewrites(req, 1);
return jobs[0] ?? null;
}
export async function listDurableDocumentRewrites(
req: Pick<DocumentRewriteRequest, "threadId" | "path">,
limit = 20,
): Promise<DurableDocumentRewriteJob[]> {
const query = new URLSearchParams({
thread_id: req.threadId,
path: req.path,
limit: String(Math.min(Math.max(limit, 1), 20)),
});
const response = await apiFetch(
`${getBackendBaseURL()}/api/writing/document-rewrite/jobs?${query.toString()}`,
{ method: "GET" },
);
const body = await response.json().catch(() => ({})) as Record<string, unknown>;
if (!response.ok) throw new Error((body.detail as string | undefined) ?? `HTTP ${response.status}`);
const rows = Array.isArray(body.items) ? body.items : [];
return rows.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const job = item as Record<string, unknown>;
if (typeof job.id !== "string") return [];
const request = (job.request ?? {}) as Record<string, unknown>;
return [{
id: job.id,
status: String(job.status ?? "queued"),
phase: String(job.phase ?? "initializing"),
progress: Number(job.progress ?? 0),
createdAt: typeof job.createdAt === "string" ? job.createdAt : null,
result: (job.result ?? {}) as Record<string, unknown>,
request: {
instruction: String(request.instruction ?? ""),
style: (request.style ?? "professional") as DocumentRewriteStyle,
modelName: typeof request.modelName === "string" ? request.modelName : null,
originalContent: String(request.originalContent ?? ""),
},
}];
});
}
export async function cancelDurableDocumentRewrite(
req: Pick<DocumentRewriteRequest, "threadId" | "path">,
jobId: string,
): Promise<void> {
const query = new URLSearchParams({ thread_id: req.threadId, path: req.path });
const response = await apiFetch(
`${getBackendBaseURL()}/api/writing/document-rewrite/jobs/${encodeURIComponent(jobId)}/cancel?${query.toString()}`,
{ method: "POST" },
);
if (!response.ok) {
const body = await response.json().catch(() => ({})) as Record<string, unknown>;
throw new Error((body.detail as string | undefined) ?? "停止全文改写失败");
}
}
export async function* streamDocumentRewrite(
req: DocumentRewriteRequest,
signal?: AbortSignal,
): AsyncGenerator<DocumentRewriteStreamEvent> {
const started = await startDurableDocumentRewrite(req, signal);
yield { type: "job_started", jobId: started.jobId, reused: started.reused };
yield* streamDurableDocumentRewriteJob(req, started.jobId, signal);
}
@@ -5,8 +5,15 @@ import { CanvasUIProvider, useCanvasUIContext } from "../../contexts/CanvasUICon
import { TiptapEditor } from "../../editor/TiptapEditor";
import { useWritingEditor } from "../../hooks/useWritingEditor";
import { useWritingRewrite } from "../../hooks/useWritingRewrite";
import { applyInsert, applyReplace } from "../../utils/apply-rewrite";
import {
applyInsert,
applyReplace,
createRewriteSelectionSnapshot,
isRewriteSelectionSnapshotCurrent,
type RewriteSelectionSnapshot,
} from "../../utils/apply-rewrite";
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
import { toast } from "sonner";
import { WritingAiMenu } from "./WritingAiMenu";
import { WritingBubbleMenu } from "./WritingBubbleMenu";
@@ -69,6 +76,7 @@ function MarkdownAiEditorInner({
const [phase, setPhase] = useState<FlowPhase>("idle");
const [panelPos, setPanelPos] = useState<PanelPos>({ x: 0, y: 0 });
const lastActionRef = useRef<LastAction | null>(null);
const candidateSnapshotRef = useRef<RewriteSelectionSnapshot | null>(null);
const editor = useWritingEditor({
markdown,
@@ -99,10 +107,17 @@ function MarkdownAiEditorInner({
const closeAll = useCallback(() => {
editor?.commands.clearAiHighlight();
candidateSnapshotRef.current = null;
setPhase("idle");
reset();
}, [reset, editor]);
const snapshotCurrentSelection = useCallback(() => {
if (!editor || !writingSelection) return false;
candidateSnapshotRef.current = createRewriteSelectionSnapshot(editor, writingSelection);
return true;
}, [editor, writingSelection]);
const handleAction = useCallback(
(
action: RewriteAction,
@@ -110,39 +125,53 @@ function MarkdownAiEditorInner({
customInstruction?: string,
referenceText?: string,
) => {
if (!snapshotCurrentSelection()) return;
lastActionRef.current = { action, tone, customInstruction, referenceText };
rewrite(action, tone, customInstruction, referenceText);
setPhase("preview");
},
[rewrite],
[rewrite, snapshotCurrentSelection],
);
const handleRetry = useCallback(() => {
if (!lastActionRef.current) return;
if (!snapshotCurrentSelection()) return;
const { action, tone, customInstruction, referenceText } = lastActionRef.current;
rewrite(action, tone, customInstruction, referenceText);
}, [rewrite]);
}, [rewrite, snapshotCurrentSelection]);
const handleFollowUp = useCallback(
(instruction: string) => {
if (!lastActionRef.current) return;
if (!snapshotCurrentSelection()) return;
const { action, tone, referenceText } = lastActionRef.current;
rewriteFollowUp(instruction, action, tone, referenceText);
},
[rewriteFollowUp],
[rewriteFollowUp, snapshotCurrentSelection],
);
const canApplyCandidate = useCallback(() => {
if (!editor || !candidateSnapshotRef.current) return false;
if (isRewriteSelectionSnapshotCurrent(editor, candidateSnapshotRef.current)) return true;
toast.error("原文已变化,请重新生成候选稿。", {
description: "为避免将过期候选替换到错误位置,本次内容没有写入文档。",
});
return false;
}, [editor]);
const handleReplace = useCallback(() => {
if (!editor || !writingSelection || !result) return;
applyReplace(editor, writingSelection, result);
const snapshot = candidateSnapshotRef.current;
if (!editor || !snapshot || !result || !canApplyCandidate()) return;
applyReplace(editor, snapshot, result);
closeAll();
}, [editor, writingSelection, result, closeAll]);
}, [editor, result, canApplyCandidate, closeAll]);
const handleInsert = useCallback(() => {
if (!editor || !writingSelection || !result) return;
applyInsert(editor, writingSelection, result);
const snapshot = candidateSnapshotRef.current;
if (!editor || !snapshot || !result || !canApplyCandidate()) return;
applyInsert(editor, snapshot, result);
closeAll();
}, [editor, writingSelection, result, closeAll]);
}, [editor, result, canApplyCandidate, closeAll]);
return (
<div className={className ?? "h-full flex flex-col overflow-hidden"}>
@@ -0,0 +1,394 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
type DocumentRewriteRequest,
type DocumentRewriteSummary,
type DocumentRewriteStreamEvent,
cancelDurableDocumentRewrite,
streamDocumentRewrite,
} from "../api/rewrite";
export type DocumentRewriteStage =
| "idle"
| "snapshot"
| "requirements"
| "writing"
| "validation"
| "committing"
| "completed"
| "conflict"
| "error"
| "cancelled";
export interface DocumentRewriteRun {
stage: DocumentRewriteStage;
draft: string;
/** Live, compact execution plan produced before Markdown generation starts. */
requirementsPlan: string;
originalCharCount: number;
writtenCharCount: number;
fileName: string;
warnings: string[];
summary: DocumentRewriteSummary | null;
versionId: string | null;
jobId: string | null;
error: string | null;
}
const INITIAL_RUN: DocumentRewriteRun = {
stage: "idle",
draft: "",
requirementsPlan: "",
originalCharCount: 0,
writtenCharCount: 0,
fileName: "",
warnings: [],
summary: null,
versionId: null,
jobId: null,
error: null,
};
/**
* Full-document rewrite stream with requestAnimationFrame batching.
*
* The backend only persists after it emits `committed`; until then `draft`
* represents a temporary sandbox view and must never be treated as the file's
* actual content.
*/
export function useDocumentRewrite() {
const [run, setRun] = useState<DocumentRewriteRun>(INITIAL_RUN);
const controllerRef = useRef<AbortController | null>(null);
const jobRef = useRef<{ id: string; request: DocumentRewriteRequest } | null>(null);
const pendingDeltaRef = useRef("");
const pendingWrittenCharCountRef = useRef<number | null>(null);
const frameRef = useRef<number | null>(null);
const pendingRequirementsDeltaRef = useRef("");
const requirementsFrameRef = useRef<number | null>(null);
const flushDelta = useCallback(() => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
const delta = pendingDeltaRef.current;
const writtenCharCount = pendingWrittenCharCountRef.current;
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
if (!delta) return;
setRun((previous) => ({
...previous,
stage: "writing",
draft: previous.draft + delta,
...(writtenCharCount !== null ? { writtenCharCount } : {}),
}));
}, []);
const enqueueDelta = useCallback((delta: string, writtenCharCount: number) => {
if (!delta) return;
pendingDeltaRef.current += delta;
pendingWrittenCharCountRef.current = writtenCharCount;
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
const pending = pendingDeltaRef.current;
const pendingWrittenCharCount = pendingWrittenCharCountRef.current;
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
if (!pending) return;
setRun((previous) => ({
...previous,
stage: "writing",
draft: previous.draft + pending,
...(pendingWrittenCharCount !== null ? { writtenCharCount: pendingWrittenCharCount } : {}),
}));
});
}, []);
const flushRequirements = useCallback(() => {
if (requirementsFrameRef.current !== null) {
cancelAnimationFrame(requirementsFrameRef.current);
requirementsFrameRef.current = null;
}
const delta = pendingRequirementsDeltaRef.current;
pendingRequirementsDeltaRef.current = "";
if (!delta) return;
setRun((previous) => ({
...previous,
stage: ["snapshot", "requirements"].includes(previous.stage)
? "requirements"
: previous.stage,
requirementsPlan: previous.requirementsPlan + delta,
}));
}, []);
const enqueueRequirementsDelta = useCallback((delta: string) => {
if (!delta) return;
pendingRequirementsDeltaRef.current += delta;
if (requirementsFrameRef.current !== null) return;
requirementsFrameRef.current = requestAnimationFrame(() => {
requirementsFrameRef.current = null;
const pending = pendingRequirementsDeltaRef.current;
pendingRequirementsDeltaRef.current = "";
if (!pending) return;
setRun((previous) => ({
...previous,
stage: ["snapshot", "requirements"].includes(previous.stage)
? "requirements"
: previous.stage,
requirementsPlan: previous.requirementsPlan + pending,
}));
});
}, []);
const cancel = useCallback(() => {
controllerRef.current?.abort();
controllerRef.current = null;
const job = jobRef.current;
if (job) {
void cancelDurableDocumentRewrite(job.request, job.id).catch(() => {
// The browser stream is already stopped. The terminal event will be
// recovered on the next hydration if the cancel request is delayed.
});
}
flushDelta();
flushRequirements();
setRun((previous) =>
previous.stage === "completed"
? previous
: { ...previous, stage: "cancelled", error: "已停止全文改写,原文件未被修改。" },
);
}, [flushDelta, flushRequirements]);
const reset = useCallback(() => {
controllerRef.current?.abort();
controllerRef.current = null;
jobRef.current = null;
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
pendingRequirementsDeltaRef.current = "";
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
if (requirementsFrameRef.current !== null) {
cancelAnimationFrame(requirementsFrameRef.current);
requirementsFrameRef.current = null;
}
setRun(INITIAL_RUN);
}, []);
const start = useCallback(
async (
request: DocumentRewriteRequest,
options?: {
onCommitted?: (content: string) => void;
onEvent?: (event: DocumentRewriteStreamEvent) => void;
stream?: (
signal: AbortSignal,
) => AsyncIterable<DocumentRewriteStreamEvent>;
/** Resume a durable task instead of creating a new one. */
jobId?: string;
},
) => {
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
if (options?.jobId) {
jobRef.current = { id: options.jobId, request };
}
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
pendingRequirementsDeltaRef.current = "";
setRun({
...INITIAL_RUN,
stage: "snapshot",
fileName: request.path.split("/").filter(Boolean).pop() ?? request.path,
jobId: options?.jobId ?? null,
});
try {
let receivedTerminalEvent = false;
const stream = options?.stream
? options.stream(controller.signal)
: streamDocumentRewrite(request, controller.signal);
for await (const event of stream) {
if (controller.signal.aborted) return;
options?.onEvent?.(event);
switch (event.type) {
case "job_started":
jobRef.current = { id: event.jobId, request };
setRun((previous) => ({ ...previous, jobId: event.jobId }));
break;
case "snapshot_fixed":
setRun((previous) => ({
...previous,
stage: "requirements",
fileName: event.fileName || previous.fileName,
originalCharCount: event.originalCharCount,
}));
break;
case "requirements_started":
setRun((previous) => ({ ...previous, stage: "requirements", requirementsPlan: "" }));
break;
case "requirements_delta":
enqueueRequirementsDelta(event.delta);
break;
case "requirements_checkpoint":
flushRequirements();
setRun((previous) => ({
...previous,
stage: "requirements",
requirementsPlan: event.content,
}));
break;
case "requirements_completed":
flushRequirements();
setRun((previous) => ({
...previous,
stage: "requirements",
requirementsPlan: event.content,
}));
break;
case "requirements_resolved":
flushRequirements();
setRun((previous) => ({
...previous,
stage: "writing",
...(event.plan ? { requirementsPlan: event.plan } : {}),
}));
break;
case "rewrite_delta":
enqueueDelta(event.delta, event.charCount);
break;
case "rewrite_checkpoint":
// A background job may have continued while this browser was
// disconnected. Replace, rather than append, so no missed or
// duplicated live deltas corrupt the temporary sandbox draft.
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
setRun((previous) => ({
...previous,
stage: "writing",
draft: event.content,
writtenCharCount: event.charCount,
}));
break;
case "validation_started":
flushDelta();
flushRequirements();
setRun((previous) => ({ ...previous, stage: "validation" }));
break;
case "validation_completed":
setRun((previous) => ({
...previous,
warnings: event.warnings,
}));
break;
case "comparison_ready":
setRun((previous) => ({ ...previous, summary: event.summary }));
break;
case "commit_started":
setRun((previous) => ({ ...previous, stage: "committing" }));
break;
case "committed":
flushDelta();
flushRequirements();
setRun((previous) => ({
...previous,
draft: event.content,
writtenCharCount: event.content.replace(/\s/g, "").length,
versionId: event.versionId ?? previous.versionId,
}));
options?.onCommitted?.(event.content);
break;
case "done":
flushDelta();
flushRequirements();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
stage: "completed",
versionId: event.versionId ?? previous.versionId,
}));
return;
case "conflict":
flushDelta();
flushRequirements();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
stage: "conflict",
error: event.message,
}));
return;
case "cancelled":
flushDelta();
flushRequirements();
receivedTerminalEvent = true;
setRun((previous) => ({ ...previous, stage: "cancelled", error: event.message }));
return;
case "error":
flushDelta();
flushRequirements();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
stage: "error",
error: event.message,
}));
return;
default:
break;
}
}
if (!controller.signal.aborted && !receivedTerminalEvent) {
flushDelta();
flushRequirements();
setRun((previous) => ({
...previous,
stage: "error",
error: "改写任务连接已结束,但未收到完成状态;原文件未被修改。",
}));
}
} catch (error) {
if (controller.signal.aborted) return;
flushDelta();
flushRequirements();
setRun((previous) => ({
...previous,
stage: "error",
error: error instanceof Error ? error.message : "全文改写失败",
}));
} finally {
if (controllerRef.current === controller) {
controllerRef.current = null;
}
}
},
[enqueueDelta, enqueueRequirementsDelta, flushDelta, flushRequirements],
);
useEffect(
() => () => {
controllerRef.current?.abort();
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
if (requirementsFrameRef.current !== null) cancelAnimationFrame(requirementsFrameRef.current);
},
[],
);
return {
run,
isActive: ["snapshot", "requirements", "writing", "validation", "committing"].includes(run.stage),
start,
cancel,
reset,
};
}
@@ -1,6 +1,54 @@
import type { Editor } from "@tiptap/react";
import type { WritingSelection } from "../types/selection";
/**
* Immutable anchor for an AI rewrite candidate.
*
* An AI response can take seconds to arrive. During that time a user may
* keep editing the document, so a saved ProseMirror range is no longer a safe
* write target by itself. We bind the candidate to both the complete document
* and the selected text before allowing a destructive replacement.
*/
export interface RewriteSelectionSnapshot extends WritingSelection {
documentFingerprint: string;
}
/** A small deterministic fingerprint is sufficient for stale-candidate checks. */
function fingerprintDocument(editor: Editor): string {
const serialized = JSON.stringify(editor.getJSON());
let hash = 0x811c9dc5;
for (let index = 0; index < serialized.length; index += 1) {
hash ^= serialized.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16);
}
export function createRewriteSelectionSnapshot(
editor: Editor,
selection: WritingSelection,
): RewriteSelectionSnapshot {
return {
...selection,
documentFingerprint: fingerprintDocument(editor),
};
}
export function isRewriteSelectionSnapshotCurrent(
editor: Editor,
snapshot: RewriteSelectionSnapshot,
): boolean {
const documentSize = editor.state.doc.content.size;
if (snapshot.from < 0 || snapshot.to < snapshot.from || snapshot.to > documentSize) {
return false;
}
const selectedText = editor.state.doc.textBetween(snapshot.from, snapshot.to, "\n");
return (
selectedText === snapshot.text &&
fingerprintDocument(editor) === snapshot.documentFingerprint
);
}
/** Convert plain text (possibly multi-paragraph) to a list of Tiptap paragraph nodes. */
function textToNodes(schema: Editor["schema"], text: string) {
const paragraphs = text.split(/\n{2,}/);
+54 -14
View File
@@ -46,6 +46,8 @@ import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import {
isFrontendRagProfile,
isMultiRoundRagProfile,
isSingleRoundRagProfile,
SINGLE_ROUND_RAG_MODELS,
} from "@/core/models/single-round-rag";
@@ -59,6 +61,7 @@ import {
import { useThreadStream } from "@/core/threads/hooks";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag";
import { useLayoutMode } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base";
import { textOfMessage } from "@/core/threads/utils";
@@ -310,19 +313,38 @@ export default function ChatPage() {
},
});
const singleRoundRag = useSingleRoundRagMessages(threadId);
const multiRoundRag = useMultiRoundRagMessages(threadId);
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
const frontendRagSelected = isFrontendRagProfile(settings.context);
const displayThread = useMemo(() => {
if (singleRoundRag.messages.length === 0 && !singleRoundRag.isLoading) {
if (
singleRoundRag.messages.length === 0 &&
multiRoundRag.messages.length === 0 &&
!singleRoundRag.isLoading &&
!multiRoundRag.isLoading
) {
return thread;
}
const messages = [...thread.messages, ...singleRoundRag.messages];
const messages = [
...thread.messages,
...singleRoundRag.messages,
...multiRoundRag.messages,
];
return {
...thread,
values: { ...thread.values, messages },
messages,
isLoading: thread.isLoading || singleRoundRag.isLoading,
isLoading:
thread.isLoading || singleRoundRag.isLoading || multiRoundRag.isLoading,
} as typeof thread;
}, [singleRoundRag.isLoading, singleRoundRag.messages, thread]);
}, [
multiRoundRag.isLoading,
multiRoundRag.messages,
singleRoundRag.isLoading,
singleRoundRag.messages,
thread,
]);
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
@@ -337,9 +359,9 @@ export default function ChatPage() {
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
if (singleRoundRagSelected) {
if (frontendRagSelected) {
if (message.files && message.files.length > 0) {
toast.error("单轮 RAG 问答暂不支持附件,请移除附件后重试。");
toast.error("RAG 问答暂不支持附件,请移除附件后重试。");
return;
}
setHasStartedConversation(true);
@@ -366,13 +388,23 @@ export default function ChatPage() {
.catch(() => undefined);
}
}
await singleRoundRag.submit({
targetThreadId,
query: message.text,
modelName: settings.context.model_name ?? "OpenAI3",
});
if (multiRoundRagSelected) {
await multiRoundRag.submit({ targetThreadId, query: message.text });
} else {
await singleRoundRag.submit({
targetThreadId,
query: message.text,
modelName: settings.context.model_name ?? "OpenAI3",
});
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "单轮 RAG 问答失败。");
toast.error(
error instanceof Error
? error.message
: multiRoundRagSelected
? "多轮 RAG 问答失败。"
: "单轮 RAG 问答失败。",
);
}
})();
return;
@@ -483,6 +515,9 @@ export default function ChatPage() {
writingArtifactPath,
singleRoundRag,
singleRoundRagSelected,
multiRoundRag,
multiRoundRagSelected,
frontendRagSelected,
routeBase,
setIsNewThread,
setThreadId,
@@ -580,8 +615,12 @@ export default function ChatPage() {
singleRoundRag.stop();
return;
}
if (multiRoundRag.isLoading) {
multiRoundRag.stop();
return;
}
await thread.stop();
}, [singleRoundRag, thread]);
}, [multiRoundRag, singleRoundRag, thread]);
const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined;
@@ -782,7 +821,8 @@ export default function ChatPage() {
isUploading ||
agentHandoffPending ||
knowledgeSelectionLoading ||
singleRoundRag.isLoading
singleRoundRag.isLoading ||
multiRoundRag.isLoading
}
onContextChange={(context) =>
setSettings("context", context)
File diff suppressed because it is too large. Load diff
@@ -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>
);
}
+25
View File
@@ -3,6 +3,7 @@ import { Toaster } from "sonner";
import { SidebarProvider } from "@/components/ui/sidebar";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
import { RouteBaseProvider } from "@/core/route-base";
import GeneralQA from "@/strategy-components/components/chat-qa/GeneralQA";
import SentimentAnalyzes from "@/strategy-components/components/task-components/SentimentAnalyzes";
@@ -24,10 +25,12 @@ import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanni
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage";
import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage";
import DeepResearchPage from "./DeepResearchPage";
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
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";
@@ -113,6 +116,7 @@ export default function StrategyRoutes() {
</ChatRuntime>
}
/>
<Route path="qa/deep-research" element={<DeepResearchPage />} />
{/* 大屏绘制智能体独立页面(按 taskId 存取;ChatRuntime 提供 ArtifactsProvider 给沙箱预览) */}
<Route
@@ -309,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 />}
+43 -3
View File
@@ -2,6 +2,7 @@ import type { CSSProperties, ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { getStoredAuth } from '@/core/auth';
import { isSentimentAgentVisible } from '@/core/sentiment-agent/config';
import {
resolveChatsIframeEntryRedirect,
resolveStandardChatFallback,
@@ -21,6 +22,7 @@ import { CommandPalette } from '@/components/workspace/command-palette';
import { Toaster } from 'sonner';
import { SubtasksProvider } from '@/core/tasks/context';
import { ArtifactsProvider } from '@/components/workspace/artifacts';
import { DocumentRewritePresentationProvider } from '@/core/document-rewrite/context';
import { RouteBaseProvider } from '@/core/route-base';
@@ -29,6 +31,7 @@ import ChatPage from './ChatPage';
import IframeChatPage from './IframeChatPage';
import AgentsPage from './AgentsPage';
import AgentChatPage from './AgentChatPage';
import SentimentAgentChatPage from './SentimentAgentChatPage';
import NewAgentPage from './NewAgentPage';
import AdminAgentsPage from './AdminAgentsPage';
import AdminScheduledTasksPage from './AdminScheduledTasksPage';
@@ -58,6 +61,7 @@ import PositionRolesPage from './PositionRolesPage';
import PluginManagementPage from './PluginManagementPage';
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import DeepResearchPage from './DeepResearchPage';
import StudioNotebooksPage from './StudioNotebooksPage';
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
@@ -210,9 +214,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 +474,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 />}
@@ -522,6 +552,16 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="deep-research"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<DeepResearchPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks"
element={
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,
};
}
@@ -138,6 +138,52 @@ const PRIMARY_API_PATH = "/api/position-roundtable";
const COMPAT_API_PATH = "/api/multi-agent/position-roundtable";
const BASE = () => `${getBackendBaseURL()}${PRIMARY_API_PATH}`;
/**
* 生成一次写操作的幂等键(command_id)。
*
* 后端命令式状态机用 ``UNIQUE(session_id, node_key, command_id)`` 保证同一 command_id
* 只执行一次。把它在**一次调用入口**生成并固定进请求体,使得 `positionApiFetch` 的
* 主/兼容路由回退重试复用同一 command_id —— 网络层重发绝不会把同一操作执行两遍
* (不会重复 +revision / 重复解锁下游 / 重复驳回)。每次新的用户操作则取新键。
*/
function newCommandId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
}
/**
* 用**同一个 command_id** 重试一个写操作至多 ``attempts`` 次(指数退避)。
*
* 后端命令幂等表保证同 command_id + 同参数只执行一次:即便第一次其实已提交成功、
* 只是响应在网络层丢失,重试也只会重放上次结果,而不会重复推进状态机(重复
* +revision / 重复解锁下游)。因此「提交产物给下一阶段」这类关键写操作可以放心
* 重试,把偶发网络错误/超时变成自愈,而不是让下一阶段永远拿不到上游产物。
*
* 返回 ``{ ok, result?, error? }``;``ok=false`` 时 ``error`` 是最后一次失败。
*/
export async function retryWithSameCommandId<T>(
operation: (commandId: string) => Promise<T>,
options?: { attempts?: number; baseDelayMs?: number },
): Promise<{ ok: true; result: T } | { ok: false; error: unknown }> {
const attempts = Math.max(1, options?.attempts ?? 3);
const baseDelay = options?.baseDelayMs ?? 400;
const commandId = newCommandId();
let lastError: unknown = null;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
return { ok: true, result: await operation(commandId) };
} catch (error) {
lastError = error;
if (attempt < attempts - 1) {
await new Promise((resolve) => setTimeout(resolve, baseDelay * 2 ** attempt));
}
}
}
return { ok: false, error: lastError };
}
/**
* Some intranet gateways only forward the long-established `/api/multi-agent`
* namespace. The position workspace was added later under its own top-level
@@ -383,7 +429,7 @@ export async function activatePositionSession(
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chain_id: chainId }),
body: JSON.stringify({ chain_id: chainId, command_id: newCommandId() }),
}),
"activate position session",
);
@@ -417,6 +463,7 @@ export async function bindPositionNodeThread(
sessionId: string,
nodeKey: string,
threadId: string,
commandId?: string,
): Promise<PositionRoundtableNode> {
const result = await readJson<ServerNode>(
await positionApiFetch(
@@ -424,7 +471,7 @@ export async function bindPositionNodeThread(
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ thread_id: threadId }),
body: JSON.stringify({ thread_id: threadId, command_id: commandId ?? newCommandId() }),
},
),
"bind position node thread",
@@ -438,6 +485,7 @@ export async function savePositionNodeConversation(
input: {
threadId?: string | null;
conversationSnapshot: PositionConversationSnapshot;
commandId?: string;
},
): Promise<PositionRoundtableNode> {
const result = await readJson<ServerNode>(
@@ -449,6 +497,7 @@ export async function savePositionNodeConversation(
body: JSON.stringify({
thread_id: input.threadId || null,
conversation_snapshot: input.conversationSnapshot,
command_id: input.commandId ?? newCommandId(),
}),
},
),
@@ -533,6 +582,8 @@ export async function completePositionNodeTurn(
latestAnswer: string;
artifactManifest?: Array<Record<string, unknown>>;
upstreamRevision?: Record<string, unknown> | null;
/** Retry-stable idempotency key; omit to auto-generate per call. */
commandId?: string;
},
): Promise<PositionRoundtableNode> {
// 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。
@@ -546,6 +597,7 @@ export async function completePositionNodeTurn(
latest_answer: input.latestAnswer,
artifact_manifest: input.artifactManifest ?? [],
upstream_revision: input.upstreamRevision ?? null,
command_id: input.commandId ?? newCommandId(),
}),
},
),
@@ -557,7 +609,7 @@ export async function completePositionNodeTurn(
export async function rejectPositionNode(
sessionId: string,
nodeKey: string,
input: { reason?: string; title?: string },
input: { reason?: string; title?: string; commandId?: string },
): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await positionApiFetch(
@@ -570,6 +622,7 @@ export async function rejectPositionNode(
// workspace deliberately sends only an optional reason.
...(input.title ? { title: input.title } : {}),
reason: input.reason ?? "",
command_id: input.commandId ?? newCommandId(),
}),
},
),
@@ -27,6 +27,7 @@ import {
bindPositionNodeThread,
completePositionNodeTurn,
getPositionNodeContext,
retryWithSameCommandId,
savePositionNodeConversation,
type PositionRoundtableNode,
} from "@/position-roundtable/api/sessions";
@@ -530,17 +531,26 @@ export function PositionNodeChat({
// Artifact listing is best-effort: a valid text answer must still be
// persisted and unlock downstream work even if files cannot be read.
}
try {
const updated = await completePositionNodeTurn(sessionId, node.nodeKey, {
// 这是「岗位完成问答 → 把产物提交给下一阶段」的关键路径。偶发网络错误/超时
// 不能让它悄悄失败(否则下一阶段永远拿不到上游产物):用同一个 command_id
// 重试,后端命令幂等表保证即使第一次其实已提交成功,重试也只重放结果,
// 绝不重复推进状态机(不会重复 +revision / 重复解锁下游)。
const delivery = await retryWithSameCommandId((commandId) =>
completePositionNodeTurn(sessionId, node.nodeKey, {
latestAnswer,
artifactManifest,
upstreamRevision: upstreamRevisionRef.current,
});
commandId,
}),
);
if (delivery.ok) {
const updated = delivery.result;
onNodeUpdate(updated);
if (updated.status !== "done" && !hasMarkdownArtifact(artifactManifest)) {
toast.info("已保存本轮回答;该席位需生成并交付一份 Markdown 报告文档后才会标记完成。");
}
} catch (error) {
} else {
const error = delivery.error;
toast.error(`节点结果保存失败:${error instanceof Error ? error.message : String(error)}`);
}
})();
@@ -665,14 +675,19 @@ export function PositionNodeChat({
[messageId]: textOfMessage(updatedMessage) ?? content,
}));
if (updatedMessage.type === "ai" && messageId === latestAiMessageId) {
try {
const updatedNode = await completePositionNodeTurn(sessionId, node.nodeKey, {
// 与自动交付路径同样:同一 command_id 重试,幂等保证不重复推进状态机。
const sync = await retryWithSameCommandId((commandId) =>
completePositionNodeTurn(sessionId, node.nodeKey, {
latestAnswer: content,
artifactManifest: node.artifactManifest,
upstreamRevision: node.upstreamRevision,
});
onNodeUpdate(updatedNode);
} catch (syncError) {
commandId,
}),
);
if (sync.ok) {
onNodeUpdate(sync.result);
} else {
const syncError = sync.error;
toast.warning(
`消息已保存,但节点交接摘要同步失败:${
syncError instanceof Error ? syncError.message : String(syncError)
@@ -189,6 +189,11 @@ export interface DraftMeta {
/** Auto-derived task summary (objective → first user message → timestamp). */
title: string;
furthestStep: 1 | 2 | 3;
/**
* 乐观锁版本号:写回时经 `expectedVersion` 回传;并发冲突(409)时用它识别
* 本地副本已过期,重拉最新草稿合并后重试。
*/
version: number;
/**
* 本次会商所用业务链名(历史卡片展示)。无业务链(自由/recommend 模式)→ null/undefined,
* 由前端展示为「自由模式」。后端从 step2 快照里抽取(见 roundtable_drafts/sql.py)。
@@ -234,6 +239,33 @@ export interface DraftUpdatePayload {
step1?: DraftStep1Snapshot;
step2?: DraftStep2Snapshot;
step3?: DraftStep3Snapshot | null;
/**
* 本地副本基于的草稿版本号(乐观锁)。提供时服务端做原子条件 UPDATE;
* 版本不匹配 → 409(`DraftVersionConflictError`),调用方应重拉合并后重试。
* 省略 = 盲写(向后兼容旧调用点,不推荐)。
*/
expectedVersion?: number;
}
/**
* 乐观锁冲突(HTTP 409):服务端草稿版本比本地副本新。
* `currentVersion` 是服务端最新版本,调用方应重拉最新草稿、合并本地改动后用
* 新版本号重试——一致性来自 DB,不靠前端运气。
*/
export class DraftVersionConflictError extends Error {
readonly draftId: string;
readonly currentVersion: number | null;
constructor(draftId: string, currentVersion: number | null) {
super(`Draft ${draftId} was modified concurrently (server version ${currentVersion})`);
this.name = "DraftVersionConflictError";
this.draftId = draftId;
this.currentVersion = currentVersion;
}
}
export function isDraftVersionConflictError(error: unknown): error is DraftVersionConflictError {
return error instanceof DraftVersionConflictError;
}
const BASE = () => `${getBackendBaseURL()}/api/roundtable-drafts`;
@@ -256,6 +288,7 @@ export interface ServerDraftMeta {
task_id?: string | null;
title: string;
furthest_step: number;
version?: number;
chain_title?: string | null;
created_at: string | null;
updated_at: string | null;
@@ -276,6 +309,7 @@ export function toMeta(s: ServerDraftMeta): DraftMeta {
taskId: s.task_id ?? null,
title: s.title ?? "",
furthestStep: step,
version: s.version ?? 0,
chainTitle: s.chain_title ?? null,
createdAt: s.created_at ?? "",
updatedAt: s.updated_at ?? "",
@@ -313,6 +347,7 @@ export function fromUpdate(p: DraftUpdatePayload) {
if (p.step1 !== undefined) out.step1 = p.step1;
if (p.step2 !== undefined) out.step2 = p.step2;
if (p.step3 !== undefined) out.step3 = p.step3;
if (p.expectedVersion !== undefined) out.expected_version = p.expectedVersion;
return out;
}
@@ -366,6 +401,23 @@ export async function createDraft(payload: DraftCreatePayload): Promise<DraftRec
return record;
}
/**
* 把 409 乐观锁冲突转成类型化错误(解析服务端最新版本号)。个人与任务草稿客户端共用,
* 保证两条 PUT 路径的冲突语义完全一致。非 409 原样放行。
*/
export async function throwIfDraftConflict(res: Response, id: string): Promise<void> {
if (res.status !== 409) return;
const raw = await res.text().catch(() => "");
let currentVersion: number | null = null;
try {
const parsed = JSON.parse(raw) as { detail?: { current_version?: number } };
currentVersion = parsed?.detail?.current_version ?? null;
} catch {
currentVersion = null;
}
throw new DraftVersionConflictError(id, currentVersion);
}
export async function updateDraft(id: string, patch: DraftUpdatePayload): Promise<DraftRecord | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
method: "PUT",
@@ -373,6 +425,7 @@ export async function updateDraft(id: string, patch: DraftUpdatePayload): Promis
body: JSON.stringify(fromUpdate(patch)),
});
if (res.status === 404) return null;
await throwIfDraftConflict(res, id);
const data = await readJson<ServerDraftFull>(res, "update draft");
const record = toRecord(data);
writeYLog({
@@ -190,6 +190,12 @@ export interface StreamIntentRequest {
model?: string;
/** Explicit built-in Step-1 agent. Omit to retain the multi-agent default. */
intentAgentId?: string;
/**
* 本回合幂等键(Phase 4 单飞):同一 (threadId, clientTurnId) 在后端至多一个 run。
* 缺省由调用方(useStep1Intent)每条消息生成;同一回合的重试 / 双击复用它,后端据此
* 去重(在跑→丢弃重复请求 / 已完成→幂等回放),杜绝并发下双发意图 / 重复澄清卡。
*/
clientTurnId?: string;
/** Optional hidden business/task context injected into the model run. */
taskContext?: Record<string, unknown> | null;
/** Step 1 用户上传的参考文件(可选)。 */
@@ -403,6 +409,8 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
model: req.model,
intent_agent_id: req.intentAgentId || undefined,
task_context: req.taskContext ?? undefined,
// Phase 4 单飞幂等键:缺省由后端兜底生成,但调用方应始终传(useStep1Intent 每条消息生成)。
client_turn_id: req.clientTurnId || undefined,
// 仅在有文件时带上,空数组后端也安全忽略。
files: req.files && req.files.length > 0 ? req.files : undefined,
}),
@@ -11,6 +11,8 @@
* 增量事件归并。
*/
import { nanoid } from "nanoid";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
@@ -87,6 +89,9 @@ function toStartBody(p: StartJobPayload) {
// 缺省(未选模式)时不带这三字段 → 后端按 policy 默认(≈pro),向后兼容。
const seat = p.seatMode ? seatModeToRunParams(p.seatMode) : null;
return {
// 幂等请求 id:缺省现生成;「同一草稿至多一个活跃作业」由后端作用域唯一键保证,
// requestId 仅作审计/排查线索(重试时复用可让后端识别「同 id 但入参变了」的异常重试)。
requestId: p.requestId ?? nanoid(),
draftId: p.draftId ?? null,
// taskId 非空 → task 作业(按 task 共享、不分权,报告写回独立 task-draft 存储)。
...(p.taskId ? { taskId: p.taskId } : {}),
@@ -18,6 +18,7 @@ import {
fromCreate,
fromUpdate,
readJson,
throwIfDraftConflict,
toMeta,
toRecord,
type DraftCreatePayload,
@@ -103,6 +104,7 @@ export async function updateTaskDraft(id: string, patch: DraftUpdatePayload): Pr
body: JSON.stringify(fromUpdate(patch)),
});
if (res.status === 404) return null;
await throwIfDraftConflict(res, id);
const data = await readJson<ServerDraftFull>(res, "update task draft");
const record = toRecord(data);
writeYLog({
@@ -24,10 +24,12 @@ import {
deleteDraft,
deriveDraftName,
getDraft,
isDraftVersionConflictError,
listDrafts,
updateDraft,
type DraftCreatePayload,
type DraftRecord,
type DraftStep2RunSnapshot,
type DraftStep2Snapshot,
type DraftStep3Snapshot,
type DraftUpdatePayload,
@@ -42,6 +44,34 @@ import {
} from "@/roundtable-planning/api/task-drafts";
import type { JobStatus } from "@/roundtable-planning/lib/job-types";
/**
* 409 冲突后的 step2 合并:以**服务端**为基准,把本地 runs 按 id upsert 回去。
*
* 这样既保住并发写手(后台作业 / 另一个标签页)新增的 runs,又保留本标签页刚产出的
* run 内容。activeRunId 若本地显式有值则以本地为准(本标签页正在看的 run),否则保留
* 服务端值。其余 step2 顶层字段以本地为准(本地是最新交互态)。一致性来自 DB 版本号,
* 合并只是把双方都不丢地拼回来。
*/
function mergeStep2OnConflict(
local: DraftStep2Snapshot,
server: DraftStep2Snapshot | null,
): DraftStep2Snapshot {
if (!server) return local;
const byId = new Map<string, DraftStep2RunSnapshot>();
for (const run of server.runs ?? []) {
if (run?.id) byId.set(run.id, run);
}
for (const run of local.runs ?? []) {
if (run?.id) byId.set(run.id, run); // 本地覆盖同 id 的服务端 run
}
return {
...server,
...local,
runs: Array.from(byId.values()),
activeRunId: local.activeRunId ?? server.activeRunId,
};
}
/** 历史下拉渲染用的轻量列表项(`name` 映射自后端 `title`)。 */
export interface DraftListItem {
id: string;
@@ -198,6 +228,10 @@ export function useDraftPersistence(
// 同步镜像,供保存队列在 render 之前读到最新 id(修复重复建草稿竞态)。
const currentDraftIdRef = useRef<string | null>(null);
// 当前草稿的乐观锁版本号(本地副本基于的 server version)。update 时经 expectedVersion
// 回传;冲突(409)后重拉最新草稿、合并本地改动、用新版本号重试。load/create/update 成功
// 后同步刷新,保证下一次写基于最新版本。
const draftVersionRef = useRef<number | null>(null);
// 串行化所有写操作,避免并发 create。
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
// 当前步骤镜像 —— 自动保存(不带 furthestStep)落到 create 分支时,用它兜底建档步骤。
@@ -207,10 +241,12 @@ export function useDraftPersistence(
}, [currentStep]);
// 包一层 setter:同时更新 state 与同步 ref。主页面通过它 setCurrentDraftId(null)。
// 切换到不同草稿(或清空)时把乐观锁版本一并复位,防止拿旧草稿的版本号去写新草稿。
const setCurrentDraftId = useCallback<React.Dispatch<React.SetStateAction<string | null>>>(
(action) => {
setCurrentDraftIdState((prev) => {
const next = typeof action === "function" ? (action as (p: string | null) => string | null)(prev) : action;
if (next !== currentDraftIdRef.current) draftVersionRef.current = null;
currentDraftIdRef.current = next;
return next;
});
@@ -273,14 +309,40 @@ export function useDraftPersistence(
const id = currentDraftIdRef.current;
if (id) {
const updated = await apiUpdate(id, {
...(furthestStep !== undefined ? { furthestStep } : {}),
step1,
step2,
...(step3 ? { step3 } : {}),
});
if (updated) return updated.id;
// 服务端已不存在(被删/换账号)→ 落回新建。
try {
const updated = await apiUpdate(id, {
...(furthestStep !== undefined ? { furthestStep } : {}),
step1,
step2,
...(step3 ? { step3 } : {}),
...(draftVersionRef.current !== null ? { expectedVersion: draftVersionRef.current } : {}),
});
if (updated) {
draftVersionRef.current = updated.version;
return updated.id;
}
// 服务端已不存在(被删/换账号)→ 落回新建。
} catch (err) {
if (!isDraftVersionConflictError(err)) throw err;
// 乐观锁冲突:另一写手(后台作业 / 另一标签页 / 重命名)已推进版本。
// 重拉最新草稿,把本地 step2.runs 合并回去,用新版本号重试一次——双方都不丢。
const latest = await apiGet(id);
if (latest) {
const mergedStep2 = mergeStep2OnConflict(step2, latest.step2);
const retried = await apiUpdate(id, {
...(furthestStep !== undefined ? { furthestStep } : {}),
step1,
step2: mergedStep2,
...(step3 ? { step3 } : {}),
expectedVersion: latest.version,
});
if (retried) {
draftVersionRef.current = retried.version;
return retried.id;
}
}
// latest 为 null(被删)或重试仍 404 → 落回新建。
}
}
const created = await apiCreate({
...(taskIdRef.current ? { taskId: taskIdRef.current } : {}),
@@ -294,6 +356,7 @@ export function useDraftPersistence(
...(step3 ? { step3 } : {}),
});
currentDraftIdRef.current = created.id;
draftVersionRef.current = created.version;
setCurrentDraftId(created.id);
return created.id;
};
@@ -301,7 +364,7 @@ export function useDraftPersistence(
saveQueueRef.current = next.catch(() => undefined);
return next as Promise<string | null>;
},
[apiCreate, apiUpdate, getStep1Snapshot, getStep2Snapshot, getStep3Snapshot, hasSavableContent, setCurrentDraftId],
[apiCreate, apiGet, apiUpdate, getStep1Snapshot, getStep2Snapshot, getStep3Snapshot, hasSavableContent, setCurrentDraftId],
);
const ensureDraft = useCallback(
@@ -340,12 +403,35 @@ export function useDraftPersistence(
const run = async (): Promise<string | null> => {
const id = currentDraftIdRef.current;
if (id) {
const updated = await apiUpdate(id, { furthestStep, step2 });
if (updated) {
currentDraftIdRef.current = updated.id;
return updated.id;
try {
const updated = await apiUpdate(id, {
furthestStep,
step2,
...(draftVersionRef.current !== null ? { expectedVersion: draftVersionRef.current } : {}),
});
if (updated) {
currentDraftIdRef.current = updated.id;
draftVersionRef.current = updated.version;
return updated.id;
}
// 服务端已不存在 → 落回新建。
} catch (err) {
if (!isDraftVersionConflictError(err)) throw err;
// 冲突 → 重拉合并 runs 后用新版本号重试一次(与 enqueueSave 同策略)。
const latest = await apiGet(id);
if (latest) {
const retried = await apiUpdate(id, {
furthestStep,
step2: mergeStep2OnConflict(step2, latest.step2),
expectedVersion: latest.version,
});
if (retried) {
currentDraftIdRef.current = retried.id;
draftVersionRef.current = retried.version;
return retried.id;
}
}
}
// 服务端已不存在 → 落回新建。
}
const step1 = getStep1Snapshot();
if (!hasSavableContent(step1, step2)) return null;
@@ -360,6 +446,7 @@ export function useDraftPersistence(
step2,
});
currentDraftIdRef.current = created.id;
draftVersionRef.current = created.version;
setCurrentDraftId(created.id);
return created.id;
};
@@ -367,7 +454,7 @@ export function useDraftPersistence(
saveQueueRef.current = next.catch(() => undefined);
return next as Promise<string | null>;
},
[apiCreate, apiUpdate, getStep1Snapshot, hasSavableContent, setCurrentDraftId],
[apiCreate, apiGet, apiUpdate, getStep1Snapshot, hasSavableContent, setCurrentDraftId],
);
const handleManualSave = useCallback(() => {
@@ -463,6 +550,8 @@ export function useDraftPersistence(
setStep2InitGate(draft.furthestStep >= 2);
setCurrentDraftId(draft.id);
// 必须在 setCurrentDraftId 之后:切换草稿会先把版本复位,这里再打上加载到的版本。
draftVersionRef.current = draft.version;
setIsHistoryOpen(false);
prevStepRef.current = draft.furthestStep;
setCurrentStep(draft.furthestStep);
@@ -563,8 +652,12 @@ export function useDraftPersistence(
void (async () => {
try {
// 只发 title —— 与自动保存的「不带 title」互补,重命名是唯一改标题的入口。
// 不带 expectedVersion:部分更新不触碰内容字段,与自动保存并发时双方各写各的
// 字段;自动保存侧撞 409 会重拉合并重试(标题被保留),一致性由 DB 版本保证。
const updated = await apiUpdate(id, { title: trimmed });
if (updated) {
// 重命名的正是当前草稿 → 同步版本号,省掉下一次自动保存的一次 409 往返。
if (id === currentDraftIdRef.current) draftVersionRef.current = updated.version;
await refreshList();
triggerToast("已重命名");
}
@@ -447,12 +447,19 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
}
}
// Phase 4 单飞幂等键:每个回合一个稳定 id。后端据此对 (threadId, clientTurnId)
// 跨 worker 单飞——重复 / 双击命中既有回合即幂等返回,杜绝并发下双发意图。
const clientTurnId =
(typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID()) ||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const res = await streamIntent({
threadId: intentThreadId,
message: trimmed,
model: selectedModelRef.current || undefined,
intentAgentId: intentAgentIdRef.current,
taskContext: taskContextRef.current,
clientTurnId,
files: fileRefs,
signal: aborter.signal,
onTextDelta: (delta) => {
@@ -153,6 +153,13 @@ export interface StartJobPayload {
* 普通会商省略。`toStartBody` 里下发为后端 `business_code`。
*/
businessCode?: string | null;
/**
* 幂等请求 id(Phase 3 并发根治)。缺省由 `toStartBody` 用 nanoid 现生成;调用方若想在
* 逻辑重试间复用同一 id(便于后端审计「同 requestId 但入参变了」的异常重试),可显式传入。
* 「同一草稿至多一个活跃作业」的真正互斥由后端按 `(draftId/userId/taskId)` 作用域的唯一活跃键
* 保证,requestId 仅作审计/排查线索,不承担正确性。
*/
requestId?: string;
}
/** 终态集合——SSE/轮询判定作业是否结束。 */
File diff suppressed because it is too large. Load diff