Commit dcfda87a authored by 黄同智's avatar 黄同智
parents af8a7c8b 32507e88
TZ=Asia/Shanghai
MYSQL_PORT=13306
MYSQL_PORT=23306
MYSQL_ROOT_PASSWORD=root123
MYSQL_DATABASE=yaoai_comic
MYSQL_USER=yaoai
MYSQL_PASSWORD=yaoai123
REDIS_PORT=16379
REDIS_PORT=26379
BACKEND_PORT=8080
BACKEND_PORT=28081
FRONTEND_PORT=3000
ADMIN_SERVER_PORT=8081
ADMIN_WEB_PORT=3001
SPRING_PROFILES_ACTIVE=prod
VITE_API_BASE_URL=/api
......
......@@ -70,8 +70,9 @@ npm run dev
默认开发访问地址:
- 前端:`http://localhost:5173`
- 后端:`http://localhost:8080`
- Swagger:`http://localhost:8080/swagger-ui/index.html`
- 管理端:`http://localhost:5174`
- 后端:`http://localhost:8081`
- Swagger:`http://localhost:8081/swagger-ui/index.html`
## Docker 一键部署
......@@ -103,19 +104,58 @@ docker compose up -d --build
默认部署访问地址:
- 前端:`http://localhost:3000`
- 后端:`http://localhost:8080`
- 管理端:`http://localhost:3001`
- 后端:`http://localhost:28081`
- Swagger:`http://localhost:28081/swagger-ui/index.html`
- MySQL:`localhost:23306`
- Redis:`localhost:26379`
说明:
- 前端容器内部使用 Nginx 托管打包后的页面,并将 `/api` 反向代理到后端容器。
- 当前前端 Nginx 已配置 `100MB` 上传限制,与后端 `multipart` 配置保持一致,适合脚本、素材等较大的上传请求。
- 如果本机已经启动了开发态依赖服务,请先停止它们,或修改 `.env` 中的端口后再启动根目录部署编排
- 根目录发布态默认使用独立端口:`MySQL 23306``Redis 26379``后端 28081`,可以和开发态同时保留
- 当前 Docker 基础镜像已切换到更适合当前网络环境的镜像源,减少拉取失败概率。
## 双环境稳定使用
如果你需要同时保留“开发态”和“本地发布态”,请固定按下面方式使用,不要混用入口。
开发态:
- 入口:`yaoai-comic-studio/docker-compose.yml`
- 用途:本地开发后端、前端、管理端
- 数据库:`localhost:13306`
- Redis:`localhost:16379`
- 后端:`http://localhost:8081`
- 前端:`http://localhost:5173`
- 管理端:`http://localhost:5174`
- 启动脚本:`./start-dev.ps1`
本地发布态:
- 入口:根目录 `docker-compose.yml`
- 用途:本地模拟完整部署
- 数据库:`localhost:23306`
- Redis:`localhost:26379`
- 后端:`http://localhost:28081`
- 前端:`http://localhost:3000`
- 管理端:`http://localhost:3001`
- 启动脚本:`./start-prod-local.ps1`
稳定规则:
- 开发态和本地发布态是两套独立数据库,不共享数据。
- 启动前先确认自己要进哪套环境,不要把两套环境当成同一份数据。
- 不要执行 `docker compose down -v`,否则会删除对应环境的数据卷。
- 前端登录态会缓存到浏览器,切环境时建议使用不同浏览器配置文件或无痕窗口,避免把本地缓存误判成数据库数据。
## 关键文件
- `docker-compose.yml`:发布态部署入口
- `yaoai-comic-studio/docker-compose.yml`:开发态依赖服务编排
- `start-dev.ps1`:开发态启动入口说明脚本
- `start-prod-local.ps1`:本地发布态启动入口脚本
- `doc/html/Dockerfile`:前端镜像构建
- `doc/html/nginx.conf`:前端反向代理与上传限制配置
- `yaoai-comic-studio/Dockerfile`:后端镜像构建
......@@ -126,4 +166,4 @@ docker compose up -d --build
- 仓库已经整理为“前端 + 后端”双工程结构
- GitHub 远程仓库已完成同步
- 根目录发布态镜像构建链路已验证通过
- 若开发态 Docker 服务正在运行,发布态启动前需要避免端口和容器资源冲突
- 双环境默认端口已分离,可同时保留但应按固定入口使用
......@@ -25,6 +25,7 @@
| 14 | [前端原型截图复盘与最终确认](./14-前端原型截图复盘与最终确认.md) | 34 截图映射、页面覆盖率、原型与设计文档差异 |
| 15 | [执行任务列表](./15-执行任务列表.md) | M0~M9 全量任务清单,~302 项,完成即标记 |
| 17 | [平台运营后台设计 (M14)](./17-平台运营后台设计.md) | yaoai-admin 模块 + vue-vben-admin 前端,跨租户管理/充值/审计 |
| 18 | [Agent 升级方案 PRD](./18-Agent升级方案PRD.md) | 动态能力编排、Prompt 升级、上下文统一、质量门禁与审计方案 |
## 一句话总结
......
# 18 · Agent 剧情理解与一致性附录
# 18 · Agent 剧情理解与一致性附录
> 主文档: [18-Agent升级方案PRD.md](/Users/linshizhanghu/Documents/yaoai-video/design-docs/18-Agent升级方案PRD.md)
> 用途: 记录剧情理解、证据追溯、一致性基准和提示词准确性约束的详细结构。
## 1. ScriptUnderstanding 输出结构
```json
{
"scriptDigest": {
"titleGuess": "隐婚复仇",
"genre": "现代都市情感",
"theme": "真相、背叛、成长",
"oneSentenceLogline": "女主在被背叛后查清真相并夺回人生主动权"
},
"plotFacts": [
{
"factId": "pf_001",
"episodeHint": 1,
"event": "女主发现未婚夫与继妹联手设计她",
"characters": ["女主", "未婚夫", "继妹"],
"location": "订婚宴后台",
"cause": "继妹想夺取女主继承权",
"effect": "女主决定暗中调查",
"evidence": {
"source": "script",
"assetId": 123,
"excerpt": "……",
"startOffset": 1024,
"endOffset": 1288
}
}
],
"characterFacts": [
{
"name": "女主",
"aliases": ["林知夏", "知夏"],
"identity": "设计师,家族继承人",
"relationshipFacts": ["与继妹存在继承权冲突"],
"firstAppearanceFactId": "pf_001",
"evidenceFactIds": ["pf_001", "pf_004"]
}
],
"sceneFacts": [
{
"name": "订婚宴后台",
"sceneType": "indoor",
"narrativeFunction": "揭露背叛与主角觉醒",
"evidenceFactIds": ["pf_001"]
}
],
"uncertainties": [
{
"type": "character_name_missing",
"message": "剧本片段中多处使用“她”,需要结合上下文确认是否均指女主",
"affectedFactIds": ["pf_003"]
}
]
}
```
## 2. 剧情理解复核输出
```json
{
"coverageScore": 0.86,
"coveredChunks": ["chunk_001", "chunk_002"],
"missingChunks": ["chunk_009"],
"contradictions": [
{
"type": "timeline_conflict",
"message": "同一事件在两个片段中出现不同先后顺序",
"factIds": ["pf_012", "pf_019"],
"blocking": true
}
],
"readyForGeneration": false
}
```
门禁规则:
- 剧本中每个关键场次至少生成一条 `plotFact`,或被明确标记为无效/过场。
- `plotFacts` 必须覆盖主线冲突、转折点、人物关系变化和结局方向。
- 无证据的人物关系、动机、事件因果必须降级为 `pending_inference`
- `coverageScore` 低于阈值,或关键事实存在冲突时,不允许进入 `outline_gen`
## 3. ProjectConsistencyBible 结构
```json
{
"storyWorld": {
"genre": "现代都市言情",
"theme": "复仇与自我成长",
"timeline": "当代,故事主线跨度约 30 天",
"coreConflict": "女主寻找真相并对抗商业家族操控",
"forbiddenDrift": ["不得突然改成古风", "不得新增超自然设定"]
},
"characters": [
{
"id": 1001,
"name": "林知夏",
"roleType": "female_lead",
"identity": "被陷害的年轻设计师",
"personality": "克制、敏感、外柔内刚",
"visualAnchor": "25岁,黑色长发,清冷气质,浅色通勤装",
"costumeRules": ["职场场景以白/灰/浅蓝为主", "危机场景可换深色外套"],
"forbiddenDrift": ["不得变成短发", "不得改成学生身份"]
}
],
"scenes": [
{
"id": 2001,
"name": "盛华集团顶层会议室",
"sceneType": "indoor",
"spatialAnchor": "落地窗在右侧,长会议桌居中,城市天际线作为背景",
"lighting": "冷白顶光 + 玻璃反射",
"colorPalette": "steel blue / cold grey / black",
"forbiddenDrift": ["不得出现人物肖像海报", "不得变成暖色咖啡厅"]
}
],
"visualStyle": {
"baseStyle": "写实电影感短剧",
"lensLanguage": "中近景推动情绪,关键冲突用低角度或压迫性构图",
"colorPolicy": "冷灰蓝为主,高压情绪可加入低饱和红色点缀",
"characterRendering": "同一角色保持年龄、发型、体型、服装色系一致",
"sceneRendering": "场景保持空间方位、主色调、光线方向一致",
"negativeRules": ["不得随机切换二次元风格", "不得出现不相关道具和人物"]
}
}
```
## 4. 一致性硬约束模板
```text
一致性基准:
- 剧情主线: {{storyWorld.coreConflict}}
- 时间线: {{storyWorld.timeline}}
- 角色视觉锚点: {{characters[].visualAnchor}}
- 角色禁改项: {{characters[].forbiddenDrift}}
- 场景空间锚点: {{scenes[].spatialAnchor}}
- 场景色彩/光线: {{scenes[].colorPalette}} / {{scenes[].lighting}}
- 整体画面风格: {{visualStyle.baseStyle}} / {{visualStyle.lensLanguage}}
硬性要求:
1. 不得改写任何已存在角色的身份、年龄段、发型、体型、核心服装色系。
2. 不得把已存在场景改成另一种空间结构、主色调或光线方向。
3. 不得引入与剧情主线无关的新人物、新场景、新世界观设定。
4. 如确实需要新增角色或场景,必须输出为 pending_suggestion,不得直接混入正式结果。
```
## 5. 视频提示词中间结构
```json
{
"storyboardId": 3001,
"sourceFactIds": ["pf_023", "pf_024"],
"characterRefs": [
{
"characterId": 1001,
"name": "林知夏",
"usedAnchor": "25岁,黑色长发,清冷气质,浅色通勤装"
}
],
"sceneRef": {
"sceneId": 2001,
"usedAnchor": "落地窗在右侧,长会议桌居中,城市天际线作为背景"
},
"shotFacts": {
"action": "女主克制地看向会议桌对面,压下愤怒",
"emotion": "隐忍、警觉",
"dialogueIntent": "她意识到对方在隐瞒关键证据"
},
"visualDirectives": {
"camera": "medium close-up, slight low angle",
"lighting": "cold white overhead light with glass reflection",
"composition": "female lead on left third, conference table leading lines"
},
"finalPrompt": "..."
}
```
校验规则:
- `finalPrompt` 中的所有角色名、动作、情绪、场景、关键道具,都必须能在 `sourceFactIds / characterRefs / sceneRef / visualDirectives` 中找到来源。
- `finalPrompt` 不允许出现中间结构里没有声明的新增人物、地点、事件和道具。
- 如果模型无法从事实生成足够准确的画面提示词,应返回 `need_more_context`,而不是补剧情。
This diff is collapsed.
# 18 · Agent 实施细节附录
# 18 · Agent 实施细节附录
> 主文档: [18-Agent升级方案PRD.md](/Users/linshizhanghu/Documents/yaoai-video/design-docs/18-Agent升级方案PRD.md)
> 用途: 记录数据库、后端服务、前端页面和验收测试的落地细节。
## 1. 新增表
### 1.1 `script_understandings`
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | bigint | 主键 |
| `tenant_id` | bigint | 租户 ID |
| `project_id` | bigint | 项目 ID |
| `asset_id` | bigint | 来源剧本资产 ID |
| `understanding_json` | json | 剧情事实、角色事实、场景事实、不确定项 |
| `evidence_hash` | varchar(64) | 原文证据摘要哈希 |
| `coverage_report_json` | json | 剧情覆盖率、缺失片段、冲突事实 |
| `quality_status` | varchar(32) | PASSED / NEED_CONFIRMATION / FAILED |
| `quality_issues_json` | json | 剧情理解问题 |
| `created_at` | datetime(3) | 创建时间 |
| `updated_at` | datetime(3) | 更新时间 |
### 1.2 `project_consistency_bibles`
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | bigint | 主键 |
| `tenant_id` | bigint | 租户 ID |
| `project_id` | bigint | 项目 ID |
| `bible_json` | json | 剧情、角色、场景、风格、画面基准 |
| `source_version` | varchar(64) | 来源版本,如 outline_v1 / manual_v2 |
| `locked_fields_json` | json | 人工锁定字段 |
| `created_at` | datetime(3) | 创建时间 |
| `updated_at` | datetime(3) | 更新时间 |
### 1.3 `agent_run_plans`
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | bigint | 主键 |
| `run_id` | bigint | `graph_runs.id` |
| `run_mode` | varchar(64) | 运行模式 |
| `reasoning_summary` | text | 计划说明 |
| `planned_steps_json` | json | 计划步骤 |
| `skipped_steps_json` | json | 跳过步骤与原因 |
| `step_inputs_json` | json | 每步上下文输入 |
| `quality_rules_json` | json | 质量规则 |
| `model_policy_json` | json | 模型策略 |
| `context_options_json` | json | 上下文开关 |
| `idempotency_key` | varchar(128) | 幂等键 |
| `plan_version` | varchar(32) | 计划版本 |
| `created_at` | datetime(3) | 创建时间 |
### 1.4 `agent_prompt_snapshots`
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | bigint | 主键 |
| `run_id` | bigint | 运行 ID |
| `step_key` | varchar(64) | 步骤标识 |
| `prompt_role` | varchar(64) | outline / episode / asset / storyboard / review |
| `system_prompt_version` | varchar(64) | Prompt 版本 |
| `context_hash` | varchar(64) | 上下文哈希 |
| `context_summary` | text | 上下文摘要 |
| `request_json` | json | 模型请求快照,注意脱敏 |
| `response_raw` | longtext | 原始响应 |
| `parsed_output_json` | json | 解析结果 |
| `validation_result_json` | json | 校验结果 |
| `model_provider` | varchar(64) | 当前应为 `volcengine` |
| `model_id` | varchar(128) | 方舟模型 ID |
| `latency_ms` | bigint | 耗时 |
| `error_message` | text | 错误信息 |
| `created_at` | datetime(3) | 创建时间 |
## 2. 后端服务
建议在 `yaoai-agent` 新增:
- `RunIntentResolver`
- `RunContextLoader`
- `RunModeRouter`
- `RunPlanExecutor`
- `ScriptUnderstandingService`
- `ConsistencyBibleService`
- `QualityGateService`
- `ConsistencyGateService`
- `PromptSnapshotService`
建议在 `yaoai-pipeline` 新增或扩展:
- `ScriptUnderstandingPromptContext`
- `OutlinePromptContext`
- `EpisodePromptContext`
- `AssetPromptContext`
- `StoryboardPromptContext`
- `PromptAssemblyService`
新增上下文驱动方法:
```java
ScriptUnderstanding understandScript(ScriptUnderstandingPromptContext context);
Outline generateOutline(OutlinePromptContext context);
List<Episode> generateEpisodes(EpisodePromptContext context);
List<Character> extractCharacters(AssetPromptContext context);
List<Scene> extractScenes(AssetPromptContext context);
List<Storyboard> generateStoryboards(StoryboardPromptContext context);
```
旧方法保留并包装新方法,避免破坏现有手动生成入口。
## 3. 前端页面
`AgentStudio` 需要增加:
- 运行意图选择: 自动、续跑失败、局部补齐、强制重跑。
- 剧情理解面板: 展示剧情事实、人物关系、关键场景、不确定项。
- 用户确认入口: 关键不确定项存在时,允许用户确认或修正后再继续。
- 一致性面板: 展示角色视觉锚点、场景空间锚点、项目风格和锁定字段。
- 漂移提醒: 展示某次生成偏离了哪个剧情事实、角色、场景或风格规则。
- 计划展示: 本次会执行哪些步骤、跳过哪些步骤、为什么跳过。
## 4. 分阶段实施
### Phase 0: 快速止血
- 读取 `project_assets.asset_type = script`
- 新增最小 `ScriptUnderstandingService`
- `outline_gen` 前优先使用剧情事实。
- 没有剧本才使用 `goal`
### Phase 1: 计划与路由
- 新增 `RunIntentResolver``RunModeRouter``RunPlan`
- 新增 `agent_run_plans`
- 支持 `script_based``outline_continue``storyboard_only``resume_failed`
- `graph_steps` 由计划动态创建。
### Phase 2: Prompt 协议化与门禁
- 新增 `PromptAssemblyService`
- 新增 `QualityGateService`
- 剧情理解、大纲、分集、角色、场景、分镜接入结构校验。
- 新增 `agent_prompt_snapshots`
### Phase 3: 一致性基准与漂移检测
- 新增 `project_consistency_bibles`
- 新增 `ConsistencyBibleService`
- 新增 `ConsistencyGateService`
- 检查剧情、角色、人物视觉、场景、风格、画面连续性漂移。
### Phase 4: 分镜与提示词追溯
- 分镜生成显式引用剧情事实、角色、场景。
- 视频提示词基于中间结构生成。
- 视频提示词必须能追溯到 `sourceFactIds / characterRefs / sceneRef`
### Phase 5: 前端解释与后台审计
- AgentStudio 展示剧情理解、运行计划、一致性基准、漂移提醒。
- 管理端可查询 Prompt 快照和失败记录。
## 5. 验收测试
核心测试:
- `ScriptUnderstandingService` 具备切片、事实合并、覆盖率、冲突检测测试。
- `RunModeRouter` 具备规则路由测试。
- `QualityGateService` 具备剧情理解、大纲、分集、资产、分镜校验测试。
- `ConsistencyGateService` 具备角色视觉、场景空间、风格冲突、相邻分镜连续性测试。
- 旧 API 行为兼容,不影响已有手动生成入口。
This diff is collapsed.
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'node:url';
import path from 'node:path';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
const repoRoot = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '../..');
function resolvePort(value: string | undefined, fallback: number) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, repoRoot, '');
return {
envDir: repoRoot,
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: resolvePort(env.H5_FRONTEND_PORT, 4173)
}
},
server: {
port: 4173
}
};
});
......@@ -5819,24 +5819,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zustand": {
"version": "5.0.3",
"resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.3.tgz",
......
......@@ -42,7 +42,7 @@ export function Layout() {
const [currentGroupId, setCurrentGroupId] = useState<number | "">("");
const userMenuRef = useRef<HTMLDivElement>(null);
const { user } = useAuthStore();
const user = useAuthStore((state) => state.user);
const { logout } = useAuth();
const { data: balance } = useBalance();
const { data: teamGroups = [] } = useTeamGroups();
......@@ -81,9 +81,19 @@ export function Layout() {
];
const notifications = [
{ id: 1, title: "项目《重生之豪门千金》第3集分镜待审核", time: "5分钟前" },
{ id: 2, title: "系统更新:新增AI水印功能", time: "1小时前" },
{ id: 3, title: "张三提交的角色设定已通过审核", time: "2小时前" },
{
id: 1,
title: "项目《重生之豪门千金》第3集分镜待审核",
time: "5分钟前",
path: projectId ? `/project/${projectId}/storyboard` : "/",
},
{ id: 2, title: "系统更新:新增AI水印功能", time: "1小时前", path: "/settings" },
{
id: 3,
title: "张三提交的角色设定已通过审核",
time: "2小时前",
path: projectId ? `/project/${projectId}/characters` : "/",
},
];
const primaryMenu = [
......@@ -152,10 +162,18 @@ export function Layout() {
<div className="absolute right-0 top-11 z-50 w-80 overflow-hidden rounded-lg border border-[#dfe3ee] bg-white shadow-xl">
<div className="border-b border-[#edf0f6] px-4 py-3 text-sm font-medium text-[#111827]">通知</div>
{notifications.map((item) => (
<div key={item.id} className="border-b border-[#f1f3f8] px-4 py-3 last:border-b-0">
<button
key={item.id}
type="button"
onClick={() => {
setShowNotifications(false);
navigate(item.path);
}}
className="block w-full border-b border-[#f1f3f8] px-4 py-3 text-left transition hover:bg-[#f6f9ff] focus:bg-[#f6f9ff] focus:outline-none last:border-b-0"
>
<div className="text-sm text-[#1f2937]">{item.title}</div>
<div className="mt-1 text-xs text-[#8a94a6]">{item.time}</div>
</div>
</button>
))}
</div>
)}
......
......@@ -6,11 +6,20 @@ interface Props {
}
export function PrivateRoute({ children }: Props) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const hasHydrated = useAuthStore((state) => state.hasHydrated);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const location = useLocation();
if (!hasHydrated) {
return (
<div className="flex min-h-screen items-center justify-center bg-background text-sm text-muted-foreground">
正在加载账号状态...
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
return <Navigate to="/login" replace state={{ from: location }} />;
}
return <>{children}</>;
......
import type { ReactNode } from "react";
import { AlertCircle, Loader2 } from "lucide-react";
import { cn } from "./utils";
export function PageShell({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={cn("h-full overflow-auto bg-background p-6", className)}>
{children}
</div>
);
}
export function PageHeader({
title,
subtitle,
eyebrow,
actions,
className,
}: {
title: ReactNode;
subtitle?: ReactNode;
eyebrow?: ReactNode;
actions?: ReactNode;
className?: string;
}) {
return (
<div className={cn("mb-6 flex items-start justify-between gap-4", className)}>
<div className="min-w-0">
{eyebrow && (
<div className="mb-1 text-xs font-medium uppercase tracking-normal text-muted-foreground">
{eyebrow}
</div>
)}
<h1 className="truncate text-xl font-semibold leading-tight text-foreground">
{title}
</h1>
{subtitle && (
<p className="mt-1 max-w-3xl text-sm leading-6 text-muted-foreground">
{subtitle}
</p>
)}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
);
}
export function WorkPanel({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<section className={cn("rounded-lg border border-border bg-card", className)}>
{children}
</section>
);
}
export function StatsStrip({
items,
className,
}: {
items: Array<{ label: ReactNode; value: ReactNode; hint?: ReactNode }>;
className?: string;
}) {
return (
<div className={cn("grid gap-3 sm:grid-cols-2 lg:grid-cols-4", className)}>
{items.map((item, index) => (
<div key={index} className="rounded-lg border border-border bg-card px-4 py-3">
<div className="text-xs text-muted-foreground">{item.label}</div>
<div className="mt-1 text-xl font-semibold leading-tight text-foreground">{item.value}</div>
{item.hint && <div className="mt-1 text-xs text-muted-foreground">{item.hint}</div>}
</div>
))}
</div>
);
}
export function EmptyState({
icon,
title,
description,
action,
className,
}: {
icon?: ReactNode;
title: ReactNode;
description?: ReactNode;
action?: ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card px-6 py-12 text-center", className)}>
{icon && <div className="mb-3 text-muted-foreground/50">{icon}</div>}
<div className="text-sm font-medium text-foreground">{title}</div>
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
);
}
export function LoadingState({ label = "加载中" }: { label?: ReactNode }) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2 className="h-7 w-7 animate-spin" />
<div className="text-sm">{label}</div>
</div>
);
}
export function ErrorState({ label }: { label: ReactNode }) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground">
<AlertCircle className="h-9 w-9" />
<div className="text-sm">{label}</div>
</div>
);
}
export function StatusBadge({
tone = "neutral",
children,
className,
}: {
tone?: "neutral" | "info" | "success" | "warning" | "danger";
children: ReactNode;
className?: string;
}) {
const tones = {
neutral: "bg-muted text-muted-foreground",
info: "bg-primary/10 text-primary",
success: "bg-emerald-500/10 text-emerald-500",
warning: "bg-amber-500/10 text-amber-600",
danger: "bg-red-500/10 text-red-500",
};
return (
<span className={cn("inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium", tones[tone], className)}>
{children}
</span>
);
}
......@@ -19,6 +19,8 @@ import {
Star,
MessageSquare,
} from "lucide-react";
import { Switch } from "../components/ui/switch";
import { EmptyState, ErrorState, LoadingState } from "../components/ui/page";
import {
useLatestAgentRun,
useStartAgentRun,
......@@ -135,6 +137,9 @@ export function AgentStudio() {
const { projectId = "" } = useParams();
const [goal, setGoal] = useState("将剧本生成完整动画短剧,5集,现代都市言情风格");
const [activeTab, setActiveTab] = useState<"chat" | "logs">("logs");
const [useScriptAsset, setUseScriptAsset] = useState(true);
const [reuseExisting, setReuseExisting] = useState(false);
const [forceRegenerate, setForceRegenerate] = useState(true);
const logsEndRef = useRef<HTMLDivElement>(null);
const { data: run, isLoading, isError } = useLatestAgentRun(projectId);
......@@ -148,8 +153,10 @@ export function AgentStudio() {
const runStatus = run?.status ?? "idle";
const doneCount = steps.filter((s) => s.status === "DONE").length;
const skippedCount = steps.filter((s) => s.status === "SKIPPED").length;
const completedCount = doneCount + skippedCount;
const totalCount = steps.length;
const progress = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0;
const progress = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
const runningStep = steps.find((s) => s.status === "RUNNING");
useEffect(() => {
......@@ -157,7 +164,22 @@ export function AgentStudio() {
}, [logs, activeTab]);
const handleStart = () => {
startMut.mutate(goal);
const trimmedGoal = goal.trim();
if (!trimmedGoal) return;
const effectiveReuseExisting = reuseExisting && !forceRegenerate;
const effectiveForceRegenerate = forceRegenerate || !effectiveReuseExisting;
startMut.mutate({
goal: trimmedGoal,
runIntent: useScriptAsset ? "script_understanding" : "auto",
forceRegenerate: effectiveForceRegenerate,
contextOptions: {
useScriptAsset,
useExistingOutline: effectiveReuseExisting,
useExistingEpisodes: effectiveReuseExisting,
useExistingAssets: effectiveReuseExisting,
useExistingStoryboards: effectiveReuseExisting,
},
});
};
const handlePause = () => {
......@@ -169,20 +191,11 @@ export function AgentStudio() {
};
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
);
return <LoadingState label="正在读取 Agent 状态" />;
}
if (isError) {
return (
<div className="h-full flex items-center justify-center flex-col gap-3 text-muted-foreground">
<AlertCircle className="w-10 h-10" />
<p className="text-sm">无法连接后端服务,请确认后端已启动</p>
</div>
);
return <ErrorState label="无法连接后端服务,请确认后端已启动" />;
}
const isIdle = !run || runStatus === "DONE" || runStatus === "FAILED";
......@@ -229,7 +242,7 @@ export function AgentStudio() {
{isIdle && (
<button
onClick={handleStart}
disabled={startMut.isPending}
disabled={startMut.isPending || !goal.trim()}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{startMut.isPending ? (
......@@ -283,7 +296,7 @@ export function AgentStudio() {
<div className="mt-4">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">
{runningStep ? `正在执行:${runningStep.stepName}` : `${doneCount} / ${totalCount} 步骤完成`}
{runningStep ? `正在执行:${runningStep.stepName}` : `${completedCount} / ${totalCount} 步骤完成`}
</span>
<span className="text-xs font-medium text-foreground">{progress}%</span>
</div>
......@@ -303,8 +316,13 @@ export function AgentStudio() {
<div className="flex-1 overflow-auto p-6">
{/* Goal input — idle state */}
{isIdle && (
<div className="mb-6 p-4 rounded-xl border border-primary/20 bg-primary/5">
<label className="block text-sm font-medium text-foreground mb-2">制作目标</label>
<div className="mb-6 rounded-lg border border-primary/20 bg-primary/5 p-4">
<div className="mb-2 flex items-center justify-between gap-3">
<label className="block text-sm font-medium text-foreground">制作目标</label>
<span className="rounded bg-background/80 px-2 py-1 text-xs text-muted-foreground">
动态 Agent
</span>
</div>
<textarea
value={goal}
onChange={(e) => setGoal(e.target.value)}
......@@ -312,9 +330,20 @@ export function AgentStudio() {
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="描述你想要的短剧,例如:现代都市爱情,5集,每集3分钟..."
/>
<p className="mt-2 text-xs text-muted-foreground">
AI 将自动完成:大纲 → 分集脚本 → 角色/场景提取 → 分镜生成,全程监督审核
</p>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<label className="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-background/80 px-3 py-2">
<span className="text-xs text-muted-foreground">优先理解剧本</span>
<Switch checked={useScriptAsset} onCheckedChange={setUseScriptAsset} />
</label>
<label className="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-background/80 px-3 py-2">
<span className="text-xs text-muted-foreground">复用已有内容</span>
<Switch checked={reuseExisting} onCheckedChange={setReuseExisting} />
</label>
<label className="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-background/80 px-3 py-2">
<span className="text-xs text-muted-foreground">强制重生成</span>
<Switch checked={forceRegenerate} onCheckedChange={setForceRegenerate} />
</label>
</div>
</div>
)}
......@@ -329,11 +358,11 @@ export function AgentStudio() {
{/* Placeholder when no run */}
{!run && (
<div className="flex flex-col items-center justify-center py-20 text-center text-muted-foreground">
<Sparkles className="w-12 h-12 mb-4 opacity-20" />
<p className="text-sm">填写制作目标,点击"开始制作"</p>
<p className="text-xs mt-1">AI 将自动规划并执行所有步骤</p>
</div>
<EmptyState
icon={<Sparkles className="h-10 w-10" />}
title="填写制作目标后开始"
description="系统会根据项目已有剧本和素材动态规划执行步骤"
/>
)}
{/* Done summary */}
......@@ -344,7 +373,7 @@ export function AgentStudio() {
<span className="font-medium">制作完成</span>
</div>
<p className="text-sm text-muted-foreground">
全部 {totalCount} 个步骤已完成。前往分镜工作台查看生成结果。
全部 {totalCount} 个步骤已完成,其中 {skippedCount} 个步骤复用了现有内容。前往分镜工作台查看生成结果。
</p>
</div>
)}
......@@ -412,7 +441,7 @@ export function AgentStudio() {
<div>
<p className="font-medium text-foreground mb-1">执行顺序</p>
<ol className="text-xs space-y-1 list-decimal list-inside">
<li>目标分析与规划</li>
<li>剧情理解或目标分析</li>
<li>大纲生成</li>
<li>分集脚本生成</li>
<li>监督层审核剧本</li>
......
......@@ -52,6 +52,7 @@ const bgColorMap: Record<string, string> = {
};
type EditingProvider = Omit<ModelProvider, "id"> & { id?: number };
const EMPTY_DEFAULTS: ModelDefaults = {};
export function ModelSettings() {
const [activeCapability, setActiveCapability] = useState<Capability>("text");
......@@ -60,16 +61,18 @@ export function ModelSettings() {
const [modelSearch, setModelSearch] = useState("");
const { data: providers = [], isLoading: loadingProviders } = useModelProviders();
const { data: defaults = {}, isLoading: loadingDefaults } = useModelDefaults();
const { data: defaults, isLoading: loadingDefaults } = useModelDefaults();
const createProvider = useCreateProvider();
const updateProvider = useUpdateProvider();
const deleteProvider = useDeleteProvider();
const saveDefaults = useSaveModelDefaults();
const [localDefaults, setLocalDefaults] = useState<ModelDefaults>({});
const [localDefaults, setLocalDefaults] = useState<ModelDefaults>(EMPTY_DEFAULTS);
useEffect(() => {
if (!loadingDefaults) setLocalDefaults(defaults);
if (!loadingDefaults && defaults) {
setLocalDefaults(defaults);
}
}, [loadingDefaults, defaults]);
const getProvidersByCapability = (cap: Capability) =>
......
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { agentApi, type AgentRunDTO, type AgentStepDTO } from "../lib/api/agent";
import { agentApi, type AgentRunDTO, type AgentStartRequest, type AgentStepDTO } from "../lib/api/agent";
import { getAuthToken, useAuthStore } from "../stores/authStore";
const runKey = (projectId: string) => ["agent-run", projectId];
export function useLatestAgentRun(projectId: string) {
const isAuthReady = useAuthStore((state) => state.hasHydrated && state.isAuthenticated);
return useQuery({
queryKey: runKey(projectId),
queryFn: () => agentApi.getLatestRun(projectId),
enabled: !!projectId,
enabled: isAuthReady && !!projectId,
refetchInterval: false,
});
}
......@@ -16,7 +18,7 @@ export function useLatestAgentRun(projectId: string) {
export function useStartAgentRun(projectId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (goal: string) => agentApi.startRun(projectId, goal),
mutationFn: (request: string | AgentStartRequest) => agentApi.startRun(projectId, request),
onSuccess: (run) => {
qc.setQueryData(runKey(projectId), run);
},
......@@ -56,15 +58,18 @@ export function useAgentSse(runId: number | null, projectId: string) {
const qc = useQueryClient();
const [logs, setLogs] = useState<SseLog[]>([]);
const logIdRef = useRef(0);
const isAuthReady = useAuthStore((state) => state.hasHydrated && state.isAuthenticated);
useEffect(() => {
// 从 localStorage 直接取原始 token(不带 Bearer 前缀)
const token = localStorage.getItem("yaoai_token");
if (!runId || !token) return;
const token = getAuthToken() ?? localStorage.getItem("yaoai_token");
if (!runId || !token || !isAuthReady) return;
const BASE = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080";
const BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
const url = `${BASE}/agent/runs/${runId}/events?Authorization=${encodeURIComponent(token)}`;
const es = new EventSource(url);
let errCount = 0;
let reconnectTimer: number | null = null;
let shouldStop = false;
let source: EventSource | null = null;
const addLog = (level: SseLog["level"], message: string) => {
setLogs((prev) => [
......@@ -82,9 +87,9 @@ export function useAgentSse(runId: number | null, projectId: string) {
qc.invalidateQueries({ queryKey: runKey(projectId) });
};
es.onmessage = (e) => {
const handleEventPayload = (payload: string) => {
try {
const evt: SseEvent = JSON.parse(e.data);
const evt: SseEvent = JSON.parse(payload);
const { type, data } = evt;
if (type === "log") {
......@@ -98,14 +103,25 @@ export function useAgentSse(runId: number | null, projectId: string) {
} else if (type === "step.failed") {
addLog("error", `步骤失败:${data.stepKey}${data.error}`);
refreshRun();
} else if (type === "step.skipped") {
addLog("info", `步骤跳过:${data.stepKey}${data.output ?? "已复用现有内容"}`);
refreshRun();
} else if (type === "run.done") {
addLog("agent", "制作完成!");
refreshRun();
es.close();
shouldStop = true;
if (source) {
source.close();
source = null;
}
} else if (type === "run.failed") {
addLog("error", `制作失败:${data.error}`);
refreshRun();
es.close();
shouldStop = true;
if (source) {
source.close();
source = null;
}
} else if (type === "run.paused") {
addLog("info", "制作已暂停");
refreshRun();
......@@ -118,21 +134,80 @@ export function useAgentSse(runId: number | null, projectId: string) {
}
};
let errCount = 0;
es.onerror = () => {
const scheduleReconnect = () => {
if (shouldStop) {
return;
}
if (source) {
source.close();
source = null;
}
errCount++;
if (errCount === 1) addLog("warn", "SSE 连接断开,尝试重连...");
// 如果连续出错说明鉴权失败或服务不可用,停止重连
if (errCount === 1) {
addLog("warn", "SSE 连接断开,尝试重连...");
}
if (errCount >= 3) {
addLog("error", "SSE 无法连接,请刷新页面重试");
es.close();
shouldStop = true;
return;
}
reconnectTimer = window.setTimeout(() => {
connect();
}, 1000 * errCount);
};
const bindEvent = (eventType: string, eventSource: EventSource) => {
eventSource.addEventListener(eventType, (event) => {
const payload = (event as MessageEvent<string>).data;
if (typeof payload === "string") {
handleEventPayload(payload);
}
});
};
const connect = () => {
if (shouldStop) return;
const eventSource = new EventSource(url);
source = eventSource;
eventSource.onopen = () => {
errCount = 0;
};
for (const eventType of [
"log",
"step.start",
"step.done",
"step.failed",
"step.skipped",
"run.done",
"run.failed",
"run.paused",
"run.resumed",
]) {
bindEvent(eventType, eventSource);
}
eventSource.onerror = () => {
if (shouldStop) {
return;
}
scheduleReconnect();
};
};
return () => es.close();
// token 每次从 localStorage 读取,不需要作为依赖
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [runId, projectId]);
connect();
return () => {
shouldStop = true;
if (reconnectTimer != null) {
window.clearTimeout(reconnectTimer);
}
if (source) {
source.close();
}
};
}, [runId, projectId, qc, isAuthReady]);
return { logs };
}
This diff is collapsed.
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { useLocation, useNavigate } from "react-router";
import { authApi, LoginPayload, RegisterPayload } from "../lib/api/auth";
import { useAuthStore } from "../stores/authStore";
export function useAuth() {
const navigate = useNavigate();
const { setAuth, clearAuth, isAuthenticated, user } = useAuthStore();
const location = useLocation();
const setAuth = useAuthStore((state) => state.setAuth);
const clearAuth = useAuthStore((state) => state.clearAuth);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const user = useAuthStore((state) => state.user);
const getRedirectTarget = () => {
const queryRedirect = new URLSearchParams(location.search).get("redirect");
const stateRedirect = (location.state as { from?: { pathname?: string } } | null)?.from?.pathname;
return queryRedirect || stateRedirect || "/";
};
const loginMutation = useMutation({
mutationFn: (payload: LoginPayload) => authApi.login(payload),
......@@ -17,7 +27,7 @@ export function useAuth() {
role: data.user.role,
tenantId: String(data.user.tenantId),
});
navigate("/");
navigate(getRedirectTarget(), { replace: true });
},
});
......@@ -31,7 +41,7 @@ export function useAuth() {
role: data.user.role,
tenantId: String(data.user.tenantId),
});
navigate("/");
navigate(getRedirectTarget(), { replace: true });
},
});
......
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { projectsApi, ProjectCreatePayload, ProjectUpdatePayload } from "../lib/api/projects";
import { useAuthStore } from "../stores/authStore";
const PROJECTS_KEY = ["projects"];
const projectKey = (id: string) => ["projects", id];
export function useProjects() {
const isAuthReady = useAuthStore((state) => state.hasHydrated && state.isAuthenticated);
return useQuery({
queryKey: PROJECTS_KEY,
queryFn: projectsApi.list,
enabled: isAuthReady,
});
}
export function useProject(id: string) {
const isAuthReady = useAuthStore((state) => state.hasHydrated && state.isAuthenticated);
return useQuery({
queryKey: projectKey(id),
queryFn: () => projectsApi.get(id),
enabled: !!id,
enabled: isAuthReady && !!id,
});
}
......
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { teamApi, type CreateMemberPayload } from "../lib/api/team";
import { useAuthStore } from "../stores/authStore";
const membersKey = () => ["team-members"];
const groupsKey = () => ["team-groups"];
export function useTeamMembers() {
return useQuery({ queryKey: membersKey(), queryFn: teamApi.listMembers, staleTime: 30_000 });
const isAuthReady = useAuthStore((state) => state.hasHydrated && state.isAuthenticated);
return useQuery({ queryKey: membersKey(), queryFn: teamApi.listMembers, staleTime: 30_000, enabled: isAuthReady });
}
export function useAddMember() {
......@@ -34,7 +36,8 @@ export function useRemoveMember() {
}
export function useTeamGroups() {
return useQuery({ queryKey: groupsKey(), queryFn: teamApi.listGroups, staleTime: 30_000 });
const isAuthReady = useAuthStore((state) => state.hasHydrated && state.isAuthenticated);
return useQuery({ queryKey: groupsKey(), queryFn: teamApi.listGroups, staleTime: 30_000, enabled: isAuthReady });
}
export function useCreateGroup() {
......
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { usageApi } from "../lib/api/usage";
import { useAuthStore } from "../stores/authStore";
const balanceKey = () => ["usage-balance"];
const recordsKey = (limit: number, offset: number) => ["usage-records", limit, offset];
const costsKey = () => ["usage-costs"];
export function useBalance() {
const isAuthReady = useAuthStore((state) => state.hasHydrated && state.isAuthenticated);
return useQuery({
queryKey: balanceKey(),
queryFn: () => usageApi.getBalance(),
staleTime: 30_000,
enabled: isAuthReady,
});
}
export function useRecords(limit = 20, offset = 0) {
const isAuthReady = useAuthStore((state) => state.hasHydrated && state.isAuthenticated);
return useQuery({
queryKey: recordsKey(limit, offset),
queryFn: () => usageApi.getRecords(limit, offset),
staleTime: 30_000,
enabled: isAuthReady,
});
}
export function useCosts() {
const isAuthReady = useAuthStore((state) => state.hasHydrated && state.isAuthenticated);
return useQuery({
queryKey: costsKey(),
queryFn: () => usageApi.getCosts(),
staleTime: 5 * 60_000,
enabled: isAuthReady,
});
}
......
......@@ -24,10 +24,28 @@ export interface AgentRunDTO {
steps: AgentStepDTO[];
}
export interface AgentStartRequest {
goal: string;
runIntent?: "auto" | "script_understanding" | "continue_existing" | string;
forceRegenerate?: boolean;
selectedSteps?: string[];
contextOptions?: {
useScriptAsset?: boolean;
useExistingOutline?: boolean;
useExistingEpisodes?: boolean;
useExistingAssets?: boolean;
useExistingStoryboards?: boolean;
};
idempotencyKey?: string;
}
export const agentApi = {
startRun: (projectId: string, goal: string) =>
startRun: (projectId: string, request: string | AgentStartRequest) =>
apiClient
.post<{ data: AgentRunDTO }>(`/agent/projects/${projectId}/runs`, { goal })
.post<{ data: AgentRunDTO }>(
`/agent/projects/${projectId}/runs`,
typeof request === "string" ? { goal: request } : request,
)
.then((r) => r.data.data),
getLatestRun: (projectId: string) =>
......
......@@ -43,6 +43,32 @@ export interface Storyboard {
status: string;
createdAt: string;
updatedAt: string;
characterImageKeys?: string[] | null;
sceneImageKey?: string | null;
propImageKeys?: string[] | null;
styleImageKey?: string | null;
}
export interface ShotAsset {
id: string;
storyboardId: string;
projectId: string | null;
episodeId: string | null;
assetType: "first_frame" | "last_frame" | "reference" | "video" | string;
tosKey: string | null;
externalUrl: string | null;
sourceTaskId: string | null;
prompt: string | null;
modelProvider: string | null;
modelId: string | null;
assetVersion: number;
characters: string | null;
metaJson: string | null;
errorMessage: string | null;
status: string;
isActive: number;
createdAt: string;
updatedAt: string;
}
export interface Character {
......@@ -68,6 +94,22 @@ export interface Character {
createdAt: string;
}
export interface CharacterState {
id: string;
projectId: string;
characterId: string;
characterName?: string | null;
name: string;
stateType: string;
description: string | null;
prompt: string | null;
referenceImageKey: string | null;
imageUrl: string | null;
imageTosKey: string | null;
status: string;
createdAt: string;
}
export interface Scene {
id: string;
projectId: string;
......@@ -112,7 +154,7 @@ export interface StructuredVideoRequest {
characterImageKey?: string | null;
/** V16:多角色出镜(@图1..@图N,按顺序) */
characterImageKeys?: string[] | null;
sceneImageKey: string;
sceneImageKey?: string | null;
propImageKeys?: string[] | null;
styleImageKey?: string | null;
/** V17:单段 freeform 提示词(与 userPrompt 二选一,优先使用) */
......@@ -202,6 +244,14 @@ export const aiApi = {
const r = await apiClient.post(`/projects/${projectId}/storyboards/${storyboardId}/generate-prompt`);
return r.data.data.prompt;
},
listShotAssets: async (projectId: string, storyboardId: string): Promise<ShotAsset[]> => {
const r = await apiClient.get(`/projects/${projectId}/storyboards/${storyboardId}/assets`);
return r.data.data;
},
activateShotAsset: async (projectId: string, storyboardId: string, assetId: string): Promise<ShotAsset> => {
const r = await apiClient.post(`/projects/${projectId}/storyboards/${storyboardId}/assets/${assetId}/activate`);
return r.data.data;
},
// ---- Characters ----
extractCharacters: async (projectId: string): Promise<Character[]> => {
......@@ -216,10 +266,26 @@ export const aiApi = {
const r = await apiClient.get(`/projects/${projectId}/characters`);
return r.data.data;
},
listAllCharacterStates: async (projectId: string): Promise<CharacterState[]> => {
const r = await apiClient.get(`/projects/${projectId}/characters/states`);
return r.data.data;
},
listCharacterStates: async (projectId: string, characterId: string): Promise<CharacterState[]> => {
const r = await apiClient.get(`/projects/${projectId}/characters/${characterId}/states`);
return r.data.data;
},
saveCharacter: async (projectId: string, req: Partial<Character>): Promise<Character> => {
const r = await apiClient.post(`/projects/${projectId}/characters`, req);
return r.data.data;
},
saveCharacterState: async (
projectId: string,
characterId: string,
req: Partial<CharacterState>
): Promise<CharacterState> => {
const r = await apiClient.post(`/projects/${projectId}/characters/${characterId}/states`, req);
return r.data.data;
},
generateCharacterImage: async (projectId: string, characterId: string): Promise<Character> => {
const r = await apiClient.post(
`/projects/${projectId}/characters/${characterId}/generate-image`,
......@@ -228,6 +294,18 @@ export const aiApi = {
);
return r.data.data;
},
generateCharacterStateImage: async (
projectId: string,
characterId: string,
stateId: string
): Promise<CharacterState> => {
const r = await apiClient.post(
`/projects/${projectId}/characters/${characterId}/states/${stateId}/generate-image`,
undefined,
{ timeout: 180_000 }
);
return r.data.data;
},
uploadCharacterImage: async (
projectId: string,
characterId: string,
......@@ -246,6 +324,9 @@ export const aiApi = {
deleteCharacter: async (projectId: string, characterId: string): Promise<void> => {
await apiClient.delete(`/projects/${projectId}/characters/${characterId}`);
},
deleteCharacterState: async (projectId: string, characterId: string, stateId: string): Promise<void> => {
await apiClient.delete(`/projects/${projectId}/characters/${characterId}/states/${stateId}`);
},
// ---- Scenes ----
extractScenes: async (projectId: string): Promise<Scene[]> => {
......
import axios from "axios";
import { getAuthToken, useAuthStore } from "../../stores/authStore";
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
const AUTH_ROUTES = new Set(["/login", "/register"]);
let isRedirectingToLogin = false;
export const apiClient = axios.create({
baseURL: BASE_URL,
......@@ -10,9 +13,27 @@ export const apiClient = axios.create({
},
});
// Request interceptor: attach Bearer token from localStorage
function redirectToLogin() {
if (typeof window === "undefined" || isRedirectingToLogin) return;
try {
localStorage.removeItem("yaoai_token");
} catch {
// Ignore storage access errors in restricted browser contexts.
}
useAuthStore.getState().clearAuth();
if (AUTH_ROUTES.has(window.location.pathname)) return;
isRedirectingToLogin = true;
const redirect = `${window.location.pathname}${window.location.search}${window.location.hash}`;
window.location.replace(`/login?redirect=${encodeURIComponent(redirect)}`);
}
// Request interceptor: attach Bearer token from the persisted auth store
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem("yaoai_token");
const token = getAuthToken() ?? localStorage.getItem("yaoai_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
......@@ -33,8 +54,28 @@ apiClient.interceptors.response.use(
(error) => {
if (error.response?.data?.error) {
const { code, message } = error.response.data.error;
if (code === "UNAUTHORIZED") {
redirectToLogin();
}
return Promise.reject(new ApiError(code, message));
}
if (error.response?.status >= 500) {
const status = error.response.status;
return Promise.reject(
new ApiError(
"SERVER_UNAVAILABLE",
`后端接口异常(HTTP ${status}),请查看后端日志;同时确认 yaoai-comic-studio 已按根目录 .env 的 BACKEND_PORT 启动`
)
);
}
if (error.code === "ERR_NETWORK" || error.code === "ECONNABORTED") {
return Promise.reject(
new ApiError(
"NETWORK_ERROR",
"无法连接后端服务,请确认本地后端与数据库已启动"
)
);
}
return Promise.reject(error);
}
);
......
......@@ -13,6 +13,7 @@ interface AuthState {
token: string | null;
user: User | null;
isAuthenticated: boolean;
hasHydrated: boolean;
setAuth: (token: string, user: User) => void;
clearAuth: () => void;
}
......@@ -23,20 +24,34 @@ export const useAuthStore = create<AuthState>()(
token: null,
user: null,
isAuthenticated: false,
hasHydrated: false,
setAuth: (token, user) => {
localStorage.setItem("yaoai_token", token);
set({ token, user, isAuthenticated: true });
},
clearAuth: () => {
localStorage.removeItem("yaoai_token");
set({ token: null, user: null, isAuthenticated: false });
},
}),
{
name: "yaoai-auth",
partialize: (state) => ({ token: state.token, user: state.user, isAuthenticated: state.isAuthenticated }),
partialize: (state) => ({ token: state.token, user: state.user }),
merge: (persistedState, currentState) => {
const persisted = (persistedState ?? {}) as Partial<AuthState>;
const token = typeof persisted.token === "string" ? persisted.token : null;
return {
...currentState,
token,
user: persisted.user ?? null,
hasHydrated: true,
isAuthenticated: !!token,
};
},
}
)
);
export function getAuthToken() {
return useAuthStore.getState().token;
}
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import path from 'path'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
const repoRoot = path.resolve(__dirname, '../..')
function resolveBackendTarget(env: Record<string, string>) {
const explicitTarget = env.VITE_API_TARGET
if (explicitTarget?.startsWith('http')) {
return explicitTarget.replace(/\/$/, '')
}
const apiBaseUrl = env.VITE_API_BASE_URL
if (apiBaseUrl?.startsWith('http')) {
return apiBaseUrl.replace(/\/api\/?$/, '').replace(/\/$/, '')
}
return `http://localhost:${env.SERVER_PORT || env.BACKEND_PORT || '8081'}`
}
function resolvePort(value: string | undefined, fallback: number) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
}
function figmaAssetResolver() {
return {
......@@ -16,26 +36,38 @@ function figmaAssetResolver() {
}
}
export default defineConfig({
plugins: [
figmaAssetResolver(),
// The React and Tailwind plugins are both required for Make, even if
// Tailwind is not being actively used – do not remove them
react(),
tailwindcss(),
],
resolve: {
alias: {
// Alias @ to the src directory
'@': path.resolve(__dirname, './src'),
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, repoRoot, '')
return {
envDir: repoRoot,
plugins: [
figmaAssetResolver(),
// The React and Tailwind plugins are both required for Make, even if
// Tailwind is not being actively used – do not remove them
react(),
tailwindcss(),
],
resolve: {
alias: {
// Alias @ to the src directory
'@': path.resolve(__dirname, './src'),
},
},
},
server: {
host: true,
port: 5173,
allowedHosts: true
},
// File types to support raw imports. Never add .css, .tsx, or .ts files to this.
assetsInclude: ['**/*.svg', '**/*.csv'],
server: {
host: true,
port: resolvePort(env.FRONTEND_PORT, 5173),
allowedHosts: true,
proxy: {
'/api': {
target: resolveBackendTarget(env),
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
// File types to support raw imports. Never add .css, .tsx, or .ts files to this.
assetsInclude: ['**/*.svg', '**/*.csv'],
}
})
......@@ -8,7 +8,7 @@ services:
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-yaoai123}
TZ: ${TZ:-Asia/Shanghai}
ports:
- "${MYSQL_PORT:-13306}:3306"
- "${MYSQL_PORT:-23306}:3306"
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
......@@ -26,7 +26,7 @@ services:
redis:
image: docker.m.daocloud.io/library/redis:7.2-alpine
ports:
- "${REDIS_PORT:-16379}:6379"
- "${REDIS_PORT:-26379}:6379"
volumes:
- redis-data:/data
healthcheck:
......@@ -43,41 +43,7 @@ services:
environment:
TZ: ${TZ:-Asia/Shanghai}
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-prod}
SERVER_PORT: 8080
BILLING_MODE: ${BILLING_MODE:-STRICT}
DB_URL: jdbc:mysql://mysql:3306/${MYSQL_DATABASE:-yaoai_comic}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8
DB_USERNAME: ${MYSQL_USER:-yaoai}
DB_PASSWORD: ${MYSQL_PASSWORD:-yaoai123}
REDIS_HOST: redis
REDIS_PORT: 6379
VOLCENGINE_ARK_API_KEY: ${VOLCENGINE_ARK_API_KEY:-}
VOLCENGINE_ARK_BASE_URL: ${VOLCENGINE_ARK_BASE_URL:-https://ark.cn-beijing.volces.com/api/v3}
VOLCENGINE_ARK_TEXT_MODEL: ${VOLCENGINE_ARK_TEXT_MODEL:-doubao-seed-2-0-code-preview-260215}
VOLCENGINE_ARK_IMAGE_MODEL: ${VOLCENGINE_ARK_IMAGE_MODEL:-doubao-seedream-5-0-260128}
VOLCENGINE_ARK_VIDEO_MODEL: ${VOLCENGINE_ARK_VIDEO_MODEL:-doubao-seedance-2-0-fast-260128}
VOLCENGINE_TOS_ACCESS_KEY: ${VOLCENGINE_TOS_ACCESS_KEY:-}
VOLCENGINE_TOS_SECRET_KEY: ${VOLCENGINE_TOS_SECRET_KEY:-}
VOLCENGINE_TOS_ENDPOINT: ${VOLCENGINE_TOS_ENDPOINT:-https://tos-cn-beijing.volces.com}
VOLCENGINE_TOS_REGION: ${VOLCENGINE_TOS_REGION:-cn-beijing}
VOLCENGINE_TOS_BUCKET: ${VOLCENGINE_TOS_BUCKET:-}
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "${BACKEND_PORT:-8080}:8080"
restart: unless-stopped
admin-server:
build:
context: ./yaoai-comic-studio
dockerfile: Dockerfile
environment:
TZ: ${TZ:-Asia/Shanghai}
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-prod}
SERVER_PORT: 8081
SPRING_FLYWAY_ENABLED: "false"
BILLING_MODE: ${BILLING_MODE:-STRICT}
DB_URL: jdbc:mysql://mysql:3306/${MYSQL_DATABASE:-yaoai_comic}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8
DB_USERNAME: ${MYSQL_USER:-yaoai}
......@@ -95,14 +61,12 @@ services:
VOLCENGINE_TOS_REGION: ${VOLCENGINE_TOS_REGION:-cn-beijing}
VOLCENGINE_TOS_BUCKET: ${VOLCENGINE_TOS_BUCKET:-}
depends_on:
backend:
condition: service_started
mysql:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "${ADMIN_SERVER_PORT:-8081}:8081"
- "${BACKEND_PORT:-28081}:8081"
restart: unless-stopped
frontend:
......@@ -114,7 +78,7 @@ services:
depends_on:
- backend
ports:
- "${FRONTEND_PORT:-3000}:80"
- "${FRONTEND_PORT:-3000}:3000"
restart: unless-stopped
admin-web:
......@@ -122,11 +86,11 @@ services:
context: ./yaoai-admin-web
dockerfile: Dockerfile
environment:
ADMIN_API_UPSTREAM: http://admin-server:8081
ADMIN_API_UPSTREAM: http://backend:8081
depends_on:
- admin-server
- backend
ports:
- "${ADMIN_WEB_PORT:-3001}:80"
- "${ADMIN_WEB_PORT:-3001}:3001"
restart: unless-stopped
volumes:
......
Write-Host "========================================="
Write-Host "YaoAI Video - Environment Setup Script"
Write-Host "========================================="
Write-Host ""
Write-Host "This script will install Node.js, Java 17, and Docker Desktop."
Write-Host "It may take several minutes to download and install."
Write-Host ""
Write-Host "[1/3] Installing Node.js LTS..."
winget install OpenJS.NodeJS.LTS --silent --accept-package-agreements --accept-source-agreements
Write-Host "[2/3] Installing Java 17 (Microsoft OpenJDK 17)..."
winget install Microsoft.OpenJDK.17 --silent --accept-package-agreements --accept-source-agreements
Write-Host "[3/3] Installing Docker Desktop..."
winget install Docker.DockerDesktop --silent --accept-package-agreements --accept-source-agreements
Write-Host ""
Write-Host "========================================="
Write-Host "Installation process finished."
Write-Host "IMPORTANT: You may need to RESTART your computer to complete Docker Desktop setup."
Write-Host "After restarting, please reopen Trae to continue."
Write-Host "========================================="
Write-Host "Press any key to close this window..."
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
$ErrorActionPreference = "Stop"
Write-Host "========================================="
Write-Host "Downloading and Installing Dependencies..."
Write-Host "========================================="
# 1. Download and install Node.js
Write-Host "`n[1/3] Downloading Node.js..."
$nodeUrl = "https://nodejs.org/dist/v20.11.1/node-v20.11.1-x64.msi"
$nodeMsi = "$env:TEMP\nodejs.msi"
Invoke-WebRequest -Uri $nodeUrl -OutFile $nodeMsi
Write-Host "Installing Node.js..."
Start-Process msiexec.exe -Wait -ArgumentList "/i $nodeMsi /quiet /norestart"
Write-Host "Node.js installed."
# 2. Download and install Java 17
Write-Host "`n[2/3] Downloading Java 17..."
$javaUrl = "https://aka.ms/download-jdk/microsoft-jdk-17.0.10-windows-x64.msi"
$javaMsi = "$env:TEMP\jdk17.msi"
Invoke-WebRequest -Uri $javaUrl -OutFile $javaMsi
Write-Host "Installing Java 17..."
Start-Process msiexec.exe -Wait -ArgumentList "/i $javaMsi /quiet /norestart"
Write-Host "Java 17 installed."
# 3. Download and install Docker Desktop
Write-Host "`n[3/3] Downloading Docker Desktop..."
$dockerUrl = "https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe"
$dockerExe = "$env:TEMP\DockerDesktopInstaller.exe"
Invoke-WebRequest -Uri $dockerUrl -OutFile $dockerExe
Write-Host "Installing Docker Desktop (this may take a few minutes)..."
Start-Process $dockerExe -Wait -ArgumentList "install --quiet"
Write-Host "Docker Desktop installed."
Write-Host "`n========================================="
Write-Host "Installation Complete!"
Write-Host "IMPORTANT: Please RESTART your computer now."
Write-Host "========================================="
Write-Host "Press any key to exit..."
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
$ErrorActionPreference = "Stop"
$root = $PSScriptRoot
$backendDir = Join-Path $root "yaoai-comic-studio"
$frontendDir = Join-Path $root "doc\html"
$adminDir = Join-Path $root "yaoai-admin-web"
$mavenCmd = Join-Path $root ".tools\apache-maven-3.9.6\bin\mvn.cmd"
Push-Location $root
try {
Write-Host "[dev] starting infrastructure from yaoai-comic-studio/docker-compose.yml ..."
docker compose -f (Join-Path $backendDir "docker-compose.yml") up -d
Write-Host ""
Write-Host "[dev] stable local development environment"
Write-Host " mysql : localhost:13306"
Write-Host " redis : localhost:16379"
Write-Host " rabbitmq : localhost:15672"
Write-Host " prometheus : localhost:19090"
Write-Host " grafana : localhost:13000"
Write-Host " backend : http://localhost:8081"
Write-Host " frontend : http://localhost:5173"
Write-Host " admin-web : http://localhost:5174"
Write-Host ""
Write-Host "[dev] next commands"
Write-Host " 1. build backend"
Write-Host " & `"$mavenCmd`" -f `"$backendDir\pom.xml`" package -pl yaoai-bootstrap -am -DskipTests"
Write-Host " 2. run backend"
Write-Host " java -jar `"$backendDir\yaoai-bootstrap\target\yaoai-bootstrap-0.1.0-SNAPSHOT.jar`""
Write-Host " 3. run frontend"
Write-Host " Set-Location `"$frontendDir`"; npm install; npm run dev"
Write-Host " 4. run admin-web"
Write-Host " Set-Location `"$adminDir`"; npm install; npm run dev"
Write-Host ""
Write-Host "[dev] do not run 'docker compose down -v' unless you really want to delete dev data."
}
finally {
Pop-Location
}
$ErrorActionPreference = "Stop"
$root = $PSScriptRoot
$envFile = Join-Path $root ".env"
$envExample = Join-Path $root ".env.example"
Push-Location $root
try {
if (-not (Test-Path $envFile)) {
Copy-Item $envExample $envFile
Write-Host "[prod-local] .env not found, copied from .env.example"
}
Write-Host "[prod-local] starting root docker-compose.yml ..."
docker compose up -d --build
Write-Host ""
Write-Host "[prod-local] stable local deployment environment"
Write-Host " mysql : localhost:23306"
Write-Host " redis : localhost:26379"
Write-Host " frontend : http://localhost:3000"
Write-Host " admin-web : http://localhost:3001"
Write-Host " backend : http://localhost:28081"
Write-Host " swagger : http://localhost:28081/swagger-ui/index.html"
Write-Host ""
Write-Host "[prod-local] if you need different ports, edit .env before running this script."
Write-Host "[prod-local] do not run 'docker compose down -v' unless you really want to delete prod-local data."
}
finally {
Pop-Location
}
......@@ -48,7 +48,12 @@ client.interceptors.response.use(
auth.logoutLocal();
window.location.replace('/auth/login');
}
ElMessage.error(error.response?.data?.error?.message || error.message || '网络异常');
const message =
error.response?.data?.error?.message ||
(error.response?.status >= 500
? '后端管理服务未启动或接口异常,请确认后端已按根目录 .env 的 BACKEND_PORT 启动'
: error.message || '网络异常');
ElMessage.error(message);
return Promise.reject(error);
},
);
......
......@@ -17,8 +17,8 @@
body {
margin: 0;
background:
linear-gradient(90deg, rgba(31, 122, 90, 0.04) 1px, transparent 1px),
linear-gradient(rgba(31, 122, 90, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(31, 122, 90, 0.025) 1px, transparent 1px),
linear-gradient(rgba(31, 122, 90, 0.02) 1px, transparent 1px),
var(--paper);
background-size: 28px 28px;
color: var(--ink);
......@@ -96,6 +96,9 @@ body {
}
.topbar {
position: sticky;
top: 0;
z-index: 20;
height: 72px;
display: flex;
align-items: center;
......@@ -119,6 +122,7 @@ body {
.page-stage {
padding: 26px;
max-width: 1600px;
}
.page-head {
......@@ -131,9 +135,10 @@ body {
.page-head h1 {
margin: 0;
font-family: Georgia, "Times New Roman", serif;
font-size: 30px;
font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif;
font-size: 26px;
letter-spacing: 0;
line-height: 1.25;
}
.page-head p {
......@@ -147,6 +152,10 @@ body {
gap: 10px;
align-items: center;
margin-bottom: 14px;
padding: 12px;
background: rgba(255, 253, 248, 0.68);
border: 1px solid rgba(221, 216, 204, 0.72);
border-radius: 8px;
}
.metric-grid {
......@@ -160,7 +169,7 @@ body {
border: 1px solid var(--line);
border-radius: 8px;
padding: 16px;
box-shadow: var(--shadow);
box-shadow: 0 8px 22px rgba(46, 39, 28, 0.06);
}
.metric span {
......@@ -179,7 +188,33 @@ body {
border: 1px solid var(--line);
border-radius: 8px;
padding: 18px;
box-shadow: var(--shadow);
box-shadow: 0 8px 22px rgba(46, 39, 28, 0.06);
}
.panel .el-table,
.page-stage .el-table {
--el-table-border-color: var(--line);
--el-table-header-bg-color: #f7f4ed;
--el-table-header-text-color: var(--ink);
border-radius: 8px;
overflow: hidden;
}
.panel .el-table th.el-table__cell,
.page-stage .el-table th.el-table__cell {
font-weight: 700;
}
.panel .el-pagination,
.page-stage .el-pagination {
margin-top: 14px;
justify-content: flex-end;
}
.toolbar .el-input,
.toolbar .el-select,
.toolbar .el-button {
min-height: 36px;
}
.panel-grid {
......@@ -372,6 +407,16 @@ body {
padding: 16px;
}
.page-head {
align-items: flex-start;
flex-direction: column;
}
.toolbar {
align-items: stretch;
flex-direction: column;
}
.metric-grid {
grid-template-columns: 1fr;
}
......
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [vue()],
server: {
port: 5174,
proxy: {
'/admin-api': {
target: process.env.VITE_API_TARGET || 'http://localhost:8080',
changeOrigin: true,
import path from 'node:path';
import { defineConfig, loadEnv } from 'vite';
const repoRoot = path.resolve(__dirname, '..');
function resolveBackendTarget(env: Record<string, string>) {
const explicitTarget = env.VITE_API_TARGET;
if (explicitTarget?.startsWith('http')) {
return explicitTarget.replace(/\/$/, '');
}
const apiBaseUrl = env.VITE_API_BASE_URL;
if (apiBaseUrl?.startsWith('http')) {
return apiBaseUrl.replace(/\/api\/?$/, '').replace(/\/$/, '');
}
return `http://localhost:${env.SERVER_PORT || env.BACKEND_PORT || '8081'}`;
}
function resolvePort(value: string | undefined, fallback: number) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, repoRoot, '');
return {
envDir: repoRoot,
plugins: [vue()],
server: {
port: resolvePort(env.ADMIN_FRONTEND_PORT, 5174),
proxy: {
'/admin-api': {
target: resolveBackendTarget(env),
changeOrigin: true,
rewrite: (path) => path.replace(/^\/admin-api/, '/admin'),
},
},
},
},
};
});
......@@ -7,10 +7,12 @@ DB_URL=jdbc:mysql://localhost:13306/yaoai_comic?useSSL=false&allowPublicKeyRetri
DB_USERNAME=yaoai
DB_PASSWORD=yaoai123
# ===== Volcano Engine ARK (LLM / Video Generation) =====
# ===== Volcano Engine ARK =====
VOLCENGINE_ARK_API_KEY=your-ark-api-key
VOLCENGINE_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
VOLCENGINE_ARK_MODEL=doubao-seedance-2-0-260128
VOLCENGINE_ARK_TEXT_MODEL=doubao-seed-2-0-code-preview-260215
VOLCENGINE_ARK_IMAGE_MODEL=doubao-seedream-5-0-260128
VOLCENGINE_ARK_VIDEO_MODEL=doubao-seedance-2-0-fast-260128
# ===== Volcano Engine TOS (Object Storage) =====
VOLCENGINE_TOS_ACCESS_KEY=your-access-key
......
......@@ -3,6 +3,24 @@ FROM docker.m.daocloud.io/library/maven:3.9.9-eclipse-temurin-17 AS builder
WORKDIR /workspace
ARG MAVEN_MIRROR=https://maven.aliyun.com/repository/public
RUN mkdir -p /root/.m2 \
&& printf '%s\n' \
'<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"' \
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"' \
' xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd">' \
' <mirrors>' \
' <mirror>' \
' <id>public-mirror</id>' \
' <name>Public Maven Mirror</name>' \
' <url>'"${MAVEN_MIRROR}"'</url>' \
' <mirrorOf>*</mirrorOf>' \
' </mirror>' \
' </mirrors>' \
'</settings>' \
> /root/.m2/settings.xml
# Cache Maven dependencies
COPY pom.xml ./
COPY yaoai-common/pom.xml yaoai-common/
......@@ -22,11 +40,11 @@ COPY yaoai-worker/pom.xml yaoai-worker/
COPY yaoai-bootstrap/pom.xml yaoai-bootstrap/
COPY yaoai-test/pom.xml yaoai-test/
RUN mvn dependency:go-offline -B -q
RUN mvn -s /root/.m2/settings.xml dependency:go-offline -B -ntp
# Build
COPY . .
RUN mvn package -pl yaoai-bootstrap -am -DskipTests -B -q
RUN mvn -s /root/.m2/settings.xml package -pl yaoai-bootstrap -am -DskipTests -B -ntp
# ===== Stage 2: Runtime =====
FROM docker.m.daocloud.io/library/eclipse-temurin:17-jre-jammy
......@@ -39,8 +57,17 @@ ENV LANG=C.UTF-8 \
LANGUAGE=C.UTF-8 \
LC_ALL=C.UTF-8
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
ARG APT_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/ubuntu
RUN set -eux; \
if [ -f /etc/apt/sources.list ]; then \
sed -i "s|http://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g; s|http://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" /etc/apt/sources.list; \
fi; \
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then \
sed -i "s|http://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g; s|http://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" /etc/apt/sources.list.d/ubuntu.sources; \
fi; \
apt-get -o Acquire::Retries=5 update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Non-root user
......
......@@ -29,6 +29,10 @@
</dependency>
<dependency>
<groupId>com.yaoai</groupId>
<artifactId>yaoai-storage</artifactId>
</dependency>
<dependency>
<groupId>com.yaoai</groupId>
<artifactId>yaoai-billing</artifactId>
</dependency>
<dependency>
......
package com.yaoai.agent.dto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class AgentRunCommand {
private String goal;
private String runIntent = "auto";
private Boolean forceRegenerate = false;
private List<String> selectedSteps = new ArrayList<>();
private ContextOptions contextOptions = new ContextOptions();
private String idempotencyKey;
public boolean shouldForceRegenerate() {
return Boolean.TRUE.equals(forceRegenerate);
}
@Data
public static class ContextOptions {
private Boolean useScriptAsset = true;
private Boolean useExistingOutline = true;
private Boolean useExistingEpisodes = true;
private Boolean useExistingAssets = true;
private Boolean useExistingStoryboards = true;
public boolean useScriptAsset() {
return !Boolean.FALSE.equals(useScriptAsset);
}
public boolean useExistingOutline() {
return !Boolean.FALSE.equals(useExistingOutline);
}
public boolean useExistingEpisodes() {
return !Boolean.FALSE.equals(useExistingEpisodes);
}
public boolean useExistingAssets() {
return !Boolean.FALSE.equals(useExistingAssets);
}
public boolean useExistingStoryboards() {
return !Boolean.FALSE.equals(useExistingStoryboards);
}
}
}
package com.yaoai.agent.service;
import com.yaoai.agent.dto.AgentRunCommand;
import com.yaoai.domain.entity.GraphRun;
import com.yaoai.domain.entity.GraphStep;
......@@ -10,6 +11,9 @@ public interface AgentRunService {
/** 创建并异步启动一个 Agent 制作流程 */
GraphRun startRun(Long projectId, Long tenantId, String goal);
/** 创建并异步启动一个带运行意图的 Agent 制作流程 */
GraphRun startRun(Long projectId, Long tenantId, AgentRunCommand command);
GraphRun getRun(Long runId, Long tenantId);
/** 获取项目最新的 run */
......
package com.yaoai.agent.service;
import com.yaoai.domain.entity.ProjectAsset;
import com.yaoai.domain.entity.ScriptUnderstanding;
import java.util.Optional;
public interface ScriptUnderstandingService {
Optional<ProjectAsset> latestScriptAsset(Long projectId, Long tenantId);
ScriptUnderstanding understandLatestScript(Long projectId, Long tenantId, Long userId, String goal);
String toGenerationContext(ScriptUnderstanding understanding, String fallbackGoal);
}
......@@ -5,6 +5,7 @@ import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
@Builder
......@@ -19,4 +20,7 @@ public class ChatRequest {
@JsonProperty("max_tokens")
@Builder.Default
private int maxTokens = 4096;
@JsonProperty("response_format")
private Map<String, Object> responseFormat;
}
package com.yaoai.ai.providers.service;
import java.util.List;
public interface SeedreamService {
/**
......@@ -11,10 +13,16 @@ public interface SeedreamService {
String generateImage(String prompt);
/**
* 文生图:自定义画布尺寸(如 "2048x1152" 用于 16:9 横版角色三视图)
* 文生图:自定义画布尺寸(如 "2560x1440" 用于 16:9 横版角色三视图)
*/
String generateImage(String prompt, String size);
/**
* 图生图/参考图生成:基于一张或多张参考图按提示词生成图片。
* 参考图用于保持角色身份、服装状态或画面构图的一致性。
*/
String generateImage(String prompt, String size, List<String> referenceImageUrls);
/** 当前使用的 Seedream 模型 ID,用于计费明细 */
String getModelId();
......
......@@ -7,13 +7,19 @@ import com.yaoai.ai.providers.config.ArkProperties;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
@Slf4j
@Service
@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 final ArkProperties properties;
private final RestClient restClient;
......@@ -21,13 +27,13 @@ public class ArkLlmService implements LlmService {
this.properties = properties;
this.restClient = RestClient.builder()
.baseUrl(properties.getBaseUrl())
.defaultHeader("Authorization", "Bearer " + properties.getApiKey())
.defaultHeader("Content-Type", "application/json")
.build();
}
@Override
public String chat(ChatRequest request) {
String apiKey = resolveApiKey();
// 如果未指定 model,使用默认文本模型
if (request.getModel() == null) {
request.setModel(properties.getTextModel());
......@@ -37,6 +43,7 @@ public class ArkLlmService implements LlmService {
try {
ChatResponse response = restClient.post()
.uri("/chat/completions")
.header("Authorization", "Bearer " + apiKey)
.body(request)
.retrieve()
.body(ChatResponse.class);
......@@ -47,6 +54,20 @@ public class ArkLlmService implements LlmService {
String content = response.firstContent();
log.debug("ARK 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={}",
e.getResponseBodyAsString());
request.setResponseFormat(null);
return chat(request);
}
if (e.getStatusCode().value() == 401) {
throw new BizException(ErrorCode.INTERNAL_ERROR,
"AI 服务鉴权失败,请检查 VOLCENGINE_ARK_API_KEY 是否正确且仍有效");
}
log.error("ARK LLM call failed: status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString(), e);
throw new BizException(ErrorCode.INTERNAL_ERROR,
"AI 服务调用失败: HTTP " + e.getStatusCode().value());
} catch (BizException e) {
throw e;
} catch (Exception e) {
......@@ -69,4 +90,16 @@ public class ArkLlmService implements LlmService {
public String getProvider() {
return "volcengine";
}
private String resolveApiKey() {
String apiKey = properties.getApiKey();
if (apiKey == null || apiKey.isBlank()) {
throw new BizException(ErrorCode.INTERNAL_ERROR, ARK_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);
}
return normalized;
}
}
package com.yaoai.ai.providers.service.impl;
import com.yaoai.ai.core.model.ChatMessage;
import com.yaoai.ai.core.model.ChatRequest;
import com.yaoai.ai.core.service.LlmService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import java.util.stream.Collectors;
@Slf4j
@Service
@Profile("dev-h2")
@ConditionalOnProperty(name = "ai.local-stub.enabled", havingValue = "true")
public class LocalLlmService implements LlmService {
@Override
public String chat(ChatRequest request) {
String prompt = request.getMessages() == null
? ""
: request.getMessages().stream()
.map(ChatMessage::getContent)
.collect(Collectors.joining("\n"));
log.info("Local LLM stub responding: chars={}", prompt.length());
if (prompt.contains("plotFacts") || prompt.contains("剧情事实")) {
return """
{
"plotFacts": [
{"id": "pf_001", "summary": "本地开发环境已读取剧本内容,并生成可追溯的剧情理解占位结果。", "evidence": "dev-h2 local script"}
],
"characterFacts": [],
"sceneFacts": [],
"uncertainties": [],
"coverageScore": 0.8
}
""";
}
if (prompt.contains("episode_number") || prompt.contains("生成每集")) {
return """
[
{
"episode_number": 1,
"title": "本地推理第1",
"summary": "主角发现关键线索,开始追查真相。",
"script": "主角在办公室翻阅资料,发现异常记录,决定继续调查。"
}
]
""";
}
if (prompt.contains("sequence_num") || prompt.contains("storyboard")) {
return """
[
{
"sequence_num": 1,
"scene_number": "001",
"short_description": "主角发现关键线索",
"detailed_description": "主角在办公室中查看文件,神情逐渐凝重,意识到事件并不简单。",
"characters": "",
"dialogues": "",
"camera_direction": "slow push in",
"composition_guide": "medium shot",
"duration_seconds": 5,
"start_frame_prompt": "cinematic office, tense mood, medium shot",
"motion_script": "镜头缓慢推进,主角低头查看文件后抬眼。"
}
]
""";
}
if (prompt.contains("role_type") || prompt.contains("角色姓名")) {
return """
[
{
"name": "主角",
"role_type": "female_lead",
"gender": "female",
"age": "25岁左右",
"personality": "冷静坚韧",
"costume": "浅色通勤装",
"visual_hint": "黑色长发,清冷气质",
"image_prompt": "young woman, black long hair, light office outfit, calm determined look"
}
]
""";
}
if (prompt.contains("scene_type") || prompt.contains("场景名称")) {
return """
[
{
"name": "办公室",
"scene_type": "indoor",
"description": "冷色调现代办公室,文件散落,气氛紧张",
"image_prompt": "wide establishing shot of modern office, cool grey palette, scattered documents, tense atmosphere"
}
]
""";
}
if (prompt.contains("episode_count") || prompt.contains("结构化大纲")) {
return """
{
"title": "本地推理短剧",
"genre": "现代都市",
"episode_count": 1,
"synopsis": "主角发现异常线索后展开调查,并逐步接近事件真相。"
}
""";
}
return "0-2s 主角发现线索;2-5s 镜头推进,气氛紧张,画面保持现代都市写实风格。";
}
@Override
public String getModelId() {
return "local-dev-llm";
}
@Override
public String getModelName() {
return "本地开发推理";
}
@Override
public String getProvider() {
return "local";
}
}
package com.yaoai.ai.providers.service.impl;
import com.yaoai.ai.providers.service.SeedreamService;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
import java.util.List;
@Slf4j
@Service
@Profile("dev-h2")
@ConditionalOnProperty(name = "ai.local-stub.enabled", havingValue = "true")
public class LocalSeedreamService implements SeedreamService {
@Override
public String generateImage(String prompt) {
return generateImage(prompt, "2048x2048");
}
@Override
public String generateImage(String prompt, String size) {
return generateImage(prompt, size, List.of());
}
@Override
public String generateImage(String prompt, String size, List<String> referenceImageUrls) {
try {
int[] wh = parseSize(size);
String refNote = referenceImageUrls == null || referenceImageUrls.isEmpty()
? ""
: "\nREFERENCE IMAGE MODE: " + referenceImageUrls.size() + " image(s)";
byte[] bytes = renderPlaceholder((prompt == null ? "" : prompt) + refNote, wh[0], wh[1]);
log.info("Local image stub generated: size={}, bytes={}", size, bytes.length);
return "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(bytes);
} catch (Exception e) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "本地图片生成失败: " + e.getMessage());
}
}
@Override
public String getModelId() {
return "local-dev-image";
}
@Override
public String getModelName() {
return "本地开发图片生成";
}
@Override
public String getProvider() {
return "local";
}
private int[] parseSize(String size) {
if (size == null || !size.matches("\\d+x\\d+")) {
return new int[] {1280, 720};
}
String[] parts = size.split("x");
int width = Math.max(512, Math.min(2560, Integer.parseInt(parts[0])));
int height = Math.max(512, Math.min(1440, Integer.parseInt(parts[1])));
return new int[] {width, height};
}
private byte[] renderPlaceholder(String prompt, int width, int height) throws Exception {
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
try {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setColor(new Color(242, 246, 255));
g.fillRect(0, 0, width, height);
g.setColor(new Color(25, 55, 110));
g.setStroke(new BasicStroke(Math.max(4f, width / 320f)));
g.drawRoundRect(24, 24, width - 48, height - 48, 36, 36);
boolean sheet = width > height;
if (sheet) {
drawCharacterSheet(g, width, height);
} else {
drawScenePlaceholder(g, width, height);
}
g.setColor(new Color(40, 55, 85));
g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, Math.max(28, width / 48)));
g.drawString(sheet ? "LOCAL CHARACTER SHEET" : "LOCAL SCENE IMAGE", 56, 78);
g.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, Math.max(18, width / 80)));
drawWrapped(g, prompt == null ? "" : prompt, 56, height - 150, width - 112, Math.max(24, width / 68), 4);
} finally {
g.dispose();
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
ImageIO.write(image, "jpg", out);
return out.toByteArray();
}
private void drawCharacterSheet(Graphics2D g, int width, int height) {
String[] labels = {"head", "front", "side", "back"};
int top = height / 5;
int panelGap = width / 48;
int panelWidth = (width - panelGap * 5) / 4;
for (int i = 0; i < 4; i += 1) {
int x = panelGap + i * (panelWidth + panelGap);
g.setColor(Color.WHITE);
g.fillRoundRect(x, top, panelWidth, height / 2, 28, 28);
g.setColor(new Color(150, 165, 190));
g.drawRoundRect(x, top, panelWidth, height / 2, 28, 28);
g.setColor(new Color(82, 98, 135));
int cx = x + panelWidth / 2;
int cy = top + height / 5;
int head = Math.max(36, panelWidth / 7);
g.fillOval(cx - head, cy - head, head * 2, head * 2);
g.setStroke(new BasicStroke(Math.max(6f, width / 180f)));
g.drawLine(cx, cy + head, cx, top + height / 2 - 70);
g.drawLine(cx, cy + head * 2, cx - panelWidth / 5, cy + height / 7);
g.drawLine(cx, cy + head * 2, cx + panelWidth / 5, cy + height / 7);
g.drawLine(cx, top + height / 2 - 70, cx - panelWidth / 6, top + height / 2 - 20);
g.drawLine(cx, top + height / 2 - 70, cx + panelWidth / 6, top + height / 2 - 20);
g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, Math.max(20, width / 90)));
g.drawString(labels[i], x + 28, top + height / 2 - 24);
}
}
private void drawScenePlaceholder(Graphics2D g, int width, int height) {
g.setColor(Color.WHITE);
g.fillRoundRect(width / 8, height / 5, width * 3 / 4, height / 2, 32, 32);
g.setColor(new Color(105, 125, 170));
g.setStroke(new BasicStroke(Math.max(5f, width / 220f)));
g.drawLine(width / 6, height * 3 / 5, width * 5 / 6, height * 3 / 5);
g.drawLine(width / 4, height * 3 / 5, width / 3, height / 3);
g.drawLine(width / 3, height / 3, width / 2, height * 3 / 5);
g.drawLine(width / 2, height * 3 / 5, width * 2 / 3, height / 3);
g.drawLine(width * 2 / 3, height / 3, width * 3 / 4, height * 3 / 5);
}
private void drawWrapped(Graphics2D g, String text, int x, int y, int maxWidth, int lineHeight, int maxLines) {
String[] words = text.replace('\n', ' ').split("\\s+");
StringBuilder line = new StringBuilder();
int lines = 0;
for (String word : words) {
String candidate = line.isEmpty() ? word : line + " " + word;
if (g.getFontMetrics().stringWidth(candidate) > maxWidth && !line.isEmpty()) {
g.drawString(line.toString(), x, y + lines * lineHeight);
line = new StringBuilder(word);
lines += 1;
if (lines >= maxLines) return;
} else {
line = new StringBuilder(candidate);
}
}
if (!line.isEmpty() && lines < maxLines) {
g.drawString(line.toString(), x, y + lines * lineHeight);
}
}
}
......@@ -12,6 +12,7 @@ import lombok.extern.slf4j.Slf4j;
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;
import java.util.ArrayList;
......@@ -23,6 +24,9 @@ import java.util.Map;
@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 final ArkProperties properties;
private final RestClient restClient;
......@@ -33,7 +37,6 @@ public class SeedanceServiceImpl implements SeedanceService {
rf.setReadTimeout(Duration.ofMinutes(2));
this.restClient = RestClient.builder()
.baseUrl(properties.getBaseUrl())
.defaultHeader("Authorization", "Bearer " + properties.getApiKey())
.defaultHeader("Content-Type", "application/json")
.requestFactory(rf)
.build();
......@@ -44,6 +47,7 @@ public class SeedanceServiceImpl implements SeedanceService {
@Override
public String submitVideoTask(List<String> imageUrls, String prompt,
int durationSeconds, boolean generateAudio, String ratio, String model) {
String apiKey = resolveApiKey();
if (imageUrls == null || imageUrls.isEmpty()) {
throw new BizException(ErrorCode.INVALID_PARAM, "至少需要一张参考图");
}
......@@ -89,6 +93,7 @@ public class SeedanceServiceImpl implements SeedanceService {
try {
TaskSubmitResponse resp = restClient.post()
.uri("/contents/generations/tasks")
.header("Authorization", "Bearer " + apiKey)
.body(body)
.retrieve()
.body(TaskSubmitResponse.class);
......@@ -98,6 +103,22 @@ public class SeedanceServiceImpl implements SeedanceService {
}
log.info("Seedance task submitted: id={}", resp.getId());
return resp.getId();
} catch (RestClientResponseException e) {
if (e.getStatusCode().value() == 401) {
throw new BizException(ErrorCode.INTERNAL_ERROR,
"视频服务鉴权失败,请检查 VOLCENGINE_ARK_API_KEY 是否正确且仍有效");
}
String responseBody = e.getResponseBodyAsString();
log.error("Seedance 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) {
......@@ -108,10 +129,12 @@ public class SeedanceServiceImpl implements SeedanceService {
@Override
public VideoTaskResult getTaskStatus(String externalTaskId) {
String apiKey = resolveApiKey();
try {
// Use JsonNode to handle flexible API response structure
JsonNode resp = restClient.get()
.uri("/contents/generations/tasks/{id}", externalTaskId)
.header("Authorization", "Bearer " + apiKey)
.retrieve()
.body(JsonNode.class);
......@@ -138,6 +161,15 @@ 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 是否正确且仍有效");
}
log.error("Seedance 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) {
......@@ -156,6 +188,18 @@ public class SeedanceServiceImpl implements SeedanceService {
return "Seedance 图生视频";
}
private String resolveApiKey() {
String apiKey = properties.getApiKey();
if (apiKey == null || apiKey.isBlank()) {
throw new BizException(ErrorCode.INTERNAL_ERROR, ARK_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);
}
return normalized;
}
// ---- helpers ----
/**
......
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.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.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;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
@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 ObjectMapper JSON = new ObjectMapper();
private final ArkProperties properties;
private final RestClient restClient;
......@@ -28,7 +38,6 @@ public class SeedreamServiceImpl implements SeedreamService {
rf.setReadTimeout(Duration.ofMinutes(2));
this.restClient = RestClient.builder()
.baseUrl(properties.getBaseUrl())
.defaultHeader("Authorization", "Bearer " + properties.getApiKey())
.defaultHeader("Content-Type", "application/json")
.requestFactory(rf)
.build();
......@@ -41,18 +50,34 @@ public class SeedreamServiceImpl implements SeedreamService {
@Override
public String generateImage(String prompt, String size) {
Map<String, Object> body = Map.of(
"model", properties.getImageModel(),
"prompt", prompt,
"n", 1,
"size", size,
"response_format", "url"
);
log.info("Seedream generate image: model={}", properties.getImageModel());
return generateImage(prompt, size, List.of());
}
@Override
public String generateImage(String prompt, String size, List<String> referenceImageUrls) {
String apiKey = resolveApiKey();
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", properties.getImageModel());
body.put("prompt", prompt);
body.put("n", 1);
body.put("size", size);
body.put("response_format", "url");
List<String> cleanReferences = referenceImageUrls == null
? List.of()
: referenceImageUrls.stream()
.filter(url -> url != null && !url.isBlank())
.map(String::trim)
.toList();
if (!cleanReferences.isEmpty()) {
body.put("image", cleanReferences.size() == 1 ? cleanReferences.get(0) : cleanReferences);
}
log.info("Seedream generate image: model={}, size={}, references={}",
properties.getImageModel(), size, cleanReferences.size());
try {
ImageResponse resp = restClient.post()
.uri("/images/generations")
.header("Authorization", "Bearer " + apiKey)
.body(body)
.retrieve()
.body(ImageResponse.class);
......@@ -63,6 +88,16 @@ public class SeedreamServiceImpl implements SeedreamService {
String url = resp.getData().get(0).getUrl();
log.info("Seedream image generated: url={}", url);
return url;
} catch (RestClientResponseException e) {
if (e.getStatusCode().value() == 401) {
throw new BizException(ErrorCode.INTERNAL_ERROR,
"图片服务鉴权失败,请检查 VOLCENGINE_ARK_API_KEY 是否正确且仍有效");
}
log.error("Seedream generate failed: status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString(), e);
String providerMessage = extractProviderError(e.getResponseBodyAsString());
throw new BizException(ErrorCode.INTERNAL_ERROR,
"图片生成失败: HTTP " + e.getStatusCode().value()
+ (providerMessage.isBlank() ? "" : " - " + providerMessage));
} catch (BizException e) {
throw e;
} catch (Exception e) {
......@@ -81,6 +116,35 @@ public class SeedreamServiceImpl implements SeedreamService {
return "Seedream 文生图";
}
private String resolveApiKey() {
String apiKey = properties.getApiKey();
if (apiKey == null || apiKey.isBlank()) {
throw new BizException(ErrorCode.INTERNAL_ERROR, ARK_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);
}
return normalized;
}
private String extractProviderError(String body) {
if (body == null || body.isBlank()) {
return "";
}
try {
JsonNode root = JSON.readTree(body);
JsonNode message = root.path("error").path("message");
if (message.isTextual() && !message.asText().isBlank()) {
return message.asText().trim();
}
} catch (Exception ignore) {
// Fall back to the raw body below when provider response is not JSON.
}
String normalized = body.replaceAll("\\s+", " ").trim();
return normalized.length() > 300 ? normalized.substring(0, 300) + "..." : normalized;
}
// ---- internal response models ----
@Data
......
......@@ -59,17 +59,17 @@ public final class SeedancePromptBuilder {
int idx = 0;
if (characterCount == 1) {
sb.append("@图").append(idx + 1)
.append(" 是角色参考图,请保持人物脸型、发型、服装、气质一致,不要随意改变角色身份。\n");
.append(" 是角色/角色状态参考图,请保持人物脸型、五官比例、发型、体型、服装或状态一致,不要随意改变角色身份。\n");
idx += 1;
} else if (characterCount > 1) {
sb.append("@图").append(idx + 1).append("-").append(idx + characterCount)
.append(" 是出镜角色参考图(按顺序对应剧本中第 1..").append(characterCount)
.append(" 位角色),请保持每位人物的脸型、发型、服装、气质一致,不要混淆角色身份。\n");
.append(" 位角色或角色状态),请保持每位人物的脸型、五官比例、发型、体型、服装或状态一致,不要混淆角色身份。\n");
idx += characterCount;
}
if (hasSceneImage) {
sb.append("@图").append(idx + 1)
.append(" 是场景参考图,请参考空间结构、环境布局、光影、氛围和构图。\n");
.append(" 是场景参考图,只参考空间结构、环境布局、走位区域、光影、氛围和构图,不要从场景图里生成新人物。\n");
idx += 1;
}
if (propCount > 0) {
......@@ -92,10 +92,12 @@ public final class SeedancePromptBuilder {
private static void appendCommonRules(StringBuilder sb) {
sb.append("\n生成要求:\n");
sb.append("- 保持角色一致性\n");
sb.append("- 如果参考图是同一角色的不同状态,只允许采用状态差异,不要改变身份、脸型、体型和项目视觉风格\n");
sb.append("- 保持场景逻辑合理\n");
sb.append("- 人物与环境融合自然\n");
sb.append("- 镜头运动流畅\n");
sb.append("- 画面具备电影感\n");
sb.append("- 不要混合真人、动漫、CG 等不同视觉模式,除非用户在分镜内容里明确要求\n");
sb.append("- 不要出现多余人物\n");
sb.append("- 不要出现明显变脸、错位、肢体畸形\n");
}
......
......@@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
......@@ -19,6 +20,7 @@ import java.time.Duration;
* 配置 Redis Cache 使用 JSON 序列化,避免实体类需要实现 Serializable 接口。
*/
@Configuration
@ConditionalOnProperty(name = "spring.cache.type", havingValue = "redis", matchIfMissing = true)
public class RedisCacheConfig {
@Bean
......
package com.yaoai.api.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.yaoai.agent.dto.AgentRunCommand;
import com.yaoai.agent.service.AgentRunService;
import com.yaoai.agent.sse.AgentSseManager;
import com.yaoai.api.dto.agent.AgentRunDTO;
......@@ -40,11 +41,30 @@ public class AgentController {
public ApiResponse<AgentRunDTO> startRun(@PathVariable Long projectId,
@Valid @RequestBody AgentStartRequest req) {
Long tenantId = TenantContext.get();
GraphRun run = agentRunService.startRun(projectId, tenantId, req.getGoal());
GraphRun run = agentRunService.startRun(projectId, tenantId, toCommand(req));
List<GraphStep> steps = agentRunService.getSteps(run.getId(), tenantId);
return ApiResponse.success(AgentRunDTO.from(run, steps));
}
private AgentRunCommand toCommand(AgentStartRequest req) {
AgentRunCommand command = new AgentRunCommand();
command.setGoal(req.getGoal());
command.setRunIntent(req.getRunIntent());
command.setForceRegenerate(req.getForceRegenerate());
command.setSelectedSteps(req.getSelectedSteps());
command.setIdempotencyKey(req.getIdempotencyKey());
AgentRunCommand.ContextOptions options = new AgentRunCommand.ContextOptions();
if (req.getContextOptions() != null) {
options.setUseScriptAsset(req.getContextOptions().getUseScriptAsset());
options.setUseExistingOutline(req.getContextOptions().getUseExistingOutline());
options.setUseExistingEpisodes(req.getContextOptions().getUseExistingEpisodes());
options.setUseExistingAssets(req.getContextOptions().getUseExistingAssets());
options.setUseExistingStoryboards(req.getContextOptions().getUseExistingStoryboards());
}
command.setContextOptions(options);
return command;
}
@Operation(summary = "查询 Run 状态及所有步骤")
@GetMapping("/runs/{runId}")
public ApiResponse<AgentRunDTO> getRun(@PathVariable Long runId) {
......@@ -64,18 +84,25 @@ public class AgentController {
return ApiResponse.success(AgentRunDTO.from(run, steps));
}
@Operation(summary = "SSE 实时进度流(token 通过 ?Authorization=xxx 传入)")
@Operation(summary = "SSE 实时进度流")
@GetMapping(value = "/runs/{runId}/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(@PathVariable Long runId,
@RequestHeader(value = "Authorization", required = false) String authHeader,
@RequestParam(required = false) String Authorization,
HttpServletResponse response) throws IOException {
// SSE 不能发 custom header,token 通过 query param 传入,此处手动校验
if (Authorization == null || Authorization.isBlank()) {
String token = authHeader;
if (token != null && token.startsWith("Bearer ")) {
token = token.substring("Bearer ".length()).trim();
}
if (token == null || token.isBlank()) {
token = Authorization;
}
if (token == null || token.isBlank()) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing token");
return null;
}
try {
Object loginId = StpUtil.getLoginIdByToken(Authorization);
Object loginId = StpUtil.getLoginIdByToken(token);
if (loginId == null) throw new RuntimeException("invalid token");
Object tenantId = StpUtil.getSessionByLoginId(loginId).get("tenantId");
if (tenantId != null) {
......
......@@ -2,6 +2,7 @@ package com.yaoai.api.controller;
import com.yaoai.common.response.ApiResponse;
import com.yaoai.domain.entity.Character;
import com.yaoai.domain.entity.CharacterState;
import com.yaoai.pipeline.service.AssetGenPipelineService;
import com.yaoai.security.context.TenantContext;
import io.swagger.v3.oas.annotations.Operation;
......@@ -33,6 +34,12 @@ public class CharacterController {
return ApiResponse.success(assetService.listCharacters(projectId, TenantContext.get()));
}
@Operation(summary = "获取项目角色状态图列表")
@GetMapping("/states")
public ApiResponse<List<CharacterState>> listAllStates(@PathVariable Long projectId) {
return ApiResponse.success(assetService.listCharacterStates(projectId, TenantContext.get()));
}
@Operation(summary = "新建或更新角色")
@PostMapping
public ApiResponse<Character> save(@PathVariable Long projectId, @RequestBody Character req) {
......@@ -41,6 +48,29 @@ public class CharacterController {
return ApiResponse.success(assetService.saveCharacter(req));
}
@Operation(summary = "获取角色状态/造型列表")
@GetMapping("/{characterId}/states")
public ApiResponse<List<CharacterState>> listStates(@PathVariable Long projectId,
@PathVariable Long characterId) {
return ApiResponse.success(assetService.listCharacterStates(projectId, characterId, TenantContext.get()));
}
@Operation(summary = "新建或更新角色状态/造型")
@PostMapping("/{characterId}/states")
public ApiResponse<CharacterState> saveState(@PathVariable Long projectId,
@PathVariable Long characterId,
@RequestBody CharacterState req) {
return ApiResponse.success(assetService.saveCharacterState(projectId, characterId, TenantContext.get(), req));
}
@Operation(summary = "基于角色参考图生成状态图")
@PostMapping("/{characterId}/states/{stateId}/generate-image")
public ApiResponse<CharacterState> generateStateImage(@PathVariable Long projectId,
@PathVariable Long characterId,
@PathVariable Long stateId) {
return ApiResponse.success(assetService.generateCharacterStateImage(projectId, characterId, stateId, TenantContext.get()));
}
@Operation(summary = "AI 生成角色三视图")
@PostMapping("/{characterId}/generate-image")
public ApiResponse<Character> generateImage(@PathVariable Long projectId,
......@@ -72,4 +102,13 @@ public class CharacterController {
assetService.deleteCharacter(characterId, TenantContext.get());
return ApiResponse.success();
}
@Operation(summary = "删除角色状态/造型")
@DeleteMapping("/{characterId}/states/{stateId}")
public ApiResponse<Void> deleteState(@PathVariable Long projectId,
@PathVariable Long characterId,
@PathVariable Long stateId) {
assetService.deleteCharacterState(projectId, characterId, stateId, TenantContext.get());
return ApiResponse.success();
}
}
package com.yaoai.api.controller;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@RestController
@Profile("dev-h2")
public class LocalFilesController {
private final Path rootDir;
public LocalFilesController(@Value("${local-storage.base-dir:./.local-data/storage}") String baseDir) {
this.rootDir = Paths.get(baseDir).toAbsolutePath().normalize();
}
@GetMapping("/local-files")
public ResponseEntity<Resource> get(@RequestParam String key) {
Path file = resolveKey(key);
if (!Files.isRegularFile(file)) {
throw new BizException(ErrorCode.NOT_FOUND, "本地文件不存在");
}
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(probeContentType(file)))
.body(new FileSystemResource(file));
}
private Path resolveKey(String key) {
Path path = rootDir.resolve(key).normalize();
if (!path.startsWith(rootDir)) {
throw new BizException(ErrorCode.INVALID_PARAM, "非法文件路径");
}
return path;
}
private String probeContentType(Path file) {
try {
String detected = Files.probeContentType(file);
return detected == null || detected.isBlank() ? MediaType.APPLICATION_OCTET_STREAM_VALUE : detected;
} catch (IOException e) {
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
}
}
package com.yaoai.api.controller;
import com.yaoai.api.dto.ai.StoryboardDTO;
import com.yaoai.api.dto.ai.ShotAssetDTO;
import com.yaoai.common.response.ApiResponse;
import com.yaoai.domain.entity.Storyboard;
import com.yaoai.pipeline.service.ShotAssetService;
import com.yaoai.pipeline.service.StoryboardPipelineService;
import com.yaoai.security.context.TenantContext;
import io.swagger.v3.oas.annotations.Operation;
......@@ -21,14 +23,16 @@ import java.util.stream.Collectors;
public class StoryboardController {
private final StoryboardPipelineService storyboardService;
private final ShotAssetService shotAssetService;
@Operation(summary = "AI 批量生成分集分镜")
@PostMapping("/episodes/{episodeId}/storyboards/generate")
public ApiResponse<List<StoryboardDTO>> generate(@PathVariable Long projectId,
@PathVariable Long episodeId) {
Long tenantId = TenantContext.get();
storyboardService.generateStoryboards(episodeId, projectId, tenantId);
return ApiResponse.success(
storyboardService.generateStoryboards(episodeId, projectId, tenantId)
storyboardService.populateMissingVideoPromptsByEpisode(episodeId, tenantId)
.stream().map(StoryboardDTO::from).collect(Collectors.toList())
);
}
......@@ -101,4 +105,26 @@ public class StoryboardController {
Map<String, String> body = Map.of("prompt", storyboardService.generatePrompt(storyboardId, tenantId));
return ApiResponse.success(body);
}
@Operation(summary = "获取分镜素材版本列表")
@GetMapping("/storyboards/{storyboardId}/assets")
public ApiResponse<List<ShotAssetDTO>> listAssets(@PathVariable Long projectId,
@PathVariable Long storyboardId) {
Long tenantId = TenantContext.get();
return ApiResponse.success(
shotAssetService.listByStoryboard(storyboardId, tenantId)
.stream().map(ShotAssetDTO::from).collect(Collectors.toList())
);
}
@Operation(summary = "激活某个分镜素材版本")
@PostMapping("/storyboards/{storyboardId}/assets/{assetId}/activate")
public ApiResponse<ShotAssetDTO> activateAsset(@PathVariable Long projectId,
@PathVariable Long storyboardId,
@PathVariable Long assetId) {
Long tenantId = TenantContext.get();
return ApiResponse.success(ShotAssetDTO.from(
shotAssetService.activateAsset(assetId, storyboardId, tenantId)
));
}
}
......@@ -3,8 +3,25 @@ package com.yaoai.api.dto.agent;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class AgentStartRequest {
@NotBlank
private String goal;
private String runIntent = "auto";
private Boolean forceRegenerate = false;
private List<String> selectedSteps = new ArrayList<>();
private ContextOptions contextOptions = new ContextOptions();
private String idempotencyKey;
@Data
public static class ContextOptions {
private Boolean useScriptAsset = true;
private Boolean useExistingOutline = true;
private Boolean useExistingEpisodes = true;
private Boolean useExistingAssets = true;
private Boolean useExistingStoryboards = true;
}
}
package com.yaoai.api.dto.ai;
import com.yaoai.domain.entity.ShotAsset;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class ShotAssetDTO {
private Long id;
private Long storyboardId;
private Long projectId;
private Long episodeId;
private String assetType;
private String tosKey;
private String externalUrl;
private Long sourceTaskId;
private String prompt;
private String modelProvider;
private String modelId;
private Integer assetVersion;
private String characters;
private String metaJson;
private String errorMessage;
private String status;
private Integer isActive;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public static ShotAssetDTO from(ShotAsset asset) {
ShotAssetDTO dto = new ShotAssetDTO();
dto.setId(asset.getId());
dto.setStoryboardId(asset.getStoryboardId());
dto.setProjectId(asset.getProjectId());
dto.setEpisodeId(asset.getEpisodeId());
dto.setAssetType(asset.getAssetType());
dto.setTosKey(asset.getTosKey());
dto.setExternalUrl(asset.getExternalUrl());
dto.setSourceTaskId(asset.getSourceTaskId());
dto.setPrompt(asset.getPrompt());
dto.setModelProvider(asset.getModelProvider());
dto.setModelId(asset.getModelId());
dto.setAssetVersion(asset.getAssetVersion());
dto.setCharacters(asset.getCharacters());
dto.setMetaJson(asset.getMetaJson());
dto.setErrorMessage(asset.getErrorMessage());
dto.setStatus(asset.getStatus());
dto.setIsActive(asset.getIsActive());
dto.setCreatedAt(asset.getCreatedAt());
dto.setUpdatedAt(asset.getUpdatedAt());
return dto;
}
}
......@@ -76,7 +76,7 @@
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
<scope>runtime</scope>
</dependency>
</dependencies>
......
spring:
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
- org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration
- cn.dev33.satoken.dao.SaTokenDaoRedisJackson
datasource:
url: jdbc:h2:file:./.local-data/yaoai-dev;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1
username: sa
password:
driver-class-name: org.h2.Driver
flyway:
enabled: false
sql:
init:
mode: always
schema-locations: classpath:db/dev-h2/schema-auth.sql
cache:
type: simple
local-storage:
base-dir: ./.local-data/storage
public-base-url: ${LOCAL_STORAGE_PUBLIC_BASE_URL:http://127.0.0.1:${SERVER_PORT:${BACKEND_PORT:8081}}}
storage:
local:
enabled: ${LOCAL_STORAGE_ENABLED:false}
local-migration:
enabled: ${LOCAL_STORAGE_MIGRATION_ENABLED:true}
billing:
mode: OFF
ai:
local-stub:
enabled: ${LOCAL_AI_STUB_ENABLED:false}
server:
port: ${SERVER_PORT:${BACKEND_PORT:8081}}
logging:
level:
com.yaoai: INFO
spring:
datasource:
url: jdbc:mysql://localhost:3306/yaoai_comic?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8
url: jdbc:mysql://localhost:13306/yaoai_comic?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8
username: your_db_username
password: your_db_password
......
......@@ -5,13 +5,18 @@ spring:
banner:
charset: UTF-8
config:
import:
- optional:file:.env[.properties]
- optional:file:../.env[.properties]
profiles:
active: ${SPRING_PROFILES_ACTIVE:local}
datasource:
url: ${DB_URL:jdbc:mysql://localhost:13306/yaoai_comic?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8}
username: ${DB_USERNAME:yaoai}
password: ${DB_PASSWORD:yaoai123}
url: ${DB_URL:jdbc:mysql://localhost:${MYSQL_PORT:13306}/${MYSQL_DATABASE:yaoai_comic}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8}
username: ${DB_USERNAME:${MYSQL_USER:yaoai}}
password: ${DB_PASSWORD:${MYSQL_PASSWORD:yaoai123}}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
pool-name: YaoAiHikariPool
......@@ -64,7 +69,7 @@ spring:
request-timeout: 600000
server:
port: ${SERVER_PORT:8080}
port: ${SERVER_PORT:${BACKEND_PORT:8081}}
servlet:
context-path: /
compression:
......@@ -104,7 +109,7 @@ volcengine:
ark:
api-key: ${VOLCENGINE_ARK_API_KEY:}
base-url: ${VOLCENGINE_ARK_BASE_URL:https://ark.cn-beijing.volces.com/api/v3}
text-model: ${VOLCENGINE_ARK_TEXT_MODEL:doubao-seed-2-0-code-preview-260215}
text-model: ${VOLCENGINE_ARK_TEXT_MODEL:${VOLCENGINE_ARK_MODEL:doubao-seed-2-0-code-preview-260215}}
image-model: ${VOLCENGINE_ARK_IMAGE_MODEL:doubao-seedream-5-0-260128}
video-model: ${VOLCENGINE_ARK_VIDEO_MODEL:doubao-seedance-2-0-fast-260128}
......
CREATE TABLE script_understandings (
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
project_id BIGINT NOT NULL,
asset_id BIGINT,
understanding_json JSON,
evidence_hash VARCHAR(64),
coverage_report_json JSON,
quality_status VARCHAR(32) NOT NULL DEFAULT 'PENDING'
COMMENT 'PENDING|PASSED|NEED_CONFIRMATION|FAILED',
quality_issues_json JSON,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
INDEX idx_tenant_project (tenant_id, project_id),
INDEX idx_asset (asset_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE project_consistency_bibles (
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
project_id BIGINT NOT NULL,
bible_json JSON,
source_version VARCHAR(64),
locked_fields_json JSON,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_project (tenant_id, project_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE agent_run_plans (
id BIGINT NOT NULL AUTO_INCREMENT,
run_id BIGINT NOT NULL,
run_mode VARCHAR(64) NOT NULL,
reasoning_summary TEXT,
planned_steps_json JSON,
skipped_steps_json JSON,
step_inputs_json JSON,
quality_rules_json JSON,
model_policy_json JSON,
context_options_json JSON,
idempotency_key VARCHAR(128),
plan_version VARCHAR(32) NOT NULL DEFAULT 'v1',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_run (run_id),
INDEX idx_idempotency (idempotency_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE agent_prompt_snapshots (
id BIGINT NOT NULL AUTO_INCREMENT,
run_id BIGINT NOT NULL,
step_key VARCHAR(64) NOT NULL,
prompt_role VARCHAR(64) NOT NULL,
system_prompt_version VARCHAR(64),
context_hash VARCHAR(64),
context_summary TEXT,
request_json JSON,
response_raw LONGTEXT,
parsed_output_json JSON,
validation_result_json JSON,
model_provider VARCHAR(64),
model_id VARCHAR(128),
latency_ms BIGINT,
error_message TEXT,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
INDEX idx_run_step (run_id, step_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- M19 角色多状态/造型变体
CREATE TABLE character_states (
id BIGINT NOT NULL PRIMARY KEY,
project_id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
character_id BIGINT NOT NULL,
name VARCHAR(128) NOT NULL,
state_type VARCHAR(32) DEFAULT 'costume',
description TEXT,
prompt TEXT,
reference_image_key VARCHAR(512),
image_url VARCHAR(1024),
image_tos_key VARCHAR(512),
status VARCHAR(32) NOT NULL DEFAULT 'draft',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
INDEX idx_project (project_id),
INDEX idx_character (character_id),
INDEX idx_tenant (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- M20 分镜素材版本与视频任务回写
ALTER TABLE shot_assets
ADD COLUMN project_id BIGINT DEFAULT NULL COMMENT 'Project id',
ADD COLUMN episode_id BIGINT DEFAULT NULL COMMENT 'Episode id',
ADD COLUMN source_task_id BIGINT DEFAULT NULL COMMENT 'Source ai_tasks id',
ADD COLUMN asset_version INT NOT NULL DEFAULT 1 COMMENT 'Version within storyboard + asset type',
ADD COLUMN characters TEXT DEFAULT NULL COMMENT 'Referenced character image keys JSON',
ADD COLUMN meta_json LONGTEXT DEFAULT NULL COMMENT 'Asset metadata JSON',
ADD COLUMN error_message TEXT DEFAULT NULL COMMENT 'Failure message',
ADD COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3);
CREATE INDEX idx_shot_assets_project ON shot_assets (project_id, tenant_id);
CREATE INDEX idx_shot_assets_task ON shot_assets (source_task_id);
CREATE INDEX idx_shot_assets_type_version ON shot_assets (storyboard_id, asset_type, asset_version);
package com.yaoai.agent.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.agent.dto.AgentRunCommand;
import com.yaoai.agent.service.ScriptUnderstandingService;
import com.yaoai.agent.sse.AgentSseManager;
import com.yaoai.ai.core.service.LlmService;
import com.yaoai.domain.entity.Episode;
......@@ -7,9 +10,14 @@ import com.yaoai.domain.entity.GraphRun;
import com.yaoai.domain.entity.GraphStep;
import com.yaoai.domain.entity.Outline;
import com.yaoai.domain.entity.Storyboard;
import com.yaoai.domain.mapper.AgentRunPlanMapper;
import com.yaoai.domain.mapper.CharacterMapper;
import com.yaoai.domain.mapper.EpisodeMapper;
import com.yaoai.domain.mapper.GraphRunMapper;
import com.yaoai.domain.mapper.GraphStepMapper;
import com.yaoai.domain.mapper.OutlineMapper;
import com.yaoai.domain.mapper.SceneMapper;
import com.yaoai.domain.mapper.StoryboardMapper;
import com.yaoai.pipeline.service.AssetGenPipelineService;
import com.yaoai.pipeline.service.OutlinePipelineService;
import com.yaoai.pipeline.service.StoryboardPipelineService;
......@@ -36,12 +44,24 @@ class AgentRunServiceImplTest {
@Mock
private GraphStepMapper graphStepMapper;
@Mock
private AgentRunPlanMapper agentRunPlanMapper;
@Mock
private OutlineMapper outlineMapper;
@Mock
private EpisodeMapper episodeMapper;
@Mock
private CharacterMapper characterMapper;
@Mock
private SceneMapper sceneMapper;
@Mock
private StoryboardMapper storyboardMapper;
@Mock
private AgentSseManager sseManager;
@Mock
private LlmService llmService;
@Mock
private ScriptUnderstandingService scriptUnderstandingService;
@Mock
private OutlinePipelineService outlinePipeline;
@Mock
private AssetGenPipelineService assetGenPipeline;
......@@ -53,9 +73,16 @@ class AgentRunServiceImplTest {
AgentRunServiceImpl service = new AgentRunServiceImpl(
graphRunMapper,
graphStepMapper,
agentRunPlanMapper,
outlineMapper,
episodeMapper,
characterMapper,
sceneMapper,
storyboardMapper,
sseManager,
llmService,
new ObjectMapper(),
scriptUnderstandingService,
outlinePipeline,
assetGenPipeline,
storyboardPipeline
......@@ -82,6 +109,7 @@ class AgentRunServiceImplTest {
when(graphStepMapper.findByRunId(runId)).thenReturn(steps);
when(graphStepMapper.updateById(any(GraphStep.class))).thenReturn(1);
when(llmService.chat(any())).thenReturn("SCORE: A");
when(scriptUnderstandingService.toGenerationContext(any(), any())).thenReturn("goal");
Outline outline = new Outline();
outline.setId(77L);
......@@ -93,6 +121,9 @@ class AgentRunServiceImplTest {
episode.setId(101L);
episode.setEpisodeNumber(1);
when(outlinePipeline.generateEpisodes(77L, projectId, tenantId)).thenReturn(List.of(episode));
when(characterMapper.findByProject(projectId, tenantId)).thenReturn(List.of());
when(sceneMapper.findByProject(projectId, tenantId)).thenReturn(List.of());
when(storyboardMapper.findByProject(projectId, tenantId)).thenReturn(List.of());
when(assetGenPipeline.extractCharacters(projectId, tenantId)).thenReturn(List.of());
when(assetGenPipeline.extractScenes(projectId, tenantId)).thenReturn(List.of());
when(storyboardPipeline.generateStoryboards(101L, projectId, tenantId))
......@@ -107,7 +138,8 @@ class AgentRunServiceImplTest {
projectId,
tenantId,
1001L,
"goal"
runCommand("goal"),
false
);
verify(storyboardPipeline).populateMissingVideoPromptsByEpisode(101L, tenantId);
......@@ -136,4 +168,14 @@ class AgentRunServiceImplTest {
storyboard.setVideoPrompt("prompt");
return storyboard;
}
private static AgentRunCommand runCommand(String goal) {
AgentRunCommand command = new AgentRunCommand();
command.setGoal(goal);
command.setRunIntent("auto");
command.setForceRegenerate(false);
command.setSelectedSteps(List.of());
command.setContextOptions(new AgentRunCommand.ContextOptions());
return command;
}
}
package com.yaoai.ai.providers.service.impl;
import com.yaoai.ai.core.model.ChatMessage;
import com.yaoai.ai.core.model.ChatRequest;
import com.yaoai.ai.providers.config.ArkProperties;
import com.yaoai.common.exception.BizException;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
class ArkLlmServiceTest {
@Test
void shouldFailFastWhenApiKeyIsMissing() {
ArkLlmService service = new ArkLlmService(buildProperties(""));
BizException exception = assertThrows(BizException.class, () -> service.chat(buildRequest()));
assertTrue(exception.getMessage().contains("VOLCENGINE_ARK_API_KEY"));
}
@Test
void shouldFailFastWhenApiKeyUsesPlaceholderValue() {
ArkLlmService service = new ArkLlmService(buildProperties("your-ark-api-key"));
BizException exception = assertThrows(BizException.class, () -> service.chat(buildRequest()));
assertTrue(exception.getMessage().contains("VOLCENGINE_ARK_API_KEY"));
}
private static ArkProperties buildProperties(String apiKey) {
ArkProperties properties = new ArkProperties();
properties.setApiKey(apiKey);
properties.setBaseUrl("https://ark.cn-beijing.volces.com/api/v3");
properties.setTextModel("test-model");
return properties;
}
private static ChatRequest buildRequest() {
return ChatRequest.builder()
.messages(List.of(ChatMessage.user("hello")))
.build();
}
}
......@@ -13,7 +13,10 @@ import com.yaoai.domain.entity.Scene;
import com.yaoai.domain.entity.Storyboard;
import com.yaoai.domain.mapper.CharacterMapper;
import com.yaoai.domain.mapper.EpisodeMapper;
import com.yaoai.domain.mapper.OutlineMapper;
import com.yaoai.domain.mapper.ProjectConsistencyBibleMapper;
import com.yaoai.domain.mapper.SceneMapper;
import com.yaoai.domain.mapper.ScriptUnderstandingMapper;
import com.yaoai.domain.mapper.StoryboardMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
......@@ -52,10 +55,16 @@ class StoryboardPipelineServiceImplTest {
@Mock
private EpisodeMapper episodeMapper;
@Mock
private OutlineMapper outlineMapper;
@Mock
private CharacterMapper characterMapper;
@Mock
private SceneMapper sceneMapper;
@Mock
private ScriptUnderstandingMapper scriptUnderstandingMapper;
@Mock
private ProjectConsistencyBibleMapper projectConsistencyBibleMapper;
@Mock
private BillingService billingService;
private StoryboardPipelineServiceImpl service;
......@@ -66,8 +75,11 @@ class StoryboardPipelineServiceImplTest {
llmService,
storyboardMapper,
episodeMapper,
outlineMapper,
characterMapper,
sceneMapper,
scriptUnderstandingMapper,
projectConsistencyBibleMapper,
new ObjectMapper(),
billingService
);
......@@ -119,6 +131,53 @@ class StoryboardPipelineServiceImplTest {
}
@Test
void generateStoryboards_shouldRemoveProductionMetadataFromInputsAndOutputs() {
Episode episode = new Episode();
episode.setId(12L);
episode.setTitle("ep-2");
episode.setScript("""
> 类型:都市心理悬疑 | 集数:35集 | 每集约1800字
> 制作方式:AI 3D漫剧 | 变现:付费解锁
女主在雨夜发现门缝下的匿名照片。
""");
when(episodeMapper.selectById(12L)).thenReturn(episode);
when(characterMapper.findByProject(22L, 33L)).thenReturn(List.of());
when(sceneMapper.findByProject(22L, 33L)).thenReturn(List.of());
when(storyboardMapper.insert(any(Storyboard.class))).thenReturn(1);
when(llmService.chat(any(ChatRequest.class))).thenReturn("""
[{
"sequence_num": 1,
"scene_number": "001",
"short_description": "> 类型:都市心理悬疑 | 集数:35",
"detailed_description": "制作方式:AI 3D漫剧 | 变现:付费解锁\\n女主低头看见匿名照片。",
"characters": "",
"dialogues": "每集约1800",
"camera_direction": "push",
"composition_guide": "medium",
"duration_seconds": 5,
"start_frame_prompt": "rainy doorway, anonymous photo",
"motion_script": "镜头缓慢推进匿名照片"
}]
""");
service.generateStoryboards(12L, 22L, 33L);
ArgumentCaptor<ChatRequest> requestCaptor = ArgumentCaptor.forClass(ChatRequest.class);
verify(llmService).chat(requestCaptor.capture());
String userMessage = requestCaptor.getValue().getMessages().get(1).getContent();
assertTrue(!userMessage.contains("制作方式"));
assertTrue(!userMessage.contains("变现"));
assertTrue(userMessage.contains("匿名照片"));
ArgumentCaptor<Storyboard> storyboardCaptor = ArgumentCaptor.forClass(Storyboard.class);
verify(storyboardMapper).insert(storyboardCaptor.capture());
Storyboard inserted = storyboardCaptor.getValue();
assertEquals("", inserted.getShortDescription());
assertEquals("女主低头看见匿名照片。", inserted.getDetailedDescription());
assertEquals("", inserted.getDialogues());
}
@Test
void generatePrompt_shouldPersistVideoPrompt() {
Storyboard storyboard = new Storyboard();
storyboard.setId(7L);
......
package com.yaoai.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("agent_prompt_snapshots")
public class AgentPromptSnapshot {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long runId;
private String stepKey;
private String promptRole;
private String systemPromptVersion;
private String contextHash;
private String contextSummary;
private String requestJson;
private String responseRaw;
private String parsedOutputJson;
private String validationResultJson;
private String modelProvider;
private String modelId;
private Long latencyMs;
private String errorMessage;
private LocalDateTime createdAt;
}
package com.yaoai.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("agent_run_plans")
public class AgentRunPlan {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long runId;
private String runMode;
private String reasoningSummary;
private String plannedStepsJson;
private String skippedStepsJson;
private String stepInputsJson;
private String qualityRulesJson;
private String modelPolicyJson;
private String contextOptionsJson;
private String idempotencyKey;
private String planVersion;
private LocalDateTime createdAt;
}
package com.yaoai.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import org.apache.ibatis.type.Alias;
import java.time.LocalDateTime;
@Data
@TableName("character_states")
@Alias("CharacterState")
public class CharacterState {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long projectId;
private Long tenantId;
private Long characterId;
private String name;
private String stateType;
private String description;
private String prompt;
private String referenceImageKey;
private String imageUrl;
private String imageTosKey;
/** draft / generating / ready / failed */
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@TableField(exist = false)
private String characterName;
}
......@@ -10,7 +10,7 @@ import java.time.LocalDateTime;
@TableName("graph_runs")
public class GraphRun {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long projectId;
......
......@@ -8,7 +8,7 @@ import java.time.LocalDateTime;
@TableName("graph_steps")
public class GraphStep {
@TableId(type = IdType.AUTO)
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long runId;
private String stepKey;
......
package com.yaoai.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("project_consistency_bibles")
public class ProjectConsistencyBible {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long projectId;
private String bibleJson;
private String sourceVersion;
private String lockedFieldsJson;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
package com.yaoai.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("script_understandings")
public class ScriptUnderstanding {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long projectId;
private Long assetId;
private String understandingJson;
private String evidenceHash;
private String coverageReportJson;
private String qualityStatus;
private String qualityIssuesJson;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
......@@ -15,16 +15,23 @@ public class ShotAsset {
private Long id;
private Long storyboardId;
private Long projectId;
private Long episodeId;
private Long tenantId;
/** first_frame / last_frame / keyframe_video / reference */
/** first_frame / last_frame / reference / video */
private String assetType;
private String tosKey;
private String externalUrl;
private Long sourceTaskId;
private String prompt;
private String modelProvider;
private String modelId;
private Integer assetVersion;
private String characters;
private String metaJson;
private String errorMessage;
/** pending / generating / ready / failed */
private String status;
......@@ -32,4 +39,5 @@ public class ShotAsset {
private Integer isActive;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
package com.yaoai.domain.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yaoai.domain.entity.AgentPromptSnapshot;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AgentPromptSnapshotMapper extends BaseMapper<AgentPromptSnapshot> {
}
package com.yaoai.domain.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.yaoai.domain.entity.AgentRunPlan;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AgentRunPlanMapper extends BaseMapper<AgentRunPlan> {
default AgentRunPlan findByRunId(Long runId) {
return selectOne(Wrappers.<AgentRunPlan>lambdaQuery()
.eq(AgentRunPlan::getRunId, runId)
.last("LIMIT 1"));
}
}
package com.yaoai.domain.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yaoai.domain.entity.CharacterState;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface CharacterStateMapper extends BaseMapper<CharacterState> {
@Select("""
SELECT cs.*, c.name AS character_name
FROM character_states cs
LEFT JOIN characters c ON c.id = cs.character_id
WHERE cs.project_id=#{projectId} AND cs.tenant_id=#{tenantId}
ORDER BY c.created_at ASC, cs.created_at ASC
""")
List<CharacterState> findByProject(Long projectId, Long tenantId);
@Select("""
SELECT cs.*, c.name AS character_name
FROM character_states cs
LEFT JOIN characters c ON c.id = cs.character_id
WHERE cs.project_id=#{projectId}
AND cs.character_id=#{characterId}
AND cs.tenant_id=#{tenantId}
ORDER BY cs.created_at ASC
""")
List<CharacterState> findByCharacter(Long projectId, Long characterId, Long tenantId);
@Delete("DELETE FROM character_states WHERE character_id=#{characterId} AND tenant_id=#{tenantId}")
int deleteByCharacter(Long characterId, Long tenantId);
}
......@@ -17,6 +17,15 @@ public interface ProjectAssetMapper extends BaseMapper<ProjectAsset> {
.orderByDesc(ProjectAsset::getCreatedAt));
}
default Optional<ProjectAsset> findLatestByProjectAndType(Long projectId, Long tenantId, String assetType) {
return Optional.ofNullable(selectOne(Wrappers.<ProjectAsset>lambdaQuery()
.eq(ProjectAsset::getProjectId, projectId)
.eq(ProjectAsset::getTenantId, tenantId)
.eq(ProjectAsset::getAssetType, assetType)
.orderByDesc(ProjectAsset::getCreatedAt)
.last("LIMIT 1")));
}
default Optional<ProjectAsset> findById(Long id, Long tenantId) {
return Optional.ofNullable(selectOne(Wrappers.<ProjectAsset>lambdaQuery()
.eq(ProjectAsset::getId, id)
......
package com.yaoai.domain.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.yaoai.domain.entity.ProjectConsistencyBible;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ProjectConsistencyBibleMapper extends BaseMapper<ProjectConsistencyBible> {
default ProjectConsistencyBible findByProject(Long projectId, Long tenantId) {
return selectOne(Wrappers.<ProjectConsistencyBible>lambdaQuery()
.eq(ProjectConsistencyBible::getProjectId, projectId)
.eq(ProjectConsistencyBible::getTenantId, tenantId)
.last("LIMIT 1"));
}
}
package com.yaoai.domain.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.yaoai.domain.entity.ScriptUnderstanding;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ScriptUnderstandingMapper extends BaseMapper<ScriptUnderstanding> {
default ScriptUnderstanding findLatestByProject(Long projectId, Long tenantId) {
return selectOne(Wrappers.<ScriptUnderstanding>lambdaQuery()
.eq(ScriptUnderstanding::getProjectId, projectId)
.eq(ScriptUnderstanding::getTenantId, tenantId)
.orderByDesc(ScriptUnderstanding::getCreatedAt)
.last("LIMIT 1"));
}
}
......@@ -3,7 +3,9 @@ package com.yaoai.domain.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yaoai.domain.entity.ShotAsset;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
......@@ -12,4 +14,25 @@ public interface ShotAssetMapper extends BaseMapper<ShotAsset> {
@Select("SELECT * FROM shot_assets WHERE storyboard_id=#{storyboardId} AND is_active=1 ORDER BY created_at DESC")
List<ShotAsset> findActiveByStoryboard(Long storyboardId);
@Select("SELECT * FROM shot_assets WHERE storyboard_id=#{storyboardId} AND tenant_id=#{tenantId} ORDER BY asset_type, asset_version DESC, created_at DESC")
List<ShotAsset> findByStoryboard(@Param("storyboardId") Long storyboardId, @Param("tenantId") Long tenantId);
@Select("SELECT * FROM shot_assets WHERE storyboard_id=#{storyboardId} AND tenant_id=#{tenantId} AND asset_type=#{assetType} ORDER BY asset_version DESC, created_at DESC")
List<ShotAsset> findHistory(@Param("storyboardId") Long storyboardId,
@Param("tenantId") Long tenantId,
@Param("assetType") String assetType);
@Select("SELECT COALESCE(MAX(asset_version), 0) FROM shot_assets WHERE storyboard_id=#{storyboardId} AND tenant_id=#{tenantId} AND asset_type=#{assetType}")
Integer maxVersion(@Param("storyboardId") Long storyboardId,
@Param("tenantId") Long tenantId,
@Param("assetType") String assetType);
@Select("SELECT * FROM shot_assets WHERE source_task_id=#{sourceTaskId} AND tenant_id=#{tenantId} LIMIT 1")
ShotAsset findBySourceTask(@Param("sourceTaskId") Long sourceTaskId, @Param("tenantId") Long tenantId);
@Update("UPDATE shot_assets SET is_active=0 WHERE storyboard_id=#{storyboardId} AND tenant_id=#{tenantId} AND asset_type=#{assetType} AND is_active=1")
int deactivateActive(@Param("storyboardId") Long storyboardId,
@Param("tenantId") Long tenantId,
@Param("assetType") String assetType);
}
......@@ -6,6 +6,7 @@ import com.yaoai.billing.service.BillingService;
import com.yaoai.domain.entity.AiTask;
import com.yaoai.domain.mapper.AiTaskMapper;
import com.yaoai.pipeline.service.ImageGenPipelineService;
import com.yaoai.pipeline.service.ShotAssetService;
import com.yaoai.storage.service.TosService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
......@@ -27,6 +28,7 @@ public class VideoTaskAsyncProcessor {
private final SeedanceService seedanceService;
private final TosService tosService;
private final BillingService billingService;
private final ShotAssetService shotAssetService;
/**
* 异步执行视频生成流程:
......@@ -115,6 +117,7 @@ public class VideoTaskAsyncProcessor {
failure.setStatus("failed");
failure.setErrorMessage(e.getMessage() != null ? e.getMessage() : "生成失败");
aiTaskMapper.updateById(failure);
shotAssetService.markVideoAssetFailed(tenantId, taskId, failure.getErrorMessage());
}
}
}
......@@ -4,6 +4,7 @@ import com.yaoai.ai.providers.model.VideoTaskResult;
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;
......@@ -28,6 +29,7 @@ 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();
......@@ -57,6 +59,13 @@ public class VideoTaskCompletionService {
done.setErrorMessage(result.getErrorMessage());
}
aiTaskMapper.updateById(done);
if ("succeeded".equals(status)) {
shotAssetService.markVideoAssetSucceeded(
task.getTenantId(), taskId, done.getResultVideoUrl());
} else {
shotAssetService.markVideoAssetFailed(
task.getTenantId(), taskId, done.getErrorMessage());
}
log.info("Video task completed: taskId={}, status={}, url={}",
taskId, status, done.getResultVideoUrl());
return true;
......
package com.yaoai.pipeline.service;
import com.yaoai.domain.entity.Character;
import com.yaoai.domain.entity.CharacterState;
import com.yaoai.domain.entity.Scene;
import java.io.InputStream;
......@@ -19,14 +20,24 @@ public interface AssetGenPipelineService {
List<Character> listCharacters(Long projectId, Long tenantId);
List<CharacterState> listCharacterStates(Long projectId, Long tenantId);
List<CharacterState> listCharacterStates(Long projectId, Long characterId, Long tenantId);
List<Scene> listScenes(Long projectId, Long tenantId);
Character saveCharacter(Character character);
CharacterState saveCharacterState(Long projectId, Long characterId, Long tenantId, CharacterState state);
CharacterState generateCharacterStateImage(Long projectId, Long characterId, Long stateId, Long tenantId);
Scene saveScene(Scene scene);
void deleteCharacter(Long id, Long tenantId);
void deleteCharacterState(Long projectId, Long characterId, Long id, Long tenantId);
void deleteScene(Long id, Long tenantId);
Character uploadCharacterImage(Long characterId, Long tenantId, String viewType,
......
package com.yaoai.pipeline.service;
import com.yaoai.domain.entity.ShotAsset;
import java.util.List;
import java.util.Map;
public interface ShotAssetService {
List<ShotAsset> listByStoryboard(Long storyboardId, Long tenantId);
ShotAsset activateAsset(Long assetId, Long storyboardId, Long tenantId);
ShotAsset recordPendingVideoAsset(Long tenantId, Long projectId, Long episodeId, Long storyboardId,
Long taskId, String prompt, String modelProvider, String modelId,
List<String> characterImageKeys, String sceneImageKey,
List<String> propImageKeys, String styleImageKey,
Map<String, Object> meta);
void markVideoAssetSucceeded(Long tenantId, Long taskId, String videoUrl);
void markVideoAssetFailed(Long tenantId, Long taskId, String errorMessage);
}
......@@ -12,8 +12,12 @@ import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import com.yaoai.domain.entity.Episode;
import com.yaoai.domain.entity.Outline;
import com.yaoai.domain.entity.ProjectConsistencyBible;
import com.yaoai.domain.entity.ScriptUnderstanding;
import com.yaoai.domain.mapper.EpisodeMapper;
import com.yaoai.domain.mapper.OutlineMapper;
import com.yaoai.domain.mapper.ProjectConsistencyBibleMapper;
import com.yaoai.domain.mapper.ScriptUnderstandingMapper;
import com.yaoai.pipeline.service.OutlinePipelineService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
......@@ -28,14 +32,19 @@ import java.util.Map;
@RequiredArgsConstructor
public class OutlinePipelineServiceImpl implements OutlinePipelineService {
private static final int MAX_CONTEXT_CHARS = 12000;
private final LlmService llmService;
private final OutlineMapper outlineMapper;
private final EpisodeMapper episodeMapper;
private final ScriptUnderstandingMapper scriptUnderstandingMapper;
private final ProjectConsistencyBibleMapper projectConsistencyBibleMapper;
private final ObjectMapper objectMapper;
private final BillingService billingService;
private static final String OUTLINE_SYSTEM = """
你是一位专业的影视编剧助手。根据用户提供的剧本内容,提取并生成结构化大纲。
如果输入包含“剧情理解事实”或“一致性圣经”,必须优先遵循其中的事实与证据,不得自由改写主线。
请以 JSON 格式返回,格式如下:
{
"title": "剧集名称",
......@@ -48,6 +57,7 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService {
private static final String EPISODE_SYSTEM = """
你是一位专业的影视编剧助手。根据用户提供的大纲信息,生成每集的详细内容。
如果输入包含“剧情理解事实”或“一致性圣经”,必须按事实展开分集,不得新增与事实冲突的人物关系、地点和动机。
请以 JSON 数组格式返回,每集格式如下:
[
{
......@@ -63,11 +73,12 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService {
@Override
public Outline generateOutline(Long projectId, Long tenantId, String scriptContent) {
log.info("Generating outline: projectId={}", projectId);
String enrichedScriptContent = enrichWithContinuityContext(projectId, tenantId, scriptContent);
ChatRequest request = ChatRequest.builder()
.messages(List.of(
ChatMessage.system(OUTLINE_SYSTEM),
ChatMessage.user("剧本内容如下:\n\n" + scriptContent)
ChatMessage.user("剧本内容如下:\n\n" + enrichedScriptContent)
))
.build();
......@@ -134,7 +145,8 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService {
log.info("Generating episodes: outlineId={}, count={}", outlineId, outline.getEpisodeCount());
String userMsg = String.format(
"剧集名称:%s\n类型:%s\n集数:%d\n故事梗概:%s\n\n请生成每集详细内容。",
"%s\n\n剧集名称:%s\n类型:%s\n集数:%d\n故事梗概:%s\n\n请生成每集详细内容。",
buildContinuityContext(projectId, tenantId),
outline.getTitle(), outline.getGenre(), outline.getEpisodeCount(), outline.getSynopsis()
);
......@@ -283,4 +295,37 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService {
}
return s;
}
private String enrichWithContinuityContext(Long projectId, Long tenantId, String content) {
String continuity = buildContinuityContext(projectId, tenantId);
String base = content == null ? "" : content;
if (continuity.isBlank()) {
return base;
}
return continuity + "\n\n用户输入/剧本内容:\n" + base;
}
private String buildContinuityContext(Long projectId, Long tenantId) {
StringBuilder builder = new StringBuilder();
ScriptUnderstanding understanding = scriptUnderstandingMapper.findLatestByProject(projectId, tenantId);
if (understanding != null && understanding.getUnderstandingJson() != null && !understanding.getUnderstandingJson().isBlank()) {
builder.append("剧情理解事实(最高优先级,必须遵循):\n")
.append(limit(understanding.getUnderstandingJson(), MAX_CONTEXT_CHARS))
.append("\n\n");
}
ProjectConsistencyBible bible = projectConsistencyBibleMapper.findByProject(projectId, tenantId);
if (bible != null && bible.getBibleJson() != null && !bible.getBibleJson().isBlank()) {
builder.append("一致性圣经(角色/场景/剧情锁定项):\n")
.append(limit(bible.getBibleJson(), MAX_CONTEXT_CHARS))
.append("\n\n");
}
return builder.toString().trim();
}
private String limit(String value, int maxChars) {
if (value == null || value.length() <= maxChars) {
return value == null ? "" : value;
}
return value.substring(0, maxChars) + "\n[上下文过长,已截断]";
}
}
package com.yaoai.pipeline.service.impl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import com.yaoai.domain.entity.ShotAsset;
import com.yaoai.domain.mapper.ShotAssetMapper;
import com.yaoai.pipeline.service.ShotAssetService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class ShotAssetServiceImpl implements ShotAssetService {
private static final String TYPE_VIDEO = "video";
private final ShotAssetMapper shotAssetMapper;
private final ObjectMapper objectMapper;
@Override
public List<ShotAsset> listByStoryboard(Long storyboardId, Long tenantId) {
return shotAssetMapper.findByStoryboard(storyboardId, tenantId);
}
@Override
@Transactional
public ShotAsset activateAsset(Long assetId, Long storyboardId, Long tenantId) {
ShotAsset asset = shotAssetMapper.selectById(assetId);
if (asset == null
|| !tenantId.equals(asset.getTenantId())
|| !storyboardId.equals(asset.getStoryboardId())) {
throw new BizException(ErrorCode.NOT_FOUND, "镜头素材不存在");
}
shotAssetMapper.deactivateActive(storyboardId, tenantId, asset.getAssetType());
ShotAsset patch = new ShotAsset();
patch.setId(assetId);
patch.setIsActive(1);
shotAssetMapper.updateById(patch);
return shotAssetMapper.selectById(assetId);
}
@Override
@Transactional
public ShotAsset recordPendingVideoAsset(Long tenantId, Long projectId, Long episodeId, Long storyboardId,
Long taskId, String prompt, String modelProvider, String modelId,
List<String> characterImageKeys, String sceneImageKey,
List<String> propImageKeys, String styleImageKey,
Map<String, Object> meta) {
if (storyboardId == null) {
return null;
}
shotAssetMapper.deactivateActive(storyboardId, tenantId, TYPE_VIDEO);
Integer maxVersion = shotAssetMapper.maxVersion(storyboardId, tenantId, TYPE_VIDEO);
Map<String, Object> metaPayload = new LinkedHashMap<>();
if (meta != null) {
metaPayload.putAll(meta);
}
metaPayload.put("sceneImageKey", sceneImageKey);
metaPayload.put("propImageKeys", propImageKeys);
metaPayload.put("styleImageKey", styleImageKey);
ShotAsset asset = new ShotAsset();
asset.setTenantId(tenantId);
asset.setProjectId(projectId);
asset.setEpisodeId(episodeId);
asset.setStoryboardId(storyboardId);
asset.setAssetType(TYPE_VIDEO);
asset.setSourceTaskId(taskId);
asset.setPrompt(prompt);
asset.setModelProvider(modelProvider);
asset.setModelId(modelId);
asset.setAssetVersion((maxVersion == null ? 0 : maxVersion) + 1);
asset.setCharacters(toJson(characterImageKeys));
asset.setMetaJson(toJson(metaPayload));
asset.setStatus("generating");
asset.setIsActive(1);
shotAssetMapper.insert(asset);
return asset;
}
@Override
public void markVideoAssetSucceeded(Long tenantId, Long taskId, String videoUrl) {
updateVideoAssetStatus(tenantId, taskId, "ready", videoUrl, null);
}
@Override
public void markVideoAssetFailed(Long tenantId, Long taskId, String errorMessage) {
updateVideoAssetStatus(tenantId, taskId, "failed", null, errorMessage);
}
private void updateVideoAssetStatus(Long tenantId, Long taskId, String status, String videoUrl, String errorMessage) {
if (taskId == null) {
return;
}
ShotAsset existing = shotAssetMapper.findBySourceTask(taskId, tenantId);
if (existing == null) {
log.debug("No shot asset found for task {}, skip asset status update", taskId);
return;
}
ShotAsset patch = new ShotAsset();
patch.setId(existing.getId());
patch.setStatus(status);
patch.setExternalUrl(videoUrl);
patch.setErrorMessage(errorMessage);
shotAssetMapper.updateById(patch);
}
private String toJson(Object value) {
if (value == null) {
return null;
}
if (value instanceof List<?> list && list.isEmpty()) {
return null;
}
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
log.warn("Failed to serialize shot asset metadata: {}", e.getMessage());
return null;
}
}
}
......@@ -11,9 +11,12 @@ import com.yaoai.billing.service.BillingService;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import com.yaoai.domain.entity.AiTask;
import com.yaoai.domain.entity.Storyboard;
import com.yaoai.domain.mapper.AiTaskMapper;
import com.yaoai.domain.mapper.StoryboardMapper;
import com.yaoai.pipeline.async.VideoTaskAsyncProcessor;
import com.yaoai.pipeline.dto.StructuredVideoSubmission;
import com.yaoai.pipeline.service.ShotAssetService;
import com.yaoai.pipeline.service.VideoTaskPipelineService;
import com.yaoai.storage.service.TosService;
import lombok.RequiredArgsConstructor;
......@@ -31,8 +34,10 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
private final SeedanceService seedanceService;
private final TosService tosService;
private final AiTaskMapper aiTaskMapper;
private final StoryboardMapper storyboardMapper;
private final BillingService billingService;
private final VideoTaskAsyncProcessor asyncProcessor;
private final ShotAssetService shotAssetService;
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
......@@ -68,6 +73,21 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
task.setPrompt(prompt);
aiTaskMapper.insert(task);
shotAssetService.recordPendingVideoAsset(
tenantId,
projectId,
episodeId,
storyboardId,
task.getId(),
prompt,
seedanceService.getProvider(),
seedanceService.getModelId(),
List.of(imageKey),
null,
null,
null,
java.util.Map.of("legacy", true, "mode", "image_to_video")
);
billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(StpUtil.getLoginIdAsLong())
......@@ -106,6 +126,11 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
}
aiTaskMapper.updateById(task);
if ("succeeded".equals(task.getStatus())) {
shotAssetService.markVideoAssetSucceeded(tenantId, taskId, task.getResultVideoUrl());
} else if ("failed".equals(task.getStatus())) {
shotAssetService.markVideoAssetFailed(tenantId, taskId, task.getErrorMessage());
}
log.info("AiTask polled: id={}, status={}", taskId, task.getStatus());
return task;
}
......@@ -124,6 +149,21 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
task.setStatus("pending");
task.setPrompt(imagePrompt);
aiTaskMapper.insert(task);
shotAssetService.recordPendingVideoAsset(
tenantId,
projectId,
episodeId,
storyboardId,
task.getId(),
videoPrompt != null ? videoPrompt : imagePrompt,
seedanceService.getProvider(),
seedanceService.getModelId(),
preImageKeys != null ? preImageKeys : List.of(),
null,
null,
null,
java.util.Map.of("legacy", true, "durationSeconds", durationOrDefault(durationSeconds))
);
log.info("Pending task created: id={}, type={}", task.getId(), task.getTaskType());
// 2. 后台异步执行(如有参考图则跳过 Seedream)
......@@ -203,6 +243,26 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
task.setVideoRatio(s.getRatio());
task.setGenerateAudio(Boolean.TRUE.equals(s.getGenerateAudio()));
aiTaskMapper.insert(task);
persistStoryboardGenerationSelection(s, characterKeys, finalPrompt);
shotAssetService.recordPendingVideoAsset(
tenantId,
projectId,
s.getEpisodeId(),
s.getStoryboardId(),
task.getId(),
finalPrompt,
seedanceService.getProvider(),
seedanceService.getModelId(),
characterKeys,
s.getSceneImageKey(),
s.getPropImageKeys(),
s.getStyleImageKey(),
java.util.Map.of(
"durationSeconds", durationOrDefault(s.getDurationSeconds()),
"ratio", s.getRatio() != null ? s.getRatio() : "16:9",
"generateAudio", Boolean.TRUE.equals(s.getGenerateAudio())
)
);
log.info("Structured video task created: id={}, characters={}, images={}, duration={}s, ratio={}",
task.getId(), characterKeys.size(), orderedKeys.size(), s.getDurationSeconds(), s.getRatio());
......@@ -216,6 +276,30 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
return task;
}
private void persistStoryboardGenerationSelection(StructuredVideoSubmission s,
List<String> characterKeys,
String finalPrompt) {
if (s.getStoryboardId() == null) {
return;
}
Storyboard patch = new Storyboard();
patch.setId(s.getStoryboardId());
patch.setCharacterImageKeys(toJson(characterKeys));
patch.setSceneImageKey(s.getSceneImageKey());
patch.setPropImageKeys(toJson(s.getPropImageKeys()));
patch.setStyleImageKey(s.getStyleImageKey());
patch.setDurationSeconds(durationOrDefault(s.getDurationSeconds()));
String editablePrompt = s.getFreeformPrompt() != null && !s.getFreeformPrompt().isBlank()
? s.getFreeformPrompt()
: finalPrompt;
patch.setVideoPrompt(editablePrompt);
storyboardMapper.updateById(patch);
}
private Integer durationOrDefault(Integer durationSeconds) {
return durationSeconds != null && durationSeconds > 0 ? durationSeconds : 5;
}
private static String toJson(Object value) {
if (value == null) return null;
if (value instanceof List<?> list && list.isEmpty()) return null;
......
......@@ -23,6 +23,7 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/actuator/**",
"/ping",
"/system/**",
"/local-files",
"/admin-api/**",
"/agent/runs/*/events" // SSE: token 通过 query param 传入,由 controller 手动校验
};
......
......@@ -5,11 +5,13 @@ import com.volcengine.tos.TOSV2ClientBuilder;
import com.volcengine.tos.TOSClientConfiguration;
import com.volcengine.tos.auth.StaticCredentials;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@RequiredArgsConstructor
@ConditionalOnProperty(name = "storage.local.enabled", havingValue = "false", matchIfMissing = true)
public class TosConfig {
private final TosProperties properties;
......
......@@ -26,6 +26,11 @@ public interface TosService {
String publicUrl(String key);
/**
* 规范化已存在的 TOS 公网 URL,确保浏览器按 inline 方式预览对象。
*/
String normalizePublicUrl(String url);
/**
* 读取 TOS 对象内容,以 UTF-8 字符串返回(用于文本文件)
*/
String readText(String key);
......
package com.yaoai.storage.service.impl;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import com.yaoai.storage.service.TosService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@Slf4j
@Service
@Profile("dev-h2")
@ConditionalOnProperty(name = "storage.local.enabled", havingValue = "true")
public class LocalTosServiceImpl implements TosService {
private final Path rootDir;
private final String publicBaseUrl;
public LocalTosServiceImpl(
@Value("${local-storage.base-dir:./.local-data/storage}") String baseDir,
@Value("${local-storage.public-base-url:}") String publicBaseUrl,
@Value("${server.port:8081}") String serverPort) {
this.rootDir = Paths.get(baseDir).toAbsolutePath().normalize();
this.publicBaseUrl = normalizePublicBaseUrl(publicBaseUrl, serverPort);
}
@Override
public String upload(String key, InputStream content, long contentLength, String contentType) {
Path target = resolveKey(key);
try {
Files.createDirectories(target.getParent());
Files.copy(content, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
log.debug("Local storage upload success: key={}, path={}, size={}", key, target, contentLength);
return key;
} catch (IOException e) {
log.error("Local storage upload failed: key={}", key, e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "本地文件写入失败: " + e.getMessage());
}
}
@Override
public void delete(String key) {
try {
Files.deleteIfExists(resolveKey(key));
} catch (IOException e) {
log.warn("Local storage delete failed: key={}, reason={}", key, e.getMessage());
}
}
@Override
public String presignedGetUrl(String key, long expiresSeconds) {
return publicUrl(key);
}
@Override
public String publicUrl(String key) {
return publicBaseUrl + "/local-files?key=" + URLEncoder.encode(key, StandardCharsets.UTF_8);
}
@Override
public String normalizePublicUrl(String url) {
return url;
}
@Override
public String readText(String key) {
try {
return Files.readString(resolveKey(key), StandardCharsets.UTF_8);
} catch (IOException e) {
log.error("Local storage readText failed: key={}", key, e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "读取文件内容失败: " + e.getMessage());
}
}
@Override
public InputStream getInputStream(String key) throws IOException {
return Files.newInputStream(resolveKey(key));
}
private Path resolveKey(String key) {
Path path = rootDir.resolve(key).normalize();
if (!path.startsWith(rootDir)) {
throw new BizException(ErrorCode.INVALID_PARAM, "非法文件路径");
}
return path;
}
private String normalizePublicBaseUrl(String configured, String serverPort) {
String value = configured == null ? "" : configured.trim();
if (value.isBlank()) {
value = "http://127.0.0.1:" + (serverPort == null || serverPort.isBlank() ? "8081" : serverPort.trim());
}
while (value.endsWith("/")) {
value = value.substring(0, value.length() - 1);
}
return value;
}
}
......@@ -9,24 +9,41 @@ import com.yaoai.storage.config.TosProperties;
import com.yaoai.storage.service.TosService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(name = "storage.local.enabled", havingValue = "false", matchIfMissing = true)
public class TosServiceImpl implements TosService {
private final TOSV2 tosClient;
private final TosProperties properties;
private final Set<String> migratedLocalKeys = ConcurrentHashMap.newKeySet();
@Value("${storage.local-migration.enabled:false}")
private boolean localMigrationEnabled;
@Value("${local-storage.base-dir:./.local-data/storage}")
private String localStorageBaseDir;
@Override
public String upload(String key, InputStream content, long contentLength, String contentType) {
ObjectMetaRequestOptions options = new ObjectMetaRequestOptions()
.setContentType(contentType)
.setContentDisposition("inline")
.setAclType(ACLType.ACL_PUBLIC_READ);
PutObjectInput input = new PutObjectInput()
.setBucket(properties.getBucketName())
......@@ -77,6 +94,7 @@ public class TosServiceImpl implements TosService {
@Override
public String presignedGetUrl(String key, long expiresSeconds) {
migrateLocalObjectIfPresent(key);
return tosClient.preSignedURL(PreSignedURLInput.builder()
.httpMethod("GET")
.bucket(properties.getBucketName())
......@@ -87,8 +105,64 @@ public class TosServiceImpl implements TosService {
@Override
public String publicUrl(String key) {
migrateLocalObjectIfPresent(key);
// bucket 须在火山引擎控制台设置为 public-read
return String.format("https://%s.tos-%s.volces.com/%s",
properties.getBucketName(), properties.getRegion(), key);
}
@Override
public String normalizePublicUrl(String url) {
// A simple implementation, can be expanded if needed
return url;
}
private void migrateLocalObjectIfPresent(String key) {
if (!localMigrationEnabled || key == null || key.isBlank()) {
return;
}
Path root = Paths.get(localStorageBaseDir).toAbsolutePath().normalize();
Path source = root.resolve(key).normalize();
if (!source.startsWith(root) || !Files.isRegularFile(source)) {
return;
}
if (!migratedLocalKeys.add(key)) {
return;
}
try {
byte[] bytes = Files.readAllBytes(source);
String contentType = probeContentType(source);
upload(key, new ByteArrayInputStream(bytes), bytes.length, contentType);
log.info("Migrated local storage object to TOS: key={}, size={}", key, bytes.length);
} catch (Exception e) {
migratedLocalKeys.remove(key);
log.warn("Local storage migration to TOS failed: key={}, reason={}", key, e.getMessage());
}
}
private String probeContentType(Path path) {
try {
String detected = Files.probeContentType(path);
if (detected != null && !detected.isBlank()) {
return detected;
}
} catch (IOException ignored) {
// fallback below
}
String name = path.getFileName().toString().toLowerCase();
if (name.endsWith(".png")) {
return "image/png";
}
if (name.endsWith(".webp")) {
return "image/webp";
}
if (name.endsWith(".jpg") || name.endsWith(".jpeg")) {
return "image/jpeg";
}
if (name.endsWith(".mp4")) {
return "video/mp4";
}
return "application/octet-stream";
}
}
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