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

- 后端 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 [editDescription, setEditDescription] = useState(agent.description ?? "");
const [editSoul, setEditSoul] = useState(agent.soul ?? ""); const [editSoul, setEditSoul] = useState(agent.soul ?? "");
const [editPublished, setEditPublished] = useState(agent.published); const [editPublished, setEditPublished] = useState(agent.published);
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
const [selectedSkills, setSelectedSkills] = useState<string[]>( const [selectedSkills, setSelectedSkills] = useState<string[]>(
agent.skills ?? [], agent.skills ?? [],
); );
@@ -98,9 +99,11 @@ export function AgentCard({ agent }: AgentCardProps) {
setEditDescription(agent.description ?? ""); setEditDescription(agent.description ?? "");
setEditSoul(agent.soul ?? ""); setEditSoul(agent.soul ?? "");
setEditPublished(agent.published); setEditPublished(agent.published);
setEditIsPrivate(agent.is_private);
setSelectedSkills(agent.skills ?? []); setSelectedSkills(agent.skills ?? []);
}, [ }, [
agent.description, agent.description,
agent.is_private,
agent.name, agent.name,
agent.published, agent.published,
agent.skills, agent.skills,
@@ -128,6 +131,10 @@ export function AgentCard({ agent }: AgentCardProps) {
toast.error(t.agents.nameStepInvalidError ?? "名称不能为空"); toast.error(t.agents.nameStepInvalidError ?? "名称不能为空");
return; return;
} }
if (editIsPrivate && editPublished) {
toast.warning("私密广场与公开发布互斥,请只勾选其一");
return;
}
try { try {
await updateAgent.mutateAsync({ await updateAgent.mutateAsync({
id: agent.id, id: agent.id,
@@ -135,7 +142,10 @@ export function AgentCard({ agent }: AgentCardProps) {
name: trimmedName, name: trimmedName,
description: editDescription, description: editDescription,
soul: editSoul, 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, skills: selectedSkills,
}, },
}); });
@@ -147,6 +157,13 @@ export function AgentCard({ agent }: AgentCardProps) {
} }
async function handleTogglePublished() { 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 { try {
await updateAgent.mutateAsync({ await updateAgent.mutateAsync({
id: agent.id, id: agent.id,
@@ -328,13 +345,9 @@ export function AgentCard({ agent }: AgentCardProps) {
</CardContent> </CardContent>
)} )}
<CardFooter className="mt-auto flex items-center justify-between gap-2 pt-3"> <CardFooter className="mt-auto flex flex-col items-stretch 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>
{(canEdit || canDelete || isOwned || isAdmin) && ( {(canEdit || canDelete || isOwned || isAdmin) && (
<div className="flex gap-1"> <div className="flex flex-wrap items-center justify-end gap-1">
{isAdmin && ( {isAdmin && (
<Button <Button
size="icon" size="icon"
@@ -424,6 +437,10 @@ export function AgentCard({ agent }: AgentCardProps) {
)} )}
</div> </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> </CardFooter>
</Card> </Card>
@@ -519,12 +536,41 @@ export function AgentCard({ agent }: AgentCardProps) {
<div> <div>
<p className="text-sm font-medium">公开发布</p> <p className="text-sm font-medium">公开发布</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
打开后,该智能体对所有用户可见。 {editIsPrivate
? "已加入私密广场,不可发布;请先关闭私密广场。"
: "打开后,该智能体对所有用户可见。"}
</p> </p>
</div> </div>
<Switch <Switch
checked={editPublished} checked={editPublished && !editIsPrivate}
onCheckedChange={setEditPublished} 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>
</div> </div>
@@ -10,9 +10,11 @@ import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useAgents, usePrivateAgents } from "@/core/agents"; import { useAgents, usePrivateAgents } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { usePrivateSquareStatus } from "@/core/system-settings/hooks"; import { usePrivateSquareStatus } from "@/core/system-settings/hooks";
import { desensitize } from "@/lib/desensitize";
import { AgentCard } from "./agent-card"; import { AgentCard } from "./agent-card";
@@ -29,8 +31,16 @@ export function AgentGallery() {
); );
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]); const [tagIds, setTagIds] = useState<string[]>([]);
// Name + tag filtering is resolved server-side via the /api/agents query. // Tag filtering stays server-side; name search must run on the *desensitized*
const { agents, isLoading } = useAgents({ search, tagIds }); // 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 storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null; 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 // owner's own private agents, which still come back in the unified list
// because owners always see their own.) // because owners always see their own.)
const allAgents = useMemo( 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( const myAgents = useMemo(
() => () =>
agents.filter( 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 filteredPrivateAgents = useMemo(() => {
const needle = search.trim().toLowerCase();
const wanted = new Set(tagIds); const wanted = new Set(tagIds);
return privateAgents.filter((a) => { return privateAgents.filter((a) => {
if (needle && !a.name.toLowerCase().includes(needle)) return false; if (!matchesSearch(a)) return false;
if (wanted.size > 0) { if (wanted.size > 0) {
if (!(a.tags ?? []).some((tg) => wanted.has(tg.id))) return false; if (!(a.tags ?? []).some((tg) => wanted.has(tg.id))) return false;
} }
return true; return true;
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [privateAgents, search, tagIds]); }, [privateAgents, search, tagIds]);
const handleNewAgent = () => { const handleNewAgent = () => {
+25 -4
View File
@@ -17,7 +17,25 @@ import {
setAgentPrivate, setAgentPrivate,
updateAgent, updateAgent,
} from "./api"; } 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[] } = {}) { export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
const search = params.search ?? ""; const search = params.search ?? "";
@@ -59,7 +77,8 @@ export function useUpdateAgent() {
id: string; id: string;
request: UpdateAgentRequest; request: UpdateAgentRequest;
}) => updateAgent(id, request), }) => updateAgent(id, request),
onSuccess: (_data, { id }) => { onSuccess: (data, { id }) => {
applyAgentToCache(queryClient, data);
void queryClient.invalidateQueries({ queryKey: ["agents"] }); void queryClient.invalidateQueries({ queryKey: ["agents"] });
void queryClient.invalidateQueries({ queryKey: ["agents", id] }); void queryClient.invalidateQueries({ queryKey: ["agents", id] });
}, },
@@ -98,7 +117,8 @@ export function useSetAgentFeatured() {
return useMutation({ return useMutation({
mutationFn: ({ id, order }: { id: string; order: number | null }) => mutationFn: ({ id, order }: { id: string; order: number | null }) =>
setAgentFeatured(id, order), setAgentFeatured(id, order),
onSuccess: (_data, { id }) => { onSuccess: (data, { id }) => {
applyAgentToCache(queryClient, data);
invalidateAgentQueries(queryClient); invalidateAgentQueries(queryClient);
void queryClient.invalidateQueries({ queryKey: ["agents", id] }); void queryClient.invalidateQueries({ queryKey: ["agents", id] });
}, },
@@ -134,7 +154,8 @@ export function useSetAgentPrivate() {
return useMutation({ return useMutation({
mutationFn: ({ id, isPrivate }: { id: string; isPrivate: boolean }) => mutationFn: ({ id, isPrivate }: { id: string; isPrivate: boolean }) =>
setAgentPrivate(id, isPrivate), setAgentPrivate(id, isPrivate),
onSuccess: (_data, { id }) => { onSuccess: (data, { id }) => {
applyAgentToCache(queryClient, data);
invalidateAgentQueries(queryClient); invalidateAgentQueries(queryClient);
void queryClient.invalidateQueries({ queryKey: ["agents", id] }); void queryClient.invalidateQueries({ queryKey: ["agents", id] });
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
@@ -19,11 +19,18 @@ interface Shortcut {
export function useGlobalShortcuts(shortcuts: Shortcut[]) { export function useGlobalShortcuts(shortcuts: Shortcut[]) {
useEffect(() => { useEffect(() => {
function handleKeyDown(event: KeyboardEvent) { 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 meta = event.metaKey || event.ctrlKey;
const pressed = event.key.toLowerCase();
for (const shortcut of shortcuts) { for (const shortcut of shortcuts) {
if (typeof shortcut.key !== "string") continue;
if ( if (
event.key.toLowerCase() === shortcut.key.toLowerCase() && pressed === shortcut.key.toLowerCase() &&
meta === shortcut.meta && meta === shortcut.meta &&
(shortcut.shift ?? false) === event.shiftKey (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) update_data["published"] = bool(request.published)
if "is_private" in fields_set: if "is_private" in fields_set:
update_data["is_private"] = bool(request.is_private) 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_name = update_data.get("name", current["name"])
next_description = update_data.get("description", current.get("description") or "") next_description = update_data.get("description", current.get("description") or "")
@@ -750,7 +757,12 @@ async def set_agent_private(
if record is None: if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") 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: if record.get("user_id") is None:
updated = await store.update_builtin(agent_id, update) updated = await store.update_builtin(agent_id, update)
else: else: