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,
MessageSquareIcon,
PinIcon,
SearchIcon,
ShieldIcon,
SparklesIcon,
SquarePenIcon,
@@ -74,6 +75,9 @@ interface AgentCardProps {
/** "card" (default) renders the full gallery card; "list" renders a compact
* single-row layout for the list view. All dialogs/actions are shared. */
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 {
@@ -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 { t } = useI18n();
const router = useRouter();
@@ -208,6 +212,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
const [selectedSkills, setSelectedSkills] = useState<string[]>(
agent.skills ?? [],
);
const [skillSearch, setSkillSearch] = useState("");
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
// The /api/agents list response intentionally omits `soul` (perf optimization
@@ -245,6 +250,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
setEditPublished(agent.published);
setEditIsPrivate(agent.is_private);
setSelectedSkills(agent.skills ?? []);
setSkillSearch("");
}, [
agent.description,
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
// 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 =
!isBuiltin && agent.owner_username ? (
<div
className="text-muted-foreground flex items-center gap-1 text-xs"
title={`发布者:${desensitize(agent.owner_username)}`}
<button
type="button"
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" />
<span className="truncate">发布者:{desensitize(agent.owner_username)}</span>
</div>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">发布者:{publisherLabel}</span>
</button>
) : null;
// 技能英文名 → 中文名映射,用于把智能体的技能徽章显示为中文(agent.skills 只存英文名)。
@@ -816,6 +859,17 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
已选 {selectedSkills.length} 个
</span>
</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">
{isSkillsLoading ? (
<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>
) : filteredSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">没有匹配的技能</p>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{enabledSkills.map((skill) => {
{filteredSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<div
@@ -1,6 +1,6 @@
"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 { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -36,6 +36,11 @@ export function AgentGallery() {
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
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.
const [pages, setPages] = useState<{ all: number; mine: number; private: number }>(
{ all: 1, mine: 1, private: 1 },
@@ -48,7 +53,7 @@ export function AgentGallery() {
const tagKey = tagIds.join(",");
useEffect(() => {
setPages({ all: 1, mine: 1, private: 1 });
}, [search, tagKey, sort]);
}, [search, tagKey, sort, publisher?.id]);
const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled;
@@ -77,6 +82,7 @@ export function AgentGallery() {
page: mainPage,
pageSize: PAGE_SIZE,
enabled: activeTab !== "private",
owner: publisher?.id,
});
const {
@@ -91,6 +97,7 @@ export function AgentGallery() {
page: pages.private,
pageSize: PAGE_SIZE,
enabled: activeTab === "private",
owner: publisher?.id,
});
// If the server reports the stored password no longer works (likely the
@@ -127,17 +134,29 @@ export function AgentGallery() {
setPrivatePassword(null);
};
const handlePublisherClick = (p: { id: string; label: string }) =>
setPublisher(p);
const renderGrid = (list: Agent[]) =>
viewMode === "list" ? (
<div className="flex flex-col gap-2">
{list.map((agent) => (
<AgentCard key={agent.id} agent={agent} layout="list" />
<AgentCard
key={agent.id}
agent={agent}
layout="list"
onPublisherClick={handlePublisherClick}
/>
))}
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((agent) => (
<AgentCard key={agent.id} agent={agent} />
<AgentCard
key={agent.id}
agent={agent}
onPublisherClick={handlePublisherClick}
/>
))}
</div>
);
@@ -356,6 +375,20 @@ export function AgentGallery() {
</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>
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
@@ -23,6 +23,7 @@ import {
TrendingUpIcon,
Upload,
UserIcon,
XIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -154,6 +155,11 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<SkillSort>("time_desc");
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 reconcileMutation = useReconcileCustomSkills();
const fillNamesMutation = useGenerateMissingSkillNameZh();
@@ -203,7 +209,14 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
};
}, [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(
() =>
filteredSkills
@@ -228,7 +241,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
}, [page, totalPages]);
useEffect(() => {
setPage(1);
}, [search, tagIds, sort]);
}, [search, tagIds, sort, publisher?.id, filter]);
const pagedSkills = useMemo(
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[orderedSkills, page],
@@ -344,12 +357,13 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<Button
size="sm"
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()}
disabled={
fillNamesMutation.isPending ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
title="为所有尚未配置中文名称的技能自动生成中文名"
title="用大模型为所有尚未配置中文名称的技能自动生成中文名"
>
{fillNamesMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
@@ -399,6 +413,20 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
</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 && (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
)}
@@ -421,6 +449,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
currentUserId={currentUserId}
isAdmin={isAdmin}
layout={viewMode === "card" ? "card" : "row"}
onPublisherClick={setPublisher}
/>
))}
</div>
@@ -478,12 +507,16 @@ export function SkillRow({
currentUserId,
isAdmin,
layout = "row",
onPublisherClick,
}: {
skill: Skill;
currentUserId: string | null;
isAdmin: boolean;
/** "row" = full-width horizontal item (default); "card" = grid card cell. */
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 { t } = useI18n();
@@ -674,10 +707,31 @@ export function SkillRow({
const metaNodes = (
<>
{!isBuiltin && skill.owner_username && (
<div className="text-muted-foreground mt-1 flex items-center gap-1 text-xs">
<UserIcon className="size-3" />
发布者:{desensitize(skill.owner_username)}
</div>
<button
type="button"
disabled={!skill.owner_user_id || !onPublisherClick}
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 && (
<div className="text-muted-foreground mt-1 text-xs">
@@ -11,6 +11,7 @@ import {
PencilIcon,
SlidersHorizontalIcon,
SparklesIcon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -106,6 +107,7 @@ export function SkillDetailDialog({
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [editingNameZh, setEditingNameZh] = useState(false);
const [nameZhDraft, setNameZhDraft] = useState("");
const [activeTab, setActiveTab] = useState<
"detail" | "display" | "source" | "files" | null
@@ -137,6 +139,7 @@ export function SkillDetailDialog({
useEffect(() => {
setEditing(false);
setEditingNameZh(false);
setActiveTab(null);
setShowRawSource(false);
setOpenedFilePath(null);
@@ -169,6 +172,11 @@ export function SkillDetailDialog({
}
}
function startEditingNameZh() {
setNameZhDraft(skill?.name_zh ?? "");
setEditingNameZh(true);
}
async function handleSaveNameZh() {
if (!skill) return;
try {
@@ -177,6 +185,7 @@ export function SkillDetailDialog({
nameZh: nameZhDraft.trim(),
});
toast.success("中文名称已保存");
setEditingNameZh(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
@@ -215,32 +224,99 @@ export function SkillDetailDialog({
<SparklesIcon className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="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 text-xs">
{editingNameZh ? (
<div className="flex items-center gap-1.5">
<DialogTitle className="sr-only">
{desensitize(skill?.name ?? skillName ?? "")}
</span>
)}
{skill?.builtin && (
<Badge variant="secondary" className="text-xs">
{tx.badgeBuiltin}
</Badge>
)}
{skill?.published && (
<Badge
</DialogTitle>
<Input
value={nameZhDraft}
onChange={(event) => setNameZhDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSaveNameZh();
if (event.key === "Escape") setEditingNameZh(false);
}}
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"
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" />
{tx.badgePublished}
</Badge>
)}
</div>
{generateNameZh.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<SparklesIcon className="size-3.5" />
)}
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">
{skill?.description ? desensitize(skill.description) : "-"}
</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"
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 ? (
<div className="space-y-2">
<Textarea