Commit fde0aba7 authored by yaoke.yk's avatar yaoke.yk

测试尝试用Agnes测demo

parent 0aa3bd46
......@@ -41,11 +41,6 @@ const RATIO_OPTIONS = [
{ value: "9:16", label: "9:16 竖屏" },
{ value: "1:1", label: "1:1 方形" },
];
const VIDEO_MODEL_OPTIONS = [
{ value: "doubao-seedance-2-0-fast-260128", label: "Doubao-Seedance-2-0 fast" },
{ value: "doubao-seedance-2-0-260128", label: "Doubao-Seedance-2-0" },
];
const DEFAULT_VIDEO_MODEL = VIDEO_MODEL_OPTIONS[0].value;
const MAX_CHARACTERS = 5;
const MAX_PROPS = 5;
const PROMPT_PREVIEW_LIMIT = 80;
......@@ -71,7 +66,6 @@ interface SbDraft {
freeformPrompt: string;
selectedDuration: number;
selectedRatio: string;
selectedModel: string;
audioOn: boolean;
}
......@@ -328,7 +322,6 @@ export function StoryboardWorkspace() {
const [freeformPrompt, setFreeformPrompt] = useState<string>("");
const [selectedDuration, setSelectedDuration] = useState(DEFAULT_VIDEO_DURATION);
const [selectedRatio, setSelectedRatio] = useState<string>("16:9");
const [selectedModel, setSelectedModel] = useState<string>(DEFAULT_VIDEO_MODEL);
const [audioOn, setAudioOn] = useState(true);
const [generatingVideoId, setGeneratingVideoId] = useState<string | null>(null);
......@@ -437,7 +430,6 @@ export function StoryboardWorkspace() {
setFreeformPrompt(draft.freeformPrompt);
setSelectedDuration(normalizeVideoDuration(draft.selectedDuration));
setSelectedRatio(draft.selectedRatio);
setSelectedModel(draft.selectedModel);
setAudioOn(draft.audioOn);
if (nextSceneKey && draft.sceneKey !== nextSceneKey) {
updateSb.mutate({ id: expanded.id, patch: { sceneImageKey: nextSceneKey } });
......@@ -503,7 +495,6 @@ export function StoryboardWorkspace() {
freeformPrompt,
selectedDuration,
selectedRatio,
selectedModel,
audioOn,
});
}, [
......@@ -511,7 +502,7 @@ export function StoryboardWorkspace() {
generationMode, shortDescription, characterKeys, sceneKey, propKeys,
firstFrameImageKey, firstFrameImageUrl, lastFrameImageKey, lastFrameImageUrl,
freeformPrompt,
selectedDuration, selectedRatio, selectedModel, audioOn,
selectedDuration, selectedRatio, audioOn,
]);
useEffect(() => {
......@@ -680,7 +671,6 @@ export function StoryboardWorkspace() {
freeformPrompt: freeformPrompt.trim(),
duration: selectedDuration,
ratio: selectedRatio,
model: selectedModel,
generateAudio: audioOn,
});
setActiveVideoSbId(expanded.id);
......@@ -950,20 +940,6 @@ export function StoryboardWorkspace() {
<div className="flex items-center gap-2">
<div className="relative flex-1">
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={!expanded}
title="视频生成模型"
className={`w-full appearance-none pl-3 pr-7 py-2 rounded-md bg-white text-xs text-[#111827] cursor-pointer disabled:opacity-50 transition-colors ${CONTROL_SURFACE} ${FOCUS_RING}`}
>
{VIDEO_MODEL_OPTIONS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
</div>
<div className="relative flex-1">
<select
value={selectedRatio}
onChange={(e) => setSelectedRatio(e.target.value)}
disabled={!expanded}
......
......@@ -174,8 +174,6 @@ export interface StructuredVideoRequest {
} | null;
duration?: number | null;
ratio?: string | null;
/** Seedance 模型 ID(如 doubao-seedance-2-0-fast-260128 / doubao-seedance-2-0-260128),缺省由后端 ArkProperties 兜底 */
model?: string | null;
generateAudio?: boolean | null;
}
......
......@@ -6,6 +6,7 @@ import {
OfficeBuilding,
Operation,
PriceTag,
Setting,
Tickets,
UserFilled,
} from '@element-plus/icons-vue';
......@@ -50,6 +51,11 @@ export const routes: RouteRecordRaw[] = [
meta: { title: '计费价格', icon: PriceTag, authority: ['SUPER_ADMIN'] },
},
{
path: '/model-config',
component: () => import('./views/ModelConfigView.vue'),
meta: { title: '模型配置', icon: Setting, authority: ['SUPER_ADMIN', 'OPERATOR'] },
},
{
path: '/plans',
component: () => import('./views/PlanManagementView.vue'),
meta: { title: '套餐管理', icon: PriceTag, authority: ['SUPER_ADMIN'] },
......
<script setup lang="ts">
import { ElMessage } from 'element-plus';
import { computed, onMounted, reactive, ref } from 'vue';
import { http } from '../api/http';
import { useAuthStore } from '../stores/auth';
type CapabilityKey = 'text' | 'image' | 'video';
interface PlatformModelItem {
provider: string;
providerName: string;
protocol: string;
baseUrl: string;
apiKey: string;
modelId: string;
modelName: string;
endpoint: string;
statusEndpoint?: string | null;
resolveIp?: string | null;
enabled: boolean;
}
type PlatformModelSettings = Record<CapabilityKey, PlatformModelItem>;
const MASKED_API_KEY = '******';
const auth = useAuthStore();
const loading = ref(false);
const saving = ref(false);
const capabilityMeta: Array<{
key: CapabilityKey;
title: string;
tag: string;
description: string;
endpointPlaceholder: string;
statusPlaceholder?: string;
}> = [
{
key: 'text',
title: '文本模型',
tag: '剧本 / 分镜 / 提示词',
description: '用于大纲、剧本理解、分镜拆解、提示词生成等文本任务。',
endpointPlaceholder: '/chat/completions',
},
{
key: 'image',
title: '图片模型',
tag: '角色 / 场景 / 道具图',
description: '用于角色图、场景图、道具图、封面等图片生成任务。',
endpointPlaceholder: '/images/generations',
},
{
key: 'video',
title: '视频模型',
tag: '分镜视频生成',
description: '用于参考图生成视频、首尾帧生成视频等视频任务。',
endpointPlaceholder: '/videos',
statusPlaceholder: '/videos/{id}',
},
];
const settings = reactive<PlatformModelSettings>({
text: emptyItem('text'),
image: emptyItem('image'),
video: emptyItem('video'),
});
const canEdit = computed(() => auth.role === 'SUPER_ADMIN');
function emptyItem(key: CapabilityKey): PlatformModelItem {
const presets = volcenginePreset(key);
return {
...presets,
apiKey: '',
enabled: false,
};
}
function volcenginePreset(key: CapabilityKey): PlatformModelItem {
if (key === 'text') {
return {
provider: 'volcengine',
providerName: 'Volcengine ARK',
protocol: 'openai',
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
apiKey: MASKED_API_KEY,
modelId: 'doubao-seed-2-0-code-preview-260215',
modelName: 'Doubao Text',
endpoint: '/chat/completions',
statusEndpoint: '',
resolveIp: '',
enabled: false,
};
}
if (key === 'image') {
return {
provider: 'volcengine',
providerName: 'Volcengine ARK',
protocol: 'seedream',
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
apiKey: MASKED_API_KEY,
modelId: 'doubao-seedream-5-0-260128',
modelName: 'Seedream Image',
endpoint: '/images/generations',
statusEndpoint: '',
resolveIp: '',
enabled: false,
};
}
return {
provider: 'volcengine',
providerName: 'Volcengine ARK',
protocol: 'seedance',
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
apiKey: MASKED_API_KEY,
modelId: 'doubao-seedance-2-0-fast-260128',
modelName: 'Seedance Image-to-Video',
endpoint: '/contents/generations/tasks',
statusEndpoint: '/contents/generations/tasks/{id}',
resolveIp: '',
enabled: false,
};
}
function agnesPreset(key: CapabilityKey): PlatformModelItem {
const common = {
provider: 'agnes',
providerName: 'Agnes',
protocol: 'openai',
baseUrl: 'https://apihub.agnes-ai.com/v1',
apiKey: settings[key].apiKey || '',
resolveIp: settings[key].resolveIp || '104.18.18.62',
enabled: true,
};
if (key === 'text') {
return {
...common,
modelId: 'agnes-2.0-flash',
modelName: 'Agnes 2.0 Flash',
endpoint: '/chat/completions',
statusEndpoint: '',
};
}
if (key === 'image') {
return {
...common,
modelId: 'agnes-image-2.0-flash',
modelName: 'Agnes Image 2.0 Flash',
endpoint: '/images/generations',
statusEndpoint: '',
};
}
return {
...common,
modelId: 'agnes-video-v2.0',
modelName: 'Agnes Video v2.0',
endpoint: '/videos',
statusEndpoint: '/videos/{id}',
};
}
function applyPreset(key: CapabilityKey, type: 'agnes' | 'volcengine') {
const oldApiKey = settings[key].apiKey;
Object.assign(settings[key], type === 'agnes' ? agnesPreset(key) : volcenginePreset(key));
settings[key].apiKey = oldApiKey || settings[key].apiKey;
}
function normalizeItem(key: CapabilityKey, item?: Partial<PlatformModelItem> | null): PlatformModelItem {
return {
...emptyItem(key),
...(item || {}),
enabled: Boolean(item?.enabled),
};
}
async function load() {
loading.value = true;
try {
const data = await http.get<Partial<PlatformModelSettings>>('/model-config');
for (const meta of capabilityMeta) {
Object.assign(settings[meta.key], normalizeItem(meta.key, data?.[meta.key]));
}
} finally {
loading.value = false;
}
}
function validate() {
for (const meta of capabilityMeta) {
const item = settings[meta.key];
if (!item.enabled) continue;
if (!item.baseUrl || !item.apiKey || !item.modelId || !item.endpoint) {
ElMessage.warning(`${meta.title} 启用后需要填写 Base URL、API Key、模型 ID 和调用路径`);
return false;
}
}
return true;
}
function buildPayload(): PlatformModelSettings {
return {
text: { ...settings.text },
image: { ...settings.image },
video: { ...settings.video },
};
}
async function save() {
if (!validate()) return;
saving.value = true;
try {
const data = await http.put<PlatformModelSettings>('/model-config', buildPayload());
for (const meta of capabilityMeta) {
Object.assign(settings[meta.key], normalizeItem(meta.key, data?.[meta.key]));
}
ElMessage.success('模型配置已保存,新生成任务将读取这份配置');
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<div>
<div class="page-head">
<div>
<h1>模型配置</h1>
<p>统一配置平台默认的文本、图片、视频模型。未启用时继续使用后端环境变量里的火山引擎配置。</p>
</div>
<el-button type="primary" :disabled="!canEdit" :loading="saving" @click="save">保存配置</el-button>
</div>
<el-alert
class="model-tip"
type="info"
show-icon
:closable="false"
title="API Key 保存后会以 ****** 显示;保持 ****** 或留空保存时,会继续沿用旧密钥。"
/>
<div v-loading="loading" class="model-grid">
<el-card v-for="meta in capabilityMeta" :key="meta.key" class="model-card" shadow="never">
<template #header>
<div class="card-head">
<div>
<strong>{{ meta.title }}</strong>
<span>{{ meta.tag }}</span>
</div>
<el-switch v-model="settings[meta.key].enabled" :disabled="!canEdit" active-text="启用配置" />
</div>
</template>
<p class="card-desc">{{ meta.description }}</p>
<div class="preset-row">
<el-button size="small" @click="applyPreset(meta.key, 'agnes')">填入 Agnes 模板</el-button>
<el-button size="small" @click="applyPreset(meta.key, 'volcengine')">恢复火山模板</el-button>
</div>
<el-form label-position="top" class="model-form" :disabled="!canEdit">
<div class="form-pair">
<el-form-item label="供应商 Key">
<el-input v-model="settings[meta.key].provider" placeholder="agnes / volcengine" />
</el-form-item>
<el-form-item label="供应商名称">
<el-input v-model="settings[meta.key].providerName" placeholder="Agnes" />
</el-form-item>
</div>
<div class="form-pair">
<el-form-item label="协议">
<el-select v-model="settings[meta.key].protocol">
<el-option label="OpenAI Compatible" value="openai" />
<el-option label="Seedream" value="seedream" />
<el-option label="Seedance" value="seedance" />
</el-select>
</el-form-item>
<el-form-item label="Base URL">
<el-input v-model="settings[meta.key].baseUrl" placeholder="https://apihub.agnes-ai.com/v1" />
</el-form-item>
</div>
<el-form-item label="API Key">
<el-input
v-model="settings[meta.key].apiKey"
type="password"
show-password
placeholder="粘贴供应商 API Key"
/>
</el-form-item>
<div class="form-pair">
<el-form-item label="模型 ID">
<el-input v-model="settings[meta.key].modelId" placeholder="agnes-2.0-flash" />
</el-form-item>
<el-form-item label="模型名称">
<el-input v-model="settings[meta.key].modelName" placeholder="展示在计费与日志里的名称" />
</el-form-item>
</div>
<div class="form-pair">
<el-form-item label="调用路径">
<el-input v-model="settings[meta.key].endpoint" :placeholder="meta.endpointPlaceholder" />
</el-form-item>
<el-form-item label="查询路径">
<el-input
v-model="settings[meta.key].statusEndpoint"
:disabled="!canEdit || meta.key !== 'video'"
:placeholder="meta.statusPlaceholder || '仅视频模型需要'"
/>
</el-form-item>
</div>
<el-form-item label="解析 IP(可选)">
<el-input v-model="settings[meta.key].resolveIp" placeholder="DNS 异常时填写,如 104.18.18.62" />
</el-form-item>
</el-form>
</el-card>
</div>
</div>
</template>
<style scoped>
.model-tip {
margin-bottom: 16px;
}
.model-grid {
display: grid;
grid-template-columns: repeat(3, minmax(280px, 1fr));
gap: 16px;
align-items: start;
}
.model-card {
border-color: var(--line);
border-radius: 8px;
}
.card-head {
display: flex;
justify-content: space-between;
gap: 14px;
align-items: center;
}
.card-head strong {
display: block;
font-size: 17px;
}
.card-head span,
.card-desc {
color: var(--muted);
font-size: 13px;
}
.card-desc {
margin: 0 0 12px;
line-height: 1.6;
}
.preset-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.model-form :deep(.el-form-item) {
margin-bottom: 12px;
}
.form-pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 1280px) {
.model-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.form-pair {
grid-template-columns: 1fr;
}
}
</style>
{"root":["./src/env.d.ts","./src/main.ts","./src/router.ts","./src/api/http.ts","./src/api/types.ts","./src/stores/auth.ts","./src/app.vue","./src/views/adminusermanagementview.vue","./src/views/auditlogsview.vue","./src/views/billingcostsview.vue","./src/views/billingrecordsview.vue","./src/views/dashboardview.vue","./src/views/loginview.vue","./src/views/notfoundview.vue","./src/views/planmanagementview.vue","./src/views/tenantdetailview.vue","./src/views/tenantlistview.vue","./src/views/usermanagementview.vue"],"version":"5.9.3"}
\ No newline at end of file
{"root":["./src/env.d.ts","./src/main.ts","./src/router.ts","./src/api/http.ts","./src/api/types.ts","./src/stores/auth.ts","./src/app.vue","./src/views/adminusermanagementview.vue","./src/views/auditlogsview.vue","./src/views/billingcostsview.vue","./src/views/billingrecordsview.vue","./src/views/dashboardview.vue","./src/views/loginview.vue","./src/views/modelconfigview.vue","./src/views/notfoundview.vue","./src/views/planmanagementview.vue","./src/views/tenantdetailview.vue","./src/views/tenantlistview.vue","./src/views/usermanagementview.vue"],"version":"5.9.3"}
\ No newline at end of file
......@@ -35,7 +35,6 @@ export default defineConfig(({ mode }) => {
'/admin-api': {
target: resolveBackendTarget(env),
changeOrigin: true,
rewrite: (path) => path.replace(/^\/admin-api/, '/admin'),
},
},
},
......
......@@ -25,6 +25,10 @@
</dependency>
<dependency>
<groupId>com.yaoai</groupId>
<artifactId>yaoai-ai-providers</artifactId>
</dependency>
<dependency>
<groupId>com.yaoai</groupId>
<artifactId>yaoai-security</artifactId>
</dependency>
<dependency>
......
package com.yaoai.admin.modelconfig;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.annotation.SaMode;
import com.yaoai.admin.audit.annotation.AdminAudited;
import com.yaoai.admin.auth.StpAdminUtil;
import com.yaoai.ai.providers.config.PlatformModelConfigService;
import com.yaoai.ai.providers.config.PlatformModelSettings;
import com.yaoai.common.result.Result;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@SaCheckLogin(type = "admin")
public class ModelConfigAdminController {
private final PlatformModelConfigService platformModelConfigService;
@GetMapping("/admin-api/model-config")
@SaCheckRole(type = "admin", value = {"SUPER_ADMIN", "OPERATOR"}, mode = SaMode.OR)
public Result<PlatformModelSettings> getConfig() {
return Result.ok(platformModelConfigService.getAdminSettings());
}
@PutMapping("/admin-api/model-config")
@AdminAudited(action = "MODEL_CONFIG_UPDATE", resource = "MODEL_CONFIG")
@SaCheckRole(type = "admin", value = {"SUPER_ADMIN"})
public Result<PlatformModelSettings> saveConfig(@RequestBody PlatformModelSettings settings) {
return Result.ok(platformModelConfigService.saveAdminSettings(settings, StpAdminUtil.getLoginIdAsLong()));
}
}
......@@ -20,8 +20,16 @@
<artifactId>yaoai-ai-core</artifactId>
</dependency>
<dependency>
<groupId>com.yaoai</groupId>
<artifactId>yaoai-domain</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
</dependency>
</dependencies>
</project>
package com.yaoai.ai.providers.config;
public enum ModelCapability {
TEXT("text"),
IMAGE("image"),
VIDEO("video");
private final String key;
ModelCapability(String key) {
this.key = key;
}
public String key() {
return key;
}
public static ModelCapability fromKey(String value) {
if (value == null) {
throw new IllegalArgumentException("capability is required");
}
for (ModelCapability capability : values()) {
if (capability.key.equalsIgnoreCase(value) || capability.name().equalsIgnoreCase(value)) {
return capability;
}
}
throw new IllegalArgumentException("unsupported capability: " + value);
}
}
package com.yaoai.ai.providers.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import com.yaoai.domain.entity.SystemSetting;
import com.yaoai.domain.mapper.SystemSettingMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
@Slf4j
@Service
public class PlatformModelConfigService {
public static final String SETTING_KEY = "ai.model.config";
public static final String MASKED_API_KEY = "******";
private final SystemSettingMapper systemSettingMapper;
private final ArkProperties arkProperties;
private final ObjectMapper objectMapper;
@Autowired
public PlatformModelConfigService(SystemSettingMapper systemSettingMapper, ArkProperties arkProperties) {
this(systemSettingMapper, arkProperties, new ObjectMapper());
}
PlatformModelConfigService(SystemSettingMapper systemSettingMapper,
ArkProperties arkProperties,
ObjectMapper objectMapper) {
this.systemSettingMapper = systemSettingMapper;
this.arkProperties = arkProperties;
this.objectMapper = objectMapper;
}
public ResolvedModelConfig resolve(ModelCapability capability) {
PlatformModelItem item = loadStoredSettings().get(capability);
if (isUsable(item)) {
return toResolved(item, fallback(capability));
}
return fallback(capability);
}
public PlatformModelSettings getAdminSettings() {
PlatformModelSettings settings = withFallbacks(loadStoredSettings());
maskApiKey(settings.getText());
maskApiKey(settings.getImage());
maskApiKey(settings.getVideo());
return settings;
}
public PlatformModelSettings saveAdminSettings(PlatformModelSettings input, Long adminId) {
PlatformModelSettings current = loadStoredSettings();
PlatformModelSettings next = withFallbacks(input == null ? new PlatformModelSettings() : input);
preserveMaskedApiKey(next.getText(), current.getText());
preserveMaskedApiKey(next.getImage(), current.getImage());
preserveMaskedApiKey(next.getVideo(), current.getVideo());
try {
String json = objectMapper.writeValueAsString(next);
SystemSetting setting = systemSettingMapper.findByKey(SETTING_KEY).orElseGet(SystemSetting::new);
setting.setSettingKey(SETTING_KEY);
setting.setSettingValue(json);
setting.setUpdatedAt(LocalDateTime.now());
setting.setUpdatedBy(adminId);
if (setting.getId() == null) {
systemSettingMapper.insert(setting);
} else {
systemSettingMapper.updateById(setting);
}
return getAdminSettings();
} catch (Exception e) {
log.error("save platform model config failed", e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "保存模型配置失败: " + e.getMessage());
}
}
private PlatformModelSettings loadStoredSettings() {
return systemSettingMapper.findByKey(SETTING_KEY)
.map(SystemSetting::getSettingValue)
.filter(StringUtils::hasText)
.map(this::parseSettings)
.orElseGet(PlatformModelSettings::new);
}
private PlatformModelSettings parseSettings(String json) {
try {
PlatformModelSettings settings = objectMapper.readValue(json, PlatformModelSettings.class);
return settings == null ? new PlatformModelSettings() : settings;
} catch (Exception e) {
log.warn("ignore invalid platform model config json", e);
return new PlatformModelSettings();
}
}
private PlatformModelSettings withFallbacks(PlatformModelSettings settings) {
PlatformModelSettings next = new PlatformModelSettings();
next.setText(merge(settings.getText(), fallback(ModelCapability.TEXT)));
next.setImage(merge(settings.getImage(), fallback(ModelCapability.IMAGE)));
next.setVideo(merge(settings.getVideo(), fallback(ModelCapability.VIDEO)));
return next;
}
private PlatformModelItem merge(PlatformModelItem item, ResolvedModelConfig fallback) {
PlatformModelItem next = item == null ? new PlatformModelItem() : item;
if (!StringUtils.hasText(next.getProvider())) next.setProvider(fallback.provider());
if (!StringUtils.hasText(next.getProviderName())) next.setProviderName(fallback.providerName());
if (!StringUtils.hasText(next.getProtocol())) next.setProtocol(fallback.protocol());
if (!StringUtils.hasText(next.getBaseUrl())) next.setBaseUrl(fallback.baseUrl());
if (!StringUtils.hasText(next.getApiKey())) next.setApiKey(fallback.apiKey());
if (!StringUtils.hasText(next.getModelId())) next.setModelId(fallback.modelId());
if (!StringUtils.hasText(next.getModelName())) next.setModelName(fallback.modelName());
if (!StringUtils.hasText(next.getEndpoint())) next.setEndpoint(fallback.endpoint());
if (!StringUtils.hasText(next.getStatusEndpoint())) next.setStatusEndpoint(fallback.statusEndpoint());
if (next.getEnabled() == null) next.setEnabled(false);
return next;
}
private ResolvedModelConfig toResolved(PlatformModelItem item, ResolvedModelConfig fallback) {
return new ResolvedModelConfig(
valueOr(item.getProvider(), fallback.provider()),
valueOr(item.getProviderName(), fallback.providerName()),
valueOr(item.getProtocol(), fallback.protocol()),
normalizeBaseUrl(valueOr(item.getBaseUrl(), fallback.baseUrl())),
valueOr(item.getApiKey(), fallback.apiKey()),
valueOr(item.getModelId(), fallback.modelId()),
valueOr(item.getModelName(), fallback.modelName()),
normalizeEndpoint(valueOr(item.getEndpoint(), fallback.endpoint())),
normalizeEndpoint(valueOr(item.getStatusEndpoint(), fallback.statusEndpoint())),
valueOr(item.getResolveIp(), fallback.resolveIp())
);
}
private ResolvedModelConfig fallback(ModelCapability capability) {
return switch (capability) {
case TEXT -> new ResolvedModelConfig(
"volcengine",
"Volcengine ARK",
"openai",
normalizeBaseUrl(arkProperties.getBaseUrl()),
arkProperties.getApiKey(),
arkProperties.getTextModel(),
"Doubao Text",
"/chat/completions",
null,
null
);
case IMAGE -> new ResolvedModelConfig(
"volcengine",
"Volcengine ARK",
"seedream",
normalizeBaseUrl(arkProperties.getBaseUrl()),
arkProperties.getApiKey(),
arkProperties.getImageModel(),
"Seedream Image",
"/images/generations",
null,
null
);
case VIDEO -> new ResolvedModelConfig(
"volcengine",
"Volcengine ARK",
"seedance",
normalizeBaseUrl(arkProperties.getBaseUrl()),
arkProperties.getApiKey(),
arkProperties.getVideoModel(),
"Seedance Image-to-Video",
"/contents/generations/tasks",
"/contents/generations/tasks/{id}",
null
);
};
}
private boolean isUsable(PlatformModelItem item) {
return item != null
&& Boolean.TRUE.equals(item.getEnabled())
&& StringUtils.hasText(item.getBaseUrl())
&& StringUtils.hasText(item.getApiKey())
&& StringUtils.hasText(item.getModelId())
&& !MASKED_API_KEY.equals(item.getApiKey());
}
private void preserveMaskedApiKey(PlatformModelItem next, PlatformModelItem current) {
if (next == null) return;
String apiKey = next.getApiKey();
if (!StringUtils.hasText(apiKey) || MASKED_API_KEY.equals(apiKey)) {
next.setApiKey(current == null ? "" : current.getApiKey());
}
}
private void maskApiKey(PlatformModelItem item) {
if (item != null && StringUtils.hasText(item.getApiKey())) {
item.setApiKey(MASKED_API_KEY);
}
}
private String valueOr(String value, String fallback) {
return StringUtils.hasText(value) ? value.trim() : fallback;
}
private String normalizeBaseUrl(String value) {
if (!StringUtils.hasText(value)) return value;
return value.trim().replaceAll("/+$", "");
}
private String normalizeEndpoint(String value) {
if (!StringUtils.hasText(value)) return value;
String trimmed = value.trim();
return trimmed.startsWith("/") ? trimmed : "/" + trimmed;
}
}
package com.yaoai.ai.providers.config;
import lombok.Data;
@Data
public class PlatformModelItem {
private String provider;
private String providerName;
private String protocol;
private String baseUrl;
private String apiKey;
private String modelId;
private String modelName;
private String endpoint;
private String statusEndpoint;
private String resolveIp;
private Boolean enabled;
}
package com.yaoai.ai.providers.config;
import lombok.Data;
@Data
public class PlatformModelSettings {
private PlatformModelItem text;
private PlatformModelItem image;
private PlatformModelItem video;
public PlatformModelItem get(ModelCapability capability) {
return switch (capability) {
case TEXT -> text;
case IMAGE -> image;
case VIDEO -> video;
};
}
public void set(ModelCapability capability, PlatformModelItem item) {
switch (capability) {
case TEXT -> text = item;
case IMAGE -> image = item;
case VIDEO -> video = item;
}
}
}
package com.yaoai.ai.providers.config;
import org.apache.hc.client5.http.DnsResolver;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClient;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.time.Duration;
@Component
public class ProviderRestClientFactory {
private static final String AGNES_API_HOST = "apihub.agnes-ai.com";
private static final String AGNES_API_RESOLVE_IP = "104.18.18.62";
public RestClient create(ResolvedModelConfig config) {
return create(config, Duration.ofSeconds(10), Duration.ofMinutes(2));
}
public RestClient create(ResolvedModelConfig config, Duration connectTimeout, Duration readTimeout) {
RestClient.Builder builder = RestClient.builder()
.baseUrl(config.baseUrl())
.defaultHeader("Content-Type", "application/json");
String resolveIp = resolveIp(config);
if (StringUtils.hasText(resolveIp)) {
builder.requestFactory(requestFactory(config, resolveIp, connectTimeout, readTimeout));
}
return builder.build();
}
private HttpComponentsClientHttpRequestFactory requestFactory(ResolvedModelConfig config,
String resolveIp,
Duration connectTimeout,
Duration readTimeout) {
String host = URI.create(config.baseUrl()).getHost();
DnsResolver resolver = new FixedHostDnsResolver(host, resolveIp.trim());
CloseableHttpClient httpClient = HttpClients.custom()
.setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create()
.setDnsResolver(resolver)
.build())
.build();
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(httpClient);
factory.setConnectTimeout(connectTimeout);
factory.setConnectionRequestTimeout(connectTimeout);
return factory;
}
private String resolveIp(ResolvedModelConfig config) {
if (StringUtils.hasText(config.resolveIp())) {
return config.resolveIp();
}
String host = URI.create(config.baseUrl()).getHost();
if (AGNES_API_HOST.equalsIgnoreCase(host)) {
return AGNES_API_RESOLVE_IP;
}
return null;
}
private record FixedHostDnsResolver(String host, String resolveIp) implements DnsResolver {
@Override
public InetAddress[] resolve(String requestedHost) throws UnknownHostException {
if (host != null && host.equalsIgnoreCase(requestedHost)) {
return new InetAddress[]{InetAddress.getByName(resolveIp)};
}
return InetAddress.getAllByName(requestedHost);
}
@Override
public String resolveCanonicalHostname(String requestedHost) throws UnknownHostException {
return requestedHost;
}
}
}
package com.yaoai.ai.providers.config;
public record ResolvedModelConfig(
String provider,
String providerName,
String protocol,
String baseUrl,
String apiKey,
String modelId,
String modelName,
String endpoint,
String statusEndpoint,
String resolveIp
) {
public boolean isProtocol(String value) {
return protocol != null && protocol.equalsIgnoreCase(value);
}
public boolean isProvider(String value) {
return provider != null && provider.equalsIgnoreCase(value);
}
public boolean isAgnes() {
return isProvider("agnes") || isProtocol("agnes");
}
}
......@@ -4,9 +4,14 @@ import com.yaoai.ai.core.model.ChatRequest;
import com.yaoai.ai.core.model.ChatResponse;
import com.yaoai.ai.core.service.LlmService;
import com.yaoai.ai.providers.config.ArkProperties;
import com.yaoai.ai.providers.config.ModelCapability;
import com.yaoai.ai.providers.config.PlatformModelConfigService;
import com.yaoai.ai.providers.config.ProviderRestClientFactory;
import com.yaoai.ai.providers.config.ResolvedModelConfig;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
......@@ -17,32 +22,39 @@ import org.springframework.web.client.RestClientResponseException;
@ConditionalOnProperty(name = "ai.local-stub.enabled", havingValue = "false", matchIfMissing = true)
public class ArkLlmService implements LlmService {
private static final String ARK_API_KEY_HINT =
"未配置火山方舟 API Key,请设置环境变量 VOLCENGINE_ARK_API_KEY,或在 application-local.yml 中配置 volcengine.ark.api-key";
private static final String API_KEY_HINT =
"未配置文本模型 API Key,请在运营端模型配置中填写,或设置 VOLCENGINE_ARK_API_KEY";
private final ArkProperties properties;
private final RestClient restClient;
private final PlatformModelConfigService platformModelConfigService;
private final ProviderRestClientFactory restClientFactory;
public ArkLlmService(ArkProperties properties) {
this(properties, null, new ProviderRestClientFactory());
}
@Autowired
public ArkLlmService(ArkProperties properties,
PlatformModelConfigService platformModelConfigService,
ProviderRestClientFactory restClientFactory) {
this.properties = properties;
this.restClient = RestClient.builder()
.baseUrl(properties.getBaseUrl())
.defaultHeader("Content-Type", "application/json")
.build();
this.platformModelConfigService = platformModelConfigService;
this.restClientFactory = restClientFactory;
}
@Override
public String chat(ChatRequest request) {
String apiKey = resolveApiKey();
// 如果未指定 model,使用默认文本模型
if (request.getModel() == null) {
request.setModel(properties.getTextModel());
ResolvedModelConfig config = resolveConfig();
String apiKey = resolveApiKey(config);
if (request.getModel() == null || request.getModel().isBlank()) {
request.setModel(config.modelId());
}
log.debug("ARK LLM call: model={}, messages={}", request.getModel(), request.getMessages().size());
log.debug("LLM call: provider={}, model={}, messages={}",
config.provider(), request.getModel(), request.getMessages().size());
try {
ChatResponse response = restClient.post()
.uri("/chat/completions")
ChatResponse response = restClientFactory.create(config).post()
.uri(config.endpoint())
.header("Authorization", "Bearer " + apiKey)
.body(request)
.retrieve()
......@@ -52,53 +64,72 @@ public class ArkLlmService implements LlmService {
throw new BizException(ErrorCode.INTERNAL_ERROR, "AI 服务返回空结果");
}
String content = response.firstContent();
log.debug("ARK LLM response: {} chars", content.length());
log.debug("LLM response: {} chars", content.length());
return content;
} catch (RestClientResponseException e) {
if (e.getStatusCode().value() == 400 && request.getResponseFormat() != null) {
log.warn("ARK LLM response_format not accepted, retrying without response_format: body={}",
log.warn("LLM response_format not accepted, retrying without response_format: body={}",
e.getResponseBodyAsString());
request.setResponseFormat(null);
return chat(request);
}
if (e.getStatusCode().value() == 401) {
throw new BizException(ErrorCode.INTERNAL_ERROR,
"AI 服务鉴权失败,请检查 VOLCENGINE_ARK_API_KEY 是否正确且仍有效");
throw new BizException(ErrorCode.INTERNAL_ERROR, "文本模型鉴权失败,请检查 API Key 是否有效");
}
log.error("ARK LLM call failed: status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString(), e);
log.error("LLM call failed: status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString(), e);
throw new BizException(ErrorCode.INTERNAL_ERROR,
"AI 服务调用失败: HTTP " + e.getStatusCode().value());
"文本模型调用失败: HTTP " + e.getStatusCode().value());
} catch (BizException e) {
throw e;
} catch (Exception e) {
log.error("ARK LLM call failed", e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "AI 服务调用失败: " + e.getMessage());
log.error("LLM call failed", e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "文本模型调用失败: " + e.getMessage());
}
}
@Override
public String getModelId() {
return properties.getTextModel();
return resolveConfig().modelId();
}
@Override
public String getModelName() {
return "豆包 Pro 文本生成";
return resolveConfig().modelName();
}
@Override
public String getProvider() {
return "volcengine";
return resolveConfig().provider();
}
private ResolvedModelConfig resolveConfig() {
if (platformModelConfigService != null) {
return platformModelConfigService.resolve(ModelCapability.TEXT);
}
return new ResolvedModelConfig(
"volcengine",
"Volcengine ARK",
"openai",
properties.getBaseUrl(),
properties.getApiKey(),
properties.getTextModel(),
"Doubao Text",
"/chat/completions",
null,
null
);
}
private String resolveApiKey() {
String apiKey = properties.getApiKey();
private String resolveApiKey(ResolvedModelConfig config) {
String apiKey = config.apiKey();
if (apiKey == null || apiKey.isBlank()) {
throw new BizException(ErrorCode.INTERNAL_ERROR, ARK_API_KEY_HINT);
throw new BizException(ErrorCode.INTERNAL_ERROR, API_KEY_HINT);
}
String normalized = apiKey.trim();
if ("your-ark-api-key".equalsIgnoreCase(normalized) || "your_ark_api_key".equalsIgnoreCase(normalized)) {
throw new BizException(ErrorCode.INTERNAL_ERROR, ARK_API_KEY_HINT);
if ("your-ark-api-key".equalsIgnoreCase(normalized)
|| "your_ark_api_key".equalsIgnoreCase(normalized)
|| PlatformModelConfigService.MASKED_API_KEY.equals(normalized)) {
throw new BizException(ErrorCode.INTERNAL_ERROR, API_KEY_HINT);
}
return normalized;
}
......
......@@ -3,15 +3,18 @@ package com.yaoai.ai.providers.service.impl;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.JsonNode;
import com.yaoai.ai.providers.config.ArkProperties;
import com.yaoai.ai.providers.config.ModelCapability;
import com.yaoai.ai.providers.config.PlatformModelConfigService;
import com.yaoai.ai.providers.config.ProviderRestClientFactory;
import com.yaoai.ai.providers.config.ResolvedModelConfig;
import com.yaoai.ai.providers.model.VideoTaskResult;
import com.yaoai.ai.providers.service.SeedanceService;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
import java.time.Duration;
......@@ -19,121 +22,94 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
public class SeedanceServiceImpl implements SeedanceService {
private static final String ARK_API_KEY_HINT =
"未配置火山方舟 API Key,请设置环境变量 VOLCENGINE_ARK_API_KEY,或在 application-local.yml 中配置 volcengine.ark.api-key";
private static final String API_KEY_HINT =
"未配置视频模型 API Key,请在运营端模型配置中填写,或设置 VOLCENGINE_ARK_API_KEY";
private static final java.util.Set<String> SUPPORTED_RATIOS = java.util.Set.of("16:9", "9:16", "1:1");
private final ArkProperties properties;
private final RestClient restClient;
private final PlatformModelConfigService platformModelConfigService;
private final ProviderRestClientFactory restClientFactory;
public SeedanceServiceImpl(ArkProperties properties) {
this.properties = properties;
SimpleClientHttpRequestFactory rf = new SimpleClientHttpRequestFactory();
rf.setConnectTimeout(Duration.ofSeconds(10));
rf.setReadTimeout(Duration.ofMinutes(2));
this.restClient = RestClient.builder()
.baseUrl(properties.getBaseUrl())
.defaultHeader("Content-Type", "application/json")
.requestFactory(rf)
.build();
this(properties, null, new ProviderRestClientFactory());
}
private static final java.util.Set<String> SUPPORTED_RATIOS = java.util.Set.of("16:9", "9:16", "1:1");
@Autowired
public SeedanceServiceImpl(ArkProperties properties,
PlatformModelConfigService platformModelConfigService,
ProviderRestClientFactory restClientFactory) {
this.properties = properties;
this.platformModelConfigService = platformModelConfigService;
this.restClientFactory = restClientFactory;
}
@Override
public String submitVideoTask(List<String> imageUrls, String prompt,
int durationSeconds, boolean generateAudio, String ratio, String model) {
String apiKey = resolveApiKey();
ResolvedModelConfig config = resolveConfig();
String apiKey = resolveApiKey(config);
if (imageUrls == null || imageUrls.isEmpty()) {
throw new BizException(ErrorCode.INVALID_PARAM, "至少需要一张参考图");
}
String safeRatio = (ratio != null && SUPPORTED_RATIOS.contains(ratio)) ? ratio : "16:9";
int safeDuration = durationSeconds > 0 ? durationSeconds : 5;
String safeModel = (model != null && !model.isBlank()) ? model : properties.getVideoModel();
// Seedance 2.0 优先走 body 字段(generate_audio / ratio / duration / watermark),
// 文本后缀作冗余兜底(部分老接入点仍按 --rt/--dur/--wm 解析)。
StringBuilder textBuilder = new StringBuilder();
if (prompt != null && !prompt.isBlank()) {
textBuilder.append(prompt.trim());
}
textBuilder.append(" --rt ").append(safeRatio)
.append(" --dur ").append(safeDuration)
.append(" --wm false");
String finalText = textBuilder.toString();
List<Map<String, Object>> content = new ArrayList<>();
content.add(Map.of("type", "text", "text", finalText));
// 按顺序添加参考图 → 对应 @图1、@图2、@图3...
for (String url : imageUrls) {
content.add(Map.of(
"type", "image_url",
"image_url", Map.of("url", url),
"role", "reference_image"
));
}
Map<String, Object> body = new java.util.LinkedHashMap<>();
body.put("model", safeModel);
body.put("content", content);
body.put("ratio", safeRatio);
body.put("duration", safeDuration);
body.put("watermark", false);
body.put("generate_audio", generateAudio);
log.info("Seedance submit: model={}, images={}, duration={}s, audio={}, ratio={}, finalTextSuffix='{}'",
safeModel, imageUrls.size(), safeDuration, generateAudio, safeRatio,
finalText.length() > 200 ? finalText.substring(finalText.length() - 200) : finalText);
String safeModel = (model != null && !model.isBlank()) ? model : config.modelId();
String finalText = buildSeedancePrompt(prompt, safeRatio, safeDuration);
Map<String, Object> body = config.isProtocol("seedance")
? buildSeedanceBody(imageUrls, finalText, safeModel, safeRatio, safeDuration, generateAudio)
: buildOpenAiVideoBody(config, imageUrls, prompt, safeModel, safeRatio, safeDuration, generateAudio);
log.info("Video submit: provider={}, protocol={}, model={}, images={}, duration={}s, audio={}, ratio={}",
config.provider(), config.protocol(), safeModel, imageUrls.size(), safeDuration, generateAudio, safeRatio);
try {
TaskSubmitResponse resp = restClient.post()
.uri("/contents/generations/tasks")
TaskSubmitResponse resp = restClientFactory.create(config, Duration.ofSeconds(10), Duration.ofMinutes(2)).post()
.uri(config.endpoint())
.header("Authorization", "Bearer " + apiKey)
.body(body)
.retrieve()
.body(TaskSubmitResponse.class);
if (resp == null || resp.getId() == null) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "Seedance 提交任务失败:返回空");
String taskId = extractTaskId(resp);
if (taskId == null || taskId.isBlank()) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "视频模型提交任务失败:返回为空");
}
log.info("Seedance task submitted: id={}", resp.getId());
return resp.getId();
log.info("Video task submitted: id={}", taskId);
return taskId;
} catch (RestClientResponseException e) {
if (e.getStatusCode().value() == 401) {
throw new BizException(ErrorCode.INTERNAL_ERROR,
"视频服务鉴权失败,请检查 VOLCENGINE_ARK_API_KEY 是否正确且仍有效");
throw new BizException(ErrorCode.INTERNAL_ERROR, "视频模型鉴权失败,请检查 API Key 是否有效");
}
String responseBody = e.getResponseBodyAsString();
log.error("Seedance submit failed: status={}, body={}", e.getStatusCode(), responseBody, e);
log.error("Video submit failed: status={}, body={}", e.getStatusCode(), responseBody, e);
if (e.getStatusCode().value() == 400
&& responseBody != null
&& responseBody.contains("image_url")
&& responseBody.contains("resource download failed")) {
throw new BizException(ErrorCode.INTERNAL_ERROR,
"视频参考图无法被火山方舟下载,请重新生成角色/场景图片,确保图片已上传到 TOS 公网地址");
"视频参考图无法被模型服务下载,请重新生成或上传公网可访问的参考图");
}
throw new BizException(ErrorCode.INTERNAL_ERROR,
"视频生成任务提交失败: HTTP " + e.getStatusCode().value());
} catch (BizException e) {
throw e;
} catch (Exception e) {
log.error("Seedance submit failed", e);
log.error("Video submit failed", e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "视频生成任务提交失败: " + e.getMessage());
}
}
@Override
public VideoTaskResult getTaskStatus(String externalTaskId) {
String apiKey = resolveApiKey();
ResolvedModelConfig config = resolveConfig();
String apiKey = resolveApiKey(config);
try {
// Use JsonNode to handle flexible API response structure
JsonNode resp = restClient.get()
.uri("/contents/generations/tasks/{id}", externalTaskId)
JsonNode resp = restClientFactory.create(config, Duration.ofSeconds(10), Duration.ofMinutes(2)).get()
.uri(statusUri(config), externalTaskId)
.header("Authorization", "Bearer " + apiKey)
.retrieve()
.body(JsonNode.class);
......@@ -141,17 +117,19 @@ public class SeedanceServiceImpl implements SeedanceService {
if (resp == null) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "查询任务状态失败");
}
log.debug("Seedance task status raw response: taskId={}, body={}", externalTaskId, resp);
log.debug("Video task status raw response: taskId={}, body={}", externalTaskId, resp);
VideoTaskResult result = new VideoTaskResult();
result.setTaskId(resp.path("id").asText(externalTaskId));
result.setStatus(resp.path("status").asText("unknown"));
result.setTaskId(firstText(resp, externalTaskId, "id", "task_id"));
result.setStatus(firstText(resp, "unknown", "status", "state"));
if ("succeeded".equals(result.getStatus())) {
if (isSucceeded(result.getStatus())) {
result.setStatus("succeeded");
result.setVideoUrl(extractVideoUrl(resp));
}
if ("failed".equals(result.getStatus())) {
if (isFailed(result.getStatus())) {
result.setStatus("failed");
JsonNode errorNode = resp.path("error");
if (!errorNode.isMissingNode()) {
String msg = errorNode.isObject()
......@@ -163,57 +141,154 @@ public class SeedanceServiceImpl implements SeedanceService {
return result;
} catch (RestClientResponseException e) {
if (e.getStatusCode().value() == 401) {
throw new BizException(ErrorCode.INTERNAL_ERROR,
"视频服务鉴权失败,请检查 VOLCENGINE_ARK_API_KEY 是否正确且仍有效");
throw new BizException(ErrorCode.INTERNAL_ERROR, "视频模型鉴权失败,请检查 API Key 是否有效");
}
log.error("Seedance status check failed: taskId={}, status={}, body={}",
log.error("Video status check failed: taskId={}, status={}, body={}",
externalTaskId, e.getStatusCode(), e.getResponseBodyAsString(), e);
throw new BizException(ErrorCode.INTERNAL_ERROR,
"查询任务状态失败: HTTP " + e.getStatusCode().value());
} catch (BizException e) {
throw e;
} catch (Exception e) {
log.error("Seedance status check failed: taskId={}", externalTaskId, e);
log.error("Video status check failed: taskId={}", externalTaskId, e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "查询任务状态失败: " + e.getMessage());
}
}
@Override
public String getModelId() {
return properties.getVideoModel();
return resolveConfig().modelId();
}
@Override
public String getModelName() {
return "Seedance 图生视频";
return resolveConfig().modelName();
}
private String resolveApiKey() {
String apiKey = properties.getApiKey();
@Override
public String getProvider() {
return resolveConfig().provider();
}
private Map<String, Object> buildSeedanceBody(List<String> imageUrls, String finalText, String safeModel,
String safeRatio, int safeDuration, boolean generateAudio) {
List<Map<String, Object>> content = new ArrayList<>();
content.add(Map.of("type", "text", "text", finalText));
for (String url : imageUrls) {
content.add(Map.of(
"type", "image_url",
"image_url", Map.of("url", url),
"role", "reference_image"
));
}
Map<String, Object> body = new java.util.LinkedHashMap<>();
body.put("model", safeModel);
body.put("content", content);
body.put("ratio", safeRatio);
body.put("duration", safeDuration);
body.put("watermark", false);
body.put("generate_audio", generateAudio);
return body;
}
private Map<String, Object> buildOpenAiVideoBody(ResolvedModelConfig config, List<String> imageUrls, String prompt, String safeModel,
String safeRatio, int safeDuration, boolean generateAudio) {
Map<String, Object> body = new java.util.LinkedHashMap<>();
body.put("model", safeModel);
body.put("prompt", prompt == null ? "" : prompt);
body.put("duration", safeDuration);
body.put("aspect_ratio", safeRatio);
body.put("image_urls", imageUrls);
if (!config.isAgnes()) {
body.put("generate_audio", generateAudio);
}
return body;
}
private String buildSeedancePrompt(String prompt, String safeRatio, int safeDuration) {
StringBuilder textBuilder = new StringBuilder();
if (prompt != null && !prompt.isBlank()) {
textBuilder.append(prompt.trim());
}
textBuilder.append(" --rt ").append(safeRatio)
.append(" --dur ").append(safeDuration)
.append(" --wm false");
return textBuilder.toString();
}
private ResolvedModelConfig resolveConfig() {
if (platformModelConfigService != null) {
return platformModelConfigService.resolve(ModelCapability.VIDEO);
}
return new ResolvedModelConfig(
"volcengine",
"Volcengine ARK",
"seedance",
properties.getBaseUrl(),
properties.getApiKey(),
properties.getVideoModel(),
"Seedance Image-to-Video",
"/contents/generations/tasks",
"/contents/generations/tasks/{id}",
null
);
}
private String statusUri(ResolvedModelConfig config) {
if (config.statusEndpoint() != null && !config.statusEndpoint().isBlank()) {
return config.statusEndpoint();
}
return config.endpoint().replaceAll("/+$", "") + "/{id}";
}
private String resolveApiKey(ResolvedModelConfig config) {
String apiKey = config.apiKey();
if (apiKey == null || apiKey.isBlank()) {
throw new BizException(ErrorCode.INTERNAL_ERROR, ARK_API_KEY_HINT);
throw new BizException(ErrorCode.INTERNAL_ERROR, API_KEY_HINT);
}
String normalized = apiKey.trim();
if ("your-ark-api-key".equalsIgnoreCase(normalized) || "your_ark_api_key".equalsIgnoreCase(normalized)) {
throw new BizException(ErrorCode.INTERNAL_ERROR, ARK_API_KEY_HINT);
if ("your-ark-api-key".equalsIgnoreCase(normalized)
|| "your_ark_api_key".equalsIgnoreCase(normalized)
|| PlatformModelConfigService.MASKED_API_KEY.equals(normalized)) {
throw new BizException(ErrorCode.INTERNAL_ERROR, API_KEY_HINT);
}
return normalized;
}
// ---- helpers ----
private String extractTaskId(TaskSubmitResponse resp) {
if (resp == null) return null;
if (resp.getId() != null && !resp.getId().isBlank()) return resp.getId();
if (resp.getTaskId() != null && !resp.getTaskId().isBlank()) return resp.getTaskId();
return resp.getData() == null ? null : resp.getData().getId();
}
private boolean isSucceeded(String status) {
return "succeeded".equalsIgnoreCase(status)
|| "success".equalsIgnoreCase(status)
|| "completed".equalsIgnoreCase(status)
|| "done".equalsIgnoreCase(status);
}
private boolean isFailed(String status) {
return "failed".equalsIgnoreCase(status)
|| "fail".equalsIgnoreCase(status)
|| "error".equalsIgnoreCase(status);
}
private String firstText(JsonNode node, String fallback, String... fields) {
for (String field : fields) {
JsonNode value = node.path(field);
if (value.isTextual() && !value.asText().isBlank()) {
return value.asText();
}
}
return fallback;
}
/**
* Extracts video URL from ARK API task status response.
* Handles 4 possible response structures used by Seedance 2.0:
* 1. content[].type=="video_url" -> content[].video_url.url (array, object)
* 2. content[].type=="video_url" -> content[].video_url (array, string)
* 3. content.video_url (content is object)
* 4. video_url / output.video_url (root or output level)
*/
private String extractVideoUrl(JsonNode resp) {
JsonNode content = resp.path("content");
// Pattern 1 & 2: content is array
if (content.isArray()) {
for (JsonNode item : content) {
if ("video_url".equals(item.path("type").asText(""))) {
......@@ -227,7 +302,6 @@ public class SeedanceServiceImpl implements SeedanceService {
}
}
// Pattern 3: content is object with video_url field
if (content.isObject()) {
JsonNode videoUrlNode = content.path("video_url");
if (!videoUrlNode.isMissingNode()) {
......@@ -237,7 +311,6 @@ public class SeedanceServiceImpl implements SeedanceService {
}
}
// Pattern 4a: root-level video_url
JsonNode rootVideoUrl = resp.path("video_url");
if (!rootVideoUrl.isMissingNode()) {
return rootVideoUrl.isObject()
......@@ -245,7 +318,6 @@ public class SeedanceServiceImpl implements SeedanceService {
: rootVideoUrl.asText(null);
}
// Pattern 4b: output.video_url
JsonNode outputVideoUrl = resp.path("output").path("video_url");
if (!outputVideoUrl.isMissingNode()) {
return outputVideoUrl.isObject()
......@@ -253,17 +325,34 @@ public class SeedanceServiceImpl implements SeedanceService {
: outputVideoUrl.asText(null);
}
JsonNode dataVideoUrl = resp.path("data").path("video_url");
if (!dataVideoUrl.isMissingNode()) {
return dataVideoUrl.isObject()
? dataVideoUrl.path("url").asText(null)
: dataVideoUrl.asText(null);
}
String agnesVideoUrl = firstText(resp, null, "remixed_from_video_id", "url", "video");
if (agnesVideoUrl != null && agnesVideoUrl.startsWith("http")) {
return agnesVideoUrl;
}
log.warn("extractVideoUrl: no video URL found in response: {}", resp);
return null;
}
// ---- internal response models ----
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
private static class TaskSubmitResponse {
private String id;
private String taskId;
private String status;
private TaskData data;
}
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
private static class TaskData {
private String id;
}
}
......@@ -3,15 +3,18 @@ package com.yaoai.ai.providers.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.ai.providers.config.ArkProperties;
import com.yaoai.ai.providers.config.ModelCapability;
import com.yaoai.ai.providers.config.PlatformModelConfigService;
import com.yaoai.ai.providers.config.ProviderRestClientFactory;
import com.yaoai.ai.providers.config.ResolvedModelConfig;
import com.yaoai.ai.providers.service.SeedreamService;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
import java.time.Duration;
......@@ -24,23 +27,25 @@ import java.util.Map;
@ConditionalOnProperty(name = "ai.local-stub.enabled", havingValue = "false", matchIfMissing = true)
public class SeedreamServiceImpl implements SeedreamService {
private static final String ARK_API_KEY_HINT =
"未配置火山方舟 API Key,请设置环境变量 VOLCENGINE_ARK_API_KEY,或在 application-local.yml 中配置 volcengine.ark.api-key";
private static final String API_KEY_HINT =
"未配置图片模型 API Key,请在运营端模型配置中填写,或设置 VOLCENGINE_ARK_API_KEY";
private static final ObjectMapper JSON = new ObjectMapper();
private final ArkProperties properties;
private final RestClient restClient;
private final PlatformModelConfigService platformModelConfigService;
private final ProviderRestClientFactory restClientFactory;
public SeedreamServiceImpl(ArkProperties properties) {
this(properties, null, new ProviderRestClientFactory());
}
@Autowired
public SeedreamServiceImpl(ArkProperties properties,
PlatformModelConfigService platformModelConfigService,
ProviderRestClientFactory restClientFactory) {
this.properties = properties;
SimpleClientHttpRequestFactory rf = new SimpleClientHttpRequestFactory();
rf.setConnectTimeout(Duration.ofSeconds(10));
rf.setReadTimeout(Duration.ofMinutes(2));
this.restClient = RestClient.builder()
.baseUrl(properties.getBaseUrl())
.defaultHeader("Content-Type", "application/json")
.requestFactory(rf)
.build();
this.platformModelConfigService = platformModelConfigService;
this.restClientFactory = restClientFactory;
}
@Override
......@@ -55,13 +60,17 @@ public class SeedreamServiceImpl implements SeedreamService {
@Override
public String generateImage(String prompt, String size, List<String> referenceImageUrls) {
String apiKey = resolveApiKey();
ResolvedModelConfig config = resolveConfig();
String apiKey = resolveApiKey(config);
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", properties.getImageModel());
body.put("model", config.modelId());
body.put("prompt", prompt);
body.put("n", 1);
body.put("size", size);
body.put("response_format", "url");
if (!config.isAgnes()) {
body.put("response_format", "url");
}
List<String> cleanReferences = referenceImageUrls == null
? List.of()
: referenceImageUrls.stream()
......@@ -69,31 +78,34 @@ public class SeedreamServiceImpl implements SeedreamService {
.map(String::trim)
.toList();
if (!cleanReferences.isEmpty()) {
body.put("image", cleanReferences.size() == 1 ? cleanReferences.get(0) : cleanReferences);
if (config.isProtocol("openai") || config.isProtocol("agnes")) {
body.put("image_urls", cleanReferences);
} else {
body.put("image", cleanReferences.size() == 1 ? cleanReferences.get(0) : cleanReferences);
}
}
log.info("Seedream generate image: model={}, size={}, references={}",
properties.getImageModel(), size, cleanReferences.size());
log.info("Image generate: provider={}, model={}, size={}, references={}",
config.provider(), config.modelId(), size, cleanReferences.size());
try {
ImageResponse resp = restClient.post()
.uri("/images/generations")
ImageResponse resp = restClientFactory.create(config, Duration.ofSeconds(10), Duration.ofMinutes(2)).post()
.uri(config.endpoint())
.header("Authorization", "Bearer " + apiKey)
.body(body)
.retrieve()
.body(ImageResponse.class);
if (resp == null || resp.getData() == null || resp.getData().isEmpty()) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "Seedream 返回空结果");
throw new BizException(ErrorCode.INTERNAL_ERROR, "图片模型返回空结果");
}
String url = resp.getData().get(0).getUrl();
log.info("Seedream image generated: url={}", url);
log.info("Image generated: url={}", url);
return url;
} catch (RestClientResponseException e) {
if (e.getStatusCode().value() == 401) {
throw new BizException(ErrorCode.INTERNAL_ERROR,
"图片服务鉴权失败,请检查 VOLCENGINE_ARK_API_KEY 是否正确且仍有效");
throw new BizException(ErrorCode.INTERNAL_ERROR, "图片模型鉴权失败,请检查 API Key 是否有效");
}
log.error("Seedream generate failed: status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString(), e);
log.error("Image generate failed: status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString(), e);
String providerMessage = extractProviderError(e.getResponseBodyAsString());
throw new BizException(ErrorCode.INTERNAL_ERROR,
"图片生成失败: HTTP " + e.getStatusCode().value()
......@@ -101,29 +113,54 @@ public class SeedreamServiceImpl implements SeedreamService {
} catch (BizException e) {
throw e;
} catch (Exception e) {
log.error("Seedream generate failed", e);
log.error("Image generate failed", e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "图片生成失败: " + e.getMessage());
}
}
@Override
public String getModelId() {
return properties.getImageModel();
return resolveConfig().modelId();
}
@Override
public String getModelName() {
return "Seedream 文生图";
return resolveConfig().modelName();
}
@Override
public String getProvider() {
return resolveConfig().provider();
}
private ResolvedModelConfig resolveConfig() {
if (platformModelConfigService != null) {
return platformModelConfigService.resolve(ModelCapability.IMAGE);
}
return new ResolvedModelConfig(
"volcengine",
"Volcengine ARK",
"seedream",
properties.getBaseUrl(),
properties.getApiKey(),
properties.getImageModel(),
"Seedream Image",
"/images/generations",
null,
null
);
}
private String resolveApiKey() {
String apiKey = properties.getApiKey();
private String resolveApiKey(ResolvedModelConfig config) {
String apiKey = config.apiKey();
if (apiKey == null || apiKey.isBlank()) {
throw new BizException(ErrorCode.INTERNAL_ERROR, ARK_API_KEY_HINT);
throw new BizException(ErrorCode.INTERNAL_ERROR, API_KEY_HINT);
}
String normalized = apiKey.trim();
if ("your-ark-api-key".equalsIgnoreCase(normalized) || "your_ark_api_key".equalsIgnoreCase(normalized)) {
throw new BizException(ErrorCode.INTERNAL_ERROR, ARK_API_KEY_HINT);
if ("your-ark-api-key".equalsIgnoreCase(normalized)
|| "your_ark_api_key".equalsIgnoreCase(normalized)
|| PlatformModelConfigService.MASKED_API_KEY.equals(normalized)) {
throw new BizException(ErrorCode.INTERNAL_ERROR, API_KEY_HINT);
}
return normalized;
}
......@@ -145,8 +182,6 @@ public class SeedreamServiceImpl implements SeedreamService {
return normalized.length() > 300 ? normalized.substring(0, 300) + "..." : normalized;
}
// ---- internal response models ----
@Data
private static class ImageResponse {
private Long created;
......
......@@ -73,7 +73,6 @@ public class VideoTaskController {
.userPromptRaw(buildUserPromptMap(up, req.getFreeformPrompt()))
.durationSeconds(req.getDuration())
.ratio(req.getRatio())
.model(req.getModel())
.generateAudio(req.getGenerateAudio())
.build();
......
......@@ -61,9 +61,8 @@ public class StructuredVideoGenerateRequest {
@Pattern(regexp = "16:9|9:16|1:1", message = "宽高比仅支持 16:9 / 9:16 / 1:1")
private String ratio = "16:9";
/** Seedance 模型 ID(doubao-seedance-2-0-260128 / doubao-seedance-2-0-fast-260128),为空走 ArkProperties 默认值 */
@Pattern(regexp = "doubao-seedance-2-0-260128|doubao-seedance-2-0-fast-260128",
message = "模型仅支持 Seedance 2.0 标准 / 极速")
/** @deprecated 模型统一由运营端视频模型配置决定,客户端传入值会被忽略。 */
@Deprecated
private String model;
/** 是否生成配音 */
......
package com.yaoai.ai.providers.config;
import com.yaoai.domain.entity.SystemSetting;
import com.yaoai.domain.mapper.SystemSettingMapper;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class PlatformModelConfigServiceTest {
@Test
void resolveUsesEnabledPlatformConfigBeforeEnvironmentDefaults() {
SystemSettingMapper mapper = mock(SystemSettingMapper.class);
SystemSetting setting = new SystemSetting();
setting.setSettingKey(PlatformModelConfigService.SETTING_KEY);
setting.setSettingValue("""
{
"text": {
"provider": "agnes",
"providerName": "Agnes",
"protocol": "openai",
"baseUrl": "https://apihub.agnes-ai.com/v1",
"apiKey": "agnes-secret",
"modelId": "agnes-2.0-flash",
"modelName": "Agnes 2.0 Flash",
"endpoint": "/chat/completions",
"resolveIp": "104.18.18.62",
"enabled": true
}
}
""");
when(mapper.findByKey(PlatformModelConfigService.SETTING_KEY)).thenReturn(Optional.of(setting));
PlatformModelConfigService service = new PlatformModelConfigService(mapper, buildArkProperties());
ResolvedModelConfig config = service.resolve(ModelCapability.TEXT);
assertEquals("agnes", config.provider());
assertEquals("openai", config.protocol());
assertEquals("https://apihub.agnes-ai.com/v1", config.baseUrl());
assertEquals("agnes-secret", config.apiKey());
assertEquals("agnes-2.0-flash", config.modelId());
assertEquals("Agnes 2.0 Flash", config.modelName());
assertEquals("/chat/completions", config.endpoint());
assertEquals("104.18.18.62", config.resolveIp());
}
@Test
void resolveFallsBackToVolcenginePropertiesWhenPlatformConfigIsMissing() {
SystemSettingMapper mapper = mock(SystemSettingMapper.class);
when(mapper.findByKey(PlatformModelConfigService.SETTING_KEY)).thenReturn(Optional.empty());
PlatformModelConfigService service = new PlatformModelConfigService(mapper, buildArkProperties());
ResolvedModelConfig config = service.resolve(ModelCapability.IMAGE);
assertEquals("volcengine", config.provider());
assertEquals("seedream", config.protocol());
assertEquals("https://ark.cn-beijing.volces.com/api/v3", config.baseUrl());
assertEquals("ark-secret", config.apiKey());
assertEquals("doubao-seedream-test", config.modelId());
assertTrue(config.modelName().contains("Seedream"));
assertEquals("/images/generations", config.endpoint());
}
@Test
void adminSettingsMasksStoredApiKeys() {
SystemSettingMapper mapper = mock(SystemSettingMapper.class);
SystemSetting setting = new SystemSetting();
setting.setSettingKey(PlatformModelConfigService.SETTING_KEY);
setting.setSettingValue("""
{
"video": {
"provider": "agnes",
"apiKey": "agnes-secret",
"modelId": "agnes-video-v2.0",
"enabled": true
}
}
""");
when(mapper.findByKey(PlatformModelConfigService.SETTING_KEY)).thenReturn(Optional.of(setting));
PlatformModelConfigService service = new PlatformModelConfigService(mapper, buildArkProperties());
PlatformModelSettings settings = service.getAdminSettings();
assertEquals("******", settings.getVideo().getApiKey());
}
private static ArkProperties buildArkProperties() {
ArkProperties properties = new ArkProperties();
properties.setApiKey("ark-secret");
properties.setBaseUrl("https://ark.cn-beijing.volces.com/api/v3");
properties.setTextModel("doubao-text-test");
properties.setImageModel("doubao-seedream-test");
properties.setVideoModel("doubao-seedance-test");
return properties;
}
}
......@@ -30,7 +30,10 @@ public interface AiTaskMapper extends BaseMapper<AiTask> {
/** 待轮询的视频任务:已提交 Ark 且未到终态。limit 控制单轮处理上限,避免单次扫描挤占线程。 */
@Select("""
SELECT * FROM ai_tasks
WHERE status IN ('submitted','running')
WHERE (
status IN ('submitted','running')
OR (status = 'succeeded' AND (result_video_url IS NULL OR result_video_url = ''))
)
AND external_task_id IS NOT NULL
ORDER BY created_at ASC
LIMIT #{limit}
......
......@@ -5,22 +5,10 @@ import com.yaoai.ai.providers.service.SeedanceService;
import com.yaoai.domain.entity.AiTask;
import com.yaoai.domain.mapper.AiTaskMapper;
import com.yaoai.pipeline.service.ShotAssetService;
import com.yaoai.storage.service.TosService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/**
* 单条视频任务的查询与完成处理:查 Ark → 更新 DB → 转存视频到自家 TOS。
* 给 {@link VideoTaskPoller} 调用,未来若加管理后台补救接口也可复用。
*/
@Slf4j
@Service
@RequiredArgsConstructor
......@@ -28,15 +16,13 @@ public class VideoTaskCompletionService {
private final AiTaskMapper aiTaskMapper;
private final SeedanceService seedanceService;
private final TosService tosService;
private final ShotAssetService shotAssetService;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private final VideoTransferService videoTransferService;
/**
* 单次轮询一个任务:查 Ark 状态并按状态更新 DB;succeeded 时把视频转存到我们桶。
* 返回此任务在本轮处理后是否进入终态(succeeded/failed)。
* Poll one video task and update local state.
* When provider video is ready, write the provider URL immediately so the UI can play it,
* then transfer it to TOS asynchronously and replace the URL later.
*/
public boolean pollOnce(AiTask task) {
Long taskId = task.getId();
......@@ -44,34 +30,38 @@ public class VideoTaskCompletionService {
try {
VideoTaskResult result = seedanceService.getTaskStatus(externalTaskId);
String status = result.getStatus();
log.debug("Polling Ark: taskId={}, externalId={}, status={}", taskId, externalTaskId, status);
log.debug("Polling video provider: taskId={}, externalId={}, status={}", taskId, externalTaskId, status);
if ("succeeded".equals(status) || "failed".equals(status)) {
String videoUrl = result.getVideoUrl();
if ("succeeded".equals(status) && (videoUrl == null || videoUrl.isBlank())) {
log.warn("Video task succeeded but URL is empty, will retry: taskId={}, externalId={}",
taskId, externalTaskId);
return false;
}
AiTask done = new AiTask();
done.setId(taskId);
done.setStatus(status);
if (result.getVideoUrl() != null) {
String persistentUrl = persistVideoToTos(
task.getTenantId(), task.getProjectId(), taskId, result.getVideoUrl());
done.setResultVideoUrl(persistentUrl);
if ("succeeded".equals(status)) {
done.setResultVideoUrl(videoUrl);
}
if (result.getErrorMessage() != null) {
done.setErrorMessage(result.getErrorMessage());
}
aiTaskMapper.updateById(done);
if ("succeeded".equals(status)) {
shotAssetService.markVideoAssetSucceeded(
task.getTenantId(), taskId, done.getResultVideoUrl());
shotAssetService.markVideoAssetSucceeded(task.getTenantId(), taskId, videoUrl);
videoTransferService.transferVideoToTos(task.getTenantId(), task.getProjectId(), taskId, videoUrl);
} else {
shotAssetService.markVideoAssetFailed(
task.getTenantId(), taskId, done.getErrorMessage());
shotAssetService.markVideoAssetFailed(task.getTenantId(), taskId, done.getErrorMessage());
}
log.info("Video task completed: taskId={}, status={}, url={}",
taskId, status, done.getResultVideoUrl());
log.info("Video task completed: taskId={}, status={}, url={}", taskId, status, videoUrl);
return true;
}
// running 时同步状态,便于前端区分 submitted/running
if ("running".equals(status) && !"running".equals(task.getStatus())) {
AiTask running = new AiTask();
running.setId(taskId);
......@@ -80,38 +70,8 @@ public class VideoTaskCompletionService {
}
return false;
} catch (Exception e) {
log.warn("Ark poll error (will retry): taskId={}, error={}", taskId, e.getMessage());
log.warn("Video poll error (will retry): taskId={}, error={}", taskId, e.getMessage());
return false;
}
}
/**
* 把 Ark 临时视频 URL 下载下来上传到我们 public-read TOS bucket,返回永不过期的公开 URL。
* 失败时退回原始 URL(24h 内仍可用)+ 错误日志,不阻塞任务完成流程。
*/
public String persistVideoToTos(Long tenantId, Long projectId, Long taskId, String arkVideoUrl) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(arkVideoUrl))
.timeout(Duration.ofMinutes(2))
.GET()
.build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
log.warn("Download Ark video failed (status={}), keep raw URL: taskId={}", response.statusCode(), taskId);
return arkVideoUrl;
}
byte[] bytes = response.body();
String key = String.format("%d/%d/video/%d.mp4", tenantId, projectId, taskId);
try (var is = new ByteArrayInputStream(bytes)) {
tosService.upload(key, is, bytes.length, "video/mp4");
}
String publicUrl = tosService.publicUrl(key);
log.info("Video persisted to TOS: taskId={}, key={}, size={}", taskId, key, bytes.length);
return publicUrl;
} catch (Exception e) {
log.error("Persist Ark video to TOS failed, fallback to raw URL: taskId={}", taskId, e);
return arkVideoUrl;
}
}
}
package com.yaoai.pipeline.async;
import com.yaoai.domain.entity.AiTask;
import com.yaoai.domain.mapper.AiTaskMapper;
import com.yaoai.pipeline.service.ShotAssetService;
import com.yaoai.storage.service.TosService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
@Slf4j
@Service
@RequiredArgsConstructor
public class VideoTransferService {
private final AiTaskMapper aiTaskMapper;
private final TosService tosService;
private final ShotAssetService shotAssetService;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
@Async
public void transferVideoToTos(Long tenantId, Long projectId, Long taskId, String temporaryVideoUrl) {
try {
if (temporaryVideoUrl == null || temporaryVideoUrl.isBlank()) {
return;
}
AiTask current = aiTaskMapper.selectById(taskId);
if (current == null || !tenantId.equals(current.getTenantId())) {
log.warn("Skip video transfer: task not found or tenant mismatch, taskId={}", taskId);
return;
}
if (!temporaryVideoUrl.equals(current.getResultVideoUrl())) {
log.info("Skip stale video transfer: taskId={}", taskId);
return;
}
log.info("Video transfer started: taskId={}, temporaryUrl={}", taskId, temporaryVideoUrl);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(temporaryVideoUrl))
.timeout(Duration.ofMinutes(2))
.GET()
.build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
log.warn("Download provider video failed (status={}), keep temporary URL: taskId={}",
response.statusCode(), taskId);
return;
}
byte[] bytes = response.body();
String key = String.format("%d/%d/video/%d.mp4", tenantId, projectId, taskId);
try (var input = new ByteArrayInputStream(bytes)) {
tosService.upload(key, input, bytes.length, "video/mp4");
}
AiTask latest = aiTaskMapper.selectById(taskId);
if (latest == null || !tenantId.equals(latest.getTenantId()) || !temporaryVideoUrl.equals(latest.getResultVideoUrl())) {
log.info("Skip stale video replace after upload: taskId={}", taskId);
return;
}
String publicUrl = tosService.publicUrl(key);
AiTask patch = new AiTask();
patch.setId(taskId);
patch.setResultVideoUrl(publicUrl);
patch.setResultTosKey(key);
aiTaskMapper.updateById(patch);
shotAssetService.markVideoAssetSucceeded(tenantId, taskId, publicUrl);
log.info("Video transfer completed: taskId={}, key={}, size={}", taskId, key, bytes.length);
} catch (Exception e) {
log.error("Video transfer failed, keep temporary URL: taskId={}", taskId, e);
}
}
}
......@@ -118,6 +118,7 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
private final ProjectConsistencyBibleMapper projectConsistencyBibleMapper;
private final ObjectMapper objectMapper;
private final BillingService billingService;
private final SceneImageTransferService sceneImageTransferService;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
......@@ -365,13 +366,9 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
try {
String prompt = limitImagePrompt(buildSceneRenderPrompt(scene.getImagePrompt(), visualStyle, renderSpec));
String imageUrl = seedreamService.generateImage(prompt, renderSpec.size());
byte[] bytes = downloadBytes(imageUrl);
String key = TosService.buildKey(scene.getTenantId(), scene.getProjectId(), "scenes", scene.getName() + ".jpg");
tosService.upload(key, new ByteArrayInputStream(bytes), bytes.length, "image/jpeg");
scene.setImageUrl(tosService.publicUrl(key));
scene.setImageTosKey(key);
scene.setStatus("ready");
scene.setImageUrl(imageUrl);
scene.setImageTosKey(null);
scene.setStatus("generating");
sceneMapper.updateById(scene);
billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId)
......@@ -385,6 +382,7 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
.unitCount(1)
.refId(String.valueOf(sceneId))
.build());
sceneImageTransferService.transferSceneImageToTos(sceneId, tenantId, imageUrl);
return scene;
} catch (BizException e) {
scene.setStatus("failed");
......
package com.yaoai.pipeline.service.impl;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import com.yaoai.domain.entity.Scene;
import com.yaoai.domain.mapper.SceneMapper;
import com.yaoai.storage.service.TosService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Base64;
@Slf4j
@Service
@RequiredArgsConstructor
public class SceneImageTransferService {
private final SceneMapper sceneMapper;
private final TosService tosService;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
@Async
public void transferSceneImageToTos(Long sceneId, Long tenantId, String temporaryImageUrl) {
try {
Scene scene = sceneMapper.selectById(sceneId);
if (scene == null || !tenantId.equals(scene.getTenantId())) {
log.warn("Skip scene image transfer: scene not found or tenant mismatch, sceneId={}", sceneId);
return;
}
if (!temporaryImageUrl.equals(scene.getImageUrl())) {
log.info("Skip stale scene image transfer: sceneId={}", sceneId);
return;
}
log.info("Scene image transfer started: sceneId={}, temporaryUrl={}", sceneId, temporaryImageUrl);
DownloadedImage image = downloadImage(temporaryImageUrl);
String key = TosService.buildKey(
scene.getTenantId(),
scene.getProjectId(),
"scenes",
scene.getName() + "." + image.extension()
);
tosService.upload(key, new ByteArrayInputStream(image.bytes()), image.bytes().length, image.contentType());
Scene latest = sceneMapper.selectById(sceneId);
if (latest == null || !tenantId.equals(latest.getTenantId()) || !temporaryImageUrl.equals(latest.getImageUrl())) {
log.info("Skip stale scene image replace after upload: sceneId={}", sceneId);
return;
}
latest.setImageUrl(tosService.publicUrl(key));
latest.setImageTosKey(key);
latest.setStatus("ready");
sceneMapper.updateById(latest);
log.info("Scene image transfer completed: sceneId={}, key={}", sceneId, key);
} catch (Exception e) {
log.warn("Scene image transfer failed: sceneId={}, temporaryUrl={}", sceneId, temporaryImageUrl, e);
markFailedIfStillCurrent(sceneId, tenantId, temporaryImageUrl);
}
}
private void markFailedIfStillCurrent(Long sceneId, Long tenantId, String temporaryImageUrl) {
Scene scene = sceneMapper.selectById(sceneId);
if (scene == null || !tenantId.equals(scene.getTenantId()) || !temporaryImageUrl.equals(scene.getImageUrl())) {
return;
}
scene.setStatus("failed");
sceneMapper.updateById(scene);
}
private DownloadedImage downloadImage(String url) throws Exception {
if (url != null && url.startsWith("data:")) {
int comma = url.indexOf(',');
int semicolon = url.indexOf(';');
if (comma <= 0) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "图片 data URL 格式无效");
}
String contentType = semicolon > 5 ? url.substring(5, semicolon) : "image/png";
return new DownloadedImage(Base64.getDecoder().decode(url.substring(comma + 1)), contentType, extensionFor(contentType));
}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofMinutes(2))
.GET()
.build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "下载图片失败 HTTP " + response.statusCode());
}
String contentType = response.headers().firstValue("content-type")
.map(value -> value.split(";")[0].trim())
.filter(value -> value.startsWith("image/"))
.orElseGet(() -> contentTypeFromUrl(url));
return new DownloadedImage(response.body(), contentType, extensionFor(contentType));
}
private String contentTypeFromUrl(String url) {
String lower = url == null ? "" : url.toLowerCase();
if (lower.contains(".jpg") || lower.contains(".jpeg")) return "image/jpeg";
if (lower.contains(".webp")) return "image/webp";
if (lower.contains(".gif")) return "image/gif";
return "image/png";
}
private String extensionFor(String contentType) {
if ("image/jpeg".equalsIgnoreCase(contentType)) return "jpg";
if ("image/webp".equalsIgnoreCase(contentType)) return "webp";
if ("image/gif".equalsIgnoreCase(contentType)) return "gif";
return "png";
}
private record DownloadedImage(byte[] bytes, String contentType, String extension) {
}
}
......@@ -277,7 +277,7 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
String ratio = s.getRatio() != null && !s.getRatio().isBlank() ? s.getRatio() : "16:9";
boolean generateAudio = Boolean.TRUE.equals(s.getGenerateAudio());
asyncProcessor.processTextToVideo(task.getId(), tenantId, s.getUserId(), projectId,
finalPrompt, finalPrompt, orderedKeys, duration, ratio, generateAudio, s.getModel());
finalPrompt, finalPrompt, orderedKeys, duration, ratio, generateAudio, null);
return task;
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment