码桶
发现社区成员的开源项目
Settings.tsx12.4 KB
import { useEffect, useState } from 'react'
import { api } from '@/lib/api'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Loader2 } from 'lucide-react'
import { toast } from 'sonner'
function Field({ label, value, onChange, type = 'text', placeholder, autoComplete }: { label: string; value: string; onChange: (v: string) => void; type?: string; placeholder?: string; autoComplete?: string }) {
return (
<div className="space-y-1.5">
<Label>{label}</Label>
<Input type={type} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} autoComplete={autoComplete} className="h-9" />
</div>
)
}
export default function Settings() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null)
const [f, setF] = useState({
ai_api_base: '', ai_api_key: '', ai_model: '',
ai_system_prompt: '', ai_max_tokens: '', ai_temperature: '',
ai_stt_mode: 'local', ai_stt_model: 'Xenova/whisper-base', ai_transcribe_model: 'whisper-1',
ai_stt_mimo_api_key: '', ai_stt_mimo_base: 'https://api.xiaomimimo.com',
ai_stt_mimo_model: 'mimo-v2.5-asr', ai_stt_mimo_lang: 'zh',
ai_endpoints: '[]', ai_endpoint_strategy: 'round_robin',
ai_image_gen_enabled: '1', ai_image_gen_api_base: '', ai_image_gen_api_key: '', ai_image_gen_model: 'dall-e-3', ai_image_gen_size: '1024x1024',
})
// 多接口备选列表(可编辑),保存时序列化为 JSON 写入 f.ai_endpoints
const [eps, setEps] = useState<Array<{ name?: string; api_base: string; api_key: string; model: string }>>([])
useEffect(() => {
setLoading(true)
api.post('/api/ai_settings').then((d: any) => {
if (d?.ok) {
const s = d.settings || {}
setF({
ai_api_base: s.ai_api_base || '', ai_api_key: s.ai_api_key || '', ai_model: s.ai_model || '',
ai_system_prompt: s.ai_system_prompt || '', ai_max_tokens: s.ai_max_tokens || '', ai_temperature: s.ai_temperature || '',
ai_stt_mode: s.ai_stt_mode || 'local', ai_stt_model: s.ai_stt_model || 'Xenova/whisper-base', ai_transcribe_model: s.ai_transcribe_model || 'whisper-1',
ai_stt_mimo_api_key: s.ai_stt_mimo_api_key || '', ai_stt_mimo_base: s.ai_stt_mimo_base || 'https://api.xiaomimimo.com',
ai_stt_mimo_model: s.ai_stt_mimo_model || 'mimo-v2.5-asr', ai_stt_mimo_lang: s.ai_stt_mimo_lang || 'zh',
ai_endpoints: s.ai_endpoints || '[]', ai_endpoint_strategy: s.ai_endpoint_strategy || 'round_robin',
ai_image_gen_enabled: s.ai_image_gen_enabled || '1', ai_image_gen_api_base: s.ai_image_gen_api_base || '', ai_image_gen_api_key: s.ai_image_gen_api_key || '', ai_image_gen_model: s.ai_image_gen_model || 'dall-e-3', ai_image_gen_size: s.ai_image_gen_size || '1024x1024',
})
try { setEps(JSON.parse(s.ai_endpoints || '[]')) } catch { setEps([]) }
}
}).catch(() => {}).finally(() => setLoading(false))
}, [])
const set = (k: string, v: any) => setF({ ...f, [k]: v })
const save = async (payload: any) => {
setSaving(true); setMsg(null)
const d: any = await api.post('/api/ai_settings_save', payload)
setSaving(false)
if (d?.ok) setMsg({ ok: true, text: d.msg || '已保存' })
else setMsg({ ok: false, text: (d && d.msg) || '保存失败' })
}
const aiPanel = (
<div className="space-y-3">
<h3 className="text-sm font-semibold">AI 接口(支持多个备选,自动轮询 + 故障转移)</h3>
<div className="space-y-1.5">
<Label>轮询策略</Label>
<select value={f.ai_endpoint_strategy} onChange={e => set('ai_endpoint_strategy', e.target.value)} className="h-9 w-full rounded-md border bg-background px-3 text-sm">
<option value="round_robin">顺序轮询(均摊请求,负载均衡)</option>
<option value="random">随机选择</option>
<option value="fallback">仅故障转移(优先用第一个,失败才用下一个)</option>
</select>
</div>
{eps.map((e, i) => (
<div key={i} className="space-y-2 rounded-md border p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">接口 {i + 1}</span>
<Button variant="ghost" size="sm" onClick={() => setEps(eps.filter((_, j) => j !== i))}>删除</Button>
</div>
<Field label="名称(可选)" value={e.name || ''} onChange={v => setEps(eps.map((x, j) => j === i ? { ...x, name: v } : x))} placeholder="如 OpenAI / 备用1" />
<Field label="API Base" value={e.api_base || ''} onChange={v => setEps(eps.map((x, j) => j === i ? { ...x, api_base: v } : x))} placeholder="如 https://api.openai.com/v1" />
<Field label="API Key" value={e.api_key || ''} onChange={v => setEps(eps.map((x, j) => j === i ? { ...x, api_key: v } : x))} type="password" autoComplete="new-password" placeholder="sk-..." />
<Field label="模型" value={e.model || ''} onChange={v => setEps(eps.map((x, j) => j === i ? { ...x, model: v } : x))} placeholder="如 gpt-4o-mini" />
</div>
))}
<Button variant="outline" onClick={() => setEps([...eps, { name: '', api_base: '', api_key: '', model: '' }])}>+ 添加 AI 接口</Button>
<div className="space-y-1.5"><Label>系统提示词</Label><Textarea value={f.ai_system_prompt} onChange={e => set('ai_system_prompt', e.target.value)} placeholder="设定 AI 助手的角色、语气与回复规范" /></div>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Max Tokens" value={f.ai_max_tokens} onChange={v => set('ai_max_tokens', v)} placeholder="如 4096" />
<Field label="Temperature" value={f.ai_temperature} onChange={v => set('ai_temperature', v)} placeholder="如 0.7" />
</div>
<Button onClick={() => save({ ...f, ai_endpoints: JSON.stringify(eps), ai_endpoint_strategy: f.ai_endpoint_strategy })} disabled={saving}>{saving ? '保存中…' : '保存 AI 接口配置'}</Button>
</div>
)
const imagePanel = (
<div className="space-y-3">
<h3 className="text-sm font-semibold">AI 图片生成</h3>
<p className="text-xs text-muted-foreground">用户可通过智能助手说「帮我画一张...」来生成图片,每次生图消耗用户生图额度。</p>
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-semibold">启用图片生成</p>
<p className="text-xs text-muted-foreground">关闭后用户无法生成图片</p>
</div>
<label className="relative inline-flex cursor-pointer items-center">
<input type="checkbox" className="peer sr-only" checked={f.ai_image_gen_enabled === '1'} onChange={e => set('ai_image_gen_enabled', e.target.checked ? '1' : '0')} />
<div className="h-5 w-9 rounded-full bg-border transition-colors peer-checked:bg-primary after:absolute after:left-0.5 after:top-0.5 after:h-4 after:w-4 after:rounded-full after:bg-background after:transition-transform peer-checked:after:translate-x-4"></div>
</label>
</div>
<Field label="生图 API Base" value={f.ai_image_gen_api_base} onChange={v => set('ai_image_gen_api_base', v)} placeholder="如 https://api.openai.com" />
<Field label="生图 API Key" value={f.ai_image_gen_api_key} onChange={v => set('ai_image_gen_api_key', v)} placeholder="sk-..." type="password" />
<Field label="生图模型" value={f.ai_image_gen_model} onChange={v => set('ai_image_gen_model', v)} placeholder="如 dall-e-3、flux、sdxl 等" />
<Field label="默认图片尺寸" value={f.ai_image_gen_size} onChange={v => set('ai_image_gen_size', v)} placeholder="如 1024x1024、1792x1024、1024x1792" />
<p className="text-xs text-muted-foreground">图片生成使用独立的 API 接口(不依赖 AI 对话配置),走 OpenAI 兼容的 /v1/images/generations。</p>
<Button onClick={() => save(f)} disabled={saving}>{saving ? '保存中…' : '保存生图配置'}</Button>
</div>
)
const voicePanel = (
<div className="space-y-3">
<h3 className="text-sm font-semibold">语音识别模式</h3>
<div className="space-y-1.5">
<Label>识别方式</Label>
<select value={f.ai_stt_mode} onChange={e => set('ai_stt_mode', e.target.value)} className="h-9 w-full rounded-md border bg-background px-3 text-sm">
<option value="local">本地 Whisper(无需外部接口,常驻内存约 400~700MB)</option>
<option value="ai">AI 接口(省内存,需服务商支持 /v1/audio/transcriptions)</option>
<option value="mimo">MIMO 语音识别(api.xiaomimimo.com,省内存)</option>
</select>
</div>
{f.ai_stt_mode === 'local' ? (
<Field label="本地模型(HuggingFace ID)" value={f.ai_stt_model} onChange={v => set('ai_stt_model', v)} placeholder="如 Xenova/whisper-base" />
) : f.ai_stt_mode === 'ai' ? (
<Field label="转写模型(AI 接口)" value={f.ai_transcribe_model} onChange={v => set('ai_transcribe_model', v)} placeholder="如 whisper-1" />
) : (
<div className="space-y-3 rounded-md border p-3">
<h4 className="text-xs font-semibold text-muted-foreground">MIMO 语音识别配置</h4>
<Field label="API Key" value={f.ai_stt_mimo_api_key} onChange={v => set('ai_stt_mimo_api_key', v)} type="password" autoComplete="new-password" placeholder="MIMO API Key" />
<Field label="API Base" value={f.ai_stt_mimo_base} onChange={v => set('ai_stt_mimo_base', v)} placeholder="https://api.xiaomimimo.com" />
<Field label="模型" value={f.ai_stt_mimo_model} onChange={v => set('ai_stt_mimo_model', v)} placeholder="mimo-v2.5-asr" />
<Field label="语言(asr_options.language)" value={f.ai_stt_mimo_lang} onChange={v => set('ai_stt_mimo_lang', v)} placeholder="zh" />
<p className="text-xs text-muted-foreground">走 /v1/chat/completions,模型 mimo-v2.5-asr,音频以 input_audio(base64) 提交。需填写 API Key。</p>
</div>
)}
<p className="text-xs text-muted-foreground">切换为「AI 接口」可让服务器内存回到约 80MB;保存后即时生效(新语音即走新通道),如需立即释放已加载的本地模型内存,重启一次 bot 即可。</p>
<Button onClick={() => save(f)} disabled={saving}>{saving ? '保存中…' : '保存语音接口配置'}</Button>
</div>
)
return (
<div className="mx-auto w-full max-w-4xl space-y-5 p-3 md:p-6">
<div className="space-y-1">
<h1 className="text-xl font-bold">AI 配置</h1>
<p className="text-sm text-muted-foreground">站点 AI(智能助手)与语音识别配置</p>
</div>
{loading ? (
<div className="flex justify-center py-20"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : (
<Card>
<CardHeader>
<CardTitle>AI 接口配置</CardTitle>
<CardDescription>配置后即时生效,无需重启服务</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{msg && (
<div className={'rounded-md border p-3 text-sm ' + (msg.ok ? 'border-green-500/30 bg-green-500/10 text-green-600' : 'border-red-500/30 bg-red-500/10 text-red-600')}>
{msg.text}
</div>
)}
<Tabs defaultValue="ai" className="w-full">
<TabsList className="mb-2">
<TabsTrigger value="ai">AI 接口</TabsTrigger>
<TabsTrigger value="voice">语音接口</TabsTrigger>
<TabsTrigger value="image">图片生成</TabsTrigger>
</TabsList>
<TabsContent value="ai">{aiPanel}</TabsContent>
<TabsContent value="voice">{voicePanel}</TabsContent>
<TabsContent value="image">{imagePanel}</TabsContent>
</Tabs>
</CardContent>
</Card>
)}
</div>
)
}