Skip to content

Commit a288e25

Browse files
ChanMeng666claude
andcommitted
feat: HSR DreamWriter - complete product redesign
Transform FanFic Lab from generic AI writing tool to autonomous Honkai: Star Rail story delivery service. - Adaptive LangGraph agent with 5 nodes + quality loop - Pre-built HSR knowledge pack (characters, relationships, world) - Single-input creation flow with SSE progress streaming - New pages: landing, create, story reader, shelf - Updated navigation for new product flow Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2 parents cbd31cc + 171f0de commit a288e25

33 files changed

Lines changed: 10105 additions & 15811 deletions

package-lock.json

Lines changed: 8007 additions & 15709 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
},
88
"scripts": {
99
"dev": "next dev",
10-
"dev:agent": "langgraphjs dev --host 0.0.0.0 --port 8123 --config src/agent/langgraph.json",
10+
"dev:agent": "dotenvx run --env-file=.env.local --env-file=.env -- langgraphjs dev --host 0.0.0.0 --port 8123 --config src/agent/langgraph.json",
1111
"dev:all": "concurrently \"npm run dev\" \"npm run dev:agent\"",
1212
"build": "prisma generate && next build",
1313
"postinstall": "prisma generate",
@@ -50,6 +50,7 @@
5050
"motion": "^12.23.26",
5151
"next": "16.1.1",
5252
"next-themes": "^0.4.6",
53+
"openai": "^6.33.0",
5354
"pg": "^8.17.1",
5455
"prisma": "^7.2.0",
5556
"react": "19.2.3",
@@ -60,10 +61,12 @@
6061
"zod": "^3.25.76"
6162
},
6263
"devDependencies": {
64+
"@dotenvx/dotenvx": "^1.59.1",
6365
"@tailwindcss/postcss": "^4",
6466
"@types/node": "^20",
6567
"@types/react": "^19",
6668
"@types/react-dom": "^19",
69+
"commander": "^14.0.3",
6770
"eslint": "^9",
6871
"eslint-config-next": "16.1.1",
6972
"tailwindcss": "^4",

prisma/schema.prisma

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ model UserPreferences {
5252
aiPersonality String @default("helpful") // AI writing style preference
5353
darkMode Boolean @default(false)
5454
55+
// DreamWriter preference learning
56+
preferredCPs String[] // Learned from generation history
57+
preferredTropes String[] // e.g., ["现代AU", "甜饼", "ABO"]
58+
avoidTropes String[] // e.g., ["主要角色死亡", "NTR"]
59+
tonePreference String? // e.g., "偏虐带HE", "纯甜"
60+
lengthPreference String? // "short" | "medium" | "long"
61+
5562
createdAt DateTime @default(now())
5663
updatedAt DateTime @updatedAt
5764
}
@@ -350,6 +357,22 @@ enum GenerationStatus {
350357
FAILED
351358
}
352359

360+
// ============================================
361+
// KNOWLEDGE BASE (RAG for DreamWriter)
362+
// ============================================
363+
364+
model KnowledgeChunk {
365+
id String @id @default(cuid())
366+
fandom String @default("hsr")
367+
content String @db.Text
368+
embedding Unsupported("vector(1536)")
369+
metadata Json // { chapter?, characters?, location?, version? }
370+
371+
createdAt DateTime @default(now())
372+
373+
@@index([fandom])
374+
}
375+
353376
// ============================================
354377
// RESEARCH CACHE (Cost optimization)
355378
// ============================================

src/agent/dreamwriter/graph.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { StateGraph, MemorySaver, START, END } from "@langchain/langgraph";
2+
import { DreamWriterStateAnnotation } from "./state";
3+
import { intentParserNode } from "./nodes/intent-parser";
4+
import { storyArchitectNode } from "./nodes/story-architect";
5+
import { writerNode } from "./nodes/writer";
6+
import { qualityGuardNode } from "./nodes/quality-guard";
7+
import { deliveryNode } from "./nodes/delivery";
8+
import type { DreamWriterState } from "./state";
9+
10+
const MAX_REVISIONS = 2;
11+
12+
function routeAfterQualityCheck(state: DreamWriterState): string {
13+
const report = state.qualityReport;
14+
if (!report) return "delivery_node";
15+
if (!report.passesThreshold && state.revisionCount < MAX_REVISIONS) {
16+
console.log(`[DreamWriter] Quality score ${report.overallScore}/10, revision ${state.revisionCount + 1}/${MAX_REVISIONS}`);
17+
return "writer_node";
18+
}
19+
return "delivery_node";
20+
}
21+
22+
async function revisionCounterNode(state: DreamWriterState): Promise<Partial<DreamWriterState>> {
23+
return { revisionCount: state.revisionCount + 1, stage: "revising", logs: [{ message: `正在根据反馈修改第 ${state.revisionCount + 1} 版...`, done: true }] };
24+
}
25+
26+
const workflow = new StateGraph(DreamWriterStateAnnotation)
27+
.addNode("intent_parser_node", intentParserNode)
28+
.addNode("story_architect_node", storyArchitectNode)
29+
.addNode("writer_node", writerNode)
30+
.addNode("quality_guard_node", qualityGuardNode)
31+
.addNode("revision_counter_node", revisionCounterNode)
32+
.addNode("delivery_node", deliveryNode)
33+
.addEdge(START, "intent_parser_node")
34+
.addEdge("intent_parser_node", "story_architect_node")
35+
.addEdge("story_architect_node", "writer_node")
36+
.addEdge("writer_node", "quality_guard_node")
37+
.addConditionalEdges("quality_guard_node", routeAfterQualityCheck, { writer_node: "revision_counter_node", delivery_node: "delivery_node" })
38+
.addEdge("revision_counter_node", "writer_node")
39+
.addEdge("delivery_node", END);
40+
41+
const memory = new MemorySaver();
42+
export const graph = workflow.compile({ checkpointer: memory });
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { ChatOpenAI } from "@langchain/openai";
2+
import { SystemMessage, HumanMessage } from "@langchain/core/messages";
3+
import type { RunnableConfig } from "@langchain/core/runnables";
4+
import type { DreamWriterState } from "../state";
5+
import { DELIVERY_PROMPT } from "../prompts/system";
6+
import type { StoryResult } from "../../../lib/types/dreamwriter";
7+
8+
function parseJsonSafe(text: string): unknown {
9+
const cleaned = text.replace(/```json?\s*/g, "").replace(/```\s*/g, "").trim();
10+
return JSON.parse(cleaned);
11+
}
12+
13+
function countWords(text: string): number {
14+
const chinese = (text.match(/[\u4e00-\u9fff]/g) || []).length;
15+
const english = text.replace(/[\u4e00-\u9fff]/g, "").trim().split(/\s+/).filter(Boolean).length;
16+
return chinese + english;
17+
}
18+
19+
export async function deliveryNode(state: DreamWriterState, _config: RunnableConfig): Promise<Partial<DreamWriterState>> {
20+
console.log("[DreamWriter] ========== DELIVERY ==========");
21+
const outline = state.outline;
22+
const story = state.storyDraft;
23+
if (!outline || !story) return { stage: "error", logs: [{ message: "缺少故事数据", done: true }] };
24+
25+
let suggestions: string[] = [];
26+
try {
27+
const model = new ChatOpenAI({ temperature: 0.7, model: "gpt-4o-mini" });
28+
const response = await model.invoke([new SystemMessage(DELIVERY_PROMPT), new HumanMessage(`当前故事:${outline.title}\nCP: ${outline.cp.join(" × ")}\n设定: ${outline.setting}\n基调: ${outline.tone}`)]);
29+
const content = typeof response.content === "string" ? response.content : JSON.stringify(response.content);
30+
suggestions = parseJsonSafe(content) as string[];
31+
} catch { suggestions = ["换一个设定试试?", "试试不同的情感基调?", "看看其他CP?"]; }
32+
33+
const result: StoryResult = { title: outline.title, body: story, cp: outline.cp, tags: [outline.setting, outline.tone], setting: outline.setting, wordCount: countWords(story), qualityScore: state.qualityReport?.overallScore ?? 7, language: state.detectedLanguage, suggestions };
34+
return { stage: "complete", result, logs: [{ message: `创作完成!《${result.title}》共 ${result.wordCount} 字`, done: true }] };
35+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { ChatOpenAI } from "@langchain/openai";
2+
import { SystemMessage, HumanMessage } from "@langchain/core/messages";
3+
import type { RunnableConfig } from "@langchain/core/runnables";
4+
import type { DreamWriterState } from "../state";
5+
import { INTENT_PARSER_PROMPT } from "../prompts/system";
6+
7+
function parseJsonSafe(text: string): Record<string, unknown> {
8+
const cleaned = text.replace(/```json?\s*/g, "").replace(/```\s*/g, "").trim();
9+
return JSON.parse(cleaned);
10+
}
11+
12+
export async function intentParserNode(
13+
state: DreamWriterState,
14+
_config: RunnableConfig
15+
): Promise<Partial<DreamWriterState>> {
16+
console.log("[DreamWriter] ========== INTENT PARSER ==========");
17+
const lastMessage = state.messages[state.messages.length - 1];
18+
const userInput = typeof lastMessage.content === "string" ? lastMessage.content : JSON.stringify(lastMessage.content);
19+
const model = new ChatOpenAI({ temperature: 0.3, model: "gpt-4o-mini" });
20+
const response = await model.invoke([new SystemMessage(INTENT_PARSER_PROMPT), new HumanMessage(userInput)]);
21+
const content = typeof response.content === "string" ? response.content : JSON.stringify(response.content);
22+
try {
23+
const parsed = parseJsonSafe(content) as { cp: string[]; setting: string; tone: string; constraints: Record<string, string>; language: "zh" | "en" };
24+
return { stage: "parsing", parsedCP: parsed.cp || [], parsedSetting: parsed.setting || "原著向", parsedTone: parsed.tone || "甜", parsedConstraints: parsed.constraints || {}, detectedLanguage: parsed.language || "zh", logs: [{ message: "已理解你的创作需求", done: true }] };
25+
} catch {
26+
return { stage: "parsing", parsedCP: [], parsedSetting: "原著向", parsedTone: "甜", parsedConstraints: { ending: "HE", rating: "T", length: "medium" }, detectedLanguage: "zh", logs: [{ message: "已理解你的创作需求(使用默认设定)", done: true }] };
27+
}
28+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { ChatOpenAI } from "@langchain/openai";
2+
import { SystemMessage, HumanMessage } from "@langchain/core/messages";
3+
import type { RunnableConfig } from "@langchain/core/runnables";
4+
import type { DreamWriterState } from "../state";
5+
import { QUALITY_GUARD_PROMPT } from "../prompts/system";
6+
import { getHSRKnowledgePrompt } from "../prompts/hsr";
7+
import type { QualityReport } from "../../../lib/types/dreamwriter";
8+
9+
function parseJsonSafe(text: string): Record<string, unknown> {
10+
const cleaned = text.replace(/```json?\s*/g, "").replace(/```\s*/g, "").trim();
11+
return JSON.parse(cleaned);
12+
}
13+
14+
export async function qualityGuardNode(state: DreamWriterState, _config: RunnableConfig): Promise<Partial<DreamWriterState>> {
15+
console.log("[DreamWriter] ========== QUALITY GUARD ==========");
16+
if (!state.storyDraft) return { stage: "error", logs: [{ message: "没有故事草稿可供检查", done: true }] };
17+
const knowledgePrompt = getHSRKnowledgePrompt();
18+
const systemPrompt = QUALITY_GUARD_PROMPT(knowledgePrompt);
19+
const checkInput = `故事需求:\nCP: ${state.parsedCP.join(" × ")}\n设定: ${state.parsedSetting}\n基调: ${state.parsedTone}\n\n故事正文:\n${state.storyDraft}`;
20+
const model = new ChatOpenAI({ temperature: 0.3, model: "gpt-4o-mini" });
21+
const response = await model.invoke([new SystemMessage(systemPrompt), new HumanMessage(checkInput)]);
22+
const content = typeof response.content === "string" ? response.content : JSON.stringify(response.content);
23+
try {
24+
const report = parseJsonSafe(content) as QualityReport;
25+
return { stage: "checking", qualityReport: report, logs: [{ message: report.passesThreshold ? `质量检查通过 (${report.overallScore}/10)` : `质量检查未通过 (${report.overallScore}/10),正在修改...`, done: true }] };
26+
} catch {
27+
return { stage: "checking", qualityReport: { overallScore: 7, oocIssues: [], consistencyIssues: [], proseNotes: [], passesThreshold: true }, logs: [{ message: "质量检查完成", done: true }] };
28+
}
29+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { ChatOpenAI } from "@langchain/openai";
2+
import { SystemMessage, HumanMessage } from "@langchain/core/messages";
3+
import type { RunnableConfig } from "@langchain/core/runnables";
4+
import type { DreamWriterState } from "../state";
5+
import { STORY_ARCHITECT_PROMPT } from "../prompts/system";
6+
import { getHSRKnowledgePrompt } from "../prompts/hsr";
7+
import type { StoryOutline } from "../../../lib/types/dreamwriter";
8+
9+
function parseJsonSafe(text: string): Record<string, unknown> {
10+
const cleaned = text.replace(/```json?\s*/g, "").replace(/```\s*/g, "").trim();
11+
return JSON.parse(cleaned);
12+
}
13+
14+
export async function storyArchitectNode(state: DreamWriterState, _config: RunnableConfig): Promise<Partial<DreamWriterState>> {
15+
console.log("[DreamWriter] ========== STORY ARCHITECT ==========");
16+
const knowledgePrompt = getHSRKnowledgePrompt();
17+
const systemPrompt = STORY_ARCHITECT_PROMPT(knowledgePrompt);
18+
const requestSummary = `CP: ${state.parsedCP.join(" × ")}\n设定: ${state.parsedSetting}\n基调: ${state.parsedTone}\n约束: ${JSON.stringify(state.parsedConstraints)}`;
19+
const model = new ChatOpenAI({ temperature: 0.8, model: "gpt-4o" });
20+
const response = await model.invoke([new SystemMessage(systemPrompt), new HumanMessage(requestSummary)]);
21+
const content = typeof response.content === "string" ? response.content : JSON.stringify(response.content);
22+
try {
23+
const parsed = parseJsonSafe(content) as StoryOutline;
24+
return { stage: "planning", outline: parsed, logs: [{ message: `故事构思完成:${parsed.title}`, done: true }] };
25+
} catch {
26+
const fallbackOutline: StoryOutline = { title: `${state.parsedCP.join("×")}的故事`, cp: state.parsedCP, setting: state.parsedSetting, tone: state.parsedTone, wordTarget: 3000, scenes: [{ summary: "完整短篇", characters: state.parsedCP, emotion: state.parsedTone }], emotionalArc: "起承转合" };
27+
return { stage: "planning", outline: fallbackOutline, logs: [{ message: `故事构思完成:${fallbackOutline.title}`, done: true }] };
28+
}
29+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { ChatOpenAI } from "@langchain/openai";
2+
import { SystemMessage, HumanMessage } from "@langchain/core/messages";
3+
import type { RunnableConfig } from "@langchain/core/runnables";
4+
import type { DreamWriterState } from "../state";
5+
import { WRITER_PROMPT } from "../prompts/system";
6+
import { getHSRKnowledgePrompt, buildRAGContext } from "../prompts/hsr";
7+
import { retrieveRelevantChunks } from "../../../knowledge/base/rag";
8+
9+
export async function writerNode(state: DreamWriterState, _config: RunnableConfig): Promise<Partial<DreamWriterState>> {
10+
console.log("[DreamWriter] ========== WRITER ==========");
11+
const outline = state.outline;
12+
if (!outline) return { stage: "error", logs: [{ message: "没有故事大纲", done: true }] };
13+
14+
const ragQuery = `${outline.cp.join(" ")} ${outline.setting} ${outline.scenes.map((s) => s.summary).join(" ")}`;
15+
let ragChunks: { content: string }[] = [];
16+
try { ragChunks = await retrieveRelevantChunks(ragQuery, "hsr", 3); } catch (e) { console.log("[DreamWriter] RAG retrieval failed, continuing without:", e); }
17+
18+
const knowledgePrompt = getHSRKnowledgePrompt();
19+
const ragContext = buildRAGContext(ragChunks);
20+
const systemPrompt = WRITER_PROMPT(knowledgePrompt + ragContext);
21+
22+
const outlineText = `标题:${outline.title}\nCP:${outline.cp.join(" × ")}\n设定:${outline.setting}\n基调:${outline.tone}\n目标字数:${outline.wordTarget}\n情感曲线:${outline.emotionalArc}\n\n场景安排:\n${outline.scenes.map((s, i) => `${i + 1}. ${s.summary}(角色:${s.characters.join("、")},情绪:${s.emotion})`).join("\n")}${state.qualityReport ? `\n\n上一版的质量反馈(请针对性修改):\n${state.qualityReport.oocIssues.map((i) => `- ${i.character}: ${i.issue}${i.suggestion}`).join("\n")}\n${state.qualityReport.proseNotes.map((n) => `- ${n}`).join("\n")}` : ""}`;
23+
24+
const model = new ChatOpenAI({ temperature: 0.9, model: "gpt-4o" });
25+
const response = await model.invoke([new SystemMessage(systemPrompt), new HumanMessage(outlineText)]);
26+
const story = typeof response.content === "string" ? response.content : JSON.stringify(response.content);
27+
return { stage: "writing", storyDraft: story, ragContext: ragChunks.map((c) => c.content), logs: [{ message: "故事初稿完成,正在进行质量检查...", done: true }] };
28+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { hsrKnowledge } from "../../../knowledge/hsr";
2+
3+
export function getHSRKnowledgePrompt(): string {
4+
return hsrKnowledge.toSystemPrompt();
5+
}
6+
7+
export function buildRAGContext(chunks: { content: string }[]): string {
8+
if (chunks.length === 0) return "";
9+
return `\n\n## 原著参考段落\n以下是与当前创作相关的原著段落,请参考其中的描写风格和细节:\n\n${chunks.map((c, i) => `[参考${i + 1}] ${c.content}`).join("\n\n")}`;
10+
}

0 commit comments

Comments
 (0)