feat(agents): 智能体归属/发布操作前增加「账号已切换」提醒
- 新增 useLiveAccount 钩子:跟踪「实时」登录账号(随 storage/focus 刷新), 并与组件挂载时捕获的账号对比;token 存 localStorage 会被同浏览器其他标签页 共享,另一标签页登录会静默替换本页 API 所用账号 - agent-card / agent-detail-panel / publish-square-picker-dialog / NewAgentPage 接入该钩子,切换账号后对归属/发布类操作做提醒,避免误归属 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2b6424b3ff
commit
16ba5deb0e
5 files changed
+221
-7
No files matched your search
@@ -77,6 +77,7 @@ import {
|
||||
import { getAgent } from "@/core/agents/api";
|
||||
import type { Agent, AgentQuestion, UpdateAgentRequest } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { formatDateTime } from "@/core/utils/datetime";
|
||||
@@ -246,6 +247,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
// 发布请求实际以「实时」token 执行 —— 用 live 账号来提示发布者并检测账号是否被切换。
|
||||
const live = useLiveAccount();
|
||||
const isAdmin = storedAuth?.system_role === "admin";
|
||||
const isBuiltin = agent.builtin;
|
||||
const isOwned =
|
||||
@@ -414,18 +417,38 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
setPublishPickerOpen(true);
|
||||
return;
|
||||
}
|
||||
if (!warnIfAccountSwitched()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
request: { published: !agent.published },
|
||||
});
|
||||
toast.success(agent.published ? "已取消发布" : "已发布");
|
||||
toast.success(
|
||||
agent.published ? "已取消发布" : `已发布(操作账号:${live.displayName})`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// 发布操作以实时 token 执行。若会话中途账号被切换(常见于另一标签页登录了别的
|
||||
// 账号),先弹一次确认 —— 避免误以本人之外的账号执行发布。返回 false = 用户取消。
|
||||
function warnIfAccountSwitched(): boolean {
|
||||
if (!live.switched) {
|
||||
return true;
|
||||
}
|
||||
return window.confirm(
|
||||
`当前登录账号已从「${live.mountDisplayName}」切换为「${live.displayName}」。\n` +
|
||||
`本次发布将以「${live.displayName}」执行。如需以「${live.mountDisplayName}」操作,请取消并刷新页面。\n\n仍要继续吗?`,
|
||||
);
|
||||
}
|
||||
|
||||
async function handlePublishToSquare(squareId: string) {
|
||||
if (!warnIfAccountSwitched()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
@@ -433,7 +456,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
});
|
||||
const sqName =
|
||||
publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
|
||||
toast.success(`已发布到「${sqName}」`);
|
||||
toast.success(`已发布到「${sqName}」(操作账号:${live.displayName})`);
|
||||
setPublishPickerOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
@@ -1094,6 +1117,23 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
loading={updateAgent.isPending}
|
||||
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
|
||||
description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
|
||||
accountNotice={
|
||||
<div className="text-xs">
|
||||
<p className="text-muted-foreground">
|
||||
发布操作账号:
|
||||
<span className="text-foreground font-medium">
|
||||
「{live.displayName}」
|
||||
</span>
|
||||
</p>
|
||||
{live.switched && (
|
||||
<p className="text-destructive mt-1">
|
||||
⚠ 当前登录账号已从「{live.mountDisplayName}」切换为「
|
||||
{live.displayName}」。如需以「{live.mountDisplayName}
|
||||
」操作,请刷新页面确认。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
type UpdateAgentRequest,
|
||||
} from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { isAgentSkillCandidate } from "@/core/skills";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { usePublishSquares } from "@/core/system-settings/hooks";
|
||||
@@ -156,6 +157,8 @@ export function AgentDetailPanel({
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
|
||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||
// 发布以实时 token 执行 —— 用 live 账号提示发布者并检测账号是否被切换。
|
||||
const live = useLiveAccount();
|
||||
|
||||
const updateAgent = useUpdateAgent();
|
||||
const deleteAgent = useDeleteAgent();
|
||||
@@ -338,19 +341,39 @@ export function AgentDetailPanel({
|
||||
setPublishPickerOpen(true);
|
||||
return;
|
||||
}
|
||||
if (!warnIfAccountSwitched()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
request: { published: !agent.published },
|
||||
});
|
||||
toast.success(agent.published ? "已下架" : "已发布");
|
||||
toast.success(
|
||||
agent.published ? "已下架" : `已发布(操作账号:${live.displayName})`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// 发布以实时 token 执行。会话中途账号被切换(常见于另一标签页登录了别的账号)时
|
||||
// 先弹确认,避免误以本人之外的账号执行发布。返回 false = 用户取消。
|
||||
function warnIfAccountSwitched(): boolean {
|
||||
if (!live.switched) {
|
||||
return true;
|
||||
}
|
||||
return window.confirm(
|
||||
`当前登录账号已从「${live.mountDisplayName}」切换为「${live.displayName}」。\n` +
|
||||
`本次发布将以「${live.displayName}」执行。如需以「${live.mountDisplayName}」操作,请取消并刷新页面。\n\n仍要继续吗?`,
|
||||
);
|
||||
}
|
||||
|
||||
async function handlePublishToSquare(squareId: string) {
|
||||
if (!agent) return;
|
||||
if (!warnIfAccountSwitched()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
@@ -358,7 +381,7 @@ export function AgentDetailPanel({
|
||||
});
|
||||
const sqName =
|
||||
publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
|
||||
toast.success(`已发布到「${sqName}」`);
|
||||
toast.success(`已发布到「${sqName}」(操作账号:${live.displayName})`);
|
||||
setPublishPickerOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
@@ -901,6 +924,23 @@ export function AgentDetailPanel({
|
||||
loading={updateAgent.isPending}
|
||||
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
|
||||
description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
|
||||
accountNotice={
|
||||
<div className="text-xs">
|
||||
<p className="text-muted-foreground">
|
||||
发布操作账号:
|
||||
<span className="text-foreground font-medium">
|
||||
「{live.displayName}」
|
||||
</span>
|
||||
</p>
|
||||
{live.switched && (
|
||||
<p className="text-destructive mt-1">
|
||||
⚠ 当前登录账号已从「{live.mountDisplayName}」切换为「
|
||||
{live.displayName}」。如需以「{live.mountDisplayName}
|
||||
」操作,请刷新页面确认。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -26,6 +26,8 @@ interface PublishSquarePickerDialogProps {
|
||||
loading?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
/** 可选提示节点,渲染在描述下方 —— 用于显示「本次发布将以哪个账号进行」及账号切换告警。 */
|
||||
accountNotice?: ReactNode;
|
||||
}
|
||||
|
||||
/** A "publish to which square (发布广场)?" chooser. Squares are rendered as a
|
||||
@@ -39,6 +41,7 @@ export function PublishSquarePickerDialog({
|
||||
loading,
|
||||
title = "选择发布广场",
|
||||
description = "请选择要把它发布到哪个广场。每个项目只能归属一个广场,不会重复出现。",
|
||||
accountNotice,
|
||||
}: PublishSquarePickerDialogProps) {
|
||||
const [selected, setSelected] = useState<string>(
|
||||
defaultSquareId || squares[0]?.id || "",
|
||||
@@ -58,6 +61,7 @@ export function PublishSquarePickerDialog({
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{accountNotice}
|
||||
<div className="max-h-[50vh] space-y-1.5 overflow-y-auto py-1">
|
||||
{squares.map((sq) => {
|
||||
const active = selected === sq.id;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||
import { isEmbedActive } from "@/core/embed/embed-session";
|
||||
|
||||
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||
|
||||
export interface LiveAccount {
|
||||
/** 实时登录用户 id(随 storage/focus 刷新)。null = 未登录。 */
|
||||
userId: string | null;
|
||||
/** 实时显示名(邮箱 @ 前缀,否则 user_id)。 */
|
||||
displayName: string;
|
||||
/** 组件首次挂载时捕获的用户 id。 */
|
||||
mountUserId: string | null;
|
||||
/** 组件首次挂载时捕获的显示名。 */
|
||||
mountDisplayName: string;
|
||||
/**
|
||||
* 当前登录账号与挂载时不一致 —— 即本视图加载后登录被切换过(通常是在另一个
|
||||
* 标签页登录了别的账号)。归属/发布类操作必须据此提醒,避免误归属。
|
||||
*/
|
||||
switched: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 跟踪「实时」登录账号,而不是挂载时缓存的值。
|
||||
*
|
||||
* 鉴权 token 存在 localStorage(非嵌入态下被同浏览器所有标签页共享),所以在
|
||||
* 另一个标签页登录会**静默替换**本标签页 API 调用所用的 token。任何会决定归属的
|
||||
* 界面(创建/发布智能体)都必须显示请求实际将以哪个账号执行,并在会话中途被切换
|
||||
* 账号时给出提示 —— 否则会出现「用 A 账号创建却归到 B 账号名下」。
|
||||
*/
|
||||
export function useLiveAccount(): LiveAccount {
|
||||
const mountRef = useRef<{ id: string | null; name: string } | null>(null);
|
||||
if (mountRef.current === null) {
|
||||
const auth = getStoredAuth();
|
||||
mountRef.current = {
|
||||
id: auth?.user_id ?? null,
|
||||
name: getAccountDisplayName(auth),
|
||||
};
|
||||
}
|
||||
const [live, setLive] = useState(() => mountRef.current!);
|
||||
|
||||
useEffect(() => {
|
||||
function refresh() {
|
||||
const auth = getStoredAuth();
|
||||
const id = auth?.user_id ?? null;
|
||||
const name = getAccountDisplayName(auth);
|
||||
setLive((prev) =>
|
||||
prev.id === id && prev.name === name ? prev : { id, name },
|
||||
);
|
||||
}
|
||||
// 嵌入(iframe)态鉴权走按标签页的 sessionStorage、不跨标签,收不到 storage 事件
|
||||
// —— 只挂在正常态;focus/visibilitychange 兜底刷新两种形态都有效。
|
||||
function onStorage(e: StorageEvent) {
|
||||
if (e.key === AUTH_STORAGE_KEY) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
if (!isEmbedActive()) {
|
||||
window.addEventListener("storage", onStorage);
|
||||
}
|
||||
window.addEventListener("focus", refresh);
|
||||
document.addEventListener("visibilitychange", refresh);
|
||||
refresh();
|
||||
return () => {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener("focus", refresh);
|
||||
document.removeEventListener("visibilitychange", refresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const mount = mountRef.current!;
|
||||
return {
|
||||
userId: live.id,
|
||||
displayName: live.name,
|
||||
mountUserId: mount.id,
|
||||
mountDisplayName: mount.name,
|
||||
switched: live.id !== null && mount.id !== null && live.id !== mount.id,
|
||||
};
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
updateAgent,
|
||||
} from "@/core/agents/api";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { isAgentSkillCandidate, type Skill } from "@/core/skills";
|
||||
@@ -96,6 +97,10 @@ export default function NewAgentPage() {
|
||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||
const { models, isLoading: isModelsLoading } = useModels();
|
||||
|
||||
// 创建请求的归属账号取自「实时」token(apiFetch 每次都重读 localStorage),而非
|
||||
// 本页加载时缓存的身份 —— 用 live 账号提示归属并检测账号是否在会话中途被切换。
|
||||
const live = useLiveAccount();
|
||||
|
||||
// 可配置的技能限定为「内置 + 自己的 + 已发布到广场的」,
|
||||
// 其他用户未发布的自定义技能不可选。
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
@@ -287,13 +292,37 @@ export default function NewAgentPage() {
|
||||
]);
|
||||
|
||||
const handleSaveAgent = useCallback(async () => {
|
||||
// 新建时归属账号 = 创建请求所用的实时 token 对应账号。若本页加载后登录被切换
|
||||
// (常见于另一标签页登录了别的账号),现在创建会把智能体静默归到新账号名下。
|
||||
// 先弹确认,避免「用 A 账号创建却归到 B 账号」。更新已有智能体不改归属,故跳过。
|
||||
if (!agentId && live.switched) {
|
||||
const ok = window.confirm(
|
||||
`当前登录账号已从「${live.mountDisplayName}」切换为「${live.displayName}」。\n` +
|
||||
`现在创建将把该智能体归属到「${live.displayName}」名下。\n` +
|
||||
`如需归属「${live.mountDisplayName}」,请取消并刷新页面后重新登录。\n\n仍要以「${live.displayName}」创建吗?`,
|
||||
);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const saved = await ensureAgent();
|
||||
if (!saved) {
|
||||
return;
|
||||
}
|
||||
toast.success(`智能体「${saved.name}」已保存`);
|
||||
toast.success(
|
||||
agentId
|
||||
? `智能体「${saved.name}」已保存`
|
||||
: `智能体「${saved.name}」已创建(归属账号:${live.displayName})`,
|
||||
);
|
||||
router.push("/page/workspace/agents");
|
||||
}, [ensureAgent, router]);
|
||||
}, [
|
||||
agentId,
|
||||
ensureAgent,
|
||||
live.displayName,
|
||||
live.mountDisplayName,
|
||||
live.switched,
|
||||
router,
|
||||
]);
|
||||
|
||||
const handleChatSubmit = useCallback(
|
||||
async (text: string) => {
|
||||
@@ -661,6 +690,25 @@ export default function NewAgentPage() {
|
||||
<p className="text-destructive text-sm">{formError}</p>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-md border px-3 py-2 text-xs dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
<p className="text-muted-foreground">
|
||||
{agentId ? "当前登录账号:" : "创建后归属账号:"}
|
||||
<span className="text-foreground font-medium">
|
||||
「{live.displayName}」
|
||||
</span>
|
||||
</p>
|
||||
{live.switched && (
|
||||
<p className="text-destructive mt-1">
|
||||
⚠ 本页加载时为「{live.mountDisplayName}」,现已切换为「
|
||||
{live.displayName}」。
|
||||
{agentId
|
||||
? "继续保存将以当前账号执行。"
|
||||
: `继续创建将归属「${live.displayName}」。`}
|
||||
建议刷新页面确认账号。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => void handleSaveAgent()}
|
||||
|
||||
Reference in new issue
Block a user