合并 agent-mvp 到 master #1
@@ -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": [
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) }}
|
||||||
|
|||||||
@@ -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)}` },
|
||||||
|
|||||||
@@ -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) },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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`, {
|
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/models`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
signal,
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user