Browse Source

feat: inject event context into Steps 0+4, add Step 7 ReaderFeedback

Task 9: Event-aware pipeline - inject event graph context into Step0ControlCard
and iron-rules/style-anchor anti-drift content into Step4ContentEditor.

Task 10: Step 7 ReaderFeedback - every 5 chapters, AI simulates reader experience
with scores for pace, excitement density, emotional engagement, and confusion.
Saves feedback to logs/reader-feedback/ for future milestone audits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nonvioce 6 months ago
parent
commit
6c67e9c647

+ 93 - 0
src/app/api/novel/reader-feedback/route.ts

@@ -0,0 +1,93 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { isPathSafe } from '@/app/lib/novel-server-utils';
+import { promises as fs } from 'fs';
+import path from 'path';
+
+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, chapterIndex, chapterContent, apiSettings } = body;
+  if (!projectPath || !isPathSafe(projectPath) || !apiSettings || !chapterContent) {
+    return NextResponse.json({ success: false, error: '缺少必要参数' }, { status: 400 });
+  }
+
+  // Only trigger every 5 chapters, but allow override
+  if (chapterIndex % 5 !== 0 && !body.force) {
+    return NextResponse.json({ success: true, skipped: true });
+  }
+
+  // Get recent chapters for context
+  let recentContext = '';
+  const startChapter = Math.max(1, chapterIndex - 4);
+  const chaptersDir = path.join(projectPath, 'chapters');
+  try {
+    const files = await fs.readdir(chaptersDir);
+    for (const file of files) {
+      const match = file.match(/^chapter_(\d+)_/);
+      if (match) {
+        const idx = parseInt(match[1]);
+        if (idx >= startChapter && idx <= chapterIndex) {
+          const content = await fs.readFile(path.join(chaptersDir, file), 'utf-8');
+          recentContext += `\n### 第${idx}章\n${content.slice(0, 800)}\n`;
+        }
+      }
+    }
+  } catch { /* continue without context */ }
+
+  const prompt = `你是一位付费阅读的资深读者。请对以下小说章节给出读者反馈。
+
+## 最近章节(摘要)
+${recentContext.slice(0, 3000)}
+
+## 当前章节
+${chapterContent.slice(0, 3000)}
+
+## 评分维度(1-10分)
+1. 节奏感:情节推进是否紧凑?是否拖沓或过赶?
+2. 爽点密度:情绪释放是否到位?是否有满足感的峰值?
+3. 情感共鸣:是否关心角色的命运?是否有打动人的时刻?
+4. 阅读困惑:哪些地方可能需要回看才能理解?是否有逻辑断层?
+
+## 输出格式
+只返回 JSON:
+{
+  "pace": 7,
+  "excitementDensity": 6,
+  "emotionalEngagement": 8,
+  "confusion": 3,
+  "suggestions": "具体的读者感受和建议,2-3句话"
+}`;
+
+  try {
+    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: 500 }),
+      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(/\{[\s\S]*\}/);
+    const feedback = jsonMatch ? JSON.parse(jsonMatch[0]) : null;
+
+    // Save feedback to file
+    const feedbackDir = path.join(projectPath, 'logs', 'reader-feedback');
+    await fs.mkdir(feedbackDir, { recursive: true });
+    const range = `${startChapter}-${chapterIndex}`;
+    await fs.writeFile(
+      path.join(feedbackDir, `chapters-${range}.json`),
+      JSON.stringify({ ...feedback, chapterRange: range }, null, 2),
+      'utf-8'
+    );
+
+    return NextResponse.json({ success: true, feedback });
+  } catch (error) {
+    return NextResponse.json({ success: false, error: (error as Error).message }, { status: 500 });
+  }
+}

+ 63 - 4
src/app/features/novel-writer/components/ChapterDraftPipeline.tsx

@@ -1,8 +1,8 @@
 'use client';
 'use client';
 
 
-import { useState, useCallback } from 'react';
+import { useState, useCallback, useEffect } from 'react';
 import { X, Check } from 'lucide-react';
 import { X, Check } from 'lucide-react';
