From 433a4551f9aff33fbad6b5b2685bd03d3a1ef232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E6=A2=93=E6=9F=92?= <1787882683@qq.com> Date: Wed, 21 Jan 2026 15:41:43 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E9=99=A4gitignore=E4=B8=AD=E7=9A=84li?= =?UTF-8?q?b=E6=96=87=E4=BB=B6=E5=A4=B9=EF=BC=8C=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E8=A2=AB=E6=8E=92=E9=99=A4=E6=8E=89=E7=9A=84=E5=89=8D=E7=AB=AF?= =?UTF-8?q?lib=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - dashboard/src/lib/adapter-config-api.ts | 95 ++++ dashboard/src/lib/animation-context.ts | 10 + dashboard/src/lib/annual-report-api.ts | 136 +++++ dashboard/src/lib/api.ts | 8 + dashboard/src/lib/config-api.ts | 269 +++++++++ dashboard/src/lib/emoji-api.ts | 284 ++++++++++ dashboard/src/lib/expression-api.ts | 236 ++++++++ dashboard/src/lib/fetch-with-auth.ts | 82 +++ dashboard/src/lib/jargon-api.ts | 188 ++++++ dashboard/src/lib/knowledge-api.ts | 69 +++ dashboard/src/lib/log-stream.ts | 0 dashboard/src/lib/log-websocket.ts | 326 +++++++++++ dashboard/src/lib/pack-api.ts | 570 +++++++++++++++++++ dashboard/src/lib/person-api.ts | 138 +++++ dashboard/src/lib/planner-api.ts | 201 +++++++ dashboard/src/lib/plugin-api.ts | 722 ++++++++++++++++++++++++ dashboard/src/lib/plugin-stats.ts | 244 ++++++++ dashboard/src/lib/restart-context.tsx | 350 ++++++++++++ dashboard/src/lib/settings-manager.ts | 282 +++++++++ dashboard/src/lib/survey-api.ts | 176 ++++++ dashboard/src/lib/system-api.ts | 44 ++ dashboard/src/lib/theme-context.ts | 15 + dashboard/src/lib/token-validator.ts | 82 +++ dashboard/src/lib/utils.ts | 6 + dashboard/src/lib/version.ts | 26 + 26 files changed, 4559 insertions(+), 1 deletion(-) create mode 100644 dashboard/src/lib/adapter-config-api.ts create mode 100644 dashboard/src/lib/animation-context.ts create mode 100644 dashboard/src/lib/annual-report-api.ts create mode 100644 dashboard/src/lib/api.ts create mode 100644 dashboard/src/lib/config-api.ts create mode 100644 dashboard/src/lib/emoji-api.ts create mode 100644 dashboard/src/lib/expression-api.ts create mode 100644 dashboard/src/lib/fetch-with-auth.ts create mode 100644 dashboard/src/lib/jargon-api.ts create mode 100644 dashboard/src/lib/knowledge-api.ts create mode 100644 dashboard/src/lib/log-stream.ts create mode 100644 dashboard/src/lib/log-websocket.ts create mode 100644 dashboard/src/lib/pack-api.ts create mode 100644 dashboard/src/lib/person-api.ts create mode 100644 dashboard/src/lib/planner-api.ts create mode 100644 dashboard/src/lib/plugin-api.ts create mode 100644 dashboard/src/lib/plugin-stats.ts create mode 100644 dashboard/src/lib/restart-context.tsx create mode 100644 dashboard/src/lib/settings-manager.ts create mode 100644 dashboard/src/lib/survey-api.ts create mode 100644 dashboard/src/lib/system-api.ts create mode 100644 dashboard/src/lib/theme-context.ts create mode 100644 dashboard/src/lib/token-validator.ts create mode 100644 dashboard/src/lib/utils.ts create mode 100644 dashboard/src/lib/version.ts diff --git a/.gitignore b/.gitignore index 3fb19310..93f972cc 100644 --- a/.gitignore +++ b/.gitignore @@ -91,7 +91,6 @@ develop-eggs/ downloads/ eggs/ .eggs/ -lib/ lib64/ parts/ sdist/ diff --git a/dashboard/src/lib/adapter-config-api.ts b/dashboard/src/lib/adapter-config-api.ts new file mode 100644 index 00000000..e7c1aee7 --- /dev/null +++ b/dashboard/src/lib/adapter-config-api.ts @@ -0,0 +1,95 @@ +/** + * 适配器配置API客户端 + */ + +import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth' + +const API_BASE = '/api/webui/config' + +export interface AdapterConfigPath { + path: string + lastModified?: string +} + +interface ConfigPathResponse { + success: boolean + path?: string + lastModified?: string +} + +interface ConfigContentResponse { + success: boolean + content: string +} + +interface ConfigMessageResponse { + success: boolean + message: string +} + +/** + * 获取保存的适配器配置文件路径 + */ +export async function getSavedConfigPath(): Promise { + const response = await fetchWithAuth(`${API_BASE}/adapter-config/path`) + const data: ConfigPathResponse = await response.json() + + if (!data.success || !data.path) { + return null + } + + return { + path: data.path, + lastModified: data.lastModified, + } +} + +/** + * 保存适配器配置文件路径偏好设置 + */ +export async function saveConfigPath(path: string): Promise { + const response = await fetchWithAuth(`${API_BASE}/adapter-config/path`, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ path }), + }) + + const data: ConfigMessageResponse = await response.json() + + if (!data.success) { + throw new Error(data.message || '保存路径失败') + } +} + +/** + * 从指定路径读取适配器配置文件 + */ +export async function loadConfigFromPath(path: string): Promise { + const response = await fetchWithAuth( + `${API_BASE}/adapter-config?path=${encodeURIComponent(path)}` + ) + const data: ConfigContentResponse = await response.json() + + if (!data.success) { + throw new Error('读取配置文件失败') + } + + return data.content +} + +/** + * 保存适配器配置到指定路径 + */ +export async function saveConfigToPath(path: string, content: string): Promise { + const response = await fetchWithAuth(`${API_BASE}/adapter-config`, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ path, content }), + }) + + const data: ConfigMessageResponse = await response.json() + + if (!data.success) { + throw new Error(data.message || '保存配置失败') + } +} diff --git a/dashboard/src/lib/animation-context.ts b/dashboard/src/lib/animation-context.ts new file mode 100644 index 00000000..3ae51ed1 --- /dev/null +++ b/dashboard/src/lib/animation-context.ts @@ -0,0 +1,10 @@ +import { createContext } from 'react' + +export type AnimationSettings = { + enableAnimations: boolean + enableWavesBackground: boolean + setEnableAnimations: (enable: boolean) => void + setEnableWavesBackground: (enable: boolean) => void +} + +export const AnimationContext = createContext(undefined) diff --git a/dashboard/src/lib/annual-report-api.ts b/dashboard/src/lib/annual-report-api.ts new file mode 100644 index 00000000..c19ce2de --- /dev/null +++ b/dashboard/src/lib/annual-report-api.ts @@ -0,0 +1,136 @@ +import { fetchWithAuth } from './fetch-with-auth' + +export interface TimeFootprintData { + total_online_hours: number + first_message_time: string | null + first_message_user: string | null + first_message_content: string | null + busiest_day: string | null + busiest_day_count: number + hourly_distribution: number[] + midnight_chat_count: number + is_night_owl: boolean +} + +export interface SocialNetworkData { + total_groups: number + top_groups: Array<{ + group_id: string + group_name: string + message_count: number + is_webui?: boolean + }> + top_users: Array<{ + user_id: string + user_nickname: string + message_count: number + is_webui?: boolean + }> + at_count: number + mentioned_count: number + longest_companion_user: string | null + longest_companion_days: number +} + +export interface BrainPowerData { + total_tokens: number + total_cost: number + favorite_model: string | null + favorite_model_count: number + model_distribution: Array<{ + model: string + count: number + tokens: number + cost: number + }> + top_reply_models: Array<{ + model: string + count: number + }> + most_expensive_cost: number + most_expensive_time: string | null + top_token_consumers: Array<{ + user_id: string + cost: number + tokens: number + }> + silence_rate: number + total_actions: number + no_reply_count: number + avg_interest_value: number + max_interest_value: number + max_interest_time: string | null + avg_reasoning_length: number + max_reasoning_length: number + max_reasoning_time: string | null +} + +export interface ExpressionVibeData { + top_emoji: { + id: number + path: string + description: string + usage_count: number + hash: string + } | null + top_emojis: Array<{ + id: number + path: string + description: string + usage_count: number + hash: string + }> + top_expressions: Array<{ + style: string + count: number + }> + rejected_expression_count: number + checked_expression_count: number + total_expressions: number + action_types: Array<{ + action: string + count: number + }> + image_processed_count: number + late_night_reply: { + time: string + content: string + } | null + favorite_reply: { + content: string + count: number + } | null +} + +export interface AchievementData { + new_jargon_count: number + sample_jargons: Array<{ + content: string + meaning: string + count: number + }> + total_messages: number + total_replies: number +} + +export interface AnnualReportData { + year: number + bot_name: string + generated_at: string + time_footprint: TimeFootprintData + social_network: SocialNetworkData + brain_power: BrainPowerData + expression_vibe: ExpressionVibeData + achievements: AchievementData +} + +export async function getAnnualReport(year: number = 2025): Promise { + const response = await fetchWithAuth(`/api/webui/annual-report/full?year=${year}`) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '获取年度报告失败') + } + + return response.json() +} diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts new file mode 100644 index 00000000..4c8d1bb9 --- /dev/null +++ b/dashboard/src/lib/api.ts @@ -0,0 +1,8 @@ +import axios from 'axios' + +const apiClient = axios.create({ + baseURL: import.meta.env.DEV ? 'http://localhost:8000' : '', + timeout: 10000, +}) + +export default apiClient diff --git a/dashboard/src/lib/config-api.ts b/dashboard/src/lib/config-api.ts new file mode 100644 index 00000000..9f818f03 --- /dev/null +++ b/dashboard/src/lib/config-api.ts @@ -0,0 +1,269 @@ +/** + * 配置API客户端 + */ + +import { fetchWithAuth } from '@/lib/fetch-with-auth' +import type { + ConfigSchema, + ConfigSchemaResponse, + ConfigDataResponse, + ConfigUpdateResponse, +} from '@/types/config-schema' + +const API_BASE = '/api/webui/config' + +/** + * 获取麦麦主程序配置架构 + */ +export async function getBotConfigSchema(): Promise { + const response = await fetchWithAuth(`${API_BASE}/schema/bot`) + const data: ConfigSchemaResponse = await response.json() + + if (!data.success) { + throw new Error('获取配置架构失败') + } + + return data.schema +} + +/** + * 获取模型配置架构 + */ +export async function getModelConfigSchema(): Promise { + const response = await fetchWithAuth(`${API_BASE}/schema/model`) + const data: ConfigSchemaResponse = await response.json() + + if (!data.success) { + throw new Error('获取模型配置架构失败') + } + + return data.schema +} + +/** + * 获取指定配置节的架构 + */ +export async function getConfigSectionSchema(sectionName: string): Promise { + const response = await fetchWithAuth(`${API_BASE}/schema/section/${sectionName}`) + const data: ConfigSchemaResponse = await response.json() + + if (!data.success) { + throw new Error(`获取配置节 ${sectionName} 架构失败`) + } + + return data.schema +} + +/** + * 获取麦麦主程序配置数据 + */ +export async function getBotConfig(): Promise> { + const response = await fetchWithAuth(`${API_BASE}/bot`) + const data: ConfigDataResponse = await response.json() + + if (!data.success) { + throw new Error('获取配置数据失败') + } + + return data.config +} + +/** + * 获取模型配置数据 + */ +export async function getModelConfig(): Promise> { + const response = await fetchWithAuth(`${API_BASE}/model`) + const data: ConfigDataResponse = await response.json() + + if (!data.success) { + throw new Error('获取模型配置数据失败') + } + + return data.config +} + +/** + * 更新麦麦主程序配置 + */ +export async function updateBotConfig(config: Record): Promise { + const response = await fetchWithAuth(`${API_BASE}/bot`, { + method: 'POST', + body: JSON.stringify(config), + }) + + const data: ConfigUpdateResponse = await response.json() + + if (!data.success) { + throw new Error(data.message || '保存配置失败') + } +} + +/** + * 获取麦麦主程序配置的原始 TOML 内容 + */ +export async function getBotConfigRaw(): Promise { + const response = await fetchWithAuth(`${API_BASE}/bot/raw`) + const data: { success: boolean; content: string } = await response.json() + + if (!data.success) { + throw new Error('获取配置源代码失败') + } + + return data.content +} + +/** + * 更新麦麦主程序配置(原始 TOML 内容) + */ +export async function updateBotConfigRaw(rawContent: string): Promise { + const response = await fetchWithAuth(`${API_BASE}/bot/raw`, { + method: 'POST', + body: JSON.stringify({ raw_content: rawContent }), + }) + + const data: ConfigUpdateResponse = await response.json() + + if (!data.success) { + throw new Error(data.message || '保存配置失败') + } +} + +/** + * 更新模型配置 + */ +export async function updateModelConfig(config: Record): Promise { + const response = await fetchWithAuth(`${API_BASE}/model`, { + method: 'POST', + body: JSON.stringify(config), + }) + + const data: ConfigUpdateResponse = await response.json() + + if (!data.success) { + throw new Error(data.message || '保存配置失败') + } +} + +/** + * 更新麦麦主程序配置的指定节 + */ +export async function updateBotConfigSection( + sectionName: string, + sectionData: unknown +): Promise { + const response = await fetchWithAuth(`${API_BASE}/bot/section/${sectionName}`, { + method: 'POST', + body: JSON.stringify(sectionData), + }) + + const data: ConfigUpdateResponse = await response.json() + + if (!data.success) { + throw new Error(data.message || `保存配置节 ${sectionName} 失败`) + } +} + +/** + * 更新模型配置的指定节 + */ +export async function updateModelConfigSection( + sectionName: string, + sectionData: unknown +): Promise { + const response = await fetchWithAuth(`${API_BASE}/model/section/${sectionName}`, { + method: 'POST', + body: JSON.stringify(sectionData), + }) + + const data: ConfigUpdateResponse = await response.json() + + if (!data.success) { + throw new Error(data.message || `保存配置节 ${sectionName} 失败`) + } +} + +/** + * 模型信息 + */ +export interface ModelListItem { + id: string + name: string + owned_by?: string +} + +/** + * 获取模型列表响应 + */ +export interface FetchModelsResponse { + success: boolean + models: ModelListItem[] + provider?: string + count: number +} + +/** + * 获取指定提供商的可用模型列表 + * @param providerName 提供商名称(在 model_config.toml 中配置的名称) + * @param parser 响应解析器类型 ('openai' | 'gemini') + * @param endpoint 获取模型列表的端点(默认 '/models') + */ +export async function fetchProviderModels( + providerName: string, + parser: 'openai' | 'gemini' = 'openai', + endpoint: string = '/models' +): Promise { + const params = new URLSearchParams({ + provider_name: providerName, + parser, + endpoint, + }) + + const response = await fetchWithAuth(`/api/webui/models/list?${params}`) + + // 处理非 2xx 响应 + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.detail || `获取模型列表失败 (${response.status})`) + } + + const data: FetchModelsResponse = await response.json() + + if (!data.success) { + throw new Error('获取模型列表失败') + } + + return data.models +} + +/** + * 测试提供商连接结果 + */ +export interface TestConnectionResult { + network_ok: boolean + api_key_valid: boolean | null + latency_ms: number | null + error: string | null + http_status: number | null +} + +/** + * 测试提供商连接状态(通过提供商名称) + * @param providerName 提供商名称 + */ +export async function testProviderConnection(providerName: string): Promise { + const params = new URLSearchParams({ + provider_name: providerName, + }) + + const response = await fetchWithAuth(`/api/webui/models/test-connection-by-name?${params}`, { + method: 'POST', + }) + + // 处理非 2xx 响应 + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.detail || `测试连接失败 (${response.status})`) + } + + return await response.json() +} diff --git a/dashboard/src/lib/emoji-api.ts b/dashboard/src/lib/emoji-api.ts new file mode 100644 index 00000000..778a2b9d --- /dev/null +++ b/dashboard/src/lib/emoji-api.ts @@ -0,0 +1,284 @@ +/** + * 表情包管理 API 客户端 + */ + +import { fetchWithAuth } from '@/lib/fetch-with-auth' +import type { + EmojiListResponse, + EmojiDetailResponse, + EmojiUpdateRequest, + EmojiUpdateResponse, + EmojiDeleteResponse, + EmojiStatsResponse, +} from '@/types/emoji' + +const API_BASE = '/api/webui/emoji' + +/** + * 获取表情包列表 + */ +export async function getEmojiList(params: { + page?: number + page_size?: number + search?: string + is_registered?: boolean + is_banned?: boolean + format?: string + sort_by?: string + sort_order?: 'asc' | 'desc' +}): Promise { + const query = new URLSearchParams() + if (params.page) query.append('page', params.page.toString()) + if (params.page_size) query.append('page_size', params.page_size.toString()) + if (params.search) query.append('search', params.search) + if (params.is_registered !== undefined) query.append('is_registered', params.is_registered.toString()) + if (params.is_banned !== undefined) query.append('is_banned', params.is_banned.toString()) + if (params.format) query.append('format', params.format) + if (params.sort_by) query.append('sort_by', params.sort_by) + if (params.sort_order) query.append('sort_order', params.sort_order) + + const response = await fetchWithAuth(`${API_BASE}/list?${query}`, { + }) + + if (!response.ok) { + throw new Error(`获取表情包列表失败: ${response.statusText}`) + } + + return response.json() +} + +/** + * 获取表情包详情 + */ +export async function getEmojiDetail(id: number): Promise { + const response = await fetchWithAuth(`${API_BASE}/${id}`, { + }) + + if (!response.ok) { + throw new Error(`获取表情包详情失败: ${response.statusText}`) + } + + return response.json() +} + +/** + * 更新表情包信息 + */ +export async function updateEmoji( + id: number, + data: EmojiUpdateRequest +): Promise { + const response = await fetchWithAuth(`${API_BASE}/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }) + + if (!response.ok) { + throw new Error(`更新表情包失败: ${response.statusText}`) + } + + return response.json() +} + +/** + * 删除表情包 + */ +export async function deleteEmoji(id: number): Promise { + const response = await fetchWithAuth(`${API_BASE}/${id}`, { + method: 'DELETE', + }) + + if (!response.ok) { + throw new Error(`删除表情包失败: ${response.statusText}`) + } + + return response.json() +} + +/** + * 获取表情包统计数据 + */ +export async function getEmojiStats(): Promise { + const response = await fetchWithAuth(`${API_BASE}/stats/summary`, { + }) + + if (!response.ok) { + throw new Error(`获取统计数据失败: ${response.statusText}`) + } + + return response.json() +} + +/** + * 注册表情包 + */ +export async function registerEmoji(id: number): Promise { + const response = await fetchWithAuth(`${API_BASE}/${id}/register`, { + method: 'POST', + }) + + if (!response.ok) { + throw new Error(`注册表情包失败: ${response.statusText}`) + } + + return response.json() +} + +/** + * 封禁表情包 + */ +export async function banEmoji(id: number): Promise { + const response = await fetchWithAuth(`${API_BASE}/${id}/ban`, { + method: 'POST', + + }) + + if (!response.ok) { + throw new Error(`封禁表情包失败: ${response.statusText}`) + } + + return response.json() +} + +/** + * 获取表情包缩略图 URL + * 注意:使用 HttpOnly Cookie 进行认证,浏览器会自动携带 + * @param id 表情包 ID + * @param original 是否获取原图(默认返回压缩后的缩略图) + */ +export function getEmojiThumbnailUrl(id: number, original: boolean = false): string { + if (original) { + return `${API_BASE}/${id}/thumbnail?original=true` + } + return `${API_BASE}/${id}/thumbnail` +} + +/** + * 获取表情包原图 URL + */ +export function getEmojiOriginalUrl(id: number): string { + return `${API_BASE}/${id}/thumbnail?original=true` +} + +/** + * 批量删除表情包 + */ +export async function batchDeleteEmojis(emojiIds: number[]): Promise<{ + success: boolean + message: string + deleted_count: number + failed_count: number + failed_ids: number[] +}> { + const response = await fetchWithAuth(`${API_BASE}/batch/delete`, { + method: 'POST', + + body: JSON.stringify({ emoji_ids: emojiIds }), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '批量删除失败') + } + + return response.json() +} + +/** + * 获取表情包上传 URL(供 Uppy 使用) + */ +export function getEmojiUploadUrl(): string { + return `${API_BASE}/upload` +} + +/** + * 获取批量上传 URL + */ +export function getEmojiBatchUploadUrl(): string { + return `${API_BASE}/batch/upload` +} + +// ==================== 缩略图缓存管理 API ==================== + +export interface ThumbnailCacheStatsResponse { + success: boolean + cache_dir: string + total_count: number + total_size_mb: number + emoji_count: number + coverage_percent: number +} + +export interface ThumbnailCleanupResponse { + success: boolean + message: string + cleaned_count: number + kept_count: number +} + +export interface ThumbnailPreheatResponse { + success: boolean + message: string + generated_count: number + skipped_count: number + failed_count: number +} + +/** + * 获取缩略图缓存统计信息 + */ +export async function getThumbnailCacheStats(): Promise { + const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/stats`, {}) + + if (!response.ok) { + throw new Error(`获取缩略图缓存统计失败: ${response.statusText}`) + } + + return response.json() +} + +/** + * 清理孤立的缩略图缓存 + */ +export async function cleanupThumbnailCache(): Promise { + const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/cleanup`, { + method: 'POST', + }) + + if (!response.ok) { + throw new Error(`清理缩略图缓存失败: ${response.statusText}`) + } + + return response.json() +} + +/** + * 预热缩略图缓存 + * @param limit 最多预热数量 (1-1000) + */ +export async function preheatThumbnailCache(limit: number = 100): Promise { + const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/preheat?limit=${limit}`, { + method: 'POST', + }) + + if (!response.ok) { + throw new Error(`预热缩略图缓存失败: ${response.statusText}`) + } + + return response.json() +} + +/** + * 清空所有缩略图缓存 + */ +export async function clearAllThumbnailCache(): Promise { + const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/clear`, { + method: 'DELETE', + }) + + if (!response.ok) { + throw new Error(`清空缩略图缓存失败: ${response.statusText}`) + } + + return response.json() +} \ No newline at end of file diff --git a/dashboard/src/lib/expression-api.ts b/dashboard/src/lib/expression-api.ts new file mode 100644 index 00000000..01c25d9b --- /dev/null +++ b/dashboard/src/lib/expression-api.ts @@ -0,0 +1,236 @@ +/** + * 表达方式管理 API + */ +import { fetchWithAuth } from '@/lib/fetch-with-auth' +import type { + ExpressionListResponse, + ExpressionDetailResponse, + ExpressionCreateRequest, + ExpressionCreateResponse, + ExpressionUpdateRequest, + ExpressionUpdateResponse, + ExpressionDeleteResponse, + ExpressionStatsResponse, + ChatListResponse, + ReviewStats, + ReviewListResponse, + BatchReviewItem, + BatchReviewResponse, +} from '@/types/expression' + +const API_BASE = '/api/webui/expression' + +/** + * 获取聊天列表 + */ +export async function getChatList(): Promise { + const response = await fetchWithAuth(`${API_BASE}/chats`, { + + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '获取聊天列表失败') + } + + return response.json() +} + +/** + * 获取表达方式列表 + */ +export async function getExpressionList(params: { + page?: number + page_size?: number + search?: string + chat_id?: string +}): Promise { + const queryParams = new URLSearchParams() + + if (params.page) queryParams.append('page', params.page.toString()) + if (params.page_size) queryParams.append('page_size', params.page_size.toString()) + if (params.search) queryParams.append('search', params.search) + if (params.chat_id) queryParams.append('chat_id', params.chat_id) + + const response = await fetchWithAuth(`${API_BASE}/list?${queryParams}`, { + + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '获取表达方式列表失败') + } + + return response.json() +} + +/** + * 获取表达方式详细信息 + */ +export async function getExpressionDetail(expressionId: number): Promise { + const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, { + + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '获取表达方式详情失败') + } + + return response.json() +} + +/** + * 创建表达方式 + */ +export async function createExpression( + data: ExpressionCreateRequest +): Promise { + const response = await fetchWithAuth(`${API_BASE}/`, { + method: 'POST', + + body: JSON.stringify(data), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '创建表达方式失败') + } + + return response.json() +} + +/** + * 更新表达方式(增量更新) + */ +export async function updateExpression( + expressionId: number, + data: ExpressionUpdateRequest +): Promise { + const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, { + method: 'PATCH', + + body: JSON.stringify(data), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '更新表达方式失败') + } + + return response.json() +} + +/** + * 删除表达方式 + */ +export async function deleteExpression(expressionId: number): Promise { + const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, { + method: 'DELETE', + + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '删除表达方式失败') + } + + return response.json() +} + +/** + * 批量删除表达方式 + */ +export async function batchDeleteExpressions(expressionIds: number[]): Promise { + const response = await fetchWithAuth(`${API_BASE}/batch/delete`, { + method: 'POST', + + body: JSON.stringify({ ids: expressionIds }), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '批量删除表达方式失败') + } + + return response.json() +} + +/** + * 获取表达方式统计数据 + */ +export async function getExpressionStats(): Promise { + const response = await fetchWithAuth(`${API_BASE}/stats/summary`, { + + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '获取统计数据失败') + } + + return response.json() +} + +// ============ 审核相关 API ============ + +/** + * 获取审核统计数据 + */ +export async function getReviewStats(): Promise { + const response = await fetchWithAuth(`${API_BASE}/review/stats`) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '获取审核统计失败') + } + + return response.json() +} + +/** + * 获取审核列表 + */ +export async function getReviewList(params: { + page?: number + page_size?: number + filter_type?: 'unchecked' | 'passed' | 'rejected' | 'all' + search?: string + chat_id?: string +}): Promise { + const queryParams = new URLSearchParams() + + if (params.page) queryParams.append('page', params.page.toString()) + if (params.page_size) queryParams.append('page_size', params.page_size.toString()) + if (params.filter_type) queryParams.append('filter_type', params.filter_type) + if (params.search) queryParams.append('search', params.search) + if (params.chat_id) queryParams.append('chat_id', params.chat_id) + + const response = await fetchWithAuth(`${API_BASE}/review/list?${queryParams}`) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '获取审核列表失败') + } + + return response.json() +} + +/** + * 批量审核表达方式 + */ +export async function batchReviewExpressions( + items: BatchReviewItem[] +): Promise { + const response = await fetchWithAuth(`${API_BASE}/review/batch`, { + method: 'POST', + body: JSON.stringify({ items }), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '批量审核失败') + } + + return response.json() +} diff --git a/dashboard/src/lib/fetch-with-auth.ts b/dashboard/src/lib/fetch-with-auth.ts new file mode 100644 index 00000000..7a2ef31e --- /dev/null +++ b/dashboard/src/lib/fetch-with-auth.ts @@ -0,0 +1,82 @@ +// 带自动认证处理的 fetch 封装 + +/** + * 增强的 fetch 函数,自动处理 401 错误并跳转到登录页 + * 使用 HttpOnly Cookie 进行认证,自动携带 credentials + * + * 对于 FormData 请求,不自动设置 Content-Type,让浏览器自动设置 multipart/form-data + */ +export async function fetchWithAuth( + input: RequestInfo | URL, + init?: RequestInit +): Promise { + // 检查是否是 FormData 请求 + const isFormData = init?.body instanceof FormData + + // 构建 headers,对于 FormData 不设置 Content-Type + const headers: HeadersInit = isFormData + ? { ...init?.headers } + : { 'Content-Type': 'application/json', ...init?.headers } + + // 合并默认配置,确保携带 Cookie + const config: RequestInit = { + ...init, + credentials: 'include', // 确保携带 Cookie + headers, + } + + const response = await fetch(input, config) + + // 检测 401 未授权错误 + if (response.status === 401) { + // 跳转到登录页 + window.location.href = '/auth' + + // 抛出错误以便调用者可以处理 + throw new Error('认证失败,请重新登录') + } + + return response +} + +/** + * 获取带认证的请求配置 + * 现在使用 Cookie 认证,不再需要手动设置 Authorization header + */ +export function getAuthHeaders(): HeadersInit { + return { + 'Content-Type': 'application/json', + } +} + +/** + * 调用登出接口并跳转到登录页 + */ +export async function logout(): Promise { + try { + await fetch('/api/webui/auth/logout', { + method: 'POST', + credentials: 'include', + }) + } catch (error) { + console.error('登出请求失败:', error) + } + // 无论成功与否都跳转到登录页 + window.location.href = '/auth' +} + +/** + * 检查当前认证状态 + */ +export async function checkAuthStatus(): Promise { + try { + const response = await fetch('/api/webui/auth/check', { + method: 'GET', + credentials: 'include', + }) + const data = await response.json() + return data.authenticated === true + } catch { + return false + } +} diff --git a/dashboard/src/lib/jargon-api.ts b/dashboard/src/lib/jargon-api.ts new file mode 100644 index 00000000..110cc675 --- /dev/null +++ b/dashboard/src/lib/jargon-api.ts @@ -0,0 +1,188 @@ +/** + * 黑话(俚语)管理 API + */ +import { fetchWithAuth } from '@/lib/fetch-with-auth' +import type { + JargonListResponse, + JargonDetailResponse, + JargonCreateRequest, + JargonCreateResponse, + JargonUpdateRequest, + JargonUpdateResponse, + JargonDeleteResponse, + JargonStatsResponse, + JargonChatListResponse, +} from '@/types/jargon' + +const API_BASE = '/api/webui/jargon' + +/** + * 获取聊天列表(有黑话记录的聊天) + */ +export async function getJargonChatList(): Promise { + const response = await fetchWithAuth(`${API_BASE}/chats`, {}) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '获取聊天列表失败') + } + + return response.json() +} + +/** + * 获取黑话列表 + */ +export async function getJargonList(params: { + page?: number + page_size?: number + search?: string + chat_id?: string + is_jargon?: boolean | null + is_global?: boolean +}): Promise { + const queryParams = new URLSearchParams() + + if (params.page) queryParams.append('page', params.page.toString()) + if (params.page_size) queryParams.append('page_size', params.page_size.toString()) + if (params.search) queryParams.append('search', params.search) + if (params.chat_id) queryParams.append('chat_id', params.chat_id) + if (params.is_jargon !== undefined && params.is_jargon !== null) { + queryParams.append('is_jargon', params.is_jargon.toString()) + } + if (params.is_global !== undefined) { + queryParams.append('is_global', params.is_global.toString()) + } + + const response = await fetchWithAuth(`${API_BASE}/list?${queryParams}`, {}) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '获取黑话列表失败') + } + + return response.json() +} + +/** + * 获取黑话详细信息 + */ +export async function getJargonDetail(jargonId: number): Promise { + const response = await fetchWithAuth(`${API_BASE}/${jargonId}`, {}) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '获取黑话详情失败') + } + + return response.json() +} + +/** + * 创建黑话 + */ +export async function createJargon( + data: JargonCreateRequest +): Promise { + const response = await fetchWithAuth(`${API_BASE}/`, { + method: 'POST', + body: JSON.stringify(data), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '创建黑话失败') + } + + return response.json() +} + +/** + * 更新黑话(增量更新) + */ +export async function updateJargon( + jargonId: number, + data: JargonUpdateRequest +): Promise { + const response = await fetchWithAuth(`${API_BASE}/${jargonId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '更新黑话失败') + } + + return response.json() +} + +/** + * 删除黑话 + */ +export async function deleteJargon(jargonId: number): Promise { + const response = await fetchWithAuth(`${API_BASE}/${jargonId}`, { + method: 'DELETE', + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '删除黑话失败') + } + + return response.json() +} + +/** + * 批量删除黑话 + */ +export async function batchDeleteJargons(jargonIds: number[]): Promise { + const response = await fetchWithAuth(`${API_BASE}/batch/delete`, { + method: 'POST', + body: JSON.stringify({ ids: jargonIds }), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '批量删除黑话失败') + } + + return response.json() +} + +/** + * 获取黑话统计数据 + */ +export async function getJargonStats(): Promise { + const response = await fetchWithAuth(`${API_BASE}/stats/summary`, {}) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '获取黑话统计失败') + } + + return response.json() +} + +/** + * 批量设置黑话状态 + */ +export async function batchSetJargonStatus( + jargonIds: number[], + isJargon: boolean +): Promise { + const queryParams = new URLSearchParams() + jargonIds.forEach(id => queryParams.append('ids', id.toString())) + queryParams.append('is_jargon', isJargon.toString()) + + const response = await fetchWithAuth(`${API_BASE}/batch/set-jargon?${queryParams}`, { + method: 'POST', + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '批量设置黑话状态失败') + } + + return response.json() +} diff --git a/dashboard/src/lib/knowledge-api.ts b/dashboard/src/lib/knowledge-api.ts new file mode 100644 index 00000000..b07fe653 --- /dev/null +++ b/dashboard/src/lib/knowledge-api.ts @@ -0,0 +1,69 @@ +/** + * 知识库 API + */ + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/webui' + +export interface KnowledgeNode { + id: string + type: 'entity' | 'paragraph' + content: string + create_time?: number +} + +export interface KnowledgeEdge { + source: string + target: string + weight: number + create_time?: number + update_time?: number +} + +export interface KnowledgeGraph { + nodes: KnowledgeNode[] + edges: KnowledgeEdge[] +} + +export interface KnowledgeStats { + total_nodes: number + total_edges: number + entity_nodes: number + paragraph_nodes: number +} + +/** + * 获取知识图谱数据 + */ +export async function getKnowledgeGraph(limit: number = 100, nodeType: 'all' | 'entity' | 'paragraph' = 'all'): Promise { + const url = `${API_BASE_URL}/knowledge/graph?limit=${limit}&node_type=${nodeType}` + + const response = await fetch(url) + + if (!response.ok) { + throw new Error(`获取知识图谱失败: ${response.status}`) + } + + return response.json() +} + +/** + * 获取知识图谱统计信息 + */ +export async function getKnowledgeStats(): Promise { + const response = await fetch(`${API_BASE_URL}/knowledge/stats`) + if (!response.ok) { + throw new Error('获取知识图谱统计信息失败') + } + return response.json() +} + +/** + * 搜索知识节点 + */ +export async function searchKnowledgeNode(query: string): Promise { + const response = await fetch(`${API_BASE_URL}/knowledge/search?query=${encodeURIComponent(query)}`) + if (!response.ok) { + throw new Error('搜索知识节点失败') + } + return response.json() +} diff --git a/dashboard/src/lib/log-stream.ts b/dashboard/src/lib/log-stream.ts new file mode 100644 index 00000000..e69de29b diff --git a/dashboard/src/lib/log-websocket.ts b/dashboard/src/lib/log-websocket.ts new file mode 100644 index 00000000..19d928cf --- /dev/null +++ b/dashboard/src/lib/log-websocket.ts @@ -0,0 +1,326 @@ +/** + * 全局日志 WebSocket 管理器 + * 确保整个应用只有一个 WebSocket 连接 + */ + +import { fetchWithAuth, checkAuthStatus } from './fetch-with-auth' +import { getSetting } from './settings-manager' + +export interface LogEntry { + id: string + timestamp: string + level: 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' + module: string + message: string +} + +type LogCallback = (log: LogEntry) => void +type ConnectionCallback = (connected: boolean) => void + +class LogWebSocketManager { + private ws: WebSocket | null = null + private reconnectTimeout: number | null = null + private reconnectAttempts = 0 + private heartbeatInterval: number | null = null + + // 订阅者 + private logCallbacks: Set = new Set() + private connectionCallbacks: Set = new Set() + + private isConnected = false + + // 日志缓存 - 保存所有接收到的日志 + private logCache: LogEntry[] = [] + + /** + * 获取最大缓存大小(从设置读取) + */ + private getMaxCacheSize(): number { + return getSetting('logCacheSize') + } + + /** + * 获取最大重连次数(从设置读取) + */ + private getMaxReconnectAttempts(): number { + return getSetting('wsMaxReconnectAttempts') + } + + /** + * 获取重连间隔(从设置读取) + */ + private getReconnectInterval(): number { + return getSetting('wsReconnectInterval') + } + + /** + * 获取 WebSocket URL + */ + private getWebSocketUrl(token?: string): string { + let baseUrl: string + if (import.meta.env.DEV) { + // 开发模式:连接到 WebUI 后端服务器 + baseUrl = 'ws://127.0.0.1:8001/ws/logs' + } else { + // 生产模式:使用当前页面的 host + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + baseUrl = `${protocol}//${host}/ws/logs` + } + + // 如果有 token,添加到 URL 参数 + if (token) { + return `${baseUrl}?token=${encodeURIComponent(token)}` + } + return baseUrl + } + + /** + * 获取 WebSocket 临时认证 token + */ + private async getWsToken(): Promise { + try { + // 使用相对路径,让前端代理处理请求,避免 CORS 问题 + const response = await fetchWithAuth('/api/webui/ws-token', { + method: 'GET', + credentials: 'include', // 携带 Cookie + }) + + if (!response.ok) { + console.error('获取 WebSocket token 失败:', response.status) + return null + } + + const data = await response.json() + if (data.success && data.token) { + return data.token + } + return null + } catch (error) { + console.error('获取 WebSocket token 失败:', error) + return null + } + } + + /** + * 连接 WebSocket(会先检查登录状态) + */ + async connect() { + if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) { + return + } + + // 检查是否在登录页面 + if (window.location.pathname === '/auth') { + console.log('📡 在登录页面,跳过 WebSocket 连接') + return + } + + // 检查登录状态,避免未登录时尝试连接 + const isAuthenticated = await checkAuthStatus() + if (!isAuthenticated) { + console.log('📡 未登录,跳过 WebSocket 连接') + return + } + + // 先获取临时认证 token + const wsToken = await this.getWsToken() + if (!wsToken) { + console.log('📡 无法获取 WebSocket token,跳过连接') + return + } + + const wsUrl = this.getWebSocketUrl(wsToken) + + try { + this.ws = new WebSocket(wsUrl) + + this.ws.onopen = () => { + this.isConnected = true + this.reconnectAttempts = 0 + this.notifyConnection(true) + this.startHeartbeat() + } + + this.ws.onmessage = (event) => { + try { + // 忽略心跳响应 + if (event.data === 'pong') { + return + } + + const log: LogEntry = JSON.parse(event.data) + this.notifyLog(log) + } catch (error) { + console.error('解析日志消息失败:', error) + } + } + + this.ws.onerror = (error) => { + console.error('❌ WebSocket 错误:', error) + this.isConnected = false + this.notifyConnection(false) + } + + this.ws.onclose = () => { + this.isConnected = false + this.notifyConnection(false) + this.stopHeartbeat() + this.attemptReconnect() + } + } catch (error) { + console.error('创建 WebSocket 连接失败:', error) + this.attemptReconnect() + } + } + + /** + * 尝试重连 + */ + private attemptReconnect() { + const maxAttempts = this.getMaxReconnectAttempts() + if (this.reconnectAttempts >= maxAttempts) { + return + } + + this.reconnectAttempts += 1 + const baseInterval = this.getReconnectInterval() + const delay = Math.min(baseInterval * this.reconnectAttempts, 30000) + + this.reconnectTimeout = window.setTimeout(() => { + this.connect() // connect 是 async 但这里不需要 await,它内部会处理错误 + }, delay) + } + + /** + * 启动心跳 + */ + private startHeartbeat() { + this.heartbeatInterval = window.setInterval(() => { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send('ping') + } + }, 30000) // 每30秒发送一次心跳 + } + + /** + * 停止心跳 + */ + private stopHeartbeat() { + if (this.heartbeatInterval !== null) { + clearInterval(this.heartbeatInterval) + this.heartbeatInterval = null + } + } + + /** + * 断开连接 + */ + disconnect() { + if (this.reconnectTimeout !== null) { + clearTimeout(this.reconnectTimeout) + this.reconnectTimeout = null + } + + this.stopHeartbeat() + + if (this.ws) { + this.ws.close() + this.ws = null + } + + this.isConnected = false + this.reconnectAttempts = 0 + } + + /** + * 订阅日志消息 + */ + onLog(callback: LogCallback) { + this.logCallbacks.add(callback) + return () => this.logCallbacks.delete(callback) + } + + /** + * 订阅连接状态 + */ + onConnectionChange(callback: ConnectionCallback) { + this.connectionCallbacks.add(callback) + // 立即通知当前状态 + callback(this.isConnected) + return () => this.connectionCallbacks.delete(callback) + } + + /** + * 通知所有订阅者新日志 + */ + private notifyLog(log: LogEntry) { + // 检查是否已存在(通过 id 去重) + const exists = this.logCache.some(existingLog => existingLog.id === log.id) + + if (!exists) { + // 添加到缓存 + this.logCache.push(log) + + // 限制缓存大小(动态读取配置) + const maxCacheSize = this.getMaxCacheSize() + if (this.logCache.length > maxCacheSize) { + this.logCache = this.logCache.slice(-maxCacheSize) + } + + // 只有新日志才通知订阅者 + this.logCallbacks.forEach(callback => { + try { + callback(log) + } catch (error) { + console.error('日志回调执行失败:', error) + } + }) + } + } + + /** + * 通知所有订阅者连接状态变化 + */ + private notifyConnection(connected: boolean) { + this.connectionCallbacks.forEach(callback => { + try { + callback(connected) + } catch (error) { + console.error('连接状态回调执行失败:', error) + } + }) + } + + /** + * 获取缓存的所有日志 + */ + getAllLogs(): LogEntry[] { + return [...this.logCache] + } + + /** + * 清空日志缓存 + */ + clearLogs() { + this.logCache = [] + } + + /** + * 获取当前连接状态 + */ + getConnectionStatus(): boolean { + return this.isConnected + } +} + +// 导出单例 +export const logWebSocket = new LogWebSocketManager() + +// 自动连接(应用启动时) +if (typeof window !== 'undefined') { + // 延迟一下确保页面加载完成 + setTimeout(() => { + logWebSocket.connect() + }, 100) +} diff --git a/dashboard/src/lib/pack-api.ts b/dashboard/src/lib/pack-api.ts new file mode 100644 index 00000000..c56b732f --- /dev/null +++ b/dashboard/src/lib/pack-api.ts @@ -0,0 +1,570 @@ +/** + * 模型配置 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 +} + +/** + * 单个任务配置 + */ +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 { + 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 服务基础 URL(Cloudflare 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 { + 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 { + 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 { + 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 { + 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 { + // 获取当前配置 + 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, // pack_provider_name -> local_provider_name + newProviderApiKeys: Record, // provider_name -> api_key +): Promise { + // 获取当前配置 + 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 +} diff --git a/dashboard/src/lib/person-api.ts b/dashboard/src/lib/person-api.ts new file mode 100644 index 00000000..1415aa97 --- /dev/null +++ b/dashboard/src/lib/person-api.ts @@ -0,0 +1,138 @@ +/** + * 人物信息管理 API + */ +import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth' +import type { + PersonListResponse, + PersonDetailResponse, + PersonUpdateRequest, + PersonUpdateResponse, + PersonDeleteResponse, + PersonStatsResponse, +} from '@/types/person' + +const API_BASE = '/api/webui/person' + +/** + * 获取人物信息列表 + */ +export async function getPersonList(params: { + page?: number + page_size?: number + search?: string + is_known?: boolean + platform?: string +}): Promise { + const queryParams = new URLSearchParams() + + if (params.page) queryParams.append('page', params.page.toString()) + if (params.page_size) queryParams.append('page_size', params.page_size.toString()) + if (params.search) queryParams.append('search', params.search) + if (params.is_known !== undefined) queryParams.append('is_known', params.is_known.toString()) + if (params.platform) queryParams.append('platform', params.platform) + + const response = await fetchWithAuth(`${API_BASE}/list?${queryParams}`, { + headers: getAuthHeaders(), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '获取人物列表失败') + } + + return response.json() +} + +/** + * 获取人物详细信息 + */ +export async function getPersonDetail(personId: string): Promise { + const response = await fetchWithAuth(`${API_BASE}/${personId}`, { + headers: getAuthHeaders(), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '获取人物详情失败') + } + + return response.json() +} + +/** + * 更新人物信息(增量更新) + */ +export async function updatePerson( + personId: string, + data: PersonUpdateRequest +): Promise { + const response = await fetchWithAuth(`${API_BASE}/${personId}`, { + method: 'PATCH', + headers: getAuthHeaders(), + body: JSON.stringify(data), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '更新人物信息失败') + } + + return response.json() +} + +/** + * 删除人物信息 + */ +export async function deletePerson(personId: string): Promise { + const response = await fetchWithAuth(`${API_BASE}/${personId}`, { + method: 'DELETE', + headers: getAuthHeaders(), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '删除人物信息失败') + } + + return response.json() +} + +/** + * 获取人物统计数据 + */ +export async function getPersonStats(): Promise { + const response = await fetchWithAuth(`${API_BASE}/stats/summary`, { + headers: getAuthHeaders(), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '获取统计数据失败') + } + + return response.json() +} + +/** + * 批量删除人物信息 + */ +export async function batchDeletePersons(personIds: string[]): Promise<{ + success: boolean + message: string + deleted_count: number + failed_count: number + failed_ids: string[] +}> { + const response = await fetchWithAuth(`${API_BASE}/batch/delete`, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ person_ids: personIds }), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '批量删除失败') + } + + return response.json() +} diff --git a/dashboard/src/lib/planner-api.ts b/dashboard/src/lib/planner-api.ts new file mode 100644 index 00000000..0da67f73 --- /dev/null +++ b/dashboard/src/lib/planner-api.ts @@ -0,0 +1,201 @@ +import { fetchWithAuth } from './fetch-with-auth' + +// ========== 新的优化接口 ========== + +export interface ChatSummary { + chat_id: string + plan_count: number + latest_timestamp: number + latest_filename: string +} + +export interface PlannerOverview { + total_chats: number + total_plans: number + chats: ChatSummary[] +} + +export interface PlanLogSummary { + chat_id: string + timestamp: number + filename: string + action_count: number + action_types: string[] // 动作类型列表 + total_plan_ms: number + llm_duration_ms: number + reasoning_preview: string +} + +export interface PlanLogDetail { + type: string + chat_id: string + timestamp: number + prompt: string + reasoning: string + raw_output: string + actions: any[] + timing: { + prompt_build_ms: number + llm_duration_ms: number + total_plan_ms: number + loop_start_time: number + } + extra: any +} + +export interface PaginatedChatLogs { + data: PlanLogSummary[] + total: number + page: number + page_size: number + chat_id: string +} + +/** + * 获取规划器总览 - 轻量级,只统计文件数量 + */ +export async function getPlannerOverview(): Promise { + const response = await fetchWithAuth('/api/planner/overview') + return response.json() +} + +/** + * 获取指定聊天的规划日志列表(分页) + */ +export async function getChatLogs(chatId: string, page = 1, pageSize = 20, search?: string): Promise { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString() + }) + if (search) { + params.append('search', search) + } + const response = await fetchWithAuth(`/api/planner/chat/${chatId}/logs?${params}`) + return response.json() +} + +/** + * 获取规划日志详情 - 按需加载 + */ +export async function getLogDetail(chatId: string, filename: string): Promise { + const response = await fetchWithAuth(`/api/planner/log/${chatId}/${filename}`) + return response.json() +} + +// ========== 兼容旧接口 ========== + +export interface PlannerStats { + total_chats: number + total_plans: number + avg_plan_time_ms: number + avg_llm_time_ms: number + recent_plans: PlanLogSummary[] +} + +export interface PaginatedPlanLogs { + data: PlanLogSummary[] + total: number + page: number + page_size: number +} + +export async function getPlannerStats(): Promise { + const response = await fetchWithAuth('/api/planner/stats') + return response.json() +} + +export async function getAllLogs(page = 1, pageSize = 20): Promise { + const response = await fetchWithAuth(`/api/planner/all-logs?page=${page}&page_size=${pageSize}`) + return response.json() +} + +export async function getChatList(): Promise { + const response = await fetchWithAuth('/api/planner/chats') + return response.json() +} + +// ========== 回复器接口 ========== + +export interface ReplierChatSummary { + chat_id: string + reply_count: number + latest_timestamp: number + latest_filename: string +} + +export interface ReplierOverview { + total_chats: number + total_replies: number + chats: ReplierChatSummary[] +} + +export interface ReplyLogSummary { + chat_id: string + timestamp: number + filename: string + model: string + success: boolean + llm_ms: number + overall_ms: number + output_preview: string +} + +export interface ReplyLogDetail { + type: string + chat_id: string + timestamp: number + prompt: string + output: string + processed_output: string[] + model: string + reasoning: string + think_level: number + timing: { + prompt_ms: number + overall_ms: number + timing_logs: string[] + llm_ms: number + almost_zero: string + } + error: string | null + success: boolean +} + +export interface PaginatedReplyLogs { + data: ReplyLogSummary[] + total: number + page: number + page_size: number + chat_id: string +} + +/** + * 获取回复器总览 - 轻量级,只统计文件数量 + */ +export async function getReplierOverview(): Promise { + const response = await fetchWithAuth('/api/replier/overview') + return response.json() +} + +/** + * 获取指定聊天的回复日志列表(分页) + */ +export async function getReplyChatLogs(chatId: string, page = 1, pageSize = 20, search?: string): Promise { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString() + }) + if (search) { + params.append('search', search) + } + const response = await fetchWithAuth(`/api/replier/chat/${chatId}/logs?${params}`) + return response.json() +} + +/** + * 获取回复日志详情 - 按需加载 + */ +export async function getReplyLogDetail(chatId: string, filename: string): Promise { + const response = await fetchWithAuth(`/api/replier/log/${chatId}/${filename}`) + return response.json() +} diff --git a/dashboard/src/lib/plugin-api.ts b/dashboard/src/lib/plugin-api.ts new file mode 100644 index 00000000..97133693 --- /dev/null +++ b/dashboard/src/lib/plugin-api.ts @@ -0,0 +1,722 @@ +import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth' +import type { PluginInfo } from '@/types/plugin' + +/** + * Git 安装状态 + */ +export interface GitStatus { + installed: boolean + version?: string + path?: string + error?: string +} + +/** + * 麦麦版本信息 + */ +export interface MaimaiVersion { + version: string + version_major: number + version_minor: number + version_patch: number +} + +/** + * 已安装插件信息 + */ +export interface InstalledPlugin { + id: string + manifest: { + manifest_version: number + name: string + version: string + description: string + author: { + name: string + url?: string + } + license: string + host_application: { + min_version: string + max_version?: string + } + homepage_url?: string + repository_url?: string + keywords?: string[] + categories?: string[] + [key: string]: unknown // 允许其他字段 + } + path: string +} + +/** + * 插件加载进度 + */ +export interface PluginLoadProgress { + operation: 'idle' | 'fetch' | 'install' | 'uninstall' | 'update' + stage: 'idle' | 'loading' | 'success' | 'error' + progress: number // 0-100 + message: string + error?: string + plugin_id?: string + total_plugins: number + loaded_plugins: number +} + +/** + * 插件仓库配置 + */ +const PLUGIN_REPO_OWNER = 'Mai-with-u' +const PLUGIN_REPO_NAME = 'plugin-repo' +const PLUGIN_REPO_BRANCH = 'main' +const PLUGIN_DETAILS_FILE = 'plugin_details.json' + +/** + * 插件列表 API 响应类型(只包含我们需要的字段) + */ +interface PluginApiResponse { + id: string + manifest: { + manifest_version: number + name: string + version: string + description: string + author: { + name: string + url?: string + } + license: string + host_application: { + min_version: string + max_version?: string + } + homepage_url?: string + repository_url?: string + keywords: string[] + categories?: string[] + default_locale: string + locales_path?: string + } + // 可能还有其他字段,但我们不关心 + [key: string]: unknown +} + +/** + * 从远程获取插件列表(通过后端代理避免 CORS) + */ +export async function fetchPluginList(): Promise { + try { + // 通过后端 API 获取 Raw 文件 + const response = await fetchWithAuth('/api/webui/plugins/fetch-raw', { + method: 'POST', + + body: JSON.stringify({ + owner: PLUGIN_REPO_OWNER, + repo: PLUGIN_REPO_NAME, + branch: PLUGIN_REPO_BRANCH, + file_path: PLUGIN_DETAILS_FILE + }) + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const result = await response.json() + + // 检查后端返回的结果 + if (!result.success || !result.data) { + throw new Error(result.error || '获取插件列表失败') + } + + const data: PluginApiResponse[] = JSON.parse(result.data) + + // 转换为 PluginInfo 格式,并过滤掉无效数据 + const pluginList = data + .filter(item => { + // 验证必需字段 + if (!item?.id || !item?.manifest) { + console.warn('跳过无效插件数据:', item) + return false + } + if (!item.manifest.name || !item.manifest.version) { + console.warn('跳过缺少必需字段的插件:', item.id) + return false + } + return true + }) + .map((item) => ({ + id: item.id, + manifest: { + manifest_version: item.manifest.manifest_version || 1, + name: item.manifest.name, + version: item.manifest.version, + description: item.manifest.description || '', + author: item.manifest.author || { name: 'Unknown' }, + license: item.manifest.license || 'Unknown', + host_application: item.manifest.host_application || { min_version: '0.0.0' }, + homepage_url: item.manifest.homepage_url, + repository_url: item.manifest.repository_url, + keywords: item.manifest.keywords || [], + categories: item.manifest.categories || [], + default_locale: item.manifest.default_locale || 'zh-CN', + locales_path: item.manifest.locales_path, + }, + // 默认值,这些信息可能需要从其他 API 获取 + downloads: 0, + rating: 0, + review_count: 0, + installed: false, + published_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + })) + + return pluginList + } catch (error) { + console.error('Failed to fetch plugin list:', error) + throw error + } +} + +/** + * 检查本机 Git 安装状态 + */ +export async function checkGitStatus(): Promise { + try { + const response = await fetchWithAuth('/api/webui/plugins/git-status') + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + return await response.json() + } catch (error) { + console.error('Failed to check Git status:', error) + // 返回未安装状态 + return { + installed: false, + error: '无法检测 Git 安装状态' + } + } +} + +/** + * 获取麦麦版本信息 + */ +export async function getMaimaiVersion(): Promise { + try { + const response = await fetchWithAuth('/api/webui/plugins/version') + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + return await response.json() + } catch (error) { + console.error('Failed to get Maimai version:', error) + // 返回默认版本 + return { + version: '0.0.0', + version_major: 0, + version_minor: 0, + version_patch: 0 + } + } +} + +/** + * 比较版本号 + * + * @param pluginMinVersion 插件要求的最小版本 + * @param pluginMaxVersion 插件要求的最大版本(可选) + * @param maimaiVersion 麦麦当前版本 + * @returns true 表示兼容,false 表示不兼容 + */ +export function isPluginCompatible( + pluginMinVersion: string, + pluginMaxVersion: string | undefined, + maimaiVersion: MaimaiVersion +): boolean { + // 解析插件最小版本 + const minParts = pluginMinVersion.split('.').map(p => parseInt(p) || 0) + const minMajor = minParts[0] || 0 + const minMinor = minParts[1] || 0 + const minPatch = minParts[2] || 0 + + // 检查最小版本 + if (maimaiVersion.version_major < minMajor) return false + if (maimaiVersion.version_major === minMajor && maimaiVersion.version_minor < minMinor) return false + if (maimaiVersion.version_major === minMajor && + maimaiVersion.version_minor === minMinor && + maimaiVersion.version_patch < minPatch) return false + + // 检查最大版本(如果有) + if (pluginMaxVersion) { + const maxParts = pluginMaxVersion.split('.').map(p => parseInt(p) || 0) + const maxMajor = maxParts[0] || 0 + const maxMinor = maxParts[1] || 0 + const maxPatch = maxParts[2] || 0 + + if (maimaiVersion.version_major > maxMajor) return false + if (maimaiVersion.version_major === maxMajor && maimaiVersion.version_minor > maxMinor) return false + if (maimaiVersion.version_major === maxMajor && + maimaiVersion.version_minor === maxMinor && + maimaiVersion.version_patch > maxPatch) return false + } + + return true +} + +/** + * 获取 WebSocket 临时认证 token + */ +async function getWsToken(): Promise { + try { + const response = await fetchWithAuth('/api/webui/ws-token') + if (!response.ok) { + console.error('获取 WebSocket token 失败:', response.status) + return null + } + const data = await response.json() + if (data.success && data.token) { + return data.token + } + return null + } catch (error) { + console.error('获取 WebSocket token 失败:', error) + return null + } +} + +/** + * 连接插件加载进度 WebSocket + * + * 使用临时 token 进行认证,异步获取 token 后连接 + */ +export async function connectPluginProgressWebSocket( + onProgress: (progress: PluginLoadProgress) => void, + onError?: (error: Event) => void +): Promise { + // 先获取临时 token + const wsToken = await getWsToken() + if (!wsToken) { + console.warn('无法获取 WebSocket token,可能未登录') + return null + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + const wsUrl = `${protocol}//${host}/api/webui/ws/plugin-progress?token=${encodeURIComponent(wsToken)}` + + try { + const ws = new WebSocket(wsUrl) + + ws.onopen = () => { + console.log('Plugin progress WebSocket connected') + // 发送心跳 + const heartbeat = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send('ping') + } else { + clearInterval(heartbeat) + } + }, 30000) + } + + ws.onmessage = (event) => { + try { + // 忽略心跳响应 + if (event.data === 'pong') { + return + } + + const data = JSON.parse(event.data) as PluginLoadProgress + onProgress(data) + } catch (error) { + console.error('Failed to parse progress data:', error) + } + } + + ws.onerror = (error) => { + console.error('Plugin progress WebSocket error:', error) + onError?.(error) + } + + ws.onclose = () => { + console.log('Plugin progress WebSocket disconnected') + } + + return ws + } catch (error) { + console.error('创建 WebSocket 连接失败:', error) + return null + } +} + +/** + * 获取已安装插件列表 + */ +export async function getInstalledPlugins(): Promise { + try { + const response = await fetchWithAuth('/api/webui/plugins/installed', { + headers: getAuthHeaders() + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const result = await response.json() + + if (!result.success) { + throw new Error(result.message || '获取已安装插件列表失败') + } + + return result.plugins || [] + } catch (error) { + console.error('Failed to get installed plugins:', error) + return [] + } +} + +/** + * 检查插件是否已安装 + */ +export function checkPluginInstalled(pluginId: string, installedPlugins: InstalledPlugin[]): boolean { + return installedPlugins.some(p => p.id === pluginId) +} + +/** + * 获取已安装插件的版本 + */ +export function getInstalledPluginVersion(pluginId: string, installedPlugins: InstalledPlugin[]): string | undefined { + const plugin = installedPlugins.find(p => p.id === pluginId) + if (!plugin) return undefined + + // 兼容两种格式:新格式有 manifest,旧格式直接有 version + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return plugin.manifest?.version || (plugin as any).version +} + +/** + * 安装插件 + */ +export async function installPlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise<{ success: boolean; message: string }> { + const response = await fetchWithAuth('/api/webui/plugins/install', { + method: 'POST', + + body: JSON.stringify({ + plugin_id: pluginId, + repository_url: repositoryUrl, + branch: branch + }) + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '安装失败') + } + + return await response.json() +} + +/** + * 卸载插件 + */ +export async function uninstallPlugin(pluginId: string): Promise<{ success: boolean; message: string }> { + const response = await fetchWithAuth('/api/webui/plugins/uninstall', { + method: 'POST', + + body: JSON.stringify({ + plugin_id: pluginId + }) + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '卸载失败') + } + + return await response.json() +} + +/** + * 更新插件 + */ +export async function updatePlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise<{ success: boolean; message: string; old_version: string; new_version: string }> { + const response = await fetchWithAuth('/api/webui/plugins/update', { + method: 'POST', + + body: JSON.stringify({ + plugin_id: pluginId, + repository_url: repositoryUrl, + branch: branch + }) + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '更新失败') + } + + return await response.json() +} + + +// ============ 插件配置管理 ============ + +/** + * 列表项字段定义(用于 object 类型的数组项) + */ +export interface ItemFieldDefinition { + type: string + label?: string + placeholder?: string + default?: unknown +} + +/** + * 配置字段定义 + */ +export interface ConfigFieldSchema { + name: string + type: string + default: unknown + description: string + example?: string + required: boolean + choices?: unknown[] + min?: number + max?: number + step?: number + pattern?: string + max_length?: number + label: string + placeholder?: string + hint?: string + icon?: string + hidden: boolean + disabled: boolean + order: number + input_type?: string + ui_type: string + rows?: number + group?: string + depends_on?: string + depends_value?: unknown + // 列表类型专用 + item_type?: string // "string" | "number" | "object" + item_fields?: Record + min_items?: number + max_items?: number +} + +/** + * 配置节定义 + */ +export interface ConfigSectionSchema { + name: string + title: string + description?: string + icon?: string + collapsed: boolean + order: number + fields: Record +} + +/** + * 配置标签页定义 + */ +export interface ConfigTabSchema { + id: string + title: string + sections: string[] + icon?: string + order: number + badge?: string +} + +/** + * 配置布局定义 + */ +export interface ConfigLayoutSchema { + type: 'auto' | 'tabs' | 'pages' + tabs: ConfigTabSchema[] +} + +/** + * 插件配置 Schema + */ +export interface PluginConfigSchema { + plugin_id: string + plugin_info: { + name: string + version: string + description: string + author: string + } + sections: Record + layout: ConfigLayoutSchema + _note?: string +} + +/** + * 获取插件配置 Schema + */ +export async function getPluginConfigSchema(pluginId: string): Promise { + const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/schema`, { + headers: getAuthHeaders() + }) + + if (!response.ok) { + const text = await response.text() + try { + const error = JSON.parse(text) + throw new Error(error.detail || '获取配置 Schema 失败') + } catch { + throw new Error(`获取配置 Schema 失败 (${response.status})`) + } + } + + const result = await response.json() + + if (!result.success) { + throw new Error(result.message || '获取配置 Schema 失败') + } + + return result.schema +} + +/** + * 获取插件当前配置值 + */ +export async function getPluginConfig(pluginId: string): Promise> { + const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}`, { + headers: getAuthHeaders() + }) + + if (!response.ok) { + const text = await response.text() + try { + const error = JSON.parse(text) + throw new Error(error.detail || '获取配置失败') + } catch { + throw new Error(`获取配置失败 (${response.status})`) + } + } + + const result = await response.json() + + if (!result.success) { + throw new Error(result.message || '获取配置失败') + } + + return result.config +} + +/** + * 获取插件原始 TOML 配置 + */ +export async function getPluginConfigRaw(pluginId: string): Promise { + const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/raw`, { + headers: getAuthHeaders() + }) + + if (!response.ok) { + const text = await response.text() + try { + const error = JSON.parse(text) + throw new Error(error.detail || '获取配置失败') + } catch { + throw new Error(`获取配置失败 (${response.status})`) + } + } + + const result = await response.json() + + if (!result.success) { + throw new Error(result.message || '获取配置失败') + } + + return result.config +} + +/** + * 更新插件配置 + */ +export async function updatePluginConfig( + pluginId: string, + config: Record +): Promise<{ success: boolean; message: string; note?: string }> { + const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}`, { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify({ config }) + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '保存配置失败') + } + + return await response.json() +} + +/** + * 更新插件原始 TOML 配置 + */ +export async function updatePluginConfigRaw( + pluginId: string, + configToml: string +): Promise<{ success: boolean; message: string; note?: string }> { + const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/raw`, { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify({ config: configToml }) + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '保存配置失败') + } + + return await response.json() +} + +/** + * 重置插件配置为默认值 + */ +export async function resetPluginConfig( + pluginId: string +): Promise<{ success: boolean; message: string; backup?: string }> { + const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/reset`, { + method: 'POST', + headers: getAuthHeaders() + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '重置配置失败') + } + + return await response.json() +} + +/** + * 切换插件启用状态 + */ +export async function togglePlugin( + pluginId: string +): Promise<{ success: boolean; enabled: boolean; message: string; note?: string }> { + const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/toggle`, { + method: 'POST', + headers: getAuthHeaders() + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '切换状态失败') + } + + return await response.json() +} diff --git a/dashboard/src/lib/plugin-stats.ts b/dashboard/src/lib/plugin-stats.ts new file mode 100644 index 00000000..178a16a9 --- /dev/null +++ b/dashboard/src/lib/plugin-stats.ts @@ -0,0 +1,244 @@ +/** + * 插件统计 API 客户端 + * 用于与 Cloudflare Workers 统计服务交互 + */ + +// 配置统计服务 API 地址(所有用户共享的云端统计服务) +const STATS_API_BASE_URL = 'https://maibot-plugin-stats.maibot-webui.workers.dev' + +export interface PluginStatsData { + plugin_id: string + likes: number + dislikes: number + downloads: number + rating: number + rating_count: number + recent_ratings?: Array<{ + user_id: string + rating: number + comment?: string + created_at: string + }> +} + +export interface StatsResponse { + success: boolean + error?: string + remaining?: number + [key: string]: unknown +} + +/** + * 获取插件统计数据 + */ +export async function getPluginStats(pluginId: string): Promise { + try { + const response = await fetch(`${STATS_API_BASE_URL}/stats/${pluginId}`) + + if (!response.ok) { + console.error('Failed to fetch plugin stats:', response.statusText) + return null + } + + return await response.json() + } catch (error) { + console.error('Error fetching plugin stats:', error) + return null + } +} + +/** + * 点赞插件 + */ +export async function likePlugin(pluginId: string, userId?: string): Promise { + try { + const finalUserId = userId || getUserId() + + const response = await fetch(`${STATS_API_BASE_URL}/stats/like`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ plugin_id: pluginId, user_id: finalUserId }), + }) + + const data = await response.json() + + if (response.status === 429) { + return { success: false, error: '操作过于频繁,请稍后再试' } + } + + if (!response.ok) { + return { success: false, error: data.error || '点赞失败' } + } + + return { success: true, ...data } + } catch (error) { + console.error('Error liking plugin:', error) + return { success: false, error: '网络错误' } + } +} + +/** + * 点踩插件 + */ +export async function dislikePlugin(pluginId: string, userId?: string): Promise { + try { + const finalUserId = userId || getUserId() + + const response = await fetch(`${STATS_API_BASE_URL}/stats/dislike`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ plugin_id: pluginId, user_id: finalUserId }), + }) + + const data = await response.json() + + if (response.status === 429) { + return { success: false, error: '操作过于频繁,请稍后再试' } + } + + if (!response.ok) { + return { success: false, error: data.error || '点踩失败' } + } + + return { success: true, ...data } + } catch (error) { + console.error('Error disliking plugin:', error) + return { success: false, error: '网络错误' } + } +} + +/** + * 评分插件 + */ +export async function ratePlugin( + pluginId: string, + rating: number, + comment?: string, + userId?: string +): Promise { + if (rating < 1 || rating > 5) { + return { success: false, error: '评分必须在 1-5 之间' } + } + + try { + const finalUserId = userId || getUserId() + + const response = await fetch(`${STATS_API_BASE_URL}/stats/rate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ plugin_id: pluginId, rating, comment, user_id: finalUserId }), + }) + + const data = await response.json() + + if (response.status === 429) { + return { success: false, error: '每天最多评分 3 次' } + } + + if (!response.ok) { + return { success: false, error: data.error || '评分失败' } + } + + return { success: true, ...data } + } catch (error) { + console.error('Error rating plugin:', error) + return { success: false, error: '网络错误' } + } +} + +/** + * 记录插件下载 + */ +export async function recordPluginDownload(pluginId: string): Promise { + try { + const response = await fetch(`${STATS_API_BASE_URL}/stats/download`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ plugin_id: pluginId }), + }) + + const data = await response.json() + + if (response.status === 429) { + // 下载统计被限流时静默失败,不影响用户体验 + console.warn('Download recording rate limited') + return { success: true } + } + + if (!response.ok) { + console.error('Failed to record download:', data.error) + return { success: false, error: data.error } + } + + return { success: true, ...data } + } catch (error) { + console.error('Error recording download:', error) + return { success: false, error: '网络错误' } + } +} + +/** + * 生成用户指纹(基于浏览器特征) + * 用于在未登录时识别用户,防止重复投票 + */ +export function generateUserFingerprint(): string { + const nav = navigator as Navigator & { deviceMemory?: number } + const features = [ + navigator.userAgent, + navigator.language, + navigator.languages?.join(',') || '', + navigator.platform, + navigator.hardwareConcurrency || 0, + screen.width, + screen.height, + screen.colorDepth, + screen.pixelDepth, + new Date().getTimezoneOffset(), + Intl.DateTimeFormat().resolvedOptions().timeZone, + navigator.maxTouchPoints || 0, + nav.deviceMemory || 0, + ].join('|') + + // 简单哈希函数 + let hash = 0 + for (let i = 0; i < features.length; i++) { + const char = features.charCodeAt(i) + hash = ((hash << 5) - hash) + char + hash = hash & hash // Convert to 32bit integer + } + + return `fp_${Math.abs(hash).toString(36)}` +} + +/** + * 生成或获取用户 UUID + * 存储在 localStorage 中持久化 + */ +export function getUserId(): string { + const STORAGE_KEY = 'maibot_user_id' + + // 尝试从 localStorage 获取 + let userId = localStorage.getItem(STORAGE_KEY) + + if (!userId) { + // 生成新的 UUID + const fingerprint = generateUserFingerprint() + const timestamp = Date.now().toString(36) + const random = Math.random().toString(36).substring(2, 15) + + userId = `${fingerprint}_${timestamp}_${random}` + + // 存储到 localStorage + localStorage.setItem(STORAGE_KEY, userId) + } + + return userId +} diff --git a/dashboard/src/lib/restart-context.tsx b/dashboard/src/lib/restart-context.tsx new file mode 100644 index 00000000..53ef0d31 --- /dev/null +++ b/dashboard/src/lib/restart-context.tsx @@ -0,0 +1,350 @@ +/** + * 重启管理 Context + * + * 提供全局的重启状态管理和触发能力 + * 使用方式: + * const { triggerRestart, isRestarting } = useRestart() + * triggerRestart() // 触发重启 + */ + +import { + createContext, + useContext, + useState, + useCallback, + useRef, + type ReactNode, +} from 'react' +import { restartMaiBot } from './system-api' + +// ============ 类型定义 ============ + +export type RestartStatus = + | 'idle' + | 'requesting' + | 'restarting' + | 'checking' + | 'success' + | 'failed' + +export interface RestartState { + status: RestartStatus + progress: number + elapsedTime: number + checkAttempts: number + maxAttempts: number + error?: string +} + +export interface RestartContextValue { + /** 当前重启状态 */ + state: RestartState + /** 是否正在重启中(任何非 idle 状态) */ + isRestarting: boolean + /** 触发重启 */ + triggerRestart: (options?: TriggerRestartOptions) => Promise + /** 重置状态(用于失败后重试) */ + resetState: () => void + /** 手动开始健康检查(用于重试) */ + retryHealthCheck: () => void +} + +export interface TriggerRestartOptions { + /** 重启前延迟(毫秒),用于显示提示 */ + delay?: number + /** 自定义重启消息 */ + message?: string + /** 跳过 API 调用(用于后端已触发重启的情况) */ + skipApiCall?: boolean +} + +// ============ 配置常量 ============ + +const CONFIG = { + /** 初始等待时间(毫秒),给后端重启时间 */ + INITIAL_DELAY: 3000, + /** 健康检查间隔(毫秒) */ + CHECK_INTERVAL: 2000, + /** 健康检查超时(毫秒) */ + CHECK_TIMEOUT: 3000, + /** 最大检查次数 */ + MAX_ATTEMPTS: 60, + /** 进度条更新间隔(毫秒) */ + PROGRESS_INTERVAL: 200, + /** 成功后跳转延迟(毫秒) */ + SUCCESS_REDIRECT_DELAY: 1500, +} as const + +// ============ Context ============ + +const RestartContext = createContext(null) + +// ============ Provider ============ + +interface RestartProviderProps { + children: ReactNode + /** 重启成功后的回调 */ + onRestartComplete?: () => void + /** 重启失败后的回调 */ + onRestartFailed?: (error: string) => void + /** 自定义健康检查 URL */ + healthCheckUrl?: string + /** 自定义最大尝试次数 */ + maxAttempts?: number +} + +export function RestartProvider({ + children, + onRestartComplete, + onRestartFailed, + healthCheckUrl = '/api/webui/system/status', + maxAttempts = CONFIG.MAX_ATTEMPTS, +}: RestartProviderProps) { + const [state, setState] = useState({ + status: 'idle', + progress: 0, + elapsedTime: 0, + checkAttempts: 0, + maxAttempts, + }) + + // 使用 useRef 存储定时器引用,避免闭包陷阱 + const timersRef = useRef<{ + progress?: ReturnType + elapsed?: ReturnType + check?: ReturnType + }>({}) + + // 清理所有定时器 + const clearAllTimers = useCallback(() => { + const timers = timersRef.current + if (timers.progress) { + clearInterval(timers.progress) + timers.progress = undefined + } + if (timers.elapsed) { + clearInterval(timers.elapsed) + timers.elapsed = undefined + } + if (timers.check) { + clearTimeout(timers.check) + timers.check = undefined + } + }, []) + + // 重置状态 + const resetState = useCallback(() => { + clearAllTimers() + setState({ + status: 'idle', + progress: 0, + elapsedTime: 0, + checkAttempts: 0, + maxAttempts, + }) + }, [clearAllTimers, maxAttempts]) + + // 健康检查 + const checkHealth = useCallback( + async (): Promise => { + try { + const controller = new AbortController() + const timeoutId = setTimeout( + () => controller.abort(), + CONFIG.CHECK_TIMEOUT + ) + + const response = await fetch(healthCheckUrl, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + signal: controller.signal, + }) + + clearTimeout(timeoutId) + return response.ok + } catch { + // 网络错误、超时等都视为服务不可用,这是正常的 + return false + } + }, + [healthCheckUrl] + ) + + // 开始健康检查循环 + const startHealthCheck = useCallback(() => { + let currentAttempt = 0 + + const doCheck = async () => { + currentAttempt++ + setState((prev) => ({ + ...prev, + status: 'checking', + checkAttempts: currentAttempt, + })) + + const isHealthy = await checkHealth() + + if (isHealthy) { + // 成功 + clearAllTimers() + setState((prev) => ({ + ...prev, + status: 'success', + progress: 100, + })) + + // 延迟后跳转 + setTimeout(() => { + onRestartComplete?.() + // 默认跳转到 auth 页面 + window.location.href = '/auth' + }, CONFIG.SUCCESS_REDIRECT_DELAY) + } else if (currentAttempt >= maxAttempts) { + // 失败 + clearAllTimers() + const error = `健康检查超时 (${currentAttempt}/${maxAttempts})` + setState((prev) => ({ + ...prev, + status: 'failed', + error, + })) + onRestartFailed?.(error) + } else { + // 继续检查 + const checkTimer = setTimeout(doCheck, CONFIG.CHECK_INTERVAL) + timersRef.current.check = checkTimer + } + } + + doCheck() + }, [checkHealth, clearAllTimers, maxAttempts, onRestartComplete, onRestartFailed]) + + // 重试健康检查 + const retryHealthCheck = useCallback(() => { + setState((prev) => ({ + ...prev, + status: 'checking', + checkAttempts: 0, + error: undefined, + })) + startHealthCheck() + }, [startHealthCheck]) + + // 触发重启 + const triggerRestart = useCallback( + async (options?: TriggerRestartOptions) => { + const { delay = 0, skipApiCall = false } = options ?? {} + + // 已经在重启中,忽略 + if (state.status !== 'idle' && state.status !== 'failed') { + return + } + + // 重置状态 + clearAllTimers() + setState({ + status: 'requesting', + progress: 0, + elapsedTime: 0, + checkAttempts: 0, + maxAttempts, + }) + + // 可选延迟 + if (delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)) + } + + // 调用重启 API + if (!skipApiCall) { + try { + setState((prev) => ({ ...prev, status: 'restarting' })) + // 重启 API 可能不返回响应(服务立即关闭) + await Promise.race([ + restartMaiBot(), + // 5秒超时,超时也视为成功(服务已关闭) + new Promise((resolve) => setTimeout(resolve, 5000)), + ]) + } catch { + // API 调用失败也是正常的(服务已关闭) + // 继续进行健康检查 + } + } else { + setState((prev) => ({ ...prev, status: 'restarting' })) + } + + // 启动进度条动画 + const progressTimer = setInterval(() => { + setState((prev) => ({ + ...prev, + progress: prev.progress >= 90 ? prev.progress : prev.progress + 1, + })) + }, CONFIG.PROGRESS_INTERVAL) + + // 启动计时器 + const elapsedTimer = setInterval(() => { + setState((prev) => ({ + ...prev, + elapsedTime: prev.elapsedTime + 1, + })) + }, 1000) + + timersRef.current.progress = progressTimer + timersRef.current.elapsed = elapsedTimer + + // 延迟后开始健康检查 + setTimeout(() => { + startHealthCheck() + }, CONFIG.INITIAL_DELAY) + }, + [state.status, clearAllTimers, maxAttempts, startHealthCheck] + ) + + const contextValue: RestartContextValue = { + state, + isRestarting: state.status !== 'idle', + triggerRestart, + resetState, + retryHealthCheck, + } + + return ( + + {children} + + ) +} + +// ============ Hook ============ + +export function useRestart(): RestartContextValue { + const context = useContext(RestartContext) + if (!context) { + throw new Error('useRestart must be used within a RestartProvider') + } + return context +} + +// ============ 便捷 Hook(无需 Provider) ============ + +/** + * 独立的重启 Hook,不依赖 Provider + * 适用于只需要触发重启,不需要全局状态的场景 + */ +export function useRestartAction() { + const [isRestarting, setIsRestarting] = useState(false) + + const triggerRestart = useCallback(async () => { + if (isRestarting) return + + setIsRestarting(true) + try { + await restartMaiBot() + } catch { + // 忽略错误,服务可能已关闭 + } + }, [isRestarting]) + + return { isRestarting, triggerRestart } +} diff --git a/dashboard/src/lib/settings-manager.ts b/dashboard/src/lib/settings-manager.ts new file mode 100644 index 00000000..bded8ad1 --- /dev/null +++ b/dashboard/src/lib/settings-manager.ts @@ -0,0 +1,282 @@ +/** + * 前端设置管理器 + * 统一管理所有前端 localStorage 设置 + */ + +// 所有设置的 key 定义 +export const STORAGE_KEYS = { + // 外观设置 + THEME: 'maibot-ui-theme', + ACCENT_COLOR: 'accent-color', + ENABLE_ANIMATIONS: 'maibot-animations', + ENABLE_WAVES_BACKGROUND: 'maibot-waves-background', + + // 性能与存储设置 + LOG_CACHE_SIZE: 'maibot-log-cache-size', + LOG_AUTO_SCROLL: 'maibot-log-auto-scroll', + LOG_FONT_SIZE: 'maibot-log-font-size', + LOG_LINE_SPACING: 'maibot-log-line-spacing', + DATA_SYNC_INTERVAL: 'maibot-data-sync-interval', + WS_RECONNECT_INTERVAL: 'maibot-ws-reconnect-interval', + WS_MAX_RECONNECT_ATTEMPTS: 'maibot-ws-max-reconnect-attempts', + + // 用户数据 + // 注意:ACCESS_TOKEN 已弃用,现在使用 HttpOnly Cookie 存储认证信息 + // 保留此常量仅用于向后兼容和清理旧数据 + ACCESS_TOKEN: 'access-token', + COMPLETED_TOURS: 'maibot-completed-tours', + CHAT_USER_ID: 'maibot_webui_user_id', + CHAT_USER_NAME: 'maibot_webui_user_name', +} as const + +// 默认设置值 +export const DEFAULT_SETTINGS = { + // 外观 + theme: 'system' as 'light' | 'dark' | 'system', + accentColor: 'blue', + enableAnimations: true, + enableWavesBackground: true, + + // 性能与存储 + logCacheSize: 1000, + logAutoScroll: true, + logFontSize: 'xs' as 'xs' | 'sm' | 'base', + logLineSpacing: 4, + dataSyncInterval: 30, // 秒 + wsReconnectInterval: 3000, // 毫秒 + wsMaxReconnectAttempts: 10, +} + +// 设置类型定义 +export type Settings = typeof DEFAULT_SETTINGS + +// 可导出的设置(不包含敏感信息) +export type ExportableSettings = Omit & { + completedTours?: string[] +} + +/** + * 获取单个设置值 + */ +export function getSetting(key: K): Settings[K] { + const storageKey = getStorageKey(key) + const stored = localStorage.getItem(storageKey) + + if (stored === null) { + return DEFAULT_SETTINGS[key] + } + + // 根据默认值类型进行转换 + const defaultValue = DEFAULT_SETTINGS[key] + + if (typeof defaultValue === 'boolean') { + return (stored === 'true') as Settings[K] + } + + if (typeof defaultValue === 'number') { + const num = parseFloat(stored) + return (isNaN(num) ? defaultValue : num) as Settings[K] + } + + return stored as Settings[K] +} + +/** + * 设置单个值 + */ +export function setSetting(key: K, value: Settings[K]): void { + const storageKey = getStorageKey(key) + localStorage.setItem(storageKey, String(value)) + + // 触发自定义事件,通知其他组件设置已更新 + window.dispatchEvent(new CustomEvent('maibot-settings-change', { + detail: { key, value } + })) +} + +/** + * 获取所有设置 + */ +export function getAllSettings(): Settings { + return { + theme: getSetting('theme'), + accentColor: getSetting('accentColor'), + enableAnimations: getSetting('enableAnimations'), + enableWavesBackground: getSetting('enableWavesBackground'), + logCacheSize: getSetting('logCacheSize'), + logAutoScroll: getSetting('logAutoScroll'), + logFontSize: getSetting('logFontSize'), + logLineSpacing: getSetting('logLineSpacing'), + dataSyncInterval: getSetting('dataSyncInterval'), + wsReconnectInterval: getSetting('wsReconnectInterval'), + wsMaxReconnectAttempts: getSetting('wsMaxReconnectAttempts'), + } +} + +/** + * 导出设置(用于备份) + */ +export function exportSettings(): ExportableSettings { + const settings = getAllSettings() + + // 添加已完成的引导 + const completedToursStr = localStorage.getItem(STORAGE_KEYS.COMPLETED_TOURS) + const completedTours = completedToursStr ? JSON.parse(completedToursStr) : [] + + return { + ...settings, + completedTours, + } +} + +/** + * 导入设置 + */ +export function importSettings(settings: Partial): { success: boolean; imported: string[]; skipped: string[] } { + const imported: string[] = [] + const skipped: string[] = [] + + // 验证并导入每个设置 + for (const [key, value] of Object.entries(settings)) { + if (key === 'completedTours') { + // 特殊处理已完成的引导 + if (Array.isArray(value)) { + localStorage.setItem(STORAGE_KEYS.COMPLETED_TOURS, JSON.stringify(value)) + imported.push('completedTours') + } else { + skipped.push('completedTours') + } + continue + } + + if (key in DEFAULT_SETTINGS) { + const settingKey = key as keyof Settings + const defaultValue = DEFAULT_SETTINGS[settingKey] + + // 类型验证 + if (typeof value === typeof defaultValue) { + // 额外验证 + if (settingKey === 'theme' && !['light', 'dark', 'system'].includes(value as string)) { + skipped.push(key) + continue + } + if (settingKey === 'logFontSize' && !['xs', 'sm', 'base'].includes(value as string)) { + skipped.push(key) + continue + } + + setSetting(settingKey, value as Settings[typeof settingKey]) + imported.push(key) + } else { + skipped.push(key) + } + } else { + skipped.push(key) + } + } + + return { + success: imported.length > 0, + imported, + skipped, + } +} + +/** + * 重置所有设置为默认值 + */ +export function resetAllSettings(): void { + for (const key of Object.keys(DEFAULT_SETTINGS) as (keyof Settings)[]) { + setSetting(key, DEFAULT_SETTINGS[key]) + } + + // 清除已完成的引导 + localStorage.removeItem(STORAGE_KEYS.COMPLETED_TOURS) + + // 触发全局事件 + window.dispatchEvent(new CustomEvent('maibot-settings-reset')) +} + +/** + * 清除所有本地缓存 + * 注意:认证信息现在存储在 HttpOnly Cookie 中,不受此函数影响 + */ +export function clearLocalCache(): { clearedKeys: string[]; preservedKeys: string[] } { + const clearedKeys: string[] = [] + const preservedKeys: string[] = [] + + // 遍历所有 localStorage 项 + const keysToRemove: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key) { + if (key.startsWith('maibot') || key.startsWith('accent-color') || key === 'access-token') { + keysToRemove.push(key) + } + } + } + + // 删除需要清除的 key + for (const key of keysToRemove) { + localStorage.removeItem(key) + clearedKeys.push(key) + } + + return { clearedKeys, preservedKeys } +} + +/** + * 获取本地存储使用情况 + */ +export function getStorageUsage(): { used: number; items: number; details: { key: string; size: number }[] } { + let totalSize = 0 + const details: { key: string; size: number }[] = [] + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key) { + const value = localStorage.getItem(key) || '' + const size = (key.length + value.length) * 2 // UTF-16 编码,每个字符 2 字节 + totalSize += size + details.push({ key, size }) + } + } + + // 按大小排序 + details.sort((a, b) => b.size - a.size) + + return { + used: totalSize, + items: localStorage.length, + details, + } +} + +/** + * 格式化字节大小 + */ +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] +} + +// 内部辅助函数:获取 localStorage key +function getStorageKey(settingKey: keyof Settings): string { + const keyMap: Record = { + theme: STORAGE_KEYS.THEME, + accentColor: STORAGE_KEYS.ACCENT_COLOR, + enableAnimations: STORAGE_KEYS.ENABLE_ANIMATIONS, + enableWavesBackground: STORAGE_KEYS.ENABLE_WAVES_BACKGROUND, + logCacheSize: STORAGE_KEYS.LOG_CACHE_SIZE, + logAutoScroll: STORAGE_KEYS.LOG_AUTO_SCROLL, + logFontSize: STORAGE_KEYS.LOG_FONT_SIZE, + logLineSpacing: STORAGE_KEYS.LOG_LINE_SPACING, + dataSyncInterval: STORAGE_KEYS.DATA_SYNC_INTERVAL, + wsReconnectInterval: STORAGE_KEYS.WS_RECONNECT_INTERVAL, + wsMaxReconnectAttempts: STORAGE_KEYS.WS_MAX_RECONNECT_ATTEMPTS, + } + return keyMap[settingKey] +} diff --git a/dashboard/src/lib/survey-api.ts b/dashboard/src/lib/survey-api.ts new file mode 100644 index 00000000..111f548f --- /dev/null +++ b/dashboard/src/lib/survey-api.ts @@ -0,0 +1,176 @@ +/** + * 问卷调查 API 客户端 + * 用于与 Cloudflare Workers 问卷服务交互 + */ + +import type { + SurveySubmission, + StoredSubmission, + SurveyStats, + SurveySubmitResponse, + SurveyStatsResponse, + UserSubmissionsResponse, + QuestionAnswer +} from '@/types/survey' + +// 配置统计服务 API 地址 +const STATS_API_BASE_URL = 'https://maibot-plugin-stats.maibot-webui.workers.dev' + +/** + * 生成或获取用户ID + */ +export function getUserId(): string { + const storageKey = 'maibot_user_id' + let userId = localStorage.getItem(storageKey) + + if (!userId) { + // 生成新的用户ID: fp_{fingerprint}_{timestamp}_{random} + const fingerprint = Math.random().toString(36).substring(2, 10) + const timestamp = Date.now().toString(36) + const random = Math.random().toString(36).substring(2, 10) + userId = `fp_${fingerprint}_${timestamp}_${random}` + localStorage.setItem(storageKey, userId) + } + + return userId +} + +/** + * 提交问卷 + */ +export async function submitSurvey( + surveyId: string, + surveyVersion: string, + answers: QuestionAnswer[], + options?: { + allowMultiple?: boolean + userId?: string + } +): Promise { + try { + const userId = options?.userId || getUserId() + + const submission: SurveySubmission & { allowMultiple?: boolean } = { + surveyId, + surveyVersion, + userId, + answers, + submittedAt: new Date().toISOString(), + allowMultiple: options?.allowMultiple, + metadata: { + userAgent: navigator.userAgent, + language: navigator.language + } + } + + const response = await fetch(`${STATS_API_BASE_URL}/survey/submit`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(submission), + }) + + const data = await response.json() + + if (response.status === 429) { + return { success: false, error: '提交过于频繁,请稍后再试' } + } + + if (response.status === 409) { + return { success: false, error: data.error || '你已经提交过这份问卷了' } + } + + if (!response.ok) { + return { success: false, error: data.error || '提交失败' } + } + + return { + success: true, + submissionId: data.submissionId, + message: data.message + } + } catch (error) { + console.error('Error submitting survey:', error) + return { success: false, error: '网络错误' } + } +} + +/** + * 获取问卷统计数据 + */ +export async function getSurveyStats(surveyId: string): Promise { + try { + const response = await fetch(`${STATS_API_BASE_URL}/survey/stats/${surveyId}`) + + if (!response.ok) { + const data = await response.json() + return { success: false, error: data.error || '获取统计数据失败' } + } + + const data = await response.json() + return { success: true, stats: data.stats as SurveyStats } + } catch (error) { + console.error('Error fetching survey stats:', error) + return { success: false, error: '网络错误' } + } +} + +/** + * 获取用户提交记录 + */ +export async function getUserSubmissions( + surveyId?: string, + userId?: string +): Promise { + try { + const finalUserId = userId || getUserId() + const params = new URLSearchParams({ user_id: finalUserId }) + + if (surveyId) { + params.append('survey_id', surveyId) + } + + const response = await fetch(`${STATS_API_BASE_URL}/survey/submissions?${params}`) + + if (!response.ok) { + const data = await response.json() + return { success: false, error: data.error || '获取提交记录失败' } + } + + const data = await response.json() + return { success: true, submissions: data.submissions as StoredSubmission[] } + } catch (error) { + console.error('Error fetching user submissions:', error) + return { success: false, error: '网络错误' } + } +} + +/** + * 检查用户是否已提交问卷 + */ +export async function checkUserSubmission( + surveyId: string, + userId?: string +): Promise<{ success: boolean; hasSubmitted?: boolean; error?: string }> { + try { + const finalUserId = userId || getUserId() + const params = new URLSearchParams({ + user_id: finalUserId, + survey_id: surveyId + }) + + const response = await fetch(`${STATS_API_BASE_URL}/survey/check?${params}`) + + if (!response.ok) { + const data = await response.json() + return { success: false, error: data.error || '检查失败' } + } + + const data = await response.json() + return { success: true, hasSubmitted: data.hasSubmitted } + } catch (error) { + console.error('Error checking submission:', error) + return { success: false, error: '网络错误' } + } +} diff --git a/dashboard/src/lib/system-api.ts b/dashboard/src/lib/system-api.ts new file mode 100644 index 00000000..0310667d --- /dev/null +++ b/dashboard/src/lib/system-api.ts @@ -0,0 +1,44 @@ +import { fetchWithAuth, getAuthHeaders } from './fetch-with-auth' + +/** + * 系统控制 API + */ + +/** + * 重启麦麦主程序 + */ +export async function restartMaiBot(): Promise<{ success: boolean; message: string }> { + const response = await fetchWithAuth('/api/webui/system/restart', { + method: 'POST', + headers: getAuthHeaders(), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '重启失败') + } + + return await response.json() +} + +/** + * 检查麦麦运行状态 + */ +export async function getMaiBotStatus(): Promise<{ + running: boolean + uptime: number + version: string + start_time: string +}> { + const response = await fetchWithAuth('/api/webui/system/status', { + method: 'GET', + headers: getAuthHeaders(), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '获取状态失败') + } + + return await response.json() +} diff --git a/dashboard/src/lib/theme-context.ts b/dashboard/src/lib/theme-context.ts new file mode 100644 index 00000000..19358e7a --- /dev/null +++ b/dashboard/src/lib/theme-context.ts @@ -0,0 +1,15 @@ +import { createContext } from 'react' + +type Theme = 'dark' | 'light' | 'system' + +export type ThemeProviderState = { + theme: Theme + setTheme: (theme: Theme) => void +} + +const initialState: ThemeProviderState = { + theme: 'system', + setTheme: () => null, +} + +export const ThemeProviderContext = createContext(initialState) diff --git a/dashboard/src/lib/token-validator.ts b/dashboard/src/lib/token-validator.ts new file mode 100644 index 00000000..c6e845b1 --- /dev/null +++ b/dashboard/src/lib/token-validator.ts @@ -0,0 +1,82 @@ +/** + * Token 验证规则和状态 + */ + +export interface TokenValidationRule { + id: string + label: string + validate: (token: string) => boolean + description: string +} + +export interface TokenValidationResult { + isValid: boolean + rules: Array<{ + id: string + label: string + passed: boolean + description: string + }> +} + +// Token 验证规则定义 +export const TOKEN_VALIDATION_RULES: TokenValidationRule[] = [ + { + id: 'minLength', + label: '长度至少 10 位', + description: 'Token 长度必须大于等于 10 个字符', + validate: (token: string) => token.length >= 10, + }, + { + id: 'hasUppercase', + label: '包含大写字母', + description: '至少包含一个大写字母 (A-Z)', + validate: (token: string) => /[A-Z]/.test(token), + }, + { + id: 'hasLowercase', + label: '包含小写字母', + description: '至少包含一个小写字母 (a-z)', + validate: (token: string) => /[a-z]/.test(token), + }, + { + id: 'hasSpecialChar', + label: '包含特殊符号', + description: '至少包含一个特殊符号 (!@#$%^&*()_+-=[]{}|;:,.<>?/)', + validate: (token: string) => /[!@#$%^&*()_+\-=[\]{}|;:,.<>?/]/.test(token), + }, +] + +/** + * 验证 Token 并返回详细结果 + */ +export function validateToken(token: string): TokenValidationResult { + const rules = TOKEN_VALIDATION_RULES.map((rule) => ({ + id: rule.id, + label: rule.label, + description: rule.description, + passed: rule.validate(token), + })) + + const isValid = rules.every((rule) => rule.passed) + + return { + isValid, + rules, + } +} + +/** + * 获取验证失败的规则 + */ +export function getFailedRules(token: string): string[] { + const result = validateToken(token) + return result.rules.filter((rule) => !rule.passed).map((rule) => rule.label) +} + +/** + * 检查 Token 是否完全有效 + */ +export function isTokenValid(token: string): boolean { + return validateToken(token).isValid +} diff --git a/dashboard/src/lib/utils.ts b/dashboard/src/lib/utils.ts new file mode 100644 index 00000000..fed2fe91 --- /dev/null +++ b/dashboard/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/dashboard/src/lib/version.ts b/dashboard/src/lib/version.ts new file mode 100644 index 00000000..38609c84 --- /dev/null +++ b/dashboard/src/lib/version.ts @@ -0,0 +1,26 @@ +/** + * MaiBot Dashboard 版本管理 + * + * 这是唯一需要修改版本号的地方 + * 修改此处的版本号后,所有展示版本的地方都会自动更新 + */ + +export const APP_VERSION = '0.12.2' +export const APP_NAME = 'MaiBot Dashboard' +export const APP_FULL_NAME = `${APP_NAME} v${APP_VERSION}` + +/** + * 获取版本信息 + */ +export const getVersionInfo = () => ({ + version: APP_VERSION, + name: APP_NAME, + fullName: APP_FULL_NAME, + buildDate: import.meta.env.VITE_BUILD_DATE || new Date().toISOString().split('T')[0], + buildEnv: import.meta.env.MODE, +}) + +/** + * 格式化版本显示 + */ +export const formatVersion = (prefix = 'v') => `${prefix}${APP_VERSION}`