码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/web/src/pages/Settings.tsx
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>
  )
}