上传完整的WebUI前端仓库
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user