From 507f9709e99122ceed06b8f4ec134dc200f36c7b Mon Sep 17 00:00:00 2001 From: DigHuang <114602213+DigHuang@users.noreply.github.com> Date: Fri, 14 Nov 2025 09:54:11 +0800 Subject: [PATCH 1/7] chore: clean log --- frontend/src-tauri/src/backend.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/frontend/src-tauri/src/backend.rs b/frontend/src-tauri/src/backend.rs index d2b31d242..b4a26dc6e 100644 --- a/frontend/src-tauri/src/backend.rs +++ b/frontend/src-tauri/src/backend.rs @@ -58,7 +58,6 @@ impl BackendManager { self.env_path, module_name ); - log::info!("Working directory: {:?}", self.backend_path); // Use sidecar command directly (Tauri handles platform automatically) let sidecar_command = self @@ -233,9 +232,6 @@ impl BackendManager { processes.len() ); - // Note: CommandChild doesn't have try_wait, so we just log the count - log::info!("Processes started: {}", processes.len()); - Ok(()) } From 2809884c31b0675a39142348bfa2f94da12a8b24 Mon Sep 17 00:00:00 2001 From: DigHuang <114602213+DigHuang@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:36:51 +0800 Subject: [PATCH 2/7] feat: add model provider api --- frontend/src/api/setting.ts | 120 ++++++++++- frontend/src/app/setting/_layout.tsx | 8 +- frontend/src/app/setting/models.tsx | 299 +++++++++++++++++++++++++++ frontend/src/constants/api.ts | 2 + frontend/src/routes.ts | 1 + frontend/src/types/setting.ts | 17 ++ 6 files changed, 445 insertions(+), 2 deletions(-) create mode 100644 frontend/src/app/setting/models.tsx diff --git a/frontend/src/api/setting.ts b/frontend/src/api/setting.ts index 23bb02c2d..ad41b32f1 100644 --- a/frontend/src/api/setting.ts +++ b/frontend/src/api/setting.ts @@ -2,7 +2,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { API_QUERY_KEYS } from "@/constants/api"; import type { ApiResponse } from "@/lib/api-client"; import { apiClient } from "@/lib/api-client"; -import type { MemoryItem } from "@/types/setting"; +import type { + MemoryItem, + ModelProvider, + ProviderDetail, + ProviderModelInfo, +} from "@/types/setting"; export const useGetMemoryList = () => { return useQuery({ @@ -26,3 +31,116 @@ export const useRemoveMemory = () => { }, }); }; + +export const useGetModelProviders = () => { + return useQuery({ + queryKey: API_QUERY_KEYS.SETTING.modelProviders, + queryFn: () => + apiClient.get< + ApiResponse<{ + providers: ModelProvider[]; + }> + >("/models/providers"), + select: (resp) => resp.data.providers, + }); +}; + +export const useGetModelProviderDetail = (provider: string | undefined) => { + return useQuery({ + enabled: !!provider, + queryKey: API_QUERY_KEYS.SETTING.modelProviderDetail( + provider ? [provider] : [], + ), + queryFn: () => + apiClient.get>( + `/models/providers/${provider}`, + ), + select: (resp) => resp.data, + }); +}; + +export const useUpdateProviderConfig = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: { + provider: string; + api_key?: string; + base_url?: string; + }) => + apiClient.put>( + `/models/providers/${params.provider}/config`, + { + api_key: params.api_key, + base_url: params.base_url, + }, + ), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: API_QUERY_KEYS.SETTING.modelProviderDetail([ + variables.provider, + ]), + }); + }, + }); +}; + +export const useAddProviderModel = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: { + provider: string; + model_id: string; + model_name: string; + }) => + apiClient.post>( + `/models/providers/${params.provider}/models`, + { + model_id: params.model_id, + model_name: params.model_name, + }, + ), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: API_QUERY_KEYS.SETTING.modelProviderDetail([ + variables.provider, + ]), + }); + }, + }); +}; + +export const useDeleteProviderModel = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: { provider: string; model_id: string }) => + apiClient.delete>( + `/models/providers/${params.provider}/models?model_id=${encodeURIComponent(params.model_id)}`, + ), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: API_QUERY_KEYS.SETTING.modelProviderDetail([ + variables.provider, + ]), + }); + }, + }); +}; + +export const useSetDefaultProvider = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: { provider: string }) => + apiClient.put>("/models/providers/default", { + provider: params.provider, + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: API_QUERY_KEYS.SETTING.modelProviders, + }); + }, + }); +}; diff --git a/frontend/src/app/setting/_layout.tsx b/frontend/src/app/setting/_layout.tsx index 12a034779..cf86b7317 100644 --- a/frontend/src/app/setting/_layout.tsx +++ b/frontend/src/app/setting/_layout.tsx @@ -1,4 +1,4 @@ -import { Brain, Settings } from "lucide-react"; +import { Brain, Cpu, Settings } from "lucide-react"; import { NavLink, Outlet, useLocation } from "react-router"; import { Item, @@ -22,6 +22,12 @@ const settingNavItems = [ label: "Memory", path: "/setting/memory", }, + { + id: "models", + icon: Cpu, + label: "Models", + path: "/setting/models", + }, // { // id: "language", // icon: Globe, diff --git a/frontend/src/app/setting/models.tsx b/frontend/src/app/setting/models.tsx new file mode 100644 index 000000000..25b2bda0a --- /dev/null +++ b/frontend/src/app/setting/models.tsx @@ -0,0 +1,299 @@ +import { useState } from "react"; + +import { + useAddProviderModel, + useDeleteProviderModel, + useGetModelProviderDetail, + useGetModelProviders, + useSetDefaultProvider, + useUpdateProviderConfig, +} from "@/api/setting"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; + +export default function ModelsSettingPage() { + const { data: providers = [], isLoading: providersLoading } = + useGetModelProviders(); + const [selectedProvider, setSelectedProvider] = useState( + undefined, + ); + + const currentProvider = + selectedProvider || (providers.length > 0 ? providers[0]?.provider : ""); + + const { data: providerDetail, isLoading: detailLoading } = + useGetModelProviderDetail(currentProvider); + + const { mutate: updateConfig, isPending: updatingConfig } = + useUpdateProviderConfig(); + const { mutate: addModel, isPending: addingModel } = useAddProviderModel(); + const { mutate: deleteModel, isPending: deletingModel } = + useDeleteProviderModel(); + const { mutate: setDefault, isPending: settingDefault } = + useSetDefaultProvider(); + + const [apiKeyInput, setApiKeyInput] = useState(""); + const [baseUrlInput, setBaseUrlInput] = useState(""); + const [newModelId, setNewModelId] = useState(""); + const [newModelName, setNewModelName] = useState(""); + + const handleSaveConfig = () => { + if (!currentProvider) return; + + updateConfig({ + provider: currentProvider, + api_key: apiKeyInput || providerDetail?.api_key, + base_url: baseUrlInput || providerDetail?.base_url, + }); + }; + + const handleAddModel = () => { + if (!currentProvider || !newModelId || !newModelName) return; + + addModel({ + provider: currentProvider, + model_id: newModelId, + model_name: newModelName, + }); + setNewModelId(""); + setNewModelName(""); + }; + + const handleDeleteModel = (modelId: string) => { + if (!currentProvider) return; + deleteModel({ provider: currentProvider, model_id: modelId }); + }; + + const handleSetDefaultProvider = () => { + if (!currentProvider) return; + setDefault({ provider: currentProvider }); + }; + + const isBusy = + updatingConfig || addingModel || deletingModel || settingDefault; + + return ( +
+
+

