私密/公开互斥兜底 + 智能体搜索按显示后名称匹配 + 多处小修

- 后端 update_agent / set_agent_private 增加兜底:is_private=true 时强制
  published=false,杜绝同时落在公开+私密两个广场的灰色态
- agent-card 编辑弹窗暴露 editIsPrivate 开关,与公开发布开关在前端互斥校验
- agent-gallery 智能体模糊搜索改为前端针对 desensitize() 后的名字比对,
  解决脱敏显示与原始名字不一致导致看似搜索失效的问题
- useUpdateAgent 在 onSuccess 立刻把新数据 patch 进所有 ["agents"] 列表
  缓存,卡片在后台 refetch 完成前先行 re-render,避免「切换私密/发布后
  好像没反应」的体感延迟
- useGlobalShortcuts 对 event.key 为 undefined(IME 合成 / 扩展注入)做
  容错,否则会抛 TypeError 把后续所有快捷键吃掉

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.7 authored and xuhd committed 2026-05-28 18:28:49 +08:00
1 parent ae3e2fb4e8
commit 83714f628b
5 files changed
+130 -24

No files matched your search

@@ -72,6 +72,7 @@ export function AgentCard({ agent }: AgentCardProps) {
const [editDescription, setEditDescription] = useState(agent.description ?? "");
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
const [editPublished, setEditPublished] = useState(agent.published);
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
const [selectedSkills, setSelectedSkills] = useState<string[]>(
agent.skills ?? [],
);
@@ -98,9 +99,11 @@ export function AgentCard({ agent }: AgentCardProps) {
setEditDescription(agent.description ?? "");
setEditSoul(agent.soul ?? "");
setEditPublished(agent.published);
setEditIsPrivate(agent.is_private);
setSelectedSkills(agent.skills ?? []);
}, [
agent.description,
agent.is_private,
agent.name,
agent.published,
agent.skills,
@@ -128,6 +131,10 @@ export function AgentCard({ agent }: AgentCardProps) {
toast.error(t.agents.nameStepInvalidError ?? "名称不能为空");
return;
}
if (editIsPrivate && editPublished) {
toast.warning("私密广场与公开发布互斥,请只勾选其一");
return;
}
try {
await updateAgent.mutateAsync({
id: agent.id,
@@ -135,7 +142,10 @@ export function AgentCard({ agent }: AgentCardProps) {
name: trimmedName,
description: editDescription,
soul: editSoul,
published: editPublished,
// Private agents cannot also be published — force the value the
// backend will see, regardless of any stale state in editPublished.
published: editIsPrivate ? false : editPublished,
is_private: editIsPrivate,
skills: selectedSkills,
},
});
@@ -147,6 +157,13 @@ export function AgentCard({ agent }: AgentCardProps) {
}
async function handleTogglePublished() {
// Private and public squares are mutually exclusive — a private agent
// can't simultaneously be published to everyone. Block the toggle
// instead of silently moving the agent between squares.
if (!agent.published && agent.is_private) {
toast.warning("该智能体已加入私密广场,不可发布");
return;
}
try {
await updateAgent.mutateAsync({
id: agent.id,
@@ -328,13 +345,9 @@ export function AgentCard({ agent }: AgentCardProps) {
</CardContent>
)}
<CardFooter className="mt-auto flex items-center justify-between gap-2 pt-3">
<Button size="sm" className="flex-1" onClick={handleChat}>
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
</Button>
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3">
{(canEdit || canDelete || isOwned || isAdmin) && (
<div className="flex gap-1">
<div className="flex flex-wrap items-center justify-end gap-1">
{isAdmin && (
<Button
size="icon"
@@ -424,6 +437,10 @@ export function AgentCard({ agent }: AgentCardProps) {
)}
</div>
)}
<Button size="sm" className="w-full" onClick={handleChat}>
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
</Button>
</CardFooter>
</Card>
@@ -519,12 +536,41 @@ export function AgentCard({ agent }: AgentCardProps) {
<div>
<p className="text-sm font-medium">公开发布</p>
<p className="text-muted-foreground text-xs">
打开后,该智能体对所有用户可见。
{editIsPrivate
? "已加入私密广场,不可发布;请先关闭私密广场。"
: "打开后,该智能体对所有用户可见。"}
</p>
</div>
<Switch
checked={editPublished}
onCheckedChange={setEditPublished}
checked={editPublished && !editIsPrivate}
disabled={editIsPrivate}
onCheckedChange={(v) => {
if (editIsPrivate) {
toast.warning("该智能体已加入私密广场,不可发布");
return;
}
setEditPublished(v);
}}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-medium">加入私密广场</p>
<p className="text-muted-foreground text-xs">
打开后,该智能体仅在私密广场显示,需输入密码才能查看;与「公开发布」互斥。
</p>
</div>
<Switch
checked={editIsPrivate}
onCheckedChange={(v) => {
setEditIsPrivate(v);
if (v) {
// Mutual-exclusion: switching the agent into the private
// square automatically clears the public-publish flag so
// the user can't end up with a half-and-half state.
setEditPublished(false);
}
}}
/>
</div>
</div>
@@ -10,9 +10,11 @@ import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useAgents, usePrivateAgents } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { usePrivateSquareStatus } from "@/core/system-settings/hooks";
import { desensitize } from "@/lib/desensitize";
import { AgentCard } from "./agent-card";
@@ -29,8 +31,16 @@ export function AgentGallery() {
);
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
// Name + tag filtering is resolved server-side via the /api/agents query.
const { agents, isLoading } = useAgents({ search, tagIds });
// Tag filtering stays server-side; name search must run on the *desensitized*
// form because that's what the user sees on the card. If we sent the search
// string to the backend it would compare against the raw stored name and
// miss every desensitized hit (e.g. typing "TS分析" wouldn't match the
// stored "态势分析").
const { agents, isLoading } = useAgents({ tagIds });
const needle = search.trim().toLowerCase();
const matchesSearch = (a: Agent) =>
!needle || desensitize(a.name).toLowerCase().includes(needle);
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
@@ -69,28 +79,38 @@ export function AgentGallery() {
// owner's own private agents, which still come back in the unified list
// because owners always see their own.)
const allAgents = useMemo(
() => agents.filter((a) => (a.builtin || a.published) && !a.is_private),
[agents],
() =>
agents.filter(
(a) => (a.builtin || a.published) && !a.is_private && matchesSearch(a),
),
// matchesSearch closes over `needle`; depending on `search` is sufficient
// and avoids re-running on every render via the function reference.
// eslint-disable-next-line react-hooks/exhaustive-deps
[agents, search],
);
const myAgents = useMemo(
() =>
agents.filter(
(a) => a.user_id !== null && a.user_id === currentUserId,
(a) =>
a.user_id !== null &&
a.user_id === currentUserId &&
matchesSearch(a),
),
[agents, currentUserId],
// eslint-disable-next-line react-hooks/exhaustive-deps
[agents, currentUserId, search],
);
const filteredPrivateAgents = useMemo(() => {
const needle = search.trim().toLowerCase();
const wanted = new Set(tagIds);
return privateAgents.filter((a) => {
if (needle && !a.name.toLowerCase().includes(needle)) return false;
if (!matchesSearch(a)) return false;
if (wanted.size > 0) {
if (!(a.tags ?? []).some((tg) => wanted.has(tg.id))) return false;
}
return true;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [privateAgents, search, tagIds]);
const handleNewAgent = () => {
+25 -4
View File
@@ -17,7 +17,25 @@ import {
setAgentPrivate,
updateAgent,
} from "./api";
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
/** Patch an updated agent into every cached agent listing immediately so
* cards re-render before the background refetch finishes. Without this the
* mutation just invalidates the query and the user has to wait for the
* round-trip + render — which on a big list feels like nothing happened.
*/
function applyAgentToCache(
queryClient: ReturnType<typeof useQueryClient>,
next: Agent,
) {
queryClient.setQueriesData<Agent[] | undefined>(
{ queryKey: ["agents"] },
(old) => {
if (!Array.isArray(old)) return old;
return old.map((a) => (a.id === next.id ? { ...a, ...next } : a));
},
);
}
export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
const search = params.search ?? "";
@@ -59,7 +77,8 @@ export function useUpdateAgent() {
id: string;
request: UpdateAgentRequest;
}) => updateAgent(id, request),
onSuccess: (_data, { id }) => {
onSuccess: (data, { id }) => {
applyAgentToCache(queryClient, data);
void queryClient.invalidateQueries({ queryKey: ["agents"] });
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
},
@@ -98,7 +117,8 @@ export function useSetAgentFeatured() {
return useMutation({
mutationFn: ({ id, order }: { id: string; order: number | null }) =>
setAgentFeatured(id, order),
onSuccess: (_data, { id }) => {
onSuccess: (data, { id }) => {
applyAgentToCache(queryClient, data);
invalidateAgentQueries(queryClient);
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
},
@@ -134,7 +154,8 @@ export function useSetAgentPrivate() {
return useMutation({
mutationFn: ({ id, isPrivate }: { id: string; isPrivate: boolean }) =>
setAgentPrivate(id, isPrivate),
onSuccess: (_data, { id }) => {
onSuccess: (data, { id }) => {
applyAgentToCache(queryClient, data);
invalidateAgentQueries(queryClient);
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
void queryClient.invalidateQueries({
@@ -19,11 +19,18 @@ interface Shortcut {
export function useGlobalShortcuts(shortcuts: Shortcut[]) {
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
// ``event.key`` is ``undefined`` for synthetic / IME-composition /
// some extension-injected keydown events. Calling ``.toLowerCase()``
// on it throws and kills every shortcut after this one — bail out
// before that happens.
if (typeof event.key !== "string") return;
const meta = event.metaKey || event.ctrlKey;
const pressed = event.key.toLowerCase();
for (const shortcut of shortcuts) {
if (typeof shortcut.key !== "string") continue;
if (
event.key.toLowerCase() === shortcut.key.toLowerCase() &&
pressed === shortcut.key.toLowerCase() &&
meta === shortcut.meta &&
(shortcut.shift ?? false) === event.shiftKey
) {
@@ -495,6 +495,13 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
update_data["published"] = bool(request.published)
if "is_private" in fields_set:
update_data["is_private"] = bool(request.is_private)
# Mutual exclusion: an agent moved into the private square cannot
# also be publicly published. Force ``published=false`` whenever the
# same request sets ``is_private=true``, even if the client forgot
# to clear it. (When the request only sets one of the two, the
# other side is left untouched.)
if update_data.get("is_private") is True:
update_data["published"] = False
next_name = update_data.get("name", current["name"])
next_description = update_data.get("description", current.get("description") or "")
@@ -750,7 +757,12 @@ async def set_agent_private(
if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
update = {"is_private": bool(body.is_private)}
update: dict[str, Any] = {"is_private": bool(body.is_private)}
# Mutual exclusion — see ``update_agent`` above. Moving an agent into
# the private square also drops the public ``published`` flag so we
# never end up with an agent that's "in both squares".
if update["is_private"]:
update["published"] = False
if record.get("user_id") is None:
updated = await store.update_builtin(agent_id, update)
else: