From 19ca8fec0c287fb131d369ace867e04bdfcaebf0 Mon Sep 17 00:00:00 2001 From: yuwangi Date: Fri, 10 Apr 2026 15:29:20 +0800 Subject: [PATCH] .3719011831386640:0088a1563add343d699ea1f9c7a0bf18_69d895b6f71ec60279171dcc.69d895b9f71ec60279171dd9.69d895b8d8cc83b8921c8549:Trae CN.T(2026/4/10 14:16:25) --- apps/backend/src/index.ts | 234 ++++++++++++------ apps/backend/src/queue/worker.ts | 26 ++ apps/backend/src/routes/task.routes.ts | 112 +++++++++ .../components/novel/ChapterGenerator.tsx | 13 + apps/frontend/lib/api.ts | 5 + 5 files changed, 311 insertions(+), 79 deletions(-) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 5857dc6..1cf563a 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,52 +1,58 @@ -import './init-env'; // Must be the first import to ensure env vars are loaded before other modules -import dns from 'node:dns'; +import "./init-env"; // Must be the first import to ensure env vars are loaded before other modules +import dns from "node:dns"; // Force IPv4 first to avoid "other side closed" errors with GitHub/OAuth providers on some networks if (dns.setDefaultResultOrder) { - dns.setDefaultResultOrder('ipv4first'); + dns.setDefaultResultOrder("ipv4first"); } -import express, { Application, Request, Response } from 'express'; -import path from 'path'; -import cors from 'cors'; -import { createServer } from 'http'; -import { Server } from 'socket.io'; -import swaggerUi from 'swagger-ui-express'; -import { logger } from './utils/logger'; -import { errorHandler } from './middleware/errorHandler'; -import { authMiddleware } from './middleware/auth'; -import { startWorker } from './queue/worker'; -import { i18nMiddleware } from './config/i18n'; +import express, { Application, Request, Response } from "express"; +import path from "path"; +import cors from "cors"; +import { createServer } from "http"; +import { Server } from "socket.io"; +import swaggerUi from "swagger-ui-express"; +import { logger } from "./utils/logger"; +import { errorHandler } from "./middleware/errorHandler"; +import { authMiddleware } from "./middleware/auth"; +import { startWorker } from "./queue/worker"; +import { i18nMiddleware } from "./config/i18n"; // Import routes -import novelRoutes from './routes/novel.routes'; -import chapterRoutes from './routes/chapter.routes'; -import knowledgeRoutes from './routes/knowledge.routes'; -import aiConfigRoutes from './routes/aiConfig.routes'; -import taskRoutes from './routes/task.routes'; -import chatRoutes from './routes/chat.routes'; -import sandboxRoutes from './routes/sandbox.routes'; +import novelRoutes from "./routes/novel.routes"; +import chapterRoutes from "./routes/chapter.routes"; +import knowledgeRoutes from "./routes/knowledge.routes"; +import aiConfigRoutes from "./routes/aiConfig.routes"; +import taskRoutes from "./routes/task.routes"; +import chatRoutes from "./routes/chat.routes"; +import sandboxRoutes from "./routes/sandbox.routes"; const app: Application = express(); const httpServer = createServer(app); // CORS configuration -const allowedOrigins = process.env.CORS_ORIGIN - ? process.env.CORS_ORIGIN.split(',').map(origin => origin.trim()) - : ['http://localhost:8001', 'tauri://localhost', 'http://tauri.localhost']; +const allowedOrigins = process.env.CORS_ORIGIN + ? process.env.CORS_ORIGIN.split(",").map((origin) => origin.trim()) + : ["http://localhost:8001", "tauri://localhost", "http://tauri.localhost"]; const corsOptions = { - origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => { + origin: ( + origin: string | undefined, + callback: (err: Error | null, allow?: boolean) => void, + ) => { // allow requests with no origin (like mobile apps, curl requests, or SSR) if (!origin) return callback(null, true); - + // Check for allowed origins or development mode - if (allowedOrigins.indexOf(origin) !== -1 || process.env.NODE_ENV === 'development') { + if ( + allowedOrigins.indexOf(origin) !== -1 || + process.env.NODE_ENV === "development" + ) { return callback(null, true); } - + // If we're here, check if we want to be permissive for debugging - if (process.env.CORS_ALLOW_ALL === 'true') { - return callback(null, true); + if (process.env.CORS_ALLOW_ALL === "true") { + return callback(null, true); } console.warn(`Blocked by CORS: ${origin}`); @@ -55,7 +61,7 @@ const corsOptions = { credentials: true, }; -import { setIO } from './socket-singleton'; +import { setIO } from "./socket-singleton"; const io = new Server(httpServer, { cors: corsOptions, @@ -71,43 +77,52 @@ const PORT = process.env.BACKEND_PORT || 8002; app.use(cors(corsOptions)); // Trust proxy - Required for secure cookies behind Nginx/Cloudflare -app.set('trust proxy', true); // Trust all proxies to ensure protocol is correctly identified as HTTPS +app.set("trust proxy", true); // Trust all proxies to ensure protocol is correctly identified as HTTPS // Better-Auth handler - MUST be before body parsers but AFTER CORS -import { auth } from './config/auth.config'; -import { toNodeHandler } from 'better-auth/node'; - -app.use('/api/auth', (req, _res, next) => { - console.log(`[Auth Debug] Incoming request: ${req.method} ${req.originalUrl || req.url}`); - console.log(`[Auth Debug] Headers: ${JSON.stringify({ - host: req.headers.host, - 'x-forwarded-proto': req.headers['x-forwarded-proto'], - 'x-forwarded-host': req.headers['x-forwarded-host'], - origin: req.headers.origin, - cookieKeys: req.headers.cookie ? req.headers.cookie.split(';').map(c => c.trim().split('=')[0]).join(', ') : 'NONE' - })}`); - +import { auth } from "./config/auth.config"; +import { toNodeHandler } from "better-auth/node"; + +app.use("/api/auth", (req, _res, next) => { + console.log( + `[Auth Debug] Incoming request: ${req.method} ${req.originalUrl || req.url}`, + ); + console.log( + `[Auth Debug] Headers: ${JSON.stringify({ + host: req.headers.host, + "x-forwarded-proto": req.headers["x-forwarded-proto"], + "x-forwarded-host": req.headers["x-forwarded-host"], + origin: req.headers.origin, + cookieKeys: req.headers.cookie + ? req.headers.cookie + .split(";") + .map((c) => c.trim().split("=")[0]) + .join(", ") + : "NONE", + })}`, + ); + // Also log body if it exists (for POST requests) - if (req.method === 'POST') { + if (req.method === "POST") { // Note: body might not be parsed yet if this is before body-parser // but better-auth handles its own body if needed, or we might need to parse it } next(); }); -app.all('/api/auth/*', (req, res) => { - toNodeHandler(auth)(req, res); +app.all("/api/auth/*", (req, res) => { + toNodeHandler(auth)(req, res); }); // Body parsers - AFTER Better-Auth -app.use(express.json({ limit: '10mb' })); -app.use(express.urlencoded({ extended: true, limit: '10mb' })); +app.use(express.json({ limit: "10mb" })); +app.use(express.urlencoded({ extended: true, limit: "10mb" })); // i18n middleware - MUST be before routes app.use(i18nMiddleware); // Static files -app.use('/uploads', express.static(path.join(__dirname, '../uploads'))); +app.use("/uploads", express.static(path.join(__dirname, "../uploads"))); // Request logging app.use((req, _res, next) => { @@ -116,62 +131,123 @@ app.use((req, _res, next) => { }); // Health check -app.get('/health', (_req: Request, res: Response) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); +app.get("/health", (_req: Request, res: Response) => { + res.json({ status: "ok", timestamp: new Date().toISOString() }); }); // API Documentation -app.use('/api-docs', swaggerUi.serve as any, swaggerUi.setup({ - openapi: '3.0.0', - info: { - title: 'Daer Novel API', - version: '1.0.0', - description: 'AI Novel Generation Platform API', - }, -})) +app.use( + "/api-docs", + swaggerUi.serve as any, + swaggerUi.setup({ + openapi: "3.0.0", + info: { + title: "Daer Novel API", + version: "1.0.0", + description: "AI Novel Generation Platform API", + }, + }), +); // Protected API Routes -app.use('/api/novels', authMiddleware, novelRoutes); -app.use('/api/novels', authMiddleware, taskRoutes); // Task routes for novel generation -app.use('/api/chapters', authMiddleware, chapterRoutes); -app.use('/api', authMiddleware, sandboxRoutes); // Sandbox routes for plot branching -app.use('/api/knowledge', authMiddleware, knowledgeRoutes); -app.use('/api/ai-config', authMiddleware, aiConfigRoutes); -app.use('/api/tasks', authMiddleware, taskRoutes); // Task status routes -app.use('/api/chat', authMiddleware, chatRoutes); +app.use("/api/novels", authMiddleware, novelRoutes); +app.use("/api/novels", authMiddleware, taskRoutes); // Task routes for novel generation +app.use("/api/chapters", authMiddleware, chapterRoutes); +app.use("/api", authMiddleware, sandboxRoutes); // Sandbox routes for plot branching +app.use("/api/knowledge", authMiddleware, knowledgeRoutes); +app.use("/api/ai-config", authMiddleware, aiConfigRoutes); +app.use("/api/tasks", authMiddleware, taskRoutes); // Task status routes +app.use("/api/chat", authMiddleware, chatRoutes); // WebSocket for real-time task updates -io.on('connection', (socket) => { +io.on("connection", (socket) => { logger.info(`Client connected: ${socket.id}`); - - socket.on('subscribe:task', (taskId: string) => { + + socket.on("subscribe:task", (taskId: string) => { socket.join(`task:${taskId}`); logger.info(`Client ${socket.id} subscribed to task ${taskId}`); }); - - socket.on('unsubscribe:task', (taskId: string) => { + + socket.on("unsubscribe:task", (taskId: string) => { socket.leave(`task:${taskId}`); }); - - socket.on('disconnect', () => { + + socket.on("disconnect", () => { logger.info(`Client disconnected: ${socket.id}`); }); }); // Make io accessible to routes -app.set('io', io); +app.set("io", io); // Error handling app.use(errorHandler); // 404 handler app.use((_req, res) => { - res.status(404).json({ error: 'Route not found' }); + res.status(404).json({ error: "Route not found" }); }); - // Start server startWorker(io); + +// Sync stale tasks on startup +async function syncStaleTasks() { + try { + const { db, schema } = await import("./database"); + const { eq } = await import("drizzle-orm"); + + // Find all tasks that are still running or queued + const activeTasks = await db.query.tasks.findMany({ + where: (tasks, { inArray }) => + inArray(tasks.status, ["running", "queued"]), + }); + + logger.info(`Found ${activeTasks.length} active tasks on startup`); + + for (const task of activeTasks) { + // Mark task as failed + await db + .update(schema.tasks) + .set({ + status: "failed", + error: "Task interrupted - server restart", + updatedAt: new Date(), + }) + .where(eq(schema.tasks.id, task.id)); + + // If task has a chapter, update chapter status + if (task.chapterId) { + await db + .update(schema.chapters) + .set({ status: "failed", updatedAt: new Date() }) + .where(eq(schema.chapters.id, task.chapterId)); + } + } + + // Also check for chapters stuck in "generating" status + const generatingChapters = await db.query.chapters.findMany({ + where: eq(schema.chapters.status, "generating"), + }); + + for (const chapter of generatingChapters) { + await db + .update(schema.chapters) + .set({ status: "pending", updatedAt: new Date() }) + .where(eq(schema.chapters.id, chapter.id)); + } + + logger.info( + `Synced ${activeTasks.length} tasks and ${generatingChapters.length} chapters`, + ); + } catch (error) { + logger.error("Failed to sync stale tasks on startup:", error); + } +} + +// Run sync after a short delay to ensure DB is ready +setTimeout(syncStaleTasks, 2000); + httpServer.listen(PORT, () => { logger.info(`🚀 Server running on port ${PORT}`); logger.info(`📚 API Docs: http://localhost:${PORT}/api-docs`); diff --git a/apps/backend/src/queue/worker.ts b/apps/backend/src/queue/worker.ts index 352e493..552284b 100644 --- a/apps/backend/src/queue/worker.ts +++ b/apps/backend/src/queue/worker.ts @@ -87,6 +87,19 @@ export function startWorker(io: Server) { .set({ status: "running", updatedAt: new Date() }) .where(eq(schema.tasks.id, taskId)); + // Update chapter status to generating for content tasks + if ( + chapterId && + (type === "content" || + type === "chapter_outline" || + type === "chapter_detail") + ) { + await db + .update(schema.chapters) + .set({ status: "generating", updatedAt: new Date() }) + .where(eq(schema.chapters.id, chapterId)); + } + // Emit progress update io.to(`task:${taskId}`).emit("task:progress", { taskId, @@ -422,6 +435,19 @@ export function startWorker(io: Server) { }) .where(eq(schema.tasks.id, taskId)); + // Update chapter status to failed for content tasks + if ( + chapterId && + (type === "content" || + type === "chapter_outline" || + type === "chapter_detail") + ) { + await db + .update(schema.chapters) + .set({ status: "failed", updatedAt: new Date() }) + .where(eq(schema.chapters.id, chapterId)); + } + io.to(`task:${taskId}`).emit("task:failed", { taskId, error: error.message, diff --git a/apps/backend/src/routes/task.routes.ts b/apps/backend/src/routes/task.routes.ts index 48bab18..39ceeb8 100644 --- a/apps/backend/src/routes/task.routes.ts +++ b/apps/backend/src/routes/task.routes.ts @@ -323,4 +323,116 @@ router.get("/tasks/:taskId", async (req: AuthRequest, res, next) => { } }); +// Sync task status - check for stale tasks and update chapter status +router.post("/sync", async (_req: AuthRequest, res, next) => { + try { + // Find all tasks that are still running or queued + const activeTasks = await db.query.tasks.findMany({ + where: (tasks, { inArray }) => + inArray(tasks.status, ["running", "queued"]), + }); + + const syncResults: { + taskId: string; + oldStatus: string | null; + newStatus: string; + chapterId?: string | null; + }[] = []; + + // Check each active task + for (const task of activeTasks) { + // Check if the task has been running for more than 10 minutes (stale) + const taskAge = Date.now() - new Date(task.updatedAt).getTime(); + const isStale = taskAge > 10 * 60 * 1000; // 10 minutes + + if (isStale) { + // Mark task as failed + await db + .update(schema.tasks) + .set({ + status: "failed", + error: "Task timeout - browser disconnected", + updatedAt: new Date(), + }) + .where(eq(schema.tasks.id, task.id)); + + // If task has a chapter, update chapter status + if (task.chapterId) { + await db + .update(schema.chapters) + .set({ status: "failed", updatedAt: new Date() }) + .where(eq(schema.chapters.id, task.chapterId)); + } + + syncResults.push({ + taskId: task.id, + oldStatus: task.status, + newStatus: "failed", + chapterId: task.chapterId, + }); + } + } + + // Also check for chapters stuck in "generating" status without active tasks + const generatingChapters = await db.query.chapters.findMany({ + where: eq(schema.chapters.status, "generating"), + }); + + for (const chapter of generatingChapters) { + // Check if there's an active task for this chapter + const activeTask = await db.query.tasks.findFirst({ + where: (tasks, { and, eq, inArray }) => + and( + eq(tasks.chapterId, chapter.id), + inArray(tasks.status, ["running", "queued"]), + ), + }); + + if (!activeTask) { + // No active task, reset chapter status to pending + await db + .update(schema.chapters) + .set({ status: "pending", updatedAt: new Date() }) + .where(eq(schema.chapters.id, chapter.id)); + + syncResults.push({ + taskId: "none", + oldStatus: "generating", + newStatus: "pending", + chapterId: chapter.id, + }); + } + } + + res.json({ + synced: syncResults.length, + details: syncResults, + }); + } catch (error) { + next(error); + } +}); + +// Get active tasks for a novel +router.get("/:novelId/tasks/active", async (req: AuthRequest, res, next) => { + try { + const { novelId } = req.params; + + const activeTasks = await db.query.tasks.findMany({ + where: (tasks, { and, eq, inArray }) => + and( + eq(tasks.novelId, novelId), + inArray(tasks.status, ["running", "queued"]), + ), + with: { + chapter: true, + }, + }); + + res.json(activeTasks); + } catch (error) { + next(error); + } +}); + export default router; diff --git a/apps/frontend/components/novel/ChapterGenerator.tsx b/apps/frontend/components/novel/ChapterGenerator.tsx index 77d98b6..2e96f6c 100644 --- a/apps/frontend/components/novel/ChapterGenerator.tsx +++ b/apps/frontend/components/novel/ChapterGenerator.tsx @@ -116,6 +116,19 @@ export default function ChapterGenerator({ const [contentInstructions, setContentInstructions] = useState(""); useEffect(() => { + // Sync task status on mount - check for stale tasks + const syncTasks = async () => { + try { + await tasksAPI.syncTasks(); + // Refresh data after sync + onUpdate(); + } catch (error) { + console.error("Failed to sync tasks:", error); + } + }; + + syncTasks(); + // Setup WebSocket const socketUrl = process.env.NEXT_PUBLIC_API_URL || diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index 928011c..8e5817e 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -249,6 +249,11 @@ export const tasksAPI = { // Analysis checkOoc: (chapterId: string, content: string) => api.post(`/chapters/${chapterId}/ooc-check`, { content }), + + // Task Sync + syncTasks: () => api.post("/tasks/sync"), + getActiveTasks: (novelId: string) => + api.get(`/novels/${novelId}/tasks/active`), }; // AI Config API