From ca9e05aa915f0168560eb2a84dab6dbd66f10d3f Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Fri, 10 Jul 2026 16:45:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E8=AE=BE=E6=96=BD=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit axios 请求封装及七个业务模块 API,Pinia 状态管理(auth/system/models/tools),Mock 适配器与数据,以及流式对话、轮询、倒计时组合式函数。 --- frontend/src/api/modules/compare.ts | 80 +++++++ frontend/src/api/modules/dataset.ts | 40 ++++ frontend/src/api/modules/eval.ts | 27 +++ frontend/src/api/modules/fineTune.ts | 40 ++++ frontend/src/api/modules/log.ts | 18 ++ frontend/src/api/modules/model.ts | 48 ++++ frontend/src/api/modules/system.ts | 12 + frontend/src/api/request.ts | 73 ++++++ frontend/src/composables/useCountdown.ts | 35 +++ frontend/src/composables/usePolling.ts | 20 ++ frontend/src/composables/useStreamChat.ts | 143 ++++++++++++ frontend/src/mock/adapter.ts | 268 ++++++++++++++++++++++ frontend/src/mock/data.ts | 230 +++++++++++++++++++ frontend/src/stores/auth.ts | 45 ++++ frontend/src/stores/models.ts | 34 +++ frontend/src/stores/system.ts | 36 +++ frontend/src/stores/tools.ts | 48 ++++ 17 files changed, 1197 insertions(+) create mode 100644 frontend/src/api/modules/compare.ts create mode 100644 frontend/src/api/modules/dataset.ts create mode 100644 frontend/src/api/modules/eval.ts create mode 100644 frontend/src/api/modules/fineTune.ts create mode 100644 frontend/src/api/modules/log.ts create mode 100644 frontend/src/api/modules/model.ts create mode 100644 frontend/src/api/modules/system.ts create mode 100644 frontend/src/api/request.ts create mode 100644 frontend/src/composables/useCountdown.ts create mode 100644 frontend/src/composables/usePolling.ts create mode 100644 frontend/src/composables/useStreamChat.ts create mode 100644 frontend/src/mock/adapter.ts create mode 100644 frontend/src/mock/data.ts create mode 100644 frontend/src/stores/auth.ts create mode 100644 frontend/src/stores/models.ts create mode 100644 frontend/src/stores/system.ts create mode 100644 frontend/src/stores/tools.ts diff --git a/frontend/src/api/modules/compare.ts b/frontend/src/api/modules/compare.ts new file mode 100644 index 0000000..30586cf --- /dev/null +++ b/frontend/src/api/modules/compare.ts @@ -0,0 +1,80 @@ +import { get, post, del } from '../request' +import type { CompareTask, CompareModelRef } from '@/types' + +/** 推理/对比任务列表 */ +export const getCompareList = () => get('/model-compare') + +/** 任务详情 */ +export const getCompare = (id: string | number) => get(`/model-compare/${id}`) + +/** 创建任务 */ +export const createCompare = (data: Partial) => + post<{ id: string | number }>('/model-compare', data) + +/** 删除任务 */ +export const deleteCompare = (id: string | number) => del(`/model-compare/${id}`) + +/** 更新任务加载状态 */ +export const updateLoadStatus = (id: string | number, load_status: any) => + post(`/model-compare/${id}/load-status`, { load_status }) + +/** 查询任务加载状态 */ +export const getLoadStatus = (id: string | number) => + get<{ all_ready: boolean; loaded_models: any[] }>(`/model-compare/${id}/load-status`) + +/** 停止所有旧模型服务 */ +export const stopAllModels = () => post('/model-compare/all/stop-all') + +/** 启动单个模型服务 */ +export const startModel = (id: string | number, data: Partial) => + post<{ pid: number; port: number }>(`/model-compare/${id}/start-model`, data) + +/** 按 PID 停止模型进程 */ +export const stopModelByPid = (pid: number) => + post('/model-compare/stop-by-pid', { pid }) + +/** 加载任务 */ +export const loadCompare = (id: string | number) => post(`/model-compare/${id}/load`) + +/** 卸载任务 */ +export const unloadCompare = (id: string | number) => post(`/model-compare/${id}/unload`) + +/** 流式对话(mock 模式下返回非流式响应,调用方需兼容) */ +export const streamChat = async (data: any): Promise => { + // Mock 环境:返回非流式响应对象,调用方检测 content-type 决定如何处理 + const resp = await post<{ response: string }>('/model-compare/stream-chat', data) + return { + ok: true, + body: { + getReader: () => { + // 模拟流式读取:把整个响应切成小块逐个返回 + const text = resp?.response || '' + const encoder = new TextEncoder() + const chunks = [text.slice(0, text.length / 3), text.slice(text.length / 3, 2 * text.length / 3), text.slice(2 * text.length / 3)] + let i = 0 + return { + read: async () => { + if (i >= chunks.length) return { done: true, value: undefined } + const value = encoder.encode(chunks[i++]) + return { done: false, value } + }, + } + }, + }, + } +} + +/** 非流式对话(按端口代理) */ +export const chatWithPort = (data: any) => post('/model-compare/chat-with-port', data) + +/** 批量对话(API 类型模型) */ +export const batchChat = (data: any) => post('/model-chat/batch', data) + +/** 本地 transformers 模型对话 */ +export const localChat = (data: any) => post('/model-chat/local/chat', data) + +/** 预加载本地模型 */ +export const preloadLocalModel = (data: any) => post('/model-chat/local/preload', data) + +/** 预加载已训练模型 */ +export const preloadTrainedModel = (data: any) => post('/model-chat/trained/preload', data) diff --git a/frontend/src/api/modules/dataset.ts b/frontend/src/api/modules/dataset.ts new file mode 100644 index 0000000..5c65a2e --- /dev/null +++ b/frontend/src/api/modules/dataset.ts @@ -0,0 +1,40 @@ +import { get, post, put, del } from '../request' +import type { DatasetItem } from '@/types' + +/** 数据集列表 */ +export const getDatasetList = () => get('/dataset-manage') + +/** 数据集详情 */ +export const getDataset = (id: string | number) => get(`/dataset-manage/${id}`) + +/** 创建数据集 */ +export const createDataset = (data: Partial) => + post<{ id: string | number }>('/dataset-manage', data) + +/** 更新数据集 */ +export const updateDataset = (id: string | number, data: Partial) => + put(`/dataset-manage/${id}`, data) + +/** 删除数据集 */ +export const deleteDataset = (id: string | number) => del(`/dataset-manage/${id}`) + +/** 上传数据集文件(multipart,字段名 files) */ +export const uploadDatasetFiles = (datasetId: string | number, files: File[]) => { + const formData = new FormData() + files.forEach((f) => formData.append('files', f)) + return post(`/dataset-manage/upload/${datasetId}`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) +} + +/** 预览数据集文件内容 */ +export const previewDatasetFile = (fileId: string | number) => + get<{ content: string }>(`/dataset-manage/preview/${fileId}`) + +/** 下载文件 URL */ +export const downloadFileUrl = (datasetId: string | number, fileId: string | number) => + `/api/dataset-manage/download/${datasetId}/${fileId}` + +/** 打包下载数据集 URL */ +export const downloadDatasetUrl = (datasetId: string | number) => + `/api/dataset-manage/download/${datasetId}` diff --git a/frontend/src/api/modules/eval.ts b/frontend/src/api/modules/eval.ts new file mode 100644 index 0000000..86111f8 --- /dev/null +++ b/frontend/src/api/modules/eval.ts @@ -0,0 +1,27 @@ +import { get, post, put, del } from '../request' +import type { EvalTask, Dimension } from '@/types' + +/** 评测任务列表 */ +export const getEvalList = () => get('/model-eval') + +/** 删除评测任务 */ +export const deleteEval = (id: string | number) => del(`/model-eval/${id}`) + +/** 启动评测 */ +export const startEval = (data: any) => post('/model-eval/start', data) + +/** 评测维度列表 */ +export const getDimensionList = () => get('/dimension') + +/** 评测维度详情 */ +export const getDimension = (id: string | number) => get(`/dimension/${id}`) + +/** 创建维度 */ +export const createDimension = (data: Partial) => post('/dimension', data) + +/** 编辑维度 */ +export const updateDimension = (id: string | number, data: Partial) => + put(`/dimension/${id}`, data) + +/** 删除维度 */ +export const deleteDimension = (id: string | number) => del(`/dimension/${id}`) diff --git a/frontend/src/api/modules/fineTune.ts b/frontend/src/api/modules/fineTune.ts new file mode 100644 index 0000000..eb69c22 --- /dev/null +++ b/frontend/src/api/modules/fineTune.ts @@ -0,0 +1,40 @@ +import { get, post, put, del } from '../request' +import type { FineTuneTask, TrainingProgress } from '@/types' + +/** 训练任务列表 */ +export const getFineTuneList = () => get('/fine-tune') + +/** 训练任务详情 */ +export const getFineTune = (id: string | number) => get(`/fine-tune/${id}`) + +/** 任务名查重 */ +export const checkFineTuneName = (name: string) => + get<{ exists: boolean }>('/fine-tune/check-name', { name }) + +/** 创建训练任务记录(第一步) */ +export const createFineTune = (data: Partial) => + post<{ id: string | number }>('/fine-tune', data) + +/** 启动训练(第二步) */ +export const startFineTune = (data: any) => post('/fine-tune/start', data) + +/** 更新训练任务 */ +export const updateFineTune = (id: string | number, data: Partial) => + put(`/fine-tune/${id}`, data) + +/** 停止训练任务 */ +export const stopFineTune = (id: string | number) => post(`/fine-tune/stop/${id}`) + +/** 删除训练任务 */ +export const deleteFineTune = (id: string | number) => del(`/fine-tune/${id}`) + +/** 获取训练进度 */ +export const getFineTuneProgress = (id: string | number) => + get(`/fine-tune/progress/${id}`) + +/** 启动 TensorBoard */ +export const startTensorboard = () => post('/fine-tune/tensorboard/start') + +/** 提交 Web 日志 */ +export const sendWebLog = (level: string, message: string, page: string) => + post('/web-log', { level, message, page, timestamp: new Date().toISOString() }) diff --git a/frontend/src/api/modules/log.ts b/frontend/src/api/modules/log.ts new file mode 100644 index 0000000..90df865 --- /dev/null +++ b/frontend/src/api/modules/log.ts @@ -0,0 +1,18 @@ +import { get } from '../request' +import type { LogFile, LogContent, TrainingLogFile } from '@/types' + +/** 按日期获取系统日志文件列表 */ +export const getLogFiles = (date: string) => + get('/log-files', { date }) + +/** 获取系统日志内容 */ +export const getLogContent = (file: string) => + get('/log-content', { file }) + +/** 训练日志文件列表 */ +export const getTrainingLogFiles = () => + get('/training-log-files') + +/** 训练日志内容 */ +export const getTrainingLogContent = (file: string) => + get('/training-log-content', { file }) diff --git a/frontend/src/api/modules/model.ts b/frontend/src/api/modules/model.ts new file mode 100644 index 0000000..68aa240 --- /dev/null +++ b/frontend/src/api/modules/model.ts @@ -0,0 +1,48 @@ +import { get, post, put, del } from '../request' +import type { ModelItem, ModelForm, TrainedModel } from '@/types' + +/** 模型列表 */ +export const getModelList = () => get('/model-manage') + +/** 模型详情 */ +export const getModel = (id: string | number) => get(`/model-manage/${id}`) + +/** 按名称查模型 */ +export const getModelByName = (name: string) => get(`/model-manage/name/${name}`) + +/** 本地模型路径列表 */ +export const getLocalModels = () => + get<{ models: { path: string; name: string }[] }>('/model-manage/local-models') + +/** 已训练模型列表 */ +export const getTrainedModels = () => + get<{ models: TrainedModel[] }>('/model-manage/trained-models') + +/** 创建模型 */ +export const createModel = (data: ModelForm) => post('/model-manage', data) + +/** 编辑模型 */ +export const updateModel = (id: string | number, data: ModelForm) => + put(`/model-manage/${id}`, data) + +/** 删除模型 */ +export const deleteModel = (id: string | number) => del(`/model-manage/${id}`) + +/** 删除已训练模型(合并权重) */ +export const deleteTrainedModel = (id: string | number, type: 'merged' | 'lora' = 'merged') => + del(`/model-manage/trained-models/${id}`, { type }) + +/** 更新模型用途 */ +export const updateModelPurpose = (id: string | number, purpose: string) => + put(`/model-manage/${id}/purpose`, { purpose }) + +/** 合并 LoRA 权重 */ +export const mergeModel = (data: { + model_name: string + train_method: string + base_model_path: string +}) => post('/model-manage/merge', data) + +/** 导出已训练模型权重 */ +export const exportModelUrl = (modelName: string) => + `/api/model-manage/trained-models/${encodeURIComponent(modelName)}/export` diff --git a/frontend/src/api/modules/system.ts b/frontend/src/api/modules/system.ts new file mode 100644 index 0000000..8d8e6f8 --- /dev/null +++ b/frontend/src/api/modules/system.ts @@ -0,0 +1,12 @@ +import { get, post } from '../request' +import type { SystemInfo, HealthMetrics } from '@/types' + +/** 系统信息(CPU/内存/磁盘/GPU/网络/系统) */ +export const getSystemInfo = () => get('/system-info') + +/** 健康指标(顶部栏轻量指标) */ +export const getHealth = () => get('/health') + +/** 登录 */ +export const login = (username: string, password: string) => + post('/login', { username, password }) diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts new file mode 100644 index 0000000..3ee62a7 --- /dev/null +++ b/frontend/src/api/request.ts @@ -0,0 +1,73 @@ +import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios' +import { ElMessage } from 'element-plus' + +/** + * 后端统一响应格式 + * code === 0 表示成功,data 为业务数据 + */ +export interface ApiResult { + code: number + message?: string + data: T +} + +const service: AxiosInstance = axios.create({ + // 统一走相对路径,由 Vite 代理转发到 http://localhost:7861 + baseURL: '/api', + timeout: 30000, +}) + +// 安装 mock adapter(拦截所有 axios 请求返回 mock 数据,方便前端独立开发调试) +import { installMockAdapter } from '@/mock/adapter' +installMockAdapter(service) + +// 请求拦截器 +service.interceptors.request.use( + (config) => config, + (error) => Promise.reject(error), +) + +// 响应拦截器:统一解包 { code, data, message } +service.interceptors.response.use( + (response) => { + const res = response.data as ApiResult + // 二进制流等非 JSON 响应直接返回 + if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') { + return response + } + if (res.code === 0) { + return res.data + } + // 业务错误 + const message = res.message || '请求失败' + ElMessage.error(message) + return Promise.reject(new Error(message)) + }, + (error) => { + const message = error.response?.data?.message || error.message || '网络异常' + ElMessage.error(message) + return Promise.reject(error) + }, +) + +/** GET 请求,返回已解包的 data */ +export function get(url: string, params?: any, config?: AxiosRequestConfig): Promise { + return service.get(url, { params, ...config }) as unknown as Promise +} + +/** POST 请求 */ +export function post(url: string, data?: any, config?: AxiosRequestConfig): Promise { + return service.post(url, data, config) as unknown as Promise +} + +/** PUT 请求 */ +export function put(url: string, data?: any, config?: AxiosRequestConfig): Promise { + return service.put(url, data, config) as unknown as Promise +} + +/** DELETE 请求 */ +export function del(url: string, params?: any, config?: AxiosRequestConfig): Promise { + return service.delete(url, { params, ...config }) as unknown as Promise +} + +export default service diff --git a/frontend/src/composables/useCountdown.ts b/frontend/src/composables/useCountdown.ts new file mode 100644 index 0000000..f0542bc --- /dev/null +++ b/frontend/src/composables/useCountdown.ts @@ -0,0 +1,35 @@ +import { ref, onUnmounted } from 'vue' + +/** + * 倒计时 composable(日志自动刷新倒计时用) + */ +export function useCountdown(total: number) { + const remaining = ref(total) + let timer: ReturnType | null = null + + function start() { + stop() + remaining.value = total + timer = setInterval(() => { + remaining.value-- + if (remaining.value <= 0) { + remaining.value = total + } + }, 1000) + } + + function reset() { + remaining.value = total + } + + function stop() { + if (timer) { + clearInterval(timer) + timer = null + } + } + + onUnmounted(stop) + + return { remaining, start, stop, reset } +} diff --git a/frontend/src/composables/usePolling.ts b/frontend/src/composables/usePolling.ts new file mode 100644 index 0000000..e922131 --- /dev/null +++ b/frontend/src/composables/usePolling.ts @@ -0,0 +1,20 @@ +import { useIntervalFn } from '@vueuse/core' + +/** + * 轮询 composable + * 封装 useIntervalFn,自动在组件卸载时清理 + */ +export function usePolling(fn: () => void | Promise, interval = 5000, immediate = true) { + const { pause, resume } = useIntervalFn(fn, interval, { immediate }) + + function stop() { + pause() + } + + function start() { + if (immediate) fn() + resume() + } + + return { start, stop, pause, resume } +} diff --git a/frontend/src/composables/useStreamChat.ts b/frontend/src/composables/useStreamChat.ts new file mode 100644 index 0000000..331cbe9 --- /dev/null +++ b/frontend/src/composables/useStreamChat.ts @@ -0,0 +1,143 @@ +import { ref } from 'vue' +import { streamChat } from '@/api/modules/compare' + +export interface StreamMessage { + /** 用户问题 */ + question: string + /** 完整回答(含 think 标签原始内容) */ + fullContent: string + /** 去除 think 标签后的展示内容 */ + displayContent: string + /** 思考过程内容 */ + thinkContent: string + /** 是否正在思考(think 标签未闭合) */ + isThinking: boolean + /** 是否流式中 */ + isStreaming: boolean + /** 是否已完成 */ + done: boolean + /** 错误信息 */ + error?: string +} + +/** + * 流式对话 composable + * 移植自原 model-chat.html: + * - fetch + body.getReader() + TextDecoder + * - ... 标签解析(思考过程可折叠) + * - 50ms 节流更新 + */ +export function useStreamChat() { + const message = ref({ + question: '', + fullContent: '', + displayContent: '', + thinkContent: '', + isThinking: false, + isStreaming: false, + done: false, + }) + const loading = ref(false) + + /** 从内容中解析 think 标签 */ + function parseContent(content: string) { + const thinkRegex = /([\s\S]*?)(<\/think>)?/g + let think = '' + let display = content + let isThinking = false + + let match + // 检查是否有未闭合的 think 标签 + const openTags = (content.match(//g) || []).length + const closeTags = (content.match(/<\/think>/g) || []).length + isThinking = openTags > closeTags + + // 提取所有 think 内容 + while ((match = thinkRegex.exec(content)) !== null) { + think += match[1] + } + // 去除 think 标签得到展示内容 + display = content.replace(/[\s\S]*?(<\/think>|$)/g, '').trim() + + return { think: think.trim(), display, isThinking } + } + + /** + * 发起流式对话 + * @param payload 后端请求体 { port, model_name, model_path, system_prompt, user_question, ... } + */ + async function send(payload: any) { + loading.value = true + message.value = { + question: payload.user_question || '', + fullContent: '', + displayContent: '', + thinkContent: '', + isThinking: false, + isStreaming: true, + done: false, + } + + let lastUpdate = 0 + const UPDATE_INTERVAL = 50 // 50ms 节流 + + try { + const response = await streamChat(payload) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + const reader = response.body?.getReader() + if (!reader) throw new Error('无法读取响应流') + + const decoder = new TextDecoder('utf-8') + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + message.value.fullContent = buffer + + const now = Date.now() + if (now - lastUpdate >= UPDATE_INTERVAL) { + lastUpdate = now + const parsed = parseContent(buffer) + message.value.thinkContent = parsed.think + message.value.displayContent = parsed.display + message.value.isThinking = parsed.isThinking + } + } + + // 最终更新 + const parsed = parseContent(buffer) + message.value.thinkContent = parsed.think + message.value.displayContent = parsed.display + message.value.isThinking = false + message.value.isStreaming = false + message.value.done = true + } catch (e: any) { + message.value.isStreaming = false + message.value.done = true + message.value.error = e.message || '流式请求失败' + message.value.displayContent = message.value.fullContent || message.value.error || '请求失败' + } finally { + loading.value = false + } + } + + function reset() { + message.value = { + question: '', + fullContent: '', + displayContent: '', + thinkContent: '', + isThinking: false, + isStreaming: false, + done: false, + } + } + + return { message, loading, send, reset } +} diff --git a/frontend/src/mock/adapter.ts b/frontend/src/mock/adapter.ts new file mode 100644 index 0000000..1f185f7 --- /dev/null +++ b/frontend/src/mock/adapter.ts @@ -0,0 +1,268 @@ +/** + * Axios Mock Adapter + * 拦截所有 API 请求并返回 mock 数据 + * 通过 URL + method 路由到对应的 mock 响应 + */ +import type { AxiosInstance, AxiosRequestConfig } from 'axios' +import { + mockLoginOk, + mockHealth, + mockSystemInfo, + mockModels, + mockTrainedModels, + mockLocalModels, + mockDatasets, + mockFineTuneList, + mockCompareList, + mockEvalList, + mockDimensions, + mockLogFiles, + mockTrainingLogFiles, + mockLogContent, +} from './data' + +/** 模拟网络延迟 */ +function delay(ms = 200): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** 构造 Axios 风格的成功响应 */ +function ok(data: any, config: AxiosRequestConfig = {}) { + return { + data: { code: 0, message: 'ok', data }, + status: 200, + statusText: 'OK', + headers: {}, + config, + } +} + +/** 构造失败响应 */ +function fail(message: string, code = 500, config: AxiosRequestConfig = {}) { + return { + data: { code, message }, + status: code, + statusText: message, + headers: {}, + config, + } +} + +/** 简易 URL 解析(去掉 baseURL 前缀) */ +function stripBaseURL(url: string): string { + // url 可能带查询参数 + const [path] = url.split('?') + return path +} + +/** 通过路径 + method 匹配 mock 响应 */ +async function handleMock(config: AxiosRequestConfig) { + await delay(150) // 模拟网络延迟 + + const url = stripBaseURL(config.url || '') + const method = (config.method || 'get').toLowerCase() + const params = config.params || {} + const body = typeof config.data === 'string' ? safeJSON(config.data) : config.data || {} + + // ==================== 认证 ==================== + if (url === '/login' && method === 'post') { + if (body.username === 'admin' && body.password === 'admin') { + return ok(mockLoginOk.data) + } + return fail('账号或密码错误', 401) + } + if (url === '/web-log' && method === 'post') { + return ok({ received: true }) + } + + // ==================== 系统监控 ==================== + if (url === '/health' && method === 'get') return ok(mockHealth) + if (url === '/system-info' && method === 'get') return ok(mockSystemInfo) + + // ==================== 模型管理 ==================== + if (url === '/model-manage' && method === 'get') return ok(mockModels) + if (url === '/model-manage/local-models' && method === 'get') return ok(mockLocalModels) + if (url === '/model-manage/trained-models' && method === 'get') return ok(mockTrainedModels) + if (url === '/model-manage/merge' && method === 'post') { + return ok({ merged: true, path: '/data/saves/' + body.model_name + '-merged' }) + } + // 模型详情 / 编辑 / 删除 / 用途更新 + let m = url.match(/^\/model-manage\/([^/]+)$/) + if (m && method === 'get') { + const id = m[1] + const found = mockModels.find((x) => String(x.id) === id || x.name === id) + return found ? ok(found) : fail('模型不存在', 404) + } + m = url.match(/^\/model-manage\/([^/]+)$/) + if (m && (method === 'put' || method === 'delete')) { + return ok({ id: m[1], ...body }) + } + m = url.match(/^\/model-manage\/name\/([^/]+)$/) + if (m && method === 'get') { + const name = decodeURIComponent(m[1]) + const found = mockModels.find((x) => x.name === name) + return found ? ok(found) : fail('模型不存在', 404) + } + m = url.match(/^\/model-manage\/trained-models\/([^/]+)$/) + if (m && method === 'delete') { + return ok({ deleted: m[1] }) + } + m = url.match(/^\/model-manage\/([^/]+)\/purpose$/) + if (m && method === 'put') { + return ok({ id: m[1], purpose: body.purpose }) + } + + // ==================== 数据集 ==================== + if (url === '/dataset-manage' && method === 'get') return ok(mockDatasets) + if (url === '/dataset-manage' && method === 'post') { + const newId = Math.max(...mockDatasets.map((d) => Number(d.id))) + 1 + return ok({ id: newId }) + } + m = url.match(/^\/dataset-manage\/([^/]+)$/) + if (m && method === 'get') { + const found = mockDatasets.find((x) => String(x.id) === m[1]) + return found ? ok(found) : fail('数据集不存在', 404) + } + if (m && (method === 'put' || method === 'delete')) { + return ok({ id: m[1] }) + } + m = url.match(/^\/dataset-manage\/upload\/([^/]+)$/) + if (m && method === 'post') return ok({ uploaded: true }) + m = url.match(/^\/dataset-manage\/preview\/([^/]+)$/) + if (m && method === 'get') return ok(mockLogContent) + + // ==================== 训练任务 ==================== + if (url === '/fine-tune' && method === 'get') return ok(mockFineTuneList) + if (url === '/fine-tune' && method === 'post') { + return ok({ id: Math.floor(Math.random() * 10000) + 100 }) + } + if (url === '/fine-tune/start' && method === 'post') { + return ok({ started: true }) + } + if (url === '/fine-tune/check-name' && method === 'get') { + return ok({ exists: false }) + } + m = url.match(/^\/fine-tune\/progress\/([^/]+)$/) + if (m && method === 'get') { + const task = mockFineTuneList.find((t) => String(t.id) === m[1]) + if (!task) return fail('任务不存在', 404) + if (task.status === 'running') { + return ok({ + status: task.status, + progress: task.progress ?? 0, + step: `${Math.floor((task.progress ?? 0) * 10)}/1000`, + speed: '1.23s/it', + eta: '00:23:45', + }) + } + return ok({ status: task.status, progress: task.progress ?? 0 }) + } + m = url.match(/^\/fine-tune\/([^/]+)$/) + if (m && method === 'get') { + const found = mockFineTuneList.find((x) => String(x.id) === m[1]) + return found ? ok(found) : fail('任务不存在', 404) + } + m = url.match(/^\/fine-tune\/stop\/([^/]+)$/) + if (m && method === 'post') return ok({ stopped: true }) + m = url.match(/^\/fine-tune\/([^/]+)$/) + if (m && (method === 'put' || method === 'delete')) { + return ok({ id: m[1] }) + } + if (url === '/fine-tune/tensorboard/start' && method === 'post') { + return ok({ url: 'http://10.10.10.77:6006' }) + } + + // ==================== 模型推理/对比 ==================== + if (url === '/model-compare' && method === 'get') return ok(mockCompareList) + if (url === '/model-compare' && method === 'post') { + return ok({ id: Math.floor(Math.random() * 10000) + 100 }) + } + m = url.match(/^\/model-compare\/([^/]+)$/) + if (m && method === 'get') { + const found = mockCompareList.find((x) => String(x.id) === m[1]) + return found ? ok(found) : fail('任务不存在', 404) + } + if (m && method === 'delete') return ok({ deleted: m[1] }) + m = url.match(/^\/model-compare\/([^/]+)\/load-status$/) + if (m && method === 'get') { + return ok({ all_ready: true, loaded_models: [] }) + } + if (m && method === 'post') return ok({ updated: true }) + m = url.match(/^\/model-compare\/([^/]+)\/load$/) + if (m && method === 'post') return ok({ loading: true }) + m = url.match(/^\/model-compare\/([^/]+)\/unload$/) + if (m && method === 'post') return ok({ stopped: true }) + m = url.match(/^\/model-compare\/([^/]+)\/start-model$/) + if (m && method === 'post') { + return ok({ pid: 12345 + Math.floor(Math.random() * 100), port: 18000 + Math.floor(Math.random() * 1000) }) + } + if (url === '/model-compare/all/stop-all' && method === 'post') return ok({ stopped: true }) + if (url === '/model-compare/stop-by-pid' && method === 'post') return ok({ stopped: true }) + if (url === '/model-compare/chat-with-port' && method === 'post') { + // 模拟推理回答(用于对比结果页) + const userQ = body?.messages?.find((m: any) => m.role === 'user')?.content || '你的问题' + const answer = `这是一个针对「${userQ.slice(0, 30)}」的模拟回答。\n\n在真实环境中,对比结果页会通过端口代理调用对应模型服务,返回该模型的真实推理结果。\n\n模型参数量、温度、Top-p 等参数都会影响输出内容。\n\n- 模型:${body?.model_name || '未知'}\n- 端口:${body?.port || '-'}\n- 参数:temperature=${body?.temperature ?? 0.7}, max_tokens=${body?.max_tokens ?? 2048}` + return ok({ response: answer, content: answer }) + } + if (url === '/model-compare/stream-chat' && method === 'post') { + // 模拟流式对话(前端 useStreamChat 会按块读取) + const userQ = body?.user_question || '' + const sysPrompt = body?.system_prompt || '' + const answer = `${sysPrompt ? '【系统提示】' + sysPrompt.slice(0, 50) + '\n\n' : ''}关于「${userQ}」的回答:\n\n这是一段**模拟流式输出**。在真实部署中,后端会通过 SSE/WebSocket 逐字推送 token。\n\n## 模型参数\n- 温度(temperature):${body?.temperature ?? 0.7}\n- 最大长度(max_tokens):${body?.max_tokens ?? 2048}\n\n## 思考过程\n让我先分析这个问题...\n- 识别用户意图\n- 检索相关知识\n- 生成回答\n\n回答已生成。以上为前端 Mock 演示内容。` + return ok({ response: answer }) + } + if (url === '/model-compare/test-stream' && method === 'post') { + return ok({ response: '测试流式输出' }) + } + if (url === '/model-chat/batch' && method === 'post') return ok({ responses: [] }) + if (url === '/model-chat/local/chat' && method === 'post') return ok({ response: '本地模型回复' }) + if (url === '/model-chat/local/preload' && method === 'post') return ok({ loaded: true }) + if (url === '/model-chat/trained/preload' && method === 'post') return ok({ loaded: true }) + + // ==================== 模型评测 ==================== + if (url === '/model-eval' && method === 'get') return ok(mockEvalList) + if (url === '/model-eval' && method === 'delete') return ok({ deleted: true }) + if (url === '/model-eval/start' && method === 'post') return ok({ task_id: Math.floor(Math.random() * 1000) + 1 }) + if (url === '/dimension' && method === 'get') return ok(mockDimensions) + if (url === '/dimension' && method === 'post') { + return ok({ id: Math.floor(Math.random() * 1000) + 100 }) + } + m = url.match(/^\/dimension\/([^/]+)$/) + if (m && method === 'get') { + const found = mockDimensions.find((x) => String(x.id) === m[1]) + return found ? ok(found) : fail('维度不存在', 404) + } + if (m && (method === 'put' || method === 'delete')) { + return ok({ id: m[1] }) + } + + // ==================== 日志 ==================== + if (url === '/log-files' && method === 'get') return ok(mockLogFiles) + if (url === '/log-content' && method === 'get') return ok(mockLogContent) + if (url === '/training-log-files' && method === 'get') return ok(mockTrainingLogFiles) + if (url === '/training-log-content' && method === 'get') return ok(mockLogContent) + + // 未匹配的请求 → 兜底返回空成功(避免阻断 UI) + console.warn('[Mock] 未匹配路由:', method.toUpperCase(), url, params) + return ok({ mocked: true, url, method, params, body }) +} + +function safeJSON(str: string) { + try { + return JSON.parse(str) + } catch { + return {} + } +} + +/** 给 axios instance 安装 mock adapter */ +export function installMockAdapter(instance: AxiosInstance) { + instance.defaults.adapter = async (config: AxiosRequestConfig) => { + try { + const response = await handleMock(config) + return response + } catch (e: any) { + return fail(e.message || 'Mock 错误', 500, config) + } + } +} \ No newline at end of file diff --git a/frontend/src/mock/data.ts b/frontend/src/mock/data.ts new file mode 100644 index 0000000..f48730a --- /dev/null +++ b/frontend/src/mock/data.ts @@ -0,0 +1,230 @@ +/** + * 全量 Mock 数据 + * 为前端开发提供不依赖后端的模拟数据 + */ + +import type { + FineTuneTask, + ModelItem, + TrainedModel, + DatasetItem, + CompareTask, + EvalTask, + Dimension, + SystemInfo, + HealthMetrics, + LogFile, + TrainingLogFile, + LogContent, +} from '@/types' + +// ============ 认证 ============ +export const mockLoginOk = { code: 0, message: 'ok', data: { token: 'mock-token' } } + +// ============ 系统监控 ============ +export const mockHealth: HealthMetrics = { + cpu_percent: 32, + memory_percent: 58, + disk_percent: 45, +} + +export const mockSystemInfo: SystemInfo = { + cpu: { + percent: 32, + cores: 8, + percents: [25, 38, 42, 30, 28, 36, 40, 18], + }, + memory: { + used_gb: 9.2, + total_gb: 16, + percent: 58, + available_gb: 6.8, + cached_gb: 2.3, + }, + disk: { + used_gb: 230, + total_gb: 512, + percent: 45, + }, + gpu: [ + { name: 'NVIDIA A800', gpu_percent: 0, memory_used_gb: 0.0, memory_total_gb: 80, temperature: 32, power_w: 38, fan_speed: 0, clock_mhz: 1410, driver_version: '535.86.10' }, + { name: 'NVIDIA A800', gpu_percent: 28, memory_used_gb: 22.5, memory_total_gb: 80, temperature: 52, power_w: 165, fan_speed: 32, clock_mhz: 1410, driver_version: '535.86.10' }, + { name: 'NVIDIA A800', gpu_percent: 73, memory_used_gb: 58.7, memory_total_gb: 80, temperature: 68, power_w: 320, fan_speed: 58, clock_mhz: 1410, driver_version: '535.86.10' }, + { name: 'NVIDIA A800', gpu_percent: 0, memory_used_gb: 0.0, memory_total_gb: 80, temperature: 34, power_w: 42, fan_speed: 0, clock_mhz: 1410, driver_version: '535.86.10' }, + { name: 'NVIDIA A800', gpu_percent: 46, memory_used_gb: 36.8, memory_total_gb: 80, temperature: 60, power_w: 210, fan_speed: 42, clock_mhz: 1410, driver_version: '535.86.10' }, + { name: 'NVIDIA A800', gpu_percent: 0, memory_used_gb: 0.0, memory_total_gb: 80, temperature: 33, power_w: 40, fan_speed: 0, clock_mhz: 1410, driver_version: '535.86.10' }, + { name: 'NVIDIA A800', gpu_percent: 85, memory_used_gb: 68.2, memory_total_gb: 80, temperature: 73, power_w: 365, fan_speed: 62, clock_mhz: 1410, driver_version: '535.86.10' }, + { name: 'NVIDIA A800', gpu_percent: 15, memory_used_gb: 12.0, memory_total_gb: 80, temperature: 45, power_w: 95, fan_speed: 22, clock_mhz: 1410, driver_version: '535.86.10' }, + ], + network: { + download_mb: 1024, + upload_mb: 256, + }, + system: { + uptime_seconds: 86400 * 3 + 3600 * 7 + 1800, + process_count: 256, + os: 'Ubuntu 22.04 LTS', + }, +} + +// ============ 模型管理 ============ +export const mockModels: ModelItem[] = [ + { id: 1, name: 'Qwen2.5-7B-Instruct', type: 'LLM', purpose: 'training', model_source: 'local', description: 'Qwen2.5 7B 指令微调基座', path: '/data/models/qwen2.5-7b', create_time: '2025-12-10T08:30:00Z' }, + { id: 2, name: 'Qwen2.5-14B-Instruct', type: 'LLM', purpose: 'training', model_source: 'local', description: 'Qwen2.5 14B 指令微调基座', path: '/data/models/qwen2.5-14b', create_time: '2025-12-12T10:15:00Z' }, + { id: 3, name: 'Llama3-8B-Instruct', type: 'LLM', purpose: 'inference', model_source: 'local', description: 'Llama3 8B 推理模型', path: '/data/models/llama3-8b', create_time: '2025-12-15T14:20:00Z' }, + { id: 4, name: 'DeepSeek-V2-Lite', type: 'LLM', purpose: 'inference', model_source: 'local', description: 'DeepSeek V2 Lite', path: '/data/models/deepseek-v2-lite', create_time: '2026-01-05T09:00:00Z' }, + { id: 5, name: 'GPT-4o', type: 'LLM', purpose: 'evaluation', model_source: 'api', description: 'OpenAI GPT-4o 在线模型', api_url: 'https://api.openai.com/v1', api_key: 'sk-***', online_model_name: 'gpt-4o', create_time: '2026-01-08T11:30:00Z' }, + { id: 6, name: 'Claude-3.5-Sonnet', type: 'LLM', purpose: 'evaluation', model_source: 'api', description: 'Anthropic Claude 3.5 Sonnet', api_url: 'https://api.anthropic.com', api_key: 'sk-***', online_model_name: 'claude-3-5-sonnet-20241022', create_time: '2026-01-10T16:45:00Z' }, + { id: 7, name: 'BGE-large-zh', type: 'Embedding', purpose: 'inference', model_source: 'local', description: '中文 embedding 模型', path: '/data/models/bge-large-zh', create_time: '2026-01-12T13:00:00Z' }, +] + +export const mockTrainedModels: { models: TrainedModel[] } = { + models: [ + { id: 1, name: 'qwen-ft-finance-001', train_methods: [{ name: 'lora' }], base_model_path: '/data/models/qwen2.5-7b', merged: true, merging: false, merged_path: '/data/saves/qwen-ft-finance-001-merged', create_time: '2026-01-15T10:30:00Z' }, + { id: 2, name: 'qwen-ft-legal-002', train_methods: [{ name: 'lora' }], base_model_path: '/data/models/qwen2.5-7b', merged: false, merging: true, create_time: '2026-01-18T14:20:00Z' }, + { id: 3, name: 'llama3-ft-customer-service', train_methods: [{ name: 'qlora' }], base_model_path: '/data/models/llama3-8b', merged: true, merging: false, merged_path: '/data/saves/llama3-ft-customer-service-merged', create_time: '2026-01-22T09:45:00Z' }, + { id: 4, name: 'qwen-ft-medical-003', train_methods: [{ name: 'lora' }], base_model_path: '/data/models/qwen2.5-14b', merged: false, merging: false, create_time: '2026-02-01T16:10:00Z' }, + ], +} + +export const mockLocalModels = { + models: [ + { path: '/data/models/qwen2.5-7b', name: 'Qwen2.5-7B-Instruct' }, + { path: '/data/models/qwen2.5-14b', name: 'Qwen2.5-14B-Instruct' }, + { path: '/data/models/llama3-8b', name: 'Llama3-8B-Instruct' }, + { path: '/data/models/deepseek-v2-lite', name: 'DeepSeek-V2-Lite' }, + { path: '/data/models/bge-large-zh', name: 'BGE-large-zh' }, + ], +} + +// ============ 数据集 ============ +export const mockDatasets: DatasetItem[] = [ + { id: 1, name: '金融问答-训练集', type: 'train', storage_type: 'local', size: '128 MB', count: 8560, description: '金融领域问答对', create_time: '2025-12-20T08:00:00Z' }, + { id: 2, name: '法律文书-训练集', type: 'train', storage_type: 'local', size: '256 MB', count: 15230, description: '法律文书数据集', create_time: '2025-12-25T10:30:00Z' }, + { id: 3, name: '客服对话-训练集', type: 'train', storage_type: 'minio', size: '512 MB', count: 24500, description: '客服对话记录', create_time: '2026-01-05T14:20:00Z' }, + { id: 4, name: '金融评测集', type: 'eval', storage_type: 'local', size: '32 MB', count: 1200, description: '金融领域评测', create_time: '2026-01-10T09:15:00Z' }, + { id: 5, name: '通用能力评测', type: 'eval', storage_type: 'local', size: '64 MB', count: 3500, description: '通用能力评测数据集', create_time: '2026-01-12T11:30:00Z' }, + { id: 6, name: '医疗问答-训练集', type: 'train', storage_type: 'local', size: '180 MB', count: 9800, description: '医疗问答对', create_time: '2026-02-01T15:00:00Z' }, +] + +// ============ 训练任务 ============ +export const mockFineTuneList: FineTuneTask[] = [ + { id: 1, name: 'finance-sft-001', description: '金融领域 SFT 训练', status: 'completed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 1, gpus: [0], progress: 100, train_duration: '2小时18分钟', create_time: '2026-01-15T08:00:00Z' }, + { id: 2, name: 'legal-sft-002', description: '法律文书 SFT', status: 'completed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 2, gpus: [1], progress: 100, train_duration: '1小时46分钟', create_time: '2026-01-18T10:00:00Z' }, + { id: 3, name: 'medical-cpt-001', description: '医疗领域继续预训练', status: 'running', train_type: 'CPT', train_method: 'lora', template: 'qwen2_5', base_model: 2, train_dataset_id: 6, gpus: [0, 1], progress: 64, train_duration: '36分钟', create_time: '2026-02-05T09:00:00Z' }, + { id: 4, name: 'service-dpo-001', description: '客服对话偏好训练', status: 'pending', train_type: 'DPO', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 3, gpus: [2], progress: 0, train_duration: '-', create_time: '2026-02-08T14:00:00Z' }, + { id: 5, name: 'finance-sft-002', description: '金融领域二轮微调', status: 'failed', train_type: 'SFT', train_method: 'lora', template: 'qwen', base_model: 1, train_dataset_id: 1, gpus: [3], progress: 32, train_duration: '18分钟', create_time: '2026-02-10T11:00:00Z' }, + { id: 6, name: 'general-sft-001', description: '通用能力微调', status: 'completed', train_type: 'SFT', train_method: 'full', template: 'llama3', base_model: 3, train_dataset_id: 3, gpus: [0, 2], progress: 100, train_duration: '3小时05分钟', create_time: '2026-02-12T13:00:00Z' }, +] + +// ============ 模型推理/对比 ============ +export const mockCompareList: CompareTask[] = [ + { + id: 1, + name: '金融问答对比', + model_name: '金融问答对比', + description: '对比基座模型与微调模型', + status: 'loaded', + models: JSON.stringify([ + { model_id: 1, model_name: 'Qwen2.5-7B-Instruct', model_path: '/data/models/qwen2.5-7b', gpu_id: 0, source: 'database', port: 18001 }, + { model_id: 101, model_name: 'qwen-ft-finance-001', model_path: '/data/saves/qwen-ft-finance-001-merged', gpu_id: 1, source: 'trained', port: 18002 }, + ]), + load_status: JSON.stringify({ + loaded_models: [ + { model_id: 1, model_name: 'Qwen2.5-7B-Instruct', status: 'ready', pid: 12345, port: 18001 }, + { model_id: 101, model_name: 'qwen-ft-finance-001', status: 'ready', pid: 12346, port: 18002 }, + ], + }), + create_time: '2026-02-15T10:00:00Z', + }, + { + id: 2, + name: '客服场景推理', + model_name: '客服场景推理', + description: '客服对话推理测试', + status: 'pending', + models: JSON.stringify([{ model_id: 3, model_name: 'Llama3-8B-Instruct', model_path: '/data/models/llama3-8b', gpu_id: 2, source: 'database' }]), + load_status: JSON.stringify({ loaded_models: [] }), + create_time: '2026-02-18T11:00:00Z', + }, + { + id: 3, + name: '法律文书推理', + model_name: '法律文书推理', + description: '法律文书推理测试', + status: 'loaded', + models: JSON.stringify([{ model_id: 102, model_name: 'qwen-ft-legal-002', model_path: '/data/saves/qwen-ft-legal-002-merged', gpu_id: 3, source: 'trained' }]), + load_status: JSON.stringify({ + loaded_models: [ + { model_id: 102, model_name: 'qwen-ft-legal-002', status: 'ready', pid: 12350, port: 18003 }, + ], + }), + create_time: '2026-02-20T14:00:00Z', + }, +] + +// ============ 模型评测 ============ +export const mockEvalList: EvalTask[] = [ + { id: 1, eval_task_name: '金融模型评测-v1', model_name: 'qwen-ft-finance-001', dataset: '金融评测集', metric: 'accuracy', score: 87.5, status: 'completed', create_time: '2026-02-01T10:00:00Z' }, + { id: 2, eval_task_name: '客服模型评测-v1', model_name: 'llama3-ft-customer-service', dataset: '通用能力评测', metric: 'rouge-1', score: 0.82, status: 'completed', create_time: '2026-02-05T11:00:00Z' }, + { id: 3, eval_task_name: '基线对比评测', model_name: 'Qwen2.5-7B-Instruct', dataset: '金融评测集', metric: 'accuracy', score: 72.3, status: 'running', create_time: '2026-02-10T09:00:00Z' }, +] + +export const mockDimensions: Dimension[] = [ + { id: 1, name: '回答准确性', type: 'classification', description: '评估模型回答是否准确', eval_model: 'GPT-4o', eval_method: 'standard', eval_prompt: '# 角色\n你是专业的评估专家...', is_active: true, is_default: true, create_time: '2025-12-01T08:00:00Z' }, + { id: 2, name: '综合评分', type: 'metric', description: '0-5 分综合评分', eval_model: 'Claude-3.5-Sonnet', eval_method: 'metric_standard', eval_prompt: '# 角色\n你是专业评分专家...', is_active: true, is_default: false, score_min: 0, score_max: 5, pass_threshold: 3.5, create_time: '2025-12-05T09:00:00Z' }, + { id: 3, name: '语义相似度', type: 'metric', description: '生成文本与参考答案的语义相似度', eval_model: 'GPT-4o', eval_method: 'semantic', eval_prompt: '# 角色\n你是语义相似度评估专家...', is_active: true, is_default: false, score_min: 0, score_max: 1, pass_threshold: 0.7, create_time: '2025-12-08T10:00:00Z' }, + { id: 4, name: 'BLEU-4 相似度', type: 'text_similarity', description: '使用 BLEU-4 评估文本相似度', is_active: true, is_default: false, eval_method: ['bleu_4'], bleu_n: 4, output_precision: 3, create_time: '2025-12-10T11:00:00Z' }, + { id: 5, name: 'ROUGE 多指标', type: 'text_similarity', description: 'ROUGE-1/2/4 多指标评估', is_active: false, is_default: false, eval_method: ['rouge_1', 'rouge_2', 'rouge_4'], bleu_n: 1, output_precision: 3, create_time: '2025-12-12T13:00:00Z' }, +] + +// ============ 日志 ============ +export const mockLogFiles: LogFile[] = [ + { file: 'system-2026-02-15.log', name: '系统日志-2026-02-15', size: '2.3 MB' }, + { file: 'error-2026-02-15.log', name: '错误日志-2026-02-15', size: '156 KB' }, + { file: 'system-2026-02-14.log', name: '系统日志-2026-02-14', size: '3.1 MB' }, +] + +export const mockTrainingLogFiles: TrainingLogFile[] = [ + { file: 'qwen-ft-finance-001_pid12345.log', name: 'finance-sft-001', size: '4.5 MB', pid: 12345, date: '2026-02-15' }, + { file: 'llama3-ft-customer-service_pid12346.log', name: 'service-dpo-001', size: '2.1 MB', pid: 12346, date: '2026-02-18' }, + { file: 'qwen-ft-legal-002_pid12350.log', name: 'legal-sft-002', size: '5.8 MB', pid: 12350, date: '2026-02-20' }, +] + +const fakeLogLines = [ + "[2026-02-15 08:30:12] INFO: Loading model from /data/models/qwen2.5-7b", + "[2026-02-15 08:30:13] INFO: Loading dataset finance-train-001 (8560 samples)", + "[2026-02-15 08:30:15] INFO: Training started with batch_size=8, learning_rate=2e-5", + "[2026-02-15 08:32:45] INFO: {'loss': 2.341, 'grad_norm': 1.234, 'learning_rate': 1.95e-05, 'epoch': 0.05}", + "[2026-02-15 08:34:15] INFO: {'loss': 1.892, 'grad_norm': 0.987, 'learning_rate': 1.88e-05, 'epoch': 0.10}", + "[2026-02-15 08:35:42] INFO: {'loss': 1.543, 'grad_norm': 0.876, 'learning_rate': 1.79e-05, 'epoch': 0.15}", + "[2026-02-15 08:37:10] INFO: {'loss': 1.287, 'grad_norm': 0.765, 'learning_rate': 1.70e-05, 'epoch': 0.20}", + "[2026-02-15 08:38:55] INFO: {'loss': 1.056, 'grad_norm': 0.654, 'learning_rate': 1.60e-05, 'epoch': 0.25}", + "[2026-02-15 08:40:30] WARN: Gradient norm exceeds threshold (0.654 > 0.5)", + "[2026-02-15 08:42:00] INFO: {'loss': 0.892, 'grad_norm': 0.543, 'learning_rate': 1.50e-05, 'epoch': 0.30}", + "[2026-02-15 08:45:15] INFO: Saved checkpoint to /data/checkpoints/finance-sft-001-step-100", + "[2026-02-15 08:46:30] INFO: {'loss': 0.754, 'grad_norm': 0.432, 'learning_rate': 1.40e-05, 'epoch': 0.35}", + "[2026-02-15 08:48:00] INFO: {'loss': 0.623, 'grad_norm': 0.398, 'learning_rate': 1.30e-05, 'epoch': 0.40}", + "[2026-02-15 08:50:15] INFO: {'loss': 0.512, 'grad_norm': 0.345, 'learning_rate': 1.20e-05, 'epoch': 0.45}", + "[2026-02-15 08:52:30] ERROR: Failed to save model: No space left on device", + "[2026-02-15 08:52:31] INFO: Retrying save with compressed format...", + "[2026-02-15 08:53:00] INFO: Model saved successfully (size: 14.2 GB)", + "[2026-02-15 08:55:00] INFO: {'loss': 0.421, 'grad_norm': 0.298, 'learning_rate': 1.10e-05, 'epoch': 0.50}", + "[2026-02-15 08:57:30] INFO: {'loss': 0.356, 'grad_norm': 0.256, 'learning_rate': 1.00e-05, 'epoch': 0.55}", + "[2026-02-15 09:00:00] INFO: Training completed successfully", + "", + "***** train metrics *****", + " epoch = 1.0", + " total_flos = 1234567890", + " train_loss = 0.342", + " train_runtime = 1785.2", + " train_samples_per_second = 4.79", + " train_steps_per_second = 0.60", + "***** train metrics end *****", +] + +export const mockLogContent: LogContent = { + file: 'system-2026-02-15.log', + size: '2.3 MB', + content: fakeLogLines.join('\n'), +} diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..1238ac5 --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,45 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { login as loginApi } from '@/api/modules/system' +import { SESSION_TIMEOUT } from '@/constants' + +/** + * 认证 store + * 沿用原项目 localStorage 的登录时间戳 + 5 分钟会话超时机制 + */ +export const useAuthStore = defineStore('auth', () => { + const username = ref(localStorage.getItem('username') || '') + const loginTime = ref(parseInt(localStorage.getItem('loginTime') || '0', 10) || 0) + + const isLoggedIn = computed(() => { + if (!loginTime.value) return false + return Date.now() - loginTime.value < SESSION_TIMEOUT + }) + + /** 登录 */ + async function login(user: string, password: string) { + await loginApi(user, password) + username.value = user + loginTime.value = Date.now() + localStorage.setItem('username', user) + localStorage.setItem('loginTime', String(loginTime.value)) + } + + /** 续期会话(活跃时刷新) */ + function refresh() { + if (isLoggedIn.value) { + loginTime.value = Date.now() + localStorage.setItem('loginTime', String(loginTime.value)) + } + } + + /** 退出 */ + function logout() { + username.value = '' + loginTime.value = 0 + localStorage.removeItem('username') + localStorage.removeItem('loginTime') + } + + return { username, loginTime, isLoggedIn, login, refresh, logout } +}) diff --git a/frontend/src/stores/models.ts b/frontend/src/stores/models.ts new file mode 100644 index 0000000..e86c93d --- /dev/null +++ b/frontend/src/stores/models.ts @@ -0,0 +1,34 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { getModelList } from '@/api/modules/model' +import type { ModelItem } from '@/types' + +/** + * 模型列表缓存 store + * 列表页根据 base_model id 渲染模型名时使用 + */ +export const useModelsStore = defineStore('models', () => { + const list = ref([]) + const loaded = ref(false) + + async function load(force = false) { + if (loaded.value && !force) return + try { + list.value = (await getModelList()) || [] + loaded.value = true + } catch { + list.value = [] + } + } + + /** 根据 id 获取模型名 */ + function getModelName(modelId: string | number): string { + if (!modelId) return '-' + const model = list.value.find( + (m) => m.id == modelId || m.id === String(modelId) || m.id === Number(modelId), + ) + return model ? model.name : `模型${modelId}` + } + + return { list, loaded, load, getModelName } +}) diff --git a/frontend/src/stores/system.ts b/frontend/src/stores/system.ts new file mode 100644 index 0000000..0bcb304 --- /dev/null +++ b/frontend/src/stores/system.ts @@ -0,0 +1,36 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { getHealth } from '@/api/modules/system' +import type { HealthMetrics } from '@/types' + +/** + * 顶部栏系统监控 store + * 30s 轮询 CPU/内存/磁盘使用率 + */ +export const useSystemStore = defineStore('system', () => { + const metrics = ref({}) + let timer: ReturnType | null = null + + async function fetchMetrics() { + try { + metrics.value = await getHealth() + } catch { + // 静默失败,顶部栏非关键 + } + } + + function start() { + if (timer) return + fetchMetrics() + timer = setInterval(fetchMetrics, 30000) + } + + function stop() { + if (timer) { + clearInterval(timer) + timer = null + } + } + + return { metrics, fetchMetrics, start, stop } +}) diff --git a/frontend/src/stores/tools.ts b/frontend/src/stores/tools.ts new file mode 100644 index 0000000..a225e03 --- /dev/null +++ b/frontend/src/stores/tools.ts @@ -0,0 +1,48 @@ +import { defineStore } from 'pinia' +import { ref, watch } from 'vue' +import type { CustomTool } from '@/types' + +/** + * 自定义工具 store(localStorage 持久化) + * 原 web 项目 customTools 仅存本地,无后端 + */ +export const useToolsStore = defineStore('tools', () => { + const STORAGE_KEY = 'customTools' + const tools = ref(loadFromStorage()) + + function loadFromStorage(): CustomTool[] { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]') + } catch { + return [] + } + } + + // 持久化 + watch( + tools, + (val) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(val)) + }, + { deep: true }, + ) + + function addTool(tool: CustomTool) { + tools.value.push(tool) + } + + function updateTool(id: string, data: Partial) { + const idx = tools.value.findIndex((t) => t.id === id) + if (idx !== -1) tools.value[idx] = { ...tools.value[idx], ...data } + } + + function removeTool(id: string) { + tools.value = tools.value.filter((t) => t.id !== id) + } + + function getTool(id: string): CustomTool | undefined { + return tools.value.find((t) => t.id === id) + } + + return { tools, addTool, updateTool, removeTool, getTool } +})