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

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

parent 824a0e6d
......@@ -13,9 +13,19 @@ import {
Users,
Clapperboard,
Video,
Plus,
X,
} from "lucide-react";
import { useProject } from "../../hooks/useProjects";
import { useOutline, useEpisodes, useCharacters, useScenes, useVideoTasks } from "../../hooks/useAi";
import {
useOutline,
useEpisodes,
useCharacters,
useScenes,
useVideoTasks,
useCreateEpisode,
useUpdateEpisode,
} from "../../hooks/useAi";
type Tab = "outline" | "characters" | "storyboard" | "video";
......@@ -216,43 +226,256 @@ function OutlineTab({
</div>
{/* Episode List */}
{episodes.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-foreground mb-4">
分集列表 ({episodes.length} 集)
</h2>
<div className="grid grid-cols-3 gap-4">
{episodes.map((ep) => (
<div
key={ep.id}
onClick={() => onNavigateEpisode(ep.id)}
className="rounded-xl border border-border bg-card p-4 hover:border-primary transition-colors cursor-pointer"
<EpisodeListSection
episodes={episodes}
projectId={projectId}
outlineExists={!!outline}
onNavigateEpisode={onNavigateEpisode}
onNavigateOutline={onNavigateOutline}
/>
</div>
);
}
// ---- Episode List with Edit/Create ----
type EpisodeDraft = {
id?: string;
episodeNumber: number;
title: string;
summary: string;
script: string;
};
function EpisodeListSection({
episodes,
projectId,
outlineExists,
onNavigateEpisode,
onNavigateOutline,
}: {
episodes: import("../../lib/api/ai").Episode[];
projectId?: string;
outlineExists: boolean;
onNavigateEpisode: (epId: string) => void;
onNavigateOutline: () => void;
}) {
const pid = projectId ?? "";
const createMutation = useCreateEpisode(pid);
const updateMutation = useUpdateEpisode(pid);
const [draft, setDraft] = useState<EpisodeDraft | null>(null);
const nextNumber =
episodes.length === 0
? 1
: Math.max(...episodes.map((e) => e.episodeNumber || 0)) + 1;
const openCreate = () =>
setDraft({ episodeNumber: nextNumber, title: "", summary: "", script: "" });
const openEdit = (ep: import("../../lib/api/ai").Episode) =>
setDraft({
id: ep.id,
episodeNumber: ep.episodeNumber,
title: ep.title ?? "",
summary: ep.summary ?? "",
script: ep.script ?? "",
});
const submit = async () => {
if (!draft) return;
if (!draft.episodeNumber || draft.episodeNumber <= 0) return;
const payload = {
episodeNumber: draft.episodeNumber,
title: draft.title,
summary: draft.summary,
script: draft.script,
};
if (draft.id) {
await updateMutation.mutateAsync({ id: draft.id, patch: payload });
} else {
await createMutation.mutateAsync(payload);
}
setDraft(null);
};
if (episodes.length === 0) {
return (
<>
{outlineExists && (
<div className="rounded-xl border border-dashed border-border bg-card p-8 text-center">
<p className="text-sm text-muted-foreground mb-4">大纲已生成,可由 AI 生成分集,或手动新增</p>
<div className="flex items-center gap-3 justify-center">
<button
onClick={onNavigateOutline}
className="px-5 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2"
>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-muted-foreground bg-accent px-2 py-0.5 rounded">
{ep.episodeNumber}
</span>
</div>
<h3 className="font-medium text-foreground text-sm mb-1">{ep.title}</h3>
<p className="text-xs text-muted-foreground line-clamp-3">{ep.summary}</p>
</div>
))}
<Sparkles className="w-4 h-4" />
生成分集
</button>
<button
onClick={openCreate}
className="px-5 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-all text-sm flex items-center gap-2"
>
<Plus className="w-4 h-4" />
手动新增一集
</button>
</div>
</div>
</div>
)}
{draft && (
<EpisodeEditModal
draft={draft}
setDraft={setDraft}
onClose={() => setDraft(null)}
onSubmit={submit}
saving={createMutation.isPending || updateMutation.isPending}
/>
)}
</>
);
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-foreground">
分集列表 ({episodes.length} 集)
</h2>
<button
onClick={openCreate}
className="text-sm text-primary hover:underline flex items-center gap-1"
>
<Plus className="w-4 h-4" />
新增一集
</button>
</div>
<div className="grid grid-cols-3 gap-4">
{episodes.map((ep) => (
<div
key={ep.id}
onClick={() => onNavigateEpisode(ep.id)}
className="relative rounded-xl border border-border bg-card p-4 hover:border-primary transition-colors cursor-pointer"
>
<button
onClick={(e) => {
e.stopPropagation();
openEdit(ep);
}}
className="absolute top-2 right-2 p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="编辑本集"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-muted-foreground bg-accent px-2 py-0.5 rounded">
{ep.episodeNumber}
</span>
</div>
<h3 className="font-medium text-foreground text-sm mb-1 pr-6">{ep.title}</h3>
<p className="text-xs text-muted-foreground line-clamp-3">{ep.summary}</p>
</div>
))}
</div>
{draft && (
<EpisodeEditModal
draft={draft}
setDraft={setDraft}
onClose={() => setDraft(null)}
onSubmit={submit}
saving={createMutation.isPending || updateMutation.isPending}
/>
)}
</div>
);
}
{episodes.length === 0 && outline && (
<div className="rounded-xl border border-dashed border-border bg-card p-8 text-center">
<p className="text-sm text-muted-foreground mb-4">大纲已生成,点击前往生成分集内容</p>
function EpisodeEditModal({
draft,
setDraft,
onClose,
onSubmit,
saving,
}: {
draft: EpisodeDraft;
setDraft: (d: EpisodeDraft) => void;
onClose: () => void;
onSubmit: () => void;
saving: boolean;
}) {
const isEdit = !!draft.id;
return (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-6">
<div className="bg-card rounded-2xl border border-border w-full max-w-xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between p-5 border-b border-border flex-shrink-0">
<h3 className="text-base font-semibold text-foreground">
{isEdit ? "编辑分集" : "新增分集"}
</h3>
<button
onClick={onNavigateOutline}
className="px-5 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2 mx-auto"
onClick={onClose}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<Sparkles className="w-4 h-4" />
生成分集
<X className="w-4 h-4" />
</button>
</div>
)}
<div className="p-5 space-y-4 overflow-auto">
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">集数</label>
<input
type="number"
min={1}
value={draft.episodeNumber}
onChange={(e) =>
setDraft({ ...draft, episodeNumber: Number(e.target.value) || 0 })
}
className="w-32 px-3 py-2 rounded-lg border border-border bg-background text-foreground text-sm focus:outline-none focus:border-primary"
/>
<p className="text-xs text-muted-foreground mt-1">允许重复,请自行确保唯一</p>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">本集标题</label>
<input
type="text"
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground text-sm focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">剧情摘要</label>
<textarea
value={draft.summary}
onChange={(e) => setDraft({ ...draft, summary: e.target.value })}
rows={3}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground text-sm focus:outline-none focus:border-primary resize-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">本集剧本正文</label>
<textarea
value={draft.script}
onChange={(e) => setDraft({ ...draft, script: e.target.value })}
rows={8}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground text-sm focus:outline-none focus:border-primary resize-none font-mono"
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 p-5 border-t border-border flex-shrink-0">
<button
onClick={onClose}
className="px-4 py-2 rounded-lg border border-border text-foreground hover:bg-muted transition-colors text-sm"
>
取消
</button>
<button
onClick={onSubmit}
disabled={saving || !draft.episodeNumber}
className="px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
>
{saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
保存
</button>
</div>
</div>
</div>
);
}
......
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
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 ----
const charactersKey = (pid: string) => ["characters", pid];
......@@ -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 ----
export function useStoryboards(projectId: string, episodeId: string) {
return useQuery({
......
......@@ -160,6 +160,14 @@ export const aiApi = {
const r = await apiClient.get(`/projects/${projectId}/episodes`);
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 ----
createStoryboard: async (projectId: string, episodeId: string, req: Partial<Storyboard>): Promise<Storyboard> => {
......
......@@ -35,6 +35,10 @@ LABEL maintainer="YaoAI Team <yaoke251@gmail.com>"
LABEL org.opencontainers.image.title="YaoAI Comic Studio"
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 \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
......@@ -52,6 +56,6 @@ USER yaoai
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"]
package com.yaoai.agent.sse;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
......@@ -10,21 +12,48 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@Slf4j
@Component
@RequiredArgsConstructor
public class AgentSseManager {
private static final long HEARTBEAT_INTERVAL_SECONDS = 15;
private final ObjectMapper objectMapper;
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) {
SseEmitter emitter = new SseEmitter(300_000L);
SseEmitter emitter = new SseEmitter(0L);
List<SseEmitter> list = emitters.computeIfAbsent(runId, k -> new CopyOnWriteArrayList<>());
list.add(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));
log.debug("SSE subscribed: runId={}, total={}", runId, list.size());
return emitter;
......@@ -57,6 +86,18 @@ public class AgentSseManager {
"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) {
List<SseEmitter> list = emitters.get(runId);
if (list != null) list.remove(emitter);
......
......@@ -5,6 +5,7 @@ import com.yaoai.api.dto.ai.OutlineDTO;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import com.yaoai.common.response.ApiResponse;
import com.yaoai.domain.entity.Episode;
import com.yaoai.domain.entity.Outline;
import com.yaoai.domain.mapper.StoryboardMapper;
import com.yaoai.pipeline.service.OutlinePipelineService;
......@@ -87,4 +88,23 @@ public class OutlineController {
.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;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import java.util.stream.Collectors;
......@@ -56,6 +57,12 @@ public class GlobalExceptionHandler {
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)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<?> handleException(Exception e) {
......
......@@ -43,6 +43,7 @@
<!-- ===== non-local: file only ===== -->
<springProfile name="!local">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ASYNC_FILE"/>
</root>
</springProfile>
......
......@@ -20,4 +20,14 @@ public interface OutlinePipelineService {
Outline getOutline(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 {
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) {
// strip markdown code fences if present
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