列表引导改为手动触发且不跳转创建页;历史 Tab 展示会商步骤/链条与深度研究模板;Step2 增加暂停与内容高度跟随滚动,并修复旧草稿恢复兜底。 Co-authored-by: Cursor <cursoragent@cursor.com>
626 lines
23 KiB
TypeScript
626 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import { ArrowDownUpIcon, BotIcon, Compass, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } 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 { 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";
|
|
import {
|
|
AgentGuide,
|
|
isAgentGuideEnvironment,
|
|
startAgentGuide,
|
|
} from "./agent-guide";
|
|
|
|
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 sentimentAgentVisible = isSentimentAgentVisible();
|
|
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
|
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
|
const [viewMode, setViewMode] = useViewMode("agent");
|
|
const [activeTab, setActiveTab] = useState<string>("mine");
|
|
const [search, setSearch] = useState("");
|
|
const [tagIds, setTagIds] = useState<string[]>([]);
|
|
const [sort, setSort] = useState<AgentSort>("time_desc");
|
|
const { data: publishSquares } = usePublishSquares();
|
|
// 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>(
|
|
null,
|
|
);
|
|
// Per-tab 1-based page index (keyed by tab value), preserved across switches.
|
|
const [pages, setPages] = useState<Record<string, number>>({});
|
|
|
|
// 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({});
|
|
}, [search, tagKey, sort, publisher?.id]);
|
|
|
|
// If the active square tab disappears (admin removed that square), fall back
|
|
// to "我的" so the gallery never points at a dead tab.
|
|
useEffect(() => {
|
|
// 非管理员落到「内置」标签(理论上点不到,兜底)→ 回到「我的」。
|
|
if (activeTab === "builtin" && !isAdmin) {
|
|
setActiveTab("mine");
|
|
return;
|
|
}
|
|
if (
|
|
activeTab !== "mine" &&
|
|
activeTab !== "private" &&
|
|
activeTab !== "builtin" &&
|
|
activeTab !== "sentiment" &&
|
|
squareTabs.length > 0 &&
|
|
!squareTabs.some((sq) => sq.id === activeTab)
|
|
) {
|
|
setActiveTab("mine");
|
|
}
|
|
}, [activeTab, squareTabs, isAdmin]);
|
|
|
|
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),
|
|
// "内置" to "builtin" (admin-only, user_id IS NULL). 「舆情分析」是前端虚拟
|
|
// 类型,不查后端列表。
|
|
const isSquareTab =
|
|
activeTab !== "mine" &&
|
|
activeTab !== "private" &&
|
|
activeTab !== "builtin" &&
|
|
activeTab !== "sentiment";
|
|
const mainScope =
|
|
activeTab === "mine"
|
|
? "mine"
|
|
: activeTab === "builtin"
|
|
? "builtin"
|
|
: "square";
|
|
const mainPage = pages[activeTab] ?? 1;
|
|
const {
|
|
agents: mainAgents,
|
|
total: mainTotal,
|
|
isLoading: mainLoading,
|
|
isFetching: mainFetching,
|
|
} = useAgentsPaginated({
|
|
scope: mainScope,
|
|
search,
|
|
tagIds,
|
|
sort,
|
|
page: mainPage,
|
|
pageSize: PAGE_SIZE,
|
|
enabled: activeTab !== "private" && activeTab !== "sentiment",
|
|
owner: publisher?.id,
|
|
// Each square tab fetches only that square's agents.
|
|
square: isSquareTab ? activeTab : undefined,
|
|
});
|
|
|
|
const {
|
|
agents: privateAgents,
|
|
total: privateTotal,
|
|
isLoading: isPrivateLoading,
|
|
isFetching: isPrivateFetching,
|
|
error: privateError,
|
|
} = usePrivateAgentsPaginated(privatePassword, {
|
|
search,
|
|
tagIds,
|
|
sort,
|
|
page: pages.private ?? 1,
|
|
pageSize: PAGE_SIZE,
|
|
enabled: activeTab === "private",
|
|
owner: publisher?.id,
|
|
});
|
|
|
|
// 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]);
|
|
|
|
// The private tab is only "unlocked" once the feature is usable and a
|
|
// 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 !== "sentiment" &&
|
|
(activeTab !== "private" || privateUnlocked);
|
|
|
|
const activeTotal =
|
|
activeTab === "sentiment"
|
|
? 1
|
|
: activeTab === "private"
|
|
? privateTotal
|
|
: mainTotal;
|
|
const setTabPage = (tab: string, 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 handlePublisherClick = (p: { id: string; label: string }) =>
|
|
setPublisher(p);
|
|
|
|
const renderGrid = (list: Agent[]) =>
|
|
viewMode === "list" ? (
|
|
<div className="flex flex-col gap-2">
|
|
{list.map((agent) => (
|
|
<AgentCard
|
|
key={agent.id}
|
|
agent={agent}
|
|
layout="list"
|
|
onPublisherClick={handlePublisherClick}
|
|
/>
|
|
))}
|
|
</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}
|
|
onPublisherClick={handlePublisherClick}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
/** Loading / empty / (grid + pagination) for one paginated tab. */
|
|
const renderListBody = (opts: {
|
|
list: Agent[];
|
|
total: number;
|
|
isLoading: boolean;
|
|
isFetching: boolean;
|
|
tab: string;
|
|
emptyDescription: string;
|
|
showNewButton?: boolean;
|
|
}) => {
|
|
const { list, total, isLoading, isFetching, tab, emptyDescription, showNewButton } =
|
|
opts;
|
|
// keepPreviousData keeps the old page visible while a new page loads, so
|
|
// this full-height spinner only covers the very first load (no rows yet),
|
|
// or a filter change while the previous result was already empty (where
|
|
// isLoading stays false but there is nothing to keep on screen).
|
|
if ((isLoading || isFetching) && list.length === 0) {
|
|
return (
|
|
<div className="text-muted-foreground flex h-40 items-center justify-center gap-2 text-sm">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
{t.common.loading}
|
|
</div>
|
|
);
|
|
}
|
|
if (list.length === 0) {
|
|
return renderEmpty(emptyDescription, showNewButton);
|
|
}
|
|
return (
|
|
<div className="relative flex flex-col gap-4">
|
|
{/* While a server-side search/tag/page query is in flight the stale
|
|
keepPreviousData rows stay mounted — dim them and float a spinner
|
|
on top so a slow backend query never looks like the filter was
|
|
ignored, and block clicks on results that are about to change. */}
|
|
<div
|
|
className={
|
|
isFetching
|
|
? "pointer-events-none opacity-40 transition-opacity duration-200"
|
|
: "transition-opacity duration-200"
|
|
}
|
|
aria-busy={isFetching}
|
|
>
|
|
{renderGrid(list)}
|
|
</div>
|
|
{isFetching && (
|
|
<div className="absolute inset-0 z-10 flex items-start justify-center pt-24">
|
|
<div className="bg-background/90 text-muted-foreground flex items-center gap-2 rounded-full border px-4 py-2 text-sm shadow-sm">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
正在查询…
|
|
</div>
|
|
</div>
|
|
)}
|
|
<Pagination
|
|
page={pages[tab] ?? 1}
|
|
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>
|
|
<form
|
|
className="flex flex-col gap-2"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
handleSubmitPassword();
|
|
}}
|
|
>
|
|
<label className="sr-only" htmlFor="private-square-access-code">
|
|
访问密码
|
|
</label>
|
|
<Input
|
|
id="private-square-access-code"
|
|
type="password"
|
|
placeholder="访问密码"
|
|
value={passwordInput}
|
|
onChange={(e) => setPasswordInput(e.target.value)}
|
|
// 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 type="submit">进入私密广场</Button>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return renderListBody({
|
|
list: privateAgents,
|
|
total: privateTotal,
|
|
isLoading: isPrivateLoading,
|
|
isFetching: isPrivateFetching,
|
|
tab: "private",
|
|
emptyDescription: "私密广场暂时还没有智能体。",
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="flex size-full min-h-0 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>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-muted-foreground hover:text-foreground"
|
|
title="重新播放智能体使用引导"
|
|
onClick={() => startAgentGuide("list")}
|
|
>
|
|
<Compass className="mr-1.5 h-4 w-4" />
|
|
使用引导
|
|
</Button>
|
|
<Button onClick={handleNewAgent} data-agent-guide="new-agent-btn">
|
|
<PlusIcon className="mr-1.5 h-4 w-4" />
|
|
{t.agents.newAgent}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={(v) => {
|
|
setActiveTab(v);
|
|
// Each tab starts from a clean slate: switching tabs clears the
|
|
// keyword / tag / sort / publisher filters instead of carrying them
|
|
// over. The TagSearchBar's own input box is reset via its key below.
|
|
setSearch("");
|
|
setTagIds([]);
|
|
setSort("time_desc");
|
|
setPublisher(null);
|
|
}}
|
|
className="flex min-h-0 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 max-w-full flex-wrap justify-start self-start"
|
|
data-agent-guide="tabs"
|
|
>
|
|
<TabsTrigger value="mine">我的</TabsTrigger>
|
|
{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>
|
|
))}
|
|
{privateFeatureAvailable && (
|
|
<TabsTrigger value="private">
|
|
<LockIcon className="mr-1 h-3.5 w-3.5" />
|
|
私密广场
|
|
</TabsTrigger>
|
|
)}
|
|
{isAdmin && (
|
|
<TabsTrigger value="builtin" title="内置/功能型智能体(仅管理员可见)">
|
|
<BotIcon 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>
|
|
{showSearchBar && (
|
|
<div data-agent-guide="search">
|
|
<TagSearchBar
|
|
// Remount per tab so the internal keyword input / 展开 state also
|
|
// resets when the parent-owned filters are cleared on tab switch.
|
|
key={activeTab}
|
|
targetType="agent"
|
|
searching={
|
|
activeTab === "private" ? isPrivateFetching : mainFetching
|
|
}
|
|
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>
|
|
)}
|
|
{publisher && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground text-xs">筛选发布者:</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPublisher(null)}
|
|
className="bg-primary/10 text-primary hover:bg-primary/20 inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors"
|
|
title="点击清除发布者筛选"
|
|
>
|
|
{publisher.label}
|
|
<XIcon className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{squareTabs.map((sq) => (
|
|
<TabsContent
|
|
key={sq.id}
|
|
value={sq.id}
|
|
className="flex-1 overflow-y-auto p-6 mt-0"
|
|
>
|
|
{renderListBody({
|
|
list: mainAgents,
|
|
total: mainTotal,
|
|
isLoading: mainLoading,
|
|
isFetching: mainFetching,
|
|
tab: sq.id,
|
|
emptyDescription: `「${sq.name}」广场暂时还没有智能体。`,
|
|
})}
|
|
</TabsContent>
|
|
))}
|
|
|
|
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
|
|
{renderListBody({
|
|
list: mainAgents,
|
|
total: mainTotal,
|
|
isLoading: mainLoading,
|
|
isFetching: mainFetching,
|
|
tab: "mine",
|
|
emptyDescription: t.agents.emptyDescription,
|
|
showNewButton: true,
|
|
})}
|
|
</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({
|
|
list: mainAgents,
|
|
total: mainTotal,
|
|
isLoading: mainLoading,
|
|
isFetching: mainFetching,
|
|
tab: "builtin",
|
|
emptyDescription: "暂时没有内置智能体。",
|
|
})}
|
|
</TabsContent>
|
|
)}
|
|
|
|
{privateFeatureAvailable && (
|
|
<TabsContent
|
|
value="private"
|
|
className="flex-1 overflow-y-auto p-6 mt-0"
|
|
>
|
|
{renderPrivateTab()}
|
|
</TabsContent>
|
|
)}
|
|
</Tabs>
|
|
<AgentGuide
|
|
stage="list"
|
|
eligible={isAgentGuideEnvironment()}
|
|
autoStart={false}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|