feat: add UI build to build process

- Created separate build script to handle both CLI and UI building
- Added automatic UI dependency installation
- Copy built UI artifacts to dist directory

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
musistudio
2025-07-30 11:15:05 +08:00
parent 31db041084
commit 112d7ef8f9
57 changed files with 13581 additions and 365 deletions

227
ui/src/App.tsx Normal file
View File

@@ -0,0 +1,227 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { SettingsDialog } from "@/components/SettingsDialog";
import { Transformers } from "@/components/Transformers";
import { Providers } from "@/components/Providers";
import { Router } from "@/components/Router";
import { Button } from "@/components/ui/button";
import { useConfig } from "@/components/ConfigProvider";
import { api } from "@/lib/api";
import { Settings, Languages, Save, RefreshCw } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Toast } from "@/components/ui/toast";
import "@/styles/animations.css";
function App() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const { config, error } = useConfig();
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
useEffect(() => {
const checkAuth = async () => {
// If we already have a config, we're authenticated
if (config) {
setIsCheckingAuth(false);
return;
}
// For empty API key, allow access without checking config
const apiKey = localStorage.getItem('apiKey');
if (!apiKey) {
setIsCheckingAuth(false);
return;
}
// If we don't have a config, try to fetch it
try {
await api.getConfig();
// If successful, we don't need to do anything special
// The ConfigProvider will handle setting the config
} catch (err) {
// If it's a 401, the API client will redirect to login
// For other errors, we still show the app to display the error
console.error('Error checking auth:', err);
// Redirect to login on authentication error
if ((err as Error).message === 'Unauthorized') {
navigate('/login');
}
} finally {
setIsCheckingAuth(false);
}
};
checkAuth();
// Listen for unauthorized events
const handleUnauthorized = () => {
navigate('/login');
};
window.addEventListener('unauthorized', handleUnauthorized);
return () => {
window.removeEventListener('unauthorized', handleUnauthorized);
};
}, [config, navigate]);
const saveConfig = async () => {
if (config) {
try {
// Save to API
const response = await api.updateConfig(config);
// Show success message or handle as needed
console.log('Config saved successfully');
// 根据响应信息进行提示
if (response && typeof response === 'object' && 'success' in response) {
const apiResponse = response as { success: boolean; message?: string };
if (apiResponse.success) {
setToast({ message: apiResponse.message || t('app.config_saved_success'), type: 'success' });
} else {
setToast({ message: apiResponse.message || t('app.config_saved_failed'), type: 'error' });
}
} else {
// 默认成功提示
setToast({ message: t('app.config_saved_success'), type: 'success' });
}
} catch (error) {
console.error('Failed to save config:', error);
// Handle error appropriately
setToast({ message: t('app.config_saved_failed') + ': ' + (error as Error).message, type: 'error' });
}
}
};
const saveConfigAndRestart = async () => {
if (config) {
try {
// Save to API
const response = await api.updateConfig(config);
// Check if save was successful before restarting
let saveSuccessful = true;
if (response && typeof response === 'object' && 'success' in response) {
const apiResponse = response as { success: boolean; message?: string };
if (!apiResponse.success) {
saveSuccessful = false;
setToast({ message: apiResponse.message || t('app.config_saved_failed'), type: 'error' });
}
}
// Only restart if save was successful
if (saveSuccessful) {
// Restart service
const response = await api.restartService();
// Show success message or handle as needed
console.log('Config saved and service restarted successfully');
// 根据响应信息进行提示
if (response && typeof response === 'object' && 'success' in response) {
const apiResponse = response as { success: boolean; message?: string };
if (apiResponse.success) {
setToast({ message: apiResponse.message || t('app.config_saved_restart_success'), type: 'success' });
}
} else {
// 默认成功提示
setToast({ message: t('app.config_saved_restart_success'), type: 'success' });
}
}
} catch (error) {
console.error('Failed to save config and restart:', error);
// Handle error appropriately
setToast({ message: t('app.config_saved_restart_failed') + ': ' + (error as Error).message, type: 'error' });
}
}
};
if (isCheckingAuth) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
if (!config) {
return <div>Loading...</div>;
}
return (
<div className="h-screen bg-gray-50 font-sans">
<header className="flex h-16 items-center justify-between border-b bg-white px-6">
<h1 className="text-xl font-semibold text-gray-800">{t('app.title')}</h1>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => setIsSettingsOpen(true)} className="transition-all-ease hover:scale-110">
<Settings className="h-5 w-5" />
</Button>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="transition-all-ease hover:scale-110">
<Languages className="h-5 w-5" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-32 p-2">
<div className="space-y-1">
<Button
variant={i18n.language.startsWith('en') ? 'default' : 'ghost'}
className="w-full justify-start transition-all-ease hover:scale-[1.02]"
onClick={() => i18n.changeLanguage('en')}
>
English
</Button>
<Button
variant={i18n.language.startsWith('zh') ? 'default' : 'ghost'}
className="w-full justify-start transition-all-ease hover:scale-[1.02]"
onClick={() => i18n.changeLanguage('zh')}
>
</Button>
</div>
</PopoverContent>
</Popover>
<Button onClick={saveConfig} variant="outline" className="transition-all-ease hover:scale-[1.02] active:scale-[0.98]">
<Save className="mr-2 h-4 w-4" />
{t('app.save')}
</Button>
<Button onClick={saveConfigAndRestart} className="transition-all-ease hover:scale-[1.02] active:scale-[0.98]">
<RefreshCw className="mr-2 h-4 w-4" />
{t('app.save_and_restart')}
</Button>
</div>
</header>
<main className="flex h-[calc(100vh-4rem)] gap-4 p-4">
<div className="w-3/5">
<Providers />
</div>
<div className="flex w-2/5 flex-col gap-4">
<div className="h-3/5">
<Router />
</div>
<div className="flex-1">
<Transformers />
</div>
</div>
</main>
<SettingsDialog isOpen={isSettingsOpen} onOpenChange={setIsSettingsOpen} />
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
</div>
);
}
export default App;

