wip: 合并 master 前暂存本地改动

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AdministratorandClaude Opus 4.8 committed 2026-06-10 08:36:47 +08:00
1 parent 1ce15fbb15
commit 44c0f6189b
13 files changed
+712 -444

No files matched your search

@@ -8,6 +8,7 @@ import {
LockIcon, LockIcon,
MessageSquareIcon, MessageSquareIcon,
PinIcon, PinIcon,
SearchIcon,
ShieldIcon, ShieldIcon,
SparklesIcon, SparklesIcon,
SquarePenIcon, SquarePenIcon,
@@ -74,6 +75,9 @@ interface AgentCardProps {
/** "card" (default) renders the full gallery card; "list" renders a compact /** "card" (default) renders the full gallery card; "list" renders a compact
* single-row layout for the list view. All dialogs/actions are shared. */ * single-row layout for the list view. All dialogs/actions are shared. */
layout?: "card" | "list"; layout?: "card" | "list";
/** Click the 发布者 line to filter the gallery to that publisher. Receives the
* owner user_id + its desensitized display label. */
onPublisherClick?: (publisher: { id: string; label: string }) => void;
} }
interface BadgeItem { interface BadgeItem {
@@ -185,7 +189,7 @@ function ClampedBadgeRow({ items }: { items: BadgeItem[] }) {
); );
} }
export function AgentCard({ agent, layout = "card" }: AgentCardProps) { export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCardProps) {
const isList = layout === "list"; const isList = layout === "list";
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
@@ -208,6 +212,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
const [selectedSkills, setSelectedSkills] = useState<string[]>( const [selectedSkills, setSelectedSkills] = useState<string[]>(
agent.skills ?? [], agent.skills ?? [],
); );
const [skillSearch, setSkillSearch] = useState("");
const [detailSkillName, setDetailSkillName] = useState<string | null>(null); const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
// The /api/agents list response intentionally omits `soul` (perf optimization // The /api/agents list response intentionally omits `soul` (perf optimization
@@ -245,6 +250,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
setEditPublished(agent.published); setEditPublished(agent.published);
setEditIsPrivate(agent.is_private); setEditIsPrivate(agent.is_private);
setSelectedSkills(agent.skills ?? []); setSelectedSkills(agent.skills ?? []);
setSkillSearch("");
}, [ }, [
agent.description, agent.description,
agent.is_private, agent.is_private,
@@ -437,7 +443,24 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
}); });
} }
const enabledSkills = skills.filter((skill) => skill.enabled); const enabledSkills = useMemo(
() => skills.filter((skill) => skill.enabled),
[skills],
);
// Search over English name, Chinese name (name_zh) and description, so both
// 中英文 work. Already-selected skills stay searchable too (selection is
// independent of this filter).
const filteredSkills = useMemo(() => {
const q = skillSearch.trim().toLowerCase();
if (!q) return enabledSkills;
return enabledSkills.filter(
(skill) =>
skill.name.toLowerCase().includes(q) ||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
(skill.description ?? "").toLowerCase().includes(q),
);
}, [enabledSkills, skillSearch]);
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by // Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
// both the card header and the compact list row. // both the card header and the compact list row.
@@ -498,16 +521,36 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
</> </>
); );
// 发布者(仅自定义/已发布的智能体显示;内置无 owner 不显示)。 // 发布者(仅自定义/已发布的智能体显示;内置无 owner 不显示)。点击可按发布者筛选。
const publisherLabel = agent.owner_username
? desensitize(agent.owner_username)
: "";
const canFilterByPublisher =
!isBuiltin && !!agent.owner_username && !!agent.user_id && !!onPublisherClick;
const publisherNode = const publisherNode =
!isBuiltin && agent.owner_username ? ( !isBuiltin && agent.owner_username ? (
<div <button
className="text-muted-foreground flex items-center gap-1 text-xs" type="button"
title={`发布者:${desensitize(agent.owner_username)}`} disabled={!canFilterByPublisher}
onClick={(e) => {
if (!canFilterByPublisher) return;
e.stopPropagation();
onPublisherClick!({ id: agent.user_id!, label: publisherLabel });
}}
className={cn(
"text-muted-foreground flex max-w-full items-center gap-1 text-sm font-medium",
canFilterByPublisher &&
"hover:text-primary cursor-pointer transition-colors hover:underline",
)}
title={
canFilterByPublisher
? `点击筛选发布者:${publisherLabel}`
: `发布者:${publisherLabel}`
}
> >
<UserIcon className="size-3 shrink-0" /> <UserIcon className="size-3.5 shrink-0" />
<span className="truncate">发布者:{desensitize(agent.owner_username)}</span> <span className="truncate">发布者:{publisherLabel}</span>
</div> </button>
) : null; ) : null;
// 技能英文名 → 中文名映射,用于把智能体的技能徽章显示为中文(agent.skills 只存英文名)。 // 技能英文名 → 中文名映射,用于把智能体的技能徽章显示为中文(agent.skills 只存英文名)。
@@ -816,6 +859,17 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
已选 {selectedSkills.length} 个 已选 {selectedSkills.length} 个
</span> </span>
</div> </div>
{enabledSkills.length > 0 && (
<div className="relative">
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
<Input
placeholder="搜索技能(支持中英文名称、描述)…"
value={skillSearch}
onChange={(e) => setSkillSearch(e.currentTarget.value)}
className="h-8 pl-7 text-xs"
/>
</div>
)}
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2"> <div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
{isSkillsLoading ? ( {isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p> <p className="text-muted-foreground text-sm">技能加载中...</p>
@@ -823,9 +877,11 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
当前没有可用技能 当前没有可用技能
</p> </p>
) : filteredSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">没有匹配的技能</p>
) : ( ) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{enabledSkills.map((skill) => { {filteredSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name); const checked = selectedSkills.includes(skill.name);
return ( return (
<div <div
@@ -1,6 +1,6 @@
"use client"; "use client";
import { ArrowDownUpIcon, BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react"; import { ArrowDownUpIcon, BotIcon, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -36,6 +36,11 @@ export function AgentGallery() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]); const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<AgentSort>("time_desc"); const [sort, setSort] = useState<AgentSort>("time_desc");
// 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, preserved while switching tabs. // Per-tab 1-based page index, preserved while switching tabs.
const [pages, setPages] = useState<{ all: number; mine: number; private: number }>( const [pages, setPages] = useState<{ all: number; mine: number; private: number }>(
{ all: 1, mine: 1, private: 1 }, { all: 1, mine: 1, private: 1 },
@@ -48,7 +53,7 @@ export function AgentGallery() {
const tagKey = tagIds.join(","); const tagKey = tagIds.join(",");
useEffect(() => { useEffect(() => {
setPages({ all: 1, mine: 1, private: 1 }); setPages({ all: 1, mine: 1, private: 1 });
}, [search, tagKey, sort]); }, [search, tagKey, sort, publisher?.id]);
const { data: privateStatus } = usePrivateSquareStatus(); const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled; const privateFeatureAvailable = !!privateStatus?.enabled;
@@ -77,6 +82,7 @@ export function AgentGallery() {
page: mainPage, page: mainPage,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
enabled: activeTab !== "private", enabled: activeTab !== "private",
owner: publisher?.id,
}); });
const { const {
@@ -91,6 +97,7 @@ export function AgentGallery() {
page: pages.private, page: pages.private,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
enabled: activeTab === "private", enabled: activeTab === "private",
owner: publisher?.id,
}); });
// If the server reports the stored password no longer works (likely the // If the server reports the stored password no longer works (likely the
@@ -127,17 +134,29 @@ export function AgentGallery() {
setPrivatePassword(null); setPrivatePassword(null);
}; };
const handlePublisherClick = (p: { id: string; label: string }) =>
setPublisher(p);
const renderGrid = (list: Agent[]) => const renderGrid = (list: Agent[]) =>
viewMode === "list" ? ( viewMode === "list" ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{list.map((agent) => ( {list.map((agent) => (
<AgentCard key={agent.id} agent={agent} layout="list" /> <AgentCard
key={agent.id}
agent={agent}
layout="list"
onPublisherClick={handlePublisherClick}
/>
))} ))}
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((agent) => ( {list.map((agent) => (
<AgentCard key={agent.id} agent={agent} /> <AgentCard
key={agent.id}
agent={agent}
onPublisherClick={handlePublisherClick}
/>
))} ))}
</div> </div>
); );
@@ -356,6 +375,20 @@ export function AgentGallery() {
</Select> </Select>
} }
/> />
{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> </div>
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0"> <TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
@@ -23,6 +23,7 @@ import {
TrendingUpIcon, TrendingUpIcon,
Upload, Upload,
UserIcon, UserIcon,
XIcon,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -154,6 +155,11 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const [tagIds, setTagIds] = useState<string[]>([]); const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<SkillSort>("time_desc"); const [sort, setSort] = useState<SkillSort>("time_desc");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Publisher filter: click a skill's 发布者 to keep only that user's skills.
// {id} is owner_user_id; {label} the desensitized display name.
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
null,
);
const [viewMode, setViewMode] = useViewMode("skill"); const [viewMode, setViewMode] = useViewMode("skill");
const reconcileMutation = useReconcileCustomSkills(); const reconcileMutation = useReconcileCustomSkills();
const fillNamesMutation = useGenerateMissingSkillNameZh(); const fillNamesMutation = useGenerateMissingSkillNameZh();
@@ -203,7 +209,14 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
}; };
}, [skills, currentUserId]); }, [skills, currentUserId]);
const filteredSkills = filter === "mine" ? activeMine : activeAll; const scopedSkills = filter === "mine" ? activeMine : activeAll;
const filteredSkills = useMemo(
() =>
publisher
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
: scopedSkills,
[scopedSkills, publisher],
);
const orderedSkills = useMemo( const orderedSkills = useMemo(
() => () =>
filteredSkills filteredSkills
@@ -228,7 +241,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
}, [page, totalPages]); }, [page, totalPages]);
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
}, [search, tagIds, sort]); }, [search, tagIds, sort, publisher?.id, filter]);
const pagedSkills = useMemo( const pagedSkills = useMemo(
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), () => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[orderedSkills, page], [orderedSkills, page],
@@ -344,12 +357,13 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="gap-1.5 border-violet-300 bg-violet-50 font-medium text-violet-700 hover:bg-violet-100 hover:text-violet-800 dark:border-violet-500/40 dark:bg-violet-500/15 dark:text-violet-300 dark:hover:bg-violet-500/25"
onClick={() => void handleFillNames()} onClick={() => void handleFillNames()}
disabled={ disabled={
fillNamesMutation.isPending || fillNamesMutation.isPending ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
} }
title="为所有尚未配置中文名称的技能自动生成中文名" title="用大模型为所有尚未配置中文名称的技能自动生成中文名"
> >
{fillNamesMutation.isPending ? ( {fillNamesMutation.isPending ? (
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
@@ -399,6 +413,20 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
</div> </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>
)}
{isLoading && ( {isLoading && (
<div className="text-muted-foreground text-sm">{t.common.loading}</div> <div className="text-muted-foreground text-sm">{t.common.loading}</div>
)} )}
@@ -421,6 +449,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
currentUserId={currentUserId} currentUserId={currentUserId}
isAdmin={isAdmin} isAdmin={isAdmin}
layout={viewMode === "card" ? "card" : "row"} layout={viewMode === "card" ? "card" : "row"}
onPublisherClick={setPublisher}
/> />
))} ))}
</div> </div>
@@ -478,12 +507,16 @@ export function SkillRow({
currentUserId, currentUserId,
isAdmin, isAdmin,
layout = "row", layout = "row",
onPublisherClick,
}: { }: {
skill: Skill; skill: Skill;
currentUserId: string | null; currentUserId: string | null;
isAdmin: boolean; isAdmin: boolean;
/** "row" = full-width horizontal item (default); "card" = grid card cell. */ /** "row" = full-width horizontal item (default); "card" = grid card cell. */
layout?: "row" | "card"; layout?: "row" | "card";
/** Click the 发布者 line to filter the list to that publisher. Receives the
* owner_user_id + its desensitized display label. */
onPublisherClick?: (publisher: { id: string; label: string }) => void;
}) { }) {
const isCard = layout === "card"; const isCard = layout === "card";
const { t } = useI18n(); const { t } = useI18n();
@@ -674,10 +707,31 @@ export function SkillRow({
const metaNodes = ( const metaNodes = (
<> <>
{!isBuiltin && skill.owner_username && ( {!isBuiltin && skill.owner_username && (
<div className="text-muted-foreground mt-1 flex items-center gap-1 text-xs"> <button
<UserIcon className="size-3" /> type="button"
发布者:{desensitize(skill.owner_username)} disabled={!skill.owner_user_id || !onPublisherClick}
</div> onClick={(e) => {
if (!skill.owner_user_id || !onPublisherClick) return;
e.stopPropagation();
onPublisherClick({
id: skill.owner_user_id,
label: desensitize(skill.owner_username!),
});
}}
className={cn(
"text-muted-foreground mt-1 flex max-w-full items-center gap-1 text-sm font-medium",
skill.owner_user_id && onPublisherClick &&
"hover:text-primary cursor-pointer transition-colors hover:underline",
)}
title={
skill.owner_user_id && onPublisherClick
? `点击筛选发布者:${desensitize(skill.owner_username)}`
: `发布者:${desensitize(skill.owner_username)}`
}
>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">发布者:{desensitize(skill.owner_username)}</span>
</button>
)} )}
{skill.use_count !== undefined && ( {skill.use_count !== undefined && (
<div className="text-muted-foreground mt-1 text-xs"> <div className="text-muted-foreground mt-1 text-xs">
@@ -11,6 +11,7 @@ import {
PencilIcon, PencilIcon,
SlidersHorizontalIcon, SlidersHorizontalIcon,
SparklesIcon, SparklesIcon,
XIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -106,6 +107,7 @@ export function SkillDetailDialog({
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [editingNameZh, setEditingNameZh] = useState(false);
const [nameZhDraft, setNameZhDraft] = useState(""); const [nameZhDraft, setNameZhDraft] = useState("");
const [activeTab, setActiveTab] = useState< const [activeTab, setActiveTab] = useState<
"detail" | "display" | "source" | "files" | null "detail" | "display" | "source" | "files" | null
@@ -137,6 +139,7 @@ export function SkillDetailDialog({
useEffect(() => { useEffect(() => {
setEditing(false); setEditing(false);
setEditingNameZh(false);
setActiveTab(null); setActiveTab(null);
setShowRawSource(false); setShowRawSource(false);
setOpenedFilePath(null); setOpenedFilePath(null);
@@ -169,6 +172,11 @@ export function SkillDetailDialog({
} }
} }
function startEditingNameZh() {
setNameZhDraft(skill?.name_zh ?? "");
setEditingNameZh(true);
}
async function handleSaveNameZh() { async function handleSaveNameZh() {
if (!skill) return; if (!skill) return;
try { try {
@@ -177,6 +185,7 @@ export function SkillDetailDialog({
nameZh: nameZhDraft.trim(), nameZh: nameZhDraft.trim(),
}); });
toast.success("中文名称已保存"); toast.success("中文名称已保存");
setEditingNameZh(false);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : String(err)); toast.error(err instanceof Error ? err.message : String(err));
} }
@@ -215,32 +224,99 @@ export function SkillDetailDialog({
<SparklesIcon className="size-5" /> <SparklesIcon className="size-5" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> {editingNameZh ? (
<DialogTitle className="truncate text-base"> <div className="flex items-center gap-1.5">
{skill?.name_zh?.trim() <DialogTitle className="sr-only">
? skill.name_zh
: desensitize(skill?.name ?? skillName ?? "")}
</DialogTitle>
{skill?.name_zh?.trim() && (
<span className="text-muted-foreground truncate text-xs">
{desensitize(skill?.name ?? skillName ?? "")} {desensitize(skill?.name ?? skillName ?? "")}
</span> </DialogTitle>
)} <Input
{skill?.builtin && ( value={nameZhDraft}
<Badge variant="secondary" className="text-xs"> onChange={(event) => setNameZhDraft(event.currentTarget.value)}
{tx.badgeBuiltin} onKeyDown={(event) => {
</Badge> if (event.key === "Enter") void handleSaveNameZh();
)} if (event.key === "Escape") setEditingNameZh(false);
{skill?.published && ( }}
<Badge placeholder={desensitize(skill?.name ?? skillName ?? "")}
className="h-8 max-w-[18rem] flex-1 text-sm"
autoFocus
disabled={generateNameZh.isPending || setNameZh.isPending}
/>
<Button
size="sm"
variant="outline" variant="outline"
className="border-sky-500/30 text-xs text-sky-700 dark:text-sky-300" className="h-8 shrink-0 gap-1 border-violet-300 bg-violet-50 px-2.5 text-xs font-medium text-violet-700 hover:bg-violet-100 hover:text-violet-800 dark:border-violet-500/40 dark:bg-violet-500/15 dark:text-violet-300 dark:hover:bg-violet-500/25"
onClick={() => void handleGenerateNameZh()}
disabled={generateNameZh.isPending || setNameZh.isPending}
title="用大模型自动生成中文名"
> >
<GlobeIcon className="size-3" /> {generateNameZh.isPending ? (
{tx.badgePublished} <Loader2 className="size-3.5 animate-spin" />
</Badge> ) : (
)} <SparklesIcon className="size-3.5" />
</div> )}
AI 生成
</Button>
<Button
size="sm"
className="h-8 shrink-0 px-3 text-xs"
onClick={() => void handleSaveNameZh()}
disabled={setNameZh.isPending || generateNameZh.isPending}
>
{setNameZh.isPending && (
<Loader2 className="size-3.5 animate-spin" />
)}
{t.common.save}
</Button>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-foreground size-8 shrink-0"
onClick={() => setEditingNameZh(false)}
disabled={setNameZh.isPending}
title={t.common.cancel}
>
<XIcon className="size-4" />
</Button>
</div>
) : (
<div className="group flex flex-wrap items-center gap-2">
<DialogTitle className="truncate text-base">
{skill?.name_zh?.trim()
? skill.name_zh
: desensitize(skill?.name ?? skillName ?? "")}
</DialogTitle>
{skill?.name_zh?.trim() && (
<span className="text-muted-foreground truncate font-mono text-xs">
{desensitize(skill?.name ?? skillName ?? "")}
</span>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-foreground size-6 shrink-0 opacity-60 transition-opacity hover:opacity-100"
onClick={startEditingNameZh}
title={skill?.name_zh?.trim() ? "编辑中文名" : "设置中文名"}
>
<PencilIcon className="size-3" />
</Button>
)}
{skill?.builtin && (
<Badge variant="secondary" className="text-xs">
{tx.badgeBuiltin}
</Badge>
)}
{skill?.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-xs text-sky-700 dark:text-sky-300"
>
<GlobeIcon className="size-3" />
{tx.badgePublished}
</Badge>
)}
</div>
)}
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed"> <DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
{skill?.description ? desensitize(skill.description) : "-"} {skill?.description ? desensitize(skill.description) : "-"}
</DialogDescription> </DialogDescription>
@@ -336,57 +412,6 @@ export function SkillDetailDialog({
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden" className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
style={{ overflowY: "auto" }} style={{ overflowY: "auto" }}
> >
<div className="mb-5 rounded-lg border p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">中文名称</span>
{canManage && (
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() => void handleGenerateNameZh()}
disabled={generateNameZh.isPending || setNameZh.isPending}
>
{generateNameZh.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<SparklesIcon className="size-3.5" />
)}
AI 生成
</Button>
)}
</div>
{canManage ? (
<div className="mt-2 flex items-center gap-2">
<Input
value={nameZhDraft}
onChange={(event) => setNameZhDraft(event.currentTarget.value)}
placeholder="为该技能设置中文名称(留空则使用英文名)"
className="h-8 text-sm"
disabled={generateNameZh.isPending}
/>
<Button
size="sm"
className="h-8"
onClick={() => void handleSaveNameZh()}
disabled={
setNameZh.isPending ||
generateNameZh.isPending ||
nameZhDraft.trim() === (skill?.name_zh ?? "").trim()
}
>
{setNameZh.isPending && (
<Loader2 className="size-3.5 animate-spin" />
)}
{t.common.save}
</Button>
</div>
) : (
<p className="text-muted-foreground mt-1 text-sm">
{skill?.name_zh?.trim() ? skill.name_zh : "未设置"}
</p>
)}
</div>
{editing ? ( {editing ? (
<div className="space-y-2"> <div className="space-y-2">
<Textarea <Textarea
+62 -56
View File
@@ -2,13 +2,13 @@ import { getBackendBaseURL } from "@/core/config";
import { apiFetch } from "@/core/api"; import { apiFetch } from "@/core/api";
import type { import type {
Agent, Agent,
AgentSelectorResponse, AgentSelectorResponse,
AgentSort, AgentSort,
CreateAgentRequest, CreateAgentRequest,
PaginatedAgents, PaginatedAgents,
UpdateAgentRequest, UpdateAgentRequest,
} from "./types"; } from "./types";
const BACKEND_UNAVAILABLE_STATUSES = new Set([502, 503, 504]); const BACKEND_UNAVAILABLE_STATUSES = new Set([502, 503, 504]);
@@ -28,21 +28,24 @@ export interface ListAgentsParams {
/** Pagination scope. Omit for the legacy combined-visibility list. */ /** Pagination scope. Omit for the legacy combined-visibility list. */
scope?: "mine" | "square"; scope?: "mine" | "square";
/** 1-based page index. Providing this (or scope) enables server-side pagination. */ /** 1-based page index. Providing this (or scope) enables server-side pagination. */
page?: number; page?: number;
pageSize?: number; pageSize?: number;
sort?: AgentSort; sort?: AgentSort;
} /** Keep only agents published by this user id (the card's publisher). */
owner?: string;
}
function buildAgentsQuery(params: ListAgentsParams): string { function buildAgentsQuery(params: ListAgentsParams): string {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search); if (params.search) qs.set("search", params.search);
for (const id of params.tagIds ?? []) qs.append("tag_ids", id); for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
if (params.scope) qs.set("scope", params.scope); if (params.scope) qs.set("scope", params.scope);
if (params.page != null) qs.set("page", String(params.page)); if (params.page != null) qs.set("page", String(params.page));
if (params.pageSize != null) qs.set("page_size", String(params.pageSize)); if (params.pageSize != null) qs.set("page_size", String(params.pageSize));
if (params.sort) qs.set("sort", params.sort); if (params.sort) qs.set("sort", params.sort);
return qs.toString(); if (params.owner) qs.set("owner", params.owner);
} return qs.toString();
}
export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> { export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> {
const query = buildAgentsQuery(params); const query = buildAgentsQuery(params);
@@ -129,13 +132,13 @@ export async function deleteAgent(id: string): Promise<void> {
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`); if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
} }
export async function getAgentSelector(): Promise<AgentSelectorResponse> { export async function getAgentSelector(): Promise<AgentSelectorResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/selector`); const res = await apiFetch(`${getBackendBaseURL()}/api/agents/selector`);
if (!res.ok) throw new Error(`Failed to load agent selector: ${res.statusText}`); if (!res.ok) throw new Error(`Failed to load agent selector: ${res.statusText}`);
return res.json() as Promise<AgentSelectorResponse>; return res.json() as Promise<AgentSelectorResponse>;
} }
export async function favoriteAgent(id: string): Promise<void> { export async function favoriteAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, { const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
method: "POST", method: "POST",
}); });
@@ -145,35 +148,35 @@ export async function favoriteAgent(id: string): Promise<void> {
} }
} }
export async function unfavoriteAgent(id: string): Promise<void> { export async function unfavoriteAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, { const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
method: "DELETE", method: "DELETE",
}); });
if (!res.ok) { if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string }; const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to unfavorite agent: ${res.statusText}`); throw new Error(err.detail ?? `Failed to unfavorite agent: ${res.statusText}`);
} }
} }
export async function pinAgent(id: string): Promise<void> { export async function pinAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/pin`, { const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/pin`, {
method: "POST", method: "POST",
}); });
if (!res.ok) { if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string }; const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to pin agent: ${res.statusText}`); throw new Error(err.detail ?? `Failed to pin agent: ${res.statusText}`);
} }
} }
export async function unpinAgent(id: string): Promise<void> { export async function unpinAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/pin`, { const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/pin`, {
method: "DELETE", method: "DELETE",
}); });
if (!res.ok) { if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string }; const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to unpin agent: ${res.statusText}`); throw new Error(err.detail ?? `Failed to unpin agent: ${res.statusText}`);
} }
} }
export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> { export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
const res = await apiFetch( const res = await apiFetch(
@@ -211,14 +214,16 @@ export async function listPrivateAgents(password: string): Promise<Agent[]> {
return data.agents; return data.agents;
} }
export interface ListPrivateAgentsParams { export interface ListPrivateAgentsParams {
password: string; password: string;
search?: string; search?: string;
tagIds?: string[]; tagIds?: string[];
page?: number; page?: number;
pageSize?: number; pageSize?: number;
sort?: AgentSort; sort?: AgentSort;
} /** Keep only agents published by this user id (the card's publisher). */
owner?: string;
}
/** Paginated private-square listing (password-gated). */ /** Paginated private-square listing (password-gated). */
export async function listPrivateAgentsPaginated( export async function listPrivateAgentsPaginated(
@@ -232,11 +237,12 @@ export async function listPrivateAgentsPaginated(
body: JSON.stringify({ body: JSON.stringify({
password: params.password, password: params.password,
search: params.search || undefined, search: params.search || undefined,
tag_ids: params.tagIds && params.tagIds.length ? params.tagIds : undefined, tag_ids: params.tagIds && params.tagIds.length ? params.tagIds : undefined,
page: params.page, page: params.page,
page_size: params.pageSize, page_size: params.pageSize,
sort: params.sort, sort: params.sort,
}), owner: params.owner || undefined,
}),
}, },
); );
if (!res.ok) { if (!res.ok) {
+113 -96
View File
@@ -3,34 +3,34 @@ import {
useMutation, useMutation,
useQuery, useQuery,
useQueryClient, useQueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { import {
adminSyncAgentSkills, adminSyncAgentSkills,
createAgent, createAgent,
deleteAgent, deleteAgent,
favoriteAgent, favoriteAgent,
getAgent, getAgent,
getAgentSelector, getAgentSelector,
listAgents, listAgents,
listAgentsPaginated, listAgentsPaginated,
listAllAgentsAdmin, listAllAgentsAdmin,
listPrivateAgents, listPrivateAgents,
listPrivateAgentsPaginated, listPrivateAgentsPaginated,
pinAgent, pinAgent,
reorderFeaturedAgents, reorderFeaturedAgents,
setAgentFeatured, setAgentFeatured,
setAgentPrivate, setAgentPrivate,
unfavoriteAgent, unfavoriteAgent,
unpinAgent, unpinAgent,
updateAgent, updateAgent,
} from "./api"; } from "./api";
import type { import type {
Agent, Agent,
AgentSort, AgentSort,
CreateAgentRequest, CreateAgentRequest,
PaginatedAgents, PaginatedAgents,
UpdateAgentRequest, UpdateAgentRequest,
} from "./types"; } from "./types";
/** A cached agent listing is either a plain ``Agent[]`` (legacy ``useAgents``) /** A cached agent listing is either a plain ``Agent[]`` (legacy ``useAgents``)
@@ -79,31 +79,43 @@ export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
/** Server-side paginated agent listing for one gallery tab (scope). Returns /** Server-side paginated agent listing for one gallery tab (scope). Returns
* the page of agents plus the total count for the "共 N 个" header. */ * the page of agents plus the total count for the "共 N 个" header. */
export function useAgentsPaginated(params: { export function useAgentsPaginated(params: {
scope: "mine" | "square"; scope: "mine" | "square";
search?: string; search?: string;
tagIds?: string[]; tagIds?: string[];
sort?: AgentSort; sort?: AgentSort;
page: number; page: number;
pageSize: number; pageSize: number;
enabled?: boolean; enabled?: boolean;
}) { /** Keep only agents published by this user id (the card's publisher). */
const { scope, page, pageSize, enabled = true } = params; owner?: string;
const search = params.search ?? ""; }) {
const tagIds = params.tagIds ?? []; const { scope, page, pageSize, enabled = true } = params;
const sort = params.sort ?? "time_desc"; const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const owner = params.owner ?? "";
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: [ queryKey: [
"agents", "agents",
"paginated", "paginated",
scope, scope,
search, search,
[...tagIds].sort(), [...tagIds].sort(),
sort, sort,
page, page,
pageSize, pageSize,
], owner,
queryFn: () => ],
listAgentsPaginated({ scope, search, tagIds, sort, page, pageSize }), queryFn: () =>
listAgentsPaginated({
scope,
search,
tagIds,
sort,
page,
pageSize,
owner: owner || undefined,
}),
enabled, enabled,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
@@ -180,7 +192,7 @@ export function useDeleteAgent() {
/** Toggle a user's favorite (收藏) on an agent, with an optimistic flip of /** Toggle a user's favorite (收藏) on an agent, with an optimistic flip of
* ``is_favorite`` across every cached agent listing + detail so the star * ``is_favorite`` across every cached agent listing + detail so the star
* reacts instantly before the round-trip completes. */ * reacts instantly before the round-trip completes. */
export function useToggleAgentFavorite() { export function useToggleAgentFavorite() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) => mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) =>
@@ -196,30 +208,30 @@ export function useToggleAgentFavorite() {
onSettled: () => { onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ["agents"] }); void queryClient.invalidateQueries({ queryKey: ["agents"] });
}, },
}); });
} }
export function useToggleAgentPin() { export function useToggleAgentPin() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, pinned }: { id: string; pinned: boolean }) => mutationFn: ({ id, pinned }: { id: string; pinned: boolean }) =>
pinned ? pinAgent(id) : unpinAgent(id), pinned ? pinAgent(id) : unpinAgent(id),
onMutate: ({ id, pinned }) => { onMutate: ({ id, pinned }) => {
mapCachedAgents(queryClient, (a) => mapCachedAgents(queryClient, (a) =>
a.id === id ? { ...a, is_pinned: pinned } : a, a.id === id ? { ...a, is_pinned: pinned } : a,
); );
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) => queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
old ? { ...old, is_pinned: pinned } : old, old ? { ...old, is_pinned: pinned } : old,
); );
}, },
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["agents"] }); await queryClient.invalidateQueries({ queryKey: ["agents"] });
await queryClient.refetchQueries({ queryKey: ["agents"], type: "active" }); await queryClient.refetchQueries({ queryKey: ["agents"], type: "active" });
}, },
}); });
} }
export function useAgentSelector() { export function useAgentSelector() {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["agents", "selector"], queryKey: ["agents", "selector"],
queryFn: () => getAgentSelector(), queryFn: () => getAgentSelector(),
@@ -230,9 +242,9 @@ export function useAgentSelector() {
isLoading, isLoading,
error, error,
}; };
} }
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) { function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
void queryClient.invalidateQueries({ queryKey: ["agents"] }); void queryClient.invalidateQueries({ queryKey: ["agents"] });
} }
@@ -277,39 +289,44 @@ export function usePrivateAgents(password: string | null) {
* password-gated private square plus the total count. */ * password-gated private square plus the total count. */
export function usePrivateAgentsPaginated( export function usePrivateAgentsPaginated(
password: string | null, password: string | null,
params: { params: {
search?: string; search?: string;
tagIds?: string[]; tagIds?: string[];
sort?: AgentSort; sort?: AgentSort;
page: number; page: number;
pageSize: number; pageSize: number;
enabled?: boolean; enabled?: boolean;
}, /** Keep only agents published by this user id (the card's publisher). */
) { owner?: string;
const { page, pageSize, enabled = true } = params; },
const search = params.search ?? ""; ) {
const tagIds = params.tagIds ?? []; const { page, pageSize, enabled = true } = params;
const sort = params.sort ?? "time_desc"; const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const owner = params.owner ?? "";
const { data, isLoading, error, refetch } = useQuery({ const { data, isLoading, error, refetch } = useQuery({
queryKey: [ queryKey: [
"agents", "agents",
"private-square", "private-square",
password ?? "", password ?? "",
"paginated", "paginated",
search, search,
[...tagIds].sort(), [...tagIds].sort(),
sort, sort,
page, page,
pageSize, pageSize,
owner,
], ],
queryFn: () => queryFn: () =>
listPrivateAgentsPaginated({ listPrivateAgentsPaginated({
password: password!, password: password!,
search, search,
tagIds, tagIds,
sort, sort,
page, page,
pageSize, pageSize,
owner: owner || undefined,
}), }),
enabled: !!password && enabled, enabled: !!password && enabled,
staleTime: 30_000, staleTime: 30_000,
+63 -6
View File
@@ -21,6 +21,7 @@ import {
RefreshCw, RefreshCw,
TagsIcon, TagsIcon,
Trash2, Trash2,
X,
} from "lucide-react"; } from "lucide-react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useRouteNavigate as useNavigate } from "@/core/route-base"; import { useRouteNavigate as useNavigate } from "@/core/route-base";
@@ -148,6 +149,11 @@ export default function ScheduledTasksPage() {
const initialTaskId = searchParams.get("task"); const initialTaskId = searchParams.get("task");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]); const [tagIds, setTagIds] = useState<string[]>([]);
// Publisher (创建者) filter for the 我的任务 tab — click a task's 创建者 to keep
// only that creator's tasks. {id} is creator_user_id; {label} the display name.
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
null,
);
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query. // Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
const { data: tasks = [], isLoading } = useScheduledTasks({ search, tagIds }); const { data: tasks = [], isLoading } = useScheduledTasks({ search, tagIds });
const { data: deliveryProfile } = useScheduledTaskDeliveryProfile(); const { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
@@ -199,6 +205,16 @@ export default function ScheduledTasksPage() {
}), }),
[tasks], [tasks],
); );
// The rendered 我的任务 list, optionally narrowed to one creator. Selection
// logic still runs against the full sortedTasks so the detail panel keeps
// working even when the picked task is from a filtered-out creator.
const visibleTasks = useMemo(
() =>
publisher
? sortedTasks.filter((task) => task.creator_user_id === publisher.id)
: sortedTasks,
[sortedTasks, publisher],
);
const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null; const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
const selectedTaskCanEditRuns = !!selectedTask && selectedTask.role !== "subscriber"; const selectedTaskCanEditRuns = !!selectedTask && selectedTask.role !== "subscriber";
const { data: runs = [], isLoading: runsLoading } = useScheduledTaskRuns(selectedTaskId); const { data: runs = [], isLoading: runsLoading } = useScheduledTaskRuns(selectedTaskId);
@@ -451,6 +467,20 @@ export default function ScheduledTasksPage() {
onTagIdsChange={setTagIds} onTagIdsChange={setTagIds}
/> />
)} )}
{activeTab === "mine" && 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}
<X className="size-3.5" />
</button>
</div>
)}
</CardHeader> </CardHeader>
<CardContent className="min-h-0 flex-1 p-0"> <CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full"> <ScrollArea className="size-full">
@@ -459,12 +489,14 @@ export default function ScheduledTasksPage() {
{isLoading && ( {isLoading && (
<div className="text-muted-foreground text-sm">正在加载任务...</div> <div className="text-muted-foreground text-sm">正在加载任务...</div>
)} )}
{!isLoading && sortedTasks.length === 0 && ( {!isLoading && visibleTasks.length === 0 && (
<div className="text-muted-foreground rounded-md border p-4 text-sm"> <div className="text-muted-foreground rounded-md border p-4 text-sm">
还没有定时任务。点击新建创建第一个任务,或去“广场”收藏他人发布的任务。 {publisher
? `没有「${publisher.label}」创建的任务。`
: "还没有定时任务。点击新建创建第一个任务,或去“广场”收藏他人发布的任务。"}
</div> </div>
)} )}
{sortedTasks.map((task) => { {visibleTasks.map((task) => {
const isOwner = task.role !== "subscriber"; const isOwner = task.role !== "subscriber";
const isSubscriber = task.role === "subscriber"; const isSubscriber = task.role === "subscriber";
const notifyOn = !!task._subscription?.notify_element; const notifyOn = !!task._subscription?.notify_element;
@@ -539,11 +571,36 @@ export default function ScheduledTasksPage() {
)} )}
</div> </div>
{(task.creator_email || task.creator_user_id) && ( {(task.creator_email || task.creator_user_id) && (
<div className="truncate"> // The card itself is a <button>, so this clickable
// creator must be a <span> (no nested buttons).
<span
role={task.creator_user_id ? "button" : undefined}
tabIndex={task.creator_user_id ? 0 : undefined}
onClick={(event) => {
if (!task.creator_user_id) return;
event.stopPropagation();
setPublisher({
id: task.creator_user_id,
label:
displayUsername(task.creator_email) ||
task.creator_user_id,
});
}}
className={cn(
"block max-w-full truncate text-sm font-medium",
task.creator_user_id &&
"hover:text-primary cursor-pointer transition-colors hover:underline",
)}
title={
task.creator_user_id
? `点击筛选创建者:${displayUsername(task.creator_email) || task.creator_user_id}`
: undefined
}
>
创建者: 创建者:
{displayUsername(task.creator_email) || {displayUsername(task.creator_email) ||
task.creator_user_id} task.creator_user_id}
</div> </span>
)} )}
</div> </div>
{(task.tags?.length ?? 0) > 0 && ( {(task.tags?.length ?? 0) > 0 && (
@@ -781,7 +838,7 @@ export default function ScheduledTasksPage() {
{selectedTask ? desensitize(selectedTask.name) : "请选择左侧任务"} {selectedTask ? desensitize(selectedTask.name) : "请选择左侧任务"}
</p> </p>
{selectedTask && (selectedTask.creator_email || selectedTask.creator_user_id) && ( {selectedTask && (selectedTask.creator_email || selectedTask.creator_user_id) && (
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-sm font-medium">
创建者: 创建者:
{displayUsername(selectedTask.creator_email) || {displayUsername(selectedTask.creator_email) ||
selectedTask.creator_user_id} selectedTask.creator_user_id}
+2 -2
View File
@@ -299,7 +299,7 @@ Self-contained product knowledge base. **All knowledge capability lives in this
Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks. Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks.
**Listing cache + pagination** — to keep `GET /api/agents` fast, the gallery's per-card extras (skills + tool_groups) are denormalized into two cache tables instead of reading every agent's `config.yaml` on each request: `agent_skills` (relational, one row per (agent, skill) with `position`) and `agent_extras` (one row per agent: `tool_groups` JSON + `model` + `skills_synced_at`). `config.yaml` remains authoritative for the agent runtime; these tables are a read cache, write-through on create/update and backfilled from disk by `_sync_legacy_agents` (mtime-vs-`skills_synced_at` drift check). The list endpoint enriches via `AgentStore.get_extras_for([ids])` (two batched `IN` queries). Both tables are auto-created by `Base.metadata.create_all` (no migration needed). `GET /api/agents` supports **opt-in server-side pagination**: `scope` (`mine` = owned + favorited / `square` = built-in/published public), `page`, `page_size`, returning `{agents, total, page, page_size}`; omitting `scope`/`page` preserves the legacy "return everything" behavior. Name `search` matches the raw OR desensitized name (the frontend's static term→abbrev map is mirrored server-side in `deerflow/config/agent_name_desensitize.py`); `tag_ids` is an SQL OR-filter. `POST /api/agents/private-square/list` takes the same pagination params. Repository entry point: `AgentStore.list_paginated(...)`. **Listing cache + pagination** — to keep `GET /api/agents` fast, the gallery's per-card extras (skills + tool_groups) are denormalized into two cache tables instead of reading every agent's `config.yaml` on each request: `agent_skills` (relational, one row per (agent, skill) with `position`) and `agent_extras` (one row per agent: `tool_groups` JSON + `model` + `skills_synced_at`). `config.yaml` remains authoritative for the agent runtime; these tables are a read cache, write-through on create/update and backfilled from disk by `_sync_legacy_agents` (mtime-vs-`skills_synced_at` drift check). The list endpoint enriches via `AgentStore.get_extras_for([ids])` (two batched `IN` queries). Both tables are auto-created by `Base.metadata.create_all` (no migration needed). `GET /api/agents` supports **opt-in server-side pagination**: `scope` (`mine` = owned + favorited / `square` = built-in/published public), `page`, `page_size`, returning `{agents, total, page, page_size}`; omitting `scope`/`page` preserves the legacy "return everything" behavior. Name `search` matches the raw OR desensitized name (the frontend's static term→abbrev map is mirrored server-side in `deerflow/config/agent_name_desensitize.py`); `tag_ids` is an SQL OR-filter; `owner` keeps only agents published by that user id (the card's 发布者 — built-in agents never match), powering the gallery's click-发布者-to-filter. `POST /api/agents/private-square/list` takes the same pagination params (incl. `owner`). Repository entry point: `AgentStore.list_paginated(...)`. Tests: `tests/test_agent_listing_cache.py::test_list_paginated_owner_filter_keeps_one_publisher`.
**Homepage selector (agent picker on the new-chat page)** — single admin-curated list backed by `agents.featured_order` (nullable BIGINT). NULL = not featured. Only `published = true` agents can be featured (router enforces on writes); any published agent is eligible regardless of ownership. Cleared with `order: null`. The list rendered to end users is `WHERE featured_order IS NOT NULL` + visibility-filter, sorted by `featured_order ASC`. Admins reorder by sending the desired ids to `PUT /api/agents/featured/order` (assigns indices 0..N-1). BIGINT is used rather than INT so the management UI can seed initial values from `Date.now()` (overflows INT on MySQL). **Homepage selector (agent picker on the new-chat page)** — single admin-curated list backed by `agents.featured_order` (nullable BIGINT). NULL = not featured. Only `published = true` agents can be featured (router enforces on writes); any published agent is eligible regardless of ownership. Cleared with `order: null`. The list rendered to end users is `WHERE featured_order IS NOT NULL` + visibility-filter, sorted by `featured_order ASC`. Admins reorder by sending the desired ids to `PUT /api/agents/featured/order` (assigns indices 0..N-1). BIGINT is used rather than INT so the management UI can seed initial values from `Date.now()` (overflows INT on MySQL).
@@ -380,7 +380,7 @@ Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` /
- **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json - **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json
- **Injection**: Enabled skills listed in agent system prompt with container paths - **Injection**: Enabled skills listed in agent system prompt with container paths
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory - **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
- **Chinese name (`name_zh`)**: a per-deployment Chinese display name stored alongside `enabled`/`display` in `extensions_config.json` (`SkillStateConfig.name_zh`); surfaced on `SkillResponse.name_zh`. Endpoints (in `app/gateway/routers/skills.py`): `PUT /api/skills/{name}/name-zh` (user sets/clears it), `POST /api/skills/{name}/name-zh/generate` (LLM generates one for that skill — reuses the curator model setting, falls back to the main model), `POST /api/skills/name-zh/generate-missing` (一键补充: bulk-generate for every skill that lacks one, bounded-concurrency `asyncio.gather`). The frontend (`skill-settings-page.tsx` list + `skill-detail-dialog.tsx` editor) shows `name_zh || name` and offers per-skill "AI 生成" plus a header "一键补充中文名" button. Tests: `tests/test_skill_name_zh.py`. - **Chinese name (`name_zh`)**: a Chinese display name stored in the **`skills` DB table** (`SkillRow.name_zh`, `String(128)`, migration `20260609_01`) — never written to the on-disk SKILL.md, so it works for built-in skills too. Threaded through `SkillStore` create/ensure_legacy/update/update_admin exactly like `detail`; surfaced on `SkillResponse.name_zh` from the ownership record in `_skill_to_response`. Authorization mirrors `detail`: custom skill → owner or admin; built-in/public → admin only (row materialized on demand via `ensure_legacy`). Endpoints (in `app/gateway/routers/skills.py`): `PUT /api/skills/{name}/name-zh` (set/clear), `POST /api/skills/{name}/name-zh/generate` (LLM generates one — reuses the curator model setting, falls back to the main model), `POST /api/skills/name-zh/generate-missing` (一键补充: bulk-generate for every editable skill that lacks one, bounded-concurrency `asyncio.gather`, skips skills the caller can't edit). The frontend (`skill-settings-page.tsx` list + `skill-detail-dialog.tsx` editor, plus the agent skill pickers/badges in `NewAgentPage.tsx` + `agent-card.tsx`) shows `name_zh || name` and offers per-skill "AI 生成" plus a header "一键补充中文名" button. Tests: `tests/test_skill_name_zh.py`.
#### Skill Curator (`packages/harness/deerflow/skills/curator.py`) #### Skill Curator (`packages/harness/deerflow/skills/curator.py`)
@@ -555,6 +555,10 @@ async def list_agents(
pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$", pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$",
description="Sort order. Default is newest created first.", description="Sort order. Default is newest created first.",
), ),
owner: str | None = Query(
default=None,
description="Keep only agents published by this user id (the card's publisher). Built-in agents never match.",
),
admin: bool = Query( admin: bool = Query(
default=False, default=False,
description=( description=(
@@ -586,6 +590,7 @@ async def list_agents(
page_size=page_size, page_size=page_size,
admin=admin, admin=admin,
sort=sort, sort=sort,
owner=owner,
) )
responses = await _build_list_responses(store, tag_store, user_id, rows) responses = await _build_list_responses(store, tag_store, user_id, rows)
return AgentsListResponse(agents=responses, total=total, page=eff_page, page_size=page_size) return AgentsListResponse(agents=responses, total=total, page=eff_page, page_size=page_size)
@@ -601,6 +606,8 @@ async def list_agents(
if tag_ids: if tag_ids:
wanted = set(tag_ids) wanted = set(tag_ids)
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)] responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
if owner and owner.strip():
responses = [r for r in responses if r.user_id == owner.strip()]
responses = _sort_agent_responses(responses, sort) responses = _sort_agent_responses(responses, sort)
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses)) return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
except HTTPException: except HTTPException:
@@ -1083,6 +1090,7 @@ class PrivateSquareListRequest(BaseModel):
pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$", pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$",
description="Sort order. Default is newest created first.", description="Sort order. Default is newest created first.",
) )
owner: str | None = Field(default=None, description="Keep only agents published by this user id (the card's publisher).")
class AgentPrivateUpdateRequest(BaseModel): class AgentPrivateUpdateRequest(BaseModel):
@@ -1128,6 +1136,7 @@ async def list_private_square(body: PrivateSquareListRequest, request: Request)
page=body.page, page=body.page,
page_size=body.page_size, page_size=body.page_size,
sort=body.sort, sort=body.sort,
owner=body.owner,
) )
responses = await _build_list_responses(store, tag_store, user_id, rows) responses = await _build_list_responses(store, tag_store, user_id, rows)
return AgentsListResponse(agents=responses, total=total, page=body.page, page_size=body.page_size) return AgentsListResponse(agents=responses, total=total, page=body.page, page_size=body.page_size)
@@ -740,7 +740,8 @@ async def _attach_and_filter_tags(
resp.tags = assignments.get(resp.name, []) resp.tags = assignments.get(resp.name, [])
if search: if search:
needle = search.strip().lower() needle = search.strip().lower()
responses = [r for r in responses if needle in r.name.lower()] # Match the English name OR the Chinese display name so 中文 search works.
responses = [r for r in responses if needle in r.name.lower() or (r.name_zh or "").lower().find(needle) >= 0]
if tag_ids: if tag_ids:
wanted = set(tag_ids) wanted = set(tag_ids)
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)] responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
@@ -769,7 +770,7 @@ async def list_skills(
config: AppConfig = Depends(get_config), config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store), skill_store: SkillStore = Depends(get_skill_store),
tag_store: TagStore = Depends(get_tag_store), tag_store: TagStore = Depends(get_tag_store),
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name"), search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name or Chinese name (name_zh)"),
tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"), tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"),
sort: str = Query( sort: str = Query(
default="time_desc", default="time_desc",
@@ -119,18 +119,19 @@ class AgentStore(ABC):
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
async def list_paginated( async def list_paginated(
self, self,
*, *,
user_id: str, user_id: str,
scope: str | None, scope: str | None,
search: str | None, search: str | None,
tag_ids: list[str] | None, tag_ids: list[str] | None,
page: int, page: int,
page_size: int, page_size: int,
admin: bool = False, admin: bool = False,
sort: str = "time_desc", sort: str = "time_desc",
) -> tuple[list[dict[str, Any]], int]: owner: str | None = None,
) -> tuple[list[dict[str, Any]], int]:
"""Return one page of agents plus the total matching count. """Return one page of agents plus the total matching count.
``scope``: ``scope``:
@@ -142,13 +143,15 @@ class AgentStore(ABC):
agent), but still honors ``search`` / ``tag_ids``. Caller authorizes. agent), but still honors ``search`` / ``tag_ids``. Caller authorizes.
``search`` matches the raw name OR its desensitized display form. ``search`` matches the raw name OR its desensitized display form.
``tag_ids`` keeps agents carrying ANY of the tags (OR). Both filters, ``tag_ids`` keeps agents carrying ANY of the tags (OR). ``owner`` keeps
the COUNT, and LIMIT/OFFSET all execute in SQL. only agents published by that user (``user_id`` equality) — built-in
agents (no owner) never match. All filters, the COUNT, and LIMIT/OFFSET
all execute in SQL.
Returns ``(rows, total)`` where ``rows`` is at most ``page_size`` items Returns ``(rows, total)`` where ``rows`` is at most ``page_size`` items
for the 1-based ``page``. for the 1-based ``page``.
""" """
raise NotImplementedError raise NotImplementedError
# --- Listing cache (skills / tool_groups / model) --- # --- Listing cache (skills / tool_groups / model) ---
@@ -199,28 +202,28 @@ class AgentStore(ABC):
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
async def delete_favorites_for_agent(self, agent_id: str) -> None: async def delete_favorites_for_agent(self, agent_id: str) -> None:
"""Drop every user's favorite of ``agent_id`` (called on agent delete).""" """Drop every user's favorite of ``agent_id`` (called on agent delete)."""
raise NotImplementedError raise NotImplementedError
# --- Per-user pins ("置顶") --- # --- Per-user pins ("置顶") ---
@abstractmethod @abstractmethod
async def add_pin(self, user_id: str, agent_id: str) -> None: async def add_pin(self, user_id: str, agent_id: str) -> None:
"""Pin ``agent_id`` for ``user_id``. Idempotent.""" """Pin ``agent_id`` for ``user_id``. Idempotent."""
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
async def remove_pin(self, user_id: str, agent_id: str) -> bool: async def remove_pin(self, user_id: str, agent_id: str) -> bool:
"""Remove a per-user pin. Returns ``False`` when none existed.""" """Remove a per-user pin. Returns ``False`` when none existed."""
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
async def list_pin_ids(self, user_id: str) -> set[str]: async def list_pin_ids(self, user_id: str) -> set[str]:
"""Return the set of agent ids pinned by ``user_id``.""" """Return the set of agent ids pinned by ``user_id``."""
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
async def delete_pins_for_agent(self, agent_id: str) -> None: async def delete_pins_for_agent(self, agent_id: str) -> None:
"""Drop every user's pin of ``agent_id`` (called on agent delete).""" """Drop every user's pin of ``agent_id`` (called on agent delete)."""
raise NotImplementedError raise NotImplementedError
@@ -21,9 +21,9 @@ def _copy(data: dict[str, Any]) -> dict[str, Any]:
class MemoryAgentStore(AgentStore): class MemoryAgentStore(AgentStore):
def __init__(self) -> None: def __init__(self) -> None:
self._agents: dict[str, dict[str, Any]] = {} self._agents: dict[str, dict[str, Any]] = {}
self._favorites: set[tuple[str, str]] = set() self._favorites: set[tuple[str, str]] = set()
self._pins: set[tuple[str, str]] = set() self._pins: set[tuple[str, str]] = set()
# Listing cache mirrors (see AgentExtrasRow / AgentSkillRow): # Listing cache mirrors (see AgentExtrasRow / AgentSkillRow):
self._skills: dict[str, list[str]] = {} self._skills: dict[str, list[str]] = {}
self._extras: dict[str, dict[str, Any]] = {} self._extras: dict[str, dict[str, Any]] = {}
@@ -186,51 +186,54 @@ class MemoryAgentStore(AgentStore):
return bool(agent.get("is_private")) return bool(agent.get("is_private"))
return self._is_visible(agent, user_id) return self._is_visible(agent, user_id)
async def list_paginated( async def list_paginated(
self, self,
*, *,
user_id: str, user_id: str,
scope: str | None, scope: str | None,
search: str | None, search: str | None,
tag_ids: list[str] | None, tag_ids: list[str] | None,
page: int, page: int,
page_size: int, page_size: int,
admin: bool = False, admin: bool = False,
sort: str = "time_desc", sort: str = "time_desc",
) -> tuple[list[dict[str, Any]], int]: owner: str | None = None,
) -> tuple[list[dict[str, Any]], int]:
# NOTE: the in-memory store has no tag-assignment data, so ``tag_ids`` # NOTE: the in-memory store has no tag-assignment data, so ``tag_ids``
# is ignored here. Tag-filter behavior is covered by the SQL repo tests. # is ignored here. Tag-filter behavior is covered by the SQL repo tests.
owner_key = owner.strip() if owner and owner.strip() else None
matched = [ matched = [
a a
for a in self._agents.values() for a in self._agents.values()
if self._matches_scope(a, user_id=user_id, scope=scope, admin=admin) if self._matches_scope(a, user_id=user_id, scope=scope, admin=admin)
and (not search or not search.strip() or name_matches(a.get("name") or "", search)) and (not search or not search.strip() or name_matches(a.get("name") or "", search))
and (owner_key is None or a.get("user_id") == owner_key)
] ]
if sort == "time_asc": if sort == "time_asc":
matched.sort(key=lambda a: (a.get("created_at") or "", a.get("id") or "")) matched.sort(key=lambda a: (a.get("created_at") or "", a.get("id") or ""))
elif sort == "updated_desc": elif sort == "updated_desc":
matched.sort( matched.sort(
key=lambda a: (a.get("updated_at") or "", a.get("id") or ""), key=lambda a: (a.get("updated_at") or "", a.get("id") or ""),
reverse=True, reverse=True,
) )
elif sort == "updated_asc": elif sort == "updated_asc":
matched.sort(key=lambda a: (a.get("updated_at") or "", a.get("id") or "")) matched.sort(key=lambda a: (a.get("updated_at") or "", a.get("id") or ""))
elif sort == "name_asc": elif sort == "name_asc":
matched.sort(key=lambda a: ((a.get("name") or "").lower(), a.get("id") or "")) matched.sort(key=lambda a: ((a.get("name") or "").lower(), a.get("id") or ""))
elif sort == "name_desc": elif sort == "name_desc":
matched.sort( matched.sort(
key=lambda a: ((a.get("name") or "").lower(), a.get("id") or ""), key=lambda a: ((a.get("name") or "").lower(), a.get("id") or ""),
reverse=True, reverse=True,
) )
else: else:
matched.sort( matched.sort(
key=lambda a: (a.get("created_at") or "", a.get("id") or ""), key=lambda a: (a.get("created_at") or "", a.get("id") or ""),
reverse=True, reverse=True,
) )
pin_ids = {agent_id for uid, agent_id in self._pins if uid == user_id} pin_ids = {agent_id for uid, agent_id in self._pins if uid == user_id}
matched.sort(key=lambda a: (0 if a.get("id") in pin_ids else 1,)) matched.sort(key=lambda a: (0 if a.get("id") in pin_ids else 1,))
total = len(matched) total = len(matched)
start = max(page - 1, 0) * page_size start = max(page - 1, 0) * page_size
page_rows = matched[start : start + page_size] page_rows = matched[start : start + page_size]
return [_copy(a) for a in page_rows], total return [_copy(a) for a in page_rows], total
@@ -275,23 +278,23 @@ class MemoryAgentStore(AgentStore):
async def list_favorite_ids(self, user_id: str) -> set[str]: async def list_favorite_ids(self, user_id: str) -> set[str]:
return {agent_id for (uid, agent_id) in self._favorites if uid == user_id} return {agent_id for (uid, agent_id) in self._favorites if uid == user_id}
async def delete_favorites_for_agent(self, agent_id: str) -> None: async def delete_favorites_for_agent(self, agent_id: str) -> None:
self._favorites = {(uid, aid) for (uid, aid) in self._favorites if aid != agent_id} self._favorites = {(uid, aid) for (uid, aid) in self._favorites if aid != agent_id}
# --- Pins ("置顶") --- # --- Pins ("置顶") ---
async def add_pin(self, user_id: str, agent_id: str) -> None: async def add_pin(self, user_id: str, agent_id: str) -> None:
self._pins.add((user_id, agent_id)) self._pins.add((user_id, agent_id))
async def remove_pin(self, user_id: str, agent_id: str) -> bool: async def remove_pin(self, user_id: str, agent_id: str) -> bool:
key = (user_id, agent_id) key = (user_id, agent_id)
if key not in self._pins: if key not in self._pins:
return False return False
self._pins.remove(key) self._pins.remove(key)
return True return True
async def list_pin_ids(self, user_id: str) -> set[str]: async def list_pin_ids(self, user_id: str) -> set[str]:
return {agent_id for uid, agent_id in self._pins if uid == user_id} return {agent_id for uid, agent_id in self._pins if uid == user_id}
async def delete_pins_for_agent(self, agent_id: str) -> None: async def delete_pins_for_agent(self, agent_id: str) -> None:
self._pins = {(uid, aid) for (uid, aid) in self._pins if aid != agent_id} self._pins = {(uid, aid) for (uid, aid) in self._pins if aid != agent_id}
@@ -6,19 +6,19 @@ from datetime import UTC, datetime
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import and_, case, delete, func, or_, select from sqlalchemy import and_, case, delete, func, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.config.agent_name_desensitize import source_terms_for_needle from deerflow.config.agent_name_desensitize import source_terms_for_needle
from deerflow.persistence.agents.base import AgentStore from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.agents.model import ( from deerflow.persistence.agents.model import (
AgentExtrasRow, AgentExtrasRow,
AgentFavoriteRow, AgentFavoriteRow,
AgentPinRow, AgentPinRow,
AgentRow, AgentRow,
AgentSkillRow, AgentSkillRow,
) )
from deerflow.persistence.tags.model import TagAssignmentRow from deerflow.persistence.tags.model import TagAssignmentRow
@@ -278,18 +278,19 @@ class AgentRepository(AgentStore):
) )
return AgentRow.id.in_(subq) return AgentRow.id.in_(subq)
async def list_paginated( async def list_paginated(
self, self,
*, *,
user_id: str, user_id: str,
scope: str | None, scope: str | None,
search: str | None, search: str | None,
tag_ids: list[str] | None, tag_ids: list[str] | None,
page: int, page: int,
page_size: int, page_size: int,
admin: bool = False, admin: bool = False,
sort: str = "time_desc", sort: str = "time_desc",
) -> tuple[list[dict[str, Any]], int]: owner: str | None = None,
) -> tuple[list[dict[str, Any]], int]:
filters = [] filters = []
scope_filter = self._scope_filter(user_id=user_id, scope=scope, admin=admin) scope_filter = self._scope_filter(user_id=user_id, scope=scope, admin=admin)
if scope_filter is not None: if scope_filter is not None:
@@ -298,30 +299,33 @@ class AgentRepository(AgentStore):
filters.append(self._search_clause(search)) filters.append(self._search_clause(search))
if tag_ids: if tag_ids:
filters.append(self._tag_clause(tag_ids)) filters.append(self._tag_clause(tag_ids))
if owner and owner.strip():
# Filter to one publisher. Built-in agents (user_id IS NULL) never match.
filters.append(AgentRow.user_id == owner.strip())
count_stmt = select(func.count()).select_from(AgentRow) count_stmt = select(func.count()).select_from(AgentRow)
pin_subq = select(AgentPinRow.agent_id).where(AgentPinRow.user_id == user_id) pin_subq = select(AgentPinRow.agent_id).where(AgentPinRow.user_id == user_id)
pin_rank = case((AgentRow.id.in_(pin_subq), 0), else_=1) pin_rank = case((AgentRow.id.in_(pin_subq), 0), else_=1)
if sort == "time_asc": if sort == "time_asc":
order_by = (AgentRow.created_at.asc(), AgentRow.id.asc()) order_by = (AgentRow.created_at.asc(), AgentRow.id.asc())
elif sort == "updated_desc": elif sort == "updated_desc":
order_by = (AgentRow.updated_at.desc(), AgentRow.id.asc()) order_by = (AgentRow.updated_at.desc(), AgentRow.id.asc())
elif sort == "updated_asc": elif sort == "updated_asc":
order_by = (AgentRow.updated_at.asc(), AgentRow.id.asc()) order_by = (AgentRow.updated_at.asc(), AgentRow.id.asc())
elif sort == "name_asc": elif sort == "name_asc":
order_by = (func.lower(AgentRow.name).asc(), AgentRow.id.asc()) order_by = (func.lower(AgentRow.name).asc(), AgentRow.id.asc())
elif sort == "name_desc": elif sort == "name_desc":
order_by = (func.lower(AgentRow.name).desc(), AgentRow.id.asc()) order_by = (func.lower(AgentRow.name).desc(), AgentRow.id.asc())
else: else:
order_by = (AgentRow.created_at.desc(), AgentRow.id.asc()) order_by = (AgentRow.created_at.desc(), AgentRow.id.asc())
page_stmt = ( page_stmt = (
select(AgentRow) select(AgentRow)
.order_by(pin_rank.asc(), *order_by) .order_by(pin_rank.asc(), *order_by)
.limit(page_size) .limit(page_size)
.offset(max(page - 1, 0) * page_size) .offset(max(page - 1, 0) * page_size)
) )
if filters: if filters:
where = and_(*filters) where = and_(*filters)
count_stmt = count_stmt.where(where) count_stmt = count_stmt.where(where)
@@ -441,49 +445,49 @@ class AgentRepository(AgentStore):
) )
return {row[0] for row in result.all()} return {row[0] for row in result.all()}
async def delete_favorites_for_agent(self, agent_id: str) -> None: async def delete_favorites_for_agent(self, agent_id: str) -> None:
async with self._sf() as session: async with self._sf() as session:
await session.execute(delete(AgentFavoriteRow).where(AgentFavoriteRow.agent_id == agent_id)) await session.execute(delete(AgentFavoriteRow).where(AgentFavoriteRow.agent_id == agent_id))
await session.commit() await session.commit()
# --- Pins ("置顶") --- # --- Pins ("置顶") ---
async def add_pin(self, user_id: str, agent_id: str) -> None: async def add_pin(self, user_id: str, agent_id: str) -> None:
async with self._sf() as session: async with self._sf() as session:
existing = ( existing = (
await session.execute( await session.execute(
select(AgentPinRow.id) select(AgentPinRow.id)
.where(AgentPinRow.user_id == user_id, AgentPinRow.agent_id == agent_id) .where(AgentPinRow.user_id == user_id, AgentPinRow.agent_id == agent_id)
.limit(1) .limit(1)
) )
).scalar_one_or_none() ).scalar_one_or_none()
if existing is not None: if existing is not None:
return return
session.add(AgentPinRow(id=uuid4().hex, user_id=user_id, agent_id=agent_id)) session.add(AgentPinRow(id=uuid4().hex, user_id=user_id, agent_id=agent_id))
try: try:
await session.commit() await session.commit()
except IntegrityError: except IntegrityError:
await session.rollback() await session.rollback()
async def remove_pin(self, user_id: str, agent_id: str) -> bool: async def remove_pin(self, user_id: str, agent_id: str) -> bool:
async with self._sf() as session: async with self._sf() as session:
result = await session.execute( result = await session.execute(
delete(AgentPinRow).where( delete(AgentPinRow).where(
AgentPinRow.user_id == user_id, AgentPinRow.user_id == user_id,
AgentPinRow.agent_id == agent_id, AgentPinRow.agent_id == agent_id,
) )
) )
await session.commit() await session.commit()
return (result.rowcount or 0) > 0 return (result.rowcount or 0) > 0
async def list_pin_ids(self, user_id: str) -> set[str]: async def list_pin_ids(self, user_id: str) -> set[str]:
async with self._sf() as session: async with self._sf() as session:
result = await session.execute( result = await session.execute(
select(AgentPinRow.agent_id).where(AgentPinRow.user_id == user_id) select(AgentPinRow.agent_id).where(AgentPinRow.user_id == user_id)
) )
return {row[0] for row in result.all()} return {row[0] for row in result.all()}
async def delete_pins_for_agent(self, agent_id: str) -> None: async def delete_pins_for_agent(self, agent_id: str) -> None:
async with self._sf() as session: async with self._sf() as session:
await session.execute(delete(AgentPinRow).where(AgentPinRow.agent_id == agent_id)) await session.execute(delete(AgentPinRow).where(AgentPinRow.agent_id == agent_id))
await session.commit() await session.commit()