码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/web/src/pages/SmartPage.tsx
SmartPage.tsx9.5 KB
import { useEffect, useState } from 'react'
import { api } from '@/lib/api'
import { useAuth } from '@/lib/auth'
import { Loader2, Sparkles, Check, Smile, Flame, Zap, Coffee, Briefcase, Heart, Crown } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import MemoryPanel from '@/pages/MemoryPanel'

const PERSONAS = [
  { id: '', name: '默认', desc: '无特殊风格,自然得体', icon: Smile, color: 'text-muted-foreground', bg: 'bg-muted/50' },
  { id: 'warm', name: '温柔体贴', desc: '像知心朋友,多用"呢""呀""哦",让人感到温暖和被理解', icon: Coffee, color: 'text-amber-500', bg: 'bg-amber-50' },
  { id: 'cool', name: '高冷干练', desc: '技术大佬风,一针见血少废话,偶尔冷幽默', icon: Zap, color: 'text-sky-500', bg: 'bg-sky-50' },
  { id: 'humorous', name: '幽默风趣', desc: '段子手朋友,俏皮话谐音梗,让人开心放松', icon: Flame, color: 'text-orange-500', bg: 'bg-orange-50' },
  { id: 'professional', name: '专业严谨', desc: '资深顾问风,条理清晰有逻辑,像和客户沟通', icon: Briefcase, color: 'text-indigo-500', bg: 'bg-indigo-50' },
  { id: 'cute', name: '软萌可爱', desc: '暖心小萌宠,叠词语气词治愈系', icon: Heart, color: 'text-pink-500', bg: 'bg-pink-50' },
  { id: 'otaku', name: '二次元萌系', desc: '元气动漫伙伴,"喵""捏""ww""诶嘿"', icon: Sparkles, color: 'text-purple-500', bg: 'bg-purple-50' },
  { id: 'tsundere', name: '傲娇系', desc: '嘴上不饶人但内心关心,"才…才不是因为关心你呢!"', icon: Crown, color: 'text-rose-500', bg: 'bg-rose-50' },
]

/** 账号级 AI 风格设置(非按机器人) */
function PersonaPanel() {
  const { me, refresh } = useAuth()
  const [selected, setSelected] = useState('')
  const [saving, setSaving] = useState(false)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    if (me) {
      setSelected(me.ai_persona || '')
      setLoading(false)
    }
  }, [me])

  async function save() {
    setSaving(true)
    try {
      const d = await api.post('/api/persona', { persona: selected })
      if (!d.ok) throw new Error(d.msg || '保存失败')
      toast.success('AI 风格已保存')
      await refresh()
    } catch (e: any) {
      toast.error(e.message || '保存失败')
    } finally {
      setSaving(false)
    }
  }

  if (loading) {
    return (
      <div className="flex h-64 items-center justify-center">
        <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
      </div>
    )
  }

  return (
    <div className="space-y-4">
      <Card>
        <CardHeader className="pb-3">
          <CardTitle className="text-base">AI 风格 / 人格</CardTitle>
          <CardDescription>选择 AI 助手说话风格,所有机器人统一使用此人格,切换后新对话立即生效。</CardDescription>
        </CardHeader>
        <CardContent>
          <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
            {PERSONAS.map(p => {
              const Icon = p.icon
              const isActive = selected === p.id
              return (
                <Card
                  key={p.id}
                  className={cn(
                    'cursor-pointer transition-all hover:shadow-md',
                    isActive && 'ring-2 ring-primary border-primary'
                  )}
                  onClick={() => setSelected(p.id)}
                >
                  <CardHeader className="pb-2">
                    <div className="flex items-start justify-between">
                      <div className={cn('flex h-10 w-10 items-center justify-center rounded-lg', p.bg, p.color)}>
                        <Icon className="h-5 w-5" />
                      </div>
                      {isActive && (
                        <div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
                          <Check className="h-3.5 w-3.5" />
                        </div>
                      )}
                    </div>
                    <CardTitle className="mt-2 text-base">{p.name}</CardTitle>
                    <CardDescription className="text-xs leading-relaxed">{p.desc}</CardDescription>
                  </CardHeader>
                </Card>
              )
            })}
          </div>

          <div className="mt-6 flex items-center justify-between border-t pt-4">
            <p className="text-xs text-muted-foreground">
              当前:<span className="font-medium text-foreground">{PERSONAS.find(p => p.id === selected)?.name || '默认'}</span>
            </p>
            <Button onClick={save} disabled={saving}>
              {saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
              保存设置
            </Button>
          </div>
        </CardContent>
      </Card>
    </div>
  )
}

