上传完整的WebUI前端仓库
This commit is contained in:
61
dashboard/src/routes/404.tsx
Normal file
61
dashboard/src/routes/404.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
883
dashboard/src/routes/annual-report.tsx
Normal file
883
dashboard/src/routes/annual-report.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
342
dashboard/src/routes/auth.tsx
Normal file
342
dashboard/src/routes/auth.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1590
dashboard/src/routes/chat.tsx
Normal file
1590
dashboard/src/routes/chat.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1348
dashboard/src/routes/config/adapter.tsx
Normal file
1348
dashboard/src/routes/config/adapter.tsx
Normal file
File diff suppressed because it is too large
Load Diff
10
dashboard/src/routes/config/adapter/index.ts
Normal file
10
dashboard/src/routes/config/adapter/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 适配器配置模块
|
||||
*
|
||||
* 模块结构:
|
||||
* - types.ts: 类型定义和默认配置
|
||||
* - utils.ts: TOML 解析和验证工具函数
|
||||
*/
|
||||
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
105
dashboard/src/routes/config/adapter/types.ts
Normal file
105
dashboard/src/routes/config/adapter/types.ts
Normal 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
|
||||
285
dashboard/src/routes/config/adapter/utils.ts
Normal file
285
dashboard/src/routes/config/adapter/utils.ts
Normal 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: '' }
|
||||
}
|
||||
735
dashboard/src/routes/config/bot.tsx
Normal file
735
dashboard/src/routes/config/bot.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
dashboard/src/routes/config/bot/hooks/index.ts
Normal file
6
dashboard/src/routes/config/bot/hooks/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Bot 配置页面相关 hooks
|
||||
*/
|
||||
|
||||
export { useAutoSave, useConfigAutoSave } from './useAutoSave'
|
||||
export type { UseAutoSaveOptions, UseAutoSaveReturn, AutoSaveState } from './useAutoSave'
|
||||
166
dashboard/src/routes/config/bot/hooks/useAutoSave.ts
Normal file
166
dashboard/src/routes/config/bot/hooks/useAutoSave.ts
Normal 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])
|
||||
}
|
||||
24
dashboard/src/routes/config/bot/index.ts
Normal file
24
dashboard/src/routes/config/bot/index.ts
Normal 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'
|
||||
192
dashboard/src/routes/config/bot/sections/BotInfoSection.tsx
Normal file
192
dashboard/src/routes/config/bot/sections/BotInfoSection.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
610
dashboard/src/routes/config/bot/sections/ChatSection.tsx
Normal file
610
dashboard/src/routes/config/bot/sections/ChatSection.tsx
Normal 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-5,0 为关闭
|
||||
</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 表示晚上11点到次日凌晨2点</li>
|
||||
<li>• <strong>数值范围</strong>:建议 0-1,0 表示完全沉默,1 表示正常发言</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
97
dashboard/src/routes/config/bot/sections/DebugSection.tsx
Normal file
97
dashboard/src/routes/config/bot/sections/DebugSection.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
215
dashboard/src/routes/config/bot/sections/DreamSection.tsx
Normal file
215
dashboard/src/routes/config/bot/sections/DreamSection.tsx
Normal 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">
|
||||
选择平台并输入用户ID,做梦结束后将梦境发送给该用户。用户ID为空则不推送
|
||||
</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>
|
||||
)
|
||||
})
|
||||
311
dashboard/src/routes/config/bot/sections/ExperimentalSection.tsx
Normal file
311
dashboard/src/routes/config/bot/sections/ExperimentalSection.tsx
Normal 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>支持多个平台:QQ、微信、WebUI</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>
|
||||
)
|
||||
})
|
||||
996
dashboard/src/routes/config/bot/sections/ExpressionSection.tsx
Normal file
996
dashboard/src/routes/config/bot/sections/ExpressionSection.tsx
Normal 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">
|
||||
表达方式自动检查的间隔时间(单位:秒),默认值:3600秒(1小时)
|
||||
</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">
|
||||
手动表达优化操作员ID,格式:platform: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>
|
||||
)
|
||||
})
|
||||
336
dashboard/src/routes/config/bot/sections/FeaturesSection.tsx
Normal file
336
dashboard/src/routes/config/bot/sections/FeaturesSection.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
150
dashboard/src/routes/config/bot/sections/LPMMSection.tsx
Normal file
150
dashboard/src/routes/config/bot/sections/LPMMSection.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
264
dashboard/src/routes/config/bot/sections/LogSection.tsx
Normal file
264
dashboard/src/routes/config/bot/sections/LogSection.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
203
dashboard/src/routes/config/bot/sections/MaimMessageSection.tsx
Normal file
203
dashboard/src/routes/config/bot/sections/MaimMessageSection.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
@@ -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="输入正则表达式(按回车添加) 示例: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>
|
||||
)
|
||||
}
|
||||
164
dashboard/src/routes/config/bot/sections/PersonalitySection.tsx
Normal file
164
dashboard/src/routes/config/bot/sections/PersonalitySection.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
1121
dashboard/src/routes/config/bot/sections/ProcessingSection.tsx
Normal file
1121
dashboard/src/routes/config/bot/sections/ProcessingSection.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
})
|
||||
27
dashboard/src/routes/config/bot/sections/VoiceSection.tsx
Normal file
27
dashboard/src/routes/config/bot/sections/VoiceSection.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
287
dashboard/src/routes/config/bot/sections/WebUISection.tsx
Normal file
287
dashboard/src/routes/config/bot/sections/WebUISection.tsx
Normal 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">
|
||||
支持精确IP、CIDR格式和通配符(如:127.0.0.1、192.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 store,会占用额外内存(约数百MB)。
|
||||
</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>
|
||||
)
|
||||
})
|
||||
19
dashboard/src/routes/config/bot/sections/index.ts
Normal file
19
dashboard/src/routes/config/bot/sections/index.ts
Normal 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'
|
||||
259
dashboard/src/routes/config/bot/types.ts
Normal file
259
dashboard/src/routes/config/bot/types.ts
Normal 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'
|
||||
1643
dashboard/src/routes/config/model.tsx
Normal file
1643
dashboard/src/routes/config/model.tsx
Normal file
File diff suppressed because it is too large
Load Diff
105
dashboard/src/routes/config/model/components/ModelCardList.tsx
Normal file
105
dashboard/src/routes/config/model/components/ModelCardList.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
142
dashboard/src/routes/config/model/components/ModelTable.tsx
Normal file
142
dashboard/src/routes/config/model/components/ModelTable.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
142
dashboard/src/routes/config/model/components/Pagination.tsx
Normal file
142
dashboard/src/routes/config/model/components/Pagination.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
155
dashboard/src/routes/config/model/components/TaskConfigCard.tsx
Normal file
155
dashboard/src/routes/config/model/components/TaskConfigCard.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
8
dashboard/src/routes/config/model/components/index.ts
Normal file
8
dashboard/src/routes/config/model/components/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Model 配置页面组件导出
|
||||
*/
|
||||
|
||||
export { TaskConfigCard } from './TaskConfigCard'
|
||||
export { ModelCardList } from './ModelCardList'
|
||||
export { ModelTable } from './ModelTable'
|
||||
export { Pagination } from './Pagination'
|
||||
107
dashboard/src/routes/config/model/constants.ts
Normal file
107
dashboard/src/routes/config/model/constants.ts
Normal 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
|
||||
7
dashboard/src/routes/config/model/hooks/index.ts
Normal file
7
dashboard/src/routes/config/model/hooks/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Model 配置页面 Hooks 导出
|
||||
*/
|
||||
|
||||
export { useModelAutoSave } from './useModelAutoSave'
|
||||
export { useModelTour } from './useModelTour'
|
||||
export { useModelFetcher, useAutoFetchModels } from './useModelFetcher'
|
||||
164
dashboard/src/routes/config/model/hooks/useModelAutoSave.ts
Normal file
164
dashboard/src/routes/config/model/hooks/useModelAutoSave.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
143
dashboard/src/routes/config/model/hooks/useModelFetcher.ts
Normal file
143
dashboard/src/routes/config/model/hooks/useModelFetcher.ts
Normal 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])
|
||||
}
|
||||
109
dashboard/src/routes/config/model/hooks/useModelTour.ts
Normal file
109
dashboard/src/routes/config/model/hooks/useModelTour.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
15
dashboard/src/routes/config/model/index.ts
Normal file
15
dashboard/src/routes/config/model/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Model 配置页面模块化导出
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export * from './types'
|
||||
|
||||
// 常量
|
||||
export * from './constants'
|
||||
|
||||
// Hooks
|
||||
export * from './hooks'
|
||||
|
||||
// 组件
|
||||
export * from './components'
|
||||
71
dashboard/src/routes/config/model/types.ts
Normal file
71
dashboard/src/routes/config/model/types.ts
Normal 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
|
||||
1713
dashboard/src/routes/config/modelProvider.tsx
Normal file
1713
dashboard/src/routes/config/modelProvider.tsx
Normal file
File diff suppressed because it is too large
Load Diff
11
dashboard/src/routes/config/modelProvider/index.ts
Normal file
11
dashboard/src/routes/config/modelProvider/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 模型提供商配置模块
|
||||
*
|
||||
* 模块结构:
|
||||
* - types.ts: 类型定义
|
||||
* - utils.ts: 工具函数
|
||||
* - 主组件在上级目录的 modelProvider.tsx
|
||||
*/
|
||||
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
33
dashboard/src/routes/config/modelProvider/types.ts
Normal file
33
dashboard/src/routes/config/modelProvider/types.ts
Normal 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
|
||||
}
|
||||
61
dashboard/src/routes/config/modelProvider/utils.ts
Normal file
61
dashboard/src/routes/config/modelProvider/utils.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
929
dashboard/src/routes/config/pack-detail.tsx
Normal file
929
dashboard/src/routes/config/pack-detail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
422
dashboard/src/routes/config/pack-market.tsx
Normal file
422
dashboard/src/routes/config/pack-market.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
235
dashboard/src/routes/config/providerTemplates.ts
Normal file
235
dashboard/src/routes/config/providerTemplates.ts
Normal 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
|
||||
}
|
||||
1054
dashboard/src/routes/index.tsx
Normal file
1054
dashboard/src/routes/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
628
dashboard/src/routes/logs.tsx
Normal file
628
dashboard/src/routes/logs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
68
dashboard/src/routes/model-presets.tsx
Normal file
68
dashboard/src/routes/model-presets.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
86
dashboard/src/routes/monitor/index.tsx
Normal file
86
dashboard/src/routes/monitor/index.tsx
Normal 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">计划器 & 回复器监控</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>
|
||||
)
|
||||
}
|
||||
636
dashboard/src/routes/monitor/planner-monitor.tsx
Normal file
636
dashboard/src/routes/monitor/planner-monitor.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
648
dashboard/src/routes/monitor/replier-monitor.tsx
Normal file
648
dashboard/src/routes/monitor/replier-monitor.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
78
dashboard/src/routes/monitor/use-monitor.ts
Normal file
78
dashboard/src/routes/monitor/use-monitor.ts
Normal 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])
|
||||
}
|
||||
949
dashboard/src/routes/person.tsx
Normal file
949
dashboard/src/routes/person.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
911
dashboard/src/routes/plugin-config.tsx
Normal file
911
dashboard/src/routes/plugin-config.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
674
dashboard/src/routes/plugin-detail.tsx
Normal file
674
dashboard/src/routes/plugin-detail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
603
dashboard/src/routes/plugin-mirrors.tsx
Normal file
603
dashboard/src/routes/plugin-mirrors.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1163
dashboard/src/routes/plugins.tsx
Normal file
1163
dashboard/src/routes/plugins.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1688
dashboard/src/routes/resource/emoji.tsx
Normal file
1688
dashboard/src/routes/resource/emoji.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1159
dashboard/src/routes/resource/expression.tsx
Normal file
1159
dashboard/src/routes/resource/expression.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1064
dashboard/src/routes/resource/jargon.tsx
Normal file
1064
dashboard/src/routes/resource/jargon.tsx
Normal file
File diff suppressed because it is too large
Load Diff
40
dashboard/src/routes/resource/knowledge-base.tsx
Normal file
40
dashboard/src/routes/resource/knowledge-base.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
722
dashboard/src/routes/resource/knowledge-graph.tsx
Normal file
722
dashboard/src/routes/resource/knowledge-graph.tsx
Normal 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 store,占用约数百MB内存。不建议在生产环境中长期开启。
|
||||
</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>
|
||||
)
|
||||
}
|
||||
1765
dashboard/src/routes/settings.tsx
Normal file
1765
dashboard/src/routes/settings.tsx
Normal file
File diff suppressed because it is too large
Load Diff
553
dashboard/src/routes/setup.tsx
Normal file
553
dashboard/src/routes/setup.tsx
Normal 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)
|
||||
|
||||
// 步骤1:Bot基础信息
|
||||
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>
|
||||
)
|
||||
}
|
||||
483
dashboard/src/routes/setup/StepForms.tsx
Normal file
483
dashboard/src/routes/setup/StepForms.tsx
Normal 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'
|
||||
|
||||
// ====== 步骤1:Bot基础配置 ======
|
||||
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 V3、Qwen、视觉模型、语音识别和嵌入模型。
|
||||
只需一个 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>
|
||||
)
|
||||
}
|
||||
|
||||
289
dashboard/src/routes/setup/api.ts
Normal file
289
dashboard/src/routes/setup/api.ts
Normal 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()
|
||||
}
|
||||
46
dashboard/src/routes/setup/types.ts
Normal file
46
dashboard/src/routes/setup/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// 设置向导相关类型定义
|
||||
|
||||
export interface SetupStep {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
// 步骤1:Bot基础信息
|
||||
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
|
||||
}
|
||||
2
dashboard/src/routes/survey/index.ts
Normal file
2
dashboard/src/routes/survey/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { WebUIFeedbackSurveyPage } from './webui-feedback'
|
||||
export { MaiBotFeedbackSurveyPage } from './maibot-feedback'
|
||||
110
dashboard/src/routes/survey/maibot-feedback.tsx
Normal file
110
dashboard/src/routes/survey/maibot-feedback.tsx
Normal 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
|
||||
98
dashboard/src/routes/survey/webui-feedback.tsx
Normal file
98
dashboard/src/routes/survey/webui-feedback.tsx
Normal 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
|
||||
Reference in New Issue
Block a user