-import { WorldStateFile, WorldLoreFile, WritingConstraint, AntiHomogenizationStrategy, NarrativeHook } from '@/app/lib/novel-types';
+import { WorldStateFile, WorldLoreFile, WritingConstraint, AntiHomogenizationStrategy, NarrativeHook, EventNode, ChapterAllocation } from '@/app/lib/novel-types';
 import { parseHooksFromJson } from '@/app/lib/novel-utils';
 import { parseHooksFromJson } from '@/app/lib/novel-utils';
 import Step0ControlCard from './pipeline/Step0ControlCard';
 import Step0ControlCard from './pipeline/Step0ControlCard';
 import Step1PlotSelection from './pipeline/Step1PlotSelection';
 import Step1PlotSelection from './pipeline/Step1PlotSelection';
@@ -11,6 +11,7 @@ import Step3ResolveHooks from './pipeline/Step3ResolveHooks';
 import Step4ContentEditor from './pipeline/Step4ContentEditor';
 import Step4ContentEditor from './pipeline/Step4ContentEditor';
 import Step5ReviewSubmit from './pipeline/Step5ReviewSubmit';
 import Step5ReviewSubmit from './pipeline/Step5ReviewSubmit';
 import Step6ContinuityCheck from './pipeline/Step6ContinuityCheck';
 import Step6ContinuityCheck from './pipeline/Step6ContinuityCheck';
