合并 agent-mvp 到 master #1
@@ -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": [
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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)}` },
|
||||
|
||||
@@ -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) },
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user