Commit 1a8f8883 authored by heke's avatar heke

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

parent fe614843
File added
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 { useAuthStore } from "../stores/authStore";
const runKey = (projectId: string) => ["agent-run", projectId];
export function useLatestAgentRun(projectId: string) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({
queryKey: runKey(projectId),
queryFn: () => agentApi.getLatestRun(projectId),
enabled: !!projectId,
enabled: isAuthenticated && !!projectId,
refetchInterval: false,
});
}
......@@ -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 isAuthenticated = useAuthStore((state) => state.isAuthenticated);
useEffect(() => {
// 从 localStorage 直接取原始 token(不带 Bearer 前缀)
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 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") {
......@@ -101,11 +106,19 @@ export function useAgentSse(runId: number | null, projectId: string) {
} 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 +131,79 @@ 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);
};
return () => es.close();
// token 每次从 localStorage 读取,不需要作为依赖
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [runId, projectId]);
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",
"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 };
}
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { aiApi } from "../lib/api/ai";
import type { Character, Episode, Outline, Scene, Storyboard, StructuredVideoRequest } from "../lib/api/ai";
import { useAuthStore } from "../stores/authStore";
// ---- Characters ----
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) {
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) {
const qc = useQueryClient();
......@@ -18,7 +45,28 @@ export function useSaveCharacter(projectId: string) {
}
export function useGenerateCharacterImage(projectId: string) {
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) {
const qc = useQueryClient();
......@@ -37,7 +85,13 @@ export function useDeleteCharacter(projectId: string) {
const scenesKey = (pid: string) => ["scenes", pid];
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) {
const qc = useQueryClient();
......@@ -49,7 +103,28 @@ export function useSaveScene(projectId: string) {
}
export function useGenerateSceneImage(projectId: string) {
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) {
const qc = useQueryClient();
......@@ -68,10 +143,11 @@ const assemblyKey = (pid: string, eid: string) => ["assembly", pid, eid];
// ---- Outline ----
export function useOutline(projectId: string) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({
queryKey: outlineKey(projectId),
queryFn: () => aiApi.getOutline(projectId),
enabled: !!projectId,
enabled: isAuthenticated && !!projectId,
retry: false,
});
}
......@@ -94,10 +170,11 @@ export function useUpdateOutline(projectId: string) {
// ---- Episodes ----
export function useEpisodes(projectId: string) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({
queryKey: episodesKey(projectId),
queryFn: () => aiApi.listEpisodes(projectId),
enabled: !!projectId,
enabled: isAuthenticated && !!projectId,
staleTime: 0,
});
}
......@@ -129,10 +206,11 @@ export function useUpdateEpisode(projectId: string) {
// ---- Storyboards ----
export function useStoryboards(projectId: string, episodeId: string) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({
queryKey: storyboardsKey(projectId, episodeId),
queryFn: () => aiApi.listStoryboards(projectId, episodeId),
enabled: !!projectId && !!episodeId,
enabled: isAuthenticated && !!projectId && !!episodeId,
});
}
......@@ -186,10 +264,11 @@ export function useGenerateStoryboardPrompt(projectId: string) {
// ---- Video Tasks ----
export function useVideoTasks(projectId: string) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({
queryKey: videoTasksKey(projectId),
queryFn: () => aiApi.listVideoTasks(projectId),
enabled: !!projectId,
enabled: isAuthenticated && !!projectId,
staleTime: 0,
refetchOnWindowFocus: true,
refetchInterval: (query) => {
......@@ -228,10 +307,11 @@ export function useGenerateStructuredVideo(projectId: string) {
// ---- Assembly ----
export function useAssemblyTask(projectId: string, episodeId: string) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({
queryKey: assemblyKey(projectId, episodeId),
queryFn: () => aiApi.getAssemblyTask(projectId, episodeId),
enabled: !!projectId && !!episodeId,
enabled: isAuthenticated && !!projectId && !!episodeId,
refetchInterval: (query) => {
const data = query.state.data;
if (!data) return false;
......@@ -258,10 +338,11 @@ export function useDeleteVideoTask(projectId: string) {
}
export function usePollVideoTask(projectId: string, taskId: string, enabled: boolean) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({
queryKey: ["video-task", projectId, taskId],
queryFn: () => aiApi.pollVideoTask(projectId, taskId),
enabled: enabled && !!taskId,
enabled: isAuthenticated && enabled && !!taskId,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "succeeded" || status === "failed" ? false : 5000;
......
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 isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({
queryKey: PROJECTS_KEY,
queryFn: projectsApi.list,
enabled: isAuthenticated,
});
}
export function useProject(id: string) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({
queryKey: projectKey(id),
queryFn: () => projectsApi.get(id),
enabled: !!id,
enabled: isAuthenticated && !!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 isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({ queryKey: membersKey(), queryFn: teamApi.listMembers, staleTime: 30_000, enabled: isAuthenticated });
}
export function useAddMember() {
......@@ -34,7 +36,8 @@ export function useRemoveMember() {
}
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() {
......
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 isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({
queryKey: balanceKey(),
queryFn: () => usageApi.getBalance(),
staleTime: 30_000,
enabled: isAuthenticated,
});
}
export function useRecords(limit = 20, offset = 0) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({
queryKey: recordsKey(limit, offset),
queryFn: () => usageApi.getRecords(limit, offset),
staleTime: 30_000,
enabled: isAuthenticated,
});
}
export function useCosts() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({
queryKey: costsKey(),
queryFn: () => usageApi.getCosts(),
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
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
......
......@@ -9,11 +9,15 @@ import com.yaoai.common.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
@Slf4j
@Service
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 +25,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 +41,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 +52,14 @@ 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() == 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 +82,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;
}
}
......@@ -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,14 @@ 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 是否正确且仍有效");
}
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) {
throw e;
} catch (Exception e) {
......@@ -108,10 +121,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 +153,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 +180,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 ----
/**
......
......@@ -9,6 +9,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.List;
......@@ -18,6 +19,9 @@ import java.util.Map;
@Service
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 RestClient restClient;
......@@ -28,7 +32,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,6 +44,7 @@ public class SeedreamServiceImpl implements SeedreamService {
@Override
public String generateImage(String prompt, String size) {
String apiKey = resolveApiKey();
Map<String, Object> body = Map.of(
"model", properties.getImageModel(),
"prompt", prompt,
......@@ -53,6 +57,7 @@ public class SeedreamServiceImpl implements SeedreamService {
try {
ImageResponse resp = restClient.post()
.uri("/images/generations")
.header("Authorization", "Bearer " + apiKey)
.body(body)
.retrieve()
.body(ImageResponse.class);
......@@ -63,6 +68,14 @@ 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);
throw new BizException(ErrorCode.INTERNAL_ERROR,
"图片生成失败: HTTP " + e.getStatusCode().value());
} catch (BizException e) {
throw e;
} catch (Exception e) {
......@@ -81,6 +94,18 @@ 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;
}
// ---- internal response models ----
@Data
......
......@@ -64,18 +64,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) {
......
......@@ -104,7 +104,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}
......
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();
}
}
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