feat(chat): load model options from backend
Build Push and Deploy / docker-image (push) Successful in 1m6s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped

This commit is contained in:
2026-06-10 19:50:43 +08:00
parent 1e872ca873
commit 7d2ae87e39
7 changed files with 220 additions and 41 deletions
+40 -29
View File
@@ -28,6 +28,7 @@ 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 type { AgentModelOption } from "@/lib/chatModels";
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream"; import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
export type AgentComposerHandle = { export type AgentComposerHandle = {
@@ -48,12 +49,23 @@ type AgentComposerProps = {
onAbort: () => void; onAbort: () => void;
onStartListening: () => void; onStartListening: () => void;
onStopListening: () => void; onStopListening: () => void;
selectedModel: AgentModel; modelOptions: AgentModelOption[];
selectedModel?: AgentModel;
onModelChange: (model: AgentModel) => void; onModelChange: (model: AgentModel) => void;
approvalMode: AgentApprovalMode; approvalMode: AgentApprovalMode;
onApprovalModeChange: (mode: AgentApprovalMode) => void; onApprovalModeChange: (mode: AgentApprovalMode) => void;
}; };
const renderModelIcon = (
icon: AgentModelOption["icon"] | undefined,
props?: React.ComponentProps<typeof BoltRounded>,
) =>
icon === "bolt" ? (
<BoltRounded {...props} />
) : (
<AutoAwesomeRounded {...props} />
);
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
isHydrating = false, isHydrating = false,
isStreaming, isStreaming,
@@ -64,6 +76,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
onAbort, onAbort,
onStartListening, onStartListening,
onStopListening, onStopListening,
modelOptions,
selectedModel, selectedModel,
onModelChange, onModelChange,
approvalMode, approvalMode,
@@ -74,6 +87,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
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 canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
const selectedModelOption = modelOptions.find((model) => model.id === selectedModel);
React.useImperativeHandle( React.useImperativeHandle(
ref, ref,
@@ -347,19 +361,21 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
<FormControl size="small" sx={{ minWidth: 80 }}> <FormControl size="small" sx={{ minWidth: 80 }}>
<Select <Select
value={selectedModel} value={selectedModel ?? ""}
onChange={(event) => onModelChange(event.target.value as AgentModel)} onChange={(event) => onModelChange(event.target.value as AgentModel)}
disabled={isHydrating || isStreaming} disabled={isHydrating || isStreaming || modelOptions.length === 0}
aria-label="模型选择" aria-label="模型选择"
renderValue={(val) => ( renderValue={() => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{val === "deepseek/deepseek-v4-flash" ? ( {renderModelIcon(selectedModelOption?.icon, {
<BoltRounded sx={{ fontSize: 18, color: "inherit", transition: "color 0.2s" }} /> sx: {
) : ( fontSize: selectedModelOption?.icon === "bolt" ? 18 : 16,
<AutoAwesomeRounded sx={{ fontSize: 16, color: "inherit", transition: "color 0.2s" }} /> color: "inherit",
)} transition: "color 0.2s",
},
})}
<Typography sx={{ fontSize: "0.8rem", fontWeight: 600, color: "inherit", transition: "color 0.2s" }}> <Typography sx={{ fontSize: "0.8rem", fontWeight: 600, color: "inherit", transition: "color 0.2s" }}>
{val === "deepseek/deepseek-v4-flash" ? "快速" : "专家"} {selectedModelOption?.label ?? "模型"}
</Typography> </Typography>
</Box> </Box>
)} )}
@@ -433,30 +449,25 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
}} }}
> >
<Box sx={{ px: 2, py: 1.5, pb: 1, display: "flex", alignItems: "center", gap: 1, pointerEvents: "none" }}> <Box sx={{ px: 2, py: 1.5, pb: 1, display: "flex", alignItems: "center", gap: 1, pointerEvents: "none" }}>
<Box <AutoAwesomeRounded sx={{ width: 16, height: 16, color: "text.secondary", flexShrink: 0 }} />
component="img"
src="/deepseek-logo.svg"
alt="DeepSeek"
sx={{ width: 16, height: 16, display: "block", flexShrink: 0 }}
/>
<Typography sx={{ fontSize: "0.75rem", fontWeight: 700, color: "text.secondary", letterSpacing: 0.5 }}> <Typography sx={{ fontSize: "0.75rem", fontWeight: 700, color: "text.secondary", letterSpacing: 0.5 }}>
DEEPSEEK V4
</Typography> </Typography>
</Box> </Box>
<MenuItem value="deepseek/deepseek-v4-flash"> {modelOptions.map((model) => (
<BoltRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 20, color: "text.secondary", transition: "color 0.2s" }} /> <MenuItem key={model.id} value={model.id}>
{renderModelIcon(model.icon, {
className: "icon",
sx: { mr: 1.5, mt: 0.2, fontSize: model.icon === "bolt" ? 20 : 18, color: "text.secondary", transition: "color 0.2s" },
})}
<Box> <Box>
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}></Typography> <Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>{model.label}</Typography>
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}></Typography> {model.description ? (
</Box> <Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>{model.description}</Typography>
</MenuItem> ) : null}
<MenuItem value="deepseek/deepseek-v4-pro">
<AutoAwesomeRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 18, color: "text.secondary", transition: "color 0.2s" }} />
<Box>
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}></Typography>
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}></Typography>
</Box> </Box>
</MenuItem> </MenuItem>
))}
</Select> </Select>
</FormControl> </FormControl>
+34 -3
View File
@@ -10,6 +10,7 @@ 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 { 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";
import { AgentComposer, type AgentComposerHandle } from "./AgentComposer"; import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
@@ -28,9 +29,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [isCheckingAuth, setIsCheckingAuth] = useState(false); const [isCheckingAuth, setIsCheckingAuth] = useState(false);
const [selectedModel, setSelectedModel] = useState<AgentModel>( const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
"deepseek/deepseek-v4-flash", const [selectedModel, setSelectedModel] = useState<AgentModel | undefined>(undefined);
);
const [approvalMode, setApprovalMode] = const [approvalMode, setApprovalMode] =
useState<AgentApprovalMode>("request"); useState<AgentApprovalMode>("request");
@@ -62,6 +62,36 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
isSupported: isSttSupported, isSupported: isSttSupported,
} = useSpeechRecognition(handleSpeechResult); } = useSpeechRecognition(handleSpeechResult);
useEffect(() => {
let cancelled = false;
const loadModels = async () => {
try {
const modelConfig = await fetchAgentModels();
if (cancelled) return;
setModelOptions(modelConfig.models);
setSelectedModel((current) => {
if (current && modelConfig.models.some((model) => model.id === current)) {
return current;
}
return modelConfig.defaultModel;
});
} catch (error) {
console.error("[GlobalChatbox] Failed to load agent models:", error);
if (!cancelled) {
setModelOptions([]);
setSelectedModel(undefined);
}
}
};
void loadModels();
return () => {
cancelled = true;
};
}, []);
const handleToolCall = useAgentToolActions(); const handleToolCall = useAgentToolActions();
const { const {
messages, messages,
@@ -372,6 +402,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
onAbort={abort} onAbort={abort}
onStartListening={startListening} onStartListening={startListening}
onStopListening={stopListening} onStopListening={stopListening}
modelOptions={modelOptions}
selectedModel={selectedModel} selectedModel={selectedModel}
onModelChange={setSelectedModel} onModelChange={setSelectedModel}
approvalMode={approvalMode} approvalMode={approvalMode}
@@ -11,7 +11,7 @@ export type UseAgentChatSessionOptions = {
}, },
) => void; ) => void;
onBeforeSend?: () => void; onBeforeSend?: () => void;
getModel?: () => AgentModel; getModel?: () => AgentModel | undefined;
getApprovalMode?: () => AgentApprovalMode; getApprovalMode?: () => AgentApprovalMode;
}; };
+65
View File
@@ -0,0 +1,65 @@
import { fetchAgentModels } from "./chatModels";
const apiFetch = jest.fn();
jest.mock("@/lib/apiFetch", () => ({
apiFetch: (...args: unknown[]) => apiFetch(...args),
}));
describe("fetchAgentModels", () => {
beforeEach(() => {
apiFetch.mockReset();
});
it("loads model options and backend default model", async () => {
apiFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
default_model: "deepseek/deepseek-v4-flash",
models: [
{
id: "deepseek/deepseek-v4-flash",
label: "快速",
description: "快速回答和任务执行",
icon: "bolt",
},
],
}),
});
await expect(fetchAgentModels()).resolves.toEqual({
defaultModel: "deepseek/deepseek-v4-flash",
models: [
{
id: "deepseek/deepseek-v4-flash",
label: "快速",
description: "快速回答和任务执行",
icon: "bolt",
},
],
});
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/models"),
expect.objectContaining({
method: "GET",
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
}),
);
});
it("falls back to the first option when default model is omitted", async () => {
apiFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
models: [{ id: "provider/model", label: "Model" }],
}),
});
await expect(fetchAgentModels()).resolves.toEqual({
defaultModel: "provider/model",
models: [{ id: "provider/model", label: "Model" }],
});
});
});
+74
View File
@@ -0,0 +1,74 @@
import { apiFetch } from "@/lib/apiFetch";
import { config } from "@config/config";
import type { AgentModel } from "./chatStream";
export type AgentModelIcon = "bolt" | "sparkle";
export type AgentModelOption = {
id: AgentModel;
label: string;
description?: string;
icon?: AgentModelIcon;
};
export type AgentModelConfig = {
defaultModel?: AgentModel;
models: AgentModelOption[];
};
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const normalizeModelOption = (value: unknown): AgentModelOption | null => {
if (!isObjectRecord(value) || typeof value.id !== "string") {
return null;
}
const id = value.id.trim();
if (!id) {
return null;
}
const label =
typeof value.label === "string" && value.label.trim()
? value.label.trim()
: id;
const description =
typeof value.description === "string" && value.description.trim()
? value.description.trim()
: undefined;
const icon =
value.icon === "bolt" || value.icon === "sparkle" ? value.icon : undefined;
return {
id,
label,
description,
icon,
};
};
export const fetchAgentModels = async (): Promise<AgentModelConfig> => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/models`, {
method: "GET",
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
});
if (!response.ok) {
throw new Error(await response.text());
}
const payload = (await response.json()) as {
default_model?: unknown;
models?: unknown[];
};
const models = (payload.models ?? [])
.map(normalizeModelOption)
.filter((model): model is AgentModelOption => Boolean(model));
const defaultModel =
typeof payload.default_model === "string" && payload.default_model.trim()
? payload.default_model.trim()
: models[0]?.id;
return {
defaultModel,
models,
};
};
+2 -2
View File
@@ -60,7 +60,7 @@ describe("streamAgentChat", () => {
await streamAgentChat({ await streamAgentChat({
message: "hi", message: "hi",
model: "deepseek/deepseek-v4-flash", model: "provider/model",
onEvent: (event) => events.push(event), onEvent: (event) => events.push(event),
}); });
@@ -73,7 +73,7 @@ describe("streamAgentChat", () => {
body: JSON.stringify({ body: JSON.stringify({
message: "hi", message: "hi",
session_id: undefined, session_id: undefined,
model: "deepseek/deepseek-v4-flash", model: "provider/model",
approval_mode: undefined, approval_mode: undefined,
}), }),
}), }),
+1 -3
View File
@@ -1,9 +1,7 @@
import { apiFetch } from "@/lib/apiFetch"; import { apiFetch } from "@/lib/apiFetch";
import { config } from "@config/config"; import { config } from "@config/config";
export type AgentModel = export type AgentModel = string;
| "deepseek/deepseek-v4-flash"
| "deepseek/deepseek-v4-pro";
export type PermissionReply = "once" | "always" | "reject"; export type PermissionReply = "once" | "always" | "reject";
export type AgentApprovalMode = "request" | "always"; export type AgentApprovalMode = "request" | "always";