1
ui/src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,147 @@
import { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode, Dispatch, SetStateAction } from 'react';
import { api } from '@/lib/api';
export interface Transformer {
path: string;
options: {
[key: string]: string;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
export interface ProviderTransformer {
use: (string | (string | Record<string, unknown> | { max_tokens: number })[])[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any; // for model specific transformers
}
export interface Provider {
name: string;
api_base_url: string;
api_key: string;
models: string[];
transformer?: ProviderTransformer;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
export interface RouterConfig {
default: string;
background: string;
think: string;
longContext: string;
webSearch: string;
}
export interface Config {
LOG: boolean;
CLAUDE_PATH: string;
HOST: string;
PORT: number;
APIKEY: string;
transformers: Transformer[];
Providers: Provider[];
Router: RouterConfig;
}
interface ConfigContextType {
config: Config | null;
setConfig: Dispatch<SetStateAction<Config | null>>;
error: Error | null;
}
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
// eslint-disable-next-line react-refresh/only-export-components
export function useConfig() {
const context = useContext(ConfigContext);
if (context === undefined) {
throw new Error('useConfig must be used within a ConfigProvider');
}
return context;
}
interface ConfigProviderProps {
children: ReactNode;
}
export function ConfigProvider({ children }: ConfigProviderProps) {
const [config, setConfig] = useState<Config | null>(null);
const [error, setError] = useState<Error | null>(null);
const [hasFetched, setHasFetched] = useState<boolean>(false);
const [apiKey, setApiKey] = useState<string | null>(localStorage.getItem('apiKey'));
// Listen for localStorage changes
useEffect(() => {
const handleStorageChange = () => {
setApiKey(localStorage.getItem('apiKey'));
};
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, []);
useEffect(() => {
const fetchConfig = async () => {
// Reset fetch state when API key changes
setHasFetched(false);
setConfig(null);
setError(null);
};
fetchConfig();
}, [apiKey]);
useEffect(() => {
const fetchConfig = async () => {
// Prevent duplicate API calls in React StrictMode
// Skip if we've already fetched
if (hasFetched) {
return;
}
setHasFetched(true);
try {
// Try to fetch config regardless of API key presence
const data = await api.getConfig();
setConfig(data);
} catch (err) {
console.error('Failed to fetch config:', err);
// If we get a 401, the API client will redirect to login
// Otherwise, set an empty config or error
if ((err as Error).message !== 'Unauthorized') {
// Set default empty config when fetch fails
setConfig({
LOG: false,
CLAUDE_PATH: '',
HOST: '127.0.0.1',
PORT: 3456,
APIKEY: '',
transformers: [],
Providers: [],
Router: {
default: '',
background: '',
think: '',
longContext: '',
webSearch: ''
}
});
setError(err as Error);
}
}
};
fetchConfig();
}, [hasFetched, apiKey]);
return (
<ConfigContext.Provider value={{ config, setConfig, error }}>
{children}
</ConfigContext.Provider>
);
}

129
ui/src/components/Login.tsx Normal file
View File

@@ -0,0 +1,129 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { api } from '@/lib/api';
export function Login() {
const { t } = useTranslation();
const navigate = useNavigate();
const [apiKey, setApiKey] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
// Check if user is already authenticated
useEffect(() => {
const checkAuth = async () => {
const apiKey = localStorage.getItem('apiKey');
if (apiKey) {
setIsLoading(true);
// Verify the API key is still valid
try {
await api.getConfig();
navigate('/dashboard');
} catch (err) {
// If verification fails, remove the API key
localStorage.removeItem('apiKey');
} finally {
setIsLoading(false);
}
}
};
checkAuth();
// Listen for unauthorized events
const handleUnauthorized = () => {
navigate('/login');
};
window.addEventListener('unauthorized', handleUnauthorized);
return () => {
window.removeEventListener('unauthorized', handleUnauthorized);
};
}, [navigate]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
try {
// Set the API key
api.setApiKey(apiKey);
// Dispatch storage event to notify other components of the change
window.dispatchEvent(new StorageEvent('storage', {
key: 'apiKey',
newValue: apiKey,
url: window.location.href
}));
// Test the API key by fetching config (skip if apiKey is empty)
if (apiKey) {
await api.getConfig();
}
// Navigate to dashboard
// The ConfigProvider will handle fetching the config
navigate('/dashboard');
} catch (err) {
// Clear the API key on failure
api.setApiKey('');
setError(t('login.invalidApiKey'));
}
};
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl">{t('login.title')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
</div>
<p className="text-center text-sm text-gray-500">{t('login.validating')}</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl">{t('login.title')}</CardTitle>
<CardDescription>
{t('login.description')}
</CardDescription>
</CardHeader>
<form onSubmit={handleLogin}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="apiKey">{t('login.apiKey')}</Label>
<Input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={t('login.apiKeyPlaceholder')}
/>
</div>
{error && <div className="text-sm text-red-500">{error}</div>}
</CardContent>
<CardFooter>
<Button className="w-full" type="submit">
{t('login.signIn')}
</Button>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { type Provider } from "./ConfigProvider";
interface ProviderListProps {
providers: Provider[];
onEdit: (index: number) => void;
onRemove: (index: number) => void;
}
export function ProviderList({ providers, onEdit, onRemove }: ProviderListProps) {
return (
<div className="space-y-3">
{providers.map((provider, index) => (
<div key={index} className="flex items-start justify-between rounded-md border bg-white p-4 transition-all hover:shadow-md animate-slide-in hover:scale-[1.01]">
<div className="flex-1 space-y-1.5">
<p className="text-md font-semibold text-gray-800">{provider.name}</p>
<p className="text-sm text-gray-500">{provider.api_base_url}</p>
<div className="flex flex-wrap gap-2 pt-2">
{provider.models.map((model) => (
<Badge key={model} variant="outline" className="font-normal transition-all-ease hover:scale-105">{model}</Badge>
))}
</div>
</div>
<div className="ml-4 flex flex-shrink-0 items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => onEdit(index)} className="transition-all-ease hover:scale-110">
<Pencil className="h-4 w-4" />
</Button>
<Button variant="destructive" size="icon" onClick={() => onRemove(index)} className="transition-all duration-200 hover:scale-110">
<Trash2 className="h-4 w-4 text-current transition-colors duration-200" />
</Button>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,775 @@
import { useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useConfig } from "./ConfigProvider";
import { ProviderList } from "./ProviderList";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { X, Trash2, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Combobox } from "@/components/ui/combobox";
import { ComboInput } from "@/components/ui/combo-input";
export function Providers() {
const { t } = useTranslation();
const { config, setConfig } = useConfig();
const [editingProviderIndex, setEditingProviderIndex] = useState<number | null>(null);
const [deletingProviderIndex, setDeletingProviderIndex] = useState<number | null>(null);
const [hasFetchedModels, setHasFetchedModels] = useState<Record<number, boolean>>({});
const [providerParamInputs, setProviderParamInputs] = useState<Record<string, {name: string, value: string}>>({});
const [modelParamInputs, setModelParamInputs] = useState<Record<string, {name: string, value: string}>>({});
const comboInputRef = useRef<HTMLInputElement>(null);
if (!config) {
return null;
}
const handleAddProvider = () => {
const newProviders = [...config.Providers, { name: "", api_base_url: "", api_key: "", models: [] }];
setConfig({ ...config, Providers: newProviders });
setEditingProviderIndex(newProviders.length - 1);
};
const handleSaveProvider = () => {
setEditingProviderIndex(null);
};
const handleCancelAddProvider = () => {
// If we're adding a new provider, remove it regardless of content
if (editingProviderIndex !== null && editingProviderIndex === config.Providers.length - 1) {
const newProviders = [...config.Providers];
newProviders.pop();
setConfig({ ...config, Providers: newProviders });
}
// Reset fetched models state for this provider
if (editingProviderIndex !== null) {
setHasFetchedModels(prev => {
const newState = { ...prev };
delete newState[editingProviderIndex];
return newState;
});
}
setEditingProviderIndex(null);
};
const handleRemoveProvider = (index: number) => {
const newProviders = [...config.Providers];
newProviders.splice(index, 1);
setConfig({ ...config, Providers: newProviders });
setDeletingProviderIndex(null);
};
const handleProviderChange = (index: number, field: string, value: string) => {
const newProviders = [...config.Providers];
newProviders[index][field] = value;
setConfig({ ...config, Providers: newProviders });
};
const handleProviderTransformerChange = (index: number, transformerPath: string) => {
if (!transformerPath) return; // Don't add empty transformers
const newProviders = [...config.Providers];
if (!newProviders[index].transformer) {
newProviders[index].transformer = { use: [] };
}
// Add transformer to the use array
newProviders[index].transformer!.use = [...newProviders[index].transformer!.use, transformerPath];
setConfig({ ...config, Providers: newProviders });
};
const removeProviderTransformerAtIndex = (index: number, transformerIndex: number) => {
const newProviders = [...config.Providers];
if (newProviders[index].transformer) {
const newUseArray = [...newProviders[index].transformer!.use];
newUseArray.splice(transformerIndex, 1);
newProviders[index].transformer!.use = newUseArray;
// If use array is now empty and no other properties, remove transformer entirely
if (newUseArray.length === 0 && Object.keys(newProviders[index].transformer!).length === 1) {
delete newProviders[index].transformer;
}
}
setConfig({ ...config, Providers: newProviders });
};
const handleModelTransformerChange = (providerIndex: number, model: string, transformerPath: string) => {
if (!transformerPath) return; // Don't add empty transformers
const newProviders = [...config.Providers];
if (!newProviders[providerIndex].transformer) {
newProviders[providerIndex].transformer = { use: [] };
}
// Initialize model transformer if it doesn't exist
if (!newProviders[providerIndex].transformer![model]) {
newProviders[providerIndex].transformer![model] = { use: [] };
}
// Add transformer to the use array
newProviders[providerIndex].transformer![model].use = [...newProviders[providerIndex].transformer![model].use, transformerPath];
setConfig({ ...config, Providers: newProviders });
};
const removeModelTransformerAtIndex = (providerIndex: number, model: string, transformerIndex: number) => {
const newProviders = [...config.Providers];
if (newProviders[providerIndex].transformer && newProviders[providerIndex].transformer![model]) {
const newUseArray = [...newProviders[providerIndex].transformer![model].use];
newUseArray.splice(transformerIndex, 1);
newProviders[providerIndex].transformer![model].use = newUseArray;
// If use array is now empty and no other properties, remove model transformer entirely
if (newUseArray.length === 0 && Object.keys(newProviders[providerIndex].transformer![model]).length === 1) {
delete newProviders[providerIndex].transformer![model];
}
}
setConfig({ ...config, Providers: newProviders });
};
const addProviderTransformerParameter = (providerIndex: number, transformerIndex: number, paramName: string, paramValue: string) => {
const newProviders = [...config.Providers];
if (!newProviders[providerIndex].transformer) {
newProviders[providerIndex].transformer = { use: [] };
}
// Add parameter to the specified transformer in use array
if (newProviders[providerIndex].transformer!.use && newProviders[providerIndex].transformer!.use.length > transformerIndex) {
const targetTransformer = newProviders[providerIndex].transformer!.use[transformerIndex];
// If it's already an array with parameters, update it
if (Array.isArray(targetTransformer)) {
const transformerArray = [...targetTransformer];
// Check if the second element is an object (parameters object)
if (transformerArray.length > 1 && typeof transformerArray[1] === 'object' && transformerArray[1] !== null) {
// Update the existing parameters object
const existingParams = transformerArray[1] as Record<string, unknown>;
const paramsObj: Record<string, unknown> = { ...existingParams, [paramName]: paramValue };
transformerArray[1] = paramsObj;
} else if (transformerArray.length > 1) {
// If there are other elements, add the parameters object
const paramsObj = { [paramName]: paramValue };
transformerArray.splice(1, transformerArray.length - 1, paramsObj);
} else {
// Add a new parameters object
const paramsObj = { [paramName]: paramValue };
transformerArray.push(paramsObj);
}
newProviders[providerIndex].transformer!.use[transformerIndex] = transformerArray as any;
} else {
// Convert to array format with parameters
const paramsObj = { [paramName]: paramValue };
newProviders[providerIndex].transformer!.use[transformerIndex] = [targetTransformer as string, paramsObj] as any;
}
}
setConfig({ ...config, Providers: newProviders });
};
const removeProviderTransformerParameterAtIndex = (providerIndex: number, transformerIndex: number, paramName: string) => {
const newProviders = [...config.Providers];
if (!newProviders[providerIndex].transformer?.use || newProviders[providerIndex].transformer.use.length <= transformerIndex) {
return;
}
const targetTransformer = newProviders[providerIndex].transformer.use[transformerIndex];
if (Array.isArray(targetTransformer) && targetTransformer.length > 1) {
const transformerArray = [...targetTransformer];
// Check if the second element is an object (parameters object)
if (typeof transformerArray[1] === 'object' && transformerArray[1] !== null) {
const paramsObj = { ...(transformerArray[1] as Record<string, unknown>) };
delete paramsObj[paramName];
// If the parameters object is now empty, remove it
if (Object.keys(paramsObj).length === 0) {
transformerArray.splice(1, 1);
} else {
transformerArray[1] = paramsObj;
}
newProviders[providerIndex].transformer!.use[transformerIndex] = transformerArray;
setConfig({ ...config, Providers: newProviders });
}
}
};
const addModelTransformerParameter = (providerIndex: number, model: string, transformerIndex: number, paramName: string, paramValue: string) => {
const newProviders = [...config.Providers];
if (!newProviders[providerIndex].transformer) {
newProviders[providerIndex].transformer = { use: [] };
}
if (!newProviders[providerIndex].transformer![model]) {
newProviders[providerIndex].transformer![model] = { use: [] };
}
// Add parameter to the specified transformer in use array
if (newProviders[providerIndex].transformer![model].use && newProviders[providerIndex].transformer![model].use.length > transformerIndex) {
const targetTransformer = newProviders[providerIndex].transformer![model].use[transformerIndex];
// If it's already an array with parameters, update it
if (Array.isArray(targetTransformer)) {
const transformerArray = [...targetTransformer];
// Check if the second element is an object (parameters object)
if (transformerArray.length > 1 && typeof transformerArray[1] === 'object' && transformerArray[1] !== null) {
// Update the existing parameters object
const existingParams = transformerArray[1] as Record<string, unknown>;
const paramsObj: Record<string, unknown> = { ...existingParams, [paramName]: paramValue };
transformerArray[1] = paramsObj;
} else if (transformerArray.length > 1) {
// If there are other elements, add the parameters object
const paramsObj = { [paramName]: paramValue };
transformerArray.splice(1, transformerArray.length - 1, paramsObj);
} else {
// Add a new parameters object
const paramsObj = { [paramName]: paramValue };
transformerArray.push(paramsObj);
}
newProviders[providerIndex].transformer![model].use[transformerIndex] = transformerArray as any;
} else {
// Convert to array format with parameters
const paramsObj = { [paramName]: paramValue };
newProviders[providerIndex].transformer![model].use[transformerIndex] = [targetTransformer as string, paramsObj] as any;
}
}
setConfig({ ...config, Providers: newProviders });
};
const removeModelTransformerParameterAtIndex = (providerIndex: number, model: string, transformerIndex: number, paramName: string) => {
const newProviders = [...config.Providers];
if (!newProviders[providerIndex].transformer?.[model]?.use || newProviders[providerIndex].transformer[model].use.length <= transformerIndex) {
return;
}
const targetTransformer = newProviders[providerIndex].transformer[model].use[transformerIndex];
if (Array.isArray(targetTransformer) && targetTransformer.length > 1) {
const transformerArray = [...targetTransformer];
// Check if the second element is an object (parameters object)
if (typeof transformerArray[1] === 'object' && transformerArray[1] !== null) {
const paramsObj = { ...(transformerArray[1] as Record<string, unknown>) };
delete paramsObj[paramName];
// If the parameters object is now empty, remove it
if (Object.keys(paramsObj).length === 0) {
transformerArray.splice(1, 1);
} else {
transformerArray[1] = paramsObj;
}
newProviders[providerIndex].transformer![model].use[transformerIndex] = transformerArray;
setConfig({ ...config, Providers: newProviders });
}
}
};
const handleAddModel = (index: number, model: string) => {
if (!model.trim()) return;
const newProviders = [...config.Providers];
const models = [...newProviders[index].models];
// Check if model already exists
if (!models.includes(model.trim())) {
models.push(model.trim());
newProviders[index].models = models;
setConfig({ ...config, Providers: newProviders });
}
};
const handleRemoveModel = (providerIndex: number, modelIndex: number) => {
const newProviders = [...config.Providers];
const models = [...newProviders[providerIndex].models];
models.splice(modelIndex, 1);
newProviders[providerIndex].models = models;
setConfig({ ...config, Providers: newProviders });
};
const editingProvider = editingProviderIndex !== null ? config.Providers[editingProviderIndex] : null;
return (
<Card className="flex h-full flex-col rounded-lg border shadow-sm">
<CardHeader className="flex flex-row items-center justify-between border-b p-4">
<CardTitle className="text-lg">{t("providers.title")} <span className="text-sm font-normal text-gray-500">({config.Providers.length})</span></CardTitle>
<Button onClick={handleAddProvider}>{t("providers.add")}</Button>
</CardHeader>
<CardContent className="flex-grow overflow-y-auto p-4">
<ProviderList
providers={config.Providers}
onEdit={setEditingProviderIndex}
onRemove={setDeletingProviderIndex}
/>
</CardContent>
{/* Edit Dialog */}
<Dialog open={editingProviderIndex !== null} onOpenChange={(open) => {
if (!open) {
handleCancelAddProvider();
}
}}>
<DialogContent className="max-h-[80vh] flex flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("providers.edit")}</DialogTitle>
</DialogHeader>
{editingProvider && editingProviderIndex !== null && (
<div className="space-y-4 p-4 overflow-y-auto flex-grow">
<div className="space-y-2">
<Label htmlFor="name">{t("providers.name")}</Label>
<Input id="name" value={editingProvider.name} onChange={(e) => handleProviderChange(editingProviderIndex, 'name', e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="api_base_url">{t("providers.api_base_url")}</Label>
<Input id="api_base_url" value={editingProvider.api_base_url} onChange={(e) => handleProviderChange(editingProviderIndex, 'api_base_url', e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="api_key">{t("providers.api_key")}</Label>
<Input id="api_key" type="password" value={editingProvider.api_key} onChange={(e) => handleProviderChange(editingProviderIndex, 'api_key', e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="models">{t("providers.models")}</Label>
<div className="space-y-2">
<div className="flex gap-2">
<div className="flex-1">
{hasFetchedModels[editingProviderIndex] ? (
<ComboInput
ref={comboInputRef}
options={editingProvider.models.map(model => ({ label: model, value: model }))}
value=""
onChange={(_) => {
// 只更新输入值,不添加模型
}}
onEnter={(value) => {
if (editingProviderIndex !== null) {
handleAddModel(editingProviderIndex, value);
}
}}
inputPlaceholder={t("providers.models_placeholder")}
/>
) : (
<Input
id="models"
placeholder={t("providers.models_placeholder")}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.currentTarget.value.trim() && editingProviderIndex !== null) {
handleAddModel(editingProviderIndex, e.currentTarget.value);
e.currentTarget.value = '';
}
}}
/>
)}
</div>
<Button
onClick={() => {
if (hasFetchedModels[editingProviderIndex] && comboInputRef.current) {
// 使用ComboInput的逻辑
const comboInput = comboInputRef.current as any;
const currentValue = comboInput.getCurrentValue();
if (currentValue && currentValue.trim() && editingProviderIndex !== null) {
handleAddModel(editingProviderIndex, currentValue.trim());
// 清空ComboInput
comboInput.clearInput();
}
} else {
// 使用普通Input的逻辑
const input = document.getElementById('models') as HTMLInputElement;
if (input && input.value.trim() && editingProviderIndex !== null) {
handleAddModel(editingProviderIndex, input.value);
input.value = '';
}
}
}}
>
{t("providers.add_model")}
</Button>
{/* <Button
onClick={() => editingProvider && fetchAvailableModels(editingProvider)}
disabled={isFetchingModels}
variant="outline"
>
{isFetchingModels ? t("providers.fetching_models") : t("providers.fetch_available_models")}
</Button> */}
</div>
<div className="flex flex-wrap gap-2 pt-2">
{editingProvider.models.map((model, modelIndex) => (
<Badge key={modelIndex} variant="outline" className="font-normal flex items-center gap-1">
{model}
<button
type="button"
className="ml-1 rounded-full hover:bg-gray-200"
onClick={() => editingProviderIndex !== null && handleRemoveModel(editingProviderIndex, modelIndex)}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
</div>
</div>
{/* Provider Transformer Selection */}
<div className="space-y-2">
<Label>{t("providers.provider_transformer")}</Label>
{/* Add new transformer */}
<div className="flex gap-2">
<Combobox
options={config.transformers.map(t => ({
label: t.path.split('/').pop() || t.path,
value: t.path
}))}
value=""
onChange={(value) => {
if (editingProviderIndex !== null) {
handleProviderTransformerChange(editingProviderIndex, value);
}
}}
placeholder={t("providers.select_transformer")}
emptyPlaceholder={t("providers.no_transformers")}
/>
</div>
{/* Display existing transformers */}
{editingProvider.transformer?.use && editingProvider.transformer.use.length > 0 && (
<div className="space-y-2 mt-2">
<div className="text-sm font-medium text-gray-700">{t("providers.selected_transformers")}</div>
{editingProvider.transformer.use.map((transformer: any, transformerIndex: number) => (
<div key={transformerIndex} className="border rounded-md p-3">
<div className="flex gap-2 items-center mb-2">
<div className="flex-1 bg-gray-50 rounded p-2 text-sm">
{typeof transformer === 'string' ? transformer : Array.isArray(transformer) ? String(transformer[0]) : String(transformer)}
</div>
<Button
variant="outline"
size="icon"
onClick={() => {
if (editingProviderIndex !== null) {
removeProviderTransformerAtIndex(editingProviderIndex, transformerIndex);
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Transformer-specific Parameters */}
<div className="mt-2 pl-4 border-l-2 border-gray-200">
<Label className="text-sm">{t("providers.transformer_parameters")}</Label>
<div className="space-y-2 mt-1">
<div className="flex gap-2">
<Input
placeholder={t("providers.parameter_name")}
value={providerParamInputs[`provider-${editingProviderIndex}-transformer-${transformerIndex}`]?.name || ""}
onChange={(e) => {
const key = `provider-${editingProviderIndex}-transformer-${transformerIndex}`;
setProviderParamInputs(prev => ({
...prev,
[key]: {
...prev[key] || {name: "", value: ""},
name: e.target.value
}
}));
}}
/>
<Input
placeholder={t("providers.parameter_value")}
value={providerParamInputs[`provider-${editingProviderIndex}-transformer-${transformerIndex}`]?.value || ""}
onChange={(e) => {
const key = `provider-${editingProviderIndex}-transformer-${transformerIndex}`;
setProviderParamInputs(prev => ({
...prev,
[key]: {
...prev[key] || {name: "", value: ""},
value: e.target.value
}
}));
}}
/>
<Button
size="sm"
onClick={() => {
if (editingProviderIndex !== null) {
const key = `provider-${editingProviderIndex}-transformer-${transformerIndex}`;
const paramInput = providerParamInputs[key];
if (paramInput && paramInput.name && paramInput.value) {
addProviderTransformerParameter(editingProviderIndex, transformerIndex, paramInput.name, paramInput.value);
setProviderParamInputs(prev => ({
...prev,
[key]: {name: "", value: ""}
}));
}
}
}}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* Display existing parameters for this transformer */}
{(() => {
// Get parameters for this specific transformer
if (!editingProvider.transformer?.use || editingProvider.transformer.use.length <= transformerIndex) {
return null;
}
const targetTransformer = editingProvider.transformer.use[transformerIndex];
let params = {};
if (Array.isArray(targetTransformer) && targetTransformer.length > 1) {
// Check if the second element is an object (parameters object)
if (typeof targetTransformer[1] === 'object' && targetTransformer[1] !== null) {
params = targetTransformer[1] as Record<string, unknown>;
}
}
return Object.keys(params).length > 0 ? (
<div className="space-y-1">
{Object.entries(params).map(([key, value]) => (
<div key={key} className="flex items-center justify-between bg-gray-50 rounded p-2">
<div className="text-sm">
<span className="font-medium">{key}:</span> {String(value)}
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => {
if (editingProviderIndex !== null) {
// We need a function to remove parameters from a specific transformer
removeProviderTransformerParameterAtIndex(editingProviderIndex, transformerIndex, key);
}
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
) : null;
})()}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Model-specific Transformers */}
{editingProvider.models.length > 0 && (
<div className="space-y-2">
<Label>{t("providers.model_transformers")}</Label>
<div className="space-y-3">
{editingProvider.models.map((model, modelIndex) => (
<div key={modelIndex} className="border rounded-md p-3">
<div className="font-medium text-sm mb-2">{model}</div>
{/* Add new transformer */}
<div className="flex gap-2">
<div className="flex-1 flex gap-2">
<Combobox
options={config.transformers.map(t => ({
label: t.path.split('/').pop() || t.path,
value: t.path
}))}
value=""
onChange={(value) => {
if (editingProviderIndex !== null) {
handleModelTransformerChange(editingProviderIndex, model, value);
}
}}
placeholder={t("providers.select_transformer")}
emptyPlaceholder={t("providers.no_transformers")}
/>
</div>
</div>
{/* Display existing transformers */}
{editingProvider.transformer?.[model]?.use && editingProvider.transformer[model].use.length > 0 && (
<div className="space-y-2 mt-2">
<div className="text-sm font-medium text-gray-700">{t("providers.selected_transformers")}</div>
{editingProvider.transformer[model].use.map((transformer: any, transformerIndex: number) => (
<div key={transformerIndex} className="border rounded-md p-3">
<div className="flex gap-2 items-center mb-2">
<div className="flex-1 bg-gray-50 rounded p-2 text-sm">
{typeof transformer === 'string' ? transformer : Array.isArray(transformer) ? String(transformer[0]) : String(transformer)}
</div>
<Button
variant="outline"
size="icon"
onClick={() => {
if (editingProviderIndex !== null) {
removeModelTransformerAtIndex(editingProviderIndex, model, transformerIndex);
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Transformer-specific Parameters */}
<div className="mt-2 pl-4 border-l-2 border-gray-200">
<Label className="text-sm">{t("providers.transformer_parameters")}</Label>
<div className="space-y-2 mt-1">
<div className="flex gap-2">
<Input
placeholder={t("providers.parameter_name")}
value={modelParamInputs[`model-${editingProviderIndex}-${model}-transformer-${transformerIndex}`]?.name || ""}
onChange={(e) => {
const key = `model-${editingProviderIndex}-${model}-transformer-${transformerIndex}`;
setModelParamInputs(prev => ({
...prev,
[key]: {
...prev[key] || {name: "", value: ""},
name: e.target.value
}
}));
}}
/>
<Input
placeholder={t("providers.parameter_value")}
value={modelParamInputs[`model-${editingProviderIndex}-${model}-transformer-${transformerIndex}`]?.value || ""}
onChange={(e) => {
const key = `model-${editingProviderIndex}-${model}-transformer-${transformerIndex}`;
setModelParamInputs(prev => ({
...prev,
[key]: {
...prev[key] || {name: "", value: ""},
value: e.target.value
}
}));
}}
/>
<Button
size="sm"
onClick={() => {
if (editingProviderIndex !== null) {
const key = `model-${editingProviderIndex}-${model}-transformer-${transformerIndex}`;
const paramInput = modelParamInputs[key];
if (paramInput && paramInput.name && paramInput.value) {
addModelTransformerParameter(editingProviderIndex, model, transformerIndex, paramInput.name, paramInput.value);
setModelParamInputs(prev => ({
...prev,
[key]: {name: "", value: ""}
}));
}
}
}}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* Display existing parameters for this transformer */}
{(() => {
// Get parameters for this specific transformer
if (!editingProvider.transformer?.[model]?.use || editingProvider.transformer[model].use.length <= transformerIndex) {
return null;
}
const targetTransformer = editingProvider.transformer[model].use[transformerIndex];
let params = {};
if (Array.isArray(targetTransformer) && targetTransformer.length > 1) {
// Check if the second element is an object (parameters object)
if (typeof targetTransformer[1] === 'object' && targetTransformer[1] !== null) {
params = targetTransformer[1] as Record<string, unknown>;
}
}
return Object.keys(params).length > 0 ? (
<div className="space-y-1">
{Object.entries(params).map(([key, value]) => (
<div key={key} className="flex items-center justify-between bg-gray-50 rounded p-2">
<div className="text-sm">
<span className="font-medium">{key}:</span> {String(value)}
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => {
if (editingProviderIndex !== null) {
// We need a function to remove parameters from a specific transformer
removeModelTransformerParameterAtIndex(editingProviderIndex, model, transformerIndex, key);
}
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
) : null;
})()}
</div>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
<div className="space-y-3 mt-auto">
<div className="flex justify-end gap-2">
{/* <Button
variant="outline"
onClick={() => editingProvider && testConnectivity(editingProvider)}
disabled={isTestingConnectivity || !editingProvider}
>
<Wifi className="mr-2 h-4 w-4" />
{isTestingConnectivity ? t("providers.testing") : t("providers.test_connectivity")}
</Button> */}
<Button onClick={handleSaveProvider}>{t("app.save")}</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deletingProviderIndex !== null} onOpenChange={() => setDeletingProviderIndex(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("providers.delete")}</DialogTitle>
<DialogDescription>
{t("providers.delete_provider_confirm")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingProviderIndex(null)}>{t("providers.cancel")}</Button>
<Button variant="destructive" onClick={() => deletingProviderIndex !== null && handleRemoveProvider(deletingProviderIndex)}>{t("providers.delete")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -0,0 +1,91 @@
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { useConfig } from "./ConfigProvider";
import { Combobox } from "./ui/combobox";
export function Router() {
const { t } = useTranslation();
const { config, setConfig } = useConfig();
if (!config) {
return null;
}
const handleRouterChange = (field: string, value: string) => {
const newRouter = { ...config.Router, [field]: value };
setConfig({ ...config, Router: newRouter });
};
const modelOptions = config.Providers.flatMap((provider) =>
provider.models.map((model) => ({
value: `${provider.name},${model}`,
label: `${provider.name}, ${model}`,
}))
);
return (
<Card className="flex h-full flex-col rounded-lg border shadow-sm">
<CardHeader className="border-b p-4">
<CardTitle className="text-lg">{t("router.title")}</CardTitle>
</CardHeader>
<CardContent className="flex-grow space-y-5 overflow-y-auto p-4">
<div className="space-y-2">
<Label>{t("router.default")}</Label>
<Combobox
options={modelOptions}
value={config.Router.default}
onChange={(value) => handleRouterChange("default", value)}
placeholder={t("router.selectModel")}
searchPlaceholder={t("router.searchModel")}
emptyPlaceholder={t("router.noModelFound")}
/>
</div>
<div className="space-y-2">
<Label>{t("router.background")}</Label>
<Combobox
options={modelOptions}
value={config.Router.background}
onChange={(value) => handleRouterChange("background", value)}
placeholder={t("router.selectModel")}
searchPlaceholder={t("router.searchModel")}
emptyPlaceholder={t("router.noModelFound")}
/>
</div>
<div className="space-y-2">
<Label>{t("router.think")}</Label>
<Combobox
options={modelOptions}
value={config.Router.think}
onChange={(value) => handleRouterChange("think", value)}
placeholder={t("router.selectModel")}
searchPlaceholder={t("router.searchModel")}
emptyPlaceholder={t("router.noModelFound")}
/>
</div>
<div className="space-y-2">
<Label>{t("router.longContext")}</Label>
<Combobox
options={modelOptions}
value={config.Router.longContext}
onChange={(value) => handleRouterChange("longContext", value)}
placeholder={t("router.selectModel")}
searchPlaceholder={t("router.searchModel")}
emptyPlaceholder={t("router.noModelFound")}
/>
</div>
<div className="space-y-2">
<Label>{t("router.webSearch")}</Label>
<Combobox
options={modelOptions}
value={config.Router.webSearch}
onChange={(value) => handleRouterChange("webSearch", value)}
placeholder={t("router.selectModel")}
searchPlaceholder={t("router.searchModel")}
emptyPlaceholder={t("router.noModelFound")}
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,71 @@
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useConfig } from "./ConfigProvider";
interface SettingsDialogProps {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
}
export function SettingsDialog({ isOpen, onOpenChange }: SettingsDialogProps) {
const { t } = useTranslation();
const { config, setConfig } = useConfig();
if (!config) {
return null;
}
const handleLogChange = (checked: boolean) => {
setConfig({ ...config, LOG: checked });
};
const handlePathChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setConfig({ ...config, CLAUDE_PATH: e.target.value });
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("toplevel.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center space-x-2">
<Switch id="log" checked={config.LOG} onCheckedChange={handleLogChange} />
<Label htmlFor="log" className="transition-all-ease hover:scale-[1.02] cursor-pointer">{t("toplevel.log")}</Label>
</div>
<div className="space-y-2">
<Label htmlFor="claude-path" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.claude_path")}</Label>
<Input id="claude-path" value={config.CLAUDE_PATH} onChange={handlePathChange} className="transition-all-ease focus:scale-[1.01]" />
</div>
<div className="space-y-2">
<Label htmlFor="host" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.host")}</Label>
<Input id="host" value={config.HOST} onChange={(e) => setConfig({ ...config, HOST: e.target.value })} className="transition-all-ease focus:scale-[1.01]" />
</div>
<div className="space-y-2">
<Label htmlFor="port" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.port")}</Label>
<Input id="port" type="number" value={config.PORT} onChange={(e) => setConfig({ ...config, PORT: parseInt(e.target.value, 10) })} className="transition-all-ease focus:scale-[1.01]" />
</div>
<div className="space-y-2">
<Label htmlFor="apikey" className="transition-all-ease hover:scale-[1.01] cursor-pointer">{t("toplevel.apikey")}</Label>
<Input id="apikey" type="password" value={config.APIKEY} onChange={(e) => setConfig({ ...config, APIKEY: e.target.value })} className="transition-all-ease focus:scale-[1.01]" />
</div>
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)} className="transition-all-ease hover:scale-[1.02] active:scale-[0.98]">{t("app.save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,32 @@
import { Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { type Transformer } from "./ConfigProvider";
interface TransformerListProps {
transformers: Transformer[];
onEdit: (index: number) => void;
onRemove: (index: number) => void;
}
export function TransformerList({ transformers, onEdit, onRemove }: TransformerListProps) {
return (
<div className="space-y-3">
{transformers.map((transformer, index) => (
<div key={index} className="flex items-start justify-between rounded-md border bg-white p-4 transition-all hover:shadow-md animate-slide-in hover:scale-[1.01]">
<div className="flex-1 space-y-1.5">
<p className="text-md font-semibold text-gray-800">{transformer.path}</p>
<p className="text-sm text-gray-500">{transformer.options.project}</p>
</div>
<div className="ml-4 flex flex-shrink-0 items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => onEdit(index)} className="transition-all-ease hover:scale-110">
<Pencil className="h-4 w-4" />
</Button>
<Button variant="destructive" size="icon" onClick={() => onRemove(index)} className="transition-all duration-200 hover:scale-110">
<Trash2 className="h-4 w-4 text-current transition-colors duration-200" />
</Button>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,220 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Plus, Trash2 } from "lucide-react";
import { useConfig } from "./ConfigProvider";
import { TransformerList } from "./TransformerList";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export function Transformers() {
const { t } = useTranslation();
const { config, setConfig } = useConfig();
const [editingTransformerIndex, setEditingTransformerIndex] = useState<number | null>(null);
const [deletingTransformerIndex, setDeletingTransformerIndex] = useState<number | null>(null);
const [newTransformer, setNewTransformer] = useState<{ path: string; options: { [key: string]: string } } | null>(null);
if (!config) {
return null;
}
const handleAddTransformer = () => {
const newTransformer = { path: "", options: {} };
setNewTransformer(newTransformer);
setEditingTransformerIndex(config.transformers.length); // Use the length as index for the new item
};
const handleRemoveTransformer = (index: number) => {
const newTransformers = [...config.transformers];
newTransformers.splice(index, 1);
setConfig({ ...config, transformers: newTransformers });
setDeletingTransformerIndex(null);
};
const handleTransformerChange = (index: number, field: string, value: string, optionKey?: string) => {
if (index < config.transformers.length) {
// Editing an existing transformer
const newTransformers = [...config.transformers];
if (optionKey !== undefined) {
newTransformers[index].options[optionKey] = value;
} else {
(newTransformers[index] as Record<string, unknown>)[field] = value;
}
setConfig({ ...config, transformers: newTransformers });
} else {
// Editing the new transformer
if (newTransformer) {
const updatedTransformer = { ...newTransformer };
if (optionKey !== undefined) {
updatedTransformer.options[optionKey] = value;
} else {
(updatedTransformer as Record<string, unknown>)[field] = value;
}
setNewTransformer(updatedTransformer);
}
}
};
const editingTransformer = editingTransformerIndex !== null ?
(editingTransformerIndex < config.transformers.length ?
config.transformers[editingTransformerIndex] :
newTransformer) :
null;
const handleSaveTransformer = () => {
if (newTransformer && editingTransformerIndex === config.transformers.length) {
// Saving a new transformer
const newTransformers = [...config.transformers, newTransformer];
setConfig({ ...config, transformers: newTransformers });
}
// Close the dialog
setEditingTransformerIndex(null);
setNewTransformer(null);
};
const handleCancelTransformer = () => {
// Close the dialog without saving
setEditingTransformerIndex(null);
setNewTransformer(null);
};
return (
<Card className="flex h-full flex-col rounded-lg border shadow-sm">
<CardHeader className="flex flex-row items-center justify-between border-b p-4">
<CardTitle className="text-lg">{t("transformers.title")} <span className="text-sm font-normal text-gray-500">({config.transformers.length})</span></CardTitle>
<Button onClick={handleAddTransformer}>{t("transformers.add")}</Button>
</CardHeader>
<CardContent className="flex-grow overflow-y-auto p-4">
<TransformerList
transformers={config.transformers}
onEdit={setEditingTransformerIndex}
onRemove={setDeletingTransformerIndex}
/>
</CardContent>
{/* Edit Dialog */}
<Dialog open={editingTransformerIndex !== null} onOpenChange={handleCancelTransformer}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("transformers.edit")}</DialogTitle>
</DialogHeader>
{editingTransformer && editingTransformerIndex !== null && (
<div className="space-y-4 py-4 px-6 max-h-96 overflow-y-auto">
<div className="space-y-2">
<Label htmlFor="transformer-path">{t("transformers.path")}</Label>
<Input
id="transformer-path"
value={editingTransformer.path}
onChange={(e) => handleTransformerChange(editingTransformerIndex, "path", e.target.value)}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>{t("transformers.parameters")}</Label>
<Button
variant="outline"
size="sm"
onClick={() => {
const newKey = `param${Object.keys(editingTransformer.options).length + 1}`;
if (editingTransformerIndex !== null) {
const newOptions = { ...editingTransformer.options, [newKey]: "" };
if (editingTransformerIndex < config.transformers.length) {
const newTransformers = [...config.transformers];
newTransformers[editingTransformerIndex].options = newOptions;
setConfig({ ...config, transformers: newTransformers });
} else if (newTransformer) {
setNewTransformer({ ...newTransformer, options: newOptions });
}
}
}}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{Object.entries(editingTransformer.options).map(([key, value]) => (
<div key={key} className="flex items-center gap-2">
<Input
value={key}
onChange={(e) => {
const newOptions = { ...editingTransformer.options };
delete newOptions[key];
newOptions[e.target.value] = value;
if (editingTransformerIndex !== null) {
if (editingTransformerIndex < config.transformers.length) {
const newTransformers = [...config.transformers];
newTransformers[editingTransformerIndex].options = newOptions;
setConfig({ ...config, transformers: newTransformers });
} else if (newTransformer) {
setNewTransformer({ ...newTransformer, options: newOptions });
}
}
}}
className="flex-1"
/>
<Input
value={value}
onChange={(e) => {
if (editingTransformerIndex !== null) {
handleTransformerChange(editingTransformerIndex, "options", e.target.value, key);
}
}}
className="flex-1"
/>
<Button
variant="outline"
size="icon"
onClick={() => {
if (editingTransformerIndex !== null) {
const newOptions = { ...editingTransformer.options };
delete newOptions[key];
if (editingTransformerIndex < config.transformers.length) {
const newTransformers = [...config.transformers];
newTransformers[editingTransformerIndex].options = newOptions;
setConfig({ ...config, transformers: newTransformers });
} else if (newTransformer) {
setNewTransformer({ ...newTransformer, options: newOptions });
}
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={handleCancelTransformer}>{t("app.cancel")}</Button>
<Button onClick={handleSaveTransformer}>{t("app.save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deletingTransformerIndex !== null} onOpenChange={() => setDeletingTransformerIndex(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("transformers.delete")}</DialogTitle>
<DialogDescription>
{t("transformers.delete_transformer_confirm")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingTransformerIndex(null)}>{t("app.cancel")}</Button>
<Button variant="destructive" onClick={() => deletingTransformerIndex !== null && handleRemoveTransformer(deletingTransformerIndex)}>{t("app.delete")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -0,0 +1,38 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
// eslint-disable-next-line react-refresh/only-export-components
export { Badge, badgeVariants }

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"border border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
// eslint-disable-next-line react-refresh/only-export-components
export { Button, buttonVariants }

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import { Check, ChevronsUpDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface ComboInputProps {
options: { label: string; value: string }[];
value?: string;
onChange: (value: string) => void;
onEnter?: (value: string) => void;
searchPlaceholder?: string;
emptyPlaceholder?: string;
inputPlaceholder?: string;
}
export const ComboInput = React.forwardRef<HTMLInputElement, ComboInputProps>(({
options,
value,
onChange,
onEnter,
searchPlaceholder = "Search...",
emptyPlaceholder = "No options found.",
inputPlaceholder = "Type or select...",
}, ref) => {
const [open, setOpen] = React.useState(false)
const [inputValue, setInputValue] = React.useState(value || "")
const internalInputRef = React.useRef<HTMLInputElement>(null)
// Forward ref to the internal input
React.useImperativeHandle(ref, () => internalInputRef.current as HTMLInputElement)
React.useEffect(() => {
setInputValue(value || "")
}, [value])
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
setInputValue(newValue)
onChange(newValue)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && inputValue.trim() && onEnter) {
onEnter(inputValue.trim())
setInputValue("")
}
}
const handleSelect = (selectedValue: string) => {
setInputValue(selectedValue)
onChange(selectedValue)
if (onEnter) {
onEnter(selectedValue)
setInputValue("")
}
setOpen(false)
}
// Function to get current value for external access
const getCurrentValue = () => inputValue
// Expose methods through the ref
React.useImperativeHandle(ref, () => ({
...internalInputRef.current!,
value: inputValue,
getCurrentValue,
clearInput: () => {
setInputValue("")
onChange("")
}
}))
return (
<div className="relative">
<Input
ref={internalInputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={inputPlaceholder}
className="pr-10"
/>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
>
<ChevronsUpDown className="h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0 animate-fade-in">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyPlaceholder}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => handleSelect(option.value)}
className="transition-all-ease hover:bg-accent hover:text-accent-foreground"
>
<Check
className={cn(
"mr-2 h-4 w-4 transition-opacity",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)
})

View File

@@ -0,0 +1,87 @@
"use client"
import * as React from "react"
import { Check, ChevronsUpDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface ComboboxProps {
options: { label: string; value: string }[];
value?: string;
onChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyPlaceholder?: string;
}
export function Combobox({
options,
value,
onChange,
placeholder = "Select an option...",
searchPlaceholder = "Search...",
emptyPlaceholder = "No options found.",
}: ComboboxProps) {
const [open, setOpen] = React.useState(false)
const selectedOption = options.find((option) => option.value === value)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between transition-all-ease hover:scale-[1.02] active:scale-[0.98]"
>
{selectedOption ? selectedOption.label : placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0 animate-fade-in">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyPlaceholder}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
onChange(currentValue === value ? "" : currentValue)
setOpen(false)
}}
className="transition-all-ease hover:bg-accent hover:text-accent-foreground"
>
<Check
className={cn(
"mr-2 h-4 w-4 transition-opacity",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,181 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,125 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<(
React.ElementRef<typeof DialogPrimitive.Overlay>
), (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
)>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<(
React.ElementRef<typeof DialogPrimitive.Content>
), (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
)>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-300 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg animate-scale-in",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground transition-all-ease hover:scale-110">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<(
React.ElementRef<typeof DialogPrimitive.Title>
), (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
)>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<(
React.ElementRef<typeof DialogPrimitive.Description>
), (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
)>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,114 @@
"use client"
import * as React from "react"
import { Check, ChevronsUpDown, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Badge } from "@/components/ui/badge"
interface MultiComboboxProps {
options: { label: string; value: string }[];
value?: string[];
onChange: (value: string[]) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyPlaceholder?: string;
}
export function MultiCombobox({
options,
value = [],
onChange,
placeholder = "Select options...",
searchPlaceholder = "Search...",
emptyPlaceholder = "No options found.",
}: MultiComboboxProps) {
const [open, setOpen] = React.useState(false)
const handleSelect = (currentValue: string) => {
if (value.includes(currentValue)) {
onChange(value.filter(v => v !== currentValue))
} else {
onChange([...value, currentValue])
}
}
const removeValue = (val: string, e: React.MouseEvent) => {
e.stopPropagation()
onChange(value.filter(v => v !== val))
}
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1">
{value.map((val) => {
const option = options.find(opt => opt.value === val)
return (
<Badge key={val} variant="outline" className="font-normal">
{option?.label || val}
<button
onClick={(e) => removeValue(val, e)}
className="ml-1 rounded-full hover:bg-gray-200"
>
<X className="h-3 w-3" />
</button>
</Badge>
)
})}
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between transition-all-ease hover:scale-[1.02] active:scale-[0.98]"
>
{value.length > 0 ? `${value.length} selected` : placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0 animate-fade-in">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyPlaceholder}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => handleSelect(option.value)}
className="transition-all-ease hover:bg-accent hover:text-accent-foreground"
>
<Check
className={cn(
"mr-2 h-4 w-4 transition-opacity",
value.includes(option.value) ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden animate-fade-in",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-all-ease focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 transition-all-ease"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,59 @@
import { useEffect } from 'react';
import { CheckCircle, XCircle, AlertCircle, X } from 'lucide-react';
interface ToastProps {
message: string;
type: 'success' | 'error' | 'warning';
onClose: () => void;
}
export function Toast({ message, type, onClose }: ToastProps) {
useEffect(() => {
const timer = setTimeout(() => {
onClose();
}, 3000);
return () => clearTimeout(timer);
}, [onClose]);
const getIcon = () => {
switch (type) {
case 'success':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'error':
return <XCircle className="h-5 w-5 text-red-500" />;
case 'warning':
return <AlertCircle className="h-5 w-5 text-yellow-500" />;
default:
return null;
}
};
const getBackgroundColor = () => {
switch (type) {
case 'success':
return 'bg-green-100 border-green-200';
case 'error':
return 'bg-red-100 border-red-200';
case 'warning':
return 'bg-yellow-100 border-yellow-200';
default:
return 'bg-gray-100 border-gray-200';
}
};
return (
<div className={`fixed top-4 right-4 z-50 flex items-center justify-between p-4 rounded-lg border shadow-lg ${getBackgroundColor()} transition-all duration-300 ease-in-out`}>
<div className="flex items-center space-x-2">
{getIcon()}
<span className="text-sm font-medium">{message}</span>
</div>
<button
onClick={onClose}
className="ml-4 text-gray-500 hover:text-gray-700 focus:outline-none"
>
<X className="h-4 w-4" />
</button>
</div>
);
}

28
ui/src/i18n.ts Normal file
View File

@@ -0,0 +1,28 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import en from "./locales/en.json";
import zh from "./locales/zh.json";
const resources = {
en: {
translation: en,
},
zh: {
translation: zh,
},
};
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
});
export default i18n;

122
ui/src/index.css Normal file
View File

@@ -0,0 +1,122 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

189
ui/src/lib/api.ts Normal file
View File

@@ -0,0 +1,189 @@
import type { Config, Provider, Transformer } from '@/components/ConfigProvider';
// API Client Class for handling requests with baseUrl and apikey authentication
class ApiClient {
private baseUrl: string;
private apiKey: string;
constructor(baseUrl: string = 'http://127.0.0.1:3456/api', apiKey: string = '') {
this.baseUrl = baseUrl;
// Load API key from localStorage if available
this.apiKey = apiKey || localStorage.getItem('apiKey') || '';
}
// Update base URL
setBaseUrl(url: string) {
this.baseUrl = url;
}
// Update API key
setApiKey(apiKey: string) {
this.apiKey = apiKey;
// Save API key to localStorage
if (apiKey) {
localStorage.setItem('apiKey', apiKey);
} else {
localStorage.removeItem('apiKey');
}
}
// Create headers with API key authentication
private createHeaders(contentType: string = 'application/json'): HeadersInit {
const headers: Record<string, string> = {
'X-API-Key': this.apiKey,
'Accept': 'application/json',
};
if (contentType) {
headers['Content-Type'] = contentType;
}
return headers;
}
// Generic fetch wrapper with base URL and authentication
private async apiFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const config: RequestInit = {
...options,
headers: {
...this.createHeaders(),
...options.headers,
},
};
try {
const response = await fetch(url, config);
// Handle 401 Unauthorized responses
if (response.status === 401) {
// Remove API key when it's invalid
localStorage.removeItem('apiKey');
// Redirect to login page if not already there
// For memory router, we need to use the router instance
// We'll dispatch a custom event that the app can listen to
window.dispatchEvent(new CustomEvent('unauthorized'));
// Return a promise that never resolves to prevent further execution
return new Promise(() => {}) as Promise<T>;
}
if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
if (response.status === 204) {
return {} as T;
}
const text = await response.text();
return text ? JSON.parse(text) : ({} as T);
} catch (error) {
console.error('API request error:', error);
throw error;
}
}
// GET request
async get<T>(endpoint: string): Promise<T> {
return this.apiFetch<T>(endpoint, {
method: 'GET',
});
}
// POST request
async post<T>(endpoint: string, data: unknown): Promise<T> {
return this.apiFetch<T>(endpoint, {
method: 'POST',
body: JSON.stringify(data),
});
}
// PUT request
async put<T>(endpoint: string, data: unknown): Promise<T> {
return this.apiFetch<T>(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
});
}
// DELETE request
async delete<T>(endpoint: string): Promise<T> {
return this.apiFetch<T>(endpoint, {
method: 'DELETE',
});
}
// API methods for configuration
// Get current configuration
async getConfig(): Promise<Config> {
return this.get<Config>('/config');
}
// Update entire configuration
async updateConfig(config: Config): Promise<Config> {
return this.post<Config>('/config', config);
}
// Get providers
async getProviders(): Promise<Provider[]> {
return this.get<Provider[]>('/api/providers');
}
// Add a new provider
async addProvider(provider: Provider): Promise<Provider> {
return this.post<Provider>('/api/providers', provider);
}
// Update a provider
async updateProvider(index: number, provider: Provider): Promise<Provider> {
return this.post<Provider>(`/api/providers/${index}`, provider);
}
// Delete a provider
async deleteProvider(index: number): Promise<void> {
return this.delete<void>(`/api/providers/${index}`);
}
// Get transformers
async getTransformers(): Promise<Transformer[]> {
return this.get<Transformer[]>('/api/transformers');
}
// Add a new transformer
async addTransformer(transformer: Transformer): Promise<Transformer> {
return this.post<Transformer>('/api/transformers', transformer);
}
// Update a transformer
async updateTransformer(index: number, transformer: Transformer): Promise<Transformer> {
return this.post<Transformer>(`/api/transformers/${index}`, transformer);
}
// Delete a transformer
async deleteTransformer(index: number): Promise<void> {
return this.delete<void>(`/api/transformers/${index}`);
}
// Get configuration (new endpoint)
async getConfigNew(): Promise<Config> {
return this.get<Config>('/config');
}
// Save configuration (new endpoint)
async saveConfig(config: Config): Promise<unknown> {
return this.post<Config>('/config', config);
}
// Restart service
async restartService(): Promise<unknown> {
return this.post<void>('/restart', {});
}
}
// Create a default instance of the API client
export const api = new ApiClient();
// Export the class for creating custom instances
export default ApiClient;

6
ui/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

91
ui/src/locales/en.json Normal file
View File

@@ -0,0 +1,91 @@
{
"app": {
"title": "Configuration",
"save": "Save",
"save_and_restart": "Save and Restart",
"cancel": "Cancel",
"edit": "Edit",
"remove": "Remove",
"delete": "Delete",
"settings": "Settings",
"selectFile": "Select File",
"config_saved_success": "Config saved successfully",
"config_saved_failed": "Failed to save config",
"config_saved_restart_success": "Config saved and service restarted successfully",
"config_saved_restart_failed": "Failed to save config and restart service"
},
"login": {
"title": "Sign in to your account",
"description": "Enter your API key to access the configuration panel",
"apiKey": "API Key",
"apiKeyPlaceholder": "Enter your API key",
"signIn": "Sign In",
"invalidApiKey": "Invalid API key",
"configError": "Configuration not loaded",
"validating": "Validating API key..."
},
"toplevel": {
"title": "General Settings",
"log": "Enable Logging",
"claude_path": "Claude Path",
"host": "Host",
"port": "Port",
"apikey": "API Key"
},
"transformers": {
"title": "Custom Transformers",
"path": "Path",
"project": "Project",
"remove": "Remove",
"add": "Add Custom Transformer",
"edit": "Edit Custom Transformer",
"delete": "Delete Custom Transformer",
"delete_transformer_confirm": "Are you sure you want to delete this custom transformer?",
"parameters": "Parameters"
},
"providers": {
"title": "Providers",
"name": "Name",
"api_base_url": "API Base URL",
"api_key": "API Key",
"models": "Models",
"models_placeholder": "Enter model name and press Enter to add",
"add_model": "Add Model",
"select_models": "Select Models",
"remove": "Remove",
"add": "Add Provider",
"edit": "Edit Provider",
"delete": "Delete",
"cancel": "Cancel",
"delete_provider_confirm": "Are you sure you want to delete this provider?",
"test_connectivity": "Test Connectivity",
"testing": "Testing...",
"connection_successful": "Connection successful!",
"connection_failed": "Connection failed!",
"missing_credentials": "Missing API base URL or API key",
"fetch_available_models": "Fetch available models",
"fetching_models": "Fetching models...",
"fetch_models_failed": "Failed to fetch models",
"transformers": "Transformers",
"select_transformer": "Select Transformer",
"no_transformers": "No transformers available",
"provider_transformer": "Provider Transformer",
"model_transformers": "Model Transformers",
"transformer_parameters": "Transformer Parameters",
"add_parameter": "Add Parameter",
"parameter_name": "Parameter Name",
"parameter_value": "Parameter Value",
"selected_transformers": "Selected Transformers"
},
"router": {
"title": "Router",
"default": "Default",
"background": "Background",
"think": "Think",
"longContext": "Long Context",
"webSearch": "Web Search",
"selectModel": "Select a model...",
"searchModel": "Search model...",
"noModelFound": "No model found."
}
}

91
ui/src/locales/zh.json Normal file
View File

@@ -0,0 +1,91 @@
{
"app": {
"title": "配置",
"save": "保存",
"save_and_restart": "保存并重启",
"cancel": "取消",
"edit": "编辑",
"remove": "移除",
"delete": "删除",
"settings": "设置",
"selectFile": "选择文件",
"config_saved_success": "配置保存成功",
"config_saved_failed": "配置保存失败",
"config_saved_restart_success": "配置保存并服务重启成功",
"config_saved_restart_failed": "配置保存并服务重启失败"
},
"login": {
"title": "登录到您的账户",
"description": "请输入您的API密钥以访问配置面板",
"apiKey": "API密钥",
"apiKeyPlaceholder": "请输入您的API密钥",
"signIn": "登录",
"invalidApiKey": "API密钥无效",
"configError": "配置未加载",
"validating": "正在验证API密钥..."
},
"toplevel": {
"title": "通用设置",
"log": "启用日志",
"claude_path": "Claude 路径",
"host": "主机",
"port": "端口",
"apikey": "API 密钥"
},
"transformers": {
"title": "自定义转换器",
"path": "路径",
"project": "项目",
"remove": "移除",
"add": "添加自定义转换器",
"edit": "编辑自定义转换器",
"delete": "删除自定义转换器",
"delete_transformer_confirm": "您确定要删除此自定义转换器吗?",
"parameters": "参数"
},
"providers": {
"title": "供应商",
"name": "名称",
"api_base_url": "API 基础地址",
"api_key": "API 密钥",
"models": "模型",
"models_placeholder": "输入模型名称并按回车键添加",
"add_model": "添加模型",
"select_models": "选择模型",
"remove": "移除",
"add": "添加供应商",
"edit": "编辑供应商",
"delete": "删除",
"cancel": "取消",
"delete_provider_confirm": "您确定要删除此供应商吗?",
"test_connectivity": "测试连通性",
"testing": "测试中...",
"connection_successful": "连接成功!",
"connection_failed": "连接失败!",
"missing_credentials": "缺少 API 基础地址或 API 密钥",
"fetch_available_models": "获取可用模型",
"fetching_models": "获取模型中...",
"fetch_models_failed": "获取模型失败",
"transformers": "转换器",
"select_transformer": "选择转换器",
"no_transformers": "无可用转换器",
"provider_transformer": "供应商转换器",
"model_transformers": "模型转换器",
"transformer_parameters": "转换器参数",
"add_parameter": "添加参数",
"parameter_name": "参数名称",
"parameter_value": "参数值",
"selected_transformers": "已选转换器"
},
"router": {
"title": "路由",
"default": "默认",
"background": "后台",
"think": "思考",
"longContext": "长上下文",
"webSearch": "网络搜索",
"selectModel": "选择一个模型...",
"searchModel": "搜索模型...",
"noModelFound": "未找到模型."
}
}

15
ui/src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import './i18n';
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import { RouterProvider } from 'react-router-dom';
import { router } from './routes';
import { ConfigProvider } from '@/components/ConfigProvider';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ConfigProvider>
<RouterProvider router={router} />
</ConfigProvider>
</StrictMode>,
)

32
ui/src/routes.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { createMemoryRouter, Navigate } from 'react-router-dom';
import App from './App';
import { Login } from '@/components/Login';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
// For this application, we allow access without an API key
// The App component will handle loading and error states
return children;
};
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
// Always show login page
// The login page will handle empty API keys appropriately
return children;
};
export const router = createMemoryRouter([
{
path: '/',
element: <Navigate to="/dashboard" replace />,
},
{
path: '/login',
element: <PublicRoute><Login /></PublicRoute>,
},
{
path: '/dashboard',
element: <ProtectedRoute><App /></ProtectedRoute>,
},
], {
initialEntries: ['/dashboard']
});

View File

@@ -0,0 +1,48 @@
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-fade-in {
animation: fadeIn 0.2s ease-out forwards;
}
.animate-scale-in {
animation: scaleIn 0.2s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.animate-slide-in {
animation: slideIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.transition-all-ease {
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}

1
ui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />