fix: restore upstream canvas capabilities

This commit is contained in:
2026-05-25 18:28:11 +08:00
parent 8bb4c96556
commit cce9779a8a
18 changed files with 3459 additions and 463 deletions

View File

@@ -0,0 +1,396 @@
<template>
<!-- API Settings Modal | API 设置弹窗 -->
<n-modal v-model:show="showModal" preset="card" title="API 设置" style="width: 560px;">
<n-tabs type="line" animated>
<!-- API 配置标签 -->
<n-tab-pane name="api" tab="API 配置">
<n-form ref="formRef" :model="formData" label-placement="left" label-width="80">
<n-form-item label="渠道" path="provider">
<n-select
v-model:value="formData.provider"
:options="providerOptions"
placeholder="选择 API 渠道"
/>
</n-form-item>
<n-form-item label="Base URL" path="baseUrl">
<n-input
v-model:value="formData.baseUrl"
placeholder="/api"
/>
</n-form-item>
<n-form-item label="API Key" path="apiKey">
<n-input
v-model:value="formData.apiKey"
type="password"
show-password-on="click"
placeholder="内部接口无需填写"
/>
</n-form-item>
<n-divider title-placement="left" class="!my-3">
<span class="text-xs text-[var(--text-secondary)]">端点路径</span>
</n-divider>
<div class="endpoint-list">
<div class="endpoint-item">
<span class="endpoint-label">问答</span>
<n-tag size="small" type="info" class="endpoint-tag">{{ currentEndpoints.chat }}</n-tag>
</div>
<div class="endpoint-item">
<span class="endpoint-label">生图</span>
<n-tag size="small" type="success" class="endpoint-tag">{{ currentEndpoints.image }}</n-tag>
</div>
<div class="endpoint-item">
<span class="endpoint-label">视频生成</span>
<n-tag size="small" type="warning" class="endpoint-tag">{{ currentEndpoints.video }}</n-tag>
</div>
<div class="endpoint-item">
<span class="endpoint-label">视频查询</span>
<n-tag size="small" type="warning" class="endpoint-tag">{{ currentEndpoints.videoQuery }}</n-tag>
</div>
</div>
<n-alert v-if="!isConfigured" type="warning" title="未配置" class="mb-4">
<div class="flex flex-col gap-2">
<p>当前使用 SKG 内部登录会话调用生成接口</p>
</div>
</n-alert>
<n-alert v-else type="success" title="已配置" class="mb-4">
API 已就绪可以使用 AI 功能
</n-alert>
</n-form>
</n-tab-pane>
<!-- 模型配置标签 -->
<n-tab-pane name="models" tab="模型配置">
<div class="model-config-section">
<!-- 问答模型 -->
<div class="model-group">
<div class="model-group-header">
<span class="model-group-title">问答模型</span>
<n-tag size="tiny" type="info">{{ allChatModels.length }} </n-tag>
</div>
<div class="model-input-row">
<n-input
v-model:value="newChatModel"
placeholder="输入模型名称,如 gpt-4o"
size="small"
@keyup.enter="handleAddChatModel"
/>
<n-button size="small" type="primary" @click="handleAddChatModel" :disabled="!newChatModel">
添加
</n-button>
</div>
<div class="model-tags">
<n-tag
v-for="model in allChatModels"
:key="model.key"
size="small"
:closable="model.isCustom"
:type="model.isCustom ? 'info' : 'default'"
@close="handleRemoveChatModel(model.key)"
>
{{ model.label }}
</n-tag>
</div>
</div>
<!-- 图片模型 -->
<div class="model-group">
<div class="model-group-header">
<span class="model-group-title">图片模型</span>
<n-tag size="tiny" type="success">{{ allImageModels.length }} </n-tag>
</div>
<div class="model-input-row">
<n-input
v-model:value="newImageModel"
placeholder="输入模型名称,如 dall-e-3"
size="small"
@keyup.enter="handleAddImageModel"
/>
<n-button size="small" type="primary" @click="handleAddImageModel" :disabled="!newImageModel">
添加
</n-button>
</div>
<div class="model-tags">
<n-tag
v-for="model in allImageModels"
:key="model.key"
size="small"
:closable="model.isCustom"
:type="model.isCustom ? 'success' : 'default'"
@close="handleRemoveImageModel(model.key)"
>
{{ model.label }}
</n-tag>
</div>
</div>
<!-- 视频模型 -->
<div class="model-group">
<div class="model-group-header">
<span class="model-group-title">视频模型</span>
<n-tag size="tiny" type="warning">{{ allVideoModels.length }} </n-tag>
</div>
<div class="model-input-row">
<n-input
v-model:value="newVideoModel"
placeholder="输入模型名称,如 sora-2"
size="small"
@keyup.enter="handleAddVideoModel"
/>
<n-button size="small" type="primary" @click="handleAddVideoModel" :disabled="!newVideoModel">
添加
</n-button>
</div>
<div class="model-tags">
<n-tag
v-for="model in allVideoModels"
:key="model.key"
size="small"
:closable="model.isCustom"
:type="model.isCustom ? 'warning' : 'default'"
@close="handleRemoveVideoModel(model.key)"
>
{{ model.label }}
</n-tag>
</div>
</div>
</div>
</n-tab-pane>
</n-tabs>
<template #footer>
<div class="flex justify-between items-center">
<span class="text-xs text-[var(--text-secondary)]">生成调用走当前登录会话无需个人 API Key</span>
<div class="flex gap-2">
<n-button @click="handleClear" tertiary>清除配置</n-button>
<n-button @click="showModal = false">取消</n-button>
<n-button type="primary" @click="handleSave">保存</n-button>
</div>
</div>
</template>
</n-modal>
</template>
<script setup>
/**
* API Settings Component | API 设置组件
* Modal for configuring API key, base URL, and custom models
*/
import { ref, reactive, watch, computed } from 'vue'
import { NModal, NForm, NFormItem, NInput, NButton, NAlert, NDivider, NTag, NTabs, NTabPane, NSelect } from 'naive-ui'
import { useModelStore } from '../stores/pinia'
import { getProviderConfig } from '../config/providers'
// Props | 属性
const props = defineProps({
show: {
type: Boolean,
default: false
}
})
// Emits | 事件
const emit = defineEmits(['update:show', 'saved'])
// API Config 状态
const isConfigured = computed(() => !!modelStore.currentApiKey)
// Model Store (Pinia) | 模型配置 Store
const modelStore = useModelStore()
// Provider options for select | 渠道下拉选项
const providerOptions = modelStore.providerList.map(p => ({
label: p.label,
value: p.key
}))
// 当前渠道的端点路径
const currentEndpoints = computed(() => {
const config = getProviderConfig(formData.provider)
return config.endpoints || {
chat: '/chat/completions',
image: '/v1/images/generations',
video: '/v1/videos',
videoQuery: '/v1/videos/{taskId}'
}
})
// 全局模型列表(不区分渠道)
const allChatModels = computed(() => modelStore.allChatModels)
const allImageModels = computed(() => modelStore.allImageModels)
const allVideoModels = computed(() => modelStore.allVideoModels)
// Modal visibility | 弹窗可见性
const showModal = ref(props.show)
// Form data | 表单数据
const formData = reactive({
provider: modelStore.currentProvider,
apiKey: '',
baseUrl: ''
})
// New model inputs | 新模型输入
const newChatModel = ref('')
const newImageModel = ref('')
const newVideoModel = ref('')
// 初始化或切换渠道时,更新 API 配置
const updateFormApiConfig = () => {
const provider = formData.provider
const config = getProviderConfig(provider)
formData.apiKey = modelStore.apiKeysByProvider[provider] || ''
formData.baseUrl = modelStore.baseUrlsByProvider[provider] || config.defaultBaseUrl || ''
}
// Watch prop changes | 监听属性变化
watch(() => props.show, (val) => {
showModal.value = val
if (val) {
formData.provider = modelStore.currentProvider
updateFormApiConfig()
}
})
// 监听渠道变化,更新表单中的 API 配置
watch(() => formData.provider, () => {
updateFormApiConfig()
})
// Watch modal changes | 监听弹窗变化
watch(showModal, (val) => {
emit('update:show', val)
})
// Handle add models | 处理添加模型
const handleAddChatModel = () => {
if (newChatModel.value.trim()) {
modelStore.addCustomChatModel(newChatModel.value.trim())
newChatModel.value = ''
}
}
const handleAddImageModel = () => {
if (newImageModel.value.trim()) {
modelStore.addCustomImageModel(newImageModel.value.trim())
newImageModel.value = ''
}
}
const handleAddVideoModel = () => {
if (newVideoModel.value.trim()) {
modelStore.addCustomVideoModel(newVideoModel.value.trim())
newVideoModel.value = ''
}
}
// Handle remove models | 处理删除模型
const handleRemoveChatModel = (modelKey) => {
modelStore.removeCustomChatModel(modelKey)
}
const handleRemoveImageModel = (modelKey) => {
modelStore.removeCustomImageModel(modelKey)
}
const handleRemoveVideoModel = (modelKey) => {
modelStore.removeCustomVideoModel(modelKey)
}
// Handle save | 处理保存
const handleSave = () => {
if (formData.provider) {
modelStore.setProvider(formData.provider)
}
if (formData.apiKey) {
modelStore.setApiKeyByProvider(formData.provider, formData.apiKey)
}
if (formData.baseUrl) {
modelStore.setBaseUrlByProvider(formData.provider, formData.baseUrl)
}
showModal.value = false
emit('saved')
}
// Handle clear | 处理清除
const handleClear = () => {
modelStore.clearApiConfigByProvider(formData.provider)
modelStore.clearCustomModels()
formData.apiKey = ''
formData.baseUrl = ''
}
</script>
<style scoped>
.endpoint-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
padding: 12px;
background: var(--bg-secondary, #f5f5f5);
border-radius: 6px;
}
.endpoint-item {
display: flex;
align-items: center;
justify-content: space-between;
}
.endpoint-label {
font-size: 13px;
color: var(--text-secondary, #666);
min-width: 70px;
}
.endpoint-tag {
font-family: monospace;
font-size: 12px;
}
.model-config-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.model-group {
padding: 12px;
background: var(--bg-secondary, #f5f5f5);
border-radius: 8px;
}
.model-group-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.model-group-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary, #333);
}
.model-input-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.model-input-row .n-input {
flex: 1;
}
.model-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
</style>

View File

@@ -55,8 +55,8 @@ const props = defineProps({
// Image role options | 图片角色选项
const imageRoleOptions = [
{ label: '图片', key: 'first_frame_image' },
{ label: '结束图', key: 'last_frame_image' },
{ label: '首帧', key: 'first_frame_image' },
{ label: '尾帧', key: 'last_frame_image' },
{ label: '参考图', key: 'input_reference' }
]
@@ -66,7 +66,7 @@ const currentRole = computed(() => props.data?.imageRole || 'first_frame_image')
// Current role label | 当前角色标签
const currentRoleLabel = computed(() => {
const option = imageRoleOptions.find(o => o.key === currentRole.value)
return option?.label || '图片'
return option?.label || '首帧'
})
// Calculate bezier path | 计算贝塞尔路径
@@ -95,7 +95,7 @@ const edgeStyle = computed(() => ({
// Handle role selection | 处理角色选择
const handleRoleSelect = (role) => {
// Keep endpoint image roles unique when advanced users edit edge roles.
// If selecting first_frame or last_frame, ensure uniqueness | 如果选择首帧或尾帧,确保唯一性
if (role === 'first_frame_image' || role === 'last_frame_image') {
// Find other edges connected to the same target with the same role | 查找连接到同一目标且具有相同角色的其他边
const sameTargetEdges = edges.value.filter(edge =>

View File

@@ -586,7 +586,7 @@ const handleGenerate = async (mode = 'auto') => {
}
if (!isConfigured.value) {
window.$message?.warning('登录状态异常,请重新进入工作台')
window.$message?.warning('生成接口未就绪,请稍后重试')
return
}

View File

@@ -932,7 +932,7 @@ const handleVideoGen = () => {
sourceHandle: 'right',
targetHandle: 'left',
type: 'imageRole',
data: { imageRole: 'first_frame_image' } // Default reference image | 默认参考图
data: { imageRole: 'first_frame_image' } // Default to first frame | 默认首帧
})
// Connect text node to config node | 连接文本节点到配置节点

View File

@@ -764,7 +764,7 @@ const getInputFromConnections = () => {
// Handle generate | 处理生成
const handleGenerate = async () => {
if (!isApiConfigured.value) {
window.$message?.warning('登录状态异常,请重新进入工作台')
window.$message?.warning('生成接口未就绪,请稍后重试')
return
}

View File

@@ -623,7 +623,7 @@ const handlePolish = async () => {
// Check API configuration | 检查 API 配置
if (!isApiConfigured.value) {
window.$message?.warning('登录状态异常,请重新进入工作台')
window.$message?.warning('生成接口未就绪,请稍后重试')
return
}

View File

@@ -82,8 +82,16 @@
提示词 {{ connectedPrompt ? '✓' : '○' }}
</span>
<span class="px-2 py-0.5 rounded-full"
:class="connectedImages.length > 0 ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
图片 {{ connectedImages.length > 0 ? `${connectedImages.length}` : '○' }}
:class="imagesByRole.firstFrame ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
首帧 {{ imagesByRole.firstFrame ? '✓' : '○' }}
</span>
<span class="px-2 py-0.5 rounded-full"
:class="imagesByRole.lastFrame ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
尾帧 {{ imagesByRole.lastFrame ? '✓' : '○' }}
</span>
<span class="px-2 py-0.5 rounded-full"
:class="imagesByRole.referenceImages.length > 0 ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
参考图 {{ imagesByRole.referenceImages.length > 0 ? `${imagesByRole.referenceImages.length}` : '○' }}
</span>
</div>
@@ -187,7 +195,7 @@ const connectedImages = computed(() => {
edgeId: edge.id,
url: sourceNode.data.url,
base64: sourceNode.data.base64,
role: edge.data?.imageRole || 'first_frame_image' // Default reference image | 默认参考图
role: edge.data?.imageRole || 'first_frame_image' // Default to first frame | 默认首帧
})
}
}
@@ -334,7 +342,7 @@ const handleGenerate = async () => {
}
if (!isConfigured.value) {
window.$message?.warning('登录状态异常,请重新进入工作台')
window.$message?.warning('生成接口未就绪,请稍后重试')
isGenerating.value = false
return
}
@@ -377,12 +385,12 @@ const handleGenerate = async () => {
params.prompt = prompt
}
// Add primary reference image | 添加主参考图
// Add first frame image | 添加首帧图片
if (first_frame_image) {
params.first_frame_image = first_frame_image
}
// Add optional ending reference image | 添加可选结束参考图
// Add last frame image | 添加尾帧图片
if (last_frame_image) {
params.last_frame_image = last_frame_image
}

View File

@@ -1,118 +1,269 @@
/**
* SKG model and size configuration.
* These values mirror the backend /health capabilities and keep the canvas UI simple.
* Models Configuration | 模型配置
* Centralized model configuration | 集中模型配置
*/
export const SKG_IMAGE_SIZE_OPTIONS = [
{ label: '自动', key: 'auto' },
{ label: '竖图 2:3', key: '1024x1536' },
{ label: '方图 1:1', key: '1024x1024' },
{ label: '横图 3:2', key: '1536x1024' }
// Seedream image size options | 豆包图片尺寸选项
export const SEEDREAM_SIZE_OPTIONS = [
{ label: '21:9', key: '3024x1296' },
{ label: '16:9', key: '2560x1440' },
{ label: '4:3', key: '2304x1728' },
{ label: '3:2', key: '2496x1664' },
{ label: '1:1', key: '2048x2048' },
{ label: '2:3', key: '1664x2496' },
{ label: '3:4', key: '1728x2304' },
{ label: '9:16', key: '1440x2560' },
{ label: '9:21', key: '1296x3024' }
]
export const SKG_IMAGE_QUALITY_OPTIONS = [
{ label: '标准', key: 'standard' }
// Seedream 4K image size options | 豆包4K图片尺寸选项
export const SEEDREAM_4K_SIZE_OPTIONS = [
{ label: '21:9', key: '6198x2656' },
{ label: '16:9', key: '5404x3040' },
{ label: '4:3', key: '4694x3520' },
{ label: '3:2', key: '4992x3328' },
{ label: '1:1', key: '4096x4096' },
{ label: '2:3', key: '3328x4992' },
{ label: '3:4', key: '3520x4694' },
{ label: '9:16', key: '3040x5404' },
{ label: '9:21', key: '2656x6198' }
]
export const SKG_VIDEO_SIZE_OPTIONS = [
{ label: '竖屏 9:16', key: '720x1280' },
{ label: '横屏 16:9', key: '1280x720' },
{ label: '方形 1:1', key: '1024x1024' },
{ label: '竖屏 3:4', key: '960x1280' }
// Seedream quality options | 豆包画质选项
export const SEEDREAM_QUALITY_OPTIONS = [
{ label: '标准画质', key: 'standard' },
{ label: '4K 高清', key: '4k' }
]
export const VIDEO_RATIO_LIST = SKG_VIDEO_SIZE_OPTIONS
export const SEEDANCE_RESOLUTION_OPTIONS = [
{ label: '720p', key: '720p' },
{ label: '1080p', key: '1080p' }
export const BANANA_SIZE_OPTIONS = [
{ label: '16:9', key: '16x9' },
{ label: '4:3', key: '4x3' },
{ label: '3:2', key: '3x2' },
{ label: '1:1', key: '1x1' },
{ label: '2:3', key: '2x3' },
{ label: '3:4', key: '3x4' },
{ label: '9:16', key: '9x16' },
]
// Image generation models | 图片生成模型
export const IMAGE_MODELS = [
{
label: '自动',
key: 'auto',
provider: ['skg'],
sizes: SKG_IMAGE_SIZE_OPTIONS.map(s => s.key),
qualities: SKG_IMAGE_QUALITY_OPTIONS,
defaultParams: { size: '1024x1536', quality: 'standard', style: 'commercial' }
},
{
label: 'GPT Image 2',
key: 'gpt-image-2',
provider: ['skg'],
sizes: SKG_IMAGE_SIZE_OPTIONS.map(s => s.key),
qualities: SKG_IMAGE_QUALITY_OPTIONS,
defaultParams: { size: '1024x1536', quality: 'standard', style: 'commercial' }
},
{
label: 'Gemini 图片备用',
key: 'gemini-3-pro-image-preview',
provider: ['skg'],
sizes: SKG_IMAGE_SIZE_OPTIONS.map(s => s.key),
qualities: SKG_IMAGE_QUALITY_OPTIONS,
defaultParams: { size: '1024x1536', quality: 'standard', style: 'commercial' }
}
{
label: 'Nano Banana 2',
key: 'nano-banana-2',
provider: ['chatfire'], // 火宝渠道
sizes: BANANA_SIZE_OPTIONS.map(s => s.key),
// qualities: SEEDREAM_QUALITY_OPTIONS,
// getSizesByQuality: (quality) => quality === '4k' ? SEEDREAM_4K_SIZE_OPTIONS : SEEDREAM_SIZE_OPTIONS,
defaultParams: {
size: '1x1',
quality: 'standard',
style: 'vivid'
}
},
{
label: 'Nano Banana Pro',
key: 'nano-banana-pro',
provider: ['chatfire'], // 火宝渠道
sizes: BANANA_SIZE_OPTIONS.map(s => s.key),
// qualities: SEEDREAM_QUALITY_OPTIONS,
// getSizesByQuality: (quality) => quality === '4k' ? SEEDREAM_4K_SIZE_OPTIONS : SEEDREAM_SIZE_OPTIONS,
defaultParams: {
size: '1x1',
quality: 'standard',
style: 'vivid'
}
},
{
label: '豆包 Seedream 4.5',
key: 'doubao-seedream-4-5-251128',
provider: ['chatfire'], // 火宝渠道
sizes: SEEDREAM_SIZE_OPTIONS.map(s => s.key),
qualities: SEEDREAM_QUALITY_OPTIONS,
getSizesByQuality: (quality) => quality === '4k' ? SEEDREAM_4K_SIZE_OPTIONS : SEEDREAM_SIZE_OPTIONS,
defaultParams: {
size: '2048x2048',
quality: 'standard',
style: 'vivid'
}
},
{
label: 'Nano Banana',
key: 'nano-banana',
provider: ['chatfire'], // 火宝渠道
tips: '尺寸写在提示词中: 尺寸 9:16',
sizes: [],
defaultParams: {
quality: 'standard',
style: 'vivid'
}
},
]
// Video ratio options | 视频比例选项
export const VIDEO_RATIO_LIST = [
{ label: '16:9 (横版)', key: '16x9' },
{ label: '4:3', key: '4x3' },
{ label: '1:1 (方形)', key: '1x1' },
{ label: '3:4', key: '3x4' },
{ label: '9:16 (竖版)', key: '9x16' }
]
// Video resolution options for Seedance | Seedance 分辨率选项
export const SEEDANCE_RESOLUTION_OPTIONS = [
{ label: '480p', key: '480p' },
{ label: '720p', key: '720p' },
{ label: '1080p', key: '1080p' }
]
// Video generation models | 视频生成模型
export const VIDEO_MODELS = [
{
label: 'Seedance',
key: 'seedance',
provider: ['skg'],
type: 't2v+i2v',
ratios: SKG_VIDEO_SIZE_OPTIONS.map(s => s.key),
durs: [5, 8, 10, 12, 15].map(s => ({ label: `${s}`, key: s })),
resolutions: ['720p', '1080p'],
defaultResolution: '1080p',
defaultParams: { ratio: '720x1280', duration: 10, resolution: '1080p' }
},
{
label: 'Kling',
key: 'kling',
provider: ['skg'],
type: 't2v+i2v',
ratios: SKG_VIDEO_SIZE_OPTIONS.map(s => s.key),
durs: [4, 8, 12].map(s => ({ label: `${s}`, key: s })),
resolutions: ['720p'],
defaultResolution: '720p',
defaultParams: { ratio: '720x1280', duration: 8, resolution: '720p' }
},
{
label: 'Veo 3',
key: 'veo3',
provider: ['skg'],
type: 't2v+i2v',
ratios: SKG_VIDEO_SIZE_OPTIONS.map(s => s.key),
durs: [4, 8, 12].map(s => ({ label: `${s}`, key: s })),
resolutions: ['720p'],
defaultResolution: '720p',
defaultParams: { ratio: '720x1280', duration: 8, resolution: '720p' }
}
// Seedance 模型 - 1.5 Pro
{
label: 'Seedance 1.5 Pro (图文视频)',
key: 'doubao-seedance-1-5-pro-251215',
provider: ['chatfire'],
type: 't2v+i2v',
ratios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
resolutions: ['480p', '720p', '1080p'],
defaultResolution: '1080p',
defaultParams: { ratio: '16:9', duration: 10, resolution: '1080p' }
},
// Seedance 模型 - 文生视频
{
label: 'Seedance 1.0 Lite (文生视频)',
key: 'doubao-seedance-1-0-lite-t2v-250428',
provider: ['chatfire'],
type: 't2v', // 文生视频
ratios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
resolutions: ['480p', '720p', '1080p'],
defaultResolution: '720p',
defaultParams: { ratio: '16:9', duration: 5, resolution: '720p' }
},
// Seedance 模型 - 图生视频
{
label: 'Seedance 1.0 Lite (图生视频)',
key: 'doubao-seedance-1-0-lite-i2v-250428',
provider: ['chatfire'],
type: 'i2v', // 图生视频
ratios: ['16:9'],
durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
resolutions: ['480p', '720p', '1080p'],
defaultResolution: '720p',
defaultParams: { ratio: '16:9', duration: 5, resolution: '720p' }
},
// Seedance 模型 - 图文视频 Pro
{
label: 'Seedance 1.0 Pro (图文视频)',
key: 'doubao-seedance-1-0-pro-250528',
provider: ['chatfire'],
type: 't2v+i2v', // 图文视频
ratios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9', '16:9'],
durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
resolutions: ['480p', '720p', '1080p'],
defaultResolution: '1080p',
defaultParams: { ratio: '16:9', duration: 5, resolution: '1080p' }
},
// Seedance 模型 - 1.0 Pro Fast
{
label: 'Seedance 1.0 Pro Fast (图文视频)',
key: 'doubao-seedance-1-0-pro-fast-251015',
provider: ['chatfire'],
type: 't2v+i2v',
ratios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
resolutions: ['480p', '720p', '1080p'],
defaultResolution: '1080p',
defaultParams: { ratio: '16:9', duration: 5, resolution: '1080p' }
},
// 可灵 Kling
// {
// label: '可灵 Kling v2.5-turbo',
// key: 'kling-v2-1',
// provider: ['chatfire'], // 仅火宝渠道
// ratios: VIDEO_RATIO_LIST.map(s => s.key),
// durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
// defaultParams: { ratio: '9:16', duration: 10 }
// },
// {
// label: 'runway/gen4-turbo',
// key: 'runway/gen4-turbo',
// ratios: VIDEO_RATIO_LIST.map(s => s.key),
// durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
// defaultParams: { ratio: '16:9', duration: 5 }
// },
// {
// label: '可灵视频 O1',
// key: 'kling-video-o1',
// ratios: VIDEO_RATIO_LIST.map(s => s.key),
// durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
// defaultParams: { ratio: '16:9', duration: 5 }
// },
// {
// label: 'viduq2-pro_720p', key: 'viduq2-pro_720p',
// ratios: VIDEO_RATIO_LIST.map(s => s.key),
// durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
// defaultParams: { ratio: '16:9', duration: 5 }
// },
// {
// label: 'Sora 2', key: 'sora-2',
// ratios: VIDEO_RATIO_LIST.map(s => s.key),
// durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }],
// defaultParams: { ratio: '16:9', duration: 5 }
// }
]
// Chat/LLM models | 对话模型
export const CHAT_MODELS = [
{ label: 'SKG 提示词助手', key: 'skg-copy', provider: ['skg'] }
{ label: 'GPT-4o Mini', key: 'gpt-4o-mini', provider: ['openai'] },
{ label: 'GPT-4o', key: 'gpt-4o', provider: ['openai'] },
{ label: 'GPT-5.2', key: 'gpt-5.2', provider: ['openai'] },
{ label: 'DeepSeek Chat', key: 'deepseek-chat', provider: ['openai', 'chatfire'] },
{ label: '豆包 Seed Flash', key: 'doubao-seed-1-6-flash-250615', provider: ['chatfire'] },
{ label: 'Gemini 3 Pro', key: 'gemini-3-pro', provider: ['openai'] }
]
export const IMAGE_SIZE_OPTIONS = SKG_IMAGE_SIZE_OPTIONS
export const IMAGE_QUALITY_OPTIONS = SKG_IMAGE_QUALITY_OPTIONS
export const IMAGE_STYLE_OPTIONS = [{ label: '商业营销', key: 'commercial' }]
export const VIDEO_RATIO_OPTIONS = SKG_VIDEO_SIZE_OPTIONS
export const VIDEO_DURATION_OPTIONS = [5, 8, 10, 12, 15].map(s => ({ label: `${s}`, key: s }))
// Image size options | 图片尺寸选项
export const IMAGE_SIZE_OPTIONS = [
{ label: '2048x2048', key: '2048x2048' },
{ label: '1792x1024 (横版)', key: '1792x1024' },
{ label: '1024x1792 (竖版)', key: '1024x1792' }
]
export const SEEDREAM_SIZE_OPTIONS = SKG_IMAGE_SIZE_OPTIONS
export const SEEDREAM_4K_SIZE_OPTIONS = SKG_IMAGE_SIZE_OPTIONS
export const SEEDREAM_QUALITY_OPTIONS = SKG_IMAGE_QUALITY_OPTIONS
// Image quality options | 图片质量选项
export const IMAGE_QUALITY_OPTIONS = [
{ label: '标准', key: 'standard' },
{ label: '高清', key: 'hd' }
]
export const DEFAULT_IMAGE_MODEL = 'auto'
export const DEFAULT_VIDEO_MODEL = 'seedance'
export const DEFAULT_CHAT_MODEL = 'skg-copy'
export const DEFAULT_IMAGE_SIZE = '1024x1536'
export const DEFAULT_VIDEO_RATIO = '720x1280'
export const DEFAULT_VIDEO_DURATION = 10
// Image style options | 图片风格选项
export const IMAGE_STYLE_OPTIONS = [
{ label: '生动', key: 'vivid' },
{ label: '自然', key: 'natural' }
]
// Video ratio options | 视频比例选项
export const VIDEO_RATIO_OPTIONS = VIDEO_RATIO_LIST
// Video duration options | 视频时长选项
export const VIDEO_DURATION_OPTIONS = [
{ label: '5 秒', key: 5 },
{ label: '10 秒', key: 10 }
]
// Default values | 默认值
export const DEFAULT_IMAGE_MODEL = 'nano-banana-pro'
export const DEFAULT_VIDEO_MODEL = 'doubao-seedance-1-5-pro-251215'
export const DEFAULT_CHAT_MODEL = 'gpt-4o-mini'
export const DEFAULT_IMAGE_SIZE = '2048x2048'
export const DEFAULT_VIDEO_RATIO = '16:9'
export const DEFAULT_VIDEO_DURATION = 5
// Get model by key | 根据 key 获取模型
export const getModelByName = (key) => {
const allModels = [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS]
return allModels.find(m => m.key === key)
const allModels = [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS]
return allModels.find(m => m.key === key)
}

View File

@@ -1,40 +1,272 @@
/**
* SKG internal provider config.
* The browser never receives upstream model keys; all generation goes through /api.
* API Provider Adapters | API 渠道适配器
* 适配不同 API 提供商的请求参数和响应格式
*/
// 渠道适配配置
export const PROVIDERS = {
skg: {
label: 'SKG 内部模型',
chatfire: {
label: 'SKG 内部',
defaultBaseUrl: '/api',
// 端点路径
endpoints: {
chat: '/creative/copy',
image: '/jobs/{jobId}/frames/{idx}/generate',
video: '/jobs/{jobId}/frames/{idx}/storyboard/video',
videoQuery: '/jobs/{jobId}'
chat: '/v1/chat/completions',
image: '/v1/images/generations',
video: '/v1/video/generations',
videoQuery: '/v1/video/task/{taskId}'
},
// 火宝渠道请求适配
requestAdapter: {
chat: (params) => params,
image: (params) => params,
video: (params) => params
chat: (params) => {
const adapted = {
model: params.model,
messages: params.messages
}
if (params.temperature !== undefined) adapted.temperature = params.temperature
if (params.max_tokens !== undefined) adapted.max_tokens = params.max_tokens
if (params.stream !== undefined) adapted.stream = params.stream
return adapted
},
image: (params) => {
const adapted = {
model: params.model,
prompt: params.prompt
}
if (params.size) adapted.size = params.size
if (params.n) adapted.n = params.n
if (params.quality) adapted.quality = params.quality
if (params.style) adapted.style = params.style
if (params.image) adapted.image = params.image
return adapted
},
video: (params) => {
const model = params.model || ''
// Seedance 模型 - 使用 content 数组格式
if (model.includes('seedance')) {
const content = []
// 构建完整参数文本
// 格式: prompt --resolution 720p --ratio 16:9 --dur 5 --fps 24 --wm true --seed 11 --cf false
let textPrompt = params.prompt || ''
// 添加 resolution 参数
if (params.resolution) {
textPrompt += ` --resolution ${params.resolution}`
}
// 添加 ratio 参数 (图生视频用 16:9)
if (params.size) {
textPrompt += ` --ratio ${params.size}`
}
// 添加 duration 参数
if (params.seconds) {
textPrompt += ` --dur ${params.seconds}`
}
// 添加 fps (固定 24)
textPrompt += ` --fps 24`
// 添加水印参数 (默认 true)
textPrompt += ` --wm ${params.wm !== false ? 'true' : 'false'}`
// 添加 seed 参数 (可选)
if (params.seed !== undefined) {
textPrompt += ` --seed ${params.seed}`
}
// 添加 cf 参数 (默认 false)
textPrompt += ` --cf ${params.cf === true ? 'true' : 'false'}`
content.push({
type: 'text',
text: textPrompt
})
// 添加参考图(如果有)
if (params.first_frame_image) {
content.push({
type: 'image_url',
image_url: {
url: params.first_frame_image
}
})
}
const adapted = {
model: model,
content: content,
generate_audio: params.generateAudio !== false
}
return adapted
}
// Kling 模型 - 使用 kling 特定格式
if (model.includes('kling')) {
// 将 ratio 转换为 aspect_ratio 格式
const ratioMap = {
'16:9': '16:9',
'9:16': '9:16',
'1:1': '1:1',
'4:3': '4:3',
'3:4': '3:4'
}
const adapted = {
model_name: model,
mode: 'std',
prompt: params.prompt || '',
aspect_ratio: ratioMap[params.size] || '16:9',
duration: params.seconds || 5,
negative_prompt: '',
cfg_scale: 0.5
}
// 添加参考图(如果有)
if (params.first_frame_image) {
adapted.image = params.first_frame_image
}
return adapted
}
// 默认格式veo 等)
const adapted = {
model: params.model,
prompt: params.prompt || ''
}
if (params.first_frame_image) adapted.first_frame_image = params.first_frame_image
if (params.last_frame_image) adapted.last_frame_image = params.last_frame_image
if (params.size) adapted.size = params.size
if (params.seconds) adapted.seconds = params.seconds
return adapted
}
},
// 火宝渠道响应格式
responseAdapter: {
chat: (response) => response,
image: (response) => response,
video: (response) => response
chat: (response) => {
if (response.choices && response.choices.length > 0) {
return response.choices[0].message?.content || ''
}
return ''
},
image: (response) => {
const data = response.data || response
return (Array.isArray(data) ? data : [data]).map(item => ({
url: item.url || item.b64_json || '',
revisedPrompt: item.revised_prompt || ''
}))
},
video: (response) => {
return {
url: response.data?.url || response.url || response.data?.[0]?.url || '',
...response
}
}
}
},
default: 'skg'
openai: {
label: 'OpenAI',
defaultBaseUrl: 'https://api.openai.com',
// 端点路径
endpoints: {
chat: '/v1/chat/completions',
image: '/v1/images/generations',
video: '/v1/videos',
videoQuery: '/v1/videos/{taskId}'
},
// 请求参数适配
requestAdapter: {
chat: (params) => {
const adapted = {
model: params.model,
messages: params.messages
}
// 添加可选参数
if (params.temperature !== undefined) adapted.temperature = params.temperature
if (params.max_tokens !== undefined) adapted.max_tokens = params.max_tokens
if (params.stream !== undefined) adapted.stream = params.stream
return adapted
},
image: (params) => {
const adapted = {
model: params.model,
prompt: params.prompt
}
if (params.size) adapted.size = params.size
if (params.n) adapted.n = params.n
if (params.quality) adapted.quality = params.quality
if (params.style) adapted.style = params.style
if (params.image) adapted.image = params.image
return adapted
},
video: (params) => {
const adapted = {
model: params.model,
prompt: params.prompt || ''
}
if (params.first_frame_image) adapted.first_frame_image = params.first_frame_image
if (params.last_frame_image) adapted.last_frame_image = params.last_frame_image
if (params.size) adapted.size = params.size
if (params.seconds) adapted.seconds = params.seconds
return adapted
}
},
// 响应数据适配
responseAdapter: {
chat: (response) => {
if (response.choices && response.choices.length > 0) {
return response.choices[0].message?.content || ''
}
return ''
},
image: (response) => {
const data = response.data || response
return (Array.isArray(data) ? data : [data]).map(item => ({
url: item.url || item.b64_json || '',
revisedPrompt: item.revised_prompt || ''
}))
},
video: (response) => {
return {
url: response.data?.url || response.url || response.data?.[0]?.url || '',
...response
}
}
}
},
// 默认使用 OpenAI 格式
default: 'chatfire'
}
export const getProviderList = () => (
Object.entries(PROVIDERS)
// 获取渠道列表
export const getProviderList = () => {
return Object.entries(PROVIDERS)
.filter(([key]) => key !== 'default')
.map(([key, value]) => ({ key, label: value.label }))
)
.map(([key, value]) => ({
key,
label: value.label
}))
}
export const getDefaultProvider = () => PROVIDERS.default || 'skg'
// 获取默认渠道
export const getDefaultProvider = () => {
return PROVIDERS.default || 'chatfire'
}
export const getProviderConfig = (provider) => PROVIDERS[provider] || PROVIDERS.skg
// 获取渠道的默认 Base URL
export const getDefaultBaseUrl = (providerKey) => {
const config = getProviderConfig(providerKey)
return config.defaultBaseUrl || ''
}
export const getDefaultBaseUrl = (provider) => getProviderConfig(provider).defaultBaseUrl
// 获取渠道配置
export const getProviderConfig = (providerKey) => {
return PROVIDERS[providerKey] || PROVIDERS[PROVIDERS.default]
}

File diff suppressed because it is too large Load Diff

View File

@@ -84,7 +84,7 @@ const customChatModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, []))
const customImageModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, []))
const customVideoModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS, []))
// 按渠道存储的自定义模型 | 结构: { 'skg': [{key, label}] }
// 按渠道存储的自定义模型 | 结构: { 'openai': [{key, label}], 'chatfire': [{key, label}] }
const customChatModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER || 'custom-chat-models-by-provider', {}))
const customImageModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER || 'custom-image-models-by-provider', {}))
const customVideoModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER || 'custom-video-models-by-provider', {}))
@@ -411,7 +411,7 @@ export const useModelConfig = () => {
getImageModel,
getVideoModel,
// Get models by provider
// Get models by provider (for ApiSettings)
getModelsByProvider,
// Custom models by provider

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,14 @@ const setStored = (key, value) => {
}
}
const removeStored = (key) => {
try {
localStorage.removeItem(key)
} catch {
// ignore
}
}
/**
* Get stored JSON value from localStorage
*/
@@ -94,7 +102,8 @@ export const useModelStore = defineStore('model', () => {
// ============ Provider 状态 | Provider State ============
// 当前选中的渠道
const currentProvider = ref(getStored(STORAGE_KEYS.PROVIDER) || getDefaultProvider())
const storedProvider = getStored(STORAGE_KEYS.PROVIDER)
const currentProvider = ref(PROVIDERS[storedProvider] ? storedProvider : getDefaultProvider())
// 渠道列表
const providerList = computed(() => getProviderList())

View File

@@ -4,7 +4,7 @@
<!-- Header | 顶部导航 -->
<AppHeader class="bg-[var(--bg-secondary)]">
<template #left>
<button
<button
@click="goBack"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
>
@@ -18,7 +18,7 @@
</n-dropdown>
</template>
<template #right>
<button
<button
@click="showDownloadModal = true"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
:class="{ 'text-[var(--accent-color)]': hasDownloadableAssets }"
@@ -26,6 +26,14 @@
>
<n-icon :size="20"><DownloadOutline /></n-icon>
</button>
<button
@click="showApiSettings = true"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
:class="{ 'text-[var(--accent-color)]': isApiConfigured }"
title="API 设置"
>
<n-icon :size="20"><SettingsOutline /></n-icon>
</button>
</template>
</AppHeader>
@@ -52,7 +60,7 @@
class="canvas-flow"
>
<Background v-if="showGrid" :gap="20" :size="1" />
<MiniMap
<MiniMap
v-if="!isMobile"
position="bottom-right"
:pannable="true"
@@ -62,14 +70,14 @@
<!-- Left toolbar | 左侧工具栏 -->
<aside class="absolute left-4 top-1/2 -translate-y-1/2 flex flex-col gap-1 p-2 bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] shadow-lg z-10">
<button
<button
@click="showNodeMenu = !showNodeMenu"
class="w-10 h-10 flex items-center justify-center rounded-xl bg-[var(--accent-color)] text-white hover:bg-[var(--accent-hover)] transition-colors"
title="添加节点"
>
<n-icon :size="20"><AddOutline /></n-icon>
</button>
<button
<button
@click="showWorkflowPanel = true"
class="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-[var(--bg-tertiary)] transition-colors"
title="工作流模板"
@@ -77,8 +85,8 @@
<n-icon :size="20"><AppsOutline /></n-icon>
</button>
<div class="w-full h-px bg-[var(--border-color)] my-1"></div>
<button
v-for="tool in tools"
<button
v-for="tool in tools"
:key="tool.id"
@click="tool.action"
:disabled="tool.disabled && tool.disabled()"
@@ -90,12 +98,12 @@
</aside>
<!-- Node menu popup | 节点菜单弹窗 -->
<div
<div
v-if="showNodeMenu"
class="absolute left-20 top-1/2 -translate-y-1/2 bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] shadow-lg p-2 z-20"
>
<button
v-for="nodeType in nodeTypeOptions"
<button
v-for="nodeType in nodeTypeOptions"
:key="nodeType.type"
@click="addNewNode(nodeType.type)"
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors text-left"
@@ -107,16 +115,16 @@
<!-- Bottom controls | 底部控制 -->
<div class="absolute bottom-4 left-4 flex items-center gap-2 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)] p-1">
<!-- <button
@click="showGrid = !showGrid"
<!-- <button
@click="showGrid = !showGrid"
:class="showGrid ? 'bg-[var(--accent-color)] text-white' : 'hover:bg-[var(--bg-tertiary)]'"
class="p-2 rounded transition-colors"
title="切换网格"
>
<n-icon :size="16"><GridOutline /></n-icon>
</button> -->
<button
@click="fitView({ padding: 0.2 })"
<button
@click="fitView({ padding: 0.2 })"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded transition-colors"
title="适应视图"
>
@@ -136,13 +144,13 @@
<!-- Bottom input panel (floating) | 底部输入面板悬浮 -->
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 w-full max-w-2xl px-4 z-20">
<!-- Processing indicator | 处理中指示器 -->
<div
v-if="isProcessing"
<div
v-if="isProcessing"
class="mb-3 p-3 bg-[var(--bg-primary)] rounded-xl border border-[var(--accent-color)] animate-pulse"
>
<div class="flex items-center gap-2 text-sm text-[var(--accent-color)] mb-2">
<n-spin :size="14" />
<span>正在创建生成任务...</span>
<span>正在生成提示词...</span>
</div>
<div v-if="currentResponse" class="text-sm text-[var(--text-primary)] whitespace-pre-wrap">
{{ currentResponse }}
@@ -150,31 +158,6 @@
</div>
<div class="bg-[var(--bg-primary)] rounded-xl border border-[var(--border-color)] p-3">
<div class="mb-2 flex flex-wrap items-center gap-2">
<button
v-for="modeItem in creationModes"
:key="modeItem.id"
@click="setCreationMode(modeItem.id)"
class="px-3 py-1.5 text-xs rounded-lg border transition-colors"
:class="creationMode === modeItem.id ? 'bg-[var(--accent-color)] text-white border-[var(--accent-color)]' : 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'"
>
{{ modeItem.label }}
</button>
<label
v-if="needsFirstFrame"
class="px-3 py-1.5 text-xs rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-secondary)] cursor-pointer hover:text-[var(--text-primary)]"
>
{{ firstFrameFile ? `图片 · ${firstFrameFile.name}` : '上传图片' }}
<input type="file" accept="image/*" class="hidden" @change="event => handleFrameFile('first', event)" />
</label>
<button
v-if="firstFrameFile"
@click="clearFrameFiles"
class="px-2 py-1.5 text-xs rounded-lg text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]"
>
清空图片
</button>
</div>
<textarea
v-model="chatInput"
:placeholder="inputPlaceholder"
@@ -186,14 +169,23 @@
/>
<div class="flex items-center justify-between mt-2">
<div class="flex items-center gap-2">
<span v-if="firstFramePreview" class="h-8 w-8 overflow-hidden rounded-md border border-[var(--border-color)] bg-[var(--bg-secondary)]">
<img :src="firstFramePreview" alt="参考图片" class="h-full w-full object-cover" />
</span>
<button
@click="handlePolish"
:disabled="isProcessing || !chatInput.trim()"
class="px-3 py-1.5 text-xs rounded-lg bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="AI 润色提示词"
>
AI 润色
</button>
</div>
<div class="flex items-center gap-3">
<button
<label class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<n-switch v-model:value="autoExecute" size="small" />
自动执行
</label>
<button
@click="sendMessage"
:disabled="isProcessing || !canSubmit"
:disabled="isProcessing"
class="w-8 h-8 rounded-xl bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] flex items-center justify-center transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<n-spin v-if="isProcessing" :size="16" />
@@ -202,10 +194,28 @@
</div>
</div>
</div>
<!-- Quick suggestions | 快捷建议 -->
<div class="flex flex-wrap items-center justify-center gap-2 mt-2">
<span class="text-xs text-[var(--text-secondary)]">推荐</span>
<button
v-for="tag in suggestions"
:key="tag"
@click="chatInput = tag"
class="px-2 py-0.5 text-xs rounded-full bg-[var(--bg-secondary)]/80 border border-[var(--border-color)] hover:border-[var(--accent-color)] transition-colors"
>
{{ tag }}
</button>
<button class="p-1 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
<n-icon :size="14"><RefreshOutline /></n-icon>
</button>
</div>
</div>
</div>
<!-- API Settings Modal | API 设置弹窗 -->
<ApiSettings v-model:show="showApiSettings" />
<!-- Rename Modal | 重命名弹窗 -->
<n-modal v-model:show="showRenameModal" preset="dialog" title="重命名项目">
<n-input v-model:value="renameValue" placeholder="请输入项目名称" />
@@ -242,13 +252,15 @@ import { useRouter, useRoute } from 'vue-router'
import { VueFlow, useVueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { MiniMap } from '@vue-flow/minimap'
import { NIcon, NDropdown, NSpin, NModal, NInput, NButton } from 'naive-ui'
import {
import { NIcon, NSwitch, NDropdown, NMessageProvider, NSpin, NModal, NInput, NButton } from 'naive-ui'
import {
ChevronBackOutline,
ChevronDownOutline,
SettingsOutline,
AddOutline,
ImageOutline,
SendOutline,
RefreshOutline,
TextOutline,
VideocamOutline,
ColorPaletteOutline,
@@ -265,12 +277,19 @@ import {
import { nodes, edges, addNode, addNodes, addEdge, addEdges, updateNode, initSampleData, loadProject, saveProject, clearCanvas, canvasViewport, updateViewport, undo, redo, canUndo, canRedo, manualSaveHistory, startBatchOperation, endBatchOperation } from '../stores/canvas'
import { loadAllModels } from '../stores/models'
import { useChat, useWorkflowOrchestrator } from '../hooks'
import { useModelStore } from '../stores/pinia'
import { projects, initProjectsStore, updateProject, renameProject, currentProject } from '../stores/projects'
// API Settings component | API 设置组件
import ApiSettings from '../components/ApiSettings.vue'
import DownloadModal from '../components/DownloadModal.vue'
import WorkflowPanel from '../components/WorkflowPanel.vue'
import AppHeader from '../components/AppHeader.vue'
// API Config state | API 配置状态
const modelStore = useModelStore()
const isApiConfigured = computed(() => !!modelStore.currentApiKey)
// Initialize models on page load | 页面加载时初始化模型
onMounted(() => {
loadAllModels()
@@ -294,11 +313,11 @@ const CHAT_TEMPLATES = {
const currentTemplate = ref('imagePrompt')
// Chat hook with image prompt template | 问答 hook
const {
loading: chatLoading,
status: chatStatus,
currentResponse,
send: sendChat
const {
loading: chatLoading,
status: chatStatus,
currentResponse,
send: sendChat
} = useChat({
systemPrompt: CHAT_TEMPLATES.imagePrompt.systemPrompt,
model: CHAT_TEMPLATES.imagePrompt.model
@@ -355,25 +374,12 @@ const edgeTypes = {
// UI state | UI状态
const showNodeMenu = ref(false)
const chatInput = ref('')
const autoExecute = ref(false)
const isMobile = ref(false)
const showGrid = ref(true)
const showApiSettings = ref(false)
const isProcessing = ref(false)
const creationModes = [
{ id: 'text-image', label: '文生图' },
{ id: 'text-video', label: '文生视频' },
{ id: 'image-video', label: '图生视频' }
]
const creationMode = ref('text-image')
const firstFrameFile = ref(null)
const firstFramePreview = ref('')
const needsFirstFrame = computed(() => creationMode.value === 'image-video')
const canSubmit = computed(() => {
if (!chatInput.value.trim()) return false
if (needsFirstFrame.value && !firstFrameFile.value) return false
return true
})
// Flow key for forcing re-render on project switch | 项目切换时强制重新渲染的 key
const flowKey = ref(Date.now())
@@ -386,7 +392,7 @@ const renameValue = ref('')
// Check if has downloadable assets | 检查是否有可下载素材
const hasDownloadableAssets = computed(() => {
return nodes.value.some(n =>
return nodes.value.some(n =>
(n.type === 'image' || n.type === 'video') && n.data?.url
)
})
@@ -426,61 +432,34 @@ const nodeTypeOptions = [
]
// Input placeholder | 输入占位符
const inputPlaceholder = computed(() => {
if (creationMode.value === 'text-image') return '写清楚画面、主体、构图、光线、比例和 SKG 产品露出方式'
if (creationMode.value === 'image-video') return '上传图片后,写人物动作、镜头运动、产品细节保持和视频节奏'
return '写清楚画面、动作、镜头、产品出现方式、视频比例和时长'
})
const inputPlaceholder = '你可以试着说"帮我生成一个二次元的卡通角色"'
const setCreationMode = (mode) => {
creationMode.value = mode
if (mode !== 'image-video') {
clearFrameFiles()
}
}
const fileToDataUrl = (file) => new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(reader.error || new Error('读取图片失败'))
reader.readAsDataURL(file)
})
const handleFrameFile = (slot, event) => {
const file = event?.target?.files?.[0]
if (!file) return
const url = URL.createObjectURL(file)
if (slot === 'first') {
if (firstFramePreview.value) URL.revokeObjectURL(firstFramePreview.value)
firstFrameFile.value = file
firstFramePreview.value = url
}
}
const clearFrameFiles = () => {
if (firstFramePreview.value) URL.revokeObjectURL(firstFramePreview.value)
firstFrameFile.value = null
firstFramePreview.value = ''
}
// Quick suggestions | 快捷建议
const suggestions = [
'像个魔法森林',
'三只不同的小猫',
'生成多角度分镜',
'夏日田野环绕漫步'
]
// Add new node | 添加新节点
const addNewNode = async (type) => {
// Calculate viewport center position | 计算视口中心位置
const viewportCenterX = -viewport.value.x / viewport.value.zoom + (window.innerWidth / 2) / viewport.value.zoom
const viewportCenterY = -viewport.value.y / viewport.value.zoom + (window.innerHeight / 2) / viewport.value.zoom
// Add node at viewport center | 在视口中心添加节点
const nodeId = addNode(type, { x: viewportCenterX - 100, y: viewportCenterY - 100 })
// Set highest z-index | 设置最高层级
const maxZIndex = Math.max(0, ...nodes.value.map(n => n.zIndex || 0))
updateNode(nodeId, { zIndex: maxZIndex + 1 })
// Force Vue Flow to recalculate node dimensions | 强制 Vue Flow 重新计算节点尺寸
setTimeout(() => {
updateNodeInternals(nodeId)
}, 50)
showNodeMenu.value = false
}
@@ -543,22 +522,22 @@ const onConnect = (params) => {
// Check connection types | 检查连接类型
const sourceNode = nodes.value.find(n => n.id === params.source)
const targetNode = nodes.value.find(n => n.id === params.target)
if (sourceNode?.type === 'image' && targetNode?.type === 'videoConfig') {
// Use imageRole edge type | 使用图片角色边类型
addEdge({
...params,
type: 'imageRole',
data: { imageRole: 'first_frame_image' } // Default reference image | 默认参考图
data: { imageRole: 'first_frame_image' } // Default to first frame | 默认首帧
})
} else if (sourceNode?.type === 'text' && targetNode?.type === 'imageConfig') {
// Use promptOrder edge type | 使用提示词顺序边类型
// Calculate next order number | 计算下一个顺序号
const existingTextEdges = edges.value.filter(e =>
const existingTextEdges = edges.value.filter(e =>
e.target === params.target && e.type === 'promptOrder'
)
const nextOrder = existingTextEdges.length + 1
addEdge({
...params,
type: 'promptOrder',
@@ -625,7 +604,7 @@ const onNodeClick = (event) => {
// nodes.value.forEach(node => {
// updateNode(node.id, { selected: false })
// })
// // Select clicked node | 选中的节点
// const clickedNode = nodes.value.find(n => n.id === event.node.id)
// if (clickedNode) {
@@ -642,7 +621,7 @@ const handleViewportChange = (newViewport) => {
const onEdgesChange = (changes) => {
// Check if any edge is being removed | 检查是否有边被删除
const hasRemoval = changes.some(change => change.type === 'remove')
if (hasRemoval) {
// Trigger history save after edge removal | 边删除后触发历史保存
nextTick(() => {
@@ -703,10 +682,48 @@ const handleEnterKey = (e) => {
sendMessage()
}
// Handle AI polish | 处理 AI 润色
const handlePolish = async () => {
const input = chatInput.value.trim()
if (!input) return
// Check API configuration | 检查 API 配置
if (!isApiConfigured.value) {
window.$message?.warning('生成接口未就绪,请稍后重试')
showApiSettings.value = true
return
}
isProcessing.value = true
const originalInput = chatInput.value
try {
// Call chat API to polish the prompt | 调用 AI 润色提示词
const result = await sendChat(input, true)
if (result) {
chatInput.value = result
window.$message?.success('提示词已润色')
}
} catch (err) {
chatInput.value = originalInput
window.$message?.error(err.message || '润色失败')
} finally {
isProcessing.value = false
}
}
// Send message | 发送消息
const sendMessage = async () => {
const input = chatInput.value.trim()
if (!input || !canSubmit.value) return
if (!input) return
// Check API configuration | 检查 API 配置
if (!isApiConfigured.value) {
window.$message?.warning('生成接口未就绪,请稍后重试')
showApiSettings.value = true
return
}
isProcessing.value = true
const content = chatInput.value
@@ -721,63 +738,54 @@ const sendMessage = async () => {
const baseX = 100
const baseY = maxY + 200
const textNodeId = addNode('text', { x: baseX, y: baseY }, {
content,
label: '提示词'
})
if (autoExecute.value) {
// Auto-execute mode: analyze intent and execute workflow | 自动执行模式:分析意图并执行工作流
window.$message?.info('正在分析工作流...')
if (creationMode.value === 'text-image') {
const imageConfigNodeId = addNode('imageConfig', { x: baseX + 400, y: baseY }, {
label: '文生图',
autoExecute: true
try {
// Analyze user intent | 分析用户意图
const result = await analyzeIntent(content)
// Ensure we have valid workflow params | 确保有效的工作流参数
const workflowParams = {
workflow_type: result?.workflow_type || WORKFLOW_TYPES.TEXT_TO_IMAGE,
image_prompt: result?.image_prompt || content,
video_prompt: result?.video_prompt || content,
character: result?.character,
shots: result?.shots,
multi_angle: result?.multi_angle,
picture_book: result?.picture_book
}
window.$message?.info(`执行工作流: ${result?.description || '文生图'}`)
// Execute the workflow | 执行工作流
await executeWorkflow(workflowParams, { x: baseX, y: baseY })
window.$message?.success('工作流已启动')
} catch (err) {
console.error('Workflow error:', err)
// Fallback to simple text-to-image | 回退到文生图
window.$message?.warning('使用默认文生图工作流')
await createTextToImageWorkflow(content, { x: baseX, y: baseY })
}
} else {
// Manual mode: just create nodes | 手动模式:仅创建节点
const textNodeId = addNode('text', { x: baseX, y: baseY }, {
content: content,
label: '提示词'
})
const imageConfigNodeId = addNode('imageConfig', { x: baseX + 400, y: baseY }, {
label: '文生图'
})
addEdge({
source: textNodeId,
target: imageConfigNodeId,
sourceHandle: 'right',
targetHandle: 'left'
})
} else {
let videoX = baseX + 400
let promptY = baseY
const imageNodeIds = []
if (needsFirstFrame.value && firstFrameFile.value) {
const dataUrl = await fileToDataUrl(firstFrameFile.value)
const firstId = addNode('image', { x: baseX, y: baseY + 160 }, {
url: dataUrl,
base64: dataUrl,
label: '参考图'
})
imageNodeIds.push({ id: firstId, role: 'first_frame_image' })
promptY = baseY - 140
updateNode(textNodeId, { zIndex: 5 })
}
const videoConfigNodeId = addNode('videoConfig', { x: videoX, y: promptY }, {
label: creationMode.value === 'text-video' ? '文生视频' : '图生视频',
autoExecute: true
})
addEdge({
source: textNodeId,
target: videoConfigNodeId,
sourceHandle: 'right',
targetHandle: 'left',
type: 'promptOrder',
data: { promptOrder: 1 }
})
for (const item of imageNodeIds) {
addEdge({
source: item.id,
target: videoConfigNodeId,
sourceHandle: 'right',
targetHandle: 'left',
type: 'imageRole',
data: { imageRole: item.role }
})
}
}
} catch (err) {
window.$message?.error(err.message || '创建失败')
@@ -800,7 +808,7 @@ const checkMobile = () => {
const loadProjectById = (projectId) => {
// Update flow key to force VueFlow re-render | 更新 key 强制 VueFlow 重新渲染
flowKey.value = Date.now()
if (projectId && projectId !== 'new') {
loadProject(projectId)
} else {
@@ -828,13 +836,13 @@ watch(
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
// Initialize projects store | 初始化项目存储
initProjectsStore()
// Load project data | 加载项目数据
loadProjectById(route.params.id)
// Check for initial prompt from home page | 检查来自首页的初始提示词
const initialPrompt = sessionStorage.getItem('ai-canvas-initial-prompt')
if (initialPrompt) {
@@ -850,7 +858,6 @@ onMounted(() => {
// Cleanup on unmount | 卸载时清理
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
clearFrameFiles()
// Save project before leaving | 离开前保存项目
saveProject()
})

View File

@@ -3,10 +3,15 @@
<div class="min-h-screen h-screen overflow-y-auto bg-[var(--bg-primary)]">
<!-- Header | 顶部导航 -->
<AppHeader>
<template #left>
<div class="flex h-8 items-center rounded-full bg-white px-3 shadow-sm">
<img src="/skg-logo-black.svg" alt="SKG" class="h-6 w-auto dark:invert" />
</div>
<template #right>
<button
@click="showApiSettings = true"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
:class="{ 'text-[var(--accent-color)]': isApiConfigured }"
title="API 设置"
>
<n-icon :size="20"><SettingsOutline /></n-icon>
</button>
</template>
</AppHeader>
@@ -14,17 +19,17 @@
<main class="max-w-5xl mx-auto px-4 py-8 md:py-16">
<!-- Welcome section | 欢迎区域 -->
<section class="text-center mb-12">
<div class="flex items-center justify-center mb-8">
<img src="/skg-logo-black.svg" alt="SKG" class="h-12 w-auto dark:invert" />
<div class="flex items-center justify-center gap-4 mb-8">
<img src="/skg-logo-black.svg" alt="SKG" class="h-12 w-auto md:h-16 dark:invert" />
<h1 class="sr-only">SKG</h1>
</div>
<!-- Input area | 输入区域 -->
<div class="max-w-2xl mx-auto">
<div class="bg-[var(--bg-secondary)] rounded-2xl border border-[var(--border-color)] p-4 shadow-sm">
<textarea
v-model="inputText"
placeholder="写提示词,生成图片或视频,结果会放进画布"
placeholder="输入你的创意,开始新项目"
class="w-full bg-transparent resize-none outline-none text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] min-h-[80px]"
@keydown.enter.ctrl="handleCreateWithInput"
/>
@@ -38,7 +43,7 @@
</button> -->
</div>
<div class="flex items-center gap-3">
<button
<button
@click="handleCreateWithInput"
class="w-8 h-8 rounded-xl bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] flex items-center justify-center transition-colors"
>
@@ -47,7 +52,22 @@
</div>
</div>
</div>
<!-- Quick suggestions | 快捷建议 -->
<div class="flex flex-wrap items-center justify-center gap-2 mt-4">
<span class="text-sm text-[var(--text-secondary)]">推荐</span>
<button
v-for="tag in suggestions"
:key="tag"
@click="inputText = tag"
class="px-3 py-1.5 text-sm rounded-full bg-[var(--bg-secondary)] border border-[var(--border-color)] hover:border-[var(--accent-color)] transition-colors"
>
{{ tag }}
</button>
<button class="p-1.5 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors">
<n-icon :size="16"><RefreshOutline /></n-icon>
</button>
</div>
</div>
</section>
@@ -55,7 +75,7 @@
<section ref="projectsSection">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-[var(--text-primary)]">我的项目</h2>
<button
<button
@click="createNewProject"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white transition-colors"
>
@@ -63,32 +83,32 @@
新建项目
</button>
</div>
<!-- Empty state | 空状态 -->
<div v-if="projects.length === 0" class="text-center py-12 bg-[var(--bg-secondary)] rounded-xl border border-dashed border-[var(--border-color)]">
<n-icon :size="48" class="text-[var(--text-secondary)] mb-4"><FolderOutline /></n-icon>
<p class="text-[var(--text-secondary)] mb-4">还没有项目创建一个开始吧</p>
<button
<button
@click="createNewProject"
class="px-4 py-2 text-sm rounded-lg bg-[var(--accent-color)] hover:bg-[var(--accent-hover)] text-white transition-colors"
>
创建第一个项目
</button>
</div>
<!-- Projects grid | 项目网格 -->
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div
v-for="project in projects"
<div
v-for="project in projects"
:key="project.id"
class="group relative"
>
<!-- Project card | 项目卡片 -->
<div
<div
@click="openProject(project)"
class="cursor-pointer"
>
<div
<div
class="aspect-video rounded-xl overflow-hidden bg-[var(--bg-tertiary)] mb-2 border border-[var(--border-color)] relative"
@mouseenter="handleThumbnailHover(project, true)"
@mouseleave="handleThumbnailHover(project, false)"
@@ -96,7 +116,7 @@
<!-- Thumbnail or placeholder | 缩略图或占位 -->
<template v-if="project.thumbnail">
<!-- Video thumbnail | 视频缩略图 -->
<video
<video
v-if="isVideoUrl(project.thumbnail)"
:ref="el => setVideoRef(project.id, el)"
:src="project.thumbnail"
@@ -106,9 +126,9 @@
playsinline
/>
<!-- Image thumbnail | 图片缩略图 -->
<img
<img
v-else
:src="project.thumbnail"
:src="project.thumbnail"
:alt="project.name"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
@@ -116,7 +136,7 @@
<div v-else class="w-full h-full flex items-center justify-center">
<n-icon :size="32" class="text-[var(--text-secondary)]"><DocumentOutline /></n-icon>
</div>
<!-- Hover overlay | 悬浮遮罩 -->
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<span class="text-white text-sm">打开项目</span>
@@ -125,11 +145,11 @@
<p class="text-sm text-[var(--text-primary)] truncate">{{ project.name }}</p>
<p class="text-xs text-[var(--text-secondary)]">{{ formatDate(project.updatedAt) }}</p>
</div>
<!-- Project actions | 项目操作 -->
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<n-dropdown :options="getProjectActions(project)" @select="(key) => handleProjectAction(key, project)" placement="bottom-end">
<button
<button
@click.stop
class="p-1.5 bg-white/90 dark:bg-gray-800/90 rounded-lg shadow hover:bg-white dark:hover:bg-gray-800 transition-colors"
>
@@ -144,14 +164,14 @@
<!-- Left sidebar | 左侧边栏 -->
<aside class="fixed left-4 top-1/2 -translate-y-1/2 hidden md:flex flex-col gap-2 p-2 bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] shadow-sm">
<button
<button
@click="createNewProject"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
title="新建项目"
>
<n-icon :size="20"><DocumentOutline /></n-icon>
</button>
<button
<button
@click="scrollToProjects"
class="p-2 hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors"
title="我的项目"
@@ -160,6 +180,9 @@
</button>
</aside>
<!-- API Settings Modal | API 设置弹窗 -->
<ApiSettings v-model:show="showApiSettings" @saved="refreshApiConfig" />
<!-- Rename modal | 重命名弹窗 -->
<n-modal v-model:show="showRenameModal" preset="dialog" title="重命名项目">
<n-input v-model:value="renameValue" placeholder="请输入项目名称" />
@@ -176,31 +199,47 @@
* Home view component | 首页视图组件
* Entry point with project list and creation input
*/
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { NIcon, NDropdown, NModal, NInput, NButton, useDialog } from 'naive-ui'
import {
import {
AddOutline,
ImageOutline,
SendOutline,
RefreshOutline,
DocumentOutline,
FolderOutline,
EllipsisHorizontalOutline,
CreateOutline,
CopyOutline,
SettingsOutline,
TrashOutline
} from '@vicons/ionicons5'
import {
projects,
initProjectsStore,
createProject,
deleteProject,
duplicateProject,
renameProject
import {
projects,
initProjectsStore,
createProject,
deleteProject,
duplicateProject,
renameProject
} from '../stores/projects'
import { useModelStore } from '../stores/pinia'
import ApiSettings from '../components/ApiSettings.vue'
import AppHeader from '../components/AppHeader.vue'
const router = useRouter()
const dialog = useDialog()
const modelStore = useModelStore()
// API Settings state | API 设置状态
const showApiSettings = ref(false)
const isApiConfigured = computed(() => !!modelStore.currentApiKey)
// Refresh API config state | 刷新 API 配置状态
const refreshApiConfig = () => {
// 通过 computed 自动更新,不需要手动刷新
}
// Video refs for hover play | 视频引用用于悬停播放
const videoRefs = new Map()
@@ -216,10 +255,10 @@ const setVideoRef = (projectId, el) => {
// Handle thumbnail hover | 处理缩略图悬停
const handleThumbnailHover = (project, isHovering) => {
if (!isVideoUrl(project.thumbnail)) return
const video = videoRefs.get(project.id)
if (!video) return
if (isHovering) {
video.play().catch(() => {
// Ignore play errors (e.g., autoplay policy)
@@ -238,13 +277,21 @@ const showRenameModal = ref(false)
const renameValue = ref('')
const renameTargetId = ref(null)
// Suggestions tags | 建议标签
const suggestions = [
'雨中魔法森林',
'日式街面美食摄影',
'瀑布水流飞溅',
'雨天富声旁边花语'
]
// Format date | 格式化日期
const formatDate = (date) => {
if (!date) return ''
const d = new Date(date)
const now = new Date()
const diff = now - d
// Less than 1 minute | 小于1分钟
if (diff < 60000) return '刚刚'
// Less than 1 hour | 小于1小时
@@ -305,24 +352,46 @@ const confirmRename = () => {
renameValue.value = ''
}
// Check internal API before navigation | 跳转前检查内部接口
const checkApiKeyAndNavigate = (callback) => {
if (!isApiConfigured.value) {
dialog.warning({
title: '生成接口未就绪',
content: '当前登录会话还不能使用生成接口,请稍后重试或联系管理员。',
positiveText: '知道了'
})
return false
}
callback()
return true
}
// Create new project | 创建新项目
const createNewProject = () => {
const id = createProject('未命名项目')
router.push(`/p/${id}`)
checkApiKeyAndNavigate(() => {
const id = createProject('未命名项目')
router.push(`/p/${id}`)
})
}
// Create project with input text | 使用输入文本创建项目
const handleCreateWithInput = () => {
const name = inputText.value.trim() || '未命名项目'
const id = createProject(name)
sessionStorage.setItem('ai-canvas-initial-prompt', inputText.value.trim())
inputText.value = ''
router.push(`/p/${id}`)
checkApiKeyAndNavigate(() => {
const name = inputText.value.trim() || '未命名项目'
const id = createProject(name)
// Store the input text to be used as initial prompt
sessionStorage.setItem('ai-canvas-initial-prompt', inputText.value.trim())
inputText.value = ''
router.push(`/p/${id}`)
})
}
// Open existing project | 打开已有项目
const openProject = (project) => {
router.push(`/p/${project.id}`)
checkApiKeyAndNavigate(() => {
router.push(`/p/${project.id}`)
})
}
// Check if URL is a video | 检查 URL 是否为视频