wip: 合并 master 前暂存本地改动
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+45
-42
@@ -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
|
||||||
+62
-59
@@ -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}
|
||||||
+90
-86
@@ -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()
|
||||||
Reference in new issue
Block a user