diff --git a/CLAUDE.md b/CLAUDE.md index 089acf2..d288bdc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ Vue 3 (Composition API) + Vite 7 + Pinia + Vue Router + Element Plus + Less + pn ## 架构概览 -这是一个 AI 绘画/视频生成的前端操作平台,通过 WebSocket 连接后端和第三方 AI 平台(RunningHub)提交生成任务并接收结果。 +AI 绘画/视频生成前端操作平台,通过 HTTP 接口对接后端和第三方 AI 平台(RunningHub),提交生成任务并轮询结果。 ### 关键目录 @@ -26,53 +26,65 @@ src/ ├── router/index.js # 路由定义 + token 验证守卫 ├── stores/ # Pinia 状态管理 │ ├── user.js # 用户认证、信息、免费次数 -│ ├── display.js # 生成历史列表、UI 状态(滚动、画布等) -│ └── param.js # 占位 store(当前为空) -├── apis/ # HTTP API 模块 -│ ├── auth/ # 登录/登出/用户信息/验证码 -│ └── display/ # 获取历史列表/收藏/删除 +│ └── display.js # 生成历史列表、UI 状态(滚动、画布等) +├── apis/ # HTTP API 模块(auth/display),通过 axios 实例调用 ├── components/ # 通用组件 │ ├── dialogBox/ # 生成参数输入面板(核心交互入口),含模型选择、比例、上传等子组件 -│ ├── virtual-scroller/# 虚拟滚动列表组件(自定义实现) -│ ├── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘) -│ └── ... -├── views/ # 页面 -│ ├── home/index.vue # 主页面容器(dialogBox + display) -│ ├── home/display/ # 历史记录展示区 -│ └── login/ # 登录页(跳转外部登录) +│ ├── virtual-scroller/# 虚拟滚动列表组件(自定义实现,reverse 模式) +│ └── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘) +├── views/ # 页面(home、login) ├── utils/ │ ├── request.js # Axios 实例,拦截器处理 token 和不同服务的 baseURL 路由 -│ ├── websocket.js # WebSocket 生成任务的核心流程(心跳、提交流程、结果处理) -│ ├── createTask.js # 根据配置构造任务 payload -│ ├── modelConfig.js # 从远程 JSON 加载模型配置,localStorage 每日缓存 -│ ├── auth.ts # token 存取工具(localStorage) -│ └── encrypt.ts # 加密工具(Base64/MD5/RSA/AES) +│ ├── websocket.js # 任务生成入口:HTTP POST 创建任务 + 20s 轮询获取结果 +│ ├── createTask.js # 调用平台适配器 Playload() 构造任务 body +│ ├── modelConfig.js # 从远程 JSON 加载 workflow 配置,localStorage 每日缓存 +│ ├── modelApi.js # 从 /suanli/v1/platforms/:code/models 获取模型列表及 UUID +│ └── auth.ts # token 存取工具(localStorage) ├── config/ -│ ├── index.js # 平台配置入口 -│ └── runninghub/ # RunningHub 平台适配器:Payload 构造和 Result 解析 -└── config/ # 项目根目录下 - └── plugins.js # Vite 插件配置(自动导入/组件注册/图标) +│ ├── index.js # 平台配置入口,导出 runninghub 适配器 +│ └── runninghub/ # RunningHub 平台适配器:Playload() 构造和 result() 解析 ``` ### 核心数据流 1. 用户在 `dialogBox` 中设置参数(模型、提示词、比例、上传图片等) -2. 点击生成 → `websocket.js:generate()` 被调用 -3. 先通过 `createTask.js` 调用 `config/runninghub` 的 `Playload()` 构造任务数据(从远程 JSON 加载 workflow 配置) -4. 建立 WebSocket 连接,经过握手协议(`please give me taskId` → `OK! Please continue.`)提交任务 -5. 任务排队中 → `displayStore.addGeneratingItem()` 在前端列表中插入 "生成中" 条目 -6. 完成后 WebSocket 关闭(code=1000 reason=success) → `getTask()` 解析结果 URL → `updateItemToSuccess()` 更新列表 +2. 点击生成 → `dialogBox:handleStart()` 组装 data(含 modelId、params、imgs、request) +3. 调用 `websocket.js:generate(data, generateData)` +4. `generate()` 内部先通过 `createTask(data)` → `runninghub.Playload()` 构造 RunningHub workflow payload 作为 body +5. 调用 `modelApi.getModelId(type, modelName)` 从 `/suanli/v1/platforms/:code/models` 查找模型 UUID(带 localStorage 每日缓存) +6. POST `/api/v1/tasks` 提交任务(`{ model_id, body, request }`),携带 `X-Session-Id` 用于预扣费 +7. 返回 task_id → `displayStore.addGeneratingItem()` 在前端列表插入"生成中"条目 +8. 每 20 秒轮询 GET `/api/v1/tasks/{task_id}`,completed 时调用 `updateItemToSuccess()` 更新列表 + +### 平台编码映射 + +| 类型 | 平台编码 | +|------|----------| +| Painting | `ai_painting_talk` | +| Video | `ai_video_talk` | ### 自动导入 -- `unplugin-auto-import` 自动导入 Vue/VRouter/Pinia API,无需在 `.vue` 文件中手动 `import { ref, computed, watch } from 'vue'` -- `unplugin-vue-components` 自动注册 `src/components/` 下的组件和 Element Plus 组件 +- `unplugin-auto-import`:自动导入 Vue/Router/Pinia API,`.vue` 中无需手动 import +- `unplugin-vue-components`:自动注册 `src/components/` 下的组件和 Element Plus 组件 - Element Plus 图标通过 `unplugin-icons` 按需加载 ### 环境变量 -有两套环境文件。`VITE_API_BASE_URL` 定义主 API 地址,请求拦截器根据 URL 前缀自动切换不同的后端服务(主服务/支付服务/AIGC 工作流服务)。`VITE_API_WORKFLOW_WS` 定义 WebSocket 地址。 +`VITE_API_BASE_URL` 定义主 API 地址(含 `/api` 后缀)。请求拦截器根据 URL 前缀自动切换后端服务: + +| 前缀 | 用途 | +|------|------| +| `/pay` | 支付服务 | +| `/api`(默认) | 主服务(含任务创建 `/api/v1/tasks`) | + +新增的 `/suanli` 接口使用 `VITE_API_BASE_URL` 去掉 `/api` 后缀作为基础 URL。 ### 路由守卫 `src/router/index.js` 的 `beforeEach` 守卫检查 token 存在性和有效性(调用 `/auth/check/token`),无效则跳转 `/login`。支持通过 URL query `?token=xxx` 传入 token。 + +### Authorization 头 + +- 通过 axios 的请求(auth/display 等):拦截器自动加 `Bearer` 前缀 +- 通过 fetch 的请求(任务创建/轮询/模型列表):直接传 token,**不加** `Bearer` 前缀 diff --git a/src/components/canvas/index.vue b/src/components/canvas/index.vue index 8f218d2..794f742 100644 --- a/src/components/canvas/index.vue +++ b/src/components/canvas/index.vue @@ -163,6 +163,7 @@ import { generate } from '@/utils/websocket' import { useDisplayStore, useUserStore } from '@/stores' import request from '@/utils/request' +import { getModelId } from '@/utils/modelApi' const props = defineProps({ visible: { @@ -725,6 +726,8 @@ const handleSend = async () => { const proportion = getImageAspectRatio() + const modelId = await getModelId(props.type, 'GPT') + const generateData = { model: 'GPT-image2.0', modelType: 'edit', @@ -737,6 +740,7 @@ const handleSend = async () => { AIGC: 'Painting', platform: 'runninghub', modelName: 'GPT', + modelId, quantity: 1, free: useUserStore().freeTimes, params: [ @@ -745,7 +749,7 @@ const handleSend = async () => { { name: 'proportion', data: proportion?.aspectRatio || '4:3' }, ], imgs: uploadedImgs, - result: JSON.stringify(generateData) + request: JSON.stringify(generateData) } emit('send', { diff --git a/src/components/dialogBox/index.vue b/src/components/dialogBox/index.vue index f86e29c..0f2503f 100644 --- a/src/components/dialogBox/index.vue +++ b/src/components/dialogBox/index.vue @@ -87,6 +87,7 @@ import { useDisplayStore, useUserStore } from '@/stores' import { generate } from '@/utils/websocket' import { useRouter } from 'vue-router' import { fetchModelConfig } from '@/utils/modelConfig' +import { getModelId } from '@/utils/modelApi' const props = defineProps({ isGenerate: { @@ -223,12 +224,15 @@ const handleStart = async () => { videoPattern: videoPattern.value } - const data = { + const modelId = await getModelId(currentType, model.value) + + const data = { type: currentType, modelType: currentModelType, AIGC: currentType, platform: 'runninghub', modelName: model.value, + modelId: modelId || modelDisplayConfig.value?.modelId || '', quantity: quantity.value, free: useUser.freeTimes, params: [ @@ -239,7 +243,7 @@ const handleStart = async () => { { name: 'duration', data: duration.value} ], imgs, - result: JSON.stringify(generateData) + request: JSON.stringify(generateData) } await generate(data, generateData) console.log('生成中', isgerenate.value) diff --git a/src/utils/createTask.js b/src/utils/createTask.js index c51d322..d80114f 100644 --- a/src/utils/createTask.js +++ b/src/utils/createTask.js @@ -1,22 +1,11 @@ import outPlatform from '@/config/index' // 处理音频生成任务的数据并返回 -export async function createTask(data, taskId, token) { +export async function createTask(data) { console.log(data) const payload = await outPlatform[data.platform].Playload(data) - return { - AIGC: data.AIGC, - platform: data.platform, - taskType: data.modelType === 'text' ? 1 : 2, - modelName: data.modelName, - payload, - taskId, - token, - quantity: data.quantity, - free: data.free, - result: data.result - } + return payload } // 获取结果 diff --git a/src/utils/modelApi.js b/src/utils/modelApi.js new file mode 100644 index 0000000..c5141a7 --- /dev/null +++ b/src/utils/modelApi.js @@ -0,0 +1,119 @@ +import { getToken } from '@/utils/auth' + +const CACHE_PREFIX = 'platform_models_' + +function getTodayDateString() { + const today = new Date() + return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}` +} + +function getCacheKey(code) { + return `${CACHE_PREFIX}${code}` +} + +function getFromCache(code) { + try { + const key = getCacheKey(code) + const stored = localStorage.getItem(key) + if (!stored) return null + + const data = JSON.parse(stored) + if (data.storageDate !== getTodayDateString()) { + localStorage.removeItem(key) + return null + } + return data.models + } catch { + return null + } +} + +function saveToCache(code, models) { + try { + const key = getCacheKey(code) + localStorage.setItem(key, JSON.stringify({ + models, + storageDate: getTodayDateString(), + timestamp: Date.now() + })) + } catch (error) { + console.error('保存模型列表缓存失败:', error) + } +} + +// 类型 → 平台编码映射 +export function getPlatformCode(type) { + switch (type) { + case 'Painting': + return 'ai_painting_talk' + case 'Video': + return 'ai_video_talk' + default: + return 'ai_painting_talk' + } +} + +// suanli 接口的基础 URL(不带 /api 后缀) +function getSuanliBaseUrl() { + const apiBase = import.meta.env.VITE_API_BASE_URL || '' + return apiBase.replace(/\/api$/, '') +} + +// 获取平台模型列表 +export async function fetchPlatformModels(code) { + const cached = getFromCache(code) + if (cached) { + console.log(`从缓存加载平台模型列表: ${code}`) + return cached + } + + try { + const token = getToken() + const baseUrl = getSuanliBaseUrl() + const url = `${baseUrl}/suanli/v1/platforms/${code}/models` + + console.log(`从远程获取平台模型列表: ${url}`) + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': token + } + }) + + const result = await response.json() + + if (result.code === 0 && result.data?.models) { + saveToCache(code, result.data.models) + return result.data.models + } + + console.error('获取模型列表失败:', result.message) + return [] + } catch (error) { + console.error('获取模型列表失败:', error) + return [] + } +} + +// 根据模型名称查找 model_id +export async function getModelId(type, modelName) { + if (!modelName) return '' + + const code = getPlatformCode(type) + const models = await fetchPlatformModels(code) + + const found = models.find(m => m.name === modelName) + return found?.id || '' +} + +// 清除所有平台模型缓存 +export function clearPlatformModelCache() { + const keysToRemove = [] + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key && key.startsWith(CACHE_PREFIX)) { + keysToRemove.push(key) + } + } + keysToRemove.forEach(key => localStorage.removeItem(key)) +} diff --git a/src/utils/websocket.js b/src/utils/websocket.js index 1fffe5a..cfe92fa 100644 --- a/src/utils/websocket.js +++ b/src/utils/websocket.js @@ -1,8 +1,8 @@ import { ElNotification } from 'element-plus' -import { h, ref } from 'vue' +import { h } from 'vue' import { useDisplayStore, useUserStore } from '@/stores' import { getToken } from '@/utils/auth' -import { createTask, getTask } from '@/utils/createTask' +import { createTask } from '@/utils/createTask' import { userError } from '@/utils/tokenError' export function getChargeType(chargeType) { @@ -23,13 +23,13 @@ export function websocketError(code, msg) { message = '用户身份验证失败' userError() break - case 4401: // 后端返回常规错误 + case 4401: message = msg break - case 4402: // 后端返回外部平台提交时的错误 + case 4402: message = JSON.parse(msg) break - case 4403: // 外部平台的任务结果的错误 + case 4403: message = msg break default: @@ -38,15 +38,13 @@ export function websocketError(code, msg) { ElNotification({ title: '生成失败', - message: h('i', { style: 'color: teal' }, message), type: 'error', - duration: 6000 // 增加持续时间以适应更多信息 + duration: 6000 }) } export function websocketSuccess() { - // 合并两个通知为一个 ElNotification({ title: '生成成功', message: h('div', [ @@ -55,135 +53,151 @@ export function websocketSuccess() { h('div', { style: 'color: orange; margin-top: 5px;' }, '内测状态,请及时下载生成的文件,云端储存与历史记录保留24小时!') ]), type: 'success', - duration: 6000 // 增加持续时间以适应更多信息 + duration: 6000 }) } + +// 当前活跃的轮询定时器集合,用于页面卸载时清理 +const activePollIntervals = new Set() + export async function generate(data, generateData) { - const progress_text = ref('') - const message = ref('') const useDisplay = useDisplayStore() const token = getToken() - const taskId = crypto.randomUUID() - let currentTaskId = null - + const baseUrl = import.meta.env.VITE_API_BASE_URL + let taskId = null + let pollInterval = null + + if (!data.modelId) { + ElNotification({ + title: '生成失败', + message: h('i', { style: 'color: teal' }, '未找到模型ID,请联系管理员配置'), + type: 'error' + }) + return + } + useDisplay.isSubGerenate = true - const result = await createTask(data, taskId, token) - console.log(result) - // const token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJsb2dpblR5cGUiOiJsb2dpbiIsImxvZ2luSWQiOjY0NDEwODAyMjk1OTgzNzIzMCwicm5TdHIiOiJiWkVwS2JLWFJyZmRIaFFHWXZKTkdzOGdGM0JSRmxQOCJ9.5eQ2GtVdrDntQDe2tnF8vl_DhTfd2uW-KNqzvl1imc0' - const wsURL = `${import.meta.env.VITE_API_WORKFLOW_WS}/?token=Bearer ${token}` - const socket = new WebSocket(wsURL) - console.log('WebSocket连接已建立') - - // 心跳机制相关变量 - let heartbeatInterval = null - const heartbeatIntervalTime = 20000 // 30秒发送一次心跳 + // 会话 ID,用于创建任务时的预扣费标识 + const sessionId = crypto.randomUUID() try { - // 接收服务器消息 - socket.onmessage = async (event) => { - // 处理pong响应 - if (event.data === 'pong') { - console.log('收到心跳响应') - return - } else if (event.data === 'please give me taskId') { - socket.send(`setTaskId:${taskId}`) - progress_text.value = '信息提交中...' - return - } else if (event.data === 'OK! Please continue. ') { - socket.send(JSON.stringify({ - type: 'generate', - data: result - })) - return - } else if (event.data === '任务提交成功,正在排队中...') { - progress_text.value = '视频生成中...' - currentTaskId = taskId - - useDisplay.addGeneratingItem({ - taskId: taskId, - type: data.type, - generateData: generateData - }) - setTimeout(() => { - useDisplay.scrollToBottom() - }, 100) - return - } - message.value = event.data + // 通过 createTask 获取 body 内容(RunningHub workflow payload) + const body = await createTask(data) + + // 构造请求体 + const requestBody = { + model_id: data.modelId, + body, + request: data.request } - // 处理链接错误 - socket.onerror = (error) => { - console.error('WebSocket链接出错:', error) + // POST 创建任务 + const createResponse = await fetch(`${baseUrl}/v1/tasks`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': token, + 'X-Session-Id': sessionId + }, + body: JSON.stringify(requestBody) + }) - // 清理心跳定时器 - if (heartbeatInterval) { - clearInterval(heartbeatInterval) - } + const createResult = await createResponse.json() - // eslint-disable-next-line no-undef + if (createResult.code !== 0) { ElNotification({ - title: '生成通知', - // eslint-disable-next-line no-undef - message: h('i', { style: 'color: teal' }, '生成视频失败'), + title: '生成失败', + message: h('i', { style: 'color: teal' }, createResult.message || '任务创建失败'), type: 'error' }) - } - - // 处理链接关闭 - socket.onclose = async (event) => { - console.log('WebSocket已关闭:', event) useDisplay.isSubGerenate = false - if (heartbeatInterval) { - clearInterval(heartbeatInterval) - } - const res = JSON.parse(message.value) - if (event.code === 1006) { - console.error('用户身份验证失败') - userError() - } else if (event.code === 1000 && event.reason === 'success') { - console.log('收到服务器消息:', res) - const result = await getTask(res) - if(useUserStore().freeTimes) await useUserStore().fetchFreeTimes() - if (result.type) { - if (currentTaskId) { - useDisplay.updateItemToSuccess(currentTaskId, result.urls) + return + } + + taskId = createResult.data.task_id + + // 在列表中插入"生成中"条目 + useDisplay.addGeneratingItem({ + taskId, + type: data.type, + generateData + }) + setTimeout(() => { + useDisplay.scrollToBottom() + }, 100) + + // 轮询任务状态 + const pollTask = async () => { + try { + const pollResponse = await fetch(`${baseUrl}/v1/tasks/${taskId}`, { + method: 'GET', + headers: { + 'Authorization': token } - - websocketSuccess() - } else { - websocketError(4403, result.message) + }) + + const pollResult = await pollResponse.json() + + if (pollResult.code !== 0) return + + const taskData = pollResult.data + + if (taskData.status === 'completed') { + clearInterval(pollInterval) + activePollIntervals.delete(pollInterval) + useDisplay.isSubGerenate = false + + // 提取结果 URL + const urls = taskData.outputs?.images?.map(img => img.url) || [] + if (urls.length > 0) { + useDisplay.updateItemToSuccess(taskId, urls) + if (useUserStore().freeTimes) await useUserStore().fetchFreeTimes() + websocketSuccess() + } else { + websocketError(4403, '未获取到生成结果') + } + } else if (taskData.status === 'failed') { + clearInterval(pollInterval) + activePollIntervals.delete(pollInterval) + useDisplay.isSubGerenate = false + websocketError(4403, taskData.vendor_error || '生成失败') } - } else { - websocketError(event.code, event.reason) - } - if (heartbeatInterval) { - clearInterval(heartbeatInterval) + // queued / processing 状态继续轮询 + } catch (error) { + console.error('轮询任务状态失败:', error) } } - // 等待 WebSocket 连接打开 - socket.onopen = () => { - console.log('WebSocket连接已建立') + // 每 20 秒轮询一次 + pollInterval = setInterval(pollTask, 20000) + activePollIntervals.add(pollInterval) + + // 5 秒后先做第一次轮询 + setTimeout(pollTask, 5000) - // 启动心跳机制 - heartbeatInterval = setInterval(() => { - if (socket.readyState === WebSocket.OPEN) { - socket.send('ping') - console.log('发送心跳包') - } - }, heartbeatIntervalTime) - } } catch (error) { - console.log('Error creating AI3D_file:', error) - // eslint-disable-next-line no-undef + console.error('创建任务失败:', error) + useDisplay.isSubGerenate = false ElNotification({ title: '生成通知', - // eslint-disable-next-line no-undef message: h('i', { style: 'color: teal' }, '生成失败,请检查参数后重新提交任务'), type: 'error' }) + if (pollInterval) { + clearInterval(pollInterval) + activePollIntervals.delete(pollInterval) + } } } + +// 页面卸载时清理所有轮询 +if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => { + for (const interval of activePollIntervals) { + clearInterval(interval) + } + activePollIntervals.clear() + }) +}