export default function SmartPage() {
  const [bots, setBots] = useState<any[]>([])
  const [selBotId, setSelBotId] = useState<number>(0)

  useEffect(() => {
    api.post('/api/me').then((d: any) => {
      if (d?.ok) {
        const bs: any[] = d.bots || []
        setBots(bs)
        if (bs[0]) setSelBotId(bs[0].id)
      }
    }).catch(() => {})
  }, [])

  return (
    <div className="mx-auto max-w-none space-y-4 p-4 md:p-6">
      <div className="flex items-center gap-2">
        <Sparkles className="h-6 w-6 text-primary" />
        <h1 className="text-xl font-semibold">智能助手</h1>
        <span className="text-xs text-muted-foreground">内置 AI 助手 · 始终运行</span>
      </div>

      <Tabs defaultValue="model" className="w-full">
        <TabsList>
          <TabsTrigger value="model">模型设置</TabsTrigger>
          <TabsTrigger value="memory">用户记忆</TabsTrigger>
          <TabsTrigger value="companion">主动陪伴</TabsTrigger>
        </TabsList>

        <TabsContent value="model" className="space-y-4">
          {selBotId ? (
            <>
              <Card>
                <CardHeader className="pb-2">
                  <CardTitle className="text-base">AI 模型</CardTitle>
                  <CardDescription className="text-xs">
                    智能助手直接使用站点全局 AI 配置(接口、模型、图片生成等)。请在左侧「AI 配置」菜单中设置,无需在此单独配置。
                  </CardDescription>
                </CardHeader>
              </Card>
              <PersonaPanel />
            </>
          ) : (
            <div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
              <Loader2 className="h-4 w-4 animate-spin" /> 加载机器人中…
            </div>
          )}
        </TabsContent>

        <TabsContent value="memory">
          <MemoryPanel />
        </TabsContent>

        <TabsContent value="companion">
          <CompanionTab />
        </TabsContent>
      </Tabs>
    </div>
  )
}

/** 主动陪伴:用户主动开启后,Bot 会在白天随机发来问候或分享有趣内容 */
function CompanionTab() {
  const { me, refresh } = useAuth()
  const [enabled, setEnabled] = useState<boolean>(!!me?.companion_enabled)
  const [saving, setSaving] = useState(false)

  useEffect(() => {
    setEnabled(!!me?.companion_enabled)
  }, [me?.companion_enabled])

  const toggle = async (v: boolean) => {
    setSaving(true)
    try {
      const r = await api.post('/api/companion', { enabled: v })
      if (r.ok) {
        setEnabled(!!r.companion_enabled)
        await refresh()
        toast.success(v ? '已开启主动陪伴 💬' : '已关闭主动陪伴')
      } else {
        toast.error('设置失败:' + (r.msg || ''))
      }
    } catch {
      toast.error('网络错误,请稍后再试')
    } finally {
      setSaving(false)
    }
  }

  return (
    <Card>
      <CardHeader className="pb-3">
        <CardTitle className="flex items-center gap-2 text-base">
          <Heart className="h-4 w-4 text-rose-500" /> 主动陪伴
        </CardTitle>
        <CardDescription className="leading-relaxed">
          开启后,我会在白天(9:00–23:00)偶尔给你发一条朋友式的问候,
          或者顺手分享一个有趣的内容(如金价、每日简报等),就像朋友偶尔的惦记~
          同一用户至少间隔 4 小时才可能再收到,绝不会频繁打扰。
        </CardDescription>
      </CardHeader>
      <CardContent>
        <div className="flex items-center justify-between rounded-lg border p-4">
          <div className="space-y-0.5">
            <div className="text-sm font-medium">开启主动陪伴</div>
            <div className="text-xs text-muted-foreground">
              默认关闭,需你主动开启。关闭后不再主动发消息。
            </div>
          </div>
          <Switch checked={enabled} onCheckedChange={toggle} disabled={saving} />
        </div>
        <div className="mt-3 text-xs text-muted-foreground">
          当前状态:{enabled ? '已开启 · 我会偶尔来找你聊天 💬' : '已关闭'}
        </div>
      </CardContent>
    </Card>
  )
}