代码提交
This commit is contained in:
1 parent
70da7c5649
commit
178572bc7f
79 files changed
+13456
-798
No files matched your search
@@ -16,9 +16,11 @@ import { getStoredAuth } from "@/core/auth";
|
||||
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
||||
import type { Agent, AgentSort } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
|
||||
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
|
||||
|
||||
import { AgentCard } from "./agent-card";
|
||||
import { SentimentAgentCard } from "./sentiment-agent-card";
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
@@ -30,6 +32,7 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
|
||||
export function AgentGallery() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const sentimentAgentVisible = isSentimentAgentVisible();
|
||||
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [viewMode, setViewMode] = useViewMode("agent");
|
||||
@@ -41,6 +44,9 @@ export function AgentGallery() {
|
||||
// Each configured publish square (发布广场) becomes its own browse tab; the
|
||||
// backend always returns at least one (the built-in default).
|
||||
const squareTabs = publishSquares?.squares ?? [];
|
||||
// 默认广场固定排在「我的」之后;舆情分析是依附在默认广场后的前端虚拟类型,
|
||||
// 其余自定义广场继续保持后端给出的顺序。
|
||||
const [defaultSquare, ...additionalSquares] = squareTabs;
|
||||
// Publisher filter: click a card's 发布者 to keep only that user's agents.
|
||||
// {id} is the owner user_id (server filter), {label} the desensitized name.
|
||||
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
|
||||
@@ -70,6 +76,7 @@ export function AgentGallery() {
|
||||
activeTab !== "mine" &&
|
||||
activeTab !== "private" &&
|
||||
activeTab !== "builtin" &&
|
||||
activeTab !== "sentiment" &&
|
||||
squareTabs.length > 0 &&
|
||||
!squareTabs.some((sq) => sq.id === activeTab)
|
||||
) {
|
||||
@@ -90,9 +97,13 @@ export function AgentGallery() {
|
||||
|
||||
// Only the active tab fetches. "广场" maps to the server "square" scope
|
||||
// (built-in/published, never private), "我的" to "mine" (owned + favorited),
|
||||
// "内置" to "builtin" (admin-only, user_id IS NULL).
|
||||
// "内置" to "builtin" (admin-only, user_id IS NULL). 「舆情分析」是前端虚拟
|
||||
// 类型,不查后端列表。
|
||||
const isSquareTab =
|
||||
activeTab !== "mine" && activeTab !== "private" && activeTab !== "builtin";
|
||||
activeTab !== "mine" &&
|
||||
activeTab !== "private" &&
|
||||
activeTab !== "builtin" &&
|
||||
activeTab !== "sentiment";
|
||||
const mainScope =
|
||||
activeTab === "mine"
|
||||
? "mine"
|
||||
@@ -112,7 +123,7 @@ export function AgentGallery() {
|
||||
sort,
|
||||
page: mainPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
enabled: activeTab !== "private",
|
||||
enabled: activeTab !== "private" && activeTab !== "sentiment",
|
||||
owner: publisher?.id,
|
||||
// Each square tab fetches only that square's agents.
|
||||
square: isSquareTab ? activeTab : undefined,
|
||||
@@ -148,9 +159,16 @@ export function AgentGallery() {
|
||||
// password has been verified. Until then we show the gate (or the lock
|
||||
// prompt) and hide the keyword/tag search bar — there is no list to filter.
|
||||
const privateUnlocked = privateFeatureUsable && !!privatePassword;
|
||||
const showSearchBar = activeTab !== "private" || privateUnlocked;
|
||||
const showSearchBar =
|
||||
activeTab !== "sentiment" &&
|
||||
(activeTab !== "private" || privateUnlocked);
|
||||
|
||||
const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
|
||||
const activeTotal =
|
||||
activeTab === "sentiment"
|
||||
? 1
|
||||
: activeTab === "private"
|
||||
? privateTotal
|
||||
: mainTotal;
|
||||
const setTabPage = (tab: string, page: number) =>
|
||||
setPages((prev) => ({ ...prev, [tab]: page }));
|
||||
|
||||
@@ -398,7 +416,22 @@ export function AgentGallery() {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start">
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
{squareTabs.map((sq) => (
|
||||
{defaultSquare && (
|
||||
<TabsTrigger
|
||||
key={defaultSquare.id}
|
||||
value={defaultSquare.id}
|
||||
title={defaultSquare.description || defaultSquare.name}
|
||||
>
|
||||
{defaultSquare.name}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{sentimentAgentVisible && (
|
||||
/* 舆情分析:前端虚拟类型,直连外部舆情问答接口。 */
|
||||
<TabsTrigger value="sentiment" title="舆情分析智能体(外部接口对接)">
|
||||
舆情分析
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{additionalSquares.map((sq) => (
|
||||
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
|
||||
{sq.name}
|
||||
</TabsTrigger>
|
||||
@@ -512,6 +545,25 @@ export function AgentGallery() {
|
||||
})}
|
||||
</TabsContent>
|
||||
|
||||
{/* 舆情分析类型:固定一张虚拟智能体卡片(名称/简介可编辑),
|
||||
接口地址与鉴权在 runtime-config.js 配置。 */}
|
||||
{sentimentAgentVisible && (
|
||||
<TabsContent
|
||||
value="sentiment"
|
||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||
>
|
||||
{viewMode === "list" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<SentimentAgentCard layout="list" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<SentimentAgentCard />
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
{renderListBody({
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, MessageSquareIcon, Radio, SquarePenIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { SENTIMENT_AGENT_ID } from "@/core/sentiment-agent/config";
|
||||
import { readSentimentAgentProfile } from "@/core/sentiment-agent/profile";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SentimentAgentProfileDialog } from "./sentiment-agent-profile-dialog";
|
||||
|
||||
/**
|
||||
* 「舆情分析」类型下的虚拟智能体卡片:只存在于前端(不落库 /api/agents),
|
||||
* 对话直连外部 AG-UI 网关。名称 / 简介可编辑(localStorage),
|
||||
* 视觉与普通智能体卡片保持一致(网格 / 列表两种布局)。
|
||||
*/
|
||||
export function SentimentAgentCard({
|
||||
layout = "grid",
|
||||
className,
|
||||
}: {
|
||||
layout?: "grid" | "list";
|
||||
className?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [profile, setProfile] = useState(() => readSentimentAgentProfile());
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
// 编辑弹窗也可能挂在对话页,保存后广播事件,这里即时刷新卡片。
|
||||
useEffect(() => {
|
||||
const onChange = () => setProfile(readSentimentAgentProfile());
|
||||
window.addEventListener("sentiment-agent-profile-changed", onChange);
|
||||
return () =>
|
||||
window.removeEventListener("sentiment-agent-profile-changed", onChange);
|
||||
}, []);
|
||||
|
||||
const handleChat = () => {
|
||||
router.push(`/page/workspace/agents/${SENTIMENT_AGENT_ID}/chats/new`);
|
||||
};
|
||||
|
||||
const editButton = (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setEditOpen(true)}
|
||||
title="编辑名称与简介"
|
||||
aria-label="编辑名称与简介"
|
||||
>
|
||||
<SquarePenIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
const chatButton = (
|
||||
<Button
|
||||
size="sm"
|
||||
className={cn(layout === "list" ? "shrink-0" : "w-full")}
|
||||
onClick={handleChat}
|
||||
>
|
||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
对话
|
||||
</Button>
|
||||
);
|
||||
|
||||
const badge = (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Radio className="size-3" />
|
||||
舆情分析
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const dialog = (
|
||||
<SentimentAgentProfileDialog
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
if (layout === "list") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<BotIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="min-w-0 truncate text-sm font-medium"
|
||||
title={desensitize(profile.name)}
|
||||
>
|
||||
{desensitize(profile.name)}
|
||||
</span>
|
||||
{badge}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 truncate text-xs">
|
||||
{desensitize(profile.description)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{editButton}
|
||||
{chatButton}
|
||||
</div>
|
||||
{dialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"group flex min-w-0 flex-col transition-shadow hover:shadow-md",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<CardHeader className="min-w-0 pb-2">
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<BotIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<CardTitle
|
||||
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
|
||||
title={desensitize(profile.name)}
|
||||
>
|
||||
{desensitize(profile.name)}
|
||||
</CardTitle>
|
||||
<div className="mt-0.5">{badge}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end gap-1">
|
||||
{editButton}
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription className="mt-2 line-clamp-2 text-sm">
|
||||
{desensitize(profile.description)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-2">
|
||||
{chatButton}
|
||||
</CardFooter>
|
||||
{dialog}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
DEFAULT_SENTIMENT_AGENT_PROFILE,
|
||||
type SentimentAgentProfile,
|
||||
readSentimentAgentProfile,
|
||||
saveSentimentAgentProfile,
|
||||
} from "@/core/sentiment-agent/profile";
|
||||
|
||||
/**
|
||||
* 舆情分析智能体的「编辑信息」弹窗:修改名称 / 简介并保存到 localStorage。
|
||||
* 智能体管理页的卡片和对话页顶栏共用。
|
||||
*/
|
||||
export function SentimentAgentProfileDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const profile = readSentimentAgentProfile();
|
||||
setName(profile.name);
|
||||
setDescription(profile.description);
|
||||
}, [open]);
|
||||
|
||||
const handleSave = () => {
|
||||
const next: SentimentAgentProfile = {
|
||||
name: name.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.name,
|
||||
description:
|
||||
description.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.description,
|
||||
};
|
||||
saveSentimentAgentProfile(next);
|
||||
toast.success("舆情分析智能体信息已更新");
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑舆情分析智能体</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="sentiment-agent-name" className="text-sm font-medium">
|
||||
名称
|
||||
</label>
|
||||
<Input
|
||||
id="sentiment-agent-name"
|
||||
value={name}
|
||||
maxLength={50}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="智能体名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="sentiment-agent-description"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
简介
|
||||
</label>
|
||||
<Textarea
|
||||
id="sentiment-agent-description"
|
||||
value={description}
|
||||
maxLength={300}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="智能体简介(展示在卡片与对话页)"
|
||||
className="min-h-[96px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
仅修改本浏览器展示的名称与简介;接口地址与鉴权请在部署目录的
|
||||
runtime-config.js 中配置。
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave}>保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -28,11 +28,14 @@ export function ArtifactFileList({
|
||||
files,
|
||||
threadId,
|
||||
onOpenArtifact,
|
||||
onDownloadArtifact,
|
||||
}: {
|
||||
className?: string;
|
||||
files: string[];
|
||||
threadId: string;
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
/** Optional host download action for virtual/session-owned artifacts. */
|
||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { isMock, publicShareCode } = useThread();
|
||||
@@ -65,10 +68,14 @@ export function ArtifactFileList({
|
||||
async (e: React.MouseEvent, filepath: string) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (downloadingFile) return;
|
||||
setDownloadingFile(filepath);
|
||||
try {
|
||||
await downloadArtifactFile({ filepath, threadId, isMock, publicShareCode });
|
||||
if (downloadingFile) return;
|
||||
setDownloadingFile(filepath);
|
||||
try {
|
||||
if (onDownloadArtifact) {
|
||||
await onDownloadArtifact(filepath);
|
||||
} else {
|
||||
await downloadArtifactFile({ filepath, threadId, isMock, publicShareCode });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
@@ -78,7 +85,7 @@ export function ArtifactFileList({
|
||||
setDownloadingFile(null);
|
||||
}
|
||||
},
|
||||
[threadId, isMock, publicShareCode, downloadingFile],
|
||||
[threadId, isMock, publicShareCode, downloadingFile, onDownloadArtifact],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,22 +1,43 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useContext,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||
import { env } from "@/env";
|
||||
import { env } from "@/env";
|
||||
|
||||
export function artifactPreviewKey(artifact: string): string {
|
||||
if (!artifact.startsWith("write-file:")) {
|
||||
return artifact.trim();
|
||||
}
|
||||
try {
|
||||
const url = new URL(artifact);
|
||||
const messageId = url.searchParams.get("message_id");
|
||||
const toolCallId = url.searchParams.get("tool_call_id");
|
||||
if (messageId && toolCallId) {
|
||||
return `${messageId}:${toolCallId}`;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the complete value for malformed legacy virtual URLs.
|
||||
}
|
||||
return artifact;
|
||||
}
|
||||
|
||||
export interface ArtifactsContextType {
|
||||
artifacts: string[];
|
||||
setArtifacts: (artifacts: string[]) => void;
|
||||
|
||||
selectedArtifact: string | null;
|
||||
autoSelect: boolean;
|
||||
select: (artifact: string, autoSelect?: boolean) => void;
|
||||
deselect: () => void;
|
||||
selectedArtifact: string | null;
|
||||
selectedArtifactKey: string | null;
|
||||
autoSelect: boolean;
|
||||
select: (artifact: string, autoSelect?: boolean) => void;
|
||||
/** Open/update a streamed write without overriding a manual close of that call. */
|
||||
openStreamedArtifact: (artifact: string, previewKey: string) => boolean;
|
||||
deselect: () => void;
|
||||
|
||||
open: boolean;
|
||||
autoOpen: boolean;
|
||||
@@ -36,32 +57,55 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
const setArtifacts = useCallback((next: string[]) => {
|
||||
setArtifactsState(Array.isArray(next) ? next : []);
|
||||
}, []);
|
||||
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
|
||||
const [autoSelect, setAutoSelect] = useState(true);
|
||||
const [open, setOpen] = useState(
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
const [autoOpen, setAutoOpen] = useState(true);
|
||||
const sidebar = useSidebarSafe();
|
||||
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
|
||||
const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null);
|
||||
const [autoSelect, setAutoSelect] = useState(true);
|
||||
const [open, setOpenState] = useState(
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
const [autoOpen, setAutoOpen] = useState(true);
|
||||
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
|
||||
const sidebar = useSidebarSafe();
|
||||
|
||||
const select = useCallback(
|
||||
(artifact: string, autoSelect = false) => {
|
||||
setSelectedArtifact(artifact);
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
|
||||
(artifact: string, autoSelect = false) => {
|
||||
setSelectedArtifact(artifact);
|
||||
setSelectedArtifactKey(artifactPreviewKey(artifact));
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
|
||||
sidebar?.setOpen(false);
|
||||
}
|
||||
if (!autoSelect) {
|
||||
setAutoSelect(false);
|
||||
}
|
||||
},
|
||||
[sidebar, setSelectedArtifact, setAutoSelect],
|
||||
);
|
||||
[sidebar],
|
||||
);
|
||||
|
||||
const openStreamedArtifact = useCallback(
|
||||
(artifact: string, previewKey: string) => {
|
||||
if (suppressedAutoOpenKeyRef.current === previewKey) {
|
||||
return false;
|
||||
}
|
||||
setSelectedArtifact(artifact);
|
||||
setSelectedArtifactKey(previewKey);
|
||||
setAutoSelect(true);
|
||||
setAutoOpen(true);
|
||||
setOpenState(true);
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
|
||||
sidebar?.setOpen(false);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[sidebar],
|
||||
);
|
||||
|
||||
const deselect = useCallback(() => {
|
||||
setSelectedArtifact(null);
|
||||
setAutoSelect(true);
|
||||
setOpen(false);
|
||||
}, []);
|
||||
setSelectedArtifact(null);
|
||||
setSelectedArtifactKey(null);
|
||||
setAutoSelect(true);
|
||||
setOpenState(false);
|
||||
suppressedAutoOpenKeyRef.current = null;
|
||||
}, []);
|
||||
|
||||
const value: ArtifactsContextType = {
|
||||
artifacts,
|
||||
@@ -75,12 +119,17 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
setAutoOpen(false);
|
||||
setAutoSelect(false);
|
||||
}
|
||||
setOpen(isOpen);
|
||||
},
|
||||
|
||||
selectedArtifact,
|
||||
select,
|
||||
deselect,
|
||||
if (!isOpen) {
|
||||
suppressedAutoOpenKeyRef.current = selectedArtifactKey;
|
||||
}
|
||||
setOpenState(isOpen);
|
||||
},
|
||||
|
||||
selectedArtifact,
|
||||
selectedArtifactKey,
|
||||
select,
|
||||
openStreamedArtifact,
|
||||
deselect,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { SparklesIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
|
||||
|
||||
const STYLE_OPTIONS: { value: DocumentRewriteStyle; label: string; description: string }[] = [
|
||||
{
|
||||
value: "formal_analysis",
|
||||
label: "正式分析",
|
||||
description: "客观、严谨,适合报告与研究材料。",
|
||||
},
|
||||
{
|
||||
value: "professional",
|
||||
label: "专业正式",
|
||||
description: "适合业务文档和说明材料。",
|
||||
},
|
||||
{
|
||||
value: "concise",
|
||||
label: "精炼简洁",
|
||||
description: "减少重复,保留核心信息。",
|
||||
},
|
||||
{
|
||||
value: "readable",
|
||||
label: "清晰易读",
|
||||
description: "优化段落衔接和阅读节奏。",
|
||||
},
|
||||
];
|
||||
|
||||
export function DocumentRewriteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
fileName,
|
||||
charCount,
|
||||
onStart,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
fileName: string;
|
||||
charCount: number;
|
||||
onStart: (input: {
|
||||
instruction: string;
|
||||
style: DocumentRewriteStyle;
|
||||
modelName: string | null;
|
||||
}) => void;
|
||||
}) {
|
||||
const [instruction, setInstruction] = useState(
|
||||
"统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。",
|
||||
);
|
||||
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
|
||||
const [modelName, setModelName] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setStyle("formal_analysis");
|
||||
setModelName(null);
|
||||
}, [open]);
|
||||
|
||||
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
|
||||
const canStart = instruction.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<SparklesIcon className="size-5 text-primary" />
|
||||
AI 全文改写
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
将以当前文件快照生成临时草稿。校验通过前不会覆盖原 Markdown。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-lg border bg-muted/35 px-3 py-2.5 text-sm">
|
||||
<div className="font-medium">{fileName}</div>
|
||||
<div className="text-muted-foreground mt-1">原文约 {charCount.toLocaleString()} 字</div>
|
||||
</div>
|
||||
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
改写要求
|
||||
<Textarea
|
||||
value={instruction}
|
||||
onChange={(event) => setInstruction(event.target.value)}
|
||||
placeholder="例如:统一为正式行业分析语气,减少重复,不新增外部事实。"
|
||||
rows={4}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
改写风格
|
||||
<Select value={style} onValueChange={(value) => setStyle(value as DocumentRewriteStyle)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STYLE_OPTIONS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-xs">{selectedStyle?.description}</span>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm font-medium">
|
||||
模型
|
||||
<ModelNameSelect
|
||||
value={modelName}
|
||||
onChange={setModelName}
|
||||
triggerClassName="h-10 text-sm"
|
||||
defaultLabel="系统默认模型"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">仅影响本次全文改写。</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canStart}
|
||||
onClick={() => {
|
||||
if (!canStart) return;
|
||||
onStart({ instruction: instruction.trim(), style, modelName });
|
||||
}}
|
||||
>
|
||||
<SparklesIcon />
|
||||
开始改写
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -118,6 +118,7 @@ const ChatBox: React.FC<{
|
||||
select: selectArtifact,
|
||||
deselect,
|
||||
selectedArtifact,
|
||||
selectedArtifactKey,
|
||||
} = useArtifacts();
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
@@ -455,6 +456,7 @@ const ChatBox: React.FC<{
|
||||
</div>
|
||||
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
<ArtifactFileDetail
|
||||
key={selectedArtifactKey ?? selectedArtifact}
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
@@ -470,6 +472,7 @@ const ChatBox: React.FC<{
|
||||
</Tabs>
|
||||
) : selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
key={selectedArtifactKey ?? selectedArtifact}
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type DiffRow = {
|
||||
left: { text: string; kind: "same" | "removed" } | null;
|
||||
right: { text: string; kind: "same" | "added" } | null;
|
||||
};
|
||||
|
||||
function buildLineDiff(before: string, after: string): DiffRow[] {
|
||||
const left = before.split("\n");
|
||||
const right = after.split("\n");
|
||||
// A large quadratic matrix would make the dialog itself a performance risk.
|
||||
// Documents above this threshold still get an honest two-column comparison,
|
||||
// just without expensive alignment of every unchanged line.
|
||||
if (left.length * right.length > 1_000_000) {
|
||||
const rows: DiffRow[] = [];
|
||||
const length = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const beforeLine = left[index];
|
||||
const afterLine = right[index];
|
||||
const same = beforeLine === afterLine;
|
||||
rows.push({
|
||||
left: beforeLine === undefined ? null : { text: beforeLine, kind: same ? "same" : "removed" },
|
||||
right: afterLine === undefined ? null : { text: afterLine, kind: same ? "same" : "added" },
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const table = Array.from({ length: left.length + 1 }, () => new Uint16Array(right.length + 1));
|
||||
for (let i = left.length - 1; i >= 0; i -= 1) {
|
||||
for (let j = right.length - 1; j >= 0; j -= 1) {
|
||||
table[i][j] = left[i] === right[j]
|
||||
? table[i + 1][j + 1] + 1
|
||||
: Math.max(table[i + 1][j], table[i][j + 1]);
|
||||
}
|
||||
}
|
||||
const rows: DiffRow[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < left.length || j < right.length) {
|
||||
if (i < left.length && j < right.length && left[i] === right[j]) {
|
||||
rows.push({ left: { text: left[i], kind: "same" }, right: { text: right[j], kind: "same" } });
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (j < right.length && (i === left.length || table[i][j + 1] >= table[i + 1][j])) {
|
||||
rows.push({ left: null, right: { text: right[j], kind: "added" } });
|
||||
j += 1;
|
||||
} else {
|
||||
rows.push({ left: { text: left[i], kind: "removed" }, right: null });
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function DiffCell({
|
||||
value,
|
||||
line,
|
||||
}: {
|
||||
value: DiffRow["left"] | DiffRow["right"];
|
||||
line: number | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-w-0 grid-cols-[3rem_minmax(0,1fr)] border-b font-mono text-xs leading-5",
|
||||
value?.kind === "removed" && "bg-red-50 text-red-950 dark:bg-red-950/30 dark:text-red-100",
|
||||
value?.kind === "added" && "bg-emerald-50 text-emerald-950 dark:bg-emerald-950/30 dark:text-emerald-100",
|
||||
!value && "bg-muted/30",
|
||||
)}
|
||||
>
|
||||
<span className="select-none border-r px-2 text-right text-muted-foreground">{line ?? ""}</span>
|
||||
<pre className="min-w-0 whitespace-pre-wrap break-words px-3">{value?.text ?? ""}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentRewriteDiffDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
fileName,
|
||||
original,
|
||||
rewritten,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
fileName: string;
|
||||
original: string;
|
||||
rewritten: string;
|
||||
}) {
|
||||
const rows = useMemo(() => buildLineDiff(original, rewritten), [original, rewritten]);
|
||||
let leftLine = 0;
|
||||
let rightLine = 0;
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[85vh] max-w-[calc(100vw-2rem)] flex-col p-0 sm:max-w-[min(94vw,1440px)]">
|
||||
<DialogHeader className="border-b px-6 py-4">
|
||||
<DialogTitle>改写前后对比 · {fileName}</DialogTitle>
|
||||
<DialogDescription>红色为删除或调整前内容,绿色为新增或改写后内容。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x overflow-auto" role="region" aria-label="Markdown 改写前后对比">
|
||||
<section aria-label="改写前原文">
|
||||
<div className="sticky top-0 z-10 border-b bg-background px-4 py-2 text-sm font-medium">改写前</div>
|
||||
{rows.map((row, index) => {
|
||||
const line = row.left ? ++leftLine : null;
|
||||
return <DiffCell key={`left-${index}`} value={row.left} line={line} />;
|
||||
})}
|
||||
</section>
|
||||
<section aria-label="改写后内容">
|
||||
<div className="sticky top-0 z-10 border-b bg-background px-4 py-2 text-sm font-medium">改写后</div>
|
||||
{rows.map((row, index) => {
|
||||
const line = row.right ? ++rightLine : null;
|
||||
return <DiffCell key={`right-${index}`} value={row.right} line={line} />;
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
FilePenLineIcon,
|
||||
Loader2Icon,
|
||||
Undo2Icon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import {
|
||||
DOCUMENT_REWRITE_UNDONE_EVENT,
|
||||
type DocumentRewritePresentation,
|
||||
useDocumentRewritePresentation,
|
||||
} from "@/core/document-rewrite/context";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
|
||||
|
||||
const STEPS = [
|
||||
{ key: "snapshot", label: "固定原文版本" },
|
||||
{ key: "requirements", label: "理解改写要求" },
|
||||
{ key: "writing", label: "流式重写 Markdown" },
|
||||
{ key: "validation", label: "校验文档" },
|
||||
{ key: "comparison", label: "对比原文并生成说明" },
|
||||
] as const;
|
||||
|
||||
function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
|
||||
switch (stage) {
|
||||
case "snapshot":
|
||||
return 0;
|
||||
case "requirements":
|
||||
return 1;
|
||||
case "writing":
|
||||
return 2;
|
||||
case "validation":
|
||||
return 3;
|
||||
case "committing":
|
||||
case "completed":
|
||||
return 5;
|
||||
case "conflict":
|
||||
case "error":
|
||||
case "cancelled":
|
||||
return 4;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminal(stage: DocumentRewritePresentation["stage"]) {
|
||||
return ["completed", "conflict", "error", "cancelled"].includes(stage);
|
||||
}
|
||||
|
||||
export function DocumentRewriteTimeline({
|
||||
item,
|
||||
}: {
|
||||
item: DocumentRewritePresentation;
|
||||
}) {
|
||||
const current = stepPosition(item.stage);
|
||||
const failed = ["conflict", "error", "cancelled"].includes(item.stage);
|
||||
const completed = item.stage === "completed";
|
||||
const presentation = useDocumentRewritePresentation();
|
||||
const [undoing, setUndoing] = useState(false);
|
||||
const [diffOpen, setDiffOpen] = useState(false);
|
||||
|
||||
const reapplying = Boolean(item.revertedAt);
|
||||
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
|
||||
|
||||
const handleUndo = async () => {
|
||||
if (!activeVersionId || undoing) return;
|
||||
const baseUrl = getBackendBaseURL();
|
||||
const endpoint =
|
||||
item.source.kind === "artifact"
|
||||
? `${baseUrl}/api/writing/document-rewrite/versions/${encodeURIComponent(activeVersionId)}/undo`
|
||||
: `${baseUrl}/api/deep-research/sessions/${encodeURIComponent(item.source.id)}/document-rewrite/versions/${encodeURIComponent(activeVersionId)}/undo`;
|
||||
const payload =
|
||||
item.source.kind === "artifact"
|
||||
? { threadId: item.source.id, path: item.source.path }
|
||||
: undefined;
|
||||
setUndoing(true);
|
||||
try {
|
||||
const response = await apiFetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: payload ? { "Content-Type": "application/json" } : undefined,
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
const result = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
typeof result.detail === "string" ? result.detail : "撤销改写失败,请稍后重试。",
|
||||
);
|
||||
}
|
||||
const content = typeof result.content === "string" ? result.content : "";
|
||||
const nextInverseVersionId =
|
||||
typeof result.redoVersionId === "string" ? result.redoVersionId : null;
|
||||
presentation?.update(
|
||||
item.id,
|
||||
reapplying
|
||||
? { versionId: nextInverseVersionId, redoVersionId: null, revertedAt: null }
|
||||
: { redoVersionId: nextInverseVersionId, revertedAt: new Date().toISOString() },
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(DOCUMENT_REWRITE_UNDONE_EVENT, {
|
||||
detail: { source: item.source, content },
|
||||
}),
|
||||
);
|
||||
toast.success(reapplying ? "已重新应用本次 AI 全文改写版本。" : "已恢复到本次 AI 全文改写前的版本。");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "撤销改写失败,请稍后重试。");
|
||||
} finally {
|
||||
setUndoing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="w-full rounded-2xl border bg-card shadow-sm">
|
||||
<header className="flex items-start gap-3 border-b px-5 py-4">
|
||||
<div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-full">
|
||||
<FilePenLineIcon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-semibold">AI 全文改写 · {item.fileName}</h3>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs",
|
||||
completed
|
||||
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-300"
|
||||
: failed
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{completed ? "已完成" : failed ? "未覆盖原文件" : "进行中"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{item.instruction}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-3 px-5 py-4">
|
||||
{STEPS.map((step, index) => {
|
||||
const active = !isTerminal(item.stage) && index === current;
|
||||
const done = completed || index < current;
|
||||
return (
|
||||
<div className="flex items-start gap-3" key={step.key}>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border",
|
||||
done
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-400/30 dark:bg-emerald-400/10"
|
||||
: active
|
||||
? "border-primary/30 bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{done ? (
|
||||
<CheckCircle2Icon className="size-4" />
|
||||
) : active ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<span className="text-xs">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 pt-0.5">
|
||||
<div className="text-sm font-medium">{step.label}</div>
|
||||
{step.key === "snapshot" && item.originalCharCount > 0 && (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
已冻结 {item.originalCharCount.toLocaleString()} 字原文快照。
|
||||
</p>
|
||||
)}
|
||||
{step.key === "requirements" && (
|
||||
item.requirementsPlan ? (
|
||||
<div
|
||||
aria-live={active ? "polite" : undefined}
|
||||
className="bg-muted/45 text-muted-foreground mt-2 max-w-3xl rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap"
|
||||
role={active ? "status" : undefined}
|
||||
>
|
||||
{item.requirementsPlan}
|
||||
</div>
|
||||
) : active ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
正在识别改写目标、保留约束与执行方式…
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
已根据改写要求确定执行计划。
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{step.key === "writing" && item.writtenCharCount > 0 && (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
正在写入右侧沙箱,已生成 {item.writtenCharCount.toLocaleString()} 字。
|
||||
</p>
|
||||
)}
|
||||
{step.key === "validation" && item.warnings.length > 0 && (
|
||||
<p className="mt-0.5 text-xs text-amber-700 dark:text-amber-300">
|
||||
{item.warnings.join(";")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{item.summary && (
|
||||
<div className="border-t px-5 py-4 text-sm leading-6">
|
||||
<div className="mb-2 font-semibold">改写说明</div>
|
||||
<p>
|
||||
原文 {item.summary.originalCharCount.toLocaleString()} 字,改写后{" "}
|
||||
{item.summary.rewrittenCharCount.toLocaleString()} 字;调整了约{" "}
|
||||
{item.summary.changedBlockCount} 个内容块。
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
标题数量 {item.summary.headingCountBefore} → {item.summary.headingCountAfter},
|
||||
使用模型:{item.summary.modelName}。
|
||||
</p>
|
||||
{item.summary.requestedOptimizations?.length ? (
|
||||
<p className="text-muted-foreground">
|
||||
本次优化:{item.summary.requestedOptimizations.join("、")}。
|
||||
</p>
|
||||
) : null}
|
||||
{item.summary.headingChanges && (
|
||||
item.summary.headingChanges.reordered ||
|
||||
item.summary.headingChanges.added.length > 0 ||
|
||||
item.summary.headingChanges.removed.length > 0 ||
|
||||
item.summary.headingChanges.levelWarnings.length > 0
|
||||
) && (
|
||||
<p className="text-muted-foreground">
|
||||
{item.summary.headingChanges.reordered ? "已调整章节顺序;" : ""}
|
||||
{item.summary.headingChanges.added.length
|
||||
? `新增标题:${item.summary.headingChanges.added.join("、")};`
|
||||
: ""}
|
||||
{item.summary.headingChanges.removed.length
|
||||
? `移除标题:${item.summary.headingChanges.removed.join("、")};`
|
||||
: ""}
|
||||
{item.summary.headingChanges.levelWarnings.join(";")}
|
||||
</p>
|
||||
)}
|
||||
{item.originalContent && item.rewrittenContent && (
|
||||
<Button
|
||||
className="mt-3 mr-2"
|
||||
onClick={() => setDiffOpen(true)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
查看前后对比
|
||||
</Button>
|
||||
)}
|
||||
{completed && activeVersionId && (
|
||||
<Button
|
||||
className="mt-3"
|
||||
disabled={undoing}
|
||||
onClick={() => void handleUndo()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{undoing ? <Loader2Icon className="mr-1.5 size-3.5 animate-spin" /> : <Undo2Icon className="mr-1.5 size-3.5" />}
|
||||
{reapplying ? "重新应用此次改写" : "撤销此次改写"}
|
||||
</Button>
|
||||
)}
|
||||
{item.revertedAt && (
|
||||
<p className="mt-2 text-xs text-emerald-700 dark:text-emerald-300">
|
||||
已恢复为改写前版本。{item.redoVersionId ? "可重新应用本次改写。" : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.error && (
|
||||
<div className="flex items-center gap-2 border-t px-5 py-3 text-sm text-destructive">
|
||||
<XCircleIcon className="size-4 shrink-0" />
|
||||
{item.error}
|
||||
</div>
|
||||
)}
|
||||
<DocumentRewriteDiffDialog
|
||||
fileName={item.fileName}
|
||||
onOpenChange={setDiffOpen}
|
||||
open={diffOpen}
|
||||
original={item.originalContent}
|
||||
rewritten={item.rewrittenContent ?? ""}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import {
|
||||
BookOpenTextIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUp,
|
||||
BookOpenTextIcon,
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronUp,
|
||||
FolderOpenIcon,
|
||||
GlobeIcon,
|
||||
LightbulbIcon,
|
||||
ListTodoIcon,
|
||||
LightbulbIcon,
|
||||
ListTodoIcon,
|
||||
Loader2Icon,
|
||||
MessageCircleQuestionMarkIcon,
|
||||
NotebookPenIcon,
|
||||
SearchIcon,
|
||||
SquareTerminalIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
ChainOfThought,
|
||||
@@ -29,14 +31,20 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
buildWriteFileVirtualUrl,
|
||||
hasCompleteArtifactExtension,
|
||||
normalizeStreamingArtifactPath,
|
||||
provisionalWriteFilePath,
|
||||
} from "@/core/artifacts/streaming-write-preview";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
normalizeSearchResults,
|
||||
resolveSearchQuery,
|
||||
@@ -227,7 +235,7 @@ export function MessageGroup({
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCall({
|
||||
function ToolCall({
|
||||
id,
|
||||
messageId,
|
||||
name,
|
||||
@@ -249,8 +257,6 @@ function ToolCall({
|
||||
ragModeEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const artifacts = useArtifactsOptional();
|
||||
|
||||
const displayMatch =
|
||||
result !== undefined && isDisplaySamplePayload(result)
|
||||
? null
|
||||
@@ -265,7 +271,7 @@ function ToolCall({
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}))
|
||||
: [];
|
||||
if (displayItems.length > 0) {
|
||||
if (displayItems.length > 0) {
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
@@ -279,9 +285,16 @@ function ToolCall({
|
||||
<DisplayResultsList results={displayItems} />
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
const searchItems = normalizeSearchResults(result);
|
||||
}
|
||||
|
||||
// Deep Research uses a virtual tool call for each durable writing phase.
|
||||
// Rendering it here keeps its lifecycle in the very same ChainOfThought
|
||||
// component as ordinary Q&A retrieval and sandbox tool calls.
|
||||
if (name === "deep_research_progress") {
|
||||
return <DeepResearchProgressStep id={id} args={args} />;
|
||||
}
|
||||
|
||||
const searchItems = normalizeSearchResults(result);
|
||||
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
||||
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||
...item,
|
||||
@@ -424,44 +437,18 @@ function ToolCall({
|
||||
if (!description) {
|
||||
description = t.toolCalls.writeFile;
|
||||
}
|
||||
const path: string | undefined = (args as { path: string })?.path;
|
||||
const artifactPath =
|
||||
name === "write_file" && path
|
||||
? new URL(
|
||||
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
|
||||
).toString()
|
||||
: path;
|
||||
if (artifacts && isLoading && isLast && artifacts.autoOpen && artifacts.autoSelect && path && !result) {
|
||||
setTimeout(() => {
|
||||
if (!artifactPath || artifacts.selectedArtifact === artifactPath) {
|
||||
return;
|
||||
}
|
||||
artifacts.select(artifactPath, true);
|
||||
artifacts.setOpen(true);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
className="cursor-pointer"
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
if (!artifactPath || !artifacts) {
|
||||
return;
|
||||
}
|
||||
artifacts.select(artifactPath);
|
||||
artifacts.setOpen(true);
|
||||
}}
|
||||
>
|
||||
{path && (
|
||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||
{path}
|
||||
</ChainOfThoughtSearchResult>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
return (
|
||||
<ArtifactWriteToolCall
|
||||
id={id}
|
||||
messageId={messageId}
|
||||
name={name}
|
||||
args={args}
|
||||
result={result}
|
||||
description={description}
|
||||
isLast={isLast}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
} else if (name === "bash") {
|
||||
const description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
@@ -525,6 +512,123 @@ function ToolCall({
|
||||
}
|
||||
}
|
||||
|
||||
function DeepResearchProgressStep({
|
||||
id,
|
||||
args,
|
||||
}: {
|
||||
id?: string;
|
||||
args: Record<string, unknown>;
|
||||
}) {
|
||||
const label = typeof args.label === "string" && args.label.trim()
|
||||
? args.label
|
||||
: "正在撰写研究报告";
|
||||
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
|
||||
const active = args.status !== "complete";
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
status={active ? "active" : "complete"}
|
||||
icon={active
|
||||
? <Loader2Icon className="text-primary size-4 animate-spin" />
|
||||
: <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />}
|
||||
label={
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
<span>{label}</span>
|
||||
<span className={active ? "text-primary text-xs" : "text-muted-foreground text-xs"}>
|
||||
{active ? "进行中" : "已完成"}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<div className="text-muted-foreground text-xs leading-5">
|
||||
{detail}
|
||||
</div>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactWriteToolCall({
|
||||
id,
|
||||
messageId,
|
||||
name,
|
||||
args,
|
||||
result,
|
||||
description,
|
||||
isLast,
|
||||
isLoading,
|
||||
}: {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
name: "write_file" | "str_replace";
|
||||
args: Record<string, unknown>;
|
||||
result?: string | Record<string, unknown>;
|
||||
description: string;
|
||||
isLast: boolean;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const artifacts = useArtifactsOptional();
|
||||
const openStreamedArtifact = artifacts?.openStreamedArtifact;
|
||||
const rawPath = typeof args.path === "string" ? args.path : "";
|
||||
const content = typeof args.content === "string" ? args.content : "";
|
||||
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
|
||||
const hasUsablePath =
|
||||
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
|
||||
const sourcePath = hasUsablePath
|
||||
? normalizedPath
|
||||
: name === "write_file" && content.trim()
|
||||
? provisionalWriteFilePath(content)
|
||||
: null;
|
||||
const previewKey = messageId && id ? `${messageId}:${id}` : null;
|
||||
const artifactPath =
|
||||
sourcePath && name === "write_file" && messageId && id
|
||||
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
|
||||
: sourcePath;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!openStreamedArtifact ||
|
||||
!artifactPath ||
|
||||
!previewKey ||
|
||||
!isLoading ||
|
||||
!isLast ||
|
||||
result !== undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
openStreamedArtifact(artifactPath, previewKey);
|
||||
}, [
|
||||
artifactPath,
|
||||
isLast,
|
||||
isLoading,
|
||||
openStreamedArtifact,
|
||||
previewKey,
|
||||
result,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
className={cn(artifactPath && "cursor-pointer")}
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
if (!artifactPath || !artifacts) return;
|
||||
artifacts.select(artifactPath);
|
||||
artifacts.setOpen(true);
|
||||
}}
|
||||
>
|
||||
{(normalizedPath || sourcePath) && (
|
||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||
{normalizedPath || sourcePath}
|
||||
</ChainOfThoughtSearchResult>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
interface GenericCoTStep<T extends string = string> {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
|
||||
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
|
||||
import { useDocumentRewritePresentation } from "@/core/document-rewrite/context";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
@@ -45,7 +46,8 @@ import { getQuestionAnchorId } from "../question-navigator";
|
||||
import { StreamingIndicator } from "../streaming-indicator";
|
||||
|
||||
import { ReferenceBatchesContext, ThreadContext } from "./context";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
|
||||
import { MessageGroup } from "./message-group";
|
||||
import { MessageListItem } from "./message-list-item";
|
||||
import {
|
||||
@@ -110,8 +112,10 @@ export function MessageList({
|
||||
onSetupWritingApprove,
|
||||
onSetupWritingReject,
|
||||
onSetupWritingStart,
|
||||
onOpenArtifact,
|
||||
}: {
|
||||
researchApprovalSlot,
|
||||
onOpenArtifact,
|
||||
onDownloadArtifact,
|
||||
}: {
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
fitMessageRowsToContent?: boolean;
|
||||
@@ -133,14 +137,24 @@ export function MessageList({
|
||||
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
|
||||
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
/**
|
||||
* 深度研究的交互卡插槽(报告配置、计划确认、失败重试):workbench 向
|
||||
* displayThread 注入带 `additional_kwargs.setup_research_approval` 的虚拟 ai
|
||||
* 消息。撰写步骤和完成摘要不走此插槽,而是分别复用原生 tool-call 步骤条和
|
||||
* assistant 消息行;不传该 slot 时普通对话/AI 写作零行为变化。
|
||||
*/
|
||||
researchApprovalSlot?: (message: Message) => React.ReactNode;
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
/** Host override for downloading virtual/session-owned files in a present-files card. */
|
||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const rehypePlugins = useMemo(
|
||||
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
||||
[baseRehypePlugins, extraRehypePlugins],
|
||||
);
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const documentRewritePresentation = useDocumentRewritePresentation();
|
||||
const { t } = useI18n();
|
||||
const threadContext = useContext(ThreadContext);
|
||||
// Read-only contexts (public share, mock previews) must not offer deletion.
|
||||
@@ -180,7 +194,7 @@ export function MessageList({
|
||||
: ({ ...message, content: override } as Message);
|
||||
});
|
||||
}, [messageContentOverrides, thread.messages]);
|
||||
const messages = useMemo(
|
||||
const messages = useMemo(
|
||||
() => {
|
||||
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
|
||||
return hiddenMessageIds.size === 0 && externalHidden.size === 0
|
||||
@@ -191,8 +205,9 @@ export function MessageList({
|
||||
(!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)),
|
||||
);
|
||||
},
|
||||
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
||||
);
|
||||
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
||||
);
|
||||
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
|
||||
|
||||
const askTimes = useChatAskTimes();
|
||||
|
||||
@@ -428,10 +443,16 @@ export function MessageList({
|
||||
)}
|
||||
>
|
||||
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
||||
{groupMessages(messages, (group) => {
|
||||
{groupMessages(messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return group.messages.map((msg) => {
|
||||
const isAssistantMessage =
|
||||
return group.messages.map((msg) => {
|
||||
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
|
||||
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
|
||||
const messageIsLoading =
|
||||
thread.isLoading ||
|
||||
msg.additional_kwargs?.deep_research_summary_streaming === true ||
|
||||
msg.additional_kwargs?.deep_research_follow_up_streaming === true;
|
||||
const isAssistantMessage =
|
||||
referencesEnabled &&
|
||||
group.type === "assistant" &&
|
||||
msg.type === "ai";
|
||||
@@ -464,9 +485,9 @@ export function MessageList({
|
||||
msg.type === "human"
|
||||
? getQuestionAnchorId(msg, messages.indexOf(msg))
|
||||
: undefined
|
||||
}
|
||||
message={msg}
|
||||
isLoading={thread.isLoading}
|
||||
}
|
||||
message={msg}
|
||||
isLoading={messageIsLoading}
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
@@ -561,6 +582,15 @@ export function MessageList({
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:research-approval") {
|
||||
// 深度研究交互卡(配置/计划确认/失败重试):宿主不传渲染函数时该
|
||||
// 分组不渲染。撰写步骤和完成摘要会经过普通的 processing/assistant 分组。
|
||||
const message = group.messages[0];
|
||||
return researchApprovalSlot && message ? (
|
||||
<div key={group.id} className="w-full pb-2">
|
||||
{researchApprovalSlot(message)}
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:present-files") {
|
||||
const files: string[] = [];
|
||||
for (const message of group.messages) {
|
||||
@@ -580,10 +610,11 @@ export function MessageList({
|
||||
/>
|
||||
)}
|
||||
<ArtifactFileList
|
||||
files={files}
|
||||
threadId={threadId}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
/>
|
||||
files={files}
|
||||
threadId={threadId}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
onDownloadArtifact={onDownloadArtifact}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={thread.isLoading}
|
||||
@@ -707,8 +738,11 @@ export function MessageList({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{thread.isLoading && <StreamingIndicator className="my-4" />}
|
||||
})}
|
||||
{documentRewriteItems.map((item) => (
|
||||
<DocumentRewriteTimeline key={item.id} item={item} />
|
||||
))}
|
||||
{thread.isLoading && <StreamingIndicator className="my-4" />}
|
||||
<div style={{ height: `${paddingBottom}px` }} />
|
||||
</MessageMetricsProvider>
|
||||
</ConversationContent>
|
||||
|
||||
Reference in new issue
Block a user