上传完整的WebUI前端仓库
This commit is contained in:
1348
dashboard/src/routes/config/adapter.tsx
Normal file
1348
dashboard/src/routes/config/adapter.tsx
Normal file
File diff suppressed because it is too large
Load Diff
10
dashboard/src/routes/config/adapter/index.ts
Normal file
10
dashboard/src/routes/config/adapter/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 适配器配置模块
|
||||
*
|
||||
* 模块结构:
|
||||
* - types.ts: 类型定义和默认配置
|
||||
* - utils.ts: TOML 解析和验证工具函数
|
||||
*/
|
||||
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
105
dashboard/src/routes/config/adapter/types.ts
Normal file
105
dashboard/src/routes/config/adapter/types.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 适配器配置类型定义
|
||||
*/
|
||||
|
||||
import { Package, Container } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* 完整的适配器配置接口
|
||||
*/
|
||||
export interface AdapterConfig {
|
||||
inner: {
|
||||
version: string
|
||||
}
|
||||
nickname: {
|
||||
nickname: string
|
||||
}
|
||||
napcat_server: {
|
||||
host: string
|
||||
port: number
|
||||
token: string
|
||||
heartbeat_interval: number
|
||||
}
|
||||
maibot_server: {
|
||||
host: string
|
||||
port: number
|
||||
}
|
||||
chat: {
|
||||
group_list_type: 'whitelist' | 'blacklist'
|
||||
group_list: number[]
|
||||
private_list_type: 'whitelist' | 'blacklist'
|
||||
private_list: number[]
|
||||
ban_user_id: number[]
|
||||
ban_qq_bot: boolean
|
||||
enable_poke: boolean
|
||||
}
|
||||
voice: {
|
||||
use_tts: boolean
|
||||
}
|
||||
forward: {
|
||||
image_threshold: number
|
||||
}
|
||||
debug: {
|
||||
level: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认配置
|
||||
*/
|
||||
export const DEFAULT_CONFIG: AdapterConfig = {
|
||||
inner: {
|
||||
version: '0.1.2',
|
||||
},
|
||||
nickname: {
|
||||
nickname: '',
|
||||
},
|
||||
napcat_server: {
|
||||
host: 'localhost',
|
||||
port: 8095,
|
||||
token: '',
|
||||
heartbeat_interval: 30,
|
||||
},
|
||||
maibot_server: {
|
||||
host: 'localhost',
|
||||
port: 8000,
|
||||
},
|
||||
chat: {
|
||||
group_list_type: 'whitelist',
|
||||
group_list: [],
|
||||
private_list_type: 'whitelist',
|
||||
private_list: [],
|
||||
ban_user_id: [],
|
||||
ban_qq_bot: false,
|
||||
enable_poke: true,
|
||||
},
|
||||
voice: {
|
||||
use_tts: false,
|
||||
},
|
||||
forward: {
|
||||
image_threshold: 30,
|
||||
},
|
||||
debug: {
|
||||
level: 'INFO',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设配置定义
|
||||
*/
|
||||
export const PRESETS = {
|
||||
oneclick: {
|
||||
name: '一键包',
|
||||
description: '使用一键包部署的适配器配置',
|
||||
path: '../MaiBot-Napcat-Adapter/config.toml',
|
||||
icon: Package,
|
||||
},
|
||||
docker: {
|
||||
name: 'Docker',
|
||||
description: 'Docker Compose 部署的适配器配置',
|
||||
path: '/MaiMBot/adapters-config/config.toml',
|
||||
icon: Container,
|
||||
},
|
||||
} as const
|
||||
|
||||
export type PresetKey = keyof typeof PRESETS
|
||||
285
dashboard/src/routes/config/adapter/utils.ts
Normal file
285
dashboard/src/routes/config/adapter/utils.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* 适配器配置 TOML 处理工具
|
||||
* 使用 smol-toml 库进行可靠的 TOML 解析和生成
|
||||
*/
|
||||
|
||||
import { parse, stringify } from 'smol-toml'
|
||||
import type { AdapterConfig } from './types'
|
||||
import { DEFAULT_CONFIG } from './types'
|
||||
|
||||
/**
|
||||
* 解析 TOML 内容为配置对象
|
||||
* @param content TOML 格式的字符串
|
||||
* @returns 解析后的配置对象
|
||||
* @throws 如果 TOML 格式无效
|
||||
*/
|
||||
export function parseTOML(content: string): AdapterConfig {
|
||||
try {
|
||||
const parsed = parse(content) as unknown as AdapterConfig
|
||||
|
||||
// 合并默认配置,确保所有必需字段都存在
|
||||
return {
|
||||
inner: { ...DEFAULT_CONFIG.inner, ...parsed.inner },
|
||||
nickname: { ...DEFAULT_CONFIG.nickname, ...parsed.nickname },
|
||||
napcat_server: { ...DEFAULT_CONFIG.napcat_server, ...parsed.napcat_server },
|
||||
maibot_server: { ...DEFAULT_CONFIG.maibot_server, ...parsed.maibot_server },
|
||||
chat: { ...DEFAULT_CONFIG.chat, ...parsed.chat },
|
||||
voice: { ...DEFAULT_CONFIG.voice, ...parsed.voice },
|
||||
forward: { ...DEFAULT_CONFIG.forward, ...parsed.forward },
|
||||
debug: { ...DEFAULT_CONFIG.debug, ...parsed.debug },
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('TOML 解析失败:', error)
|
||||
throw new Error(`无法解析 TOML 文件: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将配置对象转换为 TOML 格式字符串
|
||||
* @param config 配置对象
|
||||
* @returns TOML 格式的字符串
|
||||
*/
|
||||
export function generateTOML(config: AdapterConfig): string {
|
||||
try {
|
||||
// 填充默认值的辅助函数
|
||||
const fillDefaults = <T>(value: T, defaultValue: T): T => {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// 创建填充了默认值的配置副本
|
||||
const filledConfig: AdapterConfig = {
|
||||
inner: {
|
||||
version: fillDefaults(config.inner.version, DEFAULT_CONFIG.inner.version),
|
||||
},
|
||||
nickname: {
|
||||
nickname: fillDefaults(config.nickname.nickname, DEFAULT_CONFIG.nickname.nickname),
|
||||
},
|
||||
napcat_server: {
|
||||
host: fillDefaults(config.napcat_server.host, DEFAULT_CONFIG.napcat_server.host),
|
||||
port: fillDefaults(config.napcat_server.port || 0, DEFAULT_CONFIG.napcat_server.port),
|
||||
token: fillDefaults(config.napcat_server.token, DEFAULT_CONFIG.napcat_server.token),
|
||||
heartbeat_interval: fillDefaults(
|
||||
config.napcat_server.heartbeat_interval || 0,
|
||||
DEFAULT_CONFIG.napcat_server.heartbeat_interval
|
||||
),
|
||||
},
|
||||
maibot_server: {
|
||||
host: fillDefaults(config.maibot_server.host, DEFAULT_CONFIG.maibot_server.host),
|
||||
port: fillDefaults(config.maibot_server.port || 0, DEFAULT_CONFIG.maibot_server.port),
|
||||
},
|
||||
chat: {
|
||||
group_list_type: fillDefaults(config.chat.group_list_type, DEFAULT_CONFIG.chat.group_list_type),
|
||||
group_list: config.chat.group_list || [],
|
||||
private_list_type: fillDefaults(config.chat.private_list_type, DEFAULT_CONFIG.chat.private_list_type),
|
||||
private_list: config.chat.private_list || [],
|
||||
ban_user_id: config.chat.ban_user_id || [],
|
||||
ban_qq_bot: config.chat.ban_qq_bot ?? DEFAULT_CONFIG.chat.ban_qq_bot,
|
||||
enable_poke: config.chat.enable_poke ?? DEFAULT_CONFIG.chat.enable_poke,
|
||||
},
|
||||
voice: {
|
||||
use_tts: config.voice.use_tts ?? DEFAULT_CONFIG.voice.use_tts,
|
||||
},
|
||||
forward: {
|
||||
image_threshold: fillDefaults(
|
||||
config.forward.image_threshold || 0,
|
||||
DEFAULT_CONFIG.forward.image_threshold
|
||||
),
|
||||
},
|
||||
debug: {
|
||||
level: fillDefaults(config.debug.level, DEFAULT_CONFIG.debug.level),
|
||||
},
|
||||
}
|
||||
|
||||
// 使用 smol-toml 生成基础 TOML
|
||||
let toml = stringify(filledConfig)
|
||||
|
||||
// 添加注释(smol-toml 不支持注释,需要手动添加)
|
||||
toml = addComments(toml)
|
||||
|
||||
return toml
|
||||
} catch (error) {
|
||||
console.error('TOML 生成失败:', error)
|
||||
throw new Error(`无法生成 TOML 文件: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为生成的 TOML 添加注释
|
||||
* @param toml 基础 TOML 字符串
|
||||
* @returns 添加了注释的 TOML 字符串
|
||||
*/
|
||||
function addComments(toml: string): string {
|
||||
const lines = toml.split('\n')
|
||||
const result: string[] = []
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
|
||||
// [inner] section
|
||||
if (line === '[inner]') {
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('version = ')) {
|
||||
result.push(`${line} # 版本号`)
|
||||
result.push('# 请勿修改版本号,除非你知道自己在做什么')
|
||||
continue
|
||||
}
|
||||
|
||||
// [nickname] section
|
||||
if (line === '[nickname]') {
|
||||
result.push('[nickname] # 现在没用')
|
||||
continue
|
||||
}
|
||||
|
||||
// [napcat_server] section
|
||||
if (line === '[napcat_server]') {
|
||||
result.push('[napcat_server] # Napcat连接的ws服务设置')
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('host = ') && result[result.length - 1]?.includes('[napcat_server]')) {
|
||||
result.push(`${line} # Napcat设定的主机地址`)
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('port = ') && lines[i - 1]?.includes('host')) {
|
||||
result.push(`${line} # Napcat设定的端口`)
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('token = ')) {
|
||||
result.push(`${line} # Napcat设定的访问令牌,若无则留空`)
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('heartbeat_interval = ')) {
|
||||
result.push(`${line} # 与Napcat设置的心跳相同(按秒计)`)
|
||||
continue
|
||||
}
|
||||
|
||||
// [maibot_server] section
|
||||
if (line === '[maibot_server]') {
|
||||
result.push('[maibot_server] # 连接麦麦的ws服务设置')
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('host = ') && result[result.length - 1]?.includes('[maibot_server]')) {
|
||||
result.push(`${line} # 麦麦在.env文件中设置的主机地址,即HOST字段`)
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('port = ') && result[result.length - 1]?.includes('麦麦在.env')) {
|
||||
result.push(`${line} # 麦麦在.env文件中设置的端口,即PORT字段`)
|
||||
continue
|
||||
}
|
||||
|
||||
// [chat] section
|
||||
if (line === '[chat]') {
|
||||
result.push('[chat] # 黑白名单功能')
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('group_list_type = ')) {
|
||||
result.push(`${line} # 群组名单类型,可选为:whitelist, blacklist`)
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('group_list = ')) {
|
||||
result.push(`${line} # 群组名单`)
|
||||
result.push('# 当group_list_type为whitelist时,只有群组名单中的群组可以聊天')
|
||||
result.push('# 当group_list_type为blacklist时,群组名单中的任何群组无法聊天')
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('private_list_type = ')) {
|
||||
result.push(`${line} # 私聊名单类型,可选为:whitelist, blacklist`)
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('private_list = ')) {
|
||||
result.push(`${line} # 私聊名单`)
|
||||
result.push('# 当private_list_type为whitelist时,只有私聊名单中的用户可以聊天')
|
||||
result.push('# 当private_list_type为blacklist时,私聊名单中的任何用户无法聊天')
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('ban_user_id = ')) {
|
||||
result.push(`${line} # 全局禁止名单(全局禁止名单中的用户无法进行任何聊天)`)
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('ban_qq_bot = ')) {
|
||||
result.push(`${line} # 是否屏蔽QQ官方机器人`)
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('enable_poke = ')) {
|
||||
result.push(`${line} # 是否启用戳一戳功能`)
|
||||
continue
|
||||
}
|
||||
|
||||
// [voice] section
|
||||
if (line === '[voice]') {
|
||||
result.push('[voice] # 发送语音设置')
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('use_tts = ')) {
|
||||
result.push(`${line} # 是否使用tts语音(请确保你配置了tts并有对应的adapter)`)
|
||||
continue
|
||||
}
|
||||
|
||||
// [forward] section
|
||||
if (line === '[forward]') {
|
||||
result.push('[forward] # 转发消息处理设置')
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('image_threshold = ')) {
|
||||
result.push(`${line} # 图片数量阈值:转发消息中图片数量超过此值时使用占位符(避免麦麦VLM处理卡死)`)
|
||||
continue
|
||||
}
|
||||
|
||||
// [debug] section
|
||||
if (line.startsWith('level = ') && result[result.length - 1] === '[debug]') {
|
||||
result.push(`${line} # 日志等级(DEBUG, INFO, WARNING, ERROR, CRITICAL)`)
|
||||
continue
|
||||
}
|
||||
|
||||
result.push(line)
|
||||
}
|
||||
|
||||
return result.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证配置路径格式
|
||||
* @param path 文件路径
|
||||
* @returns 验证结果
|
||||
*/
|
||||
export function validatePath(path: string): { valid: boolean; error: string } {
|
||||
if (!path.trim()) {
|
||||
return { valid: false, error: '路径不能为空' }
|
||||
}
|
||||
|
||||
if (!path.toLowerCase().endsWith('.toml')) {
|
||||
return { valid: false, error: '文件必须是 .toml 格式' }
|
||||
}
|
||||
|
||||
// 支持相对路径和绝对路径
|
||||
// Windows 绝对路径: C:\path\to\file.toml 或 \\server\share\file.toml
|
||||
const windowsPathRegex = /^([a-zA-Z]:\\|\\\\[^\\]+\\[^\\]+\\).+\.toml$/i
|
||||
// Linux/Unix 绝对路径: /path/to/file.toml 或 ~/path/to/file.toml
|
||||
const unixPathRegex = /^(\/|~\/).+\.toml$/i
|
||||
// 相对路径: ./path/to/file.toml 或 ../path/to/file.toml 或 path/to/file.toml
|
||||
const relativePathRegex = /^(\.{1,2}[\\/]|[^:\\/]).+\.toml$/i
|
||||
|
||||
const isWindows = windowsPathRegex.test(path)
|
||||
const isUnix = unixPathRegex.test(path)
|
||||
const isRelative = relativePathRegex.test(path)
|
||||
|
||||
if (!isWindows && !isUnix && !isRelative) {
|
||||
return {
|
||||
valid: false,
|
||||
error: '路径格式错误',
|
||||
}
|
||||
}
|
||||
|
||||
// 检查路径中是否包含非法字符
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const illegalChars = /[<>"|?*\x00-\x1F]/
|
||||
if (illegalChars.test(path)) {
|
||||
return { valid: false, error: '路径包含非法字符' }
|
||||
}
|
||||
|
||||
return { valid: true, error: '' }
|
||||
}
|
||||
735
dashboard/src/routes/config/bot.tsx
Normal file
735
dashboard/src/routes/config/bot.tsx
Normal file
@@ -0,0 +1,735 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
BotInfoSection,
|
||||
PersonalitySection,
|
||||
ChatSection,
|
||||
DreamSection,
|
||||
LPMMSection,
|
||||
LogSection,
|
||||
DebugSection,
|
||||
ExperimentalSection,
|
||||
MaimMessageSection,
|
||||
TelemetrySection,
|
||||
FeaturesSection,
|
||||
ExpressionSection,
|
||||
ProcessingSection,
|
||||
MessageReceiveSection,
|
||||
WebUISection,
|
||||
} from './bot/sections'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Save, Power, Code2, Layout } from 'lucide-react'
|
||||
import { getBotConfig, updateBotConfig, getBotConfigRaw, updateBotConfigRaw } from '@/lib/config-api'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Info } from 'lucide-react'
|
||||
import { RestartOverlay } from '@/components/restart-overlay'
|
||||
import { RestartProvider, useRestart } from '@/lib/restart-context'
|
||||
import { CodeEditor } from '@/components'
|
||||
import { parse as parseToml } from 'smol-toml'
|
||||
|
||||
// 导入模块化的类型定义
|
||||
import type {
|
||||
BotConfig,
|
||||
PersonalityConfig,
|
||||
ChatConfig,
|
||||
ExpressionConfig,
|
||||
EmojiConfig,
|
||||
MemoryConfig,
|
||||
ToolConfig,
|
||||
VoiceConfig,
|
||||
MessageReceiveConfig,
|
||||
DreamConfig,
|
||||
LPMMKnowledgeConfig,
|
||||
KeywordReactionConfig,
|
||||
ResponsePostProcessConfig,
|
||||
ChineseTypoConfig,
|
||||
ResponseSplitterConfig,
|
||||
LogConfig,
|
||||
DebugConfig,
|
||||
ExperimentalConfig,
|
||||
MaimMessageConfig,
|
||||
TelemetryConfig,
|
||||
WebUIConfig,
|
||||
} from './bot/types'
|
||||
|
||||
// 导入 useAutoSave hook
|
||||
import { useAutoSave, useConfigAutoSave } from './bot/hooks'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
// ==================== 常量定义 ====================
|
||||
/** Toast 显示前的延迟时间 (毫秒) */
|
||||
const TOAST_DISPLAY_DELAY = 500
|
||||
|
||||
// 主导出组件:包装 RestartProvider
|
||||
export function BotConfigPage() {
|
||||
return (
|
||||
<RestartProvider>
|
||||
<BotConfigPageContent />
|
||||
</RestartProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// 内部实现组件
|
||||
function BotConfigPageContent() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [autoSaving, setAutoSaving] = useState(false)
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
||||
const [editMode, setEditMode] = useState<'visual' | 'source'>('visual')
|
||||
const [sourceCode, setSourceCode] = useState<string>('')
|
||||
const [hasTomlError, setHasTomlError] = useState(false)
|
||||
const [tomlErrorMessage, setTomlErrorMessage] = useState<string>('')
|
||||
const { toast } = useToast()
|
||||
const { triggerRestart, isRestarting } = useRestart()
|
||||
|
||||
// 配置状态
|
||||
const [botConfig, setBotConfig] = useState<BotConfig | null>(null)
|
||||
const [personalityConfig, setPersonalityConfig] = useState<PersonalityConfig | null>(null)
|
||||
const [chatConfig, setChatConfig] = useState<ChatConfig | null>(null)
|
||||
const [expressionConfig, setExpressionConfig] = useState<ExpressionConfig | null>(null)
|
||||
const [emojiConfig, setEmojiConfig] = useState<EmojiConfig | null>(null)
|
||||
const [memoryConfig, setMemoryConfig] = useState<MemoryConfig | null>(null)
|
||||
const [toolConfig, setToolConfig] = useState<ToolConfig | null>(null)
|
||||
const [voiceConfig, setVoiceConfig] = useState<VoiceConfig | null>(null)
|
||||
const [messageReceiveConfig, setMessageReceiveConfig] = useState<MessageReceiveConfig | null>(null)
|
||||
const [dreamConfig, setDreamConfig] = useState<DreamConfig | null>(null)
|
||||
const [lpmmConfig, setLpmmConfig] = useState<LPMMKnowledgeConfig | null>(null)
|
||||
const [keywordReactionConfig, setKeywordReactionConfig] = useState<KeywordReactionConfig | null>(null)
|
||||
const [responsePostProcessConfig, setResponsePostProcessConfig] = useState<ResponsePostProcessConfig | null>(null)
|
||||
const [chineseTypoConfig, setChineseTypoConfig] = useState<ChineseTypoConfig | null>(null)
|
||||
const [responseSplitterConfig, setResponseSplitterConfig] = useState<ResponseSplitterConfig | null>(null)
|
||||
const [logConfig, setLogConfig] = useState<LogConfig | null>(null)
|
||||
const [debugConfig, setDebugConfig] = useState<DebugConfig | null>(null)
|
||||
const [experimentalConfig, setExperimentalConfig] = useState<ExperimentalConfig | null>(null)
|
||||
const [maimMessageConfig, setMaimMessageConfig] = useState<MaimMessageConfig | null>(null)
|
||||
const [telemetryConfig, setTelemetryConfig] = useState<TelemetryConfig | null>(null)
|
||||
const [webuiConfig, setWebuiConfig] = useState<WebUIConfig | null>(null)
|
||||
|
||||
// 用于标记初始加载和配置缓存
|
||||
const initialLoadRef = useRef(true)
|
||||
const configRef = useRef<Record<string, unknown>>({})
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
/**
|
||||
* 翻译 TOML 错误信息为中文
|
||||
*/
|
||||
const translateTomlError = (errorMessage: string): string => {
|
||||
// 分行处理,保留多行格式
|
||||
const lines = errorMessage.split('\n')
|
||||
|
||||
// 翻译第一行(主要错误信息)
|
||||
let firstLine = lines[0]
|
||||
|
||||
// 移除 "Error: " 前缀(如果有)
|
||||
firstLine = firstLine.replace(/^Error:\s*/, '')
|
||||
|
||||
// 常见 TOML 错误模式匹配和翻译
|
||||
const translations: Array<[RegExp, string | ((match: RegExpMatchArray) => string)]> = [
|
||||
// Invalid TOML document 系列
|
||||
[/Invalid TOML document: unrecognized escape sequence/, 'TOML 文档错误:无法识别的转义序列(提示:在双引号字符串中使用 \\\\ 转义反斜杠,或使用单引号字符串)'],
|
||||
[/Invalid TOML document: only letter, numbers, dashes and underscores are allowed in keys/, 'TOML 文档错误:键名只能包含字母、数字、短横线和下划线'],
|
||||
[/Invalid TOML document: (.+)/, 'TOML 文档错误:$1'],
|
||||
|
||||
// 位置错误系列
|
||||
[/Unexpected character.*at line (\d+), column (\d+)/, '第 $1 行第 $2 列:意外的字符'],
|
||||
[/Expected.*at line (\d+), column (\d+)/, '第 $1 行第 $2 列:缺少必要的字符'],
|
||||
[/Invalid.*at line (\d+), column (\d+)/, '第 $1 行第 $2 列:无效的语法'],
|
||||
[/Unterminated string at line (\d+)/, '第 $1 行:字符串未正常结束(缺少引号)'],
|
||||
[/Duplicate key.*at line (\d+)/, '第 $1 行:重复的键名'],
|
||||
[/Invalid escape sequence at line (\d+)/, '第 $1 行:无效的转义序列(提示:在双引号字符串中使用 \\\\ 转义反斜杠)'],
|
||||
[/Expected.*but got.*at line (\d+)/, '第 $1 行:类型不匹配'],
|
||||
[/line (\d+), column (\d+)/, '第 $1 行第 $2 列'],
|
||||
|
||||
// 通用错误系列
|
||||
[/Unexpected end of input/, '意外的文件结束(可能缺少闭合符号)'],
|
||||
[/Unexpected token/, '意外的标记'],
|
||||
[/Invalid number/, '无效的数字'],
|
||||
[/Invalid date/, '无效的日期格式'],
|
||||
[/Invalid boolean/, '无效的布尔值(应为 true 或 false)'],
|
||||
[/Unexpected character/, '意外的字符'],
|
||||
[/unrecognized escape sequence/, '无法识别的转义序列'],
|
||||
]
|
||||
|
||||
// 尝试翻译第一行
|
||||
for (const [pattern, replacement] of translations) {
|
||||
if (pattern.test(firstLine)) {
|
||||
firstLine = firstLine.replace(pattern, replacement as string)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 重组多行错误信息
|
||||
if (lines.length > 1) {
|
||||
lines[0] = firstLine
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
return firstLine
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并设置所有配置状态
|
||||
* 抽取自 loadConfig 和 handleModeChange 中的重复逻辑
|
||||
*/
|
||||
const parseAndSetConfig = useCallback((config: Record<string, unknown>) => {
|
||||
configRef.current = config
|
||||
|
||||
setBotConfig(config.bot as BotConfig)
|
||||
setPersonalityConfig(config.personality as PersonalityConfig)
|
||||
|
||||
// 确保 talk_value_rules 有默认值
|
||||
const chatConfigData = config.chat as ChatConfig
|
||||
if (!chatConfigData.talk_value_rules) {
|
||||
chatConfigData.talk_value_rules = []
|
||||
}
|
||||
setChatConfig(chatConfigData)
|
||||
|
||||
setExpressionConfig(config.expression as ExpressionConfig)
|
||||
setEmojiConfig(config.emoji as EmojiConfig)
|
||||
setMemoryConfig(config.memory as MemoryConfig)
|
||||
setToolConfig(config.tool as ToolConfig)
|
||||
setVoiceConfig(config.voice as VoiceConfig)
|
||||
setMessageReceiveConfig(config.message_receive as MessageReceiveConfig)
|
||||
setDreamConfig(config.dream as DreamConfig)
|
||||
setLpmmConfig(config.lpmm_knowledge as LPMMKnowledgeConfig)
|
||||
setKeywordReactionConfig(config.keyword_reaction as KeywordReactionConfig)
|
||||
setResponsePostProcessConfig(config.response_post_process as ResponsePostProcessConfig)
|
||||
setChineseTypoConfig(config.chinese_typo as ChineseTypoConfig)
|
||||
setResponseSplitterConfig(config.response_splitter as ResponseSplitterConfig)
|
||||
setLogConfig(config.log as LogConfig)
|
||||
setDebugConfig(config.debug as DebugConfig)
|
||||
setExperimentalConfig(config.experimental as ExperimentalConfig)
|
||||
setMaimMessageConfig(config.maim_message as MaimMessageConfig)
|
||||
setTelemetryConfig(config.telemetry as TelemetryConfig)
|
||||
setWebuiConfig(config.webui as WebUIConfig)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 构建完整的配置对象用于保存
|
||||
* 抽取自 saveConfig 和 handleSaveAndRestart 中的重复逻辑
|
||||
*/
|
||||
const buildFullConfig = useCallback(() => {
|
||||
return {
|
||||
...configRef.current,
|
||||
bot: botConfig,
|
||||
personality: personalityConfig,
|
||||
chat: chatConfig,
|
||||
expression: expressionConfig,
|
||||
emoji: emojiConfig,
|
||||
memory: memoryConfig,
|
||||
tool: toolConfig,
|
||||
voice: voiceConfig,
|
||||
message_receive: messageReceiveConfig,
|
||||
dream: dreamConfig,
|
||||
lpmm_knowledge: lpmmConfig,
|
||||
keyword_reaction: keywordReactionConfig,
|
||||
response_post_process: responsePostProcessConfig,
|
||||
chinese_typo: chineseTypoConfig,
|
||||
response_splitter: responseSplitterConfig,
|
||||
log: logConfig,
|
||||
debug: debugConfig,
|
||||
experimental: experimentalConfig,
|
||||
maim_message: maimMessageConfig,
|
||||
telemetry: telemetryConfig,
|
||||
webui: webuiConfig,
|
||||
}
|
||||
}, [
|
||||
botConfig, personalityConfig, chatConfig, expressionConfig,
|
||||
emojiConfig, memoryConfig, toolConfig,
|
||||
voiceConfig, messageReceiveConfig, dreamConfig, lpmmConfig, keywordReactionConfig, responsePostProcessConfig,
|
||||
chineseTypoConfig, responseSplitterConfig, logConfig, debugConfig, experimentalConfig,
|
||||
maimMessageConfig, telemetryConfig, webuiConfig
|
||||
])
|
||||
|
||||
// 加载源代码
|
||||
const loadSourceCode = useCallback(async () => {
|
||||
try {
|
||||
const raw = await getBotConfigRaw()
|
||||
// 将 TOML 基本字符串中的转义序列转换为实际字符以便在编辑器中正确显示
|
||||
// 使用正则表达式只处理双引号字符串内的转义序列,不影响单引号字符串
|
||||
const unescaped = raw.replace(/"([^"]*)"/g, (_match, content) => {
|
||||
const decoded = content
|
||||
.replace(/\\n/g, '\n') // 换行符
|
||||
.replace(/\\t/g, '\t') // 制表符
|
||||
.replace(/\\r/g, '\r') // 回车符
|
||||
.replace(/\\"/g, '"') // 双引号
|
||||
.replace(/\\\\/g, '\\') // 反斜杠(必须放在最后)
|
||||
return `"${decoded}"`
|
||||
})
|
||||
setSourceCode(unescaped)
|
||||
setHasTomlError(false)
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '加载失败',
|
||||
description: error instanceof Error ? error.message : '加载源代码失败',
|
||||
})
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
// 加载配置
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const config = await getBotConfig()
|
||||
parseAndSetConfig(config)
|
||||
setHasUnsavedChanges(false)
|
||||
initialLoadRef.current = false
|
||||
|
||||
// 同时加载源代码
|
||||
await loadSourceCode()
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error)
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: '无法加载配置文件',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [toast, loadSourceCode, parseAndSetConfig])
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
}, [loadConfig])
|
||||
|
||||
// 使用模块化的 useAutoSave hook
|
||||
const { triggerAutoSave, cancelPendingAutoSave } = useAutoSave(
|
||||
initialLoadRef.current,
|
||||
setAutoSaving,
|
||||
setHasUnsavedChanges
|
||||
)
|
||||
|
||||
// 使用 useConfigAutoSave hook 简化配置变化监听
|
||||
// 注意: useConfigAutoSave 是一个 hook,不能在条件语句或循环中调用
|
||||
// 因此我们仍然需要逐个调用,但代码更简洁
|
||||
useConfigAutoSave(botConfig, 'bot', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(personalityConfig, 'personality', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(chatConfig, 'chat', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(expressionConfig, 'expression', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(emojiConfig, 'emoji', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(memoryConfig, 'memory', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(toolConfig, 'tool', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(voiceConfig, 'voice', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(dreamConfig, 'dream', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(lpmmConfig, 'lpmm_knowledge', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(keywordReactionConfig, 'keyword_reaction', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(responsePostProcessConfig, 'response_post_process', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(chineseTypoConfig, 'chinese_typo', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(responseSplitterConfig, 'response_splitter', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(logConfig, 'log', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(debugConfig, 'debug', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(maimMessageConfig, 'maim_message', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(telemetryConfig, 'telemetry', initialLoadRef.current, triggerAutoSave)
|
||||
useConfigAutoSave(webuiConfig, 'webui', initialLoadRef.current, triggerAutoSave)
|
||||
|
||||
// 保存源代码
|
||||
const saveSourceCode = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
|
||||
// 前端验证 TOML 格式
|
||||
try {
|
||||
parseToml(sourceCode)
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'TOML 格式错误'
|
||||
const translatedMsg = translateTomlError(errorMsg)
|
||||
setHasTomlError(true)
|
||||
setTomlErrorMessage(translatedMsg)
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'TOML 格式错误',
|
||||
description: translatedMsg,
|
||||
})
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 将双引号字符串中的实际字符转换回 TOML 转义序列
|
||||
// 使用正则表达式只处理双引号字符串内的内容,不影响单引号字符串
|
||||
const escaped = sourceCode.replace(/"([^"]*)"/g, (_match, content) => {
|
||||
const encoded = content
|
||||
.replace(/\\/g, '\\\\') // 反斜杠(必须放在最前)
|
||||
.replace(/"/g, '\\"') // 双引号
|
||||
.replace(/\n/g, '\\n') // 换行符
|
||||
.replace(/\t/g, '\\t') // 制表符
|
||||
.replace(/\r/g, '\\r') // 回车符
|
||||
return `"${encoded}"`
|
||||
})
|
||||
await updateBotConfigRaw(escaped)
|
||||
setHasUnsavedChanges(false)
|
||||
setHasTomlError(false)
|
||||
setTomlErrorMessage('')
|
||||
toast({
|
||||
title: '保存成功',
|
||||
description: '配置已保存',
|
||||
})
|
||||
// 重新加载可视化配置
|
||||
await loadConfig()
|
||||
} catch (error) {
|
||||
setHasTomlError(true)
|
||||
const errorMsg = error instanceof Error ? error.message : '保存配置失败'
|
||||
setTomlErrorMessage(errorMsg)
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '保存失败',
|
||||
description: errorMsg,
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理模式切换
|
||||
const handleModeChange = async (mode: 'visual' | 'source') => {
|
||||
if (hasUnsavedChanges) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '切换失败',
|
||||
description: '请先保存当前更改',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setEditMode(mode)
|
||||
if (mode === 'source') {
|
||||
await loadSourceCode()
|
||||
} else {
|
||||
// 切换回可视化时,直接重新加载配置但不显示全局 loading
|
||||
try {
|
||||
const config = await getBotConfig()
|
||||
parseAndSetConfig(config)
|
||||
setHasUnsavedChanges(false)
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error)
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: '无法加载配置文件',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 手动保存
|
||||
const saveConfig = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
// 取消待处理的自动保存
|
||||
cancelPendingAutoSave()
|
||||
|
||||
await updateBotConfig(buildFullConfig())
|
||||
setHasUnsavedChanges(false)
|
||||
toast({
|
||||
title: '保存成功',
|
||||
description: '麦麦主程序配置已保存',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 重启麦麦
|
||||
const handleRestart = async () => {
|
||||
await triggerRestart()
|
||||
}
|
||||
|
||||
// 保存并重启
|
||||
const handleSaveAndRestart = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
// 取消待处理的自动保存
|
||||
cancelPendingAutoSave()
|
||||
|
||||
await updateBotConfig(buildFullConfig())
|
||||
setHasUnsavedChanges(false)
|
||||
toast({
|
||||
title: '保存成功',
|
||||
description: '配置已保存,即将重启麦麦...',
|
||||
})
|
||||
// 等待一下让用户看到保存成功的提示
|
||||
await new Promise(resolve => setTimeout(resolve, TOAST_DISPLAY_DELAY))
|
||||
await handleRestart()
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-muted-foreground">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex flex-col gap-3 sm:gap-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold">麦麦主程序配置</h1>
|
||||
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">管理麦麦的核心功能和行为设置</p>
|
||||
</div>
|
||||
{/* 按钮组 - 桌面端靠右 */}
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<Button
|
||||
onClick={editMode === 'visual' ? saveConfig : saveSourceCode}
|
||||
disabled={saving || autoSaving || !hasUnsavedChanges || isRestarting}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-20 sm:w-24"
|
||||
>
|
||||
<Save className="h-4 w-4 flex-shrink-0" strokeWidth={2} fill="none" />
|
||||
<span className="ml-1 truncate text-xs sm:text-sm">
|
||||
{saving ? '保存中' : autoSaving ? '自动' : hasUnsavedChanges ? '保存' : '已保存'}
|
||||
</span>
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
disabled={saving || autoSaving || isRestarting}
|
||||
size="sm"
|
||||
className="w-20 sm:w-28"
|
||||
>
|
||||
<Power className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="ml-1 truncate text-xs sm:text-sm">
|
||||
{isRestarting ? '重启中' : hasUnsavedChanges ? '保存重启' : '重启'}
|
||||
</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认重启麦麦?</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div>
|
||||
<p>
|
||||
{hasUnsavedChanges
|
||||
? '当前有未保存的配置更改。点击确认将先保存配置,然后重启麦麦使新配置生效。重启过程中麦麦将暂时离线。'
|
||||
: '即将重启麦麦主程序。重启过程中麦麦将暂时离线,配置将在重启后生效。'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={hasUnsavedChanges ? handleSaveAndRestart : handleRestart}>
|
||||
{hasUnsavedChanges ? '保存并重启' : '确认重启'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模式切换 - 单独一行 */}
|
||||
<div className="flex">
|
||||
<Tabs value={editMode} onValueChange={(v) => handleModeChange(v as 'visual' | 'source')} className="w-full">
|
||||
<TabsList className="h-8 sm:h-9 w-full grid grid-cols-2">
|
||||
<TabsTrigger value="visual" className="text-xs sm:text-sm">
|
||||
<Layout className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||
可视化编辑
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="source" className="text-xs sm:text-sm">
|
||||
<Code2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||
源代码编辑
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 重启提示 */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
配置更新后需要<strong>重启麦麦</strong>才能生效。你可以点击右上角的"保存并重启"按钮一键完成保存和重启。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* 源代码模式 */}
|
||||
{editMode === 'source' && (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>源代码模式(高级功能):</strong>直接编辑 TOML 配置文件。此功能仅适用于熟悉 TOML 语法的高级用户。保存时会在前端验证格式,只有格式完全正确才能保存。
|
||||
{hasTomlError && tomlErrorMessage && (
|
||||
<div className="text-destructive font-semibold mt-3 p-3 bg-destructive/10 rounded-md">
|
||||
<div className="font-bold mb-2">⚠️ TOML 格式错误:</div>
|
||||
<pre className="text-sm font-mono whitespace-pre-wrap break-words">
|
||||
{tomlErrorMessage}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<CodeEditor
|
||||
value={sourceCode}
|
||||
onChange={(value) => {
|
||||
setSourceCode(value)
|
||||
setHasUnsavedChanges(true)
|
||||
// 清除之前的错误状态
|
||||
if (hasTomlError) {
|
||||
setHasTomlError(false)
|
||||
setTomlErrorMessage('')
|
||||
}
|
||||
}}
|
||||
language="toml"
|
||||
theme="dark"
|
||||
height="calc(100vh - 280px)"
|
||||
minHeight="500px"
|
||||
placeholder="TOML 配置内容"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 可视化模式 */}
|
||||
{editMode === 'visual' && (
|
||||
<>
|
||||
{/* 标签页 */}
|
||||
<Tabs defaultValue="bot" className="w-full">
|
||||
<TabsList className="flex flex-wrap h-auto gap-1 p-1 sm:grid sm:grid-cols-5 lg:grid-cols-10">
|
||||
<TabsTrigger value="bot" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm">基本信息</TabsTrigger>
|
||||
<TabsTrigger value="personality" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm">人格</TabsTrigger>
|
||||
<TabsTrigger value="chat" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm">聊天</TabsTrigger>
|
||||
<TabsTrigger value="expression" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm">表达</TabsTrigger>
|
||||
<TabsTrigger value="features" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm">功能</TabsTrigger>
|
||||
<TabsTrigger value="processing" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm">处理</TabsTrigger>
|
||||
<TabsTrigger value="dream" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm">做梦</TabsTrigger>
|
||||
<TabsTrigger value="lpmm" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm">知识库</TabsTrigger>
|
||||
<TabsTrigger value="webui" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm">WebUI</TabsTrigger>
|
||||
<TabsTrigger value="other" className="text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm">其他</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* 基本信息 */}
|
||||
<TabsContent value="bot" className="space-y-4">
|
||||
{botConfig && <BotInfoSection config={botConfig} onChange={setBotConfig} />}
|
||||
</TabsContent>
|
||||
|
||||
{/* 人格配置 */}
|
||||
<TabsContent value="personality" className="space-y-4">
|
||||
{personalityConfig && (
|
||||
<PersonalitySection config={personalityConfig} onChange={setPersonalityConfig} />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 聊天配置 */}
|
||||
<TabsContent value="chat" className="space-y-4">
|
||||
{chatConfig && <ChatSection config={chatConfig} onChange={setChatConfig} />}
|
||||
</TabsContent>
|
||||
|
||||
{/* 表达配置 */}
|
||||
<TabsContent value="expression" className="space-y-4">
|
||||
{expressionConfig && (
|
||||
<ExpressionSection config={expressionConfig} onChange={setExpressionConfig} />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 功能配置(合并表情、记忆、工具) */}
|
||||
<TabsContent value="features" className="space-y-4">
|
||||
{emojiConfig && memoryConfig && toolConfig && voiceConfig && (
|
||||
<FeaturesSection
|
||||
emojiConfig={emojiConfig}
|
||||
memoryConfig={memoryConfig}
|
||||
toolConfig={toolConfig}
|
||||
voiceConfig={voiceConfig}
|
||||
onEmojiChange={setEmojiConfig}
|
||||
onMemoryChange={setMemoryConfig}
|
||||
onToolChange={setToolConfig}
|
||||
onVoiceChange={setVoiceConfig}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 处理配置(关键词反应和回复后处理) */}
|
||||
<TabsContent value="processing" className="space-y-4">
|
||||
{keywordReactionConfig && responsePostProcessConfig && chineseTypoConfig && responseSplitterConfig && (
|
||||
<ProcessingSection
|
||||
keywordReactionConfig={keywordReactionConfig}
|
||||
responsePostProcessConfig={responsePostProcessConfig}
|
||||
chineseTypoConfig={chineseTypoConfig}
|
||||
responseSplitterConfig={responseSplitterConfig}
|
||||
onKeywordReactionChange={setKeywordReactionConfig}
|
||||
onResponsePostProcessChange={setResponsePostProcessConfig}
|
||||
onChineseTypoChange={setChineseTypoConfig}
|
||||
onResponseSplitterChange={setResponseSplitterConfig}
|
||||
/>
|
||||
)}
|
||||
{messageReceiveConfig && (
|
||||
<MessageReceiveSection
|
||||
config={messageReceiveConfig}
|
||||
onChange={setMessageReceiveConfig}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 做梦配置 */}
|
||||
<TabsContent value="dream" className="space-y-4">
|
||||
{dreamConfig && <DreamSection config={dreamConfig} onChange={setDreamConfig} />}
|
||||
</TabsContent>
|
||||
|
||||
{/* 知识库配置 */}
|
||||
<TabsContent value="lpmm" className="space-y-4">
|
||||
{lpmmConfig && <LPMMSection config={lpmmConfig} onChange={setLpmmConfig} />}
|
||||
</TabsContent>
|
||||
|
||||
{/* WebUI 配置 */}
|
||||
<TabsContent value="webui" className="space-y-4">
|
||||
{webuiConfig && <WebUISection config={webuiConfig} onChange={setWebuiConfig} />}
|
||||
</TabsContent>
|
||||
|
||||
{/* 其他配置 */}
|
||||
<TabsContent value="other" className="space-y-4">
|
||||
{logConfig && <LogSection config={logConfig} onChange={setLogConfig} />}
|
||||
{debugConfig && <DebugSection config={debugConfig} onChange={setDebugConfig} />}
|
||||
{experimentalConfig && <ExperimentalSection config={experimentalConfig} onChange={setExperimentalConfig} />}
|
||||
{maimMessageConfig && <MaimMessageSection config={maimMessageConfig} onChange={setMaimMessageConfig} />}
|
||||
{telemetryConfig && <TelemetrySection config={telemetryConfig} onChange={setTelemetryConfig} />}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 重启遮罩层 */}
|
||||
<RestartOverlay />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
6
dashboard/src/routes/config/bot/hooks/index.ts
Normal file
6
dashboard/src/routes/config/bot/hooks/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Bot 配置页面相关 hooks
|
||||
*/
|
||||
|
||||
export { useAutoSave, useConfigAutoSave } from './useAutoSave'
|
||||
export type { UseAutoSaveOptions, UseAutoSaveReturn, AutoSaveState } from './useAutoSave'
|
||||
166
dashboard/src/routes/config/bot/hooks/useAutoSave.ts
Normal file
166
dashboard/src/routes/config/bot/hooks/useAutoSave.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { updateBotConfigSection } from '@/lib/config-api'
|
||||
import type { ConfigSectionName } from '../types'
|
||||
|
||||
export interface UseAutoSaveOptions {
|
||||
/** 防抖延迟时间(毫秒),默认 2000ms */
|
||||
debounceMs?: number
|
||||
/** 保存成功回调 */
|
||||
onSaveSuccess?: () => void
|
||||
/** 保存失败回调 */
|
||||
onSaveError?: (error: Error) => void
|
||||
}
|
||||
|
||||
export interface UseAutoSaveReturn {
|
||||
/** 触发自动保存 */
|
||||
triggerAutoSave: (sectionName: ConfigSectionName, sectionData: unknown) => void
|
||||
/** 立即保存(不防抖) */
|
||||
saveNow: (sectionName: ConfigSectionName, sectionData: unknown) => Promise<void>
|
||||
/** 取消待处理的自动保存 */
|
||||
cancelPendingAutoSave: () => void
|
||||
}
|
||||
|
||||
export interface AutoSaveState {
|
||||
/** 是否正在保存中 */
|
||||
isAutoSaving: boolean
|
||||
/** 是否有未保存的更改 */
|
||||
hasUnsavedChanges: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动保存 hook
|
||||
*
|
||||
* 用于监听配置变化并自动防抖保存到后端
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { triggerAutoSave } = useAutoSave({
|
||||
* isInitialLoad,
|
||||
* setAutoSaving,
|
||||
* setHasUnsavedChanges,
|
||||
* })
|
||||
*
|
||||
* // 配置变化时触发
|
||||
* useEffect(() => {
|
||||
* if (config) triggerAutoSave('bot', config)
|
||||
* }, [config])
|
||||
* ```
|
||||
*/
|
||||
export function useAutoSave(
|
||||
isInitialLoad: boolean,
|
||||
setAutoSaving: (saving: boolean) => void,
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void,
|
||||
options: UseAutoSaveOptions = {}
|
||||
): UseAutoSaveReturn {
|
||||
const { debounceMs = 2000, onSaveSuccess, onSaveError } = options
|
||||
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// 执行保存操作
|
||||
const saveSection = useCallback(
|
||||
async (sectionName: ConfigSectionName, sectionData: unknown) => {
|
||||
try {
|
||||
setAutoSaving(true)
|
||||
await updateBotConfigSection(sectionName, sectionData)
|
||||
setHasUnsavedChanges(false)
|
||||
onSaveSuccess?.()
|
||||
} catch (error) {
|
||||
console.error(`自动保存 ${sectionName} 失败:`, error)
|
||||
setHasUnsavedChanges(true)
|
||||
onSaveError?.(error instanceof Error ? error : new Error(String(error)))
|
||||
} finally {
|
||||
setAutoSaving(false)
|
||||
}
|
||||
},
|
||||
[setAutoSaving, setHasUnsavedChanges, onSaveSuccess, onSaveError]
|
||||
)
|
||||
|
||||
// 触发自动保存(带防抖)
|
||||
const triggerAutoSave = useCallback(
|
||||
(sectionName: ConfigSectionName, sectionData: unknown) => {
|
||||
if (isInitialLoad) return
|
||||
|
||||
setHasUnsavedChanges(true)
|
||||
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current)
|
||||
}
|
||||
|
||||
autoSaveTimerRef.current = setTimeout(() => {
|
||||
saveSection(sectionName, sectionData)
|
||||
}, debounceMs)
|
||||
},
|
||||
[isInitialLoad, setHasUnsavedChanges, saveSection, debounceMs]
|
||||
)
|
||||
|
||||
// 立即保存(不防抖)
|
||||
const saveNow = useCallback(
|
||||
async (sectionName: ConfigSectionName, sectionData: unknown) => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current)
|
||||
autoSaveTimerRef.current = null
|
||||
}
|
||||
await saveSection(sectionName, sectionData)
|
||||
},
|
||||
[saveSection]
|
||||
)
|
||||
|
||||
// 取消待处理的自动保存
|
||||
const cancelPendingAutoSave = useCallback(() => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current)
|
||||
autoSaveTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
triggerAutoSave,
|
||||
saveNow,
|
||||
cancelPendingAutoSave,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建配置自动保存 effect
|
||||
*
|
||||
* 这是一个工厂函数,用于创建监听特定配置变化并触发自动保存的 effect
|
||||
* 简化重复的 useEffect 代码
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 使用方式 1: 直接在组件中调用
|
||||
* useConfigAutoSave(botConfig, 'bot', isInitialLoad, triggerAutoSave)
|
||||
* useConfigAutoSave(chatConfig, 'chat', isInitialLoad, triggerAutoSave)
|
||||
*
|
||||
* // 使用方式 2: 批量配置
|
||||
* const configs = [
|
||||
* { config: botConfig, section: 'bot' },
|
||||
* { config: chatConfig, section: 'chat' },
|
||||
* ] as const
|
||||
*
|
||||
* configs.forEach(({ config, section }) => {
|
||||
* useConfigAutoSave(config, section, isInitialLoad, triggerAutoSave)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function useConfigAutoSave<T>(
|
||||
config: T | null,
|
||||
sectionName: ConfigSectionName,
|
||||
isInitialLoad: boolean,
|
||||
triggerAutoSave: (sectionName: ConfigSectionName, data: unknown) => void
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (config && !isInitialLoad) {
|
||||
triggerAutoSave(sectionName, config)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config])
|
||||
}
|
||||
24
dashboard/src/routes/config/bot/index.ts
Normal file
24
dashboard/src/routes/config/bot/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Bot 配置模块
|
||||
*
|
||||
* 这个模块包含麦麦主程序配置页面的所有组件和类型
|
||||
*
|
||||
* 目录结构:
|
||||
* - types.ts: 类型定义
|
||||
* - hooks/: 自定义 hooks
|
||||
* - useAutoSave.ts: 自动保存 hook
|
||||
* - sections/: 各个配置区块组件
|
||||
* - BotInfoSection.tsx
|
||||
* - PersonalitySection.tsx
|
||||
* - ChatSection.tsx
|
||||
* - ...等
|
||||
*/
|
||||
|
||||
// 类型导出
|
||||
export * from './types'
|
||||
|
||||
// Hooks 导出
|
||||
export * from './hooks'
|
||||
|
||||
// Section 组件导出
|
||||
export * from './sections'
|
||||
192
dashboard/src/routes/config/bot/sections/BotInfoSection.tsx
Normal file
192
dashboard/src/routes/config/bot/sections/BotInfoSection.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import type { BotConfig } from '../types'
|
||||
|
||||
interface BotInfoSectionProps {
|
||||
config: BotConfig
|
||||
onChange: (config: BotConfig) => void
|
||||
}
|
||||
|
||||
export const BotInfoSection = React.memo(function BotInfoSection({ config, onChange }: BotInfoSectionProps) {
|
||||
// 确保 platforms 和 alias_names 始终是数组
|
||||
const platforms = config.platforms || []
|
||||
const aliasNames = config.alias_names || []
|
||||
|
||||
const addPlatform = () => {
|
||||
onChange({ ...config, platforms: [...platforms, ''] })
|
||||
}
|
||||
|
||||
const removePlatform = (index: number) => {
|
||||
onChange({
|
||||
...config,
|
||||
platforms: platforms.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
|
||||
const updatePlatform = (index: number, value: string) => {
|
||||
const newPlatforms = [...platforms]
|
||||
newPlatforms[index] = value
|
||||
onChange({ ...config, platforms: newPlatforms })
|
||||
}
|
||||
|
||||
const addAlias = () => {
|
||||
onChange({ ...config, alias_names: [...aliasNames, ''] })
|
||||
}
|
||||
|
||||
const removeAlias = (index: number) => {
|
||||
onChange({
|
||||
...config,
|
||||
alias_names: aliasNames.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
|
||||
const updateAlias = (index: number, value: string) => {
|
||||
const newAliases = [...aliasNames]
|
||||
newAliases[index] = value
|
||||
onChange({ ...config, alias_names: newAliases })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">基本信息</h3>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="platform">平台</Label>
|
||||
<Input
|
||||
id="platform"
|
||||
value={config.platform}
|
||||
onChange={(e) => onChange({ ...config, platform: e.target.value })}
|
||||
placeholder="qq"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="qq_account">QQ账号</Label>
|
||||
<Input
|
||||
id="qq_account"
|
||||
value={config.qq_account}
|
||||
onChange={(e) => onChange({ ...config, qq_account: e.target.value })}
|
||||
placeholder="123456789"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="nickname">昵称</Label>
|
||||
<Input
|
||||
id="nickname"
|
||||
value={config.nickname}
|
||||
onChange={(e) => onChange({ ...config, nickname: e.target.value })}
|
||||
placeholder="麦麦"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>别名</Label>
|
||||
<Button onClick={addAlias} size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{aliasNames.map((alias, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={alias}
|
||||
onChange={(e) => updateAlias(index, e.target.value)}
|
||||
placeholder="小麦"
|
||||
/>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="icon" variant="outline">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除别名 "{alias || '(空)'}" 吗?此操作无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => removeAlias(index)}>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
))}
|
||||
{aliasNames.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">暂无别名</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>其他平台账号</Label>
|
||||
<Button onClick={addPlatform} size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{platforms.map((platform, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={platform}
|
||||
onChange={(e) => updatePlatform(index, e.target.value)}
|
||||
placeholder="wx:114514"
|
||||
/>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="icon" variant="outline">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除平台账号 "{platform || '(空)'}" 吗?此操作无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => removePlatform(index)}>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
))}
|
||||
{platforms.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">暂无其他平台账号</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
610
dashboard/src/routes/config/bot/sections/ChatSection.tsx
Normal file
610
dashboard/src/routes/config/bot/sections/ChatSection.tsx
Normal file
@@ -0,0 +1,610 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Plus, Trash2, Eye, Clock } from 'lucide-react'
|
||||
import type { ChatConfig } from '../types'
|
||||
|
||||
interface ChatSectionProps {
|
||||
config: ChatConfig
|
||||
onChange: (config: ChatConfig) => void
|
||||
}
|
||||
|
||||
// 时间选择组件
|
||||
const TimeRangePicker = React.memo(function TimeRangePicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
// 解析初始值
|
||||
const parsedValue = useMemo(() => {
|
||||
const parts = value.split('-')
|
||||
if (parts.length === 2) {
|
||||
const [start, end] = parts
|
||||
const [sh, sm] = start.split(':')
|
||||
const [eh, em] = end.split(':')
|
||||
return {
|
||||
startHour: sh ? sh.padStart(2, '0') : '00',
|
||||
startMinute: sm ? sm.padStart(2, '0') : '00',
|
||||
endHour: eh ? eh.padStart(2, '0') : '23',
|
||||
endMinute: em ? em.padStart(2, '0') : '59',
|
||||
}
|
||||
}
|
||||
return {
|
||||
startHour: '00',
|
||||
startMinute: '00',
|
||||
endHour: '23',
|
||||
endMinute: '59',
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const [startHour, setStartHour] = useState(parsedValue.startHour)
|
||||
const [startMinute, setStartMinute] = useState(parsedValue.startMinute)
|
||||
const [endHour, setEndHour] = useState(parsedValue.endHour)
|
||||
const [endMinute, setEndMinute] = useState(parsedValue.endMinute)
|
||||
|
||||
// 当value变化时同步状态
|
||||
useEffect(() => {
|
||||
setStartHour(parsedValue.startHour)
|
||||
setStartMinute(parsedValue.startMinute)
|
||||
setEndHour(parsedValue.endHour)
|
||||
setEndMinute(parsedValue.endMinute)
|
||||
}, [parsedValue])
|
||||
|
||||
const updateTime = (
|
||||
newStartHour: string,
|
||||
newStartMinute: string,
|
||||
newEndHour: string,
|
||||
newEndMinute: string
|
||||
) => {
|
||||
const newValue = `${newStartHour}:${newStartMinute}-${newEndHour}:${newEndMinute}`
|
||||
onChange(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start font-mono text-sm">
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
{value || '选择时间段'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 sm:w-80">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-3">开始时间</h4>
|
||||
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">小时</Label>
|
||||
<Select
|
||||
value={startHour}
|
||||
onValueChange={(v) => {
|
||||
setStartHour(v)
|
||||
updateTime(v, startMinute, endHour, endMinute)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 24 }, (_, i) => i).map((h) => (
|
||||
<SelectItem key={h} value={h.toString().padStart(2, '0')}>
|
||||
{h.toString().padStart(2, '0')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">分钟</Label>
|
||||
<Select
|
||||
value={startMinute}
|
||||
onValueChange={(v) => {
|
||||
setStartMinute(v)
|
||||
updateTime(startHour, v, endHour, endMinute)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 60 }, (_, i) => i).map((m) => (
|
||||
<SelectItem key={m} value={m.toString().padStart(2, '0')}>
|
||||
{m.toString().padStart(2, '0')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-3">结束时间</h4>
|
||||
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">小时</Label>
|
||||
<Select
|
||||
value={endHour}
|
||||
onValueChange={(v) => {
|
||||
setEndHour(v)
|
||||
updateTime(startHour, startMinute, v, endMinute)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 24 }, (_, i) => i).map((h) => (
|
||||
<SelectItem key={h} value={h.toString().padStart(2, '0')}>
|
||||
{h.toString().padStart(2, '0')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">分钟</Label>
|
||||
<Select
|
||||
value={endMinute}
|
||||
onValueChange={(v) => {
|
||||
setEndMinute(v)
|
||||
updateTime(startHour, startMinute, endHour, v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 60 }, (_, i) => i).map((m) => (
|
||||
<SelectItem key={m} value={m.toString().padStart(2, '0')}>
|
||||
{m.toString().padStart(2, '0')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
})
|
||||
|
||||
// 预览窗口组件
|
||||
const RulePreview = React.memo(function RulePreview({ rule }: { rule: { target: string; time: string; value: number } }) {
|
||||
const previewText = `{ target = "${rule.target}", time = "${rule.time}", value = ${rule.value.toFixed(1)} }`
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
预览
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 sm:w-96">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-sm">配置预览</h4>
|
||||
<div className="rounded-md bg-muted p-3 font-mono text-xs break-all">
|
||||
{previewText}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
这是保存到 bot_config.toml 文件中的格式
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
})
|
||||
|
||||
export const ChatSection = React.memo(function ChatSection({ config, onChange }: ChatSectionProps) {
|
||||
// 添加发言频率规则
|
||||
const addTalkValueRule = () => {
|
||||
onChange({
|
||||
...config,
|
||||
talk_value_rules: [
|
||||
...config.talk_value_rules,
|
||||
{ target: '', time: '00:00-23:59', value: 1.0 },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// 删除发言频率规则
|
||||
const removeTalkValueRule = (index: number) => {
|
||||
onChange({
|
||||
...config,
|
||||
talk_value_rules: config.talk_value_rules.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
|
||||
// 更新发言频率规则
|
||||
const updateTalkValueRule = (
|
||||
index: number,
|
||||
field: 'target' | 'time' | 'value',
|
||||
value: string | number
|
||||
) => {
|
||||
const newRules = [...config.talk_value_rules]
|
||||
newRules[index] = {
|
||||
...newRules[index],
|
||||
[field]: value,
|
||||
}
|
||||
onChange({
|
||||
...config,
|
||||
talk_value_rules: newRules,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">聊天设置</h3>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="talk_value">聊天频率(基础值)</Label>
|
||||
<Input
|
||||
id="talk_value"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={config.talk_value}
|
||||
onChange={(e) => onChange({ ...config, talk_value: parseFloat(e.target.value) })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">越小越沉默,范围 0-1</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="think_mode">思考模式</Label>
|
||||
<Select
|
||||
value={config.think_mode || 'classic'}
|
||||
onValueChange={(value) => onChange({ ...config, think_mode: value as 'classic' | 'deep' | 'dynamic' })}
|
||||
>
|
||||
<SelectTrigger id="think_mode">
|
||||
<SelectValue placeholder="选择思考模式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="classic">经典模式 - 浅度思考和回复</SelectItem>
|
||||
<SelectItem value="deep">深度模式 - 进行深度思考和回复</SelectItem>
|
||||
<SelectItem value="dynamic">动态模式 - 自动选择思考深度</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
控制麦麦的思考深度。经典模式回复快但简单;深度模式更深入但较慢;动态模式根据情况自动选择
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="mentioned_bot_reply"
|
||||
checked={config.mentioned_bot_reply}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, mentioned_bot_reply: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="mentioned_bot_reply" className="cursor-pointer">
|
||||
启用提及必回复
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="max_context_size">上下文长度</Label>
|
||||
<Input
|
||||
id="max_context_size"
|
||||
type="number"
|
||||
min="1"
|
||||
value={config.max_context_size}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, max_context_size: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="planner_smooth">规划器平滑</Label>
|
||||
<Input
|
||||
id="planner_smooth"
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={config.planner_smooth}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, planner_smooth: parseFloat(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
增大数值会减小 planner 负荷,推荐 1-5,0 为关闭
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="plan_reply_log_max_per_chat">每个聊天流最大日志数量</Label>
|
||||
<Input
|
||||
id="plan_reply_log_max_per_chat"
|
||||
type="number"
|
||||
step="1"
|
||||
min="100"
|
||||
value={config.plan_reply_log_max_per_chat ?? 1024}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, plan_reply_log_max_per_chat: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
每个聊天流保存的 Plan/Reply 日志最大数量,超过此数量时会自动删除最老的日志
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="llm_quote"
|
||||
checked={config.llm_quote ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, llm_quote: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="llm_quote" className="cursor-pointer">
|
||||
启用 LLM 控制引用
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-2 ml-10">
|
||||
启用后,LLM 可以决定是否在回复时引用消息
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="enable_talk_value_rules"
|
||||
checked={config.enable_talk_value_rules}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, enable_talk_value_rules: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="enable_talk_value_rules" className="cursor-pointer">
|
||||
启用动态发言频率规则
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 动态发言频率规则配置 */}
|
||||
{config.enable_talk_value_rules && (
|
||||
<div className="border-t pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold">动态发言频率规则</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
按时段或聊天流ID调整发言频率,优先匹配具体聊天,再匹配全局规则
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={addTalkValueRule} size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加规则
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{config.talk_value_rules && config.talk_value_rules.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{config.talk_value_rules.map((rule, index) => (
|
||||
<div key={index} className="rounded-lg border p-4 bg-muted/50 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
规则 #{index + 1}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<RulePreview rule={rule} />
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除规则 #{index + 1} 吗?此操作无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => removeTalkValueRule(index)}>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 配置类型选择 */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">配置类型</Label>
|
||||
<Select
|
||||
value={rule.target === '' ? 'global' : 'specific'}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'global') {
|
||||
updateTalkValueRule(index, 'target', '')
|
||||
} else {
|
||||
updateTalkValueRule(index, 'target', 'qq::group')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">全局配置</SelectItem>
|
||||
<SelectItem value="specific">详细配置</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 详细配置选项 - 只在非全局时显示 */}
|
||||
{rule.target !== '' && (() => {
|
||||
const parts = rule.target.split(':')
|
||||
const platform = parts[0] || 'qq'
|
||||
const chatId = parts[1] || ''
|
||||
const chatType = parts[2] || 'group'
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">平台</Label>
|
||||
<Select
|
||||
value={platform}
|
||||
onValueChange={(value) => {
|
||||
updateTalkValueRule(index, 'target', `${value}:${chatId}:${chatType}`)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="qq">QQ</SelectItem>
|
||||
<SelectItem value="wx">微信</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">群 ID</Label>
|
||||
<Input
|
||||
value={chatId}
|
||||
onChange={(e) => {
|
||||
updateTalkValueRule(index, 'target', `${platform}:${e.target.value}:${chatType}`)
|
||||
}}
|
||||
placeholder="输入群 ID"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">类型</Label>
|
||||
<Select
|
||||
value={chatType}
|
||||
onValueChange={(value) => {
|
||||
updateTalkValueRule(index, 'target', `${platform}:${chatId}:${value}`)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="group">群组(group)</SelectItem>
|
||||
<SelectItem value="private">私聊(private)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
当前聊天流 ID:{rule.target || '(未设置)'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* 时间段选择器 */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">时间段 (Time)</Label>
|
||||
<TimeRangePicker
|
||||
value={rule.time}
|
||||
onChange={(v) => updateTalkValueRule(index, 'time', v)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
支持跨夜区间,例如 23:00-02:00
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 发言频率滑块 */}
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={`rule-value-${index}`} className="text-xs font-medium">
|
||||
发言频率值 (Value)
|
||||
</Label>
|
||||
<Input
|
||||
id={`rule-value-${index}`}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max="1"
|
||||
value={rule.value}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value)
|
||||
if (!isNaN(val)) {
|
||||
updateTalkValueRule(index, 'value', Math.max(0.01, Math.min(1, val)))
|
||||
}
|
||||
}}
|
||||
className="w-20 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
value={[rule.value]}
|
||||
onValueChange={(values) =>
|
||||
updateTalkValueRule(index, 'value', values[0])
|
||||
}
|
||||
min={0.01}
|
||||
max={1}
|
||||
step={0.01}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>0.01 (极少发言)</span>
|
||||
<span>0.5</span>
|
||||
<span>1.0 (正常)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p className="text-sm">暂无规则,点击"添加规则"按钮创建</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<h5 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-2">
|
||||
📝 规则说明
|
||||
</h5>
|
||||
<ul className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
|
||||
<li>• <strong>Target 为空</strong>:全局规则,对所有聊天生效</li>
|
||||
<li>• <strong>Target 指定</strong>:仅对特定聊天流生效(格式:platform:id:type)</li>
|
||||
<li>• <strong>优先级</strong>:先匹配具体聊天流规则,再匹配全局规则</li>
|
||||
<li>• <strong>时间支持跨夜</strong>:例如 23:00-02:00 表示晚上11点到次日凌晨2点</li>
|
||||
<li>• <strong>数值范围</strong>:建议 0-1,0 表示完全沉默,1 表示正常发言</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
97
dashboard/src/routes/config/bot/sections/DebugSection.tsx
Normal file
97
dashboard/src/routes/config/bot/sections/DebugSection.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import type { DebugConfig } from '../types'
|
||||
|
||||
interface DebugSectionProps {
|
||||
config: DebugConfig
|
||||
onChange: (config: DebugConfig) => void
|
||||
}
|
||||
|
||||
export const DebugSection = React.memo(function DebugSection({ config, onChange }: DebugSectionProps) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold">调试配置</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>显示 Prompt</Label>
|
||||
<p className="text-sm text-muted-foreground">是否在日志中显示提示词</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.show_prompt}
|
||||
onCheckedChange={(checked) => onChange({ ...config, show_prompt: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>显示回复器 Prompt</Label>
|
||||
<p className="text-sm text-muted-foreground">是否显示回复器的提示词</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.show_replyer_prompt}
|
||||
onCheckedChange={(checked) => onChange({ ...config, show_replyer_prompt: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>显示回复器推理</Label>
|
||||
<p className="text-sm text-muted-foreground">是否显示回复器的推理过程</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.show_replyer_reasoning}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, show_replyer_reasoning: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>显示 Jargon Prompt</Label>
|
||||
<p className="text-sm text-muted-foreground">是否显示术语相关的提示词</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.show_jargon_prompt}
|
||||
onCheckedChange={(checked) => onChange({ ...config, show_jargon_prompt: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>显示记忆检索 Prompt</Label>
|
||||
<p className="text-sm text-muted-foreground">是否显示记忆检索相关的提示词</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.show_memory_prompt}
|
||||
onCheckedChange={(checked) => onChange({ ...config, show_memory_prompt: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>显示 Planner Prompt</Label>
|
||||
<p className="text-sm text-muted-foreground">是否显示 Planner 的提示词和原始返回结果</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.show_planner_prompt}
|
||||
onCheckedChange={(checked) => onChange({ ...config, show_planner_prompt: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>显示 LPMM 相关文段</Label>
|
||||
<p className="text-sm text-muted-foreground">是否显示 LPMM 知识库找到的相关文段日志</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.show_lpmm_paragraph}
|
||||
onCheckedChange={(checked) => onChange({ ...config, show_lpmm_paragraph: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
215
dashboard/src/routes/config/bot/sections/DreamSection.tsx
Normal file
215
dashboard/src/routes/config/bot/sections/DreamSection.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { X } from 'lucide-react'
|
||||
import type { DreamConfig } from '../types'
|
||||
|
||||
interface DreamSectionProps {
|
||||
config: DreamConfig
|
||||
onChange: (config: DreamConfig) => void
|
||||
}
|
||||
|
||||
interface TimeRange {
|
||||
startTime: string
|
||||
endTime: string
|
||||
}
|
||||
|
||||
export const DreamSection = React.memo(function DreamSection({ config, onChange }: DreamSectionProps) {
|
||||
// 解析 dream_send 为 platform 和 userId
|
||||
const parseDreamSend = (dreamSend: string): { platform: string; userId: string } => {
|
||||
if (!dreamSend || !dreamSend.includes(':')) {
|
||||
return { platform: 'qq', userId: '' }
|
||||
}
|
||||
const [platform, userId] = dreamSend.split(':')
|
||||
return { platform, userId }
|
||||
}
|
||||
|
||||
const { platform: initialPlatform, userId: initialUserId } = parseDreamSend(config.dream_send)
|
||||
const [platform, setPlatform] = useState(initialPlatform)
|
||||
const [userId, setUserId] = useState(initialUserId)
|
||||
|
||||
// 解析时间段字符串为开始和结束时间
|
||||
const parseTimeRange = (range: string): TimeRange => {
|
||||
const [start, end] = range.split('-')
|
||||
return { startTime: start || '09:00', endTime: end || '22:00' }
|
||||
}
|
||||
|
||||
// 更新 dream_send
|
||||
const updateDreamSend = (newPlatform: string, newUserId: string) => {
|
||||
const dreamSend = newUserId ? `${newPlatform}:${newUserId}` : ''
|
||||
onChange({ ...config, dream_send: dreamSend })
|
||||
}
|
||||
|
||||
const handlePlatformChange = (value: string) => {
|
||||
setPlatform(value)
|
||||
updateDreamSend(value, userId)
|
||||
}
|
||||
|
||||
const handleUserIdChange = (value: string) => {
|
||||
setUserId(value)
|
||||
updateDreamSend(platform, value)
|
||||
}
|
||||
|
||||
const handleAddTimeRange = () => {
|
||||
onChange({
|
||||
...config,
|
||||
dream_time_ranges: [...config.dream_time_ranges, '09:00-22:00']
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemoveTimeRange = (index: number) => {
|
||||
onChange({
|
||||
...config,
|
||||
dream_time_ranges: config.dream_time_ranges.filter((_, i) => i !== index)
|
||||
})
|
||||
}
|
||||
|
||||
const handleTimeRangeChange = (index: number, field: 'startTime' | 'endTime', value: string) => {
|
||||
const newRanges = [...config.dream_time_ranges]
|
||||
const currentRange = parseTimeRange(newRanges[index])
|
||||
|
||||
if (field === 'startTime') {
|
||||
currentRange.startTime = value
|
||||
} else {
|
||||
currentRange.endTime = value
|
||||
}
|
||||
|
||||
newRanges[index] = `${currentRange.startTime}-${currentRange.endTime}`
|
||||
onChange({
|
||||
...config,
|
||||
dream_time_ranges: newRanges
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
|
||||
<h3 className="text-lg font-semibold">做梦配置</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="interval_minutes">做梦时间间隔(分钟)</Label>
|
||||
<Input
|
||||
id="interval_minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
value={config.interval_minutes}
|
||||
onChange={(e) => onChange({ ...config, interval_minutes: Number(e.target.value) })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">默认30分钟</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max_iterations">做梦最大轮次</Label>
|
||||
<Input
|
||||
id="max_iterations"
|
||||
type="number"
|
||||
min="1"
|
||||
value={config.max_iterations}
|
||||
onChange={(e) => onChange({ ...config, max_iterations: Number(e.target.value) })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">默认20轮</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first_delay_seconds">首次做梦延迟(秒)</Label>
|
||||
<Input
|
||||
id="first_delay_seconds"
|
||||
type="number"
|
||||
min="0"
|
||||
value={config.first_delay_seconds}
|
||||
onChange={(e) => onChange({ ...config, first_delay_seconds: Number(e.target.value) })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">程序启动后首次做梦前的延迟时间,默认60秒</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>做梦结果推送目标</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select value={platform} onValueChange={handlePlatformChange}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="选择平台" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="qq">QQ</SelectItem>
|
||||
<SelectItem value="wx">微信</SelectItem>
|
||||
<SelectItem value="webui">WebUI</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="输入用户ID (例如: 123456)"
|
||||
value={userId}
|
||||
onChange={(e) => handleUserIdChange(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择平台并输入用户ID,做梦结束后将梦境发送给该用户。用户ID为空则不推送
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>做梦时间段配置</Label>
|
||||
<Button type="button" size="sm" onClick={handleAddTimeRange}>
|
||||
添加时间段
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
设置允许做梦的时间段,支持跨夜区间(如 23:00 到次日 02:00)。列表为空则全天允许做梦
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{config.dream_time_ranges.map((range, index) => {
|
||||
const { startTime, endTime } = parseTimeRange(range)
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => handleTimeRangeChange(index, 'startTime', e.target.value)}
|
||||
className="w-[140px]"
|
||||
/>
|
||||
<span className="text-muted-foreground">至</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => handleTimeRangeChange(index, 'endTime', e.target.value)}
|
||||
className="w-[140px]"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveTimeRange(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{config.dream_time_ranges.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">当前配置为全天允许做梦</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="dream_visible"
|
||||
checked={config.dream_visible}
|
||||
onCheckedChange={(checked) => onChange({ ...config, dream_visible: checked })}
|
||||
/>
|
||||
<Label htmlFor="dream_visible" className="cursor-pointer">
|
||||
梦境结果存储到上下文
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
开启后,梦境发送给配置的用户后,也会存储到聊天上下文中,在后续对话中可见
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
311
dashboard/src/routes/config/bot/sections/ExperimentalSection.tsx
Normal file
311
dashboard/src/routes/config/bot/sections/ExperimentalSection.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import React from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Plus, Trash2, AlertTriangle, Eye, Code2 } from 'lucide-react'
|
||||
import type { ExperimentalConfig } from '../types'
|
||||
|
||||
interface ChatPromptData {
|
||||
platform: string
|
||||
id: string
|
||||
type: 'group' | 'private'
|
||||
prompt: string
|
||||
}
|
||||
|
||||
interface ExperimentalSectionProps {
|
||||
config: ExperimentalConfig
|
||||
onChange: (config: ExperimentalConfig) => void
|
||||
}
|
||||
|
||||
export const ExperimentalSection = React.memo(function ExperimentalSection({ config, onChange }: ExperimentalSectionProps) {
|
||||
// 解析 chat_prompt 字符串为结构化数据
|
||||
const parseChatPrompt = (promptStr: string): ChatPromptData => {
|
||||
const parts = promptStr.split(':')
|
||||
if (parts.length >= 4) {
|
||||
const platform = parts[0]
|
||||
const id = parts[1]
|
||||
const type = parts[2] as 'group' | 'private'
|
||||
const prompt = parts.slice(3).join(':') // 处理 prompt 中可能包含的冒号
|
||||
return { platform, id, type, prompt }
|
||||
}
|
||||
return { platform: 'qq', id: '', type: 'group', prompt: '' }
|
||||
}
|
||||
|
||||
// 将结构化数据转换为字符串
|
||||
const stringifyChatPrompt = (data: ChatPromptData): string => {
|
||||
return `${data.platform}:${data.id}:${data.type}:${data.prompt}`
|
||||
}
|
||||
|
||||
const addChatPrompt = () => {
|
||||
onChange({ ...config, chat_prompts: [...config.chat_prompts, 'qq::group:'] })
|
||||
}
|
||||
|
||||
const removeChatPrompt = (index: number) => {
|
||||
onChange({
|
||||
...config,
|
||||
chat_prompts: config.chat_prompts.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
|
||||
const updateChatPrompt = (index: number, data: Partial<ChatPromptData>) => {
|
||||
const currentData = parseChatPrompt(config.chat_prompts[index])
|
||||
const newData = { ...currentData, ...data }
|
||||
const newPrompts = [...config.chat_prompts]
|
||||
newPrompts[index] = stringifyChatPrompt(newData)
|
||||
onChange({ ...config, chat_prompts: newPrompts })
|
||||
}
|
||||
|
||||
// 预览组件
|
||||
const ChatPromptPreview = ({ promptStr }: { promptStr: string }) => {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
预览
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 sm:w-96">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-sm">配置预览</h4>
|
||||
<div className="rounded-md bg-muted p-3 font-mono text-xs break-all">
|
||||
"{promptStr}"
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
这是保存到 bot_config.toml 文件中的格式
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20">
|
||||
<AlertTriangle className="h-5 w-5 text-orange-500 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-medium text-orange-500">实验性功能</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
此部分包含实验性功能,可能不稳定或在未来版本中发生变化。请谨慎使用,并注意不推荐在生产环境中修改私聊规则。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">实验性设置</h3>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="lpmm_memory"
|
||||
checked={config.lpmm_memory ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, lpmm_memory: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="lpmm_memory" className="cursor-pointer">
|
||||
将聊天历史总结导入到 LPMM 知识库
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-4">
|
||||
开启后,chat_history_summarizer 总结出的历史记录会同时导入到知识库
|
||||
</p>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="private_plan_style">私聊规则(实验性)</Label>
|
||||
<Textarea
|
||||
id="private_plan_style"
|
||||
value={config.private_plan_style}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange({ ...config, private_plan_style: e.target.value })}
|
||||
placeholder="私聊的说话规则和行为风格(不推荐修改)"
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
⚠️ 不推荐修改此项,可能会影响私聊对话的稳定性
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>特定聊天 Prompt 配置</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
为指定聊天添加额外的 prompt,用于定制特定场景的对话行为
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={addChatPrompt} size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加配置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{config.chat_prompts.map((promptStr, index) => {
|
||||
const data = parseChatPrompt(promptStr)
|
||||
|
||||
return (
|
||||
<div key={index} className="rounded-lg border p-4 space-y-4 bg-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
Prompt 配置 {index + 1}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChatPromptPreview promptStr={promptStr} />
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="ghost">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除这个 prompt 配置吗?此操作无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => removeChatPrompt(index)}>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{/* 平台选择 */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">平台</Label>
|
||||
<Select
|
||||
value={data.platform}
|
||||
onValueChange={(value) => updateChatPrompt(index, { platform: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择平台" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="qq">QQ</SelectItem>
|
||||
<SelectItem value="wx">微信</SelectItem>
|
||||
<SelectItem value="webui">WebUI</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* ID 输入 */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">
|
||||
{data.type === 'group' ? '群号' : '用户ID'}
|
||||
</Label>
|
||||
<Input
|
||||
value={data.id}
|
||||
onChange={(e) => updateChatPrompt(index, { id: e.target.value })}
|
||||
placeholder={data.type === 'group' ? '输入群号' : '输入用户ID'}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 类型选择 */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">类型</Label>
|
||||
<Select
|
||||
value={data.type}
|
||||
onValueChange={(value: 'group' | 'private') => updateChatPrompt(index, { type: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="group">群聊 (group)</SelectItem>
|
||||
<SelectItem value="private">私聊 (private)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Prompt 内容 */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">Prompt 内容</Label>
|
||||
<Textarea
|
||||
value={data.prompt}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => updateChatPrompt(index, { prompt: e.target.value })}
|
||||
placeholder="输入额外的 prompt 内容,例如:这是一个摄影群,你精通摄影知识"
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
这段文本会作为系统提示添加到该聊天的上下文中
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 原始格式显示 */}
|
||||
<div className="rounded-md bg-muted/50 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Code2 className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground">原始格式</span>
|
||||
</div>
|
||||
<code className="text-xs font-mono text-muted-foreground break-all">
|
||||
{promptStr || '(未配置)'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{config.chat_prompts.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p className="text-sm">暂无特定聊天 prompt 配置</p>
|
||||
<p className="text-xs mt-1">点击上方"添加配置"按钮创建新配置</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<div className="text-xs text-muted-foreground space-y-2 p-4 rounded-lg bg-muted/30 border">
|
||||
<p className="font-medium text-foreground">💡 使用说明</p>
|
||||
<ul className="list-disc list-inside space-y-1 pl-2">
|
||||
<li>为不同的聊天环境配置专属的行为提示</li>
|
||||
<li>支持多个平台:QQ、微信、WebUI</li>
|
||||
<li>可为群聊或私聊分别配置</li>
|
||||
<li>Prompt 会自动注入到该聊天的上下文中</li>
|
||||
</ul>
|
||||
<p className="font-medium text-foreground mt-3">📝 配置示例</p>
|
||||
<ul className="list-disc list-inside space-y-1 pl-2">
|
||||
<li>摄影群:<code className="text-xs bg-muted px-1 py-0.5 rounded">这是一个摄影群,你精通摄影知识</code></li>
|
||||
<li>二次元群:<code className="text-xs bg-muted px-1 py-0.5 rounded">这是一个二次元交流群</code></li>
|
||||
<li>好友私聊:<code className="text-xs bg-muted px-1 py-0.5 rounded">这是你与好朋友的私聊</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
996
dashboard/src/routes/config/bot/sections/ExpressionSection.tsx
Normal file
996
dashboard/src/routes/config/bot/sections/ExpressionSection.tsx
Normal file
@@ -0,0 +1,996 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Plus, Trash2, Eye } from 'lucide-react'
|
||||
import type { ExpressionConfig } from '../types'
|
||||
|
||||
interface ExpressionGroupMemberInputProps {
|
||||
member: string
|
||||
groupIndex: number
|
||||
memberIndex: number
|
||||
availableChatIds: string[]
|
||||
onUpdate: (groupIndex: number, memberIndex: number, value: string) => void
|
||||
onRemove: (groupIndex: number, memberIndex: number) => void
|
||||
}
|
||||
|
||||
const ExpressionGroupMemberInput = React.memo(function ExpressionGroupMemberInput({
|
||||
member,
|
||||
groupIndex,
|
||||
memberIndex,
|
||||
availableChatIds,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: ExpressionGroupMemberInputProps) {
|
||||
// 判断当前成员是否在可选列表中
|
||||
const isFromList = availableChatIds.includes(member) || member === '*'
|
||||
const [inputMode, setInputMode] = useState(!isFromList)
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{/* 输入模式切换 */}
|
||||
<div className="flex-1 flex gap-2">
|
||||
{inputMode ? (
|
||||
// 手动输入模式
|
||||
<>
|
||||
<Input
|
||||
value={member}
|
||||
onChange={(e) => onUpdate(groupIndex, memberIndex, e.target.value)}
|
||||
placeholder='输入 "*" 或 "qq:123456:group"'
|
||||
className="flex-1"
|
||||
/>
|
||||
{availableChatIds.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setInputMode(false)}
|
||||
title="切换到下拉选择"
|
||||
>
|
||||
下拉
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// 下拉选择模式
|
||||
<>
|
||||
<Select
|
||||
value={member}
|
||||
onValueChange={(value) => onUpdate(groupIndex, memberIndex, value)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="选择聊天流" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="*">* (全局共享)</SelectItem>
|
||||
{availableChatIds.map((chatId, idx) => (
|
||||
<SelectItem key={idx} value={chatId}>
|
||||
{chatId}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setInputMode(true)}
|
||||
title="切换到手动输入"
|
||||
>
|
||||
输入
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="icon" variant="outline">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除组成员 "{member || '(空)'}" 吗?此操作无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => onRemove(groupIndex, memberIndex)}>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
interface ExpressionSectionProps {
|
||||
config: ExpressionConfig
|
||||
onChange: (config: ExpressionConfig) => void
|
||||
}
|
||||
|
||||
export const ExpressionSection = React.memo(function ExpressionSection({
|
||||
config,
|
||||
onChange,
|
||||
}: ExpressionSectionProps) {
|
||||
// 添加学习规则
|
||||
const addLearningRule = () => {
|
||||
onChange({
|
||||
...config,
|
||||
learning_list: [...config.learning_list, ['', 'enable', 'enable', '1.0']],
|
||||
})
|
||||
}
|
||||
|
||||
// 删除学习规则
|
||||
const removeLearningRule = (index: number) => {
|
||||
onChange({
|
||||
...config,
|
||||
learning_list: config.learning_list.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
|
||||
// 更新学习规则
|
||||
const updateLearningRule = (
|
||||
index: number,
|
||||
field: 0 | 1 | 2 | 3,
|
||||
value: string
|
||||
) => {
|
||||
const newList = [...config.learning_list]
|
||||
newList[index][field] = value
|
||||
onChange({
|
||||
...config,
|
||||
learning_list: newList,
|
||||
})
|
||||
}
|
||||
|
||||
// 预览组件
|
||||
const LearningRulePreview = ({ rule }: { rule: [string, string, string, string] }) => {
|
||||
const previewText = `["${rule[0]}", "${rule[1]}", "${rule[2]}", "${rule[3]}"]`
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
预览
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 sm:w-96">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-sm">配置预览</h4>
|
||||
<div className="rounded-md bg-muted p-3 font-mono text-xs break-all">
|
||||
{previewText}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
这是保存到 bot_config.toml 文件中的格式
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
// 添加表达组
|
||||
const addExpressionGroup = () => {
|
||||
onChange({
|
||||
...config,
|
||||
expression_groups: [...config.expression_groups, []],
|
||||
})
|
||||
}
|
||||
|
||||
// 删除表达组
|
||||
const removeExpressionGroup = (index: number) => {
|
||||
onChange({
|
||||
...config,
|
||||
expression_groups: config.expression_groups.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
|
||||
// 添加组成员
|
||||
const addGroupMember = (groupIndex: number) => {
|
||||
const newGroups = [...config.expression_groups]
|
||||
newGroups[groupIndex] = [...newGroups[groupIndex], '']
|
||||
onChange({
|
||||
...config,
|
||||
expression_groups: newGroups,
|
||||
})
|
||||
}
|
||||
|
||||
// 删除组成员
|
||||
const removeGroupMember = (groupIndex: number, memberIndex: number) => {
|
||||
const newGroups = [...config.expression_groups]
|
||||
newGroups[groupIndex] = newGroups[groupIndex].filter((_, i) => i !== memberIndex)
|
||||
onChange({
|
||||
...config,
|
||||
expression_groups: newGroups,
|
||||
})
|
||||
}
|
||||
|
||||
// 更新组成员
|
||||
const updateGroupMember = (groupIndex: number, memberIndex: number, value: string) => {
|
||||
const newGroups = [...config.expression_groups]
|
||||
newGroups[groupIndex][memberIndex] = value
|
||||
onChange({
|
||||
...config,
|
||||
expression_groups: newGroups,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 黑话设置 - 移到顶部 */}
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold mb-4">黑话设置</h3>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="all_global_jargon"
|
||||
checked={config.all_global_jargon ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, all_global_jargon: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="all_global_jargon" className="cursor-pointer">
|
||||
全局黑话模式
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
开启后,新增的黑话将默认设为全局(所有聊天流共享)。关闭后,已记录的全局黑话不会改变,需要手动删除。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="enable_jargon_explanation"
|
||||
checked={config.enable_jargon_explanation ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, enable_jargon_explanation: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="enable_jargon_explanation" className="cursor-pointer">
|
||||
启用黑话解释
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
在回复前尝试对上下文中的黑话进行解释。关闭可减少一次LLM调用,仅影响回复前的黑话匹配与解释,不影响黑话学习。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="jargon_mode">黑话解释来源模式</Label>
|
||||
<Select
|
||||
value={config.jargon_mode ?? 'context'}
|
||||
onValueChange={(value) => onChange({ ...config, jargon_mode: value })}
|
||||
>
|
||||
<SelectTrigger id="jargon_mode" className="mt-2">
|
||||
<SelectValue placeholder="选择黑话解释来源" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="context">上下文模式(自动匹配黑话)</SelectItem>
|
||||
<SelectItem value="planner">Planner模式(使用unknown_words列表)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
上下文模式:使用上下文自动匹配黑话并解释<br />
|
||||
Planner模式:仅使用Planner在reply动作中给出的unknown_words列表进行黑话检索
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表达学习配置 */}
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">表达学习配置</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
配置麦麦如何学习和使用表达方式
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={addLearningRule} size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加规则
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{config.learning_list.map((rule, index) => {
|
||||
// 检查是否已有全局配置(rule[0] === '')
|
||||
const hasGlobalConfig = config.learning_list.some((r, i) => i !== index && r[0] === '')
|
||||
const isGlobal = rule[0] === ''
|
||||
|
||||
// 解析聊天流 ID(格式:platform:id:type)
|
||||
const parts = rule[0].split(':')
|
||||
const platform = parts[0] || 'qq'
|
||||
const chatId = parts[1] || ''
|
||||
const chatType = parts[2] || 'group'
|
||||
|
||||
return (
|
||||
<div key={index} className="rounded-lg border p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
规则 {index + 1} {isGlobal && '(全局配置)'}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<LearningRulePreview rule={rule} />
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="ghost">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除学习规则 {index + 1} 吗?此操作无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => removeLearningRule(index)}>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 配置类型选择 */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">配置类型</Label>
|
||||
<Select
|
||||
value={isGlobal ? 'global' : 'specific'}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'global') {
|
||||
updateLearningRule(index, 0, '')
|
||||
} else {
|
||||
// 切换到详细配置时,设置默认值
|
||||
updateLearningRule(index, 0, 'qq::group')
|
||||
}
|
||||
}}
|
||||
disabled={hasGlobalConfig && !isGlobal}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">全局配置</SelectItem>
|
||||
<SelectItem value="specific" disabled={hasGlobalConfig && !isGlobal}>
|
||||
详细配置
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{hasGlobalConfig && !isGlobal && (
|
||||
<p className="text-xs text-amber-600">
|
||||
已存在全局配置,无法创建新的全局配置
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 详细配置选项 - 只在非全局时显示 */}
|
||||
{!isGlobal && (
|
||||
<div className="grid gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{/* 平台选择 */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">平台</Label>
|
||||
<Select
|
||||
value={platform}
|
||||
onValueChange={(value) => {
|
||||
updateLearningRule(index, 0, `${value}:${chatId}:${chatType}`)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="qq">QQ</SelectItem>
|
||||
<SelectItem value="wx">微信</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 群 ID 输入 */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">群 ID</Label>
|
||||
<Input
|
||||
value={chatId}
|
||||
onChange={(e) => {
|
||||
updateLearningRule(index, 0, `${platform}:${e.target.value}:${chatType}`)
|
||||
}}
|
||||
placeholder="输入群 ID"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 类型选择 */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">类型</Label>
|
||||
<Select
|
||||
value={chatType}
|
||||
onValueChange={(value) => {
|
||||
updateLearningRule(index, 0, `${platform}:${chatId}:${value}`)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="group">群组(group)</SelectItem>
|
||||
<SelectItem value="private">私聊(private)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
当前聊天流 ID:{rule[0] || '(未设置)'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 使用学到的表达 - 改为开关 */}
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs font-medium">使用学到的表达</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
允许麦麦使用从聊天中学到的表达方式
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={rule[1] === 'enable'}
|
||||
onCheckedChange={(checked) =>
|
||||
updateLearningRule(index, 1, checked ? 'enable' : 'disable')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 学习表达 - 改为开关 */}
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs font-medium">学习表达</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
允许麦麦从聊天中学习新的表达方式
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={rule[2] === 'enable'}
|
||||
onCheckedChange={(checked) =>
|
||||
updateLearningRule(index, 2, checked ? 'enable' : 'disable')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 启用黑话学习 - 改为开关 */}
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs font-medium">启用黑话学习</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
允许麦麦在此聊天流中学习和记录黑话
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={rule[3] === 'true' || rule[3] === 'enable'}
|
||||
onCheckedChange={(checked) =>
|
||||
updateLearningRule(index, 3, checked ? 'true' : 'false')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{config.learning_list.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
暂无学习规则,点击"添加规则"开始配置
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表达反思配置 */}
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">表达优化配置</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
配置麦麦如何优化和改进表达方式
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 自动表达优化 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="expression_self_reflect" className="cursor-pointer font-medium">
|
||||
自动表达优化
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
启用后,麦麦会自动检查并优化表达方式,无需管理员手动干预
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="expression_self_reflect"
|
||||
checked={config.expression_self_reflect ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, expression_self_reflect: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.expression_self_reflect && (
|
||||
<div className="space-y-4 pl-4 border-l-2 border-primary/20">
|
||||
{/* 自动检查间隔 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expression_auto_check_interval">
|
||||
自动检查间隔(秒)
|
||||
</Label>
|
||||
<Input
|
||||
id="expression_auto_check_interval"
|
||||
type="number"
|
||||
min="60"
|
||||
value={config.expression_auto_check_interval ?? 3600}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
expression_auto_check_interval: parseInt(e.target.value) || 3600,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
表达方式自动检查的间隔时间(单位:秒),默认值:3600秒(1小时)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 每次检查数量 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expression_auto_check_count">
|
||||
每次检查数量
|
||||
</Label>
|
||||
<Input
|
||||
id="expression_auto_check_count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={config.expression_auto_check_count ?? 10}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
expression_auto_check_count: parseInt(e.target.value) || 10,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
每次自动检查时随机选取的表达方式数量,默认值:10条
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 自定义评估标准 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>自定义评估标准</Label>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onChange({
|
||||
...config,
|
||||
expression_auto_check_custom_criteria: [
|
||||
...(config.expression_auto_check_custom_criteria || []),
|
||||
'',
|
||||
],
|
||||
})
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加标准
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(config.expression_auto_check_custom_criteria || []).map((criterion, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={criterion}
|
||||
onChange={(e) => {
|
||||
const newCriteria = [...(config.expression_auto_check_custom_criteria || [])]
|
||||
newCriteria[index] = e.target.value
|
||||
onChange({ ...config, expression_auto_check_custom_criteria: newCriteria })
|
||||
}}
|
||||
placeholder="输入评估标准,例如:是否符合角色人设"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onChange({
|
||||
...config,
|
||||
expression_auto_check_custom_criteria: (config.expression_auto_check_custom_criteria || []).filter((_, i) => i !== index),
|
||||
})
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(!config.expression_auto_check_custom_criteria || config.expression_auto_check_custom_criteria.length === 0) && (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">
|
||||
暂无自定义标准,点击"添加标准"开始配置
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
这些标准会被添加到评估提示词中,作为额外的评估要求
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 仅使用已检查的表达方式 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="expression_checked_only" className="cursor-pointer font-medium">
|
||||
仅使用已审核通过的表达方式
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
开启后,只有通过审核(已检查)的项目会被使用;关闭时,未审核的项目也会被使用。无论开关状态,被拒绝的项目永远不会被使用。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="expression_checked_only"
|
||||
checked={config.expression_checked_only ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, expression_checked_only: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 手动表达优化 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="expression_manual_reflect" className="cursor-pointer font-medium">
|
||||
手动表达优化
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
启用后,麦麦会主动向管理员询问表达方式是否合适
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="expression_manual_reflect"
|
||||
checked={config.expression_manual_reflect ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, expression_manual_reflect: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.expression_manual_reflect && (
|
||||
<div className="space-y-4 pl-4 border-l-2 border-primary/20">
|
||||
{/* 表达反思操作员 ID */}
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">反思操作员</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const operatorId = config.manual_reflect_operator_id || ''
|
||||
const parts = operatorId.split(':')
|
||||
const platform = parts[0] || 'qq'
|
||||
const chatId = parts[1] || ''
|
||||
const chatType = parts[2] || 'private'
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{/* 平台选择 */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">平台</Label>
|
||||
<Select
|
||||
value={platform}
|
||||
onValueChange={(value) => {
|
||||
onChange({ ...config, manual_reflect_operator_id: `${value}:${chatId}:${chatType}` })
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="qq">QQ</SelectItem>
|
||||
<SelectItem value="wx">微信</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* ID 输入 */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">用户/群 ID</Label>
|
||||
<Input
|
||||
value={chatId}
|
||||
onChange={(e) => {
|
||||
onChange({ ...config, manual_reflect_operator_id: `${platform}:${e.target.value}:${chatType}` })
|
||||
}}
|
||||
placeholder="输入 ID"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 类型选择 */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-medium">类型</Label>
|
||||
<Select
|
||||
value={chatType}
|
||||
onValueChange={(value) => {
|
||||
onChange({ ...config, manual_reflect_operator_id: `${platform}:${chatId}:${value}` })
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="private">私聊(private)</SelectItem>
|
||||
<SelectItem value="group">群组(group)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
当前操作员 ID:{config.manual_reflect_operator_id || '(未设置)'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
手动表达优化操作员ID,格式:platform:id:type (例如 "qq:123456:private" 或 "qq:654321:group")
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 允许反思的聊天流列表 */}
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium">允许进行表达反思的聊天流</span>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
只有在此列表中的聊天流才会提出问题并跟踪。如果列表为空,则所有聊天流都可以进行表达反思(前提是启用了手动表达优化)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onChange({
|
||||
...config,
|
||||
allow_reflect: [...(config.allow_reflect || []), 'qq::group'],
|
||||
})
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加聊天流
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(config.allow_reflect || []).map((chatId, index) => {
|
||||
const parts = chatId.split(':')
|
||||
const platform = parts[0] || 'qq'
|
||||
const id = parts[1] || ''
|
||||
const chatType = parts[2] || 'group'
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<Select
|
||||
value={platform}
|
||||
onValueChange={(value) => {
|
||||
const newList = [...config.allow_reflect]
|
||||
newList[index] = `${value}:${id}:${chatType}`
|
||||
onChange({ ...config, allow_reflect: newList })
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="qq">QQ</SelectItem>
|
||||
<SelectItem value="wx">微信</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
value={id}
|
||||
onChange={(e) => {
|
||||
const newList = [...config.allow_reflect]
|
||||
newList[index] = `${platform}:${e.target.value}:${chatType}`
|
||||
onChange({ ...config, allow_reflect: newList })
|
||||
}}
|
||||
placeholder="ID"
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={chatType}
|
||||
onValueChange={(value) => {
|
||||
const newList = [...config.allow_reflect]
|
||||
newList[index] = `${platform}:${id}:${value}`
|
||||
onChange({ ...config, allow_reflect: newList })
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="group">群组</SelectItem>
|
||||
<SelectItem value="private">私聊</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
onChange({
|
||||
...config,
|
||||
allow_reflect: config.allow_reflect.filter((_, i) => i !== index),
|
||||
})
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{(!config.allow_reflect || config.allow_reflect.length === 0) && (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">
|
||||
列表为空,所有聊天流都可以进行表达反思
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表达共享组配置 */}
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">表达共享组配置</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
配置不同聊天流之间如何共享学到的表达方式
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={addExpressionGroup} size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加共享组
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{config.expression_groups.map((group, groupIndex) => {
|
||||
// 获取所有已配置的聊天流 ID(用于下拉框选项)
|
||||
const availableChatIds = config.learning_list
|
||||
.map(rule => rule[0])
|
||||
.filter(id => id !== '') // 过滤掉全局配置
|
||||
|
||||
return (
|
||||
<div key={groupIndex} className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
共享组 {groupIndex + 1}
|
||||
{group.length === 1 && group[0] === '*' && '(全局共享)'}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => addGroupMember(groupIndex)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="ghost">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除共享组 {groupIndex + 1} 吗?此操作无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => removeExpressionGroup(groupIndex)}>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{group.map((member, memberIndex) => (
|
||||
<ExpressionGroupMemberInput
|
||||
key={`${groupIndex}-${memberIndex}`}
|
||||
member={member}
|
||||
groupIndex={groupIndex}
|
||||
memberIndex={memberIndex}
|
||||
availableChatIds={availableChatIds}
|
||||
onUpdate={updateGroupMember}
|
||||
onRemove={removeGroupMember}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
提示:可以从下拉框选择已配置的聊天流,或手动输入。输入 "*" 启用全局共享
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{config.expression_groups.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
暂无共享组,点击"添加共享组"开始配置
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
})
|
||||
336
dashboard/src/routes/config/bot/sections/FeaturesSection.tsx
Normal file
336
dashboard/src/routes/config/bot/sections/FeaturesSection.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import React from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import type { EmojiConfig, MemoryConfig, ToolConfig, VoiceConfig } from '../types'
|
||||
|
||||
interface FeaturesSectionProps {
|
||||
emojiConfig: EmojiConfig
|
||||
memoryConfig: MemoryConfig
|
||||
toolConfig: ToolConfig
|
||||
voiceConfig: VoiceConfig
|
||||
onEmojiChange: (config: EmojiConfig) => void
|
||||
onMemoryChange: (config: MemoryConfig) => void
|
||||
onToolChange: (config: ToolConfig) => void
|
||||
onVoiceChange: (config: VoiceConfig) => void
|
||||
}
|
||||
|
||||
export const FeaturesSection = React.memo(function FeaturesSection({
|
||||
emojiConfig,
|
||||
memoryConfig,
|
||||
toolConfig,
|
||||
voiceConfig,
|
||||
onEmojiChange,
|
||||
onMemoryChange,
|
||||
onToolChange,
|
||||
onVoiceChange,
|
||||
}: FeaturesSectionProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 工具设置 */}
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">工具设置</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="enable_tool"
|
||||
checked={toolConfig.enable_tool}
|
||||
onCheckedChange={(checked) => onToolChange({ ...toolConfig, enable_tool: checked })}
|
||||
/>
|
||||
<Label htmlFor="enable_tool" className="cursor-pointer">
|
||||
启用工具系统
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
允许麦麦使用各种工具来增强功能
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Switch
|
||||
id="enable_asr"
|
||||
checked={voiceConfig.enable_asr}
|
||||
onCheckedChange={(checked) => onVoiceChange({ ...voiceConfig, enable_asr: checked })}
|
||||
/>
|
||||
<Label htmlFor="enable_asr" className="cursor-pointer">
|
||||
启用语音识别
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
启用后麦麦可以识别语音消息,需要配置语音识别模型
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 记忆设置 */}
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">记忆设置</h3>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="max_agent_iterations">记忆思考深度</Label>
|
||||
<Input
|
||||
id="max_agent_iterations"
|
||||
type="number"
|
||||
min="1"
|
||||
value={memoryConfig.max_agent_iterations}
|
||||
onChange={(e) =>
|
||||
onMemoryChange({ ...memoryConfig, max_agent_iterations: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">最低为 1(不深入思考)</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="agent_timeout_seconds">最长回忆时间(秒)</Label>
|
||||
<Input
|
||||
id="agent_timeout_seconds"
|
||||
type="number"
|
||||
min="1"
|
||||
step="0.1"
|
||||
value={memoryConfig.agent_timeout_seconds ?? 120}
|
||||
onChange={(e) =>
|
||||
onMemoryChange({ ...memoryConfig, agent_timeout_seconds: parseFloat(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">记忆检索的超时时间,避免过长的等待</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="enable_jargon_detection"
|
||||
checked={memoryConfig.enable_jargon_detection ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
onMemoryChange({ ...memoryConfig, enable_jargon_detection: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="enable_jargon_detection" className="cursor-pointer">
|
||||
启用黑话识别
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
记忆检索过程中是否启用黑话识别
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="global_memory"
|
||||
checked={memoryConfig.global_memory ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
onMemoryChange({ ...memoryConfig, global_memory: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="global_memory" className="cursor-pointer">
|
||||
全局记忆查询
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
允许记忆检索在所有聊天记录中进行全局查询(忽略当前聊天流)
|
||||
</p>
|
||||
|
||||
{/* 聊天历史总结配置 */}
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<h4 className="text-sm font-semibold mb-3">聊天历史总结配置</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="chat_history_topic_check_message_threshold">话题检查消息数阈值</Label>
|
||||
<Input
|
||||
id="chat_history_topic_check_message_threshold"
|
||||
type="number"
|
||||
min="1"
|
||||
value={memoryConfig.chat_history_topic_check_message_threshold ?? 80}
|
||||
onChange={(e) =>
|
||||
onMemoryChange({ ...memoryConfig, chat_history_topic_check_message_threshold: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
当累积消息数达到此值时触发话题检查
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="chat_history_topic_check_time_hours">话题检查时间阈值(小时)</Label>
|
||||
<Input
|
||||
id="chat_history_topic_check_time_hours"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
value={memoryConfig.chat_history_topic_check_time_hours ?? 8.0}
|
||||
onChange={(e) =>
|
||||
onMemoryChange({ ...memoryConfig, chat_history_topic_check_time_hours: parseFloat(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
当距离上次检查超过此时间且消息数达到最小阈值时触发话题检查
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="chat_history_topic_check_min_messages">时间触发最小消息数</Label>
|
||||
<Input
|
||||
id="chat_history_topic_check_min_messages"
|
||||
type="number"
|
||||
min="1"
|
||||
value={memoryConfig.chat_history_topic_check_min_messages ?? 20}
|
||||
onChange={(e) =>
|
||||
onMemoryChange({ ...memoryConfig, chat_history_topic_check_min_messages: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
时间触发模式下的最小消息数阈值
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="chat_history_finalize_no_update_checks">打包存储连续无更新次数</Label>
|
||||
<Input
|
||||
id="chat_history_finalize_no_update_checks"
|
||||
type="number"
|
||||
min="1"
|
||||
value={memoryConfig.chat_history_finalize_no_update_checks ?? 3}
|
||||
onChange={(e) =>
|
||||
onMemoryChange({ ...memoryConfig, chat_history_finalize_no_update_checks: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
当话题连续N次检查无新增内容时触发打包存储
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="chat_history_finalize_message_count">打包存储消息条数阈值</Label>
|
||||
<Input
|
||||
id="chat_history_finalize_message_count"
|
||||
type="number"
|
||||
min="1"
|
||||
value={memoryConfig.chat_history_finalize_message_count ?? 5}
|
||||
onChange={(e) =>
|
||||
onMemoryChange({ ...memoryConfig, chat_history_finalize_message_count: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
当话题的消息条数超过此值时触发打包存储
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表情包设置 */}
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">表情包设置</h3>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="emoji_chance">表情包激活概率</Label>
|
||||
<Input
|
||||
id="emoji_chance"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={emojiConfig.emoji_chance}
|
||||
onChange={(e) =>
|
||||
onEmojiChange({ ...emojiConfig, emoji_chance: parseFloat(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">范围 0-1,越大越容易发送表情包</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="max_reg_num">最大注册数量</Label>
|
||||
<Input
|
||||
id="max_reg_num"
|
||||
type="number"
|
||||
min="1"
|
||||
value={emojiConfig.max_reg_num}
|
||||
onChange={(e) =>
|
||||
onEmojiChange({ ...emojiConfig, max_reg_num: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">麦麦最多可以注册的表情包数量</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="check_interval">检查间隔(分钟)</Label>
|
||||
<Input
|
||||
id="check_interval"
|
||||
type="number"
|
||||
min="1"
|
||||
value={emojiConfig.check_interval}
|
||||
onChange={(e) =>
|
||||
onEmojiChange({ ...emojiConfig, check_interval: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
检查表情包(注册、破损、删除)的时间间隔
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="do_replace"
|
||||
checked={emojiConfig.do_replace}
|
||||
onCheckedChange={(checked) =>
|
||||
onEmojiChange({ ...emojiConfig, do_replace: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="do_replace" className="cursor-pointer">
|
||||
达到最大数量时替换表情包
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="steal_emoji"
|
||||
checked={emojiConfig.steal_emoji}
|
||||
onCheckedChange={(checked) =>
|
||||
onEmojiChange({ ...emojiConfig, steal_emoji: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="steal_emoji" className="cursor-pointer">
|
||||
偷取表情包
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
允许麦麦将看到的表情包据为己有
|
||||
</p>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="content_filtration"
|
||||
checked={emojiConfig.content_filtration}
|
||||
onCheckedChange={(checked) =>
|
||||
onEmojiChange({ ...emojiConfig, content_filtration: checked })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="content_filtration" className="cursor-pointer">
|
||||
启用表情包过滤
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{emojiConfig.content_filtration && (
|
||||
<div className="grid gap-2 pl-6 border-l-2 border-primary/20">
|
||||
<Label htmlFor="filtration_prompt">过滤要求</Label>
|
||||
<Input
|
||||
id="filtration_prompt"
|
||||
value={emojiConfig.filtration_prompt}
|
||||
onChange={(e) =>
|
||||
onEmojiChange({ ...emojiConfig, filtration_prompt: e.target.value })
|
||||
}
|
||||
placeholder="符合公序良俗"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
只有符合此要求的表情包才会被保存
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
150
dashboard/src/routes/config/bot/sections/LPMMSection.tsx
Normal file
150
dashboard/src/routes/config/bot/sections/LPMMSection.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { LPMMKnowledgeConfig } from '../types'
|
||||
|
||||
interface LPMMSectionProps {
|
||||
config: LPMMKnowledgeConfig
|
||||
onChange: (config: LPMMKnowledgeConfig) => void
|
||||
}
|
||||
|
||||
export const LPMMSection = React.memo(function LPMMSection({ config, onChange }: LPMMSectionProps) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold">LPMM 知识库设置</h3>
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={config.enable}
|
||||
onCheckedChange={(checked) => onChange({ ...config, enable: checked })}
|
||||
/>
|
||||
<Label className="cursor-pointer">启用 LPMM 知识库</Label>
|
||||
</div>
|
||||
|
||||
{config.enable && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>LPMM 模式</Label>
|
||||
<Select
|
||||
value={config.lpmm_mode}
|
||||
onValueChange={(value) => onChange({ ...config, lpmm_mode: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择 LPMM 模式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="classic">经典模式</SelectItem>
|
||||
<SelectItem value="agent">Agent 模式</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>同义词搜索 TopK</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={config.rag_synonym_search_top_k}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, rag_synonym_search_top_k: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>同义词阈值</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={config.rag_synonym_threshold}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, rag_synonym_threshold: parseFloat(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>实体提取线程数</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={config.info_extraction_workers}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, info_extraction_workers: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>嵌入向量维度</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={config.embedding_dimension}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, embedding_dimension: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>嵌入并发线程数</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={config.max_embedding_workers}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, max_embedding_workers: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>每批嵌入条数</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={config.embedding_chunk_size}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, embedding_chunk_size: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>同义实体数上限</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={config.max_synonym_entities}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, max_synonym_entities: parseInt(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={config.enable_ppr}
|
||||
onCheckedChange={(checked) => onChange({ ...config, enable_ppr: checked })}
|
||||
/>
|
||||
<Label className="cursor-pointer">启用 PPR (低配机器可关闭)</Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
264
dashboard/src/routes/config/bot/sections/LogSection.tsx
Normal file
264
dashboard/src/routes/config/bot/sections/LogSection.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import type { LogConfig } from '../types'
|
||||
|
||||
interface LogSectionProps {
|
||||
config: LogConfig
|
||||
onChange: (config: LogConfig) => void
|
||||
}
|
||||
|
||||
export const LogSection = React.memo(function LogSection({ config, onChange }: LogSectionProps) {
|
||||
const [newLibrary, setNewLibrary] = useState('')
|
||||
const [newLogLevel, setNewLogLevel] = useState('WARNING')
|
||||
|
||||
const addSuppressedLibrary = () => {
|
||||
if (newLibrary && !config.suppress_libraries.includes(newLibrary)) {
|
||||
onChange({
|
||||
...config,
|
||||
suppress_libraries: [...config.suppress_libraries, newLibrary],
|
||||
})
|
||||
setNewLibrary('')
|
||||
}
|
||||
}
|
||||
|
||||
const removeSuppressedLibrary = (library: string) => {
|
||||
onChange({
|
||||
...config,
|
||||
suppress_libraries: config.suppress_libraries.filter((l) => l !== library),
|
||||
})
|
||||
}
|
||||
|
||||
const addLibraryLogLevel = () => {
|
||||
if (newLibrary && !config.library_log_levels[newLibrary]) {
|
||||
onChange({
|
||||
...config,
|
||||
library_log_levels: { ...config.library_log_levels, [newLibrary]: newLogLevel },
|
||||
})
|
||||
setNewLibrary('')
|
||||
setNewLogLevel('WARNING')
|
||||
}
|
||||
}
|
||||
|
||||
const removeLibraryLogLevel = (library: string) => {
|
||||
const newLevels = { ...config.library_log_levels }
|
||||
delete newLevels[library]
|
||||
onChange({ ...config, library_log_levels: newLevels })
|
||||
}
|
||||
|
||||
const logLevels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
||||
const logLevelStyles = ['FULL', 'compact', 'lite']
|
||||
const colorTextOptions = ['none', 'title', 'full']
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">日志配置</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>日期格式</Label>
|
||||
<Input
|
||||
value={config.date_style}
|
||||
onChange={(e) => onChange({ ...config, date_style: e.target.value })}
|
||||
placeholder="例如: m-d H:i:s"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">m=月, d=日, H=时, i=分, s=秒</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>日志级别样式</Label>
|
||||
<Select
|
||||
value={config.log_level_style}
|
||||
onValueChange={(value) => onChange({ ...config, log_level_style: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{logLevelStyles.map((style) => (
|
||||
<SelectItem key={style} value={style}>
|
||||
{style}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>日志文本颜色</Label>
|
||||
<Select
|
||||
value={config.color_text}
|
||||
onValueChange={(value) => onChange({ ...config, color_text: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{colorTextOptions.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>全局日志级别</Label>
|
||||
<Select
|
||||
value={config.log_level}
|
||||
onValueChange={(value) => onChange({ ...config, log_level: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{logLevels.map((level) => (
|
||||
<SelectItem key={level} value={level}>
|
||||
{level}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>控制台日志级别</Label>
|
||||
<Select
|
||||
value={config.console_log_level}
|
||||
onValueChange={(value) => onChange({ ...config, console_log_level: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{logLevels.map((level) => (
|
||||
<SelectItem key={level} value={level}>
|
||||
{level}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>文件日志级别</Label>
|
||||
<Select
|
||||
value={config.file_log_level}
|
||||
onValueChange={(value) => onChange({ ...config, file_log_level: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{logLevels.map((level) => (
|
||||
<SelectItem key={level} value={level}>
|
||||
{level}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 屏蔽的库 */}
|
||||
<div>
|
||||
<Label className="mb-2 block">完全屏蔽的库</Label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Input
|
||||
value={newLibrary}
|
||||
onChange={(e) => setNewLibrary(e.target.value)}
|
||||
placeholder="输入库名"
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
addSuppressedLibrary()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button onClick={addSuppressedLibrary} size="sm" className="flex-shrink-0">
|
||||
<Plus className="h-4 w-4" strokeWidth={2} fill="none" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{config.suppress_libraries.map((library) => (
|
||||
<div
|
||||
key={library}
|
||||
className="flex items-center gap-1 bg-secondary px-3 py-1 rounded-md"
|
||||
>
|
||||
<span className="text-sm">{library}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={() => removeSuppressedLibrary(library)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" strokeWidth={2} fill="none" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 特定库日志级别 */}
|
||||
<div>
|
||||
<Label className="mb-2 block">特定库的日志级别</Label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Input
|
||||
value={newLibrary}
|
||||
onChange={(e) => setNewLibrary(e.target.value)}
|
||||
placeholder="输入库名"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select value={newLogLevel} onValueChange={setNewLogLevel}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{logLevels.map((level) => (
|
||||
<SelectItem key={level} value={level}>
|
||||
{level}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={addLibraryLogLevel} size="sm">
|
||||
<Plus className="h-4 w-4" strokeWidth={2} fill="none" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(config.library_log_levels).map(([library, level]) => (
|
||||
<div
|
||||
key={library}
|
||||
className="flex items-center justify-between bg-secondary px-3 py-2 rounded-md"
|
||||
>
|
||||
<span className="text-sm font-medium">{library}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{level}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => removeLibraryLogLevel(library)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" strokeWidth={2} fill="none" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
203
dashboard/src/routes/config/bot/sections/MaimMessageSection.tsx
Normal file
203
dashboard/src/routes/config/bot/sections/MaimMessageSection.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import type { MaimMessageConfig } from '../types'
|
||||
|
||||
interface MaimMessageSectionProps {
|
||||
config: MaimMessageConfig
|
||||
onChange: (config: MaimMessageConfig) => void
|
||||
}
|
||||
|
||||
export const MaimMessageSection = React.memo(function MaimMessageSection({ config, onChange }: MaimMessageSectionProps) {
|
||||
const [newToken, setNewToken] = useState('')
|
||||
const [newApiKey, setNewApiKey] = useState('')
|
||||
|
||||
const addToken = () => {
|
||||
if (newToken && !config.auth_token.includes(newToken)) {
|
||||
onChange({ ...config, auth_token: [...config.auth_token, newToken] })
|
||||
setNewToken('')
|
||||
}
|
||||
}
|
||||
|
||||
const removeToken = (index: number) => {
|
||||
onChange({
|
||||
...config,
|
||||
auth_token: config.auth_token.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
|
||||
const addApiKey = () => {
|
||||
if (newApiKey && !config.api_server_allowed_api_keys.includes(newApiKey)) {
|
||||
onChange({ ...config, api_server_allowed_api_keys: [...config.api_server_allowed_api_keys, newApiKey] })
|
||||
setNewApiKey('')
|
||||
}
|
||||
}
|
||||
|
||||
const removeApiKey = (index: number) => {
|
||||
onChange({
|
||||
...config,
|
||||
api_server_allowed_api_keys: config.api_server_allowed_api_keys.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
|
||||
{/* 认证令牌 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">旧版 API 认证令牌</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">用于旧版 API 验证,为空则不启用验证</p>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Input
|
||||
value={newToken}
|
||||
onChange={(e) => setNewToken(e.target.value)}
|
||||
placeholder="输入认证令牌"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
addToken()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button onClick={addToken} size="sm">
|
||||
<Plus className="h-4 w-4" strokeWidth={2} fill="none" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{config.auth_token.map((token, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between bg-secondary px-3 py-2 rounded-md"
|
||||
>
|
||||
<span className="text-sm font-mono">{token}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => removeToken(index)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" strokeWidth={2} fill="none" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 新版 API Server */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">新版 API Server 配置</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>启用新版 API Server</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
是否启用额外的新版 API Server(额外监听端口)
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.enable_api_server}
|
||||
onCheckedChange={(checked) => onChange({ ...config, enable_api_server: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.enable_api_server && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>主机地址</Label>
|
||||
<Input
|
||||
value={config.api_server_host}
|
||||
onChange={(e) => onChange({ ...config, api_server_host: e.target.value })}
|
||||
placeholder="0.0.0.0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>端口号</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.api_server_port}
|
||||
onChange={(e) => onChange({ ...config, api_server_port: parseInt(e.target.value) })}
|
||||
placeholder="8090"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={config.api_server_use_wss}
|
||||
onCheckedChange={(checked) => onChange({ ...config, api_server_use_wss: checked })}
|
||||
/>
|
||||
<Label>启用 WSS 安全连接</Label>
|
||||
</div>
|
||||
|
||||
{config.api_server_use_wss && (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>SSL 证书文件路径</Label>
|
||||
<Input
|
||||
value={config.api_server_cert_file}
|
||||
onChange={(e) => onChange({ ...config, api_server_cert_file: e.target.value })}
|
||||
placeholder="cert.pem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>SSL 密钥文件路径</Label>
|
||||
<Input
|
||||
value={config.api_server_key_file}
|
||||
onChange={(e) => onChange({ ...config, api_server_key_file: e.target.value })}
|
||||
placeholder="key.pem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Keys */}
|
||||
<div>
|
||||
<Label className="mb-2 block">允许的 API Key 列表</Label>
|
||||
<p className="text-sm text-muted-foreground mb-2">为空则允许所有连接</p>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Input
|
||||
value={newApiKey}
|
||||
onChange={(e) => setNewApiKey(e.target.value)}
|
||||
placeholder="输入 API Key"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
addApiKey()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button onClick={addApiKey} size="sm">
|
||||
<Plus className="h-4 w-4" strokeWidth={2} fill="none" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{config.api_server_allowed_api_keys.map((apiKey, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between bg-secondary px-3 py-2 rounded-md"
|
||||
>
|
||||
<span className="text-sm font-mono">{apiKey}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => removeApiKey(index)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" strokeWidth={2} fill="none" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,259 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Plus, Trash2, AlertTriangle } from 'lucide-react'
|
||||
import type { MessageReceiveConfig } from '../types'
|
||||
|
||||
interface MessageReceiveSectionProps {
|
||||
config: MessageReceiveConfig
|
||||
onChange: (config: MessageReceiveConfig) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息过滤配置模块
|
||||
* 管理 ban_words、ban_msgs_regex 和 mute_group_list
|
||||
*/
|
||||
export default function MessageReceiveSection({
|
||||
config,
|
||||
onChange,
|
||||
}: MessageReceiveSectionProps) {
|
||||
const [newBanWord, setNewBanWord] = useState('')
|
||||
const [newBanRegex, setNewBanRegex] = useState('')
|
||||
|
||||
// === 禁用词管理 ===
|
||||
const handleAddBanWord = () => {
|
||||
const trimmed = newBanWord.trim()
|
||||
if (trimmed && !config.ban_words.includes(trimmed)) {
|
||||
onChange({
|
||||
...config,
|
||||
ban_words: [...config.ban_words, trimmed],
|
||||
})
|
||||
setNewBanWord('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveBanWord = (index: number) => {
|
||||
onChange({
|
||||
...config,
|
||||
ban_words: config.ban_words.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
|
||||
const handleBanWordKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddBanWord()
|
||||
}
|
||||
}
|
||||
|
||||
// === 禁用正则表达式管理 ===
|
||||
const handleAddBanRegex = () => {
|
||||
const trimmed = newBanRegex.trim()
|
||||
if (trimmed && !config.ban_msgs_regex.includes(trimmed)) {
|
||||
// 验证正则表达式语法
|
||||
try {
|
||||
new RegExp(trimmed)
|
||||
onChange({
|
||||
...config,
|
||||
ban_msgs_regex: [...config.ban_msgs_regex, trimmed],
|
||||
})
|
||||
setNewBanRegex('')
|
||||
} catch (err) {
|
||||
alert(`正则表达式语法错误:${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveBanRegex = (index: number) => {
|
||||
onChange({
|
||||
...config,
|
||||
ban_msgs_regex: config.ban_msgs_regex.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
|
||||
const handleBanRegexKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddBanRegex()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>消息过滤配置</CardTitle>
|
||||
<CardDescription>
|
||||
配置消息过滤规则,过滤特定消息或在特定群组启用静默模式
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="ban_words" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="ban_words">禁用关键词</TabsTrigger>
|
||||
<TabsTrigger value="ban_regex">禁用正则</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 禁用关键词 Tab */}
|
||||
<TabsContent value="ban_words" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500 mt-1 flex-shrink-0" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
包含以下关键词的消息将被过滤,Bot 不会读取这些消息
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="输入要禁用的关键词(按回车添加)"
|
||||
value={newBanWord}
|
||||
onChange={(e) => setNewBanWord(e.target.value)}
|
||||
onKeyDown={handleBanWordKeyDown}
|
||||
/>
|
||||
<Button onClick={handleAddBanWord} size="icon">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{config.ban_words.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
暂无禁用关键词,点击上方添加
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{config.ban_words.map((word, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between rounded-md border p-3"
|
||||
>
|
||||
<code className="text-sm">{word}</code>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除关键词 <code>"{word}"</code> 吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleRemoveBanWord(index)}
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 禁用正则表达式 Tab */}
|
||||
<TabsContent value="ban_regex" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500 mt-1 flex-shrink-0" />
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p>匹配以下正则表达式的消息将被过滤</p>
|
||||
<p className="text-xs">
|
||||
⚠️ 若不了解正则表达式,请勿随意修改
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
placeholder="输入正则表达式(按回车添加) 示例:https?://[^\s]+ 匹配链接"
|
||||
value={newBanRegex}
|
||||
onChange={(e) => setNewBanRegex(e.target.value)}
|
||||
onKeyDown={handleBanRegexKeyDown}
|
||||
className="min-h-[60px] font-mono text-sm"
|
||||
/>
|
||||
<Button onClick={handleAddBanRegex} size="icon">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{config.ban_msgs_regex.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
暂无禁用正则表达式,点击上方添加
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{config.ban_msgs_regex.map((regex, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between rounded-md border p-3"
|
||||
>
|
||||
<code className="text-sm font-mono flex-1 break-all">
|
||||
{regex}
|
||||
</code>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="ml-2 flex-shrink-0">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除正则表达式 <code>"{regex}"</code> 吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleRemoveBanRegex(index)}
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
164
dashboard/src/routes/config/bot/sections/PersonalitySection.tsx
Normal file
164
dashboard/src/routes/config/bot/sections/PersonalitySection.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import type { PersonalityConfig } from '../types'
|
||||
|
||||
interface PersonalitySectionProps {
|
||||
config: PersonalityConfig
|
||||
onChange: (config: PersonalityConfig) => void
|
||||
}
|
||||
|
||||
export const PersonalitySection = React.memo(function PersonalitySection({ config, onChange }: PersonalitySectionProps) {
|
||||
const addState = () => {
|
||||
onChange({ ...config, states: [...config.states, ''] })
|
||||
}
|
||||
|
||||
const removeState = (index: number) => {
|
||||
onChange({
|
||||
...config,
|
||||
states: config.states.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
|
||||
const updateState = (index: number, value: string) => {
|
||||
const newStates = [...config.states]
|
||||
newStates[index] = value
|
||||
onChange({ ...config, states: newStates })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">人格设置</h3>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="personality">人格特质</Label>
|
||||
<Textarea
|
||||
id="personality"
|
||||
value={config.personality}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange({ ...config, personality: e.target.value })}
|
||||
placeholder="描述人格特质和身份特征(建议120字以内)"
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
建议120字以内,描述人格特质和身份特征
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 多重人格配置 - 移到人格特质下方 */}
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>多重人格</Label>
|
||||
<Button onClick={addState} size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加人格
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
可以定义多个不同的人格状态,麦麦会随机切换
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{config.states.map((state, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Textarea
|
||||
value={state}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => updateState(index, e.target.value)}
|
||||
placeholder="描述一个人格状态"
|
||||
rows={2}
|
||||
/>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="icon" variant="outline">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除这个人格状态吗?此操作无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => removeState(index)}>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="state_probability">替换为多重人格概率</Label>
|
||||
<Input
|
||||
id="state_probability"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={config.state_probability}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, state_probability: parseFloat(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
每次构建人格时,用多重人格替换主人格的概率(0.0-1.0)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="reply_style">表达风格</Label>
|
||||
<Textarea
|
||||
id="reply_style"
|
||||
value={config.reply_style}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange({ ...config, reply_style: e.target.value })}
|
||||
placeholder="描述说话的表达风格和习惯"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="plan_style">说话规则与行为风格</Label>
|
||||
<Textarea
|
||||
id="plan_style"
|
||||
value={config.plan_style}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange({ ...config, plan_style: e.target.value })}
|
||||
placeholder="麦麦的说话规则和行为风格"
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="visual_style">识图规则</Label>
|
||||
<Textarea
|
||||
id="visual_style"
|
||||
value={config.visual_style}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange({ ...config, visual_style: e.target.value })}
|
||||
placeholder="识图时的处理规则"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
1121
dashboard/src/routes/config/bot/sections/ProcessingSection.tsx
Normal file
1121
dashboard/src/routes/config/bot/sections/ProcessingSection.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import type { TelemetryConfig } from '../types'
|
||||
|
||||
interface TelemetrySectionProps {
|
||||
config: TelemetryConfig
|
||||
onChange: (config: TelemetryConfig) => void
|
||||
}
|
||||
|
||||
export const TelemetrySection = React.memo(function TelemetrySection({ config, onChange }: TelemetrySectionProps) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold">统计信息</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>启用统计信息发送</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
发送匿名统计信息,帮助我们了解全球有多少只麦麦在运行
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.enable}
|
||||
onCheckedChange={(checked) => onChange({ ...config, enable: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
27
dashboard/src/routes/config/bot/sections/VoiceSection.tsx
Normal file
27
dashboard/src/routes/config/bot/sections/VoiceSection.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import type { VoiceConfig } from '../types'
|
||||
|
||||
interface VoiceSectionProps {
|
||||
config: VoiceConfig
|
||||
onChange: (config: VoiceConfig) => void
|
||||
}
|
||||
|
||||
export const VoiceSection = React.memo(function VoiceSection({ config, onChange }: VoiceSectionProps) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold">语音设置</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={config.enable_asr}
|
||||
onCheckedChange={(checked) => onChange({ ...config, enable_asr: checked })}
|
||||
/>
|
||||
<Label className="cursor-pointer">启用语音识别</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
启用后麦麦可以识别语音消息,需要配置语音识别模型
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
287
dashboard/src/routes/config/bot/sections/WebUISection.tsx
Normal file
287
dashboard/src/routes/config/bot/sections/WebUISection.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { X, Plus } from 'lucide-react'
|
||||
import type { WebUIConfig } from '../types'
|
||||
|
||||
interface WebUISectionProps {
|
||||
config: WebUIConfig
|
||||
onChange: (config: WebUIConfig) => void
|
||||
}
|
||||
|
||||
export const WebUISection = React.memo(function WebUISection({ config, onChange }: WebUISectionProps) {
|
||||
const [newAllowedIp, setNewAllowedIp] = useState('')
|
||||
const [newTrustedProxy, setNewTrustedProxy] = useState('')
|
||||
const [showDisableWarning, setShowDisableWarning] = useState(false)
|
||||
|
||||
// 将逗号分隔的字符串转换为数组
|
||||
const allowedIpsList = config.allowed_ips
|
||||
? config.allowed_ips.split(',').map(ip => ip.trim()).filter(ip => ip)
|
||||
: []
|
||||
|
||||
const trustedProxiesList = config.trusted_proxies
|
||||
? config.trusted_proxies.split(',').map(ip => ip.trim()).filter(ip => ip)
|
||||
: []
|
||||
|
||||
// 处理添加IP白名单
|
||||
const handleAddAllowedIp = () => {
|
||||
if (!newAllowedIp.trim()) return
|
||||
const updatedList = [...allowedIpsList, newAllowedIp.trim()]
|
||||
onChange({ ...config, allowed_ips: updatedList.join(',') })
|
||||
setNewAllowedIp('')
|
||||
}
|
||||
|
||||
// 处理删除IP白名单
|
||||
const handleRemoveAllowedIp = (index: number) => {
|
||||
const updatedList = allowedIpsList.filter((_, i) => i !== index)
|
||||
onChange({ ...config, allowed_ips: updatedList.join(',') })
|
||||
}
|
||||
|
||||
// 处理添加信任代理
|
||||
const handleAddTrustedProxy = () => {
|
||||
if (!newTrustedProxy.trim()) return
|
||||
const updatedList = [...trustedProxiesList, newTrustedProxy.trim()]
|
||||
onChange({ ...config, trusted_proxies: updatedList.join(',') })
|
||||
setNewTrustedProxy('')
|
||||
}
|
||||
|
||||
// 处理删除信任代理
|
||||
const handleRemoveTrustedProxy = (index: number) => {
|
||||
const updatedList = trustedProxiesList.filter((_, i) => i !== index)
|
||||
onChange({ ...config, trusted_proxies: updatedList.join(',') })
|
||||
}
|
||||
|
||||
// 处理WebUI开关变更
|
||||
const handleEnabledChange = (checked: boolean) => {
|
||||
if (!checked && config.enabled) {
|
||||
// 用户尝试关闭WebUI,显示警告
|
||||
setShowDisableWarning(true)
|
||||
} else {
|
||||
// 用户开启WebUI,直接更新
|
||||
onChange({ ...config, enabled: checked })
|
||||
}
|
||||
}
|
||||
|
||||
// 确认关闭WebUI
|
||||
const confirmDisableWebUI = () => {
|
||||
onChange({ ...config, enabled: false })
|
||||
setShowDisableWarning(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold">WebUI 服务配置</h3>
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
onCheckedChange={handleEnabledChange}
|
||||
/>
|
||||
<Label className="cursor-pointer">启用 WebUI</Label>
|
||||
</div>
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>运行模式</Label>
|
||||
<Select
|
||||
value={config.mode}
|
||||
onValueChange={(value) => onChange({ ...config, mode: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择运行模式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="development">开发模式</SelectItem>
|
||||
<SelectItem value="production">生产模式</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
注意: WebUI 的监听地址和端口请在 .env 文件中配置 WEBUI_HOST 和 WEBUI_PORT
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>防爬虫模式</Label>
|
||||
<Select
|
||||
value={config.anti_crawler_mode}
|
||||
onValueChange={(value) => onChange({ ...config, anti_crawler_mode: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择防爬虫模式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="false">禁用</SelectItem>
|
||||
<SelectItem value="basic">基础(只记录不阻止)</SelectItem>
|
||||
<SelectItem value="loose">宽松</SelectItem>
|
||||
<SelectItem value="strict">严格</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:col-span-2">
|
||||
<Label>IP 白名单</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newAllowedIp}
|
||||
onChange={(e) => setNewAllowedIp(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddAllowedIp()
|
||||
}
|
||||
}}
|
||||
placeholder="输入IP地址后按回车或点击添加"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleAddAllowedIp}
|
||||
disabled={!newAllowedIp.trim()}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{allowedIpsList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{allowedIpsList.map((ip, index) => (
|
||||
<Badge key={index} variant="secondary" className="flex items-center gap-1">
|
||||
{ip}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveAllowedIp(index)}
|
||||
className="ml-1 hover:bg-destructive/20 rounded-full p-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
支持精确IP、CIDR格式和通配符(如:127.0.0.1、192.168.1.0/24)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:col-span-2">
|
||||
<Label>信任的代理 IP</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newTrustedProxy}
|
||||
onChange={(e) => setNewTrustedProxy(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddTrustedProxy()
|
||||
}
|
||||
}}
|
||||
placeholder="输入代理IP后按回车或点击添加"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleAddTrustedProxy}
|
||||
disabled={!newTrustedProxy.trim()}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{trustedProxiesList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{trustedProxiesList.map((ip, index) => (
|
||||
<Badge key={index} variant="secondary" className="flex items-center gap-1">
|
||||
{ip}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTrustedProxy(index)}
|
||||
className="ml-1 hover:bg-destructive/20 rounded-full p-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
只有来自这些IP的X-Forwarded-For头才被信任
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={config.trust_xff}
|
||||
onCheckedChange={(checked) => onChange({ ...config, trust_xff: checked })}
|
||||
/>
|
||||
<Label className="cursor-pointer">启用 X-Forwarded-For 代理解析</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={config.secure_cookie}
|
||||
onCheckedChange={(checked) => onChange({ ...config, secure_cookie: checked })}
|
||||
/>
|
||||
<Label className="cursor-pointer">启用安全 Cookie(仅 HTTPS)</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={config.enable_paragraph_content}
|
||||
onCheckedChange={(checked) => onChange({ ...config, enable_paragraph_content: checked })}
|
||||
/>
|
||||
<Label className="cursor-pointer">在知识图谱中加载段落完整内容</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
启用后,知识图谱可视化界面会显示段落节点的完整内容。需要加载 embedding store,会占用额外内存(约数百MB)。
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 关闭WebUI警告对话框 */}
|
||||
<AlertDialog open={showDisableWarning} onOpenChange={setShowDisableWarning}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>警告:即将关闭 WebUI</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
关闭 WebUI 后,在您下次重启麦麦之前,WebUI 界面将无法访问。
|
||||
<br />
|
||||
<br />
|
||||
您需要通过修改配置文件或命令行重新启用 WebUI 才能再次访问此界面。
|
||||
<br />
|
||||
<br />
|
||||
确定要关闭 WebUI 吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={confirmDisableWebUI}>
|
||||
确认关闭
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
19
dashboard/src/routes/config/bot/sections/index.ts
Normal file
19
dashboard/src/routes/config/bot/sections/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Bot 配置页面各个 Section 组件
|
||||
*/
|
||||
|
||||
export { BotInfoSection } from './BotInfoSection'
|
||||
export { PersonalitySection } from './PersonalitySection'
|
||||
export { ChatSection } from './ChatSection'
|
||||
export { DreamSection } from './DreamSection'
|
||||
export { LPMMSection } from './LPMMSection'
|
||||
export { LogSection } from './LogSection'
|
||||
export { DebugSection } from './DebugSection'
|
||||
export { ExperimentalSection } from './ExperimentalSection'
|
||||
export { MaimMessageSection } from './MaimMessageSection'
|
||||
export { TelemetrySection } from './TelemetrySection'
|
||||
export { FeaturesSection } from './FeaturesSection'
|
||||
export { ExpressionSection } from './ExpressionSection'
|
||||
export { ProcessingSection } from './ProcessingSection'
|
||||
export { default as MessageReceiveSection } from './MessageReceiveSection'
|
||||
export { WebUISection } from './WebUISection'
|
||||
259
dashboard/src/routes/config/bot/types.ts
Normal file
259
dashboard/src/routes/config/bot/types.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Bot 配置页面相关类型定义
|
||||
*/
|
||||
|
||||
export interface BotConfig {
|
||||
platform: string
|
||||
qq_account: string | number
|
||||
nickname: string
|
||||
platforms: string[]
|
||||
alias_names: string[]
|
||||
}
|
||||
|
||||
export interface PersonalityConfig {
|
||||
personality: string
|
||||
reply_style: string
|
||||
interest: string
|
||||
plan_style: string
|
||||
visual_style: string
|
||||
states: string[]
|
||||
state_probability: number
|
||||
}
|
||||
|
||||
export interface ChatConfig {
|
||||
talk_value: number
|
||||
mentioned_bot_reply: boolean
|
||||
max_context_size: number
|
||||
planner_smooth: number
|
||||
think_mode: 'classic' | 'deep' | 'dynamic'
|
||||
plan_reply_log_max_per_chat: number
|
||||
llm_quote: boolean
|
||||
enable_talk_value_rules: boolean
|
||||
talk_value_rules: Array<{
|
||||
target: string
|
||||
time: string
|
||||
value: number
|
||||
}>
|
||||
}
|
||||
|
||||
export interface ExpressionConfig {
|
||||
learning_list: Array<[string, string, string, string]>
|
||||
expression_groups: Array<string[]>
|
||||
expression_manual_reflect: boolean
|
||||
manual_reflect_operator_id: string
|
||||
allow_reflect: string[]
|
||||
expression_self_reflect: boolean
|
||||
expression_auto_check_interval: number
|
||||
expression_auto_check_count: number
|
||||
expression_auto_check_custom_criteria: string[]
|
||||
expression_checked_only: boolean
|
||||
all_global_jargon: boolean
|
||||
enable_jargon_explanation: boolean
|
||||
jargon_mode: string
|
||||
}
|
||||
|
||||
export interface EmojiConfig {
|
||||
emoji_chance: number
|
||||
max_reg_num: number
|
||||
do_replace: boolean
|
||||
check_interval: number
|
||||
steal_emoji: boolean
|
||||
content_filtration: boolean
|
||||
filtration_prompt: string
|
||||
}
|
||||
|
||||
export interface MemoryConfig {
|
||||
max_agent_iterations: number
|
||||
agent_timeout_seconds: number
|
||||
enable_jargon_detection: boolean
|
||||
global_memory: boolean
|
||||
chat_history_topic_check_message_threshold: number
|
||||
chat_history_topic_check_time_hours: number
|
||||
chat_history_topic_check_min_messages: number
|
||||
chat_history_finalize_no_update_checks: number
|
||||
chat_history_finalize_message_count: number
|
||||
}
|
||||
|
||||
export interface ToolConfig {
|
||||
enable_tool: boolean
|
||||
}
|
||||
|
||||
// MoodConfig 已在后端移除
|
||||
|
||||
export interface VoiceConfig {
|
||||
enable_asr: boolean
|
||||
}
|
||||
|
||||
export interface MessageReceiveConfig {
|
||||
ban_words: string[]
|
||||
ban_msgs_regex: string[]
|
||||
}
|
||||
|
||||
export interface DreamConfig {
|
||||
interval_minutes: number
|
||||
max_iterations: number
|
||||
first_delay_seconds: number
|
||||
dream_send: string
|
||||
dream_time_ranges: string[]
|
||||
dream_visible: boolean
|
||||
}
|
||||
|
||||
export interface LPMMKnowledgeConfig {
|
||||
enable: boolean
|
||||
lpmm_mode: string
|
||||
rag_synonym_search_top_k: number
|
||||
rag_synonym_threshold: number
|
||||
info_extraction_workers: number
|
||||
qa_relation_search_top_k: number
|
||||
qa_relation_threshold: number
|
||||
qa_paragraph_search_top_k: number
|
||||
qa_paragraph_node_weight: number
|
||||
qa_ent_filter_top_k: number
|
||||
qa_ppr_damping: number
|
||||
qa_res_top_k: number
|
||||
embedding_dimension: number
|
||||
max_embedding_workers: number
|
||||
embedding_chunk_size: number
|
||||
max_synonym_entities: number
|
||||
enable_ppr: boolean
|
||||
}
|
||||
|
||||
export interface KeywordRule {
|
||||
keywords?: string[]
|
||||
regex?: string[]
|
||||
reaction: string
|
||||
}
|
||||
|
||||
export interface KeywordReactionConfig {
|
||||
keyword_rules: KeywordRule[]
|
||||
regex_rules: KeywordRule[]
|
||||
}
|
||||
|
||||
export interface ResponsePostProcessConfig {
|
||||
enable_response_post_process: boolean
|
||||
}
|
||||
|
||||
export interface ChineseTypoConfig {
|
||||
enable: boolean
|
||||
error_rate: number
|
||||
min_freq: number
|
||||
tone_error_rate: number
|
||||
word_replace_rate: number
|
||||
}
|
||||
|
||||
export interface ResponseSplitterConfig {
|
||||
enable: boolean
|
||||
max_length: number
|
||||
max_sentence_num: number
|
||||
enable_kaomoji_protection: boolean
|
||||
enable_overflow_return_all: boolean
|
||||
}
|
||||
|
||||
export interface LogConfig {
|
||||
date_style: string
|
||||
log_level_style: string
|
||||
color_text: string
|
||||
log_level: string
|
||||
console_log_level: string
|
||||
file_log_level: string
|
||||
suppress_libraries: string[]
|
||||
library_log_levels: Record<string, string>
|
||||
}
|
||||
|
||||
export interface DebugConfig {
|
||||
show_prompt: boolean
|
||||
show_replyer_prompt: boolean
|
||||
show_replyer_reasoning: boolean
|
||||
show_jargon_prompt: boolean
|
||||
show_memory_prompt: boolean
|
||||
show_planner_prompt: boolean
|
||||
show_lpmm_paragraph: boolean
|
||||
}
|
||||
|
||||
export interface ExperimentalConfig {
|
||||
private_plan_style: string
|
||||
chat_prompts: string[]
|
||||
lpmm_memory: boolean
|
||||
}
|
||||
|
||||
export interface MaimMessageConfig {
|
||||
auth_token: string[]
|
||||
enable_api_server: boolean
|
||||
api_server_host: string
|
||||
api_server_port: number
|
||||
api_server_use_wss: boolean
|
||||
api_server_cert_file: string
|
||||
api_server_key_file: string
|
||||
api_server_allowed_api_keys: string[]
|
||||
}
|
||||
|
||||
export interface TelemetryConfig {
|
||||
enable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* WebUI 配置
|
||||
* 注意: host 和 port 配置已移至环境变量 WEBUI_HOST 和 WEBUI_PORT
|
||||
*/
|
||||
export interface WebUIConfig {
|
||||
enabled: boolean
|
||||
mode: string
|
||||
anti_crawler_mode: string
|
||||
allowed_ips: string
|
||||
trusted_proxies: string
|
||||
trust_xff: boolean
|
||||
secure_cookie: boolean
|
||||
enable_paragraph_content: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有配置的聚合类型
|
||||
*/
|
||||
export interface AllBotConfigs {
|
||||
botConfig: BotConfig | null
|
||||
personalityConfig: PersonalityConfig | null
|
||||
chatConfig: ChatConfig | null
|
||||
expressionConfig: ExpressionConfig | null
|
||||
emojiConfig: EmojiConfig | null
|
||||
memoryConfig: MemoryConfig | null
|
||||
toolConfig: ToolConfig | null
|
||||
voiceConfig: VoiceConfig | null
|
||||
messageReceiveConfig: MessageReceiveConfig | null
|
||||
dreamConfig: DreamConfig | null
|
||||
lpmmConfig: LPMMKnowledgeConfig | null
|
||||
keywordReactionConfig: KeywordReactionConfig | null
|
||||
responsePostProcessConfig: ResponsePostProcessConfig | null
|
||||
chineseTypoConfig: ChineseTypoConfig | null
|
||||
responseSplitterConfig: ResponseSplitterConfig | null
|
||||
logConfig: LogConfig | null
|
||||
debugConfig: DebugConfig | null
|
||||
experimentalConfig: ExperimentalConfig | null
|
||||
maimMessageConfig: MaimMessageConfig | null
|
||||
telemetryConfig: TelemetryConfig | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置节名称到类型的映射
|
||||
*/
|
||||
export type ConfigSectionName =
|
||||
| 'bot'
|
||||
| 'personality'
|
||||
| 'chat'
|
||||
| 'expression'
|
||||
| 'emoji'
|
||||
| 'memory'
|
||||
| 'tool'
|
||||
| 'voice'
|
||||
| 'message_receive'
|
||||
| 'dream'
|
||||
| 'lpmm_knowledge'
|
||||
| 'keyword_reaction'
|
||||
| 'response_post_process'
|
||||
| 'chinese_typo'
|
||||
| 'response_splitter'
|
||||
| 'log'
|
||||
| 'debug'
|
||||
| 'experimental'
|
||||
| 'maim_message'
|
||||
| 'telemetry'
|
||||
| 'webui'
|
||||
1643
dashboard/src/routes/config/model.tsx
Normal file
1643
dashboard/src/routes/config/model.tsx
Normal file
File diff suppressed because it is too large
Load Diff
105
dashboard/src/routes/config/model/components/ModelCardList.tsx
Normal file
105
dashboard/src/routes/config/model/components/ModelCardList.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 模型列表 - 移动端卡片视图
|
||||
*/
|
||||
import React from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import type { ModelInfo } from '../types'
|
||||
|
||||
interface ModelCardListProps {
|
||||
/** 当前页显示的模型 (分页后的) */
|
||||
paginatedModels: ModelInfo[]
|
||||
/** 所有模型列表 (未分页) */
|
||||
allModels: ModelInfo[]
|
||||
/** 编辑模型回调 */
|
||||
onEdit: (model: ModelInfo, index: number) => void
|
||||
/** 删除模型回调 */
|
||||
onDelete: (index: number) => void
|
||||
/** 检查模型是否被使用 */
|
||||
isModelUsed: (modelName: string) => boolean
|
||||
/** 搜索关键词 */
|
||||
searchQuery: string
|
||||
}
|
||||
|
||||
export const ModelCardList = React.memo(function ModelCardList({
|
||||
paginatedModels,
|
||||
allModels,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isModelUsed,
|
||||
searchQuery,
|
||||
}: ModelCardListProps) {
|
||||
if (paginatedModels.length === 0) {
|
||||
return (
|
||||
<div className="md:hidden text-center text-muted-foreground py-8 rounded-lg border bg-card">
|
||||
{searchQuery ? '未找到匹配的模型' : '暂无模型配置'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="md:hidden space-y-3">
|
||||
{paginatedModels.map((model, displayIndex) => {
|
||||
const actualIndex = allModels.findIndex(m => m === model)
|
||||
const used = isModelUsed(model.name)
|
||||
return (
|
||||
<div key={displayIndex} className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-base">{model.name}</h3>
|
||||
<Badge
|
||||
variant={used ? "default" : "secondary"}
|
||||
className={used ? "bg-green-600 hover:bg-green-700" : ""}
|
||||
>
|
||||
{used ? '已使用' : '未使用'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground break-all" title={model.model_identifier}>
|
||||
{model.model_identifier}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onEdit(model, actualIndex)}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-1" strokeWidth={2} fill="none" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onDelete(actualIndex)}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" strokeWidth={2} fill="none" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">提供商</span>
|
||||
<p className="font-medium">{model.api_provider}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">模型温度</span>
|
||||
<p className="font-medium">{model.temperature != null ? model.temperature : <span className="text-muted-foreground">默认</span>}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">输入价格</span>
|
||||
<p className="font-medium">¥{model.price_in}/M</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">输出价格</span>
|
||||
<p className="font-medium">¥{model.price_out}/M</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
142
dashboard/src/routes/config/model/components/ModelTable.tsx
Normal file
142
dashboard/src/routes/config/model/components/ModelTable.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 模型列表 - 桌面端表格视图
|
||||
*/
|
||||
import React from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import type { ModelInfo } from '../types'
|
||||
|
||||
interface ModelTableProps {
|
||||
/** 当前页显示的模型 (分页后的) */
|
||||
paginatedModels: ModelInfo[]
|
||||
/** 所有模型列表 (未分页) */
|
||||
allModels: ModelInfo[]
|
||||
/** 过滤后的模型列表 */
|
||||
filteredModels: ModelInfo[]
|
||||
/** 已选中的模型索引集合 */
|
||||
selectedModels: Set<number>
|
||||
/** 编辑模型回调 */
|
||||
onEdit: (model: ModelInfo, index: number) => void
|
||||
/** 删除模型回调 */
|
||||
onDelete: (index: number) => void
|
||||
/** 切换选中状态回调 */
|
||||
onToggleSelection: (index: number) => void
|
||||
/** 切换全选回调 */
|
||||
onToggleSelectAll: () => void
|
||||
/** 检查模型是否被使用 */
|
||||
isModelUsed: (modelName: string) => boolean
|
||||
/** 搜索关键词 */
|
||||
searchQuery: string
|
||||
}
|
||||
|
||||
export const ModelTable = React.memo(function ModelTable({
|
||||
paginatedModels,
|
||||
allModels,
|
||||
filteredModels,
|
||||
selectedModels,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleSelection,
|
||||
onToggleSelectAll,
|
||||
isModelUsed,
|
||||
searchQuery,
|
||||
}: ModelTableProps) {
|
||||
return (
|
||||
<div className="hidden md:block rounded-lg border bg-card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedModels.size === filteredModels.length && filteredModels.length > 0}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-24">使用状态</TableHead>
|
||||
<TableHead>模型名称</TableHead>
|
||||
<TableHead>模型标识符</TableHead>
|
||||
<TableHead>提供商</TableHead>
|
||||
<TableHead className="text-center">温度</TableHead>
|
||||
<TableHead className="text-right">输入价格</TableHead>
|
||||
<TableHead className="text-right">输出价格</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedModels.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center text-muted-foreground py-8">
|
||||
{searchQuery ? '未找到匹配的模型' : '暂无模型配置'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedModels.map((model, displayIndex) => {
|
||||
const actualIndex = allModels.findIndex(m => m === model)
|
||||
const used = isModelUsed(model.name)
|
||||
return (
|
||||
<TableRow key={displayIndex}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedModels.has(actualIndex)}
|
||||
onCheckedChange={() => onToggleSelection(actualIndex)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={used ? "default" : "secondary"}
|
||||
className={used ? "bg-green-600 hover:bg-green-700" : ""}
|
||||
>
|
||||
{used ? '已使用' : '未使用'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{model.name}</TableCell>
|
||||
<TableCell className="max-w-xs truncate" title={model.model_identifier}>
|
||||
{model.model_identifier}
|
||||
</TableCell>
|
||||
<TableCell>{model.api_provider}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{model.temperature != null ? model.temperature : <span className="text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">¥{model.price_in}/M</TableCell>
|
||||
<TableCell className="text-right">¥{model.price_out}/M</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onEdit(model, actualIndex)}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-1" strokeWidth={2} fill="none" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onDelete(actualIndex)}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" strokeWidth={2} fill="none" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
142
dashboard/src/routes/config/model/components/Pagination.tsx
Normal file
142
dashboard/src/routes/config/model/components/Pagination.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 模型列表分页组件
|
||||
*/
|
||||
import React from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'
|
||||
import { PAGE_SIZE_OPTIONS } from '../constants'
|
||||
|
||||
interface PaginationProps {
|
||||
page: number
|
||||
pageSize: number
|
||||
totalItems: number
|
||||
jumpToPage: string
|
||||
onPageChange: (page: number) => void
|
||||
onPageSizeChange: (size: number) => void
|
||||
onJumpToPageChange: (value: string) => void
|
||||
onJumpToPage: () => void
|
||||
onSelectionClear?: () => void
|
||||
}
|
||||
|
||||
export const Pagination = React.memo(function Pagination({
|
||||
page,
|
||||
pageSize,
|
||||
totalItems,
|
||||
jumpToPage,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onJumpToPageChange,
|
||||
onJumpToPage,
|
||||
onSelectionClear,
|
||||
}: PaginationProps) {
|
||||
const totalPages = Math.ceil(totalItems / pageSize)
|
||||
|
||||
const handlePageSizeChange = (value: string) => {
|
||||
onPageSizeChange(parseInt(value))
|
||||
onPageChange(1)
|
||||
onSelectionClear?.()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
onJumpToPage()
|
||||
}
|
||||
}
|
||||
|
||||
if (totalItems === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="page-size-model" className="text-sm whitespace-nowrap">每页显示</Label>
|
||||
<Select
|
||||
value={pageSize.toString()}
|
||||
onValueChange={handlePageSizeChange}
|
||||
>
|
||||
<SelectTrigger id="page-size-model" className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<SelectItem key={size} value={size.toString()}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
显示 {(page - 1) * pageSize + 1} 到{' '}
|
||||
{Math.min(page * pageSize, totalItems)} 条,共 {totalItems} 条
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={page === 1}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">上一页</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={jumpToPage}
|
||||
onChange={(e) => onJumpToPageChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={page.toString()}
|
||||
className="w-16 h-8 text-center"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onJumpToPage}
|
||||
disabled={!jumpToPage}
|
||||
className="h-8"
|
||||
>
|
||||
跳转
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
<span className="hidden sm:inline">下一页</span>
|
||||
<ChevronRight className="h-4 w-4 sm:ml-1" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={page >= totalPages}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
155
dashboard/src/routes/config/model/components/TaskConfigCard.tsx
Normal file
155
dashboard/src/routes/config/model/components/TaskConfigCard.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 任务配置卡片组件
|
||||
*/
|
||||
import React from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { MultiSelect } from '@/components/ui/multi-select'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { TaskConfig } from '../types'
|
||||
|
||||
interface TaskConfigCardProps {
|
||||
title: string
|
||||
description: string
|
||||
taskConfig: TaskConfig
|
||||
modelNames: string[]
|
||||
onChange: (field: keyof TaskConfig, value: string[] | number | string) => void
|
||||
hideTemperature?: boolean
|
||||
hideMaxTokens?: boolean
|
||||
dataTour?: string
|
||||
}
|
||||
|
||||
export const TaskConfigCard = React.memo(function TaskConfigCard({
|
||||
title,
|
||||
description,
|
||||
taskConfig,
|
||||
modelNames,
|
||||
onChange,
|
||||
hideTemperature = false,
|
||||
hideMaxTokens = false,
|
||||
dataTour,
|
||||
}: TaskConfigCardProps) {
|
||||
const handleModelChange = (values: string[]) => {
|
||||
onChange('model_list', values)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-4">
|
||||
<div>
|
||||
<h4 className="font-semibold text-base sm:text-lg">{title}</h4>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{/* 模型列表 */}
|
||||
<div className="grid gap-2" data-tour={dataTour}>
|
||||
<Label>模型列表</Label>
|
||||
<MultiSelect
|
||||
options={modelNames.map((name) => ({ label: name, value: name }))}
|
||||
selected={taskConfig.model_list || []}
|
||||
onChange={handleModelChange}
|
||||
placeholder="选择模型..."
|
||||
emptyText="暂无可用模型"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 温度和最大 Token */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{!hideTemperature && (
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>温度</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={taskConfig.temperature ?? 0.3}
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.target.value)
|
||||
if (!isNaN(value) && value >= 0 && value <= 1) {
|
||||
onChange('temperature', value)
|
||||
}
|
||||
}}
|
||||
className="w-20 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
value={[taskConfig.temperature ?? 0.3]}
|
||||
onValueChange={(values) => onChange('temperature', values[0])}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hideMaxTokens && (
|
||||
<div className="grid gap-2">
|
||||
<Label>最大 Token</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="1"
|
||||
value={taskConfig.max_tokens ?? 1024}
|
||||
onChange={(e) => onChange('max_tokens', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 慢请求阈值 */}
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>慢请求阈值 (秒)</Label>
|
||||
<span className="text-xs text-muted-foreground">超时警告</span>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="1"
|
||||
value={taskConfig.slow_threshold ?? 15}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value)
|
||||
if (!isNaN(value) && value >= 1) {
|
||||
onChange('slow_threshold', value)
|
||||
}
|
||||
}}
|
||||
placeholder="15"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
模型响应时间超过此阈值将输出警告日志
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 模型选择策略 */}
|
||||
<div className="grid gap-2">
|
||||
<Label>模型选择策略</Label>
|
||||
<Select
|
||||
value={taskConfig.selection_strategy ?? 'balance'}
|
||||
onValueChange={(value) => onChange('selection_strategy', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择模型选择策略" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="balance">负载均衡(balance)</SelectItem>
|
||||
<SelectItem value="random">随机选择(random)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
负载均衡:优先选择使用次数少的模型。随机选择:完全随机从模型列表中选择
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
8
dashboard/src/routes/config/model/components/index.ts
Normal file
8
dashboard/src/routes/config/model/components/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Model 配置页面组件导出
|
||||
*/
|
||||
|
||||
export { TaskConfigCard } from './TaskConfigCard'
|
||||
export { ModelCardList } from './ModelCardList'
|
||||
export { ModelTable } from './ModelTable'
|
||||
export { Pagination } from './Pagination'
|
||||
107
dashboard/src/routes/config/model/constants.ts
Normal file
107
dashboard/src/routes/config/model/constants.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Model 配置页面常量
|
||||
*/
|
||||
|
||||
import type { ModelListItem } from '@/lib/config-api'
|
||||
|
||||
/**
|
||||
* 模型列表缓存 TTL (5 分钟)
|
||||
*/
|
||||
export const CACHE_TTL = 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* 模型列表缓存
|
||||
*/
|
||||
export const modelListCache = new Map<string, { models: ModelListItem[], timestamp: number }>()
|
||||
|
||||
/**
|
||||
* 任务配置信息
|
||||
*/
|
||||
export const TASK_CONFIGS = [
|
||||
{
|
||||
key: 'utils' as const,
|
||||
title: '组件模型 (utils)',
|
||||
description: '用于表情包、取名、关系、情绪变化等组件',
|
||||
},
|
||||
{
|
||||
key: 'utils_small' as const,
|
||||
title: '组件小模型 (utils_small)',
|
||||
description: '消耗量较大的组件,建议使用速度较快的小模型',
|
||||
},
|
||||
{
|
||||
key: 'tool_use' as const,
|
||||
title: '工具调用模型 (tool_use)',
|
||||
description: '需要使用支持工具调用的模型',
|
||||
},
|
||||
{
|
||||
key: 'replyer' as const,
|
||||
title: '首要回复模型 (replyer)',
|
||||
description: '用于表达器和表达方式学习',
|
||||
},
|
||||
{
|
||||
key: 'planner' as const,
|
||||
title: '决策模型 (planner)',
|
||||
description: '负责决定麦麦该什么时候回复',
|
||||
},
|
||||
{
|
||||
key: 'vlm' as const,
|
||||
title: '图像识别模型 (vlm)',
|
||||
description: '视觉语言模型',
|
||||
hideTemperature: true,
|
||||
},
|
||||
{
|
||||
key: 'voice' as const,
|
||||
title: '语音识别模型 (voice)',
|
||||
description: '语音转文字',
|
||||
hideTemperature: true,
|
||||
hideMaxTokens: true,
|
||||
},
|
||||
{
|
||||
key: 'embedding' as const,
|
||||
title: '嵌入模型 (embedding)',
|
||||
description: '用于向量化',
|
||||
hideTemperature: true,
|
||||
hideMaxTokens: true,
|
||||
},
|
||||
] as const
|
||||
|
||||
/**
|
||||
* LPMM 任务配置信息
|
||||
*/
|
||||
export const LPMM_TASK_CONFIGS = [
|
||||
{
|
||||
key: 'lpmm_entity_extract' as const,
|
||||
title: '实体提取模型 (lpmm_entity_extract)',
|
||||
description: '从文本中提取实体',
|
||||
},
|
||||
{
|
||||
key: 'lpmm_rdf_build' as const,
|
||||
title: 'RDF 构建模型 (lpmm_rdf_build)',
|
||||
description: '构建知识图谱',
|
||||
},
|
||||
{
|
||||
key: 'lpmm_qa' as const,
|
||||
title: '问答模型 (lpmm_qa)',
|
||||
description: '知识库问答',
|
||||
},
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 默认模型信息
|
||||
*/
|
||||
export const DEFAULT_MODEL_INFO = {
|
||||
model_identifier: '',
|
||||
name: '',
|
||||
api_provider: '',
|
||||
price_in: 0,
|
||||
price_out: 0,
|
||||
temperature: null,
|
||||
max_tokens: null,
|
||||
force_stream_mode: false,
|
||||
extra_params: {},
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 分页大小选项
|
||||
*/
|
||||
export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const
|
||||
7
dashboard/src/routes/config/model/hooks/index.ts
Normal file
7
dashboard/src/routes/config/model/hooks/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Model 配置页面 Hooks 导出
|
||||
*/
|
||||
|
||||
export { useModelAutoSave } from './useModelAutoSave'
|
||||
export { useModelTour } from './useModelTour'
|
||||
export { useModelFetcher, useAutoFetchModels } from './useModelFetcher'
|
||||
164
dashboard/src/routes/config/model/hooks/useModelAutoSave.ts
Normal file
164
dashboard/src/routes/config/model/hooks/useModelAutoSave.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Model 配置页面自动保存 Hook
|
||||
* 监听 models 和 taskConfig 变化,自动保存到服务器
|
||||
*/
|
||||
import { useRef, useEffect, useCallback } from 'react'
|
||||
import { updateModelConfigSection } from '@/lib/config-api'
|
||||
import type { ModelInfo, ModelTaskConfig } from '../types'
|
||||
|
||||
interface UseModelAutoSaveOptions {
|
||||
/** 模型列表 */
|
||||
models: ModelInfo[]
|
||||
/** 任务配置 */
|
||||
taskConfig: ModelTaskConfig | null
|
||||
/** 防抖延迟时间 (ms) */
|
||||
debounceMs?: number
|
||||
/** 保存状态回调 */
|
||||
onSavingChange?: (saving: boolean) => void
|
||||
/** 未保存变更回调 */
|
||||
onUnsavedChange?: (hasUnsaved: boolean) => void
|
||||
}
|
||||
|
||||
interface UseModelAutoSaveReturn {
|
||||
/** 清除所有待执行的保存定时器 */
|
||||
clearTimers: () => void
|
||||
/** 初始加载状态标记引用 (用于设置初始加载完成) */
|
||||
initialLoadRef: React.MutableRefObject<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型配置自动保存 Hook
|
||||
*/
|
||||
export function useModelAutoSave(
|
||||
options: UseModelAutoSaveOptions
|
||||
): UseModelAutoSaveReturn {
|
||||
const {
|
||||
models,
|
||||
taskConfig,
|
||||
debounceMs = 2000,
|
||||
onSavingChange,
|
||||
onUnsavedChange,
|
||||
} = options
|
||||
|
||||
// 防抖定时器
|
||||
const modelsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const taskConfigTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const initialLoadRef = useRef(true)
|
||||
|
||||
// 清除定时器
|
||||
const clearTimers = useCallback(() => {
|
||||
if (modelsTimerRef.current) {
|
||||
clearTimeout(modelsTimerRef.current)
|
||||
modelsTimerRef.current = null
|
||||
}
|
||||
if (taskConfigTimerRef.current) {
|
||||
clearTimeout(taskConfigTimerRef.current)
|
||||
taskConfigTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 清理模型中的 null 值(TOML 不支持 null)
|
||||
const cleanModelForSave = useCallback((model: ModelInfo): ModelInfo => {
|
||||
const cleaned: ModelInfo = {
|
||||
model_identifier: model.model_identifier,
|
||||
name: model.name,
|
||||
api_provider: model.api_provider,
|
||||
price_in: model.price_in ?? 0,
|
||||
price_out: model.price_out ?? 0,
|
||||
force_stream_mode: model.force_stream_mode ?? false,
|
||||
extra_params: model.extra_params ?? {},
|
||||
}
|
||||
// 只有在有值时才添加可选字段
|
||||
if (model.temperature != null) {
|
||||
cleaned.temperature = model.temperature
|
||||
}
|
||||
if (model.max_tokens != null) {
|
||||
cleaned.max_tokens = model.max_tokens
|
||||
}
|
||||
return cleaned
|
||||
}, [])
|
||||
|
||||
// 自动保存模型列表
|
||||
const autoSaveModels = useCallback(async (newModels: ModelInfo[]) => {
|
||||
try {
|
||||
onSavingChange?.(true)
|
||||
// 清理每个模型中的 null 值
|
||||
const cleanedModels = newModels.map(cleanModelForSave)
|
||||
await updateModelConfigSection('models', cleanedModels)
|
||||
onUnsavedChange?.(false)
|
||||
} catch (error) {
|
||||
console.error('自动保存模型列表失败:', error)
|
||||
onUnsavedChange?.(true)
|
||||
} finally {
|
||||
onSavingChange?.(false)
|
||||
}
|
||||
}, [onSavingChange, onUnsavedChange, cleanModelForSave])
|
||||
|
||||
// 自动保存任务配置
|
||||
const autoSaveTaskConfig = useCallback(async (newTaskConfig: ModelTaskConfig) => {
|
||||
try {
|
||||
onSavingChange?.(true)
|
||||
await updateModelConfigSection('model_task_config', newTaskConfig)
|
||||
onUnsavedChange?.(false)
|
||||
} catch (error) {
|
||||
console.error('自动保存任务配置失败:', error)
|
||||
onUnsavedChange?.(true)
|
||||
} finally {
|
||||
onSavingChange?.(false)
|
||||
}
|
||||
}, [onSavingChange, onUnsavedChange])
|
||||
|
||||
// 监听 models 变化
|
||||
useEffect(() => {
|
||||
if (initialLoadRef.current) return
|
||||
|
||||
onUnsavedChange?.(true)
|
||||
|
||||
if (modelsTimerRef.current) {
|
||||
clearTimeout(modelsTimerRef.current)
|
||||
}
|
||||
|
||||
modelsTimerRef.current = setTimeout(() => {
|
||||
autoSaveModels(models)
|
||||
}, debounceMs)
|
||||
|
||||
return () => {
|
||||
if (modelsTimerRef.current) {
|
||||
clearTimeout(modelsTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [models, autoSaveModels, debounceMs, onUnsavedChange])
|
||||
|
||||
// 监听 taskConfig 变化
|
||||
useEffect(() => {
|
||||
if (initialLoadRef.current || !taskConfig) return
|
||||
|
||||
onUnsavedChange?.(true)
|
||||
|
||||
if (taskConfigTimerRef.current) {
|
||||
clearTimeout(taskConfigTimerRef.current)
|
||||
}
|
||||
|
||||
taskConfigTimerRef.current = setTimeout(() => {
|
||||
autoSaveTaskConfig(taskConfig)
|
||||
}, debounceMs)
|
||||
|
||||
return () => {
|
||||
if (taskConfigTimerRef.current) {
|
||||
clearTimeout(taskConfigTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [taskConfig, autoSaveTaskConfig, debounceMs, onUnsavedChange])
|
||||
|
||||
// 组件卸载时清除定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimers()
|
||||
}
|
||||
}, [clearTimers])
|
||||
|
||||
return {
|
||||
clearTimers,
|
||||
initialLoadRef,
|
||||
}
|
||||
}
|
||||
143
dashboard/src/routes/config/model/hooks/useModelFetcher.ts
Normal file
143
dashboard/src/routes/config/model/hooks/useModelFetcher.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 模型列表获取 Hook
|
||||
*/
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { fetchProviderModels, type ModelListItem } from '@/lib/config-api'
|
||||
import { findTemplateByBaseUrl, type ProviderTemplate } from '../../providerTemplates'
|
||||
import { modelListCache, CACHE_TTL } from '../constants'
|
||||
import type { ProviderConfig } from '../types'
|
||||
|
||||
interface UseModelFetcherOptions {
|
||||
/** 获取提供商配置的函数 */
|
||||
getProviderConfig: (providerName: string) => ProviderConfig | undefined
|
||||
}
|
||||
|
||||
interface UseModelFetcherReturn {
|
||||
/** 可用模型列表 */
|
||||
availableModels: ModelListItem[]
|
||||
/** 是否正在获取模型列表 */
|
||||
fetchingModels: boolean
|
||||
/** 模型获取错误信息 */
|
||||
modelFetchError: string | null
|
||||
/** 匹配的模板 */
|
||||
matchedTemplate: ProviderTemplate | null
|
||||
/** 获取指定提供商的模型列表 */
|
||||
fetchModelsForProvider: (providerName: string, forceRefresh?: boolean) => Promise<void>
|
||||
/** 清空模型列表和错误状态 */
|
||||
clearModels: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型列表获取 Hook
|
||||
*/
|
||||
export function useModelFetcher(options: UseModelFetcherOptions): UseModelFetcherReturn {
|
||||
const { getProviderConfig } = options
|
||||
|
||||
const [availableModels, setAvailableModels] = useState<ModelListItem[]>([])
|
||||
const [fetchingModels, setFetchingModels] = useState(false)
|
||||
const [modelFetchError, setModelFetchError] = useState<string | null>(null)
|
||||
const [matchedTemplate, setMatchedTemplate] = useState<ProviderTemplate | null>(null)
|
||||
|
||||
// 清空模型列表和错误状态
|
||||
const clearModels = useCallback(() => {
|
||||
setAvailableModels([])
|
||||
setModelFetchError(null)
|
||||
setMatchedTemplate(null)
|
||||
}, [])
|
||||
|
||||
// 获取提供商的模型列表
|
||||
const fetchModelsForProvider = useCallback(async (providerName: string, forceRefresh = false) => {
|
||||
const config = getProviderConfig(providerName)
|
||||
if (!config?.base_url) {
|
||||
setAvailableModels([])
|
||||
setMatchedTemplate(null)
|
||||
setModelFetchError('提供商配置不完整,请先在"模型提供商配置"中配置')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 API Key 是否已配置
|
||||
if (!config.api_key) {
|
||||
setAvailableModels([])
|
||||
setMatchedTemplate(null)
|
||||
setModelFetchError('该提供商未配置 API Key,请先在"模型提供商配置"中填写')
|
||||
return
|
||||
}
|
||||
|
||||
// 查找匹配的模板
|
||||
const template = findTemplateByBaseUrl(config.base_url)
|
||||
setMatchedTemplate(template)
|
||||
|
||||
// 如果没有模板或模板不支持获取模型列表
|
||||
if (!template?.modelFetcher) {
|
||||
setAvailableModels([])
|
||||
setModelFetchError(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
const cacheKey = `${providerName}:${config.base_url}`
|
||||
const cached = modelListCache.get(cacheKey)
|
||||
if (!forceRefresh && cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
setAvailableModels(cached.models)
|
||||
setModelFetchError(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取模型列表
|
||||
setFetchingModels(true)
|
||||
setModelFetchError(null)
|
||||
|
||||
try {
|
||||
const models = await fetchProviderModels(
|
||||
providerName,
|
||||
template.modelFetcher.parser,
|
||||
template.modelFetcher.endpoint
|
||||
)
|
||||
setAvailableModels(models)
|
||||
// 更新缓存
|
||||
modelListCache.set(cacheKey, { models, timestamp: Date.now() })
|
||||
} catch (error) {
|
||||
console.error('获取模型列表失败:', error)
|
||||
const errorMessage = (error as Error).message || '获取模型列表失败'
|
||||
// 根据错误类型提供更友好的提示
|
||||
if (errorMessage.includes('无效') || errorMessage.includes('过期') || errorMessage.includes('API Key')) {
|
||||
setModelFetchError('API Key 无效或已过期,请检查"模型提供商配置"中的密钥')
|
||||
} else if (errorMessage.includes('权限')) {
|
||||
setModelFetchError('没有权限获取模型列表,请检查 API Key 权限')
|
||||
} else if (errorMessage.includes('timeout') || errorMessage.includes('超时')) {
|
||||
setModelFetchError('请求超时,请检查网络连接后重试')
|
||||
} else if (errorMessage.includes('不支持')) {
|
||||
setModelFetchError('该提供商不支持自动获取模型列表,请手动输入')
|
||||
} else {
|
||||
setModelFetchError(errorMessage)
|
||||
}
|
||||
setAvailableModels([])
|
||||
} finally {
|
||||
setFetchingModels(false)
|
||||
}
|
||||
}, [getProviderConfig])
|
||||
|
||||
return {
|
||||
availableModels,
|
||||
fetchingModels,
|
||||
modelFetchError,
|
||||
matchedTemplate,
|
||||
fetchModelsForProvider,
|
||||
clearModels,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当选择的提供商变化时自动获取模型列表的 Hook
|
||||
*/
|
||||
export function useAutoFetchModels(
|
||||
editDialogOpen: boolean,
|
||||
apiProvider: string | undefined,
|
||||
fetchModelsForProvider: (providerName: string, forceRefresh?: boolean) => Promise<void>
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (editDialogOpen && apiProvider) {
|
||||
fetchModelsForProvider(apiProvider)
|
||||
}
|
||||
}, [editDialogOpen, apiProvider, fetchModelsForProvider])
|
||||
}
|
||||
109
dashboard/src/routes/config/model/hooks/useModelTour.ts
Normal file
109
dashboard/src/routes/config/model/hooks/useModelTour.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Model 配置页面 Tour 引导 Hook
|
||||
*/
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useTour } from '@/components/tour'
|
||||
import { MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps, STEP_ROUTE_MAP } from '@/components/tour/tours/model-assignment-tour'
|
||||
|
||||
interface UseModelTourOptions {
|
||||
/** 关闭编辑对话框回调 */
|
||||
onCloseEditDialog?: () => void
|
||||
}
|
||||
|
||||
interface UseModelTourReturn {
|
||||
/** 开始引导 */
|
||||
startTour: () => void
|
||||
/** Tour 是否正在运行 */
|
||||
isRunning: boolean
|
||||
/** 当前步骤索引 */
|
||||
stepIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Model 配置页面 Tour 引导 Hook
|
||||
*/
|
||||
export function useModelTour(options: UseModelTourOptions = {}): UseModelTourReturn {
|
||||
const { onCloseEditDialog } = options
|
||||
const navigate = useNavigate()
|
||||
const { registerTour, startTour: startTourFn, state: tourState, goToStep } = useTour()
|
||||
|
||||
// 用于追踪前一个步骤
|
||||
const prevTourStepRef = useRef(tourState.stepIndex)
|
||||
|
||||
// 注册 Tour
|
||||
useEffect(() => {
|
||||
registerTour(MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps)
|
||||
}, [registerTour])
|
||||
|
||||
// 监听 Tour 步骤变化,处理页面导航
|
||||
useEffect(() => {
|
||||
if (tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID && tourState.isRunning) {
|
||||
const targetRoute = STEP_ROUTE_MAP[tourState.stepIndex]
|
||||
if (targetRoute && !window.location.pathname.endsWith(targetRoute.replace('/config/', ''))) {
|
||||
navigate({ to: targetRoute })
|
||||
}
|
||||
}
|
||||
}, [tourState.stepIndex, tourState.activeTourId, tourState.isRunning, navigate])
|
||||
|
||||
// 监听 Tour 步骤变化,当从弹窗内步骤回退到弹窗外步骤时,自动关闭弹窗
|
||||
// 模型弹窗步骤: 12-17 (index 12-17),弹窗外步骤: 10-11 (index 10-11)
|
||||
useEffect(() => {
|
||||
if (tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID && tourState.isRunning) {
|
||||
const prevStep = prevTourStepRef.current
|
||||
const currentStep = tourState.stepIndex
|
||||
|
||||
// 如果从弹窗内步骤 (12-17) 回退到弹窗外步骤 (<=11),关闭弹窗
|
||||
if (prevStep >= 12 && prevStep <= 17 && currentStep < 12) {
|
||||
onCloseEditDialog?.()
|
||||
}
|
||||
|
||||
prevTourStepRef.current = currentStep
|
||||
}
|
||||
}, [tourState.stepIndex, tourState.activeTourId, tourState.isRunning, onCloseEditDialog])
|
||||
|
||||
// 处理 Tour 中需要用户点击才能继续的步骤
|
||||
useEffect(() => {
|
||||
if (tourState.activeTourId !== MODEL_ASSIGNMENT_TOUR_ID || !tourState.isRunning) return
|
||||
|
||||
const handleTourClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
const currentStep = tourState.stepIndex
|
||||
|
||||
// Step 3 (index 2): 点击添加提供商按钮
|
||||
if (currentStep === 2 && target.closest('[data-tour="add-provider-button"]')) {
|
||||
setTimeout(() => goToStep(3), 300)
|
||||
}
|
||||
// Step 10 (index 9): 点击取消按钮(关闭提供商弹窗)
|
||||
else if (currentStep === 9 && target.closest('[data-tour="provider-cancel-button"]')) {
|
||||
setTimeout(() => goToStep(10), 300)
|
||||
}
|
||||
// Step 12 (index 11): 点击添加模型按钮
|
||||
else if (currentStep === 11 && target.closest('[data-tour="add-model-button"]')) {
|
||||
setTimeout(() => goToStep(12), 300)
|
||||
}
|
||||
// Step 18 (index 17): 点击取消按钮(关闭模型弹窗)
|
||||
else if (currentStep === 17 && target.closest('[data-tour="model-cancel-button"]')) {
|
||||
setTimeout(() => goToStep(18), 300)
|
||||
}
|
||||
// Step 19 (index 18): 点击为模型分配功能标签页
|
||||
else if (currentStep === 18 && target.closest('[data-tour="tasks-tab-trigger"]')) {
|
||||
setTimeout(() => goToStep(19), 300)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleTourClick, true)
|
||||
return () => document.removeEventListener('click', handleTourClick, true)
|
||||
}, [tourState, goToStep])
|
||||
|
||||
// 开始引导
|
||||
const handleStartTour = useCallback(() => {
|
||||
startTourFn(MODEL_ASSIGNMENT_TOUR_ID)
|
||||
}, [startTourFn])
|
||||
|
||||
return {
|
||||
startTour: handleStartTour,
|
||||
isRunning: tourState.isRunning && tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID,
|
||||
stepIndex: tourState.stepIndex,
|
||||
}
|
||||
}
|
||||
15
dashboard/src/routes/config/model/index.ts
Normal file
15
dashboard/src/routes/config/model/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Model 配置页面模块化导出
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export * from './types'
|
||||
|
||||
// 常量
|
||||
export * from './constants'
|
||||
|
||||
// Hooks
|
||||
export * from './hooks'
|
||||
|
||||
// 组件
|
||||
export * from './components'
|
||||
71
dashboard/src/routes/config/model/types.ts
Normal file
71
dashboard/src/routes/config/model/types.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Model 配置页面类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 模型信息
|
||||
*/
|
||||
export interface ModelInfo {
|
||||
model_identifier: string
|
||||
name: string
|
||||
api_provider: string
|
||||
price_in: number | null
|
||||
price_out: number | null
|
||||
temperature?: number | null // 模型级别温度,覆盖任务配置中的温度
|
||||
max_tokens?: number | null // 模型级别最大token数,覆盖任务配置中的max_tokens
|
||||
force_stream_mode?: boolean
|
||||
extra_params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供商完整配置接口
|
||||
*/
|
||||
export interface ProviderConfig {
|
||||
name: string
|
||||
base_url: string
|
||||
api_key: string
|
||||
client_type: string
|
||||
max_retry?: number
|
||||
timeout?: number
|
||||
retry_interval?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个任务配置
|
||||
*/
|
||||
export interface TaskConfig {
|
||||
model_list: string[]
|
||||
temperature?: number
|
||||
max_tokens?: number
|
||||
slow_threshold?: number
|
||||
selection_strategy?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有模型任务配置
|
||||
*/
|
||||
export interface ModelTaskConfig {
|
||||
utils: TaskConfig
|
||||
tool_use: TaskConfig
|
||||
replyer: TaskConfig
|
||||
planner: TaskConfig
|
||||
vlm: TaskConfig
|
||||
voice: TaskConfig
|
||||
embedding: TaskConfig
|
||||
lpmm_entity_extract: TaskConfig
|
||||
lpmm_rdf_build: TaskConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单验证错误
|
||||
*/
|
||||
export interface FormErrors {
|
||||
name?: string
|
||||
api_provider?: string
|
||||
model_identifier?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务名称类型
|
||||
*/
|
||||
export type TaskName = keyof ModelTaskConfig
|
||||
1713
dashboard/src/routes/config/modelProvider.tsx
Normal file
1713
dashboard/src/routes/config/modelProvider.tsx
Normal file
File diff suppressed because it is too large
Load Diff
11
dashboard/src/routes/config/modelProvider/index.ts
Normal file
11
dashboard/src/routes/config/modelProvider/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 模型提供商配置模块
|
||||
*
|
||||
* 模块结构:
|
||||
* - types.ts: 类型定义
|
||||
* - utils.ts: 工具函数
|
||||
* - 主组件在上级目录的 modelProvider.tsx
|
||||
*/
|
||||
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
33
dashboard/src/routes/config/modelProvider/types.ts
Normal file
33
dashboard/src/routes/config/modelProvider/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* API 提供商接口定义
|
||||
*/
|
||||
export interface APIProvider {
|
||||
name: string
|
||||
base_url: string
|
||||
api_key: string
|
||||
client_type: string
|
||||
max_retry: number | null
|
||||
timeout: number | null
|
||||
retry_interval: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除确认对话框状态
|
||||
*/
|
||||
export interface DeleteConfirmState {
|
||||
isOpen: boolean
|
||||
providersToDelete: string[]
|
||||
affectedModels: any[]
|
||||
pendingProviders: APIProvider[]
|
||||
context: 'auto' | 'manual' | 'restart'
|
||||
oldProviders: APIProvider[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单验证错误
|
||||
*/
|
||||
export interface FormErrors {
|
||||
name?: string
|
||||
base_url?: string
|
||||
api_key?: string
|
||||
}
|
||||
61
dashboard/src/routes/config/modelProvider/utils.ts
Normal file
61
dashboard/src/routes/config/modelProvider/utils.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { APIProvider } from './types'
|
||||
|
||||
/**
|
||||
* 清理 provider 数据,填充默认值
|
||||
* 用于确保所有数值字段都有有效值,避免 null 导致的后端验证错误
|
||||
*/
|
||||
export const cleanProviderData = (provider: APIProvider): APIProvider => ({
|
||||
...provider,
|
||||
max_retry: provider.max_retry ?? 2,
|
||||
timeout: provider.timeout ?? 30,
|
||||
retry_interval: provider.retry_interval ?? 10,
|
||||
})
|
||||
|
||||
/**
|
||||
* 验证提供商表单数据
|
||||
* @param provider 当前编辑的提供商
|
||||
* @param existingProviders 现有提供商列表
|
||||
* @param editingIndex 当前编辑的索引(新增时为 null)
|
||||
*/
|
||||
export const validateProvider = (
|
||||
provider: APIProvider | null,
|
||||
existingProviders: APIProvider[] = [],
|
||||
editingIndex: number | null = null
|
||||
): {
|
||||
isValid: boolean
|
||||
errors: { name?: string; base_url?: string; api_key?: string }
|
||||
} => {
|
||||
const errors: { name?: string; base_url?: string; api_key?: string } = {}
|
||||
|
||||
if (!provider) {
|
||||
return { isValid: false, errors: { name: '提供商数据为空' } }
|
||||
}
|
||||
|
||||
if (!provider.name?.trim()) {
|
||||
errors.name = '请输入提供商名称'
|
||||
} else {
|
||||
// 检查名称是否与现有提供商重复
|
||||
const isDuplicate = existingProviders.some((p, index) => {
|
||||
// 编辑时排除自身
|
||||
if (editingIndex !== null && index === editingIndex) {
|
||||
return false
|
||||
}
|
||||
return p.name.trim().toLowerCase() === provider.name.trim().toLowerCase()
|
||||
})
|
||||
if (isDuplicate) {
|
||||
errors.name = '提供商名称已存在,请使用其他名称'
|
||||
}
|
||||
}
|
||||
|
||||
if (!provider.base_url?.trim()) {
|
||||
errors.base_url = '请输入基础 URL'
|
||||
}
|
||||
if (!provider.api_key?.trim()) {
|
||||
errors.api_key = '请输入 API Key'
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: Object.keys(errors).length === 0,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
929
dashboard/src/routes/config/pack-detail.tsx
Normal file
929
dashboard/src/routes/config/pack-detail.tsx
Normal file
@@ -0,0 +1,929 @@
|
||||
/**
|
||||
* Pack 详情页面
|
||||
*
|
||||
* 查看 Pack 详情并应用到本地配置
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { packDetailRoute } from '@/router'
|
||||
import {
|
||||
Package,
|
||||
ArrowLeft,
|
||||
Download,
|
||||
Heart,
|
||||
Clock,
|
||||
User,
|
||||
Server,
|
||||
Layers,
|
||||
ListChecks,
|
||||
Tag,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
ChevronRight,
|
||||
Key,
|
||||
Settings,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import {
|
||||
getPack,
|
||||
recordPackDownload,
|
||||
togglePackLike,
|
||||
checkPackLike,
|
||||
detectPackConflicts,
|
||||
applyPack,
|
||||
getPackUserId,
|
||||
type ModelPack,
|
||||
type ApplyPackOptions,
|
||||
type ApplyPackConflicts,
|
||||
} from '@/lib/pack-api'
|
||||
|
||||
// 任务类型名称映射
|
||||
const TASK_TYPE_NAMES: Record<string, string> = {
|
||||
utils: '通用工具',
|
||||
utils_small: '轻量工具',
|
||||
tool_use: '工具调用',
|
||||
replyer: '回复生成',
|
||||
planner: '规划推理',
|
||||
vlm: '视觉模型',
|
||||
voice: '语音处理',
|
||||
embedding: '向量嵌入',
|
||||
lpmm_entity_extract: '实体提取',
|
||||
lpmm_rdf_build: 'RDF构建',
|
||||
lpmm_qa: '问答模型',
|
||||
}
|
||||
|
||||
export default function PackDetailPage() {
|
||||
const { packId } = packDetailRoute.useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [pack, setPack] = useState<ModelPack | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [liked, setLiked] = useState(false)
|
||||
const [liking, setLiking] = useState(false)
|
||||
|
||||
// 应用向导状态
|
||||
const [showApplyDialog, setShowApplyDialog] = useState(false)
|
||||
const [applyStep, setApplyStep] = useState(1)
|
||||
const [conflicts, setConflicts] = useState<ApplyPackConflicts | null>(null)
|
||||
const [detectingConflicts, setDetectingConflicts] = useState(false)
|
||||
const [applying, setApplying] = useState(false)
|
||||
|
||||
// 应用选项
|
||||
const [applyOptions, setApplyOptions] = useState<ApplyPackOptions>({
|
||||
apply_providers: true,
|
||||
apply_models: true,
|
||||
apply_task_config: true,
|
||||
task_mode: 'append',
|
||||
selected_providers: undefined,
|
||||
selected_models: undefined,
|
||||
selected_tasks: undefined,
|
||||
})
|
||||
|
||||
// 提供商映射和 API Key
|
||||
const [providerMapping, setProviderMapping] = useState<Record<string, string>>({})
|
||||
const [newProviderApiKeys, setNewProviderApiKeys] = useState<Record<string, string>>({})
|
||||
|
||||
const userId = getPackUserId()
|
||||
|
||||
// 加载 Pack
|
||||
const loadPack = useCallback(async () => {
|
||||
if (!packId) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await getPack(packId)
|
||||
setPack(data)
|
||||
|
||||
const isLiked = await checkPackLike(packId, userId)
|
||||
setLiked(isLiked)
|
||||
} catch (error) {
|
||||
console.error('加载 Pack 失败:', error)
|
||||
toast({ title: '加载模板失败', variant: 'destructive' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [packId, userId])
|
||||
|
||||
useEffect(() => {
|
||||
loadPack()
|
||||
}, [loadPack])
|
||||
|
||||
// 点赞
|
||||
const handleLike = async () => {
|
||||
if (!packId || liking) return
|
||||
|
||||
setLiking(true)
|
||||
try {
|
||||
const result = await togglePackLike(packId, userId)
|
||||
setLiked(result.liked)
|
||||
if (pack) {
|
||||
setPack({ ...pack, likes: result.likes })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('点赞失败:', error)
|
||||
toast({ title: '点赞失败', variant: 'destructive' })
|
||||
} finally {
|
||||
setLiking(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 开始应用流程
|
||||
const startApply = async () => {
|
||||
if (!pack) return
|
||||
|
||||
setShowApplyDialog(true)
|
||||
setApplyStep(1)
|
||||
setDetectingConflicts(true)
|
||||
|
||||
try {
|
||||
const detected = await detectPackConflicts(pack)
|
||||
setConflicts(detected)
|
||||
|
||||
// 初始化提供商映射(已存在的提供商默认使用第一个匹配的本地提供商)
|
||||
const mapping: Record<string, string> = {}
|
||||
for (const c of detected.existing_providers) {
|
||||
mapping[c.pack_provider.name] = c.local_providers[0].name
|
||||
}
|
||||
setProviderMapping(mapping)
|
||||
|
||||
// 初始化新提供商的 API Key
|
||||
const keys: Record<string, string> = {}
|
||||
for (const p of detected.new_providers) {
|
||||
keys[p.name] = ''
|
||||
}
|
||||
setNewProviderApiKeys(keys)
|
||||
} catch (error) {
|
||||
console.error('检测冲突失败:', error)
|
||||
toast({ title: '检测配置冲突失败', variant: 'destructive' })
|
||||
setShowApplyDialog(false)
|
||||
} finally {
|
||||
setDetectingConflicts(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行应用
|
||||
const executeApply = async () => {
|
||||
if (!pack) return
|
||||
|
||||
// 验证新提供商都有 API Key
|
||||
if (applyOptions.apply_providers && conflicts) {
|
||||
for (const p of conflicts.new_providers) {
|
||||
if (!newProviderApiKeys[p.name]) {
|
||||
toast({ title: `请填写提供商 "${p.name}" 的 API Key`, variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setApplying(true)
|
||||
try {
|
||||
await applyPack(pack, applyOptions, providerMapping, newProviderApiKeys)
|
||||
|
||||
// 记录下载
|
||||
await recordPackDownload(pack.id, userId)
|
||||
|
||||
// 更新下载数
|
||||
setPack({ ...pack, downloads: pack.downloads + 1 })
|
||||
|
||||
toast({ title: '配置模板应用成功!' })
|
||||
setShowApplyDialog(false)
|
||||
} catch (error) {
|
||||
console.error('应用 Pack 失败:', error)
|
||||
toast({ title: error instanceof Error ? error.message : '应用配置失败', variant: 'destructive' })
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <PackDetailSkeleton />
|
||||
}
|
||||
|
||||
if (!pack) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<Package className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<h2 className="text-xl font-semibold">模板不存在</h2>
|
||||
<p className="text-muted-foreground mt-2">该配置模板可能已被删除或尚未通过审核</p>
|
||||
<Button className="mt-4" onClick={() => navigate({ to: '/config/pack-market' })}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
返回市场
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] flex flex-col p-4 sm:p-6">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* 返回按钮 */}
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/config/pack-market' })} className="gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回市场
|
||||
</Button>
|
||||
|
||||
{/* 头部信息 */}
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Package className="w-10 h-10 text-primary mt-1" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
{pack.name}
|
||||
<Badge variant="secondary">v{pack.version}</Badge>
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">{pack.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="w-4 h-4" />
|
||||
{pack.author}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatDate(pack.created_at)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Download className="w-4 h-4" />
|
||||
{pack.downloads} 次下载
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className={`w-4 h-4 ${liked ? 'fill-red-500 text-red-500' : ''}`} />
|
||||
{pack.likes} 赞
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
{pack.tags && pack.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{pack.tags.map(tag => (
|
||||
<Badge key={tag} variant="outline">
|
||||
<Tag className="w-3 h-3 mr-1" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex flex-col gap-2 min-w-[160px]">
|
||||
<Button size="lg" onClick={startApply}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
应用模板
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleLike}
|
||||
disabled={liking}
|
||||
className={liked ? 'text-red-500 border-red-200' : ''}
|
||||
>
|
||||
<Heart className={`w-4 h-4 mr-2 ${liked ? 'fill-current' : ''}`} />
|
||||
{liked ? '已点赞' : '点赞'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 内容统计 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<Server className="w-8 h-8 text-blue-500 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{pack.providers.length}</p>
|
||||
<p className="text-sm text-muted-foreground">API 提供商</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<Layers className="w-8 h-8 text-green-500 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{pack.models.length}</p>
|
||||
<p className="text-sm text-muted-foreground">模型配置</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<ListChecks className="w-8 h-8 text-purple-500 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{Object.keys(pack.task_config).length}</p>
|
||||
<p className="text-sm text-muted-foreground">任务配置</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 详细内容 */}
|
||||
<Tabs defaultValue="providers" className="space-y-4">
|
||||
<TabsList className="w-full sm:w-auto grid grid-cols-3 sm:flex">
|
||||
<TabsTrigger value="providers" className="gap-1 sm:gap-2">
|
||||
<Server className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">提供商</span>
|
||||
<span className="sm:hidden">提供商</span>
|
||||
<span className="hidden sm:inline">({pack.providers.length})</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models" className="gap-1 sm:gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">模型</span>
|
||||
<span className="sm:hidden">模型</span>
|
||||
<span className="hidden sm:inline">({pack.models.length})</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tasks" className="gap-1 sm:gap-2">
|
||||
<ListChecks className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">任务配置</span>
|
||||
<span className="sm:hidden">任务</span>
|
||||
<span className="hidden sm:inline">({Object.keys(pack.task_config).length})</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="providers">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API 提供商</CardTitle>
|
||||
<CardDescription>模板中包含的 API 提供商配置(不含 API Key)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>Base URL</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pack.providers.map(provider => (
|
||||
<TableRow key={provider.name}>
|
||||
<TableCell className="font-medium whitespace-nowrap">{provider.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground font-mono text-sm max-w-[200px] truncate">
|
||||
{provider.base_url}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{provider.client_type}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="models">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>模型配置</CardTitle>
|
||||
<CardDescription>模板中包含的模型配置</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>模型名称</TableHead>
|
||||
<TableHead>标识符</TableHead>
|
||||
<TableHead>提供商</TableHead>
|
||||
<TableHead className="text-right">价格 (入/出)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pack.models.map(model => (
|
||||
<TableRow key={model.name}>
|
||||
<TableCell className="font-medium whitespace-nowrap">{model.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground font-mono text-sm max-w-[150px] truncate">
|
||||
{model.model_identifier}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{model.api_provider}</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground whitespace-nowrap">
|
||||
¥{model.price_in} / ¥{model.price_out}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tasks">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>任务配置</CardTitle>
|
||||
<CardDescription>模板中各任务类型的模型分配</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{Object.entries(pack.task_config).map(([taskKey, config]) => (
|
||||
<AccordionItem key={taskKey} value={taskKey}>
|
||||
<AccordionTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
{TASK_TYPE_NAMES[taskKey] || taskKey}
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{config.model_list.length} 个模型
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-2 pl-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
分配的模型:
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{config.model_list.map((model: string) => (
|
||||
<Badge key={model} variant="outline">{model}</Badge>
|
||||
))}
|
||||
</div>
|
||||
{config.temperature !== undefined && (
|
||||
<div className="text-sm">
|
||||
Temperature: <span className="font-mono">{config.temperature}</span>
|
||||
</div>
|
||||
)}
|
||||
{config.max_tokens !== undefined && (
|
||||
<div className="text-sm">
|
||||
Max Tokens: <span className="font-mono">{config.max_tokens}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 应用向导对话框 */}
|
||||
<ApplyDialog
|
||||
open={showApplyDialog}
|
||||
onOpenChange={setShowApplyDialog}
|
||||
pack={pack}
|
||||
step={applyStep}
|
||||
setStep={setApplyStep}
|
||||
conflicts={conflicts}
|
||||
detectingConflicts={detectingConflicts}
|
||||
applying={applying}
|
||||
options={applyOptions}
|
||||
setOptions={setApplyOptions}
|
||||
_providerMapping={providerMapping}
|
||||
_setProviderMapping={setProviderMapping}
|
||||
newProviderApiKeys={newProviderApiKeys}
|
||||
setNewProviderApiKeys={setNewProviderApiKeys}
|
||||
onApply={executeApply}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 应用向导对话框
|
||||
function ApplyDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
pack,
|
||||
step,
|
||||
setStep,
|
||||
conflicts,
|
||||
detectingConflicts,
|
||||
applying,
|
||||
options,
|
||||
setOptions,
|
||||
_providerMapping,
|
||||
_setProviderMapping,
|
||||
newProviderApiKeys,
|
||||
setNewProviderApiKeys,
|
||||
onApply,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
pack: ModelPack
|
||||
step: number
|
||||
setStep: (step: number) => void
|
||||
conflicts: ApplyPackConflicts | null
|
||||
detectingConflicts: boolean
|
||||
applying: boolean
|
||||
options: ApplyPackOptions
|
||||
setOptions: (options: ApplyPackOptions) => void
|
||||
_providerMapping: Record<string, string>
|
||||
_setProviderMapping: (mapping: Record<string, string>) => void
|
||||
newProviderApiKeys: Record<string, string>
|
||||
setNewProviderApiKeys: (keys: Record<string, string>) => void
|
||||
onApply: () => void
|
||||
}) {
|
||||
const totalSteps = 3
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Package className="w-5 h-5" />
|
||||
应用配置模板
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
步骤 {step} / {totalSteps}:
|
||||
{step === 1 && '选择要应用的内容'}
|
||||
{step === 2 && '配置提供商映射'}
|
||||
{step === 3 && '确认并应用'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{detectingConflicts ? (
|
||||
<div className="py-8 text-center">
|
||||
<Loader2 className="w-8 h-8 mx-auto animate-spin text-primary" />
|
||||
<p className="mt-4 text-muted-foreground">正在检测配置冲突...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 步骤 1: 选择内容 */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="apply_providers"
|
||||
checked={options.apply_providers}
|
||||
onCheckedChange={checked =>
|
||||
setOptions({ ...options, apply_providers: checked as boolean })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="apply_providers" className="flex items-center gap-2">
|
||||
<Server className="w-4 h-4" />
|
||||
应用提供商配置 ({pack.providers.length} 个)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="apply_models"
|
||||
checked={options.apply_models}
|
||||
onCheckedChange={checked =>
|
||||
setOptions({ ...options, apply_models: checked as boolean })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="apply_models" className="flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
应用模型配置 ({pack.models.length} 个)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="apply_task_config"
|
||||
checked={options.apply_task_config}
|
||||
onCheckedChange={checked =>
|
||||
setOptions({ ...options, apply_task_config: checked as boolean })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="apply_task_config" className="flex items-center gap-2">
|
||||
<ListChecks className="w-4 h-4" />
|
||||
应用任务配置 ({Object.keys(pack.task_config).length} 个)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{options.apply_task_config && (
|
||||
<div className="pl-6 space-y-2 border-l-2 border-muted">
|
||||
<Label className="text-sm font-medium">任务配置应用模式</Label>
|
||||
<RadioGroup
|
||||
value={options.task_mode}
|
||||
onValueChange={value =>
|
||||
setOptions({ ...options, task_mode: value as 'replace' | 'append' })
|
||||
}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="append" id="mode_append" />
|
||||
<Label htmlFor="mode_append" className="font-normal">
|
||||
追加模式 - 将模板中的模型添加到现有配置
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="replace" id="mode_replace" />
|
||||
<Label htmlFor="mode_replace" className="font-normal">
|
||||
替换模式 - 用模板配置完全替换现有配置
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 步骤 2: 提供商映射 */}
|
||||
{step === 2 && conflicts && (
|
||||
<div className="space-y-4">
|
||||
{/* 已存在的提供商 */}
|
||||
{options.apply_providers && conflicts.existing_providers.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>发现已有的提供商</AlertTitle>
|
||||
<AlertDescription>
|
||||
以下提供商的 URL 与您本地配置中的提供商匹配,将自动使用本地提供商:
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
{conflicts.existing_providers.map(({ pack_provider, local_providers }) => (
|
||||
<div
|
||||
key={pack_provider.name}
|
||||
className="flex items-center gap-2 p-3 bg-muted rounded-lg"
|
||||
>
|
||||
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||
<span className="font-medium flex-shrink-0">{pack_provider.name}</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
|
||||
{local_providers.length === 1 ? (
|
||||
<>
|
||||
<span className="text-muted-foreground">{local_providers[0].name}</span>
|
||||
<Badge variant="outline" className="ml-auto">URL 匹配</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
value={_providerMapping[pack_provider.name] || local_providers[0].name}
|
||||
onValueChange={value =>
|
||||
_setProviderMapping({
|
||||
..._providerMapping,
|
||||
[pack_provider.name]: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{local_providers.map(p => (
|
||||
<SelectItem key={p.name} value={p.name}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{local_providers.length} 个匹配
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新提供商 */}
|
||||
{options.apply_providers && conflicts.new_providers.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>需要配置 API Key</AlertTitle>
|
||||
<AlertDescription>
|
||||
以下提供商在您的本地配置中不存在,需要填写 API Key:
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4">
|
||||
{conflicts.new_providers.map(provider => (
|
||||
<div key={provider.name} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="w-4 h-4 text-amber-500" />
|
||||
<span className="font-medium">{provider.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({provider.base_url})
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={`输入 ${provider.name} 的 API Key`}
|
||||
value={newProviderApiKeys[provider.name] || ''}
|
||||
onChange={e =>
|
||||
setNewProviderApiKeys({
|
||||
...newProviderApiKeys,
|
||||
[provider.name]: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!options.apply_providers || (conflicts.existing_providers.length === 0 && conflicts.new_providers.length === 0)) && (
|
||||
<Alert>
|
||||
<Check className="h-4 w-4" />
|
||||
<AlertTitle>无需配置</AlertTitle>
|
||||
<AlertDescription>
|
||||
模板中没有提供商配置,或您选择不应用提供商。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 步骤 3: 确认 */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>确认应用</AlertTitle>
|
||||
<AlertDescription>
|
||||
请确认以下将要应用的内容:
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
{options.apply_providers && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
<Server className="w-4 h-4" />
|
||||
<span>应用 {pack.providers.length} 个提供商配置</span>
|
||||
</div>
|
||||
)}
|
||||
{options.apply_models && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
<Layers className="w-4 h-4" />
|
||||
<span>应用 {pack.models.length} 个模型配置</span>
|
||||
</div>
|
||||
)}
|
||||
{options.apply_task_config && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
<ListChecks className="w-4 h-4" />
|
||||
<span>
|
||||
{options.task_mode === 'append' ? '追加' : '替换'} {Object.keys(pack.task_config).length} 个任务配置
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{conflicts && conflicts.new_providers.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
将添加 {conflicts.new_providers.length} 个新提供商,请确保已填写正确的 API Key。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex justify-between">
|
||||
<div>
|
||||
{step > 1 && !detectingConflicts && (
|
||||
<Button variant="outline" onClick={() => setStep(step - 1)} disabled={applying}>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={applying}>
|
||||
取消
|
||||
</Button>
|
||||
{step < totalSteps ? (
|
||||
<Button onClick={() => setStep(step + 1)} disabled={detectingConflicts}>
|
||||
下一步
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onApply} disabled={applying}>
|
||||
{applying && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
应用模板
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// 加载骨架
|
||||
function PackDetailSkeleton() {
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] flex flex-col p-4 sm:p-6">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* 返回按钮 */}
|
||||
<Skeleton className="h-9 w-24" />
|
||||
|
||||
{/* 头部信息 */}
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Skeleton className="w-10 h-10" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-8 w-2/3" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Skeleton className="h-6 w-20" />
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-6 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex flex-col gap-2 min-w-[160px]">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-px w-full" />
|
||||
|
||||
{/* 内容统计卡片 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Skeleton className="h-24" />
|
||||
<Skeleton className="h-24" />
|
||||
<Skeleton className="h-24" />
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-10 w-32" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
422
dashboard/src/routes/config/pack-market.tsx
Normal file
422
dashboard/src/routes/config/pack-market.tsx
Normal file
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* Pack 市场页面
|
||||
*
|
||||
* 浏览、搜索、应用模型配置 Pack
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Package,
|
||||
Search,
|
||||
Download,
|
||||
Heart,
|
||||
Clock,
|
||||
Tag,
|
||||
ChevronDown,
|
||||
ArrowUpDown,
|
||||
RefreshCw,
|
||||
User,
|
||||
Layers,
|
||||
Server,
|
||||
ListChecks,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import {
|
||||
listPacks,
|
||||
togglePackLike,
|
||||
checkPackLike,
|
||||
getPackUserId,
|
||||
type PackListItem,
|
||||
type ListPacksResponse,
|
||||
} from '@/lib/pack-api'
|
||||
|
||||
// 排序选项
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'created_at', label: '最新发布', icon: Clock },
|
||||
{ value: 'downloads', label: '下载最多', icon: Download },
|
||||
{ value: 'likes', label: '最受欢迎', icon: Heart },
|
||||
] as const
|
||||
|
||||
type SortBy = typeof SORT_OPTIONS[number]['value']
|
||||
|
||||
export default function PackMarketPage() {
|
||||
const navigate = useNavigate()
|
||||
const [packs, setPacks] = useState<PackListItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState<SortBy>('downloads')
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [likedPacks, setLikedPacks] = useState<Set<string>>(new Set())
|
||||
const [likingPacks, setLikingPacks] = useState<Set<string>>(new Set())
|
||||
|
||||
const userId = getPackUserId()
|
||||
|
||||
// 加载 Pack 列表
|
||||
const loadPacks = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response: ListPacksResponse = await listPacks({
|
||||
status: 'approved',
|
||||
page,
|
||||
page_size: 12,
|
||||
search: searchQuery || undefined,
|
||||
sort_by: sortBy,
|
||||
sort_order: 'desc',
|
||||
})
|
||||
setPacks(response.packs)
|
||||
setTotalPages(response.total_pages)
|
||||
setTotal(response.total)
|
||||
|
||||
// 检查点赞状态
|
||||
const likedSet = new Set<string>()
|
||||
for (const pack of response.packs) {
|
||||
const liked = await checkPackLike(pack.id, userId)
|
||||
if (liked) likedSet.add(pack.id)
|
||||
}
|
||||
setLikedPacks(likedSet)
|
||||
} catch (error) {
|
||||
console.error('加载 Pack 列表失败:', error)
|
||||
toast({ title: '加载 Pack 列表失败', variant: 'destructive' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, searchQuery, sortBy, userId])
|
||||
|
||||
useEffect(() => {
|
||||
loadPacks()
|
||||
}, [loadPacks])
|
||||
|
||||
// 搜索
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setPage(1)
|
||||
loadPacks()
|
||||
}
|
||||
|
||||
// 点赞
|
||||
const handleLike = async (packId: string) => {
|
||||
if (likingPacks.has(packId)) return
|
||||
|
||||
setLikingPacks(prev => new Set(prev).add(packId))
|
||||
try {
|
||||
const result = await togglePackLike(packId, userId)
|
||||
|
||||
// 更新点赞状态
|
||||
setLikedPacks(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (result.liked) {
|
||||
newSet.add(packId)
|
||||
} else {
|
||||
newSet.delete(packId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
|
||||
// 更新点赞数
|
||||
setPacks(prev => prev.map(p =>
|
||||
p.id === packId ? { ...p, likes: result.likes } : p
|
||||
))
|
||||
} catch (error) {
|
||||
console.error('点赞失败:', error)
|
||||
toast({ title: '点赞失败', variant: 'destructive' })
|
||||
} finally {
|
||||
setLikingPacks(prev => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(packId)
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleViewPack = (packId: string) => {
|
||||
navigate({ to: '/config/pack-market/$packId', params: { packId } })
|
||||
}
|
||||
|
||||
// 获取当前排序选项
|
||||
const currentSort = SORT_OPTIONS.find(o => o.value === sortBy) || SORT_OPTIONS[0]
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] flex flex-col p-4 sm:p-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2">
|
||||
<Package className="h-8 w-8" strokeWidth={2} />
|
||||
配置模板市场
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm sm:text-base">
|
||||
浏览和应用社区分享的模型配置模板,快速配置你的 MaiBot
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={loadPacks} disabled={loading} className="gap-2">
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-4">
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<form onSubmit={handleSearch} className="flex-1 min-w-[200px] max-w-md">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索模板名称、描述..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="min-w-[140px] gap-2">
|
||||
<ArrowUpDown className="w-4 h-4" />
|
||||
{currentSort.label}
|
||||
<ChevronDown className="w-4 h-4 ml-auto" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{SORT_OPTIONS.map(option => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
setSortBy(option.value)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<option.icon className="w-4 h-4 mr-2" />
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共找到 <span className="font-medium text-foreground">{total}</span> 个模板
|
||||
</div>
|
||||
|
||||
{/* Pack 列表 */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-3/4" />
|
||||
<Skeleton className="h-4 w-full mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : packs.length === 0 ? (
|
||||
<Card className="py-12">
|
||||
<CardContent className="text-center text-muted-foreground">
|
||||
<Package className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium">暂无模板</p>
|
||||
<p className="mt-1">还没有人分享配置模板,快来分享第一个吧!</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{packs.map(pack => (
|
||||
<PackCard
|
||||
key={pack.id}
|
||||
pack={pack}
|
||||
liked={likedPacks.has(pack.id)}
|
||||
liking={likingPacks.has(pack.id)}
|
||||
onLike={() => handleLike(pack.id)}
|
||||
onView={() => handleViewPack(pack.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
className={page === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 1)
|
||||
.map((p, i, arr) => {
|
||||
const showEllipsis = i > 0 && p - arr[i - 1] > 1
|
||||
return (
|
||||
<PaginationItem key={p}>
|
||||
{showEllipsis && <span className="px-2">...</span>}
|
||||
<PaginationLink
|
||||
onClick={() => setPage(p)}
|
||||
isActive={p === page}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{p}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
})}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
className={page === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Pack 卡片组件
|
||||
function PackCard({
|
||||
pack,
|
||||
liked,
|
||||
liking,
|
||||
onLike,
|
||||
onView,
|
||||
}: {
|
||||
pack: PackListItem
|
||||
liked: boolean
|
||||
liking: boolean
|
||||
onLike: () => void
|
||||
onView: () => void
|
||||
}) {
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-lg line-clamp-1">{pack.name}</CardTitle>
|
||||
<Badge variant="secondary" className="text-xs">v{pack.version}</Badge>
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2 min-h-[40px]">
|
||||
{pack.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 space-y-3">
|
||||
{/* 作者和日期 */}
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="w-3.5 h-3.5" />
|
||||
{pack.author}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{formatDate(pack.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 内容统计 */}
|
||||
<div className="flex gap-4 text-sm">
|
||||
<span className="flex items-center gap-1 text-muted-foreground" title="提供商数量">
|
||||
<Server className="w-3.5 h-3.5" />
|
||||
{pack.provider_count}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-muted-foreground" title="模型数量">
|
||||
<Layers className="w-3.5 h-3.5" />
|
||||
{pack.model_count}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-muted-foreground" title="任务配置数">
|
||||
<ListChecks className="w-3.5 h-3.5" />
|
||||
{pack.task_count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
{pack.tags && pack.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{pack.tags.slice(0, 3).map(tag => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
<Tag className="w-2.5 h-2.5 mr-1" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{pack.tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{pack.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="pt-3 border-t">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
{/* 统计 */}
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Download className="w-4 h-4" />
|
||||
{pack.downloads}
|
||||
</span>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onLike() }}
|
||||
disabled={liking}
|
||||
className={`flex items-center gap-1 transition-colors ${
|
||||
liked ? 'text-red-500' : 'hover:text-red-500'
|
||||
} ${liking ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<Heart className={`w-4 h-4 ${liked ? 'fill-current' : ''}`} />
|
||||
{pack.likes}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 查看按钮 */}
|
||||
<Button size="sm" onClick={onView}>
|
||||
查看详情
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
235
dashboard/src/routes/config/providerTemplates.ts
Normal file
235
dashboard/src/routes/config/providerTemplates.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* 模型提供商模板配置
|
||||
*
|
||||
* 这些预设模板帮助用户快速配置常用的 API 提供商
|
||||
*/
|
||||
|
||||
// 模型获取器配置定义
|
||||
export interface ModelFetcherConfig {
|
||||
// 获取模型列表的端点(相对于 base_url)
|
||||
endpoint: string
|
||||
// 响应解析器类型
|
||||
parser: 'openai' | 'gemini'
|
||||
}
|
||||
|
||||
// 提供商模板定义
|
||||
export interface ProviderTemplate {
|
||||
id: string
|
||||
name: string
|
||||
base_url: string
|
||||
client_type: 'openai' | 'gemini'
|
||||
display_name: string
|
||||
// 模型列表获取配置(可选,未配置则不支持自动获取)
|
||||
modelFetcher?: ModelFetcherConfig
|
||||
}
|
||||
|
||||
// 内置提供商模板
|
||||
export const PROVIDER_TEMPLATES: ProviderTemplate[] = [
|
||||
// 国内提供商
|
||||
{
|
||||
id: 'siliconflow',
|
||||
name: 'SiliconFlow',
|
||||
base_url: 'https://api.siliconflow.cn/v1',
|
||||
client_type: 'openai',
|
||||
display_name: '硅基流动 (SiliconFlow)',
|
||||
modelFetcher: { endpoint: '/models', parser: 'openai' },
|
||||
},
|
||||
{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com',
|
||||
client_type: 'openai',
|
||||
display_name: 'DeepSeek',
|
||||
modelFetcher: { endpoint: '/models', parser: 'openai' },
|
||||
},
|
||||
{
|
||||
id: 'rinkoai',
|
||||
name: 'RinkoAI',
|
||||
base_url: 'https://rinkoai.com/v1',
|
||||
client_type: 'openai',
|
||||
display_name: 'RinkoAI',
|
||||
modelFetcher: { endpoint: '/models', parser: 'openai' },
|
||||
},
|
||||
{
|
||||
id: 'zhipu',
|
||||
name: 'ZhipuAI',
|
||||
base_url: 'https://open.bigmodel.cn/api/paas/v4',
|
||||
client_type: 'openai',
|
||||
display_name: '智谱 AI (ZhipuAI / GLM)',
|
||||
modelFetcher: { endpoint: '/models', parser: 'openai' },
|
||||
},
|
||||
{
|
||||
id: 'moonshot',
|
||||
name: 'Moonshot',
|
||||
base_url: 'https://api.moonshot.cn/v1',
|
||||
client_type: 'openai',
|
||||
display_name: '月之暗面 (Moonshot / Kimi)',
|
||||
modelFetcher: { endpoint: '/models', parser: 'openai' },
|
||||
},
|
||||
{
|
||||
id: 'doubao',
|
||||
name: 'Doubao',
|
||||
base_url: 'https://ark.cn-beijing.volces.com/api/v3',
|
||||
client_type: 'openai',
|
||||
display_name: '字节豆包 (Doubao)',
|
||||
modelFetcher: { endpoint: '/models', parser: 'openai' },
|
||||
},
|
||||
{
|
||||
id: 'alibaba',
|
||||
name: 'Alibaba',
|
||||
base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
client_type: 'openai',
|
||||
display_name: '阿里云百炼 (Alibaba Qwen)',
|
||||
modelFetcher: { endpoint: '/models', parser: 'openai' },
|
||||
},
|
||||
{
|
||||
id: 'baichuan',
|
||||
name: 'Baichuan',
|
||||
base_url: 'https://api.baichuan-ai.com/v1',
|
||||
client_type: 'openai',
|
||||
display_name: '百川智能 (Baichuan)',
|
||||
modelFetcher: { endpoint: '/models', parser: 'openai' },
|
||||
},
|
||||
{
|
||||
id: 'minimax',
|
||||
name: 'MiniMax',
|
||||
base_url: 'https://api.minimax.chat/v1',
|
||||
client_type: 'openai',
|
||||
display_name: 'MiniMax (海螺 AI)',
|
||||
modelFetcher: { endpoint: '/models', parser: 'openai' },
|
||||
},
|
||||
{
|
||||
id: 'stepfun',
|
||||
name: 'StepFun',
|
||||
base_url: 'https://api.stepfun.com/v1',
|
||||
client_type: 'openai',
|
||||
display_name: '阶跃星辰 (StepFun)',
|
||||
modelFetcher: { endpoint: '/models', parser: 'openai' },
|
||||
},
|
||||
{
|
||||
id: 'lingyi',
|
||||
name: 'Lingyi',
|
||||
base_url: 'https://api.lingyiwanwu.com/v1',
|
||||
client_type: 'openai',
|
||||
display_name: '零一万物 (Lingyi / Yi)',
|
||||
modelFetcher: { endpoint: '/models', parser: 'openai' },
|
||||
},
|
||||
|
||||
// 国际提供商
|
||||
{
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
base_url: 'https://api.openai.com/v1',
|
||||
client_type: 'openai',
|
||||
display_name: 'OpenAI',
|
||||
modelFetcher: { endpoint: '/models', parser: 'openai' },
|
||||
},
|
||||
{
|
||||
id: 'xai',
|
||||
name: 'xAI',
|
||||
base_url: 'https://api.x.ai/v1',
|
||||
client_type: 'openai',
|
||||
display_name: 'xAI (Grok)',
|
||||
modelFetcher: { endpoint: '/models', parser: 'openai' },
|
||||
},
|
||||
{
|
||||
id: 'anthropic',
|
||||
name: 'Anthropic',
|
||||
base_url: 'https://api.anthropic.com/v1',
|
||||
client_type: 'openai',
|
||||
display_name: 'Anthropic (Claude)',
|
||||
// Anthropic 使用不同的 API 格式,暂不支持自动获取
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
name: 'Gemini',
|
||||
base_url: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
client_type: 'gemini',
|
||||
display_name: 'Google Gemini',
|
||||
modelFetcher: { endpoint: '/models', parser: 'gemini' },
|
||||
},
|
||||
{
|
||||
id: 'cohere',
|
||||
name: 'Cohere',
|
||||
base_url: 'https://api.cohere.ai/v1',
|
||||
client_type: 'openai',
|
||||
display_name: 'Cohere',
|
||||
// Cohere 使用不同的 API 格式,暂不支持自动获取
|
||||
},
|
||||
{
|
||||
id: 'groq',
|
||||
name: 'Groq',
|
||||
base_url: 'https://api.groq.com/openai/v1',
|
||||
client_type: 'openai',
|
||||
display_name: 'Groq',
|
||||
modelFetcher: { endpoint: '/models', parser: 'openai' },
|
||||
},
|
||||
{
|
||||
id: 'together',
|
||||
name: 'Together AI',
|
||||
base_url: 'https://api.together.xyz/v1',
|
||||
client_type: 'openai',
|
||||
display_name: 'Together AI',
|
||||
modelFetcher: { endpoint: '/models', parser: 'openai' },
|
||||
},
|
||||
{
|
||||
id: 'fireworks',
|
||||
name: 'Fireworks',
|
||||
base_url: 'https://api.fireworks.ai/inference/v1',
|
||||
client_type: 'openai',
|
||||
display_name: 'Fireworks AI',
|
||||
modelFetcher: { endpoint: '/models', parser: 'openai' },
|
||||
},
|
||||
{
|
||||
id: 'mistral',
|
||||
name: 'Mistral',
|
||||
base_url: 'https://api.mistral.ai/v1',
|
||||
client_type: 'openai',
|
||||
display_name: 'Mistral AI',
|
||||
modelFetcher: { endpoint: '/models', parser: 'openai' },
|
||||
},
|
||||
{
|
||||
id: 'perplexity',
|
||||
name: 'Perplexity',
|
||||
base_url: 'https://api.perplexity.ai',
|
||||
client_type: 'openai',
|
||||
display_name: 'Perplexity AI',
|
||||
// Perplexity 不支持 /models 端点
|
||||
},
|
||||
|
||||
// 自定义选项
|
||||
{
|
||||
id: 'custom',
|
||||
name: '',
|
||||
base_url: '',
|
||||
client_type: 'openai',
|
||||
display_name: '自定义',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 规范化 URL(去掉尾部斜杠,统一格式)
|
||||
*/
|
||||
export function normalizeUrl(url: string): string {
|
||||
if (!url) return ''
|
||||
// 去掉尾部斜杠
|
||||
const normalized = url.replace(/\/+$/, '')
|
||||
// 转小写用于比较
|
||||
return normalized.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 base_url 查找匹配的模板
|
||||
* @param baseUrl 提供商的 base_url
|
||||
* @returns 匹配的模板,如果未找到则返回 null
|
||||
*/
|
||||
export function findTemplateByBaseUrl(baseUrl: string): ProviderTemplate | null {
|
||||
if (!baseUrl) return null
|
||||
|
||||
const normalizedUrl = normalizeUrl(baseUrl)
|
||||
|
||||
return PROVIDER_TEMPLATES.find(template =>
|
||||
template.id !== 'custom' &&
|
||||
normalizeUrl(template.base_url) === normalizedUrl
|
||||
) || null
|
||||
}
|
||||
Reference in New Issue
Block a user