Просмотр исходного кода

feat: add event-graph CRUD, validate, and allocate API routes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nonvioce 6 месяцев назад
Родитель
Сommit
63fc02d662

+ 77 - 0
src/app/api/novel/event-graph/allocate/route.ts

@@ -0,0 +1,77 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { promises as fs } from 'fs';
+import path from 'path';
+import { isPathSafe } from '@/app/lib/novel-server-utils';
+
+export async function POST(request: NextRequest) {
+  const body = await request.json().catch(() => null);
+  if (!body) return NextResponse.json({ success: false, error: '无效请求' }, { status: 400 });
+
+  const { projectPath, chapterWordMin, chapterWordMax, totalChapters, apiSettings } = body;
+  if (!projectPath || !isPathSafe(projectPath) || !apiSettings) {
+    return NextResponse.json({ success: false, error: '缺少参数' }, { status: 400 });
+  }
+
+  try {
+    const graphDir = path.join(projectPath, 'event-graph');
+    const [eventsRaw, edgesRaw] = await Promise.all([
+      fs.readFile(path.join(graphDir, 'events.json'), 'utf-8'),
+      fs.readFile(path.join(graphDir, 'edges.json'), 'utf-8'),
+    ]);
+    const events = JSON.parse(eventsRaw);
+
+    const prompt = `你是一位小说结构规划师。请将以下事件分配到各章节。
+
+## 事件列表
+${JSON.stringify(events, null, 2)}
+
+## 边缘
+${edgesRaw}
+
+## 约束
+- 总章数:约 ${totalChapters || '待定'} 章
+- 每章字数:${chapterWordMin || 2000}-${chapterWordMax || 4000} 字
+- 每章事件数:3-8 个
+- 每章应有完整的小起承转合
+- 伏笔事件 (foreshadow) 与呼应事件间隔 3-10 章
+- 预叙/倒叙事件每卷不超过 2 个
+
+## 输出格式
+返回 JSON 数组(只返回 JSON):
+[{"chapterIndex":1,"eventIds":["evt-1","evt-2"],"narrativeMode":"linear"}, ...]`;
+
+    const response = await fetch(apiSettings.apiUrl, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Authorization: `Bearer ${apiSettings.apiKey}`,
+      },
+      body: JSON.stringify({
+        model: apiSettings.model,
+        messages: [{ role: 'user', content: prompt }],
+        max_tokens: 4000,
+      }),
+      signal: AbortSignal.timeout(55000),
+    });
+
+    if (!response.ok) {
+      return NextResponse.json({ success: false, error: `AI 请求失败: ${response.status}` }, { status: 502 });
+    }
+
+    const data = await response.json();
+    const text = data.choices?.[0]?.message?.content || '';
+    const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/) || [null, text];
+    const allocations = JSON.parse(jsonMatch[1].trim());
+
+    // Save allocation
+    await fs.writeFile(
+      path.join(graphDir, 'chapter-allocation.json'),
+      JSON.stringify(allocations, null, 2),
+      'utf-8'
+    );
+
+    return NextResponse.json({ success: true, allocations });
+  } catch (error) {
+    return NextResponse.json({ success: false, error: (error as Error).message }, { status: 500 });
+  }
+}

+ 56 - 0
src/app/api/novel/event-graph/route.ts

