feat(chat): load model options from backend
This commit is contained in:
@@ -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}>
|
||||||
<Box>
|
{renderModelIcon(model.icon, {
|
||||||
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>快速</Typography>
|
className: "icon",
|
||||||
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>快速回答和任务执行</Typography>
|
sx: { mr: 1.5, mt: 0.2, fontSize: model.icon === "bolt" ? 20 : 18, color: "text.secondary", transition: "color 0.2s" },
|
||||||
</Box>
|
})}
|
||||||
</MenuItem>
|
<Box>
|
||||||
<MenuItem value="deepseek/deepseek-v4-pro">
|
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>{model.label}</Typography>
|
||||||
<AutoAwesomeRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 18, color: "text.secondary", transition: "color 0.2s" }} />
|
{model.description ? (
|
||||||
<Box>
|
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>{model.description}</Typography>
|
||||||
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>专家</Typography>
|
) : null}
|
||||||
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>探索、解决复杂任务</Typography>
|
</Box>
|
||||||
</Box>
|
</MenuItem>
|
||||||
</MenuItem>
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,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";
|
||||||
|
|||||||
Reference in New Issue
Block a user