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")}