@@ -0,0 +1,56 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { promises as fs } from 'fs';
+import path from 'path';
+import { isPathSafe } from '@/app/lib/novel-server-utils';
+
+export async function GET(request: NextRequest) {
+  const projectPath = request.nextUrl.searchParams.get('projectPath') || '';
+  if (!projectPath || !isPathSafe(projectPath)) {
+    return NextResponse.json({ success: false, error: '无效路径' }, { status: 400 });
+  }
+
+  try {
+    const graphDir = path.join(projectPath, 'event-graph');
+    const [eventsRaw, edgesRaw, allocationRaw] = await Promise.all([
+      fs.readFile(path.join(graphDir, 'events.json'), 'utf-8').catch(() => '[]'),
+      fs.readFile(path.join(graphDir, 'edges.json'), 'utf-8').catch(() => '[]'),
+      fs.readFile(path.join(graphDir, 'chapter-allocation.json'), 'utf-8').catch(() => '[]'),
+    ]);
+    return NextResponse.json({
+      success: true,
+      events: JSON.parse(eventsRaw),
+      edges: JSON.parse(edgesRaw),
+      chapterAllocations: JSON.parse(allocationRaw),
+    });
+  } catch (error) {
+    return NextResponse.json({ success: false, error: (error as Error).message }, { status: 500 });
+  }
+}
+
+export async function POST(request: NextRequest) {
+  const body = await request.json().catch(() => null);
+  if (!body) return NextResponse.json({ success: false, error: '无效请求' }, { status: 400 });
+
+  const { projectPath, events, edges, chapterAllocations } = body;
+  if (!projectPath || !isPathSafe(projectPath)) {
+    return NextResponse.json({ success: false, error: '无效路径' }, { status: 400 });
+  }
+
+  try {
+    const graphDir = path.join(projectPath, 'event-graph');
+    const writes: Promise<void>[] = [];
+    if (events !== undefined) {
+      writes.push(fs.writeFile(path.join(graphDir, 'events.json'), JSON.stringify(events, null, 2), 'utf-8'));
+    }
+    if (edges !== undefined) {
+      writes.push(fs.writeFile(path.join(graphDir, 'edges.json'), JSON.stringify(edges, null, 2), 'utf-8'));
+    }
+    if (chapterAllocations !== undefined) {
+      writes.push(fs.writeFile(path.join(graphDir, 'chapter-allocation.json'), JSON.stringify(chapterAllocations, null, 2), 'utf-8'));
+    }
+    await Promise.all(writes);
+    return NextResponse.json({ success: true });
+  } catch (error) {
+    return NextResponse.json({ success: false, error: (error as Error).message }, { status: 500 });
+  }
+}

+ 75 - 0
src/app/api/novel/event-graph/validate/route.ts

@@ -0,0 +1,75 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { promises as fs } from 'fs';
+import path from 'path';
+import { isPathSafe } from '@/app/lib/novel-server-utils';
+import { validateEventGraph } from '@/app/lib/event-graph-utils';
+
+export async function POST(request: NextRequest) {
+  const body = await request.json().catch(() => null);
+  if (!body) return NextResponse.json({ success: false, error: '无效请求' }, { status: 400 });
+
+  const { projectPath, useAI, apiSettings } = body;
+  if (!projectPath || !isPathSafe(projectPath)) {
+    return NextResponse.json({ success: false, error: '无效路径' }, { status: 400 });
+  }
+
+  try {
+    const graphDir = path.join(projectPath, 'event-graph');
+    const [eventsRaw, edgesRaw] = await Promise.all([
+      fs.readFile(path.join(graphDir, 'events.json'), 'utf-8'),
+      fs.readFile(path.join(graphDir, 'edges.json'), 'utf-8'),
+    ]);
+    const events = JSON.parse(eventsRaw);
+    const edges = JSON.parse(edgesRaw);
+
+    // Structural validation
+    const structuralResult = validateEventGraph(events, edges);
+
+    // AI validation (optional)
+    let aiFeedback = null;
+    if (useAI && apiSettings) {
+      const prompt = `你是一位小说结构顾问。请检查以下事件图是否存在逻辑问题。
+
+## 事件列表
+${JSON.stringify(events, null, 2)}
+
+## 事件关系
+${JSON.stringify(edges, null, 2)}
+
+## 检查项
+1. 事件是否形成完整的因果链(无断裂)?
+2. 主角是否参与足够多的事件(不过度缺席)?
+3. 高潮事件(tags 含"高潮")的分布是否合理?
+4. 伏笔事件(eventType=foreshadow)是否有对应的呼应?
+5. 是否有事件冗余或可合并?
+
+请用中文简要给出问题和建议。`;
+
+      const response = await fetch(apiSettings.apiUrl, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          Authorization: `Bearer ${apiSettings.apiKey}`,
+        },
+        body: JSON.stringify({
+          model: apiSettings.model,
+          messages: [{ role: 'user', content: prompt }],
+          max_tokens: 1500,
+        }),
+        signal: AbortSignal.timeout(55000),
+      });
+      if (response.ok) {
+        const data = await response.json();
+        aiFeedback = data.choices?.[0]?.message?.content || null;
+      }
+    }
+
+    return NextResponse.json({
+      success: true,
+      structural: structuralResult,
+      aiFeedback,
+    });
+  } catch (error) {
+    return NextResponse.json({ success: false, error: (error as Error).message }, { status: 500 });
+  }
+}