+import Step7ReaderFeedback from './pipeline/Step7ReaderFeedback';
 import type { ControlCardData } from './pipeline/Step0ControlCard';
 import type { ControlCardData } from './pipeline/Step0ControlCard';
 
 
 interface ApiSettings {
 interface ApiSettings {
@@ -30,11 +31,14 @@ interface ChapterDraftPipelineProps {
   wordCountMin: number;
   wordCountMin: number;
   wordCountMax: number;
   wordCountMax: number;
   recentChaptersCount: number;
   recentChaptersCount: number;
+  chapterEvents?: EventNode[];
+  ironRules?: string;
+  styleAnchor?: string;
   onComplete: (chapterTitle: string, content: string) => void;
   onComplete: (chapterTitle: string, content: string) => void;
   onCancel: () => void;
   onCancel: () => void;
 }
 }
 
 
-type PipelineStep = 0 | 1 | 2 | 3 | 4 | 5 | 6;
+type PipelineStep = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
 
 
 interface PipelineState {
 interface PipelineState {
   controlCard: ControlCardData | null;
   controlCard: ControlCardData | null;
@@ -54,6 +58,7 @@ const STEPS = [
   { id: 4, label: '生成内容' },
   { id: 4, label: '生成内容' },
   { id: 5, label: '审核提交' },
   { id: 5, label: '审核提交' },
   { id: 6, label: '连续性检查' },
   { id: 6, label: '连续性检查' },
+  { id: 7, label: '读者反馈' },
 ] as const;
 ] as const;
 
 
 export default function ChapterDraftPipeline({
 export default function ChapterDraftPipeline({
@@ -103,6 +108,9 @@ export default function ChapterDraftPipeline({
   const [currentStep, setCurrentStep] = useState<PipelineStep>(0);
   const [currentStep, setCurrentStep] = useState<PipelineStep>(0);
   const [showCancelConfirm, setShowCancelConfirm] = useState(false);
   const [showCancelConfirm, setShowCancelConfirm] = useState(false);
   const [pipelineState, setPipelineState] = useState<PipelineState>(getInitialState);
   const [pipelineState, setPipelineState] = useState<PipelineState>(getInitialState);
+  const [chapterEvents, setChapterEvents] = useState<EventNode[]>([]);
+  const [ironRulesContent, setIronRulesContent] = useState('');
+  const [styleAnchorContent, setStyleAnchorContent] = useState('');
 
 
   // Auto-save state to sessionStorage on every change
   // Auto-save state to sessionStorage on every change
   const savePipelineState = (newState: PipelineState) => {
   const savePipelineState = (newState: PipelineState) => {
@@ -126,6 +134,37 @@ export default function ChapterDraftPipeline({
     return parseHooksFromJson(hooksFile.content).filter((h) => h.status === 'open');
     return parseHooksFromJson(hooksFile.content).filter((h) => h.status === 'open');
   })();
   })();
 
 
+  // 加载事件图谱和防漂移文件
+  useEffect(() => {
+    // Load event graph allocation for this chapter
+    fetch(`/api/novel/event-graph?projectPath=${encodeURIComponent(projectPath)}`)
+      .then(r => r.json())
+      .then(data => {
+        if (data.success && data.chapterAllocations) {
+          const alloc = (data.chapterAllocations as ChapterAllocation[]).find(
+            a => a.chapterIndex === chapterIndex
+          );
+          if (alloc) {
+            setChapterEvents((data.events as EventNode[]).filter(e => alloc.eventIds.includes(e.id)));
+          }
+        }
+      })
+      .catch(() => {});
+
+    // Load iron-rules and style-anchor
+    fetch('/api/novel/files', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ action: 'read', projectPath, filePath: 'iron-rules.md' }),
+    }).then(r => r.json()).then(d => { if (d.success) setIronRulesContent(d.content as string); }).catch(() => {});
+
+    fetch('/api/novel/files', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ action: 'read', projectPath, filePath: 'style-anchor.md' }),
+    }).then(r => r.json()).then(d => { if (d.success) setStyleAnchorContent(d.content as string); }).catch(() => {});
+  }, [projectPath, chapterIndex]);
+
   // Step 0 完成(控制卡)
   // Step 0 完成(控制卡)
   const handleStep0Next = useCallback((data: ControlCardData) => {
   const handleStep0Next = useCallback((data: ControlCardData) => {
     updateState((prev) => ({ ...prev, controlCard: data }));
     updateState((prev) => ({ ...prev, controlCard: data }));
@@ -188,8 +227,13 @@ export default function ChapterDraftPipeline({
     []
     []
   );
   );
 
 
-  // Step 6 完成 → 最终提交
+  // Step 6 完成 → Step 7 读者反馈
   const handleStep6Complete = useCallback(() => {
   const handleStep6Complete = useCallback(() => {
+    setCurrentStep(7);
+  }, []);
+
+  // Step 7 完成 → 最终提交
+  const handleStep7Complete = useCallback(() => {
     sessionStorage.removeItem(storageKey);
     sessionStorage.removeItem(storageKey);
     const title = pipelineState.controlCard?.workingTitle || `第${chapterIndex}章`;
     const title = pipelineState.controlCard?.workingTitle || `第${chapterIndex}章`;
     onComplete(title, pipelineState.chapterContent);
     onComplete(title, pipelineState.chapterContent);
@@ -295,6 +339,7 @@ export default function ChapterDraftPipeline({
               chapterIndex={chapterIndex}
               chapterIndex={chapterIndex}
               apiSettings={apiSettings}
               apiSettings={apiSettings}
               savedControlCard={pipelineState.controlCard}
               savedControlCard={pipelineState.controlCard}
+              chapterEvents={chapterEvents}
               onNext={handleStep0Next}
               onNext={handleStep0Next}
             />
             />
           )}
           )}
@@ -348,6 +393,8 @@ export default function ChapterDraftPipeline({
               wordCountMin={wordCountMin}
               wordCountMin={wordCountMin}
               wordCountMax={wordCountMax}
               wordCountMax={wordCountMax}
               recentChaptersCount={recentChaptersCount}
               recentChaptersCount={recentChaptersCount}
+              ironRules={ironRulesContent}
+              styleAnchor={styleAnchorContent}
               onNext={handleStep4Next}
               onNext={handleStep4Next}
               onBack={() => setCurrentStep(3)}
               onBack={() => setCurrentStep(3)}
             />
             />
@@ -381,6 +428,18 @@ export default function ChapterDraftPipeline({
               onBack={() => setCurrentStep(5)}
               onBack={() => setCurrentStep(5)}
             />
             />
           )}
           )}
+
+          {/* Step 7: 读者反馈 */}
+          {currentStep === 7 && (
+            <Step7ReaderFeedback
+              projectPath={projectPath}
+              chapterIndex={chapterIndex}
+              chapterContent={pipelineState.chapterContent}
+              apiSettings={apiSettings}
+              onComplete={handleStep7Complete}
+              onSkip={handleStep7Complete}
+            />
+          )}
         </div>
         </div>
       </div>
       </div>
     </div>
     </div>

+ 14 - 1
src/app/features/novel-writer/components/pipeline/Step0ControlCard.tsx

@@ -3,6 +3,7 @@
 import { useState } from 'react';
 import { useState } from 'react';
 import { Sparkles, ArrowRight } from 'lucide-react';
 import { Sparkles, ArrowRight } from 'lucide-react';
 import { buildControlCardPrompt } from '@/app/lib/chapter-prompts';
 import { buildControlCardPrompt } from '@/app/lib/chapter-prompts';
+import type { EventNode } from '@/app/lib/novel-types';
 
 
 interface ApiSettings {
 interface ApiSettings {
   apiUrl: string;
   apiUrl: string;
@@ -15,6 +16,7 @@ interface Step0ControlCardProps {
   chapterIndex: number;
   chapterIndex: number;
   apiSettings: ApiSettings | null;
   apiSettings: ApiSettings | null;
   savedControlCard: ControlCardData | null;
   savedControlCard: ControlCardData | null;
+  chapterEvents?: EventNode[];
   onNext: (data: ControlCardData) => void;
   onNext: (data: ControlCardData) => void;
 }
 }
 
 
@@ -34,6 +36,7 @@ export default function Step0ControlCard({
   chapterIndex,
   chapterIndex,
   apiSettings,
   apiSettings,
   savedControlCard,
   savedControlCard,
+  chapterEvents,
   onNext,
   onNext,
 }: Step0ControlCardProps) {
 }: Step0ControlCardProps) {
   const [whatChanges, setWhatChanges] = useState(savedControlCard?.whatChanges || '');
   const [whatChanges, setWhatChanges] = useState(savedControlCard?.whatChanges || '');
@@ -76,7 +79,17 @@ export default function Step0ControlCard({
         } catch { /* file may not exist */ }
         } catch { /* file may not exist */ }
       }
       }
 
 
-      const prompt = buildControlCardPrompt(chapterIndex, contextParts.join('\n\n'));
+      let projectContext = contextParts.join('\n\n');
+
+      // Append event context if available
+      if (chapterEvents && chapterEvents.length > 0) {
+        projectContext += '\n\n## 本章事件节点\n';
+        projectContext += chapterEvents.map(e =>
+          `- **${e.title}** (${e.timeLabel}, ${e.location}): ${e.description} [类型:${e.eventType}]`
+        ).join('\n');
+      }
+
+      const prompt = buildControlCardPrompt(chapterIndex, projectContext);
 
 
       const requestBody = {
       const requestBody = {
         model: apiSettings.model,
         model: apiSettings.model,

+ 12 - 1
src/app/features/novel-writer/components/pipeline/Step4ContentEditor.tsx

@@ -24,6 +24,8 @@ interface Step4ContentEditorProps {
   wordCountMin: number;
   wordCountMin: number;
   wordCountMax: number;
   wordCountMax: number;
   recentChaptersCount: number;
   recentChaptersCount: number;
+  ironRules?: string;
+  styleAnchor?: string;
   onNext: (content: string) => void;
   onNext: (content: string) => void;
   onBack: () => void;
   onBack: () => void;
 }
 }
@@ -56,6 +58,8 @@ export default function Step4ContentEditor({
   wordCountMin,
   wordCountMin,
   wordCountMax,
   wordCountMax,
   recentChaptersCount,
   recentChaptersCount,
+  ironRules,
+  styleAnchor,
   onNext,
   onNext,
   onBack,
   onBack,
 }: Step4ContentEditorProps) {
 }: Step4ContentEditorProps) {
@@ -156,7 +160,12 @@ export default function Step4ContentEditor({
         } catch { /* style guide file may not exist */ }
         } catch { /* style guide file may not exist */ }
       } catch { /* ignore */ }
       } catch { /* ignore */ }
 
 
-      return `${worldContext}${styleGuideText}
+      const antiDriftSection = [
+        ironRules ? `## 项目铁律\n${ironRules}` : '',
+        styleAnchor ? `## 风格锚定\n${styleAnchor.slice(0, 3000)}` : '',
+      ].filter(Boolean).join('\n\n');
+
+      return `${worldContext}${styleGuideText}${antiDriftSection ? `\n\n${antiDriftSection}` : ''}
 
 
 请根据以下要求创作本章完整内容:
 请根据以下要求创作本章完整内容:
 
 
@@ -190,6 +199,8 @@ ${antiText}
       antiHomogenizationStrategies,
       antiHomogenizationStrategies,
       wordCountMin,
       wordCountMin,
       wordCountMax,
       wordCountMax,
+      ironRules,
+      styleAnchor,
     ]
     ]
   );
   );
 
 

+ 131 - 0
src/app/features/novel-writer/components/pipeline/Step7ReaderFeedback.tsx

@@ -0,0 +1,131 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Check, SkipForward, Star } from 'lucide-react';
+
+interface Step7ReaderFeedbackProps {
+  projectPath: string;
+  chapterIndex: number;
+  chapterContent: string;
+  apiSettings: { apiUrl: string; apiKey: string; model: string } | null;
+  onComplete: () => void;
+  onSkip: () => void;
+}
+
+interface Feedback {
+  pace: number;
+  excitementDensity: number;
+  emotionalEngagement: number;
+  confusion: number;
+  suggestions: string;
+}
+
+export default function Step7ReaderFeedback({
+  projectPath, chapterIndex, chapterContent, apiSettings, onComplete, onSkip,
+}: Step7ReaderFeedbackProps) {
+  const [feedback, setFeedback] = useState<Feedback | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  const shouldRun = chapterIndex % 5 === 0;
+
+  useEffect(() => {
+    if (!shouldRun || !apiSettings) return;
+    setLoading(true);
+    fetch('/api/novel/reader-feedback', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ projectPath, chapterIndex, chapterContent, apiSettings }),
+    })
+      .then(r => r.json())
+      .then(data => {
+        if (data.success) {
+          if (data.skipped) return;
+          setFeedback(data.feedback);
+        } else {
+          setError(data.error || '请求失败');
+        }
+      })
+      .catch(() => setError('网络请求失败'))
+      .finally(() => setLoading(false));
+  }, [shouldRun, chapterIndex]);
+
+  if (!shouldRun) {
+    return (
+      <div className="flex flex-col items-center justify-center h-full gap-4">
+        <SkipForward className="h-12 w-12 text-gray-300" />
+        <p className="text-gray-500 text-sm">读者反馈每 5 章触发一次</p>
+        <p className="text-sm text-gray-400">
+          第 {chapterIndex} 章不需要反馈(下一轮在第 {Math.ceil(chapterIndex / 5) * 5} 章)
+        </p>
+        <button onClick={onSkip} className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700">
+          跳过,完成任务
+        </button>
+      </div>
+    );
+  }
+
+  if (loading) {
+    return (
+      <div className="flex items-center justify-center h-full">
+        <div className="text-center">
+          <div className="animate-spin w-8 h-8 border-2 border-indigo-600 border-t-transparent rounded-full mx-auto mb-3" />
+          <p className="text-gray-500 text-sm">AI 正在模拟读者体验...</p>
+        </div>
+      </div>
+    );
+  }
+
+  if (error) {
+    return <div className="flex items-center justify-center h-full text-red-500 text-sm">{error}</div>;
+  }
+
+  if (!feedback) return null;
+
+  const scoreBars = [
+    { label: '节奏感', value: feedback.pace },
+    { label: '爽点密度', value: feedback.excitementDensity },
+    { label: '情感共鸣', value: feedback.emotionalEngagement },
+    { label: '阅读困惑', value: feedback.confusion },
+  ];
+
+  return (
+    <div className="flex flex-col items-center h-full py-8">
+      <div className="text-center mb-6">
+        <Star className="h-10 w-10 text-yellow-400 mx-auto mb-2" />
+        <h2 className="text-lg font-semibold text-gray-800">读者反馈</h2>
+        <p className="text-sm text-gray-500">第 {chapterIndex} 章 · AI 模拟读者视角</p>
+      </div>
+
+      <div className="space-y-3 max-w-md w-full">
+        {scoreBars.map(({ label, value }) => (
+          <div key={label}>
+            <div className="flex justify-between text-xs text-gray-600 mb-1">
+              <span>{label}</span>
+              <span className="font-medium">{value}/10</span>
+            </div>
+            <div className="w-full bg-gray-200 rounded-full h-2">
+              <div
+                className={`h-2 rounded-full transition-all ${
+                  label === '阅读困惑'
+                    ? value > 5 ? 'bg-red-500' : 'bg-green-500'
+                    : value >= 7 ? 'bg-green-500' : value >= 5 ? 'bg-yellow-500' : 'bg-red-500'
+                }`}
+                style={{ width: `${value * 10}%` }}
+              />
+            </div>
+          </div>
+        ))}
+        <div className="mt-4 p-4 bg-gray-50 rounded-lg">
+          <p className="text-sm text-gray-700">{feedback.suggestions}</p>
+        </div>
+      </div>
+
+      <div className="mt-8">
+        <button onClick={onComplete} className="px-6 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 flex items-center gap-2">
+          <Check className="h-4 w-4" /> 记录反馈并完成
+        </button>
+      </div>
+    </div>
+  );
+}