diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index aac8f52b..5ee1c8d3 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -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( 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) { )} - - + {(canEdit || canDelete || isOwned || isAdmin) && ( -
+
{isAdmin && ( @@ -519,12 +536,41 @@ export function AgentCard({ agent }: AgentCardProps) {

公开发布

- 打开后,该智能体对所有用户可见。 + {editIsPrivate + ? "已加入私密广场,不可发布;请先关闭私密广场。" + : "打开后,该智能体对所有用户可见。"}

{ + if (editIsPrivate) { + toast.warning("该智能体已加入私密广场,不可发布"); + return; + } + setEditPublished(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); + } + }} />
diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index 87d438e5..10001772 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -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([]); - // 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 = () => { diff --git a/frontend-web/src/core/agents/hooks.ts b/frontend-web/src/core/agents/hooks.ts index 8d8e81ad..802283f6 100644 --- a/frontend-web/src/core/agents/hooks.ts +++ b/frontend-web/src/core/agents/hooks.ts @@ -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, + next: Agent, +) { + queryClient.setQueriesData( + { 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({ diff --git a/frontend-web/src/hooks/use-global-shortcuts.ts b/frontend-web/src/hooks/use-global-shortcuts.ts index 64a6feef..97e5c645 100644 --- a/frontend-web/src/hooks/use-global-shortcuts.ts +++ b/frontend-web/src/hooks/use-global-shortcuts.ts @@ -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 ) { diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py index 7f21014f..9b836a95 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py @@ -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: