合并 agent-mvp 到 master #1

Merged
jiang merged 282 commits from agent-mvp into master 2026-08-18 17:56:46 +08:00
21 changed files with 1025 additions and 226 deletions
Showing only changes of commit 9e75e2df8a - Show all commits
+3 -1
View File
@@ -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": [
+1 -5
View File
@@ -3,11 +3,7 @@
"contracts": {
"agent": {
"file": "agent-v1.openapi.json",
"sha256": "d559c6e76c33e7a7451743f60d85da0630d0df14fb5215228acefcf2eaea555a"
},
"server": {
"file": "server-v1.openapi.json",
"sha256": "b003a57c9b8c1a041b644363ef58afb8bfcede1fe076ce48a190d2a1ac915e3f"
"sha256": "94bd8914597c56b6429160e8c556993ac0617ad079de2980a4b6cb9fdf89c039"
}
}
}
@@ -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) => {
+22 -4
View File
@@ -39,25 +39,43 @@ const refreshAccessToken = async (token: JWT): Promise<JWT> => {
refresh_token: token.refreshToken,
});
try {
const response = await fetch(keycloakTokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
const refreshed = (await response.json()) as KeycloakTokenResponse;
const refreshed = (await response.json()) as Partial<KeycloakTokenResponse> | 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,
accessToken: refreshed.access_token.trim(),
accessTokenIssuedAt: Date.now(),
accessTokenExpires: Date.now() + refreshed.expires_in * 1000,
refreshToken: refreshed.refresh_token ?? token.refreshToken,
refreshToken: rotatedRefreshToken,
error: undefined,
};
} catch {
return { ...token, error: "RefreshAccessTokenError" };
}
};
const authOptions: NextAuthOptions = {
+51 -1
View File
@@ -44,7 +44,7 @@ describe("AgentComposer", () => {
modelOptions={[{ id: "test-model", label: "测试模型" }]}
selectedModel="test-model"
onModelChange={jest.fn()}
approvalMode="request"
approvalMode="auto"
onApprovalModeChange={jest.fn()}
/>
</ThemeProvider>,
@@ -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(
<ThemeProvider theme={createTheme()}>
<AgentComposer
isStreaming={false}
runtimeState="unavailable"
isListening={false}
isSttSupported={false}
presets={[]}
onSend={jest.fn()}
onAbort={jest.fn()}
onStartListening={jest.fn()}
onStopListening={jest.fn()}
modelOptions={[]}
onModelChange={jest.fn()}
approvalMode="auto"
onApprovalModeChange={jest.fn()}
/>
</ThemeProvider>,
);
expect(screen.getByPlaceholderText("Agent 服务未就绪,暂时无法发送消息")).toBeDisabled();
expect(screen.getByRole("button", { name: "发送" })).toBeDisabled();
});
it("renders the always-allow mode as an explicit warning state", () => {
render(
<ThemeProvider theme={createTheme()}>
<AgentComposer
isStreaming={false}
isListening={false}
isSttSupported={false}
presets={[]}
onSend={jest.fn()}
onAbort={jest.fn()}
onStartListening={jest.fn()}
onStopListening={jest.fn()}
modelOptions={[]}
onModelChange={jest.fn()}
approvalMode="always"
onApprovalModeChange={jest.fn()}
/>
</ThemeProvider>,
);
expect(screen.getByText("始终允许")).toBeInTheDocument();
expect(screen.getByTestId("WarningAmberRoundedIcon")).toBeInTheDocument();
});
});
+108 -28
View File
@@ -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<typeof BoltRounded>,
@@ -67,6 +99,7 @@ const renderModelIcon = (
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
isHydrating = false,
runtimeState = "ready",
isStreaming,
isListening,
isSttSupported,
@@ -85,8 +118,19 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(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<AgentComposerHandle, AgentComposer
const handleSend = React.useCallback(() => {
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 (
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
@@ -154,6 +198,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
label={prompt.replace(/[。.]$/, "")}
size="medium"
clickable
disabled={!isRuntimeReady || isHydrating || isStreaming}
onClick={() => {
setInput(prompt);
setIsPresetOpen(false);
@@ -209,12 +254,12 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
handleSend();
}
}}
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."}
placeholder={placeholder}
fullWidth
multiline
maxRows={5}
variant="standard"
disabled={isHydrating}
disabled={isHydrating || !isRuntimeReady}
InputProps={{
disableUnderline: true,
sx: { px: 1, py: 0.5, fontSize: "1rem", lineHeight: 1.6, fontWeight: 500, color: "text.primary" },
@@ -223,26 +268,33 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}>
<Stack direction="row" spacing={0.5} alignItems="center">
<FormControl size="small" sx={{ minWidth: 96 }}>
<FormControl size="small" sx={{ minWidth: 128 }}>
<Select
value={approvalMode}
onChange={(event) =>
onApprovalModeChange(event.target.value as AgentApprovalMode)
}
disabled={isHydrating || isStreaming}
disabled={isHydrating || isStreaming || !isRuntimeReady}
aria-label="权限批准模式"
renderValue={(val) => (
renderValue={() => {
const SelectedApprovalIcon = selectedApprovalModeOption.icon;
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.45 }}>
{val === "always" ? (
<AdminPanelSettingsRounded sx={{ fontSize: 18, color: "inherit" }} />
) : (
<VerifiedUserRounded sx={{ fontSize: 18, color: "inherit" }} />
)}
<SelectedApprovalIcon
sx={{
fontSize: 18,
color:
selectedApprovalModeOption.value === "always"
? "warning.main"
: "inherit",
}}
/>
<Typography sx={{ fontSize: "0.75rem", fontWeight: 600, color: "inherit" }}>
{val === "always" ? "始终允许" : "请求批准"}
{selectedApprovalModeOption.label}
</Typography>
</Box>
)}
);
}}
MenuProps={{
anchorOrigin: { vertical: "top", horizontal: "left" },
transformOrigin: { vertical: "bottom", horizontal: "left" },
@@ -250,7 +302,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
PaperProps: {
sx: {
mb: 1.5,
width: 210,
width: 248,
borderRadius: 4,
bgcolor: alpha("#fff", 0.9),
backdropFilter: "blur(24px)",
@@ -269,6 +321,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
"&:hover": { bgcolor: alpha("#00acc1", 0.12) },
"& .title": { color: "#00838f" },
"& .icon": { color: "#00acc1" },
"& .always-icon": { color: "warning.main" },
},
},
},
@@ -298,20 +351,47 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
},
}}
>
<MenuItem value="request">
<VerifiedUserRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} />
{approvalModeOptions.map((option) => {
const ApprovalIcon = option.icon;
const isAlways = option.value === "always";
return (
<MenuItem key={option.value} value={option.value}>
<ApprovalIcon
className={isAlways ? "icon always-icon" : "icon"}
sx={{
mr: 1.5,
mt: 0.15,
fontSize: 18,
color: isAlways ? "warning.main" : "text.secondary",
}}
/>
<Box>
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}></Typography>
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}></Typography>
</Box>
</MenuItem>
<MenuItem value="always">
<AdminPanelSettingsRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} />
<Box>
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}></Typography>
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}></Typography>
<Typography
className="title"
sx={{
mb: 0.2,
color: "text.primary",
fontSize: "0.85rem",
fontWeight: 700,
}}
>
{option.label}
</Typography>
<Typography
sx={{
color: "text.secondary",
fontSize: "0.7rem",
fontWeight: 500,
lineHeight: 1.3,
whiteSpace: "nowrap",
}}
>
{option.description}
</Typography>
</Box>
</MenuItem>
);
})}
</Select>
</FormControl>
</Stack>
@@ -452,7 +532,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
) : (
<IconButton
onClick={onStartListening}
disabled={isStreaming || isHydrating}
disabled={isStreaming || isHydrating || !isRuntimeReady}
aria-label="语音输入"
size="small"
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
+16 -3
View File
@@ -19,12 +19,14 @@ import CloseRounded from "@mui/icons-material/CloseRounded";
import EditRounded from "@mui/icons-material/EditRounded";
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
import HistoryRounded from "@mui/icons-material/HistoryRounded";
import type { AgentRuntimeState } from "@/lib/agentRuntime";
type AgentHeaderProps = {
sessionTitle?: string;
canRenameSessionTitle?: boolean;
isHydrating?: boolean;
isStreaming: boolean;
runtimeState?: AgentRuntimeState;
isHistoryOpen: boolean;
onHistoryToggle: () => 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 = ({
/>
</Avatar>
<Box
role="status"
aria-label={runtimeStatus.label}
sx={{
position: "absolute",
bottom: -2,
right: -2,
width: 14,
height: 14,
bgcolor: isStreaming ? "#ff9800" : "#00e676",
bgcolor: runtimeStatus.color,
borderRadius: "50%",
border: "2.5px solid #fff",
boxShadow: `0 0 10px ${isStreaming ? "#ff9800" : "#00e676"}`,
animation: isStreaming ? "pulse 1.5s infinite" : "none",
boxShadow: `0 0 10px ${runtimeStatus.color}`,
animation: isStreaming && runtimeState === "ready" ? "pulse 1.5s infinite" : "none",
"@keyframes pulse": {
"0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` },
"70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` },
+38 -12
View File
@@ -19,12 +19,12 @@ import TerminalRounded from "@mui/icons-material/TerminalRounded";
import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import BlockRounded from "@mui/icons-material/BlockRounded";
import PushPinRounded from "@mui/icons-material/PushPinRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
import GppGoodRounded from "@mui/icons-material/GppGoodRounded";
import type { PermissionReply } from "@/lib/chatStream";
import type { PermissionDecision } from "@/lib/chatStream";
import type { Message } from "./GlobalChatbox.types";
const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => {
@@ -58,7 +58,7 @@ const PermissionIcon = ({
};
const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[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<Message["permissions"]>[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 (
<Box
@@ -203,6 +206,29 @@ const PermissionRequestCard = ({
{primaryValue}
</Typography>
</Box>
{isPending || isSubmitting ? (
<Box
sx={{
px: 1.25,
py: 0.85,
borderRadius: 2.5,
bgcolor: alpha(theme.palette.success.main, 0.055),
border: `1px solid ${alpha(theme.palette.success.main, 0.11)}`,
}}
>
<Typography variant="caption" color="success.dark" fontWeight={800}>
</Typography>
<Typography
variant="caption"
color="text.secondary"
fontFamily={permission.permission === "bash" ? "monospace" : undefined}
sx={{ display: "block", mt: 0.2, lineHeight: 1.45, wordBreak: "break-word", whiteSpace: "pre-wrap" }}
>
{persistentScope.join("\n")}
</Typography>
</Box>
) : null}
</Stack>
{permission.error ? (
@@ -264,10 +290,11 @@ const PermissionRequestCard = ({
</Button>
<Button
size="small"
color="success"
variant="outlined"
disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "always")}
startIcon={<PushPinRounded fontSize="small" />}
startIcon={<GppGoodRounded fontSize="small" />}
sx={{
height: 34,
borderRadius: "17px",
@@ -275,16 +302,15 @@ const PermissionRequestCard = ({
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
color: "#00838f",
borderColor: alpha("#00838f", 0.24),
borderColor: alpha(theme.palette.success.main, 0.28),
bgcolor: alpha("#fff", 0.45),
"&:hover": {
borderColor: alpha("#00838f", 0.36),
bgcolor: alpha("#00838f", 0.08),
borderColor: alpha(theme.palette.success.main, 0.42),
bgcolor: alpha(theme.palette.success.main, 0.08),
},
}}
>
</Button>
<Button
size="small"
@@ -323,7 +349,7 @@ export const PermissionRequestGroup = ({
}: {
permissions: NonNullable<Message["permissions"]>;
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) },
];
+52
View File
@@ -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(
<AgentTurn
message={{
id: "assistant-permission",
role: "assistant",
content: "",
progress: [
{
id: "permission-progress",
phase: "permission",
status: "running",
title: "等待权限确认",
},
],
permissions: [
{
requestId: "permission-1",
sessionId: "session-1",
permission: "bash",
patterns: ["npm test"],
target: "npm test",
always: ["npm test"],
createdAt: 1,
status: "pending",
},
],
}}
isStreaming
messageSpeechState="idle"
onSpeak={jest.fn()}
onPause={jest.fn()}
onResume={jest.fn()}
onStopSpeech={jest.fn()}
isTtsSupported
onCreateBranch={jest.fn()}
onReplyPermission={onReplyPermission}
onReplyQuestion={jest.fn()}
onRejectQuestion={jest.fn()}
/>,
);
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();
});
});
+2 -2
View File
@@ -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;
};
+78 -1
View File
@@ -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(
<AgentWorkspace
{...defaultProps}
isStreaming={false}
runtimeState="unavailable"
onRetryRuntime={onRetryRuntime}
messages={[]}
/>,
);
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(
<AgentWorkspace
{...defaultProps}
isStreaming={false}
runtimeState="models_unavailable"
messages={[]}
/>,
);
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(
<AgentWorkspace
{...defaultProps}
isStreaming={false}
messages={messages}
/>,
);
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(
<AgentWorkspace
{...defaultProps}
isStreaming={false}
messages={messages.slice(0, 40)}
/>,
);
rerender(
<AgentWorkspace
{...defaultProps}
isStreaming={false}
messages={messages}
/>,
);
expect(mountCounts.get("message-0")).toBe(1);
expect(unmountCounts.get("message-0") ?? 0).toBe(0);
});
});
+273 -19
View File
@@ -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<HTMLDivElement | null>;
bottomRef: React.RefObject<HTMLDivElement | null>;
@@ -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,11 +99,98 @@ const TurnListInner = ({
onReplyQuestion,
onRejectQuestion,
}: TurnListProps) => {
const [measuredHeights, setMeasuredHeights] = React.useState(
() => new Map<string, number>(),
);
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<number>(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) => (
<TurnItem
{windowState.topSpacerHeight > 0 ? (
<Box aria-hidden sx={{ height: windowState.topSpacerHeight, flexShrink: 0 }} />
) : null}
{visibleMessages.map((message) => (
<MeasuredTurn
key={message.id}
message={message}
measure={isWindowed}
onHeightChange={updateMeasuredHeight}
>
<TurnItem
message={message}
isStreaming={isAssistantStreaming && message.id === streamingMessageId}
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
@@ -104,15 +204,69 @@ const TurnListInner = ({
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
</MeasuredTurn>
))}
{windowState.bottomSpacerHeight > 0 ? (
<Box aria-hidden sx={{ height: windowState.bottomSpacerHeight, flexShrink: 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<HTMLDivElement>(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 (
<Box
ref={rowRef}
sx={{ mb: measure ? `${TURN_GAP_PX}px` : 0, flexShrink: 0 }}
>
{children}
</Box>
);
};
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: <WaterDropRounded sx={{ fontSize: 20, color: "#00acc1" }} />, label: "水力瓶颈识别" },
{ icon: <SensorsRounded sx={{ fontSize: 20, color: "#0288d1" }} />, label: "异常状态预警" },
@@ -147,6 +327,8 @@ const EmptyState = () => {
style={{ margin: "auto", width: "100%", maxWidth: 440, padding: 16 }}
>
<Paper
role={isReady || isChecking ? "status" : "alert"}
aria-live="polite"
elevation={0}
sx={{
p: 4,
@@ -170,9 +352,9 @@ const EmptyState = () => {
}} />
<motion.div
animate={{
y: [-6, 4, -6],
scale: [1, 1.04, 1],
rotate: [-3, 3, -3],
y: isReady ? [-6, 4, -6] : 0,
scale: isReady ? [1, 1.04, 1] : 1,
rotate: isReady ? [-3, 3, -3] : 0,
}}
transition={{ duration: 4.8, repeat: Infinity, ease: "easeInOut" }}
style={{
@@ -194,17 +376,32 @@ const EmptyState = () => {
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)",
}}
/>
</motion.div>
<Typography variant="h6" color="text.primary" fontWeight={800} gutterBottom>
{statusCopy.title}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6, mb: 3 }}>
使
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6, mb: isReady ? 3 : 2 }}>
{statusCopy.detail}
</Typography>
{isChecking ? (
<CircularProgress size={28} thickness={4} aria-label="正在检测 Agent 服务" />
) : !isReady ? (
<Button
variant="contained"
size="small"
startIcon={<ReplayRounded />}
onClick={onRetryRuntime}
sx={{ borderRadius: 999, px: 2, boxShadow: "none" }}
>
</Button>
) : (
<Grid container spacing={1.5}>
{capabilities.map((item) => (
<Grid item xs={6} key={item.label}>
@@ -239,6 +436,7 @@ const EmptyState = () => {
</Grid>
))}
</Grid>
)}
</Paper>
</motion.div>
);
@@ -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<HTMLDivElement>(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<HTMLDivElement>) => {
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 (
<Box
ref={scrollContainerRef}
ref={setScrollContainer}
onScroll={handleScroll}
sx={{
flex: 1,
@@ -363,13 +600,30 @@ export const AgentWorkspace = ({
) : (
<>
<AnimatePresence initial={false}>
{messages.length === 0 ? <EmptyState /> : null}
{messages.length === 0 ? (
<EmptyState
runtimeState={runtimeState}
onRetryRuntime={onRetryRuntime}
/>
) : null}
</AnimatePresence>
{messages.length > 0 ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: messages.length > TURN_WINDOW_THRESHOLD ? 0 : 2,
}}
>
<TurnList
messages={messages}
scrollTop={
messages.length > TURN_WINDOW_THRESHOLD
? scrollMetrics.scrollTop
: 0
}
viewportHeight={scrollMetrics.viewportHeight}
isAssistantStreaming={isStreaming}
streamingMessageId={streamingMessageId}
speakingMessageId={speakingMessageId}
+28 -3
View File
@@ -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: () => <div data-testid="agent-workspace">Workspace</div>,
AgentWorkspace: ({ runtimeState }: { runtimeState: string }) => (
<div data-testid="agent-workspace">Workspace state: {runtimeState}</div>
),
}));
jest.mock("./AgentComposer", () => ({
AgentComposer: React.forwardRef(function MockAgentComposer() {
return <div>Composer</div>;
AgentComposer: React.forwardRef(function MockAgentComposer(props: { approvalMode: string }, _ref) {
return <div>Composer mode: {props.approvalMode}</div>;
}),
}));
@@ -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(<GlobalChatbox open onClose={jest.fn()} />);
expect(screen.getByText("Composer mode: auto")).toBeInTheDocument();
});
it("passes backend startup failures into the existing workspace empty state", async () => {
mockFetchAgentRuntimeHealth.mockResolvedValueOnce(false);
render(<GlobalChatbox open onClose={jest.fn()} />);
await act(async () => Promise.resolve());
expect(screen.getByText("Workspace state: unavailable")).toBeInTheDocument();
});
});
+68 -14
View File
@@ -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<Props> = ({ open, onClose }) => {
const [width, setWidth] = useState(520);
@@ -34,8 +40,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState<AgentModel | undefined>(undefined);
const [runtimeState, setRuntimeState] = useState<AgentRuntimeState>("checking");
const [approvalMode, setApprovalMode] =
useState<AgentApprovalMode>("request");
useState<AgentApprovalMode>("auto");
const bottomRef = useRef<HTMLDivElement>(null);
const workspaceScrollRef = useRef<HTMLDivElement>(null);
@@ -43,6 +50,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const streamingScrollFrameRef = useRef<number | null>(null);
const composerRef = useRef<AgentComposerHandle | null>(null);
const initializedProjectIdRef = useRef<string | null | undefined>(undefined);
const runtimeRequestIdRef = useRef(0);
const runtimeAbortRef = useRef<AbortController | null>(null);
const theme = useTheme();
const { open: openNotification } = useNotification();
const currentProjectId = useProjectStore((state) => state.currentProjectId);
@@ -68,13 +77,38 @@ export const GlobalChatbox: React.FC<Props> = ({ 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;
if (showChecking) setRuntimeState("checking");
const loadModels = async () => {
try {
const modelConfig = await fetchAgentModels();
if (cancelled) return;
runtimeHealthy = await fetchAgentRuntimeHealth(controller.signal);
if (requestId !== runtimeRequestIdRef.current) return;
if (!runtimeHealthy) {
setRuntimeState("unavailable");
setModelOptions([]);
setSelectedModel(undefined);
return;
}
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)) {
@@ -82,21 +116,37 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
}
return modelConfig.defaultModel;
});
setRuntimeState("ready");
} catch (error) {
console.error("[GlobalChatbox] Failed to load agent models:", error);
if (!cancelled) {
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;
}
}
};
}, []);
void loadModels();
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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ open, onClose }) => {
<AgentWorkspace
messages={messages}
isStreaming={isStreaming}
runtimeState={runtimeState}
onRetryRuntime={() => void refreshAgentRuntime(true)}
isLoadingSession={Boolean(loadingSessionId)}
scrollContainerRef={workspaceScrollRef}
bottomRef={bottomRef}
@@ -450,6 +503,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
<AgentComposer
ref={composerRef}
isHydrating={isHydrating || isCheckingAuth}
runtimeState={runtimeState}
isStreaming={isStreaming}
isListening={isListening}
isSttSupported={isSttSupported}
@@ -13,7 +13,7 @@ import {
resumeAgentChatStream,
streamAgentChat,
} from "@/lib/chatStream";
import type { PermissionReply, StreamEvent } from "@/lib/chatStream";
import type { PermissionDecision, StreamEvent } from "@/lib/chatStream";
import { useAuthStore } from "@/store/authStore";
import type {
AgentArtifact,
@@ -799,7 +799,7 @@ export const useAgentChatSession = ({
}, [flushPendingTokens, getLastAssistantMessageId]);
const replyPermission = useCallback(
async (requestId: string, reply: PermissionReply) => {
async (requestId: string, reply: PermissionDecision) => {
const target = messagesRef.current
.flatMap((message) => message.permissions ?? [])
.find((permission) => permission.requestId === requestId);
+5 -2
View File
@@ -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";
};
};
};
+44
View File
@@ -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);
});
});
+37
View File
@@ -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<boolean> => {
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,
);
};
+2 -1
View File
@@ -46,9 +46,10 @@ const normalizeModelOption = (value: unknown): AgentModelOption | null => {
};
};
export const fetchAgentModels = async (): Promise<AgentModelConfig> => {
export const fetchAgentModels = async (signal?: AbortSignal): Promise<AgentModelConfig> => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/models`, {
method: "GET",
signal,
projectHeaderMode: "include",
skipAuthRedirect: true,
});
+14
View File
@@ -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 () => {
+4 -3
View File
@@ -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"),