feat: 支持智能体置顶配置

This commit is contained in:
吉鑫升 committed 2026-06-04 16:58:42 +08:00
1 parent 1f2b1e3b8a
commit bcebc8d5fb
20 files changed
+852 -310

No files matched your search

@@ -6,8 +6,9 @@ import {
GlobeIcon,
InfoIcon,
LockIcon,
MessageSquareIcon,
ShieldIcon,
MessageSquareIcon,
PinIcon,
ShieldIcon,
SparklesIcon,
SquarePenIcon,
StarIcon,
@@ -53,10 +54,11 @@ import {
useAgent,
useCreateAgent,
useDeleteAgent,
useSetAgentFeatured,
useSetAgentPrivate,
useToggleAgentFavorite,
useUpdateAgent,
useSetAgentFeatured,
useSetAgentPrivate,
useToggleAgentFavorite,
useToggleAgentPin,
useUpdateAgent,
} from "@/core/agents";
import { getAgent } from "@/core/agents/api";
import type { Agent } from "@/core/agents";
@@ -186,8 +188,9 @@ export function AgentCard({ agent }: AgentCardProps) {
const updateAgent = useUpdateAgent();
const createAgent = useCreateAgent();
const setFeatured = useSetAgentFeatured();
const setPrivate = useSetAgentPrivate();
const toggleFavorite = useToggleAgentFavorite();
const setPrivate = useSetAgentPrivate();
const toggleFavorite = useToggleAgentFavorite();
const togglePin = useToggleAgentPin();
const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
@@ -225,7 +228,8 @@ export function AgentCard({ agent }: AgentCardProps) {
// Favoriting collects a square agent into "我的"; pointless for one you
// already own, so only offer it on built-in / others' agents.
const canFavorite = !isOwned;
const isFavorite = !!agent.is_favorite;
const isFavorite = !!agent.is_favorite;
const isPinned = !!agent.is_pinned;
useEffect(() => {
if (!editOpen) {
@@ -379,7 +383,7 @@ export function AgentCard({ agent }: AgentCardProps) {
}
}
async function handleToggleFavorite() {
async function handleToggleFavorite() {
const next = !isFavorite;
try {
await toggleFavorite.mutateAsync({ id: agent.id, favorite: next });
@@ -387,7 +391,17 @@ export function AgentCard({ agent }: AgentCardProps) {
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
}
async function handleTogglePin() {
const next = !isPinned;
try {
await togglePin.mutateAsync({ id: agent.id, pinned: next });
toast.success(next ? "已置顶" : "已取消置顶");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleToggleFeatured() {
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
@@ -552,8 +566,8 @@ export function AgentCard({ agent }: AgentCardProps) {
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3">
{(canEdit || canDelete || isOwned || isAdmin || canFavorite) && (
<div className="flex flex-wrap items-center justify-end gap-1">
{canFavorite && (
<Button
{canFavorite && (
<Button
size="icon"
variant="ghost"
className={cn(
@@ -567,9 +581,23 @@ export function AgentCard({ agent }: AgentCardProps) {
<StarIcon
className={cn("h-3.5 w-3.5", isFavorite && "fill-current")}
/>
</Button>
)}
{isAdmin && (
</Button>
)}
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
isPinned && "text-emerald-600 hover:text-emerald-700",
!canFavorite && "mr-auto",
)}
onClick={() => void handleTogglePin()}
disabled={togglePin.isPending}
title={isPinned ? "取消置顶" : "置顶"}
>
<PinIcon className={cn("h-3.5 w-3.5", isPinned && "fill-current")} />
</Button>
{isAdmin && (
<Button
size="icon"
variant="ghost"
@@ -1,6 +1,6 @@
"use client";
import { BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
import { ArrowDownUpIcon, BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -8,10 +8,11 @@ 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 { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import type { Agent } from "@/core/agents";
import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { usePrivateSquareStatus } from "@/core/system-settings/hooks";
@@ -32,6 +33,7 @@ export function AgentGallery() {
);
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 },
@@ -44,7 +46,7 @@ export function AgentGallery() {
const tagKey = tagIds.join(",");
useEffect(() => {
setPages({ all: 1, mine: 1, private: 1 });
}, [search, tagKey]);
}, [search, tagKey, sort]);
const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled;
@@ -69,6 +71,7 @@ export function AgentGallery() {
scope: mainScope,
search,
tagIds,
sort,
page: mainPage,
pageSize: PAGE_SIZE,
enabled: activeTab !== "private",
@@ -82,6 +85,7 @@ export function AgentGallery() {
} = usePrivateAgentsPaginated(privatePassword, {
search,
tagIds,
sort,
page: pages.private,
pageSize: PAGE_SIZE,
enabled: activeTab === "private",
@@ -98,7 +102,6 @@ export function AgentGallery() {
}, [privateError, privatePassword]);
const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
const setTabPage = (tab: "all" | "mine" | "private", page: number) =>
setPages((prev) => ({ ...prev, [tab]: page }));
@@ -240,6 +243,15 @@ export function AgentGallery() {
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>
@@ -314,6 +326,25 @@ export function AgentGallery() {
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>
@@ -7,7 +7,7 @@ import {
Settings2Icon,
Trash2Icon,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -193,6 +193,19 @@ function ManagePromptsDialog({
const agentNameById = (id?: string | null) =>
id ? agents.find((a) => a.id === id)?.name ?? null : null;
// Only offer the agents the user has favorited (收藏到「我的」) for
// association — the full catalog is huge, mostly irrelevant, and overflowed
// the dropdown. The agent a prompt is *already* bound to is kept in the list
// even if it is no longer a favorite, so editing never silently drops it.
const selectableAgents = useMemo(() => {
const favorites = agents.filter((a) => a.is_favorite);
if (agentId !== NO_AGENT && !favorites.some((a) => a.id === agentId)) {
const bound = agents.find((a) => a.id === agentId);
if (bound) return [bound, ...favorites];
}
return favorites;
}, [agents, agentId]);
const resetForm = () => {
setEditingId(null);
setTitle("");
@@ -299,23 +312,27 @@ function ManagePromptsDialog({
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">关联智能体(可选)</p>
<Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate [&>span]:text-left">
<SelectValue placeholder="不关联(所有对话可见)" />
</SelectTrigger>
<SelectContent
position="popper"
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
className="max-h-[40vh] w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
>
<SelectItem value={NO_AGENT}>不关联(所有对话可见)</SelectItem>
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<span className="block truncate">{desensitize(agent.name)}</span>
{selectableAgents.map((agent) => (
<SelectItem key={agent.id} value={agent.id} className="pr-8">
<span className="block max-w-full truncate">
{desensitize(agent.name)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
关联后,仅在选中该智能体的对话里显示此提示词。
{selectableAgents.length === 0
? "仅可关联你收藏到「我的」的常用智能体;当前还没有收藏任何智能体。"
: "关联后,仅在选中该智能体的对话里显示此提示词。这里只列出你收藏的常用智能体。"}
</p>
</div>
<div className="flex justify-end gap-2">
@@ -1,9 +1,10 @@
"use client";
import {
AlertCircle,
ArchiveIcon,
CheckCircle2,
AlertCircle,
ArchiveIcon,
ArrowDownUpIcon,
CheckCircle2,
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon,
@@ -51,8 +52,9 @@ import {
ItemContent,
ItemDescription,
} from "@/components/ui/item";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
@@ -91,7 +93,16 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
);
}
const PAGE_SIZE = 10;
const PAGE_SIZE = 10;
type SkillSort =
| "time_desc"
| "time_asc"
| "activity_desc"
| "activity_asc"
| "usage_desc"
| "usage_asc"
| "name_asc"
| "name_desc";
/** 重新扫描成功 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */
const rescanSuccessToastDarkOptions = {
@@ -134,12 +145,13 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const [installOpen, setInstallOpen] = useState(false);
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const [page, setPage] = useState(1);
const reconcileMutation = useReconcileCustomSkills();
// Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagIds });
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<SkillSort>("time_desc");
const [page, setPage] = useState(1);
const reconcileMutation = useReconcileCustomSkills();
// Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
@@ -191,9 +203,12 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const totalPages = Math.max(1, Math.ceil(filteredSkills.length / PAGE_SIZE));
// Filter / search / tag changes may shrink the result set — pull the page
// back into range without forcing a reset on every keystroke.
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
useEffect(() => {
setPage(1);
}, [search, tagIds, sort]);
const pagedSkills = useMemo(
() => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[filteredSkills, page],
@@ -309,12 +324,33 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
</Button>
</div>
</header>
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
/>
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
<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="activity_desc">最近使用</SelectItem>
<SelectItem value="activity_asc">最早使用</SelectItem>
<SelectItem value="usage_desc">使用最多</SelectItem>
<SelectItem value="usage_asc">使用最少</SelectItem>
<SelectItem value="name_asc">名称 A-Z</SelectItem>
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
}
/>
{isLoading && (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
)}
@@ -1,7 +1,7 @@
"use client";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, SearchIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Input } from "@/components/ui/input";
import { useI18n } from "@/core/i18n/hooks";
@@ -31,12 +31,14 @@ export function TagSearchBar({
tagIds,
onTagIdsChange,
className,
actions,
}: {
targetType: TagType;
onSearchChange: (value: string) => void;
tagIds: string[];
onTagIdsChange: (value: string[]) => void;
className?: string;
actions?: ReactNode;
}) {
const { t } = useI18n();
const { data: tags } = useTags(targetType);
@@ -67,14 +69,31 @@ export function TagSearchBar({
return (
<div className={cn("flex flex-col gap-2.5", className)}>
<div className="relative">
<SearchIcon className="text-muted-foreground dark:text-[#60a5fa]/90 absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t.adminTags.searchPlaceholder}
className="pl-8"
/>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative min-w-0 flex-1">
<SearchIcon className="text-muted-foreground dark:text-[#60a5fa]/90 absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t.adminTags.searchPlaceholder}
className="pl-8"
name="tag-search"
// A `type="password"` field elsewhere on the page (e.g. the private
// square access gate) makes browsers / password managers pick the
// nearest text input as the "username" and autofill the account into
// it. Mark this search box so it is never treated as a credential
// field.
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
/>
</div>
{actions && (
<div className="flex w-full shrink-0 justify-end sm:w-auto">
{actions}
</div>
)}
</div>
{allTags.length > 0 && (