私密/公开互斥兜底 + 智能体搜索按显示后名称匹配 + 多处小修
- 后端 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:
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 = () => {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user