上传完整的WebUI前端仓库

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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