Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
Y
yaoai-video
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
姚珂
yaoai-video
Commits
fde0aba7
Commit
fde0aba7
authored
Jun 06, 2026
by
yaoke.yk
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
测试尝试用Agnes测demo
parent
0aa3bd46
Hide whitespace changes
Inline
Side-by-side
Showing
27 changed files
with
1510 additions
and
269 deletions
+1510
-269
StoryboardWorkspace.tsx
doc/html/src/app/pages/StoryboardWorkspace.tsx
+1
-25
ai.ts
doc/html/src/lib/api/ai.ts
+0
-2
router.ts
yaoai-admin-web/src/router.ts
+6
-0
ModelConfigView.vue
yaoai-admin-web/src/views/ModelConfigView.vue
+393
-0
tsconfig.tsbuildinfo
yaoai-admin-web/tsconfig.tsbuildinfo
+2
-2
vite.config.ts
yaoai-admin-web/vite.config.ts
+0
-1
pom.xml
yaoai-comic-studio/yaoai-admin/pom.xml
+4
-0
ModelConfigAdminController.java
...m/yaoai/admin/modelconfig/ModelConfigAdminController.java
+36
-0
pom.xml
yaoai-comic-studio/yaoai-ai-providers/pom.xml
+8
-0
ModelCapability.java
...n/java/com/yaoai/ai/providers/config/ModelCapability.java
+29
-0
PlatformModelConfigService.java
...yaoai/ai/providers/config/PlatformModelConfigService.java
+215
-0
PlatformModelItem.java
...java/com/yaoai/ai/providers/config/PlatformModelItem.java
+18
-0
PlatformModelSettings.java
.../com/yaoai/ai/providers/config/PlatformModelSettings.java
+26
-0
ProviderRestClientFactory.java
.../yaoai/ai/providers/config/ProviderRestClientFactory.java
+82
-0
ResolvedModelConfig.java
...va/com/yaoai/ai/providers/config/ResolvedModelConfig.java
+26
-0
ArkLlmService.java
...va/com/yaoai/ai/providers/service/impl/ArkLlmService.java
+61
-30
SeedanceServiceImpl.java
.../yaoai/ai/providers/service/impl/SeedanceServiceImpl.java
+188
-99
SeedreamServiceImpl.java
.../yaoai/ai/providers/service/impl/SeedreamServiceImpl.java
+71
-36
VideoTaskController.java
...in/java/com/yaoai/api/controller/VideoTaskController.java
+0
-1
StructuredVideoGenerateRequest.java
.../com/yaoai/api/dto/ai/StructuredVideoGenerateRequest.java
+2
-3
PlatformModelConfigServiceTest.java
...i/ai/providers/config/PlatformModelConfigServiceTest.java
+104
-0
AiTaskMapper.java
...n/src/main/java/com/yaoai/domain/mapper/AiTaskMapper.java
+4
-1
VideoTaskCompletionService.java
.../com/yaoai/pipeline/async/VideoTaskCompletionService.java
+21
-61
VideoTransferService.java
...n/java/com/yaoai/pipeline/async/VideoTransferService.java
+83
-0
AssetGenPipelineServiceImpl.java
...ai/pipeline/service/impl/AssetGenPipelineServiceImpl.java
+5
-7
SceneImageTransferService.java
...aoai/pipeline/service/impl/SceneImageTransferService.java
+124
-0
VideoTaskPipelineServiceImpl.java
...i/pipeline/service/impl/VideoTaskPipelineServiceImpl.java
+1
-1
No files found.
doc/html/src/app/pages/StoryboardWorkspace.tsx
View file @
fde0aba7
...
...
@@ -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
}
...
...
doc/html/src/lib/api/ai.ts
View file @
fde0aba7
...
...
@@ -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
;
}
...
...
yaoai-admin-web/src/router.ts
View file @
fde0aba7
...
...
@@ -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'
]
},
...
...
yaoai-admin-web/src/views/ModelConfigView.vue
0 → 100644
View file @
fde0aba7
<
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
,
1
fr
));
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
:
1
fr
1
fr
;
gap
:
12px
;
}
@media
(
max-width
:
1280px
)
{
.model-grid
{
grid-template-columns
:
1
fr
;
}
}
@media
(
max-width
:
720px
)
{
.form-pair
{
grid-template-columns
:
1
fr
;
}
}
</
style
>
yaoai-admin-web/tsconfig.tsbuildinfo
View file @
fde0aba7
{"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
yaoai-admin-web/vite.config.ts
View file @
fde0aba7
...
...
@@ -35,7 +35,6 @@ export default defineConfig(({ mode }) => {
'/admin-api'
:
{
target
:
resolveBackendTarget
(
env
),
changeOrigin
:
true
,
rewrite
:
(
path
)
=>
path
.
replace
(
/^
\/
admin-api/
,
'/admin'
),
},
},
},
...
...
yaoai-comic-studio/yaoai-admin/pom.xml
View file @
fde0aba7
...
...
@@ -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>
...
...
yaoai-comic-studio/yaoai-admin/src/main/java/com/yaoai/admin/modelconfig/ModelConfigAdminController.java
0 → 100644
View file @
fde0aba7
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
()));
}
}
yaoai-comic-studio/yaoai-ai-providers/pom.xml
View file @
fde0aba7
...
...
@@ -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>
yaoai-comic-studio/yaoai-ai-providers/src/main/java/com/yaoai/ai/providers/config/ModelCapability.java
0 → 100644
View file @
fde0aba7
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
);
}
}
yaoai-comic-studio/yaoai-ai-providers/src/main/java/com/yaoai/ai/providers/config/PlatformModelConfigService.java
0 → 100644
View file @
fde0aba7
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
;
}
}
yaoai-comic-studio/yaoai-ai-providers/src/main/java/com/yaoai/ai/providers/config/PlatformModelItem.java
0 → 100644
View file @
fde0aba7
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
;
}
yaoai-comic-studio/yaoai-ai-providers/src/main/java/com/yaoai/ai/providers/config/PlatformModelSettings.java
0 → 100644
View file @
fde0aba7
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
;
}
}
}
yaoai-comic-studio/yaoai-ai-providers/src/main/java/com/yaoai/ai/providers/config/ProviderRestClientFactory.java
0 → 100644
View file @
fde0aba7
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
;
}
}
}
yaoai-comic-studio/yaoai-ai-providers/src/main/java/com/yaoai/ai/providers/config/ResolvedModelConfig.java
0 → 100644
View file @
fde0aba7
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"
);
}
}
yaoai-comic-studio/yaoai-ai-providers/src/main/java/com/yaoai/ai/providers/service/impl/ArkLlmService.java
View file @
fde0aba7
...
...
@@ -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
A
RK_A
PI_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
=
restClient
Factory
.
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
.
getA
piKey
();
private
String
resolveApiKey
(
ResolvedModelConfig
config
)
{
String
apiKey
=
config
.
a
piKey
();
if
(
apiKey
==
null
||
apiKey
.
isBlank
())
{
throw
new
BizException
(
ErrorCode
.
INTERNAL_ERROR
,
A
RK_A
PI_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
;
}
...
...
yaoai-comic-studio/yaoai-ai-providers/src/main/java/com/yaoai/ai/providers/service/impl/SeedanceServiceImpl.java
View file @
fde0aba7
...
...
@@ -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
=
restClient
Factory
.
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
,
A
RK_A
PI_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
;
}
}
yaoai-comic-studio/yaoai-ai-providers/src/main/java/com/yaoai/ai/providers/service/impl/SeedreamServiceImpl.java
View file @
fde0aba7
...
...
@@ -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
A
RK_A
PI_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
=
restClient
Factory
.
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 i
mage generated: url={}"
,
url
);
log
.
info
(
"
I
mage 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
.
getA
piKey
();
private
String
resolveApiKey
(
ResolvedModelConfig
config
)
{
String
apiKey
=
config
.
a
piKey
();
if
(
apiKey
==
null
||
apiKey
.
isBlank
())
{
throw
new
BizException
(
ErrorCode
.
INTERNAL_ERROR
,
A
RK_A
PI_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
;
...
...
yaoai-comic-studio/yaoai-api/src/main/java/com/yaoai/api/controller/VideoTaskController.java
View file @
fde0aba7
...
...
@@ -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
();
...
...
yaoai-comic-studio/yaoai-api/src/main/java/com/yaoai/api/dto/ai/StructuredVideoGenerateRequest.java
View file @
fde0aba7
...
...
@@ -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
;
/** 是否生成配音 */
...
...
yaoai-comic-studio/yaoai-bootstrap/src/test/java/com/yaoai/ai/providers/config/PlatformModelConfigServiceTest.java
0 → 100644
View file @
fde0aba7
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
;
}
}
yaoai-comic-studio/yaoai-domain/src/main/java/com/yaoai/domain/mapper/AiTaskMapper.java
View file @
fde0aba7
...
...
@@ -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}
...
...
yaoai-comic-studio/yaoai-pipeline/src/main/java/com/yaoai/pipeline/async/VideoTaskCompletionService.java
View file @
fde0aba7
...
...
@@ -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
;
}
}
}
yaoai-comic-studio/yaoai-pipeline/src/main/java/com/yaoai/pipeline/async/VideoTransferService.java
0 → 100644
View file @
fde0aba7
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
);
}
}
}
yaoai-comic-studio/yaoai-pipeline/src/main/java/com/yaoai/pipeline/service/impl/AssetGenPipelineServiceImpl.java
View file @
fde0aba7
...
...
@@ -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"
);
...
...
yaoai-comic-studio/yaoai-pipeline/src/main/java/com/yaoai/pipeline/service/impl/SceneImageTransferService.java
0 → 100644
View file @
fde0aba7
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
)
{
}
}
yaoai-comic-studio/yaoai-pipeline/src/main/java/com/yaoai/pipeline/service/impl/VideoTaskPipelineServiceImpl.java
View file @
fde0aba7
...
...
@@ -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
;
}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment