Переглянути джерело

fix(studio): simplify book creation form

Ma 6 місяців тому
батько
коміт
d1566dbb04

+ 1 - 0
packages/studio/src/api/book-create.ts

@@ -7,6 +7,7 @@ export interface StudioCreateBookBody {
   readonly platform?: string;
   readonly chapterWordCount?: number;
   readonly targetChapters?: number;
+  readonly blurb?: string;
 }
 
 export interface StudioBookConfigDraft {

+ 2 - 0
packages/studio/src/api/server.test.ts

@@ -1700,6 +1700,7 @@ describe("createStudioServer daemon lifecycle", () => {
         language: "zh",
         chapterWordCount: 2600,
         targetChapters: 88,
+        blurb: "主角在旧城查账洗白,卷一先追账本。",
       }),
     });
 
@@ -1715,6 +1716,7 @@ describe("createStudioServer daemon lifecycle", () => {
         platform: "qidian",
         chapterWordCount: 2600,
         targetChapters: 88,
+        blurb: "主角在旧城查账洗白,卷一先追账本。",
       },
     }));
   });

+ 2 - 0
packages/studio/src/api/server.ts

@@ -906,6 +906,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       platform?: string;
       chapterWordCount?: number;
       targetChapters?: number;
+      blurb?: string;
     }>();
 
     const now = new Date().toISOString();
@@ -936,6 +937,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         platform: body.platform,
         chapterWordCount: body.chapterWordCount,
         targetChapters: body.targetChapters,
