上传完整的WebUI前端仓库

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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