394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { ArrowDownUpIcon, BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useEffect, useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Pagination } from "@/components/ui/pagination";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
|
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
|
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
|
import type { Agent, AgentSort } from "@/core/agents";
|
|
import { useI18n } from "@/core/i18n/hooks";
|
|
import { usePrivateSquareStatus, useViewMode } from "@/core/system-settings/hooks";
|
|
|
|
import { AgentCard } from "./agent-card";
|
|
|
|
const PAGE_SIZE = 12;
|
|
|
|
// Within a single tab the verified password is kept in sessionStorage so a
|
|
// refresh / route navigation does not re-prompt. Cleared on tab close, so the
|
|
// password never persists beyond the browsing session.
|
|
const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
|
|
|
|
export function AgentGallery() {
|
|
const { t } = useI18n();
|
|
const router = useRouter();
|
|
const [viewMode, setViewMode] = useViewMode("agent");
|
|
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
|
|
"mine",
|
|
);
|
|
const [search, setSearch] = useState("");
|
|
const [tagIds, setTagIds] = useState<string[]>([]);
|
|
const [sort, setSort] = useState<AgentSort>("time_desc");
|
|
// Per-tab 1-based page index, preserved while switching tabs.
|
|
const [pages, setPages] = useState<{ all: number; mine: number; private: number }>(
|
|
{ all: 1, mine: 1, private: 1 },
|
|
);
|
|
|
|
// Search + tag filtering are both server-side now. The backend mirrors the
|
|
// frontend's desensitize map, so typing the displayed abbreviation (e.g.
|
|
// "TS") still matches the stored raw name (e.g. "态势分析"). Whenever the
|
|
// filters change, every tab returns to its first page.
|
|
const tagKey = tagIds.join(",");
|
|
useEffect(() => {
|
|
setPages({ all: 1, mine: 1, private: 1 });
|
|
}, [search, tagKey, sort]);
|
|
|
|
const { data: privateStatus } = usePrivateSquareStatus();
|
|
const privateFeatureAvailable = !!privateStatus?.enabled;
|
|
const privateFeatureUsable =
|
|
privateFeatureAvailable && !!privateStatus?.password_set;
|
|
|
|
const [privatePassword, setPrivatePassword] = useState<string | null>(() => {
|
|
if (typeof window === "undefined") return null;
|
|
return window.sessionStorage.getItem(PRIVATE_SQUARE_PASSWORD_KEY);
|
|
});
|
|
const [passwordInput, setPasswordInput] = useState("");
|
|
|
|
// Only the active tab fetches. "广场" maps to the server "square" scope
|
|
// (built-in/published, never private), "我的" to "mine" (owned + favorited).
|
|
const mainScope = activeTab === "mine" ? "mine" : "square";
|
|
const mainPage = activeTab === "mine" ? pages.mine : pages.all;
|
|
const {
|
|
agents: mainAgents,
|
|
total: mainTotal,
|
|
isLoading: mainLoading,
|
|
} = useAgentsPaginated({
|
|
scope: mainScope,
|
|
search,
|
|
tagIds,
|
|
sort,
|
|
page: mainPage,
|
|
pageSize: PAGE_SIZE,
|
|
enabled: activeTab !== "private",
|
|
});
|
|
|
|
const {
|
|
agents: privateAgents,
|
|
total: privateTotal,
|
|
isLoading: isPrivateLoading,
|
|
error: privateError,
|
|
} = usePrivateAgentsPaginated(privatePassword, {
|
|
search,
|
|
tagIds,
|
|
sort,
|
|
page: pages.private,
|
|
pageSize: PAGE_SIZE,
|
|
enabled: activeTab === "private",
|
|
});
|
|
|
|
// If the server reports the stored password no longer works (likely the
|
|
// admin rotated it), drop it from session storage and re-prompt.
|
|
useEffect(() => {
|
|
if (privatePassword && privateError) {
|
|
window.sessionStorage.removeItem(PRIVATE_SQUARE_PASSWORD_KEY);
|
|
setPrivatePassword(null);
|
|
toast.error("私密广场密码已失效,请重新输入");
|
|
}
|
|
}, [privateError, privatePassword]);
|
|
|
|
const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
|
|
const setTabPage = (tab: "all" | "mine" | "private", page: number) =>
|
|
setPages((prev) => ({ ...prev, [tab]: page }));
|
|
|
|
const handleNewAgent = () => {
|
|
router.push("/page/workspace/agents/new");
|
|
};
|
|
|
|
const handleSubmitPassword = () => {
|
|
const trimmed = passwordInput.trim();
|
|
if (!trimmed) {
|
|
toast.error("请输入密码");
|
|
return;
|
|
}
|
|
window.sessionStorage.setItem(PRIVATE_SQUARE_PASSWORD_KEY, trimmed);
|
|
setPrivatePassword(trimmed);
|
|
setPasswordInput("");
|
|
};
|
|
|
|
const handleLockPrivate = () => {
|
|
window.sessionStorage.removeItem(PRIVATE_SQUARE_PASSWORD_KEY);
|
|
setPrivatePassword(null);
|
|
};
|
|
|
|
const renderGrid = (list: Agent[]) =>
|
|
viewMode === "list" ? (
|
|
<div className="flex flex-col gap-2">
|
|
{list.map((agent) => (
|
|
<AgentCard key={agent.id} agent={agent} layout="list" />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
{list.map((agent) => (
|
|
<AgentCard key={agent.id} agent={agent} />
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
/** Loading / empty / (grid + pagination) for one paginated tab. */
|
|
const renderListBody = (opts: {
|
|
list: Agent[];
|
|
total: number;
|
|
isLoading: boolean;
|
|
tab: "all" | "mine" | "private";
|
|
emptyDescription: string;
|
|
showNewButton?: boolean;
|
|
}) => {
|
|
const { list, total, isLoading, tab, emptyDescription, showNewButton } = opts;
|
|
// keepPreviousData keeps the old page visible while a new page loads, so
|
|
// only show the spinner on the very first load (no rows yet).
|
|
if (isLoading && list.length === 0) {
|
|
return (
|
|
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
|
{t.common.loading}
|
|
</div>
|
|
);
|
|
}
|
|
if (list.length === 0) {
|
|
return renderEmpty(emptyDescription, showNewButton);
|
|
}
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{renderGrid(list)}
|
|
<Pagination
|
|
page={pages[tab]}
|
|
pageSize={PAGE_SIZE}
|
|
total={total}
|
|
onPageChange={(p) => setTabPage(tab, p)}
|
|
className="pt-2"
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderEmpty = (description: string, showNewButton = false) => (
|
|
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
|
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full dark:bg-[#022248]">
|
|
<BotIcon className="text-muted-foreground h-7 w-7 dark:text-[#AACEFF]" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium dark:text-[#AACEFF]">{t.agents.emptyTitle}</p>
|
|
<p className="text-muted-foreground mt-1 text-sm dark:text-[#55A6FE]">
|
|
{description}
|
|
</p>
|
|
</div>
|
|
{showNewButton && (
|
|
<Button
|
|
variant="outline"
|
|
className="mt-2 dark:border-[#2580FF] dark:bg-[#2580FF] dark:text-white dark:hover:border-[#2580FF] dark:hover:bg-[#2580FF]/90 dark:hover:text-white"
|
|
onClick={handleNewAgent}
|
|
>
|
|
<PlusIcon className="mr-1.5 h-4 w-4" />
|
|
{t.agents.newAgent}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const renderPrivateTab = () => {
|
|
if (!privateFeatureAvailable) {
|
|
return (
|
|
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
|
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
|
<ShieldIcon className="text-muted-foreground h-7 w-7" />
|
|
</div>
|
|
<p className="font-medium">私密广场尚未开启</p>
|
|
<p className="text-muted-foreground max-w-sm text-sm">
|
|
请管理员前往「综合管理 → 私密广场配置」开启该功能并设置密码。
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!privateFeatureUsable) {
|
|
return (
|
|
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
|
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
|
<ShieldIcon className="text-muted-foreground h-7 w-7" />
|
|
</div>
|
|
<p className="font-medium">私密广场未配置密码</p>
|
|
<p className="text-muted-foreground max-w-sm text-sm">
|
|
请管理员在私密广场配置页设置一个访问密码。
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!privatePassword) {
|
|
return (
|
|
<div className="mx-auto flex w-full max-w-sm flex-col gap-4 pt-12 text-center">
|
|
<div className="bg-muted mx-auto flex h-14 w-14 items-center justify-center rounded-full">
|
|
<LockIcon className="text-muted-foreground h-7 w-7" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium">私密广场</p>
|
|
<p className="text-muted-foreground mt-1 text-sm">
|
|
请输入访问密码,验证通过后才能查看其中的智能体。
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<Input
|
|
type="password"
|
|
placeholder="访问密码"
|
|
value={passwordInput}
|
|
onChange={(e) => setPasswordInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleSubmitPassword();
|
|
}}
|
|
// This is a one-off access code, not a login credential. Tell
|
|
// browsers / password managers not to save or autofill it (which
|
|
// would also stop them from spilling a saved account into the
|
|
// name search box on this page).
|
|
name="private-square-access-code"
|
|
autoComplete="off"
|
|
data-1p-ignore
|
|
data-lpignore="true"
|
|
data-form-type="other"
|
|
/>
|
|
<Button onClick={handleSubmitPassword}>进入私密广场</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return renderListBody({
|
|
list: privateAgents,
|
|
total: privateTotal,
|
|
isLoading: isPrivateLoading,
|
|
tab: "private",
|
|
emptyDescription: "私密广场暂时还没有智能体。",
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="flex size-full flex-col">
|
|
{/* Page header */}
|
|
<div className="flex items-center justify-between border-b px-6 py-4">
|
|
<div>
|
|
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
|
|
<p className="text-muted-foreground mt-0.5 text-sm">
|
|
{t.agents.description}
|
|
</p>
|
|
</div>
|
|
<Button onClick={handleNewAgent}>
|
|
<PlusIcon className="mr-1.5 h-4 w-4" />
|
|
{t.agents.newAgent}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={(v) =>
|
|
setActiveTab(v as "all" | "mine" | "private")
|
|
}
|
|
className="flex flex-1 flex-col overflow-hidden"
|
|
>
|
|
<div className="mx-6 mt-4 flex flex-col gap-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<TabsList className="w-auto self-start">
|
|
<TabsTrigger value="mine">我的</TabsTrigger>
|
|
<TabsTrigger value="all">广场</TabsTrigger>
|
|
{privateFeatureAvailable && (
|
|
<TabsTrigger value="private">
|
|
<LockIcon className="mr-1 h-3.5 w-3.5" />
|
|
私密广场
|
|
</TabsTrigger>
|
|
)}
|
|
</TabsList>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
|
共 {activeTotal} 个
|
|
</span>
|
|
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
|
{activeTab === "private" && privatePassword && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleLockPrivate}
|
|
title="锁定私密广场,下次需要重新输入密码"
|
|
>
|
|
<LockIcon className="mr-1.5 h-3.5 w-3.5" />
|
|
锁定
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<TagSearchBar
|
|
targetType="agent"
|
|
onSearchChange={setSearch}
|
|
tagIds={tagIds}
|
|
onTagIdsChange={setTagIds}
|
|
actions={
|
|
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
|
|
<SelectTrigger
|
|
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
|
|
title="排序"
|
|
>
|
|
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
|
<SelectItem value="time_desc">最新创建</SelectItem>
|
|
<SelectItem value="time_asc">最早创建</SelectItem>
|
|
<SelectItem value="updated_desc">最近更新</SelectItem>
|
|
<SelectItem value="updated_asc">最早更新</SelectItem>
|
|
<SelectItem value="name_asc">名称 A-Z</SelectItem>
|
|
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
|
|
{renderListBody({
|
|
list: mainAgents,
|
|
total: mainTotal,
|
|
isLoading: mainLoading,
|
|
tab: "all",
|
|
emptyDescription: t.agents.emptyDescription,
|
|
})}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
|
|
{renderListBody({
|
|
list: mainAgents,
|
|
total: mainTotal,
|
|
isLoading: mainLoading,
|
|
tab: "mine",
|
|
emptyDescription: t.agents.emptyDescription,
|
|
showNewButton: true,
|
|
})}
|
|
</TabsContent>
|
|
|
|
{privateFeatureAvailable && (
|
|
<TabsContent
|
|
value="private"
|
|
className="flex-1 overflow-y-auto p-6 mt-0"
|
|
>
|
|
{renderPrivateTab()}
|
|
</TabsContent>
|
|
)}
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|