diff --git a/contracts/agent-v1.openapi.json b/contracts/agent-v1.openapi.json index f4d1ffa..04b46ba 100644 --- a/contracts/agent-v1.openapi.json +++ b/contracts/agent-v1.openapi.json @@ -1011,8 +1011,10 @@ "type": "string", "enum": [ "request", + "auto", "always" - ] + ], + "description": "request forwards approval prompts; auto approves only the low-risk allowlist; always approves every prompt not explicitly denied by OpenCode." } }, "required": [ diff --git a/contracts/manifest.json b/contracts/manifest.json index 9c66061..f89affa 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,11 +3,7 @@ "contracts": { "agent": { "file": "agent-v1.openapi.json", - "sha256": "d559c6e76c33e7a7451743f60d85da0630d0df14fb5215228acefcf2eaea555a" - }, - "server": { - "file": "server-v1.openapi.json", - "sha256": "b003a57c9b8c1a041b644363ef58afb8bfcede1fe076ce48a190d2a1ac915e3f" + "sha256": "94bd8914597c56b6429160e8c556993ac0617ad079de2980a4b6cb9fdf89c039" } } } diff --git a/src/app/api/auth/[...nextauth]/options.test.ts b/src/app/api/auth/[...nextauth]/options.test.ts index 82cfa14..d922e92 100644 --- a/src/app/api/auth/[...nextauth]/options.test.ts +++ b/src/app/api/auth/[...nextauth]/options.test.ts @@ -51,6 +51,58 @@ describe("NextAuth access token refresh", () => { restoreEnv("KEYCLOAK_CLIENT_SECRET", previousEnv.clientSecret); } }); + + it.each([ + ["network failure", () => Promise.reject(new Error("connection refused"))], + [ + "non-JSON response", + () => + Promise.resolve({ + ok: false, + json: async () => { + throw new SyntaxError("Unexpected token"); + }, + }), + ], + [ + "invalid token payload", + () => + Promise.resolve({ + ok: true, + json: async () => ({ access_token: " ", expires_in: 0 }), + }), + ], + ])("returns a session error for %s", async (_label, fetchResult) => { + const previousEnv = { + issuer: process.env.KEYCLOAK_ISSUER, + clientId: process.env.KEYCLOAK_CLIENT_ID, + clientSecret: process.env.KEYCLOAK_CLIENT_SECRET, + }; + process.env.KEYCLOAK_ISSUER = "https://keycloak.example/realms/tjwater"; + process.env.KEYCLOAK_CLIENT_ID = "frontend"; + process.env.KEYCLOAK_CLIENT_SECRET = "secret"; + const originalFetch = globalThis.fetch; + globalThis.fetch = jest.fn(fetchResult) as unknown as typeof fetch; + + try { + const jwt = authOptions.callbacks?.jwt as (input: unknown) => Promise<{ + error?: string; + }>; + await expect( + jwt({ + token: { refreshToken: "refresh-token" }, + trigger: "update", + session: { forceRefresh: true }, + }), + ).resolves.toMatchObject({ error: "RefreshAccessTokenError" }); + } finally { + if (originalFetch) globalThis.fetch = originalFetch; + else delete (globalThis as { fetch?: typeof fetch }).fetch; + restoreEnv("KEYCLOAK_ISSUER", previousEnv.issuer); + restoreEnv("KEYCLOAK_CLIENT_ID", previousEnv.clientId); + restoreEnv("KEYCLOAK_CLIENT_SECRET", previousEnv.clientSecret); + } + }); }); const restoreEnv = (key: string, value: string | undefined) => { diff --git a/src/app/api/auth/[...nextauth]/options.ts b/src/app/api/auth/[...nextauth]/options.ts index dc1865a..4d92dfa 100644 --- a/src/app/api/auth/[...nextauth]/options.ts +++ b/src/app/api/auth/[...nextauth]/options.ts @@ -39,25 +39,43 @@ const refreshAccessToken = async (token: JWT): Promise => { refresh_token: token.refreshToken, }); - const response = await fetch(keycloakTokenEndpoint, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body, - }); - const refreshed = (await response.json()) as KeycloakTokenResponse; + try { + const response = await fetch(keycloakTokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + const refreshed = (await response.json()) as Partial | null; - if (!response.ok || !refreshed.access_token || typeof refreshed.expires_in !== "number") { + if ( + !response.ok || + !refreshed || + typeof refreshed.access_token !== "string" || + !refreshed.access_token.trim() || + typeof refreshed.expires_in !== "number" || + !Number.isFinite(refreshed.expires_in) || + refreshed.expires_in <= 0 + ) { + return { ...token, error: "RefreshAccessTokenError" }; + } + + const rotatedRefreshToken = + typeof refreshed.refresh_token === "string" && + refreshed.refresh_token.trim() + ? refreshed.refresh_token.trim() + : token.refreshToken; + + return { + ...token, + accessToken: refreshed.access_token.trim(), + accessTokenIssuedAt: Date.now(), + accessTokenExpires: Date.now() + refreshed.expires_in * 1000, + refreshToken: rotatedRefreshToken, + error: undefined, + }; + } catch { return { ...token, error: "RefreshAccessTokenError" }; } - - return { - ...token, - accessToken: refreshed.access_token, - accessTokenIssuedAt: Date.now(), - accessTokenExpires: Date.now() + refreshed.expires_in * 1000, - refreshToken: refreshed.refresh_token ?? token.refreshToken, - error: undefined, - }; }; const authOptions: NextAuthOptions = { diff --git a/src/components/chat/AgentComposer.test.tsx b/src/components/chat/AgentComposer.test.tsx index 32c0004..9924beb 100644 --- a/src/components/chat/AgentComposer.test.tsx +++ b/src/components/chat/AgentComposer.test.tsx @@ -44,7 +44,7 @@ describe("AgentComposer", () => { modelOptions={[{ id: "test-model", label: "测试模型" }]} selectedModel="test-model" onModelChange={jest.fn()} - approvalMode="request" + approvalMode="auto" onApprovalModeChange={jest.fn()} /> , @@ -56,6 +56,56 @@ describe("AgentComposer", () => { expect(screen.queryByRole("button", { name: "上传附件" })).not.toBeInTheDocument(); expect(screen.getByTitle("快捷指令图标")).toBeInTheDocument(); expect(screen.queryByAltText("TJWater Agent")).not.toBeInTheDocument(); + expect(screen.getByText("自动批准")).toBeInTheDocument(); expect(voiceButton.nextElementSibling?.contains(sendButton)).toBe(true); }); + + it("disables input while the Agent runtime is unavailable", () => { + render( + + + , + ); + + expect(screen.getByPlaceholderText("Agent 服务未就绪,暂时无法发送消息")).toBeDisabled(); + expect(screen.getByRole("button", { name: "发送" })).toBeDisabled(); + }); + + it("renders the always-allow mode as an explicit warning state", () => { + render( + + + , + ); + + expect(screen.getByText("始终允许")).toBeInTheDocument(); + expect(screen.getByTestId("WarningAmberRoundedIcon")).toBeInTheDocument(); + }); }); diff --git a/src/components/chat/AgentComposer.tsx b/src/components/chat/AgentComposer.tsx index a6e0b16..d89ebb0 100644 --- a/src/components/chat/AgentComposer.tsx +++ b/src/components/chat/AgentComposer.tsx @@ -27,6 +27,8 @@ import BoltRounded from "@mui/icons-material/BoltRounded"; import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded"; import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded"; import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded"; +import WarningAmberRounded from "@mui/icons-material/WarningAmberRounded"; +import type { AgentRuntimeState } from "@/lib/agentRuntime"; import type { AgentModelOption } from "@/lib/chatModels"; import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream"; @@ -40,6 +42,7 @@ export type AgentComposerHandle = { type AgentComposerProps = { isHydrating?: boolean; + runtimeState?: AgentRuntimeState; isStreaming: boolean; isListening: boolean; isSttSupported: boolean; @@ -55,6 +58,35 @@ type AgentComposerProps = { onApprovalModeChange: (mode: AgentApprovalMode) => void; }; +const approvalModeOptions: ReadonlyArray<{ + value: AgentApprovalMode; + label: string; + description: string; + icon: React.ElementType; +}> = [ + { + value: "request", + label: "请求批准", + description: "白名单外的工具权限逐次确认", + icon: VerifiedUserRounded, + }, + { + value: "auto", + label: "自动批准", + description: "低风险自动批准,其余仍需确认", + icon: AdminPanelSettingsRounded, + }, + { + value: "always", + label: "始终允许", + description: "除明确禁止项外自动放行", + icon: WarningAmberRounded, + }, +]; + +const getApprovalModeOption = (value: AgentApprovalMode) => + approvalModeOptions.find((option) => option.value === value) ?? approvalModeOptions[0]; + const renderModelIcon = ( icon: AgentModelOption["icon"] | undefined, props?: React.ComponentProps, @@ -67,6 +99,7 @@ const renderModelIcon = ( export const AgentComposer = React.forwardRef(function AgentComposer({ isHydrating = false, + runtimeState = "ready", isStreaming, isListening, isSttSupported, @@ -85,8 +118,19 @@ export const AgentComposer = React.forwardRef(null); const [input, setInput] = React.useState(""); const [isPresetOpen, setIsPresetOpen] = React.useState(false); - const canSend = input.trim().length > 0 && !isStreaming && !isHydrating; + const isRuntimeReady = runtimeState === "ready"; + const canSend = input.trim().length > 0 && !isStreaming && !isHydrating && isRuntimeReady; + const placeholder = isHydrating + ? "正在加载对话记录..." + : runtimeState === "checking" + ? "正在连接 Agent 服务..." + : runtimeState === "models_unavailable" + ? "模型尚未加载,暂时无法发送消息" + : runtimeState === "unavailable" + ? "Agent 服务未就绪,暂时无法发送消息" + : "描述你的分析目标,或点击上方指令库..."; const selectedModelOption = modelOptions.find((model) => model.id === selectedModel); + const selectedApprovalModeOption = getApprovalModeOption(approvalMode); React.useImperativeHandle( ref, @@ -102,10 +146,10 @@ export const AgentComposer = React.forwardRef { const prompt = input.trim(); - if (!prompt || isStreaming || isHydrating) return; + if (!prompt || isStreaming || isHydrating || !isRuntimeReady) return; setInput(""); onSend(prompt); - }, [input, isHydrating, isStreaming, onSend]); + }, [input, isHydrating, isRuntimeReady, isStreaming, onSend]); return ( @@ -154,6 +198,7 @@ export const AgentComposer = React.forwardRef { setInput(prompt); setIsPresetOpen(false); @@ -209,12 +254,12 @@ export const AgentComposer = React.forwardRef - + @@ -452,7 +532,7 @@ export const AgentComposer = React.forwardRef void; onRenameSessionTitle?: (title: string) => void; @@ -37,6 +39,7 @@ export const AgentHeader = ({ canRenameSessionTitle = false, isHydrating = false, isStreaming, + runtimeState = "ready", isHistoryOpen, onHistoryToggle, onRenameSessionTitle, @@ -47,6 +50,14 @@ export const AgentHeader = ({ const displayTitle = sessionTitle?.trim() || "新对话"; const [isEditingTitle, setIsEditingTitle] = React.useState(false); const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || ""); + const runtimeStatus = + runtimeState === "unavailable" || runtimeState === "models_unavailable" + ? { color: "#e53935", label: "Agent 服务未就绪" } + : runtimeState === "checking" + ? { color: "#ffb300", label: "正在连接 Agent 服务" } + : isStreaming + ? { color: "#ff9800", label: "Agent 正在生成" } + : { color: "#00e676", label: "Agent 已就绪" }; React.useEffect(() => { if (!isEditingTitle) { @@ -109,17 +120,19 @@ export const AgentHeader = ({ /> [number]) => { @@ -58,7 +58,7 @@ const PermissionIcon = ({ }; const getPermissionStatusLabel = (status: NonNullable[number]["status"]) => { - if (status === "approved_always") return "已始终允许"; + if (status === "approved_always") return "已保存授权"; if (status === "approved_once") return "已允许一次"; if (status === "rejected") return "已拒绝"; if (status === "aborted") return "已中断"; @@ -99,7 +99,7 @@ const PermissionRequestCard = ({ }: { permission: NonNullable[number]; isRunning: boolean; - onReply: (requestId: string, reply: PermissionReply) => void; + onReply: (requestId: string, reply: PermissionDecision) => void; }) => { const theme = useTheme(); const isPending = @@ -109,6 +109,9 @@ const PermissionRequestCard = ({ const accentColor = getPermissionStatusColor(permission.status, theme); const statusTextColor = getPermissionStatusTextColor(permission.status, theme); const statusLabel = getPermissionStatusLabel(permission.status); + const persistentScope = permission.always.length > 0 + ? permission.always + : permission.patterns; return ( + {isPending || isSubmitting ? ( + + + 保存授权范围 + + + {persistentScope.join("\n")} + + + ) : null} {permission.error ? ( @@ -232,84 +258,84 @@ const PermissionRequestCard = ({ useFlexGap sx={{ px: 1.5, pb: 1.35, pl: 1.75, pt: 0 }} > - - - + + + ) : null} @@ -323,7 +349,7 @@ export const PermissionRequestGroup = ({ }: { permissions: NonNullable; isRunning: boolean; - onReply: (requestId: string, reply: PermissionReply) => void; + onReply: (requestId: string, reply: PermissionDecision) => void; }) => { const theme = useTheme(); const onceCount = permissions.filter((permission) => permission.status === "approved_once").length; @@ -348,7 +374,7 @@ export const PermissionRequestGroup = ({ const summaryItems = [ { label: "共", value: permissions.length, color: theme.palette.text.secondary }, { label: "允许一次", value: onceCount, color: getPermissionStatusColor("approved_once", theme), textColor: getPermissionStatusTextColor("approved_once", theme) }, - { label: "始终允许", value: alwaysCount, color: getPermissionStatusColor("approved_always", theme), textColor: getPermissionStatusTextColor("approved_always", theme) }, + { label: "保存授权", value: alwaysCount, color: getPermissionStatusColor("approved_always", theme), textColor: getPermissionStatusTextColor("approved_always", theme) }, { label: "拒绝", value: rejectedCount, color: getPermissionStatusColor("rejected", theme), textColor: getPermissionStatusTextColor("rejected", theme) }, { label: "中断", value: abortedCount, color: getPermissionStatusColor("aborted", theme), textColor: getPermissionStatusTextColor("aborted", theme) }, ]; diff --git a/src/components/chat/AgentTurn.test.tsx b/src/components/chat/AgentTurn.test.tsx index d84cfc2..613103e 100644 --- a/src/components/chat/AgentTurn.test.tsx +++ b/src/components/chat/AgentTurn.test.tsx @@ -112,4 +112,56 @@ describe("AgentTurn speech selection", () => { expect(screen.queryByRole("button", { name: "从这里开始朗读" })).not.toBeInTheDocument(); }); }); + + it("offers one-time and saved permission approval with distinct actions", () => { + const onReplyPermission = jest.fn(); + render( + , + ); + + expect(screen.getByRole("button", { name: "允许一次" })).toBeInTheDocument(); + expect(screen.getByText("保存授权范围")).toBeInTheDocument(); + expect(screen.getAllByText("npm test")).toHaveLength(2); + expect(screen.getByTestId("GppGoodRoundedIcon")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "保存授权" })); + expect(onReplyPermission).toHaveBeenCalledWith("permission-1", "always"); + expect(screen.getByRole("button", { name: "拒绝" })).toBeInTheDocument(); + }); }); diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index c4c78d4..829cc95 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -20,7 +20,7 @@ import { } from "@mui/material"; import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded"; import { TbArrowsSplit2 } from "react-icons/tb"; -import type { PermissionReply } from "@/lib/chatStream"; +import type { PermissionDecision } from "@/lib/chatStream"; import { parseAssistantMessageSections, parseContentWithToolCalls, @@ -100,7 +100,7 @@ type AgentTurnProps = { onStopSpeech: () => void; isTtsSupported: boolean; onCreateBranch: (messageId: string) => void; - onReplyPermission: (requestId: string, reply: PermissionReply) => void; + onReplyPermission: (requestId: string, reply: PermissionDecision) => void; onReplyQuestion: (requestId: string, answers: string[][]) => void; onRejectQuestion: (requestId: string) => void; }; diff --git a/src/components/chat/AgentWorkspace.test.tsx b/src/components/chat/AgentWorkspace.test.tsx index a135fdf..bf27416 100644 --- a/src/components/chat/AgentWorkspace.test.tsx +++ b/src/components/chat/AgentWorkspace.test.tsx @@ -1,7 +1,7 @@ /* eslint-disable @next/next/no-img-element */ import "@testing-library/jest-dom"; import React from "react"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { AgentWorkspace } from "./AgentWorkspace"; import type { Message } from "./GlobalChatbox.types"; @@ -85,6 +85,38 @@ describe("AgentWorkspace", () => { expect(screen.queryByText("我已就绪,请描述任务")).not.toBeInTheDocument(); }); + it("shows runtime startup failures in the existing empty state and retries there", () => { + const onRetryRuntime = jest.fn(); + render( + , + ); + + expect(screen.getByText("Agent 服务未就绪")).toBeInTheDocument(); + expect(screen.queryByText("我已就绪,请描述任务")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "重新检测" })); + expect(onRetryRuntime).toHaveBeenCalledTimes(1); + }); + + it("distinguishes model loading failures from backend startup failures", () => { + render( + , + ); + + expect(screen.getByText("模型未加载")).toBeInTheDocument(); + expect(screen.getByText(/模型配置加载失败/)).toBeInTheDocument(); + }); + it("keeps stable history turns from re-rendering while the last assistant message streams", () => { const userMessage: Message = { id: "user-1", @@ -164,4 +196,49 @@ describe("AgentWorkspace", () => { expect(unmountCounts.get("assistant-1") ?? 0).toBe(0); expect(streamingFlags.get("assistant-1")).toBe(false); }); + + it("windows long conversations instead of mounting every turn", () => { + const messages = Array.from({ length: 200 }, (_, index): Message => ({ + id: `message-${index}`, + role: index % 2 === 0 ? "user" : "assistant", + content: `message ${index}`, + })); + + render( + , + ); + + expect(renderCounts.size).toBeGreaterThan(0); + expect(renderCounts.size).toBeLessThan(40); + }); + + it("keeps visible turns mounted when crossing the windowing threshold", () => { + const messages = Array.from({ length: 41 }, (_, index): Message => ({ + id: `message-${index}`, + role: index % 2 === 0 ? "user" : "assistant", + content: `message ${index}`, + })); + const { rerender } = render( + , + ); + + rerender( + , + ); + + expect(mountCounts.get("message-0")).toBe(1); + expect(unmountCounts.get("message-0") ?? 0).toBe(0); + }); }); diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index 69c9ee8..95f2da1 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -3,14 +3,16 @@ import Image from "next/image"; import React from "react"; import { AnimatePresence, motion } from "framer-motion"; -import { Box, Paper, Skeleton, Stack, Typography, alpha, useTheme, Grid } from "@mui/material"; +import { Box, Button, CircularProgress, Paper, Skeleton, Stack, Typography, alpha, Grid } from "@mui/material"; import WaterDropRounded from "@mui/icons-material/WaterDropRounded"; import SensorsRounded from "@mui/icons-material/SensorsRounded"; import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded"; import MapRounded from "@mui/icons-material/MapRounded"; +import ReplayRounded from "@mui/icons-material/ReplayRounded"; import { AgentTurn } from "./AgentTurn"; -import type { PermissionReply } from "@/lib/chatStream"; +import type { AgentRuntimeState } from "@/lib/agentRuntime"; +import type { PermissionDecision } from "@/lib/chatStream"; import type { Message, SpeechState, @@ -19,6 +21,8 @@ import type { type AgentWorkspaceProps = { messages: Message[]; isStreaming: boolean; + runtimeState?: AgentRuntimeState; + onRetryRuntime?: () => void; isLoadingSession?: boolean; scrollContainerRef?: React.RefObject; bottomRef: React.RefObject; @@ -35,13 +39,15 @@ type AgentWorkspaceProps = { onStopSpeech: () => void; isTtsSupported: boolean; onCreateBranch: (messageId: string) => void; - onReplyPermission: (requestId: string, reply: PermissionReply) => void; + onReplyPermission: (requestId: string, reply: PermissionDecision) => void; onReplyQuestion: (requestId: string, answers: string[][]) => void; onRejectQuestion: (requestId: string) => void; }; type TurnListProps = { messages: Message[]; + scrollTop: number; + viewportHeight: number; isAssistantStreaming: boolean; streamingMessageId: string | null; speakingMessageId: string | null; @@ -56,13 +62,18 @@ type TurnListProps = { onStopSpeech: () => void; isTtsSupported: boolean; onCreateBranch: (messageId: string) => void; - onReplyPermission: (requestId: string, reply: PermissionReply) => void; + onReplyPermission: (requestId: string, reply: PermissionDecision) => void; onReplyQuestion: (requestId: string, answers: string[][]) => void; onRejectQuestion: (requestId: string) => void; }; const STREAMING_BOTTOM_RESERVE_PX = 180; const STREAMING_NEAR_BOTTOM_THRESHOLD_PX = STREAMING_BOTTOM_RESERVE_PX + 120; +const TURN_WINDOW_THRESHOLD = 40; +const TURN_ESTIMATED_HEIGHT_PX = 220; +const TURN_GAP_PX = 16; +const TURN_OVERSCAN_PX = 600; +const DEFAULT_VIEWPORT_HEIGHT_PX = 720; const sameMessages = (left: Message[], right: Message[]) => left.length === right.length && @@ -72,6 +83,8 @@ const TurnItem = React.memo(AgentTurn); const TurnListInner = ({ messages, + scrollTop, + viewportHeight, isAssistantStreaming, streamingMessageId, speakingMessageId, @@ -86,33 +99,174 @@ const TurnListInner = ({ onReplyQuestion, onRejectQuestion, }: TurnListProps) => { + const [measuredHeights, setMeasuredHeights] = React.useState( + () => new Map(), + ); + const isWindowed = messages.length > TURN_WINDOW_THRESHOLD; + + React.useEffect(() => { + const activeIds = new Set(messages.map((message) => message.id)); + setMeasuredHeights((current) => { + if ([...current.keys()].every((messageId) => activeIds.has(messageId))) { + return current; + } + return new Map( + [...current].filter(([messageId]) => activeIds.has(messageId)), + ); + }); + }, [messages]); + + const updateMeasuredHeight = React.useCallback( + (messageId: string, height: number) => { + if (!Number.isFinite(height) || height <= 0) return; + const roundedHeight = Math.ceil(height); + setMeasuredHeights((current) => { + if (current.get(messageId) === roundedHeight) return current; + const next = new Map(current); + next.set(messageId, roundedHeight); + return next; + }); + }, + [], + ); + + const turnOffsets = React.useMemo(() => { + const offsets = new Array(messages.length + 1).fill(0); + if (!isWindowed) return offsets; + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + const size = + (measuredHeights.get(message.id) ?? TURN_ESTIMATED_HEIGHT_PX) + + TURN_GAP_PX; + offsets[index + 1] = offsets[index] + size; + } + return offsets; + }, [isWindowed, measuredHeights, messages]); + + const windowState = React.useMemo(() => { + if (!isWindowed) { + return { + endIndex: messages.length, + startIndex: 0, + topSpacerHeight: 0, + bottomSpacerHeight: 0, + }; + } + + const visibleStart = Math.max(0, scrollTop - TURN_OVERSCAN_PX); + const visibleEnd = scrollTop + viewportHeight + TURN_OVERSCAN_PX; + const startIndex = Math.max( + 0, + lowerBound(turnOffsets, visibleStart, 1) - 1, + ); + const endIndex = lowerBound(turnOffsets, visibleEnd, startIndex); + + const boundedEndIndex = Math.min( + messages.length, + Math.max(startIndex + 1, endIndex), + ); + return { + startIndex, + endIndex: boundedEndIndex, + topSpacerHeight: turnOffsets[startIndex], + bottomSpacerHeight: + turnOffsets[messages.length] - turnOffsets[boundedEndIndex], + }; + }, [isWindowed, messages.length, scrollTop, turnOffsets, viewportHeight]); + + const visibleMessages = isWindowed + ? messages.slice(windowState.startIndex, windowState.endIndex) + : messages; + return ( <> - {messages.map((message) => ( - 0 ? ( + + ) : null} + {visibleMessages.map((message) => ( + + measure={isWindowed} + onHeightChange={updateMeasuredHeight} + > + + ))} + {windowState.bottomSpacerHeight > 0 ? ( + + ) : null} ); }; +const lowerBound = (values: number[], target: number, fromIndex: number) => { + let low = Math.max(0, fromIndex); + let high = values.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if (values[middle] < target) low = middle + 1; + else high = middle; + } + return low; +}; + +type MeasuredTurnProps = { + children: React.ReactNode; + measure: boolean; + message: Message; + onHeightChange: (messageId: string, height: number) => void; +}; + +const MeasuredTurn = ({ + children, + measure, + message, + onHeightChange, +}: MeasuredTurnProps) => { + const rowRef = React.useRef(null); + + React.useEffect(() => { + const row = rowRef.current; + if (!measure || !row) return; + const measureRow = () => + onHeightChange(message.id, row.getBoundingClientRect().height); + measureRow(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measureRow); + observer.observe(row); + return () => observer.disconnect(); + }, [measure, message.id, onHeightChange]); + + return ( + + {children} + + ); +}; + const TurnList = React.memo( TurnListInner, (prevProps, nextProps) => sameMessages(prevProps.messages, nextProps.messages) && + prevProps.scrollTop === nextProps.scrollTop && + prevProps.viewportHeight === nextProps.viewportHeight && prevProps.isAssistantStreaming === nextProps.isAssistantStreaming && prevProps.streamingMessageId === nextProps.streamingMessageId && prevProps.speakingMessageId === nextProps.speakingMessageId && @@ -130,8 +284,34 @@ const TurnList = React.memo( TurnList.displayName = "TurnList"; -const EmptyState = () => { - const theme = useTheme(); +const EmptyState = ({ + runtimeState, + onRetryRuntime, +}: { + runtimeState: AgentRuntimeState; + onRetryRuntime?: () => void; +}) => { + const isReady = runtimeState === "ready"; + const isChecking = runtimeState === "checking"; + const statusCopy = isChecking + ? { + title: "正在连接 Agent 服务", + detail: "正在确认后端运行时和模型加载状态,请稍候。", + } + : runtimeState === "models_unavailable" + ? { + title: "模型未加载", + detail: "Agent 服务已启动,但模型配置加载失败。请检查模型配置后重新检测。", + } + : runtimeState === "unavailable" + ? { + title: "Agent 服务未就绪", + detail: "无法连接 Agent 后端,或运行时启动失败。请检查服务后重新检测。", + } + : { + title: "我已就绪,请描述任务", + detail: "你可以使用自然语言下达指令,我会自主规划决策执行、并在地图上呈现分析结果。", + }; const capabilities = [ { icon: , label: "水力瓶颈识别" }, { icon: , label: "异常状态预警" }, @@ -147,6 +327,8 @@ const EmptyState = () => { style={{ margin: "auto", width: "100%", maxWidth: 440, padding: 16 }} > { }} /> { height={54} style={{ objectFit: "contain", - filter: "drop-shadow(0 4px 12px rgba(0, 131, 143, 0.2))", + filter: isReady + ? "drop-shadow(0 4px 12px rgba(0, 131, 143, 0.2))" + : "grayscale(0.65) opacity(0.72)", }} /> - 我已就绪,请描述任务 + {statusCopy.title} - - 你可以使用自然语言下达指令,我会自主规划决策执行、并在地图上呈现分析结果。 + + {statusCopy.detail} - - {capabilities.map((item) => ( - - - + ) : !isReady ? ( + + ) : ( + + {capabilities.map((item) => ( + + + { {item.label} - - - - ))} - + + + + ))} + + )} ); @@ -309,6 +507,8 @@ const SessionLoadingSkeleton = () => ( export const AgentWorkspace = ({ messages, isStreaming, + runtimeState = "ready", + onRetryRuntime, isLoadingSession = false, scrollContainerRef, bottomRef, @@ -325,14 +525,23 @@ export const AgentWorkspace = ({ onReplyQuestion, onRejectQuestion, }: AgentWorkspaceProps) => { + const localScrollContainerRef = React.useRef(null); + const [scrollMetrics, setScrollMetrics] = React.useState({ + scrollTop: 0, + viewportHeight: DEFAULT_VIEWPORT_HEIGHT_PX, + }); const streamingMessageId = isStreaming && messages.at(-1)?.role === "assistant" ? messages.at(-1)?.id ?? null : null; const handleScroll = React.useCallback( (event: React.UIEvent) => { - if (!onScrollStateChange) return; const target = event.currentTarget; + setScrollMetrics({ + scrollTop: target.scrollTop, + viewportHeight: target.clientHeight || DEFAULT_VIEWPORT_HEIGHT_PX, + }); + if (!onScrollStateChange) return; const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight; onScrollStateChange( @@ -343,9 +552,37 @@ export const AgentWorkspace = ({ [isStreaming, onScrollStateChange], ); + const setScrollContainer = React.useCallback( + (node: HTMLDivElement | null) => { + localScrollContainerRef.current = node; + if (scrollContainerRef) { + scrollContainerRef.current = node; + } + }, + [scrollContainerRef], + ); + + React.useEffect(() => { + const container = localScrollContainerRef.current; + if (!container || typeof ResizeObserver === "undefined") return; + const updateViewportHeight = () => { + const viewportHeight = + container.clientHeight || DEFAULT_VIEWPORT_HEIGHT_PX; + setScrollMetrics((current) => + current.viewportHeight === viewportHeight + ? current + : { ...current, viewportHeight }, + ); + }; + updateViewportHeight(); + const observer = new ResizeObserver(updateViewportHeight); + observer.observe(container); + return () => observer.disconnect(); + }, []); + return ( - {messages.length === 0 ? : null} + {messages.length === 0 ? ( + + ) : null} {messages.length > 0 ? ( - + TURN_WINDOW_THRESHOLD ? 0 : 2, + }} + > TURN_WINDOW_THRESHOLD + ? scrollMetrics.scrollTop + : 0 + } + viewportHeight={scrollMetrics.viewportHeight} isAssistantStreaming={isStreaming} streamingMessageId={streamingMessageId} speakingMessageId={speakingMessageId} diff --git a/src/components/chat/GlobalChatbox.test.tsx b/src/components/chat/GlobalChatbox.test.tsx index 235eac9..f2a3d51 100644 --- a/src/components/chat/GlobalChatbox.test.tsx +++ b/src/components/chat/GlobalChatbox.test.tsx @@ -5,6 +5,7 @@ import { act, render, screen } from "@testing-library/react"; import { GlobalChatbox } from "./GlobalChatbox"; const createSession = jest.fn(); +const mockFetchAgentRuntimeHealth = jest.fn(); let mockCurrentProjectId = "project-1"; jest.mock("@refinedev/core", () => ({ @@ -15,6 +16,11 @@ jest.mock("@/lib/chatModels", () => ({ fetchAgentModels: jest.fn(() => new Promise(() => {})), })); +jest.mock("@/lib/agentRuntime", () => ({ + fetchAgentRuntimeHealth: (...args: unknown[]) => + mockFetchAgentRuntimeHealth(...args), +})); + jest.mock("@/store/projectStore", () => ({ useProjectStore: (selector: (state: { currentProjectId: string }) => unknown) => selector({ currentProjectId: mockCurrentProjectId }), @@ -73,12 +79,14 @@ jest.mock("./AgentHistoryPanel", () => ({ })); jest.mock("./AgentWorkspace", () => ({ - AgentWorkspace: () =>
Workspace
, + AgentWorkspace: ({ runtimeState }: { runtimeState: string }) => ( +
Workspace state: {runtimeState}
+ ), })); jest.mock("./AgentComposer", () => ({ - AgentComposer: React.forwardRef(function MockAgentComposer() { - return
Composer
; + AgentComposer: React.forwardRef(function MockAgentComposer(props: { approvalMode: string }, _ref) { + return
Composer mode: {props.approvalMode}
; }), })); @@ -90,6 +98,8 @@ describe("GlobalChatbox lifecycle", () => { beforeEach(() => { jest.useFakeTimers(); createSession.mockClear(); + mockFetchAgentRuntimeHealth.mockReset(); + mockFetchAgentRuntimeHealth.mockImplementation(() => new Promise(() => {})); mockCurrentProjectId = "project-1"; }); @@ -121,4 +131,19 @@ describe("GlobalChatbox lifecycle", () => { expect(createSession).toHaveBeenCalledTimes(2); }); + + it("defaults the composer to automatic permission approval", () => { + render(); + + expect(screen.getByText("Composer mode: auto")).toBeInTheDocument(); + }); + + it("passes backend startup failures into the existing workspace empty state", async () => { + mockFetchAgentRuntimeHealth.mockResolvedValueOnce(false); + render(); + + await act(async () => Promise.resolve()); + + expect(screen.getByText("Workspace state: unavailable")).toBeInTheDocument(); + }); }); diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 6c0e51f..cf19fb7 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -10,6 +10,10 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material"; import { useNotification } from "@refinedev/core"; import { getAccessToken } from "@/lib/authToken"; +import { + fetchAgentRuntimeHealth, + type AgentRuntimeState, +} from "@/lib/agentRuntime"; import { fetchAgentModels, type AgentModelOption } from "@/lib/chatModels"; import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream"; import { useProjectStore } from "@/store/projectStore"; @@ -26,6 +30,8 @@ import { useAgentToolActions } from "./hooks/useAgentToolActions"; const STREAMING_BOTTOM_RESERVE_PX = 180; const STREAMING_SCROLL_RESTORE_AT_PX = STREAMING_BOTTOM_RESERVE_PX - 36; +const AGENT_RUNTIME_POLL_MS = 30_000; +const AGENT_RUNTIME_TIMEOUT_MS = 8_000; export const GlobalChatbox: React.FC = ({ open, onClose }) => { const [width, setWidth] = useState(520); @@ -34,8 +40,9 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { const [isCheckingAuth, setIsCheckingAuth] = useState(false); const [modelOptions, setModelOptions] = useState([]); const [selectedModel, setSelectedModel] = useState(undefined); + const [runtimeState, setRuntimeState] = useState("checking"); const [approvalMode, setApprovalMode] = - useState("request"); + useState("auto"); const bottomRef = useRef(null); const workspaceScrollRef = useRef(null); @@ -43,6 +50,8 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { const streamingScrollFrameRef = useRef(null); const composerRef = useRef(null); const initializedProjectIdRef = useRef(undefined); + const runtimeRequestIdRef = useRef(0); + const runtimeAbortRef = useRef(null); const theme = useTheme(); const { open: openNotification } = useNotification(); const currentProjectId = useProjectStore((state) => state.currentProjectId); @@ -68,35 +77,76 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { isSupported: isSttSupported, } = useSpeechRecognition(handleSpeechResult); - useEffect(() => { - let cancelled = false; + const refreshAgentRuntime = useCallback(async (showChecking = true) => { + const requestId = ++runtimeRequestIdRef.current; + runtimeAbortRef.current?.abort(); + const controller = new AbortController(); + runtimeAbortRef.current = controller; + const timeoutId = window.setTimeout( + () => controller.abort(), + AGENT_RUNTIME_TIMEOUT_MS, + ); + let runtimeHealthy = false; - const loadModels = async () => { - try { - const modelConfig = await fetchAgentModels(); - if (cancelled) return; - setModelOptions(modelConfig.models); - setSelectedModel((current) => { - if (current && modelConfig.models.some((model) => model.id === current)) { - return current; - } - return modelConfig.defaultModel; - }); - } catch (error) { - console.error("[GlobalChatbox] Failed to load agent models:", error); - if (!cancelled) { - setModelOptions([]); - setSelectedModel(undefined); - } + if (showChecking) setRuntimeState("checking"); + + try { + runtimeHealthy = await fetchAgentRuntimeHealth(controller.signal); + if (requestId !== runtimeRequestIdRef.current) return; + if (!runtimeHealthy) { + setRuntimeState("unavailable"); + setModelOptions([]); + setSelectedModel(undefined); + return; } - }; - void loadModels(); + const modelConfig = await fetchAgentModels(controller.signal); + if (requestId !== runtimeRequestIdRef.current) return; + if (modelConfig.models.length === 0) { + setRuntimeState("models_unavailable"); + setModelOptions([]); + setSelectedModel(undefined); + return; + } + + setModelOptions(modelConfig.models); + setSelectedModel((current) => { + if (current && modelConfig.models.some((model) => model.id === current)) { + return current; + } + return modelConfig.defaultModel; + }); + setRuntimeState("ready"); + } catch (error) { + if (requestId !== runtimeRequestIdRef.current) return; + console.error("[GlobalChatbox] Failed to check agent runtime:", error); + setRuntimeState(runtimeHealthy ? "models_unavailable" : "unavailable"); + setModelOptions([]); + setSelectedModel(undefined); + } finally { + window.clearTimeout(timeoutId); + if (runtimeAbortRef.current === controller) { + runtimeAbortRef.current = null; + } + } + }, []); + + useEffect(() => { + if (!open) return; + + void refreshAgentRuntime(true); + const intervalId = window.setInterval( + () => void refreshAgentRuntime(false), + AGENT_RUNTIME_POLL_MS, + ); return () => { - cancelled = true; + window.clearInterval(intervalId); + runtimeRequestIdRef.current += 1; + runtimeAbortRef.current?.abort(); + runtimeAbortRef.current = null; }; - }, []); + }, [open, refreshAgentRuntime]); const handleToolCall = useAgentToolActions(); const { @@ -203,7 +253,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { }, [createSession, currentProjectId, isHydrating, open, resetConversationView]); const handleSend = useCallback(async (prompt: string) => { - if (isStreaming || isCheckingAuth) return; + if (isStreaming || isCheckingAuth || runtimeState !== "ready") return; setIsCheckingAuth(true); try { @@ -229,7 +279,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { } finally { setIsCheckingAuth(false); } - }, [isCheckingAuth, isStreaming, openNotification, sendPrompt]); + }, [isCheckingAuth, isStreaming, openNotification, runtimeState, sendPrompt]); const handleNewConversation = useCallback(() => { handleStopSpeech(); @@ -373,6 +423,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { canRenameSessionTitle={Boolean(activeSessionId)} isHydrating={isHydrating} isStreaming={isStreaming} + runtimeState={runtimeState} isHistoryOpen={isHistoryOpen} onHistoryToggle={handleHistoryToggle} onRenameSessionTitle={handleRenameActiveSession} @@ -430,6 +481,8 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { void refreshAgentRuntime(true)} isLoadingSession={Boolean(loadingSessionId)} scrollContainerRef={workspaceScrollRef} bottomRef={bottomRef} @@ -450,6 +503,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { { + async (requestId: string, reply: PermissionDecision) => { const target = messagesRef.current .flatMap((message) => message.permissions ?? []) .find((permission) => permission.requestId === requestId); diff --git a/src/generated/agentApi.ts b/src/generated/agentApi.ts index bf44fc1..4e746d7 100644 --- a/src/generated/agentApi.ts +++ b/src/generated/agentApi.ts @@ -899,8 +899,11 @@ export interface operations { "application/json": { message: string; model?: string; - /** @enum {string} */ - approval_mode?: "request" | "always"; + /** + * @description request forwards approval prompts; auto approves only the low-risk allowlist; always approves every prompt not explicitly denied by OpenCode. + * @enum {string} + */ + approval_mode?: "request" | "auto" | "always"; }; }; }; diff --git a/src/lib/agentRuntime.test.ts b/src/lib/agentRuntime.test.ts new file mode 100644 index 0000000..553a570 --- /dev/null +++ b/src/lib/agentRuntime.test.ts @@ -0,0 +1,44 @@ +import { fetchAgentRuntimeHealth } from "./agentRuntime"; + +const apiFetch = jest.fn(); + +jest.mock("@/lib/apiFetch", () => ({ + apiFetch: (...args: unknown[]) => apiFetch(...args), +})); + +describe("fetchAgentRuntimeHealth", () => { + beforeEach(() => { + apiFetch.mockReset(); + }); + + it("reports ready only after runtime warmup is complete", async () => { + apiFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + ok: true, + ready: true, + warmed_up: true, + runtime: { healthy: true }, + }), + }); + + await expect(fetchAgentRuntimeHealth()).resolves.toBe(true); + expect(apiFetch).toHaveBeenCalledWith( + expect.stringContaining("/health"), + expect.objectContaining({ + method: "GET", + projectHeaderMode: "omit", + skipAuthRedirect: true, + }), + ); + }); + + it("reports unavailable when runtime health is not ready", async () => { + apiFetch.mockResolvedValueOnce({ + ok: false, + json: async () => ({ ok: false, ready: false, warmed_up: true }), + }); + + await expect(fetchAgentRuntimeHealth()).resolves.toBe(false); + }); +}); diff --git a/src/lib/agentRuntime.ts b/src/lib/agentRuntime.ts new file mode 100644 index 0000000..a2b5e6f --- /dev/null +++ b/src/lib/agentRuntime.ts @@ -0,0 +1,37 @@ +import { apiFetch } from "@/lib/apiFetch"; +import { config } from "@config/config"; + +export type AgentRuntimeState = + | "checking" + | "ready" + | "unavailable" + | "models_unavailable"; + +type AgentHealthPayload = { + ok?: unknown; + ready?: unknown; + warmed_up?: unknown; + runtime?: { + healthy?: unknown; + }; +}; + +export const fetchAgentRuntimeHealth = async ( + signal?: AbortSignal, +): Promise => { + const response = await apiFetch(`${config.AGENT_URL}/health`, { + method: "GET", + signal, + projectHeaderMode: "omit", + skipAuthRedirect: true, + }); + const payload = (await response.json().catch(() => null)) as AgentHealthPayload | null; + + return Boolean( + response.ok && + payload?.ok === true && + payload.ready === true && + payload.warmed_up === true && + payload.runtime?.healthy === true, + ); +}; diff --git a/src/lib/chatModels.ts b/src/lib/chatModels.ts index 1ea914d..55d5fec 100644 --- a/src/lib/chatModels.ts +++ b/src/lib/chatModels.ts @@ -46,9 +46,10 @@ const normalizeModelOption = (value: unknown): AgentModelOption | null => { }; }; -export const fetchAgentModels = async (): Promise => { +export const fetchAgentModels = async (signal?: AbortSignal): Promise => { const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/models`, { method: "GET", + signal, projectHeaderMode: "include", skipAuthRedirect: true, }); diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index ab10db4..a588084 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -390,6 +390,7 @@ describe("streamAgentChat", () => { skipAuthRedirect: true, }), ); + }); it("calls permission reply endpoint", async () => { @@ -413,6 +414,19 @@ describe("streamAgentChat", () => { }), }), ); + + await replyAgentPermission("s1", "perm-2", "always"); + + expect(apiFetch).toHaveBeenLastCalledWith( + expect.stringContaining("/api/v1/agent/sessions/s1/permission-responses"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + request_id: "perm-2", + reply: "always", + }), + }), + ); }); it("submits refreshed credentials through the authenticated context", async () => { diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index fce65ea..efe6213 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -8,8 +8,9 @@ const getAgentSessionUrl = (sessionId: string, suffix = "") => export type AgentModel = string; -export type PermissionReply = "once" | "always" | "reject"; -export type AgentApprovalMode = "request" | "always"; +export type PermissionDecision = "once" | "always" | "reject"; +export type PermissionReply = PermissionDecision; +export type AgentApprovalMode = "request" | "auto" | "always"; export type AgentQuestionStatus = | "pending" @@ -664,7 +665,7 @@ export const abortAgentChat = async (sessionId?: string) => { export const replyAgentPermission = async ( sessionId: string, requestId: string, - reply: PermissionReply, + reply: PermissionDecision, ) => { const response = await apiFetch( getAgentSessionUrl(sessionId, "/permission-responses"),