上传完整的WebUI前端仓库

This commit is contained in:
墨梓柒
2026-01-13 06:24:35 +08:00
parent a9187dc312
commit 812296590e
184 changed files with 47854 additions and 1 deletions

View File

@@ -0,0 +1,61 @@
import { useNavigate } from '@tanstack/react-router'
import { Home, Search, ArrowLeft } from 'lucide-react'
import { Button } from '@/components/ui/button'
export function NotFoundPage() {
const navigate = useNavigate()
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-2xl text-center">
{/* 404 大标题 */}
<div className="relative mb-8">
<h1 className="text-[150px] font-black leading-none text-primary/10 select-none sm:text-[200px]">
404
</h1>
<div className="absolute inset-0 flex items-center justify-center">
<Search className="h-20 w-20 text-primary/30 sm:h-24 sm:w-24" />
</div>
</div>
{/* 错误信息 */}
<div className="space-y-4 mb-8">
<h2 className="text-2xl font-bold text-foreground sm:text-3xl">
</h2>
<p className="text-base text-muted-foreground sm:text-lg max-w-md mx-auto">
访 URL
</p>
</div>
{/* 操作按钮 */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Button
size="lg"
onClick={() => navigate({ to: '/' })}
className="gap-2 w-full sm:w-auto"
>
<Home className="h-4 w-4" />
</Button>
<Button
size="lg"
variant="outline"
onClick={() => window.history.back()}
className="gap-2 w-full sm:w-auto"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</div>
{/* 提示信息 */}
<div className="mt-12 pt-8 border-t border-border">
<p className="text-sm text-muted-foreground">
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,883 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { getAnnualReport, type AnnualReportData } from '@/lib/annual-report-api'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { useToast } from '@/hooks/use-toast'
import { toPng } from 'html-to-image'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts'
import {
Clock,
Users,
Brain,
Smile,
Trophy,
Calendar,
MessageSquare,
Zap,
Moon,
Sun,
AtSign,
Heart,
Image as ImageIcon,
Bot,
Download,
Loader2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
// 颜色常量
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8', '#82ca9d']
// 动态比喻生成函数
function getOnlineHoursMetaphor(hours: number): string {
if (hours >= 8760) return "相当于全年无休7x24小时在线"
if (hours >= 5000) return "相当于一位全职员工的年工作时长"
if (hours >= 2000) return "相当于看完了 1000 部电影"
if (hours >= 1000) return "相当于环球飞行 80 次"
if (hours >= 500) return "相当于读完了 100 本书"
if (hours >= 100) return "相当于马拉松跑了 25 次"
return "虽然不多,但每一刻都很珍贵"
}
function getMidnightMetaphor(count: number): string {
if (count >= 1000) return "夜深人静时的知心好友"
if (count >= 500) return "午夜场的常客"
if (count >= 100) return "偶尔熬夜的小伙伴"
if (count >= 50) return "深夜有时也会陪你聊聊"
return "早睡早起,健康作息"
}
function getTokenMetaphor(tokens: number): string {
const millions = tokens / 1000000
if (millions >= 100) return "思考量堪比一座图书馆"
if (millions >= 50) return "相当于写了一部百科全书"
if (millions >= 10) return "脑细胞估计消耗了不少"
if (millions >= 1) return "也算是费了一番脑筋"
return "轻轻松松,游刃有余"
}
function getCostMetaphor(cost: number): string {
if (cost >= 1000) return "这钱够吃一年的泡面了"
if (cost >= 500) return "相当于买了一台游戏机"
if (cost >= 100) return "够请大家喝几杯奶茶"
if (cost >= 50) return "一顿火锅的钱"
if (cost >= 10) return "几杯咖啡的价格"
return "省钱小能手"
}
function getSilenceMetaphor(rate: number): string {
if (rate >= 80) return "沉默是金,惜字如金"
if (rate >= 60) return "话不多但句句到位"
if (rate >= 40) return "该说的时候才开口"
if (rate >= 20) return "能聊的都聊了"
return "话痨本痨,有问必答"
}
function getImageMetaphor(count: number): string {
if (count >= 10000) return "眼睛都快看花了"
if (count >= 5000) return "堪比专业摄影师的阅片量"
if (count >= 1000) return "看图小达人"
if (count >= 500) return "图片鉴赏家"
if (count >= 100) return "偶尔欣赏一下美图"
return "图片?有空再看"
}
function getRejectedMetaphor(count: number): string {
if (count >= 500) return "在不断的纠正中成长"
if (count >= 200) return "学习永无止境"
if (count >= 100) return "虚心接受,积极改正"
if (count >= 50) return "偶尔也会犯错"
if (count >= 10) return "表现还算不错"
return "完美表达,无需纠正"
}
function getExpensiveThinkingMetaphor(cost: number): string {
if (cost >= 1) return "这次思考的价值堪比一顿大餐!"
if (cost >= 0.5) return "为了这个问题,我可是认真思考了!"
if (cost >= 0.1) return "下了点功夫,值得的!"
if (cost >= 0.01) return "花了点小钱,但很值得"
return "小小思考,不足挂齿"
}
function getFavoriteReplyMetaphor(count: number, botName: string): string {
if (count >= 100) return "这句话简直是万能钥匙!"
if (count >= 50) return "百试不爽的经典回复"
if (count >= 20) return `${botName}的口头禅`
if (count >= 10) return "常用语录之一"
return "偶尔用用的小确幸"
}
function getNightOwlMetaphor(isNightOwl: boolean, midnightCount: number): string {
if (isNightOwl) {
if (midnightCount >= 1000) return "深夜的守护者,黑暗中的光芒"
if (midnightCount >= 500) return "月亮是我的好朋友"
if (midnightCount >= 100) return "越夜越精神,夜晚才是主场"
return "偶尔熬夜,享受宁静时光"
} else {
if (midnightCount <= 10) return "作息规律,健康生活的典范"
if (midnightCount <= 50) return "早睡早起,偶尔也会熬个夜"
return "虽然是早起鸟,但也会守候深夜"
}
}
function getBusiestDayMetaphor(count: number): string {
if (count >= 1000) return "忙到飞起,键盘都要冒烟了"
if (count >= 500) return "这天简直是话痨附体"
if (count >= 200) return "社交达人上线"
if (count >= 100) return "比平时活跃不少"
if (count >= 50) return "小忙一下"
return "还算轻松的一天"
}
export function AnnualReportPage() {
const [year] = useState(2025)
const [data, setData] = useState<AnnualReportData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isExporting, setIsExporting] = useState(false)
const [error, setError] = useState<Error | null>(null)
const reportRef = useRef<HTMLDivElement>(null)
const { toast } = useToast()
const loadReport = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
const result = await getAnnualReport(year)
setData(result)
} catch (err) {
setError(err instanceof Error ? err : new Error('获取年度报告失败'))
} finally {
setIsLoading(false)
}
}, [year])
// 导出为图片
const handleExport = useCallback(async () => {
if (!reportRef.current || !data) return
setIsExporting(true)
toast({
title: '正在生成图片',
description: '请稍候...',
})
try {
const element = reportRef.current
// 获取当前主题的背景色
const computedStyle = getComputedStyle(document.documentElement)
const backgroundColor = computedStyle.getPropertyValue('--background').trim()
? `hsl(${computedStyle.getPropertyValue('--background').trim()})`
: (document.documentElement.classList.contains('dark') ? '#0a0a0a' : '#ffffff')
// 保存原始样式
const originalWidth = element.style.width
const originalMaxWidth = element.style.maxWidth
// 临时设置固定宽度以去除左右空白
element.style.width = '1024px'
element.style.maxWidth = '1024px'
const dataUrl = await toPng(element, {
quality: 1,
pixelRatio: 2,
backgroundColor,
cacheBust: true,
filter: (node) => {
// 过滤掉导出按钮
if (node instanceof HTMLElement && node.hasAttribute('data-export-btn')) {
return false
}
return true
},
})
// 恢复原始样式
element.style.width = originalWidth
element.style.maxWidth = originalMaxWidth
// 创建下载链接
const link = document.createElement('a')
link.download = `${data.bot_name}_${data.year}_年度总结.png`
link.href = dataUrl
link.click()
toast({
title: '导出成功',
description: '年度报告已保存为图片',
})
} catch (err) {
console.error('导出图片失败:', err)
toast({
title: '导出失败',
description: '请重试',
variant: 'destructive',
})
} finally {
setIsExporting(false)
}
}, [data, toast])
useEffect(() => {
loadReport()
}, [loadReport])
if (isLoading) {
return <LoadingSkeleton />
}
if (error) {
return (
<div className="flex h-screen items-center justify-center text-red-500">
: {error.message}
</div>
)
}
if (!data) return null
return (
<ScrollArea className="h-[calc(100vh-4rem)]">
<div className="min-h-screen bg-gradient-to-b from-background to-muted/50 p-4 md:p-8 print:p-0" ref={reportRef}>
<div className="mx-auto max-w-5xl space-y-8 print:space-y-4">
{/* 头部 Hero */}
<header className="relative overflow-hidden rounded-3xl bg-primary p-8 text-primary-foreground shadow-2xl print:rounded-none print:shadow-none">
{/* 导出按钮 */}
<div className="absolute right-4 top-4 z-20 print:hidden" data-export-btn>
<Button
variant="secondary"
size="sm"
onClick={handleExport}
disabled={isExporting}
className="gap-2 bg-white/20 hover:bg-white/30 text-white border-white/30"
>
{isExporting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Download className="h-4 w-4" />
</>
)}
</Button>
</div>
<div className="relative z-10 flex flex-col items-center text-center">
<Bot className="mb-4 h-16 w-16 animate-bounce" />
<h1 className="text-4xl font-bold tracking-tighter sm:text-6xl">
{data.bot_name} {data.year}
</h1>
<p className="mt-4 max-w-2xl text-lg opacity-90">
· Connection & Growth
</p>
<div className="mt-6 flex items-center gap-2 text-sm opacity-75">
<Calendar className="h-4 w-4" />
<span>: {data.generated_at}</span>
</div>
</div>
{/* 背景装饰 */}
<div className="absolute -right-20 -top-20 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
<div className="absolute -bottom-20 -left-20 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
</header>
{/* 维度一:时光足迹 */}
<section className="space-y-4 break-inside-avoid">
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
<Clock className="h-8 w-8" />
<h2></h2>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title="年度在线时长"
value={`${data.time_footprint.total_online_hours} 小时`}
description={getOnlineHoursMetaphor(data.time_footprint.total_online_hours)}
icon={<Clock className="h-4 w-4" />}
/>
<StatCard
title="最忙碌的一天"
value={data.time_footprint.busiest_day || 'N/A'}
description={getBusiestDayMetaphor(data.time_footprint.busiest_day_count)}
icon={<Calendar className="h-4 w-4" />}
/>
<StatCard
title="深夜互动 (0-4点)"
value={`${data.time_footprint.midnight_chat_count}`}
description={getMidnightMetaphor(data.time_footprint.midnight_chat_count)}
icon={<Moon className="h-4 w-4" />}
/>
<StatCard
title="作息属性"
value={data.time_footprint.is_night_owl ? '夜猫子' : '早起鸟'}
description={getNightOwlMetaphor(data.time_footprint.is_night_owl, data.time_footprint.midnight_chat_count)}
icon={data.time_footprint.is_night_owl ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
/>
</div>
<Card className="overflow-hidden">
<CardHeader>
<CardTitle>24</CardTitle>
<CardDescription>{data.bot_name}</CardDescription>
</CardHeader>
<CardContent className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data.time_footprint.hourly_distribution.map((count: number, hour: number) => ({ hour: `${hour}`, count }))}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="hour" />
<YAxis />
<Tooltip
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
cursor={{ fill: 'transparent' }}
/>
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{data.time_footprint.first_message_time && (
<Card className="bg-muted/30 border-dashed">
<CardContent className="flex flex-col items-center justify-center p-6 text-center">
<p className="text-muted-foreground mb-2">2025</p>
<div className="text-xl font-bold text-primary mb-1">{data.time_footprint.first_message_time}</div>
<p className="text-lg">
<span className="font-semibold text-foreground">{data.time_footprint.first_message_user}</span>
<span className="italic text-muted-foreground">"{data.time_footprint.first_message_content}"</span>
</p>
</CardContent>
</Card>
)}
</section>
{/* 维度二:社交网络 */}
<section className="space-y-4 break-inside-avoid">
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
<Users className="h-8 w-8" />
<h2></h2>
</div>
<div className="grid gap-4 md:grid-cols-3">
<StatCard
title="社交圈子"
value={`${data.social_network.total_groups} 个群组`}
description={`${data.bot_name}加入的群组总数`}
icon={<Users className="h-4 w-4" />}
/>
<StatCard
title="被呼叫次数"
value={`${data.social_network.at_count + data.social_network.mentioned_count}`}
description="我的名字被大家频繁提起"
icon={<AtSign className="h-4 w-4" />}
/>
<StatCard
title="最长情陪伴"
value={data.social_network.longest_companion_user || 'N/A'}
description={`始终都在,已陪伴 ${data.social_network.longest_companion_days}`}
icon={<Heart className="h-4 w-4 text-red-500" />}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle> TOP5</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{data.social_network.top_groups.length > 0 ? (
data.social_network.top_groups.map((group: { group_id: string; group_name: string; message_count: number; is_webui?: boolean }, index: number) => (
<div key={group.group_id} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge variant={index === 0 ? "default" : "secondary"} className="h-6 w-6 rounded-full p-0 flex items-center justify-center shrink-0">
{index + 1}
</Badge>
<span className="font-medium truncate max-w-[120px]">{group.group_name}</span>
{group.is_webui && (
<Badge variant="outline" className="text-xs px-1.5 py-0 h-5 bg-blue-50 text-blue-600 border-blue-200">
WebUI
</Badge>
)}
</div>
<span className="text-muted-foreground text-sm shrink-0">{group.message_count} </span>
</div>
))
) : (
<div className="text-center text-muted-foreground py-4"></div>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle> TOP5</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{data.social_network.top_users.length > 0 ? (
data.social_network.top_users.map((user: { user_id: string; user_nickname: string; message_count: number; is_webui?: boolean }, index: number) => (
<div key={user.user_id} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge variant={index === 0 ? "default" : "secondary"} className="h-6 w-6 rounded-full p-0 flex items-center justify-center shrink-0">
{index + 1}
</Badge>
<span className="font-medium truncate max-w-[120px]">{user.user_nickname}</span>
{user.is_webui && (
<Badge variant="outline" className="text-xs px-1.5 py-0 h-5 bg-blue-50 text-blue-600 border-blue-200">
WebUI
</Badge>
)}
</div>
<span className="text-muted-foreground text-sm shrink-0">{user.message_count} </span>
</div>
))
) : (
<div className="text-center text-muted-foreground py-4"></div>
)}
</div>
</CardContent>
</Card>
</div>
</section>
{/* 维度三:最强大脑 */}
<section className="space-y-4 break-inside-avoid">
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
<Brain className="h-8 w-8" />
<h2></h2>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title="年度 Token 消耗"
value={(data.brain_power.total_tokens / 1000000).toFixed(2) + ' M'}
description={getTokenMetaphor(data.brain_power.total_tokens)}
icon={<Zap className="h-4 w-4" />}
/>
<StatCard
title="年度总花费"
value={`$${data.brain_power.total_cost.toFixed(2)}`}
description={getCostMetaphor(data.brain_power.total_cost)}
icon={<span className="font-bold">$</span>}
/>
<StatCard
title="高冷指数"
value={`${data.brain_power.silence_rate}%`}
description={getSilenceMetaphor(data.brain_power.silence_rate)}
icon={<Moon className="h-4 w-4" />}
/>
<StatCard
title="最高兴趣值"
value={data.brain_power.max_interest_value ?? 'N/A'}
description={data.brain_power.max_interest_time ? `出现在 ${data.brain_power.max_interest_time}` : '暂无数据'}
icon={<Heart className="h-4 w-4" />}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{data.brain_power.model_distribution.slice(0, 5).map((item: { model: string; count: number }, index: number) => {
const maxCount = data.brain_power.model_distribution[0]?.count || 1
const percentage = Math.round((item.count / maxCount) * 100)
return (
<div key={item.model} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-medium truncate max-w-[200px]">{item.model}</span>
<span className="text-muted-foreground">{item.count.toLocaleString()} </span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full transition-all duration-500"
style={{
width: `${percentage}%`,
backgroundColor: COLORS[index % COLORS.length]
}}
/>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
{/* 最喜欢的回复模型 TOP5 */}
{data.brain_power.top_reply_models && data.brain_power.top_reply_models.length > 0 && (
<Card>
<CardHeader>
<CardTitle> TOP5</CardTitle>
<CardDescription>{data.bot_name}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{data.brain_power.top_reply_models.map((item: { model: string; count: number }, index: number) => {
const maxCount = data.brain_power.top_reply_models[0]?.count || 1
const percentage = Math.round((item.count / maxCount) * 100)
return (
<div key={item.model} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-medium truncate max-w-[200px]">{item.model}</span>
<span className="text-muted-foreground">{item.count.toLocaleString()} </span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full transition-all duration-500"
style={{
width: `${percentage}%`,
backgroundColor: COLORS[index % COLORS.length]
}}
/>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
)}
{/* 烧钱大户 - 只有有有效用户数据时才显示 */}
{data.brain_power.top_token_consumers && data.brain_power.top_token_consumers.length > 0 && (
<Card>
<CardHeader>
<CardTitle> TOP3</CardTitle>
<CardDescription> API </CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{data.brain_power.top_token_consumers.map((consumer: { user_id: string; cost: number; tokens: number }) => (
<div key={consumer.user_id} className="space-y-2">
<div className="flex justify-between text-sm font-medium">
<span> {consumer.user_id}</span>
<span>${consumer.cost.toFixed(2)}</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full bg-primary transition-all duration-500"
style={{ width: `${(consumer.cost / (data.brain_power.top_token_consumers[0]?.cost || 1)) * 100}%` }}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
{/* 最昂贵的思考 & 思考深度 */}
<div className="grid gap-4 md:grid-cols-2">
<Card className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/20 dark:to-orange-950/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">💰</span>
</CardTitle>
</CardHeader>
<CardContent className="text-center">
<div className="text-4xl font-bold text-amber-600 dark:text-amber-400">
${data.brain_power.most_expensive_cost.toFixed(4)}
</div>
{data.brain_power.most_expensive_time && (
<p className="mt-2 text-sm text-muted-foreground">
{data.brain_power.most_expensive_time}
</p>
)}
<p className="mt-4 text-sm text-muted-foreground">
{getExpensiveThinkingMetaphor(data.brain_power.most_expensive_cost)}
</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-indigo-50 to-blue-50 dark:from-indigo-950/20 dark:to-blue-950/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">🧠</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-indigo-600 dark:text-indigo-400">
{data.brain_power.avg_reasoning_length?.toFixed(0) || 0}
</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div>
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{data.brain_power.max_reasoning_length?.toLocaleString() || 0}
</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
{data.brain_power.max_reasoning_time && (
<p className="mt-4 text-center text-xs text-muted-foreground">
{data.brain_power.max_reasoning_time}
</p>
)}
</CardContent>
</Card>
</div>
</section>
{/* 维度四:个性与表达 */}
<section className="space-y-4 break-inside-avoid">
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
<Smile className="h-8 w-8" />
<h2></h2>
</div>
{/* 深夜回复 & 最喜欢的回复 */}
{(data.expression_vibe.late_night_reply || data.expression_vibe.favorite_reply) && (
<div className="grid gap-4 md:grid-cols-2">
{data.expression_vibe.late_night_reply && (
<Card className="bg-gradient-to-br from-indigo-50 to-violet-50 dark:from-indigo-950/20 dark:to-violet-950/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">🌙</span>
</CardTitle>
<CardDescription> {data.expression_vibe.late_night_reply.time}{data.bot_name}...</CardDescription>
</CardHeader>
<CardContent className="text-center">
<p className="text-lg italic text-muted-foreground">
"{data.expression_vibe.late_night_reply.content}"
</p>
<p className="mt-4 text-sm text-muted-foreground">
</p>
</CardContent>
</Card>
)}
{data.expression_vibe.favorite_reply && (
<Card className="bg-gradient-to-br from-rose-50 to-pink-50 dark:from-rose-950/20 dark:to-pink-950/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">💬</span>
</CardTitle>
<CardDescription>使 {data.expression_vibe.favorite_reply.count} </CardDescription>
</CardHeader>
<CardContent className="text-center">
<p className="text-lg font-medium text-primary">
"{data.expression_vibe.favorite_reply.content}"
</p>
<p className="mt-4 text-sm text-muted-foreground">
{getFavoriteReplyMetaphor(data.expression_vibe.favorite_reply.count, data.bot_name)}
</p>
</CardContent>
</Card>
)}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
{/* 使用最多的表情包 TOP3 */}
<Card className="bg-gradient-to-br from-pink-50 to-purple-50 dark:from-pink-950/20 dark:to-purple-950/20">
<CardHeader>
<CardTitle>使 TOP3</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{data.expression_vibe.top_emojis && data.expression_vibe.top_emojis.length > 0 ? (
<div className="flex justify-center gap-4">
{data.expression_vibe.top_emojis.slice(0, 3).map((emoji: { id: number; usage_count: number }, index: number) => (
<div key={emoji.id} className="flex flex-col items-center">
<div className="relative">
<img
src={`/api/webui/emoji/${emoji.id}/thumbnail?original=true`}
alt={`TOP ${index + 1}`}
className="h-24 w-24 rounded-lg object-cover shadow-md transition-transform hover:scale-105"
/>
<Badge
className={cn(
"absolute -top-2 -right-2",
index === 0 ? "bg-yellow-500" : index === 1 ? "bg-gray-400" : "bg-amber-700"
)}
>
{index + 1}
</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">{emoji.usage_count} </p>
</div>
))}
</div>
) : (
<div className="flex h-32 items-center justify-center text-muted-foreground"></div>
)}
</CardContent>
</Card>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>{data.bot_name}使</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{data.expression_vibe.top_expressions.map((exp: { style: string; count: number }, index: number) => (
<Badge
key={exp.style}
variant="outline"
className={cn(
"px-3 py-1 text-sm",
index === 0 && "border-primary bg-primary/10 text-primary text-base px-4 py-2"
)}
>
{exp.style} ({exp.count})
</Badge>
))}
</div>
</CardContent>
</Card>
<div className="grid grid-cols-2 gap-4">
<StatCard
title="图片鉴赏"
value={`${data.expression_vibe.image_processed_count}`}
description={getImageMetaphor(data.expression_vibe.image_processed_count)}
icon={<ImageIcon className="h-4 w-4" />}
/>
<StatCard
title="成长的足迹"
value={`${data.expression_vibe.rejected_expression_count}`}
description={getRejectedMetaphor(data.expression_vibe.rejected_expression_count)}
icon={<Zap className="h-4 w-4" />}
/>
</div>
</div>
</div>
{/* 行动派 */}
{data.expression_vibe.action_types.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl"></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
{data.expression_vibe.action_types.map((action: { action: string; count: number }) => (
<div
key={action.action}
className="flex items-center gap-2 rounded-full bg-primary/10 px-4 py-2"
>
<span className="font-medium text-primary">{action.action}</span>
<Badge variant="secondary">{action.count} </Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
</section>
{/* 维度五:趣味成就 */}
<section className="space-y-4 break-inside-avoid">
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
<Trophy className="h-8 w-8" />
<h2></h2>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card className="col-span-1 md:col-span-2">
<CardHeader>
<CardTitle>"黑话"</CardTitle>
<CardDescription> {data.achievements.new_jargon_count} </CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
{data.achievements.sample_jargons.map((jargon: { content: string; meaning: string; count: number }) => (
<div key={jargon.content} className="group relative rounded-lg border bg-card p-3 shadow-sm transition-all hover:shadow-md">
<div className="font-bold text-primary">{jargon.content}</div>
<div className="text-xs text-muted-foreground mt-1 line-clamp-2 max-w-[200px]">
{jargon.meaning || '暂无解释'}
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="flex flex-col justify-center items-center bg-primary text-primary-foreground">
<CardContent className="flex flex-col items-center justify-center p-6 text-center">
<MessageSquare className="h-12 w-12 mb-4 opacity-80" />
<div className="text-4xl font-bold mb-2">{data.achievements.total_messages.toLocaleString()}</div>
<div className="text-sm opacity-80"></div>
<div className="mt-4 text-xs opacity-60">
{data.achievements.total_replies.toLocaleString()}
</div>
</CardContent>
</Card>
</div>
</section>
{/* 底部 */}
<footer className="mt-12 text-center text-muted-foreground">
<p>MaiBot 2025 Annual Report</p>
<p className="text-sm">Generated with by MaiBot Team</p>
</footer>
</div>
</div>
</ScrollArea>
)
}
function StatCard({
title,
value,
description,
icon,
}: {
title: string
value: string | number
description: string
icon: React.ReactNode
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<div className="text-muted-foreground">{icon}</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
<p className="text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
)
}
function LoadingSkeleton() {
return (
<div className="container mx-auto space-y-8 p-8">
<Skeleton className="h-64 w-full rounded-3xl" />
<div className="grid gap-4 md:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
<Skeleton className="h-96 w-full" />
</div>
)
}

View File

@@ -0,0 +1,342 @@
import { useState, useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { Key, Lock, AlertCircle, Moon, Sun, HelpCircle, FileText, Terminal, Zap } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { WavesBackground } from '@/components/waves-background'
import { useAnimation } from '@/hooks/use-animation'
import { useTheme } from '@/components/use-theme'
import { checkAuthStatus } from '@/lib/fetch-with-auth'
import { cn } from '@/lib/utils'
import { APP_FULL_NAME } from '@/lib/version'
export function AuthPage() {
const [token, setToken] = useState('')
const [isValidating, setIsValidating] = useState(false)
const [error, setError] = useState('')
const [checkingAuth, setCheckingAuth] = useState(true)
const navigate = useNavigate()
const { enableWavesBackground, setEnableWavesBackground } = useAnimation()
const { theme, setTheme } = useTheme()
// 如果已经认证,直接跳转到首页
useEffect(() => {
const verifyAuth = async () => {
try {
const isAuth = await checkAuthStatus()
if (isAuth) {
navigate({ to: '/' })
}
} catch {
// 忽略错误,保持在登录页
} finally {
setCheckingAuth(false)
}
}
verifyAuth()
}, [navigate])
// 获取实际应用的主题(处理 system 情况)
const getActualTheme = () => {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
return theme
}
const actualTheme = getActualTheme()
// 主题切换(无动画)
const toggleTheme = () => {
const newTheme = actualTheme === 'dark' ? 'light' : 'dark'
setTheme(newTheme)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!token.trim()) {
setError('请输入 Access Token')
return
}
setIsValidating(true)
console.log('开始验证 token...')
try {
// 向后端发送请求验证 token后端会设置 HttpOnly Cookie
const response = await fetch('/api/webui/auth/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // 确保接收并存储 Cookie
body: JSON.stringify({ token: token.trim() }),
})
console.log('Token 验证响应状态:', response.status)
const data = await response.json()
console.log('Token 验证响应数据:', data)
if (response.ok && data.valid) {
console.log('Token 验证成功,准备跳转...')
console.log('is_first_setup:', data.is_first_setup)
// Token 验证成功Cookie 已由后端设置
// 等待一小段时间确保 Cookie 已设置
await new Promise(resolve => setTimeout(resolve, 100))
// 再次检查认证状态
const authCheck = await checkAuthStatus()
console.log('跳转前认证状态检查:', authCheck)
// 直接使用验证响应中的 is_first_setup 字段,避免额外请求
if (data.is_first_setup) {
console.log('跳转到首次配置页面')
// 需要首次配置,跳转到配置向导
navigate({ to: '/setup' })
} else {
console.log('跳转到首页')
// 不需要配置或配置已完成,跳转到首页
navigate({ to: '/' })
}
} else {
console.error('Token 验证失败:', data.message)
setError(data.message || 'Token 验证失败,请检查后重试')
}
} catch (err) {
console.error('Token 验证错误:', err)
setError('连接服务器失败,请检查网络连接')
} finally {
setIsValidating(false)
}
}
// 正在检查认证状态时显示加载
if (checkingAuth) {
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
{enableWavesBackground && <WavesBackground />}
<div className="text-muted-foreground">...</div>
</div>
)
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
{/* 波浪背景 - 独立控制 */}
{enableWavesBackground && <WavesBackground />}
{/* 认证卡片 - 磨砂玻璃效果 */}
<Card className="relative z-10 w-full max-w-md shadow-2xl backdrop-blur-xl bg-card/80 border-border/50">
{/* 主题切换按钮 */}
<button
onClick={toggleTheme}
className="absolute right-4 top-4 rounded-lg p-2 hover:bg-accent transition-colors z-10 text-foreground"
title={actualTheme === 'dark' ? '切换到浅色模式' : '切换到深色模式'}
>
{actualTheme === 'dark' ? (
<Sun className="h-5 w-5" strokeWidth={2.5} fill="none" />
) : (
<Moon className="h-5 w-5" strokeWidth={2.5} fill="none" />
)}
</button>
<CardHeader className="space-y-4 text-center">
{/* Logo/Icon */}
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
<Lock className="h-8 w-8 text-primary" strokeWidth={2} fill="none" />
</div>
<div className="space-y-2">
<CardTitle className="text-2xl font-bold">使 MaiBot</CardTitle>
<CardDescription className="text-base">
Access Token 访
</CardDescription>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Token 输入框 */}
<div className="space-y-2">
<Label htmlFor="token" className="text-sm font-medium">
Access Token
</Label>
<div className="relative">
<Key className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" strokeWidth={2} fill="none" />
<Input
id="token"
type="password"
placeholder="请输入您的 Access Token"
value={token}
onChange={(e) => setToken(e.target.value)}
className={cn('pl-10', error && 'border-red-500 focus-visible:ring-red-500')}
disabled={isValidating}
autoFocus
autoComplete="off"
/>
</div>
</div>
{/* 错误提示 */}
{error && (
<div className="flex items-center gap-2 rounded-md bg-red-50 p-3 text-sm text-red-600 dark:bg-red-950/50 dark:text-red-400">
<AlertCircle className="h-4 w-4 flex-shrink-0" strokeWidth={2} fill="none" />
<span>{error}</span>
</div>
)}
{/* 提交按钮 */}
<Button type="submit" className="w-full" disabled={isValidating}>
{isValidating ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
...
</>
) : (
'验证并进入'
)}
</Button>
{/* 帮助文本 */}
<Dialog>
<DialogTrigger asChild>
<button className="w-full text-center text-sm text-primary hover:text-primary/80 transition-colors underline-offset-4 hover:underline flex items-center justify-center gap-1">
<HelpCircle className="h-4 w-4" strokeWidth={2} fill="none" />
Token Token
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Lock className="h-5 w-5 text-primary" strokeWidth={2} fill="none" />
Access Token
</DialogTitle>
<DialogDescription>
Access Token 访 MaiBot WebUI
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 方式一:查看控制台 */}
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
<div className="flex items-start gap-3">
<Terminal className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" strokeWidth={2} fill="none" />
<div className="flex-1 space-y-2">
<h4 className="font-semibold text-sm"></h4>
<p className="text-sm text-muted-foreground">
MaiBot WebUI Access Token
</p>
<div className="rounded bg-background p-2 font-mono text-xs">
<p className="text-muted-foreground">🔑 WebUI Access Token: abc123...</p>
<p className="text-muted-foreground">💡 使 Token WebUI</p>
</div>
</div>
</div>
</div>
{/* 方式二:查看配置文件 */}
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
<div className="flex items-start gap-3">
<FileText className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" strokeWidth={2} fill="none" />
<div className="flex-1 space-y-2">
<h4 className="font-semibold text-sm"></h4>
<p className="text-sm text-muted-foreground">
Token
</p>
<div className="rounded bg-background p-2 font-mono text-xs break-all">
<code className="text-primary">data/webui.json</code>
</div>
<p className="text-xs text-muted-foreground">
<code className="px-1 py-0.5 bg-background rounded">access_token</code>
</p>
</div>
</div>
</div>
{/* 安全提示 */}
<div className="rounded-lg border border-yellow-200 dark:border-yellow-900 bg-yellow-50 dark:bg-yellow-950/30 p-3">
<div className="flex gap-2">
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" strokeWidth={2} fill="none" />
<div className="text-sm text-yellow-800 dark:text-yellow-300 space-y-1">
<p className="font-semibold"></p>
<ul className="list-disc list-inside space-y-0.5 text-xs">
<li> Token</li>
<li> Token</li>
</ul>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
{/* 性能优化选项 */}
<AlertDialog>
<AlertDialogTrigger asChild>
<button className="w-full text-center text-sm text-muted-foreground hover:text-foreground transition-colors underline-offset-4 hover:underline flex items-center justify-center gap-1">
<Zap className="h-4 w-4" strokeWidth={2} fill="none" />
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Zap className="h-5 w-5 text-primary" strokeWidth={2} fill="none" />
</AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
<p className="text-sm text-muted-foreground">
使
</p>
</div>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => setEnableWavesBackground(false)}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</form>
</CardContent>
</Card>
{/* 页脚信息 */}
<div className="absolute bottom-4 left-0 right-0 text-center text-xs text-muted-foreground">
<p>{APP_FULL_NAME}</p>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
/**
* 适配器配置模块
*
* 模块结构:
* - types.ts: 类型定义和默认配置
* - utils.ts: TOML 解析和验证工具函数
*/
export * from './types'
export * from './utils'

View File

@@ -0,0 +1,105 @@
/**
* 适配器配置类型定义
*/
import { Package, Container } from 'lucide-react'
/**
* 完整的适配器配置接口
*/
export interface AdapterConfig {
inner: {
version: string
}
nickname: {
nickname: string
}
napcat_server: {
host: string
port: number
token: string
heartbeat_interval: number
}
maibot_server: {
host: string
port: number
}
chat: {
group_list_type: 'whitelist' | 'blacklist'
group_list: number[]
private_list_type: 'whitelist' | 'blacklist'
private_list: number[]
ban_user_id: number[]
ban_qq_bot: boolean
enable_poke: boolean
}
voice: {
use_tts: boolean
}
forward: {
image_threshold: number
}
debug: {
level: string
}
}
/**
* 默认配置
*/
export const DEFAULT_CONFIG: AdapterConfig = {
inner: {
version: '0.1.2',
},
nickname: {
nickname: '',
},
napcat_server: {
host: 'localhost',
port: 8095,
token: '',
heartbeat_interval: 30,
},
maibot_server: {
host: 'localhost',
port: 8000,
},
chat: {
group_list_type: 'whitelist',
group_list: [],
private_list_type: 'whitelist',
private_list: [],
ban_user_id: [],
ban_qq_bot: false,
enable_poke: true,
},
voice: {
use_tts: false,
},
forward: {
image_threshold: 30,
},
debug: {
level: 'INFO',
},
}
/**
* 预设配置定义
*/
export const PRESETS = {
oneclick: {
name: '一键包',
description: '使用一键包部署的适配器配置',
path: '../MaiBot-Napcat-Adapter/config.toml',
icon: Package,
},
docker: {
name: 'Docker',
description: 'Docker Compose 部署的适配器配置',
path: '/MaiMBot/adapters-config/config.toml',
icon: Container,
},
} as const
export type PresetKey = keyof typeof PRESETS

View File

@@ -0,0 +1,285 @@
/**
* 适配器配置 TOML 处理工具
* 使用 smol-toml 库进行可靠的 TOML 解析和生成
*/
import { parse, stringify } from 'smol-toml'
import type { AdapterConfig } from './types'
import { DEFAULT_CONFIG } from './types'
/**
* 解析 TOML 内容为配置对象
* @param content TOML 格式的字符串
* @returns 解析后的配置对象
* @throws 如果 TOML 格式无效
*/
export function parseTOML(content: string): AdapterConfig {
try {
const parsed = parse(content) as unknown as AdapterConfig
// 合并默认配置,确保所有必需字段都存在
return {
inner: { ...DEFAULT_CONFIG.inner, ...parsed.inner },
nickname: { ...DEFAULT_CONFIG.nickname, ...parsed.nickname },
napcat_server: { ...DEFAULT_CONFIG.napcat_server, ...parsed.napcat_server },
maibot_server: { ...DEFAULT_CONFIG.maibot_server, ...parsed.maibot_server },
chat: { ...DEFAULT_CONFIG.chat, ...parsed.chat },
voice: { ...DEFAULT_CONFIG.voice, ...parsed.voice },
forward: { ...DEFAULT_CONFIG.forward, ...parsed.forward },
debug: { ...DEFAULT_CONFIG.debug, ...parsed.debug },
}
} catch (error) {
console.error('TOML 解析失败:', error)
throw new Error(`无法解析 TOML 文件: ${error instanceof Error ? error.message : '未知错误'}`)
}
}
/**
* 将配置对象转换为 TOML 格式字符串
* @param config 配置对象
* @returns TOML 格式的字符串
*/
export function generateTOML(config: AdapterConfig): string {
try {
// 填充默认值的辅助函数
const fillDefaults = <T>(value: T, defaultValue: T): T => {
if (value === '' || value === null || value === undefined) {
return defaultValue
}
return value
}
// 创建填充了默认值的配置副本
const filledConfig: AdapterConfig = {
inner: {
version: fillDefaults(config.inner.version, DEFAULT_CONFIG.inner.version),
},
nickname: {
nickname: fillDefaults(config.nickname.nickname, DEFAULT_CONFIG.nickname.nickname),
},
napcat_server: {
host: fillDefaults(config.napcat_server.host, DEFAULT_CONFIG.napcat_server.host),
port: fillDefaults(config.napcat_server.port || 0, DEFAULT_CONFIG.napcat_server.port),
token: fillDefaults(config.napcat_server.token, DEFAULT_CONFIG.napcat_server.token),
heartbeat_interval: fillDefaults(
config.napcat_server.heartbeat_interval || 0,
DEFAULT_CONFIG.napcat_server.heartbeat_interval
),
},
maibot_server: {
host: fillDefaults(config.maibot_server.host, DEFAULT_CONFIG.maibot_server.host),
port: fillDefaults(config.maibot_server.port || 0, DEFAULT_CONFIG.maibot_server.port),
},
chat: {
group_list_type: fillDefaults(config.chat.group_list_type, DEFAULT_CONFIG.chat.group_list_type),
group_list: config.chat.group_list || [],
private_list_type: fillDefaults(config.chat.private_list_type, DEFAULT_CONFIG.chat.private_list_type),
private_list: config.chat.private_list || [],
ban_user_id: config.chat.ban_user_id || [],
ban_qq_bot: config.chat.ban_qq_bot ?? DEFAULT_CONFIG.chat.ban_qq_bot,
enable_poke: config.chat.enable_poke ?? DEFAULT_CONFIG.chat.enable_poke,
},
voice: {
use_tts: config.voice.use_tts ?? DEFAULT_CONFIG.voice.use_tts,
},
forward: {
image_threshold: fillDefaults(
config.forward.image_threshold || 0,
DEFAULT_CONFIG.forward.image_threshold
),
},
debug: {
level: fillDefaults(config.debug.level, DEFAULT_CONFIG.debug.level),
},
}
// 使用 smol-toml 生成基础 TOML
let toml = stringify(filledConfig)
// 添加注释smol-toml 不支持注释,需要手动添加)
toml = addComments(toml)
return toml
} catch (error) {
console.error('TOML 生成失败:', error)
throw new Error(`无法生成 TOML 文件: ${error instanceof Error ? error.message : '未知错误'}`)
}
}
/**
* 为生成的 TOML 添加注释
* @param toml 基础 TOML 字符串
* @returns 添加了注释的 TOML 字符串
*/
function addComments(toml: string): string {
const lines = toml.split('\n')
const result: string[] = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// [inner] section
if (line === '[inner]') {
result.push(line)
continue
}
if (line.startsWith('version = ')) {
result.push(`${line} # 版本号`)
result.push('# 请勿修改版本号,除非你知道自己在做什么')
continue
}
// [nickname] section
if (line === '[nickname]') {
result.push('[nickname] # 现在没用')
continue
}
// [napcat_server] section
if (line === '[napcat_server]') {
result.push('[napcat_server] # Napcat连接的ws服务设置')
continue
}
if (line.startsWith('host = ') && result[result.length - 1]?.includes('[napcat_server]')) {
result.push(`${line} # Napcat设定的主机地址`)
continue
}
if (line.startsWith('port = ') && lines[i - 1]?.includes('host')) {
result.push(`${line} # Napcat设定的端口`)
continue
}
if (line.startsWith('token = ')) {
result.push(`${line} # Napcat设定的访问令牌若无则留空`)
continue
}
if (line.startsWith('heartbeat_interval = ')) {
result.push(`${line} # 与Napcat设置的心跳相同按秒计`)
continue
}
// [maibot_server] section
if (line === '[maibot_server]') {
result.push('[maibot_server] # 连接麦麦的ws服务设置')
continue
}
if (line.startsWith('host = ') && result[result.length - 1]?.includes('[maibot_server]')) {
result.push(`${line} # 麦麦在.env文件中设置的主机地址即HOST字段`)
continue
}
if (line.startsWith('port = ') && result[result.length - 1]?.includes('麦麦在.env')) {
result.push(`${line} # 麦麦在.env文件中设置的端口即PORT字段`)
continue
}
// [chat] section
if (line === '[chat]') {
result.push('[chat] # 黑白名单功能')
continue
}
if (line.startsWith('group_list_type = ')) {
result.push(`${line} # 群组名单类型可选为whitelist, blacklist`)
continue
}
if (line.startsWith('group_list = ')) {
result.push(`${line} # 群组名单`)
result.push('# 当group_list_type为whitelist时只有群组名单中的群组可以聊天')
result.push('# 当group_list_type为blacklist时群组名单中的任何群组无法聊天')
continue
}
if (line.startsWith('private_list_type = ')) {
result.push(`${line} # 私聊名单类型可选为whitelist, blacklist`)
continue
}
if (line.startsWith('private_list = ')) {
result.push(`${line} # 私聊名单`)
result.push('# 当private_list_type为whitelist时只有私聊名单中的用户可以聊天')
result.push('# 当private_list_type为blacklist时私聊名单中的任何用户无法聊天')
continue
}
if (line.startsWith('ban_user_id = ')) {
result.push(`${line} # 全局禁止名单(全局禁止名单中的用户无法进行任何聊天)`)
continue
}
if (line.startsWith('ban_qq_bot = ')) {
result.push(`${line} # 是否屏蔽QQ官方机器人`)
continue
}
if (line.startsWith('enable_poke = ')) {
result.push(`${line} # 是否启用戳一戳功能`)
continue
}
// [voice] section
if (line === '[voice]') {
result.push('[voice] # 发送语音设置')
continue
}
if (line.startsWith('use_tts = ')) {
result.push(`${line} # 是否使用tts语音请确保你配置了tts并有对应的adapter`)
continue
}
// [forward] section
if (line === '[forward]') {
result.push('[forward] # 转发消息处理设置')
continue
}
if (line.startsWith('image_threshold = ')) {
result.push(`${line} # 图片数量阈值:转发消息中图片数量超过此值时使用占位符(避免麦麦VLM处理卡死)`)
continue
}
// [debug] section
if (line.startsWith('level = ') && result[result.length - 1] === '[debug]') {
result.push(`${line} # 日志等级DEBUG, INFO, WARNING, ERROR, CRITICAL`)
continue
}
result.push(line)
}
return result.join('\n')
}
/**
* 验证配置路径格式
* @param path 文件路径
* @returns 验证结果
*/
export function validatePath(path: string): { valid: boolean; error: string } {
if (!path.trim()) {
return { valid: false, error: '路径不能为空' }
}
if (!path.toLowerCase().endsWith('.toml')) {
return { valid: false, error: '文件必须是 .toml 格式' }
}
// 支持相对路径和绝对路径
// Windows 绝对路径: C:\path\to\file.toml 或 \\server\share\file.toml
const windowsPathRegex = /^([a-zA-Z]:\\|\\\\[^\\]+\\[^\\]+\\).+\.toml$/i
// Linux/Unix 绝对路径: /path/to/file.toml 或 ~/path/to/file.toml
const unixPathRegex = /^(\/|~\/).+\.toml$/i
// 相对路径: ./path/to/file.toml 或 ../path/to/file.toml 或 path/to/file.toml
const relativePathRegex = /^(\.{1,2}[\\/]|[^:\\/]).+\.toml$/i
const isWindows = windowsPathRegex.test(path)
const isUnix = unixPathRegex.test(path)
const isRelative = relativePathRegex.test(path)
if (!isWindows && !isUnix && !isRelative) {
return {
valid: false,
error: '路径格式错误',
}
}
// 检查路径中是否包含非法字符
// eslint-disable-next-line no-control-regex
const illegalChars = /[<>"|?*\x00-\x1F]/
if (illegalChars.test(path)) {
return { valid: false, error: '路径包含非法字符' }
}
return { valid: true, error: '' }
}

View File

@@ -0,0 +1,735 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
BotInfoSection,
PersonalitySection,
ChatSection,
DreamSection,
LPMMSection,
LogSection,
DebugSection,
ExperimentalSection,
MaimMessageSection,
TelemetrySection,
FeaturesSection,
ExpressionSection,
ProcessingSection,
MessageReceiveSection,
WebUISection,
} from './bot/sections'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Save, Power, Code2, Layout } from 'lucide-react'
import { getBotConfig, updateBotConfig, getBotConfigRaw, updateBotConfigRaw } from '@/lib/config-api'
import { useToast } from '@/hooks/use-toast'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Info } from 'lucide-react'
import { RestartOverlay } from '@/components/restart-overlay'
import { RestartProvider, useRestart } from '@/lib/restart-context'
import { CodeEditor } from '@/components'
import { parse as parseToml } from 'smol-toml'
// 导入模块化的类型定义
import type {
BotConfig,
PersonalityConfig,
ChatConfig,
ExpressionConfig,
EmojiConfig,
MemoryConfig,
ToolConfig,
VoiceConfig,
MessageReceiveConfig,
DreamConfig,
LPMMKnowledgeConfig,
KeywordReactionConfig,
ResponsePostProcessConfig,
ChineseTypoConfig,
ResponseSplitterConfig,
LogConfig,
DebugConfig,
ExperimentalConfig,
MaimMessageConfig,
TelemetryConfig,
WebUIConfig,
} from './bot/types'
// 导入 useAutoSave hook
import { useAutoSave, useConfigAutoSave } from './bot/hooks'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
// ==================== 常量定义 ====================
/** Toast 显示前的延迟时间 (毫秒) */
const TOAST_DISPLAY_DELAY = 500
// 主导出组件:包装 RestartProvider
export function BotConfigPage() {
return (
<RestartProvider>
<BotConfigPageContent />
</RestartProvider>
)
}
// 内部实现组件
function BotConfigPageContent() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [autoSaving, setAutoSaving] = useState(false)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [editMode, setEditMode] = useState<'visual' | 'source'>('visual')
const [sourceCode, setSourceCode] = useState<string>('')
const [hasTomlError, setHasTomlError] = useState(false)
const [tomlErrorMessage, setTomlErrorMessage] = useState<string>('')
const { toast } = useToast()
const { triggerRestart, isRestarting } = useRestart()
// 配置状态
const [botConfig, setBotConfig] = useState<BotConfig | null>(null)
const [personalityConfig, setPersonalityConfig] = useState<PersonalityConfig | null>(null)
const [chatConfig, setChatConfig] = useState<ChatConfig | null>(null)
const [expressionConfig, setExpressionConfig] = useState<ExpressionConfig | null>(null)
const [emojiConfig, setEmojiConfig] = useState<EmojiConfig | null>(null)
const [memoryConfig, setMemoryConfig] = useState<MemoryConfig | null>(null)
const [toolConfig, setToolConfig] = useState<ToolConfig | null>(null)
const [voiceConfig, setVoiceConfig] = useState<VoiceConfig | null>(null)
const [messageReceiveConfig, setMessageReceiveConfig] = useState<MessageReceiveConfig | null>(null)
const [dreamConfig, setDreamConfig] = useState<DreamConfig | null>(null)
const [lpmmConfig, setLpmmConfig] = useState<LPMMKnowledgeConfig | null>(null)
const [keywordReactionConfig, setKeywordReactionConfig] = useState<KeywordReactionConfig | null>(null)
const [responsePostProcessConfig, setResponsePostProcessConfig] = useState<ResponsePostProcessConfig | null>(null)
const [chineseTypoConfig, setChineseTypoConfig] = useState<ChineseTypoConfig | null>(null)
const [responseSplitterConfig, setResponseSplitterConfig] = useState<ResponseSplitterConfig | null>(null)
const [logConfig, setLogConfig] = useState<LogConfig | null>(null)
const [debugConfig, setDebugConfig] = useState<DebugConfig | null>(null)
const [experimentalConfig, setExperimentalConfig] = useState<ExperimentalConfig | null>(null)
const [maimMessageConfig, setMaimMessageConfig] = useState<MaimMessageConfig | null>(null)
const [telemetryConfig, setTelemetryConfig] = useState<TelemetryConfig | null>(null)
const [webuiConfig, setWebuiConfig] = useState<WebUIConfig | null>(null)
// 用于标记初始加载和配置缓存
const initialLoadRef = useRef(true)
const configRef = useRef<Record<string, unknown>>({})
// ==================== 辅助函数 ====================
/**
* 翻译 TOML 错误信息为中文
*/
const translateTomlError = (errorMessage: string): string => {
// 分行处理,保留多行格式
const lines = errorMessage.split('\n')
// 翻译第一行(主要错误信息)
let firstLine = lines[0]
// 移除 "Error: " 前缀(如果有)
firstLine = firstLine.replace(/^Error:\s*/, '')
// 常见 TOML 错误模式匹配和翻译
const translations: Array<[RegExp, string | ((match: RegExpMatchArray) => string)]> = [
// Invalid TOML document 系列
[/Invalid TOML document: unrecognized escape sequence/, 'TOML 文档错误:无法识别的转义序列(提示:在双引号字符串中使用 \\\\ 转义反斜杠,或使用单引号字符串)'],
[/Invalid TOML document: only letter, numbers, dashes and underscores are allowed in keys/, 'TOML 文档错误:键名只能包含字母、数字、短横线和下划线'],
[/Invalid TOML document: (.+)/, 'TOML 文档错误:$1'],
// 位置错误系列
[/Unexpected character.*at line (\d+), column (\d+)/, '第 $1 行第 $2 列:意外的字符'],
[/Expected.*at line (\d+), column (\d+)/, '第 $1 行第 $2 列:缺少必要的字符'],
[/Invalid.*at line (\d+), column (\d+)/, '第 $1 行第 $2 列:无效的语法'],
[/Unterminated string at line (\d+)/, '第 $1 行:字符串未正常结束(缺少引号)'],
[/Duplicate key.*at line (\d+)/, '第 $1 行:重复的键名'],
[/Invalid escape sequence at line (\d+)/, '第 $1 行:无效的转义序列(提示:在双引号字符串中使用 \\\\ 转义反斜杠)'],
[/Expected.*but got.*at line (\d+)/, '第 $1 行:类型不匹配'],
[/line (\d+), column (\d+)/, '第 $1 行第 $2 列'],
// 通用错误系列
[/Unexpected end of input/, '意外的文件结束(可能缺少闭合符号)'],
[/Unexpected token/, '意外的标记'],
[/Invalid number/, '无效的数字'],
[/Invalid date/, '无效的日期格式'],
[/Invalid boolean/, '无效的布尔值(应为 true 或 false'],
[/Unexpected character/, '意外的字符'],
[/unrecognized escape sequence/, '无法识别的转义序列'],
]
// 尝试翻译第一行
for (const [pattern, replacement] of translations) {
if (pattern.test(firstLine)) {
firstLine = firstLine.replace(pattern, replacement as string)
break
}
}
// 重组多行错误信息
if (lines.length > 1) {
lines[0] = firstLine
return lines.join('\n')
}
return firstLine
}
/**
* 解析并设置所有配置状态
* 抽取自 loadConfig 和 handleModeChange 中的重复逻辑
*/
const parseAndSetConfig = useCallback((config: Record<string, unknown>) => {
configRef.current = config
setBotConfig(config.bot as BotConfig)
setPersonalityConfig(config.personality as PersonalityConfig)
// 确保 talk_value_rules 有默认值
const chatConfigData = config.chat as ChatConfig
if (!chatConfigData.talk_value_rules) {
chatConfigData.talk_value_rules = []
}
setChatConfig(chatConfigData)
setExpressionConfig(config.expression as ExpressionConfig)
setEmojiConfig(config.emoji as EmojiConfig)
setMemoryConfig(config.memory as MemoryConfig)
setToolConfig(config.tool as ToolConfig)
setVoiceConfig(config.voice as VoiceConfig)
setMessageReceiveConfig(config.message_receive as MessageReceiveConfig)
setDreamConfig(config.dream as DreamConfig)
setLpmmConfig(config.lpmm_knowledge as LPMMKnowledgeConfig)
setKeywordReactionConfig(config.keyword_reaction as KeywordReactionConfig)
setResponsePostProcessConfig(config.response_post_process as ResponsePostProcessConfig)
setChineseTypoConfig(config.chinese_typo as ChineseTypoConfig)
setResponseSplitterConfig(config.response_splitter as ResponseSplitterConfig)
setLogConfig(config.log as LogConfig)
setDebugConfig(config.debug as DebugConfig)
setExperimentalConfig(config.experimental as ExperimentalConfig)
setMaimMessageConfig(config.maim_message as MaimMessageConfig)
setTelemetryConfig(config.telemetry as TelemetryConfig)
setWebuiConfig(config.webui as WebUIConfig)
}, [])
/**
* 构建完整的配置对象用于保存
* 抽取自 saveConfig 和 handleSaveAndRestart 中的重复逻辑
*/
const buildFullConfig = useCallback(() => {
return {
...configRef.current,
bot: botConfig,
personality: personalityConfig,
chat: chatConfig,
expression: expressionConfig,
emoji: emojiConfig,
memory: memoryConfig,
tool: toolConfig,
voice: voiceConfig,
message_receive: messageReceiveConfig,
dream: dreamConfig,
lpmm_knowledge: lpmmConfig,
keyword_reaction: keywordReactionConfig,
response_post_process: responsePostProcessConfig,
chinese_typo: chineseTypoConfig,
response_splitter: responseSplitterConfig,
log: logConfig,
debug: debugConfig,
experimental: experimentalConfig,
maim_message: maimMessageConfig,
telemetry: telemetryConfig,
webui: webuiConfig,
}
}, [
botConfig, personalityConfig, chatConfig, expressionConfig,
emojiConfig, memoryConfig, toolConfig,
voiceConfig, messageReceiveConfig, dreamConfig, lpmmConfig, keywordReactionConfig, responsePostProcessConfig,
chineseTypoConfig, responseSplitterConfig, logConfig, debugConfig, experimentalConfig,
maimMessageConfig, telemetryConfig, webuiConfig
])
// 加载源代码
const loadSourceCode = useCallback(async () => {
try {
const raw = await getBotConfigRaw()
// 将 TOML 基本字符串中的转义序列转换为实际字符以便在编辑器中正确显示
// 使用正则表达式只处理双引号字符串内的转义序列,不影响单引号字符串
const unescaped = raw.replace(/"([^"]*)"/g, (_match, content) => {
const decoded = content
.replace(/\\n/g, '\n') // 换行符
.replace(/\\t/g, '\t') // 制表符
.replace(/\\r/g, '\r') // 回车符
.replace(/\\"/g, '"') // 双引号
.replace(/\\\\/g, '\\') // 反斜杠(必须放在最后)
return `"${decoded}"`
})
setSourceCode(unescaped)
setHasTomlError(false)
} catch (error) {
toast({
variant: 'destructive',
title: '加载失败',
description: error instanceof Error ? error.message : '加载源代码失败',
})
}
}, [toast])
// 加载配置
const loadConfig = useCallback(async () => {
try {
setLoading(true)
const config = await getBotConfig()
parseAndSetConfig(config)
setHasUnsavedChanges(false)
initialLoadRef.current = false
// 同时加载源代码
await loadSourceCode()
} catch (error) {
console.error('加载配置失败:', error)
toast({
title: '加载失败',
description: '无法加载配置文件',
variant: 'destructive',
})
} finally {
setLoading(false)
}
}, [toast, loadSourceCode, parseAndSetConfig])
useEffect(() => {
loadConfig()
}, [loadConfig])
// 使用模块化的 useAutoSave hook
const { triggerAutoSave, cancelPendingAutoSave } = useAutoSave(
initialLoadRef.current,
setAutoSaving,
setHasUnsavedChanges
)
// 使用 useConfigAutoSave hook 简化配置变化监听
// 注意: useConfigAutoSave 是一个 hook不能在条件语句或循环中调用
// 因此我们仍然需要逐个调用,但代码更简洁
useConfigAutoSave(botConfig, 'bot', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(personalityConfig, 'personality', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(chatConfig, 'chat', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(expressionConfig, 'expression', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(emojiConfig, 'emoji', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(memoryConfig, 'memory', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(toolConfig, 'tool', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(voiceConfig, 'voice', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(dreamConfig, 'dream', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(lpmmConfig, 'lpmm_knowledge', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(keywordReactionConfig, 'keyword_reaction', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(responsePostProcessConfig, 'response_post_process', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(chineseTypoConfig, 'chinese_typo', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(responseSplitterConfig, 'response_splitter', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(logConfig, 'log', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(debugConfig, 'debug', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(maimMessageConfig, 'maim_message', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(telemetryConfig, 'telemetry', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(webuiConfig, 'webui', initialLoadRef.current, triggerAutoSave)
// 保存源代码
const saveSourceCode = async () => {
try {
setSaving(true)
// 前端验证 TOML 格式
try {
parseToml(sourceCode)
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'TOML 格式错误'
const translatedMsg = translateTomlError(errorMsg)
setHasTomlError(true)
setTomlErrorMessage(translatedMsg)
toast({
variant: 'destructive',
title: 'TOML 格式错误',
description: translatedMsg,
})
setSaving(false)
return
}
// 将双引号字符串中的实际字符转换回 TOML 转义序列
// 使用正则表达式只处理双引号字符串内的内容,不影响单引号字符串
const escaped = sourceCode.replace(/"([^"]*)"/g, (_match, content) => {
const encoded = content
.replace(/\\/g, '\\\\') // 反斜杠(必须放在最前)
.replace(/"/g, '\\"') // 双引号
.replace(/\n/g, '\\n') // 换行符
.replace(/\t/g, '\\t') // 制表符
.replace(/\r/g, '\\r') // 回车符
return `"${encoded}"`
})
await updateBotConfigRaw(escaped)
setHasUnsavedChanges(false)
setHasTomlError(false)
setTomlErrorMessage('')
toast({
title: '保存成功',
description: '配置已保存',
})
// 重新加载可视化配置
await loadConfig()
} catch (error) {
setHasTomlError(true)
const errorMsg = error instanceof Error ? error.message : '保存配置失败'
setTomlErrorMessage(errorMsg)
toast({
variant: 'destructive',
title: '保存失败',
description: errorMsg,
})
} finally {
setSaving(false)
}
}
// 处理模式切换
const handleModeChange = async (mode: 'visual' | 'source') => {
if (hasUnsavedChanges) {
toast({
variant: 'destructive',
title: '切换失败',
description: '请先保存当前更改',
})
return
}
setEditMode(mode)
if (mode === 'source') {
await loadSourceCode()
} else {
// 切换回可视化时,直接重新加载配置但不显示全局 loading
try {
const config = await getBotConfig()
parseAndSetConfig(config)
setHasUnsavedChanges(false)
} catch (error) {
console.error('加载配置失败:', error)
toast({
title: '加载失败',
description: '无法加载配置文件',
variant: 'destructive',
})
}
}
}
// 手动保存
const saveConfig = async () => {
try {
setSaving(true)
// 取消待处理的自动保存
cancelPendingAutoSave()
await updateBotConfig(buildFullConfig())
setHasUnsavedChanges(false)
toast({
title: '保存成功',
description: '麦麦主程序配置已保存',
})
} catch (error) {
console.error('保存配置失败:', error)
toast({
title: '保存失败',
description: (error as Error).message,
variant: 'destructive',
})
} finally {
setSaving(false)
}
}
// 重启麦麦
const handleRestart = async () => {
await triggerRestart()
}
// 保存并重启
const handleSaveAndRestart = async () => {
try {
setSaving(true)
// 取消待处理的自动保存
cancelPendingAutoSave()
await updateBotConfig(buildFullConfig())
setHasUnsavedChanges(false)
toast({
title: '保存成功',
description: '配置已保存,即将重启麦麦...',
})
// 等待一下让用户看到保存成功的提示
await new Promise(resolve => setTimeout(resolve, TOAST_DISPLAY_DELAY))
await handleRestart()
} catch (error) {
console.error('保存失败:', error)
toast({
title: '保存失败',
description: (error as Error).message,
variant: 'destructive',
})
} finally {
setSaving(false)
}
}
if (loading) {
return (
<ScrollArea className="h-full">
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground">...</p>
</div>
</div>
</ScrollArea>
)
}
return (
<ScrollArea className="h-full">
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
{/* 页面标题 */}
<div className="flex flex-col gap-3 sm:gap-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="min-w-0">
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold"></h1>
<p className="text-muted-foreground mt-1 text-xs sm:text-sm"></p>
</div>
{/* 按钮组 - 桌面端靠右 */}
<div className="flex gap-2 flex-shrink-0">
<Button
onClick={editMode === 'visual' ? saveConfig : saveSourceCode}
disabled={saving || autoSaving || !hasUnsavedChanges || isRestarting}
size="sm"
variant="outline"
className="w-20 sm:w-24"
>
<Save className="h-4 w-4 flex-shrink-0" strokeWidth={2} fill="none" />
<span className="ml-1 truncate text-xs sm:text-sm">
{saving ? '保存中' : autoSaving ? '自动' : hasUnsavedChanges ? '保存' : '已保存'}
</span>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
disabled={saving || autoSaving || isRestarting}
size="sm"
className="w-20 sm:w-28"
>
<Power className="h-4 w-4 flex-shrink-0" />
<span className="ml-1 truncate text-xs sm:text-sm">
{isRestarting ? '重启中' : hasUnsavedChanges ? '保存重启' : '重启'}
</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription asChild>
<div>
<p>
{hasUnsavedChanges
? '当前有未保存的配置更改。点击确认将先保存配置,然后重启麦麦使新配置生效。重启过程中麦麦将暂时离线。'
: '即将重启麦麦主程序。重启过程中麦麦将暂时离线,配置将在重启后生效。'
}
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={hasUnsavedChanges ? handleSaveAndRestart : handleRestart}>
{hasUnsavedChanges ? '保存并重启' : '确认重启'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
{/* 模式切换 - 单独一行 */}
<div className="flex">
<Tabs value={editMode} onValueChange={(v) => handleModeChange(v as 'visual' | 'source')} className="w-full">
<TabsList className="h-8 sm:h-9 w-full grid grid-cols-2">
<TabsTrigger value="visual" className="text-xs sm:text-sm">
<Layout className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
</TabsTrigger>
<TabsTrigger value="source" className="text-xs sm:text-sm">
<Code2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
{/* 重启提示 */}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<strong></strong>"保存并重启"
</AlertDescription>
</Alert>
{/* 源代码模式 */}
{editMode === 'source' && (
<div className="space-y-4">
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<strong></strong> TOML TOML
{hasTomlError && tomlErrorMessage && (
<div className="text-destructive font-semibold mt-3 p-3 bg-destructive/10 rounded-md">
<div className="font-bold mb-2"> TOML </div>
<pre className="text-sm font-mono whitespace-pre-wrap break-words">
{tomlErrorMessage}
</pre>
</div>
)}
</AlertDescription>
</Alert>
<CodeEditor
value={sourceCode}
onChange={(value) => {
setSourceCode(value)
setHasUnsavedChanges(true)
// 清除之前的错误状态
if (hasTomlError) {
setHasTomlError(false)
setTomlErrorMessage('')
}
}}
language="toml"
theme="dark"
height="calc(100vh - 280px)"
minHeight="500px"
placeholder="TOML 配置内容"
/>
</div>
)}
{/* 可视化模式 */}
{editMode === 'visual' && (
<>
{/* 标签页 */}
<Tabs defaultValue="bot" className="w-full">
<TabsList className="flex flex-wrap h-auto gap-1 p-1 sm:grid sm:grid-cols-5 lg:grid-cols-10">
<TabsTrigger value="bot" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm"></TabsTrigger>
<TabsTrigger value="personality" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm"></TabsTrigger>
<TabsTrigger value="chat" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm"></TabsTrigger>
<TabsTrigger value="expression" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm"></TabsTrigger>
<TabsTrigger value="features" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm"></TabsTrigger>
<TabsTrigger value="processing" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm"></TabsTrigger>
<TabsTrigger value="dream" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm"></TabsTrigger>
<TabsTrigger value="lpmm" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm"></TabsTrigger>
<TabsTrigger value="webui" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm">WebUI</TabsTrigger>
<TabsTrigger value="other" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm"></TabsTrigger>
</TabsList>
{/* 基本信息 */}
<TabsContent value="bot" className="space-y-4">
{botConfig && <BotInfoSection config={botConfig} onChange={setBotConfig} />}
</TabsContent>
{/* 人格配置 */}
<TabsContent value="personality" className="space-y-4">
{personalityConfig && (
<PersonalitySection config={personalityConfig} onChange={setPersonalityConfig} />
)}
</TabsContent>
{/* 聊天配置 */}
<TabsContent value="chat" className="space-y-4">
{chatConfig && <ChatSection config={chatConfig} onChange={setChatConfig} />}
</TabsContent>
{/* 表达配置 */}
<TabsContent value="expression" className="space-y-4">
{expressionConfig && (
<ExpressionSection config={expressionConfig} onChange={setExpressionConfig} />
)}
</TabsContent>
{/* 功能配置(合并表情、记忆、工具) */}
<TabsContent value="features" className="space-y-4">
{emojiConfig && memoryConfig && toolConfig && voiceConfig && (
<FeaturesSection
emojiConfig={emojiConfig}
memoryConfig={memoryConfig}
toolConfig={toolConfig}
voiceConfig={voiceConfig}
onEmojiChange={setEmojiConfig}
onMemoryChange={setMemoryConfig}
onToolChange={setToolConfig}
onVoiceChange={setVoiceConfig}
/>
)}
</TabsContent>
{/* 处理配置(关键词反应和回复后处理) */}
<TabsContent value="processing" className="space-y-4">
{keywordReactionConfig && responsePostProcessConfig && chineseTypoConfig && responseSplitterConfig && (
<ProcessingSection
keywordReactionConfig={keywordReactionConfig}
responsePostProcessConfig={responsePostProcessConfig}
chineseTypoConfig={chineseTypoConfig}
responseSplitterConfig={responseSplitterConfig}
onKeywordReactionChange={setKeywordReactionConfig}
onResponsePostProcessChange={setResponsePostProcessConfig}
onChineseTypoChange={setChineseTypoConfig}
onResponseSplitterChange={setResponseSplitterConfig}
/>
)}
{messageReceiveConfig && (
<MessageReceiveSection
config={messageReceiveConfig}
onChange={setMessageReceiveConfig}
/>
)}
</TabsContent>
{/* 做梦配置 */}
<TabsContent value="dream" className="space-y-4">
{dreamConfig && <DreamSection config={dreamConfig} onChange={setDreamConfig} />}
</TabsContent>
{/* 知识库配置 */}
<TabsContent value="lpmm" className="space-y-4">
{lpmmConfig && <LPMMSection config={lpmmConfig} onChange={setLpmmConfig} />}
</TabsContent>
{/* WebUI 配置 */}
<TabsContent value="webui" className="space-y-4">
{webuiConfig && <WebUISection config={webuiConfig} onChange={setWebuiConfig} />}
</TabsContent>
{/* 其他配置 */}
<TabsContent value="other" className="space-y-4">
{logConfig && <LogSection config={logConfig} onChange={setLogConfig} />}
{debugConfig && <DebugSection config={debugConfig} onChange={setDebugConfig} />}
{experimentalConfig && <ExperimentalSection config={experimentalConfig} onChange={setExperimentalConfig} />}
{maimMessageConfig && <MaimMessageSection config={maimMessageConfig} onChange={setMaimMessageConfig} />}
{telemetryConfig && <TelemetrySection config={telemetryConfig} onChange={setTelemetryConfig} />}
</TabsContent>
</Tabs>
</>
)}
{/* 重启遮罩层 */}
<RestartOverlay />
</div>
</ScrollArea>
)
}

View File

@@ -0,0 +1,6 @@
/**
* Bot 配置页面相关 hooks
*/
export { useAutoSave, useConfigAutoSave } from './useAutoSave'
export type { UseAutoSaveOptions, UseAutoSaveReturn, AutoSaveState } from './useAutoSave'

View File

@@ -0,0 +1,166 @@
import { useEffect, useRef, useCallback } from 'react'
import { updateBotConfigSection } from '@/lib/config-api'
import type { ConfigSectionName } from '../types'
export interface UseAutoSaveOptions {
/** 防抖延迟时间(毫秒),默认 2000ms */
debounceMs?: number
/** 保存成功回调 */
onSaveSuccess?: () => void
/** 保存失败回调 */
onSaveError?: (error: Error) => void
}
export interface UseAutoSaveReturn {
/** 触发自动保存 */
triggerAutoSave: (sectionName: ConfigSectionName, sectionData: unknown) => void
/** 立即保存(不防抖) */
saveNow: (sectionName: ConfigSectionName, sectionData: unknown) => Promise<void>
/** 取消待处理的自动保存 */
cancelPendingAutoSave: () => void
}
export interface AutoSaveState {
/** 是否正在保存中 */
isAutoSaving: boolean
/** 是否有未保存的更改 */
hasUnsavedChanges: boolean
}
/**
* 自动保存 hook
*
* 用于监听配置变化并自动防抖保存到后端
*
* @example
* ```tsx
* const { triggerAutoSave } = useAutoSave({
* isInitialLoad,
* setAutoSaving,
* setHasUnsavedChanges,
* })
*
* // 配置变化时触发
* useEffect(() => {
* if (config) triggerAutoSave('bot', config)
* }, [config])
* ```
*/
export function useAutoSave(
isInitialLoad: boolean,
setAutoSaving: (saving: boolean) => void,
setHasUnsavedChanges: (hasChanges: boolean) => void,
options: UseAutoSaveOptions = {}
): UseAutoSaveReturn {
const { debounceMs = 2000, onSaveSuccess, onSaveError } = options
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// 执行保存操作
const saveSection = useCallback(
async (sectionName: ConfigSectionName, sectionData: unknown) => {
try {
setAutoSaving(true)
await updateBotConfigSection(sectionName, sectionData)
setHasUnsavedChanges(false)
onSaveSuccess?.()
} catch (error) {
console.error(`自动保存 ${sectionName} 失败:`, error)
setHasUnsavedChanges(true)
onSaveError?.(error instanceof Error ? error : new Error(String(error)))
} finally {
setAutoSaving(false)
}
},
[setAutoSaving, setHasUnsavedChanges, onSaveSuccess, onSaveError]
)
// 触发自动保存(带防抖)
const triggerAutoSave = useCallback(
(sectionName: ConfigSectionName, sectionData: unknown) => {
if (isInitialLoad) return
setHasUnsavedChanges(true)
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
}
autoSaveTimerRef.current = setTimeout(() => {
saveSection(sectionName, sectionData)
}, debounceMs)
},
[isInitialLoad, setHasUnsavedChanges, saveSection, debounceMs]
)
// 立即保存(不防抖)
const saveNow = useCallback(
async (sectionName: ConfigSectionName, sectionData: unknown) => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
autoSaveTimerRef.current = null
}
await saveSection(sectionName, sectionData)
},
[saveSection]
)
// 取消待处理的自动保存
const cancelPendingAutoSave = useCallback(() => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
autoSaveTimerRef.current = null
}
}, [])
// 组件卸载时清理定时器
useEffect(() => {
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
}
}
}, [])
return {
triggerAutoSave,
saveNow,
cancelPendingAutoSave,
}
}
/**
* 创建配置自动保存 effect
*
* 这是一个工厂函数,用于创建监听特定配置变化并触发自动保存的 effect
* 简化重复的 useEffect 代码
*
* @example
* ```tsx
* // 使用方式 1: 直接在组件中调用
* useConfigAutoSave(botConfig, 'bot', isInitialLoad, triggerAutoSave)
* useConfigAutoSave(chatConfig, 'chat', isInitialLoad, triggerAutoSave)
*
* // 使用方式 2: 批量配置
* const configs = [
* { config: botConfig, section: 'bot' },
* { config: chatConfig, section: 'chat' },
* ] as const
*
* configs.forEach(({ config, section }) => {
* useConfigAutoSave(config, section, isInitialLoad, triggerAutoSave)
* })
* ```
*/
export function useConfigAutoSave<T>(
config: T | null,
sectionName: ConfigSectionName,
isInitialLoad: boolean,
triggerAutoSave: (sectionName: ConfigSectionName, data: unknown) => void
): void {
useEffect(() => {
if (config && !isInitialLoad) {
triggerAutoSave(sectionName, config)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config])
}

View File

@@ -0,0 +1,24 @@
/**
* Bot 配置模块
*
* 这个模块包含麦麦主程序配置页面的所有组件和类型
*
* 目录结构:
* - types.ts: 类型定义
* - hooks/: 自定义 hooks
* - useAutoSave.ts: 自动保存 hook
* - sections/: 各个配置区块组件
* - BotInfoSection.tsx
* - PersonalitySection.tsx
* - ChatSection.tsx
* - ...等
*/
// 类型导出
export * from './types'
// Hooks 导出
export * from './hooks'
// Section 组件导出
export * from './sections'

View File

@@ -0,0 +1,192 @@
import React from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Plus, Trash2 } from 'lucide-react'
import type { BotConfig } from '../types'
interface BotInfoSectionProps {
config: BotConfig
onChange: (config: BotConfig) => void
}
export const BotInfoSection = React.memo(function BotInfoSection({ config, onChange }: BotInfoSectionProps) {
// 确保 platforms 和 alias_names 始终是数组
const platforms = config.platforms || []
const aliasNames = config.alias_names || []
const addPlatform = () => {
onChange({ ...config, platforms: [...platforms, ''] })
}
const removePlatform = (index: number) => {
onChange({
...config,
platforms: platforms.filter((_, i) => i !== index),
})
}
const updatePlatform = (index: number, value: string) => {
const newPlatforms = [...platforms]
newPlatforms[index] = value
onChange({ ...config, platforms: newPlatforms })
}
const addAlias = () => {
onChange({ ...config, alias_names: [...aliasNames, ''] })
}
const removeAlias = (index: number) => {
onChange({
...config,
alias_names: aliasNames.filter((_, i) => i !== index),
})
}
const updateAlias = (index: number, value: string) => {
const newAliases = [...aliasNames]
newAliases[index] = value
onChange({ ...config, alias_names: newAliases })
}
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="platform"></Label>
<Input
id="platform"
value={config.platform}
onChange={(e) => onChange({ ...config, platform: e.target.value })}
placeholder="qq"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="qq_account">QQ账号</Label>
<Input
id="qq_account"
value={config.qq_account}
onChange={(e) => onChange({ ...config, qq_account: e.target.value })}
placeholder="123456789"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="nickname"></Label>
<Input
id="nickname"
value={config.nickname}
onChange={(e) => onChange({ ...config, nickname: e.target.value })}
placeholder="麦麦"
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label></Label>
<Button onClick={addAlias} size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="space-y-2">
{aliasNames.map((alias, index) => (
<div key={index} className="flex gap-2">
<Input
value={alias}
onChange={(e) => updateAlias(index, e.target.value)}
placeholder="小麦"
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="icon" variant="outline">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
"{alias || '(空)'}"
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => removeAlias(index)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
{aliasNames.length === 0 && (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label></Label>
<Button onClick={addPlatform} size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="space-y-2">
{platforms.map((platform, index) => (
<div key={index} className="flex gap-2">
<Input
value={platform}
onChange={(e) => updatePlatform(index, e.target.value)}
placeholder="wx:114514"
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="icon" variant="outline">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
"{platform || '(空)'}"
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => removePlatform(index)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
{platforms.length === 0 && (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</div>
</div>
</div>
</div>
)
})

View File

@@ -0,0 +1,610 @@
import React, { useState, useEffect, useMemo } from 'react'
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 { Slider } from '@/components/ui/slider'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Plus, Trash2, Eye, Clock } from 'lucide-react'
import type { ChatConfig } from '../types'
interface ChatSectionProps {
config: ChatConfig
onChange: (config: ChatConfig) => void
}
// 时间选择组件
const TimeRangePicker = React.memo(function TimeRangePicker({
value,
onChange,
}: {
value: string
onChange: (value: string) => void
}) {
// 解析初始值
const parsedValue = useMemo(() => {
const parts = value.split('-')
if (parts.length === 2) {
const [start, end] = parts
const [sh, sm] = start.split(':')
const [eh, em] = end.split(':')
return {
startHour: sh ? sh.padStart(2, '0') : '00',
startMinute: sm ? sm.padStart(2, '0') : '00',
endHour: eh ? eh.padStart(2, '0') : '23',
endMinute: em ? em.padStart(2, '0') : '59',
}
}
return {
startHour: '00',
startMinute: '00',
endHour: '23',
endMinute: '59',
}
}, [value])
const [startHour, setStartHour] = useState(parsedValue.startHour)
const [startMinute, setStartMinute] = useState(parsedValue.startMinute)
const [endHour, setEndHour] = useState(parsedValue.endHour)
const [endMinute, setEndMinute] = useState(parsedValue.endMinute)
// 当value变化时同步状态
useEffect(() => {
setStartHour(parsedValue.startHour)
setStartMinute(parsedValue.startMinute)
setEndHour(parsedValue.endHour)
setEndMinute(parsedValue.endMinute)
}, [parsedValue])
const updateTime = (
newStartHour: string,
newStartMinute: string,
newEndHour: string,
newEndMinute: string
) => {
const newValue = `${newStartHour}:${newStartMinute}-${newEndHour}:${newEndMinute}`
onChange(newValue)
}
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start font-mono text-sm">
<Clock className="h-4 w-4 mr-2" />
{value || '选择时间段'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 sm:w-80">
<div className="space-y-4">
<div>
<h4 className="font-medium text-sm mb-3"></h4>
<div className="grid grid-cols-2 gap-2 sm:gap-3">
<div>
<Label className="text-xs"></Label>
<Select
value={startHour}
onValueChange={(v) => {
setStartHour(v)
updateTime(v, startMinute, endHour, endMinute)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 24 }, (_, i) => i).map((h) => (
<SelectItem key={h} value={h.toString().padStart(2, '0')}>
{h.toString().padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select
value={startMinute}
onValueChange={(v) => {
setStartMinute(v)
updateTime(startHour, v, endHour, endMinute)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 60 }, (_, i) => i).map((m) => (
<SelectItem key={m} value={m.toString().padStart(2, '0')}>
{m.toString().padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div>
<h4 className="font-medium text-sm mb-3"></h4>
<div className="grid grid-cols-2 gap-2 sm:gap-3">
<div>
<Label className="text-xs"></Label>
<Select
value={endHour}
onValueChange={(v) => {
setEndHour(v)
updateTime(startHour, startMinute, v, endMinute)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 24 }, (_, i) => i).map((h) => (
<SelectItem key={h} value={h.toString().padStart(2, '0')}>
{h.toString().padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select
value={endMinute}
onValueChange={(v) => {
setEndMinute(v)
updateTime(startHour, startMinute, endHour, v)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 60 }, (_, i) => i).map((m) => (
<SelectItem key={m} value={m.toString().padStart(2, '0')}>
{m.toString().padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
})
// 预览窗口组件
const RulePreview = React.memo(function RulePreview({ rule }: { rule: { target: string; time: string; value: number } }) {
const previewText = `{ target = "${rule.target}", time = "${rule.time}", value = ${rule.value.toFixed(1)} }`
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-1" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 sm:w-96">
<div className="space-y-2">
<h4 className="font-medium text-sm"></h4>
<div className="rounded-md bg-muted p-3 font-mono text-xs break-all">
{previewText}
</div>
<p className="text-xs text-muted-foreground">
bot_config.toml
</p>
</div>
</PopoverContent>
</Popover>
)
})
export const ChatSection = React.memo(function ChatSection({ config, onChange }: ChatSectionProps) {
// 添加发言频率规则
const addTalkValueRule = () => {
onChange({
...config,
talk_value_rules: [
...config.talk_value_rules,
{ target: '', time: '00:00-23:59', value: 1.0 },
],
})
}
// 删除发言频率规则
const removeTalkValueRule = (index: number) => {
onChange({
...config,
talk_value_rules: config.talk_value_rules.filter((_, i) => i !== index),
})
}
// 更新发言频率规则
const updateTalkValueRule = (
index: number,
field: 'target' | 'time' | 'value',
value: string | number
) => {
const newRules = [...config.talk_value_rules]
newRules[index] = {
...newRules[index],
[field]: value,
}
onChange({
...config,
talk_value_rules: newRules,
})
}
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="talk_value"></Label>
<Input
id="talk_value"
type="number"
step="0.1"
min="0"
max="1"
value={config.talk_value}
onChange={(e) => onChange({ ...config, talk_value: parseFloat(e.target.value) })}
/>
<p className="text-xs text-muted-foreground"> 0-1</p>
</div>
<div className="grid gap-2">
<Label htmlFor="think_mode"></Label>
<Select
value={config.think_mode || 'classic'}
onValueChange={(value) => onChange({ ...config, think_mode: value as 'classic' | 'deep' | 'dynamic' })}
>
<SelectTrigger id="think_mode">
<SelectValue placeholder="选择思考模式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="classic"> - </SelectItem>
<SelectItem value="deep"> - </SelectItem>
<SelectItem value="dynamic"> - </SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="flex items-center space-x-2">
<Switch
id="mentioned_bot_reply"
checked={config.mentioned_bot_reply}
onCheckedChange={(checked) =>
onChange({ ...config, mentioned_bot_reply: checked })
}
/>
<Label htmlFor="mentioned_bot_reply" className="cursor-pointer">
</Label>
</div>
<div className="grid gap-2">
<Label htmlFor="max_context_size"></Label>
<Input
id="max_context_size"
type="number"
min="1"
value={config.max_context_size}
onChange={(e) =>
onChange({ ...config, max_context_size: parseInt(e.target.value) })
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="planner_smooth"></Label>
<Input
id="planner_smooth"
type="number"
step="1"
min="0"
value={config.planner_smooth}
onChange={(e) =>
onChange({ ...config, planner_smooth: parseFloat(e.target.value) })
}
/>
<p className="text-xs text-muted-foreground">
planner 1-50
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="plan_reply_log_max_per_chat"></Label>
<Input
id="plan_reply_log_max_per_chat"
type="number"
step="1"
min="100"
value={config.plan_reply_log_max_per_chat ?? 1024}
onChange={(e) =>
onChange({ ...config, plan_reply_log_max_per_chat: parseInt(e.target.value) })
}
/>
<p className="text-xs text-muted-foreground">
Plan/Reply
</p>
</div>
<div className="flex items-center space-x-2">
<Switch
id="llm_quote"
checked={config.llm_quote ?? false}
onCheckedChange={(checked) =>
onChange({ ...config, llm_quote: checked })
}
/>
<Label htmlFor="llm_quote" className="cursor-pointer">
LLM
</Label>
</div>
<p className="text-xs text-muted-foreground -mt-2 ml-10">
LLM
</p>
<div className="flex items-center space-x-2">
<Switch
id="enable_talk_value_rules"
checked={config.enable_talk_value_rules}
onCheckedChange={(checked) =>
onChange({ ...config, enable_talk_value_rules: checked })
}
/>
<Label htmlFor="enable_talk_value_rules" className="cursor-pointer">
</Label>
</div>
</div>
</div>
{/* 动态发言频率规则配置 */}
{config.enable_talk_value_rules && (
<div className="border-t pt-6">
<div className="flex items-center justify-between mb-4">
<div>
<h4 className="text-base font-semibold"></h4>
<p className="text-xs text-muted-foreground mt-1">
ID调整发言频率
</p>
</div>
<Button onClick={addTalkValueRule} size="sm">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
{config.talk_value_rules && config.talk_value_rules.length > 0 ? (
<div className="space-y-4">
{config.talk_value_rules.map((rule, index) => (
<div key={index} className="rounded-lg border p-4 bg-muted/50 space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
#{index + 1}
</span>
<div className="flex items-center gap-2">
<RulePreview rule={rule} />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
#{index + 1}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => removeTalkValueRule(index)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="space-y-4">
{/* 配置类型选择 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={rule.target === '' ? 'global' : 'specific'}
onValueChange={(value) => {
if (value === 'global') {
updateTalkValueRule(index, 'target', '')
} else {
updateTalkValueRule(index, 'target', 'qq::group')
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global"></SelectItem>
<SelectItem value="specific"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 详细配置选项 - 只在非全局时显示 */}
{rule.target !== '' && (() => {
const parts = rule.target.split(':')
const platform = parts[0] || 'qq'
const chatId = parts[1] || ''
const chatType = parts[2] || 'group'
return (
<div className="grid gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={platform}
onValueChange={(value) => {
updateTalkValueRule(index, 'target', `${value}:${chatId}:${chatType}`)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="qq">QQ</SelectItem>
<SelectItem value="wx"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-xs font-medium"> ID</Label>
<Input
value={chatId}
onChange={(e) => {
updateTalkValueRule(index, 'target', `${platform}:${e.target.value}:${chatType}`)
}}
placeholder="输入群 ID"
className="font-mono text-sm"
/>
</div>
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={chatType}
onValueChange={(value) => {
updateTalkValueRule(index, 'target', `${platform}:${chatId}:${value}`)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="group">group</SelectItem>
<SelectItem value="private">private</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
ID{rule.target || '(未设置)'}
</p>
</div>
)
})()}
{/* 时间段选择器 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"> (Time)</Label>
<TimeRangePicker
value={rule.time}
onChange={(v) => updateTalkValueRule(index, 'time', v)}
/>
<p className="text-xs text-muted-foreground">
23:00-02:00
</p>
</div>
{/* 发言频率滑块 */}
<div className="grid gap-3">
<div className="flex items-center justify-between">
<Label htmlFor={`rule-value-${index}`} className="text-xs font-medium">
(Value)
</Label>
<Input
id={`rule-value-${index}`}
type="number"
step="0.01"
min="0.01"
max="1"
value={rule.value}
onChange={(e) => {
const val = parseFloat(e.target.value)
if (!isNaN(val)) {
updateTalkValueRule(index, 'value', Math.max(0.01, Math.min(1, val)))
}
}}
className="w-20 h-8 text-xs"
/>
</div>
<Slider
value={[rule.value]}
onValueChange={(values) =>
updateTalkValueRule(index, 'value', values[0])
}
min={0.01}
max={1}
step={0.01}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>0.01 ()</span>
<span>0.5</span>
<span>1.0 ()</span>
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">"添加规则"</p>
</div>
)}
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<h5 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-2">
📝
</h5>
<ul className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
<li> <strong>Target </strong></li>
<li> <strong>Target </strong>platform:id:type</li>
<li> <strong></strong></li>
<li> <strong></strong> 23:00-02:00 112</li>
<li> <strong></strong> 0-10 1 </li>
</ul>
</div>
</div>
)}
</div>
)
})

View File

@@ -0,0 +1,97 @@
import React from 'react'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import type { DebugConfig } from '../types'
interface DebugSectionProps {
config: DebugConfig
onChange: (config: DebugConfig) => void
}
export const DebugSection = React.memo(function DebugSection({ config, onChange }: DebugSectionProps) {
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
<h3 className="text-lg font-semibold"></h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> Prompt</Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={config.show_prompt}
onCheckedChange={(checked) => onChange({ ...config, show_prompt: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> Prompt</Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={config.show_replyer_prompt}
onCheckedChange={(checked) => onChange({ ...config, show_replyer_prompt: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={config.show_replyer_reasoning}
onCheckedChange={(checked) =>
onChange({ ...config, show_replyer_reasoning: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> Jargon Prompt</Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={config.show_jargon_prompt}
onCheckedChange={(checked) => onChange({ ...config, show_jargon_prompt: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> Prompt</Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={config.show_memory_prompt}
onCheckedChange={(checked) => onChange({ ...config, show_memory_prompt: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> Planner Prompt</Label>
<p className="text-sm text-muted-foreground"> Planner </p>
</div>
<Switch
checked={config.show_planner_prompt}
onCheckedChange={(checked) => onChange({ ...config, show_planner_prompt: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> LPMM </Label>
<p className="text-sm text-muted-foreground"> LPMM </p>
</div>
<Switch
checked={config.show_lpmm_paragraph}
onCheckedChange={(checked) => onChange({ ...config, show_lpmm_paragraph: checked })}
/>
</div>
</div>
</div>
)
})

View File

@@ -0,0 +1,215 @@
import React, { useState } from 'react'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { X } from 'lucide-react'
import type { DreamConfig } from '../types'
interface DreamSectionProps {
config: DreamConfig
onChange: (config: DreamConfig) => void
}
interface TimeRange {
startTime: string
endTime: string
}
export const DreamSection = React.memo(function DreamSection({ config, onChange }: DreamSectionProps) {
// 解析 dream_send 为 platform 和 userId
const parseDreamSend = (dreamSend: string): { platform: string; userId: string } => {
if (!dreamSend || !dreamSend.includes(':')) {
return { platform: 'qq', userId: '' }
}
const [platform, userId] = dreamSend.split(':')
return { platform, userId }
}
const { platform: initialPlatform, userId: initialUserId } = parseDreamSend(config.dream_send)
const [platform, setPlatform] = useState(initialPlatform)
const [userId, setUserId] = useState(initialUserId)
// 解析时间段字符串为开始和结束时间
const parseTimeRange = (range: string): TimeRange => {
const [start, end] = range.split('-')
return { startTime: start || '09:00', endTime: end || '22:00' }
}
// 更新 dream_send
const updateDreamSend = (newPlatform: string, newUserId: string) => {
const dreamSend = newUserId ? `${newPlatform}:${newUserId}` : ''
onChange({ ...config, dream_send: dreamSend })
}
const handlePlatformChange = (value: string) => {
setPlatform(value)
updateDreamSend(value, userId)
}
const handleUserIdChange = (value: string) => {
setUserId(value)
updateDreamSend(platform, value)
}
const handleAddTimeRange = () => {
onChange({
...config,
dream_time_ranges: [...config.dream_time_ranges, '09:00-22:00']
})
}
const handleRemoveTimeRange = (index: number) => {
onChange({
...config,
dream_time_ranges: config.dream_time_ranges.filter((_, i) => i !== index)
})
}
const handleTimeRangeChange = (index: number, field: 'startTime' | 'endTime', value: string) => {
const newRanges = [...config.dream_time_ranges]
const currentRange = parseTimeRange(newRanges[index])
if (field === 'startTime') {
currentRange.startTime = value
} else {
currentRange.endTime = value
}
newRanges[index] = `${currentRange.startTime}-${currentRange.endTime}`
onChange({
...config,
dream_time_ranges: newRanges
})
}
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
<h3 className="text-lg font-semibold"></h3>
<div className="space-y-2">
<Label htmlFor="interval_minutes"></Label>
<Input
id="interval_minutes"
type="number"
min="1"
value={config.interval_minutes}
onChange={(e) => onChange({ ...config, interval_minutes: Number(e.target.value) })}
/>
<p className="text-xs text-muted-foreground">30</p>
</div>
<div className="space-y-2">
<Label htmlFor="max_iterations"></Label>
<Input
id="max_iterations"
type="number"
min="1"
value={config.max_iterations}
onChange={(e) => onChange({ ...config, max_iterations: Number(e.target.value) })}
/>
<p className="text-xs text-muted-foreground">20</p>
</div>
<div className="space-y-2">
<Label htmlFor="first_delay_seconds"></Label>
<Input
id="first_delay_seconds"
type="number"
min="0"
value={config.first_delay_seconds}
onChange={(e) => onChange({ ...config, first_delay_seconds: Number(e.target.value) })}
/>
<p className="text-xs text-muted-foreground">60</p>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex gap-2">
<Select value={platform} onValueChange={handlePlatformChange}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="选择平台" />
</SelectTrigger>
<SelectContent>
<SelectItem value="qq">QQ</SelectItem>
<SelectItem value="wx"></SelectItem>
<SelectItem value="webui">WebUI</SelectItem>
</SelectContent>
</Select>
<Input
type="text"
placeholder="输入用户ID (例如: 123456)"
value={userId}
onChange={(e) => handleUserIdChange(e.target.value)}
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
IDID为空则不推送
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label></Label>
<Button type="button" size="sm" onClick={handleAddTimeRange}>
</Button>
</div>
<p className="text-xs text-muted-foreground">
23:00 02:00
</p>
<div className="space-y-2">
{config.dream_time_ranges.map((range, index) => {
const { startTime, endTime } = parseTimeRange(range)
return (
<div key={index} className="flex items-center gap-2">
<Input
type="time"
value={startTime}
onChange={(e) => handleTimeRangeChange(index, 'startTime', e.target.value)}
className="w-[140px]"
/>
<span className="text-muted-foreground"></span>
<Input
type="time"
value={endTime}
onChange={(e) => handleTimeRangeChange(index, 'endTime', e.target.value)}
className="w-[140px]"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveTimeRange(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
)
})}
{config.dream_time_ranges.length === 0 && (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Switch
id="dream_visible"
checked={config.dream_visible}
onCheckedChange={(checked) => onChange({ ...config, dream_visible: checked })}
/>
<Label htmlFor="dream_visible" className="cursor-pointer">
</Label>
</div>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
)
})

View File

@@ -0,0 +1,311 @@
import React from 'react'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Plus, Trash2, AlertTriangle, Eye, Code2 } from 'lucide-react'
import type { ExperimentalConfig } from '../types'
interface ChatPromptData {
platform: string
id: string
type: 'group' | 'private'
prompt: string
}
interface ExperimentalSectionProps {
config: ExperimentalConfig
onChange: (config: ExperimentalConfig) => void
}
export const ExperimentalSection = React.memo(function ExperimentalSection({ config, onChange }: ExperimentalSectionProps) {
// 解析 chat_prompt 字符串为结构化数据
const parseChatPrompt = (promptStr: string): ChatPromptData => {
const parts = promptStr.split(':')
if (parts.length >= 4) {
const platform = parts[0]
const id = parts[1]
const type = parts[2] as 'group' | 'private'
const prompt = parts.slice(3).join(':') // 处理 prompt 中可能包含的冒号
return { platform, id, type, prompt }
}
return { platform: 'qq', id: '', type: 'group', prompt: '' }
}
// 将结构化数据转换为字符串
const stringifyChatPrompt = (data: ChatPromptData): string => {
return `${data.platform}:${data.id}:${data.type}:${data.prompt}`
}
const addChatPrompt = () => {
onChange({ ...config, chat_prompts: [...config.chat_prompts, 'qq::group:'] })
}
const removeChatPrompt = (index: number) => {
onChange({
...config,
chat_prompts: config.chat_prompts.filter((_, i) => i !== index),
})
}
const updateChatPrompt = (index: number, data: Partial<ChatPromptData>) => {
const currentData = parseChatPrompt(config.chat_prompts[index])
const newData = { ...currentData, ...data }
const newPrompts = [...config.chat_prompts]
newPrompts[index] = stringifyChatPrompt(newData)
onChange({ ...config, chat_prompts: newPrompts })
}
// 预览组件
const ChatPromptPreview = ({ promptStr }: { promptStr: string }) => {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-1" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 sm:w-96">
<div className="space-y-2">
<h4 className="font-medium text-sm"></h4>
<div className="rounded-md bg-muted p-3 font-mono text-xs break-all">
"{promptStr}"
</div>
<p className="text-xs text-muted-foreground">
bot_config.toml
</p>
</div>
</PopoverContent>
</Popover>
)
}
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
<div className="flex items-start gap-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20">
<AlertTriangle className="h-5 w-5 text-orange-500 shrink-0 mt-0.5" />
<div className="space-y-1">
<h4 className="font-medium text-orange-500"></h4>
<p className="text-sm text-muted-foreground">
使
</p>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid gap-6">
<div className="flex items-center space-x-2">
<Switch
id="lpmm_memory"
checked={config.lpmm_memory ?? false}
onCheckedChange={(checked) =>
onChange({ ...config, lpmm_memory: checked })
}
/>
<Label htmlFor="lpmm_memory" className="cursor-pointer">
LPMM
</Label>
</div>
<p className="text-xs text-muted-foreground -mt-4">
chat_history_summarizer
</p>
<div className="grid gap-2">
<Label htmlFor="private_plan_style"></Label>
<Textarea
id="private_plan_style"
value={config.private_plan_style}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange({ ...config, private_plan_style: e.target.value })}
placeholder="私聊的说话规则和行为风格(不推荐修改)"
rows={4}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="grid gap-4">
<div className="flex items-center justify-between">
<div>
<Label> Prompt </Label>
<p className="text-xs text-muted-foreground mt-1">
prompt
</p>
</div>
<Button onClick={addChatPrompt} size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="space-y-4">
{config.chat_prompts.map((promptStr, index) => {
const data = parseChatPrompt(promptStr)
return (
<div key={index} className="rounded-lg border p-4 space-y-4 bg-card">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
Prompt {index + 1}
</span>
<div className="flex items-center gap-2">
<ChatPromptPreview promptStr={promptStr} />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
prompt
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => removeChatPrompt(index)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="grid gap-4">
{/* 平台选择 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={data.platform}
onValueChange={(value) => updateChatPrompt(index, { platform: value })}
>
<SelectTrigger>
<SelectValue placeholder="选择平台" />
</SelectTrigger>
<SelectContent>
<SelectItem value="qq">QQ</SelectItem>
<SelectItem value="wx"></SelectItem>
<SelectItem value="webui">WebUI</SelectItem>
</SelectContent>
</Select>
</div>
{/* ID 输入 */}
<div className="grid gap-2">
<Label className="text-xs font-medium">
{data.type === 'group' ? '群号' : '用户ID'}
</Label>
<Input
value={data.id}
onChange={(e) => updateChatPrompt(index, { id: e.target.value })}
placeholder={data.type === 'group' ? '输入群号' : '输入用户ID'}
className="font-mono"
/>
</div>
{/* 类型选择 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={data.type}
onValueChange={(value: 'group' | 'private') => updateChatPrompt(index, { type: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="group"> (group)</SelectItem>
<SelectItem value="private"> (private)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Prompt 内容 */}
<div className="grid gap-2">
<Label className="text-xs font-medium">Prompt </Label>
<Textarea
value={data.prompt}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => updateChatPrompt(index, { prompt: e.target.value })}
placeholder="输入额外的 prompt 内容,例如:这是一个摄影群,你精通摄影知识"
rows={3}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 原始格式显示 */}
<div className="rounded-md bg-muted/50 p-3">
<div className="flex items-center gap-2 mb-2">
<Code2 className="h-3 w-3 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground"></span>
</div>
<code className="text-xs font-mono text-muted-foreground break-all">
{promptStr || '(未配置)'}
</code>
</div>
</div>
</div>
)
})}
{config.chat_prompts.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm"> prompt </p>
<p className="text-xs mt-1">"添加配置"</p>
</div>
)}
</div>
{/* 使用说明 */}
<div className="text-xs text-muted-foreground space-y-2 p-4 rounded-lg bg-muted/30 border">
<p className="font-medium text-foreground">💡 使</p>
<ul className="list-disc list-inside space-y-1 pl-2">
<li></li>
<li>QQWebUI</li>
<li></li>
<li>Prompt </li>
</ul>
<p className="font-medium text-foreground mt-3">📝 </p>
<ul className="list-disc list-inside space-y-1 pl-2">
<li><code className="text-xs bg-muted px-1 py-0.5 rounded"></code></li>
<li><code className="text-xs bg-muted px-1 py-0.5 rounded"></code></li>
<li><code className="text-xs bg-muted px-1 py-0.5 rounded"></code></li>
</ul>
</div>
</div>
</div>
</div>
</div>
)
})

View File

@@ -0,0 +1,996 @@
import React, { useState } from 'react'
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Plus, Trash2, Eye } from 'lucide-react'
import type { ExpressionConfig } from '../types'
interface ExpressionGroupMemberInputProps {
member: string
groupIndex: number
memberIndex: number
availableChatIds: string[]
onUpdate: (groupIndex: number, memberIndex: number, value: string) => void
onRemove: (groupIndex: number, memberIndex: number) => void
}
const ExpressionGroupMemberInput = React.memo(function ExpressionGroupMemberInput({
member,
groupIndex,
memberIndex,
availableChatIds,
onUpdate,
onRemove,
}: ExpressionGroupMemberInputProps) {
// 判断当前成员是否在可选列表中
const isFromList = availableChatIds.includes(member) || member === '*'
const [inputMode, setInputMode] = useState(!isFromList)
return (
<div className="flex gap-2">
{/* 输入模式切换 */}
<div className="flex-1 flex gap-2">
{inputMode ? (
// 手动输入模式
<>
<Input
value={member}
onChange={(e) => onUpdate(groupIndex, memberIndex, e.target.value)}
placeholder='输入 "*" 或 "qq:123456:group"'
className="flex-1"
/>
{availableChatIds.length > 0 && (
<Button
size="sm"
variant="outline"
onClick={() => setInputMode(false)}
title="切换到下拉选择"
>
</Button>
)}
</>
) : (
// 下拉选择模式
<>
<Select
value={member}
onValueChange={(value) => onUpdate(groupIndex, memberIndex, value)}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="选择聊天流" />
</SelectTrigger>
<SelectContent>
<SelectItem value="*">* ()</SelectItem>
{availableChatIds.map((chatId, idx) => (
<SelectItem key={idx} value={chatId}>
{chatId}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
onClick={() => setInputMode(true)}
title="切换到手动输入"
>
</Button>
</>
)}
</div>
{/* 删除按钮 */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="icon" variant="outline">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
"{member || '(空)'}"
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => onRemove(groupIndex, memberIndex)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
})
interface ExpressionSectionProps {
config: ExpressionConfig
onChange: (config: ExpressionConfig) => void
}
export const ExpressionSection = React.memo(function ExpressionSection({
config,
onChange,
}: ExpressionSectionProps) {
// 添加学习规则
const addLearningRule = () => {
onChange({
...config,
learning_list: [...config.learning_list, ['', 'enable', 'enable', '1.0']],
})
}
// 删除学习规则
const removeLearningRule = (index: number) => {
onChange({
...config,
learning_list: config.learning_list.filter((_, i) => i !== index),
})
}
// 更新学习规则
const updateLearningRule = (
index: number,
field: 0 | 1 | 2 | 3,
value: string
) => {
const newList = [...config.learning_list]
newList[index][field] = value
onChange({
...config,
learning_list: newList,
})
}
// 预览组件
const LearningRulePreview = ({ rule }: { rule: [string, string, string, string] }) => {
const previewText = `["${rule[0]}", "${rule[1]}", "${rule[2]}", "${rule[3]}"]`
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-1" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 sm:w-96">
<div className="space-y-2">
<h4 className="font-medium text-sm"></h4>
<div className="rounded-md bg-muted p-3 font-mono text-xs break-all">
{previewText}
</div>
<p className="text-xs text-muted-foreground">
bot_config.toml
</p>
</div>
</PopoverContent>
</Popover>
)
}
// 添加表达组
const addExpressionGroup = () => {
onChange({
...config,
expression_groups: [...config.expression_groups, []],
})
}
// 删除表达组
const removeExpressionGroup = (index: number) => {
onChange({
...config,
expression_groups: config.expression_groups.filter((_, i) => i !== index),
})
}
// 添加组成员
const addGroupMember = (groupIndex: number) => {
const newGroups = [...config.expression_groups]
newGroups[groupIndex] = [...newGroups[groupIndex], '']
onChange({
...config,
expression_groups: newGroups,
})
}
// 删除组成员
const removeGroupMember = (groupIndex: number, memberIndex: number) => {
const newGroups = [...config.expression_groups]
newGroups[groupIndex] = newGroups[groupIndex].filter((_, i) => i !== memberIndex)
onChange({
...config,
expression_groups: newGroups,
})
}
// 更新组成员
const updateGroupMember = (groupIndex: number, memberIndex: number, value: string) => {
const newGroups = [...config.expression_groups]
newGroups[groupIndex][memberIndex] = value
onChange({
...config,
expression_groups: newGroups,
})
}
return (
<div className="space-y-6">
{/* 黑话设置 - 移到顶部 */}
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
<h3 className="text-lg font-semibold mb-4"></h3>
<div>
<div className="flex items-center space-x-2">
<Switch
id="all_global_jargon"
checked={config.all_global_jargon ?? false}
onCheckedChange={(checked) =>
onChange({ ...config, all_global_jargon: checked })
}
/>
<Label htmlFor="all_global_jargon" className="cursor-pointer">
</Label>
</div>
<p className="text-xs text-muted-foreground mt-2">
</p>
</div>
<div>
<div className="flex items-center space-x-2">
<Switch
id="enable_jargon_explanation"
checked={config.enable_jargon_explanation ?? true}
onCheckedChange={(checked) =>
onChange({ ...config, enable_jargon_explanation: checked })
}
/>
<Label htmlFor="enable_jargon_explanation" className="cursor-pointer">
</Label>
</div>
<p className="text-xs text-muted-foreground mt-2">
LLM调用
</p>
</div>
<div>
<Label htmlFor="jargon_mode"></Label>
<Select
value={config.jargon_mode ?? 'context'}
onValueChange={(value) => onChange({ ...config, jargon_mode: value })}
>
<SelectTrigger id="jargon_mode" className="mt-2">
<SelectValue placeholder="选择黑话解释来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="context"></SelectItem>
<SelectItem value="planner">Planner模式使unknown_words列表</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-2">
使<br />
Planner模式使Planner在reply动作中给出的unknown_words列表进行黑话检索
</p>
</div>
</div>
{/* 表达学习配置 */}
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold"></h3>
<p className="text-sm text-muted-foreground mt-1">
使
</p>
</div>
<Button onClick={addLearningRule} size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="space-y-4">
{config.learning_list.map((rule, index) => {
// 检查是否已有全局配置rule[0] === ''
const hasGlobalConfig = config.learning_list.some((r, i) => i !== index && r[0] === '')
const isGlobal = rule[0] === ''
// 解析聊天流 ID格式platform:id:type
const parts = rule[0].split(':')
const platform = parts[0] || 'qq'
const chatId = parts[1] || ''
const chatType = parts[2] || 'group'
return (
<div key={index} className="rounded-lg border p-4 space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{index + 1} {isGlobal && '(全局配置)'}
</span>
<div className="flex items-center gap-2">
<LearningRulePreview rule={rule} />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{index + 1}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => removeLearningRule(index)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="space-y-4">
{/* 配置类型选择 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={isGlobal ? 'global' : 'specific'}
onValueChange={(value) => {
if (value === 'global') {
updateLearningRule(index, 0, '')
} else {
// 切换到详细配置时,设置默认值
updateLearningRule(index, 0, 'qq::group')
}
}}
disabled={hasGlobalConfig && !isGlobal}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global"></SelectItem>
<SelectItem value="specific" disabled={hasGlobalConfig && !isGlobal}>
</SelectItem>
</SelectContent>
</Select>
{hasGlobalConfig && !isGlobal && (
<p className="text-xs text-amber-600">
</p>
)}
</div>
{/* 详细配置选项 - 只在非全局时显示 */}
{!isGlobal && (
<div className="grid gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{/* 平台选择 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={platform}
onValueChange={(value) => {
updateLearningRule(index, 0, `${value}:${chatId}:${chatType}`)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="qq">QQ</SelectItem>
<SelectItem value="wx"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 群 ID 输入 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"> ID</Label>
<Input
value={chatId}
onChange={(e) => {
updateLearningRule(index, 0, `${platform}:${e.target.value}:${chatType}`)
}}
placeholder="输入群 ID"
className="font-mono text-sm"
/>
</div>
{/* 类型选择 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={chatType}
onValueChange={(value) => {
updateLearningRule(index, 0, `${platform}:${chatId}:${value}`)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="group">group</SelectItem>
<SelectItem value="private">private</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
ID{rule[0] || '(未设置)'}
</p>
</div>
)}
{/* 使用学到的表达 - 改为开关 */}
<div className="grid gap-2">
<div className="flex items-center justify-between">
<div>
<Label className="text-xs font-medium">使</Label>
<p className="text-xs text-muted-foreground mt-1">
使
</p>
</div>
<Switch
checked={rule[1] === 'enable'}
onCheckedChange={(checked) =>
updateLearningRule(index, 1, checked ? 'enable' : 'disable')
}
/>
</div>
</div>
{/* 学习表达 - 改为开关 */}
<div className="grid gap-2">
<div className="flex items-center justify-between">
<div>
<Label className="text-xs font-medium"></Label>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
<Switch
checked={rule[2] === 'enable'}
onCheckedChange={(checked) =>
updateLearningRule(index, 2, checked ? 'enable' : 'disable')
}
/>
</div>
</div>
{/* 启用黑话学习 - 改为开关 */}
<div className="grid gap-2">
<div className="flex items-center justify-between">
<div>
<Label className="text-xs font-medium"></Label>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
<Switch
checked={rule[3] === 'true' || rule[3] === 'enable'}
onCheckedChange={(checked) =>
updateLearningRule(index, 3, checked ? 'true' : 'false')
}
/>
</div>
</div>
</div>
</div>
)
})}
{config.learning_list.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
"添加规则"
</div>
)}
</div>
</div>
</div>
{/* 表达反思配置 */}
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold"></h3>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
{/* 自动表达优化 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="expression_self_reflect" className="cursor-pointer font-medium">
</Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
id="expression_self_reflect"
checked={config.expression_self_reflect ?? false}
onCheckedChange={(checked) =>
onChange({ ...config, expression_self_reflect: checked })
}
/>
</div>
{config.expression_self_reflect && (
<div className="space-y-4 pl-4 border-l-2 border-primary/20">
{/* 自动检查间隔 */}
<div className="space-y-2">
<Label htmlFor="expression_auto_check_interval">
</Label>
<Input
id="expression_auto_check_interval"
type="number"
min="60"
value={config.expression_auto_check_interval ?? 3600}
onChange={(e) =>
onChange({
...config,
expression_auto_check_interval: parseInt(e.target.value) || 3600,
})
}
/>
<p className="text-xs text-muted-foreground">
36001
</p>
</div>
{/* 每次检查数量 */}
<div className="space-y-2">
<Label htmlFor="expression_auto_check_count">
</Label>
<Input
id="expression_auto_check_count"
type="number"
min="1"
max="100"
value={config.expression_auto_check_count ?? 10}
onChange={(e) =>
onChange({
...config,
expression_auto_check_count: parseInt(e.target.value) || 10,
})
}
/>
<p className="text-xs text-muted-foreground">
10
</p>
</div>
{/* 自定义评估标准 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label></Label>
<Button
onClick={() => {
onChange({
...config,
expression_auto_check_custom_criteria: [
...(config.expression_auto_check_custom_criteria || []),
'',
],
})
}}
size="sm"
variant="outline"
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(config.expression_auto_check_custom_criteria || []).map((criterion, index) => (
<div key={index} className="flex gap-2">
<Input
value={criterion}
onChange={(e) => {
const newCriteria = [...(config.expression_auto_check_custom_criteria || [])]
newCriteria[index] = e.target.value
onChange({ ...config, expression_auto_check_custom_criteria: newCriteria })
}}
placeholder="输入评估标准,例如:是否符合角色人设"
className="flex-1"
/>
<Button
onClick={() => {
onChange({
...config,
expression_auto_check_custom_criteria: (config.expression_auto_check_custom_criteria || []).filter((_, i) => i !== index),
})
}}
size="icon"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
{(!config.expression_auto_check_custom_criteria || config.expression_auto_check_custom_criteria.length === 0) && (
<div className="text-center py-4 text-muted-foreground text-sm">
"添加标准"
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
)}
</div>
{/* 仅使用已检查的表达方式 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="expression_checked_only" className="cursor-pointer font-medium">
使
</Label>
<p className="text-xs text-muted-foreground">
使使使
</p>
</div>
<Switch
id="expression_checked_only"
checked={config.expression_checked_only ?? false}
onCheckedChange={(checked) =>
onChange({ ...config, expression_checked_only: checked })
}
/>
</div>
</div>
{/* 手动表达优化 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="expression_manual_reflect" className="cursor-pointer font-medium">
</Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
id="expression_manual_reflect"
checked={config.expression_manual_reflect ?? false}
onCheckedChange={(checked) =>
onChange({ ...config, expression_manual_reflect: checked })
}
/>
</div>
{config.expression_manual_reflect && (
<div className="space-y-4 pl-4 border-l-2 border-primary/20">
{/* 表达反思操作员 ID */}
<div className="rounded-lg border p-4 space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium"></span>
</div>
<div className="space-y-4">
{(() => {
const operatorId = config.manual_reflect_operator_id || ''
const parts = operatorId.split(':')
const platform = parts[0] || 'qq'
const chatId = parts[1] || ''
const chatType = parts[2] || 'private'
return (
<div className="grid gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{/* 平台选择 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={platform}
onValueChange={(value) => {
onChange({ ...config, manual_reflect_operator_id: `${value}:${chatId}:${chatType}` })
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="qq">QQ</SelectItem>
<SelectItem value="wx"></SelectItem>
</SelectContent>
</Select>
</div>
{/* ID 输入 */}
<div className="grid gap-2">
<Label className="text-xs font-medium">/ ID</Label>
<Input
value={chatId}
onChange={(e) => {
onChange({ ...config, manual_reflect_operator_id: `${platform}:${e.target.value}:${chatType}` })
}}
placeholder="输入 ID"
className="font-mono text-sm"
/>
</div>
{/* 类型选择 */}
<div className="grid gap-2">
<Label className="text-xs font-medium"></Label>
<Select
value={chatType}
onValueChange={(value) => {
onChange({ ...config, manual_reflect_operator_id: `${platform}:${chatId}:${value}` })
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="private">private</SelectItem>
<SelectItem value="group">group</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
ID{config.manual_reflect_operator_id || '(未设置)'}
</p>
<p className="text-xs text-muted-foreground">
IDplatform:id:type ( "qq:123456:private" "qq:654321:group")
</p>
</div>
)
})()}
</div>
</div>
{/* 允许反思的聊天流列表 */}
<div className="rounded-lg border p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium"></span>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
<Button
onClick={() => {
onChange({
...config,
allow_reflect: [...(config.allow_reflect || []), 'qq::group'],
})
}}
size="sm"
variant="outline"
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(config.allow_reflect || []).map((chatId, index) => {
const parts = chatId.split(':')
const platform = parts[0] || 'qq'
const id = parts[1] || ''
const chatType = parts[2] || 'group'
return (
<div key={index} className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
<Select
value={platform}
onValueChange={(value) => {
const newList = [...config.allow_reflect]
newList[index] = `${value}:${id}:${chatType}`
onChange({ ...config, allow_reflect: newList })
}}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="qq">QQ</SelectItem>
<SelectItem value="wx"></SelectItem>
</SelectContent>
</Select>
<Input
value={id}
onChange={(e) => {
const newList = [...config.allow_reflect]
newList[index] = `${platform}:${e.target.value}:${chatType}`
onChange({ ...config, allow_reflect: newList })
}}
placeholder="ID"
className="flex-1 font-mono text-sm"
/>
<Select
value={chatType}
onValueChange={(value) => {
const newList = [...config.allow_reflect]
newList[index] = `${platform}:${id}:${value}`
onChange({ ...config, allow_reflect: newList })
}}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="group"></SelectItem>
<SelectItem value="private"></SelectItem>
</SelectContent>
</Select>
<Button
onClick={() => {
onChange({
...config,
allow_reflect: config.allow_reflect.filter((_, i) => i !== index),
})
}}
size="sm"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)
})}
{(!config.allow_reflect || config.allow_reflect.length === 0) && (
<div className="text-center py-4 text-muted-foreground text-sm">
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* 表达共享组配置 */}
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold"></h3>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
<Button onClick={addExpressionGroup} size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="space-y-4">
{config.expression_groups.map((group, groupIndex) => {
// 获取所有已配置的聊天流 ID用于下拉框选项
const availableChatIds = config.learning_list
.map(rule => rule[0])
.filter(id => id !== '') // 过滤掉全局配置
return (
<div key={groupIndex} className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{groupIndex + 1}
{group.length === 1 && group[0] === '*' && '(全局共享)'}
</span>
<div className="flex gap-2">
<Button
onClick={() => addGroupMember(groupIndex)}
size="sm"
variant="outline"
>
<Plus className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="ghost">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{groupIndex + 1}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => removeExpressionGroup(groupIndex)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="space-y-2">
{group.map((member, memberIndex) => (
<ExpressionGroupMemberInput
key={`${groupIndex}-${memberIndex}`}
member={member}
groupIndex={groupIndex}
memberIndex={memberIndex}
availableChatIds={availableChatIds}
onUpdate={updateGroupMember}
onRemove={removeGroupMember}
/>
))}
</div>
<p className="text-xs text-muted-foreground">
"*"
</p>
</div>
)
})}
{config.expression_groups.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
"添加共享组"
</div>
)}
</div>
</div>
</div>
</div>
)
})

View File

@@ -0,0 +1,336 @@
import React from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import type { EmojiConfig, MemoryConfig, ToolConfig, VoiceConfig } from '../types'
interface FeaturesSectionProps {
emojiConfig: EmojiConfig
memoryConfig: MemoryConfig
toolConfig: ToolConfig
voiceConfig: VoiceConfig
onEmojiChange: (config: EmojiConfig) => void
onMemoryChange: (config: MemoryConfig) => void
onToolChange: (config: ToolConfig) => void
onVoiceChange: (config: VoiceConfig) => void
}
export const FeaturesSection = React.memo(function FeaturesSection({
emojiConfig,
memoryConfig,
toolConfig,
voiceConfig,
onEmojiChange,
onMemoryChange,
onToolChange,
onVoiceChange,
}: FeaturesSectionProps) {
return (
<div className="space-y-6">
{/* 工具设置 */}
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
<div>
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Switch
id="enable_tool"
checked={toolConfig.enable_tool}
onCheckedChange={(checked) => onToolChange({ ...toolConfig, enable_tool: checked })}
/>
<Label htmlFor="enable_tool" className="cursor-pointer">
</Label>
</div>
<p className="text-xs text-muted-foreground -mt-2">
使
</p>
<div className="flex items-center space-x-2 pt-2">
<Switch
id="enable_asr"
checked={voiceConfig.enable_asr}
onCheckedChange={(checked) => onVoiceChange({ ...voiceConfig, enable_asr: checked })}
/>
<Label htmlFor="enable_asr" className="cursor-pointer">
</Label>
</div>
<p className="text-xs text-muted-foreground -mt-2">
</p>
</div>
</div>
</div>
{/* 记忆设置 */}
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
<div>
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="max_agent_iterations"></Label>
<Input
id="max_agent_iterations"
type="number"
min="1"
value={memoryConfig.max_agent_iterations}
onChange={(e) =>
onMemoryChange({ ...memoryConfig, max_agent_iterations: parseInt(e.target.value) })
}
/>
<p className="text-xs text-muted-foreground"> 1</p>
</div>
<div className="grid gap-2">
<Label htmlFor="agent_timeout_seconds"></Label>
<Input
id="agent_timeout_seconds"
type="number"
min="1"
step="0.1"
value={memoryConfig.agent_timeout_seconds ?? 120}
onChange={(e) =>
onMemoryChange({ ...memoryConfig, agent_timeout_seconds: parseFloat(e.target.value) })
}
/>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="flex items-center space-x-2">
<Switch
id="enable_jargon_detection"
checked={memoryConfig.enable_jargon_detection ?? true}
onCheckedChange={(checked) =>
onMemoryChange({ ...memoryConfig, enable_jargon_detection: checked })
}
/>
<Label htmlFor="enable_jargon_detection" className="cursor-pointer">
</Label>
</div>
<p className="text-xs text-muted-foreground -mt-2">
</p>
<div className="flex items-center space-x-2">
<Switch
id="global_memory"
checked={memoryConfig.global_memory ?? false}
onCheckedChange={(checked) =>
onMemoryChange({ ...memoryConfig, global_memory: checked })
}
/>
<Label htmlFor="global_memory" className="cursor-pointer">
</Label>
</div>
<p className="text-xs text-muted-foreground -mt-2">
</p>
{/* 聊天历史总结配置 */}
<div className="border-t pt-4 mt-4">
<h4 className="text-sm font-semibold mb-3"></h4>
<div className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="chat_history_topic_check_message_threshold"></Label>
<Input
id="chat_history_topic_check_message_threshold"
type="number"
min="1"
value={memoryConfig.chat_history_topic_check_message_threshold ?? 80}
onChange={(e) =>
onMemoryChange({ ...memoryConfig, chat_history_topic_check_message_threshold: parseInt(e.target.value) })
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="chat_history_topic_check_time_hours"></Label>
<Input
id="chat_history_topic_check_time_hours"
type="number"
min="0.1"
step="0.1"
value={memoryConfig.chat_history_topic_check_time_hours ?? 8.0}
onChange={(e) =>
onMemoryChange({ ...memoryConfig, chat_history_topic_check_time_hours: parseFloat(e.target.value) })
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="chat_history_topic_check_min_messages"></Label>
<Input
id="chat_history_topic_check_min_messages"
type="number"
min="1"
value={memoryConfig.chat_history_topic_check_min_messages ?? 20}
onChange={(e) =>
onMemoryChange({ ...memoryConfig, chat_history_topic_check_min_messages: parseInt(e.target.value) })
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="chat_history_finalize_no_update_checks"></Label>
<Input
id="chat_history_finalize_no_update_checks"
type="number"
min="1"
value={memoryConfig.chat_history_finalize_no_update_checks ?? 3}
onChange={(e) =>
onMemoryChange({ ...memoryConfig, chat_history_finalize_no_update_checks: parseInt(e.target.value) })
}
/>
<p className="text-xs text-muted-foreground">
N次检查无新增内容时触发打包存储
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="chat_history_finalize_message_count"></Label>
<Input
id="chat_history_finalize_message_count"
type="number"
min="1"
value={memoryConfig.chat_history_finalize_message_count ?? 5}
onChange={(e) =>
onMemoryChange({ ...memoryConfig, chat_history_finalize_message_count: parseInt(e.target.value) })
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* 表情包设置 */}
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
<div>
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="emoji_chance"></Label>
<Input
id="emoji_chance"
type="number"
step="0.1"
min="0"
max="1"
value={emojiConfig.emoji_chance}
onChange={(e) =>
onEmojiChange({ ...emojiConfig, emoji_chance: parseFloat(e.target.value) })
}
/>
<p className="text-xs text-muted-foreground"> 0-1</p>
</div>
<div className="grid gap-2">
<Label htmlFor="max_reg_num"></Label>
<Input
id="max_reg_num"
type="number"
min="1"
value={emojiConfig.max_reg_num}
onChange={(e) =>
onEmojiChange({ ...emojiConfig, max_reg_num: parseInt(e.target.value) })
}
/>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="grid gap-2">
<Label htmlFor="check_interval"></Label>
<Input
id="check_interval"
type="number"
min="1"
value={emojiConfig.check_interval}
onChange={(e) =>
onEmojiChange({ ...emojiConfig, check_interval: parseInt(e.target.value) })
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="flex items-center space-x-2">
<Switch
id="do_replace"
checked={emojiConfig.do_replace}
onCheckedChange={(checked) =>
onEmojiChange({ ...emojiConfig, do_replace: checked })
}
/>
<Label htmlFor="do_replace" className="cursor-pointer">
</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="steal_emoji"
checked={emojiConfig.steal_emoji}
onCheckedChange={(checked) =>
onEmojiChange({ ...emojiConfig, steal_emoji: checked })
}
/>
<Label htmlFor="steal_emoji" className="cursor-pointer">
</Label>
</div>
<p className="text-xs text-muted-foreground -mt-2">
</p>
<div className="flex items-center space-x-2">
<Switch
id="content_filtration"
checked={emojiConfig.content_filtration}
onCheckedChange={(checked) =>
onEmojiChange({ ...emojiConfig, content_filtration: checked })
}
/>
<Label htmlFor="content_filtration" className="cursor-pointer">
</Label>
</div>
{emojiConfig.content_filtration && (
<div className="grid gap-2 pl-6 border-l-2 border-primary/20">
<Label htmlFor="filtration_prompt"></Label>
<Input
id="filtration_prompt"
value={emojiConfig.filtration_prompt}
onChange={(e) =>
onEmojiChange({ ...emojiConfig, filtration_prompt: e.target.value })
}
placeholder="符合公序良俗"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
)}
</div>
</div>
</div>
</div>
)
})

View File

@@ -0,0 +1,150 @@
import React from 'react'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { LPMMKnowledgeConfig } from '../types'
interface LPMMSectionProps {
config: LPMMKnowledgeConfig
onChange: (config: LPMMKnowledgeConfig) => void
}
export const LPMMSection = React.memo(function LPMMSection({ config, onChange }: LPMMSectionProps) {
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
<h3 className="text-lg font-semibold">LPMM </h3>
<div className="grid gap-4">
<div className="flex items-center space-x-2">
<Switch
checked={config.enable}
onCheckedChange={(checked) => onChange({ ...config, enable: checked })}
/>
<Label className="cursor-pointer"> LPMM </Label>
</div>
{config.enable && (
<>
<div className="grid gap-2">
<Label>LPMM </Label>
<Select
value={config.lpmm_mode}
onValueChange={(value) => onChange({ ...config, lpmm_mode: value })}
>
<SelectTrigger>
<SelectValue placeholder="选择 LPMM 模式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="classic"></SelectItem>
<SelectItem value="agent">Agent </SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label> TopK</Label>
<Input
type="number"
min="1"
value={config.rag_synonym_search_top_k}
onChange={(e) =>
onChange({ ...config, rag_synonym_search_top_k: parseInt(e.target.value) })
}
/>
</div>
<div className="grid gap-2">
<Label></Label>
<Input
type="number"
step="0.1"
min="0"
max="1"
value={config.rag_synonym_threshold}
onChange={(e) =>
onChange({ ...config, rag_synonym_threshold: parseFloat(e.target.value) })
}
/>
</div>
<div className="grid gap-2">
<Label>线</Label>
<Input
type="number"
min="1"
value={config.info_extraction_workers}
onChange={(e) =>
onChange({ ...config, info_extraction_workers: parseInt(e.target.value) })
}
/>
</div>
<div className="grid gap-2">
<Label></Label>
<Input
type="number"
min="1"
value={config.embedding_dimension}
onChange={(e) =>
onChange({ ...config, embedding_dimension: parseInt(e.target.value) })
}
/>
</div>
<div className="grid gap-2">
<Label>线</Label>
<Input
type="number"
min="1"
value={config.max_embedding_workers}
onChange={(e) =>
onChange({ ...config, max_embedding_workers: parseInt(e.target.value) })
}
/>
</div>
<div className="grid gap-2">
<Label></Label>
<Input
type="number"
min="1"
value={config.embedding_chunk_size}
onChange={(e) =>
onChange({ ...config, embedding_chunk_size: parseInt(e.target.value) })
}
/>
</div>
<div className="grid gap-2">
<Label></Label>
<Input
type="number"
min="1"
value={config.max_synonym_entities}
onChange={(e) =>
onChange({ ...config, max_synonym_entities: parseInt(e.target.value) })
}
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={config.enable_ppr}
onCheckedChange={(checked) => onChange({ ...config, enable_ppr: checked })}
/>
<Label className="cursor-pointer"> PPR ()</Label>
</div>
</>
)}
</div>
</div>
)
})

View File

@@ -0,0 +1,264 @@
import React, { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Plus, Trash2 } from 'lucide-react'
import type { LogConfig } from '../types'
interface LogSectionProps {
config: LogConfig
onChange: (config: LogConfig) => void
}
export const LogSection = React.memo(function LogSection({ config, onChange }: LogSectionProps) {
const [newLibrary, setNewLibrary] = useState('')
const [newLogLevel, setNewLogLevel] = useState('WARNING')
const addSuppressedLibrary = () => {
if (newLibrary && !config.suppress_libraries.includes(newLibrary)) {
onChange({
...config,
suppress_libraries: [...config.suppress_libraries, newLibrary],
})
setNewLibrary('')
}
}
const removeSuppressedLibrary = (library: string) => {
onChange({
...config,
suppress_libraries: config.suppress_libraries.filter((l) => l !== library),
})
}
const addLibraryLogLevel = () => {
if (newLibrary && !config.library_log_levels[newLibrary]) {
onChange({
...config,
library_log_levels: { ...config.library_log_levels, [newLibrary]: newLogLevel },
})
setNewLibrary('')
setNewLogLevel('WARNING')
}
}
const removeLibraryLogLevel = (library: string) => {
const newLevels = { ...config.library_log_levels }
delete newLevels[library]
onChange({ ...config, library_log_levels: newLevels })
}
const logLevels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
const logLevelStyles = ['FULL', 'compact', 'lite']
const colorTextOptions = ['none', 'title', 'full']
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label></Label>
<Input
value={config.date_style}
onChange={(e) => onChange({ ...config, date_style: e.target.value })}
placeholder="例如: m-d H:i:s"
/>
<p className="text-xs text-muted-foreground">m=, d=, H=, i=, s=</p>
</div>
<div className="grid gap-2">
<Label></Label>
<Select
value={config.log_level_style}
onValueChange={(value) => onChange({ ...config, log_level_style: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{logLevelStyles.map((style) => (
<SelectItem key={style} value={style}>
{style}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label></Label>
<Select
value={config.color_text}
onValueChange={(value) => onChange({ ...config, color_text: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{colorTextOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label></Label>
<Select
value={config.log_level}
onValueChange={(value) => onChange({ ...config, log_level: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{logLevels.map((level) => (
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label></Label>
<Select
value={config.console_log_level}
onValueChange={(value) => onChange({ ...config, console_log_level: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{logLevels.map((level) => (
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label></Label>
<Select
value={config.file_log_level}
onValueChange={(value) => onChange({ ...config, file_log_level: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{logLevels.map((level) => (
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* 屏蔽的库 */}
<div>
<Label className="mb-2 block"></Label>
<div className="flex gap-2 mb-2">
<Input
value={newLibrary}
onChange={(e) => setNewLibrary(e.target.value)}
placeholder="输入库名"
className="flex-1"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addSuppressedLibrary()
}
}}
/>
<Button onClick={addSuppressedLibrary} size="sm" className="flex-shrink-0">
<Plus className="h-4 w-4" strokeWidth={2} fill="none" />
</Button>
</div>
<div className="flex flex-wrap gap-2">
{config.suppress_libraries.map((library) => (
<div
key={library}
className="flex items-center gap-1 bg-secondary px-3 py-1 rounded-md"
>
<span className="text-sm">{library}</span>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => removeSuppressedLibrary(library)}
>
<Trash2 className="h-3 w-3" strokeWidth={2} fill="none" />
</Button>
</div>
))}
</div>
</div>
{/* 特定库日志级别 */}
<div>
<Label className="mb-2 block"></Label>
<div className="flex gap-2 mb-2">
<Input
value={newLibrary}
onChange={(e) => setNewLibrary(e.target.value)}
placeholder="输入库名"
className="flex-1"
/>
<Select value={newLogLevel} onValueChange={setNewLogLevel}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{logLevels.map((level) => (
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={addLibraryLogLevel} size="sm">
<Plus className="h-4 w-4" strokeWidth={2} fill="none" />
</Button>
</div>
<div className="space-y-2">
{Object.entries(config.library_log_levels).map(([library, level]) => (
<div
key={library}
className="flex items-center justify-between bg-secondary px-3 py-2 rounded-md"
>
<span className="text-sm font-medium">{library}</span>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{level}</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => removeLibraryLogLevel(library)}
>
<Trash2 className="h-3 w-3" strokeWidth={2} fill="none" />
</Button>
</div>
</div>
))}
</div>
</div>
</div>
)
})

View File

@@ -0,0 +1,203 @@
import React, { useState } from 'react'
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 { Plus, Trash2 } from 'lucide-react'
import type { MaimMessageConfig } from '../types'
interface MaimMessageSectionProps {
config: MaimMessageConfig
onChange: (config: MaimMessageConfig) => void
}
export const MaimMessageSection = React.memo(function MaimMessageSection({ config, onChange }: MaimMessageSectionProps) {
const [newToken, setNewToken] = useState('')
const [newApiKey, setNewApiKey] = useState('')
const addToken = () => {
if (newToken && !config.auth_token.includes(newToken)) {
onChange({ ...config, auth_token: [...config.auth_token, newToken] })
setNewToken('')
}
}
const removeToken = (index: number) => {
onChange({
...config,
auth_token: config.auth_token.filter((_, i) => i !== index),
})
}
const addApiKey = () => {
if (newApiKey && !config.api_server_allowed_api_keys.includes(newApiKey)) {
onChange({ ...config, api_server_allowed_api_keys: [...config.api_server_allowed_api_keys, newApiKey] })
setNewApiKey('')
}
}
const removeApiKey = (index: number) => {
onChange({
...config,
api_server_allowed_api_keys: config.api_server_allowed_api_keys.filter((_, i) => i !== index),
})
}
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
{/* 认证令牌 */}
<div>
<h3 className="text-lg font-semibold mb-2"> API </h3>
<p className="text-sm text-muted-foreground mb-3"> API </p>
<div className="flex gap-2 mb-2">
<Input
value={newToken}
onChange={(e) => setNewToken(e.target.value)}
placeholder="输入认证令牌"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addToken()
}
}}
/>
<Button onClick={addToken} size="sm">
<Plus className="h-4 w-4" strokeWidth={2} fill="none" />
</Button>
</div>
<div className="space-y-2">
{config.auth_token.map((token, index) => (
<div
key={index}
className="flex items-center justify-between bg-secondary px-3 py-2 rounded-md"
>
<span className="text-sm font-mono">{token}</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => removeToken(index)}
>
<Trash2 className="h-3 w-3" strokeWidth={2} fill="none" />
</Button>
</div>
))}
</div>
</div>
{/* 新版 API Server */}
<div>
<h3 className="text-lg font-semibold mb-4"> API Server </h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> API Server</Label>
<p className="text-sm text-muted-foreground">
API Server
</p>
</div>
<Switch
checked={config.enable_api_server}
onCheckedChange={(checked) => onChange({ ...config, enable_api_server: checked })}
/>
</div>
{config.enable_api_server && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label></Label>
<Input
value={config.api_server_host}
onChange={(e) => onChange({ ...config, api_server_host: e.target.value })}
placeholder="0.0.0.0"
/>
</div>
<div className="grid gap-2">
<Label></Label>
<Input
type="number"
value={config.api_server_port}
onChange={(e) => onChange({ ...config, api_server_port: parseInt(e.target.value) })}
placeholder="8090"
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={config.api_server_use_wss}
onCheckedChange={(checked) => onChange({ ...config, api_server_use_wss: checked })}
/>
<Label> WSS </Label>
</div>
{config.api_server_use_wss && (
<div className="grid gap-4">
<div className="grid gap-2">
<Label>SSL </Label>
<Input
value={config.api_server_cert_file}
onChange={(e) => onChange({ ...config, api_server_cert_file: e.target.value })}
placeholder="cert.pem"
/>
</div>
<div className="grid gap-2">
<Label>SSL </Label>
<Input
value={config.api_server_key_file}
onChange={(e) => onChange({ ...config, api_server_key_file: e.target.value })}
placeholder="key.pem"
/>
</div>
</div>
)}
{/* API Keys */}
<div>
<Label className="mb-2 block"> API Key </Label>
<p className="text-sm text-muted-foreground mb-2"></p>
<div className="flex gap-2 mb-2">
<Input
value={newApiKey}
onChange={(e) => setNewApiKey(e.target.value)}
placeholder="输入 API Key"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addApiKey()
}
}}
/>
<Button onClick={addApiKey} size="sm">
<Plus className="h-4 w-4" strokeWidth={2} fill="none" />
</Button>
</div>
<div className="space-y-2">
{config.api_server_allowed_api_keys.map((apiKey, index) => (
<div
key={index}
className="flex items-center justify-between bg-secondary px-3 py-2 rounded-md"
>
<span className="text-sm font-mono">{apiKey}</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => removeApiKey(index)}
>
<Trash2 className="h-3 w-3" strokeWidth={2} fill="none" />
</Button>
</div>
))}
</div>
</div>
</>
)}
</div>
</div>
</div>
)
})

View File

@@ -0,0 +1,259 @@
import React, { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Plus, Trash2, AlertTriangle } from 'lucide-react'
import type { MessageReceiveConfig } from '../types'
interface MessageReceiveSectionProps {
config: MessageReceiveConfig
onChange: (config: MessageReceiveConfig) => void
}
/**
* 消息过滤配置模块
* 管理 ban_words、ban_msgs_regex 和 mute_group_list
*/
export default function MessageReceiveSection({
config,
onChange,
}: MessageReceiveSectionProps) {
const [newBanWord, setNewBanWord] = useState('')
const [newBanRegex, setNewBanRegex] = useState('')
// === 禁用词管理 ===
const handleAddBanWord = () => {
const trimmed = newBanWord.trim()
if (trimmed && !config.ban_words.includes(trimmed)) {
onChange({
...config,
ban_words: [...config.ban_words, trimmed],
})
setNewBanWord('')
}
}
const handleRemoveBanWord = (index: number) => {
onChange({
...config,
ban_words: config.ban_words.filter((_, i) => i !== index),
})
}
const handleBanWordKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddBanWord()
}
}
// === 禁用正则表达式管理 ===
const handleAddBanRegex = () => {
const trimmed = newBanRegex.trim()
if (trimmed && !config.ban_msgs_regex.includes(trimmed)) {
// 验证正则表达式语法
try {
new RegExp(trimmed)
onChange({
...config,
ban_msgs_regex: [...config.ban_msgs_regex, trimmed],
})
setNewBanRegex('')
} catch (err) {
alert(`正则表达式语法错误:${(err as Error).message}`)
}
}
}
const handleRemoveBanRegex = (index: number) => {
onChange({
...config,
ban_msgs_regex: config.ban_msgs_regex.filter((_, i) => i !== index),
})
}
const handleBanRegexKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddBanRegex()
}
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="ban_words" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="ban_words"></TabsTrigger>
<TabsTrigger value="ban_regex"></TabsTrigger>
</TabsList>
{/* 禁用关键词 Tab */}
<TabsContent value="ban_words" className="space-y-4">
<div className="space-y-2">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-yellow-500 mt-1 flex-shrink-0" />
<p className="text-sm text-muted-foreground">
Bot
</p>
</div>
<div className="flex gap-2">
<Input
placeholder="输入要禁用的关键词(按回车添加)"
value={newBanWord}
onChange={(e) => setNewBanWord(e.target.value)}
onKeyDown={handleBanWordKeyDown}
/>
<Button onClick={handleAddBanWord} size="icon">
<Plus className="h-4 w-4" />
</Button>
</div>
{config.ban_words.length === 0 ? (
<div className="rounded-md border border-dashed p-8 text-center">
<p className="text-sm text-muted-foreground">
</p>
</div>
) : (
<div className="space-y-2">
{config.ban_words.map((word, index) => (
<div
key={index}
className="flex items-center justify-between rounded-md border p-3"
>
<code className="text-sm">{word}</code>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
<code>"{word}"</code>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => handleRemoveBanWord(index)}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
</div>
)}
</div>
</TabsContent>
{/* 禁用正则表达式 Tab */}
<TabsContent value="ban_regex" className="space-y-4">
<div className="space-y-2">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-yellow-500 mt-1 flex-shrink-0" />
<div className="text-sm text-muted-foreground space-y-1">
<p></p>
<p className="text-xs">
</p>
</div>
</div>
<div className="flex gap-2">
<Textarea
placeholder="输入正则表达式(按回车添加)&#10;示例https?://[^\s]+ 匹配链接"
value={newBanRegex}
onChange={(e) => setNewBanRegex(e.target.value)}
onKeyDown={handleBanRegexKeyDown}
className="min-h-[60px] font-mono text-sm"
/>
<Button onClick={handleAddBanRegex} size="icon">
<Plus className="h-4 w-4" />
</Button>
</div>
{config.ban_msgs_regex.length === 0 ? (
<div className="rounded-md border border-dashed p-8 text-center">
<p className="text-sm text-muted-foreground">
</p>
</div>
) : (
<div className="space-y-2">
{config.ban_msgs_regex.map((regex, index) => (
<div
key={index}
className="flex items-center justify-between rounded-md border p-3"
>
<code className="text-sm font-mono flex-1 break-all">
{regex}
</code>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="ml-2 flex-shrink-0">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
<code>"{regex}"</code>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => handleRemoveBanRegex(index)}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
</div>
)}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,164 @@
import React from 'react'
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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Plus, Trash2 } from 'lucide-react'
import type { PersonalityConfig } from '../types'
interface PersonalitySectionProps {
config: PersonalityConfig
onChange: (config: PersonalityConfig) => void
}
export const PersonalitySection = React.memo(function PersonalitySection({ config, onChange }: PersonalitySectionProps) {
const addState = () => {
onChange({ ...config, states: [...config.states, ''] })
}
const removeState = (index: number) => {
onChange({
...config,
states: config.states.filter((_, i) => i !== index),
})
}
const updateState = (index: number, value: string) => {
const newStates = [...config.states]
newStates[index] = value
onChange({ ...config, states: newStates })
}
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="personality"></Label>
<Textarea
id="personality"
value={config.personality}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange({ ...config, personality: e.target.value })}
placeholder="描述人格特质和身份特征建议120字以内"
rows={3}
/>
<p className="text-xs text-muted-foreground">
120
</p>
</div>
{/* 多重人格配置 - 移到人格特质下方 */}
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label></Label>
<Button onClick={addState} size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
</p>
<div className="space-y-2">
{config.states.map((state, index) => (
<div key={index} className="flex gap-2">
<Textarea
value={state}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => updateState(index, e.target.value)}
placeholder="描述一个人格状态"
rows={2}
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="icon" variant="outline">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => removeState(index)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="state_probability"></Label>
<Input
id="state_probability"
type="number"
step="0.1"
min="0"
max="1"
value={config.state_probability}
onChange={(e) =>
onChange({ ...config, state_probability: parseFloat(e.target.value) })
}
/>
<p className="text-xs text-muted-foreground">
0.0-1.0
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="reply_style"></Label>
<Textarea
id="reply_style"
value={config.reply_style}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange({ ...config, reply_style: e.target.value })}
placeholder="描述说话的表达风格和习惯"
rows={3}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="plan_style"></Label>
<Textarea
id="plan_style"
value={config.plan_style}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange({ ...config, plan_style: e.target.value })}
placeholder="麦麦的说话规则和行为风格"
rows={5}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="visual_style"></Label>
<Textarea
id="visual_style"
value={config.visual_style}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange({ ...config, visual_style: e.target.value })}
placeholder="识图时的处理规则"
rows={3}
/>
</div>
</div>
</div>
</div>
)
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
import React from 'react'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import type { TelemetryConfig } from '../types'
interface TelemetrySectionProps {
config: TelemetryConfig
onChange: (config: TelemetryConfig) => void
}
export const TelemetrySection = React.memo(function TelemetrySection({ config, onChange }: TelemetrySectionProps) {
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
<h3 className="text-lg font-semibold"></h3>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label></Label>
<p className="text-sm text-muted-foreground">
</p>
</div>
<Switch
checked={config.enable}
onCheckedChange={(checked) => onChange({ ...config, enable: checked })}
/>
</div>
</div>
)
})

View File

@@ -0,0 +1,27 @@
import React from 'react'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import type { VoiceConfig } from '../types'
interface VoiceSectionProps {
config: VoiceConfig
onChange: (config: VoiceConfig) => void
}
export const VoiceSection = React.memo(function VoiceSection({ config, onChange }: VoiceSectionProps) {
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
<h3 className="text-lg font-semibold"></h3>
<div className="flex items-center space-x-2">
<Switch
checked={config.enable_asr}
onCheckedChange={(checked) => onChange({ ...config, enable_asr: checked })}
/>
<Label className="cursor-pointer"></Label>
</div>
<p className="text-xs text-muted-foreground">
</p>
</div>
)
})

View File

@@ -0,0 +1,287 @@
import React, { useState } from 'react'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { X, Plus } from 'lucide-react'
import type { WebUIConfig } from '../types'
interface WebUISectionProps {
config: WebUIConfig
onChange: (config: WebUIConfig) => void
}
export const WebUISection = React.memo(function WebUISection({ config, onChange }: WebUISectionProps) {
const [newAllowedIp, setNewAllowedIp] = useState('')
const [newTrustedProxy, setNewTrustedProxy] = useState('')
const [showDisableWarning, setShowDisableWarning] = useState(false)
// 将逗号分隔的字符串转换为数组
const allowedIpsList = config.allowed_ips
? config.allowed_ips.split(',').map(ip => ip.trim()).filter(ip => ip)
: []
const trustedProxiesList = config.trusted_proxies
? config.trusted_proxies.split(',').map(ip => ip.trim()).filter(ip => ip)
: []
// 处理添加IP白名单
const handleAddAllowedIp = () => {
if (!newAllowedIp.trim()) return
const updatedList = [...allowedIpsList, newAllowedIp.trim()]
onChange({ ...config, allowed_ips: updatedList.join(',') })
setNewAllowedIp('')
}
// 处理删除IP白名单
const handleRemoveAllowedIp = (index: number) => {
const updatedList = allowedIpsList.filter((_, i) => i !== index)
onChange({ ...config, allowed_ips: updatedList.join(',') })
}
// 处理添加信任代理
const handleAddTrustedProxy = () => {
if (!newTrustedProxy.trim()) return
const updatedList = [...trustedProxiesList, newTrustedProxy.trim()]
onChange({ ...config, trusted_proxies: updatedList.join(',') })
setNewTrustedProxy('')
}
// 处理删除信任代理
const handleRemoveTrustedProxy = (index: number) => {
const updatedList = trustedProxiesList.filter((_, i) => i !== index)
onChange({ ...config, trusted_proxies: updatedList.join(',') })
}
// 处理WebUI开关变更
const handleEnabledChange = (checked: boolean) => {
if (!checked && config.enabled) {
// 用户尝试关闭WebUI显示警告
setShowDisableWarning(true)
} else {
// 用户开启WebUI直接更新
onChange({ ...config, enabled: checked })
}
}
// 确认关闭WebUI
const confirmDisableWebUI = () => {
onChange({ ...config, enabled: false })
setShowDisableWarning(false)
}
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
<h3 className="text-lg font-semibold">WebUI </h3>
<div className="grid gap-4">
<div className="flex items-center space-x-2">
<Switch
checked={config.enabled}
onCheckedChange={handleEnabledChange}
/>
<Label className="cursor-pointer"> WebUI</Label>
</div>
{config.enabled && (
<>
<div className="grid gap-2">
<Label></Label>
<Select
value={config.mode}
onValueChange={(value) => onChange({ ...config, mode: value })}
>
<SelectTrigger>
<SelectValue placeholder="选择运行模式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="development"></SelectItem>
<SelectItem value="production"></SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
注意: WebUI .env WEBUI_HOST WEBUI_PORT
</p>
</div>
<div className="grid gap-2">
<Label></Label>
<Select
value={config.anti_crawler_mode}
onValueChange={(value) => onChange({ ...config, anti_crawler_mode: value })}
>
<SelectTrigger>
<SelectValue placeholder="选择防爬虫模式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="false"></SelectItem>
<SelectItem value="basic"></SelectItem>
<SelectItem value="loose"></SelectItem>
<SelectItem value="strict"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2 sm:col-span-2">
<Label>IP </Label>
<div className="flex gap-2">
<Input
value={newAllowedIp}
onChange={(e) => setNewAllowedIp(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddAllowedIp()
}
}}
placeholder="输入IP地址后按回车或点击添加"
/>
<Button
type="button"
size="sm"
onClick={handleAddAllowedIp}
disabled={!newAllowedIp.trim()}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{allowedIpsList.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{allowedIpsList.map((ip, index) => (
<Badge key={index} variant="secondary" className="flex items-center gap-1">
{ip}
<button
type="button"
onClick={() => handleRemoveAllowedIp(index)}
className="ml-1 hover:bg-destructive/20 rounded-full p-0.5"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
IPCIDR格式和通配符127.0.0.1192.168.1.0/24
</p>
</div>
<div className="grid gap-2 sm:col-span-2">
<Label> IP</Label>
<div className="flex gap-2">
<Input
value={newTrustedProxy}
onChange={(e) => setNewTrustedProxy(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddTrustedProxy()
}
}}
placeholder="输入代理IP后按回车或点击添加"
/>
<Button
type="button"
size="sm"
onClick={handleAddTrustedProxy}
disabled={!newTrustedProxy.trim()}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{trustedProxiesList.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{trustedProxiesList.map((ip, index) => (
<Badge key={index} variant="secondary" className="flex items-center gap-1">
{ip}
<button
type="button"
onClick={() => handleRemoveTrustedProxy(index)}
className="ml-1 hover:bg-destructive/20 rounded-full p-0.5"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
IP的X-Forwarded-For头才被信任
</p>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={config.trust_xff}
onCheckedChange={(checked) => onChange({ ...config, trust_xff: checked })}
/>
<Label className="cursor-pointer"> X-Forwarded-For </Label>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={config.secure_cookie}
onCheckedChange={(checked) => onChange({ ...config, secure_cookie: checked })}
/>
<Label className="cursor-pointer"> Cookie HTTPS</Label>
</div>
<div className="grid gap-2">
<div className="flex items-center space-x-2">
<Switch
checked={config.enable_paragraph_content}
onCheckedChange={(checked) => onChange({ ...config, enable_paragraph_content: checked })}
/>
<Label className="cursor-pointer"></Label>
</div>
<p className="text-xs text-muted-foreground">
embedding storeMB
</p>
</div>
</>
)}
</div>
{/* 关闭WebUI警告对话框 */}
<AlertDialog open={showDisableWarning} onOpenChange={setShowDisableWarning}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> WebUI</AlertDialogTitle>
<AlertDialogDescription>
WebUI WebUI 访
<br />
<br />
WebUI 访
<br />
<br />
WebUI
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={confirmDisableWebUI}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
})

View File

@@ -0,0 +1,19 @@
/**
* Bot 配置页面各个 Section 组件
*/
export { BotInfoSection } from './BotInfoSection'
export { PersonalitySection } from './PersonalitySection'
export { ChatSection } from './ChatSection'
export { DreamSection } from './DreamSection'
export { LPMMSection } from './LPMMSection'
export { LogSection } from './LogSection'
export { DebugSection } from './DebugSection'
export { ExperimentalSection } from './ExperimentalSection'
export { MaimMessageSection } from './MaimMessageSection'
export { TelemetrySection } from './TelemetrySection'
export { FeaturesSection } from './FeaturesSection'
export { ExpressionSection } from './ExpressionSection'
export { ProcessingSection } from './ProcessingSection'
export { default as MessageReceiveSection } from './MessageReceiveSection'
export { WebUISection } from './WebUISection'

View File

@@ -0,0 +1,259 @@
/**
* Bot 配置页面相关类型定义
*/
export interface BotConfig {
platform: string
qq_account: string | number
nickname: string
platforms: string[]
alias_names: string[]
}
export interface PersonalityConfig {
personality: string
reply_style: string
interest: string
plan_style: string
visual_style: string
states: string[]
state_probability: number
}
export interface ChatConfig {
talk_value: number
mentioned_bot_reply: boolean
max_context_size: number
planner_smooth: number
think_mode: 'classic' | 'deep' | 'dynamic'
plan_reply_log_max_per_chat: number
llm_quote: boolean
enable_talk_value_rules: boolean
talk_value_rules: Array<{
target: string
time: string
value: number
}>
}
export interface ExpressionConfig {
learning_list: Array<[string, string, string, string]>
expression_groups: Array<string[]>
expression_manual_reflect: boolean
manual_reflect_operator_id: string
allow_reflect: string[]
expression_self_reflect: boolean
expression_auto_check_interval: number
expression_auto_check_count: number
expression_auto_check_custom_criteria: string[]
expression_checked_only: boolean
all_global_jargon: boolean
enable_jargon_explanation: boolean
jargon_mode: string
}
export interface EmojiConfig {
emoji_chance: number
max_reg_num: number
do_replace: boolean
check_interval: number
steal_emoji: boolean
content_filtration: boolean
filtration_prompt: string
}
export interface MemoryConfig {
max_agent_iterations: number
agent_timeout_seconds: number
enable_jargon_detection: boolean
global_memory: boolean
chat_history_topic_check_message_threshold: number
chat_history_topic_check_time_hours: number
chat_history_topic_check_min_messages: number
chat_history_finalize_no_update_checks: number
chat_history_finalize_message_count: number
}
export interface ToolConfig {
enable_tool: boolean
}
// MoodConfig 已在后端移除
export interface VoiceConfig {
enable_asr: boolean
}
export interface MessageReceiveConfig {
ban_words: string[]
ban_msgs_regex: string[]
}
export interface DreamConfig {
interval_minutes: number
max_iterations: number
first_delay_seconds: number
dream_send: string
dream_time_ranges: string[]
dream_visible: boolean
}
export interface LPMMKnowledgeConfig {
enable: boolean
lpmm_mode: string
rag_synonym_search_top_k: number
rag_synonym_threshold: number
info_extraction_workers: number
qa_relation_search_top_k: number
qa_relation_threshold: number
qa_paragraph_search_top_k: number
qa_paragraph_node_weight: number
qa_ent_filter_top_k: number
qa_ppr_damping: number
qa_res_top_k: number
embedding_dimension: number
max_embedding_workers: number
embedding_chunk_size: number
max_synonym_entities: number
enable_ppr: boolean
}
export interface KeywordRule {
keywords?: string[]
regex?: string[]
reaction: string
}
export interface KeywordReactionConfig {
keyword_rules: KeywordRule[]
regex_rules: KeywordRule[]
}
export interface ResponsePostProcessConfig {
enable_response_post_process: boolean
}
export interface ChineseTypoConfig {
enable: boolean
error_rate: number
min_freq: number
tone_error_rate: number
word_replace_rate: number
}
export interface ResponseSplitterConfig {
enable: boolean
max_length: number
max_sentence_num: number
enable_kaomoji_protection: boolean
enable_overflow_return_all: boolean
}
export interface LogConfig {
date_style: string
log_level_style: string
color_text: string
log_level: string
console_log_level: string
file_log_level: string
suppress_libraries: string[]
library_log_levels: Record<string, string>
}
export interface DebugConfig {
show_prompt: boolean
show_replyer_prompt: boolean
show_replyer_reasoning: boolean
show_jargon_prompt: boolean
show_memory_prompt: boolean
show_planner_prompt: boolean
show_lpmm_paragraph: boolean
}
export interface ExperimentalConfig {
private_plan_style: string
chat_prompts: string[]
lpmm_memory: boolean
}
export interface MaimMessageConfig {
auth_token: string[]
enable_api_server: boolean
api_server_host: string
api_server_port: number
api_server_use_wss: boolean
api_server_cert_file: string
api_server_key_file: string
api_server_allowed_api_keys: string[]
}
export interface TelemetryConfig {
enable: boolean
}
/**
* WebUI 配置
* 注意: host 和 port 配置已移至环境变量 WEBUI_HOST 和 WEBUI_PORT
*/
export interface WebUIConfig {
enabled: boolean
mode: string
anti_crawler_mode: string
allowed_ips: string
trusted_proxies: string
trust_xff: boolean
secure_cookie: boolean
enable_paragraph_content: boolean
}
/**
* 所有配置的聚合类型
*/
export interface AllBotConfigs {
botConfig: BotConfig | null
personalityConfig: PersonalityConfig | null
chatConfig: ChatConfig | null
expressionConfig: ExpressionConfig | null
emojiConfig: EmojiConfig | null
memoryConfig: MemoryConfig | null
toolConfig: ToolConfig | null
voiceConfig: VoiceConfig | null
messageReceiveConfig: MessageReceiveConfig | null
dreamConfig: DreamConfig | null
lpmmConfig: LPMMKnowledgeConfig | null
keywordReactionConfig: KeywordReactionConfig | null
responsePostProcessConfig: ResponsePostProcessConfig | null
chineseTypoConfig: ChineseTypoConfig | null
responseSplitterConfig: ResponseSplitterConfig | null
logConfig: LogConfig | null
debugConfig: DebugConfig | null
experimentalConfig: ExperimentalConfig | null
maimMessageConfig: MaimMessageConfig | null
telemetryConfig: TelemetryConfig | null
}
/**
* 配置节名称到类型的映射
*/
export type ConfigSectionName =
| 'bot'
| 'personality'
| 'chat'
| 'expression'
| 'emoji'
| 'memory'
| 'tool'
| 'voice'
| 'message_receive'
| 'dream'
| 'lpmm_knowledge'
| 'keyword_reaction'
| 'response_post_process'
| 'chinese_typo'
| 'response_splitter'
| 'log'
| 'debug'
| 'experimental'
| 'maim_message'
| 'telemetry'
| 'webui'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
/**
* 模型列表 - 移动端卡片视图
*/
import React from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Pencil, Trash2 } from 'lucide-react'
import type { ModelInfo } from '../types'
interface ModelCardListProps {
/** 当前页显示的模型 (分页后的) */
paginatedModels: ModelInfo[]
/** 所有模型列表 (未分页) */
allModels: ModelInfo[]
/** 编辑模型回调 */
onEdit: (model: ModelInfo, index: number) => void
/** 删除模型回调 */
onDelete: (index: number) => void
/** 检查模型是否被使用 */
isModelUsed: (modelName: string) => boolean
/** 搜索关键词 */
searchQuery: string
}
export const ModelCardList = React.memo(function ModelCardList({
paginatedModels,
allModels,
onEdit,
onDelete,
isModelUsed,
searchQuery,
}: ModelCardListProps) {
if (paginatedModels.length === 0) {
return (
<div className="md:hidden text-center text-muted-foreground py-8 rounded-lg border bg-card">
{searchQuery ? '未找到匹配的模型' : '暂无模型配置'}
</div>
)
}
return (
<div className="md:hidden space-y-3">
{paginatedModels.map((model, displayIndex) => {
const actualIndex = allModels.findIndex(m => m === model)
const used = isModelUsed(model.name)
return (
<div key={displayIndex} className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-base">{model.name}</h3>
<Badge
variant={used ? "default" : "secondary"}
className={used ? "bg-green-600 hover:bg-green-700" : ""}
>
{used ? '已使用' : '未使用'}
</Badge>
</div>
<p className="text-xs text-muted-foreground break-all" title={model.model_identifier}>
{model.model_identifier}
</p>
</div>
<div className="flex gap-1 flex-shrink-0">
<Button
variant="default"
size="sm"
onClick={() => onEdit(model, actualIndex)}
>
<Pencil className="h-4 w-4 mr-1" strokeWidth={2} fill="none" />
</Button>
<Button
size="sm"
onClick={() => onDelete(actualIndex)}
className="bg-red-600 hover:bg-red-700 text-white"
>
<Trash2 className="h-4 w-4 mr-1" strokeWidth={2} fill="none" />
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-muted-foreground text-xs"></span>
<p className="font-medium">{model.api_provider}</p>
</div>
<div>
<span className="text-muted-foreground text-xs"></span>
<p className="font-medium">{model.temperature != null ? model.temperature : <span className="text-muted-foreground"></span>}</p>
</div>
<div>
<span className="text-muted-foreground text-xs"></span>
<p className="font-medium">¥{model.price_in}/M</p>
</div>
<div>
<span className="text-muted-foreground text-xs"></span>
<p className="font-medium">¥{model.price_out}/M</p>
</div>
</div>
</div>
)
})}
</div>
)
})

View File

@@ -0,0 +1,142 @@
/**
* 模型列表 - 桌面端表格视图
*/
import React from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Pencil, Trash2 } from 'lucide-react'
import type { ModelInfo } from '../types'
interface ModelTableProps {
/** 当前页显示的模型 (分页后的) */
paginatedModels: ModelInfo[]
/** 所有模型列表 (未分页) */
allModels: ModelInfo[]
/** 过滤后的模型列表 */
filteredModels: ModelInfo[]
/** 已选中的模型索引集合 */
selectedModels: Set<number>
/** 编辑模型回调 */
onEdit: (model: ModelInfo, index: number) => void
/** 删除模型回调 */
onDelete: (index: number) => void
/** 切换选中状态回调 */
onToggleSelection: (index: number) => void
/** 切换全选回调 */
onToggleSelectAll: () => void
/** 检查模型是否被使用 */
isModelUsed: (modelName: string) => boolean
/** 搜索关键词 */
searchQuery: string
}
export const ModelTable = React.memo(function ModelTable({
paginatedModels,
allModels,
filteredModels,
selectedModels,
onEdit,
onDelete,
onToggleSelection,
onToggleSelectAll,
isModelUsed,
searchQuery,
}: ModelTableProps) {
return (
<div className="hidden md:block rounded-lg border bg-card overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={selectedModels.size === filteredModels.length && filteredModels.length > 0}
onCheckedChange={onToggleSelectAll}
/>
</TableHead>
<TableHead className="w-24">使</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedModels.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center text-muted-foreground py-8">
{searchQuery ? '未找到匹配的模型' : '暂无模型配置'}
</TableCell>
</TableRow>
) : (
paginatedModels.map((model, displayIndex) => {
const actualIndex = allModels.findIndex(m => m === model)
const used = isModelUsed(model.name)
return (
<TableRow key={displayIndex}>
<TableCell>
<Checkbox
checked={selectedModels.has(actualIndex)}
onCheckedChange={() => onToggleSelection(actualIndex)}
/>
</TableCell>
<TableCell>
<Badge
variant={used ? "default" : "secondary"}
className={used ? "bg-green-600 hover:bg-green-700" : ""}
>
{used ? '已使用' : '未使用'}
</Badge>
</TableCell>
<TableCell className="font-medium">{model.name}</TableCell>
<TableCell className="max-w-xs truncate" title={model.model_identifier}>
{model.model_identifier}
</TableCell>
<TableCell>{model.api_provider}</TableCell>
<TableCell className="text-center">
{model.temperature != null ? model.temperature : <span className="text-muted-foreground">-</span>}
</TableCell>
<TableCell className="text-right">¥{model.price_in}/M</TableCell>
<TableCell className="text-right">¥{model.price_out}/M</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="default"
size="sm"
onClick={() => onEdit(model, actualIndex)}
>
<Pencil className="h-4 w-4 mr-1" strokeWidth={2} fill="none" />
</Button>
<Button
size="sm"
onClick={() => onDelete(actualIndex)}
className="bg-red-600 hover:bg-red-700 text-white"
>
<Trash2 className="h-4 w-4 mr-1" strokeWidth={2} fill="none" />
</Button>
</div>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
</div>
)
})

View File

@@ -0,0 +1,142 @@
/**
* 模型列表分页组件
*/
import React from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'
import { PAGE_SIZE_OPTIONS } from '../constants'
interface PaginationProps {
page: number
pageSize: number
totalItems: number
jumpToPage: string
onPageChange: (page: number) => void
onPageSizeChange: (size: number) => void
onJumpToPageChange: (value: string) => void
onJumpToPage: () => void
onSelectionClear?: () => void
}
export const Pagination = React.memo(function Pagination({
page,
pageSize,
totalItems,
jumpToPage,
onPageChange,
onPageSizeChange,
onJumpToPageChange,
onJumpToPage,
onSelectionClear,
}: PaginationProps) {
const totalPages = Math.ceil(totalItems / pageSize)
const handlePageSizeChange = (value: string) => {
onPageSizeChange(parseInt(value))
onPageChange(1)
onSelectionClear?.()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
onJumpToPage()
}
}
if (totalItems === 0) return null
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-4">
<div className="flex items-center gap-2">
<Label htmlFor="page-size-model" className="text-sm whitespace-nowrap"></Label>
<Select
value={pageSize.toString()}
onValueChange={handlePageSizeChange}
>
<SelectTrigger id="page-size-model" className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={size.toString()}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">
{(page - 1) * pageSize + 1} {' '}
{Math.min(page * pageSize, totalItems)} {totalItems}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(1)}
disabled={page === 1}
className="hidden sm:flex"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline"></span>
</Button>
<div className="flex items-center gap-2">
<Input
type="number"
value={jumpToPage}
onChange={(e) => onJumpToPageChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={page.toString()}
className="w-16 h-8 text-center"
min={1}
max={totalPages}
/>
<Button
variant="outline"
size="sm"
onClick={onJumpToPage}
disabled={!jumpToPage}
className="h-8"
>
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
>
<span className="hidden sm:inline"></span>
<ChevronRight className="h-4 w-4 sm:ml-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(totalPages)}
disabled={page >= totalPages}
className="hidden sm:flex"
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
)
})

View File

@@ -0,0 +1,155 @@
/**
* 任务配置卡片组件
*/
import React from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { MultiSelect } from '@/components/ui/multi-select'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { TaskConfig } from '../types'
interface TaskConfigCardProps {
title: string
description: string
taskConfig: TaskConfig
modelNames: string[]
onChange: (field: keyof TaskConfig, value: string[] | number | string) => void
hideTemperature?: boolean
hideMaxTokens?: boolean
dataTour?: string
}
export const TaskConfigCard = React.memo(function TaskConfigCard({
title,
description,
taskConfig,
modelNames,
onChange,
hideTemperature = false,
hideMaxTokens = false,
dataTour,
}: TaskConfigCardProps) {
const handleModelChange = (values: string[]) => {
onChange('model_list', values)
}
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
<div>
<h4 className="font-semibold text-base sm:text-lg">{title}</h4>
<p className="text-xs sm:text-sm text-muted-foreground mt-1">{description}</p>
</div>
<div className="grid gap-4">
{/* 模型列表 */}
<div className="grid gap-2" data-tour={dataTour}>
<Label></Label>
<MultiSelect
options={modelNames.map((name) => ({ label: name, value: name }))}
selected={taskConfig.model_list || []}
onChange={handleModelChange}
placeholder="选择模型..."
emptyText="暂无可用模型"
/>
</div>
{/* 温度和最大 Token */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{!hideTemperature && (
<div className="grid gap-3">
<div className="flex items-center justify-between">
<Label></Label>
<Input
type="number"
step="0.1"
min="0"
max="1"
value={taskConfig.temperature ?? 0.3}
onChange={(e) => {
const value = parseFloat(e.target.value)
if (!isNaN(value) && value >= 0 && value <= 1) {
onChange('temperature', value)
}
}}
className="w-20 h-8 text-sm"
/>
</div>
<Slider
value={[taskConfig.temperature ?? 0.3]}
onValueChange={(values) => onChange('temperature', values[0])}
min={0}
max={1}
step={0.1}
className="w-full"
/>
</div>
)}
{!hideMaxTokens && (
<div className="grid gap-2">
<Label> Token</Label>
<Input
type="number"
step="1"
min="1"
value={taskConfig.max_tokens ?? 1024}
onChange={(e) => onChange('max_tokens', parseInt(e.target.value))}
/>
</div>
)}
</div>
{/* 慢请求阈值 */}
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label> ()</Label>
<span className="text-xs text-muted-foreground"></span>
</div>
<Input
type="number"
step="1"
min="1"
value={taskConfig.slow_threshold ?? 15}
onChange={(e) => {
const value = parseInt(e.target.value)
if (!isNaN(value) && value >= 1) {
onChange('slow_threshold', value)
}
}}
placeholder="15"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 模型选择策略 */}
<div className="grid gap-2">
<Label></Label>
<Select
value={taskConfig.selection_strategy ?? 'balance'}
onValueChange={(value) => onChange('selection_strategy', value)}
>
<SelectTrigger>
<SelectValue placeholder="选择模型选择策略" />
</SelectTrigger>
<SelectContent>
<SelectItem value="balance">balance</SelectItem>
<SelectItem value="random">random</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
使
</p>
</div>
</div>
</div>
)
})

View File

@@ -0,0 +1,8 @@
/**
* Model 配置页面组件导出
*/
export { TaskConfigCard } from './TaskConfigCard'
export { ModelCardList } from './ModelCardList'
export { ModelTable } from './ModelTable'
export { Pagination } from './Pagination'

View File

@@ -0,0 +1,107 @@
/**
* Model 配置页面常量
*/
import type { ModelListItem } from '@/lib/config-api'
/**
* 模型列表缓存 TTL (5 分钟)
*/
export const CACHE_TTL = 5 * 60 * 1000
/**
* 模型列表缓存
*/
export const modelListCache = new Map<string, { models: ModelListItem[], timestamp: number }>()
/**
* 任务配置信息
*/
export const TASK_CONFIGS = [
{
key: 'utils' as const,
title: '组件模型 (utils)',
description: '用于表情包、取名、关系、情绪变化等组件',
},
{
key: 'utils_small' as const,
title: '组件小模型 (utils_small)',
description: '消耗量较大的组件,建议使用速度较快的小模型',
},
{
key: 'tool_use' as const,
title: '工具调用模型 (tool_use)',
description: '需要使用支持工具调用的模型',
},
{
key: 'replyer' as const,
title: '首要回复模型 (replyer)',
description: '用于表达器和表达方式学习',
},
{
key: 'planner' as const,
title: '决策模型 (planner)',
description: '负责决定麦麦该什么时候回复',
},
{
key: 'vlm' as const,
title: '图像识别模型 (vlm)',
description: '视觉语言模型',
hideTemperature: true,
},
{
key: 'voice' as const,
title: '语音识别模型 (voice)',
description: '语音转文字',
hideTemperature: true,
hideMaxTokens: true,
},
{
key: 'embedding' as const,
title: '嵌入模型 (embedding)',
description: '用于向量化',
hideTemperature: true,
hideMaxTokens: true,
},
] as const
/**
* LPMM 任务配置信息
*/
export const LPMM_TASK_CONFIGS = [
{
key: 'lpmm_entity_extract' as const,
title: '实体提取模型 (lpmm_entity_extract)',
description: '从文本中提取实体',
},
{
key: 'lpmm_rdf_build' as const,
title: 'RDF 构建模型 (lpmm_rdf_build)',
description: '构建知识图谱',
},
{
key: 'lpmm_qa' as const,
title: '问答模型 (lpmm_qa)',
description: '知识库问答',
},
] as const
/**
* 默认模型信息
*/
export const DEFAULT_MODEL_INFO = {
model_identifier: '',
name: '',
api_provider: '',
price_in: 0,
price_out: 0,
temperature: null,
max_tokens: null,
force_stream_mode: false,
extra_params: {},
} as const
/**
* 分页大小选项
*/
export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const

View File

@@ -0,0 +1,7 @@
/**
* Model 配置页面 Hooks 导出
*/
export { useModelAutoSave } from './useModelAutoSave'
export { useModelTour } from './useModelTour'
export { useModelFetcher, useAutoFetchModels } from './useModelFetcher'

View File

@@ -0,0 +1,164 @@
/**
* Model 配置页面自动保存 Hook
* 监听 models 和 taskConfig 变化,自动保存到服务器
*/
import { useRef, useEffect, useCallback } from 'react'
import { updateModelConfigSection } from '@/lib/config-api'
import type { ModelInfo, ModelTaskConfig } from '../types'
interface UseModelAutoSaveOptions {
/** 模型列表 */
models: ModelInfo[]
/** 任务配置 */
taskConfig: ModelTaskConfig | null
/** 防抖延迟时间 (ms) */
debounceMs?: number
/** 保存状态回调 */
onSavingChange?: (saving: boolean) => void
/** 未保存变更回调 */
onUnsavedChange?: (hasUnsaved: boolean) => void
}
interface UseModelAutoSaveReturn {
/** 清除所有待执行的保存定时器 */
clearTimers: () => void
/** 初始加载状态标记引用 (用于设置初始加载完成) */
initialLoadRef: React.MutableRefObject<boolean>
}
/**
* 模型配置自动保存 Hook
*/
export function useModelAutoSave(
options: UseModelAutoSaveOptions
): UseModelAutoSaveReturn {
const {
models,
taskConfig,
debounceMs = 2000,
onSavingChange,
onUnsavedChange,
} = options
// 防抖定时器
const modelsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const taskConfigTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const initialLoadRef = useRef(true)
// 清除定时器
const clearTimers = useCallback(() => {
if (modelsTimerRef.current) {
clearTimeout(modelsTimerRef.current)
modelsTimerRef.current = null
}
if (taskConfigTimerRef.current) {
clearTimeout(taskConfigTimerRef.current)
taskConfigTimerRef.current = null
}
}, [])
// 清理模型中的 null 值TOML 不支持 null
const cleanModelForSave = useCallback((model: ModelInfo): ModelInfo => {
const cleaned: ModelInfo = {
model_identifier: model.model_identifier,
name: model.name,
api_provider: model.api_provider,
price_in: model.price_in ?? 0,
price_out: model.price_out ?? 0,
force_stream_mode: model.force_stream_mode ?? false,
extra_params: model.extra_params ?? {},
}
// 只有在有值时才添加可选字段
if (model.temperature != null) {
cleaned.temperature = model.temperature
}
if (model.max_tokens != null) {
cleaned.max_tokens = model.max_tokens
}
return cleaned
}, [])
// 自动保存模型列表
const autoSaveModels = useCallback(async (newModels: ModelInfo[]) => {
try {
onSavingChange?.(true)
// 清理每个模型中的 null 值
const cleanedModels = newModels.map(cleanModelForSave)
await updateModelConfigSection('models', cleanedModels)
onUnsavedChange?.(false)
} catch (error) {
console.error('自动保存模型列表失败:', error)
onUnsavedChange?.(true)
} finally {
onSavingChange?.(false)
}
}, [onSavingChange, onUnsavedChange, cleanModelForSave])
// 自动保存任务配置
const autoSaveTaskConfig = useCallback(async (newTaskConfig: ModelTaskConfig) => {
try {
onSavingChange?.(true)
await updateModelConfigSection('model_task_config', newTaskConfig)
onUnsavedChange?.(false)
} catch (error) {
console.error('自动保存任务配置失败:', error)
onUnsavedChange?.(true)
} finally {
onSavingChange?.(false)
}
}, [onSavingChange, onUnsavedChange])
// 监听 models 变化
useEffect(() => {
if (initialLoadRef.current) return
onUnsavedChange?.(true)
if (modelsTimerRef.current) {
clearTimeout(modelsTimerRef.current)
}
modelsTimerRef.current = setTimeout(() => {
autoSaveModels(models)
}, debounceMs)
return () => {
if (modelsTimerRef.current) {
clearTimeout(modelsTimerRef.current)
}
}
}, [models, autoSaveModels, debounceMs, onUnsavedChange])
// 监听 taskConfig 变化
useEffect(() => {
if (initialLoadRef.current || !taskConfig) return
onUnsavedChange?.(true)
if (taskConfigTimerRef.current) {
clearTimeout(taskConfigTimerRef.current)
}
taskConfigTimerRef.current = setTimeout(() => {
autoSaveTaskConfig(taskConfig)
}, debounceMs)
return () => {
if (taskConfigTimerRef.current) {
clearTimeout(taskConfigTimerRef.current)
}
}
}, [taskConfig, autoSaveTaskConfig, debounceMs, onUnsavedChange])
// 组件卸载时清除定时器
useEffect(() => {
return () => {
clearTimers()
}
}, [clearTimers])
return {
clearTimers,
initialLoadRef,
}
}

View File

@@ -0,0 +1,143 @@
/**
* 模型列表获取 Hook
*/
import { useState, useCallback, useEffect } from 'react'
import { fetchProviderModels, type ModelListItem } from '@/lib/config-api'
import { findTemplateByBaseUrl, type ProviderTemplate } from '../../providerTemplates'
import { modelListCache, CACHE_TTL } from '../constants'
import type { ProviderConfig } from '../types'
interface UseModelFetcherOptions {
/** 获取提供商配置的函数 */
getProviderConfig: (providerName: string) => ProviderConfig | undefined
}
interface UseModelFetcherReturn {
/** 可用模型列表 */
availableModels: ModelListItem[]
/** 是否正在获取模型列表 */
fetchingModels: boolean
/** 模型获取错误信息 */
modelFetchError: string | null
/** 匹配的模板 */
matchedTemplate: ProviderTemplate | null
/** 获取指定提供商的模型列表 */
fetchModelsForProvider: (providerName: string, forceRefresh?: boolean) => Promise<void>
/** 清空模型列表和错误状态 */
clearModels: () => void
}
/**
* 模型列表获取 Hook
*/
export function useModelFetcher(options: UseModelFetcherOptions): UseModelFetcherReturn {
const { getProviderConfig } = options
const [availableModels, setAvailableModels] = useState<ModelListItem[]>([])
const [fetchingModels, setFetchingModels] = useState(false)
const [modelFetchError, setModelFetchError] = useState<string | null>(null)
const [matchedTemplate, setMatchedTemplate] = useState<ProviderTemplate | null>(null)
// 清空模型列表和错误状态
const clearModels = useCallback(() => {
setAvailableModels([])
setModelFetchError(null)
setMatchedTemplate(null)
}, [])
// 获取提供商的模型列表
const fetchModelsForProvider = useCallback(async (providerName: string, forceRefresh = false) => {
const config = getProviderConfig(providerName)
if (!config?.base_url) {
setAvailableModels([])
setMatchedTemplate(null)
setModelFetchError('提供商配置不完整,请先在"模型提供商配置"中配置')
return
}
// 检查 API Key 是否已配置
if (!config.api_key) {
setAvailableModels([])
setMatchedTemplate(null)
setModelFetchError('该提供商未配置 API Key请先在"模型提供商配置"中填写')
return
}
// 查找匹配的模板
const template = findTemplateByBaseUrl(config.base_url)
setMatchedTemplate(template)
// 如果没有模板或模板不支持获取模型列表
if (!template?.modelFetcher) {
setAvailableModels([])
setModelFetchError(null)
return
}
// 检查缓存
const cacheKey = `${providerName}:${config.base_url}`
const cached = modelListCache.get(cacheKey)
if (!forceRefresh && cached && Date.now() - cached.timestamp < CACHE_TTL) {
setAvailableModels(cached.models)
setModelFetchError(null)
return
}
// 获取模型列表
setFetchingModels(true)
setModelFetchError(null)
try {
const models = await fetchProviderModels(
providerName,
template.modelFetcher.parser,
template.modelFetcher.endpoint
)
setAvailableModels(models)
// 更新缓存
modelListCache.set(cacheKey, { models, timestamp: Date.now() })
} catch (error) {
console.error('获取模型列表失败:', error)
const errorMessage = (error as Error).message || '获取模型列表失败'
// 根据错误类型提供更友好的提示
if (errorMessage.includes('无效') || errorMessage.includes('过期') || errorMessage.includes('API Key')) {
setModelFetchError('API Key 无效或已过期,请检查"模型提供商配置"中的密钥')
} else if (errorMessage.includes('权限')) {
setModelFetchError('没有权限获取模型列表,请检查 API Key 权限')
} else if (errorMessage.includes('timeout') || errorMessage.includes('超时')) {
setModelFetchError('请求超时,请检查网络连接后重试')
} else if (errorMessage.includes('不支持')) {
setModelFetchError('该提供商不支持自动获取模型列表,请手动输入')
} else {
setModelFetchError(errorMessage)
}
setAvailableModels([])
} finally {
setFetchingModels(false)
}
}, [getProviderConfig])
return {
availableModels,
fetchingModels,
modelFetchError,
matchedTemplate,
fetchModelsForProvider,
clearModels,
}
}
/**
* 当选择的提供商变化时自动获取模型列表的 Hook
*/
export function useAutoFetchModels(
editDialogOpen: boolean,
apiProvider: string | undefined,
fetchModelsForProvider: (providerName: string, forceRefresh?: boolean) => Promise<void>
) {
useEffect(() => {
if (editDialogOpen && apiProvider) {
fetchModelsForProvider(apiProvider)
}
}, [editDialogOpen, apiProvider, fetchModelsForProvider])
}

View File

@@ -0,0 +1,109 @@
/**
* Model 配置页面 Tour 引导 Hook
*/
import { useEffect, useRef, useCallback } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTour } from '@/components/tour'
import { MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps, STEP_ROUTE_MAP } from '@/components/tour/tours/model-assignment-tour'
interface UseModelTourOptions {
/** 关闭编辑对话框回调 */
onCloseEditDialog?: () => void
}
interface UseModelTourReturn {
/** 开始引导 */
startTour: () => void
/** Tour 是否正在运行 */
isRunning: boolean
/** 当前步骤索引 */
stepIndex: number
}
/**
* Model 配置页面 Tour 引导 Hook
*/
export function useModelTour(options: UseModelTourOptions = {}): UseModelTourReturn {
const { onCloseEditDialog } = options
const navigate = useNavigate()
const { registerTour, startTour: startTourFn, state: tourState, goToStep } = useTour()
// 用于追踪前一个步骤
const prevTourStepRef = useRef(tourState.stepIndex)
// 注册 Tour
useEffect(() => {
registerTour(MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps)
}, [registerTour])
// 监听 Tour 步骤变化,处理页面导航
useEffect(() => {
if (tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID && tourState.isRunning) {
const targetRoute = STEP_ROUTE_MAP[tourState.stepIndex]
if (targetRoute && !window.location.pathname.endsWith(targetRoute.replace('/config/', ''))) {
navigate({ to: targetRoute })
}
}
}, [tourState.stepIndex, tourState.activeTourId, tourState.isRunning, navigate])
// 监听 Tour 步骤变化,当从弹窗内步骤回退到弹窗外步骤时,自动关闭弹窗
// 模型弹窗步骤: 12-17 (index 12-17),弹窗外步骤: 10-11 (index 10-11)
useEffect(() => {
if (tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID && tourState.isRunning) {
const prevStep = prevTourStepRef.current
const currentStep = tourState.stepIndex
// 如果从弹窗内步骤 (12-17) 回退到弹窗外步骤 (<=11),关闭弹窗
if (prevStep >= 12 && prevStep <= 17 && currentStep < 12) {
onCloseEditDialog?.()
}
prevTourStepRef.current = currentStep
}
}, [tourState.stepIndex, tourState.activeTourId, tourState.isRunning, onCloseEditDialog])
// 处理 Tour 中需要用户点击才能继续的步骤
useEffect(() => {
if (tourState.activeTourId !== MODEL_ASSIGNMENT_TOUR_ID || !tourState.isRunning) return
const handleTourClick = (e: MouseEvent) => {
const target = e.target as HTMLElement
const currentStep = tourState.stepIndex
// Step 3 (index 2): 点击添加提供商按钮
if (currentStep === 2 && target.closest('[data-tour="add-provider-button"]')) {
setTimeout(() => goToStep(3), 300)
}
// Step 10 (index 9): 点击取消按钮(关闭提供商弹窗)
else if (currentStep === 9 && target.closest('[data-tour="provider-cancel-button"]')) {
setTimeout(() => goToStep(10), 300)
}
// Step 12 (index 11): 点击添加模型按钮
else if (currentStep === 11 && target.closest('[data-tour="add-model-button"]')) {
setTimeout(() => goToStep(12), 300)
}
// Step 18 (index 17): 点击取消按钮(关闭模型弹窗)
else if (currentStep === 17 && target.closest('[data-tour="model-cancel-button"]')) {
setTimeout(() => goToStep(18), 300)
}
// Step 19 (index 18): 点击为模型分配功能标签页
else if (currentStep === 18 && target.closest('[data-tour="tasks-tab-trigger"]')) {
setTimeout(() => goToStep(19), 300)
}
}
document.addEventListener('click', handleTourClick, true)
return () => document.removeEventListener('click', handleTourClick, true)
}, [tourState, goToStep])
// 开始引导
const handleStartTour = useCallback(() => {
startTourFn(MODEL_ASSIGNMENT_TOUR_ID)
}, [startTourFn])
return {
startTour: handleStartTour,
isRunning: tourState.isRunning && tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID,
stepIndex: tourState.stepIndex,
}
}

View File

@@ -0,0 +1,15 @@
/**
* Model 配置页面模块化导出
*/
// 类型
export * from './types'
// 常量
export * from './constants'
// Hooks
export * from './hooks'
// 组件
export * from './components'

View File

@@ -0,0 +1,71 @@
/**
* Model 配置页面类型定义
*/
/**
* 模型信息
*/
export interface ModelInfo {
model_identifier: string
name: string
api_provider: string
price_in: number | null
price_out: number | null
temperature?: number | null // 模型级别温度,覆盖任务配置中的温度
max_tokens?: number | null // 模型级别最大token数覆盖任务配置中的max_tokens
force_stream_mode?: boolean
extra_params?: Record<string, unknown>
}
/**
* 提供商完整配置接口
*/
export interface ProviderConfig {
name: string
base_url: string
api_key: string
client_type: string
max_retry?: number
timeout?: number
retry_interval?: number
}
/**
* 单个任务配置
*/
export interface TaskConfig {
model_list: string[]
temperature?: number
max_tokens?: number
slow_threshold?: number
selection_strategy?: string
}
/**
* 所有模型任务配置
*/
export interface ModelTaskConfig {
utils: TaskConfig
tool_use: TaskConfig
replyer: TaskConfig
planner: TaskConfig
vlm: TaskConfig
voice: TaskConfig
embedding: TaskConfig
lpmm_entity_extract: TaskConfig
lpmm_rdf_build: TaskConfig
}
/**
* 表单验证错误
*/
export interface FormErrors {
name?: string
api_provider?: string
model_identifier?: string
}
/**
* 任务名称类型
*/
export type TaskName = keyof ModelTaskConfig

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
/**
* 模型提供商配置模块
*
* 模块结构:
* - types.ts: 类型定义
* - utils.ts: 工具函数
* - 主组件在上级目录的 modelProvider.tsx
*/
export * from './types'
export * from './utils'

View File

@@ -0,0 +1,33 @@
/**
* API 提供商接口定义
*/
export interface APIProvider {
name: string
base_url: string
api_key: string
client_type: string
max_retry: number | null
timeout: number | null
retry_interval: number | null
}
/**
* 删除确认对话框状态
*/
export interface DeleteConfirmState {
isOpen: boolean
providersToDelete: string[]
affectedModels: any[]
pendingProviders: APIProvider[]
context: 'auto' | 'manual' | 'restart'
oldProviders: APIProvider[]
}
/**
* 表单验证错误
*/
export interface FormErrors {
name?: string
base_url?: string
api_key?: string
}

View File

@@ -0,0 +1,61 @@
import type { APIProvider } from './types'
/**
* 清理 provider 数据,填充默认值
* 用于确保所有数值字段都有有效值,避免 null 导致的后端验证错误
*/
export const cleanProviderData = (provider: APIProvider): APIProvider => ({
...provider,
max_retry: provider.max_retry ?? 2,
timeout: provider.timeout ?? 30,
retry_interval: provider.retry_interval ?? 10,
})
/**
* 验证提供商表单数据
* @param provider 当前编辑的提供商
* @param existingProviders 现有提供商列表
* @param editingIndex 当前编辑的索引(新增时为 null
*/
export const validateProvider = (
provider: APIProvider | null,
existingProviders: APIProvider[] = [],
editingIndex: number | null = null
): {
isValid: boolean
errors: { name?: string; base_url?: string; api_key?: string }
} => {
const errors: { name?: string; base_url?: string; api_key?: string } = {}
if (!provider) {
return { isValid: false, errors: { name: '提供商数据为空' } }
}
if (!provider.name?.trim()) {
errors.name = '请输入提供商名称'
} else {
// 检查名称是否与现有提供商重复
const isDuplicate = existingProviders.some((p, index) => {
// 编辑时排除自身
if (editingIndex !== null && index === editingIndex) {
return false
}
return p.name.trim().toLowerCase() === provider.name.trim().toLowerCase()
})
if (isDuplicate) {
errors.name = '提供商名称已存在,请使用其他名称'
}
}
if (!provider.base_url?.trim()) {
errors.base_url = '请输入基础 URL'
}
if (!provider.api_key?.trim()) {
errors.api_key = '请输入 API Key'
}
return {
isValid: Object.keys(errors).length === 0,
errors,
}
}

View File

@@ -0,0 +1,929 @@
/**
* Pack 详情页面
*
* 查看 Pack 详情并应用到本地配置
*/
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { packDetailRoute } from '@/router'
import {
Package,
ArrowLeft,
Download,
Heart,
Clock,
User,
Server,
Layers,
ListChecks,
Tag,
Check,
AlertTriangle,
Info,
ChevronRight,
Key,
Settings,
Loader2,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/hooks/use-toast'
import {
getPack,
recordPackDownload,
togglePackLike,
checkPackLike,
detectPackConflicts,
applyPack,
getPackUserId,
type ModelPack,
type ApplyPackOptions,
type ApplyPackConflicts,
} from '@/lib/pack-api'
// 任务类型名称映射
const TASK_TYPE_NAMES: Record<string, string> = {
utils: '通用工具',
utils_small: '轻量工具',
tool_use: '工具调用',
replyer: '回复生成',
planner: '规划推理',
vlm: '视觉模型',
voice: '语音处理',
embedding: '向量嵌入',
lpmm_entity_extract: '实体提取',
lpmm_rdf_build: 'RDF构建',
lpmm_qa: '问答模型',
}
export default function PackDetailPage() {
const { packId } = packDetailRoute.useParams()
const navigate = useNavigate()
const [pack, setPack] = useState<ModelPack | null>(null)
const [loading, setLoading] = useState(true)
const [liked, setLiked] = useState(false)
const [liking, setLiking] = useState(false)
// 应用向导状态
const [showApplyDialog, setShowApplyDialog] = useState(false)
const [applyStep, setApplyStep] = useState(1)
const [conflicts, setConflicts] = useState<ApplyPackConflicts | null>(null)
const [detectingConflicts, setDetectingConflicts] = useState(false)
const [applying, setApplying] = useState(false)
// 应用选项
const [applyOptions, setApplyOptions] = useState<ApplyPackOptions>({
apply_providers: true,
apply_models: true,
apply_task_config: true,
task_mode: 'append',
selected_providers: undefined,
selected_models: undefined,
selected_tasks: undefined,
})
// 提供商映射和 API Key
const [providerMapping, setProviderMapping] = useState<Record<string, string>>({})
const [newProviderApiKeys, setNewProviderApiKeys] = useState<Record<string, string>>({})
const userId = getPackUserId()
// 加载 Pack
const loadPack = useCallback(async () => {
if (!packId) return
setLoading(true)
try {
const data = await getPack(packId)
setPack(data)
const isLiked = await checkPackLike(packId, userId)
setLiked(isLiked)
} catch (error) {
console.error('加载 Pack 失败:', error)
toast({ title: '加载模板失败', variant: 'destructive' })
} finally {
setLoading(false)
}
}, [packId, userId])
useEffect(() => {
loadPack()
}, [loadPack])
// 点赞
const handleLike = async () => {
if (!packId || liking) return
setLiking(true)
try {
const result = await togglePackLike(packId, userId)
setLiked(result.liked)
if (pack) {
setPack({ ...pack, likes: result.likes })
}
} catch (error) {
console.error('点赞失败:', error)
toast({ title: '点赞失败', variant: 'destructive' })
} finally {
setLiking(false)
}
}
// 开始应用流程
const startApply = async () => {
if (!pack) return
setShowApplyDialog(true)
setApplyStep(1)
setDetectingConflicts(true)
try {
const detected = await detectPackConflicts(pack)
setConflicts(detected)
// 初始化提供商映射(已存在的提供商默认使用第一个匹配的本地提供商)
const mapping: Record<string, string> = {}
for (const c of detected.existing_providers) {
mapping[c.pack_provider.name] = c.local_providers[0].name
}
setProviderMapping(mapping)
// 初始化新提供商的 API Key
const keys: Record<string, string> = {}
for (const p of detected.new_providers) {
keys[p.name] = ''
}
setNewProviderApiKeys(keys)
} catch (error) {
console.error('检测冲突失败:', error)
toast({ title: '检测配置冲突失败', variant: 'destructive' })
setShowApplyDialog(false)
} finally {
setDetectingConflicts(false)
}
}
// 执行应用
const executeApply = async () => {
if (!pack) return
// 验证新提供商都有 API Key
if (applyOptions.apply_providers && conflicts) {
for (const p of conflicts.new_providers) {
if (!newProviderApiKeys[p.name]) {
toast({ title: `请填写提供商 "${p.name}" 的 API Key`, variant: 'destructive' })
return
}
}
}
setApplying(true)
try {
await applyPack(pack, applyOptions, providerMapping, newProviderApiKeys)
// 记录下载
await recordPackDownload(pack.id, userId)
// 更新下载数
setPack({ ...pack, downloads: pack.downloads + 1 })
toast({ title: '配置模板应用成功!' })
setShowApplyDialog(false)
} catch (error) {
console.error('应用 Pack 失败:', error)
toast({ title: error instanceof Error ? error.message : '应用配置失败', variant: 'destructive' })
} finally {
setApplying(false)
}
}
// 格式化日期
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
if (loading) {
return <PackDetailSkeleton />
}
if (!pack) {
return (
<div className="text-center py-12">
<Package className="w-16 h-16 mx-auto mb-4 opacity-50" />
<h2 className="text-xl font-semibold"></h2>
<p className="text-muted-foreground mt-2"></p>
<Button className="mt-4" onClick={() => navigate({ to: '/config/pack-market' })}>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
</div>
)
}
return (
<div className="h-[calc(100vh-4rem)] flex flex-col p-4 sm:p-6">
<ScrollArea className="flex-1">
<div className="space-y-4 sm:space-y-6">
{/* 返回按钮 */}
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/config/pack-market' })} className="gap-2">
<ArrowLeft className="w-4 h-4" />
</Button>
{/* 头部信息 */}
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-1 space-y-4">
<div className="flex items-start gap-3">
<Package className="w-10 h-10 text-primary mt-1" />
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
{pack.name}
<Badge variant="secondary">v{pack.version}</Badge>
</h1>
<p className="text-muted-foreground mt-1">{pack.description}</p>
</div>
</div>
{/* 元信息 */}
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<User className="w-4 h-4" />
{pack.author}
</span>
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{formatDate(pack.created_at)}
</span>
<span className="flex items-center gap-1">
<Download className="w-4 h-4" />
{pack.downloads}
</span>
<span className="flex items-center gap-1">
<Heart className={`w-4 h-4 ${liked ? 'fill-red-500 text-red-500' : ''}`} />
{pack.likes}
</span>
</div>
{/* 标签 */}
{pack.tags && pack.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{pack.tags.map(tag => (
<Badge key={tag} variant="outline">
<Tag className="w-3 h-3 mr-1" />
{tag}
</Badge>
))}
</div>
)}
</div>
{/* 操作按钮 */}
<div className="flex flex-col gap-2 min-w-[160px]">
<Button size="lg" onClick={startApply}>
<Download className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
onClick={handleLike}
disabled={liking}
className={liked ? 'text-red-500 border-red-200' : ''}
>
<Heart className={`w-4 h-4 mr-2 ${liked ? 'fill-current' : ''}`} />
{liked ? '已点赞' : '点赞'}
</Button>
</div>
</div>
<Separator />
{/* 内容统计 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card>
<CardContent className="flex items-center gap-3 py-4">
<Server className="w-8 h-8 text-blue-500 flex-shrink-0" />
<div>
<p className="text-2xl font-bold">{pack.providers.length}</p>
<p className="text-sm text-muted-foreground">API </p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<Layers className="w-8 h-8 text-green-500 flex-shrink-0" />
<div>
<p className="text-2xl font-bold">{pack.models.length}</p>
<p className="text-sm text-muted-foreground"></p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<ListChecks className="w-8 h-8 text-purple-500 flex-shrink-0" />
<div>
<p className="text-2xl font-bold">{Object.keys(pack.task_config).length}</p>
<p className="text-sm text-muted-foreground"></p>
</div>
</CardContent>
</Card>
</div>
{/* 详细内容 */}
<Tabs defaultValue="providers" className="space-y-4">
<TabsList className="w-full sm:w-auto grid grid-cols-3 sm:flex">
<TabsTrigger value="providers" className="gap-1 sm:gap-2">
<Server className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
<span className="hidden sm:inline">({pack.providers.length})</span>
</TabsTrigger>
<TabsTrigger value="models" className="gap-1 sm:gap-2">
<Layers className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
<span className="hidden sm:inline">({pack.models.length})</span>
</TabsTrigger>
<TabsTrigger value="tasks" className="gap-1 sm:gap-2">
<ListChecks className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
<span className="hidden sm:inline">({Object.keys(pack.task_config).length})</span>
</TabsTrigger>
</TabsList>
<TabsContent value="providers">
<Card>
<CardHeader>
<CardTitle>API </CardTitle>
<CardDescription> API API Key</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>Base URL</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pack.providers.map(provider => (
<TableRow key={provider.name}>
<TableCell className="font-medium whitespace-nowrap">{provider.name}</TableCell>
<TableCell className="text-muted-foreground font-mono text-sm max-w-[200px] truncate">
{provider.base_url}
</TableCell>
<TableCell>
<Badge variant="outline">{provider.client_type}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="models">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"> (/)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pack.models.map(model => (
<TableRow key={model.name}>
<TableCell className="font-medium whitespace-nowrap">{model.name}</TableCell>
<TableCell className="text-muted-foreground font-mono text-sm max-w-[150px] truncate">
{model.model_identifier}
</TableCell>
<TableCell className="whitespace-nowrap">{model.api_provider}</TableCell>
<TableCell className="text-right text-muted-foreground whitespace-nowrap">
¥{model.price_in} / ¥{model.price_out}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="tasks">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Accordion type="multiple" className="w-full">
{Object.entries(pack.task_config).map(([taskKey, config]) => (
<AccordionItem key={taskKey} value={taskKey}>
<AccordionTrigger>
<div className="flex items-center gap-2">
<Settings className="w-4 h-4" />
{TASK_TYPE_NAMES[taskKey] || taskKey}
<Badge variant="secondary" className="ml-2">
{config.model_list.length}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-2 pl-6">
<div className="text-sm text-muted-foreground">
</div>
<div className="flex flex-wrap gap-2">
{config.model_list.map((model: string) => (
<Badge key={model} variant="outline">{model}</Badge>
))}
</div>
{config.temperature !== undefined && (
<div className="text-sm">
Temperature: <span className="font-mono">{config.temperature}</span>
</div>
)}
{config.max_tokens !== undefined && (
<div className="text-sm">
Max Tokens: <span className="font-mono">{config.max_tokens}</span>
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* 应用向导对话框 */}
<ApplyDialog
open={showApplyDialog}
onOpenChange={setShowApplyDialog}
pack={pack}
step={applyStep}
setStep={setApplyStep}
conflicts={conflicts}
detectingConflicts={detectingConflicts}
applying={applying}
options={applyOptions}
setOptions={setApplyOptions}
_providerMapping={providerMapping}
_setProviderMapping={setProviderMapping}
newProviderApiKeys={newProviderApiKeys}
setNewProviderApiKeys={setNewProviderApiKeys}
onApply={executeApply}
/>
</div>
</ScrollArea>
</div>
)
}
// 应用向导对话框
function ApplyDialog({
open,
onOpenChange,
pack,
step,
setStep,
conflicts,
detectingConflicts,
applying,
options,
setOptions,
_providerMapping,
_setProviderMapping,
newProviderApiKeys,
setNewProviderApiKeys,
onApply,
}: {
open: boolean
onOpenChange: (open: boolean) => void
pack: ModelPack
step: number
setStep: (step: number) => void
conflicts: ApplyPackConflicts | null
detectingConflicts: boolean
applying: boolean
options: ApplyPackOptions
setOptions: (options: ApplyPackOptions) => void
_providerMapping: Record<string, string>
_setProviderMapping: (mapping: Record<string, string>) => void
newProviderApiKeys: Record<string, string>
setNewProviderApiKeys: (keys: Record<string, string>) => void
onApply: () => void
}) {
const totalSteps = 3
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Package className="w-5 h-5" />
</DialogTitle>
<DialogDescription>
{step} / {totalSteps}
{step === 1 && '选择要应用的内容'}
{step === 2 && '配置提供商映射'}
{step === 3 && '确认并应用'}
</DialogDescription>
</DialogHeader>
{detectingConflicts ? (
<div className="py-8 text-center">
<Loader2 className="w-8 h-8 mx-auto animate-spin text-primary" />
<p className="mt-4 text-muted-foreground">...</p>
</div>
) : (
<>
{/* 步骤 1: 选择内容 */}
{step === 1 && (
<div className="space-y-4">
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="apply_providers"
checked={options.apply_providers}
onCheckedChange={checked =>
setOptions({ ...options, apply_providers: checked as boolean })
}
/>
<Label htmlFor="apply_providers" className="flex items-center gap-2">
<Server className="w-4 h-4" />
({pack.providers.length} )
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="apply_models"
checked={options.apply_models}
onCheckedChange={checked =>
setOptions({ ...options, apply_models: checked as boolean })
}
/>
<Label htmlFor="apply_models" className="flex items-center gap-2">
<Layers className="w-4 h-4" />
({pack.models.length} )
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="apply_task_config"
checked={options.apply_task_config}
onCheckedChange={checked =>
setOptions({ ...options, apply_task_config: checked as boolean })
}
/>
<Label htmlFor="apply_task_config" className="flex items-center gap-2">
<ListChecks className="w-4 h-4" />
({Object.keys(pack.task_config).length} )
</Label>
</div>
</div>
{options.apply_task_config && (
<div className="pl-6 space-y-2 border-l-2 border-muted">
<Label className="text-sm font-medium"></Label>
<RadioGroup
value={options.task_mode}
onValueChange={value =>
setOptions({ ...options, task_mode: value as 'replace' | 'append' })
}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="append" id="mode_append" />
<Label htmlFor="mode_append" className="font-normal">
-
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="replace" id="mode_replace" />
<Label htmlFor="mode_replace" className="font-normal">
-
</Label>
</div>
</RadioGroup>
</div>
)}
</div>
)}
{/* 步骤 2: 提供商映射 */}
{step === 2 && conflicts && (
<div className="space-y-4">
{/* 已存在的提供商 */}
{options.apply_providers && conflicts.existing_providers.length > 0 && (
<div className="space-y-3">
<Alert>
<Info className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
URL 使
</AlertDescription>
</Alert>
<div className="space-y-2">
{conflicts.existing_providers.map(({ pack_provider, local_providers }) => (
<div
key={pack_provider.name}
className="flex items-center gap-2 p-3 bg-muted rounded-lg"
>
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
<span className="font-medium flex-shrink-0">{pack_provider.name}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
{local_providers.length === 1 ? (
<>
<span className="text-muted-foreground">{local_providers[0].name}</span>
<Badge variant="outline" className="ml-auto">URL </Badge>
</>
) : (
<>
<Select
value={_providerMapping[pack_provider.name] || local_providers[0].name}
onValueChange={value =>
_setProviderMapping({
..._providerMapping,
[pack_provider.name]: value,
})
}
>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{local_providers.map(p => (
<SelectItem key={p.name} value={p.name}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Badge variant="outline" className="ml-auto">
{local_providers.length}
</Badge>
</>
)}
</div>
))}
</div>
</div>
)}
{/* 新提供商 */}
{options.apply_providers && conflicts.new_providers.length > 0 && (
<div className="space-y-3">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle> API Key</AlertTitle>
<AlertDescription>
API Key
</AlertDescription>
</Alert>
<div className="space-y-4">
{conflicts.new_providers.map(provider => (
<div key={provider.name} className="space-y-2">
<div className="flex items-center gap-2">
<Key className="w-4 h-4 text-amber-500" />
<span className="font-medium">{provider.name}</span>
<span className="text-xs text-muted-foreground">
({provider.base_url})
</span>
</div>
<Input
type="password"
placeholder={`输入 ${provider.name} 的 API Key`}
value={newProviderApiKeys[provider.name] || ''}
onChange={e =>
setNewProviderApiKeys({
...newProviderApiKeys,
[provider.name]: e.target.value,
})
}
/>
</div>
))}
</div>
</div>
)}
{(!options.apply_providers || (conflicts.existing_providers.length === 0 && conflicts.new_providers.length === 0)) && (
<Alert>
<Check className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
</AlertDescription>
</Alert>
)}
</div>
)}
{/* 步骤 3: 确认 */}
{step === 3 && (
<div className="space-y-4">
<Alert>
<Info className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
</AlertDescription>
</Alert>
<div className="space-y-2">
{options.apply_providers && (
<div className="flex items-center gap-2 text-sm">
<Check className="w-4 h-4 text-green-500" />
<Server className="w-4 h-4" />
<span> {pack.providers.length} </span>
</div>
)}
{options.apply_models && (
<div className="flex items-center gap-2 text-sm">
<Check className="w-4 h-4 text-green-500" />
<Layers className="w-4 h-4" />
<span> {pack.models.length} </span>
</div>
)}
{options.apply_task_config && (
<div className="flex items-center gap-2 text-sm">
<Check className="w-4 h-4 text-green-500" />
<ListChecks className="w-4 h-4" />
<span>
{options.task_mode === 'append' ? '追加' : '替换'} {Object.keys(pack.task_config).length}
</span>
</div>
)}
</div>
{conflicts && conflicts.new_providers.length > 0 && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
{conflicts.new_providers.length} API Key
</AlertDescription>
</Alert>
)}
</div>
)}
</>
)}
<DialogFooter className="flex justify-between">
<div>
{step > 1 && !detectingConflicts && (
<Button variant="outline" onClick={() => setStep(step - 1)} disabled={applying}>
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={applying}>
</Button>
{step < totalSteps ? (
<Button onClick={() => setStep(step + 1)} disabled={detectingConflicts}>
</Button>
) : (
<Button onClick={onApply} disabled={applying}>
{applying && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// 加载骨架
function PackDetailSkeleton() {
return (
<div className="h-[calc(100vh-4rem)] flex flex-col p-4 sm:p-6">
<ScrollArea className="flex-1">
<div className="space-y-4 sm:space-y-6">
{/* 返回按钮 */}
<Skeleton className="h-9 w-24" />
{/* 头部信息 */}
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-1 space-y-4">
<div className="flex items-start gap-3">
<Skeleton className="w-10 h-10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-8 w-2/3" />
<Skeleton className="h-4 w-full" />
</div>
</div>
{/* 元信息 */}
<div className="flex flex-wrap gap-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-20" />
</div>
{/* 标签 */}
<div className="flex flex-wrap gap-2">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-6 w-24" />
<Skeleton className="h-6 w-16" />
</div>
</div>
{/* 操作按钮 */}
<div className="flex flex-col gap-2 min-w-[160px]">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
</div>
<Skeleton className="h-px w-full" />
{/* 内容统计卡片 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
{/* Tabs */}
<div className="space-y-4">
<div className="flex gap-2">
<Skeleton className="h-10 w-32" />
<Skeleton className="h-10 w-32" />
<Skeleton className="h-10 w-32" />
</div>
<Skeleton className="h-96 w-full" />
</div>
</div>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,422 @@
/**
* Pack 市场页面
*
* 浏览、搜索、应用模型配置 Pack
*/
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from '@tanstack/react-router'
import {
Package,
Search,
Download,
Heart,
Clock,
Tag,
ChevronDown,
ArrowUpDown,
RefreshCw,
User,
Layers,
Server,
ListChecks,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from '@/hooks/use-toast'
import {
listPacks,
togglePackLike,
checkPackLike,
getPackUserId,
type PackListItem,
type ListPacksResponse,
} from '@/lib/pack-api'
// 排序选项
const SORT_OPTIONS = [
{ value: 'created_at', label: '最新发布', icon: Clock },
{ value: 'downloads', label: '下载最多', icon: Download },
{ value: 'likes', label: '最受欢迎', icon: Heart },
] as const
type SortBy = typeof SORT_OPTIONS[number]['value']
export default function PackMarketPage() {
const navigate = useNavigate()
const [packs, setPacks] = useState<PackListItem[]>([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<SortBy>('downloads')
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [total, setTotal] = useState(0)
const [likedPacks, setLikedPacks] = useState<Set<string>>(new Set())
const [likingPacks, setLikingPacks] = useState<Set<string>>(new Set())
const userId = getPackUserId()
// 加载 Pack 列表
const loadPacks = useCallback(async () => {
setLoading(true)
try {
const response: ListPacksResponse = await listPacks({
status: 'approved',
page,
page_size: 12,
search: searchQuery || undefined,
sort_by: sortBy,
sort_order: 'desc',
})
setPacks(response.packs)
setTotalPages(response.total_pages)
setTotal(response.total)
// 检查点赞状态
const likedSet = new Set<string>()
for (const pack of response.packs) {
const liked = await checkPackLike(pack.id, userId)
if (liked) likedSet.add(pack.id)
}
setLikedPacks(likedSet)
} catch (error) {
console.error('加载 Pack 列表失败:', error)
toast({ title: '加载 Pack 列表失败', variant: 'destructive' })
} finally {
setLoading(false)
}
}, [page, searchQuery, sortBy, userId])
useEffect(() => {
loadPacks()
}, [loadPacks])
// 搜索
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
setPage(1)
loadPacks()
}
// 点赞
const handleLike = async (packId: string) => {
if (likingPacks.has(packId)) return
setLikingPacks(prev => new Set(prev).add(packId))
try {
const result = await togglePackLike(packId, userId)
// 更新点赞状态
setLikedPacks(prev => {
const newSet = new Set(prev)
if (result.liked) {
newSet.add(packId)
} else {
newSet.delete(packId)
}
return newSet
})
// 更新点赞数
setPacks(prev => prev.map(p =>
p.id === packId ? { ...p, likes: result.likes } : p
))
} catch (error) {
console.error('点赞失败:', error)
toast({ title: '点赞失败', variant: 'destructive' })
} finally {
setLikingPacks(prev => {
const newSet = new Set(prev)
newSet.delete(packId)
return newSet
})
}
}
// 查看详情
const handleViewPack = (packId: string) => {
navigate({ to: '/config/pack-market/$packId', params: { packId } })
}
// 获取当前排序选项
const currentSort = SORT_OPTIONS.find(o => o.value === sortBy) || SORT_OPTIONS[0]
return (
<div className="h-[calc(100vh-4rem)] flex flex-col p-4 sm:p-6">
{/* 页面标题 */}
<div className="mb-4 sm:mb-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2">
<Package className="h-8 w-8" strokeWidth={2} />
</h1>
<p className="text-muted-foreground mt-1 text-sm sm:text-base">
MaiBot
</p>
</div>
<Button variant="outline" onClick={loadPacks} disabled={loading} className="gap-2">
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<ScrollArea className="flex-1">
<div className="space-y-4">
{/* 搜索和筛选 */}
<div className="flex gap-4 flex-wrap">
<form onSubmit={handleSearch} className="flex-1 min-w-[200px] max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索模板名称、描述..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</form>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="min-w-[140px] gap-2">
<ArrowUpDown className="w-4 h-4" />
{currentSort.label}
<ChevronDown className="w-4 h-4 ml-auto" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{SORT_OPTIONS.map(option => (
<DropdownMenuItem
key={option.value}
onClick={() => {
setSortBy(option.value)
setPage(1)
}}
>
<option.icon className="w-4 h-4 mr-2" />
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 统计信息 */}
<div className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">{total}</span>
</div>
{/* Pack 列表 */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full mt-2" />
</CardHeader>
<CardContent>
<Skeleton className="h-20 w-full" />
</CardContent>
<CardFooter>
<Skeleton className="h-9 w-full" />
</CardFooter>
</Card>
))}
</div>
) : packs.length === 0 ? (
<Card className="py-12">
<CardContent className="text-center text-muted-foreground">
<Package className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium"></p>
<p className="mt-1"></p>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{packs.map(pack => (
<PackCard
key={pack.id}
pack={pack}
liked={likedPacks.has(pack.id)}
liking={likingPacks.has(pack.id)}
onLike={() => handleLike(pack.id)}
onView={() => handleViewPack(pack.id)}
/>
))}
</div>
)}
{/* 分页 */}
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setPage(p => Math.max(1, p - 1))}
className={page === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 1)
.map((p, i, arr) => {
const showEllipsis = i > 0 && p - arr[i - 1] > 1
return (
<PaginationItem key={p}>
{showEllipsis && <span className="px-2">...</span>}
<PaginationLink
onClick={() => setPage(p)}
isActive={p === page}
className="cursor-pointer"
>
{p}
</PaginationLink>
</PaginationItem>
)
})}
<PaginationItem>
<PaginationNext
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
className={page === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
</ScrollArea>
</div>
)
}
// Pack 卡片组件
function PackCard({
pack,
liked,
liking,
onLike,
onView,
}: {
pack: PackListItem
liked: boolean
liking: boolean
onLike: () => void
onView: () => void
}) {
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
return (
<Card className="flex flex-col hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<CardTitle className="text-lg line-clamp-1">{pack.name}</CardTitle>
<Badge variant="secondary" className="text-xs">v{pack.version}</Badge>
</div>
<CardDescription className="line-clamp-2 min-h-[40px]">
{pack.description}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 space-y-3">
{/* 作者和日期 */}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<User className="w-3.5 h-3.5" />
{pack.author}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{formatDate(pack.created_at)}
</span>
</div>
{/* 内容统计 */}
<div className="flex gap-4 text-sm">
<span className="flex items-center gap-1 text-muted-foreground" title="提供商数量">
<Server className="w-3.5 h-3.5" />
{pack.provider_count}
</span>
<span className="flex items-center gap-1 text-muted-foreground" title="模型数量">
<Layers className="w-3.5 h-3.5" />
{pack.model_count}
</span>
<span className="flex items-center gap-1 text-muted-foreground" title="任务配置数">
<ListChecks className="w-3.5 h-3.5" />
{pack.task_count}
</span>
</div>
{/* 标签 */}
{pack.tags && pack.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{pack.tags.slice(0, 3).map(tag => (
<Badge key={tag} variant="outline" className="text-xs">
<Tag className="w-2.5 h-2.5 mr-1" />
{tag}
</Badge>
))}
{pack.tags.length > 3 && (
<Badge variant="outline" className="text-xs">
+{pack.tags.length - 3}
</Badge>
)}
</div>
)}
</CardContent>
<CardFooter className="pt-3 border-t">
<div className="flex items-center justify-between w-full">
{/* 统计 */}
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Download className="w-4 h-4" />
{pack.downloads}
</span>
<button
onClick={e => { e.stopPropagation(); onLike() }}
disabled={liking}
className={`flex items-center gap-1 transition-colors ${
liked ? 'text-red-500' : 'hover:text-red-500'
} ${liking ? 'opacity-50' : ''}`}
>
<Heart className={`w-4 h-4 ${liked ? 'fill-current' : ''}`} />
{pack.likes}
</button>
</div>
{/* 查看按钮 */}
<Button size="sm" onClick={onView}>
</Button>
</div>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,235 @@
/**
* 模型提供商模板配置
*
* 这些预设模板帮助用户快速配置常用的 API 提供商
*/
// 模型获取器配置定义
export interface ModelFetcherConfig {
// 获取模型列表的端点(相对于 base_url
endpoint: string
// 响应解析器类型
parser: 'openai' | 'gemini'
}
// 提供商模板定义
export interface ProviderTemplate {
id: string
name: string
base_url: string
client_type: 'openai' | 'gemini'
display_name: string
// 模型列表获取配置(可选,未配置则不支持自动获取)
modelFetcher?: ModelFetcherConfig
}
// 内置提供商模板
export const PROVIDER_TEMPLATES: ProviderTemplate[] = [
// 国内提供商
{
id: 'siliconflow',
name: 'SiliconFlow',
base_url: 'https://api.siliconflow.cn/v1',
client_type: 'openai',
display_name: '硅基流动 (SiliconFlow)',
modelFetcher: { endpoint: '/models', parser: 'openai' },
},
{
id: 'deepseek',
name: 'DeepSeek',
base_url: 'https://api.deepseek.com',
client_type: 'openai',
display_name: 'DeepSeek',
modelFetcher: { endpoint: '/models', parser: 'openai' },
},
{
id: 'rinkoai',
name: 'RinkoAI',
base_url: 'https://rinkoai.com/v1',
client_type: 'openai',
display_name: 'RinkoAI',
modelFetcher: { endpoint: '/models', parser: 'openai' },
},
{
id: 'zhipu',
name: 'ZhipuAI',
base_url: 'https://open.bigmodel.cn/api/paas/v4',
client_type: 'openai',
display_name: '智谱 AI (ZhipuAI / GLM)',
modelFetcher: { endpoint: '/models', parser: 'openai' },
},
{
id: 'moonshot',
name: 'Moonshot',
base_url: 'https://api.moonshot.cn/v1',
client_type: 'openai',
display_name: '月之暗面 (Moonshot / Kimi)',
modelFetcher: { endpoint: '/models', parser: 'openai' },
},
{
id: 'doubao',
name: 'Doubao',
base_url: 'https://ark.cn-beijing.volces.com/api/v3',
client_type: 'openai',
display_name: '字节豆包 (Doubao)',
modelFetcher: { endpoint: '/models', parser: 'openai' },
},
{
id: 'alibaba',
name: 'Alibaba',
base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
client_type: 'openai',
display_name: '阿里云百炼 (Alibaba Qwen)',
modelFetcher: { endpoint: '/models', parser: 'openai' },
},
{
id: 'baichuan',
name: 'Baichuan',
base_url: 'https://api.baichuan-ai.com/v1',
client_type: 'openai',
display_name: '百川智能 (Baichuan)',
modelFetcher: { endpoint: '/models', parser: 'openai' },
},
{
id: 'minimax',
name: 'MiniMax',
base_url: 'https://api.minimax.chat/v1',
client_type: 'openai',
display_name: 'MiniMax (海螺 AI)',
modelFetcher: { endpoint: '/models', parser: 'openai' },
},
{
id: 'stepfun',
name: 'StepFun',
base_url: 'https://api.stepfun.com/v1',
client_type: 'openai',
display_name: '阶跃星辰 (StepFun)',
modelFetcher: { endpoint: '/models', parser: 'openai' },
},
{
id: 'lingyi',
name: 'Lingyi',
base_url: 'https://api.lingyiwanwu.com/v1',
client_type: 'openai',
display_name: '零一万物 (Lingyi / Yi)',
modelFetcher: { endpoint: '/models', parser: 'openai' },
},
// 国际提供商
{
id: 'openai',
name: 'OpenAI',
base_url: 'https://api.openai.com/v1',
client_type: 'openai',
display_name: 'OpenAI',
modelFetcher: { endpoint: '/models', parser: 'openai' },
},
{
id: 'xai',
name: 'xAI',
base_url: 'https://api.x.ai/v1',
client_type: 'openai',
display_name: 'xAI (Grok)',
modelFetcher: { endpoint: '/models', parser: 'openai' },
},
{
id: 'anthropic',
name: 'Anthropic',
base_url: 'https://api.anthropic.com/v1',
client_type: 'openai',
display_name: 'Anthropic (Claude)',
// Anthropic 使用不同的 API 格式,暂不支持自动获取
},
{
id: 'gemini',
name: 'Gemini',
base_url: 'https://generativelanguage.googleapis.com/v1beta',
client_type: 'gemini',
display_name: 'Google Gemini',
modelFetcher: { endpoint: '/models', parser: 'gemini' },
},
{
id: 'cohere',
name: 'Cohere',
base_url: 'https://api.cohere.ai/v1',
client_type: 'openai',
display_name: 'Cohere',
// Cohere 使用不同的 API 格式,暂不支持自动获取
},
{
id: 'groq',
name: 'Groq',
base_url: 'https://api.groq.com/openai/v1',
client_type: 'openai',
display_name: 'Groq',
modelFetcher: { endpoint: '/models', parser: 'openai' },
},
{
id: 'together',
name: 'Together AI',
base_url: 'https://api.together.xyz/v1',
client_type: 'openai',
display_name: 'Together AI',
modelFetcher: { endpoint: '/models', parser: 'openai' },
},
{
id: 'fireworks',
name: 'Fireworks',
base_url: 'https://api.fireworks.ai/inference/v1',
client_type: 'openai',
display_name: 'Fireworks AI',
modelFetcher: { endpoint: '/models', parser: 'openai' },
},
{
id: 'mistral',
name: 'Mistral',
base_url: 'https://api.mistral.ai/v1',
client_type: 'openai',
display_name: 'Mistral AI',
modelFetcher: { endpoint: '/models', parser: 'openai' },
},
{
id: 'perplexity',
name: 'Perplexity',
base_url: 'https://api.perplexity.ai',
client_type: 'openai',
display_name: 'Perplexity AI',
// Perplexity 不支持 /models 端点
},
// 自定义选项
{
id: 'custom',
name: '',
base_url: '',
client_type: 'openai',
display_name: '自定义',
},
]
/**
* 规范化 URL去掉尾部斜杠统一格式
*/
export function normalizeUrl(url: string): string {
if (!url) return ''
// 去掉尾部斜杠
const normalized = url.replace(/\/+$/, '')
// 转小写用于比较
return normalized.toLowerCase()
}
/**
* 根据 base_url 查找匹配的模板
* @param baseUrl 提供商的 base_url
* @returns 匹配的模板,如果未找到则返回 null
*/
export function findTemplateByBaseUrl(baseUrl: string): ProviderTemplate | null {
if (!baseUrl) return null
const normalizedUrl = normalizeUrl(baseUrl)
return PROVIDER_TEMPLATES.find(template =>
template.id !== 'custom' &&
normalizeUrl(template.base_url) === normalizedUrl
) || null
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,628 @@
import { useState, useRef, useEffect, useMemo } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Slider } from '@/components/ui/slider'
import { Card } from '@/components/ui/card'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Search, RefreshCw, Download, Filter, Trash2, Pause, Play, Calendar as CalendarIcon, X, Type, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
import { logWebSocket, type LogEntry } from '@/lib/log-websocket'
import { format } from 'date-fns'
import { zhCN } from 'date-fns/locale'
// 字号配置
type FontSize = 'xs' | 'sm' | 'base'
const fontSizeConfig: Record<FontSize, { label: string; rowHeight: number; class: string }> = {
xs: { label: '小', rowHeight: 28, class: 'text-[10px] sm:text-xs' },
sm: { label: '中', rowHeight: 36, class: 'text-xs sm:text-sm' },
base: { label: '大', rowHeight: 44, class: 'text-sm sm:text-base' },
}
export function LogViewerPage() {
const [logs, setLogs] = useState<LogEntry[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [levelFilter, setLevelFilter] = useState<string>('all')
const [moduleFilter, setModuleFilter] = useState<string>('all')
const [dateFrom, setDateFrom] = useState<Date | undefined>(undefined)
const [dateTo, setDateTo] = useState<Date | undefined>(undefined)
const [autoScroll, setAutoScroll] = useState(true)
const [connected, setConnected] = useState(false)
const [fontSize, setFontSize] = useState<FontSize>('xs') // 默认使用小字号以显示更多信息
const [lineSpacing, setLineSpacing] = useState(4) // 行间距默认4px紧凑
const [filtersOpen, setFiltersOpen] = useState(false) // 控制折叠面板,默认折叠
const parentRef = useRef<HTMLDivElement>(null)
// 订阅全局 WebSocket 连接
useEffect(() => {
// 初始化时加载缓存的日志
const cachedLogs = logWebSocket.getAllLogs()
setLogs(cachedLogs)
// 订阅日志消息 - 直接使用全局缓存而不是组件状态
const unsubscribeLogs = logWebSocket.onLog(() => {
// 每次收到新日志,重新从全局缓存加载
setLogs(logWebSocket.getAllLogs())
})
// 订阅连接状态
const unsubscribeConnection = logWebSocket.onConnectionChange((isConnected) => {
setConnected(isConnected)
})
// 清理订阅
return () => {
unsubscribeLogs()
unsubscribeConnection()
}
}, [])
// 获取所有唯一的模块名(过滤掉空字符串)
const uniqueModules = useMemo(() => {
const modules = new Set(logs.map(log => log.module).filter(m => m && m.trim() !== ''))
return Array.from(modules).sort()
}, [logs])
// 日志级别颜色映射
const getLevelColor = (level: LogEntry['level']) => {
switch (level) {
case 'DEBUG':
return 'text-muted-foreground'
case 'INFO':
return 'text-blue-500 dark:text-blue-400'
case 'WARNING':
return 'text-yellow-600 dark:text-yellow-500'
case 'ERROR':
return 'text-red-600 dark:text-red-500'
case 'CRITICAL':
return 'text-red-700 dark:text-red-400 font-bold'
default:
return 'text-foreground'
}
}
const getLevelBgColor = (level: LogEntry['level']) => {
switch (level) {
case 'DEBUG':
return 'bg-gray-800/30 dark:bg-gray-800/50'
case 'INFO':
return 'bg-blue-900/20 dark:bg-blue-500/20'
case 'WARNING':
return 'bg-yellow-900/20 dark:bg-yellow-500/20'
case 'ERROR':
return 'bg-red-900/20 dark:bg-red-500/20'
case 'CRITICAL':
return 'bg-red-900/30 dark:bg-red-600/30'
default:
return 'bg-gray-800/20 dark:bg-gray-800/30'
}
}
// 刷新日志(刷新页面)
const handleRefresh = () => {
window.location.reload()
}
// 清空日志
const handleClear = () => {
logWebSocket.clearLogs() // 清空全局缓存
setLogs([])
}
// 导出日志为 TXT 格式
const handleExport = () => {
// 格式化日志为文本
const logText = filteredLogs.map(log =>
`${log.timestamp} [${log.level.padEnd(8)}] [${log.module}] ${log.message}`
).join('\n')
const dataBlob = new Blob([logText], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(dataBlob)
const link = document.createElement('a')
link.href = url
link.download = `logs-${format(new Date(), 'yyyy-MM-dd-HHmmss')}.txt`
link.click()
URL.revokeObjectURL(url)
}
// 切换自动滚动
const toggleAutoScroll = () => {
setAutoScroll(!autoScroll)
}
// 清除时间筛选
const clearDateFilter = () => {
setDateFrom(undefined)
setDateTo(undefined)
}
// 过滤日志
const filteredLogs = useMemo(() => {
return logs.filter((log) => {
// 搜索过滤
const matchesSearch =
searchQuery === '' ||
log.message.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.module.toLowerCase().includes(searchQuery.toLowerCase())
// 级别过滤
const matchesLevel = levelFilter === 'all' || log.level === levelFilter
// 模块过滤
const matchesModule = moduleFilter === 'all' || log.module === moduleFilter
// 时间过滤
let matchesDate = true
if (dateFrom || dateTo) {
const logDate = new Date(log.timestamp)
if (dateFrom) {
const fromDate = new Date(dateFrom)
fromDate.setHours(0, 0, 0, 0)
matchesDate = matchesDate && logDate >= fromDate
}
if (dateTo) {
const toDate = new Date(dateTo)
toDate.setHours(23, 59, 59, 999)
matchesDate = matchesDate && logDate <= toDate
}
}
return matchesSearch && matchesLevel && matchesModule && matchesDate
})
}, [logs, searchQuery, levelFilter, moduleFilter, dateFrom, dateTo])
// 虚拟滚动配置 - 根据字号和行间距动态计算行高
const estimatedRowHeight = fontSizeConfig[fontSize].rowHeight + lineSpacing
const rowVirtualizer = useVirtualizer({
count: filteredLogs.length,
getScrollElement: () => parentRef.current,
estimateSize: () => estimatedRowHeight,
overscan: 50, // 增加预渲染数量以减少快速滚动时的空白
})
// 用于追踪是否是程序触发的滚动
const isAutoScrollingRef = useRef(false)
// 用于追踪上一次的日志数量
const prevLogCountRef = useRef(filteredLogs.length)
// 检测用户滚动行为,当用户向上滚动时禁用自动滚动
useEffect(() => {
const scrollElement = parentRef.current
if (!scrollElement) return
const handleScroll = () => {
// 如果是程序触发的滚动,忽略
if (isAutoScrollingRef.current) return
const { scrollTop, scrollHeight, clientHeight } = scrollElement
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
// 如果距离底部超过 100px说明用户在向上查看禁用自动滚动
if (distanceFromBottom > 100 && autoScroll) {
setAutoScroll(false)
}
// 如果用户滚动到接近底部(小于 50px可以重新启用自动滚动
else if (distanceFromBottom < 50 && !autoScroll) {
setAutoScroll(true)
}
}
scrollElement.addEventListener('scroll', handleScroll, { passive: true })
return () => scrollElement.removeEventListener('scroll', handleScroll)
}, [autoScroll])
// 自动滚动到底部
useEffect(() => {
// 只有在日志数量增加时才滚动(避免删除日志时触发)
const logCountIncreased = filteredLogs.length > prevLogCountRef.current
prevLogCountRef.current = filteredLogs.length
if (autoScroll && filteredLogs.length > 0 && logCountIncreased) {
isAutoScrollingRef.current = true
rowVirtualizer.scrollToIndex(filteredLogs.length - 1, {
align: 'end',
behavior: 'auto',
})
// 稍后重置标志,给滚动事件处理一些时间
requestAnimationFrame(() => {
requestAnimationFrame(() => {
isAutoScrollingRef.current = false
})
})
}
}, [filteredLogs.length, autoScroll, rowVirtualizer])
return (
<div className="h-full flex flex-col overflow-hidden">
{/* 顶部操作面板 - 紧凑设计,默认折叠 */}
<div className="flex-shrink-0 space-y-2 sm:space-y-3 p-2 sm:p-3 lg:p-4">
{/* 标题和连接状态 */}
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-lg sm:text-xl lg:text-2xl font-bold"></h1>
<p className="text-xs text-muted-foreground mt-0.5 hidden sm:block">
</p>
</div>
{/* 连接状态指示器 */}
<div className="flex items-center gap-2">
<div
className={cn(
'h-2 w-2 sm:h-2.5 sm:w-2.5 rounded-full',
connected ? 'bg-green-500 animate-pulse' : 'bg-red-500'
)}
/>
<span className="text-xs text-muted-foreground">
{connected ? '已连接' : '未连接'}
</span>
</div>
</div>
{/* 控制栏 - 可折叠 */}
<Card className="p-2 sm:p-3">
<Collapsible open={filtersOpen} onOpenChange={setFiltersOpen}>
<div className="flex flex-col gap-2">
{/* 第一行:始终显示 - 搜索、快捷操作、展开按钮 */}
<div className="flex gap-2">
{/* 搜索框 */}
<div className="flex-1 relative min-w-0">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="搜索日志..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 h-8 text-xs sm:text-sm"
/>
</div>
{/* 快捷操作按钮 */}
<div className="flex gap-1 flex-shrink-0">
<Button
variant={autoScroll ? 'default' : 'outline'}
size="sm"
onClick={toggleAutoScroll}
className="h-8 px-2"
title={autoScroll ? '自动滚动' : '已暂停'}
>
{autoScroll ? (
<Pause className="h-3.5 w-3.5" />
) : (
<Play className="h-3.5 w-3.5" />
)}
<span className="ml-1 text-xs hidden sm:inline">
{autoScroll ? '滚动' : '暂停'}
</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleClear}
className="h-8 px-2"
title="清空日志"
>
<Trash2 className="h-3.5 w-3.5" />
<span className="ml-1 text-xs hidden md:inline"></span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExport}
className="h-8 px-2 hidden sm:flex"
title="导出日志"
>
<Download className="h-3.5 w-3.5" />
<span className="ml-1 text-xs hidden lg:inline"></span>
</Button>
{/* 展开/收起按钮 */}
<CollapsibleTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-2"
title={filtersOpen ? '收起筛选' : '展开筛选'}
>
<Filter className="h-3.5 w-3.5" />
{filtersOpen ? (
<ChevronUp className="h-3.5 w-3.5 ml-1" />
) : (
<ChevronDown className="h-3.5 w-3.5 ml-1" />
)}
</Button>
</CollapsibleTrigger>
</div>
</div>
{/* 日志数量显示 */}
<div className="text-xs text-muted-foreground text-center sm:text-right -mt-1">
<span className="font-mono">
{filteredLogs.length} / {logs.length}
</span>
<span className="ml-1"></span>
</div>
{/* 可折叠的筛选区域 */}
<CollapsibleContent className="space-y-2">
{/* 级别和模块筛选 */}
<div className="flex flex-col gap-2 sm:flex-row sm:gap-2">
<Select value={levelFilter} onValueChange={setLevelFilter}>
<SelectTrigger className="w-full sm:flex-1 h-8 text-xs">
<Filter className="h-3.5 w-3.5 mr-1.5" />
<SelectValue placeholder="级别" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="DEBUG">DEBUG</SelectItem>
<SelectItem value="INFO">INFO</SelectItem>
<SelectItem value="WARNING">WARNING</SelectItem>
<SelectItem value="ERROR">ERROR</SelectItem>
<SelectItem value="CRITICAL">CRITICAL</SelectItem>
</SelectContent>
</Select>
<Select value={moduleFilter} onValueChange={setModuleFilter}>
<SelectTrigger className="w-full sm:flex-1 h-8 text-xs">
<Filter className="h-3.5 w-3.5 mr-1.5" />
<SelectValue placeholder="模块" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{uniqueModules.map(module => (
<SelectItem key={module} value={module}>
{module}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 时间筛选 */}
<div className="flex flex-col gap-2 sm:flex-row sm:gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
'w-full sm:flex-1 justify-start text-left font-normal h-8',
!dateFrom && 'text-muted-foreground'
)}
>
<CalendarIcon className="mr-1.5 h-3.5 w-3.5" />
<span className="text-xs">
{dateFrom ? format(dateFrom, 'PP', { locale: zhCN }) : '开始日期'}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dateFrom}
onSelect={setDateFrom}
initialFocus
locale={zhCN}
/>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
'w-full sm:flex-1 justify-start text-left font-normal h-8',
!dateTo && 'text-muted-foreground'
)}
>
<CalendarIcon className="mr-1.5 h-3.5 w-3.5" />
<span className="text-xs">
{dateTo ? format(dateTo, 'PP', { locale: zhCN }) : '结束日期'}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dateTo}
onSelect={setDateTo}
initialFocus
locale={zhCN}
/>
</PopoverContent>
</Popover>
{(dateFrom || dateTo) && (
<Button
variant="outline"
size="sm"
onClick={clearDateFilter}
className="w-full sm:w-auto h-8"
>
<X className="h-3.5 w-3.5 sm:mr-1" />
<span className="text-xs"></span>
</Button>
)}
</div>
{/* 显示设置 */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3 pt-2 border-t border-border/50">
{/* 字号调整 */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Type className="h-3.5 w-3.5" />
<span></span>
</div>
<div className="flex gap-1">
{(Object.keys(fontSizeConfig) as FontSize[]).map((size) => (
<Button
key={size}
variant={fontSize === size ? 'default' : 'outline'}
size="sm"
onClick={() => setFontSize(size)}
className="h-6 px-2 text-xs"
>
{fontSizeConfig[size].label}
</Button>
))}
</div>
</div>
{/* 行间距调整 */}
<div className="flex items-center gap-2 flex-1 max-w-[200px]">
<span className="text-xs text-muted-foreground whitespace-nowrap"></span>
<Slider
value={[lineSpacing]}
onValueChange={([value]) => setLineSpacing(value)}
min={0}
max={12}
step={2}
className="flex-1"
/>
<span className="text-xs text-muted-foreground w-7">{lineSpacing}px</span>
</div>
{/* 额外操作按钮(移动端) */}
<div className="flex gap-2 sm:hidden">
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
className="flex-1 h-8"
>
<RefreshCw className="h-3.5 w-3.5 mr-1" />
<span className="text-xs"></span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExport}
className="flex-1 h-8"
>
<Download className="h-3.5 w-3.5 mr-1" />
<span className="text-xs"></span>
</Button>
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
</Card>
</div>
{/* 日志终端 - 占据剩余所有空间 */}
<div className="flex-1 min-h-0 px-2 sm:px-3 lg:px-4 pb-2 sm:pb-3 lg:pb-4">
<Card className="bg-black dark:bg-gray-950 border-gray-800 dark:border-gray-900 h-full overflow-hidden">
<div
ref={parentRef}
className={cn(
"h-full overflow-auto",
// 自定义滚动条样式
"[&::-webkit-scrollbar]:w-2.5",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:bg-border [&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb:hover]:bg-border/80"
)}
>
<div
className={cn("p-2 sm:p-3 font-mono relative", fontSizeConfig[fontSize].class)}
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{filteredLogs.length === 0 ? (
<div className="text-gray-500 dark:text-gray-600 text-center py-8 text-xs sm:text-sm">
</div>
) : (
rowVirtualizer.getVirtualItems().map((virtualRow) => {
const log = filteredLogs[virtualRow.index]
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className={cn(
'absolute top-0 left-0 w-full px-2 sm:px-3 rounded hover:bg-white/5 transition-colors',
getLevelBgColor(log.level)
)}
style={{
transform: `translateY(${virtualRow.start}px)`,
paddingTop: `${lineSpacing / 2}px`,
paddingBottom: `${lineSpacing / 2}px`,
}}
>
{/* 移动端:垂直布局 */}
<div className="flex flex-col gap-0.5 sm:hidden">
{/* 第一行:时间戳和级别 */}
<div className="flex items-center gap-2">
<span className="text-gray-500 dark:text-gray-600 text-[10px]">
{log.timestamp}
</span>
<span
className={cn(
'font-semibold text-[10px]',
getLevelColor(log.level)
)}
>
[{log.level}]
</span>
</div>
{/* 第二行:模块名 */}
<div className="text-cyan-400 dark:text-cyan-500 truncate text-[10px]">
{log.module}
</div>
{/* 第三行:消息内容 */}
<div className="text-gray-300 dark:text-gray-400 whitespace-pre-wrap break-words text-[10px]">
{log.message}
</div>
</div>
{/* 平板/桌面端:水平布局 */}
<div className="hidden sm:flex gap-2 items-start">
{/* 时间戳 */}
<span className="text-gray-500 dark:text-gray-600 flex-shrink-0 w-[130px] lg:w-[160px]">
{log.timestamp}
</span>
{/* 日志级别 */}
<span
className={cn(
'flex-shrink-0 w-[65px] lg:w-[75px] font-semibold',
getLevelColor(log.level)
)}
>
[{log.level}]
</span>
{/* 模块名 */}
<span className="text-cyan-400 dark:text-cyan-500 flex-shrink-0 w-[100px] lg:w-[130px] truncate">
{log.module}
</span>
{/* 消息内容 */}
<span className="text-gray-300 dark:text-gray-400 flex-1 whitespace-pre-wrap break-words">
{log.message}
</span>
</div>
</div>
)
})
)}
</div>
</div>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,68 @@
import { LayoutGrid, Package } from 'lucide-react'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
export function ModelPresetsPage() {
return (
<div className="h-[calc(100vh-4rem)] flex flex-col p-4 sm:p-6">
{/* 页面标题 */}
<div className="mb-4 sm:mb-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2">
<LayoutGrid className="h-8 w-8" strokeWidth={2} />
</h1>
<p className="text-muted-foreground mt-1 text-sm sm:text-base">
</p>
</div>
</div>
</div>
<ScrollArea className="flex-1">
<div className="flex items-center justify-center h-[calc(100vh-12rem)]">
<Card className="max-w-2xl w-full border-dashed">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<Package className="h-16 w-16 text-muted-foreground" />
</div>
<CardTitle className="text-2xl"></CardTitle>
<CardDescription className="text-base">
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3 text-sm text-muted-foreground">
<p className="font-medium text-foreground">📦 </p>
<ul className="space-y-2 ml-6">
<li className="flex items-start">
<span className="mr-2"></span>
<span></span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span></span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span></span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span></span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span>使</span>
</li>
</ul>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,86 @@
/**
* 监控页面主入口
* 整合规划器监控和回复器监控
*/
import { Activity, RefreshCw, MessageSquareText } from 'lucide-react'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { useState, useCallback } from 'react'
import { PlannerMonitor } from './planner-monitor'
import { ReplierMonitor } from './replier-monitor'
export function PlannerMonitorPage() {
const [activeTab, setActiveTab] = useState<'planner' | 'replier'>('planner')
const [autoRefresh, setAutoRefresh] = useState(false)
const [refreshKey, setRefreshKey] = useState(0)
const handleManualRefresh = useCallback(() => {
setRefreshKey(k => k + 1)
}, [])
return (
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
{/* 页面标题 */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold"> &amp; </h1>
<p className="text-muted-foreground mt-1 sm:mt-2 text-sm sm:text-base">
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant={autoRefresh ? "default" : "outline"}
size="sm"
onClick={() => setAutoRefresh(!autoRefresh)}
>
<RefreshCw className={`h-4 w-4 mr-2 ${autoRefresh ? 'animate-spin' : ''}`} />
{autoRefresh ? '自动刷新中' : '自动刷新'}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleManualRefresh}
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
{/* 标签页 */}
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'planner' | 'replier')}
className="w-full"
>
<TabsList className="grid w-full grid-cols-2 gap-0.5 sm:gap-1 h-auto p-1">
<TabsTrigger value="planner" className="gap-1 sm:gap-2 text-xs sm:text-sm px-2 sm:px-3 py-2">
<Activity className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
<span></span>
</TabsTrigger>
<TabsTrigger value="replier" className="gap-1 sm:gap-2 text-xs sm:text-sm px-2 sm:px-3 py-2">
<MessageSquareText className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
<span></span>
</TabsTrigger>
</TabsList>
<ScrollArea className="h-[calc(100vh-240px)] sm:h-[calc(100vh-280px)] mt-4 sm:mt-6">
<TabsContent value="planner" className="mt-0">
<PlannerMonitor
autoRefresh={autoRefresh}
refreshKey={refreshKey}
/>
</TabsContent>
<TabsContent value="replier" className="mt-0">
<ReplierMonitor
autoRefresh={autoRefresh}
refreshKey={refreshKey}
/>
</TabsContent>
</ScrollArea>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,636 @@
/**
* 规划器监控组件
*/
import { Clock, TrendingUp, FileText, Zap, Brain, List, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ArrowLeft, MessageSquare, Search } from 'lucide-react'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useState, useEffect, useCallback } from 'react'
import {
getPlannerOverview,
getChatLogs,
getLogDetail,
type PlannerOverview,
type PlanLogDetail,
type PaginatedChatLogs,
type ChatSummary
} from '@/lib/planner-api'
import { Skeleton } from '@/components/ui/skeleton'
import { useChatNameMap, formatTimestamp, formatRelativeTime, useAutoRefresh } from './use-monitor'
interface PlannerMonitorProps {
autoRefresh: boolean
refreshKey: number
}
export function PlannerMonitor({ autoRefresh, refreshKey }: PlannerMonitorProps) {
// 视图状态: 'overview' | 'chat-logs'
const [view, setView] = useState<'overview' | 'chat-logs'>('overview')
const [selectedChat, setSelectedChat] = useState<ChatSummary | null>(null)
// 聊天名称映射
const { getChatName } = useChatNameMap()
// 总览数据
const [overview, setOverview] = useState<PlannerOverview | null>(null)
const [overviewLoading, setOverviewLoading] = useState(true)
// 聊天日志数据
const [chatLogs, setChatLogs] = useState<PaginatedChatLogs | null>(null)
const [chatLogsLoading, setChatLogsLoading] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [jumpToPage, setJumpToPage] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [searchInput, setSearchInput] = useState('')
// 详情弹窗
const [selectedLog, setSelectedLog] = useState<PlanLogDetail | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
// 加载总览数据
const loadOverview = useCallback(async () => {
try {
setOverviewLoading(true)
const data = await getPlannerOverview()
setOverview(data)
} catch (error) {
console.error('加载规划器总览失败:', error)
} finally {
setOverviewLoading(false)
}
}, [])
// 加载聊天日志
const loadChatLogs = useCallback(async () => {
if (!selectedChat) return
try {
setChatLogsLoading(true)
const data = await getChatLogs(selectedChat.chat_id, page, pageSize, searchQuery || undefined)
setChatLogs(data)
} catch (error) {
console.error('加载聊天日志失败:', error)
} finally {
setChatLogsLoading(false)
}
}, [selectedChat, page, pageSize, searchQuery])
// 初始加载
useEffect(() => {
loadOverview()
}, [loadOverview])
// 响应外部刷新
useEffect(() => {
if (refreshKey > 0) {
if (view === 'overview') {
loadOverview()
} else {
loadChatLogs()
}
}
}, [refreshKey, view, loadOverview, loadChatLogs])
// 加载聊天日志
useEffect(() => {
if (view === 'chat-logs' && selectedChat) {
loadChatLogs()
}
}, [view, selectedChat, loadChatLogs])
// 自动刷新
useAutoRefresh(
autoRefresh,
useCallback(() => {
if (view === 'overview') {
loadOverview()
} else {
loadChatLogs()
}
}, [view, loadOverview, loadChatLogs])
)
const handleChatClick = (chat: ChatSummary) => {
setSelectedChat(chat)
setPage(1)
setSearchQuery('')
setSearchInput('')
setView('chat-logs')
}
const handleBackToOverview = () => {
setView('overview')
setSelectedChat(null)
setChatLogs(null)
setSearchQuery('')
setSearchInput('')
}
const handleSearch = () => {
setSearchQuery(searchInput)
setPage(1)
}
const handleClearSearch = () => {
setSearchInput('')
setSearchQuery('')
setPage(1)
}
const handleLogClick = async (chatId: string, filename: string) => {
try {
setDetailLoading(true)
setDialogOpen(true)
const detail = await getLogDetail(chatId, filename)
setSelectedLog(detail)
} catch (error) {
console.error('加载计划详情失败:', error)
} finally {
setDetailLoading(false)
}
}
const handlePageSizeChange = (value: string) => {
setPageSize(Number(value))
setPage(1)
}
const handleJumpToPage = () => {
const targetPage = parseInt(jumpToPage)
const totalPages = chatLogs ? Math.ceil(chatLogs.total / chatLogs.page_size) : 0
if (!isNaN(targetPage) && targetPage >= 1 && targetPage <= totalPages) {
setPage(targetPage)
setJumpToPage('')
}
}
const totalPages = chatLogs ? Math.ceil(chatLogs.total / chatLogs.page_size) : 0
return (
<>
<div className="space-y-4">
{view === 'overview' ? (
// ========== 第一级:总览视图 ==========
<>
{/* 统计卡片 */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<MessageSquare className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{overviewLoading ? (
<Skeleton className="h-8 w-16" />
) : (
<div className="text-2xl font-bold">{overview?.total_chats || 0}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{overviewLoading ? (
<Skeleton className="h-8 w-16" />
) : (
<div className="text-2xl font-bold">{overview?.total_plans || 0}</div>
)}
</CardContent>
</Card>
</div>
{/* 聊天卡片列表 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{overviewLoading ? (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
) : overview?.chats && overview.chats.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{overview.chats.map((chat) => (
<div
key={chat.chat_id}
className="border rounded-lg p-4 hover:bg-accent/50 transition-colors cursor-pointer"
onClick={() => handleChatClick(chat)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<span className="text-sm truncate max-w-[180px]" title={getChatName(chat.chat_id)}>
{getChatName(chat.chat_id)}
</span>
</div>
<Badge variant="secondary">{chat.plan_count}</Badge>
</div>
<div className="text-xs text-muted-foreground">
: {formatRelativeTime(chat.latest_timestamp)}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground"></div>
)}
</CardContent>
</Card>
</>
) : (
// ========== 第二级:聊天日志列表 ==========
<>
{/* 返回按钮和聊天信息 */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 mb-4">
<Button variant="outline" size="sm" onClick={handleBackToOverview}>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
<div className="text-sm text-muted-foreground">
: <span className="font-medium">{selectedChat ? getChatName(selectedChat.chat_id) : ''}</span>
<span className="mx-2"></span>
{chatLogs?.total || 0}
</div>
</div>
<Card>
<CardHeader>
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
{selectedChat ? getChatName(selectedChat.chat_id) : ''}
</CardDescription>
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<div className="flex items-center gap-1">
<Input
placeholder="搜索提示词内容..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="w-full sm:w-48"
/>
<Button variant="outline" size="icon" onClick={handleSearch}>
<Search className="h-4 w-4" />
</Button>
{searchQuery && (
<Button variant="ghost" size="sm" onClick={handleClearSearch}>
</Button>
)}
</div>
<Select value={pageSize.toString()} onValueChange={handlePageSizeChange}>
<SelectTrigger className="w-full sm:w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10/</SelectItem>
<SelectItem value="20">20/</SelectItem>
<SelectItem value="50">50/</SelectItem>
<SelectItem value="100">100/</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{searchQuery && (
<div className="text-sm text-muted-foreground mt-2">
: <span className="font-medium">"{searchQuery}"</span>
</div>
)}
</CardHeader>
<CardContent>
{chatLogsLoading ? (
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
) : chatLogs?.data && chatLogs.data.length > 0 ? (
<>
<div className="space-y-2">
{chatLogs.data.map((plan) => (
<div
key={plan.filename}
className="border rounded-lg p-3 hover:bg-accent/50 transition-colors cursor-pointer"
onClick={() => handleLogClick(plan.chat_id, plan.filename)}
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-muted-foreground">
{formatTimestamp(plan.timestamp)}
</span>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="secondary" className="text-xs">
{plan.action_count}
</Badge>
<Badge variant="outline" className="text-xs">
{plan.total_plan_ms.toFixed(0)}ms
</Badge>
</div>
</div>
{plan.action_types && plan.action_types.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{plan.action_types.map((type, idx) => (
<Badge key={idx} variant="outline" className="text-xs bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800">
{type}
</Badge>
))}
</div>
)}
<p className="text-sm line-clamp-2">{plan.reasoning_preview || '无推理内容'}</p>
</div>
))}
</div>
{/* 分页控件 */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4 pt-4 border-t">
<div className="text-sm text-muted-foreground">
{chatLogs.total} {page} / {totalPages}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(1)}
disabled={page === 1}
className="hidden sm:flex"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="hidden sm:flex items-center gap-2">
<Input
type="number"
min={1}
max={totalPages}
value={jumpToPage}
onChange={(e) => setJumpToPage(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleJumpToPage()}
placeholder="跳转"
className="w-20 h-8"
/>
<Button size="sm" variant="outline" onClick={handleJumpToPage}>
</Button>
</div>
<span className="sm:hidden text-sm text-muted-foreground">
{page}/{totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(totalPages)}
disabled={page === totalPages}
className="hidden sm:flex"
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</>
) : (
<div className="text-center py-8 text-muted-foreground"></div>
)}
</CardContent>
</Card>
</>
)}
</div>
{/* ========== 第三级:计划详情弹窗 ========== */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-4xl max-h-[80vh] grid grid-rows-[auto_1fr_auto] overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-full pr-4">
<div className="space-y-6 pb-4">
{detailLoading ? (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
) : selectedLog ? (
<>
{/* 基本信息 */}
<div className="space-y-2">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Clock className="h-4 w-4" />
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 p-4 bg-muted/50 rounded-lg">
<div>
<div className="text-xs text-muted-foreground mb-1"></div>
<div className="text-sm" title={selectedLog.chat_id}>{getChatName(selectedLog.chat_id)}</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1"></div>
<div className="text-sm">{formatTimestamp(selectedLog.timestamp)}</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1"></div>
<Badge variant="outline">{selectedLog.type}</Badge>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1"></div>
<Badge>{selectedLog.actions.length} </Badge>
</div>
</div>
</div>
<Separator />
{/* 时间统计 */}
<div className="space-y-2">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Zap className="h-4 w-4" />
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Card>
<CardHeader className="p-4 pb-2">
<CardTitle className="text-xs text-muted-foreground"></CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-xl font-bold">{selectedLog.timing.prompt_build_ms?.toFixed(2) || 0}ms</div>
</CardContent>
</Card>
<Card>
<CardHeader className="p-4 pb-2">
<CardTitle className="text-xs text-muted-foreground">LLM </CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-xl font-bold">{selectedLog.timing.llm_duration_ms?.toFixed(2) || 0}ms</div>
</CardContent>
</Card>
<Card>
<CardHeader className="p-4 pb-2">
<CardTitle className="text-xs text-muted-foreground"></CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-xl font-bold">{selectedLog.timing.total_plan_ms?.toFixed(2) || 0}ms</div>
</CardContent>
</Card>
</div>
</div>
<Separator />
{/* 推理内容 */}
<div className="space-y-2">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Brain className="h-4 w-4" />
</h3>
<div className="p-4 bg-muted/50 rounded-lg">
<p className="text-sm whitespace-pre-wrap leading-relaxed">{selectedLog.reasoning || '无推理内容'}</p>
</div>
</div>
<Separator />
{/* 执行动作 */}
<div className="space-y-2">
<h3 className="text-sm font-semibold flex items-center gap-2">
<List className="h-4 w-4" />
({selectedLog.actions.length})
</h3>
<div className="space-y-3">
{selectedLog.actions.map((action, index) => (
<Card key={index}>
<CardHeader className="p-4 pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<Badge variant="default"> {index + 1}</Badge>
<Badge variant="outline">{action.action_type}</Badge>
</div>
</div>
</CardHeader>
<CardContent className="p-4 pt-0 space-y-3">
{action.reasoning && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-1"></div>
<p className="text-sm bg-muted/30 p-2 rounded">
{typeof action.reasoning === 'string' ? action.reasoning : JSON.stringify(action.reasoning)}
</p>
</div>
)}
{action.action_message && (
<div className="overflow-hidden">
<div className="text-xs font-medium text-muted-foreground mb-1"></div>
{typeof action.action_message === 'string' ? (
<p className="text-sm bg-muted/30 p-2 rounded break-all whitespace-pre-wrap">{action.action_message}</p>
) : (
<pre className="text-xs bg-muted/30 p-2 rounded overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(action.action_message, null, 2)}
</pre>
)}
</div>
)}
{action.action_data && Object.keys(action.action_data).length > 0 && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-1"></div>
<pre className="text-xs bg-muted/30 p-2 rounded overflow-x-auto">
{JSON.stringify(action.action_data, null, 2)}
</pre>
</div>
)}
{action.action_reasoning && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-1"></div>
<p className="text-sm bg-muted/30 p-2 rounded">
{typeof action.action_reasoning === 'string' ? action.action_reasoning : JSON.stringify(action.action_reasoning)}
</p>
</div>
)}
</CardContent>
</Card>
))}
</div>
</div>
<Separator />
{/* 原始输出 */}
{selectedLog.raw_output && (
<div className="space-y-2">
<h3 className="text-sm font-semibold"></h3>
<details className="group">
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground">
</summary>
<div className="mt-2 p-4 bg-muted/50 rounded-lg">
<pre className="text-xs whitespace-pre-wrap break-words">{selectedLog.raw_output}</pre>
</div>
</details>
</div>
)}
{/* 提示词 */}
{selectedLog.prompt && (
<div className="space-y-2">
<h3 className="text-sm font-semibold"></h3>
<details className="group">
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground">
</summary>
<div className="mt-2 p-4 bg-muted/50 rounded-lg">
<pre className="text-xs whitespace-pre-wrap break-words">{selectedLog.prompt}</pre>
</div>
</details>
</div>
)}
</>
) : (
<div className="flex items-center justify-center py-12">
<p className="text-muted-foreground"></p>
</div>
)}
</div>
</ScrollArea>
<DialogFooter className="flex-shrink-0">
<Button onClick={() => setDialogOpen(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,648 @@
/**
* 回复器监控组件
*/
import { Clock, TrendingUp, FileText, Zap, Brain, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ArrowLeft, MessageSquare, CheckCircle, XCircle, Cpu, Search } from 'lucide-react'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useState, useEffect, useCallback } from 'react'
import {
getReplierOverview,
getReplyChatLogs,
getReplyLogDetail,
type ReplierOverview,
type ReplyLogDetail,
type PaginatedReplyLogs,
type ReplierChatSummary
} from '@/lib/planner-api'
import { Skeleton } from '@/components/ui/skeleton'
import { useChatNameMap, formatTimestamp, formatRelativeTime, useAutoRefresh } from './use-monitor'
interface ReplierMonitorProps {
autoRefresh: boolean
refreshKey: number
}
export function ReplierMonitor({ autoRefresh, refreshKey }: ReplierMonitorProps) {
// 视图状态: 'overview' | 'chat-logs'
const [view, setView] = useState<'overview' | 'chat-logs'>('overview')
const [selectedChat, setSelectedChat] = useState<ReplierChatSummary | null>(null)
// 聊天名称映射
const { getChatName } = useChatNameMap()
// 总览数据
const [overview, setOverview] = useState<ReplierOverview | null>(null)
const [overviewLoading, setOverviewLoading] = useState(true)
// 聊天日志数据
const [chatLogs, setChatLogs] = useState<PaginatedReplyLogs | null>(null)
const [chatLogsLoading, setChatLogsLoading] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [jumpToPage, setJumpToPage] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [searchInput, setSearchInput] = useState('')
// 详情弹窗
const [selectedLog, setSelectedLog] = useState<ReplyLogDetail | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
// 加载总览数据
const loadOverview = useCallback(async () => {
try {
setOverviewLoading(true)
const data = await getReplierOverview()
setOverview(data)
} catch (error) {
console.error('加载回复器总览失败:', error)
} finally {
setOverviewLoading(false)
}
}, [])
// 加载聊天日志
const loadChatLogs = useCallback(async () => {
if (!selectedChat) return
try {
setChatLogsLoading(true)
const data = await getReplyChatLogs(selectedChat.chat_id, page, pageSize, searchQuery || undefined)
setChatLogs(data)
} catch (error) {
console.error('加载聊天日志失败:', error)
} finally {
setChatLogsLoading(false)
}
}, [selectedChat, page, pageSize, searchQuery])
// 初始加载
useEffect(() => {
loadOverview()
}, [loadOverview])
// 响应外部刷新
useEffect(() => {
if (refreshKey > 0) {
if (view === 'overview') {
loadOverview()
} else {
loadChatLogs()
}
}
}, [refreshKey, view, loadOverview, loadChatLogs])
// 加载聊天日志
useEffect(() => {
if (view === 'chat-logs' && selectedChat) {
loadChatLogs()
}
}, [view, selectedChat, loadChatLogs])
// 自动刷新
useAutoRefresh(
autoRefresh,
useCallback(() => {
if (view === 'overview') {
loadOverview()
} else {
loadChatLogs()
}
}, [view, loadOverview, loadChatLogs])
)
const handleChatClick = (chat: ReplierChatSummary) => {
setSelectedChat(chat)
setPage(1)
setSearchQuery('')
setSearchInput('')
setView('chat-logs')
}
const handleBackToOverview = () => {
setView('overview')
setSelectedChat(null)
setChatLogs(null)
setSearchQuery('')
setSearchInput('')
}
const handleSearch = () => {
setSearchQuery(searchInput)
setPage(1)
}
const handleClearSearch = () => {
setSearchInput('')
setSearchQuery('')
setPage(1)
}
const handleLogClick = async (chatId: string, filename: string) => {
try {
setDetailLoading(true)
setDialogOpen(true)
const detail = await getReplyLogDetail(chatId, filename)
setSelectedLog(detail)
} catch (error) {
console.error('加载回复详情失败:', error)
} finally {
setDetailLoading(false)
}
}
const handlePageSizeChange = (value: string) => {
setPageSize(Number(value))
setPage(1)
}
const handleJumpToPage = () => {
const targetPage = parseInt(jumpToPage)
const totalPages = chatLogs ? Math.ceil(chatLogs.total / chatLogs.page_size) : 0
if (!isNaN(targetPage) && targetPage >= 1 && targetPage <= totalPages) {
setPage(targetPage)
setJumpToPage('')
}
}
const totalPages = chatLogs ? Math.ceil(chatLogs.total / chatLogs.page_size) : 0
return (
<>
<div className="space-y-4">
{view === 'overview' ? (
// ========== 第一级:总览视图 ==========
<>
{/* 统计卡片 */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<MessageSquare className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{overviewLoading ? (
<Skeleton className="h-8 w-16" />
) : (
<div className="text-2xl font-bold">{overview?.total_chats || 0}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{overviewLoading ? (
<Skeleton className="h-8 w-16" />
) : (
<div className="text-2xl font-bold">{overview?.total_replies || 0}</div>
)}
</CardContent>
</Card>
</div>
{/* 聊天卡片列表 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{overviewLoading ? (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
) : overview?.chats && overview.chats.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{overview.chats.map((chat) => (
<div
key={chat.chat_id}
className="border rounded-lg p-4 hover:bg-accent/50 transition-colors cursor-pointer"
onClick={() => handleChatClick(chat)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<span className="text-sm truncate max-w-[180px]" title={getChatName(chat.chat_id)}>
{getChatName(chat.chat_id)}
</span>
</div>
<Badge variant="secondary">{chat.reply_count}</Badge>
</div>
<div className="text-xs text-muted-foreground">
: {formatRelativeTime(chat.latest_timestamp)}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground"></div>
)}
</CardContent>
</Card>
</>
) : (
// ========== 第二级:聊天日志列表 ==========
<>
{/* 返回按钮和聊天信息 */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 mb-4">
<Button variant="outline" size="sm" onClick={handleBackToOverview}>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
<div className="text-sm text-muted-foreground">
: <span className="font-medium">{selectedChat ? getChatName(selectedChat.chat_id) : ''}</span>
<span className="mx-2"></span>
{chatLogs?.total || 0}
</div>
</div>
<Card>
<CardHeader>
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
{selectedChat ? getChatName(selectedChat.chat_id) : ''}
</CardDescription>
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<div className="flex items-center gap-1">
<Input
placeholder="搜索提示词内容..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="w-full sm:w-48"
/>
<Button variant="outline" size="icon" onClick={handleSearch}>
<Search className="h-4 w-4" />
</Button>
{searchQuery && (
<Button variant="ghost" size="sm" onClick={handleClearSearch}>
</Button>
)}
</div>
<Select value={pageSize.toString()} onValueChange={handlePageSizeChange}>
<SelectTrigger className="w-full sm:w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10/</SelectItem>
<SelectItem value="20">20/</SelectItem>
<SelectItem value="50">50/</SelectItem>
<SelectItem value="100">100/</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{searchQuery && (
<div className="text-sm text-muted-foreground mt-2">
: <span className="font-medium">"{searchQuery}"</span>
</div>
)}
</CardHeader>
<CardContent>
{chatLogsLoading ? (
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
) : chatLogs?.data && chatLogs.data.length > 0 ? (
<>
<div className="space-y-2">
{chatLogs.data.map((reply) => (
<div
key={reply.filename}
className="border rounded-lg p-3 hover:bg-accent/50 transition-colors cursor-pointer"
onClick={() => handleLogClick(reply.chat_id, reply.filename)}
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-muted-foreground">
{formatTimestamp(reply.timestamp)}
</span>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{reply.success ? (
<Badge variant="default" className="text-xs bg-green-600">
<CheckCircle className="h-3 w-3 mr-1" />
</Badge>
) : (
<Badge variant="destructive" className="text-xs">
<XCircle className="h-3 w-3 mr-1" />
</Badge>
)}
<Badge variant="outline" className="text-xs">
{reply.model}
</Badge>
<Badge variant="secondary" className="text-xs">
{reply.overall_ms.toFixed(0)}ms
</Badge>
</div>
</div>
<p className="text-sm line-clamp-2">{reply.output_preview || '无输出内容'}</p>
</div>
))}
</div>
{/* 分页控件 */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4 pt-4 border-t">
<div className="text-sm text-muted-foreground">
{chatLogs.total} {page} / {totalPages}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(1)}
disabled={page === 1}
className="hidden sm:flex"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="hidden sm:flex items-center gap-2">
<Input
type="number"
min={1}
max={totalPages}
value={jumpToPage}
onChange={(e) => setJumpToPage(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleJumpToPage()}
placeholder="跳转"
className="w-20 h-8"
/>
<Button size="sm" variant="outline" onClick={handleJumpToPage}>
</Button>
</div>
<span className="sm:hidden text-sm text-muted-foreground">
{page}/{totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(totalPages)}
disabled={page === totalPages}
className="hidden sm:flex"
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</>
) : (
<div className="text-center py-8 text-muted-foreground"></div>
)}
</CardContent>
</Card>
</>
)}
</div>
{/* ========== 第三级:回复详情弹窗 ========== */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-4xl max-h-[80vh] grid grid-rows-[auto_1fr_auto] overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-full pr-4">
<div className="space-y-6 pb-4">
{detailLoading ? (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
) : selectedLog ? (
<>
{/* 基本信息 */}
<div className="space-y-2">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Clock className="h-4 w-4" />
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 p-4 bg-muted/50 rounded-lg">
<div>
<div className="text-xs text-muted-foreground mb-1"></div>
<div className="text-sm" title={selectedLog.chat_id}>{getChatName(selectedLog.chat_id)}</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1"></div>
<div className="text-sm">{formatTimestamp(selectedLog.timestamp)}</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1"></div>
{selectedLog.success ? (
<Badge variant="default" className="bg-green-600">
<CheckCircle className="h-3 w-3 mr-1" />
</Badge>
) : (
<Badge variant="destructive">
<XCircle className="h-3 w-3 mr-1" />
</Badge>
)}
</div>
<div>
<div className="text-xs text-muted-foreground mb-1"></div>
<Badge variant="outline">Level {selectedLog.think_level}</Badge>
</div>
</div>
</div>
<Separator />
{/* 模型信息 */}
<div className="space-y-2">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Cpu className="h-4 w-4" />
</h3>
<div className="p-4 bg-muted/50 rounded-lg">
<Badge variant="secondary" className="text-sm">{selectedLog.model}</Badge>
</div>
</div>
<Separator />
{/* 性能统计 */}
<div className="space-y-2">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Zap className="h-4 w-4" />
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Card>
<CardHeader className="p-4 pb-2">
<CardTitle className="text-xs text-muted-foreground"></CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-xl font-bold">{selectedLog.timing.prompt_ms?.toFixed(2) || 0}ms</div>
</CardContent>
</Card>
<Card>
<CardHeader className="p-4 pb-2">
<CardTitle className="text-xs text-muted-foreground">LLM </CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-xl font-bold">{selectedLog.timing.llm_ms?.toFixed(2) || 0}ms</div>
</CardContent>
</Card>
<Card>
<CardHeader className="p-4 pb-2">
<CardTitle className="text-xs text-muted-foreground"></CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-xl font-bold">{selectedLog.timing.overall_ms?.toFixed(2) || 0}ms</div>
</CardContent>
</Card>
</div>
{/* 耗时日志 */}
{selectedLog.timing.timing_logs && selectedLog.timing.timing_logs.length > 0 && (
<div className="mt-3 p-3 bg-muted/30 rounded-lg">
<div className="text-xs font-medium text-muted-foreground mb-2"></div>
<div className="space-y-1">
{selectedLog.timing.timing_logs.map((log, idx) => (
<div key={idx} className="text-xs text-muted-foreground">{log}</div>
))}
</div>
</div>
)}
{/* 几乎为零的耗时 */}
{selectedLog.timing.almost_zero && (
<div className="mt-2 text-xs text-muted-foreground">
<span className="font-medium">: </span>
{selectedLog.timing.almost_zero}
</div>
)}
</div>
<Separator />
{/* 回复输出 */}
<div className="space-y-2">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Brain className="h-4 w-4" />
</h3>
<div className="p-4 bg-muted/50 rounded-lg">
<p className="text-sm whitespace-pre-wrap leading-relaxed">{selectedLog.output || '无输出内容'}</p>
</div>
</div>
{/* 处理后的输出 */}
{selectedLog.processed_output && selectedLog.processed_output.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<h3 className="text-sm font-semibold"></h3>
<div className="space-y-2">
{selectedLog.processed_output.map((output, idx) => (
<div key={idx} className="p-3 bg-muted/30 rounded-lg">
<p className="text-sm whitespace-pre-wrap">{output}</p>
</div>
))}
</div>
</div>
</>
)}
{/* 推理内容 */}
{selectedLog.reasoning && (
<>
<Separator />
<div className="space-y-2">
<h3 className="text-sm font-semibold"></h3>
<div className="p-4 bg-muted/50 rounded-lg">
<p className="text-sm whitespace-pre-wrap leading-relaxed">{selectedLog.reasoning}</p>
</div>
</div>
</>
)}
{/* 错误信息 */}
{selectedLog.error && (
<>
<Separator />
<div className="space-y-2">
<h3 className="text-sm font-semibold text-destructive"></h3>
<div className="p-4 bg-destructive/10 rounded-lg border border-destructive/20">
<p className="text-sm text-destructive whitespace-pre-wrap">{selectedLog.error}</p>
</div>
</div>
</>
)}
{/* 提示词 */}
{selectedLog.prompt && (
<>
<Separator />
<div className="space-y-2">
<h3 className="text-sm font-semibold"></h3>
<details className="group">
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground">
</summary>
<div className="mt-2 p-4 bg-muted/50 rounded-lg">
<pre className="text-xs whitespace-pre-wrap break-words">{selectedLog.prompt}</pre>
</div>
</details>
</div>
</>
)}
</>
) : (
<div className="flex items-center justify-center py-12">
<p className="text-muted-foreground"></p>
</div>
)}
</div>
</ScrollArea>
<DialogFooter className="flex-shrink-0">
<Button onClick={() => setDialogOpen(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,78 @@
/**
* 监控页面共享工具和钩子
*/
import { useState, useEffect, useCallback } from 'react'
import { getChatList } from '@/lib/expression-api'
import type { ChatInfo } from '@/types/expression'
/**
* 聊天名称映射 Hook
* 从表达方式 API 获取聊天列表,构建 chat_id -> chat_name 映射
*/
export function useChatNameMap() {
const [chatNameMap, setChatNameMap] = useState<Map<string, string>>(new Map())
const [loading, setLoading] = useState(true)
const loadChatNameMap = useCallback(async () => {
try {
setLoading(true)
const response = await getChatList()
if (response?.data) {
const nameMap = new Map<string, string>()
response.data.forEach((chat: ChatInfo) => {
nameMap.set(chat.chat_id, chat.chat_name)
})
setChatNameMap(nameMap)
}
} catch (error) {
console.error('加载聊天列表失败:', error)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadChatNameMap()
}, [loadChatNameMap])
const getChatName = useCallback((chatId: string): string => {
return chatNameMap.get(chatId) || chatId
}, [chatNameMap])
return { chatNameMap, getChatName, loading, reload: loadChatNameMap }
}
/**
* 格式化时间戳为本地时间字符串
*/
export function formatTimestamp(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleString('zh-CN')
}
/**
* 格式化时间戳为相对时间
*/
export function formatRelativeTime(timestamp: number): string {
const now = Date.now() / 1000
const diff = now - timestamp
if (diff < 60) return '刚刚'
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
return `${Math.floor(diff / 86400)} 天前`
}
/**
* 自动刷新 Hook
*/
export function useAutoRefresh(
enabled: boolean,
callback: () => void,
interval: number = 10000
) {
useEffect(() => {
if (!enabled) return
const timer = setInterval(callback, interval)
return () => clearInterval(timer)
}, [enabled, callback, interval])
}

View File

@@ -0,0 +1,949 @@
import { Users, Search, Edit, Trash2, Eye, User, MessageSquare, Hash, Clock, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'
import { useState, useEffect, useMemo } from 'react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useToast } from '@/hooks/use-toast'
import { Checkbox } from '@/components/ui/checkbox'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import type { PersonInfo, PersonUpdateRequest } from '@/types/person'
import { getPersonList, getPersonDetail, updatePerson, deletePerson, getPersonStats, batchDeletePersons } from '@/lib/person-api'
export function PersonManagementPage() {
const [persons, setPersons] = useState<PersonInfo[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [search, setSearch] = useState('')
const [filterKnown, setFilterKnown] = useState<boolean | undefined>(undefined)
const [filterPlatform, setFilterPlatform] = useState<string | undefined>(undefined)
const [selectedPerson, setSelectedPerson] = useState<PersonInfo | null>(null)
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [deleteConfirmPerson, setDeleteConfirmPerson] = useState<PersonInfo | null>(null)
const [stats, setStats] = useState({ total: 0, known: 0, unknown: 0, platforms: {} as Record<string, number> })
const [selectedPersons, setSelectedPersons] = useState<Set<string>>(new Set())
const [batchDeleteDialogOpen, setBatchDeleteDialogOpen] = useState(false)
const [jumpToPage, setJumpToPage] = useState('')
const { toast } = useToast()
// 加载人物列表
const loadPersons = async () => {
try {
setLoading(true)
const response = await getPersonList({
page,
page_size: pageSize,
search: search || undefined,
is_known: filterKnown,
platform: filterPlatform,
})
setPersons(response.data)
setTotal(response.total)
} catch (error) {
toast({
title: '加载失败',
description: error instanceof Error ? error.message : '无法加载人物信息',
variant: 'destructive',
})
} finally {
setLoading(false)
}
}
// 加载统计数据
const loadStats = async () => {
try {
const response = await getPersonStats()
if (response?.data) {
setStats(response.data)
}
} catch (error) {
console.error('加载统计数据失败:', error)
}
}
// 初始加载
useEffect(() => {
loadPersons()
loadStats()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, pageSize, search, filterKnown, filterPlatform])
// 查看详情
const handleViewDetail = async (person: PersonInfo) => {
try {
const response = await getPersonDetail(person.person_id)
setSelectedPerson(response.data)
setIsDetailDialogOpen(true)
} catch (error) {
toast({
title: '加载详情失败',
description: error instanceof Error ? error.message : '无法加载人物详情',
variant: 'destructive',
})
}
}
// 编辑人物
const handleEdit = (person: PersonInfo) => {
setSelectedPerson(person)
setIsEditDialogOpen(true)
}
// 删除人物
const handleDelete = async (person: PersonInfo) => {
try {
await deletePerson(person.person_id)
toast({
title: '删除成功',
description: `已删除人物信息: ${person.person_name || person.nickname || person.user_id}`,
})
setDeleteConfirmPerson(null)
loadPersons()
loadStats()
} catch (error) {
toast({
title: '删除失败',
description: error instanceof Error ? error.message : '无法删除人物信息',
variant: 'destructive',
})
}
}
// 获取平台列表
const platforms = useMemo(() => {
return Object.keys(stats.platforms)
}, [stats.platforms])
// 切换单个人物选择
const togglePersonSelection = (personId: string) => {
const newSelected = new Set(selectedPersons)
if (newSelected.has(personId)) {
newSelected.delete(personId)
} else {
newSelected.add(personId)
}
setSelectedPersons(newSelected)
}
// 全选/取消全选
const toggleSelectAll = () => {
if (selectedPersons.size === persons.length && persons.length > 0) {
setSelectedPersons(new Set())
} else {
setSelectedPersons(new Set(persons.map(p => p.person_id)))
}
}
// 打开批量删除对话框
const openBatchDeleteDialog = () => {
if (selectedPersons.size === 0) {
toast({
title: '未选择任何人物',
description: '请先选择要删除的人物',
variant: 'destructive',
})
return
}
setBatchDeleteDialogOpen(true)
}
// 批量删除确认
const handleBatchDelete = async () => {
try {
const result = await batchDeletePersons(Array.from(selectedPersons))
toast({
title: '批量删除完成',
description: result.message,
})
setSelectedPersons(new Set())
setBatchDeleteDialogOpen(false)
loadPersons()
loadStats()
} catch (error) {
toast({
title: '批量删除失败',
description: error instanceof Error ? error.message : '批量删除失败',
variant: 'destructive',
})
}
}
// 页面跳转
const handleJumpToPage = () => {
const targetPage = parseInt(jumpToPage)
const totalPages = Math.ceil(total / pageSize)
if (targetPage >= 1 && targetPage <= totalPages) {
setPage(targetPage)
setJumpToPage('')
} else {
toast({
title: '无效的页码',
description: `请输入1-${totalPages}之间的页码`,
variant: 'destructive',
})
}
}
// 格式化时间
const formatTime = (timestamp: number | null) => {
if (!timestamp) return '-'
return new Date(timestamp * 1000).toLocaleString('zh-CN')
}
return (
<div className="h-[calc(100vh-4rem)] flex flex-col p-4 sm:p-6">
{/* 页面标题 */}
<div className="mb-4 sm:mb-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2">
<Users className="h-8 w-8" strokeWidth={2} />
</h1>
<p className="text-muted-foreground mt-1 text-sm sm:text-base">
</p>
</div>
</div>
</div>
<ScrollArea className="flex-1">
<div className="space-y-4 sm:space-y-6 pr-4">
{/* 统计卡片 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="rounded-lg border bg-card p-4">
<div className="text-sm text-muted-foreground"></div>
<div className="text-2xl font-bold mt-1">{stats.total}</div>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="text-sm text-muted-foreground"></div>
<div className="text-2xl font-bold mt-1 text-green-600">{stats.known}</div>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="text-sm text-muted-foreground"></div>
<div className="text-2xl font-bold mt-1 text-muted-foreground">{stats.unknown}</div>
</div>
</div>
{/* 搜索和过滤 */}
<div className="rounded-lg border bg-card p-4">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
<div className="sm:col-span-2">
<Label htmlFor="search"></Label>
<div className="relative mt-1.5">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
id="search"
placeholder="搜索名称、昵称或用户ID..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
<div>
<Label htmlFor="filter-known"></Label>
<Select
value={filterKnown === undefined ? 'all' : filterKnown.toString()}
onValueChange={(value) => {
setFilterKnown(value === 'all' ? undefined : value === 'true')
setPage(1)
}}
>
<SelectTrigger id="filter-known" className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="true"></SelectItem>
<SelectItem value="false"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="filter-platform"></Label>
<Select
value={filterPlatform || 'all'}
onValueChange={(value) => {
setFilterPlatform(value === 'all' ? undefined : value)
setPage(1)
}}
>
<SelectTrigger id="filter-platform" className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{platforms.map((platform) => (
<SelectItem key={platform} value={platform}>
{platform} ({stats.platforms[platform]})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 批量操作工具栏 */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mt-4 pt-4 border-t">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{selectedPersons.size > 0 && (
<span> {selectedPersons.size} </span>
)}
</div>
<div className="flex items-center gap-2">
<Label htmlFor="page-size" className="text-sm whitespace-nowrap"></Label>
<Select
value={pageSize.toString()}
onValueChange={(value) => {
setPageSize(parseInt(value))
setPage(1)
setSelectedPersons(new Set())
}}
>
<SelectTrigger id="page-size" className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
{selectedPersons.size > 0 && (
<>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedPersons(new Set())}
>
</Button>
<Button
variant="destructive"
size="sm"
onClick={openBatchDeleteDialog}
>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
</>
)}
</div>
</div>
</div>
{/* 人物列表 */}
<div className="rounded-lg border bg-card">
{/* 桌面端表格视图 */}
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={persons.length > 0 && selectedPersons.size === persons.length}
onCheckedChange={toggleSelectAll}
aria-label="全选"
/>
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
...
</TableCell>
</TableRow>
) : persons.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
</TableCell>
</TableRow>
) : (
persons.map((person) => (
<TableRow key={person.id}>
<TableCell>
<Checkbox
checked={selectedPersons.has(person.person_id)}
onCheckedChange={() => togglePersonSelection(person.person_id)}
aria-label={`选择 ${person.person_name || person.nickname || person.user_id}`}
/>
</TableCell>
<TableCell>
<div className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium',
person.is_known
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400'
)}>
{person.is_known ? '已认识' : '未认识'}
</div>
</TableCell>
<TableCell className="font-medium">
{person.person_name || <span className="text-muted-foreground">-</span>}
</TableCell>
<TableCell>{person.nickname || '-'}</TableCell>
<TableCell>{person.platform}</TableCell>
<TableCell className="font-mono text-sm">{person.user_id}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatTime(person.last_know)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="default"
size="sm"
onClick={() => handleViewDetail(person)}
>
<Eye className="h-4 w-4 mr-1" />
</Button>
<Button
variant="default"
size="sm"
onClick={() => handleEdit(person)}
>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button
size="sm"
onClick={() => setDeleteConfirmPerson(person)}
className="bg-red-600 hover:bg-red-700 text-white"
>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 移动端卡片视图 */}
<div className="md:hidden space-y-3 p-4">
{loading ? (
<div className="text-center py-8 text-muted-foreground">
...
</div>
) : persons.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
</div>
) : (
persons.map((person) => (
<div key={person.id} className="rounded-lg border bg-card p-4 space-y-3 overflow-hidden">
{/* 复选框和状态 */}
<div className="flex items-start gap-3">
<Checkbox
checked={selectedPersons.has(person.person_id)}
onCheckedChange={() => togglePersonSelection(person.person_id)}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<div className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium mb-2',
person.is_known
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400'
)}>
{person.is_known ? '已认识' : '未认识'}
</div>
<h3 className="font-semibold text-sm line-clamp-1 w-full break-all">
{person.person_name || <span className="text-muted-foreground"></span>}
</h3>
{person.nickname && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-1 w-full break-all">
: {person.nickname}
</p>
)}
</div>
</div>
{/* 平台和用户信息 */}
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<div className="text-xs text-muted-foreground mb-1"></div>
<p className="font-medium text-xs">{person.platform}</p>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">ID</div>
<p className="font-mono text-xs truncate" title={person.user_id}>{person.user_id}</p>
</div>
<div className="col-span-2">
<div className="text-xs text-muted-foreground mb-1"></div>
<p className="text-xs">{formatTime(person.last_know)}</p>
</div>
</div>
{/* 操作按钮 */}
<div className="flex flex-wrap gap-1 pt-2 border-t overflow-hidden">
<Button
variant="outline"
size="sm"
onClick={() => handleViewDetail(person)}
className="text-xs px-2 py-1 h-auto flex-shrink-0"
>
<Eye className="h-3 w-3 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(person)}
className="text-xs px-2 py-1 h-auto flex-shrink-0"
>
<Edit className="h-3 w-3 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteConfirmPerson(person)}
className="text-xs px-2 py-1 h-auto flex-shrink-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3 mr-1" />
</Button>
</div>
</div>
))
)}
</div>
{/* 分页 - 增强版 */}
{total > 0 && (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-4 py-3 border-t">
<div className="text-sm text-muted-foreground">
{total} {page} / {Math.ceil(total / pageSize)}
</div>
<div className="flex items-center gap-2">
{/* 首页 */}
<Button
variant="outline"
size="sm"
onClick={() => setPage(1)}
disabled={page === 1}
className="hidden sm:flex"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
{/* 上一页 */}
<Button
variant="outline"
size="sm"
onClick={() => setPage(page - 1)}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline"></span>
</Button>
{/* 页码跳转 */}
<div className="flex items-center gap-2">
<Input
type="number"
value={jumpToPage}
onChange={(e) => setJumpToPage(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleJumpToPage()}
placeholder={page.toString()}
className="w-16 h-8 text-center"
min={1}
max={Math.ceil(total / pageSize)}
/>
<Button
variant="outline"
size="sm"
onClick={handleJumpToPage}
disabled={!jumpToPage}
className="h-8"
>
</Button>
</div>
{/* 下一页 */}
<Button
variant="outline"
size="sm"
onClick={() => setPage(page + 1)}
disabled={page >= Math.ceil(total / pageSize)}
>
<span className="hidden sm:inline"></span>
<ChevronRight className="h-4 w-4 sm:ml-1" />
</Button>
{/* 末页 */}
<Button
variant="outline"
size="sm"
onClick={() => setPage(Math.ceil(total / pageSize))}
disabled={page >= Math.ceil(total / pageSize)}
className="hidden sm:flex"
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
</div>
</ScrollArea>
{/* 详情对话框 */}
<PersonDetailDialog
person={selectedPerson}
open={isDetailDialogOpen}
onOpenChange={setIsDetailDialogOpen}
/>
{/* 编辑对话框 */}
<PersonEditDialog
person={selectedPerson}
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
onSuccess={() => {
loadPersons()
loadStats()
setIsEditDialogOpen(false)
}}
/>
{/* 删除确认对话框 */}
<AlertDialog
open={!!deleteConfirmPerson}
onOpenChange={() => setDeleteConfirmPerson(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
"{deleteConfirmPerson?.person_name || deleteConfirmPerson?.nickname || deleteConfirmPerson?.user_id}"
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteConfirmPerson && handleDelete(deleteConfirmPerson)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 批量删除确认对话框 */}
<AlertDialog open={batchDeleteDialogOpen} onOpenChange={setBatchDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{selectedPersons.size}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleBatchDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
// 人物详情对话框
function PersonDetailDialog({
person,
open,
onOpenChange,
}: {
person: PersonInfo | null
open: boolean
onOpenChange: (open: boolean) => void
}) {
if (!person) return null
const formatTime = (timestamp: number | null) => {
if (!timestamp) return '-'
return new Date(timestamp * 1000).toLocaleString('zh-CN')
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{person.person_name || person.nickname || person.user_id}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 基本信息 */}
<div className="grid grid-cols-2 gap-4">
<InfoItem icon={User} label="人物名称" value={person.person_name} />
<InfoItem icon={MessageSquare} label="昵称" value={person.nickname} />
<InfoItem icon={Hash} label="用户ID" value={person.user_id} mono />
<InfoItem icon={Hash} label="人物ID" value={person.person_id} mono />
<InfoItem label="平台" value={person.platform} />
<InfoItem label="状态" value={person.is_known ? '已认识' : '未认识'} />
</div>
{/* 名称原因 */}
{person.name_reason && (
<div className="rounded-lg border bg-muted/50 p-3">
<Label className="text-xs text-muted-foreground"></Label>
<p className="mt-1 text-sm">{person.name_reason}</p>
</div>
)}
{/* 记忆点 */}
{person.memory_points && (
<div className="rounded-lg border bg-muted/50 p-3">
<Label className="text-xs text-muted-foreground"></Label>
<p className="mt-1 text-sm whitespace-pre-wrap">{person.memory_points}</p>
</div>
)}
{/* 群昵称列表 */}
{person.group_nick_name && person.group_nick_name.length > 0 && (
<div className="rounded-lg border bg-muted/50 p-3">
<Label className="text-xs text-muted-foreground"></Label>
<div className="mt-2 space-y-1">
{person.group_nick_name.map((item, index) => (
<div key={index} className="text-sm flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">{item.group_id}</span>
<span></span>
<span>{item.group_nick_name}</span>
</div>
))}
</div>
</div>
)}
{/* 时间信息 */}
<div className="grid grid-cols-3 gap-4">
<InfoItem icon={Clock} label="认识时间" value={formatTime(person.know_times)} />
<InfoItem icon={Clock} label="首次记录" value={formatTime(person.know_since)} />
<InfoItem icon={Clock} label="最后更新" value={formatTime(person.last_know)} />
</div>
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// 信息项组件
function InfoItem({
icon: Icon,
label,
value,
mono = false,
}: {
icon?: typeof User
label: string
value: string | null | undefined
mono?: boolean
}) {
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground flex items-center gap-1">
{Icon && <Icon className="h-3 w-3" />}
{label}
</Label>
<div className={cn('text-sm', mono && 'font-mono', !value && 'text-muted-foreground')}>
{value || '-'}
</div>
</div>
)
}
// 人物编辑对话框
function PersonEditDialog({
person,
open,
onOpenChange,
onSuccess,
}: {
person: PersonInfo | null
open: boolean
onOpenChange: (open: boolean) => void
onSuccess: () => void
}) {
const [formData, setFormData] = useState<PersonUpdateRequest>({})
const [saving, setSaving] = useState(false)
const { toast } = useToast()
useEffect(() => {
if (person) {
setFormData({
person_name: person.person_name || '',
name_reason: person.name_reason || '',
nickname: person.nickname || '',
is_known: person.is_known,
})
}
}, [person])
const handleSave = async () => {
if (!person) return
try {
setSaving(true)
await updatePerson(person.person_id, formData)
toast({
title: '保存成功',
description: '人物信息已更新',
})
onSuccess()
} catch (error) {
toast({
title: '保存失败',
description: error instanceof Error ? error.message : '无法更新人物信息',
variant: 'destructive',
})
} finally {
setSaving(false)
}
}
if (!person) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{person.person_name || person.nickname || person.user_id}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="person_name"></Label>
<Input
id="person_name"
value={formData.person_name || ''}
onChange={(e) => setFormData({ ...formData, person_name: e.target.value })}
placeholder="为这个人设置一个名称"
/>
</div>
<div className="space-y-2">
<Label htmlFor="nickname"></Label>
<Input
id="nickname"
value={formData.nickname || ''}
onChange={(e) => setFormData({ ...formData, nickname: e.target.value })}
placeholder="昵称"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="name_reason"></Label>
<Textarea
id="name_reason"
value={formData.name_reason || ''}
onChange={(e) => setFormData({ ...formData, name_reason: e.target.value })}
placeholder="为什么这样称呼这个人?"
rows={2}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="is_known" className="text-base font-medium">
</Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
id="is_known"
checked={formData.is_known}
onCheckedChange={(checked) => setFormData({ ...formData, is_known: checked })}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,911 @@
import { useState, useEffect, useCallback } from 'react'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
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 { Slider } from '@/components/ui/slider'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { ListFieldEditor } from '@/components/ListFieldEditor'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { CodeEditor } from '@/components'
import { parse as parseToml } from 'smol-toml'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Settings,
Package,
AlertCircle,
CheckCircle2,
RefreshCw,
ChevronRight,
ChevronDown,
Save,
RotateCcw,
Power,
Loader2,
Search,
ArrowLeft,
Info,
Eye,
EyeOff,
RotateCw,
Code2,
Layout,
} from 'lucide-react'
import { useToast } from '@/hooks/use-toast'
import { RestartProvider, useRestart } from '@/lib/restart-context'
import { RestartOverlay } from '@/components/restart-overlay'
import {
getInstalledPlugins,
getPluginConfigSchema,
getPluginConfig,
getPluginConfigRaw,
updatePluginConfig,
updatePluginConfigRaw,
resetPluginConfig,
togglePlugin,
type InstalledPlugin,
type PluginConfigSchema,
type ConfigFieldSchema,
type ConfigSectionSchema,
} from '@/lib/plugin-api'
// 字段渲染组件
interface FieldRendererProps {
field: ConfigFieldSchema
value: unknown
onChange: (value: unknown) => void
sectionName: string
}
function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
const [showPassword, setShowPassword] = useState(false)
// 根据 ui_type 渲染不同的控件
switch (field.ui_type) {
case 'switch':
return (
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>{field.label}</Label>
{field.hint && (
<p className="text-xs text-muted-foreground">{field.hint}</p>
)}
</div>
<Switch
checked={Boolean(value)}
onCheckedChange={onChange}
disabled={field.disabled}
/>
</div>
)
case 'number':
return (
<div className="space-y-2">
<Label>{field.label}</Label>
<Input
type="number"
value={value as number ?? field.default}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
min={field.min}
max={field.max}
step={field.step ?? 1}
placeholder={field.placeholder}
disabled={field.disabled}
/>
{field.hint && (
<p className="text-xs text-muted-foreground">{field.hint}</p>
)}
</div>
)
case 'slider':
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>{field.label}</Label>
<span className="text-sm text-muted-foreground">
{value as number ?? field.default}
</span>
</div>
<Slider
value={[value as number ?? field.default as number]}
onValueChange={(v) => onChange(v[0])}
min={field.min ?? 0}
max={field.max ?? 100}
step={field.step ?? 1}
disabled={field.disabled}
/>
{field.hint && (
<p className="text-xs text-muted-foreground">{field.hint}</p>
)}
</div>
)
case 'select':
return (
<div className="space-y-2">
<Label>{field.label}</Label>
<Select
value={String(value ?? field.default)}
onValueChange={onChange}
disabled={field.disabled}
>
<SelectTrigger>
<SelectValue placeholder={field.placeholder ?? '请选择'} />
</SelectTrigger>
<SelectContent>
{field.choices?.map((choice) => (
<SelectItem key={String(choice)} value={String(choice)}>
{String(choice)}
</SelectItem>
))}
</SelectContent>
</Select>
{field.hint && (
<p className="text-xs text-muted-foreground">{field.hint}</p>
)}
</div>
)
case 'textarea':
return (
<div className="space-y-2">
<Label>{field.label}</Label>
<Textarea
value={value as string ?? field.default}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
rows={field.rows ?? 3}
disabled={field.disabled}
/>
{field.hint && (
<p className="text-xs text-muted-foreground">{field.hint}</p>
)}
</div>
)
case 'password':
return (
<div className="space-y-2">
<Label>{field.label}</Label>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
value={value as string ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={field.disabled}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
{field.hint && (
<p className="text-xs text-muted-foreground">{field.hint}</p>
)}
</div>
)
case 'list':
return (
<div className="space-y-2">
<Label>{field.label}</Label>
<ListFieldEditor
value={Array.isArray(value) ? value : []}
onChange={(newValue) => onChange(newValue)}
itemType={field.item_type ?? 'string'}
itemFields={field.item_fields}
minItems={field.min_items}
maxItems={field.max_items}
disabled={field.disabled}
placeholder={field.placeholder}
/>
{field.hint && (
<p className="text-xs text-muted-foreground">{field.hint}</p>
)}
</div>
)
case 'text':
default:
return (
<div className="space-y-2">
<Label>{field.label}</Label>
<Input
type="text"
value={value as string ?? field.default ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
maxLength={field.max_length}
disabled={field.disabled}
/>
{field.hint && (
<p className="text-xs text-muted-foreground">{field.hint}</p>
)}
</div>
)
}
}
// Section 渲染组件
interface SectionRendererProps {
section: ConfigSectionSchema
config: Record<string, unknown>
onChange: (sectionName: string, fieldName: string, value: unknown) => void
}
function SectionRenderer({ section, config, onChange }: SectionRendererProps) {
const [isOpen, setIsOpen] = useState(!section.collapsed)
// 按 order 排序字段
const sortedFields = Object.entries(section.fields)
.filter(([, field]) => !field.hidden)
.sort(([, a], [, b]) => a.order - b.order)
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<CardTitle className="text-lg">{section.title}</CardTitle>
</div>
<Badge variant="secondary" className="text-xs">
{sortedFields.length}
</Badge>
</div>
{section.description && (
<CardDescription className="ml-6">
{section.description}
</CardDescription>
)}
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4 pt-0">
{sortedFields.map(([fieldName, field]) => (
<FieldRenderer
key={fieldName}
field={field}
value={(config[section.name] as Record<string, unknown>)?.[fieldName]}
onChange={(value) => onChange(section.name, fieldName, value)}
sectionName={section.name}
/>
))}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)
}
// 插件配置编辑器
interface PluginConfigEditorProps {
plugin: InstalledPlugin
onBack: () => void
}
function PluginConfigEditor({ plugin, onBack }: PluginConfigEditorProps) {
const { toast } = useToast()
const { triggerRestart, isRestarting } = useRestart()
const [editMode, setEditMode] = useState<'visual' | 'source'>('visual')
const [schema, setSchema] = useState<PluginConfigSchema | null>(null)
const [config, setConfig] = useState<Record<string, unknown>>({})
const [originalConfig, setOriginalConfig] = useState<Record<string, unknown>>({})
const [sourceCode, setSourceCode] = useState('')
const [originalSourceCode, setOriginalSourceCode] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [hasChanges, setHasChanges] = useState(false)
const [hasTomlError, setHasTomlError] = useState(false)
const [resetDialogOpen, setResetDialogOpen] = useState(false)
// 加载配置
const loadConfig = useCallback(async () => {
setLoading(true)
try {
const [schemaData, configData, rawConfigData] = await Promise.all([
getPluginConfigSchema(plugin.id),
getPluginConfig(plugin.id),
getPluginConfigRaw(plugin.id)
])
setSchema(schemaData)
setConfig(configData)
setOriginalConfig(JSON.parse(JSON.stringify(configData)))
setSourceCode(rawConfigData)
setOriginalSourceCode(rawConfigData)
} catch (error) {
toast({
title: '加载配置失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
})
} finally {
setLoading(false)
}
}, [plugin.id, toast])
useEffect(() => {
loadConfig()
}, [loadConfig])
// 检测配置变化
useEffect(() => {
if (editMode === 'visual') {
setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig))
} else {
setHasChanges(sourceCode !== originalSourceCode)
}
}, [config, originalConfig, sourceCode, originalSourceCode, editMode])
// 处理字段变化
const handleFieldChange = (sectionName: string, fieldName: string, value: unknown) => {
setConfig(prev => ({
...prev,
[sectionName]: {
...(prev[sectionName] as Record<string, unknown> || {}),
[fieldName]: value
}
}))
}
// 保存配置
const handleSave = async () => {
setSaving(true)
try {
if (editMode === 'source') {
// 源代码模式:先验证 TOML 格式
try {
parseToml(sourceCode)
} catch (error) {
setHasTomlError(true)
toast({
title: 'TOML 格式错误',
description: error instanceof Error ? error.message : '无法解析 TOML 配置,请检查语法',
variant: 'destructive'
})
setSaving(false)
return
}
// 格式正确,保存原始配置
await updatePluginConfigRaw(plugin.id, sourceCode)
setOriginalSourceCode(sourceCode)
setHasTomlError(false)
} else {
// 可视化模式
await updatePluginConfig(plugin.id, config)
setOriginalConfig(JSON.parse(JSON.stringify(config)))
}
toast({
title: '配置已保存',
description: '更改将在插件重新加载后生效'
})
} catch (error) {
toast({
title: '保存失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
})
} finally {
setSaving(false)
}
}
// 重置配置
const handleReset = async () => {
try {
await resetPluginConfig(plugin.id)
toast({
title: '配置已重置',
description: '下次加载插件时将使用默认配置'
})
setResetDialogOpen(false)
loadConfig()
} catch (error) {
toast({
title: '重置失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
})
}
}
// 切换启用状态
const handleToggle = async () => {
try {
const result = await togglePlugin(plugin.id)
toast({
title: result.message,
description: result.note
})
loadConfig()
} catch (error) {
toast({
title: '切换状态失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
})
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (!schema) {
return (
<div className="flex flex-col items-center justify-center h-64 space-y-4">
<AlertCircle className="h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground"></p>
<Button onClick={onBack} variant="outline">
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
</div>
)
}
// 按 order 排序 sections
const sortedSections = Object.values(schema.sections)
.sort((a, b) => a.order - b.order)
// 获取当前启用状态
const isEnabled = (config.plugin as Record<string, unknown>)?.enabled !== false
return (
<div className="space-y-4 sm:space-y-6">
{/* 头部 */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex items-start gap-3">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-2xl sm:text-3xl font-bold">
{schema.plugin_info.name || plugin.manifest.name}
</h1>
<div className="flex items-center gap-2 mt-1">
<Badge variant={isEnabled ? 'default' : 'secondary'}>
{isEnabled ? '已启用' : '已禁用'}
</Badge>
<span className="text-sm text-muted-foreground">
v{schema.plugin_info.version || plugin.manifest.version}
</span>
</div>
</div>
</div>
<div className="flex gap-2 ml-10 sm:ml-0">
<Button
variant="outline"
size="sm"
onClick={() => setEditMode(editMode === 'visual' ? 'source' : 'visual')}
>
{editMode === 'visual' ? (
<>
<Code2 className="h-4 w-4 mr-2" />
</>
) : (
<>
<Layout className="h-4 w-4 mr-2" />
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => triggerRestart()}
disabled={isRestarting}
>
<RotateCw className={`h-4 w-4 mr-2 ${isRestarting ? 'animate-spin' : ''}`} />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleToggle}
>
<Power className="h-4 w-4 mr-2" />
{isEnabled ? '禁用' : '启用'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setResetDialogOpen(true)}
>
<RotateCcw className="h-4 w-4 mr-2" />
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || saving}
>
{saving ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
</Button>
</div>
</div>
{/* 未保存提示 */}
{hasChanges && (
<Card className="border-orange-200 bg-orange-50 dark:bg-orange-950/20 dark:border-orange-900">
<CardContent className="py-3">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-orange-600" />
<p className="text-sm text-orange-800 dark:text-orange-200">
</p>
</div>
</CardContent>
</Card>
)}
{/* 源代码模式 */}
{editMode === 'source' && (
<div className="space-y-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<strong></strong> TOML
{hasTomlError && (
<span className="text-destructive font-semibold ml-2"> TOML </span>
)}
</AlertDescription>
</Alert>
<CodeEditor
value={sourceCode}
onChange={(value) => {
setSourceCode(value)
if (hasTomlError) {
setHasTomlError(false)
}
}}
language="toml"
theme="dark"
height="calc(100vh - 350px)"
minHeight="500px"
placeholder="TOML 配置内容"
/>
</div>
)}
{/* 可视化模式 */}
{editMode === 'visual' && (
<>
{/* 插件未加载提示 */}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<strong></strong>WebUI
</AlertDescription>
</Alert>
{/* 配置区域 */}
{schema.layout.type === 'tabs' && schema.layout.tabs.length > 0 ? (
// 标签页布局
<Tabs defaultValue={schema.layout.tabs[0]?.id}>
<TabsList>
{schema.layout.tabs.map(tab => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.title}
{tab.badge && (
<Badge variant="secondary" className="ml-2 text-xs">
{tab.badge}
</Badge>
)}
</TabsTrigger>
))}
</TabsList>
{schema.layout.tabs.map(tab => (
<TabsContent key={tab.id} value={tab.id} className="space-y-4 mt-4">
{tab.sections.map(sectionName => {
const section = schema.sections[sectionName]
if (!section) return null
return (
<SectionRenderer
key={sectionName}
section={section}
config={config}
onChange={handleFieldChange}
/>
)
})}
</TabsContent>
))}
</Tabs>
) : (
// 自动布局
<div className="space-y-4">
{sortedSections.map(section => (
<SectionRenderer
key={section.name}
section={section}
config={config}
onChange={handleFieldChange}
/>
))}
</div>
)}
</>
)}
{/* 重置确认对话框 */}
<Dialog open={resetDialogOpen} onOpenChange={setResetDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
使
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setResetDialogOpen(false)}>
</Button>
<Button variant="destructive" onClick={handleReset}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// 主页面组件 - 包装 RestartProvider
export function PluginConfigPage() {
return (
<RestartProvider>
<PluginConfigPageContent />
</RestartProvider>
)
}
// 内部组件:实际内容
function PluginConfigPageContent() {
const { toast } = useToast()
const [plugins, setPlugins] = useState<InstalledPlugin[]>([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [selectedPlugin, setSelectedPlugin] = useState<InstalledPlugin | null>(null)
// 加载插件列表
const loadPlugins = async () => {
setLoading(true)
try {
const data = await getInstalledPlugins()
setPlugins(data)
} catch (error) {
toast({
title: '加载插件列表失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
})
} finally {
setLoading(false)
}
}
useEffect(() => {
loadPlugins()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 过滤插件
const filteredPlugins = plugins.filter(plugin => {
const query = searchQuery.toLowerCase()
return (
plugin.id.toLowerCase().includes(query) ||
plugin.manifest.name.toLowerCase().includes(query) ||
plugin.manifest.description?.toLowerCase().includes(query)
)
})
// 去重:如果有重复的 plugin.id只保留第一个
const uniqueFilteredPlugins = filteredPlugins.filter((plugin, index, self) =>
index === self.findIndex((p) => p.id === plugin.id)
)
// 统计数据
const enabledCount = plugins.length // 暂时假设都启用
const disabledCount = 0
// 如果选中了插件,显示配置编辑器
if (selectedPlugin) {
return (
<>
<ScrollArea className="h-full">
<div className="p-4 sm:p-6">
<PluginConfigEditor
plugin={selectedPlugin}
onBack={() => setSelectedPlugin(null)}
/>
</div>
</ScrollArea>
<RestartOverlay />
</>
)
}
return (
<ScrollArea className="h-full">
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
{/* 标题 */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold"></h1>
<p className="text-muted-foreground mt-1 sm:mt-2 text-sm sm:text-base">
</p>
</div>
<Button variant="outline" size="sm" onClick={loadPlugins}>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* 统计卡片 */}
<div className="grid gap-4 grid-cols-1 xs:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Package className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{plugins.length}</div>
<p className="text-xs text-muted-foreground mt-1">
{loading ? '正在加载...' : '个插件'}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{enabledCount}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<AlertCircle className="h-4 w-4 text-orange-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{disabledCount}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
</div>
{/* 搜索框 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索插件..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* 插件列表 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : uniqueFilteredPlugins.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 space-y-4">
<Package className="h-16 w-16 text-muted-foreground/50" />
<div className="text-center space-y-2">
<p className="text-lg font-medium text-muted-foreground">
{searchQuery ? '没有找到匹配的插件' : '暂无已安装的插件'}
</p>
<p className="text-sm text-muted-foreground">
{searchQuery ? '尝试其他搜索关键词' : '前往插件市场安装插件'}
</p>
</div>
</div>
) : (
<div className="space-y-2">
{uniqueFilteredPlugins.map(plugin => (
<div
key={plugin.id}
className="flex items-center justify-between p-4 rounded-lg border hover:bg-muted/50 cursor-pointer transition-colors"
onClick={() => setSelectedPlugin(plugin)}
>
<div className="flex items-center gap-3 min-w-0">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Package className="h-5 w-5 text-primary" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">
{plugin.manifest.name}
</h3>
<Badge variant="secondary" className="text-xs flex-shrink-0">
v{plugin.manifest.version}
</Badge>
</div>
<p className="text-sm text-muted-foreground truncate">
{plugin.manifest.description || '暂无描述'}
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button variant="ghost" size="sm">
<Settings className="h-4 w-4" />
</Button>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</ScrollArea>
)
}

View File

@@ -0,0 +1,674 @@
import { useState, useEffect } from 'react'
import { useNavigate, useSearch } from '@tanstack/react-router'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
ArrowLeft,
Download,
ExternalLink,
CheckCircle2,
AlertCircle,
Loader2,
Trash2,
RefreshCw,
User,
Package,
Shield,
Globe,
Tag,
GitBranch,
Info,
} from 'lucide-react'
import { useToast } from '@/hooks/use-toast'
import { fetchWithAuth } from '@/lib/fetch-with-auth'
import type { PluginInfo } from '@/types/plugin'
import {
checkGitStatus,
getMaimaiVersion,
isPluginCompatible,
installPlugin,
uninstallPlugin,
updatePlugin,
checkPluginInstalled,
getInstalledPluginVersion,
getInstalledPlugins,
type GitStatus,
type MaimaiVersion,
} from '@/lib/plugin-api'
import { PluginStats } from '@/components/plugin-stats'
import { MarkdownRenderer } from '@/components'
import { recordPluginDownload } from '@/lib/plugin-stats'
// 分类名称映射
const CATEGORY_NAMES: Record<string, string> = {
'Group Management': '群组管理',
'Entertainment & Interaction': '娱乐互动',
'Utility Tools': '实用工具',
'Content Generation': '内容生成',
Multimedia: '多媒体',
'External Integration': '外部集成',
'Data Analysis & Insights': '数据分析与洞察',
Other: '其他',
}
export function PluginDetailPage() {
const navigate = useNavigate()
const search = useSearch({ strict: false }) as { pluginId?: string }
const { toast } = useToast()
const [plugin, setPlugin] = useState<PluginInfo | null>(null)
const [readme, setReadme] = useState<string>('')
const [loading, setLoading] = useState(true)
const [readmeLoading, setReadmeLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null)
const [maimaiVersion, setMaimaiVersion] = useState<MaimaiVersion | null>(null)
const [isInstalled, setIsInstalled] = useState(false)
const [installedVersion, setInstalledVersion] = useState<string | undefined>()
const [operating, setOperating] = useState(false)
// 加载插件信息
useEffect(() => {
const loadPluginInfo = async () => {
if (!search.pluginId) {
setError('缺少插件 ID')
setLoading(false)
return
}
try {
setLoading(true)
setError(null)
// 从插件列表 API 获取数据
const response = await fetchWithAuth('/api/webui/plugins/fetch-raw', {
method: 'POST',
body: JSON.stringify({
owner: 'Mai-with-u',
repo: 'plugin-repo',
branch: 'main',
file_path: 'plugin_details.json',
}),
})
if (!response.ok) {
throw new Error('获取插件列表失败')
}
const result = await response.json()
if (!result.success || !result.data) {
throw new Error(result.error || '获取插件列表失败')
}
const pluginList = JSON.parse(result.data)
const foundPlugin = pluginList.find((p: any) => p.id === search.pluginId)
if (!foundPlugin) {
throw new Error('未找到该插件')
}
// 转换为 PluginInfo 格式
const pluginInfo: PluginInfo = {
id: foundPlugin.id,
manifest: foundPlugin.manifest,
downloads: 0,
rating: 0,
review_count: 0,
installed: false,
published_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
setPlugin(pluginInfo)
// 加载额外信息
const [gitStatusResult, versionResult, installedPlugins] = await Promise.all([
checkGitStatus(),
getMaimaiVersion(),
getInstalledPlugins(),
])
setGitStatus(gitStatusResult)
setMaimaiVersion(versionResult)
setIsInstalled(checkPluginInstalled(search.pluginId, installedPlugins))
setInstalledVersion(getInstalledPluginVersion(search.pluginId, installedPlugins))
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setLoading(false)
}
}
loadPluginInfo()
}, [search.pluginId])
// 加载 README
useEffect(() => {
const loadReadme = async () => {
if (!plugin?.manifest?.repository_url) {
setReadmeLoading(false)
return
}
try {
setReadmeLoading(true)
// 如果插件已安装,优先尝试从本地读取 README
if (isInstalled && search.pluginId) {
try {
const localResponse = await fetchWithAuth(`/api/webui/plugins/local-readme/${search.pluginId}`)
if (localResponse.ok) {
const localResult = await localResponse.json()
if (localResult.success && localResult.data) {
setReadme(localResult.data)
setReadmeLoading(false)
return // 成功获取本地 README直接返回
}
}
} catch (err) {
console.log('本地 README 获取失败,尝试远程获取:', err)
// 继续执行远程获取逻辑
}
}
// 从 repository_url 解析仓库信息
// 格式: https://github.com/owner/repo
const match = plugin.manifest.repository_url.match(/github\.com\/([^/]+)\/([^/\s]+)/)
if (!match) {
setReadme('无法解析仓库地址')
return
}
const [, owner, repo] = match
const cleanRepo = repo.replace(/\.git$/, '')
// 使用后端代理获取 README.md
const response = await fetchWithAuth('/api/webui/plugins/fetch-raw', {
method: 'POST',
body: JSON.stringify({
owner,
repo: cleanRepo,
branch: 'main',
file_path: 'README.md',
}),
})
if (!response.ok) {
throw new Error('获取 README 失败')
}
const result = await response.json()
if (result.success && result.data) {
setReadme(result.data)
} else {
setReadme('该插件暂无 README 文档')
}
} catch (err) {
console.error('加载 README 失败:', err)
setReadme('加载 README 失败')
} finally {
setReadmeLoading(false)
}
}
loadReadme()
}, [plugin, isInstalled, search.pluginId])
// 检查是否需要更新
const needsUpdate = () => {
if (!plugin || !isInstalled || !installedVersion) return false
return installedVersion !== plugin.manifest.version
}
// 检查兼容性
const checkCompatibility = () => {
if (!plugin || !maimaiVersion) return true
return isPluginCompatible(
plugin.manifest.host_application.min_version,
plugin.manifest.host_application.max_version,
maimaiVersion
)
}
// 安装插件
const handleInstall = async () => {
if (!plugin || !gitStatus?.installed) return
try {
setOperating(true)
await installPlugin(plugin.id, plugin.manifest.repository_url || '', 'main')
// 记录下载统计
recordPluginDownload(plugin.id).catch((err) => {
console.warn('Failed to record download:', err)
})
toast({
title: '安装成功',
description: `${plugin.manifest.name} 已成功安装`,
})
// 重新加载安装状态
const installedPlugins = await getInstalledPlugins()
setIsInstalled(checkPluginInstalled(plugin.id, installedPlugins))
setInstalledVersion(getInstalledPluginVersion(plugin.id, installedPlugins))
} catch (error) {
toast({
title: '安装失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
} finally {
setOperating(false)
}
}
// 卸载插件
const handleUninstall = async () => {
if (!plugin) return
try {
setOperating(true)
await uninstallPlugin(plugin.id)
toast({
title: '卸载成功',
description: `${plugin.manifest.name} 已成功卸载`,
})
// 重新加载安装状态
const installedPlugins = await getInstalledPlugins()
setIsInstalled(checkPluginInstalled(plugin.id, installedPlugins))
setInstalledVersion(getInstalledPluginVersion(plugin.id, installedPlugins))
} catch (error) {
toast({
title: '卸载失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
} finally {
setOperating(false)
}
}
// 更新插件
const handleUpdate = async () => {
if (!plugin || !gitStatus?.installed) return
try {
setOperating(true)
const result = await updatePlugin(plugin.id, plugin.manifest.repository_url || '', 'main')
toast({
title: '更新成功',
description: `${plugin.manifest.name} 已从 ${result.old_version} 更新到 ${result.new_version}`,
})
// 重新加载安装状态
const installedPlugins = await getInstalledPlugins()
setIsInstalled(checkPluginInstalled(plugin.id, installedPlugins))
setInstalledVersion(getInstalledPluginVersion(plugin.id, installedPlugins))
} catch (error) {
toast({
title: '更新失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
} finally {
setOperating(false)
}
}
if (loading) {
return (
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate({ to: '/plugins' })}
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-2xl sm:text-3xl font-bold"></h1>
</div>
</div>
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="ml-3 text-muted-foreground">...</span>
</div>
</div>
)
}
if (error || !plugin) {
return (
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate({ to: '/plugins' })}
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-2xl sm:text-3xl font-bold"></h1>
</div>
</div>
<Card className="p-6">
<div className="flex flex-col items-center justify-center py-8 text-center">
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-sm text-muted-foreground mb-4">{error}</p>
<Button onClick={() => navigate({ to: '/plugins' })}></Button>
</div>
</Card>
</div>
)
}
const isCompatible = checkCompatibility()
return (
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
{/* 页面标题和返回按钮 */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate({ to: '/plugins' })}
className="shrink-0"
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-2xl sm:text-3xl font-bold"></h1>
<p className="text-muted-foreground mt-1 sm:mt-2 text-sm sm:text-base">
{plugin.manifest.name}
</p>
</div>
</div>
{/* 操作按钮 */}
<div className="flex flex-wrap gap-2">
{isInstalled ? (
<>
{needsUpdate() ? (
<Button
disabled={!gitStatus?.installed || operating}
onClick={handleUpdate}
title={!gitStatus?.installed ? 'Git 未安装' : undefined}
>
{operating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
...
</>
) : (
<>
<RefreshCw className="h-4 w-4 mr-2" />
</>
)}
</Button>
) : null}
<Button
variant="destructive"
disabled={!gitStatus?.installed || operating}
onClick={handleUninstall}
title={!gitStatus?.installed ? 'Git 未安装' : undefined}
>
{operating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Trash2 className="h-4 w-4 mr-2" />
</>
)}
</Button>
</>
) : (
<Button
disabled={!gitStatus?.installed || !isCompatible || operating}
onClick={handleInstall}
title={
!gitStatus?.installed
? 'Git 未安装'
: !isCompatible
? `不兼容当前版本 (需要 ${plugin.manifest.host_application.min_version}${plugin.manifest.host_application.max_version ? ` - ${plugin.manifest.host_application.max_version}` : '+'},当前 ${maimaiVersion?.version})`
: undefined
}
>
{operating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
</>
)}
</Button>
)}
</div>
</div>
<ScrollArea className="h-[calc(100vh-200px)] sm:h-[calc(100vh-220px)]">
<div className="space-y-6 pr-4">
{/* 插件头部信息卡片 */}
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-3 flex-wrap">
<CardTitle className="text-2xl">{plugin.manifest.name}</CardTitle>
<Badge variant="secondary" className="text-sm">
v{plugin.manifest.version}
</Badge>
{isInstalled && (
<Badge variant="default" className="text-sm">
<CheckCircle2 className="h-3 w-3 mr-1" />
{installedVersion && `(v${installedVersion})`}
</Badge>
)}
{needsUpdate() && (
<Badge variant="outline" className="text-sm border-orange-500 text-orange-500">
<RefreshCw className="h-3 w-3 mr-1" />
</Badge>
)}
{!isCompatible && (
<Badge variant="destructive" className="text-sm">
<AlertCircle className="h-3 w-3 mr-1" />
</Badge>
)}
</div>
<CardDescription className="text-base">{plugin.manifest.description}</CardDescription>
</div>
</div>
</CardHeader>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧 - 详细信息 */}
<div className="lg:col-span-1 space-y-6">
{/* 统计信息 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<PluginStats pluginId={plugin.id} />
</CardContent>
</Card>
{/* 基本信息 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm">
<User className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">:</span>
<span className="font-medium">{plugin.manifest.author?.name || 'Unknown'}</span>
{plugin.manifest.author?.url && (
<a
href={plugin.manifest.author.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
<div className="flex items-center gap-2 text-sm">
<Package className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">:</span>
<span className="font-medium">v{plugin.manifest.version}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Shield className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">:</span>
<span className="font-medium">{plugin.manifest.license}</span>
</div>
{plugin.manifest.homepage_url && (
<div className="flex items-center gap-2 text-sm">
<Globe className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">:</span>
<a
href={plugin.manifest.homepage_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline flex items-center gap-1"
>
访
<ExternalLink className="h-3 w-3" />
</a>
</div>
)}
{plugin.manifest.repository_url && (
<div className="flex items-center gap-2 text-sm">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">:</span>
<a
href={plugin.manifest.repository_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline flex items-center gap-1"
>
GitHub
<ExternalLink className="h-3 w-3" />
</a>
</div>
)}
<div className="pt-2 border-t">
<div className="flex items-center gap-2 text-sm mb-2">
<Info className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">:</span>
</div>
<div className="text-sm pl-6 font-medium">
{plugin.manifest.host_application.min_version}
{plugin.manifest.host_application.max_version
? ` - ${plugin.manifest.host_application.max_version}`
: ' - 最新版本'}
</div>
</div>
</div>
</CardContent>
</Card>
{/* 分类和标签 */}
{(plugin.manifest.categories || plugin.manifest.keywords) && (
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{plugin.manifest.categories && plugin.manifest.categories.length > 0 && (
<div>
<p className="text-sm text-muted-foreground mb-2"></p>
<div className="flex flex-wrap gap-2">
{plugin.manifest.categories.map((category) => (
<Badge key={category} variant="secondary">
{CATEGORY_NAMES[category] || category}
</Badge>
))}
</div>
</div>
)}
{plugin.manifest.keywords && plugin.manifest.keywords.length > 0 && (
<div>
<p className="text-sm text-muted-foreground mb-2"></p>
<div className="flex flex-wrap gap-2">
{plugin.manifest.keywords.map((keyword) => (
<Badge key={keyword} variant="outline" className="text-xs">
<Tag className="h-3 w-3 mr-1" />
{keyword}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
{/* 右侧 - README */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[600px] pr-4">
{readmeLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-3 text-sm text-muted-foreground">...</span>
</div>
) : readme ? (
<MarkdownRenderer content={readme} />
) : (
<div className="text-center text-muted-foreground py-12">
</div>
)}
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,603 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { fetchWithAuth } from '@/lib/fetch-with-auth'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ArrowLeft, Plus, Pencil, Trash2, Loader2, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react'
import { useToast } from '@/hooks/use-toast'
interface MirrorConfig {
id: string
name: string
raw_prefix: string
clone_prefix: string
enabled: boolean
priority: number
created_at?: string
updated_at?: string
}
export function PluginMirrorsPage() {
const navigate = useNavigate()
const { toast } = useToast()
const [mirrors, setMirrors] = useState<MirrorConfig[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [editingMirror, setEditingMirror] = useState<MirrorConfig | null>(null)
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
// 表单状态
const [formData, setFormData] = useState({
id: '',
name: '',
raw_prefix: '',
clone_prefix: '',
enabled: true,
priority: 1
})
// 加载镜像源列表
const loadMirrors = useCallback(async () => {
try {
setLoading(true)
setError(null)
const response = await fetchWithAuth('/api/webui/plugins/mirrors')
if (!response.ok) {
throw new Error('获取镜像源列表失败')
}
const data = await response.json()
setMirrors(data.mirrors || [])
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '加载镜像源失败'
setError(errorMessage)
toast({
title: '加载失败',
description: errorMessage,
variant: 'destructive',
})
} finally {
setLoading(false)
}
}, [toast])
useEffect(() => {
loadMirrors()
}, [loadMirrors])
// 添加镜像源
const handleAddMirror = async () => {
try {
const response = await fetchWithAuth('/api/webui/plugins/mirrors', {
method: 'POST',
body: JSON.stringify(formData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.detail || '添加镜像源失败')
}
toast({
title: '添加成功',
description: '镜像源已添加'
})
setIsAddDialogOpen(false)
setFormData({
id: '',
name: '',
raw_prefix: '',
clone_prefix: '',
enabled: true,
priority: 1
})
loadMirrors()
} catch (err) {
toast({
title: '添加失败',
description: err instanceof Error ? err.message : '未知错误',
variant: 'destructive'
})
}
}
// 更新镜像源
const handleUpdateMirror = async () => {
if (!editingMirror) return
try {
const response = await fetchWithAuth(`/api/webui/plugins/mirrors/${editingMirror.id}`, {
method: 'PUT',
body: JSON.stringify({
name: formData.name,
raw_prefix: formData.raw_prefix,
clone_prefix: formData.clone_prefix,
enabled: formData.enabled,
priority: formData.priority
})
})
if (!response.ok) {
throw new Error('更新镜像源失败')
}
toast({
title: '更新成功',
description: '镜像源已更新'
})
setIsEditDialogOpen(false)
setEditingMirror(null)
loadMirrors()
} catch (err) {
toast({
title: '更新失败',
description: err instanceof Error ? err.message : '未知错误',
variant: 'destructive'
})
}
}
// 删除镜像源
const handleDeleteMirror = async (id: string) => {
if (!confirm('确定要删除这个镜像源吗?')) return
try {
const response = await fetchWithAuth(`/api/webui/plugins/mirrors/${id}`, {
method: 'DELETE'
})
if (!response.ok) {
throw new Error('删除镜像源失败')
}
toast({
title: '删除成功',
description: '镜像源已删除'
})
loadMirrors()
} catch (err) {
toast({
title: '删除失败',
description: err instanceof Error ? err.message : '未知错误',
variant: 'destructive'
})
}
}
// 切换启用状态
const handleToggleEnabled = async (mirror: MirrorConfig) => {
try {
const response = await fetchWithAuth(`/api/webui/plugins/mirrors/${mirror.id}`, {
method: 'PUT',
body: JSON.stringify({
enabled: !mirror.enabled
})
})
if (!response.ok) {
throw new Error('更新状态失败')
}
loadMirrors()
} catch (err) {
toast({
title: '更新失败',
description: err instanceof Error ? err.message : '未知错误',
variant: 'destructive'
})
}
}
// 打开编辑对话框
const openEditDialog = (mirror: MirrorConfig) => {
setEditingMirror(mirror)
setFormData({
id: mirror.id,
name: mirror.name,
raw_prefix: mirror.raw_prefix,
clone_prefix: mirror.clone_prefix,
enabled: mirror.enabled,
priority: mirror.priority
})
setIsEditDialogOpen(true)
}
// 调整优先级
const adjustPriority = async (mirror: MirrorConfig, direction: 'up' | 'down') => {
const newPriority = direction === 'up' ? mirror.priority - 1 : mirror.priority + 1
if (newPriority < 1) return
try {
const response = await fetchWithAuth(`/api/webui/plugins/mirrors/${mirror.id}`, {
method: 'PUT',
body: JSON.stringify({
priority: newPriority
})
})
if (!response.ok) {
throw new Error('更新优先级失败')
}
loadMirrors()
} catch (err) {
toast({
title: '更新失败',
description: err instanceof Error ? err.message : '未知错误',
variant: 'destructive'
})
}
}
return (
<ScrollArea className="h-full">
<div className="space-y-6 p-4 sm:p-6">
{/* 标题栏 */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => navigate({ to: '/plugins' })}
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-2xl sm:text-3xl font-bold"></h1>
<p className="text-sm text-muted-foreground mt-1">
Git
</p>
</div>
</div>
<Button onClick={() => setIsAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
{/* 加载状态 */}
{loading ? (
<Card className="p-6">
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
</Card>
) : error ? (
<Card className="p-6">
<div className="flex flex-col items-center justify-center py-8 text-center">
<AlertTriangle className="h-12 w-12 text-destructive mb-4" />
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-sm text-muted-foreground mb-4">{error}</p>
<Button onClick={loadMirrors}></Button>
</div>
</Card>
) : (
<Card>
{/* 桌面端表格 */}
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mirrors.map((mirror) => (
<TableRow key={mirror.id}>
<TableCell>
<Switch
checked={mirror.enabled}
onCheckedChange={() => handleToggleEnabled(mirror)}
/>
</TableCell>
<TableCell>
<div>
<div className="font-medium">{mirror.name}</div>
<div className="text-xs text-muted-foreground mt-1">
Raw: {mirror.raw_prefix}
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{mirror.id}</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-mono">{mirror.priority}</span>
<div className="flex flex-col gap-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => adjustPriority(mirror, 'up')}
disabled={mirror.priority === 1}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => adjustPriority(mirror, 'down')}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => openEditDialog(mirror)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteMirror(mirror.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 移动端卡片 */}
<div className="md:hidden p-4 space-y-4">
{mirrors.map((mirror) => (
<Card key={mirror.id} className="p-4">
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{mirror.name}</h3>
{mirror.enabled && (
<Badge variant="default" className="text-xs"></Badge>
)}
</div>
<Badge variant="outline" className="mt-1 text-xs">{mirror.id}</Badge>
</div>
<Switch
checked={mirror.enabled}
onCheckedChange={() => handleToggleEnabled(mirror)}
/>
</div>
<div className="text-sm space-y-1">
<div className="text-muted-foreground">
<span className="font-medium">Raw: </span>
<span className="break-all">{mirror.raw_prefix}</span>
</div>
<div className="text-muted-foreground">
<span className="font-medium">: </span>
<span className="font-mono">{mirror.priority}</span>
</div>
</div>
<div className="flex items-center gap-2 pt-2 border-t">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => openEditDialog(mirror)}
>
<Pencil className="h-4 w-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => adjustPriority(mirror, 'up')}
disabled={mirror.priority === 1}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => adjustPriority(mirror, 'down')}
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteMirror(mirror.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</Card>
))}
</div>
</Card>
)}
{/* 添加镜像源对话框 */}
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
Git
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="add-id"> ID *</Label>
<Input
id="add-id"
placeholder="例如: my-mirror"
value={formData.id}
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="add-name"> *</Label>
<Input
id="add-name"
placeholder="例如: 我的镜像源"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="add-raw">Raw *</Label>
<Input
id="add-raw"
placeholder="https://example.com/raw"
value={formData.raw_prefix}
onChange={(e) => setFormData({ ...formData, raw_prefix: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="add-clone"> *</Label>
<Input
id="add-clone"
placeholder="https://example.com/clone"
value={formData.clone_prefix}
onChange={(e) => setFormData({ ...formData, clone_prefix: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="add-priority"></Label>
<Input
id="add-priority"
type="number"
min="1"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 1 })}
/>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="flex items-center space-x-2">
<Switch
id="add-enabled"
checked={formData.enabled}
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
/>
<Label htmlFor="add-enabled"></Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>
</Button>
<Button onClick={handleAddMirror}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 编辑镜像源对话框 */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label> ID</Label>
<Input value={formData.id} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="edit-name"> *</Label>
<Input
id="edit-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-raw">Raw *</Label>
<Input
id="edit-raw"
value={formData.raw_prefix}
onChange={(e) => setFormData({ ...formData, raw_prefix: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-clone"> *</Label>
<Input
id="edit-clone"
value={formData.clone_prefix}
onChange={(e) => setFormData({ ...formData, clone_prefix: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-priority"></Label>
<Input
id="edit-priority"
type="number"
min="1"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 1 })}
/>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="flex items-center space-x-2">
<Switch
id="edit-enabled"
checked={formData.enabled}
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
/>
<Label htmlFor="edit-enabled"></Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
</Button>
<Button onClick={handleUpdateMirror}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</ScrollArea>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
import { Database } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
export function KnowledgeBasePage() {
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex-none border-b bg-card/50 px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-1 text-sm text-muted-foreground">
</p>
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1 overflow-auto p-6">
<div className="mx-auto max-w-4xl">
<Card>
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-primary/10">
<Database className="h-10 w-10 text-primary" />
</div>
<CardTitle className="text-2xl"></CardTitle>
<CardDescription className="text-base">
</CardDescription>
</CardHeader>
<CardContent className="text-center text-sm text-muted-foreground">
<p></p>
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,722 @@
import { useState, useCallback, useEffect, memo } from 'react'
import { useNavigate } from '@tanstack/react-router'
import ReactFlow, {
Controls,
Background,
BackgroundVariant,
MiniMap,
useNodesState,
useEdgesState,
Panel,
Handle,
Position,
type Node,
type Edge,
type NodeTypes,
} from 'reactflow'
import 'reactflow/dist/style.css'
import dagre from 'dagre'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Search,
RefreshCw,
Info,
Database,
Network,
FileText,
} from 'lucide-react'
import { useToast } from '@/hooks/use-toast'
import { getKnowledgeGraph, getKnowledgeStats, searchKnowledgeNode, type KnowledgeNode, type KnowledgeEdge, type KnowledgeStats } from '@/lib/knowledge-api'
import { cn } from '@/lib/utils'
// 自定义节点组件 - 实体节点
const EntityNode = memo(({ data }: { data: { label: string; content: string } }) => {
return (
<div className="px-4 py-2 shadow-md rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 border-2 border-blue-700 min-w-[120px]">
<Handle type="target" position={Position.Top} />
<div className="font-semibold text-white text-sm truncate max-w-[200px]" title={data.content}>
{data.label}
</div>
<Handle type="source" position={Position.Bottom} />
</div>
)
})
EntityNode.displayName = 'EntityNode'
// 自定义节点组件 - 段落节点
const ParagraphNode = memo(({ data }: { data: { label: string; content: string } }) => {
return (
<div className="px-3 py-2 shadow-md rounded-md bg-gradient-to-br from-green-500 to-green-600 border-2 border-green-700 min-w-[100px]">
<Handle type="target" position={Position.Top} />
<div className="font-medium text-white text-xs truncate max-w-[150px]" title={data.content}>
{data.label}
</div>
<Handle type="source" position={Position.Bottom} />
</div>
)
})
ParagraphNode.displayName = 'ParagraphNode'
const nodeTypes: NodeTypes = {
entity: EntityNode,
paragraph: ParagraphNode,
}
// 使用 dagre 进行自动布局
function calculateLayout(nodes: KnowledgeNode[], edges: KnowledgeEdge[]): { nodes: Node[]; edges: Edge[] } {
const dagreGraph = new dagre.graphlib.Graph()
dagreGraph.setDefaultEdgeLabel(() => ({}))
dagreGraph.setGraph({ rankdir: 'TB', ranksep: 100, nodesep: 80 })
const flowNodes: Node[] = []
const flowEdges: Edge[] = []
// 设置节点到 dagre 图
nodes.forEach((node) => {
dagreGraph.setNode(node.id, { width: 150, height: 50 })
})
// 设置边到 dagre 图
edges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target)
})
// 执行布局计算
dagre.layout(dagreGraph)
// 获取布局后的节点位置
nodes.forEach((node) => {
const nodeWithPosition = dagreGraph.node(node.id)
flowNodes.push({
id: node.id,
type: node.type,
position: {
x: nodeWithPosition.x - 75,
y: nodeWithPosition.y - 25,
},
data: {
label: node.content.slice(0, 20) + (node.content.length > 20 ? '...' : ''),
content: node.content,
},
})
})
// 创建边
edges.forEach((edge, index) => {
const flowEdge: Edge = {
id: `edge-${index}`,
source: edge.source,
target: edge.target,
// 节点数超过200时禁用动画提升性能
animated: nodes.length <= 200 && edge.weight > 5,
style: {
strokeWidth: Math.min(edge.weight / 2, 5),
opacity: 0.6,
},
}
// 只在节点数少于100时显示边的标签
if (edge.weight > 10 && nodes.length < 100) {
flowEdge.label = `${edge.weight.toFixed(0)}`
}
flowEdges.push(flowEdge)
})
return { nodes: flowNodes, edges: flowEdges }
}
export function KnowledgeGraphPage() {
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const [stats, setStats] = useState<KnowledgeStats | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [nodeType, setNodeType] = useState<'all' | 'entity' | 'paragraph'>('all')
const [nodeLimit, setNodeLimit] = useState(50)
const [customLimit, setCustomLimit] = useState('50')
const [showCustomInput, setShowCustomInput] = useState(false)
const [showInitialConfirm, setShowInitialConfirm] = useState(true)
const [userConfirmedLoad, setUserConfirmedLoad] = useState(false) // 用户是否确认加载
const [showHighNodeWarning, setShowHighNodeWarning] = useState(false)
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
const [nodeCount, setNodeCount] = useState(0)
const [selectedNodeData, setSelectedNodeData] = useState<KnowledgeNode | null>(null)
const [selectedEdgeData, setSelectedEdgeData] = useState<{ source: KnowledgeNode; target: KnowledgeNode; edge: KnowledgeEdge } | null>(null)
const { toast } = useToast()
// 缓存 MiniMap 的 nodeColor 函数
const miniMapNodeColor = useCallback((node: Node) => {
if (node.type === 'entity') return '#6366f1'
if (node.type === 'paragraph') return '#10b981'
return '#6b7280'
}, [])
// 加载知识图谱数据
const loadGraph = useCallback(async (skipWarning = false) => {
try {
// 检查是否需要警告用户
if (!skipWarning && nodeLimit > 200) {
setShowHighNodeWarning(true)
return
}
setLoading(true)
const [graphData, statsData] = await Promise.all([
getKnowledgeGraph(nodeLimit, nodeType),
getKnowledgeStats(),
])
setStats(statsData)
if (graphData.nodes.length === 0) {
toast({
title: '提示',
description: '知识库为空,请先导入知识数据',
})
setNodes([])
setEdges([])
return
}
const { nodes: flowNodes, edges: flowEdges } = calculateLayout(graphData.nodes, graphData.edges)
setNodes(flowNodes)
setEdges(flowEdges)
setNodeCount(flowNodes.length)
if (statsData && statsData.total_nodes > nodeLimit) {
toast({
title: '提示',
description: `知识图谱包含 ${statsData.total_nodes} 个节点,当前显示 ${flowNodes.length}`,
})
}
toast({
title: '加载成功',
description: `已加载 ${flowNodes.length} 个节点,${flowEdges.length} 条边`,
})
} catch (error) {
console.error('加载知识图谱失败:', error)
toast({
title: '加载失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
} finally {
setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodeLimit, nodeType, toast]) // setNodes 和 setEdges 是稳定的,不需要包含
// 搜索节点
const handleSearch = useCallback(async () => {
if (!searchQuery.trim()) {
toast({
title: '提示',
description: '请输入搜索关键词',
})
return
}
try {
const results = await searchKnowledgeNode(searchQuery)
if (results.length === 0) {
toast({
title: '未找到',
description: '没有找到匹配的节点',
})
return
}
// 高亮搜索结果
const resultIds = new Set(results.map(r => r.id))
setNodes(nds =>
nds.map(node => ({
...node,
style: {
...node.style,
opacity: resultIds.has(node.id) ? 1 : 0.3,
filter: resultIds.has(node.id) ? 'brightness(1.2)' : 'brightness(0.8)',
},
}))
)
toast({
title: '搜索完成',
description: `找到 ${results.length} 个匹配节点`,
})
} catch (error) {
console.error('搜索失败:', error)
toast({
title: '搜索失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery, toast]) // setNodes 是稳定的
// 重置高亮
const handleResetHighlight = useCallback(() => {
setNodes(nds =>
nds.map(node => ({
...node,
style: {
...node.style,
opacity: 1,
filter: 'brightness(1)',
},
}))
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // setNodes 是稳定的
// 初始确认后加载
const handleInitialConfirm = useCallback(() => {
setShowInitialConfirm(false)
setUserConfirmedLoad(true) // 设置用户确认标记
loadGraph()
}, [loadGraph])
// 高节点数确认后加载
const handleHighNodeConfirm = useCallback(() => {
setShowHighNodeWarning(false) // 立即关闭高节点数警告对话框
// 使用 setTimeout 确保对话框关闭后再开始加载
setTimeout(() => {
loadGraph(true)
}, 0)
}, [loadGraph])
// 节点点击事件
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
setSelectedNodeData({
id: node.id,
type: node.type as 'entity' | 'paragraph',
content: node.data.content,
})
}, [])
// 当节点数量或类型改变时自动刷新
useEffect(() => {
// 跳过初始确认对话框时的加载
if (showInitialConfirm) return
// 只有用户确认后才能自动刷新
if (!userConfirmedLoad) return
// 参数变化时加载,会根据节点数自动判断是否需要警告
loadGraph()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodeLimit, nodeType, showInitialConfirm, userConfirmedLoad]) // 不依赖 loadGraph
// 边点击事件
const onEdgeClick = useCallback((_: React.MouseEvent, edge: Edge) => {
const sourceNode = nodes.find(n => n.id === edge.source)
const targetNode = nodes.find(n => n.id === edge.target)
const edgeData = edges.find(e => e.id === edge.id)
if (sourceNode && targetNode && edgeData) {
setSelectedEdgeData({
source: {
id: sourceNode.id,
type: sourceNode.type as 'entity' | 'paragraph',
content: sourceNode.data.content,
},
target: {
id: targetNode.id,
type: targetNode.type as 'entity' | 'paragraph',
content: targetNode.data.content,
},
edge: {
source: edge.source,
target: edge.target,
weight: parseFloat(edge.label as string || '0'),
},
})
}
}, [nodes, edges])
return (
<div className="h-full flex flex-col">
{/* 顶部工具栏 */}
<div className="flex-shrink-0 p-4 border-b bg-background">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold"></h1>
<p className="text-muted-foreground mt-1"></p>
</div>
{stats && (
<div className="flex gap-2 flex-wrap">
<Badge variant="outline" className="gap-1">
<Database className="h-3 w-3" />
: {stats.total_nodes}
</Badge>
<Badge variant="outline" className="gap-1">
<Network className="h-3 w-3" />
: {stats.total_edges}
</Badge>
<Badge variant="outline" className="gap-1">
<Info className="h-3 w-3" />
: {stats.entity_nodes}
</Badge>
<Badge variant="outline" className="gap-1">
<FileText className="h-3 w-3" />
: {stats.paragraph_nodes}
</Badge>
</div>
)}
</div>
{/* 搜索和控制栏 */}
<div className="flex flex-col sm:flex-row gap-2 mt-4">
<div className="flex-1 flex gap-2">
<Input
placeholder="搜索节点内容..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="flex-1"
/>
<Button onClick={handleSearch} size="sm">
<Search className="h-4 w-4" />
</Button>
<Button onClick={handleResetHighlight} variant="outline" size="sm">
</Button>
</div>
<div className="flex gap-2">
<Select value={nodeType} onValueChange={(v) => setNodeType(v as 'all' | 'entity' | 'paragraph')}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="entity"></SelectItem>
<SelectItem value="paragraph"></SelectItem>
</SelectContent>
</Select>
<Select
value={
nodeLimit === 10000 ? 'all' :
showCustomInput ? 'custom' :
nodeLimit.toString()
}
onValueChange={(v) => {
if (v === 'custom') {
setShowCustomInput(true)
setCustomLimit(nodeLimit.toString())
} else if (v === 'all') {
setShowCustomInput(false)
setNodeLimit(10000)
} else {
setShowCustomInput(false)
setNodeLimit(Number(v))
}
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="50">50 </SelectItem>
<SelectItem value="100">100 </SelectItem>
<SelectItem value="200">200 </SelectItem>
<SelectItem value="500">500 </SelectItem>
<SelectItem value="1000">1000 </SelectItem>
<SelectItem value="all"> (10000)</SelectItem>
<SelectItem value="custom">...</SelectItem>
</SelectContent>
</Select>
{showCustomInput && (
<Input
type="number"
min="50"
value={customLimit}
onChange={(e) => setCustomLimit(e.target.value)}
onBlur={() => {
const num = parseInt(customLimit)
if (!isNaN(num) && num >= 50) {
setNodeLimit(num)
} else {
setCustomLimit('50')
setNodeLimit(50)
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const num = parseInt(customLimit)
if (!isNaN(num) && num >= 50) {
setNodeLimit(num)
} else {
setCustomLimit('50')
setNodeLimit(50)
}
}
}}
placeholder="最少50个"
className="w-[120px]"
/>
)}
<Button onClick={() => loadGraph()} variant="outline" size="sm" disabled={loading}>
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
</Button>
</div>
</div>
</div>
{/* 主内容区域 */}
<div className="flex-1 relative">
{loading ? (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2 text-muted-foreground" />
<p className="text-muted-foreground">...</p>
</div>
</div>
) : nodes.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<Database className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-muted-foreground"></p>
</div>
</div>
) : (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
nodeTypes={nodeTypes}
fitView
minZoom={0.05}
maxZoom={1.5}
defaultViewport={{ x: 0, y: 0, zoom: 0.5 }}
elevateNodesOnSelect={nodeCount <= 500}
nodesDraggable={nodeCount <= 1000}
attributionPosition="bottom-left"
>
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
<Controls />
{/* 节点数超过500时禁用MiniMap提升性能 */}
{nodeCount <= 500 && (
<MiniMap
nodeColor={miniMapNodeColor}
nodeBorderRadius={8}
pannable
zoomable
/>
)}
{/* 图例 */}
<Panel position="top-right" className="bg-background/95 backdrop-blur-sm rounded-lg border p-3 shadow-lg">
<div className="text-sm font-semibold mb-2"></div>
<div className="space-y-2 text-xs">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-gradient-to-br from-blue-500 to-blue-600 border-2 border-blue-700" />
<span></span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-gradient-to-br from-green-500 to-green-600 border-2 border-green-700" />
<span></span>
</div>
{nodeCount > 200 && (
<div className="mt-2 pt-2 border-t text-yellow-600 dark:text-yellow-500">
<div className="font-semibold"></div>
<div></div>
{nodeCount > 500 && <div></div>}
</div>
)}
</div>
</Panel>
</ReactFlow>
)}
</div>
{/* 节点详情对话框 */}
<Dialog open={!!selectedNodeData} onOpenChange={(open) => !open && setSelectedNodeData(null)}>
<DialogContent className="max-w-2xl max-h-[80vh] grid grid-rows-[auto_1fr_auto] overflow-hidden">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{selectedNodeData && (
<ScrollArea className="h-full pr-4">
<div className="space-y-4 pb-2">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<div className="mt-1">
<Badge variant={selectedNodeData.type === 'entity' ? 'default' : 'secondary'}>
{selectedNodeData.type === 'entity' ? '🏷️ 实体' : '📄 段落'}
</Badge>
</div>
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">ID</label>
<code className="mt-1 block p-2 bg-muted rounded text-xs break-all">
{selectedNodeData.id}
</code>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<div className="mt-1 p-3 bg-muted rounded border">
<p className="text-sm whitespace-pre-wrap break-words">{selectedNodeData.content}</p>
</div>
{selectedNodeData.type === 'paragraph' && selectedNodeData.content && selectedNodeData.content.length < 20 && (
<div className="mt-2 p-3 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded">
<p className="text-xs text-yellow-800 dark:text-yellow-200">
💡 <strong></strong>
<br />
<strong> WebUI </strong> "在知识图谱中加载段落完整内容"
<br />
embedding storeMB内存
</p>
</div>
)}
</div>
</div>
</ScrollArea>
)}
</DialogContent>
</Dialog>
{/* 边详情对话框 */}
<Dialog open={!!selectedEdgeData} onOpenChange={(open) => !open && setSelectedEdgeData(null)}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{selectedEdgeData && (
<ScrollArea className="flex-1 pr-4">
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="flex-1 min-w-0 p-3 bg-blue-50 dark:bg-blue-950 rounded border-2 border-blue-200 dark:border-blue-800">
<div className="text-xs text-muted-foreground mb-1"></div>
<div className="font-medium text-sm mb-2 truncate">{selectedEdgeData.source.content}</div>
<code className="text-xs text-muted-foreground truncate block">
{selectedEdgeData.source.id.slice(0, 40)}...
</code>
</div>
<div className="text-2xl text-muted-foreground flex-shrink-0"></div>
<div className="flex-1 min-w-0 p-3 bg-green-50 dark:bg-green-950 rounded border-2 border-green-200 dark:border-green-800">
<div className="text-xs text-muted-foreground mb-1"></div>
<div className="font-medium text-sm mb-2 truncate">{selectedEdgeData.target.content}</div>
<code className="text-xs text-muted-foreground truncate block">
{selectedEdgeData.target.id.slice(0, 40)}...
</code>
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground"></label>
<div className="mt-1">
<Badge variant="outline" className="text-base font-mono">
{selectedEdgeData.edge.weight.toFixed(4)}
</Badge>
</div>
</div>
</div>
</ScrollArea>
)}
</DialogContent>
</Dialog>
{/* 初始加载确认对话框 */}
<AlertDialog open={showInitialConfirm} onOpenChange={setShowInitialConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
<br />
?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => navigate({ to: '/' })}>
()
</AlertDialogCancel>
<AlertDialogAction onClick={handleInitialConfirm}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 高节点数警告对话框 */}
<AlertDialog open={showHighNodeWarning} onOpenChange={setShowHighNodeWarning}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription asChild>
<div>
<p>
<strong className="text-orange-600">{nodeLimit >= 10000 ? '全部 (最多10000个)' : nodeLimit}</strong>
</p>
<p className="mt-4">:</p>
<ul className="list-disc list-inside mt-2 space-y-1">
<li></li>
<li></li>
<li></li>
</ul>
<p className="mt-4"> (50-200 )</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
setShowHighNodeWarning(false)
// 将节点数重置为安全值
if (nodeLimit > 200) {
setNodeLimit(50)
setShowCustomInput(false)
}
}}>
</AlertDialogCancel>
<AlertDialogAction onClick={handleHighNodeConfirm} className="bg-orange-600 hover:bg-orange-700">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,553 @@
import { useState, useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import {
Sparkles,
ArrowRight,
CheckCircle2,
SkipForward,
Bot,
User,
Smile,
Settings,
Key,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { cn } from '@/lib/utils'
import { APP_NAME } from '@/lib/version'
import { useToast } from '@/hooks/use-toast'
import type {
SetupStep,
BotBasicConfig,
PersonalityConfig,
EmojiConfig,
OtherBasicConfig,
SiliconFlowConfig,
} from './setup/types'
import {
BotBasicForm,
PersonalityForm,
EmojiForm,
OtherBasicForm,
SiliconFlowForm,
} from './setup/StepForms'
import {
loadBotBasicConfig,
loadPersonalityConfig,
loadEmojiConfig,
loadOtherBasicConfig,
loadSiliconFlowConfig,
saveBotBasicConfig,
savePersonalityConfig,
saveEmojiConfig,
saveOtherBasicConfig,
saveSiliconFlowConfig,
completeSetup,
} from './setup/api'
import { RestartProvider, useRestart } from '@/lib/restart-context'
import { RestartOverlay } from '@/components/restart-overlay'
// 主导出组件:包装 RestartProvider
export function SetupPage() {
return (
<RestartProvider>
<SetupPageContent />
</RestartProvider>
)
}
// 内部实现组件
function SetupPageContent() {
const navigate = useNavigate()
const { toast } = useToast()
const { triggerRestart } = useRestart()
const [currentStep, setCurrentStep] = useState(0)
const [isCompleting, setIsCompleting] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(true)
// 步骤1Bot基础信息
const [botBasic, setBotBasic] = useState<BotBasicConfig>({
qq_account: 0,
nickname: '',
alias_names: [],
})
// 步骤2人格配置
const [personality, setPersonality] = useState<PersonalityConfig>({
personality: '是一个女大学生,现在在读大二,会刷贴吧。',
reply_style:
'请回复的平淡一些,简短一些,说中文,不要刻意突出自身学科背景。可以参考贴吧,知乎和微博的回复风格。',
interest:
'对技术相关话题,游戏和动漫相关话题感兴趣,也对日常话题感兴趣,不喜欢太过沉重严肃的话题',
plan_style:
'1.思考**所有**的可用的action中的**每个动作**是否符合当下条件,如果动作使用条件符合聊天内容就使用\n2.如果相同的内容已经被执行,请不要重复执行\n3.请控制你的发言频率,不要太过频繁的发言\n4.如果有人对你感到厌烦,请减少回复\n5.如果有人对你进行攻击,或者情绪激动,请你以合适的方法应对',
private_plan_style:
'1.思考**所有**的可用的action中的**每个动作**是否符合当下条件,如果动作使用条件符合聊天内容就使用\n2.如果相同的内容已经被执行,请不要重复执行\n3.某句话如果已经被回复过,不要重复回复',
})
// 步骤3表情包配置
const [emoji, setEmoji] = useState<EmojiConfig>({
emoji_chance: 0.4,
max_reg_num: 40,
do_replace: true,
check_interval: 10,
steal_emoji: true,
content_filtration: false,
filtration_prompt: '符合公序良俗',
})
// 步骤4其他基础配置
const [otherBasic, setOtherBasic] = useState<OtherBasicConfig>({
enable_tool: true,
all_global: true,
})
// 步骤5硅基流动API配置
const [siliconFlow, setSiliconFlow] = useState<SiliconFlowConfig>({
api_key: '',
})
const steps: SetupStep[] = [
{
id: 'bot-basic',
title: 'Bot基础',
description: '配置机器人的基本信息',
icon: Bot,
},
{
id: 'personality',
title: '人格配置',
description: '定义机器人的性格和说话风格',
icon: User,
},
{
id: 'emoji',
title: '表情包',
description: '配置表情包相关设置',
icon: Smile,
},
{
id: 'other',
title: '其他设置',
description: '工具、情绪系统等配置',
icon: Settings,
},
{
id: 'siliconflow',
title: 'API配置',
description: '配置硅基流动API密钥',
icon: Key,
},
]
const progress = ((currentStep + 1) / steps.length) * 100
// 加载现有配置
useEffect(() => {
const loadConfigs = async () => {
try {
setIsLoading(true)
// 并行加载所有配置
const [bot, personality, emoji, other, silicon] = await Promise.all([
loadBotBasicConfig(),
loadPersonalityConfig(),
loadEmojiConfig(),
loadOtherBasicConfig(),
loadSiliconFlowConfig(),
])
setBotBasic(bot)
setPersonality(personality)
setEmoji(emoji)
setOtherBasic(other)
setSiliconFlow(silicon)
} catch (error) {
toast({
title: '加载配置失败',
description:
error instanceof Error
? error.message
: '无法加载现有配置,将使用默认值',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
loadConfigs()
}, [toast])
// 保存当前步骤配置
const saveCurrentStep = async () => {
setIsSaving(true)
try {
switch (currentStep) {
case 0: // Bot基础
await saveBotBasicConfig(botBasic)
break
case 1: // 人格配置
await savePersonalityConfig(personality)
break
case 2: // 表情包
await saveEmojiConfig(emoji)
break
case 3: // 其他设置
await saveOtherBasicConfig(otherBasic)
break
case 4: // 硅基流动API
await saveSiliconFlowConfig(siliconFlow)
break
}
toast({
title: '保存成功',
description: `${steps[currentStep].title}配置已保存`,
})
return true
} catch (error) {
toast({
title: '保存失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
return false
} finally {
setIsSaving(false)
}
}
const handleNext = async () => {
// 保存当前步骤
const saved = await saveCurrentStep()
if (!saved) return
// 进入下一步
if (currentStep < steps.length - 1) {
setCurrentStep(currentStep + 1)
}
}
const handlePrevious = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1)
}
}
const handleComplete = async () => {
setIsCompleting(true)
try {
// 1. 保存最后一步的配置(硅基流动API Key)
const saved = await saveCurrentStep()
if (!saved) {
setIsCompleting(false)
return
}
// 2. 标记设置完成
await completeSetup()
toast({
title: '配置完成',
description: '麦麦正在重启以应用新配置...',
})
// 3. 触发麦麦重启(使用新的重启组件)
await triggerRestart()
} catch (error) {
toast({
title: '配置失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
} finally {
setIsCompleting(false)
}
}
const handleSkip = async () => {
try {
await completeSetup()
navigate({ to: '/' })
} catch (error) {
toast({
title: '跳过失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive',
})
}
}
// 渲染当前步骤的表单
const renderStepForm = () => {
switch (currentStep) {
case 0:
return <BotBasicForm config={botBasic} onChange={setBotBasic} />
case 1:
return (
<PersonalityForm config={personality} onChange={setPersonality} />
)
case 2:
return <EmojiForm config={emoji} onChange={setEmoji} />
case 3:
return <OtherBasicForm config={otherBasic} onChange={setOtherBasic} />
case 4:
return <SiliconFlowForm config={siliconFlow} onChange={setSiliconFlow} />
default:
return null
}
}
return (
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-gradient-to-br from-primary/5 via-background to-secondary/5 p-4 md:p-6">
{/* 重启遮罩层 */}
<RestartOverlay />
{/* 背景装饰 */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute left-1/4 top-1/4 h-64 w-64 md:h-96 md:w-96 rounded-full bg-primary/5 blur-3xl" />
<div className="absolute right-1/4 bottom-1/4 h-64 w-64 md:h-96 md:w-96 rounded-full bg-secondary/5 blur-3xl" />
</div>
{/* 加载状态 */}
{isLoading ? (
<div className="relative z-10 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center">
<div className="h-12 w-12 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
<p className="text-lg font-medium">...</p>
<p className="text-sm text-muted-foreground mt-2">
</p>
</div>
) : (
<>
{/* 主要内容 */}
<div className="relative z-10 w-full max-w-4xl">
{/* 头部 */}
<div className="mb-6 md:mb-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 md:h-16 md:w-16 items-center justify-center rounded-2xl bg-primary/10">
<Sparkles
className="h-6 w-6 md:h-8 md:w-8 text-primary"
strokeWidth={2}
fill="none"
/>
</div>
<h1 className="mb-2 text-2xl md:text-3xl font-bold">
</h1>
<p className="text-sm md:text-base text-muted-foreground">
{APP_NAME}
</p>
</div>
{/* 进度条 */}
<div className="mb-6 md:mb-8">
<div className="mb-2 flex items-center justify-between text-xs md:text-sm">
<span className="text-muted-foreground">
{currentStep + 1} / {steps.length}
</span>
<span className="font-medium text-primary">
{Math.round(progress)}%
</span>
</div>
<Progress value={progress} className="h-2" />
</div>
{/* 步骤指示器 */}
<div className="mb-6 md:mb-8 flex justify-between">
{steps.map((step, index) => {
const Icon = step.icon
return (
<div
key={step.id}
className={cn(
'flex flex-1 flex-col items-center gap-1 md:gap-2',
index < steps.length - 1 && 'relative'
)}
>
{/* 连接线 */}
{index < steps.length - 1 && (
<div
className={cn(
'absolute left-1/2 top-3 md:top-4 h-0.5 w-full',
index < currentStep ? 'bg-primary' : 'bg-border'
)}
/>
)}
{/* 步骤圆圈 */}
<div
className={cn(
'relative z-10 flex h-6 w-6 md:h-8 md:w-8 items-center justify-center rounded-full border-2 transition-all',
index === currentStep
? 'border-primary bg-primary text-primary-foreground'
: index < currentStep
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background text-muted-foreground'
)}
>
{index < currentStep ? (
<CheckCircle2
className="h-3 w-3 md:h-4 md:w-4"
strokeWidth={2.5}
fill="none"
/>
) : (
<Icon className="h-3 w-3 md:h-4 md:w-4" />
)}
</div>
{/* 步骤标题 */}
<span
className={cn(
'text-[10px] md:text-xs text-center max-w-[60px] md:max-w-none truncate md:whitespace-normal',
index === currentStep
? 'font-medium text-foreground'
: 'text-muted-foreground'
)}
title={step.title}
>
{step.title}
</span>
</div>
)
})}
</div>
{/* 步骤内容卡片 */}
<Card className="mb-6 md:mb-8 shadow-lg">
<CardContent className="p-4 md:p-8">
<div className="min-h-[300px] md:min-h-[400px]">
<div className="mb-4 md:mb-6">
<h2 className="mb-2 text-xl md:text-2xl font-semibold">
{steps[currentStep].title}
</h2>
<p className="text-sm md:text-base text-muted-foreground">
{steps[currentStep].description}
</p>
</div>
{/* 表单内容 */}
<ScrollArea className="h-[400px] md:h-[500px]">
<div className="pr-2">
{renderStepForm()}
</div>
</ScrollArea>
</div>
</CardContent>
</Card>
{/* 操作按钮 */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 sm:gap-0">
<Button
variant="outline"
onClick={handlePrevious}
disabled={currentStep === 0 || isSaving}
className="w-full sm:w-auto order-2 sm:order-1"
>
</Button>
<div className="flex gap-2 w-full sm:w-auto order-1 sm:order-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
className="flex-1 sm:flex-none gap-2"
disabled={isSaving || isCompleting}
>
<SkipForward className="h-4 w-4" strokeWidth={2} fill="none" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleSkip}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{currentStep === steps.length - 1 ? (
<Button
onClick={handleComplete}
disabled={isCompleting || isSaving}
className="flex-1 sm:flex-none"
>
{isCompleting || isSaving ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{isSaving ? '保存中...' : '完成中...'}
</>
) : (
<>
<CheckCircle2
className="ml-2 h-4 w-4"
strokeWidth={2}
fill="none"
/>
</>
)}
</Button>
) : (
<Button
onClick={handleNext}
disabled={isSaving}
className="flex-1 sm:flex-none"
>
{isSaving ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
...
</>
) : (
<>
<ArrowRight
className="ml-2 h-4 w-4"
strokeWidth={2}
fill="none"
/>
</>
)}
</Button>
)}
</div>
</div>
</div>
{/* 页脚提示 */}
<div className="relative z-10 mt-6 md:mt-8 text-center text-xs text-muted-foreground">
<p></p>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,483 @@
// 设置向导各步骤表单组件
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Separator } from '@/components/ui/separator'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { X, ExternalLink, Eye, EyeOff } from 'lucide-react'
import type {
BotBasicConfig,
PersonalityConfig,
EmojiConfig,
OtherBasicConfig,
SiliconFlowConfig,
} from './types'
// ====== 步骤1Bot基础配置 ======
interface BotBasicFormProps {
config: BotBasicConfig
onChange: (config: BotBasicConfig) => void
}
export function BotBasicForm({ config, onChange }: BotBasicFormProps) {
const handleAddAlias = (alias: string) => {
if (alias.trim() && !config.alias_names.includes(alias.trim())) {
onChange({
...config,
alias_names: [...config.alias_names, alias.trim()],
})
}
}
const handleRemoveAlias = (index: number) => {
onChange({
...config,
alias_names: config.alias_names.filter((_, i) => i !== index),
})
}
return (
<div className="space-y-6">
<div className="space-y-3">
<Label htmlFor="qq_account">QQ账号 *</Label>
<Input
id="qq_account"
type="number"
placeholder="请输入机器人的QQ账号"
value={config.qq_account || ''}
onChange={(e) =>
onChange({ ...config, qq_account: Number(e.target.value) })
}
/>
<p className="text-xs text-muted-foreground">
使QQ账号
</p>
</div>
<div className="space-y-3">
<Label htmlFor="nickname"> *</Label>
<Input
id="nickname"
placeholder="请输入机器人的昵称"
value={config.nickname}
onChange={(e) => onChange({ ...config, nickname: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-3">
<Label></Label>
<div className="flex flex-wrap gap-2 mb-2">
{config.alias_names.map((alias, index) => (
<Badge key={index} variant="secondary" className="gap-1">
{alias}
<button
type="button"
onClick={() => handleRemoveAlias(index)}
className="ml-1 hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
<div className="flex gap-2">
<Input
id="alias_input"
placeholder="输入别名后按回车添加"
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleAddAlias((e.target as HTMLInputElement).value)
;(e.target as HTMLInputElement).value = ''
}
}}
/>
<Button
type="button"
variant="outline"
onClick={() => {
const input = document.getElementById(
'alias_input'
) as HTMLInputElement
if (input) {
handleAddAlias(input.value)
input.value = ''
}
}}
>
</Button>
</div>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
)
}
// ====== 步骤2人格配置 ======
interface PersonalityFormProps {
config: PersonalityConfig
onChange: (config: PersonalityConfig) => void
}
export function PersonalityForm({ config, onChange }: PersonalityFormProps) {
return (
<div className="space-y-6">
<div className="space-y-3">
<Label htmlFor="personality"> *</Label>
<Textarea
id="personality"
placeholder="描述机器人的人格特质和身份特征建议120字以内"
value={config.personality}
onChange={(e) => onChange({ ...config, personality: e.target.value })}
rows={3}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-3">
<Label htmlFor="reply_style"> *</Label>
<Textarea
id="reply_style"
placeholder="描述机器人说话的表达风格、表达习惯"
value={config.reply_style}
onChange={(e) => onChange({ ...config, reply_style: e.target.value })}
rows={3}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-3">
<Label htmlFor="interest"> *</Label>
<Textarea
id="interest"
placeholder="描述机器人感兴趣的话题"
value={config.interest}
onChange={(e) => onChange({ ...config, interest: e.target.value })}
rows={2}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Separator />
<div className="space-y-3">
<Label htmlFor="plan_style"> *</Label>
<Textarea
id="plan_style"
placeholder="机器人在群聊中的行为风格和规则"
value={config.plan_style}
onChange={(e) => onChange({ ...config, plan_style: e.target.value })}
rows={4}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-3">
<Label htmlFor="private_plan_style"> *</Label>
<Textarea
id="private_plan_style"
placeholder="机器人在私聊中的行为风格和规则"
value={config.private_plan_style}
onChange={(e) =>
onChange({ ...config, private_plan_style: e.target.value })
}
rows={3}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
)
}
// ====== 步骤3表情包配置 ======
interface EmojiFormProps {
config: EmojiConfig
onChange: (config: EmojiConfig) => void
}
export function EmojiForm({ config, onChange }: EmojiFormProps) {
return (
<div className="space-y-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="emoji_chance"></Label>
<span className="text-sm text-muted-foreground">
{(config.emoji_chance * 100).toFixed(0)}%
</span>
</div>
<Input
id="emoji_chance"
type="range"
min="0"
max="1"
step="0.1"
value={config.emoji_chance}
onChange={(e) =>
onChange({ ...config, emoji_chance: Number(e.target.value) })
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-3">
<Label htmlFor="max_reg_num"></Label>
<Input
id="max_reg_num"
type="number"
min="1"
max="200"
value={config.max_reg_num}
onChange={(e) =>
onChange({ ...config, max_reg_num: Number(e.target.value) })
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="do_replace"></Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
id="do_replace"
checked={config.do_replace}
onCheckedChange={(checked) =>
onChange({ ...config, do_replace: checked })
}
/>
</div>
<div className="space-y-3">
<Label htmlFor="check_interval"></Label>
<Input
id="check_interval"
type="number"
min="1"
max="120"
value={config.check_interval}
onChange={(e) =>
onChange({ ...config, check_interval: Number(e.target.value) })
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="steal_emoji"></Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
id="steal_emoji"
checked={config.steal_emoji}
onCheckedChange={(checked) =>
onChange({ ...config, steal_emoji: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="content_filtration"></Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
id="content_filtration"
checked={config.content_filtration}
onCheckedChange={(checked) =>
onChange({ ...config, content_filtration: checked })
}
/>
</div>
{config.content_filtration && (
<div className="space-y-3">
<Label htmlFor="filtration_prompt"></Label>
<Input
id="filtration_prompt"
placeholder="例如:符合公序良俗"
value={config.filtration_prompt}
onChange={(e) =>
onChange({ ...config, filtration_prompt: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
)}
</div>
)
}
// ====== 步骤4其他基础配置 ======
interface OtherBasicFormProps {
config: OtherBasicConfig
onChange: (config: OtherBasicConfig) => void
}
export function OtherBasicForm({ config, onChange }: OtherBasicFormProps) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="enable_tool"></Label>
<p className="text-xs text-muted-foreground">
使
</p>
</div>
<Switch
id="enable_tool"
checked={config.enable_tool}
onCheckedChange={(checked) =>
onChange({ ...config, enable_tool: checked })
}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="all_global"></Label>
<p className="text-xs text-muted-foreground">
使
</p>
</div>
<Switch
id="all_global"
checked={config.all_global}
onCheckedChange={(checked) =>
onChange({ ...config, all_global: checked })
}
/>
</div>
</div>
)
}
// ====== 步骤5硅基流动API配置 ======
interface SiliconFlowFormProps {
config: SiliconFlowConfig
onChange: (config: SiliconFlowConfig) => void
}
export function SiliconFlowForm({ config, onChange }: SiliconFlowFormProps) {
const [showApiKey, setShowApiKey] = useState(false)
return (
<div className="space-y-6">
<div className="rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 p-4">
<div className="flex items-start gap-3">
<div className="mt-0.5">
<svg className="h-5 w-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="flex-1 text-sm">
<p className="font-medium text-blue-900 dark:text-blue-100 mb-1">
(SiliconFlow)
</p>
<p className="text-blue-700 dark:text-blue-300 mb-2">
DeepSeek V3Qwen
API Key 使
</p>
<a
href="https://cloud.siliconflow.cn"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:underline font-medium"
>
API Key
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</div>
<div className="space-y-3">
<Label htmlFor="siliconflow_api_key">SiliconFlow API Key *</Label>
<div className="relative">
<Input
id="siliconflow_api_key"
type={showApiKey ? 'text' : 'password'}
placeholder="sk-..."
value={config.api_key}
onChange={(e) => onChange({ api_key: e.target.value })}
className="font-mono pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
API
</p>
</div>
<div className="rounded-lg bg-muted/50 p-4 text-sm space-y-2">
<p className="font-medium"></p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground ml-2">
<li>DeepSeek V3 - </li>
<li>Qwen3 30B - </li>
<li>Qwen3 VL 30B - </li>
<li>SenseVoice - </li>
<li>BGE-M3 - </li>
<li> (LPMM)</li>
</ul>
</div>
<div className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/30 p-4">
<p className="text-sm text-amber-900 dark:text-amber-100">
<span className="font-medium">💡 </span>
"系统设置 → 模型配置" API
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,289 @@
// 设置向导API调用函数
import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth'
import type {
BotBasicConfig,
PersonalityConfig,
EmojiConfig,
OtherBasicConfig,
SiliconFlowConfig,
} from './types'
// ===== 读取配置 =====
// 读取Bot基础配置
export async function loadBotBasicConfig(): Promise<BotBasicConfig> {
const response = await fetchWithAuth('/api/webui/config/bot', {
method: 'GET',
headers: getAuthHeaders(),
})
if (!response.ok) {
throw new Error('读取Bot配置失败')
}
const data = await response.json()
const botConfig = data.config.bot || {}
return {
qq_account: botConfig.qq_account || 0,
nickname: botConfig.nickname || '',
alias_names: botConfig.alias_names || [],
}
}
// 读取人格配置
export async function loadPersonalityConfig(): Promise<PersonalityConfig> {
const response = await fetchWithAuth('/api/webui/config/bot', {
method: 'GET',
headers: getAuthHeaders(),
})
if (!response.ok) {
throw new Error('读取人格配置失败')
}
const data = await response.json()
const personalityConfig = data.config.personality || {}
return {
personality: personalityConfig.personality || '',
reply_style: personalityConfig.reply_style || '',
interest: personalityConfig.interest || '',
plan_style: personalityConfig.plan_style || '',
private_plan_style: personalityConfig.private_plan_style || '',
}
}
// 读取表情包配置
export async function loadEmojiConfig(): Promise<EmojiConfig> {
const response = await fetchWithAuth('/api/webui/config/bot', {
method: 'GET',
headers: getAuthHeaders(),
})
if (!response.ok) {
throw new Error('读取表情包配置失败')
}
const data = await response.json()
const emojiConfig = data.config.emoji || {}
return {
emoji_chance: emojiConfig.emoji_chance ?? 0.4,
max_reg_num: emojiConfig.max_reg_num ?? 40,
do_replace: emojiConfig.do_replace ?? true,
check_interval: emojiConfig.check_interval ?? 10,
steal_emoji: emojiConfig.steal_emoji ?? true,
content_filtration: emojiConfig.content_filtration ?? false,
filtration_prompt: emojiConfig.filtration_prompt || '',
}
}
// 读取其他基础配置
export async function loadOtherBasicConfig(): Promise<OtherBasicConfig> {
const response = await fetchWithAuth('/api/webui/config/bot', {
method: 'GET',
headers: getAuthHeaders(),
})
if (!response.ok) {
throw new Error('读取其他配置失败')
}
const data = await response.json()
const config = data.config
const toolConfig = config.tool || {}
const expressionConfig = config.expression || {}
return {
enable_tool: toolConfig.enable_tool ?? true,
all_global: expressionConfig.all_global_jargon ?? true,
}
}
// 读取硅基流动API配置
export async function loadSiliconFlowConfig(): Promise<SiliconFlowConfig> {
const response = await fetchWithAuth('/api/webui/config/model', {
method: 'GET',
headers: getAuthHeaders(),
})
if (!response.ok) {
throw new Error('读取模型配置失败')
}
const data = await response.json()
const modelConfig = data.config
// 获取SiliconFlow提供商的API Key
const apiProviders = modelConfig.api_providers || []
const siliconFlowProvider = apiProviders.find(
(p: Record<string, unknown>) => p.name === 'SiliconFlow'
)
return {
api_key: siliconFlowProvider?.api_key || '',
}
}
// ===== 保存配置 =====
// 保存Bot基础配置
export async function saveBotBasicConfig(config: BotBasicConfig) {
const response = await fetchWithAuth('/api/webui/config/bot/section/bot', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(config),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || '保存Bot基础配置失败')
}
return await response.json()
}
// 保存人格配置
export async function savePersonalityConfig(config: PersonalityConfig) {
const response = await fetchWithAuth('/api/webui/config/bot/section/personality', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(config),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || '保存人格配置失败')
}
return await response.json()
}
// 保存表情包配置
export async function saveEmojiConfig(config: EmojiConfig) {
const response = await fetchWithAuth('/api/webui/config/bot/section/emoji', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(config),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || '保存表情包配置失败')
}
return await response.json()
}
// 保存其他基础配置(工具、情绪、黑话)
export async function saveOtherBasicConfig(config: OtherBasicConfig) {
// 需要分别保存到不同的section
const promises = []
// 保存tool配置
promises.push(
fetchWithAuth('/api/webui/config/bot/section/tool', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ enable_tool: config.enable_tool }),
})
)
// 保存expression配置中的all_global_jargon
promises.push(
fetchWithAuth('/api/webui/config/bot/section/expression', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ all_global_jargon: config.all_global }),
})
)
const results = await Promise.all(promises)
// 检查所有请求是否成功
for (const response of results) {
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || '保存其他配置失败')
}
}
return { success: true }
}
// 保存硅基流动API配置
export async function saveSiliconFlowConfig(config: SiliconFlowConfig) {
// 1. 读取现有配置
const response = await fetchWithAuth('/api/webui/config/model', {
method: 'GET',
headers: getAuthHeaders(),
})
if (!response.ok) {
throw new Error('读取模型配置失败')
}
const currentModelConfig = await response.json()
const modelConfig = currentModelConfig.config
// 2. 更新SiliconFlow提供商的API Key
const apiProviders = modelConfig.api_providers || []
const siliconFlowIndex = apiProviders.findIndex(
(p: Record<string, unknown>) => p.name === 'SiliconFlow'
)
if (siliconFlowIndex >= 0) {
// 更新现有提供商的API Key
apiProviders[siliconFlowIndex] = {
...apiProviders[siliconFlowIndex],
api_key: config.api_key,
}
} else {
// 如果不存在,创建新的SiliconFlow提供商
apiProviders.push({
name: 'SiliconFlow',
base_url: 'https://api.siliconflow.cn/v1',
api_key: config.api_key,
client_type: 'openai',
max_retry: 3,
timeout: 120,
retry_interval: 5,
})
}
// 3. 保存更新后的配置
const updatedConfig = {
...modelConfig,
api_providers: apiProviders,
}
const saveResponse = await fetchWithAuth('/api/webui/config/model', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(updatedConfig),
})
if (!saveResponse.ok) {
const error = await saveResponse.json()
throw new Error(error.detail || '保存模型配置失败')
}
return await saveResponse.json()
}
// 标记设置完成
export async function completeSetup() {
const response = await fetchWithAuth('/api/webui/setup/complete', {
method: 'POST',
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || '标记配置完成失败')
}
return await response.json()
}

View File

@@ -0,0 +1,46 @@
// 设置向导相关类型定义
export interface SetupStep {
id: string
title: string
description: string
icon: React.ComponentType<{ className?: string }>
}
// 步骤1Bot基础信息
export interface BotBasicConfig {
qq_account: number
nickname: string
alias_names: string[]
}
// 步骤2人格配置
export interface PersonalityConfig {
personality: string
reply_style: string
interest: string
plan_style: string
private_plan_style: string
}
// 步骤3表情包配置
export interface EmojiConfig {
emoji_chance: number
max_reg_num: number
do_replace: boolean
check_interval: number
steal_emoji: boolean
content_filtration: boolean
filtration_prompt: string
}
// 步骤4其他基础配置
export interface OtherBasicConfig {
enable_tool: boolean
all_global: boolean // 全局黑话模式expression.all_global_jargon
}
// 步骤5硅基流动API配置
export interface SiliconFlowConfig {
api_key: string
}

View File

@@ -0,0 +1,2 @@
export { WebUIFeedbackSurveyPage } from './webui-feedback'
export { MaiBotFeedbackSurveyPage } from './maibot-feedback'

View File

@@ -0,0 +1,110 @@
/**
* 麦麦使用体验反馈问卷页面
*/
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Loader2, AlertCircle, FileQuestion } from 'lucide-react'
import { SurveyRenderer } from '@/components/survey'
import { maibotFeedbackSurvey } from '@/config/surveys'
import { getMaiBotStatus } from '@/lib/system-api'
import type { SurveyConfig, QuestionAnswer } from '@/types/survey'
export function MaiBotFeedbackSurveyPage() {
const [surveyConfig, setSurveyConfig] = useState<SurveyConfig | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [maibotVersion, setMaibotVersion] = useState<string>('未知版本')
// 初始化问卷配置,获取麦麦版本
useEffect(() => {
const init = async () => {
try {
// 获取麦麦版本
const status = await getMaiBotStatus()
setMaibotVersion(status.version || '未知版本')
} catch (error) {
console.error('Failed to get MaiBot version:', error)
setMaibotVersion('获取失败')
}
// 深拷贝配置以避免修改原始对象
const config = JSON.parse(JSON.stringify(maibotFeedbackSurvey)) as SurveyConfig
setSurveyConfig(config)
setIsLoading(false)
}
init()
}, [])
// 预填充的答案(版本号自动填写)
const initialAnswers: QuestionAnswer[] = useMemo(() => [
{
questionId: 'maibot_version',
value: maibotVersion,
},
], [maibotVersion])
// 提交成功回调
const handleSubmitSuccess = useCallback((submissionId: string) => {
console.log('MaiBot Survey submitted:', submissionId)
}, [])
// 提交错误回调
const handleSubmitError = useCallback((error: string) => {
console.error('MaiBot Survey submission error:', error)
}, [])
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (!surveyConfig) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<Alert variant="destructive" className="max-w-md">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
</AlertDescription>
</Alert>
<Button variant="outline" onClick={() => window.location.reload()}>
</Button>
</div>
)
}
return (
<div className="h-[calc(100vh-4rem)] flex flex-col p-4 sm:p-6">
{/* 页面标题 */}
<div className="mb-4 sm:mb-6 shrink-0">
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2">
<FileQuestion className="h-8 w-8" strokeWidth={2} />
使
</h1>
<p className="text-muted-foreground mt-1 text-sm sm:text-base">
AI
</p>
</div>
{/* 问卷内容 */}
<div className="flex-1 min-h-0">
<SurveyRenderer
config={surveyConfig}
initialAnswers={initialAnswers}
showProgress={true}
paginateQuestions={false}
onSubmitSuccess={handleSubmitSuccess}
onSubmitError={handleSubmitError}
/>
</div>
</div>
)
}
export default MaiBotFeedbackSurveyPage

View File

@@ -0,0 +1,98 @@
/**
* WebUI 使用反馈问卷页面
*/
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Loader2, AlertCircle, FileQuestion } from 'lucide-react'
import { SurveyRenderer } from '@/components/survey'
import { webuiFeedbackSurvey } from '@/config/surveys'
import { APP_VERSION } from '@/lib/version'
import type { SurveyConfig, QuestionAnswer } from '@/types/survey'
export function WebUIFeedbackSurveyPage() {
const [isLoading, setIsLoading] = useState(true)
// 使用 useMemo 派生配置而不是 useState + useEffect
const surveyConfig = useMemo(() => {
// 深拷贝配置以避免修改原始对象
return JSON.parse(JSON.stringify(webuiFeedbackSurvey)) as SurveyConfig
}, [])
// 初始化完成后设置加载状态
useEffect(() => {
setIsLoading(false)
}, [])
// 预填充的答案(版本号自动填写)
const initialAnswers: QuestionAnswer[] = useMemo(() => [
{
questionId: 'webui_version',
value: `v${APP_VERSION}`,
},
], [])
// 提交成功回调
const handleSubmitSuccess = useCallback((submissionId: string) => {
console.log('WebUI Survey submitted:', submissionId)
}, [])
// 提交错误回调
const handleSubmitError = useCallback((error: string) => {
console.error('WebUI Survey submission error:', error)
}, [])
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (!surveyConfig) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<Alert variant="destructive" className="max-w-md">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
</AlertDescription>
</Alert>
<Button variant="outline" onClick={() => window.location.reload()}>
</Button>
</div>
)
}
return (
<div className="h-[calc(100vh-4rem)] flex flex-col p-4 sm:p-6">
{/* 页面标题 */}
<div className="mb-4 sm:mb-6 shrink-0">
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2">
<FileQuestion className="h-8 w-8" strokeWidth={2} />
WebUI 使
</h1>
<p className="text-muted-foreground mt-1 text-sm sm:text-base">
</p>
</div>
{/* 问卷内容 */}
<div className="flex-1 min-h-0">
<SurveyRenderer
config={surveyConfig}
initialAnswers={initialAnswers}
showProgress={true}
paginateQuestions={false}
onSubmitSuccess={handleSubmitSuccess}
onSubmitError={handleSubmitError}
/>
</div>
</div>
)
}
export default WebUIFeedbackSurveyPage