Explorar o código

feat(studio): build creation flow on shared drafts

Ma hai 7 meses
pai
achega
703ac5768a
Modificáronse 2 ficheiros con 402 adicións e 122 borrados
  1. 336 122
      packages/studio/src/pages/BookCreate.tsx
  2. 66 0
      packages/studio/src/pages/page-state.test.ts

+ 336 - 122
packages/studio/src/pages/BookCreate.tsx

@@ -1,5 +1,6 @@
-import { useEffect, useState } from "react";
-import { fetchJson, useApi, postApi } from "../hooks/use-api";
+import { useEffect, useMemo, useState } from "react";
+import type { BookCreationDraft } from "@actalk/inkos-core";
+import { fetchJson, useApi } from "../hooks/use-api";
 import type { Theme } from "../hooks/use-theme";
 import type { TFunction } from "../hooks/use-i18n";
 import { useColors } from "../hooks/use-colors";
@@ -21,6 +22,48 @@ interface PlatformOption {
   readonly label: string;
 }
 
+export interface DraftSummaryRow {
+  readonly key: string;
+  readonly label: string;
+  readonly value: string;
+}
+
+interface InteractionSessionResponse {
+  readonly session?: {
+    readonly activeBookId?: string;
+    readonly creationDraft?: BookCreationDraft;
+  };
+  readonly activeBookId?: string;
+}
+
+interface AgentResponse {
+  readonly response?: string;
+  readonly error?: string;
+  readonly session?: {
+    readonly activeBookId?: string;
+    readonly creationDraft?: BookCreationDraft;
+  };
+}
+
+interface PlatformCopy {
+  readonly idleTitle: string;
+  readonly idleBody: string;
+  readonly promptLabel: string;
+  readonly promptPlaceholder: string;
+  readonly promptPlaceholderFollowup: string;
+  readonly submit: string;
+  readonly submitting: string;
+  readonly create: string;
+  readonly creating: string;
+  readonly discard: string;
+  readonly draftHeading: string;
+  readonly missingHeading: string;
+  readonly missingHint: string;
+  readonly syncedHint: string;
+  readonly helperTitle: string;
+  readonly helperBody: string;
+}
+
 const PLATFORMS_ZH: ReadonlyArray<PlatformOption> = [
   { value: "tomato", label: "番茄小说" },
   { value: "qidian", label: "起点中文网" },
@@ -35,6 +78,45 @@ const PLATFORMS_EN: ReadonlyArray<PlatformOption> = [
   { value: "other", label: "Other" },
 ];
 
+const PAGE_COPY: Record<"zh" | "en", PlatformCopy> = {
+  zh: {
+    idleTitle: "从一句模糊想法开始",
+    idleBody: "直接描述题材、世界观、主角、核心冲突,或告诉我你想先改哪一块。共享草案会在 TUI 和 Studio Chat 之间同步。",
+    promptLabel: "继续打磨这本书",
+    promptPlaceholder: "例如:我想写个港风商战悬疑,主角先做灰产再洗白。",
+    promptPlaceholderFollowup: "例如:世界观改成近未来港口城;女主不要太早出场;卷一先查账再砸场。",
+    submit: "更新草案",
+    submitting: "处理中…",
+    create: "按当前草案建书",
+    creating: "创建中…",
+    discard: "丢弃草案",
+    draftHeading: "当前 foundation 草案",
+    missingHeading: "还缺这些关键信息",
+    missingHint: "这些字段未必都要一次填满,但缺得太多时不要急着建书。",
+    syncedHint: "这份草案和 TUI / Studio Chat 共享。",
+    helperTitle: "建议这样推进",
+    helperBody: "先定世界观和主角,再定核心冲突、简介和卷一方向。想看当前草案时,可以在 TUI 里用 /draft。",
+  },
+  en: {
+    idleTitle: "Start from a rough idea",
+    idleBody: "Describe the genre, world, protagonist, and core conflict. The shared draft stays in sync across TUI and Studio Chat.",
+    promptLabel: "Refine this book",
+    promptPlaceholder: "Example: I want a harbor-noir business thriller about a fixer trying to go legit.",
+    promptPlaceholderFollowup: "Example: move the world to a near-future port city; delay the heroine; make volume one about chasing ledgers first.",
+    submit: "Update draft",
+    submitting: "Working…",
+    create: "Create book from draft",
+    creating: "Creating…",
+    discard: "Discard draft",
+    draftHeading: "Current foundation draft",
+    missingHeading: "Still missing",
+    missingHint: "You do not need every field immediately, but do not create the book while the foundation is still vague.",
+    syncedHint: "This draft is shared with TUI and Studio Chat.",
+    helperTitle: "Recommended flow",
+    helperBody: "Lock the world and protagonist first, then settle the conflict, blurb, and volume-one direction. In TUI, use /draft to inspect the same draft.",
+  },
+};
+
 export function pickValidValue(current: string, available: ReadonlyArray<string>): string {
   if (current && available.includes(current)) {
     return current;
@@ -50,6 +132,56 @@ export function platformOptionsForLanguage(language: "zh" | "en"): ReadonlyArray
   return language === "en" ? PLATFORMS_EN : PLATFORMS_ZH;
 }
 
+export function resolveDraftInstruction(input: string, hasDraft: boolean): string {
+  const trimmed = input.trim();
+  if (!trimmed) {
+    return "";
+  }
+  return hasDraft ? trimmed : `/new ${trimmed}`;
+}
+
+export function canCreateFromDraft(draft?: BookCreationDraft): boolean {
+  if (!draft) {
+    return false;
+  }
+  if (draft.readyToCreate) {
+    return true;
+  }
+  return Boolean(
+    draft.title?.trim()
+      && draft.genre?.trim()
+      && typeof draft.targetChapters === "number"
+      && typeof draft.chapterWordCount === "number",
+  );
+}
+
+export function buildCreationDraftSummary(
+  draft: BookCreationDraft,
+  language: "zh" | "en",
+): ReadonlyArray<DraftSummaryRow> {
+  const rows = language === "en"
+    ? [
+        draft.title ? { key: "title", label: "Title", value: draft.title } : undefined,
+        draft.worldPremise ? { key: "worldPremise", label: "World", value: draft.worldPremise } : undefined,
+        draft.protagonist ? { key: "protagonist", label: "Protagonist", value: draft.protagonist } : undefined,
+        draft.conflictCore ? { key: "conflictCore", label: "Core Conflict", value: draft.conflictCore } : undefined,
+        draft.volumeOutline ? { key: "volumeOutline", label: "Volume Direction", value: draft.volumeOutline } : undefined,
+        draft.blurb ? { key: "blurb", label: "Blurb", value: draft.blurb } : undefined,
+        draft.nextQuestion ? { key: "nextQuestion", label: "Next", value: draft.nextQuestion } : undefined,
+      ]
+    : [
+        draft.title ? { key: "title", label: "书名", value: draft.title } : undefined,
+        draft.worldPremise ? { key: "worldPremise", label: "世界观", value: draft.worldPremise } : undefined,
+        draft.protagonist ? { key: "protagonist", label: "主角", value: draft.protagonist } : undefined,
+        draft.conflictCore ? { key: "conflictCore", label: "核心冲突", value: draft.conflictCore } : undefined,
+        draft.volumeOutline ? { key: "volumeOutline", label: "卷纲方向", value: draft.volumeOutline } : undefined,
+        draft.blurb ? { key: "blurb", label: "简介", value: draft.blurb } : undefined,
+        draft.nextQuestion ? { key: "nextQuestion", label: "下一步", value: draft.nextQuestion } : undefined,
+      ];
+
+  return rows.filter((row): row is DraftSummaryRow => Boolean(row));
+}
+
 interface WaitForBookReadyOptions {
   readonly fetchBook?: (bookId: string) => Promise<unknown>;
   readonly fetchStatus?: (bookId: string) => Promise<{ status: string; error?: string }>;
@@ -60,6 +192,7 @@ interface WaitForBookReadyOptions {
 
 const DEFAULT_BOOK_READY_MAX_ATTEMPTS = 120;
 const DEFAULT_BOOK_READY_DELAY_MS = 250;
+const CREATION_DRAFT_SYNC_INTERVAL_MS = 2500;
 
 export async function waitForBookReady(
   bookId: string,
@@ -112,79 +245,143 @@ export async function waitForBookReady(
 
 export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
   const c = useColors(theme);
-  const { data: genreData } = useApi<{ genres: ReadonlyArray<GenreInfo> }>("/genres");
   const { data: project } = useApi<{ language: string }>("/project");
-
   const projectLang = (project?.language ?? "zh") as "zh" | "en";
+  const copy = PAGE_COPY[projectLang];
 
-  const [title, setTitle] = useState("");
-  const [genre, setGenre] = useState("");
-  const [platform, setPlatform] = useState("");
-  const [chapterWords, setChapterWords] = useState(defaultChapterWordsForLanguage(projectLang));
-  const [chapterWordsTouched, setChapterWordsTouched] = useState(false);
-  const [targetChapters, setTargetChapters] = useState("200");
+  const [draft, setDraft] = useState<BookCreationDraft | undefined>();
+  const [input, setInput] = useState("");
+  const [loadingDraft, setLoadingDraft] = useState(true);
+  const [submitting, setSubmitting] = useState(false);
   const [creating, setCreating] = useState(false);
   const [error, setError] = useState<string | null>(null);
+  const [status, setStatus] = useState<string | null>(null);
 
-  // Filter genres by project language + custom genres (always show)
-  const allGenres = genreData?.genres ?? [];
-  const genres = allGenres.filter((g) => g.language === projectLang || g.source === "project");
-  const platforms = platformOptionsForLanguage(projectLang);
-  const genreSignature = genres.map((g) => g.id).join("|");
-  const platformSignature = platforms.map((p) => `${p.value}:${p.label}`).join("|");
+  const summaryRows = useMemo(
+    () => (draft ? buildCreationDraftSummary(draft, projectLang) : []),
+    [draft, projectLang],
+  );
 
-  useEffect(() => {
-    setGenre((current) => pickValidValue(current, genres.map((g) => g.id)));
-  }, [genreSignature]);
+  const refreshDraft = async (): Promise<BookCreationDraft | undefined> => {
+    const data = await fetchJson<InteractionSessionResponse>("/interaction/session");
+    const nextDraft = data.session?.creationDraft;
+    setDraft(nextDraft);
+    return nextDraft;
+  };
 
   useEffect(() => {
-    setPlatform((current) => pickValidValue(current, platforms.map((p) => p.value)));
-  }, [platformSignature]);
+    let cancelled = false;
+    setLoadingDraft(true);
+    void refreshDraft()
+      .catch((cause) => {
+        if (!cancelled) {
+          setError(cause instanceof Error ? cause.message : String(cause));
+        }
+      })
+      .finally(() => {
+        if (!cancelled) {
+          setLoadingDraft(false);
+        }
+      });
+    return () => {
+      cancelled = true;
+    };
+  }, []);
 
   useEffect(() => {
-    if (!chapterWordsTouched) {
-      setChapterWords(defaultChapterWordsForLanguage(projectLang));
+    if (submitting || creating) {
+      return;
     }
-  }, [projectLang, chapterWordsTouched]);
 
-  const handleCreate = async () => {
-    if (!title.trim()) {
-      setError(t("create.titleRequired"));
+    const timer = setInterval(() => {
+      void refreshDraft().catch(() => undefined);
+    }, CREATION_DRAFT_SYNC_INTERVAL_MS);
+
+    return () => {
+      clearInterval(timer);
+    };
+  }, [submitting, creating]);
+
+  const runAgentInstruction = async (instruction: string): Promise<AgentResponse> => {
+    return fetchJson<AgentResponse>("/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ instruction }),
+    });
+  };
+
+  const handleDraftSubmit = async () => {
+    const instruction = resolveDraftInstruction(input, Boolean(draft));
+    if (!instruction) {
       return;
     }
-    if (!genre) {
-      setError(t("create.genreRequired"));
+
+    setSubmitting(true);
+    setError(null);
+    try {
+      const data = await runAgentInstruction(instruction);
+      setInput("");
+      setStatus(data.response ?? null);
+      setDraft(data.session?.creationDraft);
+    } catch (cause) {
+      setError(cause instanceof Error ? cause.message : String(cause));
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  const handleCreate = async () => {
+    if (!canCreateFromDraft(draft)) {
       return;
     }
+
     setCreating(true);
     setError(null);
     try {
-      const result = await postApi<{ bookId: string }>("/books/create", {
-        title: title.trim(),
-        genre,
-        language: projectLang,
-        platform,
-        chapterWordCount: parseInt(chapterWords, 10),
-        targetChapters: parseInt(targetChapters, 10),
-      });
-      await waitForBookReady(result.bookId);
-      nav.toBook(result.bookId);
-    } catch (e) {
-      setError(e instanceof Error ? e.message : "Failed to create book");
+      const data = await runAgentInstruction("/create");
+      const bookId = data.session?.activeBookId;
+      if (!bookId) {
+        throw new Error(projectLang === "zh" ? "创建完成后没有返回书籍 ID。" : "Create succeeded but no book id was returned.");
+      }
+      setStatus(data.response ?? null);
+      setDraft(undefined);
+      await waitForBookReady(bookId);
+      nav.toBook(bookId);
+    } catch (cause) {
+      setError(cause instanceof Error ? cause.message : String(cause));
     } finally {
       setCreating(false);
     }
   };
 
+  const handleDiscard = async () => {
+    setSubmitting(true);
+    setError(null);
+    try {
+      const data = await runAgentInstruction("/discard");
+      setStatus(data.response ?? null);
+      setDraft(undefined);
+      setInput("");
+      await refreshDraft().catch(() => undefined);
+    } catch (cause) {
+      setError(cause instanceof Error ? cause.message : String(cause));
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
   return (
-    <div className="max-w-lg mx-auto space-y-8">
+    <div className="max-w-4xl mx-auto space-y-8">
       <div className="flex items-center gap-2 text-sm text-muted-foreground">
         <button onClick={nav.toDashboard} className={c.link}>{t("bread.books")}</button>
         <span className="text-border">/</span>
         <span>{t("bread.newBook")}</span>
       </div>
 
-      <h1 className="font-serif text-3xl">{t("create.title")}</h1>
+      <div className="space-y-3">
+        <h1 className="font-serif text-3xl">{t("create.title")}</h1>
+        <p className="text-sm text-muted-foreground leading-7">{copy.idleBody}</p>
+      </div>
 
       {error && (
         <div className={`border ${c.error} rounded-md px-4 py-3`}>
@@ -192,95 +389,112 @@ export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
         </div>
       )}
 
-      <div className="space-y-5">
-        {/* Title */}
-        <div>
-          <label className="block text-sm text-muted-foreground mb-2">{t("create.bookTitle")}</label>
-          <input
-            type="text"
-            value={title}
-            onChange={(e) => setTitle(e.target.value)}
-            className={`w-full ${c.input} rounded-md px-4 py-3 focus:outline-none text-base`}
-            placeholder={t("create.placeholder")}
-          />
+      {status && (
+        <div className="border border-primary/20 bg-primary/5 rounded-md px-4 py-3 text-sm text-primary">
+          {status}
         </div>
+      )}
+
+      <div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
+        <section className="space-y-4">
+          <div className="rounded-2xl border border-border/60 bg-card/70 p-5 space-y-4">
+            <div className="space-y-1">
+              <div className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-bold">
+                {copy.draftHeading}
+              </div>
+              <div className="text-xs text-muted-foreground">
+                {copy.syncedHint}
+              </div>
+            </div>
+
+            {loadingDraft ? (
+              <div className="text-sm text-muted-foreground">{projectLang === "zh" ? "读取共享草案中…" : "Loading shared draft…"}</div>
+            ) : draft ? (
+              <div className="space-y-4">
+                {summaryRows.length > 0 ? (
+                  <div className="space-y-3">
+                    {summaryRows.map((row) => (
+                      <div key={row.key} className="rounded-xl border border-border/50 bg-background/70 px-4 py-3">
+                        <div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground font-semibold">{row.label}</div>
+                        <div className="mt-1 text-sm leading-7 whitespace-pre-wrap">{row.value}</div>
+                      </div>
+                    ))}
+                  </div>
+                ) : null}
 
-        {/* Genre — filtered by language */}
-        <div>
-          <label className="block text-sm text-muted-foreground mb-2">{t("create.genre")}</label>
-          <div className="grid grid-cols-3 gap-2">
-            {genres.map((g) => (
+                {draft.missingFields.length > 0 ? (
+                  <div className="space-y-2">
+                    <div className="text-xs font-medium text-foreground">{copy.missingHeading}</div>
+                    <div className="flex flex-wrap gap-2">
+                      {draft.missingFields.map((field) => (
+                        <span
+                          key={field}
+                          className="rounded-full border border-border/70 bg-secondary/50 px-3 py-1 text-xs text-muted-foreground"
+                        >
+                          {field}
+                        </span>
+                      ))}
+                    </div>
+                    <p className="text-xs text-muted-foreground">{copy.missingHint}</p>
+                  </div>
+                ) : null}
+              </div>
+            ) : (
+              <div className="rounded-xl border border-dashed border-border/70 bg-background/50 px-5 py-6">
+                <div className="font-medium">{copy.idleTitle}</div>
+                <p className="mt-2 text-sm text-muted-foreground leading-7">
+                  {copy.helperBody}
+                </p>
+              </div>
+            )}
+          </div>
+        </section>
+
+        <section className="space-y-4">
+          <div className="rounded-2xl border border-border/60 bg-card/70 p-5 space-y-4">
+            <div className="space-y-1">
+              <div className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-bold">
+                {copy.promptLabel}
+              </div>
+              <div className="text-xs text-muted-foreground">
+                {copy.helperTitle}
+              </div>
+            </div>
+
+            <textarea
+              value={input}
+              onChange={(event) => setInput(event.target.value)}
+              rows={10}
+              className={`w-full ${c.input} rounded-xl px-4 py-3 focus:outline-none text-sm leading-7 resize-y`}
+              placeholder={draft ? copy.promptPlaceholderFollowup : copy.promptPlaceholder}
+            />
+
+            <div className="flex flex-wrap gap-3">
               <button
-                key={g.id}
-                onClick={() => setGenre(g.id)}
-                className={`px-3 py-2.5 rounded-md text-sm text-left transition-all ${
-                  genre === g.id
-                    ? "bg-primary/15 text-primary border border-primary/30 font-medium"
-                    : "bg-secondary text-secondary-foreground border border-transparent hover:border-border"
-                }`}
+                onClick={handleDraftSubmit}
+                disabled={submitting || creating || !input.trim()}
+                className={`px-4 py-3 ${c.btnPrimary} rounded-md disabled:opacity-50 font-medium text-sm`}
               >
-                {g.name}
-                {g.source === "project" && <span className="text-xs text-muted-foreground ml-1">✦</span>}
+                {submitting ? copy.submitting : copy.submit}
               </button>
-            ))}
-          </div>
-        </div>
-
-        {/* Platform — filtered by language */}
-        <div>
-          <label className="block text-sm text-muted-foreground mb-2">
-            {t("create.platform")}
-          </label>
-          <div className="flex gap-2">
-            {platforms.map((p) => (
               <button
-                key={p.value}
-                onClick={() => setPlatform(p.value)}
-                className={`px-3 py-2 rounded-md text-sm transition-all ${
-                  platform === p.value
-                    ? "bg-primary/15 text-primary border border-primary/30"
-                    : "bg-secondary text-secondary-foreground border border-transparent hover:border-border"
-                }`}
+                onClick={handleCreate}
+                disabled={!canCreateFromDraft(draft) || creating || submitting}
+                className={`px-4 py-3 rounded-md border border-border bg-secondary text-secondary-foreground disabled:opacity-50 font-medium text-sm`}
               >
-                {p.label}
+                {creating ? copy.creating : copy.create}
               </button>
-            ))}
-          </div>
-        </div>
-
-        {/* Word count + chapters */}
-        <div className="grid grid-cols-2 gap-4">
-          <div>
-            <label className="block text-sm text-muted-foreground mb-2">{t("create.wordsPerChapter")}</label>
-            <input
-              type="number"
-              value={chapterWords}
-              onChange={(e) => {
-                setChapterWordsTouched(true);
-                setChapterWords(e.target.value);
-              }}
-              className={`w-full ${c.input} rounded-md px-4 py-3 focus:outline-none`}
-            />
-          </div>
-          <div>
-            <label className="block text-sm text-muted-foreground mb-2">{t("create.targetChapters")}</label>
-            <input
-              type="number"
-              value={targetChapters}
-              onChange={(e) => setTargetChapters(e.target.value)}
-              className={`w-full ${c.input} rounded-md px-4 py-3 focus:outline-none`}
-            />
+              <button
+                onClick={handleDiscard}
+                disabled={!draft || submitting || creating}
+                className="px-4 py-3 rounded-md border border-border text-muted-foreground hover:text-foreground hover:border-foreground/20 disabled:opacity-50 font-medium text-sm"
+              >
+                {copy.discard}
+              </button>
+            </div>
           </div>
-        </div>
+        </section>
       </div>
-
-      <button
-        onClick={handleCreate}
-        disabled={creating || !title.trim()}
-        className={`w-full px-4 py-3 ${c.btnPrimary} rounded-md disabled:opacity-50 font-medium text-base`}
-      >
-        {creating ? t("create.creating") : t("create.submit")}
-      </button>
     </div>
   );
 }

+ 66 - 0
packages/studio/src/pages/page-state.test.ts

@@ -1,8 +1,11 @@
 import { describe, expect, it } from "vitest";
 import {
+  buildCreationDraftSummary,
+  canCreateFromDraft,
   defaultChapterWordsForLanguage,
   platformOptionsForLanguage,
   pickValidValue,
+  resolveDraftInstruction,
   waitForBookReady,
 } from "./BookCreate";
 
@@ -93,3 +96,66 @@ describe("waitForBookReady", () => {
     })).rejects.toThrow("INKOS_LLM_API_KEY not set");
   });
 });
+
+describe("resolveDraftInstruction", () => {
+  it("forces the first ideation turn through /new so an active book does not hijack the flow", () => {
+    expect(resolveDraftInstruction("我想写个港风商战悬疑", false)).toBe("/new 我想写个港风商战悬疑");
+    expect(resolveDraftInstruction("把世界观改成近未来港口城", true)).toBe("把世界观改成近未来港口城");
+  });
+});
+
+describe("canCreateFromDraft", () => {
+  it("accepts drafts explicitly marked ready", () => {
+    expect(canCreateFromDraft({
+      concept: "港风商战悬疑",
+      readyToCreate: true,
+      missingFields: [],
+    })).toBe(true);
+  });
+
+  it("accepts drafts that already have the minimum creation fields", () => {
+    expect(canCreateFromDraft({
+      concept: "港风商战悬疑",
+      title: "夜港账本",
+      genre: "urban",
+      targetChapters: 120,
+      chapterWordCount: 2800,
+      readyToCreate: false,
+      missingFields: [],
+    })).toBe(true);
+  });
+
+  it("rejects incomplete drafts", () => {
+    expect(canCreateFromDraft({
+      concept: "港风商战悬疑",
+      title: "夜港账本",
+      readyToCreate: false,
+      missingFields: ["genre", "targetChapters"],
+    })).toBe(false);
+  });
+});
+
+describe("buildCreationDraftSummary", () => {
+  it("surfaces the shared foundation draft in a user-facing order", () => {
+    expect(buildCreationDraftSummary({
+      concept: "港风商战悬疑,主角从灰产洗白。",
+      title: "夜港账本",
+      worldPremise: "近未来港口城,账本牵出多方势力。",
+      protagonist: "林砚,水货账房出身,擅长记账和看人。",
+      conflictCore: "洗白与旧债回潮的对撞。",
+      volumeOutline: "卷一先查账,再暴露港口旧案。",
+      blurb: "一个做灰产生意的人,准备在夜港洗白,却先被旧账拖回去。",
+      nextQuestion: "卷一先查账还是先砸场?",
+      missingFields: ["targetChapters"],
+      readyToCreate: false,
+    }, "zh")).toEqual([
+      { key: "title", label: "书名", value: "夜港账本" },
+      { key: "worldPremise", label: "世界观", value: "近未来港口城,账本牵出多方势力。" },
+      { key: "protagonist", label: "主角", value: "林砚,水货账房出身,擅长记账和看人。" },
+      { key: "conflictCore", label: "核心冲突", value: "洗白与旧债回潮的对撞。" },
+      { key: "volumeOutline", label: "卷纲方向", value: "卷一先查账,再暴露港口旧案。" },
+      { key: "blurb", label: "简介", value: "一个做灰产生意的人,准备在夜港洗白,却先被旧账拖回去。" },
+      { key: "nextQuestion", label: "下一步", value: "卷一先查账还是先砸场?" },
+    ]);
+  });
+});