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