From e21757cf56d98eb7b1cfbb888a103261b3ffe445 Mon Sep 17 00:00:00 2001 From: Shu Li Date: Sun, 22 Feb 2026 19:50:51 +0800 Subject: [PATCH 1/2] feat: add minimax-cn provider for CN users Add minimax-cn provider that uses the CN endpoint (https://api.minimaxi.com/anthropic) for users in China. Closes #139 --- interface/src/components/ModelSelect.tsx | 4 + interface/src/lib/providerIcons.tsx | 1 + interface/src/routes/Settings.tsx | 344 +++++++++++------------ src/api/models.rs | 13 + src/api/providers.rs | 1 + src/config.rs | 35 +++ src/llm/routing.rs | 2 + 7 files changed, 228 insertions(+), 172 deletions(-) diff --git a/interface/src/components/ModelSelect.tsx b/interface/src/components/ModelSelect.tsx index 4720b81c7..c4841b861 100644 --- a/interface/src/components/ModelSelect.tsx +++ b/interface/src/components/ModelSelect.tsx @@ -26,6 +26,8 @@ const PROVIDER_LABELS: Record = { zhipu: "Z.ai (GLM)", ollama: "Ollama", "opencode-zen": "OpenCode Zen", + minimax: "MiniMax", + "minimax-cn": "MiniMax CN", }; function formatContextWindow(tokens: number | null): string { @@ -137,6 +139,8 @@ export function ModelSelect({ "fireworks", "zhipu", "opencode-zen", + "minimax", + "minimax-cn", ]; const sortedProviders = Object.keys(grouped).sort( (a, b) => diff --git a/interface/src/lib/providerIcons.tsx b/interface/src/lib/providerIcons.tsx index b54701c3b..08abacce5 100644 --- a/interface/src/lib/providerIcons.tsx +++ b/interface/src/lib/providerIcons.tsx @@ -113,6 +113,7 @@ export function ProviderIcon({ provider, className = "text-ink-faint", size = 24 "opencode-zen": OpenCodeZenIcon, nvidia: NvidiaIcon, minimax: Minimax, + "minimax-cn": Minimax, moonshot: Kimi, // Kimi is Moonshot AI's product brand }; diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index 8b1de5434..6426fd1cb 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -1,15 +1,15 @@ -import {useState, useEffect, useRef} from "react"; -import {useQuery, useMutation, useQueryClient} from "@tanstack/react-query"; -import {api, type GlobalSettingsResponse} from "@/api/client"; -import {Button, Input, SettingSidebarButton, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Toggle} from "@/ui"; -import {useSearch, useNavigate} from "@tanstack/react-router"; -import {ChannelSettingCard, DisabledChannelCard} from "@/components/ChannelSettingCard"; -import {ModelSelect} from "@/components/ModelSelect"; -import {ProviderIcon} from "@/lib/providerIcons"; -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faSearch} from "@fortawesome/free-solid-svg-icons"; - -import {parse as parseToml} from "smol-toml"; +import { useState, useEffect, useRef } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { api, type GlobalSettingsResponse } from "@/api/client"; +import { Button, Input, SettingSidebarButton, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Toggle } from "@/ui"; +import { useSearch, useNavigate } from "@tanstack/react-router"; +import { ChannelSettingCard, DisabledChannelCard } from "@/components/ChannelSettingCard"; +import { ModelSelect } from "@/components/ModelSelect"; +import { ProviderIcon } from "@/lib/providerIcons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faSearch } from "@fortawesome/free-solid-svg-icons"; + +import { parse as parseToml } from "smol-toml"; type SectionId = "providers" | "channels" | "api-keys" | "server" | "opencode" | "worker-logs" | "config-file"; @@ -184,6 +184,14 @@ const PROVIDERS = [ envVar: "MINIMAX_API_KEY", defaultModel: "minimax/MiniMax-M1-80k", }, + { + id: "minimax-cn", + name: "MiniMax CN", + description: "MiniMax M2.5 (Anthropic message format)", + placeholder: "eyJ...", + envVar: "MINIMAX_CN_API_KEY", + defaultModel: "minimax/MiniMax-M2.5", + }, { id: "moonshot", name: "Moonshot AI", @@ -205,7 +213,7 @@ const PROVIDERS = [ export function Settings() { const queryClient = useQueryClient(); const navigate = useNavigate(); - const search = useSearch({from: "/settings"}) as {tab?: string}; + const search = useSearch({ from: "/settings" }) as { tab?: string }; const [activeSection, setActiveSection] = useState("providers"); // Sync activeSection with URL search param @@ -217,7 +225,7 @@ export function Settings() { const handleSectionChange = (section: SectionId) => { setActiveSection(section); - navigate({to: "/settings", search: {tab: section}}); + navigate({ to: "/settings", search: { tab: section } }); }; const [editingProvider, setEditingProvider] = useState(null); const [keyInput, setKeyInput] = useState(""); @@ -234,7 +242,7 @@ export function Settings() { } | null>(null); // Fetch providers data (only when on providers tab) - const {data, isLoading} = useQuery({ + const { data, isLoading } = useQuery({ queryKey: ["providers"], queryFn: api.providers, staleTime: 5_000, @@ -242,7 +250,7 @@ export function Settings() { }); // Fetch global settings (only when on api-keys, server, or worker-logs tabs) - const {data: globalSettings, isLoading: globalSettingsLoading} = useQuery({ + const { data: globalSettings, isLoading: globalSettingsLoading } = useQuery({ queryKey: ["global-settings"], queryFn: api.globalSettings, staleTime: 5_000, @@ -250,7 +258,7 @@ export function Settings() { }); const updateMutation = useMutation({ - mutationFn: ({provider, apiKey, model}: {provider: string; apiKey: string; model: string}) => + mutationFn: ({ provider, apiKey, model }: { provider: string; apiKey: string; model: string }) => api.updateProvider(provider, apiKey, model), onSuccess: (result) => { if (result.success) { @@ -259,24 +267,24 @@ export function Settings() { setModelInput(""); setTestedSignature(null); setTestResult(null); - setMessage({text: result.message, type: "success"}); - queryClient.invalidateQueries({queryKey: ["providers"]}); + setMessage({ text: result.message, type: "success" }); + queryClient.invalidateQueries({ queryKey: ["providers"] }); // Agents will auto-start on the backend, refetch agent list after a short delay setTimeout(() => { - queryClient.invalidateQueries({queryKey: ["agents"]}); - queryClient.invalidateQueries({queryKey: ["overview"]}); + queryClient.invalidateQueries({ queryKey: ["agents"] }); + queryClient.invalidateQueries({ queryKey: ["overview"] }); }, 3000); } else { - setMessage({text: result.message, type: "error"}); + setMessage({ text: result.message, type: "error" }); } }, onError: (error) => { - setMessage({text: `Failed: ${error.message}`, type: "error"}); + setMessage({ text: `Failed: ${error.message}`, type: "error" }); }, }); const testModelMutation = useMutation({ - mutationFn: ({provider, apiKey, model}: {provider: string; apiKey: string; model: string}) => + mutationFn: ({ provider, apiKey, model }: { provider: string; apiKey: string; model: string }) => api.testProviderModel(provider, apiKey, model), }); @@ -284,14 +292,14 @@ export function Settings() { mutationFn: (provider: string) => api.removeProvider(provider), onSuccess: (result) => { if (result.success) { - setMessage({text: result.message, type: "success"}); - queryClient.invalidateQueries({queryKey: ["providers"]}); + setMessage({ text: result.message, type: "success" }); + queryClient.invalidateQueries({ queryKey: ["providers"] }); } else { - setMessage({text: result.message, type: "error"}); + setMessage({ text: result.message, type: "error" }); } }, onError: (error) => { - setMessage({text: `Failed: ${error.message}`, type: "error"}); + setMessage({ text: `Failed: ${error.message}`, type: "error" }); }, }); @@ -309,7 +317,7 @@ export function Settings() { apiKey: keyInput.trim(), model: modelInput.trim(), }); - setTestResult({success: result.success, message: result.message, sample: result.sample}); + setTestResult({ success: result.success, message: result.message, sample: result.sample }); if (result.success) { setTestedSignature(currentSignature); return true; @@ -318,7 +326,7 @@ export function Settings() { return false; } } catch (error: any) { - setTestResult({success: false, message: `Failed: ${error.message}`}); + setTestResult({ success: false, message: `Failed: ${error.message}` }); setTestedSignature(null); return false; } @@ -384,71 +392,71 @@ export function Settings() {
{activeSection === "providers" ? ( -
- {/* Section header */} -
-

- LLM Providers -

-

- Configure credentials/endpoints for LLM providers. At least one provider is - required for agents to function. -

-
- -
-

- When you add a provider, choose a model and run a completion test before saving. - Saving applies that model to all five default routing roles and to your default agent. -

-
- - {isLoading ? ( -
-
- Loading providers... +
+ {/* Section header */} +
+

+ LLM Providers +

+

+ Configure credentials/endpoints for LLM providers. At least one provider is + required for agents to function. +

- ) : ( -
- {PROVIDERS.map((provider) => ( - { - setEditingProvider(provider.id); - setKeyInput(""); - setModelInput(provider.defaultModel ?? ""); - setTestedSignature(null); - setTestResult(null); - setMessage(null); - }} - onRemove={() => removeMutation.mutate(provider.id)} - removing={removeMutation.isPending} - /> - ))} + +
+

+ When you add a provider, choose a model and run a completion test before saving. + Saving applies that model to all five default routing roles and to your default agent. +

- )} - {/* Info note */} -
-

- Provider values are written to{" "} - - config.toml - {" "} - in your instance directory. You can also set them via - environment variables ( - - ANTHROPIC_API_KEY - - , etc.). -

+ {isLoading ? ( +
+
+ Loading providers... +
+ ) : ( +
+ {PROVIDERS.map((provider) => ( + { + setEditingProvider(provider.id); + setKeyInput(""); + setModelInput(provider.defaultModel ?? ""); + setTestedSignature(null); + setTestResult(null); + setMessage(null); + }} + onRemove={() => removeMutation.mutate(provider.id)} + removing={removeMutation.isPending} + /> + ))} +
+ )} + + {/* Info note */} +
+

+ Provider values are written to{" "} + + config.toml + {" "} + in your instance directory. You can also set them via + environment variables ( + + ANTHROPIC_API_KEY + + , etc.). +

+
-
) : activeSection === "channels" ? ( ) : activeSection === "api-keys" ? ( @@ -517,11 +525,10 @@ export function Settings() {
{testResult && (
{testResult.message}
{testResult.success && testResult.sample ? ( @@ -531,11 +538,10 @@ export function Settings() { )} {message && (
{message.text}
@@ -562,28 +568,28 @@ export function Settings() { function ChannelsSection() { const [expandedPlatform, setExpandedPlatform] = useState(null); - const {data: messagingStatus, isLoading} = useQuery({ + const { data: messagingStatus, isLoading } = useQuery({ queryKey: ["messaging-status"], queryFn: api.messagingStatus, staleTime: 5_000, }); const PLATFORMS = [ - {platform: "discord" as const, name: "Discord", description: "Discord bot integration"}, - {platform: "slack" as const, name: "Slack", description: "Slack bot integration"}, - {platform: "telegram" as const, name: "Telegram", description: "Telegram bot integration"}, - {platform: "twitch" as const, name: "Twitch", description: "Twitch chat integration"}, - {platform: "webhook" as const, name: "Webhook", description: "HTTP webhook receiver"}, + { platform: "discord" as const, name: "Discord", description: "Discord bot integration" }, + { platform: "slack" as const, name: "Slack", description: "Slack bot integration" }, + { platform: "telegram" as const, name: "Telegram", description: "Telegram bot integration" }, + { platform: "twitch" as const, name: "Twitch", description: "Twitch chat integration" }, + { platform: "webhook" as const, name: "Webhook", description: "HTTP webhook receiver" }, ] as const; const COMING_SOON = [ - {platform: "email", name: "Email", description: "IMAP polling for inbound, SMTP for outbound"}, - {platform: "whatsapp", name: "WhatsApp", description: "Meta Cloud API integration"}, - {platform: "matrix", name: "Matrix", description: "Decentralized chat protocol"}, - {platform: "imessage", name: "iMessage", description: "macOS-only AppleScript bridge"}, - {platform: "irc", name: "IRC", description: "TLS socket connection"}, - {platform: "lark", name: "Lark", description: "Feishu/Lark webhook integration"}, - {platform: "dingtalk", name: "DingTalk", description: "Chinese enterprise webhook integration"}, + { platform: "email", name: "Email", description: "IMAP polling for inbound, SMTP for outbound" }, + { platform: "whatsapp", name: "WhatsApp", description: "Meta Cloud API integration" }, + { platform: "matrix", name: "Matrix", description: "Decentralized chat protocol" }, + { platform: "imessage", name: "iMessage", description: "macOS-only AppleScript bridge" }, + { platform: "irc", name: "IRC", description: "TLS socket connection" }, + { platform: "lark", name: "Lark", description: "Feishu/Lark webhook integration" }, + { platform: "dingtalk", name: "DingTalk", description: "Chinese enterprise webhook integration" }, ]; return ( @@ -602,7 +608,7 @@ function ChannelsSection() {
) : (
- {PLATFORMS.map(({platform: p, name: n, description: d}) => ( + {PLATFORMS.map(({ platform: p, name: n, description: d }) => ( setExpandedPlatform(expandedPlatform === p ? null : p)} /> ))} - {COMING_SOON.map(({platform: p, name: n, description: d}) => ( + {COMING_SOON.map(({ platform: p, name: n, description: d }) => ( ))}
@@ -629,7 +635,7 @@ interface GlobalSettingsSectionProps { isLoading: boolean; } -function ApiKeysSection({settings, isLoading}: GlobalSettingsSectionProps) { +function ApiKeysSection({ settings, isLoading }: GlobalSettingsSectionProps) { const queryClient = useQueryClient(); const [editingBraveKey, setEditingBraveKey] = useState(false); const [braveKeyInput, setBraveKeyInput] = useState(""); @@ -641,23 +647,23 @@ function ApiKeysSection({settings, isLoading}: GlobalSettingsSectionProps) { if (result.success) { setEditingBraveKey(false); setBraveKeyInput(""); - setMessage({text: result.message, type: "success"}); - queryClient.invalidateQueries({queryKey: ["global-settings"]}); + setMessage({ text: result.message, type: "success" }); + queryClient.invalidateQueries({ queryKey: ["global-settings"] }); } else { - setMessage({text: result.message, type: "error"}); + setMessage({ text: result.message, type: "error" }); } }, onError: (error) => { - setMessage({text: `Failed: ${error.message}`, type: "error"}); + setMessage({ text: `Failed: ${error.message}`, type: "error" }); }, }); const handleSaveBraveKey = () => { - updateMutation.mutate({brave_search_key: braveKeyInput.trim() || null}); + updateMutation.mutate({ brave_search_key: braveKeyInput.trim() || null }); }; const handleRemoveBraveKey = () => { - updateMutation.mutate({brave_search_key: null}); + updateMutation.mutate({ brave_search_key: null }); }; return ( @@ -720,11 +726,10 @@ function ApiKeysSection({settings, isLoading}: GlobalSettingsSectionProps) { {message && (
{message.text}
@@ -767,7 +772,7 @@ function ApiKeysSection({settings, isLoading}: GlobalSettingsSectionProps) { ); } -function ServerSection({settings, isLoading}: GlobalSettingsSectionProps) { +function ServerSection({ settings, isLoading }: GlobalSettingsSectionProps) { const queryClient = useQueryClient(); const [apiEnabled, setApiEnabled] = useState(settings?.api_enabled ?? true); const [apiPort, setApiPort] = useState(settings?.api_port.toString() ?? "19898"); @@ -787,21 +792,21 @@ function ServerSection({settings, isLoading}: GlobalSettingsSectionProps) { mutationFn: api.updateGlobalSettings, onSuccess: (result) => { if (result.success) { - setMessage({text: result.message, type: "success", requiresRestart: result.requires_restart}); - queryClient.invalidateQueries({queryKey: ["global-settings"]}); + setMessage({ text: result.message, type: "success", requiresRestart: result.requires_restart }); + queryClient.invalidateQueries({ queryKey: ["global-settings"] }); } else { - setMessage({text: result.message, type: "error"}); + setMessage({ text: result.message, type: "error" }); } }, onError: (error) => { - setMessage({text: `Failed: ${error.message}`, type: "error"}); + setMessage({ text: `Failed: ${error.message}`, type: "error" }); }, }); const handleSave = () => { const port = parseInt(apiPort, 10); if (isNaN(port) || port < 1024 || port > 65535) { - setMessage({text: "Port must be between 1024 and 65535", type: "error"}); + setMessage({ text: "Port must be between 1024 and 65535", type: "error" }); return; } @@ -883,11 +888,10 @@ function ServerSection({settings, isLoading}: GlobalSettingsSectionProps) { {message && (
{message.text} {message.requiresRestart && ( @@ -901,7 +905,7 @@ function ServerSection({settings, isLoading}: GlobalSettingsSectionProps) { ); } -function WorkerLogsSection({settings, isLoading}: GlobalSettingsSectionProps) { +function WorkerLogsSection({ settings, isLoading }: GlobalSettingsSectionProps) { const queryClient = useQueryClient(); const [logMode, setLogMode] = useState(settings?.worker_log_mode ?? "errors_only"); const [message, setMessage] = useState<{ text: string; type: "success" | "error" } | null>(null); @@ -917,19 +921,19 @@ function WorkerLogsSection({settings, isLoading}: GlobalSettingsSectionProps) { mutationFn: api.updateGlobalSettings, onSuccess: (result) => { if (result.success) { - setMessage({text: result.message, type: "success"}); - queryClient.invalidateQueries({queryKey: ["global-settings"]}); + setMessage({ text: result.message, type: "success" }); + queryClient.invalidateQueries({ queryKey: ["global-settings"] }); } else { - setMessage({text: result.message, type: "error"}); + setMessage({ text: result.message, type: "error" }); } }, onError: (error) => { - setMessage({text: `Failed: ${error.message}`, type: "error"}); + setMessage({ text: `Failed: ${error.message}`, type: "error" }); }, }); const handleSave = () => { - updateMutation.mutate({worker_log_mode: logMode}); + updateMutation.mutate({ worker_log_mode: logMode }); }; const modes = [ @@ -970,11 +974,10 @@ function WorkerLogsSection({settings, isLoading}: GlobalSettingsSectionProps) { {modes.map((mode) => (
setLogMode(mode.value)} >