合并 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", "type": "string",
"enum": [ "enum": [
"request", "request",
"auto",
"always" "always"
] ],
"description": "request forwards approval prompts; auto approves only the low-risk allowlist; always approves every prompt not explicitly denied by OpenCode."
} }
}, },
"required": [ "required": [
+1 -5
View File
@@ -3,11 +3,7 @@
"contracts": { "contracts": {
"agent": { "agent": {
"file": "agent-v1.openapi.json", "file": "agent-v1.openapi.json",
"sha256": "d559c6e76c33e7a7451743f60d85da0630d0df14fb5215228acefcf2eaea555a" "sha256": "94bd8914597c56b6429160e8c556993ac0617ad079de2980a4b6cb9fdf89c039"
},
"server": {
"file": "server-v1.openapi.json",
"sha256": "b003a57c9b8c1a041b644363ef58afb8bfcede1fe076ce48a190d2a1ac915e3f"
} }
} }
} }
@@ -51,6 +51,58 @@ describe("NextAuth access token refresh", () => {
restoreEnv("KEYCLOAK_CLIENT_SECRET", previousEnv.clientSecret); 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) => { const restoreEnv = (key: string, value: string | undefined) => {
+34 -16
View File
@@ -39,25 +39,43 @@ const refreshAccessToken = async (token: JWT): Promise<JWT> => {
refresh_token: token.refreshToken, refresh_token: token.refreshToken,
}); });
const response = await fetch(keycloakTokenEndpoint, { try {
method: "POST", const response = await fetch(keycloakTokenEndpoint, {
headers: { "Content-Type": "application/x-www-form-urlencoded" }, method: "POST",
body, 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.trim(),
accessTokenIssuedAt: Date.now(),
accessTokenExpires: Date.now() + refreshed.expires_in * 1000,
refreshToken: rotatedRefreshToken,
error: undefined,
};
} catch {
return { ...token, error: "RefreshAccessTokenError" }; 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 = { const authOptions: NextAuthOptions = {
+51 -1
View File
@@ -44,7 +44,7 @@ describe("AgentComposer", () => {
modelOptions={[{ id: "test-model", label: "测试模型" }]} modelOptions={[{ id: "test-model", label: "测试模型" }]}
selectedModel="test-model" selectedModel="test-model"
onModelChange={jest.fn()} onModelChange={jest.fn()}
approvalMode="request" approvalMode="auto"
onApprovalModeChange={jest.fn()} onApprovalModeChange={jest.fn()}
/> />
</ThemeProvider>, </ThemeProvider>,
@@ -56,6 +56,56 @@ describe("AgentComposer", () => {
expect(screen.queryByRole("button", { name: "上传附件" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "上传附件" })).not.toBeInTheDocument();
expect(screen.getByTitle("快捷指令图标")).toBeInTheDocument(); expect(screen.getByTitle("快捷指令图标")).toBeInTheDocument();
expect(screen.queryByAltText("TJWater Agent")).not.toBeInTheDocument(); expect(screen.queryByAltText("TJWater Agent")).not.toBeInTheDocument();
expect(screen.getByText("自动批准")).toBeInTheDocument();
expect(voiceButton.nextElementSibling?.contains(sendButton)).toBe(true); 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();
});
}); });
+115 -35
View File
@@ -27,6 +27,8 @@ import BoltRounded from "@mui/icons-material/BoltRounded";
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded"; import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded"; import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded"; 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 { AgentModelOption } from "@/lib/chatModels";
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream"; import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
@@ -40,6 +42,7 @@ export type AgentComposerHandle = {
type AgentComposerProps = { type AgentComposerProps = {
isHydrating?: boolean; isHydrating?: boolean;
runtimeState?: AgentRuntimeState;
isStreaming: boolean; isStreaming: boolean;
isListening: boolean; isListening: boolean;
isSttSupported: boolean; isSttSupported: boolean;
@@ -55,6 +58,35 @@ type AgentComposerProps = {
onApprovalModeChange: (mode: AgentApprovalMode) => void; 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 = ( const renderModelIcon = (
icon: AgentModelOption["icon"] | undefined, icon: AgentModelOption["icon"] | undefined,
props?: React.ComponentProps<typeof BoltRounded>, props?: React.ComponentProps<typeof BoltRounded>,
@@ -67,6 +99,7 @@ const renderModelIcon = (
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
isHydrating = false, isHydrating = false,
runtimeState = "ready",
isStreaming, isStreaming,
isListening, isListening,
isSttSupported, isSttSupported,
@@ -85,8 +118,19 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null); const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
const [input, setInput] = React.useState(""); const [input, setInput] = React.useState("");
const [isPresetOpen, setIsPresetOpen] = React.useState(false); 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 selectedModelOption = modelOptions.find((model) => model.id === selectedModel);
const selectedApprovalModeOption = getApprovalModeOption(approvalMode);
React.useImperativeHandle( React.useImperativeHandle(
ref, ref,
@@ -102,10 +146,10 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
const handleSend = React.useCallback(() => { const handleSend = React.useCallback(() => {
const prompt = input.trim(); const prompt = input.trim();
if (!prompt || isStreaming || isHydrating) return; if (!prompt || isStreaming || isHydrating || !isRuntimeReady) return;
setInput(""); setInput("");
onSend(prompt); onSend(prompt);
}, [input, isHydrating, isStreaming, onSend]); }, [input, isHydrating, isRuntimeReady, isStreaming, onSend]);
return ( return (
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}> <Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
@@ -154,6 +198,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
label={prompt.replace(/[。.]$/, "")} label={prompt.replace(/[。.]$/, "")}
size="medium" size="medium"
clickable clickable
disabled={!isRuntimeReady || isHydrating || isStreaming}
onClick={() => { onClick={() => {
setInput(prompt); setInput(prompt);
setIsPresetOpen(false); setIsPresetOpen(false);
@@ -209,12 +254,12 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
handleSend(); handleSend();
} }
}} }}
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."} placeholder={placeholder}
fullWidth fullWidth
multiline multiline
maxRows={5} maxRows={5}
variant="standard" variant="standard"
disabled={isHydrating} disabled={isHydrating || !isRuntimeReady}
InputProps={{ InputProps={{
disableUnderline: true, disableUnderline: true,
sx: { px: 1, py: 0.5, fontSize: "1rem", lineHeight: 1.6, fontWeight: 500, color: "text.primary" }, 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" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}>
<Stack direction="row" spacing={0.5} alignItems="center"> <Stack direction="row" spacing={0.5} alignItems="center">
<FormControl size="small" sx={{ minWidth: 96 }}> <FormControl size="small" sx={{ minWidth: 128 }}>
<Select <Select
value={approvalMode} value={approvalMode}
onChange={(event) => onChange={(event) =>
onApprovalModeChange(event.target.value as AgentApprovalMode) onApprovalModeChange(event.target.value as AgentApprovalMode)
} }
disabled={isHydrating || isStreaming} disabled={isHydrating || isStreaming || !isRuntimeReady}
aria-label="权限批准模式" aria-label="权限批准模式"
renderValue={(val) => ( renderValue={() => {
<Box sx={{ display: "flex", alignItems: "center", gap: 0.45 }}> const SelectedApprovalIcon = selectedApprovalModeOption.icon;
{val === "always" ? ( return (
<AdminPanelSettingsRounded sx={{ fontSize: 18, color: "inherit" }} /> <Box sx={{ display: "flex", alignItems: "center", gap: 0.45 }}>
) : ( <SelectedApprovalIcon
<VerifiedUserRounded sx={{ fontSize: 18, color: "inherit" }} /> sx={{
)} fontSize: 18,
<Typography sx={{ fontSize: "0.75rem", fontWeight: 600, color: "inherit" }}> color:
{val === "always" ? "始终允许" : "请求批准"} selectedApprovalModeOption.value === "always"
</Typography> ? "warning.main"
</Box> : "inherit",
)} }}
/>
<Typography sx={{ fontSize: "0.75rem", fontWeight: 600, color: "inherit" }}>
{selectedApprovalModeOption.label}
</Typography>
</Box>
);
}}
MenuProps={{ MenuProps={{
anchorOrigin: { vertical: "top", horizontal: "left" }, anchorOrigin: { vertical: "top", horizontal: "left" },
transformOrigin: { vertical: "bottom", horizontal: "left" }, transformOrigin: { vertical: "bottom", horizontal: "left" },
@@ -250,7 +302,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
PaperProps: { PaperProps: {
sx: { sx: {
mb: 1.5, mb: 1.5,
width: 210, width: 248,
borderRadius: 4, borderRadius: 4,
bgcolor: alpha("#fff", 0.9), bgcolor: alpha("#fff", 0.9),
backdropFilter: "blur(24px)", backdropFilter: "blur(24px)",
@@ -269,6 +321,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
"&:hover": { bgcolor: alpha("#00acc1", 0.12) }, "&:hover": { bgcolor: alpha("#00acc1", 0.12) },
"& .title": { color: "#00838f" }, "& .title": { color: "#00838f" },
"& .icon": { color: "#00acc1" }, "& .icon": { color: "#00acc1" },
"& .always-icon": { color: "warning.main" },
}, },
}, },
}, },
@@ -298,20 +351,47 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
}, },
}} }}
> >
<MenuItem value="request"> {approvalModeOptions.map((option) => {
<VerifiedUserRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} /> const ApprovalIcon = option.icon;
<Box> const isAlways = option.value === "always";
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}></Typography> return (
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}></Typography> <MenuItem key={option.value} value={option.value}>
</Box> <ApprovalIcon
</MenuItem> className={isAlways ? "icon always-icon" : "icon"}
<MenuItem value="always"> sx={{
<AdminPanelSettingsRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} /> mr: 1.5,
<Box> mt: 0.15,
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}></Typography> fontSize: 18,
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}></Typography> color: isAlways ? "warning.main" : "text.secondary",
</Box> }}
</MenuItem> />
<Box>
<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> </Select>
</FormControl> </FormControl>
</Stack> </Stack>
@@ -452,7 +532,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
) : ( ) : (
<IconButton <IconButton
onClick={onStartListening} onClick={onStartListening}
disabled={isStreaming || isHydrating} disabled={isStreaming || isHydrating || !isRuntimeReady}
aria-label="语音输入" aria-label="语音输入"
size="small" size="small"
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }} 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 EditRounded from "@mui/icons-material/EditRounded";
import EditNoteRounded from "@mui/icons-material/EditNoteRounded"; import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
import HistoryRounded from "@mui/icons-material/HistoryRounded"; import HistoryRounded from "@mui/icons-material/HistoryRounded";
import type { AgentRuntimeState } from "@/lib/agentRuntime";
type AgentHeaderProps = { type AgentHeaderProps = {
sessionTitle?: string; sessionTitle?: string;
canRenameSessionTitle?: boolean; canRenameSessionTitle?: boolean;
isHydrating?: boolean; isHydrating?: boolean;
isStreaming: boolean; isStreaming: boolean;
runtimeState?: AgentRuntimeState;
isHistoryOpen: boolean; isHistoryOpen: boolean;
onHistoryToggle: () => void; onHistoryToggle: () => void;
onRenameSessionTitle?: (title: string) => void; onRenameSessionTitle?: (title: string) => void;
@@ -37,6 +39,7 @@ export const AgentHeader = ({
canRenameSessionTitle = false, canRenameSessionTitle = false,
isHydrating = false, isHydrating = false,
isStreaming, isStreaming,
runtimeState = "ready",
isHistoryOpen, isHistoryOpen,
onHistoryToggle, onHistoryToggle,
onRenameSessionTitle, onRenameSessionTitle,
@@ -47,6 +50,14 @@ export const AgentHeader = ({
const displayTitle = sessionTitle?.trim() || "新对话"; const displayTitle = sessionTitle?.trim() || "新对话";
const [isEditingTitle, setIsEditingTitle] = React.useState(false); const [isEditingTitle, setIsEditingTitle] = React.useState(false);
const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || ""); 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(() => { React.useEffect(() => {
if (!isEditingTitle) { if (!isEditingTitle) {
@@ -109,17 +120,19 @@ export const AgentHeader = ({
/> />
</Avatar> </Avatar>
<Box <Box
role="status"
aria-label={runtimeStatus.label}
sx={{ sx={{
position: "absolute", position: "absolute",
bottom: -2, bottom: -2,
right: -2, right: -2,
width: 14, width: 14,
height: 14, height: 14,
bgcolor: isStreaming ? "#ff9800" : "#00e676", bgcolor: runtimeStatus.color,
borderRadius: "50%", borderRadius: "50%",
border: "2.5px solid #fff", border: "2.5px solid #fff",
boxShadow: `0 0 10px ${isStreaming ? "#ff9800" : "#00e676"}`, boxShadow: `0 0 10px ${runtimeStatus.color}`,
animation: isStreaming ? "pulse 1.5s infinite" : "none", animation: isStreaming && runtimeState === "ready" ? "pulse 1.5s infinite" : "none",
"@keyframes pulse": { "@keyframes pulse": {
"0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` }, "0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` },
"70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` }, "70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` },
+110 -84
View File
@@ -19,12 +19,12 @@ import TerminalRounded from "@mui/icons-material/TerminalRounded";
import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded"; import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import BlockRounded from "@mui/icons-material/BlockRounded"; import BlockRounded from "@mui/icons-material/BlockRounded";
import PushPinRounded from "@mui/icons-material/PushPinRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded"; 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"; import type { Message } from "./GlobalChatbox.types";
const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => { const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => {
@@ -58,7 +58,7 @@ const PermissionIcon = ({
}; };
const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[number]["status"]) => { 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 === "approved_once") return "已允许一次";
if (status === "rejected") return "已拒绝"; if (status === "rejected") return "已拒绝";
if (status === "aborted") return "已中断"; if (status === "aborted") return "已中断";
@@ -99,7 +99,7 @@ const PermissionRequestCard = ({
}: { }: {
permission: NonNullable<Message["permissions"]>[number]; permission: NonNullable<Message["permissions"]>[number];
isRunning: boolean; isRunning: boolean;
onReply: (requestId: string, reply: PermissionReply) => void; onReply: (requestId: string, reply: PermissionDecision) => void;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const isPending = const isPending =
@@ -109,6 +109,9 @@ const PermissionRequestCard = ({
const accentColor = getPermissionStatusColor(permission.status, theme); const accentColor = getPermissionStatusColor(permission.status, theme);
const statusTextColor = getPermissionStatusTextColor(permission.status, theme); const statusTextColor = getPermissionStatusTextColor(permission.status, theme);
const statusLabel = getPermissionStatusLabel(permission.status); const statusLabel = getPermissionStatusLabel(permission.status);
const persistentScope = permission.always.length > 0
? permission.always
: permission.patterns;
return ( return (
<Box <Box
@@ -203,6 +206,29 @@ const PermissionRequestCard = ({
{primaryValue} {primaryValue}
</Typography> </Typography>
</Box> </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> </Stack>
{permission.error ? ( {permission.error ? (
@@ -232,84 +258,84 @@ const PermissionRequestCard = ({
useFlexGap useFlexGap
sx={{ px: 1.5, pb: 1.35, pl: 1.75, pt: 0 }} sx={{ px: 1.5, pb: 1.35, pl: 1.75, pt: 0 }}
> >
<Button <Button
size="small" size="small"
variant="contained" variant="contained"
disableElevation disableElevation
disabled={isSubmitting} disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "once")} onClick={() => onReply(permission.requestId, "once")}
startIcon={ startIcon={
isSubmitting ? ( isSubmitting ? (
<CircularProgress size={14} color="inherit" /> <CircularProgress size={14} color="inherit" />
) : ( ) : (
<CheckCircleRounded fontSize="small" /> <CheckCircleRounded fontSize="small" />
) )
} }
sx={{ sx={{
minWidth: 94, minWidth: 94,
height: 34, height: 34,
borderRadius: "17px", borderRadius: "17px",
bgcolor: "#00838f", bgcolor: "#00838f",
fontWeight: 800, fontWeight: 800,
fontSize: "0.78rem", fontSize: "0.78rem",
textTransform: "none", textTransform: "none",
boxShadow: `0 4px 12px ${alpha("#00838f", 0.24)}`, boxShadow: `0 4px 12px ${alpha("#00838f", 0.24)}`,
"&:hover": { "&:hover": {
bgcolor: "#006c78", bgcolor: "#006c78",
boxShadow: `0 6px 16px ${alpha("#00838f", 0.28)}`, boxShadow: `0 6px 16px ${alpha("#00838f", 0.28)}`,
}, },
}} }}
> >
</Button> </Button>
<Button <Button
size="small" size="small"
variant="outlined" color="success"
disabled={isSubmitting} variant="outlined"
onClick={() => onReply(permission.requestId, "always")} disabled={isSubmitting}
startIcon={<PushPinRounded fontSize="small" />} onClick={() => onReply(permission.requestId, "always")}
sx={{ startIcon={<GppGoodRounded fontSize="small" />}
height: 34, sx={{
borderRadius: "17px", height: 34,
px: 1.5, borderRadius: "17px",
fontWeight: 800, px: 1.5,
fontSize: "0.78rem", fontWeight: 800,
textTransform: "none", fontSize: "0.78rem",
color: "#00838f", textTransform: "none",
borderColor: alpha("#00838f", 0.24), borderColor: alpha(theme.palette.success.main, 0.28),
bgcolor: alpha("#fff", 0.45), bgcolor: alpha("#fff", 0.45),
"&:hover": { "&:hover": {
borderColor: alpha("#00838f", 0.36), borderColor: alpha(theme.palette.success.main, 0.42),
bgcolor: alpha("#00838f", 0.08), bgcolor: alpha(theme.palette.success.main, 0.08),
}, },
}} }}
> >
</Button> </Button>
<Button <Button
size="small" size="small"
color="error" color="error"
variant="outlined" variant="outlined"
disabled={isSubmitting} disabled={isSubmitting}
onClick={() => onReply(permission.requestId, "reject")} onClick={() => onReply(permission.requestId, "reject")}
startIcon={<BlockRounded fontSize="small" />} startIcon={<BlockRounded fontSize="small" />}
sx={{ sx={{
height: 34, height: 34,
borderRadius: "17px", borderRadius: "17px",
px: 1.5, px: 1.5,
fontWeight: 800, fontWeight: 800,
fontSize: "0.78rem", fontSize: "0.78rem",
textTransform: "none", textTransform: "none",
borderColor: alpha(theme.palette.error.main, 0.22), borderColor: alpha(theme.palette.error.main, 0.22),
bgcolor: alpha("#fff", 0.45), bgcolor: alpha("#fff", 0.45),
"&:hover": { "&:hover": {
borderColor: alpha(theme.palette.error.main, 0.34), borderColor: alpha(theme.palette.error.main, 0.34),
bgcolor: alpha(theme.palette.error.main, 0.07), bgcolor: alpha(theme.palette.error.main, 0.07),
}, },
}} }}
> >
</Button> </Button>
</Stack> </Stack>
) : null} ) : null}
</Box> </Box>
@@ -323,7 +349,7 @@ export const PermissionRequestGroup = ({
}: { }: {
permissions: NonNullable<Message["permissions"]>; permissions: NonNullable<Message["permissions"]>;
isRunning: boolean; isRunning: boolean;
onReply: (requestId: string, reply: PermissionReply) => void; onReply: (requestId: string, reply: PermissionDecision) => void;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const onceCount = permissions.filter((permission) => permission.status === "approved_once").length; const onceCount = permissions.filter((permission) => permission.status === "approved_once").length;
@@ -348,7 +374,7 @@ export const PermissionRequestGroup = ({
const summaryItems = [ const summaryItems = [
{ label: "共", value: permissions.length, color: theme.palette.text.secondary }, { label: "共", value: permissions.length, color: theme.palette.text.secondary },
{ label: "允许一次", value: onceCount, color: getPermissionStatusColor("approved_once", theme), textColor: getPermissionStatusTextColor("approved_once", theme) }, { 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: rejectedCount, color: getPermissionStatusColor("rejected", theme), textColor: getPermissionStatusTextColor("rejected", theme) },
{ label: "中断", value: abortedCount, color: getPermissionStatusColor("aborted", theme), textColor: getPermissionStatusTextColor("aborted", 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(); 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"; } from "@mui/material";
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded"; import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
import { TbArrowsSplit2 } from "react-icons/tb"; import { TbArrowsSplit2 } from "react-icons/tb";
import type { PermissionReply } from "@/lib/chatStream"; import type { PermissionDecision } from "@/lib/chatStream";
import { import {
parseAssistantMessageSections, parseAssistantMessageSections,
parseContentWithToolCalls, parseContentWithToolCalls,
@@ -100,7 +100,7 @@ type AgentTurnProps = {
onStopSpeech: () => void; onStopSpeech: () => void;
isTtsSupported: boolean; isTtsSupported: boolean;
onCreateBranch: (messageId: string) => void; onCreateBranch: (messageId: string) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void; onReplyPermission: (requestId: string, reply: PermissionDecision) => void;
onReplyQuestion: (requestId: string, answers: string[][]) => void; onReplyQuestion: (requestId: string, answers: string[][]) => void;
onRejectQuestion: (requestId: string) => void; onRejectQuestion: (requestId: string) => void;
}; };
+78 -1
View File
@@ -1,7 +1,7 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
import React from "react"; import React from "react";
import { render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { AgentWorkspace } from "./AgentWorkspace"; import { AgentWorkspace } from "./AgentWorkspace";
import type { Message } from "./GlobalChatbox.types"; import type { Message } from "./GlobalChatbox.types";
@@ -85,6 +85,38 @@ describe("AgentWorkspace", () => {
expect(screen.queryByText("我已就绪,请描述任务")).not.toBeInTheDocument(); 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", () => { it("keeps stable history turns from re-rendering while the last assistant message streams", () => {
const userMessage: Message = { const userMessage: Message = {
id: "user-1", id: "user-1",
@@ -164,4 +196,49 @@ describe("AgentWorkspace", () => {
expect(unmountCounts.get("assistant-1") ?? 0).toBe(0); expect(unmountCounts.get("assistant-1") ?? 0).toBe(0);
expect(streamingFlags.get("assistant-1")).toBe(false); 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);
});
}); });
+295 -41
View File
@@ -3,14 +3,16 @@
import Image from "next/image"; import Image from "next/image";
import React from "react"; import React from "react";
import { AnimatePresence, motion } from "framer-motion"; 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 WaterDropRounded from "@mui/icons-material/WaterDropRounded";
import SensorsRounded from "@mui/icons-material/SensorsRounded"; import SensorsRounded from "@mui/icons-material/SensorsRounded";
import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded"; import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded";
import MapRounded from "@mui/icons-material/MapRounded"; import MapRounded from "@mui/icons-material/MapRounded";
import ReplayRounded from "@mui/icons-material/ReplayRounded";
import { AgentTurn } from "./AgentTurn"; import { AgentTurn } from "./AgentTurn";
import type { PermissionReply } from "@/lib/chatStream"; import type { AgentRuntimeState } from "@/lib/agentRuntime";
import type { PermissionDecision } from "@/lib/chatStream";
import type { import type {
Message, Message,
SpeechState, SpeechState,
@@ -19,6 +21,8 @@ import type {
type AgentWorkspaceProps = { type AgentWorkspaceProps = {
messages: Message[]; messages: Message[];
isStreaming: boolean; isStreaming: boolean;
runtimeState?: AgentRuntimeState;
onRetryRuntime?: () => void;
isLoadingSession?: boolean; isLoadingSession?: boolean;
scrollContainerRef?: React.RefObject<HTMLDivElement | null>; scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
bottomRef: React.RefObject<HTMLDivElement | null>; bottomRef: React.RefObject<HTMLDivElement | null>;
@@ -35,13 +39,15 @@ type AgentWorkspaceProps = {
onStopSpeech: () => void; onStopSpeech: () => void;
isTtsSupported: boolean; isTtsSupported: boolean;
onCreateBranch: (messageId: string) => void; onCreateBranch: (messageId: string) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void; onReplyPermission: (requestId: string, reply: PermissionDecision) => void;
onReplyQuestion: (requestId: string, answers: string[][]) => void; onReplyQuestion: (requestId: string, answers: string[][]) => void;
onRejectQuestion: (requestId: string) => void; onRejectQuestion: (requestId: string) => void;
}; };
type TurnListProps = { type TurnListProps = {
messages: Message[]; messages: Message[];
scrollTop: number;
viewportHeight: number;
isAssistantStreaming: boolean; isAssistantStreaming: boolean;
streamingMessageId: string | null; streamingMessageId: string | null;
speakingMessageId: string | null; speakingMessageId: string | null;
@@ -56,13 +62,18 @@ type TurnListProps = {
onStopSpeech: () => void; onStopSpeech: () => void;
isTtsSupported: boolean; isTtsSupported: boolean;
onCreateBranch: (messageId: string) => void; onCreateBranch: (messageId: string) => void;
onReplyPermission: (requestId: string, reply: PermissionReply) => void; onReplyPermission: (requestId: string, reply: PermissionDecision) => void;
onReplyQuestion: (requestId: string, answers: string[][]) => void; onReplyQuestion: (requestId: string, answers: string[][]) => void;
onRejectQuestion: (requestId: string) => void; onRejectQuestion: (requestId: string) => void;
}; };
const STREAMING_BOTTOM_RESERVE_PX = 180; const STREAMING_BOTTOM_RESERVE_PX = 180;
const STREAMING_NEAR_BOTTOM_THRESHOLD_PX = STREAMING_BOTTOM_RESERVE_PX + 120; 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[]) => const sameMessages = (left: Message[], right: Message[]) =>
left.length === right.length && left.length === right.length &&
@@ -72,6 +83,8 @@ const TurnItem = React.memo(AgentTurn);
const TurnListInner = ({ const TurnListInner = ({
messages, messages,
scrollTop,
viewportHeight,
isAssistantStreaming, isAssistantStreaming,
streamingMessageId, streamingMessageId,
speakingMessageId, speakingMessageId,
@@ -86,33 +99,174 @@ const TurnListInner = ({
onReplyQuestion, onReplyQuestion,
onRejectQuestion, onRejectQuestion,
}: TurnListProps) => { }: 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 ( return (
<> <>
{messages.map((message) => ( {windowState.topSpacerHeight > 0 ? (
<TurnItem <Box aria-hidden sx={{ height: windowState.topSpacerHeight, flexShrink: 0 }} />
) : null}
{visibleMessages.map((message) => (
<MeasuredTurn
key={message.id} key={message.id}
message={message} message={message}
isStreaming={isAssistantStreaming && message.id === streamingMessageId} measure={isWindowed}
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"} onHeightChange={updateMeasuredHeight}
onSpeak={onSpeak} >
onPause={onPauseSpeech} <TurnItem
onResume={onResumeSpeech} message={message}
onStopSpeech={onStopSpeech} isStreaming={isAssistantStreaming && message.id === streamingMessageId}
isTtsSupported={isTtsSupported} messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
onCreateBranch={onCreateBranch} onSpeak={onSpeak}
onReplyPermission={onReplyPermission} onPause={onPauseSpeech}
onReplyQuestion={onReplyQuestion} onResume={onResumeSpeech}
onRejectQuestion={onRejectQuestion} onStopSpeech={onStopSpeech}
/> isTtsSupported={isTtsSupported}
onCreateBranch={onCreateBranch}
onReplyPermission={onReplyPermission}
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( const TurnList = React.memo(
TurnListInner, TurnListInner,
(prevProps, nextProps) => (prevProps, nextProps) =>
sameMessages(prevProps.messages, nextProps.messages) && sameMessages(prevProps.messages, nextProps.messages) &&
prevProps.scrollTop === nextProps.scrollTop &&
prevProps.viewportHeight === nextProps.viewportHeight &&
prevProps.isAssistantStreaming === nextProps.isAssistantStreaming && prevProps.isAssistantStreaming === nextProps.isAssistantStreaming &&
prevProps.streamingMessageId === nextProps.streamingMessageId && prevProps.streamingMessageId === nextProps.streamingMessageId &&
prevProps.speakingMessageId === nextProps.speakingMessageId && prevProps.speakingMessageId === nextProps.speakingMessageId &&
@@ -130,8 +284,34 @@ const TurnList = React.memo(
TurnList.displayName = "TurnList"; TurnList.displayName = "TurnList";
const EmptyState = () => { const EmptyState = ({
const theme = useTheme(); 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 = [ const capabilities = [
{ icon: <WaterDropRounded sx={{ fontSize: 20, color: "#00acc1" }} />, label: "水力瓶颈识别" }, { icon: <WaterDropRounded sx={{ fontSize: 20, color: "#00acc1" }} />, label: "水力瓶颈识别" },
{ icon: <SensorsRounded sx={{ fontSize: 20, color: "#0288d1" }} />, label: "异常状态预警" }, { icon: <SensorsRounded sx={{ fontSize: 20, color: "#0288d1" }} />, label: "异常状态预警" },
@@ -147,6 +327,8 @@ const EmptyState = () => {
style={{ margin: "auto", width: "100%", maxWidth: 440, padding: 16 }} style={{ margin: "auto", width: "100%", maxWidth: 440, padding: 16 }}
> >
<Paper <Paper
role={isReady || isChecking ? "status" : "alert"}
aria-live="polite"
elevation={0} elevation={0}
sx={{ sx={{
p: 4, p: 4,
@@ -170,9 +352,9 @@ const EmptyState = () => {
}} /> }} />
<motion.div <motion.div
animate={{ animate={{
y: [-6, 4, -6], y: isReady ? [-6, 4, -6] : 0,
scale: [1, 1.04, 1], scale: isReady ? [1, 1.04, 1] : 1,
rotate: [-3, 3, -3], rotate: isReady ? [-3, 3, -3] : 0,
}} }}
transition={{ duration: 4.8, repeat: Infinity, ease: "easeInOut" }} transition={{ duration: 4.8, repeat: Infinity, ease: "easeInOut" }}
style={{ style={{
@@ -194,22 +376,37 @@ const EmptyState = () => {
height={54} height={54}
style={{ style={{
objectFit: "contain", 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> </motion.div>
<Typography variant="h6" color="text.primary" fontWeight={800} gutterBottom> <Typography variant="h6" color="text.primary" fontWeight={800} gutterBottom>
{statusCopy.title}
</Typography> </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> </Typography>
<Grid container spacing={1.5}> {isChecking ? (
{capabilities.map((item) => ( <CircularProgress size={28} thickness={4} aria-label="正在检测 Agent 服务" />
<Grid item xs={6} key={item.label}> ) : !isReady ? (
<motion.div whileHover={{ y: -2, scale: 1.02 }} transition={{ duration: 0.2 }}> <Button
<Stack 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}>
<motion.div whileHover={{ y: -2, scale: 1.02 }} transition={{ duration: 0.2 }}>
<Stack
direction="row" direction="row"
spacing={1} spacing={1}
alignItems="center" alignItems="center"
@@ -234,11 +431,12 @@ const EmptyState = () => {
<Typography variant="caption" fontWeight={700}> <Typography variant="caption" fontWeight={700}>
{item.label} {item.label}
</Typography> </Typography>
</Stack> </Stack>
</motion.div> </motion.div>
</Grid> </Grid>
))} ))}
</Grid> </Grid>
)}
</Paper> </Paper>
</motion.div> </motion.div>
); );
@@ -309,6 +507,8 @@ const SessionLoadingSkeleton = () => (
export const AgentWorkspace = ({ export const AgentWorkspace = ({
messages, messages,
isStreaming, isStreaming,
runtimeState = "ready",
onRetryRuntime,
isLoadingSession = false, isLoadingSession = false,
scrollContainerRef, scrollContainerRef,
bottomRef, bottomRef,
@@ -325,14 +525,23 @@ export const AgentWorkspace = ({
onReplyQuestion, onReplyQuestion,
onRejectQuestion, onRejectQuestion,
}: AgentWorkspaceProps) => { }: AgentWorkspaceProps) => {
const localScrollContainerRef = React.useRef<HTMLDivElement>(null);
const [scrollMetrics, setScrollMetrics] = React.useState({
scrollTop: 0,
viewportHeight: DEFAULT_VIEWPORT_HEIGHT_PX,
});
const streamingMessageId = const streamingMessageId =
isStreaming && messages.at(-1)?.role === "assistant" isStreaming && messages.at(-1)?.role === "assistant"
? messages.at(-1)?.id ?? null ? messages.at(-1)?.id ?? null
: null; : null;
const handleScroll = React.useCallback( const handleScroll = React.useCallback(
(event: React.UIEvent<HTMLDivElement>) => { (event: React.UIEvent<HTMLDivElement>) => {
if (!onScrollStateChange) return;
const target = event.currentTarget; const target = event.currentTarget;
setScrollMetrics({
scrollTop: target.scrollTop,
viewportHeight: target.clientHeight || DEFAULT_VIEWPORT_HEIGHT_PX,
});
if (!onScrollStateChange) return;
const distanceToBottom = const distanceToBottom =
target.scrollHeight - target.scrollTop - target.clientHeight; target.scrollHeight - target.scrollTop - target.clientHeight;
onScrollStateChange( onScrollStateChange(
@@ -343,9 +552,37 @@ export const AgentWorkspace = ({
[isStreaming, onScrollStateChange], [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 ( return (
<Box <Box
ref={scrollContainerRef} ref={setScrollContainer}
onScroll={handleScroll} onScroll={handleScroll}
sx={{ sx={{
flex: 1, flex: 1,
@@ -363,13 +600,30 @@ export const AgentWorkspace = ({
) : ( ) : (
<> <>
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{messages.length === 0 ? <EmptyState /> : null} {messages.length === 0 ? (
<EmptyState
runtimeState={runtimeState}
onRetryRuntime={onRetryRuntime}
/>
) : null}
</AnimatePresence> </AnimatePresence>
{messages.length > 0 ? ( {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 <TurnList
messages={messages} messages={messages}
scrollTop={
messages.length > TURN_WINDOW_THRESHOLD
? scrollMetrics.scrollTop
: 0
}
viewportHeight={scrollMetrics.viewportHeight}
isAssistantStreaming={isStreaming} isAssistantStreaming={isStreaming}
streamingMessageId={streamingMessageId} streamingMessageId={streamingMessageId}
speakingMessageId={speakingMessageId} speakingMessageId={speakingMessageId}
+28 -3
View File
@@ -5,6 +5,7 @@ import { act, render, screen } from "@testing-library/react";
import { GlobalChatbox } from "./GlobalChatbox"; import { GlobalChatbox } from "./GlobalChatbox";
const createSession = jest.fn(); const createSession = jest.fn();
const mockFetchAgentRuntimeHealth = jest.fn();
let mockCurrentProjectId = "project-1"; let mockCurrentProjectId = "project-1";
jest.mock("@refinedev/core", () => ({ jest.mock("@refinedev/core", () => ({
@@ -15,6 +16,11 @@ jest.mock("@/lib/chatModels", () => ({
fetchAgentModels: jest.fn(() => new Promise(() => {})), fetchAgentModels: jest.fn(() => new Promise(() => {})),
})); }));
jest.mock("@/lib/agentRuntime", () => ({
fetchAgentRuntimeHealth: (...args: unknown[]) =>
mockFetchAgentRuntimeHealth(...args),
}));
jest.mock("@/store/projectStore", () => ({ jest.mock("@/store/projectStore", () => ({
useProjectStore: (selector: (state: { currentProjectId: string }) => unknown) => useProjectStore: (selector: (state: { currentProjectId: string }) => unknown) =>
selector({ currentProjectId: mockCurrentProjectId }), selector({ currentProjectId: mockCurrentProjectId }),
@@ -73,12 +79,14 @@ jest.mock("./AgentHistoryPanel", () => ({
})); }));
jest.mock("./AgentWorkspace", () => ({ 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", () => ({ jest.mock("./AgentComposer", () => ({
AgentComposer: React.forwardRef(function MockAgentComposer() { AgentComposer: React.forwardRef(function MockAgentComposer(props: { approvalMode: string }, _ref) {
return <div>Composer</div>; return <div>Composer mode: {props.approvalMode}</div>;
}), }),
})); }));
@@ -90,6 +98,8 @@ describe("GlobalChatbox lifecycle", () => {
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers(); jest.useFakeTimers();
createSession.mockClear(); createSession.mockClear();
mockFetchAgentRuntimeHealth.mockReset();
mockFetchAgentRuntimeHealth.mockImplementation(() => new Promise(() => {}));
mockCurrentProjectId = "project-1"; mockCurrentProjectId = "project-1";
}); });
@@ -121,4 +131,19 @@ describe("GlobalChatbox lifecycle", () => {
expect(createSession).toHaveBeenCalledTimes(2); 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();
});
}); });
+80 -26
View File
@@ -10,6 +10,10 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import { getAccessToken } from "@/lib/authToken"; import { getAccessToken } from "@/lib/authToken";
import {
fetchAgentRuntimeHealth,
type AgentRuntimeState,
} from "@/lib/agentRuntime";
import { fetchAgentModels, type AgentModelOption } from "@/lib/chatModels"; import { fetchAgentModels, type AgentModelOption } from "@/lib/chatModels";
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream"; import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
import { useProjectStore } from "@/store/projectStore"; import { useProjectStore } from "@/store/projectStore";
@@ -26,6 +30,8 @@ import { useAgentToolActions } from "./hooks/useAgentToolActions";
const STREAMING_BOTTOM_RESERVE_PX = 180; const STREAMING_BOTTOM_RESERVE_PX = 180;
const STREAMING_SCROLL_RESTORE_AT_PX = STREAMING_BOTTOM_RESERVE_PX - 36; 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 }) => { export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [width, setWidth] = useState(520); const [width, setWidth] = useState(520);
@@ -34,8 +40,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [isCheckingAuth, setIsCheckingAuth] = useState(false); const [isCheckingAuth, setIsCheckingAuth] = useState(false);
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]); const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState<AgentModel | undefined>(undefined); const [selectedModel, setSelectedModel] = useState<AgentModel | undefined>(undefined);
const [runtimeState, setRuntimeState] = useState<AgentRuntimeState>("checking");
const [approvalMode, setApprovalMode] = const [approvalMode, setApprovalMode] =
useState<AgentApprovalMode>("request"); useState<AgentApprovalMode>("auto");
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const workspaceScrollRef = 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 streamingScrollFrameRef = useRef<number | null>(null);
const composerRef = useRef<AgentComposerHandle | null>(null); const composerRef = useRef<AgentComposerHandle | null>(null);
const initializedProjectIdRef = useRef<string | null | undefined>(undefined); const initializedProjectIdRef = useRef<string | null | undefined>(undefined);
const runtimeRequestIdRef = useRef(0);
const runtimeAbortRef = useRef<AbortController | null>(null);
const theme = useTheme(); const theme = useTheme();
const { open: openNotification } = useNotification(); const { open: openNotification } = useNotification();
const currentProjectId = useProjectStore((state) => state.currentProjectId); const currentProjectId = useProjectStore((state) => state.currentProjectId);
@@ -68,35 +77,76 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
isSupported: isSttSupported, isSupported: isSttSupported,
} = useSpeechRecognition(handleSpeechResult); } = useSpeechRecognition(handleSpeechResult);
useEffect(() => { const refreshAgentRuntime = useCallback(async (showChecking = true) => {
let cancelled = false; 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 () => { if (showChecking) setRuntimeState("checking");
try {
const modelConfig = await fetchAgentModels(); try {
if (cancelled) return; runtimeHealthy = await fetchAgentRuntimeHealth(controller.signal);
setModelOptions(modelConfig.models); if (requestId !== runtimeRequestIdRef.current) return;
setSelectedModel((current) => { if (!runtimeHealthy) {
if (current && modelConfig.models.some((model) => model.id === current)) { setRuntimeState("unavailable");
return current; setModelOptions([]);
} setSelectedModel(undefined);
return modelConfig.defaultModel; return;
});
} catch (error) {
console.error("[GlobalChatbox] Failed to load agent models:", error);
if (!cancelled) {
setModelOptions([]);
setSelectedModel(undefined);
}
} }
};
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 () => { return () => {
cancelled = true; window.clearInterval(intervalId);
runtimeRequestIdRef.current += 1;
runtimeAbortRef.current?.abort();
runtimeAbortRef.current = null;
}; };
}, []); }, [open, refreshAgentRuntime]);
const handleToolCall = useAgentToolActions(); const handleToolCall = useAgentToolActions();
const { const {
@@ -203,7 +253,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
}, [createSession, currentProjectId, isHydrating, open, resetConversationView]); }, [createSession, currentProjectId, isHydrating, open, resetConversationView]);
const handleSend = useCallback(async (prompt: string) => { const handleSend = useCallback(async (prompt: string) => {
if (isStreaming || isCheckingAuth) return; if (isStreaming || isCheckingAuth || runtimeState !== "ready") return;
setIsCheckingAuth(true); setIsCheckingAuth(true);
try { try {
@@ -229,7 +279,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
} finally { } finally {
setIsCheckingAuth(false); setIsCheckingAuth(false);
} }
}, [isCheckingAuth, isStreaming, openNotification, sendPrompt]); }, [isCheckingAuth, isStreaming, openNotification, runtimeState, sendPrompt]);
const handleNewConversation = useCallback(() => { const handleNewConversation = useCallback(() => {
handleStopSpeech(); handleStopSpeech();
@@ -373,6 +423,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
canRenameSessionTitle={Boolean(activeSessionId)} canRenameSessionTitle={Boolean(activeSessionId)}
isHydrating={isHydrating} isHydrating={isHydrating}
isStreaming={isStreaming} isStreaming={isStreaming}
runtimeState={runtimeState}
isHistoryOpen={isHistoryOpen} isHistoryOpen={isHistoryOpen}
onHistoryToggle={handleHistoryToggle} onHistoryToggle={handleHistoryToggle}
onRenameSessionTitle={handleRenameActiveSession} onRenameSessionTitle={handleRenameActiveSession}
@@ -430,6 +481,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
<AgentWorkspace <AgentWorkspace
messages={messages} messages={messages}
isStreaming={isStreaming} isStreaming={isStreaming}
runtimeState={runtimeState}
onRetryRuntime={() => void refreshAgentRuntime(true)}
isLoadingSession={Boolean(loadingSessionId)} isLoadingSession={Boolean(loadingSessionId)}
scrollContainerRef={workspaceScrollRef} scrollContainerRef={workspaceScrollRef}
bottomRef={bottomRef} bottomRef={bottomRef}
@@ -450,6 +503,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
<AgentComposer <AgentComposer
ref={composerRef} ref={composerRef}
isHydrating={isHydrating || isCheckingAuth} isHydrating={isHydrating || isCheckingAuth}
runtimeState={runtimeState}
isStreaming={isStreaming} isStreaming={isStreaming}
isListening={isListening} isListening={isListening}
isSttSupported={isSttSupported} isSttSupported={isSttSupported}
@@ -13,7 +13,7 @@ import {
resumeAgentChatStream, resumeAgentChatStream,
streamAgentChat, streamAgentChat,
} from "@/lib/chatStream"; } from "@/lib/chatStream";
import type { PermissionReply, StreamEvent } from "@/lib/chatStream"; import type { PermissionDecision, StreamEvent } from "@/lib/chatStream";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
import type { import type {
AgentArtifact, AgentArtifact,
@@ -799,7 +799,7 @@ export const useAgentChatSession = ({
}, [flushPendingTokens, getLastAssistantMessageId]); }, [flushPendingTokens, getLastAssistantMessageId]);
const replyPermission = useCallback( const replyPermission = useCallback(
async (requestId: string, reply: PermissionReply) => { async (requestId: string, reply: PermissionDecision) => {
const target = messagesRef.current const target = messagesRef.current
.flatMap((message) => message.permissions ?? []) .flatMap((message) => message.permissions ?? [])
.find((permission) => permission.requestId === requestId); .find((permission) => permission.requestId === requestId);
+5 -2
View File
@@ -899,8 +899,11 @@ export interface operations {
"application/json": { "application/json": {
message: string; message: string;
model?: 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`, { const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/models`, {
method: "GET", method: "GET",
signal,
projectHeaderMode: "include", projectHeaderMode: "include",
skipAuthRedirect: true, skipAuthRedirect: true,
}); });
+14
View File
@@ -390,6 +390,7 @@ describe("streamAgentChat", () => {
skipAuthRedirect: true, skipAuthRedirect: true,
}), }),
); );
}); });
it("calls permission reply endpoint", async () => { 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 () => { 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 AgentModel = string;
export type PermissionReply = "once" | "always" | "reject"; export type PermissionDecision = "once" | "always" | "reject";
export type AgentApprovalMode = "request" | "always"; export type PermissionReply = PermissionDecision;
export type AgentApprovalMode = "request" | "auto" | "always";
export type AgentQuestionStatus = export type AgentQuestionStatus =
| "pending" | "pending"
@@ -664,7 +665,7 @@ export const abortAgentChat = async (sessionId?: string) => {
export const replyAgentPermission = async ( export const replyAgentPermission = async (
sessionId: string, sessionId: string,
requestId: string, requestId: string,
reply: PermissionReply, reply: PermissionDecision,
) => { ) => {
const response = await apiFetch( const response = await apiFetch(
getAgentSessionUrl(sessionId, "/permission-responses"), getAgentSessionUrl(sessionId, "/permission-responses"),