重构任务提交为 HTTP 接口,替换 WebSocket 方案
- POST /api/v1/tasks 创建任务,每 20 秒轮询 GET /api/v1/tasks/{id} 获取结果
- 新增 modelApi.js 通过 /suanli/v1/platforms/:code/models 获取模型 UUID
- dialogBox/canvas 集成 getModelId 查找,result 字段改为 request
- createTask 精简为仅返回 Playload,供 body 使用
- 更新 CLAUDE.md 反映新架构
This commit is contained in:
parent
6e67acca66
commit
72267ab2c9
72
CLAUDE.md
72
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 验证守卫
|
├── router/index.js # 路由定义 + token 验证守卫
|
||||||
├── stores/ # Pinia 状态管理
|
├── stores/ # Pinia 状态管理
|
||||||
│ ├── user.js # 用户认证、信息、免费次数
|
│ ├── user.js # 用户认证、信息、免费次数
|
||||||
│ ├── display.js # 生成历史列表、UI 状态(滚动、画布等)
|
│ └── display.js # 生成历史列表、UI 状态(滚动、画布等)
|
||||||
│ └── param.js # 占位 store(当前为空)
|
├── apis/ # HTTP API 模块(auth/display),通过 axios 实例调用
|
||||||
├── apis/ # HTTP API 模块
|
|
||||||
│ ├── auth/ # 登录/登出/用户信息/验证码
|
|
||||||
│ └── display/ # 获取历史列表/收藏/删除
|
|
||||||
├── components/ # 通用组件
|
├── components/ # 通用组件
|
||||||
│ ├── dialogBox/ # 生成参数输入面板(核心交互入口),含模型选择、比例、上传等子组件
|
│ ├── dialogBox/ # 生成参数输入面板(核心交互入口),含模型选择、比例、上传等子组件
|
||||||
│ ├── virtual-scroller/# 虚拟滚动列表组件(自定义实现)
|
│ ├── virtual-scroller/# 虚拟滚动列表组件(自定义实现,reverse 模式)
|
||||||
│ ├── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘)
|
│ └── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘)
|
||||||
│ └── ...
|
├── views/ # 页面(home、login)
|
||||||
├── views/ # 页面
|
|
||||||
│ ├── home/index.vue # 主页面容器(dialogBox + display)
|
|
||||||
│ ├── home/display/ # 历史记录展示区
|
|
||||||
│ └── login/ # 登录页(跳转外部登录)
|
|
||||||
├── utils/
|
├── utils/
|
||||||
│ ├── request.js # Axios 实例,拦截器处理 token 和不同服务的 baseURL 路由
|
│ ├── request.js # Axios 实例,拦截器处理 token 和不同服务的 baseURL 路由
|
||||||
│ ├── websocket.js # WebSocket 生成任务的核心流程(心跳、提交流程、结果处理)
|
│ ├── websocket.js # 任务生成入口:HTTP POST 创建任务 + 20s 轮询获取结果
|
||||||
│ ├── createTask.js # 根据配置构造任务 payload
|
│ ├── createTask.js # 调用平台适配器 Playload() 构造任务 body
|
||||||
│ ├── modelConfig.js # 从远程 JSON 加载模型配置,localStorage 每日缓存
|
│ ├── modelConfig.js # 从远程 JSON 加载 workflow 配置,localStorage 每日缓存
|
||||||
│ ├── auth.ts # token 存取工具(localStorage)
|
│ ├── modelApi.js # 从 /suanli/v1/platforms/:code/models 获取模型列表及 UUID
|
||||||
│ └── encrypt.ts # 加密工具(Base64/MD5/RSA/AES)
|
│ └── auth.ts # token 存取工具(localStorage)
|
||||||
├── config/
|
├── config/
|
||||||
│ ├── index.js # 平台配置入口
|
│ ├── index.js # 平台配置入口,导出 runninghub 适配器
|
||||||
│ └── runninghub/ # RunningHub 平台适配器:Payload 构造和 Result 解析
|
│ └── runninghub/ # RunningHub 平台适配器:Playload() 构造和 result() 解析
|
||||||
└── config/ # 项目根目录下
|
|
||||||
└── plugins.js # Vite 插件配置(自动导入/组件注册/图标)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 核心数据流
|
### 核心数据流
|
||||||
|
|
||||||
1. 用户在 `dialogBox` 中设置参数(模型、提示词、比例、上传图片等)
|
1. 用户在 `dialogBox` 中设置参数(模型、提示词、比例、上传图片等)
|
||||||
2. 点击生成 → `websocket.js:generate()` 被调用
|
2. 点击生成 → `dialogBox:handleStart()` 组装 data(含 modelId、params、imgs、request)
|
||||||
3. 先通过 `createTask.js` 调用 `config/runninghub` 的 `Playload()` 构造任务数据(从远程 JSON 加载 workflow 配置)
|
3. 调用 `websocket.js:generate(data, generateData)`
|
||||||
4. 建立 WebSocket 连接,经过握手协议(`please give me taskId` → `OK! Please continue.`)提交任务
|
4. `generate()` 内部先通过 `createTask(data)` → `runninghub.Playload()` 构造 RunningHub workflow payload 作为 body
|
||||||
5. 任务排队中 → `displayStore.addGeneratingItem()` 在前端列表中插入 "生成中" 条目
|
5. 调用 `modelApi.getModelId(type, modelName)` 从 `/suanli/v1/platforms/:code/models` 查找模型 UUID(带 localStorage 每日缓存)
|
||||||
6. 完成后 WebSocket 关闭(code=1000 reason=success) → `getTask()` 解析结果 URL → `updateItemToSuccess()` 更新列表
|
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-auto-import`:自动导入 Vue/Router/Pinia API,`.vue` 中无需手动 import
|
||||||
- `unplugin-vue-components` 自动注册 `src/components/` 下的组件和 Element Plus 组件
|
- `unplugin-vue-components`:自动注册 `src/components/` 下的组件和 Element Plus 组件
|
||||||
- Element Plus 图标通过 `unplugin-icons` 按需加载
|
- 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。
|
`src/router/index.js` 的 `beforeEach` 守卫检查 token 存在性和有效性(调用 `/auth/check/token`),无效则跳转 `/login`。支持通过 URL query `?token=xxx` 传入 token。
|
||||||
|
|
||||||
|
### Authorization 头
|
||||||
|
|
||||||
|
- 通过 axios 的请求(auth/display 等):拦截器自动加 `Bearer` 前缀
|
||||||
|
- 通过 fetch 的请求(任务创建/轮询/模型列表):直接传 token,**不加** `Bearer` 前缀
|
||||||
|
|||||||
@ -163,6 +163,7 @@
|
|||||||
import { generate } from '@/utils/websocket'
|
import { generate } from '@/utils/websocket'
|
||||||
import { useDisplayStore, useUserStore } from '@/stores'
|
import { useDisplayStore, useUserStore } from '@/stores'
|
||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
|
import { getModelId } from '@/utils/modelApi'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: {
|
visible: {
|
||||||
@ -725,6 +726,8 @@ const handleSend = async () => {
|
|||||||
|
|
||||||
const proportion = getImageAspectRatio()
|
const proportion = getImageAspectRatio()
|
||||||
|
|
||||||
|
const modelId = await getModelId(props.type, 'GPT')
|
||||||
|
|
||||||
const generateData = {
|
const generateData = {
|
||||||
model: 'GPT-image2.0',
|
model: 'GPT-image2.0',
|
||||||
modelType: 'edit',
|
modelType: 'edit',
|
||||||
@ -737,6 +740,7 @@ const handleSend = async () => {
|
|||||||
AIGC: 'Painting',
|
AIGC: 'Painting',
|
||||||
platform: 'runninghub',
|
platform: 'runninghub',
|
||||||
modelName: 'GPT',
|
modelName: 'GPT',
|
||||||
|
modelId,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
free: useUserStore().freeTimes,
|
free: useUserStore().freeTimes,
|
||||||
params: [
|
params: [
|
||||||
@ -745,7 +749,7 @@ const handleSend = async () => {
|
|||||||
{ name: 'proportion', data: proportion?.aspectRatio || '4:3' },
|
{ name: 'proportion', data: proportion?.aspectRatio || '4:3' },
|
||||||
],
|
],
|
||||||
imgs: uploadedImgs,
|
imgs: uploadedImgs,
|
||||||
result: JSON.stringify(generateData)
|
request: JSON.stringify(generateData)
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('send', {
|
emit('send', {
|
||||||
|
|||||||
@ -87,6 +87,7 @@ import { useDisplayStore, useUserStore } from '@/stores'
|
|||||||
import { generate } from '@/utils/websocket'
|
import { generate } from '@/utils/websocket'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { fetchModelConfig } from '@/utils/modelConfig'
|
import { fetchModelConfig } from '@/utils/modelConfig'
|
||||||
|
import { getModelId } from '@/utils/modelApi'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
isGenerate: {
|
isGenerate: {
|
||||||
@ -223,12 +224,15 @@ const handleStart = async () => {
|
|||||||
videoPattern: videoPattern.value
|
videoPattern: videoPattern.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modelId = await getModelId(currentType, model.value)
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
type: currentType,
|
type: currentType,
|
||||||
modelType: currentModelType,
|
modelType: currentModelType,
|
||||||
AIGC: currentType,
|
AIGC: currentType,
|
||||||
platform: 'runninghub',
|
platform: 'runninghub',
|
||||||
modelName: model.value,
|
modelName: model.value,
|
||||||
|
modelId: modelId || modelDisplayConfig.value?.modelId || '',
|
||||||
quantity: quantity.value,
|
quantity: quantity.value,
|
||||||
free: useUser.freeTimes,
|
free: useUser.freeTimes,
|
||||||
params: [
|
params: [
|
||||||
@ -239,7 +243,7 @@ const handleStart = async () => {
|
|||||||
{ name: 'duration', data: duration.value}
|
{ name: 'duration', data: duration.value}
|
||||||
],
|
],
|
||||||
imgs,
|
imgs,
|
||||||
result: JSON.stringify(generateData)
|
request: JSON.stringify(generateData)
|
||||||
}
|
}
|
||||||
await generate(data, generateData)
|
await generate(data, generateData)
|
||||||
console.log('生成中', isgerenate.value)
|
console.log('生成中', isgerenate.value)
|
||||||
|
|||||||
@ -1,22 +1,11 @@
|
|||||||
import outPlatform from '@/config/index'
|
import outPlatform from '@/config/index'
|
||||||
|
|
||||||
// 处理音频生成任务的数据并返回
|
// 处理音频生成任务的数据并返回
|
||||||
export async function createTask(data, taskId, token) {
|
export async function createTask(data) {
|
||||||
console.log(data)
|
console.log(data)
|
||||||
const payload = await outPlatform[data.platform].Playload(data)
|
const payload = await outPlatform[data.platform].Playload(data)
|
||||||
|
|
||||||
return {
|
return payload
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取结果
|
// 获取结果
|
||||||
|
|||||||
119
src/utils/modelApi.js
Normal file
119
src/utils/modelApi.js
Normal file
@ -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))
|
||||||
|
}
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import { ElNotification } from 'element-plus'
|
import { ElNotification } from 'element-plus'
|
||||||
import { h, ref } from 'vue'
|
import { h } from 'vue'
|
||||||
import { useDisplayStore, useUserStore } from '@/stores'
|
import { useDisplayStore, useUserStore } from '@/stores'
|
||||||
import { getToken } from '@/utils/auth'
|
import { getToken } from '@/utils/auth'
|
||||||
import { createTask, getTask } from '@/utils/createTask'
|
import { createTask } from '@/utils/createTask'
|
||||||
import { userError } from '@/utils/tokenError'
|
import { userError } from '@/utils/tokenError'
|
||||||
|
|
||||||
export function getChargeType(chargeType) {
|
export function getChargeType(chargeType) {
|
||||||
@ -23,13 +23,13 @@ export function websocketError(code, msg) {
|
|||||||
message = '用户身份验证失败'
|
message = '用户身份验证失败'
|
||||||
userError()
|
userError()
|
||||||
break
|
break
|
||||||
case 4401: // 后端返回常规错误
|
case 4401:
|
||||||
message = msg
|
message = msg
|
||||||
break
|
break
|
||||||
case 4402: // 后端返回外部平台提交时的错误
|
case 4402:
|
||||||
message = JSON.parse(msg)
|
message = JSON.parse(msg)
|
||||||
break
|
break
|
||||||
case 4403: // 外部平台的任务结果的错误
|
case 4403:
|
||||||
message = msg
|
message = msg
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
@ -38,15 +38,13 @@ export function websocketError(code, msg) {
|
|||||||
|
|
||||||
ElNotification({
|
ElNotification({
|
||||||
title: '生成失败',
|
title: '生成失败',
|
||||||
|
|
||||||
message: h('i', { style: 'color: teal' }, message),
|
message: h('i', { style: 'color: teal' }, message),
|
||||||
type: 'error',
|
type: 'error',
|
||||||
duration: 6000 // 增加持续时间以适应更多信息
|
duration: 6000
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function websocketSuccess() {
|
export function websocketSuccess() {
|
||||||
// 合并两个通知为一个
|
|
||||||
ElNotification({
|
ElNotification({
|
||||||
title: '生成成功',
|
title: '生成成功',
|
||||||
message: h('div', [
|
message: h('div', [
|
||||||
@ -55,135 +53,151 @@ export function websocketSuccess() {
|
|||||||
h('div', { style: 'color: orange; margin-top: 5px;' }, '内测状态,请及时下载生成的文件,云端储存与历史记录保留24小时!')
|
h('div', { style: 'color: orange; margin-top: 5px;' }, '内测状态,请及时下载生成的文件,云端储存与历史记录保留24小时!')
|
||||||
]),
|
]),
|
||||||
type: 'success',
|
type: 'success',
|
||||||
duration: 6000 // 增加持续时间以适应更多信息
|
duration: 6000
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 当前活跃的轮询定时器集合,用于页面卸载时清理
|
||||||
|
const activePollIntervals = new Set()
|
||||||
|
|
||||||
export async function generate(data, generateData) {
|
export async function generate(data, generateData) {
|
||||||
const progress_text = ref('')
|
|
||||||
const message = ref('')
|
|
||||||
const useDisplay = useDisplayStore()
|
const useDisplay = useDisplayStore()
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
const taskId = crypto.randomUUID()
|
const baseUrl = import.meta.env.VITE_API_BASE_URL
|
||||||
let currentTaskId = null
|
let taskId = null
|
||||||
|
let pollInterval = null
|
||||||
|
|
||||||
|
if (!data.modelId) {
|
||||||
|
ElNotification({
|
||||||
|
title: '生成失败',
|
||||||
|
message: h('i', { style: 'color: teal' }, '未找到模型ID,请联系管理员配置'),
|
||||||
|
type: 'error'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
useDisplay.isSubGerenate = true
|
useDisplay.isSubGerenate = true
|
||||||
|
|
||||||
const result = await createTask(data, taskId, token)
|
// 会话 ID,用于创建任务时的预扣费标识
|
||||||
console.log(result)
|
const sessionId = crypto.randomUUID()
|
||||||
// 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秒发送一次心跳
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 接收服务器消息
|
// 通过 createTask 获取 body 内容(RunningHub workflow payload)
|
||||||
socket.onmessage = async (event) => {
|
const body = await createTask(data)
|
||||||
// 处理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,
|
const requestBody = {
|
||||||
type: data.type,
|
model_id: data.modelId,
|
||||||
generateData: generateData
|
body,
|
||||||
})
|
request: data.request
|
||||||
setTimeout(() => {
|
|
||||||
useDisplay.scrollToBottom()
|
|
||||||
}, 100)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
message.value = event.data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理链接错误
|
// POST 创建任务
|
||||||
socket.onerror = (error) => {
|
const createResponse = await fetch(`${baseUrl}/v1/tasks`, {
|
||||||
console.error('WebSocket链接出错:', error)
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': token,
|
||||||
|
'X-Session-Id': sessionId
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
})
|
||||||
|
|
||||||
// 清理心跳定时器
|
const createResult = await createResponse.json()
|
||||||
if (heartbeatInterval) {
|
|
||||||
clearInterval(heartbeatInterval)
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-undef
|
if (createResult.code !== 0) {
|
||||||
ElNotification({
|
ElNotification({
|
||||||
title: '生成通知',
|
title: '生成失败',
|
||||||
// eslint-disable-next-line no-undef
|
message: h('i', { style: 'color: teal' }, createResult.message || '任务创建失败'),
|
||||||
message: h('i', { style: 'color: teal' }, '生成视频失败'),
|
|
||||||
type: 'error'
|
type: 'error'
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
// 处理链接关闭
|
|
||||||
socket.onclose = async (event) => {
|
|
||||||
console.log('WebSocket已关闭:', event)
|
|
||||||
useDisplay.isSubGerenate = false
|
useDisplay.isSubGerenate = false
|
||||||
if (heartbeatInterval) {
|
return
|
||||||
clearInterval(heartbeatInterval)
|
}
|
||||||
}
|
|
||||||
const res = JSON.parse(message.value)
|
taskId = createResult.data.task_id
|
||||||
if (event.code === 1006) {
|
|
||||||
console.error('用户身份验证失败')
|
// 在列表中插入"生成中"条目
|
||||||
userError()
|
useDisplay.addGeneratingItem({
|
||||||
} else if (event.code === 1000 && event.reason === 'success') {
|
taskId,
|
||||||
console.log('收到服务器消息:', res)
|
type: data.type,
|
||||||
const result = await getTask(res)
|
generateData
|
||||||
if(useUserStore().freeTimes) await useUserStore().fetchFreeTimes()
|
})
|
||||||
if (result.type) {
|
setTimeout(() => {
|
||||||
if (currentTaskId) {
|
useDisplay.scrollToBottom()
|
||||||
useDisplay.updateItemToSuccess(currentTaskId, result.urls)
|
}, 100)
|
||||||
|
|
||||||
|
// 轮询任务状态
|
||||||
|
const pollTask = async () => {
|
||||||
|
try {
|
||||||
|
const pollResponse = await fetch(`${baseUrl}/v1/tasks/${taskId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': token
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
websocketSuccess()
|
const pollResult = await pollResponse.json()
|
||||||
} else {
|
|
||||||
websocketError(4403, result.message)
|
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 {
|
// queued / processing 状态继续轮询
|
||||||
websocketError(event.code, event.reason)
|
} catch (error) {
|
||||||
}
|
console.error('轮询任务状态失败:', error)
|
||||||
if (heartbeatInterval) {
|
|
||||||
clearInterval(heartbeatInterval)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 等待 WebSocket 连接打开
|
// 每 20 秒轮询一次
|
||||||
socket.onopen = () => {
|
pollInterval = setInterval(pollTask, 20000)
|
||||||
console.log('WebSocket连接已建立')
|
activePollIntervals.add(pollInterval)
|
||||||
|
|
||||||
|
// 5 秒后先做第一次轮询
|
||||||
|
setTimeout(pollTask, 5000)
|
||||||
|
|
||||||
// 启动心跳机制
|
|
||||||
heartbeatInterval = setInterval(() => {
|
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
|
||||||
socket.send('ping')
|
|
||||||
console.log('发送心跳包')
|
|
||||||
}
|
|
||||||
}, heartbeatIntervalTime)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Error creating AI3D_file:', error)
|
console.error('创建任务失败:', error)
|
||||||
// eslint-disable-next-line no-undef
|
useDisplay.isSubGerenate = false
|
||||||
ElNotification({
|
ElNotification({
|
||||||
title: '生成通知',
|
title: '生成通知',
|
||||||
// eslint-disable-next-line no-undef
|
|
||||||
message: h('i', { style: 'color: teal' }, '生成失败,请检查参数后重新提交任务'),
|
message: h('i', { style: 'color: teal' }, '生成失败,请检查参数后重新提交任务'),
|
||||||
type: 'error'
|
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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user