MaiBot/dashboard/src/lib/pack-api.ts

571 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 模型配置 Pack API
*
* 与 Cloudflare Workers Pack 服务交互
*/
import { fetchWithAuth } from './fetch-with-auth'
// ============ 类型定义 ============
/**
* 提供商配置(分享时不含 api_key
*/
export interface PackProvider {
name: string
base_url: string
client_type: 'openai' | 'gemini'
max_retry?: number
timeout?: number
retry_interval?: number
}
/**
* 模型配置
*/
export interface PackModel {
model_identifier: string
name: string
api_provider: string
price_in: number
price_out: number
temperature?: number
max_tokens?: number
force_stream_mode?: boolean
extra_params?: Record<string, unknown>
}
/**
* 单个任务配置
*/
export interface PackTaskConfig {
model_list: string[]
temperature?: number
max_tokens?: number
slow_threshold?: number
}
/**
* 所有任务配置
*/
export interface PackTaskConfigs {
utils?: PackTaskConfig
utils_small?: PackTaskConfig
tool_use?: PackTaskConfig
replyer?: PackTaskConfig
planner?: PackTaskConfig
vlm?: PackTaskConfig
voice?: PackTaskConfig
embedding?: PackTaskConfig
lpmm_entity_extract?: PackTaskConfig
lpmm_rdf_build?: PackTaskConfig
lpmm_qa?: PackTaskConfig
}
/**
* Pack 列表项
*/
export interface PackListItem {
id: string
name: string
description: string
author: string
version: string
created_at: string
updated_at: string
status: 'pending' | 'approved' | 'rejected'
reject_reason?: string
downloads: number
likes: number
tags?: string[]
provider_count: number
model_count: number
task_count: number
}
/**
* 完整的 Pack 数据
*/
export interface ModelPack extends Omit<PackListItem, 'provider_count' | 'model_count' | 'task_count'> {
providers: PackProvider[]
models: PackModel[]
task_config: PackTaskConfigs
}
/**
* Pack 列表响应
*/
export interface ListPacksResponse {
packs: PackListItem[]
total: number
page: number
page_size: number
total_pages: number
}
/**
* 应用 Pack 时的选项
*/
export interface ApplyPackOptions {
apply_providers: boolean
apply_models: boolean
apply_task_config: boolean
task_mode: 'replace' | 'append'
selected_providers?: string[]
selected_models?: string[]
selected_tasks?: string[]
}
/**
* 应用 Pack 时的冲突检测结果
*/
export interface ApplyPackConflicts {
existing_providers: Array<{
pack_provider: PackProvider
local_providers: Array<{ // 改为数组,支持多个匹配
name: string
base_url: string
}>
}>
new_providers: PackProvider[]
conflicting_models: Array<{
pack_model: string
local_model: string
}>
}
// ============ API 配置 ============
// Pack 服务基础 URLCloudflare Workers
const PACK_SERVICE_URL = 'https://maibot-plugin-stats.maibot-webui.workers.dev'
// ============ API 函数 ============
/**
* 获取 Pack 列表
*/
export async function listPacks(params?: {
status?: 'pending' | 'approved' | 'rejected' | 'all'
page?: number
page_size?: number
search?: string
sort_by?: 'created_at' | 'downloads' | 'likes'
sort_order?: 'asc' | 'desc'
}): Promise<ListPacksResponse> {
const searchParams = new URLSearchParams()
if (params?.status) searchParams.set('status', params.status)
if (params?.page) searchParams.set('page', params.page.toString())
if (params?.page_size) searchParams.set('page_size', params.page_size.toString())
if (params?.search) searchParams.set('search', params.search)
if (params?.sort_by) searchParams.set('sort_by', params.sort_by)
if (params?.sort_order) searchParams.set('sort_order', params.sort_order)
const response = await fetch(`${PACK_SERVICE_URL}/pack?${searchParams.toString()}`)
if (!response.ok) {
throw new Error(`获取 Pack 列表失败: ${response.status}`)
}
return response.json()
}
/**
* 获取单个 Pack 详情
*/
export async function getPack(packId: string): Promise<ModelPack> {
const response = await fetch(`${PACK_SERVICE_URL}/pack/${packId}`)
if (!response.ok) {
throw new Error(`获取 Pack 失败: ${response.status}`)
}
const data = await response.json()
if (!data.success) {
throw new Error(data.error || '获取 Pack 失败')
}
return data.pack
}
/**
* 创建新 Pack
*/
export async function createPack(pack: {
name: string
description: string
author: string
tags?: string[]
providers: PackProvider[]
models: PackModel[]
task_config: PackTaskConfigs
}): Promise<{ pack_id: string; message: string }> {
const response = await fetch(`${PACK_SERVICE_URL}/pack`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pack),
})
const data = await response.json()
if (!data.success) {
throw new Error(data.error || '创建 Pack 失败')
}
return data
}
/**
* 记录 Pack 下载
*/
export async function recordPackDownload(packId: string, userId?: string): Promise<void> {
await fetch(`${PACK_SERVICE_URL}/pack/download`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pack_id: packId, user_id: userId }),
})
}
/**
* 点赞/取消点赞 Pack
*/
export async function togglePackLike(packId: string, userId: string): Promise<{ likes: number; liked: boolean }> {
const response = await fetch(`${PACK_SERVICE_URL}/pack/like`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pack_id: packId, user_id: userId }),
})
const data = await response.json()
if (!data.success) {
throw new Error(data.error || '点赞失败')
}
return { likes: data.likes, liked: data.liked }
}
/**
* 检查是否已点赞
*/
export async function checkPackLike(packId: string, userId: string): Promise<boolean> {
const response = await fetch(
`${PACK_SERVICE_URL}/pack/like/check?pack_id=${packId}&user_id=${userId}`
)
const data = await response.json()
return data.liked || false
}
// ============ 本地应用 Pack 相关 ============
/**
* 检测应用 Pack 时的冲突
*/
export async function detectPackConflicts(
pack: ModelPack
): Promise<ApplyPackConflicts> {
// 获取当前配置
const response = await fetchWithAuth('/api/webui/config/model')
if (!response.ok) {
throw new Error('获取当前模型配置失败')
}
const responseData = await response.json()
const currentConfig = responseData.config || responseData
console.log('=== Pack Conflict Detection ===')
console.log('Pack providers:', pack.providers)
console.log('Local providers:', currentConfig.api_providers)
const conflicts: ApplyPackConflicts = {
existing_providers: [],
new_providers: [],
conflicting_models: [],
}
// 检测提供商冲突
const localProviders = currentConfig.api_providers || []
for (const packProvider of pack.providers) {
console.log(`\nChecking pack provider: ${packProvider.name}`)
console.log(` Pack URL: ${packProvider.base_url}`)
console.log(` Normalized: ${normalizeUrl(packProvider.base_url)}`)
// 按 URL 匹配 - 找出所有匹配的本地提供商
const matchedProviders = localProviders.filter(
(p: { base_url: string; name: string }) => {
const localNormalized = normalizeUrl(p.base_url)
const packNormalized = normalizeUrl(packProvider.base_url)
console.log(` Comparing with local "${p.name}": ${p.base_url}`)
console.log(` Local normalized: ${localNormalized}`)
console.log(` Match: ${localNormalized === packNormalized}`)
return localNormalized === packNormalized
}
)
if (matchedProviders.length > 0) {
console.log(` ✓ Matched with ${matchedProviders.length} local provider(s):`, matchedProviders.map((p: {name: string}) => p.name).join(', '))
conflicts.existing_providers.push({
pack_provider: packProvider,
local_providers: matchedProviders.map((p: { name: string; base_url: string }) => ({
name: p.name,
base_url: p.base_url,
})),
})
} else {
console.log(` ✗ No match found - will need API key`)
conflicts.new_providers.push(packProvider)
}
}
// 检测模型名称冲突
const localModels = currentConfig.models || []
console.log('\n=== Model Conflict Detection ===')
for (const packModel of pack.models) {
const conflictModel = localModels.find(
(m: { name: string }) => m.name === packModel.name
)
if (conflictModel) {
console.log(`Model conflict: ${packModel.name}`)
conflicts.conflicting_models.push({
pack_model: packModel.name,
local_model: conflictModel.name,
})
}
}
console.log('\n=== Detection Summary ===')
console.log(`Existing providers: ${conflicts.existing_providers.length}`)
console.log(`New providers: ${conflicts.new_providers.length}`)
console.log(`Conflicting models: ${conflicts.conflicting_models.length}`)
console.log('===========================\n')
return conflicts
}
/**
* 应用 Pack 到本地配置
*/
export async function applyPack(
pack: ModelPack,
options: ApplyPackOptions,
providerMapping: Record<string, string>, // pack_provider_name -> local_provider_name
newProviderApiKeys: Record<string, string>, // provider_name -> api_key
): Promise<void> {
// 获取当前配置
const response = await fetchWithAuth('/api/webui/config/model')
if (!response.ok) {
throw new Error('获取当前模型配置失败')
}
const responseData = await response.json()
const currentConfig = responseData.config || responseData
// 1. 处理提供商
if (options.apply_providers) {
const providersToApply = options.selected_providers
? pack.providers.filter(p => options.selected_providers!.includes(p.name))
: pack.providers
for (const packProvider of providersToApply) {
// 检查是否映射到已有提供商
if (providerMapping[packProvider.name]) {
// 使用已有提供商,不需要添加
continue
}
// 添加新提供商
const apiKey = newProviderApiKeys[packProvider.name]
if (!apiKey) {
throw new Error(`提供商 "${packProvider.name}" 缺少 API Key`)
}
const newProvider = {
...packProvider,
api_key: apiKey,
}
// 检查是否已存在同名提供商
const existingIndex = currentConfig.api_providers.findIndex(
(p: { name: string }) => p.name === packProvider.name
)
if (existingIndex >= 0) {
// 覆盖
currentConfig.api_providers[existingIndex] = newProvider
} else {
// 添加
currentConfig.api_providers.push(newProvider)
}
}
}
// 2. 处理模型
if (options.apply_models) {
const modelsToApply = options.selected_models
? pack.models.filter(m => options.selected_models!.includes(m.name))
: pack.models
for (const packModel of modelsToApply) {
// 映射提供商名称
const actualProvider = providerMapping[packModel.api_provider] || packModel.api_provider
const newModel = {
...packModel,
api_provider: actualProvider,
}
// 检查是否已存在同名模型
const existingIndex = currentConfig.models.findIndex(
(m: { name: string }) => m.name === packModel.name
)
if (existingIndex >= 0) {
// 覆盖
currentConfig.models[existingIndex] = newModel
} else {
// 添加
currentConfig.models.push(newModel)
}
}
}
// 3. 处理任务配置
if (options.apply_task_config) {
const taskKeys = options.selected_tasks || Object.keys(pack.task_config)
for (const taskKey of taskKeys) {
const packTaskConfig = pack.task_config[taskKey as keyof PackTaskConfigs]
if (!packTaskConfig) continue
// 映射模型名称(如果模型名称被跳过,则从任务列表中移除)
const appliedModelNames = new Set(
options.selected_models || pack.models.map(m => m.name)
)
const filteredModelList = packTaskConfig.model_list.filter(
name => appliedModelNames.has(name)
)
if (filteredModelList.length === 0) continue
const newTaskConfig = {
...packTaskConfig,
model_list: filteredModelList,
}
if (options.task_mode === 'replace') {
// 替换模式
currentConfig.model_task_config[taskKey] = newTaskConfig
} else {
// 追加模式
const existingConfig = currentConfig.model_task_config[taskKey]
if (existingConfig) {
// 合并模型列表(去重)
const mergedList = [...new Set([
...existingConfig.model_list,
...filteredModelList,
])]
currentConfig.model_task_config[taskKey] = {
...existingConfig,
model_list: mergedList,
}
} else {
currentConfig.model_task_config[taskKey] = newTaskConfig
}
}
}
}
// 保存配置
const saveResponse = await fetchWithAuth('/api/webui/config/model', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(currentConfig),
})
if (!saveResponse.ok) {
throw new Error('保存配置失败')
}
}
/**
* 从当前配置导出 Pack
*/
export async function exportCurrentConfigAsPack(params: {
name: string
description: string
author: string
tags?: string[]
selectedProviders?: string[]
selectedModels?: string[]
selectedTasks?: string[]
}): Promise<{
providers: PackProvider[]
models: PackModel[]
task_config: PackTaskConfigs
}> {
// 获取当前配置
const response = await fetchWithAuth('/api/webui/config/model')
if (!response.ok) {
throw new Error('获取当前模型配置失败')
}
const responseData = await response.json()
// API 返回的格式是 { success: true, config: {...} }
if (!responseData.success || !responseData.config) {
throw new Error('获取配置失败')
}
const currentConfig = responseData.config
// 过滤提供商(移除 api_key
let providers: PackProvider[] = (currentConfig.api_providers || []).map(
(p: { name: string; base_url: string; client_type: string; max_retry?: number; timeout?: number; retry_interval?: number }) => ({
name: p.name,
base_url: p.base_url,
client_type: p.client_type,
max_retry: p.max_retry,
timeout: p.timeout,
retry_interval: p.retry_interval,
})
)
if (params.selectedProviders) {
providers = providers.filter(p => params.selectedProviders!.includes(p.name))
}
// 过滤模型
let models: PackModel[] = currentConfig.models || []
if (params.selectedModels) {
models = models.filter(m => params.selectedModels!.includes(m.name))
}
// 过滤任务配置
const task_config: PackTaskConfigs = {}
const allTasks = currentConfig.model_task_config || {}
const taskKeys = params.selectedTasks || Object.keys(allTasks)
for (const key of taskKeys) {
if (allTasks[key]) {
task_config[key as keyof PackTaskConfigs] = allTasks[key]
}
}
return { providers, models, task_config }
}
// ============ 辅助函数 ============
/**
* 标准化 URL 用于比较
*/
function normalizeUrl(url: string): string {
try {
const parsed = new URL(url)
// 移除末尾斜杠,统一小写
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/$/, '').toLowerCase()
} catch {
return url.toLowerCase().replace(/\/$/, '')
}
}
/**
* 生成用户 ID用于统计
*/
export function getPackUserId(): string {
const storageKey = 'maibot_pack_user_id'
let userId = localStorage.getItem(storageKey)
if (!userId) {
userId = 'pack_user_' + Math.random().toString(36).substring(2, 15)
localStorage.setItem(storageKey, userId)
}
return userId
}