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 VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded";
import type { AgentModelOption } from "@/lib/chatModels";
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
export type AgentComposerHandle = {
@@ -48,12 +49,23 @@ type AgentComposerProps = {
onAbort: () => void;
onStartListening: () => void;
onStopListening: () => void;
selectedModel: AgentModel;
modelOptions: AgentModelOption[];
selectedModel?: AgentModel;
onModelChange: (model: AgentModel) => void;
approvalMode: AgentApprovalMode;
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({
isHydrating = false,
isStreaming,
@@ -64,6 +76,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
onAbort,
onStartListening,
onStopListening,
modelOptions,
selectedModel,
onModelChange,
approvalMode,
@@ -74,6 +87,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
const [input, setInput] = React.useState("");
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
const selectedModelOption = modelOptions.find((model) => model.id === selectedModel);
React.useImperativeHandle(
ref,
@@ -347,19 +361,21 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
<Stack direction="row" spacing={1} alignItems="center">
<FormControl size="small" sx={{ minWidth: 80 }}>
<Select
value={selectedModel}
value={selectedModel ?? ""}
onChange={(event) => onModelChange(event.target.value as AgentModel)}
disabled={isHydrating || isStreaming}
disabled={isHydrating || isStreaming || modelOptions.length === 0}
aria-label="模型选择"
renderValue={(val) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{val === "deepseek/deepseek-v4-flash" ? (
<BoltRounded sx={{ fontSize: 18, color: "inherit", transition: "color 0.2s" }} />
) : (
<AutoAwesomeRounded sx={{ fontSize: 16, color: "inherit", transition: "color 0.2s" }} />
)}
renderValue={() => (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{renderModelIcon(selectedModelOption?.icon, {
sx: {
fontSize: selectedModelOption?.icon === "bolt" ? 18 : 16,
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>
</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
component="img"
src="/deepseek-logo.svg"
alt="DeepSeek"
sx={{ width: 16, height: 16, display: "block", flexShrink: 0 }}
/>
<AutoAwesomeRounded sx={{ width: 16, height: 16, color: "text.secondary", flexShrink: 0 }} />
<Typography sx={{ fontSize: "0.75rem", fontWeight: 700, color: "text.secondary", letterSpacing: 0.5 }}>
DEEPSEEK V4
</Typography>
</Box>
<MenuItem value="deepseek/deepseek-v4-flash">
<BoltRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 20, color: "text.secondary", transition: "color 0.2s" }} />
{modelOptions.map((model) => (
<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>
<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>
</MenuItem>
<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>
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>{model.label}</Typography>
{model.description ? (
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>{model.description}</Typography>
) : null}
</Box>
</MenuItem>
))}
</Select>
</FormControl>
+34 -3
View File
@@ -10,6 +10,7 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material";
import { useNotification } from "@refinedev/core";
import { getAccessToken } from "@/lib/authToken";
import { fetchAgentModels, type AgentModelOption } from "@/lib/chatModels";
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
import { useProjectStore } from "@/store/projectStore";
import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
@@ -28,9 +29,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [isResizing, setIsResizing] = useState(false);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
const [selectedModel, setSelectedModel] = useState<AgentModel>(
"deepseek/deepseek-v4-flash",
);
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState<AgentModel | undefined>(undefined);
const [approvalMode, setApprovalMode] =
useState<AgentApprovalMode>("request");
@@ -62,6 +62,36 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
isSupported: isSttSupported,
} = 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 {
messages,
@@ -372,6 +402,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
onAbort={abort}
onStartListening={startListening}
onStopListening={stopListening}
modelOptions={modelOptions}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
approvalMode={approvalMode}
@@ -11,7 +11,7 @@ export type UseAgentChatSessionOptions = {
},
) => void;
onBeforeSend?: () => void;
getModel?: () => AgentModel;
getModel?: () => AgentModel | undefined;
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({
message: "hi",
model: "deepseek/deepseek-v4-flash",
model: "provider/model",
onEvent: (event) => events.push(event),
});
@@ -73,7 +73,7 @@ describe("streamAgentChat", () => {
body: JSON.stringify({
message: "hi",
session_id: undefined,
model: "deepseek/deepseek-v4-flash",
model: "provider/model",
approval_mode: undefined,
}),
}),
+1 -3
View File
@@ -1,9 +1,7 @@
import { apiFetch } from "@/lib/apiFetch";
import { config } from "@config/config";
export type AgentModel =
| "deepseek/deepseek-v4-flash"
| "deepseek/deepseek-v4-pro";
export type AgentModel = string;
export type PermissionReply = "once" | "always" | "reject";
export type AgentApprovalMode = "request" | "always";