+        blurb: body.blurb,
       },
       tools,
     }).then(

+ 358 - 75
packages/studio/src/pages/BookCreate.tsx

@@ -1,5 +1,6 @@
 import { useEffect, useMemo, useState } from "react";
 import type { BookCreationDraft } from "@actalk/inkos-core";
+import { BookPlus, CheckCircle2, RotateCcw, Sparkles } from "lucide-react";
 import { fetchJson, useApi } from "../hooks/use-api";
 import type { Theme } from "../hooks/use-theme";
 import type { TFunction } from "../hooks/use-i18n";
@@ -15,18 +16,30 @@ interface Nav {
   toBook: (id: string) => void;
 }
 
-interface GenreInfo {
-  readonly id: string;
-  readonly name: string;
-  readonly source: "project" | "builtin";
-  readonly language: "zh" | "en";
-}
-
 interface PlatformOption {
   readonly value: string;
   readonly label: string;
 }
 
+export interface BookCreateFormState {
+  readonly title: string;
+  readonly genre: string;
+  readonly platform: string;
+  readonly targetChapters: string;
+  readonly chapterWordCount: string;
+  readonly brief: string;
+}
+
+export interface BookCreatePayload {
+  readonly title: string;
+  readonly genre: string;
+  readonly platform: string;
+  readonly language: "zh" | "en";
+  readonly targetChapters: number;
+  readonly chapterWordCount: number;
+  readonly blurb: string;
+}
+
 export interface DraftSummaryRow {
   readonly key: string;
   readonly label: string;
@@ -61,6 +74,24 @@ interface SessionResponse {
 interface PlatformCopy {
   readonly idleTitle: string;
   readonly idleBody: string;
+  readonly formHeading: string;
+  readonly formHint: string;
+  readonly titleLabel: string;
+  readonly titlePlaceholder: string;
+  readonly genreLabel: string;
+  readonly genrePlaceholder: string;
+  readonly platformLabel: string;
+  readonly targetChaptersLabel: string;
+  readonly chapterWordCountLabel: string;
+  readonly briefLabel: string;
+  readonly briefPlaceholder: string;
+  readonly createBook: string;
+  readonly creatingBook: string;
+  readonly creationStatus: string;
+  readonly creationSteps: ReadonlyArray<string>;
+  readonly assistantHeading: string;
+  readonly assistantHint: string;
+  readonly applyDraft: string;
   readonly promptLabel: string;
   readonly promptPlaceholder: string;
   readonly promptPlaceholderFollowup: string;
@@ -94,7 +125,25 @@ const PLATFORMS_EN: ReadonlyArray<PlatformOption> = [
 const PAGE_COPY: Record<"zh" | "en", PlatformCopy> = {
   zh: {
     idleTitle: "从一句模糊想法开始",
-    idleBody: "直接描述题材、世界观、主角、核心冲突,或告诉我你想先改哪一块。共享草案会在 TUI 和 Studio Chat 之间同步。",
+    idleBody: "先填清楚书名、题材和故事核心,系统会生成基础设定并进入新书工作台。",
+    formHeading: "书籍基础信息",
+    formHint: "这些字段会直接进入建书流程。简介写得越具体,后续基础设定越稳定。",
+    titleLabel: "书名",
+    titlePlaceholder: "例如:夜港账本",
+    genreLabel: "题材 / 类型",
+    genrePlaceholder: "例如:都市悬疑、玄幻、科幻、女频情感",
+    platformLabel: "目标平台",
+    targetChaptersLabel: "目标章数",
+    chapterWordCountLabel: "每章字数",
+    briefLabel: "故事简介 / 核心设定",
+    briefPlaceholder: "写清世界观、主角、目标、核心冲突和第一阶段方向。例如:近未来港口城,主角是水货账房,想洗白却被旧账拖回港口旧案。",
+    createBook: "创建书籍",
+    creatingBook: "创建中…",
+    creationStatus: "正在创建书籍,完成后会自动进入工作台。",
+    creationSteps: ["写入书籍配置", "生成基础设定", "准备工作台"],
+    assistantHeading: "需要先让 AI 帮你补设定?",
+    assistantHint: "这块是辅助草案,不是必须步骤。已有草案可以一键套用到左侧表单。",
+    applyDraft: "套用草案",
     promptLabel: "继续打磨这本书",
     promptPlaceholder: "例如:我想写个港风商战悬疑,主角先做灰产再洗白。",
     promptPlaceholderFollowup: "例如:世界观改成近未来港口城;女主不要太早出场;卷一先查账再砸场。",
@@ -103,7 +152,7 @@ const PAGE_COPY: Record<"zh" | "en", PlatformCopy> = {
     create: "按当前草案建书",
     creating: "创建中…",
     discard: "丢弃草案",
-    draftHeading: "当前 foundation 草案",
+    draftHeading: "当前基础设定草案",
     missingHeading: "还缺这些关键信息",
     missingHint: "这些字段未必都要一次填满,但缺得太多时不要急着建书。",
     syncedHint: "这份草案和 TUI / Studio Chat 共享。",
@@ -112,7 +161,25 @@ const PAGE_COPY: Record<"zh" | "en", PlatformCopy> = {
   },
   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.",
+    idleBody: "Fill in the title, genre, and story core first. InkOS will generate the foundation and open the new workspace.",
+    formHeading: "Book basics",
+    formHint: "These fields go straight into creation. A concrete brief gives the foundation generator better material.",
+    titleLabel: "Title",
+    titlePlaceholder: "Example: Ledger of the Night Port",
+    genreLabel: "Genre",
+    genrePlaceholder: "Example: mystery, urban fantasy, sci-fi, romance",
+    platformLabel: "Target platform",
+    targetChaptersLabel: "Target chapters",
+    chapterWordCountLabel: "Words per chapter",
+    briefLabel: "Story brief / core premise",
+    briefPlaceholder: "Include the world, protagonist, goal, core conflict, and first arc direction.",
+    createBook: "Create book",
+    creatingBook: "Creating…",
+    creationStatus: "Creating the book. The workspace will open automatically when it is ready.",
+    creationSteps: ["Saving config", "Generating foundation", "Preparing workspace"],
+    assistantHeading: "Want AI to shape the idea first?",
+    assistantHint: "This draft area is optional. If a draft looks useful, apply it to the form.",
+    applyDraft: "Apply draft",
     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.",
@@ -141,10 +208,56 @@ export function defaultChapterWordsForLanguage(language: "zh" | "en"): string {
   return language === "en" ? "2000" : "3000";
 }
 
+export function defaultBookCreateForm(language: "zh" | "en"): BookCreateFormState {
+  return {
+    title: "",
+    genre: "",
+    platform: platformOptionsForLanguage(language)[0]?.value ?? "other",
+    targetChapters: "200",
+    chapterWordCount: defaultChapterWordsForLanguage(language),
+    brief: "",
+  };
+}
+
 export function platformOptionsForLanguage(language: "zh" | "en"): ReadonlyArray<PlatformOption> {
   return language === "en" ? PLATFORMS_EN : PLATFORMS_ZH;
 }
 
+function parsePositiveInteger(value: string): number | null {
+  const parsed = Number.parseInt(value.trim(), 10);
+  return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
+}
+
+export function isBookCreateFormReady(form: BookCreateFormState): boolean {
+  return Boolean(
+    form.title.trim()
+      && form.genre.trim()
+      && form.brief.trim()
+      && parsePositiveInteger(form.targetChapters)
+      && parsePositiveInteger(form.chapterWordCount),
+  );
+}
+
+export function buildBookCreatePayload(
+  form: BookCreateFormState,
+  language: "zh" | "en",
+): BookCreatePayload {
+  const targetChapters = parsePositiveInteger(form.targetChapters);
+  const chapterWordCount = parsePositiveInteger(form.chapterWordCount);
+  if (!targetChapters || !chapterWordCount || !isBookCreateFormReady(form)) {
+    throw new Error(language === "zh" ? "请先补齐建书表单。" : "Complete the book creation form first.");
+  }
+  return {
+    title: form.title.trim(),
+    genre: form.genre.trim(),
+    platform: form.platform,
+    language,
+    targetChapters,
+    chapterWordCount,
+    blurb: form.brief.trim(),
+  };
+}
+
 export function resolveDraftInstruction(input: string, hasDraft: boolean): string {
   const trimmed = input.trim();
   if (!trimmed) {
@@ -339,8 +452,10 @@ export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
   const { data: project } = useApi<{ language: string }>("/project");
   const projectLang = (project?.language ?? "zh") as "zh" | "en";
   const copy = PAGE_COPY[projectLang];
+  const platformChoices = platformOptionsForLanguage(projectLang);
 
   const [draft, setDraft] = useState<BookCreationDraft | undefined>();
+  const [form, setForm] = useState<BookCreateFormState>(() => defaultBookCreateForm(projectLang));
   const [input, setInput] = useState("");
   const [loadingDraft, setLoadingDraft] = useState(true);
   const [submitting, setSubmitting] = useState(false);
@@ -353,6 +468,45 @@ export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
     () => (draft ? buildCreationDraftSummary(draft, projectLang) : []),
     [draft, projectLang],
   );
+  const canSubmitForm = isBookCreateFormReady(form);
+
+  useEffect(() => {
+    setForm((current) => ({
+      ...current,
+      platform: pickValidValue(
+        current.platform,
+        platformOptionsForLanguage(projectLang).map((option) => option.value),
+      ),
+      chapterWordCount: current.chapterWordCount || defaultChapterWordsForLanguage(projectLang),
+      targetChapters: current.targetChapters || "200",
+    }));
+  }, [projectLang]);
+
+  const updateForm = (patch: Partial<BookCreateFormState>) => {
+    setForm((current) => ({ ...current, ...patch }));
+  };
+
+  const applyDraftToForm = () => {
+    if (!draft) {
+      return;
+    }
+    const draftBrief = [
+      draft.blurb,
+      draft.worldPremise,
+      draft.protagonist,
+      draft.conflictCore,
+      draft.volumeOutline,
+    ].filter((part): part is string => Boolean(part?.trim())).join("\n\n");
+    const platformValues = platformChoices.map((option) => option.value);
+    setForm((current) => ({
+      title: draft.title?.trim() || current.title,
+      genre: draft.genre?.trim() || current.genre,
+      platform: pickValidValue(draft.platform ?? current.platform, platformValues),
+      targetChapters: draft.targetChapters ? String(draft.targetChapters) : current.targetChapters,
+      chapterWordCount: draft.chapterWordCount ? String(draft.chapterWordCount) : current.chapterWordCount,
+      brief: draftBrief || current.brief,
+    }));
+  };
 
   const refreshDraft = async (): Promise<BookCreationDraft | undefined> => {
     const data = await fetchJson<InteractionSessionResponse>("/interaction/session");
@@ -442,6 +596,34 @@ export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
     }
   };
 
+  const handleFormCreate = async () => {
+    if (!canSubmitForm) {
+      return;
+    }
+
+    setCreating(true);
+    setError(null);
+    setStatus(copy.creationStatus);
+    try {
+      const payload = buildBookCreatePayload(form, projectLang);
+      const data = await fetchJson<{ status?: string; bookId?: string }>("/books/create", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(payload),
+      });
+      if (!data.bookId) {
+        throw new Error(projectLang === "zh" ? "创建请求没有返回书籍 ID。" : "Create request did not return a book id.");
+      }
+      await waitForBookReady(data.bookId);
+      nav.toBook(data.bookId);
+    } catch (cause) {
+      setError(cause instanceof Error ? cause.message : String(cause));
+      setStatus(null);
+    } finally {
+      setCreating(false);
+    }
+  };
+
   const handleCreate = async () => {
     if (!canCreateFromDraft(draft)) {
       return;
@@ -483,7 +665,7 @@ export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
   };
 
   return (
-    <div className="max-w-4xl mx-auto space-y-8">
+    <div className="max-w-5xl 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>
@@ -491,8 +673,8 @@ export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
       </div>
 
       <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>
+        <h1 className="font-serif text-4xl">{t("create.title")}</h1>
+        <p className="text-sm text-muted-foreground leading-7 max-w-2xl">{copy.idleBody}</p>
       </div>
 
       {error && (
@@ -507,28 +689,158 @@ export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
         </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="grid gap-6 lg:grid-cols-[minmax(0,1.25fr)_minmax(300px,0.75fr)]">
+        <section className="rounded-lg border border-border/60 bg-card/80 p-5 space-y-5">
+          <div className="space-y-1">
+            <div className="text-[11px] uppercase text-muted-foreground font-bold">
+              {copy.formHeading}
+            </div>
+            <p className="text-xs text-muted-foreground leading-6">{copy.formHint}</p>
+          </div>
+
+          <div className="grid gap-4 sm:grid-cols-2">
+            <label className="space-y-2">
+              <span className="text-xs font-medium text-muted-foreground">{copy.titleLabel}</span>
+              <input
+                value={form.title}
+                onChange={(event) => updateForm({ title: event.target.value })}
+                className={`w-full ${c.input} rounded-md px-3 py-2.5 focus:outline-none text-sm`}
+                placeholder={copy.titlePlaceholder}
+              />
+            </label>
+            <label className="space-y-2">
+              <span className="text-xs font-medium text-muted-foreground">{copy.genreLabel}</span>
+              <input
+                value={form.genre}
+                onChange={(event) => updateForm({ genre: event.target.value })}
+                className={`w-full ${c.input} rounded-md px-3 py-2.5 focus:outline-none text-sm`}
+                placeholder={copy.genrePlaceholder}
+              />
+            </label>
+          </div>
+
+          <div className="grid gap-4 sm:grid-cols-3">
+            <label className="space-y-2">
+              <span className="text-xs font-medium text-muted-foreground">{copy.platformLabel}</span>
+              <select
+                value={form.platform}
+                onChange={(event) => updateForm({ platform: event.target.value })}
+                className={`w-full ${c.input} rounded-md px-3 py-2.5 focus:outline-none text-sm bg-background`}
+              >
+                {platformChoices.map((option) => (
+                  <option key={option.value} value={option.value}>{option.label}</option>
+                ))}
+              </select>
+            </label>
+            <label className="space-y-2">
+              <span className="text-xs font-medium text-muted-foreground">{copy.targetChaptersLabel}</span>
+              <input
+                type="number"
+                min={1}
+                value={form.targetChapters}
+                onChange={(event) => updateForm({ targetChapters: event.target.value })}
+                className={`w-full ${c.input} rounded-md px-3 py-2.5 focus:outline-none text-sm`}
+              />
+            </label>
+            <label className="space-y-2">
+              <span className="text-xs font-medium text-muted-foreground">{copy.chapterWordCountLabel}</span>
+              <input
+                type="number"
+                min={1000}
+                value={form.chapterWordCount}
+                onChange={(event) => updateForm({ chapterWordCount: event.target.value })}
+                className={`w-full ${c.input} rounded-md px-3 py-2.5 focus:outline-none text-sm`}
+              />
+            </label>
+          </div>
+
+          <label className="space-y-2 block">
+            <span className="text-xs font-medium text-muted-foreground">{copy.briefLabel}</span>
+            <textarea
+              value={form.brief}
+              onChange={(event) => updateForm({ brief: event.target.value })}
+              rows={9}
+              className={`w-full ${c.input} rounded-md px-3 py-3 focus:outline-none text-sm leading-7 resize-y`}
+              placeholder={copy.briefPlaceholder}
+            />
+          </label>
+
+          {creating && (
+            <div className="grid gap-2 sm:grid-cols-3">
+              {copy.creationSteps.map((step) => (
+                <div key={step} className="flex items-center gap-2 rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-xs text-primary">
+                  <CheckCircle2 size={14} />
+                  <span>{step}</span>
+                </div>
+              ))}
+            </div>
+          )}
+
+          <button
+            onClick={handleFormCreate}
+            disabled={!canSubmitForm || creating || submitting}
+            className={`inline-flex items-center gap-2 px-5 py-3 ${c.btnPrimary} rounded-md disabled:opacity-50 font-medium text-sm`}
+          >
+            <BookPlus size={16} />
+            {creating ? copy.creatingBook : copy.createBook}
+          </button>
+        </section>
+
+        <aside className="space-y-4">
+          <section className="rounded-lg border border-border/60 bg-card/80 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 className="text-[11px] uppercase text-muted-foreground font-bold">
+                {copy.assistantHeading}
               </div>
-              <div className="text-xs text-muted-foreground">
-                {copy.syncedHint}
+              <p className="text-xs text-muted-foreground leading-6">{copy.assistantHint}</p>
+            </div>
+
+            <textarea
+              value={input}
+              onChange={(event) => setInput(event.target.value)}
+              rows={7}
+              className={`w-full ${c.input} rounded-md px-3 py-3 focus:outline-none text-sm leading-7 resize-y`}
+              placeholder={draft ? copy.promptPlaceholderFollowup : copy.promptPlaceholder}
+            />
+
+            <div className="flex flex-wrap gap-2">
+              <button
+                onClick={handleDraftSubmit}
+                disabled={submitting || creating || !input.trim()}
+                className={`inline-flex items-center gap-2 px-3 py-2 ${c.btnPrimary} rounded-md disabled:opacity-50 font-medium text-xs`}
+              >
+                <Sparkles size={14} />
+                {submitting ? copy.submitting : copy.submit}
+              </button>
+              <button
+                onClick={handleDiscard}
+                disabled={!draft || submitting || creating}
+                className="inline-flex items-center gap-2 px-3 py-2 rounded-md border border-border text-muted-foreground hover:text-foreground hover:border-foreground/20 disabled:opacity-50 font-medium text-xs"
+              >
+                <RotateCcw size={14} />
+                {copy.discard}
+              </button>
+            </div>
+          </section>
+
+          <section className="rounded-lg border border-border/60 bg-card/80 p-5 space-y-4">
+            <div className="space-y-1">
+              <div className="text-[11px] uppercase text-muted-foreground font-bold">
+                {copy.draftHeading}
               </div>
+              <p className="text-xs text-muted-foreground leading-6">{copy.syncedHint}</p>
             </div>
 
             {loadingDraft ? (
-              <div className="text-sm text-muted-foreground">{projectLang === "zh" ? "读取共享草案中…" : "Loading shared draft…"}</div>
+              <div className="text-sm text-muted-foreground">{projectLang === "zh" ? "读取草案中…" : "Loading draft…"}</div>
             ) : draft ? (
               <div className="space-y-4">
                 {summaryRows.length > 0 ? (
-                  <div className="space-y-3">
+                  <div className="space-y-2">
                     {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 key={row.key} className="rounded-md border border-border/50 bg-background/70 px-3 py-2">
+                        <div className="text-[10px] uppercase text-muted-foreground font-semibold">{row.label}</div>
+                        <div className="mt-1 text-sm leading-6 whitespace-pre-wrap">{row.value}</div>
                       </div>
                     ))}
                   </div>
@@ -541,71 +853,42 @@ export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
                       {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"
+                          className="rounded-md border border-border/70 bg-secondary/50 px-2 py-1 text-xs text-muted-foreground"
                         >
                           {field}
                         </span>
                       ))}
                     </div>
-                    <p className="text-xs text-muted-foreground">{copy.missingHint}</p>
+                    <p className="text-xs text-muted-foreground leading-6">{copy.missingHint}</p>
                   </div>
                 ) : null}
+
+                <div className="flex flex-wrap gap-2">
+                  <button
+                    onClick={applyDraftToForm}
+                    className="px-3 py-2 rounded-md border border-border bg-secondary text-secondary-foreground font-medium text-xs"
+                  >
+                    {copy.applyDraft}
+                  </button>
+                  <button
+                    onClick={handleCreate}
+                    disabled={!canCreateFromDraft(draft) || creating || submitting}
+                    className="px-3 py-2 rounded-md border border-border text-muted-foreground hover:text-foreground hover:border-foreground/20 disabled:opacity-50 font-medium text-xs"
+                  >
+                    {creating ? copy.creating : copy.create}
+                  </button>
+                </div>
               </div>
             ) : (
-              <div className="rounded-xl border border-dashed border-border/70 bg-background/50 px-5 py-6">
+              <div className="rounded-md border border-dashed border-border/70 bg-background/50 px-4 py-5">
                 <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
-                onClick={handleDraftSubmit}
-                disabled={submitting || creating || !input.trim()}
-                className={`px-4 py-3 ${c.btnPrimary} rounded-md disabled:opacity-50 font-medium text-sm`}
-              >
-                {submitting ? copy.submitting : copy.submit}
-              </button>
-              <button
-                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`}
-              >
-                {creating ? copy.creating : copy.create}
-              </button>
-              <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>
-        </section>
+          </section>
+        </aside>
       </div>
     </div>
   );

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

@@ -1,10 +1,13 @@
 import { describe, expect, it, vi } from "vitest";
 import {
   buildBookCreateAgentRequest,
+  buildBookCreatePayload,
   buildCreationDraftSummary,
   canCreateFromDraft,
+  defaultBookCreateForm,
   defaultChapterWordsForLanguage,
   ensureBookCreateSessionId,
+  isBookCreateFormReady,
   platformOptionsForLanguage,
   pickValidValue,
   resolveDraftInstruction,
@@ -38,6 +41,52 @@ describe("platformOptionsForLanguage", () => {
   });
 });
 
+describe("book create form", () => {
+  it("starts with sensible defaults for chinese projects", () => {
+    expect(defaultBookCreateForm("zh")).toEqual({
+      title: "",
+      genre: "",
+      platform: "tomato",
+      targetChapters: "200",
+      chapterWordCount: "3000",
+      brief: "",
+    });
+  });
+
+  it("requires title, genre, brief, and positive numeric targets before creating", () => {
+    const ready = {
+      ...defaultBookCreateForm("zh"),
+      title: "夜港账本",
+      genre: "都市悬疑",
+      brief: "近未来港口城,主角查账洗白。",
+    };
+
+    expect(isBookCreateFormReady(ready)).toBe(true);
+    expect(isBookCreateFormReady({ ...ready, title: "" })).toBe(false);
+    expect(isBookCreateFormReady({ ...ready, brief: " " })).toBe(false);
+    expect(isBookCreateFormReady({ ...ready, targetChapters: "0" })).toBe(false);
+  });
+
+  it("builds a direct create payload without dropping the story brief", () => {
+    expect(buildBookCreatePayload({
+      title: " 夜港账本 ",
+      genre: " 都市悬疑 ",
+      platform: "qidian",
+      targetChapters: "120",
+      chapterWordCount: "2600",
+      brief: " 主角查账洗白,旧案回潮。 ",
+    }, "zh")).toEqual({
+      title: "夜港账本",
+      genre: "都市悬疑",
+      platform: "qidian",
+      language: "zh",
+      targetChapters: 120,
+      chapterWordCount: 2600,
+      blurb: "主角查账洗白,旧案回潮。",
+    });
+  });
+});
+
 describe("waitForBookReady", () => {
   it("retries until the created book becomes readable", async () => {
     let attempts = 0;