Commit 69bfd17b authored by yaoke.yk's avatar yaoke.yk

Agent制作优化上下文错误和剧本集数可修改

parent 824a0e6d
This diff is collapsed.
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, Scene, Storyboard, StructuredVideoRequest } from "../lib/api/ai"; import type { Character, Episode, Scene, Storyboard, StructuredVideoRequest } from "../lib/api/ai";
// ---- Characters ---- // ---- Characters ----
const charactersKey = (pid: string) => ["characters", pid]; const charactersKey = (pid: string) => ["characters", pid];
...@@ -102,6 +102,23 @@ export function useGenerateEpisodes(projectId: string) { ...@@ -102,6 +102,23 @@ export function useGenerateEpisodes(projectId: string) {
}); });
} }
export function useCreateEpisode(projectId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: Partial<Episode>) => aiApi.createEpisode(projectId, req),
onSuccess: () => qc.invalidateQueries({ queryKey: episodesKey(projectId) }),
});
}
export function useUpdateEpisode(projectId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, patch }: { id: string; patch: Partial<Episode> }) =>
aiApi.updateEpisode(projectId, id, patch),
onSuccess: () => qc.invalidateQueries({ queryKey: episodesKey(projectId) }),
});
}
// ---- Storyboards ---- // ---- Storyboards ----
export function useStoryboards(projectId: string, episodeId: string) { export function useStoryboards(projectId: string, episodeId: string) {
return useQuery({ return useQuery({
......
...@@ -160,6 +160,14 @@ export const aiApi = { ...@@ -160,6 +160,14 @@ export const aiApi = {
const r = await apiClient.get(`/projects/${projectId}/episodes`); const r = await apiClient.get(`/projects/${projectId}/episodes`);
return r.data.data; return r.data.data;
}, },
createEpisode: async (projectId: string, req: Partial<Episode>): Promise<Episode> => {
const r = await apiClient.post(`/projects/${projectId}/episodes`, req);
return r.data.data;
},
updateEpisode: async (projectId: string, episodeId: string, patch: Partial<Episode>): Promise<Episode> => {
const r = await apiClient.put(`/projects/${projectId}/episodes/${episodeId}`, patch);
return r.data.data;
},
// ---- Storyboards ---- // ---- Storyboards ----
createStoryboard: async (projectId: string, episodeId: string, req: Partial<Storyboard>): Promise<Storyboard> => { createStoryboard: async (projectId: string, episodeId: string, req: Partial<Storyboard>): Promise<Storyboard> => {
......
...@@ -35,6 +35,10 @@ LABEL maintainer="YaoAI Team <yaoke251@gmail.com>" ...@@ -35,6 +35,10 @@ LABEL maintainer="YaoAI Team <yaoke251@gmail.com>"
LABEL org.opencontainers.image.title="YaoAI Comic Studio" LABEL org.opencontainers.image.title="YaoAI Comic Studio"
LABEL org.opencontainers.image.version="0.1.0" LABEL org.opencontainers.image.version="0.1.0"
ENV LANG=C.UTF-8 \
LANGUAGE=C.UTF-8 \
LC_ALL=C.UTF-8
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \ && apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
...@@ -52,6 +56,6 @@ USER yaoai ...@@ -52,6 +56,6 @@ USER yaoai
EXPOSE 8080 EXPOSE 8080
ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC -Djava.security.egd=file:/dev/./urandom" ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC -Djava.security.egd=file:/dev/./urandom -Dfile.encoding=UTF-8 -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
package com.yaoai.agent.sse; package com.yaoai.agent.sse;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
...@@ -10,21 +12,48 @@ import java.util.List; ...@@ -10,21 +12,48 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@Slf4j @Slf4j
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class AgentSseManager { public class AgentSseManager {
private static final long HEARTBEAT_INTERVAL_SECONDS = 15;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final Map<Long, List<SseEmitter>> emitters = new ConcurrentHashMap<>(); private final Map<Long, List<SseEmitter>> emitters = new ConcurrentHashMap<>();
private final ScheduledExecutorService heartbeatExecutor = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "agent-sse-heartbeat");
t.setDaemon(true);
return t;
});
@PostConstruct
void startHeartbeat() {
heartbeatExecutor.scheduleAtFixedRate(this::heartbeatAll,
HEARTBEAT_INTERVAL_SECONDS,
HEARTBEAT_INTERVAL_SECONDS,
TimeUnit.SECONDS);
}
@PreDestroy
void shutdownHeartbeat() {
heartbeatExecutor.shutdownNow();
}
public SseEmitter subscribe(Long runId) { public SseEmitter subscribe(Long runId) {
SseEmitter emitter = new SseEmitter(300_000L); SseEmitter emitter = new SseEmitter(0L);
List<SseEmitter> list = emitters.computeIfAbsent(runId, k -> new CopyOnWriteArrayList<>()); List<SseEmitter> list = emitters.computeIfAbsent(runId, k -> new CopyOnWriteArrayList<>());
list.add(emitter); list.add(emitter);
emitter.onCompletion(() -> remove(runId, emitter)); emitter.onCompletion(() -> remove(runId, emitter));
emitter.onTimeout(() -> remove(runId, emitter)); emitter.onTimeout(() -> {
log.debug("SSE timeout: runId={}", runId);
remove(runId, emitter);
emitter.complete();
});
emitter.onError(e -> remove(runId, emitter)); emitter.onError(e -> remove(runId, emitter));
log.debug("SSE subscribed: runId={}, total={}", runId, list.size()); log.debug("SSE subscribed: runId={}, total={}", runId, list.size());
return emitter; return emitter;
...@@ -57,6 +86,18 @@ public class AgentSseManager { ...@@ -57,6 +86,18 @@ public class AgentSseManager {
"time", java.time.LocalTime.now().toString().substring(0, 8))); "time", java.time.LocalTime.now().toString().substring(0, 8)));
} }
private void heartbeatAll() {
emitters.forEach((runId, list) -> list.removeIf(emitter -> {
try {
emitter.send(SseEmitter.event().comment("ping"));
return false;
} catch (Exception e) {
log.debug("Removed heartbeat-broken SSE emitter for runId={}", runId);
return true;
}
}));
}
private void remove(Long runId, SseEmitter emitter) { private void remove(Long runId, SseEmitter emitter) {
List<SseEmitter> list = emitters.get(runId); List<SseEmitter> list = emitters.get(runId);
if (list != null) list.remove(emitter); if (list != null) list.remove(emitter);
......
...@@ -5,6 +5,7 @@ import com.yaoai.api.dto.ai.OutlineDTO; ...@@ -5,6 +5,7 @@ import com.yaoai.api.dto.ai.OutlineDTO;
import com.yaoai.common.exception.BizException; import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode; import com.yaoai.common.exception.ErrorCode;
import com.yaoai.common.response.ApiResponse; import com.yaoai.common.response.ApiResponse;
import com.yaoai.domain.entity.Episode;
import com.yaoai.domain.entity.Outline; import com.yaoai.domain.entity.Outline;
import com.yaoai.domain.mapper.StoryboardMapper; import com.yaoai.domain.mapper.StoryboardMapper;
import com.yaoai.pipeline.service.OutlinePipelineService; import com.yaoai.pipeline.service.OutlinePipelineService;
...@@ -87,4 +88,23 @@ public class OutlineController { ...@@ -87,4 +88,23 @@ public class OutlineController {
.collect(Collectors.toList()) .collect(Collectors.toList())
); );
} }
@Operation(summary = "手动新增一集")
@PostMapping("/episodes")
public ApiResponse<EpisodeDTO> createEpisode(@PathVariable Long projectId,
@RequestBody Episode req) {
Long tenantId = TenantContext.get();
Episode ep = outlinePipelineService.createEpisode(projectId, tenantId, req);
return ApiResponse.success(EpisodeDTO.from(ep, 0));
}
@Operation(summary = "编辑单集(episodeNumber/title/summary/script)")
@PutMapping("/episodes/{episodeId}")
public ApiResponse<EpisodeDTO> updateEpisode(@PathVariable Long projectId,
@PathVariable Long episodeId,
@RequestBody Episode req) {
Long tenantId = TenantContext.get();
Episode ep = outlinePipelineService.updateEpisode(projectId, tenantId, episodeId, req);
return ApiResponse.success(EpisodeDTO.from(ep, storyboardMapper.countByEpisode(ep.getId(), tenantId)));
}
} }
...@@ -12,6 +12,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; ...@@ -12,6 +12,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import java.util.stream.Collectors; import java.util.stream.Collectors;
...@@ -56,6 +57,12 @@ public class GlobalExceptionHandler { ...@@ -56,6 +57,12 @@ public class GlobalExceptionHandler {
return ApiResponse.error(ErrorCode.INVALID_PARAM.getCode(), e.getMessage()); return ApiResponse.error(ErrorCode.INVALID_PARAM.getCode(), e.getMessage());
} }
@ExceptionHandler(AsyncRequestTimeoutException.class)
@ResponseStatus(HttpStatus.NO_CONTENT)
public void handleAsyncTimeout(AsyncRequestTimeoutException e) {
log.debug("Async request timeout", e);
}
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<?> handleException(Exception e) { public ApiResponse<?> handleException(Exception e) {
......
...@@ -43,6 +43,7 @@ ...@@ -43,6 +43,7 @@
<!-- ===== non-local: file only ===== --> <!-- ===== non-local: file only ===== -->
<springProfile name="!local"> <springProfile name="!local">
<root level="INFO"> <root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ASYNC_FILE"/> <appender-ref ref="ASYNC_FILE"/>
</root> </root>
</springProfile> </springProfile>
......
...@@ -20,4 +20,14 @@ public interface OutlinePipelineService { ...@@ -20,4 +20,14 @@ public interface OutlinePipelineService {
Outline getOutline(Long projectId, Long tenantId); Outline getOutline(Long projectId, Long tenantId);
List<Episode> getEpisodes(Long projectId, Long tenantId); List<Episode> getEpisodes(Long projectId, Long tenantId);
/**
* 手动新增一集(不调 LLM)。若 req.episodeNumber 为空,自动取当前最大 +1。
*/
Episode createEpisode(Long projectId, Long tenantId, Episode req);
/**
* 手动编辑一集(episodeNumber/title/summary/script,非 null 字段才更新)。
*/
Episode updateEpisode(Long projectId, Long tenantId, Long episodeId, Episode req);
} }
...@@ -209,6 +209,52 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService { ...@@ -209,6 +209,52 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService {
return episodeMapper.findByProject(projectId, tenantId); return episodeMapper.findByProject(projectId, tenantId);
} }
@Override
public Episode createEpisode(Long projectId, Long tenantId, Episode req) {
Outline outline = outlineMapper.findLatestByProject(projectId, tenantId);
if (outline == null) {
throw new BizException(ErrorCode.NOT_FOUND, "请先生成或保存大纲");
}
Integer epNum = req.getEpisodeNumber();
if (epNum == null || epNum <= 0) {
int max = episodeMapper.findByProject(projectId, tenantId).stream()
.mapToInt(e -> e.getEpisodeNumber() == null ? 0 : e.getEpisodeNumber())
.max().orElse(0);
epNum = max + 1;
}
Episode ep = new Episode();
ep.setOutlineId(outline.getId());
ep.setProjectId(projectId);
ep.setTenantId(tenantId);
ep.setEpisodeNumber(epNum);
ep.setTitle(req.getTitle() == null ? "" : req.getTitle());
ep.setSummary(req.getSummary() == null ? "" : req.getSummary());
ep.setScript(req.getScript() == null ? "" : req.getScript());
ep.setStatus("ready");
episodeMapper.insert(ep);
log.info("Episode manually created: id={}, episodeNumber={}", ep.getId(), epNum);
return ep;
}
@Override
public Episode updateEpisode(Long projectId, Long tenantId, Long episodeId, Episode req) {
Episode existing = episodeMapper.selectById(episodeId);
if (existing == null
|| !tenantId.equals(existing.getTenantId())
|| !projectId.equals(existing.getProjectId())) {
throw new BizException(ErrorCode.NOT_FOUND, "分集不存在");
}
if (req.getEpisodeNumber() != null && req.getEpisodeNumber() > 0) {
existing.setEpisodeNumber(req.getEpisodeNumber());
}
if (req.getTitle() != null) existing.setTitle(req.getTitle());
if (req.getSummary() != null) existing.setSummary(req.getSummary());
if (req.getScript() != null) existing.setScript(req.getScript());
episodeMapper.updateById(existing);
log.info("Episode updated: id={}", episodeId);
return existing;
}
private String extractJson(String raw) { private String extractJson(String raw) {
// strip markdown code fences if present // strip markdown code fences if present
String s = raw.strip(); String s = raw.strip();
......
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