diff --git a/packages/chat-channel/src/__tests__/ws.test.ts b/packages/chat-channel/src/__tests__/ws.test.ts index 871a0d6c..d55cfe87 100644 --- a/packages/chat-channel/src/__tests__/ws.test.ts +++ b/packages/chat-channel/src/__tests__/ws.test.ts @@ -63,11 +63,15 @@ class FakeWebSocket { const originalWebSocket = Object.getOwnPropertyDescriptor(globalThis, "WebSocket"); const originalSetTimeout = Object.getOwnPropertyDescriptor(globalThis, "setTimeout"); const originalClearTimeout = Object.getOwnPropertyDescriptor(globalThis, "clearTimeout"); +const originalDateNow = Date.now; +let currentTime: number; let timers: ScheduledTimer[]; function installFakes(): void { FakeWebSocket.instances = []; + currentTime = 0; timers = []; + Date.now = () => currentTime; Object.defineProperty(globalThis, "WebSocket", { configurable: true, @@ -120,6 +124,7 @@ describe("createYjsWsClient", () => { }); afterEach(() => { + Date.now = originalDateNow; restoreGlobal("WebSocket", originalWebSocket); restoreGlobal("setTimeout", originalSetTimeout); restoreGlobal("clearTimeout", originalClearTimeout); @@ -142,6 +147,29 @@ describe("createYjsWsClient", () => { expect(timers[0]?.delay).toBe(4000); }); + // 网关余额不足等持续故障可能表现为“连接成功后立刻再次断开”;自动恢复必须有上限, + // 不能因每次 onopen 重置退避而无限创建连接、重复触发 Agent/网关请求。 + test("反复短连接达到上限后停止自动重连", () => { + const states: string[] = []; + const reconnectDelays: Array = []; + const client = createClient((state) => states.push(state)); + client.connect(); + + for (let attempt = 0; attempt < 6; attempt += 1) { + FakeWebSocket.instances[attempt]?.open(); + FakeWebSocket.instances[attempt]?.closeFromServer(1011, "relay handle closed"); + if (attempt < 5) { + reconnectDelays.push(timers[0]?.delay); + runNextTimer(); + } + } + + expect(FakeWebSocket.instances).toHaveLength(6); + expect(reconnectDelays).toEqual([1000, 2000, 4000, 8000, 16000]); + expect(timers).toHaveLength(0); + expect(states.at(-1)).toBe("error"); + }); + // 连接曾成功恢复后,下一次断线应从最短退避时间重新开始。 test("连接成功后重置重连延迟", () => { const client = createClient(); @@ -150,6 +178,7 @@ describe("createYjsWsClient", () => { FakeWebSocket.instances[0]?.closeFromServer(); runNextTimer(); FakeWebSocket.instances[1]?.open(); + currentTime = 30_000; FakeWebSocket.instances[1]?.closeFromServer(); expect(timers[0]?.delay).toBe(1000); @@ -388,6 +417,7 @@ describe("createYjsWsClient 二进制 yjs:update 帧(SP-A4)", () => { }); afterEach(() => { + Date.now = originalDateNow; restoreGlobal("WebSocket", originalWebSocket); restoreGlobal("setTimeout", originalSetTimeout); restoreGlobal("clearTimeout", originalClearTimeout); diff --git a/packages/chat-channel/src/transport/ws.ts b/packages/chat-channel/src/transport/ws.ts index 9dbba282..ecb2498e 100644 --- a/packages/chat-channel/src/transport/ws.ts +++ b/packages/chat-channel/src/transport/ws.ts @@ -19,6 +19,10 @@ const NO_RECONNECT_CODES = new Set([ /** 重连间隔(指数退避),单位毫秒 */ const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000, 30000]; +/** 短连接连续失败上限;达到后停止自动重连,避免持续故障形成无限请求循环。 */ +const MAX_CONSECUTIVE_UNSTABLE_DISCONNECTS = 6; +/** 连接持续达到此时间才视为稳定恢复,并清零短连接失败计数。 */ +const STABLE_CONNECTION_MS = 30_000; export type YjsWsState = "connecting" | "connected" | "disconnected" | "error"; @@ -118,6 +122,8 @@ export function createYjsWsClient(options: YjsWsOptions): YjsWsClient { let ws: WebSocket | null = null; let reconnectDelayIdx = 0; let reconnectTimer: ReturnType | null = null; + let consecutiveUnstableDisconnects = 0; + let connectedAt: number | null = null; let destroyed = false; function setState(state: YjsWsState) { @@ -147,7 +153,7 @@ export function createYjsWsClient(options: YjsWsOptions): YjsWsClient { socket.onopen = () => { if (destroyed || ws !== socket) return; - reconnectDelayIdx = 0; + connectedAt = Date.now(); setState("connected"); for (const vector of getYjsStateVectors?.() ?? []) { socket.send( @@ -241,6 +247,18 @@ export function createYjsWsClient(options: YjsWsOptions): YjsWsClient { setState("error"); return; } + const connectionDuration = connectedAt === null ? 0 : Date.now() - connectedAt; + connectedAt = null; + if (connectionDuration >= STABLE_CONNECTION_MS) { + consecutiveUnstableDisconnects = 0; + reconnectDelayIdx = 0; + } else { + consecutiveUnstableDisconnects += 1; + } + if (consecutiveUnstableDisconnects >= MAX_CONSECUTIVE_UNSTABLE_DISCONNECTS) { + setState("error"); + return; + } setState("disconnected"); scheduleReconnect(); }; diff --git a/src/__tests__/round43-acp-ws-handler.test.ts b/src/__tests__/round43-acp-ws-handler.test.ts index 3455365a..9a1d10b2 100644 --- a/src/__tests__/round43-acp-ws-handler.test.ts +++ b/src/__tests__/round43-acp-ws-handler.test.ts @@ -10,6 +10,7 @@ import { stubRegistryHeartbeat, } from "../test-utils/helpers"; import { + closeAcpConnectionsForEnvironments, closeAllAcpConnections, findMachineConnectionByAgentId, findMachineConnectionById, @@ -152,6 +153,32 @@ describe("round43 acp ws handler", () => { expect(listAcpConnections().find((item) => item.wsId === "unknown-43")).toBeUndefined(); }); + // 删除 Environment 必须关闭本地绑定连接,防止客户端继续用失效环境重连/发请求。 + test("关闭已删除 Environment 的 ACP 连接并清理缓存", () => { + const ws = new FakeWs(); + handleAcpWsOpen(ws, "deleted-env-ws", "user-a", "env-deleted", false); + setAgentMachineCache("env-deleted", "machine-deleted"); + + closeAcpConnectionsForEnvironments(["env-deleted"]); + + expect(ws.closed).toEqual([[1000, "environment deleted"]]); + expect(listAcpConnections().find((item) => item.wsId === "deleted-env-ws")).toBeUndefined(); + expect(getAgentMachineCache().has("env-deleted")).toBe(false); + }); + + // 删除一个 Environment 不得影响其他环境或 machine 连接。 + test("关闭 Environment 连接时保留其他连接", () => { + const deletedWs = new FakeWs(); + const retainedWs = new FakeWs(); + handleAcpWsOpen(deletedWs, "deleted-env-ws-2", "user-a", "env-deleted-2", false); + handleAcpWsOpen(retainedWs, "retained-env-ws", "user-a", "env-retained", false); + + closeAcpConnectionsForEnvironments(["env-deleted-2"]); + + expect(deletedWs.closed).toHaveLength(1); + expect(retainedWs.closed).toEqual([]); + expect(listAcpConnections().map((item) => item.wsId)).toEqual(["retained-env-ws"]); + }); // machine 连接只保存调用者身份,不会复用其他连接的用户标识。 test("machine 快照按连接保留各自用户身份", () => { handleAcpWsOpen(new FakeWs(), "machine-user-a", "user-a", null, true); diff --git a/src/services/config/agent-config.ts b/src/services/config/agent-config.ts index fd60c39d..ddcb25b4 100644 --- a/src/services/config/agent-config.ts +++ b/src/services/config/agent-config.ts @@ -250,15 +250,19 @@ export async function deleteAgentConfig(ctx: AuthContext, name: string): Promise .from(environment) .where(and(eq(environment.organizationId, row.organizationId), eq(environment.agentConfigId, row.id))); if (boundEnvs.length > 0) { + const environmentIds = boundEnvs.map((env) => env.id); + // 先关闭本地 ACP 连接,再停止运行实例;否则客户端仍会用已删除环境继续发消息。 + const { closeAcpConnectionsForEnvironments } = await import("../../transport/acp-ws-handler"); + closeAcpConnectionsForEnvironments(environmentIds); + // 动态 import 打破模块循环:orchestration-instance 顶层静态 import ./config 的 // getReadableAgentConfigById(本文件所在 index 的 re-export),此处若静态反向 // import 会形成 agent-config → orchestration-instance → config 的循环依赖 // (同 orchestration-instance 内 reclaimYjsDocs 的惰性导入模式)。 const { stopInstancesForEnvironments } = await import("../orchestration-instance"); - await stopInstancesForEnvironments( - boundEnvs.map((env) => env.id), - { organizationId: row.organizationId }, - ); + await stopInstancesForEnvironments(environmentIds, { + organizationId: row.organizationId, + }); } return db.transaction(async (tx) => { diff --git a/src/services/environment-core.ts b/src/services/environment-core.ts index 0db758f4..df45ca0e 100644 --- a/src/services/environment-core.ts +++ b/src/services/environment-core.ts @@ -114,6 +114,8 @@ export async function getOwnedEnvironment( * 已校验归属)均在此前完成权限校验,环境内实例必然同属该环境。 */ export async function deleteEnvironment(envId: string): Promise { + const { closeAcpConnectionsForEnvironments } = await import("../transport/acp-ws-handler"); + closeAcpConnectionsForEnvironments([envId]); await stopInstancesForEnvironments([envId]); return environmentRepo.delete(envId); } diff --git a/src/transport/acp-ws-handler.ts b/src/transport/acp-ws-handler.ts index 4a1da235..88c5277d 100644 --- a/src/transport/acp-ws-handler.ts +++ b/src/transport/acp-ws-handler.ts @@ -582,6 +582,34 @@ export function triggerMachineCleanupByMachineId(machineId: string, reason: stri }); } +/** + * 关闭指定 Environment 关联的本地 ACP 连接,并清理 agent → machine 缓存。 + * + * 删除 Environment/Agent 后连接端仍可能继续发送消息;只删除 DB 和 runtime instance + * 不会使这类连接失效,导致服务端持续用已删除的 environmentId 处理请求。 + */ +export function closeAcpConnectionsForEnvironments(environmentIds: string[]): void { + if (environmentIds.length === 0) return; + const environmentIdSet = new Set(environmentIds); + + for (const [wsId, entry] of connections) { + if (entry.isMachine || !entry.boundEnvId || !environmentIdSet.has(entry.boundEnvId)) continue; + + if (entry.unsub) entry.unsub(); + if (entry.keepalive) clearInterval(entry.keepalive); + connections.delete(wsId); + try { + entry.ws.close(1000, "environment deleted"); + } catch (error) { + logError(`[ACP-WS-CLOSE] failed to close deleted environment connection: wsId=${wsId}`, error); + } + } + + for (const environmentId of environmentIds) { + agentMachineCache.delete(environmentId); + } +} + /** Called from onClose — marks agent offline and cleans up */ export function handleAcpWsClose(_ws: WsConnection, wsId: string, code?: number, reason?: string): void { const entry = connections.get(wsId); diff --git a/web/components/chat/SystemMessage.tsx b/web/components/chat/SystemMessage.tsx index 4c69219b..d28a4604 100644 --- a/web/components/chat/SystemMessage.tsx +++ b/web/components/chat/SystemMessage.tsx @@ -1,24 +1,48 @@ -import { memo } from "react"; +import { memo, useState } from "react"; import { useTranslation } from "react-i18next"; import { NS } from "../../src/i18n"; import { cn } from "../../src/lib/utils"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog"; interface SystemMessageProps { - /** 原始 system-reminder 块仅用于判空,内容不在界面暴露。 */ + /** 原始 system-reminder 块仅在用户主动查看详情时展示。 */ rawText: string; className?: string; } /** - * 系统消息仅展示弱化胶囊,不展开内部注入内容。 + * 系统消息默认展示弱化胶囊,双击后可检查完整原始内容。 */ export const SystemMessage = memo(function SystemMessage({ rawText, className }: SystemMessageProps) { const { t } = useTranslation(NS.COMPONENTS); + const [detailsOpen, setDetailsOpen] = useState(false); if (!rawText) return null; return ( -
- {t("messageBubble.systemMessage")} -
+ <> +
+ +
+ + + + {t("messageBubble.systemMessage")} + {t("messageBubble.systemMessageDescription")} + +
+            {rawText}
+          
+
+
+ ); }); diff --git a/web/components/chat/chat-navigation-aids.css b/web/components/chat/chat-navigation-aids.css index 4278b8dd..5a731414 100644 --- a/web/components/chat/chat-navigation-aids.css +++ b/web/components/chat/chat-navigation-aids.css @@ -68,6 +68,21 @@ background: #202936; } +.chat-entry--active-prompt { + border-radius: 12px; + animation: chat-active-prompt-flash 900ms ease-out; +} + +@keyframes chat-active-prompt-flash { + 0%, + 20% { + background-color: rgb(100 116 139 / 14%); + } + 100% { + background-color: transparent; + } +} + .chat-prompt-jump-index__preview { position: fixed; z-index: 30; @@ -109,6 +124,11 @@ } @media (prefers-reduced-motion: reduce) { + .chat-entry--active-prompt { + animation: none; + background-color: rgb(100 116 139 / 10%); + } + .chat-prompt-jump-index__tick, .chat-prompt-jump-index__preview { transition: none; diff --git a/web/components/chat/chat-navigation-aids.tsx b/web/components/chat/chat-navigation-aids.tsx index c0f73de8..210e6ddf 100644 --- a/web/components/chat/chat-navigation-aids.tsx +++ b/web/components/chat/chat-navigation-aids.tsx @@ -57,6 +57,12 @@ export function PromptJumpRail({ entries }: PromptJumpRailProps) { } }, [activeId, visiblePrompts]); + useEffect(() => { + const activePrompt = activeId ? document.getElementById(`chat-entry-${activeId}`) : null; + activePrompt?.classList.add("chat-entry--active-prompt"); + return () => activePrompt?.classList.remove("chat-entry--active-prompt"); + }, [activeId]); + useEffect(() => { const rail = railRef.current; const conversation = rail?.parentElement; diff --git a/web/src/__tests__/agent-create-enter-flow.test.ts b/web/src/__tests__/agent-create-enter-flow.test.ts index 1c1a78a4..d17cb955 100644 --- a/web/src/__tests__/agent-create-enter-flow.test.ts +++ b/web/src/__tests__/agent-create-enter-flow.test.ts @@ -1,6 +1,6 @@ import { describe, expect, mock, test } from "bun:test"; import type { EnterEnvironmentResponse, EnvironmentDetail } from "../api/environments"; -import { resolveCreatedAgentChatTarget } from "../pages/agent-panel/AgentPanelLayout"; +import { resolveCreatedAgentChatTarget } from "../pages/agent-panel/agent-create-navigation"; describe("新建智能体进入对话", () => { // 新建智能体创建环境后必须显式进入环境,并携带实例 UID 导航,避免聊天页永久等待连接。 diff --git a/web/src/__tests__/agent-home-generation.test.tsx b/web/src/__tests__/agent-home-generation.test.tsx index 19cc7aba..2198ceea 100644 --- a/web/src/__tests__/agent-home-generation.test.tsx +++ b/web/src/__tests__/agent-home-generation.test.tsx @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; import { hasAgentGenerationPrompt } from "../pages/agent-panel/pages/AgentHomePage"; +const agentHomeSource = Bun.file(new URL("../pages/agent-panel/pages/AgentHomePage.tsx", import.meta.url)); + describe("Agent 首页生成输入校验", () => { // 空字符串和纯空白不应启用一键创建,避免点击按钮后没有任何反馈。 test("拒绝空白描述", () => { @@ -12,4 +14,13 @@ describe("Agent 首页生成输入校验", () => { test("接受有效描述", () => { expect(hasAgentGenerationPrompt("创建一个代码审查 Agent")).toBe(true); }); + + // 首页提交必须复用统一的实例进入流程,并携带实例 UID 导航,避免聊天页永久等待。 + test("创建后显式进入实例并导航到实例路由", async () => { + const source = await agentHomeSource.text(); + + expect(source).toContain("resolveCreatedAgentChatTarget(agentConfigId"); + expect(source).toContain('to: "/agent/chat/$agentId/$sessionId"'); + expect(source).toContain("sessionId: target.instanceUid"); + }); }); diff --git a/web/src/__tests__/agent-sidebar-instance-order.test.ts b/web/src/__tests__/agent-sidebar-instance-order.test.ts index 14548f0b..0442ec8e 100644 --- a/web/src/__tests__/agent-sidebar-instance-order.test.ts +++ b/web/src/__tests__/agent-sidebar-instance-order.test.ts @@ -38,6 +38,12 @@ describe("Agent sidebar Instance 排序", () => { }); }); +test("聊天路由向侧边栏传递当前 Instance", () => { + const source = readFileSync(resolve(import.meta.dir, "../pages/agent-panel/AgentPanelLayout.tsx"), "utf8"); + + expect(source).toContain("selectedInstanceId={isChatRoute ? chatSessionId : lastChatSessionRef.current}"); +}); + // 配置导航分区只允许内容容器滚动,避免与外层包装形成嵌套双滚动条。 test("配置导航分区保持单一滚动容器", () => { const source = readFileSync(resolve(import.meta.dir, "../pages/agent-panel/agent-panel.css"), "utf8"); diff --git a/web/src/__tests__/chat-area-environment-deletion.test.tsx b/web/src/__tests__/chat-area-environment-deletion.test.tsx new file mode 100644 index 00000000..d1fc2efb --- /dev/null +++ b/web/src/__tests__/chat-area-environment-deletion.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { evictDeletedEnvironmentSlots, resolveActiveChatEnvironmentId } from "../pages/agent-panel/ChatArea"; + +describe("ChatArea 删除 Environment 生命周期", () => { + // 删除当前 Environment 后必须禁用详情请求与当前槽位回填,避免持续请求已删除资源。 + test("已删除 Environment 不再作为活跃聊天目标", () => { + expect(resolveActiveChatEnvironmentId("env-deleted", new Set(["env-deleted"]))).toBeNull(); + expect(resolveActiveChatEnvironmentId("env-active", new Set(["env-deleted"]))).toBe("env-active"); + }); + + // 删除 Agent 时只驱逐其 Environment 会话,其他 Agent 的 keep-alive 会话必须保留。 + test("驱逐已删除 Environment 的全部 keep-alive 会话", () => { + const slots = { + "session-deleted-1": { agentId: "env-deleted", sessionId: "session-deleted-1" }, + "session-deleted-2": { agentId: "env-deleted", sessionId: "session-deleted-2" }, + "session-retained": { agentId: "env-retained", sessionId: "session-retained" }, + }; + + expect(evictDeletedEnvironmentSlots(slots, new Set(["env-deleted"]))).toEqual({ + "session-retained": { agentId: "env-retained", sessionId: "session-retained" }, + }); + }); + + // 没有命中删除集合时应复用原对象,避免无意义地重建所有 keep-alive 面板。 + test("未命中删除集合时保持缓存引用", () => { + const slots = { + "session-active": { agentId: "env-active", sessionId: "session-active" }, + }; + + expect(evictDeletedEnvironmentSlots(slots, new Set(["env-other"]))).toBe(slots); + }); +}); diff --git a/web/src/__tests__/chat-navigation-aids.test.tsx b/web/src/__tests__/chat-navigation-aids.test.tsx index 0d6c4bfa..4d795e3f 100644 --- a/web/src/__tests__/chat-navigation-aids.test.tsx +++ b/web/src/__tests__/chat-navigation-aids.test.tsx @@ -135,5 +135,7 @@ describe("PromptJumpRail", () => { expect(scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" }); expect(button.getAttribute("aria-current")).toBe("location"); expect(button.getAttribute("aria-controls")).toBe("chat-entry-prompt-1"); + expect(target.classList.contains("chat-entry--active-prompt")).toBe(true); + expect(document.getElementById("chat-entry-prompt-0")?.classList.contains("chat-entry--active-prompt")).toBe(false); }); }); diff --git a/web/src/__tests__/message.ssr.test.tsx b/web/src/__tests__/message.ssr.test.tsx index 36ad8c42..c4f6b229 100644 --- a/web/src/__tests__/message.ssr.test.tsx +++ b/web/src/__tests__/message.ssr.test.tsx @@ -172,20 +172,23 @@ describe("消息组件的服务端渲染", () => { expect(markup).toContain("chat-system-reminder"); }); - // 系统消息只展示标签,并与助手消息正文左边界对齐;原始注入内容不得暴露。 - test("系统消息左对齐且不暴露原始内容", () => { + // 系统消息默认只展示标签并与助手消息正文左边界对齐,原始注入内容仅在主动查看时出现。 + test("系统消息默认隐藏原始内容", () => { const markup = renderToStaticMarkup( createElement(SystemMessage, { rawText: "不可展示" }), ); expect(markup).toContain('class="flex justify-start"'); + expect(markup).toContain("messageBubble.openSystemMessage"); expect(markup).not.toContain("不可展示"); }); - // system-reminder 标签必须提供中英文资源,不能在中文界面继续显示硬编码英文。 + // system-reminder 标签和详情交互必须提供中英文资源,不能在中文界面继续显示硬编码英文。 test("系统消息标签提供中英文翻译", () => { expect(componentsZH.messageBubble.systemMessage).toBe("系统提醒"); + expect(componentsZH.messageBubble.openSystemMessage).toBe("双击查看系统提醒详情"); expect(componentsEN.messageBubble.systemMessage).toBe("SYSTEM REMINDER"); + expect(componentsEN.messageBubble.openSystemMessage).toBe("Double-click to view system reminder details"); }); // 子 Agent 详情默认折叠,只展示执行轨迹摘要,避免占满父工具调用。 diff --git a/web/src/__tests__/organization-invite-dialog.test.tsx b/web/src/__tests__/organization-invite-dialog.test.tsx new file mode 100644 index 00000000..0618367f --- /dev/null +++ b/web/src/__tests__/organization-invite-dialog.test.tsx @@ -0,0 +1,90 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { I18nextProvider } from "react-i18next"; +import type { OrgMemberCandidate } from "../api/organizations"; +import i18n from "../i18n"; +import { initializeHappyDomWindow } from "./happy-dom-window"; + +(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; +const win = initializeHappyDomWindow(new Window()); +const globals = globalThis as Record; +const originalGlobals = new Map( + ["window", "document", "navigator", "HTMLElement", "Element", "Node", "CustomEvent"].map((key) => [ + key, + globals[key], + ]), +); +globals.window = win; +globals.document = win.document; +globals.navigator = win.navigator; +globals.HTMLElement = win.HTMLElement; +globals.Element = win.Element; +globals.Node = win.Node; +globals.CustomEvent = win.CustomEvent; + +const candidate: OrgMemberCandidate = { + id: "user-2", + name: "2222", + email: "admin1@test.com", + isMember: false, +}; + +let root: Root | null = null; +afterEach(() => { + act(() => root?.unmount()); + root = null; + win.document.body.replaceChildren(); +}); +afterAll(() => { + for (const [key, value] of originalGlobals) { + if (value === undefined) delete globals[key]; + else globals[key] = value; + } +}); + +describe("添加组织成员弹窗", () => { + // 点击搜索结果必须触发候选人选择,避免添加按钮一直停留在禁用状态。 + test("点击候选用户触发添加回调", async () => { + const { MemberCandidateButton } = await import("../pages/agent-panel/pages/agent-organizations-dialogs"); + const selected: OrgMemberCandidate[] = []; + const container = win.document.createElement("div"); + win.document.body.appendChild(container); + root = createRoot(container as unknown as HTMLElement); + await act(async () => + root?.render( + + selected.push(item)} /> + , + ), + ); + + const button = container.querySelector("button") as unknown as HTMLButtonElement | null; + expect(button?.disabled).toBe(false); + await act(async () => button?.click()); + expect(selected).toEqual([candidate]); + }); + + // 已在组织内的用户不可重复选择。 + test("已有成员候选按钮保持禁用", async () => { + const { MemberCandidateButton } = await import("../pages/agent-panel/pages/agent-organizations-dialogs"); + const container = win.document.createElement("div"); + win.document.body.appendChild(container); + root = createRoot(container as unknown as HTMLElement); + await act(async () => + root?.render( + + undefined} + /> + , + ), + ); + + const button = container.querySelector("button") as unknown as HTMLButtonElement | null; + expect(button?.disabled).toBe(true); + }); +}); diff --git a/web/src/i18n/locales/en/components.json b/web/src/i18n/locales/en/components.json index 0875fa75..63f46c32 100644 --- a/web/src/i18n/locales/en/components.json +++ b/web/src/i18n/locales/en/components.json @@ -284,6 +284,8 @@ "uploadedImage": "Uploaded image", "imagePreview": "Image preview", "systemMessage": "SYSTEM REMINDER", + "openSystemMessage": "Double-click to view system reminder details", + "systemMessageDescription": "This system-injected content is shown only for inspecting the current conversation context.", "turnError": "Execution failed", "actions": "Message actions", "copy": "Copy", diff --git a/web/src/i18n/locales/zh/components.json b/web/src/i18n/locales/zh/components.json index 4ddcd20b..a94532b9 100644 --- a/web/src/i18n/locales/zh/components.json +++ b/web/src/i18n/locales/zh/components.json @@ -284,6 +284,8 @@ "uploadedImage": "已上传图片", "imagePreview": "图片预览", "systemMessage": "系统提醒", + "openSystemMessage": "双击查看系统提醒详情", + "systemMessageDescription": "以下内容由系统注入,仅供检查当前会话上下文。", "turnError": "执行出错", "actions": "消息操作", "copy": "复制", diff --git a/web/src/pages/agent-panel/AgentPanelLayout.tsx b/web/src/pages/agent-panel/AgentPanelLayout.tsx index fe7db06b..1e5cd15c 100644 --- a/web/src/pages/agent-panel/AgentPanelLayout.tsx +++ b/web/src/pages/agent-panel/AgentPanelLayout.tsx @@ -1,37 +1,14 @@ import { Outlet, useNavigate, useRouterState } from "@tanstack/react-router"; import { useCallback, useRef, useState } from "react"; -import { type EnterEnvironmentResponse, type EnvironmentDetail, envApi } from "@/src/api/environments"; +import { envApi } from "@/src/api/environments"; import { unwrap } from "@/src/api/request"; import { dispatchConfigChange } from "../../lib/config-events"; import { AgentSidebar } from "./AgentSidebar"; +import { resolveCreatedAgentChatTarget } from "./agent-create-navigation"; import { AgentFormDialog } from "./agent-editor/AgentFormDialog"; import { ChatArea } from "./ChatArea"; import "./agent-panel.css"; -interface CreatedAgentEnvironmentGateway { - list: () => Promise; - create: (body: { name: string; agentConfigId: string; autoStart: boolean }) => Promise; - enter: (environmentId: string) => Promise; -} - -/** 新建 Agent 后确保关联到真实 Instance,再生成聊天路由目标。 */ -export async function resolveCreatedAgentChatTarget( - agentConfigId: string, - gateway: CreatedAgentEnvironmentGateway, -): Promise<{ environmentId: string; instanceUid: string }> { - const environments = await gateway.list(); - const existingEnvironment = environments.find((environment) => environment.agentConfigId === agentConfigId); - const environment = - existingEnvironment ?? - (await gateway.create({ - name: `env-${agentConfigId.slice(0, 8)}`, - agentConfigId, - autoStart: true, - })); - const entered = await gateway.enter(environment.id); - return { environmentId: entered.environmentId ?? environment.id, instanceUid: entered.instanceUid }; -} - export function AgentPanelLayout() { const navigate = useNavigate(); // 仅订阅 pathname:避免 useRouterState() 无选择器订阅全部路由状态 @@ -44,6 +21,7 @@ export function AgentPanelLayout() { const [panelHost, setPanelHost] = useState(null); const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [deletedEnvironmentIds, setDeletedEnvironmentIds] = useState>(() => new Set()); const [configDialog, setConfigDialog] = useState<{ open: boolean; agentName: string }>({ open: false, agentName: "", @@ -120,19 +98,39 @@ export function AgentPanelLayout() { lastChatSessionRef.current = chatSessionId; } + const handleDeleteAgentEnvironments = useCallback( + (environmentIds: string[]) => { + if (environmentIds.length === 0) return; + setDeletedEnvironmentIds((current) => new Set([...current, ...environmentIds])); + if (selectedEnvironmentId && environmentIds.includes(selectedEnvironmentId)) { + lastChatAgentRef.current = null; + lastChatSessionRef.current = null; + void navigate({ to: "/agent/home" }); + } + }, + [navigate, selectedEnvironmentId], + ); + return (
setCreateDialogOpen(true)} onEditAgent={(agentName) => setConfigDialog({ open: true, agentName })} + onDeleteAgentEnvironments={handleDeleteAgentEnvironments} />
- +
void; onCreateAgent?: () => void; onEditAgent?: (agentName: string) => void; + onDeleteAgentEnvironments?: (environmentIds: string[]) => void; } export const AgentSidebar = memo(function AgentSidebar({ @@ -34,6 +35,7 @@ export const AgentSidebar = memo(function AgentSidebar({ onNavigate, onCreateAgent, onEditAgent, + onDeleteAgentEnvironments, }: AgentSidebarProps) { const { t: tSidebar } = useTranslation(NS.SIDEBAR); const { data: session } = useSession(); @@ -103,6 +105,7 @@ export const AgentSidebar = memo(function AgentSidebar({ onSelectInstance={onSelectInstance} onCreateAgent={onCreateAgent} onEditAgent={onEditAgent} + onDeleteAgentEnvironments={onDeleteAgentEnvironments} />
diff --git a/web/src/pages/agent-panel/AgentSidebarTree.tsx b/web/src/pages/agent-panel/AgentSidebarTree.tsx index 99436a43..2d4e6a61 100644 --- a/web/src/pages/agent-panel/AgentSidebarTree.tsx +++ b/web/src/pages/agent-panel/AgentSidebarTree.tsx @@ -1,4 +1,3 @@ -import { useNavigate } from "@tanstack/react-router"; import { useRequest } from "ahooks"; import { Bot, @@ -81,6 +80,7 @@ interface AgentSidebarTreeProps { onSelectInstance: (instanceId: string, envId: string, sessionId: string | null) => void; onCreateAgent?: () => void; onEditAgent?: (agentName: string) => void; + onDeleteAgentEnvironments?: (environmentIds: string[]) => void; } export const AgentSidebarTree = memo(function AgentSidebarTree({ @@ -89,12 +89,12 @@ export const AgentSidebarTree = memo(function AgentSidebarTree({ onSelectInstance, onCreateAgent, onEditAgent, + onDeleteAgentEnvironments, }: AgentSidebarTreeProps) { const { t } = useTranslation(NS.AGENT_PANEL); const { t: tComponents } = useTranslation(NS.COMPONENTS); const { org } = useOrg(); const orgId = org?.id; - const navigate = useNavigate(); // 交互状态 const [expandedAgents, setExpandedAgents] = useState>({}); @@ -312,22 +312,17 @@ export const AgentSidebarTree = memo(function AgentSidebarTree({ // ---- 删除智能体(manual useRequest)---- const { run: runDeleteAgent, loading: deleting } = useRequest( async (agent: AgentConfigItem) => { - // 删除前判断:当前对话页打开的环境是否属于该 agent 配置。 - // 通过 environmentId → agentConfigId 映射比对,兼容同一 agent 存在多个环境的情况。 - const openConfigId = selectedEnvironmentId ? (envConfigMapRef.current.get(selectedEnvironmentId) ?? null) : null; - const deletingOpenAgent = openConfigId !== null && openConfigId === agent.id; + const deletingEnvironmentIds = [...envConfigMapRef.current.entries()] + .filter(([, agentConfigId]) => agentConfigId === agent.id) + .map(([environmentId]) => environmentId); await unwrap(agentApi.delete(agent.name)); toast.success(t("deleteSuccess")); + onDeleteAgentEnvironments?.(deletingEnvironmentIds); // 通知其它页面(如智能体管理页)刷新列表 dispatchConfigChange("agents"); await refresh(); - - // 若删除的正是当前对话页打开的智能体,切换到新建智能体页面 - if (deletingOpenAgent) { - void navigate({ to: "/agent/home" }); - } }, { manual: true, diff --git a/web/src/pages/agent-panel/ChatArea.tsx b/web/src/pages/agent-panel/ChatArea.tsx index 7f53e90a..e2b27a0d 100644 --- a/web/src/pages/agent-panel/ChatArea.tsx +++ b/web/src/pages/agent-panel/ChatArea.tsx @@ -29,6 +29,8 @@ interface ChatAreaProps { agentId: string | null; sessionId?: string | null; visible: boolean; + /** 已删除的 Environment;对应 keep-alive slot 必须立即卸载。 */ + deletedEnvironmentIds?: ReadonlySet; /** ProdView 模块配置,控制右侧附加面板的显示/隐藏 */ modulesConfig?: ProdViewModulesConfig; } @@ -38,6 +40,25 @@ interface SessionSlot { sessionId: string | null; } +/** 删除状态命中当前 Environment 时禁用所有依赖其 ID 的请求和渲染。 */ +export function resolveActiveChatEnvironmentId( + agentId: string | null, + deletedEnvironmentIds?: ReadonlySet, +): string | null { + return agentId && !deletedEnvironmentIds?.has(agentId) ? agentId : null; +} + +/** 驱逐已删除 Environment 的 keep-alive 会话,同时保留其他会话的引用稳定性。 */ +export function evictDeletedEnvironmentSlots( + slots: Record, + deletedEnvironmentIds: ReadonlySet, +): Record { + const next = Object.fromEntries( + Object.entries(slots).filter(([, slot]) => !deletedEnvironmentIds.has(slot.agentId)), + ) as Record; + return Object.keys(next).length === Object.keys(slots).length ? slots : next; +} + type ArtifactsLayoutMode = "floating" | "docked"; const ARTIFACTS_MIN_WIDTH = 356; @@ -73,8 +94,9 @@ function readArtifactsLayout(): ArtifactsLayoutMode { * agentId/sessionId 从 AgentPanelLayout 的 URL 解析传入(而非 Route.useParams), * 仅当用户主动切换到新的 chat agent 时才变更,切到非 chat 页面时保持上次的 agentId。 */ -export function ChatArea({ agentId, sessionId, visible, modulesConfig }: ChatAreaProps) { +export function ChatArea({ agentId, sessionId, visible, deletedEnvironmentIds, modulesConfig }: ChatAreaProps) { const { t } = useTranslation(NS.AGENT_PANEL); + const activeAgentId = resolveActiveChatEnvironmentId(agentId, deletedEnvironmentIds); const artifactsCollapsedRef = useRef(true); const [artifactsCollapsed, setArtifactsCollapsed] = useState(true); @@ -88,13 +110,13 @@ export function ChatArea({ agentId, sessionId, visible, modulesConfig }: ChatAre // 无论是否有 sessionId 都需加载——有 session 时按 agentId 拉取。 const { data: agentConfigId = null } = useRequest( async () => { - if (!agentId) return null; - const env = await unwrap(envApi.get({ id: agentId })); + if (!activeAgentId) return null; + const env = await unwrap(envApi.get({ id: activeAgentId })); return env.agentConfigId ?? null; }, { - refreshDeps: [agentId], - ready: !!agentId, + refreshDeps: [activeAgentId], + ready: !!activeAgentId, onError: (err) => console.warn("[ChatArea] 加载 environment 详情失败", err), }, ); @@ -103,10 +125,10 @@ export function ChatArea({ agentId, sessionId, visible, modulesConfig }: ChatAre // 此处只做投影存储,不再持有完整 entries 或二次全量派生。 // 按 agentName 过滤:ChatArea 维护跨 agent 的 session keep-alive 槽位, // 后台隐藏槽位(延迟节流 flush / 重连收流中)派发的 chat:stats 不得污染当前 agent 的面板 - const changedFiles = useChangedFilesFromStats(agentId); + const changedFiles = useChangedFilesFromStats(activeAgentId); // 当前 slot 会在清理缓存后立即回填,必须单独递增重连版本以重新获取新实例的 capabilities。 const [agentRestartVersions, setAgentRestartVersions] = useState>({}); - const activeAgentRestartVersion = agentId ? (agentRestartVersions[agentId] ?? 0) : 0; + const activeAgentRestartVersion = activeAgentId ? (agentRestartVersions[activeAgentId] ?? 0) : 0; // ProdView 模块配置:若所有附加面板都被禁用,则不渲染右侧面板区域 const hasPanelModules = useMemo(() => { @@ -118,24 +140,24 @@ export function ChatArea({ agentId, sessionId, visible, modulesConfig }: ChatAre // ── Session keep-alive 缓存 ── // 缓存所有访问过的 session slot,key 为 sessionId 或 agent-level 兜底 key const [sessionSlots, setSessionSlots] = useState>({}); - const currentSessionKey = sessionId ?? (agentId ? `__agent_${agentId}` : null); + const currentSessionKey = sessionId ?? (activeAgentId ? `__agent_${activeAgentId}` : null); // 新 session 首次访问时注册到缓存,触发重渲染以包含新的 ChatPanel 实例 useEffect(() => { - if (currentSessionKey && agentId && !sessionSlots[currentSessionKey]) { + if (currentSessionKey && activeAgentId && !sessionSlots[currentSessionKey]) { setSessionSlots((prev) => ({ ...prev, - [currentSessionKey]: { agentId, sessionId: sessionId ?? null }, + [currentSessionKey]: { agentId: activeAgentId, sessionId: sessionId ?? null }, })); } - }, [currentSessionKey, agentId, sessionId, sessionSlots]); + }, [currentSessionKey, activeAgentId, sessionId, sessionSlots]); // 实例重启时:清除所有同 agent 的缓存 slot(它们都需要重建连接) useEffect(() => { const handler = (e: Event) => { const detail = (e as CustomEvent).detail; const restartedEnvironmentId = detail?.envId; - if (typeof restartedEnvironmentId !== "string" || restartedEnvironmentId !== agentId) return; + if (typeof restartedEnvironmentId !== "string" || restartedEnvironmentId !== activeAgentId) return; setAgentRestartVersions((versions) => ({ ...versions, @@ -154,12 +176,18 @@ export function ChatArea({ agentId, sessionId, visible, modulesConfig }: ChatAre }; window.addEventListener("agent:reconnect", handler); return () => window.removeEventListener("agent:reconnect", handler); - }, [agentId]); + }, [activeAgentId]); + + // Agent 删除后驱逐其全部 session slot,确保隐藏 ChatPanel 断开连接并停止请求。 + useEffect(() => { + if (!deletedEnvironmentIds || deletedEnvironmentIds.size === 0) return; + setSessionSlots((prev) => evictDeletedEnvironmentSlots(prev, deletedEnvironmentIds)); + }, [deletedEnvironmentIds]); // 合并 state 中的缓存 + 当前渲染中的 slot(首次访问时 effect 尚未触发,需要兜底) const allSlots = { ...sessionSlots }; - if (currentSessionKey && agentId) { - allSlots[currentSessionKey] = { agentId, sessionId: sessionId ?? null }; + if (currentSessionKey && activeAgentId) { + allSlots[currentSessionKey] = { agentId: activeAgentId, sessionId: sessionId ?? null }; } // 聊天面板列表:每个 slot 一个 ChatPanel 实例,通过 CSS display 切换 @@ -193,7 +221,7 @@ export function ChatArea({ agentId, sessionId, visible, modulesConfig }: ChatAre // artifacts:preview-file → 展开右侧面板 useEffect(() => { const handler = (event: Event) => { - if (!getArtifactsPreviewFileDetail(event, agentId)) return; + if (!getArtifactsPreviewFileDetail(event, activeAgentId)) return; if (artifactsCollapsedRef.current) { artifactsCollapsedRef.current = false; setArtifactsCollapsed(false); @@ -201,7 +229,7 @@ export function ChatArea({ agentId, sessionId, visible, modulesConfig }: ChatAre }; window.addEventListener(ARTIFACTS_PREVIEW_FILE_EVENT, handler); return () => window.removeEventListener(ARTIFACTS_PREVIEW_FILE_EVENT, handler); - }, [agentId]); + }, [activeAgentId]); // 小屏只允许浮动模式。模式选择被保留,回到大屏时恢复用户偏好。 useEffect(() => { @@ -344,8 +372,8 @@ export function ChatArea({ agentId, sessionId, visible, modulesConfig }: ChatAre }} /> Promise; + create: (body: { name: string; agentConfigId: string; autoStart: boolean }) => Promise; + enter: (environmentId: string) => Promise; +} + +/** 新建 Agent 后确保关联到真实 Instance,再生成聊天路由目标。 */ +export async function resolveCreatedAgentChatTarget( + agentConfigId: string, + gateway: CreatedAgentEnvironmentGateway, +): Promise<{ environmentId: string; instanceUid: string }> { + const environments = await gateway.list(); + const existingEnvironment = environments.find((environment) => environment.agentConfigId === agentConfigId); + const environment = + existingEnvironment ?? + (await gateway.create({ + name: `env-${agentConfigId.slice(0, 8)}`, + agentConfigId, + autoStart: true, + })); + const entered = await gateway.enter(environment.id); + return { environmentId: entered.environmentId ?? environment.id, instanceUid: entered.instanceUid }; +} diff --git a/web/src/pages/agent-panel/chat-design-messages-tools.css b/web/src/pages/agent-panel/chat-design-messages-tools.css index 2334ebdb..f02535b6 100644 --- a/web/src/pages/agent-panel/chat-design-messages-tools.css +++ b/web/src/pages/agent-panel/chat-design-messages-tools.css @@ -230,6 +230,12 @@ 700 10px / 1.4 ui-monospace, monospace; letter-spacing: 0.04em; + cursor: pointer; +} +.chat-system-reminder:hover, +.chat-system-reminder:focus-visible { + border-color: #c5cfdd; + color: #46566d; } .message-content ul { diff --git a/web/src/pages/agent-panel/pages/AgentHomePage.tsx b/web/src/pages/agent-panel/pages/AgentHomePage.tsx index 47d3fc24..0353889a 100644 --- a/web/src/pages/agent-panel/pages/AgentHomePage.tsx +++ b/web/src/pages/agent-panel/pages/AgentHomePage.tsx @@ -11,6 +11,7 @@ import { modelApi } from "@/src/api/models"; import { unwrap } from "@/src/api/request"; import { NS } from "../../../i18n"; import { dispatchConfigChange } from "../../../lib/config-events"; +import { resolveCreatedAgentChatTarget } from "../agent-create-navigation"; import type { GenerationFormData } from "../components/AgentGenerationForm"; import { AgentGenerationForm } from "../components/AgentGenerationForm"; @@ -142,30 +143,21 @@ export function AgentHomePage() { // 刷新左侧智能体列表 dispatchConfigChange("agents"); - // 3. 查找是否已有绑定该 agentConfigId 的 environment - const envList = await unwrap(envApi.list()); - const existingEnv = (Array.isArray(envList) ? envList : []).find((e) => e.agentConfigId === agentConfigId); - if (existingEnv) { - void navigate({ to: "/agent/chat/$agentId", params: { agentId: existingEnv.id } }); - return; - } - - // 4. 没有则创建新 environment(autoStart: true 自动启动实例) - const newEnv = await unwrap( - envApi.create({ - name: `env-${agentConfigId.slice(0, 8)}`, - agentConfigId, - autoStart: true, - }), - ); - const envId = newEnv?.id; - if (!envId) { - toast.error(t("createFailed")); - return; - } + // 3. 创建或复用 environment,并显式进入实例后再导航。 + // environment 的 autoStart 是异步预热,不能作为实例已可用的确认信号。 + const target = await resolveCreatedAgentChatTarget(agentConfigId, { + list: async () => { + const environments = await unwrap(envApi.list()); + return Array.isArray(environments) ? environments : []; + }, + create: async (body) => unwrap(envApi.create(body)), + enter: async (environmentId) => unwrap(envApi.enter({ id: environmentId })), + }); - // 5. 跳转聊天页 - void navigate({ to: "/agent/chat/$agentId", params: { agentId: envId } }); + await navigate({ + to: "/agent/chat/$agentId/$sessionId", + params: { agentId: target.environmentId, sessionId: target.instanceUid }, + }); }, { manual: true, diff --git a/web/src/pages/agent-panel/pages/agent-organizations-dialogs.tsx b/web/src/pages/agent-panel/pages/agent-organizations-dialogs.tsx index 50fc4f08..2d2c1784 100644 --- a/web/src/pages/agent-panel/pages/agent-organizations-dialogs.tsx +++ b/web/src/pages/agent-panel/pages/agent-organizations-dialogs.tsx @@ -1,4 +1,4 @@ -import { Check, Copy, X } from "lucide-react"; +import { Check, Copy, Search, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { @@ -13,9 +13,9 @@ import { } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import type { OrgMemberCandidate } from "@/src/api/organizations"; import type { MachineFormState, OrganizationsDialogsProps } from "./agent-organizations-types"; function Field({ label, children }: { label: string; children: React.ReactNode }) { @@ -64,6 +64,29 @@ function CreateOrganizationDialog({ props }: { props: OrganizationsDialogsProps ); } +export function MemberCandidateButton({ + candidate, + selected, + onAdd, +}: { + candidate: OrgMemberCandidate; + selected: boolean; + onAdd: (candidate: OrgMemberCandidate) => void; +}) { + const { t } = useTranslation("orgs"); + const disabled = candidate.isMember || selected; + return ( + + ); +} + function InviteMemberDialog({ props }: { props: OrganizationsDialogsProps }) { const { t } = useTranslation("orgs"); const showResults = props.debouncedInviteKeyword.length >= 3; @@ -74,8 +97,9 @@ function InviteMemberDialog({ props }: { props: OrganizationsDialogsProps }) { {t("inviteDialog.title")}
- - +
+ {t("inviteDialog.searchLabel")} +
{props.selectedCandidates.length > 0 ? (
{props.selectedCandidates.map((candidate) => ( @@ -92,55 +116,46 @@ function InviteMemberDialog({ props }: { props: OrganizationsDialogsProps }) { ))}
) : null} - 0 - ? t("inviteDialog.searchMorePlaceholder") - : t("inviteDialog.searchPlaceholder") - } - /> - +
+
+
{props.debouncedInviteKeyword.length === 0 ? (
{t("inviteDialog.searchHint")}
) : null} {props.debouncedInviteKeyword.length > 0 && props.debouncedInviteKeyword.length < 3 ? (
{t("inviteDialog.searchMinChars")}
) : null} - {showResults ? ( - - {props.memberCandidatesLoading ? t("inviteDialog.searching") : t("inviteDialog.empty")} - + {showResults && props.memberCandidatesLoading ? ( +
{t("inviteDialog.searching")}
) : null} - {props.memberCandidates.length > 0 ? ( - - {props.memberCandidates.map((candidate) => { - const selected = props.selectedCandidates.some((item) => item.id === candidate.id); - const disabled = candidate.isMember || selected; - return ( - !disabled && props.onCandidateAdd(candidate)} - > -
- {candidate.name} - {candidate.email} -
- {candidate.isMember ? ( - {t("inviteDialog.alreadyMember")} - ) : null} - {selected ? : null} -
- ); - })} -
+ {showResults && !props.memberCandidatesLoading && props.memberCandidates.length === 0 ? ( +
{t("inviteDialog.empty")}
) : null} - - - + {props.memberCandidates.map((candidate) => { + const selected = props.selectedCandidates.some((item) => item.id === candidate.id); + return ( + + ); + })} +
+
+