feat(model):新增MiniMax-H3视频支持

This commit is contained in:
qian committed 2026-09-13 22:14:21 +08:00
1 parent 26c53c2228
commit 5ce16daa46
29 files changed
+3736 -98

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
// 模型研发厂商共享常量(管理后台模型管理列表/筛选/表单共用)
// value 与后端 mdl_models.vendor 枚举一一对应(api/admin/v1/model.go 的 v:"in:" 校验、
// internal/logic/admin/model.go 的 validModelVendors)。
// 新增厂商时需同步:后端枚举校验、租户端 web/tenant/src/constants/vendor.ts、本文件。
// 注意与渠道供应商类型(constants/channel.ts,chn_channels.type)是两个维度:厂商 = 模型的研发公司,
// 例如 Bedrock 上的 Claude 厂商仍是 anthropic。
export const vendorOptions: { label: string; value: string }[] = [
{ label: 'OpenAI', value: 'openai' },
{ label: 'Anthropic', value: 'anthropic' },
{ label: 'Google', value: 'google' },
{ label: 'xAI', value: 'xai' },
{ label: 'Mistral', value: 'mistral' },
{ label: 'Cohere', value: 'cohere' },
{ label: 'Meta', value: 'meta' },
{ label: '阿里巴巴', value: 'alibaba' },
{ label: '字节跳动', value: 'bytedance' },
{ label: '深度求索', value: 'deepseek' },
{ label: '智谱AI', value: 'zhipu' },
{ label: '月之暗面', value: 'moonshot' },
{ label: 'MiniMax', value: 'minimax' },
{ label: '百度', value: 'baidu' },
{ label: '腾讯', value: 'tencent' },
{ label: '科大讯飞', value: 'xunfei' },
{ label: '快手', value: 'kuaishou' },
{ label: 'Midjourney', value: 'midjourney' },
{ label: 'Suno', value: 'suno' },
]
// value -> label 映射,表格列展示用
export const vendorLabelMap: Record<string, string> = {}
vendorOptions.forEach(o => { vendorLabelMap[o.value] = o.label })
// value -> Arco Tag 预设色映射(淡底深字,取接近品牌色的预设)
export const vendorTagColor: Record<string, string> = {
openai: 'green',
anthropic: 'orange',
google: 'arcoblue',
xai: 'gray',
mistral: 'orangered',
cohere: 'cyan',
meta: 'blue',
alibaba: 'purple', // 图标用通义千问 mark(品牌紫),Tag 色随之取紫
bytedance: 'arcoblue',
deepseek: 'blue',
zhipu: 'purple',
moonshot: 'gray',
minimax: 'red',
baidu: 'blue',
tencent: 'cyan',
xunfei: 'arcoblue',
kuaishou: 'orangered',
midjourney: 'gray',
suno: 'purple',
}
// Arco Select 的 allow-search 自定义过滤:小写包含匹配
export function filterVendorOption(inputValue: string, option: { label: string; value: string }) {
const input = inputValue.toLowerCase()
return option.label.toLowerCase().includes(input)
}
+19 -4
View File
@@ -162,6 +162,7 @@ const pricingModeTagColor: Record<string, string> = {
token: 'arcoblue',
per_request: 'orangered',
tiered: 'purple',
per_second: 'green',
}
// ===== 移动端卡片自定义布局用的格式化(仅用于卡片插槽,不改动桌面端列 render)=====
@@ -177,18 +178,29 @@ function formatTokens(n: number | null | undefined): string {
return String(n)
}
// 定价主文本:本位币 输入/输出、单价/次、未定价(bil 层定价直显)
// 按秒模式基准单价:优先 "*" 兜底价,否则取矩阵最小正价(与定价弹窗 perSecondBasePrice 口径一致)
function perSecondBasePrice(row: any): number {
const prices = Object.values(row.per_second_prices || {}).map((p) => Number(p) || 0).filter((p) => p > 0)
if (prices.length === 0) return 0
const wildcard = Number(row.per_second_prices?.['*']) || 0
return wildcard > 0 ? wildcard : Math.min(...prices)
}
// 定价主文本:本位币 输入/输出、单价/次、单价/秒、未定价(bil 层定价直显)
function pricingMain(row: any): string {
if (!row.pricing_mode) return '未定价'
if (row.pricing_mode === 'per_request') {
return `${formatBilling(row.per_request_price ?? 0, 4)}/次`
}
if (row.pricing_mode === 'per_second') {
return `${formatBilling(perSecondBasePrice(row), 4)}/秒`
}
return `${formatBilling(row.input_price ?? 0, 2)}/${formatBilling(row.output_price ?? 0, 2)}`
}
// 定价模式副文本:按量 / 按次 / 阶梯(未定价返回空串)
// 定价模式副文本:按量 / 按次 / 阶梯 / 按秒(未定价返回空串)
function pricingMode(row: any): string {
const m: Record<string, string> = { token: '按量', per_request: '按次', tiered: '阶梯' }
const m: Record<string, string> = { token: '按量', per_request: '按次', tiered: '阶梯', per_second: '按秒' }
return row.pricing_mode ? (m[row.pricing_mode] || row.pricing_mode) : ''
}
@@ -237,13 +249,16 @@ const columns: TableColumnData[] = [
if (!record.pricing_mode) {
return h(Tag, { color: 'orangered', size: 'small' }, () => '未定价')
}
const modeLabel: Record<string, string> = { token: '按量', per_request: '按次', tiered: '阶梯' }
const modeLabel: Record<string, string> = { token: '按量', per_request: '按次', tiered: '阶梯', per_second: '按秒' }
const tags = [
h(Tag, { color: pricingModeTagColor[record.pricing_mode] || 'gray', size: 'small' }, () => modeLabel[record.pricing_mode] || record.pricing_mode),
]
if (record.pricing_mode === 'per_request') {
tags.push(h('span', { style: 'font-size: 12px; color: var(--color-text-3); margin-left: 4px;' },
`${formatBilling(record.per_request_price ?? 0, 4)}/次`))
} else if (record.pricing_mode === 'per_second') {
tags.push(h('span', { style: 'font-size: 12px; color: var(--color-text-3); margin-left: 4px;' },
`${formatBilling(perSecondBasePrice(record), 4)}/秒`))
} else {
tags.push(h('span', { style: 'font-size: 12px; color: var(--color-text-3); margin-left: 4px;' },
`${formatBilling(record.input_price ?? 0, 2)}/${formatBilling(record.output_price ?? 0, 2)}`))
+2
View File
@@ -49,6 +49,7 @@ const platformOptions = [
{ label: 'Suno', value: 'suno' },
{ label: '火山引擎', value: 'volcengine' },
{ label: '阿里', value: 'ali' },
{ label: 'MiniMax', value: 'minimax' },
]
const statusTagColor: Record<string, string | undefined> = {
@@ -74,6 +75,7 @@ const platformLabel: Record<string, string> = {
suno: 'Suno',
volcengine: '火山引擎',
ali: '阿里',
minimax: 'MiniMax',
}
const columns: TableColumnData[] = [
+49
View File
@@ -0,0 +1,49 @@
// 厂商元数据:与后端 mdl_models.vendor 枚举对齐(api/admin/v1/model.go、api/tenant/v1/marketplace.go
// 的 v:"in:" 列表,以及 internal/logic/admin/model.go 的 validModelVendors)。
// 新增厂商时需同步:后端枚举校验、本文件、web/admin/src/constants/vendor.ts,并补 src/assets/vendors/{slug}.svg。
// 注意与渠道供应商(chn_channels.type)是两个维度:厂商 = 模型的研发公司。
export interface VendorMeta {
// 枚举值(入库值)
value: string
// 显示名
label: string
// 品牌色(logo 芯片底色)
color: string
// logo 本身是多色图形(如 cohere 三色 mark),芯片用浅色底展示彩色 logo
colored?: boolean
}
export const vendorMetaList: VendorMeta[] = [
{ value: 'openai', label: 'OpenAI', color: '#10a37f' },
{ value: 'anthropic', label: 'Anthropic', color: '#d97757' },
{ value: 'google', label: 'Google', color: '#4285f4' },
{ value: 'xai', label: 'xAI', color: '#000000' },
{ value: 'mistral', label: 'Mistral', color: '#fa520f' },
{ value: 'cohere', label: 'Cohere', color: '#39594d', colored: true },
{ value: 'meta', label: 'Meta', color: '#0866ff' },
{ value: 'alibaba', label: '阿里巴巴', color: '#615ced' }, // 图标用通义千问 mark,底色取千问品牌紫
{ value: 'bytedance', label: '字节跳动', color: '#325ab4' },
{ value: 'deepseek', label: '深度求索', color: '#4d6bfe' },
{ value: 'zhipu', label: '智谱AI', color: '#3e5cfa' },
{ value: 'moonshot', label: '月之暗面', color: '#16191e' },
{ value: 'minimax', label: 'MiniMax', color: '#1f1f1f' },
{ value: 'baidu', label: '百度', color: '#2932e1' },
{ value: 'tencent', label: '腾讯', color: '#0052d9' },
{ value: 'xunfei', label: '科大讯飞', color: '#0f4cfd' },
{ value: 'kuaishou', label: '快手', color: '#ff5000' },
{ value: 'midjourney', label: 'Midjourney', color: '#111111' },
{ value: 'suno', label: 'Suno', color: '#111111' },
]
// 未收录厂商(vendor 非空但字典缺失)与未设置厂商(空串/null)的展示兜底
export function getVendorMeta(vendor?: string | null): VendorMeta | null {
if (!vendor) return null
return vendorMetaList.find(item => item.value === vendor) ?? null
}
// 厂商显示名:字典命中取 label,未收录回退原值,未设置返回「未分类」
export function vendorLabel(vendor?: string | null): string {
if (!vendor) return '未分类'
return getVendorMeta(vendor)?.label ?? vendor
}
@@ -109,6 +109,7 @@ const platformLabel: Record<string, string> = {
suno: 'Suno',
volcengine: '火山引擎',
ali: '阿里',
minimax: 'MiniMax',
}
// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染)
@@ -289,7 +290,7 @@ onMounted(() => {
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-500 whitespace-nowrap">平台</label>
<BaseSelect v-model="filterPlatform" :options="[{value:'',label:'全部'},{value:'sora',label:'Sora'},{value:'kling',label:'Kling'},{value:'midjourney',label:'Midjourney'},{value:'suno',label:'Suno'},{value:'volcengine',label:'火山引擎'},{value:'ali',label:'阿里'}]" container-class="w-[120px]" />
<BaseSelect v-model="filterPlatform" :options="[{value:'',label:'全部'},{value:'sora',label:'Sora'},{value:'kling',label:'Kling'},{value:'midjourney',label:'Midjourney'},{value:'suno',label:'Suno'},{value:'volcengine',label:'火山引擎'},{value:'ali',label:'阿里'},{value:'minimax',label:'MiniMax'}]" container-class="w-[120px]" />
</div>
<div class="ml-auto flex items-center gap-2">
<button type="submit" class="btn btn-primary btn-sm">
@@ -31,7 +31,9 @@ const duration = ref(5)
const modelOptions = computed(() => props.models.map(m => ({ value: m.model_id, label: m.model_name || m.model_id })))
const resolutionPresets = [
// MiniMax-H3 系列使用官方分辨率档位(768P/2K,Max 追加 480P;与后端计费矩阵键一致),
// 其他模型维持像素串词汇——档位词汇不能混入其他厂商(会产生非法 resolution)
const pixelResolutionPresets = [
{ value: '854x480', label: '480p 横' },
{ value: '480x854', label: '480p 竖' },
{ value: '1280x720', label: '720p 横' },
@@ -40,37 +42,58 @@ const resolutionPresets = [
{ value: '1080x1920', label: '1080p 竖' },
]
const isMiniMaxH3 = computed(() => selectedModel.value.toLowerCase().startsWith('minimax-h3'))
const resolutionPresets = computed(() => {
if (isMiniMaxH3.value) {
const presets = [
{ value: '768P', label: '768P' },
{ value: '2K', label: '2K' },
]
if (selectedModel.value.toLowerCase().startsWith('minimax-h3-max')) {
presets.unshift({ value: '480P', label: '480P' })
}
return presets
}
return pixelResolutionPresets
})
// 模型切换后当前分辨率不在该模型预设内时重置(避免像素串打到 MiniMax 或档位词汇打到其他厂商)
watch(resolutionPresets, presets => {
if (!presets.some(p => p.value === resolution.value)) {
resolution.value = presets[0]?.value || ''
}
})
interface TaskInfo {
id: string
// OpenAI Videos 协议状态机:queued / in_progress / completed / failed
status: string
progress: string
model: string
url?: string
error?: string
createdAt: number
completedAt?: number
}
const currentTask = ref<TaskInfo | null>(null)
const polling = ref(false)
// 成品视频地址:/v1/videos/{id}/content 需带 API Key 拉流,取回后转 blob URL 供播放/下载
const videoUrl = ref('')
// 任务状态轮询:页面隐藏时暂停(任务在服务端继续执行),恢复可见时立即补拉
const taskPoller = createPoller(pollLoop, 3000, { immediate: true })
const statusLabel: Record<string, string> = {
SUBMITTED: '已提交',
IN_PROGRESS: '生成中',
NOT_START: '排队中',
QUEUED: '排队中',
SUCCESS: '已完成',
FAILURE: '失败',
queued: '排队中',
in_progress: '生成中',
completed: '已完成',
failed: '失败',
}
const statusColor: Record<string, string> = {
SUBMITTED: 'badge-primary',
IN_PROGRESS: 'badge-warning',
NOT_START: 'badge-gray',
QUEUED: 'badge-gray',
SUCCESS: 'badge-success',
FAILURE: 'badge-danger',
queued: 'badge-gray',
in_progress: 'badge-warning',
completed: 'badge-success',
failed: 'badge-danger',
}
function applyResolutionPreset(val: string) {
@@ -82,43 +105,43 @@ async function submitTask() {
submitting.value = true
currentTask.value = null
stopPolling()
releaseVideo()
try {
const api = createPlaygroundApi(props.apiKey)
const metadata: Record<string, any> = {}
if (resolution.value) {
metadata.resolution = resolution.value
}
if (duration.value) {
metadata.duration = duration.value
}
// OpenAI Videos 协议:size 承载分辨率(像素串或命名档位如 768P/2K),seconds 承载时长
const body: Record<string, any> = {
model: selectedModel.value,
prompt: prompt.value,
}
if (Object.keys(metadata).length > 0) {
body.metadata = metadata
if (resolution.value) {
body.size = resolution.value
}
if (duration.value) {
body.seconds = String(duration.value)
}
const res = await api.post('/v1/video/generations', body, { timeout: 60_000 })
const res = await api.post('/v1/videos', body, { timeout: 60_000 })
const data = res.data
currentTask.value = {
id: data.id,
status: data.status || 'SUBMITTED',
status: data.status || 'queued',
progress: '',
model: data.model || selectedModel.value,
createdAt: data.created_at || Math.floor(Date.now() / 1000),
}
// 开始轮询
if (data.status !== 'SUCCESS' && data.status !== 'FAILURE') {
if (data.status !== 'completed' && data.status !== 'failed') {
startPolling()
} else if (data.status === 'completed') {
loadVideoContent()
}
} catch (e: any) {
currentTask.value = {
id: '',
status: 'FAILURE',
status: 'failed',
progress: '',
model: selectedModel.value,
error: e?.message || '提交失败',
@@ -144,20 +167,23 @@ async function pollLoop() {
try {
const api = createPlaygroundApi(props.apiKey)
const res = await api.get(`/v1/video/generations/${currentTask.value.id}`)
const res = await api.get(`/v1/videos/${currentTask.value.id}`)
const data = res.data
currentTask.value = {
...currentTask.value,
status: data.status,
progress: data.progress || '',
url: data.url || undefined,
error: data.error || undefined,
// OpenAI Videos 的 progress 是 0-100 整数,归一为百分比字符串供模板直用
progress: data.progress != null ? `${data.progress}%` : currentTask.value.progress,
error: data.error?.message || undefined,
completedAt: data.completed_at || undefined,
}
if (data.status === 'SUCCESS' || data.status === 'FAILURE') {
if (data.status === 'completed' || data.status === 'failed') {
stopPolling()
if (data.status === 'completed') {
loadVideoContent()
}
return
}
} catch {
@@ -165,21 +191,54 @@ async function pollLoop() {
}
}
// 组件卸载时停止轮询,避免路由离开后仍持续请求任务状态
// loadVideoContent 拉取成品视频:content 端点要求 API Key 鉴权,浏览器无法直接用 URL 播放,
// 取回二进制后转 blob URL 供 <video> 播放与下载
async function loadVideoContent() {
if (!currentTask.value?.id || videoUrl.value) return
try {
const api = createPlaygroundApi(props.apiKey)
const res = await api.get(`/v1/videos/${currentTask.value.id}/content`, {
responseType: 'blob',
timeout: 300_000,
})
videoUrl.value = URL.createObjectURL(res.data)
} catch {
// 拉取失败不改变任务状态,下载按钮可重试(downloadVideo 内再次触发加载)
currentTask.value = {
...currentTask.value,
error: currentTask.value?.error || '视频加载失败,请点击下载重试',
}
}
}
// releaseVideo 释放 blob URL,避免内存泄漏
function releaseVideo() {
if (videoUrl.value) {
URL.revokeObjectURL(videoUrl.value)
videoUrl.value = ''
}
}
// 组件卸载时停止轮询并释放资源,避免路由离开后仍持续请求任务状态
onUnmounted(() => {
stopPolling()
releaseVideo()
})
function resetTask() {
stopPolling()
releaseVideo()
currentTask.value = null
}
function downloadVideo() {
if (!currentTask.value?.url) return
if (!videoUrl.value) {
loadVideoContent()
return
}
const a = document.createElement('a')
a.href = currentTask.value.url
a.download = `video_${currentTask.value.id || 'output'}.mp4`
a.href = videoUrl.value
a.download = `video_${currentTask.value?.id || 'output'}.mp4`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
@@ -221,7 +280,8 @@ function downloadVideo() {
</div>
<div>
<label class="input-label">时长({{ duration }} 秒)</label>
<n-input-number v-model:value="duration" :min="5" :max="15" :step="1" class="w-full" />
<!-- min=4 覆盖 MiniMax-H3 的 4 秒档;其他模型档位由上游校验 -->
<n-input-number v-model:value="duration" :min="4" :max="15" :step="1" class="w-full" />
</div>
<button class="btn btn-primary w-full" :disabled="submitting || polling || !prompt.trim()" @click="submitTask">
{{ submitting ? '提交中...' : polling ? '生成中...' : '生成视频' }}
@@ -266,7 +326,7 @@ function downloadVideo() {
</div>
<!-- 进度条 -->
<div v-if="currentTask.status === 'IN_PROGRESS' || currentTask.status === 'SUBMITTED'" class="w-full bg-gray-100 rounded-full h-2 overflow-hidden">
<div v-if="currentTask.status === 'in_progress' || currentTask.status === 'queued'" class="w-full bg-gray-100 rounded-full h-2 overflow-hidden">
<div
class="h-full rounded-full bg-primary-500 transition-all duration-500"
:style="{ width: currentTask.progress || '10%' }"
@@ -274,16 +334,21 @@ function downloadVideo() {
</div>
<!-- 视频结果 -->
<div v-if="currentTask.status === 'SUCCESS' && currentTask.url" class="space-y-3">
<video controls class="w-full" :src="currentTask.url" />
<div v-if="currentTask.status === 'completed' && videoUrl" class="space-y-3">
<video controls class="w-full" :src="videoUrl" />
<button class="btn btn-secondary btn-sm" @click="downloadVideo">
<Icon name="arrowDown" size="sm" />
下载视频
</button>
</div>
<!-- 成品加载中(content 端点拉流转 blob) -->
<div v-else-if="currentTask.status === 'completed'" class="flex items-center justify-center py-10">
<div class="spinner h-6 w-6 text-primary-600"></div>
<span class="ml-3 text-sm text-gray-500">视频加载中...</span>
</div>
<!-- 错误信息 -->
<div v-if="currentTask.status === 'FAILURE'" class="rounded-xl border border-red-200 bg-red-50 p-4">
<div v-if="currentTask.status === 'failed'" class="rounded-xl border border-red-200 bg-red-50 p-4">
<div class="flex items-center gap-2 text-red-700">
<Icon name="xCircle" size="sm" />
<span class="text-sm font-medium">生成失败</span>