194 lines
8.1 KiB
TypeScript
194 lines
8.1 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { X, Save, Bot, Cpu, Key, FileText, Layers } from 'lucide-react';
|
|
import axios from 'axios';
|
|
import { APP_ENDPOINTS } from '../../../config/env';
|
|
import { useAppStore } from '../../../store/appStore';
|
|
import { pickLocale } from '../../../i18n';
|
|
import { managementZhCn } from '../../../i18n/management.zh-cn';
|
|
import { managementEn } from '../../../i18n/management.en';
|
|
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
|
|
import { LucentSelect } from '../../../components/lucent/LucentSelect';
|
|
import { PasswordInput } from '../../../components/PasswordInput';
|
|
|
|
interface CreateBotModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSuccess: () => void;
|
|
}
|
|
|
|
interface NanobotImage {
|
|
tag: string;
|
|
status: string;
|
|
source_dir?: string;
|
|
}
|
|
|
|
export function CreateBotModal({ isOpen, onClose, onSuccess }: CreateBotModalProps) {
|
|
const locale = useAppStore((s) => s.locale);
|
|
const { notify } = useLucentPrompt();
|
|
const t = pickLocale(locale, { 'zh-cn': managementZhCn, en: managementEn }).create;
|
|
const passwordToggleLabels = locale === 'zh'
|
|
? { show: '显示密码', hide: '隐藏密码' }
|
|
: { show: 'Show password', hide: 'Hide password' };
|
|
const [formData, setFormData] = useState({
|
|
id: '',
|
|
name: '',
|
|
llm_provider: 'openai',
|
|
llm_model: 'gpt-4o',
|
|
api_key: '',
|
|
system_prompt: t.systemPrompt,
|
|
image_tag: 'nanobot-base:v0.1.4',
|
|
});
|
|
|
|
const [availableImages, setAvailableImages] = useState<NanobotImage[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
return;
|
|
}
|
|
axios
|
|
.get<NanobotImage[]>(`${APP_ENDPOINTS.apiBase}/images`)
|
|
.then((res) => setAvailableImages(res.data.filter((img) => img.status === 'READY' || img.status === 'UNKNOWN')))
|
|
.catch((err) => console.error('Failed to fetch images', err));
|
|
}, [isOpen]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
await axios.post(`${APP_ENDPOINTS.apiBase}/bots`, formData);
|
|
onSuccess();
|
|
onClose();
|
|
} catch {
|
|
notify(t.createFail, { tone: 'error' });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
|
<div className="w-full max-w-2xl bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200">
|
|
<div className="flex justify-between items-center p-6 border-b border-slate-800 bg-slate-800/50">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-blue-600/20 rounded-lg text-white">
|
|
<Bot className="text-blue-400" size={24} />
|
|
</div>
|
|
<h2 className="text-xl font-bold text-white">{t.title}</h2>
|
|
</div>
|
|
<button onClick={onClose} className="p-2 hover:bg-slate-700 rounded-full transition-colors text-white">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="p-6 space-y-6 overflow-y-auto max-h-[80vh] scrollbar-thin scrollbar-thumb-white/10 pr-2">
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-400">{t.idLabel}</label>
|
|
<input
|
|
required
|
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none transition-all text-white"
|
|
placeholder={t.idPlaceholder}
|
|
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-400">{t.nameLabel}</label>
|
|
<input
|
|
required
|
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none transition-all text-white"
|
|
placeholder={t.namePlaceholder}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-400 flex items-center gap-2">
|
|
<Layers size={14} className="text-blue-500" /> {t.imageLabel}
|
|
</label>
|
|
<LucentSelect
|
|
onChange={(e) => setFormData({ ...formData, image_tag: e.target.value })}
|
|
value={formData.image_tag}
|
|
>
|
|
{availableImages.map((img) => (
|
|
<option key={img.tag} value={img.tag}>
|
|
{img.tag} ({img.source_dir ? `${t.source}: ${img.source_dir}` : t.pypi})
|
|
</option>
|
|
))}
|
|
{availableImages.length === 0 && <option disabled>{t.noImage}</option>}
|
|
</LucentSelect>
|
|
<p className="text-[10px] text-slate-600 italic">{t.imageHint}</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-400 flex items-center gap-2 text-white">
|
|
<Cpu size={14} /> {t.providerLabel}
|
|
</label>
|
|
<LucentSelect
|
|
onChange={(e) => setFormData({ ...formData, llm_provider: e.target.value })}
|
|
>
|
|
<option value="openai">OpenAI</option>
|
|
<option value="vllm">vLLM (OpenAI-compatible)</option>
|
|
<option value="deepseek">DeepSeek</option>
|
|
<option value="kimi">Kimi (Moonshot)</option>
|
|
<option value="minimax">MiniMax</option>
|
|
<option value="ollama">Ollama (Local)</option>
|
|
</LucentSelect>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-400 text-white">{t.modelLabel}</label>
|
|
<input
|
|
required
|
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none transition-all text-white"
|
|
placeholder={t.modelPlaceholder}
|
|
defaultValue="gpt-4o"
|
|
onChange={(e) => setFormData({ ...formData, llm_model: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-400 flex items-center gap-2 text-white">
|
|
<Key size={14} /> API Key
|
|
</label>
|
|
<PasswordInput
|
|
required
|
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none transition-all text-white"
|
|
placeholder="sk-..."
|
|
onChange={(e) => setFormData({ ...formData, api_key: e.target.value })}
|
|
toggleLabels={passwordToggleLabels}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-400 flex items-center gap-2 text-white">
|
|
<FileText size={14} /> {t.soulLabel}
|
|
</label>
|
|
<textarea
|
|
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm h-32 focus:border-blue-500 outline-none transition-all resize-none text-white"
|
|
defaultValue={formData.system_prompt}
|
|
onChange={(e) => setFormData({ ...formData, system_prompt: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
<div className="pt-4 flex gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="flex-1 bg-slate-800 hover:bg-slate-700 py-3 rounded-xl font-bold transition-all text-white"
|
|
>
|
|
{t.cancel}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="flex-1 bg-blue-600 hover:bg-blue-500 py-3 rounded-xl font-bold flex items-center justify-center gap-2 transition-all shadow-lg shadow-blue-900/40 text-white"
|
|
>
|
|
<Save size={18} /> {t.submit}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|