From 7d2ae87e39d794e68163a33134121f9f88ca9cc0 Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 10 Jun 2026 19:50:43 +0800 Subject: [PATCH] feat(chat): load model options from backend --- src/components/chat/AgentComposer.tsx | 75 +++++++++++-------- src/components/chat/GlobalChatbox.tsx | 37 ++++++++- .../chat/hooks/useAgentChatSession.types.ts | 2 +- src/lib/chatModels.test.ts | 65 ++++++++++++++++ src/lib/chatModels.ts | 74 ++++++++++++++++++ src/lib/chatStream.test.ts | 4 +- src/lib/chatStream.ts | 4 +- 7 files changed, 220 insertions(+), 41 deletions(-) create mode 100644 src/lib/chatModels.test.ts create mode 100644 src/lib/chatModels.ts diff --git a/src/components/chat/AgentComposer.tsx b/src/components/chat/AgentComposer.tsx index 40d4c33..d40a5c1 100644 --- a/src/components/chat/AgentComposer.tsx +++ b/src/components/chat/AgentComposer.tsx @@ -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, +) => + icon === "bolt" ? ( + + ) : ( + + ); + export const AgentComposer = React.forwardRef(function AgentComposer({ isHydrating = false, isStreaming, @@ -64,6 +76,7 @@ export const AgentComposer = React.forwardRef 0 && !isStreaming && !isHydrating; + const selectedModelOption = modelOptions.find((model) => model.id === selectedModel); React.useImperativeHandle( ref, @@ -347,19 +361,21 @@ export const AgentComposer = React.forwardRef diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index a6b0e8d..e0bfd11 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -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 = ({ open, onClose }) => { const [isResizing, setIsResizing] = useState(false); const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [isCheckingAuth, setIsCheckingAuth] = useState(false); - const [selectedModel, setSelectedModel] = useState( - "deepseek/deepseek-v4-flash", - ); + const [modelOptions, setModelOptions] = useState([]); + const [selectedModel, setSelectedModel] = useState(undefined); const [approvalMode, setApprovalMode] = useState("request"); @@ -62,6 +62,36 @@ export const GlobalChatbox: React.FC = ({ 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 = ({ open, onClose }) => { onAbort={abort} onStartListening={startListening} onStopListening={stopListening} + modelOptions={modelOptions} selectedModel={selectedModel} onModelChange={setSelectedModel} approvalMode={approvalMode} diff --git a/src/components/chat/hooks/useAgentChatSession.types.ts b/src/components/chat/hooks/useAgentChatSession.types.ts index 5478f7d..68fef56 100644 --- a/src/components/chat/hooks/useAgentChatSession.types.ts +++ b/src/components/chat/hooks/useAgentChatSession.types.ts @@ -11,7 +11,7 @@ export type UseAgentChatSessionOptions = { }, ) => void; onBeforeSend?: () => void; - getModel?: () => AgentModel; + getModel?: () => AgentModel | undefined; getApprovalMode?: () => AgentApprovalMode; }; diff --git a/src/lib/chatModels.test.ts b/src/lib/chatModels.test.ts new file mode 100644 index 0000000..17b33e1 --- /dev/null +++ b/src/lib/chatModels.test.ts @@ -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" }], + }); + }); +}); diff --git a/src/lib/chatModels.ts b/src/lib/chatModels.ts new file mode 100644 index 0000000..9c9166f --- /dev/null +++ b/src/lib/chatModels.ts @@ -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 => + 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 => { + 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, + }; +}; diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index d4e32d7..56d2709 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -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, }), }), diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index f1fc494..7b763f8 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -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";