Commit 1a8f8883 authored by heke's avatar heke

fix: 修复 Agent SSE 连接报错及场景/角色风格继承问题

parent fe614843
File added
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { agentApi, type AgentRunDTO, type AgentStepDTO } from "../lib/api/agent"; import { agentApi, type AgentRunDTO, type AgentStepDTO } from "../lib/api/agent";
import { useAuthStore } from "../stores/authStore";
const runKey = (projectId: string) => ["agent-run", projectId]; const runKey = (projectId: string) => ["agent-run", projectId];
export function useLatestAgentRun(projectId: string) { export function useLatestAgentRun(projectId: string) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({ return useQuery({
queryKey: runKey(projectId), queryKey: runKey(projectId),
queryFn: () => agentApi.getLatestRun(projectId), queryFn: () => agentApi.getLatestRun(projectId),
enabled: !!projectId, enabled: isAuthenticated && !!projectId,
refetchInterval: false, refetchInterval: false,
}); });
} }
...@@ -56,15 +58,18 @@ export function useAgentSse(runId: number | null, projectId: string) { ...@@ -56,15 +58,18 @@ export function useAgentSse(runId: number | null, projectId: string) {
const qc = useQueryClient(); const qc = useQueryClient();
const [logs, setLogs] = useState<SseLog[]>([]); const [logs, setLogs] = useState<SseLog[]>([]);
const logIdRef = useRef(0); const logIdRef = useRef(0);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
useEffect(() => { useEffect(() => {
// 从 localStorage 直接取原始 token(不带 Bearer 前缀)
const token = localStorage.getItem("yaoai_token"); const token = localStorage.getItem("yaoai_token");
if (!runId || !token) return; if (!runId || !token || !isAuthenticated) 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 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) => { const addLog = (level: SseLog["level"], message: string) => {
setLogs((prev) => [ setLogs((prev) => [
...@@ -82,9 +87,9 @@ export function useAgentSse(runId: number | null, projectId: string) { ...@@ -82,9 +87,9 @@ export function useAgentSse(runId: number | null, projectId: string) {
qc.invalidateQueries({ queryKey: runKey(projectId) }); qc.invalidateQueries({ queryKey: runKey(projectId) });
}; };
es.onmessage = (e) => { const handleEventPayload = (payload: string) => {
try { try {
const evt: SseEvent = JSON.parse(e.data); const evt: SseEvent = JSON.parse(payload);
const { type, data } = evt; const { type, data } = evt;
if (type === "log") { if (type === "log") {
...@@ -101,11 +106,19 @@ export function useAgentSse(runId: number | null, projectId: string) { ...@@ -101,11 +106,19 @@ export function useAgentSse(runId: number | null, projectId: string) {
} else if (type === "run.done") { } else if (type === "run.done") {
addLog("agent", "制作完成!"); addLog("agent", "制作完成!");
refreshRun(); refreshRun();
es.close(); shouldStop = true;
if (source) {
source.close();
source = null;
}
} else if (type === "run.failed") { } else if (type === "run.failed") {
addLog("error", `制作失败:${data.error}`); addLog("error", `制作失败:${data.error}`);
refreshRun(); refreshRun();
es.close(); shouldStop = true;
if (source) {
source.close();
source = null;
}
} else if (type === "run.paused") { } else if (type === "run.paused") {
addLog("info", "制作已暂停"); addLog("info", "制作已暂停");
refreshRun(); refreshRun();
...@@ -118,21 +131,79 @@ export function useAgentSse(runId: number | null, projectId: string) { ...@@ -118,21 +131,79 @@ export function useAgentSse(runId: number | null, projectId: string) {
} }
}; };
let errCount = 0; const scheduleReconnect = () => {
es.onerror = () => { if (shouldStop) {
return;
}
if (source) {
source.close();
source = null;
}
errCount++; errCount++;
if (errCount === 1) addLog("warn", "SSE 连接断开,尝试重连..."); if (errCount === 1) {
// 如果连续出错说明鉴权失败或服务不可用,停止重连 addLog("warn", "SSE 连接断开,尝试重连...");
}
if (errCount >= 3) { if (errCount >= 3) {
addLog("error", "SSE 无法连接,请刷新页面重试"); addLog("error", "SSE 无法连接,请刷新页面重试");
es.close(); shouldStop = true;
return;
} }
reconnectTimer = window.setTimeout(() => {
connect();
}, 1000 * errCount);
}; };
return () => es.close(); const bindEvent = (eventType: string, eventSource: EventSource) => {
// token 每次从 localStorage 读取,不需要作为依赖 eventSource.addEventListener(eventType, (event) => {
// eslint-disable-next-line react-hooks/exhaustive-deps const payload = (event as MessageEvent<string>).data;
}, [runId, projectId]); 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",
"run.done",
"run.failed",
"run.paused",
"run.resumed",
]) {
bindEvent(eventType, eventSource);
}
eventSource.onerror = () => {
if (shouldStop) {
return;
}
scheduleReconnect();
};
};
connect();
return () => {
shouldStop = true;
if (reconnectTimer != null) {
window.clearTimeout(reconnectTimer);
}
if (source) {
source.close();
}
};
}, [runId, projectId, qc, isAuthenticated]);
return { logs }; return { logs };
} }
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { aiApi } from "../lib/api/ai"; import { aiApi } from "../lib/api/ai";
import type { Character, Episode, Outline, Scene, Storyboard, StructuredVideoRequest } from "../lib/api/ai"; import type { Character, Episode, Outline, Scene, Storyboard, StructuredVideoRequest } from "../lib/api/ai";
import { useAuthStore } from "../stores/authStore";
// ---- Characters ---- // ---- Characters ----
const charactersKey = (pid: string) => ["characters", pid]; const charactersKey = (pid: string) => ["characters", pid];
const IMAGE_STATUS_POLL_MS = 3000;
function hasGeneratingItems<T extends { status: string }>(items: T[] | undefined) {
return !!items?.some((item) => item.status === "generating");
}
function upsertById<T extends { id: string }>(items: T[] | undefined, next: T): T[] {
if (!items || items.length === 0) {
return [next];
}
let found = false;
const merged = items.map((item) => {
if (item.id !== next.id) {
return item;
}
found = true;
return { ...item, ...next };
});
return found ? merged : [...merged, next];
}
export function useCharacters(projectId: string) { export function useCharacters(projectId: string) {
return useQuery({ queryKey: charactersKey(projectId), queryFn: () => aiApi.listCharacters(projectId), enabled: !!projectId }); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({
queryKey: charactersKey(projectId),
queryFn: () => aiApi.listCharacters(projectId),
enabled: isAuthenticated && !!projectId,
refetchInterval: (query) => hasGeneratingItems(query.state.data) ? IMAGE_STATUS_POLL_MS : false,
});
} }
export function useExtractCharacters(projectId: string) { export function useExtractCharacters(projectId: string) {
const qc = useQueryClient(); const qc = useQueryClient();
...@@ -18,7 +45,28 @@ export function useSaveCharacter(projectId: string) { ...@@ -18,7 +45,28 @@ export function useSaveCharacter(projectId: string) {
} }
export function useGenerateCharacterImage(projectId: string) { export function useGenerateCharacterImage(projectId: string) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ mutationFn: (id: string) => aiApi.generateCharacterImage(projectId, id), onSuccess: () => qc.invalidateQueries({ queryKey: charactersKey(projectId) }) }); return useMutation({
mutationFn: (id: string) => aiApi.generateCharacterImage(projectId, id),
onMutate: async (id: string) => {
await qc.cancelQueries({ queryKey: charactersKey(projectId) });
const previous = qc.getQueryData<Character[]>(charactersKey(projectId));
qc.setQueryData<Character[]>(charactersKey(projectId), (current) =>
(current ?? []).map((item) =>
item.id === id ? { ...item, status: "generating" } : item
)
);
return { previous };
},
onSuccess: (character) => {
qc.setQueryData<Character[]>(charactersKey(projectId), (current) => upsertById(current, character));
},
onError: (_error, _id, context) => {
if (context?.previous) {
qc.setQueryData(charactersKey(projectId), context.previous);
}
},
onSettled: () => qc.invalidateQueries({ queryKey: charactersKey(projectId) }),
});
} }
export function useUploadCharacterImage(projectId: string) { export function useUploadCharacterImage(projectId: string) {
const qc = useQueryClient(); const qc = useQueryClient();
...@@ -37,7 +85,13 @@ export function useDeleteCharacter(projectId: string) { ...@@ -37,7 +85,13 @@ export function useDeleteCharacter(projectId: string) {
const scenesKey = (pid: string) => ["scenes", pid]; const scenesKey = (pid: string) => ["scenes", pid];
export function useScenes(projectId: string) { export function useScenes(projectId: string) {
return useQuery({ queryKey: scenesKey(projectId), queryFn: () => aiApi.listScenes(projectId), enabled: !!projectId }); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({
queryKey: scenesKey(projectId),
queryFn: () => aiApi.listScenes(projectId),
enabled: isAuthenticated && !!projectId,
refetchInterval: (query) => hasGeneratingItems(query.state.data) ? IMAGE_STATUS_POLL_MS : false,
});
} }
export function useExtractScenes(projectId: string) { export function useExtractScenes(projectId: string) {
const qc = useQueryClient(); const qc = useQueryClient();
...@@ -49,7 +103,28 @@ export function useSaveScene(projectId: string) { ...@@ -49,7 +103,28 @@ export function useSaveScene(projectId: string) {
} }
export function useGenerateSceneImage(projectId: string) { export function useGenerateSceneImage(projectId: string) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ mutationFn: (id: string) => aiApi.generateSceneImage(projectId, id), onSuccess: () => qc.invalidateQueries({ queryKey: scenesKey(projectId) }) }); return useMutation({
mutationFn: (id: string) => aiApi.generateSceneImage(projectId, id),
onMutate: async (id: string) => {
await qc.cancelQueries({ queryKey: scenesKey(projectId) });
const previous = qc.getQueryData<Scene[]>(scenesKey(projectId));
qc.setQueryData<Scene[]>(scenesKey(projectId), (current) =>
(current ?? []).map((item) =>
item.id === id ? { ...item, status: "generating" } : item
)
);
return { previous };
},
onSuccess: (scene) => {
qc.setQueryData<Scene[]>(scenesKey(projectId), (current) => upsertById(current, scene));
},
onError: (_error, _id, context) => {
if (context?.previous) {
qc.setQueryData(scenesKey(projectId), context.previous);
}
},
onSettled: () => qc.invalidateQueries({ queryKey: scenesKey(projectId) }),
});
} }
export function useUploadSceneImage(projectId: string) { export function useUploadSceneImage(projectId: string) {
const qc = useQueryClient(); const qc = useQueryClient();
...@@ -68,10 +143,11 @@ const assemblyKey = (pid: string, eid: string) => ["assembly", pid, eid]; ...@@ -68,10 +143,11 @@ const assemblyKey = (pid: string, eid: string) => ["assembly", pid, eid];
// ---- Outline ---- // ---- Outline ----
export function useOutline(projectId: string) { export function useOutline(projectId: string) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({ return useQuery({
queryKey: outlineKey(projectId), queryKey: outlineKey(projectId),
queryFn: () => aiApi.getOutline(projectId), queryFn: () => aiApi.getOutline(projectId),
enabled: !!projectId, enabled: isAuthenticated && !!projectId,
retry: false, retry: false,
}); });
} }
...@@ -94,10 +170,11 @@ export function useUpdateOutline(projectId: string) { ...@@ -94,10 +170,11 @@ export function useUpdateOutline(projectId: string) {
// ---- Episodes ---- // ---- Episodes ----
export function useEpisodes(projectId: string) { export function useEpisodes(projectId: string) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({ return useQuery({
queryKey: episodesKey(projectId), queryKey: episodesKey(projectId),
queryFn: () => aiApi.listEpisodes(projectId), queryFn: () => aiApi.listEpisodes(projectId),
enabled: !!projectId, enabled: isAuthenticated && !!projectId,
staleTime: 0, staleTime: 0,
}); });
} }
...@@ -129,10 +206,11 @@ export function useUpdateEpisode(projectId: string) { ...@@ -129,10 +206,11 @@ export function useUpdateEpisode(projectId: string) {
// ---- Storyboards ---- // ---- Storyboards ----
export function useStoryboards(projectId: string, episodeId: string) { export function useStoryboards(projectId: string, episodeId: string) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({ return useQuery({
queryKey: storyboardsKey(projectId, episodeId), queryKey: storyboardsKey(projectId, episodeId),
queryFn: () => aiApi.listStoryboards(projectId, episodeId), queryFn: () => aiApi.listStoryboards(projectId, episodeId),
enabled: !!projectId && !!episodeId, enabled: isAuthenticated && !!projectId && !!episodeId,
}); });
} }
...@@ -186,10 +264,11 @@ export function useGenerateStoryboardPrompt(projectId: string) { ...@@ -186,10 +264,11 @@ export function useGenerateStoryboardPrompt(projectId: string) {
// ---- Video Tasks ---- // ---- Video Tasks ----
export function useVideoTasks(projectId: string) { export function useVideoTasks(projectId: string) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({ return useQuery({
queryKey: videoTasksKey(projectId), queryKey: videoTasksKey(projectId),
queryFn: () => aiApi.listVideoTasks(projectId), queryFn: () => aiApi.listVideoTasks(projectId),
enabled: !!projectId, enabled: isAuthenticated && !!projectId,
staleTime: 0, staleTime: 0,
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
refetchInterval: (query) => { refetchInterval: (query) => {
...@@ -228,10 +307,11 @@ export function useGenerateStructuredVideo(projectId: string) { ...@@ -228,10 +307,11 @@ export function useGenerateStructuredVideo(projectId: string) {
// ---- Assembly ---- // ---- Assembly ----
export function useAssemblyTask(projectId: string, episodeId: string) { export function useAssemblyTask(projectId: string, episodeId: string) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({ return useQuery({
queryKey: assemblyKey(projectId, episodeId), queryKey: assemblyKey(projectId, episodeId),
queryFn: () => aiApi.getAssemblyTask(projectId, episodeId), queryFn: () => aiApi.getAssemblyTask(projectId, episodeId),
enabled: !!projectId && !!episodeId, enabled: isAuthenticated && !!projectId && !!episodeId,
refetchInterval: (query) => { refetchInterval: (query) => {
const data = query.state.data; const data = query.state.data;
if (!data) return false; if (!data) return false;
...@@ -258,10 +338,11 @@ export function useDeleteVideoTask(projectId: string) { ...@@ -258,10 +338,11 @@ export function useDeleteVideoTask(projectId: string) {
} }
export function usePollVideoTask(projectId: string, taskId: string, enabled: boolean) { export function usePollVideoTask(projectId: string, taskId: string, enabled: boolean) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({ return useQuery({
queryKey: ["video-task", projectId, taskId], queryKey: ["video-task", projectId, taskId],
queryFn: () => aiApi.pollVideoTask(projectId, taskId), queryFn: () => aiApi.pollVideoTask(projectId, taskId),
enabled: enabled && !!taskId, enabled: isAuthenticated && enabled && !!taskId,
refetchInterval: (query) => { refetchInterval: (query) => {
const status = query.state.data?.status; const status = query.state.data?.status;
return status === "succeeded" || status === "failed" ? false : 5000; return status === "succeeded" || status === "failed" ? false : 5000;
......
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { projectsApi, ProjectCreatePayload, ProjectUpdatePayload } from "../lib/api/projects"; import { projectsApi, ProjectCreatePayload, ProjectUpdatePayload } from "../lib/api/projects";
import { useAuthStore } from "../stores/authStore";
const PROJECTS_KEY = ["projects"]; const PROJECTS_KEY = ["projects"];
const projectKey = (id: string) => ["projects", id]; const projectKey = (id: string) => ["projects", id];
export function useProjects() { export function useProjects() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({ return useQuery({
queryKey: PROJECTS_KEY, queryKey: PROJECTS_KEY,
queryFn: projectsApi.list, queryFn: projectsApi.list,
enabled: isAuthenticated,
}); });
} }
export function useProject(id: string) { export function useProject(id: string) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({ return useQuery({
queryKey: projectKey(id), queryKey: projectKey(id),
queryFn: () => projectsApi.get(id), queryFn: () => projectsApi.get(id),
enabled: !!id, enabled: isAuthenticated && !!id,
}); });
} }
......
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { teamApi, type CreateMemberPayload } from "../lib/api/team"; import { teamApi, type CreateMemberPayload } from "../lib/api/team";
import { useAuthStore } from "../stores/authStore";
const membersKey = () => ["team-members"]; const membersKey = () => ["team-members"];
const groupsKey = () => ["team-groups"]; const groupsKey = () => ["team-groups"];
export function useTeamMembers() { export function useTeamMembers() {
return useQuery({ queryKey: membersKey(), queryFn: teamApi.listMembers, staleTime: 30_000 }); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({ queryKey: membersKey(), queryFn: teamApi.listMembers, staleTime: 30_000, enabled: isAuthenticated });
} }
export function useAddMember() { export function useAddMember() {
...@@ -34,7 +36,8 @@ export function useRemoveMember() { ...@@ -34,7 +36,8 @@ export function useRemoveMember() {
} }
export function useTeamGroups() { export function useTeamGroups() {
return useQuery({ queryKey: groupsKey(), queryFn: teamApi.listGroups, staleTime: 30_000 }); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({ queryKey: groupsKey(), queryFn: teamApi.listGroups, staleTime: 30_000, enabled: isAuthenticated });
} }
export function useCreateGroup() { export function useCreateGroup() {
......
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { usageApi } from "../lib/api/usage"; import { usageApi } from "../lib/api/usage";
import { useAuthStore } from "../stores/authStore";
const balanceKey = () => ["usage-balance"]; const balanceKey = () => ["usage-balance"];
const recordsKey = (limit: number, offset: number) => ["usage-records", limit, offset]; const recordsKey = (limit: number, offset: number) => ["usage-records", limit, offset];
const costsKey = () => ["usage-costs"]; const costsKey = () => ["usage-costs"];
export function useBalance() { export function useBalance() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({ return useQuery({
queryKey: balanceKey(), queryKey: balanceKey(),
queryFn: () => usageApi.getBalance(), queryFn: () => usageApi.getBalance(),
staleTime: 30_000, staleTime: 30_000,
enabled: isAuthenticated,
}); });
} }
export function useRecords(limit = 20, offset = 0) { export function useRecords(limit = 20, offset = 0) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({ return useQuery({
queryKey: recordsKey(limit, offset), queryKey: recordsKey(limit, offset),
queryFn: () => usageApi.getRecords(limit, offset), queryFn: () => usageApi.getRecords(limit, offset),
staleTime: 30_000, staleTime: 30_000,
enabled: isAuthenticated,
}); });
} }
export function useCosts() { export function useCosts() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({ return useQuery({
queryKey: costsKey(), queryKey: costsKey(),
queryFn: () => usageApi.getCosts(), queryFn: () => usageApi.getCosts(),
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
enabled: isAuthenticated,
}); });
} }
......
This source diff could not be displayed because it is too large. You can view the blob instead.
File added
File added
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")
...@@ -7,10 +7,12 @@ DB_URL=jdbc:mysql://localhost:13306/yaoai_comic?useSSL=false&allowPublicKeyRetri ...@@ -7,10 +7,12 @@ DB_URL=jdbc:mysql://localhost:13306/yaoai_comic?useSSL=false&allowPublicKeyRetri
DB_USERNAME=yaoai DB_USERNAME=yaoai
DB_PASSWORD=yaoai123 DB_PASSWORD=yaoai123
# ===== Volcano Engine ARK (LLM / Video Generation) ===== # ===== Volcano Engine ARK =====
VOLCENGINE_ARK_API_KEY=your-ark-api-key VOLCENGINE_ARK_API_KEY=your-ark-api-key
VOLCENGINE_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 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) ===== # ===== Volcano Engine TOS (Object Storage) =====
VOLCENGINE_TOS_ACCESS_KEY=your-access-key VOLCENGINE_TOS_ACCESS_KEY=your-access-key
......
...@@ -9,11 +9,15 @@ import com.yaoai.common.exception.ErrorCode; ...@@ -9,11 +9,15 @@ import com.yaoai.common.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
@Slf4j @Slf4j
@Service @Service
public class ArkLlmService implements LlmService { 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 ArkProperties properties;
private final RestClient restClient; private final RestClient restClient;
...@@ -21,13 +25,13 @@ public class ArkLlmService implements LlmService { ...@@ -21,13 +25,13 @@ public class ArkLlmService implements LlmService {
this.properties = properties; this.properties = properties;
this.restClient = RestClient.builder() this.restClient = RestClient.builder()
.baseUrl(properties.getBaseUrl()) .baseUrl(properties.getBaseUrl())
.defaultHeader("Authorization", "Bearer " + properties.getApiKey())
.defaultHeader("Content-Type", "application/json") .defaultHeader("Content-Type", "application/json")
.build(); .build();
} }
@Override @Override
public String chat(ChatRequest request) { public String chat(ChatRequest request) {
String apiKey = resolveApiKey();
// 如果未指定 model,使用默认文本模型 // 如果未指定 model,使用默认文本模型
if (request.getModel() == null) { if (request.getModel() == null) {
request.setModel(properties.getTextModel()); request.setModel(properties.getTextModel());
...@@ -37,6 +41,7 @@ public class ArkLlmService implements LlmService { ...@@ -37,6 +41,7 @@ public class ArkLlmService implements LlmService {
try { try {
ChatResponse response = restClient.post() ChatResponse response = restClient.post()
.uri("/chat/completions") .uri("/chat/completions")
.header("Authorization", "Bearer " + apiKey)
.body(request) .body(request)
.retrieve() .retrieve()
.body(ChatResponse.class); .body(ChatResponse.class);
...@@ -47,6 +52,14 @@ public class ArkLlmService implements LlmService { ...@@ -47,6 +52,14 @@ public class ArkLlmService implements LlmService {
String content = response.firstContent(); String content = response.firstContent();
log.debug("ARK LLM response: {} chars", content.length()); log.debug("ARK LLM response: {} chars", content.length());
return content; return content;
} catch (RestClientResponseException e) {
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) { } catch (BizException e) {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
...@@ -69,4 +82,16 @@ public class ArkLlmService implements LlmService { ...@@ -69,4 +82,16 @@ public class ArkLlmService implements LlmService {
public String getProvider() { public String getProvider() {
return "volcengine"; 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;
}
} }
...@@ -12,6 +12,7 @@ import lombok.extern.slf4j.Slf4j; ...@@ -12,6 +12,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
...@@ -23,6 +24,9 @@ import java.util.Map; ...@@ -23,6 +24,9 @@ import java.util.Map;
@Service @Service
public class SeedanceServiceImpl implements SeedanceService { 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 ArkProperties properties;
private final RestClient restClient; private final RestClient restClient;
...@@ -33,7 +37,6 @@ public class SeedanceServiceImpl implements SeedanceService { ...@@ -33,7 +37,6 @@ public class SeedanceServiceImpl implements SeedanceService {
rf.setReadTimeout(Duration.ofMinutes(2)); rf.setReadTimeout(Duration.ofMinutes(2));
this.restClient = RestClient.builder() this.restClient = RestClient.builder()
.baseUrl(properties.getBaseUrl()) .baseUrl(properties.getBaseUrl())
.defaultHeader("Authorization", "Bearer " + properties.getApiKey())
.defaultHeader("Content-Type", "application/json") .defaultHeader("Content-Type", "application/json")
.requestFactory(rf) .requestFactory(rf)
.build(); .build();
...@@ -44,6 +47,7 @@ public class SeedanceServiceImpl implements SeedanceService { ...@@ -44,6 +47,7 @@ public class SeedanceServiceImpl implements SeedanceService {
@Override @Override
public String submitVideoTask(List<String> imageUrls, String prompt, public String submitVideoTask(List<String> imageUrls, String prompt,
int durationSeconds, boolean generateAudio, String ratio, String model) { int durationSeconds, boolean generateAudio, String ratio, String model) {
String apiKey = resolveApiKey();
if (imageUrls == null || imageUrls.isEmpty()) { if (imageUrls == null || imageUrls.isEmpty()) {
throw new BizException(ErrorCode.INVALID_PARAM, "至少需要一张参考图"); throw new BizException(ErrorCode.INVALID_PARAM, "至少需要一张参考图");
} }
...@@ -89,6 +93,7 @@ public class SeedanceServiceImpl implements SeedanceService { ...@@ -89,6 +93,7 @@ public class SeedanceServiceImpl implements SeedanceService {
try { try {
TaskSubmitResponse resp = restClient.post() TaskSubmitResponse resp = restClient.post()
.uri("/contents/generations/tasks") .uri("/contents/generations/tasks")
.header("Authorization", "Bearer " + apiKey)
.body(body) .body(body)
.retrieve() .retrieve()
.body(TaskSubmitResponse.class); .body(TaskSubmitResponse.class);
...@@ -98,6 +103,14 @@ public class SeedanceServiceImpl implements SeedanceService { ...@@ -98,6 +103,14 @@ public class SeedanceServiceImpl implements SeedanceService {
} }
log.info("Seedance task submitted: id={}", resp.getId()); log.info("Seedance task submitted: id={}", resp.getId());
return resp.getId(); return resp.getId();
} catch (RestClientResponseException e) {
if (e.getStatusCode().value() == 401) {
throw new BizException(ErrorCode.INTERNAL_ERROR,
"视频服务鉴权失败,请检查 VOLCENGINE_ARK_API_KEY 是否正确且仍有效");
}
log.error("Seedance submit failed: status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString(), e);
throw new BizException(ErrorCode.INTERNAL_ERROR,
"视频生成任务提交失败: HTTP " + e.getStatusCode().value());
} catch (BizException e) { } catch (BizException e) {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
...@@ -108,10 +121,12 @@ public class SeedanceServiceImpl implements SeedanceService { ...@@ -108,10 +121,12 @@ public class SeedanceServiceImpl implements SeedanceService {
@Override @Override
public VideoTaskResult getTaskStatus(String externalTaskId) { public VideoTaskResult getTaskStatus(String externalTaskId) {
String apiKey = resolveApiKey();
try { try {
// Use JsonNode to handle flexible API response structure // Use JsonNode to handle flexible API response structure
JsonNode resp = restClient.get() JsonNode resp = restClient.get()
.uri("/contents/generations/tasks/{id}", externalTaskId) .uri("/contents/generations/tasks/{id}", externalTaskId)
.header("Authorization", "Bearer " + apiKey)
.retrieve() .retrieve()
.body(JsonNode.class); .body(JsonNode.class);
...@@ -138,6 +153,15 @@ public class SeedanceServiceImpl implements SeedanceService { ...@@ -138,6 +153,15 @@ public class SeedanceServiceImpl implements SeedanceService {
} }
} }
return result; 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) { } catch (BizException e) {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
...@@ -156,6 +180,18 @@ public class SeedanceServiceImpl implements SeedanceService { ...@@ -156,6 +180,18 @@ public class SeedanceServiceImpl implements SeedanceService {
return "Seedance 图生视频"; 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 ---- // ---- helpers ----
/** /**
......
...@@ -9,6 +9,7 @@ import lombok.extern.slf4j.Slf4j; ...@@ -9,6 +9,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
import java.time.Duration; import java.time.Duration;
import java.util.List; import java.util.List;
...@@ -18,6 +19,9 @@ import java.util.Map; ...@@ -18,6 +19,9 @@ import java.util.Map;
@Service @Service
public class SeedreamServiceImpl implements SeedreamService { 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 final ArkProperties properties; private final ArkProperties properties;
private final RestClient restClient; private final RestClient restClient;
...@@ -28,7 +32,6 @@ public class SeedreamServiceImpl implements SeedreamService { ...@@ -28,7 +32,6 @@ public class SeedreamServiceImpl implements SeedreamService {
rf.setReadTimeout(Duration.ofMinutes(2)); rf.setReadTimeout(Duration.ofMinutes(2));
this.restClient = RestClient.builder() this.restClient = RestClient.builder()
.baseUrl(properties.getBaseUrl()) .baseUrl(properties.getBaseUrl())
.defaultHeader("Authorization", "Bearer " + properties.getApiKey())
.defaultHeader("Content-Type", "application/json") .defaultHeader("Content-Type", "application/json")
.requestFactory(rf) .requestFactory(rf)
.build(); .build();
...@@ -41,6 +44,7 @@ public class SeedreamServiceImpl implements SeedreamService { ...@@ -41,6 +44,7 @@ public class SeedreamServiceImpl implements SeedreamService {
@Override @Override
public String generateImage(String prompt, String size) { public String generateImage(String prompt, String size) {
String apiKey = resolveApiKey();
Map<String, Object> body = Map.of( Map<String, Object> body = Map.of(
"model", properties.getImageModel(), "model", properties.getImageModel(),
"prompt", prompt, "prompt", prompt,
...@@ -53,6 +57,7 @@ public class SeedreamServiceImpl implements SeedreamService { ...@@ -53,6 +57,7 @@ public class SeedreamServiceImpl implements SeedreamService {
try { try {
ImageResponse resp = restClient.post() ImageResponse resp = restClient.post()
.uri("/images/generations") .uri("/images/generations")
.header("Authorization", "Bearer " + apiKey)
.body(body) .body(body)
.retrieve() .retrieve()
.body(ImageResponse.class); .body(ImageResponse.class);
...@@ -63,6 +68,14 @@ public class SeedreamServiceImpl implements SeedreamService { ...@@ -63,6 +68,14 @@ public class SeedreamServiceImpl implements SeedreamService {
String url = resp.getData().get(0).getUrl(); String url = resp.getData().get(0).getUrl();
log.info("Seedream image generated: url={}", url); log.info("Seedream image generated: url={}", url);
return 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);
throw new BizException(ErrorCode.INTERNAL_ERROR,
"图片生成失败: HTTP " + e.getStatusCode().value());
} catch (BizException e) { } catch (BizException e) {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
...@@ -81,6 +94,18 @@ public class SeedreamServiceImpl implements SeedreamService { ...@@ -81,6 +94,18 @@ public class SeedreamServiceImpl implements SeedreamService {
return "Seedream 文生图"; 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;
}
// ---- internal response models ---- // ---- internal response models ----
@Data @Data
......
...@@ -64,18 +64,25 @@ public class AgentController { ...@@ -64,18 +64,25 @@ public class AgentController {
return ApiResponse.success(AgentRunDTO.from(run, steps)); 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) @GetMapping(value = "/runs/{runId}/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(@PathVariable Long runId, public SseEmitter subscribe(@PathVariable Long runId,
@RequestHeader(value = "Authorization", required = false) String authHeader,
@RequestParam(required = false) String Authorization, @RequestParam(required = false) String Authorization,
HttpServletResponse response) throws IOException { HttpServletResponse response) throws IOException {
// SSE 不能发 custom header,token 通过 query param 传入,此处手动校验 String token = authHeader;
if (Authorization == null || Authorization.isBlank()) { 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"); response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing token");
return null; return null;
} }
try { try {
Object loginId = StpUtil.getLoginIdByToken(Authorization); Object loginId = StpUtil.getLoginIdByToken(token);
if (loginId == null) throw new RuntimeException("invalid token"); if (loginId == null) throw new RuntimeException("invalid token");
Object tenantId = StpUtil.getSessionByLoginId(loginId).get("tenantId"); Object tenantId = StpUtil.getSessionByLoginId(loginId).get("tenantId");
if (tenantId != null) { if (tenantId != null) {
......
...@@ -104,7 +104,7 @@ volcengine: ...@@ -104,7 +104,7 @@ volcengine:
ark: ark:
api-key: ${VOLCENGINE_ARK_API_KEY:} api-key: ${VOLCENGINE_ARK_API_KEY:}
base-url: ${VOLCENGINE_ARK_BASE_URL:https://ark.cn-beijing.volces.com/api/v3} 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} image-model: ${VOLCENGINE_ARK_IMAGE_MODEL:doubao-seedream-5-0-260128}
video-model: ${VOLCENGINE_ARK_VIDEO_MODEL:doubao-seedance-2-0-fast-260128} video-model: ${VOLCENGINE_ARK_VIDEO_MODEL:doubao-seedance-2-0-fast-260128}
......
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();
}
}
...@@ -63,14 +63,14 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -63,14 +63,14 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
"""; """;
private static final String SCENE_SYSTEM = """ private static final String SCENE_SYSTEM = """
你是影视短剧场景设定专家。请根据给定的大纲和分集信息,提取主要场景并输出 JSON 数组。 你是影视短剧场景视觉设定专家。你只负责构建场景环境,绝不生成或描述任何人物。场景即叙事主体——氛围、光影、质感、空间张力必须自身传递情绪与悬念。镜头必须以全景(establishing shot)为主,清晰呈现场景全貌:空间结构、元素布局、方位关系、环境细节。禁止局部表达(close-up/detail shot/极端裁切),确保同一场景内画面一致性。请根据给定的大纲和分集信息,提取最具视觉冲击力的主要场景,输出 JSON 数组。
返回格式如下: 返回格式如下:
[ [
{ {
"name": "场景名称", "name": "场景名称",
"scene_type": "indoor or outdoor", "scene_type": "indoor or outdoor",
"description": "场景描述,30字以内", "description": "场景描述,40字以内,突出氛围与情绪",
"image_prompt": "英文场景图 prompt,电影感环境描述" "image_prompt": "英文场景图 prompt,电影级环境描述:指定色调(cool grey/blood red/neon green)、光影类型(harsh shadow/volumetric fog/backlit)、材质质感(cracked mirror/frosted glass/wet concrete)、镜头语言(wide establishing shot/bird's eye view/low angle wide等,仅远景全景)、氛围关键词(oppressive/eerie/suffocating),清晰描述空间布局与元素方位关系,严禁出现person/human/figure/face/silhouette/character等人物词汇,严禁close-up/detail shot/局部特写"
} }
] ]
只返回 3 到 6 个主要场景,只返回 JSON。 只返回 3 到 6 个主要场景,只返回 JSON。
...@@ -154,7 +154,9 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -154,7 +154,9 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
@Override @Override
public List<Scene> extractScenes(Long projectId, Long tenantId) { public List<Scene> extractScenes(Long projectId, Long tenantId) {
String context = buildProjectContext(projectId, tenantId); Project project = loadProject(projectId, tenantId);
ProjectVisualStyle visualStyle = resolveProjectVisualStyle(project);
String context = buildSceneExtractionContext(projectId, tenantId, visualStyle);
log.info("Extracting scenes: projectId={}", projectId); log.info("Extracting scenes: projectId={}", projectId);
billingService.checkBalance(BillingChargeRequest.builder() billingService.checkBalance(BillingChargeRequest.builder()
...@@ -214,7 +216,7 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -214,7 +216,7 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
Project project = loadProject(character.getProjectId(), tenantId); Project project = loadProject(character.getProjectId(), tenantId);
ProjectVisualStyle visualStyle = resolveProjectVisualStyle(project); ProjectVisualStyle visualStyle = resolveProjectVisualStyle(project);
if (character.getImagePrompt() == null || character.getImagePrompt().isBlank()) { if (character.getImagePrompt() == null || character.getImagePrompt().isBlank()) {
character.setImagePrompt(buildCharacterImagePromptFallback(character)); character.setImagePrompt(buildCharacterImagePromptFallback(character, visualStyle));
} }
if (character.getImagePrompt() == null || character.getImagePrompt().isBlank()) { if (character.getImagePrompt() == null || character.getImagePrompt().isBlank()) {
throw new BizException(ErrorCode.INVALID_PARAM, "角色缺少图片 Prompt,且无法从角色资料推导"); throw new BizException(ErrorCode.INVALID_PARAM, "角色缺少图片 Prompt,且无法从角色资料推导");
...@@ -297,8 +299,10 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -297,8 +299,10 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
if (scene == null || !tenantId.equals(scene.getTenantId())) { if (scene == null || !tenantId.equals(scene.getTenantId())) {
throw new BizException(ErrorCode.NOT_FOUND, "场景不存在"); throw new BizException(ErrorCode.NOT_FOUND, "场景不存在");
} }
Project project = loadProject(scene.getProjectId(), tenantId);
ProjectVisualStyle visualStyle = resolveProjectVisualStyle(project);
if (scene.getImagePrompt() == null || scene.getImagePrompt().isBlank()) { if (scene.getImagePrompt() == null || scene.getImagePrompt().isBlank()) {
scene.setImagePrompt(buildSceneImagePromptFallback(scene)); scene.setImagePrompt(buildSceneImagePromptFallback(scene, visualStyle));
} }
if (scene.getImagePrompt() == null || scene.getImagePrompt().isBlank()) { if (scene.getImagePrompt() == null || scene.getImagePrompt().isBlank()) {
throw new BizException(ErrorCode.INVALID_PARAM, "场景缺少图片 Prompt"); throw new BizException(ErrorCode.INVALID_PARAM, "场景缺少图片 Prompt");
...@@ -321,7 +325,8 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -321,7 +325,8 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
sceneMapper.updateById(scene); sceneMapper.updateById(scene);
try { try {
String imageUrl = seedreamService.generateImage(scene.getImagePrompt()); String prompt = buildSceneRenderPrompt(scene.getImagePrompt(), visualStyle);
String imageUrl = seedreamService.generateImage(prompt);
byte[] bytes = downloadBytes(imageUrl); byte[] bytes = downloadBytes(imageUrl);
String key = TosService.buildKey(scene.getTenantId(), scene.getProjectId(), "scenes", scene.getName() + ".jpg"); String key = TosService.buildKey(scene.getTenantId(), scene.getProjectId(), "scenes", scene.getName() + ".jpg");
tosService.upload(key, new ByteArrayInputStream(bytes), bytes.length, "image/jpeg"); tosService.upload(key, new ByteArrayInputStream(bytes), bytes.length, "image/jpeg");
...@@ -366,8 +371,16 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -366,8 +371,16 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
@Override @Override
public Character saveCharacter(Character character) { public Character saveCharacter(Character character) {
Project project = loadProject(character.getProjectId(), character.getTenantId());
ProjectVisualStyle visualStyle = resolveProjectVisualStyle(project);
if (character.getId() != null) {
Character existing = characterMapper.selectById(character.getId());
if (existing != null && shouldRefreshCharacterImagePrompt(character, existing)) {
character.setImagePrompt(buildCharacterImagePromptFallback(character, visualStyle));
}
}
if (character.getImagePrompt() == null || character.getImagePrompt().isBlank()) { if (character.getImagePrompt() == null || character.getImagePrompt().isBlank()) {
character.setImagePrompt(buildCharacterImagePromptFallback(character)); character.setImagePrompt(buildCharacterImagePromptFallback(character, visualStyle));
} }
if (character.getId() == null) { if (character.getId() == null) {
characterMapper.insert(character); characterMapper.insert(character);
...@@ -377,7 +390,7 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -377,7 +390,7 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
return character; return character;
} }
private String buildCharacterImagePromptFallback(Character character) { private String buildCharacterImagePromptFallback(Character character, ProjectVisualStyle visualStyle) {
if (character == null) { if (character == null) {
return ""; return "";
} }
...@@ -395,12 +408,40 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -395,12 +408,40 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
appendIfPresent(parts, character.getPersonality()); appendIfPresent(parts, character.getPersonality());
appendIfPresent(parts, character.getCostume()); appendIfPresent(parts, character.getCostume());
appendIfPresent(parts, character.getVisualHint()); appendIfPresent(parts, character.getVisualHint());
if (visualStyle != null) {
parts.add("style: " + visualStyle.label());
parts.add(buildCharacterRenderStyleSummary(visualStyle));
}
if (parts.isEmpty()) { if (parts.isEmpty()) {
return ""; return "";
} }
return String.join(", ", parts); return String.join(", ", parts);
} }
private boolean shouldRefreshCharacterImagePrompt(Character incoming, Character existing) {
if (incoming == null || existing == null) {
return false;
}
String incomingPrompt = normalizeCharacterField(incoming.getImagePrompt());
String existingPrompt = normalizeCharacterField(existing.getImagePrompt());
if (incomingPrompt.isBlank()) {
return true;
}
if (!incomingPrompt.equals(existingPrompt)) {
return false;
}
return !normalizeCharacterField(incoming.getName()).equals(normalizeCharacterField(existing.getName()))
|| !normalizeCharacterField(incoming.getGender()).equals(normalizeCharacterField(existing.getGender()))
|| !normalizeCharacterField(incoming.getAge()).equals(normalizeCharacterField(existing.getAge()))
|| !normalizeCharacterField(incoming.getPersonality()).equals(normalizeCharacterField(existing.getPersonality()))
|| !normalizeCharacterField(incoming.getCostume()).equals(normalizeCharacterField(existing.getCostume()))
|| !normalizeCharacterField(incoming.getVisualHint()).equals(normalizeCharacterField(existing.getVisualHint()));
}
private String normalizeCharacterField(String value) {
return value == null ? "" : value.trim();
}
private void appendIfPresent(List<String> parts, String value) { private void appendIfPresent(List<String> parts, String value) {
if (value != null && !value.isBlank()) { if (value != null && !value.isBlank()) {
parts.add(value.trim()); parts.add(value.trim());
...@@ -409,14 +450,16 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -409,14 +450,16 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
@Override @Override
public Scene saveScene(Scene scene) { public Scene saveScene(Scene scene) {
Project project = loadProject(scene.getProjectId(), scene.getTenantId());
ProjectVisualStyle visualStyle = resolveProjectVisualStyle(project);
if (scene.getId() != null) { if (scene.getId() != null) {
Scene existing = sceneMapper.selectById(scene.getId()); Scene existing = sceneMapper.selectById(scene.getId());
if (existing != null && shouldRefreshSceneImagePrompt(scene, existing)) { if (existing != null && shouldRefreshSceneImagePrompt(scene, existing)) {
scene.setImagePrompt(buildSceneImagePromptFallback(scene)); scene.setImagePrompt(buildSceneImagePromptFallback(scene, visualStyle));
} }
} }
if (scene.getImagePrompt() == null || scene.getImagePrompt().isBlank()) { if (scene.getImagePrompt() == null || scene.getImagePrompt().isBlank()) {
scene.setImagePrompt(buildSceneImagePromptFallback(scene)); scene.setImagePrompt(buildSceneImagePromptFallback(scene, visualStyle));
} }
if (scene.getId() == null) { if (scene.getId() == null) {
sceneMapper.insert(scene); sceneMapper.insert(scene);
...@@ -426,27 +469,58 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -426,27 +469,58 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
return scene; return scene;
} }
private String buildSceneImagePromptFallback(Scene scene) { private String buildSceneExtractionContext(Long projectId, Long tenantId, ProjectVisualStyle visualStyle) {
StringBuilder builder = new StringBuilder(buildProjectContext(projectId, tenantId));
if (builder.length() > 0) {
builder.append("\n\n");
}
builder.append("Scene visual style requirements:\n");
builder.append("- Selected project style: ").append(visualStyle.label()).append('\n');
builder.append("- Style direction: ").append(buildSceneExtractionStyleDirection(visualStyle)).append('\n');
builder.append("- The image_prompt must keep all scenes in one consistent project-wide visual language.\n");
builder.append("- Output target: ").append(buildSceneExtractionTargetRule(visualStyle)).append('\n');
builder.append("- Avoid: ").append(buildSceneExtractionAvoidRule(visualStyle)).append('\n');
builder.append("- Additional style notes: ").append(buildSceneRenderStyleSummary(visualStyle)).append('\n');
return builder.toString();
}
private String buildSceneImagePromptFallback(Scene scene, ProjectVisualStyle visualStyle) {
if (scene == null) { if (scene == null) {
return ""; return "";
} }
StringBuilder prompt = new StringBuilder(); List<String> parts = new ArrayList<>();
if (scene.getName() != null && !scene.getName().isBlank()) { if (scene.getName() != null && !scene.getName().isBlank()) {
prompt.append(scene.getName().trim()); parts.add(scene.getName().trim());
} }
if (scene.getDescription() != null && !scene.getDescription().isBlank()) { if (scene.getDescription() != null && !scene.getDescription().isBlank()) {
if (prompt.length() > 0) { parts.add(scene.getDescription().trim());
prompt.append(", ");
}
prompt.append(scene.getDescription().trim());
} }
if (scene.getSceneType() != null && !scene.getSceneType().isBlank()) { if (scene.getSceneType() != null && !scene.getSceneType().isBlank()) {
if (prompt.length() > 0) { parts.add("indoor".equalsIgnoreCase(scene.getSceneType()) ? "indoor scene" : "outdoor scene");
prompt.append(", ");
}
prompt.append("indoor".equalsIgnoreCase(scene.getSceneType()) ? "indoor scene" : "outdoor scene");
} }
return prompt.toString(); if (visualStyle != null) {
parts.add("style: " + visualStyle.label());
parts.add(buildSceneRenderStyleSummary(visualStyle));
}
return String.join(", ", parts);
}
private String buildSceneRenderPrompt(String basePrompt, ProjectVisualStyle visualStyle) {
List<String> promptParts = new ArrayList<>();
String normalizedPrompt = basePrompt == null ? "" : basePrompt.trim();
if (!normalizedPrompt.isBlank()) {
promptParts.add(normalizedPrompt);
}
promptParts.add("match the selected project visual style consistently across all scene images");
promptParts.add("selected style: " + visualStyle.label());
promptParts.add(buildSceneRenderStyleSummary(visualStyle));
promptParts.addAll(buildSceneStylePromptParts(visualStyle));
promptParts.add("environment concept art focused on scene, architecture, props, atmosphere, lighting, and spatial storytelling");
promptParts.add("no humans, no characters, no faces, no silhouettes, no body parts");
promptParts.add("wide establishing shot or wide cinematic environment shot only");
promptParts.add("keep the visual language consistent with the rest of the project scenes");
promptParts.addAll(buildSceneStyleNegativePromptParts(visualStyle));
return String.join(", ", promptParts);
} }
private boolean shouldRefreshSceneImagePrompt(Scene incoming, Scene existing) { private boolean shouldRefreshSceneImagePrompt(Scene incoming, Scene existing) {
...@@ -637,6 +711,17 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -637,6 +711,17 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
return builder.toString(); return builder.toString();
} }
private String buildSceneExtractionStyleDirection(ProjectVisualStyle visualStyle) {
return switch (visualStyle.slug()) {
case "japanese_anime" -> "2D Japanese anime environments with clear shape design, cel-shaded color logic, and stylized readability";
case "korean_webtoon" -> "2D Korean webtoon urban environments with modern fashion-editorial mood and soft lighting";
case "chinese_anime" -> "2D Chinese animation environments with strong silhouette design and polished guoman atmosphere";
case "chinese_fantasy_3d" -> "stylized 3D Chinese fantasy environments with layered materials, cinematic lighting, and xianxia worldbuilding";
case "cg_cinematic" -> "high-end CG cinematic environments with premium materials, depth, and film-grade atmosphere";
default -> "live-action short-drama environments with grounded production design and believable real-world texture";
};
}
private Project loadProject(Long projectId, Long tenantId) { private Project loadProject(Long projectId, Long tenantId) {
return projectMapper.findActiveById(projectId, tenantId).orElse(null); return projectMapper.findActiveById(projectId, tenantId).orElse(null);
} }
...@@ -798,6 +883,112 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -798,6 +883,112 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
}; };
} }
private String buildCharacterRenderStyleSummary(ProjectVisualStyle visualStyle) {
return switch (visualStyle.slug()) {
case "japanese_anime" -> "2D Japanese anime character design, clean line art, cel shading, expressive silhouette";
case "korean_webtoon" -> "2D Korean webtoon character design, soft shading, modern urban styling, stylish silhouette";
case "chinese_anime" -> "2D Chinese animation character design, refined line work, polished costume detail, guoman mood";
case "chinese_fantasy_3d" -> "3D Chinese fantasy character render, layered costume materials, xianxia guofeng styling";
case "cg_cinematic" -> "cinematic CGI character design, premium materials, hero-asset rendering, film-grade polish";
default -> "live-action short drama character styling, grounded realism, believable wardrobe, cinematic presentation";
};
}
private String buildSceneExtractionTargetRule(ProjectVisualStyle visualStyle) {
return switch (visualStyle.slug()) {
case "japanese_anime" -> "a 2D Japanese anime scene pack with consistent line, color, and atmosphere design";
case "korean_webtoon" -> "a 2D Korean webtoon urban scene pack with stylish city mood and soft webtoon lighting";
case "chinese_anime" -> "a 2D guoman scene pack with refined atmosphere and strong environmental silhouette design";
case "chinese_fantasy_3d" -> "a 3D Chinese fantasy scene pack with ornate worldbuilding, layered materials, and xianxia atmosphere";
case "cg_cinematic" -> "a CG cinematic scene pack with premium material rendering and film-grade environment storytelling";
default -> "a grounded live-action short-drama scene pack with believable architecture, props, and cinematic atmosphere";
};
}
private String buildSceneExtractionAvoidRule(ProjectVisualStyle visualStyle) {
return switch (visualStyle.slug()) {
case "japanese_anime", "korean_webtoon", "chinese_anime" ->
"live-action photography wording, realistic actor-photo language, or photorealistic skin and portrait cues";
case "chinese_fantasy_3d", "cg_cinematic" ->
"flat 2D illustration wording, manga panel framing, or low-detail casual sketch language";
default ->
"anime, manga, cartoon, cel-shaded, or stylized game-art wording unless the selected style explicitly requires it";
};
}
private String buildSceneRenderStyleSummary(ProjectVisualStyle visualStyle) {
return switch (visualStyle.slug()) {
case "japanese_anime" -> "2D Japanese anime environment art, clean line art, cel shading, stylized atmospheric perspective";
case "korean_webtoon" -> "2D Korean webtoon environment art, soft shading, modern urban mood, stylish city-drama palette";
case "chinese_anime" -> "2D Chinese animation environment art, refined line work, guoman mood, elegant environmental composition";
case "chinese_fantasy_3d" -> "3D Chinese fantasy environment art, xianxia guofeng mood, layered architecture and materials";
case "cg_cinematic" -> "cinematic CG environment art, premium materials, dramatic lighting, film-grade atmosphere";
default -> "live-action short drama environment design, grounded realism, believable set dressing, cinematic lighting";
};
}
private List<String> buildSceneStylePromptParts(ProjectVisualStyle visualStyle) {
return switch (visualStyle.slug()) {
case "japanese_anime" -> List.of(
"2D Japanese anime background art",
"clean line art",
"cel shading",
"stylized color blocking"
);
case "korean_webtoon" -> List.of(
"2D Korean webtoon environment art",
"soft webtoon shading",
"modern urban fashion-drama mood",
"clean stylish composition"
);
case "chinese_anime" -> List.of(
"2D Chinese animation environment art",
"refined line work",
"stylized guoman atmosphere",
"polished environmental composition"
);
case "chinese_fantasy_3d" -> List.of(
"3D Chinese fantasy environment render",
"xianxia guofeng worldbuilding",
"ornate architecture and layered materials",
"stylized cinematic lighting"
);
case "cg_cinematic" -> List.of(
"cinematic CGI environment render",
"premium material definition",
"film-grade lighting",
"high-end production design"
);
default -> List.of(
"live-action short drama production design",
"grounded realistic environment",
"believable set dressing",
"cinematic real-world lighting"
);
};
}
private List<String> buildSceneStyleNegativePromptParts(ProjectVisualStyle visualStyle) {
return switch (visualStyle.slug()) {
case "japanese_anime", "korean_webtoon", "chinese_anime" -> List.of(
"not live action photo",
"not photorealistic actor scene",
"not 3D render"
);
case "chinese_fantasy_3d", "cg_cinematic" -> List.of(
"not flat 2D illustration",
"not manga panel",
"not casual sketch"
);
default -> List.of(
"not anime",
"not manga",
"not cartoon",
"not cel shaded"
);
};
}
private String buildExtractionTargetRule(ProjectVisualStyle visualStyle) { private String buildExtractionTargetRule(ProjectVisualStyle visualStyle) {
return switch (visualStyle.slug()) { return switch (visualStyle.slug()) {
case "japanese_anime" -> "a 2D Japanese anime turnaround sheet with front, side, and back consistency"; case "japanese_anime" -> "a 2D Japanese anime turnaround sheet with front, side, and back consistency";
......
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