Model Providers

+

+ Manage your LLM providers, API keys and available models. +

+
+ +
+ {/* Provider list */} +
+
+ Providers +
+ + {providersLoading ? ( +
Loading providers...
+ ) : providers.length === 0 ? ( +
No providers found.
+ ) : ( +
+ {providers.map((p) => { + const isActive = currentProvider === p.provider; + return ( + + ); + })} +
+ )} +
+ + {/* Provider detail */} +
+ {detailLoading && ( +
+ Loading provider details... +
+ )} + + {providerDetail && ( + <> +
+
+
+ {currentProvider} +
+
+ Default model: {providerDetail.default_model_id} +
+
+ + +
+ +
+
+ + setApiKeyInput(e.target.value)} + /> +
+ +
+ + setBaseUrlInput(e.target.value)} + /> +
+ + +
+ +
+
+
+ Models +
+ +
+ setNewModelId(e.target.value)} + className="h-8 w-48" + /> + setNewModelName(e.target.value)} + className="h-8 w-48" + /> + +
+
+ + {providerDetail.models.length === 0 ? ( +
+ No models configured for this provider. +
+ ) : ( +
+ {providerDetail.models.map((m) => ( +
+
+ + {m.model_name} + + + {m.model_id} + +
+ +
+ + + +
+
+ ))} +
+ )} +
+ + )} +
+
+
+ ); +} diff --git a/frontend/src/constants/api.ts b/frontend/src/constants/api.ts index f3ce1e0e9..89a1be562 100644 --- a/frontend/src/constants/api.ts +++ b/frontend/src/constants/api.ts @@ -28,6 +28,8 @@ export const CONVERSATION_QUERY_KEYS = { export const SETTING_QUERY_KEYS = { memoryList: ["memory"], + modelProviders: ["model", "providers"], + modelProviderDetail: queryKeyFn(["model", "detail"]), } as const; const STRATEGY_QUERY_KEYS = { diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index b02ebbcaa..04b4680ea 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -27,6 +27,7 @@ export default [ layout("app/setting/_layout.tsx", [ index("app/setting/general.tsx"), route("/memory", "app/setting/memory.tsx"), + route("/models", "app/setting/models.tsx"), ]), ]), diff --git a/frontend/src/types/setting.ts b/frontend/src/types/setting.ts index 9118e4548..f07bb8456 100644 --- a/frontend/src/types/setting.ts +++ b/frontend/src/types/setting.ts @@ -2,3 +2,20 @@ export type MemoryItem = { id: number; content: string; }; + +export type ModelProvider = { + provider: string; +}; + +export type ProviderModelInfo = { + model_id: string; + model_name: string; +}; + +export type ProviderDetail = { + api_key: string; + base_url: string; + is_default: boolean; + default_model_id: string; + models: ProviderModelInfo[]; +}; From 0f3884b762b9ac8f553ae288e7f2a20c5dd9cb71 Mon Sep 17 00:00:00 2001 From: DigHuang <114602213+DigHuang@users.noreply.github.com> Date: Tue, 18 Nov 2025 11:05:57 +0800 Subject: [PATCH 3/7] feat: frontend support config model info --- frontend/bun.lock | 3 + frontend/package.json | 1 + frontend/src/api/setting.ts | 34 +- frontend/src/app/setting/_layout.tsx | 8 +- .../setting/components/{ => memory}/index.tsx | 0 .../{ => memory}/memory-item-card.tsx | 0 .../components/models/model-detail.tsx | 311 ++++++++++++++++++ .../components/models/model-providers.tsx | 64 ++++ frontend/src/app/setting/memory.tsx | 2 +- frontend/src/app/setting/models.tsx | 309 ++--------------- frontend/src/components/ui/scroll-area.tsx | 56 ---- frontend/src/components/ui/switch.tsx | 29 ++ frontend/src/types/setting.ts | 1 + 13 files changed, 460 insertions(+), 358 deletions(-) rename frontend/src/app/setting/components/{ => memory}/index.tsx (100%) rename frontend/src/app/setting/components/{ => memory}/memory-item-card.tsx (100%) create mode 100644 frontend/src/app/setting/components/models/model-detail.tsx create mode 100644 frontend/src/app/setting/components/models/model-providers.tsx delete mode 100644 frontend/src/components/ui/scroll-area.tsx create mode 100644 frontend/src/components/ui/switch.tsx diff --git a/frontend/bun.lock b/frontend/bun.lock index a3e08fe42..1d756c499 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -18,6 +18,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@react-router/node": "^7.9.4", @@ -292,6 +293,8 @@ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], diff --git a/frontend/package.json b/frontend/package.json index 27e4cf7ca..4acbd4766 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@react-router/node": "^7.9.4", diff --git a/frontend/src/api/setting.ts b/frontend/src/api/setting.ts index ad41b32f1..819a5ae98 100644 --- a/frontend/src/api/setting.ts +++ b/frontend/src/api/setting.ts @@ -36,12 +36,8 @@ export const useGetModelProviders = () => { return useQuery({ queryKey: API_QUERY_KEYS.SETTING.modelProviders, queryFn: () => - apiClient.get< - ApiResponse<{ - providers: ModelProvider[]; - }> - >("/models/providers"), - select: (resp) => resp.data.providers, + apiClient.get>("/models/providers"), + select: (data) => data.data, }); }; @@ -55,7 +51,7 @@ export const useGetModelProviderDetail = (provider: string | undefined) => { apiClient.get>( `/models/providers/${provider}`, ), - select: (resp) => resp.data, + select: (data) => data.data, }); }; @@ -141,6 +137,30 @@ export const useSetDefaultProvider = () => { queryClient.invalidateQueries({ queryKey: API_QUERY_KEYS.SETTING.modelProviders, }); + queryClient.invalidateQueries({ + queryKey: API_QUERY_KEYS.SETTING.modelProviderDetail([]), + }); + }, + }); +}; + +export const useSetDefaultProviderModel = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: { provider: string; model_id: string }) => + apiClient.put>( + `/models/providers/${params.provider}/default-model`, + { + model_id: params.model_id, + }, + ), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: API_QUERY_KEYS.SETTING.modelProviderDetail([ + variables.provider, + ]), + }); }, }); }; diff --git a/frontend/src/app/setting/_layout.tsx b/frontend/src/app/setting/_layout.tsx index cf86b7317..182f91e07 100644 --- a/frontend/src/app/setting/_layout.tsx +++ b/frontend/src/app/setting/_layout.tsx @@ -46,9 +46,9 @@ export default function SettingLayout() { const location = useLocation(); return ( -
+
{/* Left navigation */} -
diff --git a/frontend/src/app/setting/components/index.tsx b/frontend/src/app/setting/components/memory/index.tsx similarity index 100% rename from frontend/src/app/setting/components/index.tsx rename to frontend/src/app/setting/components/memory/index.tsx diff --git a/frontend/src/app/setting/components/memory-item-card.tsx b/frontend/src/app/setting/components/memory/memory-item-card.tsx similarity index 100% rename from frontend/src/app/setting/components/memory-item-card.tsx rename to frontend/src/app/setting/components/memory/memory-item-card.tsx diff --git a/frontend/src/app/setting/components/models/model-detail.tsx b/frontend/src/app/setting/components/models/model-detail.tsx new file mode 100644 index 000000000..5ccb50a49 --- /dev/null +++ b/frontend/src/app/setting/components/models/model-detail.tsx @@ -0,0 +1,311 @@ +import { useForm } from "@tanstack/react-form"; +import { Plus, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { NavLink } from "react-router"; + +import { z } from "zod"; +import { + useAddProviderModel, + useDeleteProviderModel, + useGetModelProviderDetail, + useSetDefaultProviderModel, + useUpdateProviderConfig, +} from "@/api/setting"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Field, + FieldError, + FieldGroup, + FieldLabel, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import ScrollContainer from "@/components/valuecell/scroll/scroll-container"; + +const configSchema = z.object({ + api_key: z.string(), + base_url: z.string(), +}); + +const addModelSchema = z.object({ + model_id: z.string().min(1, "Model ID is required"), + model_name: z.string().min(1, "Model name is required"), +}); + +type ModelDetailProps = { + provider: string; +}; + +export function ModelDetail({ provider }: ModelDetailProps) { + const { data: providerDetail, isLoading: detailLoading } = + useGetModelProviderDetail(provider); + + const { mutate: updateConfig, isPending: updatingConfig } = + useUpdateProviderConfig(); + const { mutate: addModel, isPending: addingModel } = useAddProviderModel(); + const { mutate: deleteModel, isPending: deletingModel } = + useDeleteProviderModel(); + const { mutate: setDefaultModel, isPending: settingDefaultModel } = + useSetDefaultProviderModel(); + + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + + const configForm = useForm({ + defaultValues: { + api_key: "", + base_url: "", + }, + validators: { + onSubmit: configSchema, + }, + onSubmit: async ({ value }) => { + if (!provider) return; + updateConfig({ + provider, + api_key: value.api_key || providerDetail?.api_key, + base_url: value.base_url || providerDetail?.base_url, + }); + }, + }); + + useEffect(() => { + if (providerDetail) { + configForm.setFieldValue("api_key", providerDetail.api_key || ""); + configForm.setFieldValue("base_url", providerDetail.base_url || ""); + } + }, [providerDetail, configForm]); + + const addModelForm = useForm({ + defaultValues: { + model_id: "", + model_name: "", + }, + validators: { + onSubmit: addModelSchema, + }, + onSubmit: async ({ value }) => { + if (!provider) return; + addModel({ + provider, + model_id: value.model_id, + model_name: value.model_name, + }); + addModelForm.reset(); + setIsAddDialogOpen(false); + }, + }); + + const handleSetDefaultModel = (modelId: string) => { + if (!provider) return; + setDefaultModel({ provider, model_id: modelId }); + }; + + const handleDeleteModel = (modelId: string) => { + if (!provider) return; + deleteModel({ provider, model_id: modelId }); + }; + + const isBusy = + updatingConfig || addingModel || deletingModel || settingDefaultModel; + + if (detailLoading) { + return ( +
Loading provider details...
+ ); + } + + if (!providerDetail) { + return null; + } + + return ( + +
+
+ + + {(field) => ( + + + API key + + field.handleChange(e.target.value)} + onBlur={() => configForm.handleSubmit()} + /> + + Click here to get the API key + + + + )} + + + {/* API Host section */} + + {(field) => ( + + + API Host + + field.handleChange(e.target.value)} + onBlur={() => configForm.handleSubmit()} + /> + + + )} + + + + {/* Models section */} +
+
+
Models
+ + + + + + { + e.preventDefault(); + addModelForm.handleSubmit(); + }} + > + + Add Model + +
+ + + {(field) => ( + + + Model ID + + + field.handleChange(e.target.value) + } + onBlur={field.handleBlur} + /> + + + )} + + + + {(field) => ( + + + Model Name + + + field.handleChange(e.target.value) + } + onBlur={field.handleBlur} + /> + + + )} + + +
+ + + + + +
+
+
+ + {providerDetail.models.length === 0 ? ( +
+ No models configured for this provider. +
+ ) : ( +
+ {providerDetail.models.map((m) => ( +
+ + {m.model_name} + + +
+ + handleSetDefaultModel(m.model_id) + } + /> + +
+
+ ))} +
+ )} +
+
+ +
+ ); +} diff --git a/frontend/src/app/setting/components/models/model-providers.tsx b/frontend/src/app/setting/components/models/model-providers.tsx new file mode 100644 index 000000000..083a249d0 --- /dev/null +++ b/frontend/src/app/setting/components/models/model-providers.tsx @@ -0,0 +1,64 @@ +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Item, ItemGroup } from "@/components/ui/item"; +import ScrollContainer from "@/components/valuecell/scroll/scroll-container"; +import { cn } from "@/lib/utils"; +import type { ModelProvider } from "@/types/setting"; + +type ModelProvidersProps = { + providers: ModelProvider[]; + selectedProvider?: string; + onSelect: (provider: string) => void; +}; + +export function ModelProviders({ + providers, + selectedProvider, + onSelect, +}: ModelProvidersProps) { + return ( +
+

Model Provider

+ + + + {providers.length === 0 ? ( +
+ No providers available. +
+ ) : ( + providers.map((provider) => { + const isActive = provider.provider === selectedProvider; + + return ( + onSelect(provider.provider)} + asChild + > +
+ + + {provider.provider.slice(0, 2).toUpperCase()} + + +
+ {provider.provider} + + {provider.provider} + +
+
+
+ ); + }) + )} +
+
+
+ ); +} diff --git a/frontend/src/app/setting/memory.tsx b/frontend/src/app/setting/memory.tsx index 9b582c08e..44db1a894 100644 --- a/frontend/src/app/setting/memory.tsx +++ b/frontend/src/app/setting/memory.tsx @@ -1,5 +1,5 @@ import { useGetMemoryList, useRemoveMemory } from "@/api/setting"; -import { MemoryItemCard } from "./components"; +import { MemoryItemCard } from "./components/memory"; export default function MemoryPage() { const { data: memories = [], isLoading } = useGetMemoryList(); diff --git a/frontend/src/app/setting/models.tsx b/frontend/src/app/setting/models.tsx index 25b2bda0a..011a02f52 100644 --- a/frontend/src/app/setting/models.tsx +++ b/frontend/src/app/setting/models.tsx @@ -1,299 +1,28 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; -import { - useAddProviderModel, - useDeleteProviderModel, - useGetModelProviderDetail, - useGetModelProviders, - useSetDefaultProvider, - useUpdateProviderConfig, -} from "@/api/setting"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { cn } from "@/lib/utils"; +import { useGetModelProviders } from "@/api/setting"; +import { ModelDetail } from "./components/models/model-detail"; +import { ModelProviders } from "./components/models/model-providers"; export default function ModelsSettingPage() { - const { data: providers = [], isLoading: providersLoading } = - useGetModelProviders(); - const [selectedProvider, setSelectedProvider] = useState( - undefined, - ); - - const currentProvider = - selectedProvider || (providers.length > 0 ? providers[0]?.provider : ""); - - const { data: providerDetail, isLoading: detailLoading } = - useGetModelProviderDetail(currentProvider); - - const { mutate: updateConfig, isPending: updatingConfig } = - useUpdateProviderConfig(); - const { mutate: addModel, isPending: addingModel } = useAddProviderModel(); - const { mutate: deleteModel, isPending: deletingModel } = - useDeleteProviderModel(); - const { mutate: setDefault, isPending: settingDefault } = - useSetDefaultProvider(); - - const [apiKeyInput, setApiKeyInput] = useState(""); - const [baseUrlInput, setBaseUrlInput] = useState(""); - const [newModelId, setNewModelId] = useState(""); - const [newModelName, setNewModelName] = useState(""); - - const handleSaveConfig = () => { - if (!currentProvider) return; + const { data: providers = [] } = useGetModelProviders(); + const [selectedProvider, setSelectedProvider] = useState(""); - updateConfig({ - provider: currentProvider, - api_key: apiKeyInput || providerDetail?.api_key, - base_url: baseUrlInput || providerDetail?.base_url, - }); - }; - - const handleAddModel = () => { - if (!currentProvider || !newModelId || !newModelName) return; - - addModel({ - provider: currentProvider, - model_id: newModelId, - model_name: newModelName, - }); - setNewModelId(""); - setNewModelName(""); - }; - - const handleDeleteModel = (modelId: string) => { - if (!currentProvider) return; - deleteModel({ provider: currentProvider, model_id: modelId }); - }; - - const handleSetDefaultProvider = () => { - if (!currentProvider) return; - setDefault({ provider: currentProvider }); - }; - - const isBusy = - updatingConfig || addingModel || deletingModel || settingDefault; + useEffect(() => { + if (providers.length > 0) { + setSelectedProvider(providers[0]?.provider || ""); + } + }, [providers]); return ( -
-
-

Model Providers

-

- Manage your LLM providers, API keys and available models. -

-
- -
- {/* Provider list */} -
-
- Providers -
- - {providersLoading ? ( -
Loading providers...
- ) : providers.length === 0 ? ( -
No providers found.
- ) : ( -
- {providers.map((p) => { - const isActive = currentProvider === p.provider; - return ( - - ); - })} -
- )} -
- - {/* Provider detail */} -
- {detailLoading && ( -
- Loading provider details... -
- )} - - {providerDetail && ( - <> -
-
-
- {currentProvider} -
-
- Default model: {providerDetail.default_model_id} -
-
- - -
- -
-
- - setApiKeyInput(e.target.value)} - /> -
- -
- - setBaseUrlInput(e.target.value)} - /> -
- - -
- -
-
-
- Models -
- -
- setNewModelId(e.target.value)} - className="h-8 w-48" - /> - setNewModelName(e.target.value)} - className="h-8 w-48" - /> - -
-
- - {providerDetail.models.length === 0 ? ( -
- No models configured for this provider. -
- ) : ( -
- {providerDetail.models.map((m) => ( -
-
- - {m.model_name} - - - {m.model_id} - -
- -
- - - -
-
- ))} -
- )} -
- - )} -
-
+
+ setSelectedProvider(provider)} + /> + + {selectedProvider && }
); } diff --git a/frontend/src/components/ui/scroll-area.tsx b/frontend/src/components/ui/scroll-area.tsx deleted file mode 100644 index 9376f5948..000000000 --- a/frontend/src/components/ui/scroll-area.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import * as React from "react" -import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" - -import { cn } from "@/lib/utils" - -function ScrollArea({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - - {children} - - - - - ) -} - -function ScrollBar({ - className, - orientation = "vertical", - ...props -}: React.ComponentProps) { - return ( - - - - ) -} - -export { ScrollArea, ScrollBar } diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx new file mode 100644 index 000000000..b0363e3f8 --- /dev/null +++ b/frontend/src/components/ui/switch.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as SwitchPrimitive from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +function Switch({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Switch } diff --git a/frontend/src/types/setting.ts b/frontend/src/types/setting.ts index f07bb8456..6c12f5380 100644 --- a/frontend/src/types/setting.ts +++ b/frontend/src/types/setting.ts @@ -14,6 +14,7 @@ export type ProviderModelInfo = { export type ProviderDetail = { api_key: string; + api_key_url: string; base_url: string; is_default: boolean; default_model_id: string; From 2c9c60bf2500e373d196e7dad3d99389dec506ec Mon Sep 17 00:00:00 2001 From: DigHuang <114602213+DigHuang@users.noreply.github.com> Date: Tue, 18 Nov 2025 11:52:05 +0800 Subject: [PATCH 4/7] feat: support show/hidden api key --- .../components/models/model-detail.tsx | 88 +++++++-- frontend/src/components/ui/input-group.tsx | 168 ++++++++++++++++++ frontend/src/global.css | 10 ++ python/configs/providers/openai.yaml | 112 +++++------- 4 files changed, 298 insertions(+), 80 deletions(-) create mode 100644 frontend/src/components/ui/input-group.tsx diff --git a/frontend/src/app/setting/components/models/model-detail.tsx b/frontend/src/app/setting/components/models/model-detail.tsx index 5ccb50a49..22fe4dc6e 100644 --- a/frontend/src/app/setting/components/models/model-detail.tsx +++ b/frontend/src/app/setting/components/models/model-detail.tsx @@ -1,13 +1,13 @@ import { useForm } from "@tanstack/react-form"; -import { Plus, Trash2 } from "lucide-react"; +import { Eye, EyeOff, Plus, Trash2 } from "lucide-react"; import { useEffect, useState } from "react"; -import { NavLink } from "react-router"; import { z } from "zod"; import { useAddProviderModel, useDeleteProviderModel, useGetModelProviderDetail, + useSetDefaultProvider, useSetDefaultProviderModel, useUpdateProviderConfig, } from "@/api/setting"; @@ -27,6 +27,12 @@ import { FieldLabel, } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@/components/ui/input-group"; import { Switch } from "@/components/ui/switch"; import ScrollContainer from "@/components/valuecell/scroll/scroll-container"; @@ -55,8 +61,11 @@ export function ModelDetail({ provider }: ModelDetailProps) { useDeleteProviderModel(); const { mutate: setDefaultModel, isPending: settingDefaultModel } = useSetDefaultProviderModel(); + const { mutate: setDefaultProvider, isPending: settingDefaultProvider } = + useSetDefaultProvider(); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [showApiKey, setShowApiKey] = useState(false); const configForm = useForm({ defaultValues: { @@ -83,6 +92,10 @@ export function ModelDetail({ provider }: ModelDetailProps) { } }, [providerDetail, configForm]); + useEffect(() => { + if (provider) setShowApiKey(false); + }, [provider]); + const addModelForm = useForm({ defaultValues: { model_id: "", @@ -114,7 +127,11 @@ export function ModelDetail({ provider }: ModelDetailProps) { }; const isBusy = - updatingConfig || addingModel || deletingModel || settingDefaultModel; + updatingConfig || + addingModel || + deletingModel || + settingDefaultModel || + settingDefaultProvider; if (detailLoading) { return ( @@ -128,29 +145,68 @@ export function ModelDetail({ provider }: ModelDetailProps) { return ( +
+

{provider}

+
+

+ Default Provider +

+ setDefaultProvider({ provider })} + /> +
+
+
{(field) => ( - + API key - field.handleChange(e.target.value)} - onBlur={() => configForm.handleSubmit()} - /> - + field.handleChange(e.target.value)} + onBlur={() => configForm.handleSubmit()} + /> + + setShowApiKey(!showApiKey)} + aria-label={ + showApiKey ? "Hide password" : "Show password" + } + > + {showApiKey ? ( + + ) : ( + + )} + + + + )} diff --git a/frontend/src/components/ui/input-group.tsx b/frontend/src/components/ui/input-group.tsx new file mode 100644 index 000000000..6db1784c2 --- /dev/null +++ b/frontend/src/components/ui/input-group.tsx @@ -0,0 +1,168 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
textarea]:h-auto", + + // Variants based on alignment. + "has-[>[data-align=inline-start]]:[&>input]:pl-2", + "has-[>[data-align=inline-end]]:[&>input]:pr-2", + "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", + "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", + + // Focus state. + "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]", + + // Error state. + "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", + + className + )} + {...props} + /> + ) +} + +const inputGroupAddonVariants = cva( + "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50", + { + variants: { + align: { + "inline-start": + "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]", + "inline-end": + "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]", + "block-start": + "order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5", + "block-end": + "order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5", + }, + }, + defaultVariants: { + align: "inline-start", + }, + } +) + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return + } + e.currentTarget.parentElement?.querySelector("input")?.focus() + }} + {...props} + /> + ) +} + +const inputGroupButtonVariants = cva( + "text-sm shadow-none flex gap-2 items-center", + { + variants: { + size: { + xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2", + sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + } +) + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size"> & + VariantProps) { + return ( +