From c7ac2f232490a3b2657d4a1013e80988a95474ac Mon Sep 17 00:00:00 2001 From: Ma Date: Mon, 30 Mar 2026 14:09:09 +0800 Subject: [PATCH] wip(studio): preserve detached studio worktree changes --- .../src/__tests__/pipeline-runner.test.ts | 71 ++ packages/core/src/pipeline/runner.ts | 77 ++- packages/studio/index.html | 1 + packages/studio/package.json | 8 +- packages/studio/src/App.tsx | 109 +++- packages/studio/src/api/book-create.test.ts | 54 +- packages/studio/src/api/errors.ts | 20 + packages/studio/src/api/lib/run-store.ts | 177 +++++ packages/studio/src/api/lib/sse.ts | 50 ++ packages/studio/src/api/safety.ts | 76 +++ packages/studio/src/api/server.test.ts | 33 +- packages/studio/src/components/ChatBar.tsx | 451 +++++++++---- .../studio/src/components/ConfirmDialog.tsx | 92 +++ packages/studio/src/components/Sidebar.tsx | 256 +++++--- packages/studio/src/hooks/use-i18n.ts | 188 ++++++ packages/studio/src/index.css | 312 +++++++-- packages/studio/src/pages/BookDetail.tsx | 606 ++++++++++++++---- packages/studio/src/pages/ChapterReader.tsx | 208 +++++- packages/studio/src/pages/ConfigView.tsx | 170 ++++- packages/studio/src/pages/Dashboard.tsx | 337 +++++++--- packages/studio/src/pages/DoctorView.tsx | 82 +++ packages/studio/src/pages/GenreManager.tsx | 345 +++++++++- packages/studio/src/pages/ImportManager.tsx | 216 +++++++ packages/studio/src/pages/RadarView.tsx | 114 ++++ packages/studio/src/pages/TruthFiles.tsx | 82 ++- packages/studio/src/shared/contracts.ts | 143 +++++ packages/studio/vitest.config.ts | 7 + pnpm-lock.yaml | 3 + test-project/inkos.json | 6 +- 29 files changed, 3720 insertions(+), 574 deletions(-) create mode 100644 packages/core/src/__tests__/pipeline-runner.test.ts create mode 100644 packages/studio/src/api/errors.ts create mode 100644 packages/studio/src/api/lib/run-store.ts create mode 100644 packages/studio/src/api/lib/sse.ts create mode 100644 packages/studio/src/api/safety.ts create mode 100644 packages/studio/src/components/ConfirmDialog.tsx create mode 100644 packages/studio/src/pages/DoctorView.tsx create mode 100644 packages/studio/src/pages/ImportManager.tsx create mode 100644 packages/studio/src/pages/RadarView.tsx create mode 100644 packages/studio/src/shared/contracts.ts create mode 100644 packages/studio/vitest.config.ts diff --git a/packages/core/src/__tests__/pipeline-runner.test.ts b/packages/core/src/__tests__/pipeline-runner.test.ts new file mode 100644 index 0000000..486c165 --- /dev/null +++ b/packages/core/src/__tests__/pipeline-runner.test.ts @@ -0,0 +1,71 @@ +import { access, mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { mkdtemp } from "node:fs/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BookConfig } from "../models/book.js"; + +const generateFoundationMock = vi.fn(); +const writeFoundationFilesMock = vi.fn(); + +vi.mock("../agents/architect.js", () => ({ + ArchitectAgent: class { + constructor(_ctx: unknown) {} + + generateFoundation = generateFoundationMock; + writeFoundationFiles = writeFoundationFilesMock; + }, +})); + +vi.mock("../agents/rules-reader.js", () => ({ + readGenreProfile: vi.fn(async () => ({ + profile: { + numericalSystem: false, + }, + })), +})); + +describe("PipelineRunner.initBook", () => { + let projectRoot: string; + + beforeEach(async () => { + projectRoot = await mkdtemp(join(tmpdir(), "inkos-runner-")); + await mkdir(join(projectRoot, "books"), { recursive: true }); + generateFoundationMock.mockReset(); + writeFoundationFilesMock.mockReset(); + }); + + afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); + }); + + it("does not leave a partial book directory behind when foundation generation fails", async () => { + generateFoundationMock.mockImplementationOnce(() => { + throw new Error("architect failed"); + }); + + const { PipelineRunner } = await import("../pipeline/runner.js"); + const runner = new PipelineRunner({ + client: {} as never, + model: "test-model", + projectRoot, + }); + + const book: BookConfig = { + id: "broken-book", + title: "Broken Book", + genre: "xuanhuan", + platform: "qidian", + status: "outlining", + targetChapters: 10, + chapterWordCount: 3000, + language: "zh", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await expect(runner.initBook(book)).rejects.toThrow("architect failed"); + expect(generateFoundationMock).toHaveBeenCalledOnce(); + await expect(access(join(projectRoot, "books", book.id))).rejects.toThrow(); + }); +}); diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index 459996b..e77524b 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -21,7 +21,7 @@ import type { WebhookEvent } from "../notify/webhook.js"; import type { AgentContext } from "../agents/base.js"; import type { AuditResult, AuditIssue } from "../agents/continuity.js"; import type { RadarResult } from "../agents/radar.js"; -import { readFile, readdir, writeFile, mkdir } from "node:fs/promises"; +import { access, readFile, readdir, writeFile, mkdir, rename, rm } from "node:fs/promises"; import { join } from "node:path"; export interface PipelineConfig { @@ -187,19 +187,30 @@ export class PipelineRunner { async initBook(book: BookConfig): Promise { const architect = new ArchitectAgent(this.agentCtxFor("architect", book.id)); const bookDir = this.state.bookDir(book.id); + const stagingDir = join( + this.state.booksDir, + `.tmp-${book.id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); - await this.state.saveBookConfig(book.id, book); + await this.ensureBookTargetIsWritable(book.id); + await rm(stagingDir, { recursive: true, force: true }); - const { profile: gp } = await this.loadGenreProfile(book.genre); - const foundation = await architect.generateFoundation(book, this.config.externalContext); - await architect.writeFoundationFiles(bookDir, foundation, gp.numericalSystem); + try { + await this.writeBookConfigToDir(stagingDir, book); - // Ensure chapters directory exists (prevents ENOENT if init was previously interrupted) - await mkdir(join(bookDir, "chapters"), { recursive: true }); - await this.state.saveChapterIndex(book.id, []); + const { profile: gp } = await this.loadGenreProfile(book.genre); + const foundation = await architect.generateFoundation(book, this.config.externalContext); + await architect.writeFoundationFiles(stagingDir, foundation, gp.numericalSystem); - // Snapshot initial state so rewrite of chapter 1 can restore to pre-chapter state - await this.state.snapshotState(book.id, 0); + await mkdir(join(stagingDir, "chapters"), { recursive: true }); + await writeFile(join(stagingDir, "chapters", "index.json"), "[]\n", "utf-8"); + await this.snapshotStateAtDir(stagingDir, 0); + + await rename(stagingDir, bookDir); + } catch (e) { + await rm(stagingDir, { recursive: true, force: true }); + throw e; + } } /** Import external source material and generate fanfic_canon.md */ @@ -247,6 +258,52 @@ export class PipelineRunner { await this.state.snapshotState(book.id, 0); } + private async ensureBookTargetIsWritable(bookId: string): Promise { + const bookDir = this.state.bookDir(bookId); + try { + await access(bookDir); + } catch { + return; + } + + try { + await access(join(bookDir, "story", "story_bible.md")); + throw new Error(`Book "${bookId}" already exists at books/${bookId}/`); + } catch (e) { + if (e instanceof Error && e.message.includes("already exists")) { + throw e; + } + await rm(bookDir, { recursive: true, force: true }); + } + } + + private async writeBookConfigToDir(bookDir: string, book: BookConfig): Promise { + await mkdir(bookDir, { recursive: true }); + await writeFile(join(bookDir, "book.json"), JSON.stringify(book, null, 2), "utf-8"); + } + + private async snapshotStateAtDir(bookDir: string, chapterNumber: number): Promise { + const storyDir = join(bookDir, "story"); + const snapshotDir = join(storyDir, "snapshots", String(chapterNumber)); + await mkdir(snapshotDir, { recursive: true }); + + const files = [ + "current_state.md", "particle_ledger.md", "pending_hooks.md", + "chapter_summaries.md", "subplot_board.md", "emotional_arcs.md", "character_matrix.md", + ]; + + await Promise.all( + files.map(async (f) => { + try { + const content = await readFile(join(storyDir, f), "utf-8"); + await writeFile(join(snapshotDir, f), content, "utf-8"); + } catch { + // file doesn't exist yet + } + }), + ); + } + /** Write a single draft chapter. Saves chapter file + truth files + index + snapshot. */ async writeDraft(bookId: string, context?: string, wordCount?: number): Promise { const releaseLock = await this.state.acquireBookLock(bookId); diff --git a/packages/studio/index.html b/packages/studio/index.html index 5217dd8..db3a2c7 100644 --- a/packages/studio/index.html +++ b/packages/studio/index.html @@ -3,6 +3,7 @@ + InkOS Studio diff --git a/packages/studio/package.json b/packages/studio/package.json index 3093a2f..ec5b9e6 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -5,10 +5,11 @@ "description": "InkOS Studio — Web workbench for novel writing", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --host --port 4567", "build": "vite build", "preview": "vite preview", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@actalk/inkos-core": "workspace:*", @@ -34,6 +35,7 @@ "autoprefixer": "^10.4.0", "tailwindcss": "^4.0.0", "typescript": "^5.8.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^3.0.0" } } diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 79404d2..f4394cb 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { Sidebar } from "./components/Sidebar"; -import { ChatBar } from "./components/ChatBar"; +import { ChatPanel } from "./components/ChatBar"; import { Dashboard } from "./pages/Dashboard"; import { BookDetail } from "./pages/BookDetail"; import { BookCreate } from "./pages/BookCreate"; @@ -11,11 +11,16 @@ import { TruthFiles } from "./pages/TruthFiles"; import { DaemonControl } from "./pages/DaemonControl"; import { LogViewer } from "./pages/LogViewer"; import { GenreManager } from "./pages/GenreManager"; +import { StyleManager } from "./pages/StyleManager"; +import { ImportManager } from "./pages/ImportManager"; +import { RadarView } from "./pages/RadarView"; +import { DoctorView } from "./pages/DoctorView"; import { LanguageSelector } from "./pages/LanguageSelector"; import { useSSE } from "./hooks/use-sse"; import { useTheme } from "./hooks/use-theme"; import { useI18n } from "./hooks/use-i18n"; import { useApi } from "./hooks/use-api"; +import { Sun, Moon, Bell, MessageSquare } from "lucide-react"; type Route = | { page: "dashboard" } @@ -27,7 +32,11 @@ type Route = | { page: "truth"; bookId: string } | { page: "daemon" } | { page: "logs" } - | { page: "genres" }; + | { page: "genres" } + | { page: "style" } + | { page: "import" } + | { page: "radar" } + | { page: "doctor" }; export function App() { const [route, setRoute] = useState({ page: "dashboard" }); @@ -37,6 +46,7 @@ export function App() { const { data: project, refetch: refetchProject } = useApi<{ language: string; languageExplicit: boolean }>("/project"); const [showLanguageSelector, setShowLanguageSelector] = useState(false); const [ready, setReady] = useState(false); + const [chatOpen, setChatOpen] = useState(false); const isDark = theme === "dark"; @@ -44,7 +54,6 @@ export function App() { document.documentElement.classList.toggle("dark", isDark); }, [isDark]); - // Check if language needs to be set (first-time flow) useEffect(() => { if (project) { if (!project.languageExplicit) { @@ -66,6 +75,10 @@ export function App() { toDaemon: () => setRoute({ page: "daemon" }), toLogs: () => setRoute({ page: "logs" }), toGenres: () => setRoute({ page: "genres" }), + toStyle: () => setRoute({ page: "style" }), + toImport: () => setRoute({ page: "import" }), + toRadar: () => setRoute({ page: "radar" }), + toDoctor: () => setRoute({ page: "doctor" }), }; const activePage = @@ -74,7 +87,11 @@ export function App() { : route.page; if (!ready) { - return
; + return ( +
+
+
+ ); } if (showLanguageSelector) { @@ -94,30 +111,55 @@ export function App() { } return ( -
- +
+ {/* Left Sidebar */} + -
- {/* Thin utility strip — pushed inward */} -
- - {sse.connected && ( - - {t("nav.connected")} - - )} -
+ {/* Center Content */} +
+ {/* Header Strip */} +
+
+ + InkOS Studio + +
- {/* Scrollable main — centered with generous inset */} -
-
+
+ + + + + + {/* Chat Panel Toggle */} + +
+
+ + {/* Main Content Area */} +
+
{route.page === "dashboard" && } - {route.page === "book" && } + {route.page === "book" && } {route.page === "book-create" && } {route.page === "chapter" && } {route.page === "analytics" && } @@ -126,12 +168,21 @@ export function App() { {route.page === "daemon" && } {route.page === "logs" && } {route.page === "genres" && } + {route.page === "style" && } + {route.page === "import" && } + {route.page === "radar" && } + {route.page === "doctor" && }
-
- - {/* Chat bar — inset to match content width */} - +
+ + {/* Right Chat Panel */} + setChatOpen(false)} + t={t} + sse={sse} + />
); } diff --git a/packages/studio/src/api/book-create.test.ts b/packages/studio/src/api/book-create.test.ts index 295a7a1..8ecd44a 100644 --- a/packages/studio/src/api/book-create.test.ts +++ b/packages/studio/src/api/book-create.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { buildStudioBookConfig, normalizeStudioPlatform } from "./book-create"; +import { describe, expect, it, vi } from "vitest"; +import { buildStudioBookConfig, normalizeStudioPlatform, waitForStudioBookReady } from "./book-create"; describe("normalizeStudioPlatform", () => { it("keeps supported chinese platform ids and folds unsupported values to other", () => { @@ -51,3 +51,53 @@ describe("buildStudioBookConfig", () => { expect(config.id).toBe("english-book"); }); }); + +describe("waitForStudioBookReady", () => { + it("retries until the created book becomes readable", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ error: "Book not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + book: { id: "new-book" }, + chapters: [], + nextChapter: 1, + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + const wait = vi.fn(async () => {}); + + const result = await waitForStudioBookReady("new-book", { + fetchImpl, + wait, + maxAttempts: 2, + retryDelayMs: 1, + }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(wait).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + book: { id: "new-book" }, + nextChapter: 1, + }); + }); + + it("throws a clear error when the book never becomes readable", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "Book not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect(waitForStudioBookReady("missing-book", { + fetchImpl, + wait: async () => {}, + maxAttempts: 2, + retryDelayMs: 1, + })).rejects.toThrow('Book "missing-book" was not ready after 2 attempts.'); + }); +}); diff --git a/packages/studio/src/api/errors.ts b/packages/studio/src/api/errors.ts new file mode 100644 index 0000000..c8b9698 --- /dev/null +++ b/packages/studio/src/api/errors.ts @@ -0,0 +1,20 @@ +/** + * Structured API error handling. + * Ported from PR #96 (Te9ui1a) — typed error codes for consistent JSON responses. + */ + +export class ApiError extends Error { + readonly status: number; + readonly code: string; + + constructor(status: number, code: string, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + } +} + +export function isMissingFileError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"; +} diff --git a/packages/studio/src/api/lib/run-store.ts b/packages/studio/src/api/lib/run-store.ts new file mode 100644 index 0000000..e1f7ce8 --- /dev/null +++ b/packages/studio/src/api/lib/run-store.ts @@ -0,0 +1,177 @@ +/** + * In-memory event store for run lifecycle tracking. + * Ported from PR #96 (Te9ui1a) — immutable updates, pub/sub per run. + */ + +import { randomUUID } from "node:crypto"; +import type { + RunAction, + RunLogEntry, + RunStatus, + RunStreamEvent, + StudioRun, +} from "../../shared/contracts.js"; + +type RunSubscriber = (event: RunStreamEvent) => void; + +export class RunStore { + private readonly runs = new Map(); + private readonly subscribers = new Map>(); + + create(input: { + bookId: string; + chapterNumber?: number; + action: RunAction; + }): StudioRun { + const now = new Date().toISOString(); + const run: StudioRun = { + id: randomUUID(), + bookId: input.bookId, + chapter: input.chapterNumber ?? null, + chapterNumber: input.chapterNumber ?? null, + action: input.action, + status: "queued", + stage: "Queued", + createdAt: now, + updatedAt: now, + startedAt: null, + finishedAt: null, + logs: [], + }; + + this.runs.set(run.id, run); + this.publish(run.id, { type: "snapshot", runId: run.id, run }); + return run; + } + + list(): ReadonlyArray { + return [...this.runs.values()].sort((a, b) => + b.createdAt.localeCompare(a.createdAt), + ); + } + + get(runId: string): StudioRun | null { + return this.runs.get(runId) ?? null; + } + + findActiveRun(bookId: string): StudioRun | null { + for (const run of this.runs.values()) { + if ( + run.bookId === bookId && + (run.status === "queued" || run.status === "running") + ) { + return run; + } + } + return null; + } + + markRunning(runId: string, stage: string): StudioRun { + return this.update( + runId, + { status: "running", stage, startedAt: new Date().toISOString() }, + [ + { type: "status", runId, status: "running" }, + { type: "stage", runId, stage }, + ], + ); + } + + updateStage(runId: string, stage: string): StudioRun { + return this.update(runId, { stage }, [{ type: "stage", runId, stage }]); + } + + appendLog(runId: string, log: RunLogEntry): StudioRun { + return this.update(runId, (run) => ({ logs: [...run.logs, log] }), [ + { type: "log", runId, log }, + ]); + } + + succeed(runId: string, result: unknown): StudioRun { + return this.update( + runId, + { + status: "succeeded", + stage: "Completed", + finishedAt: new Date().toISOString(), + result, + error: undefined, + }, + [{ type: "status", runId, status: "succeeded", result }], + true, + ); + } + + fail(runId: string, error: string): StudioRun { + return this.update( + runId, + { + status: "failed", + stage: "Failed", + finishedAt: new Date().toISOString(), + error, + }, + [{ type: "status", runId, status: "failed", error }], + true, + ); + } + + subscribe(runId: string, subscriber: RunSubscriber): () => void { + const current = + this.subscribers.get(runId) ?? new Set(); + current.add(subscriber); + this.subscribers.set(runId, current); + + return () => { + const listeners = this.subscribers.get(runId); + if (!listeners) return; + listeners.delete(subscriber); + if (listeners.size === 0) this.subscribers.delete(runId); + }; + } + + private update( + runId: string, + patch: Partial | ((run: StudioRun) => Partial), + events: ReadonlyArray, + publishSnapshot = false, + ): StudioRun { + const current = this.runs.get(runId); + if (!current) throw new Error(`Run ${runId} not found.`); + + const partial = typeof patch === "function" ? patch(current) : patch; + const next: StudioRun = { + ...current, + ...partial, + updatedAt: new Date().toISOString(), + }; + this.runs.set(runId, next); + + for (const event of events) { + this.publish(runId, event); + } + if (publishSnapshot) { + this.publish(runId, { type: "snapshot", runId, run: next }); + } + + return next; + } + + private publish(runId: string, event: RunStreamEvent): void { + const listeners = this.subscribers.get(runId); + if (!listeners || listeners.size === 0) return; + + const payload = + event.type === "snapshot" + ? { ...event, run: event.run ?? this.get(runId) ?? undefined } + : event; + + for (const listener of listeners) { + listener(payload as RunStreamEvent); + } + } +} + +export function isTerminalRunStatus(status: RunStatus): boolean { + return status === "succeeded" || status === "failed"; +} diff --git a/packages/studio/src/api/lib/sse.ts b/packages/studio/src/api/lib/sse.ts new file mode 100644 index 0000000..92a0cc6 --- /dev/null +++ b/packages/studio/src/api/lib/sse.ts @@ -0,0 +1,50 @@ +/** + * SSE stream factory for run event streaming. + * Ported from PR #96 (Te9ui1a) — typed ReadableStream with auto-close. + */ + +import type { RunStreamEvent } from "../../shared/contracts.js"; + +function encodeSse(event: RunStreamEvent): Uint8Array { + return new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`); +} + +export function createRunEventStream( + initialEvent: RunStreamEvent, + subscribe: (send: (event: RunStreamEvent) => void) => () => void, + shouldClose: (event: RunStreamEvent) => boolean, +): Response { + let unsubscribe: (() => void) | null = null; + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encodeSse(initialEvent)); + + if (shouldClose(initialEvent)) { + controller.close(); + return; + } + + unsubscribe = subscribe((event) => { + controller.enqueue(encodeSse(event)); + if (shouldClose(event)) { + unsubscribe?.(); + unsubscribe = null; + controller.close(); + } + }); + }, + cancel() { + unsubscribe?.(); + unsubscribe = null; + }, + }); + + return new Response(stream, { + headers: { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + connection: "keep-alive", + }, + }); +} diff --git a/packages/studio/src/api/safety.ts b/packages/studio/src/api/safety.ts new file mode 100644 index 0000000..792d80a --- /dev/null +++ b/packages/studio/src/api/safety.ts @@ -0,0 +1,76 @@ +/** + * Security validation utilities for Studio API. + * Ported from PR #97 (PapainTea) — ReDoS prevention, path traversal, port parsing. + */ + +const SAFE_UPLOAD_FILE_ID = /^[A-Za-z0-9-]{1,64}$/; +const MAX_IMPORT_PATTERN_LENGTH = 160; +const SAFE_IMPORT_PATTERN = /^[\p{L}\p{N}\s[\]\\.^$*+?{}\-,。::、_]+$/u; + +/** Validates fileId against whitelist to prevent path traversal. */ +export function isSafeUploadFileId(fileId: string): boolean { + return typeof fileId === "string" && SAFE_UPLOAD_FILE_ID.test(fileId); +} + +/** Validates bookId — blocks traversal sequences and null bytes. */ +export function isSafeBookId(bookId: string): boolean { + return bookId.length > 0 && !bookId.includes("..") && !/[\\/\0]/.test(bookId); +} + +/** + * Builds a regex from user-provided pattern with ReDoS prevention. + * Blocks grouping, alternation, backreferences, and lookahead/behind. + */ +export function buildImportRegex(pattern: string): RegExp { + const normalized = String(pattern ?? "").trim(); + if (!normalized) { + throw new Error("Import pattern is required"); + } + if (normalized.length > MAX_IMPORT_PATTERN_LENGTH) { + throw new Error(`Import pattern too long (max ${MAX_IMPORT_PATTERN_LENGTH} chars)`); + } + if (/[()|]/.test(normalized) || /\\[1-9]/.test(normalized) || normalized.includes("(?")) { + throw new Error("Import pattern uses unsafe regex features"); + } + if (!SAFE_IMPORT_PATTERN.test(normalized)) { + throw new Error("Import pattern is invalid"); + } + + try { + return new RegExp(normalized, "g"); + } catch { + throw new Error("Import pattern is invalid"); + } +} + +/** Strips filesystem paths from upload responses — only returns relative/safe fields. */ +export function createUploadResponse(input: { + readonly fileId: string; + readonly size: number; + readonly chapterCount: number; + readonly firstTitle: string; + readonly totalChars: number; +}): { + readonly ok: true; + readonly fileId: string; + readonly size: number; + readonly chapterCount: number; + readonly firstTitle: string; + readonly totalChars: number; +} { + return { + ok: true, + fileId: input.fileId, + size: input.size, + chapterCount: input.chapterCount, + firstTitle: input.firstTitle, + totalChars: input.totalChars, + }; +} + +/** Parses port from environment with safe fallback. */ +export function resolveServerPort(env: Record = process.env): number { + const raw = env.INKOS_STUDIO_PORT ?? env.PORT ?? "4567"; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 4567; +} diff --git a/packages/studio/src/api/server.test.ts b/packages/studio/src/api/server.test.ts index cef28c8..9f2c868 100644 --- a/packages/studio/src/api/server.test.ts +++ b/packages/studio/src/api/server.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdtemp, rm } from "node:fs/promises"; +import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; const schedulerStartMock = vi.fn<() => Promise>(); +const initBookMock = vi.fn(); const logger = { child: () => logger, @@ -39,6 +40,8 @@ vi.mock("@actalk/inkos-core", () => { class MockPipelineRunner { constructor(_config: unknown) {} + + initBook = initBookMock; } class MockScheduler { @@ -104,6 +107,7 @@ describe("createStudioServer daemon lifecycle", () => { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "inkos-studio-server-")); schedulerStartMock.mockReset(); + initBookMock.mockReset(); }); afterEach(async () => { @@ -138,4 +142,31 @@ describe("createStudioServer daemon lifecycle", () => { resolveStart?.(); }); + + it("rejects create requests when a complete book with the same id already exists", async () => { + await mkdir(join(root, "books", "existing-book", "story"), { recursive: true }); + await writeFile(join(root, "books", "existing-book", "book.json"), JSON.stringify({ id: "existing-book" }), "utf-8"); + await writeFile(join(root, "books", "existing-book", "story", "story_bible.md"), "# existing", "utf-8"); + + const { createStudioServer } = await import("./server.js"); + const app = createStudioServer(projectConfig as never, root); + + const response = await app.request("http://localhost/api/books/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: "Existing Book", + genre: "xuanhuan", + platform: "qidian", + language: "zh", + }), + }); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + error: expect.stringContaining('Book "existing-book" already exists'), + }); + expect(initBookMock).not.toHaveBeenCalled(); + await expect(access(join(root, "books", "existing-book", "story", "story_bible.md"))).resolves.toBeUndefined(); + }); }); diff --git a/packages/studio/src/components/ChatBar.tsx b/packages/studio/src/components/ChatBar.tsx index c239d85..4271ca5 100644 --- a/packages/studio/src/components/ChatBar.tsx +++ b/packages/studio/src/components/ChatBar.tsx @@ -1,6 +1,39 @@ -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useMemo } from "react"; import type { TFunction } from "../hooks/use-i18n"; import type { SSEMessage } from "../hooks/use-sse"; +import { cn } from "../lib/utils"; +import { fetchJson, postApi } from "../hooks/use-api"; +import { + // Panel controls + Sparkles, + Trash2, + PanelRightClose, + ArrowUp, + Loader2, + MessageSquare, + Lightbulb, + // Message avatars + User, + CheckCircle2, + XCircle, + BotMessageSquare, + // Message content badges + BadgeCheck, + CircleAlert, + // Status phase icons + Brain, + PenTool, + Shield, + Wrench, + AlertTriangle, + // Quick command chips + Zap, + Search, + FileOutput, + TrendingUp, +} from "lucide-react"; + +// ── Types ── interface ChatMessage { readonly role: "user" | "assistant"; @@ -8,12 +41,144 @@ interface ChatMessage { readonly timestamp: number; } -export function ChatBar({ t, sse }: { - t: TFunction; - sse: { messages: ReadonlyArray; connected: boolean }; +// ── Sub-components ── + +function StatusIcon({ phase }: { readonly phase: string }) { + const lower = phase.toLowerCase(); + + if (lower.includes("think") || lower.includes("plan")) + return ; + if (lower.includes("writ") || lower.includes("draft") || lower.includes("stream")) + return ; + if (lower.includes("audit") || lower.includes("review")) + return ; + if (lower.includes("revis") || lower.includes("fix") || lower.includes("spot")) + return ; + if (lower.includes("complet") || lower.includes("done") || lower.includes("success")) + return ; + if (lower.includes("error") || lower.includes("fail")) + return ; + return ; +} + +function EmptyState() { + return ( +
+
+ +
+

How shall we proceed today?

+

Type a command below

+
+ ); +} + +function ThinkingBubble() { + return ( +
+
+ +
+
+ + + +
+
+ ); +} + +function MessageBubble({ msg }: { readonly msg: ChatMessage }) { + const isUser = msg.role === "user"; + const isStatus = msg.content.startsWith("⋯"); + const isSuccess = msg.content.startsWith("✓"); + const isError = msg.content.startsWith("✗"); + + return ( +
+ {/* Avatar */} +
+ {isUser ? ( + + ) : isSuccess ? ( + + ) : isError ? ( + + ) : isStatus ? ( + + ) : ( + + )} +
+ + {/* Bubble */} +
+ {/* Status badge */} + {isSuccess && ( + + + Complete + + )} + {isError && ( + + + Error + + )} + +
{msg.content}
+ + {/* Timestamp */} +
+ {new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} +
+
+
+ ); +} + +function QuickChip({ icon, label, onClick }: { + readonly icon: React.ReactNode; + readonly label: string; + readonly onClick: () => void; +}) { + return ( + + ); +} + +// ── Main Component ── + +export function ChatPanel({ open, onClose, t, sse }: { + readonly open: boolean; + readonly onClose: () => void; + readonly t: TFunction; + readonly sse: { messages: ReadonlyArray; connected: boolean }; }) { const [input, setInput] = useState(""); - const [expanded, setExpanded] = useState(false); const [messages, setMessages] = useState>([]); const [loading, setLoading] = useState(false); const inputRef = useRef(null); @@ -22,10 +187,17 @@ export function ChatBar({ t, sse }: { // Auto-scroll on new messages useEffect(() => { if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); } }, [messages, sse.messages.length]); + // Focus input when panel opens + useEffect(() => { + if (open) { + setTimeout(() => inputRef.current?.focus(), 300); + } + }, [open]); + // SSE events → assistant messages useEffect(() => { const recent = sse.messages.slice(-1)[0]; @@ -52,9 +224,8 @@ export function ChatBar({ t, sse }: { } if (recent.event === "log" && loading) { const msg = d.message as string; - if (msg && (msg.includes("Phase") || msg.includes("streaming"))) { + if (msg && (msg.includes("Phase") || msg.includes("streaming") || msg.includes("Writing") || msg.includes("Audit") || msg.includes("Revis"))) { setMessages((prev) => { - // Replace the last "thinking" message instead of appending const last = prev[prev.length - 1]; if (last?.role === "assistant" && last.content.startsWith("⋯")) { return [...prev.slice(0, -1), { role: "assistant", content: `⋯ ${msg}`, timestamp: Date.now() }]; @@ -65,162 +236,224 @@ export function ChatBar({ t, sse }: { } }, [sse.messages.length]); + // Current phase for status bar + const currentPhase = useMemo(() => { + const lastStatus = [...messages].reverse().find((m) => m.role === "assistant" && m.content.startsWith("⋯")); + return lastStatus?.content.replace("⋯ ", "") ?? "Initializing..."; + }, [messages]); + const handleSubmit = async () => { const text = input.trim(); if (!text || loading) return; setInput(""); - setExpanded(true); setMessages((prev) => [...prev, { role: "user", content: text, timestamp: Date.now() }]); setLoading(true); - // Simple command routing — check if it's a direct action const lower = text.toLowerCase(); try { if (lower.match(/^(写下一章|write next)/)) { - // Extract book id from context or use first book - const res = await fetch("/api/books"); - const { books } = await res.json() as { books: ReadonlyArray<{ id: string }> }; + const { books } = await fetchJson<{ books: ReadonlyArray<{ id: string }> }>("/books"); if (books.length > 0) { - setMessages((prev) => [...prev, { role: "assistant", content: "⋯ Starting...", timestamp: Date.now() }]); - await fetch(`/api/books/${books[0]!.id}/write-next`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); - // SSE will handle the rest + setMessages((prev) => [...prev, { role: "assistant", content: "⋯ Initializing manuscript...", timestamp: Date.now() }]); + await postApi(`/books/${books[0]!.id}/write-next`, {}); return; } } - // Fallback: send to agent API - const res = await fetch("/api/agent", { + const data = await fetchJson<{ response?: string; error?: string }>("/agent", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ instruction: text }), }); - const data = await res.json() as { response?: string; error?: string }; setLoading(false); setMessages((prev) => [...prev, { role: "assistant", - content: data.response ?? data.error ?? "Done", + content: data.response ?? data.error ?? "Acknowledged.", timestamp: Date.now(), }]); } catch (e) { setLoading(false); setMessages((prev) => [...prev, { role: "assistant", - content: `Error: ${e instanceof Error ? e.message : String(e)}`, + content: `✗ ${e instanceof Error ? e.message : String(e)}`, timestamp: Date.now(), }]); } }; + const handleQuickCommand = (command: string) => { + setInput(command); + setTimeout(() => { + handleSubmit(); + }, 50); + }; + const isZh = t("nav.connected") === "已连接"; + // Rotating tips const TIPS_ZH = [ - "试试:写下一章", - "试试:审计第5章", - "试试:帮我创建一本都市修仙小说", - "试试:扫描市场趋势", - "试试:导出全书为 epub", - "试试:分析这篇文章的文风 → 导入到我的书", - "试试:导入已有章节续写", - "试试:创建一个玄幻题材的同人", - "试试:查看第3章的审计问题", - "试试:修订第5章,用 spot-fix 模式", + "写下一章", "审计第5章", "帮我创建一本都市修仙小说", + "扫描市场趋势", "导出全书为 epub", "分析文风 → 导入到我的书", + "导入已有章节续写", "创建一个玄幻题材的同人", "修订第5章,spot-fix", ]; - const TIPS_EN = [ - "Try: write next chapter", - "Try: audit chapter 5", - "Try: create a LitRPG novel about a programmer", - "Try: scan market trends", - "Try: export book as epub", - "Try: analyze this text's style → import to my book", - "Try: import existing chapters to continue", - "Try: create a progression fantasy fanfic", - "Try: show audit issues for chapter 3", - "Try: revise chapter 5 with spot-fix mode", + "write next chapter", "audit chapter 5", "create a LitRPG novel", + "scan market trends", "export book as epub", "analyze style → import", + "import chapters to continue", "create a progression fantasy fanfic", "revise chapter 5, spot-fix", ]; - const tips = isZh ? TIPS_ZH : TIPS_EN; const [tipIndex, setTipIndex] = useState(() => Math.floor(Math.random() * tips.length)); - // Rotate tips every 8 seconds when idle useEffect(() => { - if (input || expanded) return; - const timer = setInterval(() => { - setTipIndex((i) => (i + 1) % tips.length); - }, 8000); + if (input) return; + const timer = setInterval(() => setTipIndex((i) => (i + 1) % tips.length), 8000); return () => clearInterval(timer); - }, [input, expanded, tips.length]); - - const chatPlaceholder = tips[tipIndex]!; + }, [input, tips.length]); return ( -
- {/* Expanded message area */} - {expanded && messages.length > 0 && ( -
+
- )} - {/* Input bar — centered to match main content */} -
-
- setInput(e.target.value)} - onFocus={() => messages.length > 0 && setExpanded(true)} - onKeyDown={(e) => e.key === "Enter" && handleSubmit()} - placeholder={chatPlaceholder} - disabled={loading} - className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none disabled:opacity-50" - /> -
-
- {expanded && messages.length > 0 && ( - - )} - -
-
-
+ {/* ── Section 4: Quick Commands ── */} +
+ } + label={t("dash.writeNext")} + onClick={() => handleQuickCommand(isZh ? "写下一章" : "write next")} + /> + } + label={t("book.audit")} + onClick={() => handleQuickCommand(isZh ? "审计第1章" : "audit chapter 1")} + /> + } + label={t("book.export")} + onClick={() => handleQuickCommand(isZh ? "导出全书" : "export book as epub")} + /> + } + label={t("nav.radar")} + onClick={() => handleQuickCommand(isZh ? "扫描市场趋势" : "scan market trends")} + /> +
+ + {/* ── Section 5: Input ── */} +
+
+ + setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }} + placeholder={t("common.enterCommand")} + disabled={loading} + className="flex-1 bg-transparent text-sm placeholder:text-muted-foreground/50 outline-none ring-0 shadow-none disabled:opacity-50" + style={{ outline: "none", boxShadow: "none" }} + /> + +
+ + {/* Rotating tip */} + {!input && ( +
+ + + {tips[tipIndex]} + +
+ )} +
+ + )} + ); } diff --git a/packages/studio/src/components/ConfirmDialog.tsx b/packages/studio/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..35bcaaa --- /dev/null +++ b/packages/studio/src/components/ConfirmDialog.tsx @@ -0,0 +1,92 @@ +import { useEffect, useRef } from "react"; +import { AlertTriangle, X } from "lucide-react"; + +interface ConfirmDialogProps { + readonly open: boolean; + readonly title: string; + readonly message: string; + readonly confirmLabel: string; + readonly cancelLabel: string; + readonly variant?: "danger" | "default"; + readonly onConfirm: () => void; + readonly onCancel: () => void; +} + +export function ConfirmDialog({ + open, + title, + message, + confirmLabel, + cancelLabel, + variant = "default", + onConfirm, + onCancel, +}: ConfirmDialogProps) { + const overlayRef = useRef(null); + + useEffect(() => { + if (!open) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onCancel(); + }; + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [open, onCancel]); + + if (!open) return null; + + const isDanger = variant === "danger"; + + return ( +
{ if (e.target === overlayRef.current) onCancel(); }} + > +
+ {/* Header */} +
+
+ {isDanger && ( +
+ +
+ )} +

{title}

+
+ +
+ + {/* Body */} +
+

{message}

+
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/packages/studio/src/components/Sidebar.tsx b/packages/studio/src/components/Sidebar.tsx index 2d268ad..9750994 100644 --- a/packages/studio/src/components/Sidebar.tsx +++ b/packages/studio/src/components/Sidebar.tsx @@ -1,5 +1,21 @@ +import { useEffect } from "react"; import { useApi } from "../hooks/use-api"; +import type { SSEMessage } from "../hooks/use-sse"; +import { shouldRefetchBookCollections, shouldRefetchDaemonStatus } from "../hooks/use-book-activity"; import type { TFunction } from "../hooks/use-i18n"; +import { + Book, + Settings, + Terminal, + Plus, + ScrollText, + Boxes, + Zap, + Wand2, + FileInput, + TrendingUp, + Stethoscope, +} from "lucide-react"; interface BookSummary { readonly id: string; @@ -17,89 +33,177 @@ interface Nav { toDaemon: () => void; toLogs: () => void; toGenres: () => void; + toStyle: () => void; + toImport: () => void; + toRadar: () => void; + toDoctor: () => void; } -export function Sidebar({ nav, activePage, t }: { +export function Sidebar({ nav, activePage, sse, t }: { nav: Nav; activePage: string; + sse: { messages: ReadonlyArray }; t: TFunction; }) { - const { data } = useApi<{ books: ReadonlyArray }>("/books"); - const { data: daemon } = useApi<{ running: boolean }>("/daemon"); + const { data, refetch: refetchBooks } = useApi<{ books: ReadonlyArray }>("/books"); + const { data: daemon, refetch: refetchDaemon } = useApi<{ running: boolean }>("/daemon"); + + useEffect(() => { + const recent = sse.messages.at(-1); + if (!recent) return; + if (shouldRefetchBookCollections(recent)) { + refetchBooks(); + } + if (shouldRefetchDaemonStatus(recent)) { + refetchDaemon(); + } + }, [refetchBooks, refetchDaemon, sse.messages]); return ( - ); @@ -107,7 +211,7 @@ export function Sidebar({ nav, activePage, t }: { function SidebarItem({ label, icon, active, onClick, badge, badgeColor }: { label: string; - icon: string; + icon: React.ReactNode; active: boolean; onClick: () => void; badge?: string; @@ -116,15 +220,21 @@ function SidebarItem({ label, icon, active, onClick, badge, badgeColor }: { return ( ); } diff --git a/packages/studio/src/hooks/use-i18n.ts b/packages/studio/src/hooks/use-i18n.ts index 3ee0233..15ba9bb 100644 --- a/packages/studio/src/hooks/use-i18n.ts +++ b/packages/studio/src/hooks/use-i18n.ts @@ -37,6 +37,8 @@ const strings = { "reader.reject": { zh: "驳回", en: "Reject" }, "reader.chapterList": { zh: "章节列表", en: "Chapter List" }, "reader.characters": { zh: "字符", en: "characters" }, + "reader.edit": { zh: "编辑", en: "Edit" }, + "reader.preview": { zh: "预览", en: "Preview" }, // Book Create "create.title": { zh: "创建书籍", en: "Create Book" }, @@ -72,6 +74,192 @@ const strings = { "config.provider": { zh: "提供方", en: "Provider" }, "config.model": { zh: "模型", en: "Model" }, "config.editHint": { zh: "通过 CLI 编辑配置:", en: "Edit via CLI:" }, + + // Sidebar + "nav.system": { zh: "系统", en: "System" }, + "nav.daemon": { zh: "守护进程", en: "Daemon" }, + "nav.logs": { zh: "日志", en: "Logs" }, + "nav.running": { zh: "运行中", en: "Running" }, + "nav.agentOnline": { zh: "代理在线", en: "Agent Online" }, + "nav.agentOffline": { zh: "代理离线", en: "Agent Offline" }, + "nav.tools": { zh: "工具", en: "Tools" }, + "nav.style": { zh: "文风", en: "Style" }, + "nav.import": { zh: "导入", en: "Import" }, + "nav.radar": { zh: "市场雷达", en: "Radar" }, + "nav.doctor": { zh: "环境诊断", en: "Doctor" }, + + // Book Detail extras + "book.deleteBook": { zh: "删除书籍", en: "Delete Book" }, + "book.confirmDelete": { zh: "确认删除此书及所有章节?", en: "Delete this book and all chapters?" }, + "book.settings": { zh: "书籍设置", en: "Book Settings" }, + "book.status": { zh: "状态", en: "Status" }, + "book.drafting": { zh: "草稿中...", en: "Drafting..." }, + "book.pipelineWriting": { zh: "后台正在写作,本页会在完成后自动刷新。", en: "Background writing is running. This page will refresh automatically when it finishes." }, + "book.pipelineDrafting": { zh: "后台正在生成草稿,本页会在完成后自动刷新。", en: "Background drafting is running. This page will refresh automatically when it finishes." }, + "book.pipelineFailed": { zh: "后台任务失败", en: "Background job failed" }, + "book.save": { zh: "保存", en: "Save" }, + "book.saving": { zh: "保存中...", en: "Saving..." }, + "book.rewrite": { zh: "重写", en: "Rewrite" }, + "book.audit": { zh: "审计", en: "Audit" }, + "book.export": { zh: "导出", en: "Export" }, + "book.approvedOnly": { zh: "仅已通过", en: "Approved Only" }, + "book.manuscriptTitle": { zh: "章节标题", en: "Manuscript Title" }, + "book.curate": { zh: "操作", en: "Actions" }, + "book.spotFix": { zh: "精修", en: "Spot Fix" }, + "book.polish": { zh: "打磨", en: "Polish" }, + "book.rework": { zh: "重作", en: "Rework" }, + "book.antiDetect": { zh: "反检测", en: "Anti-Detect" }, + "book.statusActive": { zh: "进行中", en: "Active" }, + "book.statusPaused": { zh: "已暂停", en: "Paused" }, + "book.statusOutlining": { zh: "大纲中", en: "Outlining" }, + "book.statusCompleted": { zh: "已完成", en: "Completed" }, + "book.statusDropped": { zh: "已放弃", en: "Dropped" }, + "book.truthFiles": { zh: "真相文件", en: "Truth Files" }, + + // Style + "style.title": { zh: "文风分析", en: "Style Analyzer" }, + "style.sourceName": { zh: "来源名称", en: "Source Name" }, + "style.sourceExample": { zh: "如:参考小说", en: "e.g. Reference Novel" }, + "style.textSample": { zh: "文本样本", en: "Text Sample" }, + "style.pasteHint": { zh: "粘贴参考文本进行文风分析...", en: "Paste reference text for style analysis..." }, + "style.analyze": { zh: "分析", en: "Analyze" }, + "style.analyzing": { zh: "分析中...", en: "Analyzing..." }, + "style.results": { zh: "分析结果", en: "Analysis Results" }, + "style.avgSentence": { zh: "平均句长", en: "Avg Sentence Length" }, + "style.vocabDiversity": { zh: "词汇多样性", en: "Vocabulary Diversity" }, + "style.avgParagraph": { zh: "平均段落长度", en: "Avg Paragraph Length" }, + "style.sentenceStdDev": { zh: "句长标准差", en: "Sentence StdDev" }, + "style.topPatterns": { zh: "主要模式", en: "Top Patterns" }, + "style.rhetoricalFeatures": { zh: "修辞特征", en: "Rhetorical Features" }, + "style.importToBook": { zh: "导入到书籍", en: "Import to Book" }, + "style.selectBook": { zh: "选择书籍...", en: "Select book..." }, + "style.importGuide": { zh: "导入文风指南", en: "Import Style Guide" }, + "style.emptyHint": { zh: "粘贴文本并点击分析查看文风档案", en: "Paste text and click Analyze to see style profile" }, + + // Import + "import.title": { zh: "导入工具", en: "Import Tools" }, + "import.chapters": { zh: "导入章节", en: "Import Chapters" }, + "import.canon": { zh: "导入母本", en: "Import Canon" }, + "import.fanfic": { zh: "同人创作", en: "Fanfic" }, + "import.selectTarget": { zh: "选择目标书籍...", en: "Select target book..." }, + "import.splitRegex": { zh: "分割正则(可选)", en: "Split regex (optional)" }, + "import.pasteChapters": { zh: "粘贴章节文本...", en: "Paste chapter text..." }, + "import.selectSource": { zh: "选择源(母本)...", en: "Select source (parent)..." }, + "import.selectDerivative": { zh: "选择目标(衍生)...", en: "Select target (derivative)..." }, + "import.fanficTitle": { zh: "同人小说标题", en: "Fanfic title" }, + "import.pasteMaterial": { zh: "粘贴原作文本/设定/角色资料...", en: "Paste source material..." }, + "import.importing": { zh: "导入中...", en: "Importing..." }, + "import.creating": { zh: "创建中...", en: "Creating..." }, + + // Radar + "radar.title": { zh: "市场雷达", en: "Market Radar" }, + "radar.scan": { zh: "扫描市场", en: "Scan Market" }, + "radar.scanning": { zh: "扫描中...", en: "Scanning..." }, + "radar.summary": { zh: "市场概要", en: "Market Summary" }, + "radar.emptyHint": { zh: "点击「扫描市场」分析当前趋势和机会", en: "Click \"Scan Market\" to analyze trends and opportunities" }, + + // Doctor + "doctor.title": { zh: "环境诊断", en: "Environment Check" }, + "doctor.recheck": { zh: "重新检查", en: "Re-check" }, + "doctor.inkosJson": { zh: "inkos.json 配置", en: "inkos.json configuration" }, + "doctor.projectEnv": { zh: "项目 .env 文件", en: "Project .env file" }, + "doctor.globalEnv": { zh: "全局 ~/.inkos/.env", en: "Global ~/.inkos/.env" }, + "doctor.booksDir": { zh: "书籍目录", en: "Books directory" }, + "doctor.llmApi": { zh: "LLM API 连接", en: "LLM API connectivity" }, + "doctor.connected": { zh: "已连接", en: "Connected" }, + "doctor.failed": { zh: "失败", en: "Failed" }, + "doctor.allPassed": { zh: "所有检查通过 — 环境健康", en: "All checks passed — environment is healthy" }, + "doctor.someFailed": { zh: "部分检查失败 — 请查看配置", en: "Some checks failed — review configuration" }, + + // Genre extras + "genre.createNew": { zh: "创建新题材", en: "Create New Genre" }, + "genre.editGenre": { zh: "编辑", en: "Edit" }, + "genre.deleteGenre": { zh: "删除", en: "Delete" }, + "genre.confirmDelete": { zh: "确认删除此题材?", en: "Delete this genre?" }, + "genre.chapterTypes": { zh: "章节类型", en: "Chapter Types" }, + "genre.fatigueWords": { zh: "疲劳词", en: "Fatigue Words" }, + "genre.numericalSystem": { zh: "数值系统", en: "Numerical System" }, + "genre.powerScaling": { zh: "力量等级", en: "Power Scaling" }, + "genre.eraResearch": { zh: "时代研究", en: "Era Research" }, + "genre.pacingRule": { zh: "节奏规则", en: "Pacing Rule" }, + "genre.rules": { zh: "规则", en: "Rules" }, + "genre.saveChanges": { zh: "保存更改", en: "Save Changes" }, + "genre.cancel": { zh: "取消", en: "Cancel" }, + "genre.copyToProject": { zh: "复制到项目", en: "Copy to Project" }, + "genre.selectHint": { zh: "选择题材查看详情", en: "Select a genre to view details" }, + "genre.commaSeparated": { zh: "逗号分隔", en: "comma-separated" }, + "genre.rulesMd": { zh: "规则(Markdown)", en: "Rules (Markdown)" }, + + // Config extras + "config.modelRouting": { zh: "模型路由", en: "Model Routing" }, + "config.agent": { zh: "代理", en: "Agent" }, + "config.baseUrl": { zh: "基础 URL", en: "Base URL" }, + "config.default": { zh: "默认", en: "default" }, + "config.optional": { zh: "可选", en: "optional" }, + "config.saveOverrides": { zh: "保存路由", en: "Save Overrides" }, + "config.save": { zh: "保存", en: "Save" }, + "config.saving": { zh: "保存中...", en: "Saving..." }, + "config.cancel": { zh: "取消", en: "Cancel" }, + "config.edit": { zh: "编辑", en: "Edit" }, + "config.enabled": { zh: "启用", en: "Enabled" }, + "config.disabled": { zh: "禁用", en: "Disabled" }, + + // Truth Files extras + "truth.title": { zh: "真相文件", en: "Truth Files" }, + "truth.edit": { zh: "编辑", en: "Edit" }, + "truth.save": { zh: "保存", en: "Save" }, + "truth.saving": { zh: "保存中...", en: "Saving..." }, + "truth.cancel": { zh: "取消", en: "Cancel" }, + "truth.noFiles": { zh: "暂无文件", en: "No truth files" }, + "truth.notFound": { zh: "文件未找到", en: "File not found" }, + "truth.selectHint": { zh: "选择文件查看内容", en: "Select a file to view" }, + + // Dashboard + "dash.subtitle": { zh: "管理你的文学宇宙和 AI 辅助草稿。", en: "Manage your literary universe and AI-assisted drafts." }, + + // Chapter Reader extras + "reader.openingManuscript": { zh: "打开书稿中...", en: "Opening manuscript..." }, + "reader.manuscriptPage": { zh: "书稿页", en: "Manuscript Page" }, + "reader.minRead": { zh: "分钟阅读", en: "min read" }, + "reader.endOfChapter": { zh: "本章完", en: "End of Chapter" }, + + // Daemon Control + "daemon.title": { zh: "守护进程控制", en: "Daemon Control" }, + "daemon.running": { zh: "运行中", en: "Running" }, + "daemon.stopped": { zh: "已停止", en: "Stopped" }, + "daemon.start": { zh: "启动", en: "Start" }, + "daemon.stop": { zh: "停止", en: "Stop" }, + "daemon.starting": { zh: "启动中...", en: "Starting..." }, + "daemon.stopping": { zh: "停止中...", en: "Stopping..." }, + "daemon.waitingEvents": { zh: "等待事件...", en: "Waiting for events..." }, + "daemon.startHint": { zh: "启动守护进程查看事件", en: "Start the daemon to see events" }, + + // Config extras (labels) + "config.temperature": { zh: "温度", en: "Temperature" }, + "config.maxTokens": { zh: "最大令牌数", en: "Max Tokens" }, + "config.stream": { zh: "流式输出", en: "Stream" }, + "config.chinese": { zh: "中文", en: "Chinese" }, + "config.english": { zh: "英文", en: "English" }, + + // BookCreate extras + "create.platform": { zh: "平台", en: "Platform" }, + + // Common + "common.save": { zh: "保存", en: "Save" }, + "common.cancel": { zh: "取消", en: "Cancel" }, + "common.delete": { zh: "删除", en: "Delete" }, + "common.edit": { zh: "编辑", en: "Edit" }, + "common.loading": { zh: "加载中...", en: "Loading..." }, + "common.enterCommand": { zh: "输入指令...", en: "Enter command..." }, + "chapter.readyForReview": { zh: "待审核", en: "Ready for Review" }, + "chapter.approved": { zh: "已通过", en: "Approved" }, + "chapter.drafted": { zh: "草稿", en: "Drafted" }, + "chapter.needsRevision": { zh: "需修订", en: "Needs Revision" }, + "chapter.imported": { zh: "已导入", en: "Imported" }, + "chapter.auditFailed": { zh: "审计失败", en: "Audit Failed" }, + "chapter.label": { zh: "第{n}章", en: "Chapter {n}" }, + "common.exportSuccess": { zh: "已导出到项目目录", en: "Exported to project directory" }, + "common.exportFormat": { zh: "导出格式", en: "Export format" }, } as const; export type StringKey = keyof typeof strings; diff --git a/packages/studio/src/index.css b/packages/studio/src/index.css index 6371a13..ffa4071 100644 --- a/packages/studio/src/index.css +++ b/packages/studio/src/index.css @@ -1,8 +1,11 @@ -@import "tailwindcss"; - -/* Instrument Serif for literary headings + DM Sans for clean UI */ +/* + InkOS Studio — Atmospheric Literary Workbench + Fonts: Instrument Serif (Headings/Literary), DM Sans (UI), JetBrains Mono (Data) +*/ @import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=JetBrains+Mono:wght@400;500&display=swap'); +@import "tailwindcss"; + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); @@ -23,91 +26,284 @@ --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); - --color-amber: var(--amber); - --color-amber-foreground: var(--amber-foreground); + --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); + --font-serif: 'Instrument Serif', Georgia, serif; --font-sans: 'DM Sans', system-ui, sans-serif; --font-mono: 'JetBrains Mono', monospace; + + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-soft: 0 2px 15px -3px oklch(0 0 0 / 0.07), 0 4px 6px -4px oklch(0 0 0 / 0.05); + --shadow-3d: 0 10px 30px -10px oklch(0 0 0 / 0.15), 0 4px 10px -5px oklch(0 0 0 / 0.1); + --shadow-3d-hover: 0 20px 40px -15px oklch(0 0 0 / 0.2), 0 8px 16px -8px oklch(0 0 0 / 0.15); } -/* ── Light: warm parchment ── */ +/* ── Light Mode: Warm Parchment & Ink ── */ :root { - --radius: 0.375rem; - --background: oklch(0.97 0.008 80); - --foreground: oklch(0.18 0.015 55); - --card: oklch(0.99 0.005 80); - --card-foreground: oklch(0.18 0.015 55); - --popover: oklch(0.99 0.005 80); - --popover-foreground: oklch(0.18 0.015 55); - --primary: oklch(0.62 0.14 55); - --primary-foreground: oklch(0.99 0.005 80); - --secondary: oklch(0.93 0.01 80); - --secondary-foreground: oklch(0.25 0.02 55); - --muted: oklch(0.93 0.008 80); - --muted-foreground: oklch(0.42 0.03 55); - --accent: oklch(0.93 0.012 55); - --accent-foreground: oklch(0.25 0.02 55); + --radius: 0.6rem; + + /* Background: Warm, aged paper with subtle radial light */ + --background: oklch(0.985 0.005 80); + --background-radial: radial-gradient(circle at 50% 0%, oklch(0.995 0.002 85) 0%, oklch(0.985 0.005 80) 100%); + --foreground: oklch(0.13 0.02 60); + + /* Cards: Crisp sheets on paper */ + --card: oklch(1 0 0); + --card-foreground: oklch(0.15 0.015 60); + + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.15 0.015 60); + + /* Primary: Deep Oxblood / Ink */ + --primary: oklch(0.45 0.12 25); + --primary-foreground: oklch(0.98 0.006 76); + + /* Secondary: Soft paper shadow */ + --secondary: oklch(0.94 0.01 76); + --secondary-foreground: oklch(0.25 0.015 60); + + /* Muted: Faded ink */ + --muted: oklch(0.94 0.008 76); + --muted-foreground: oklch(0.38 0.02 60); + + /* Accent: Subtle gold leaf */ + --accent: oklch(0.92 0.02 85); + --accent-foreground: oklch(0.25 0.05 60); + + --destructive: oklch(0.55 0.18 25); + --destructive-foreground: oklch(0.98 0 0); + + --border: oklch(0.84 0.01 76); + --input: oklch(0.92 0.005 76); + --ring: oklch(0.45 0.12 25); +} + +/* ── Dark Mode: Obsidian & Candlelight ── */ +.dark { + /* Background: Deep obsidian with soft top glow */ + --background: oklch(0.12 0.01 250); + --background-radial: radial-gradient(circle at 50% 0%, oklch(0.16 0.015 250) 0%, oklch(0.12 0.01 250) 100%); + --foreground: oklch(0.97 0.005 250); + + /* Cards: Floating graphite */ + --card: oklch(0.18 0.015 250); + --card-foreground: oklch(0.95 0.005 250); + + --popover: oklch(0.18 0.015 250); + --popover-foreground: oklch(0.95 0.005 250); + + /* Primary: Warm Amber / Candlelight */ + --primary: oklch(0.78 0.14 85); + --primary-foreground: oklch(0.12 0.01 250); + + /* Secondary: Dark slate */ + --secondary: oklch(0.22 0.02 250); + --secondary-foreground: oklch(0.92 0.005 250); + + /* Muted: Dimmed graphite */ + --muted: oklch(0.22 0.015 250); + --muted-foreground: oklch(0.78 0.005 250); + + /* Accent: Muted gold */ + --accent: oklch(0.28 0.04 85); + --accent-foreground: oklch(0.95 0.06 85); + --destructive: oklch(0.55 0.2 25); --destructive-foreground: oklch(0.98 0 0); - --border: oklch(0.82 0.015 75); - --input: oklch(0.82 0.015 75); - --ring: oklch(0.62 0.14 55); - --amber: oklch(0.75 0.15 70); - --amber-foreground: oklch(0.25 0.05 55); -} - -/* ── Dark: neutral charcoal + amber accent ── */ -.dark { - --background: #111111; - --foreground: #e8e6e3; - --card: #191919; - --card-foreground: #e8e6e3; - --popover: #191919; - --popover-foreground: #e8e6e3; - --primary: #d4a046; - --primary-foreground: #111111; - --secondary: #252525; - --secondary-foreground: #d4d0cb; - --muted: #252525; - --muted-foreground: #a09a93; - --accent: #2a2826; - --accent-foreground: #d4d0cb; - --destructive: #e5584f; - --destructive-foreground: #ffffff; - --border: #333333; - --input: #2a2a2a; - --ring: #d4a046; - --amber: #d4a046; - --amber-foreground: #111111; + + --border: oklch(0.30 0.02 250); + --input: oklch(0.25 0.02 250); + --ring: oklch(0.78 0.14 85); } body { font-family: var(--font-sans); font-size: 15px; + font-weight: 450; line-height: 1.6; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - background-color: var(--background); + background: var(--background-radial); + background-attachment: fixed; color: var(--foreground); + transition: background 0.5s ease, color 0.5s ease; } -/* Subtle grain texture */ +/* ── Cursor interaction rules ── */ +button, [role="button"], a, select, label[for], summary { cursor: pointer; } +button:disabled, [aria-disabled="true"] { cursor: not-allowed; } +input[type="text"], input[type="number"], input[type="email"], input[type="password"], textarea { cursor: text; } +input[type="checkbox"], input[type="radio"] { cursor: pointer; } + +/* Button hover micro-feedback */ +button:not(:disabled):active { + transform: scale(0.97); + transition: transform 0.1s ease; +} + +/* Subtle grain texture for physical feel */ body::before { content: ''; position: fixed; inset: 0; pointer-events: none; z-index: 50; - opacity: 0.03; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); + opacity: 0.035; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); + mix-blend-mode: overlay; } -/* Serif headings */ -h1, h2, h3 { +/* Serif headings with literary character */ +h1, h2, h3, h4 { font-family: var(--font-serif); - letter-spacing: -0.01em; + letter-spacing: -0.02em; + font-weight: 500; +} + +h1 { font-size: 2.25rem; font-style: italic; } +h2 { font-size: 1.75rem; } +h3 { font-size: 1.25rem; } + +/* Focus indicator */ +:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--muted-foreground); +} + +/* ── Animation Classes ── */ +.fade-in { + animation: fadeIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +.stagger-1 { animation-delay: 0.05s; opacity: 0; } +.stagger-2 { animation-delay: 0.1s; opacity: 0; } +.stagger-3 { animation-delay: 0.15s; opacity: 0; } +.stagger-4 { animation-delay: 0.2s; opacity: 0; } +.stagger-5 { animation-delay: 0.25s; opacity: 0; } + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Premium Layout Utilities ── */ +.glass-panel { + backdrop-blur: 16px; + background-color: color-mix(in oklch, var(--card) 70%, transparent); + border: 1px solid color-mix(in oklch, var(--border) 50%, transparent); + box-shadow: var(--shadow-3d); +} + +.paper-sheet { + background-color: var(--card); + border: 1px solid var(--border); + box-shadow: var(--shadow-md); + transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); + transform-style: preserve-3d; +} + +.paper-sheet:hover { + box-shadow: var(--shadow-3d-hover); + transform: translateY(-4px) scale(1.005); + border-color: var(--primary); +} + +/* ── ChatPanel Animations ── */ + +/* Header sparkle glow pulse */ +@keyframes iconGlow { + 0%, 100% { filter: drop-shadow(0 0 0px transparent); } + 50% { filter: drop-shadow(0 0 6px oklch(0.78 0.14 85 / 0.4)); } +} +.chat-icon-glow { animation: iconGlow 2s ease-in-out infinite; } + +/* Writing pen gentle bobbing */ +@keyframes iconWrite { + 0%, 100% { transform: translateY(0) rotate(0deg); } + 25% { transform: translateY(-1px) rotate(-3deg); } + 75% { transform: translateY(1px) rotate(3deg); } +} +.chat-icon-write { animation: iconWrite 1.2s ease-in-out infinite; } + +/* Slow spin for revision/wrench */ +@keyframes spinSlow { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +.chat-icon-spin-slow { animation: spinSlow 3s linear infinite; } + +/* Pop-in for success checkmark */ +@keyframes iconPop { + 0% { transform: scale(0); opacity: 0; } + 60% { transform: scale(1.2); } + 100% { transform: scale(1); opacity: 1; } +} +.chat-icon-pop { animation: iconPop 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; } + +/* Shake for trash icon hover */ +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-2px) rotate(-5deg); } + 75% { transform: translateX(2px) rotate(5deg); } +} + +/* Message slide-in from right (user) */ +@keyframes msgSlideRight { + from { opacity: 0; transform: translateX(12px); } + to { opacity: 1; transform: translateX(0); } +} +.chat-msg-user { animation: msgSlideRight 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; } + +/* Message slide-in from left (assistant) */ +@keyframes msgSlideLeft { + from { opacity: 0; transform: translateX(-12px); } + to { opacity: 1; transform: translateX(0); } +} +.chat-msg-assistant { animation: msgSlideLeft 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; } + +/* Thinking breathing glow */ +@keyframes thinkGlow { + 0%, 100% { box-shadow: 0 0 0 0 oklch(0.78 0.14 85 / 0); } + 50% { box-shadow: 0 0 12px 2px oklch(0.78 0.14 85 / 0.15); } +} +.chat-thinking-glow { animation: thinkGlow 2s ease-in-out infinite; } + +/* Typing wave for dots */ +@keyframes typingWave { + 0%, 60%, 100% { transform: translateY(0); } + 30% { transform: translateY(-6px); } +} +.chat-typing-dot { animation: typingWave 1.4s ease-in-out infinite; } +.chat-typing-dot:nth-child(2) { animation-delay: 0.15s; } +.chat-typing-dot:nth-child(3) { animation-delay: 0.3s; } + +/* Panel slide transition */ +.chat-panel-enter { + transition: width 300ms cubic-bezier(0.16, 1, 0.3, 1), + opacity 200ms ease-out; } diff --git a/packages/studio/src/pages/BookDetail.tsx b/packages/studio/src/pages/BookDetail.tsx index 042ad62..09e6c4e 100644 --- a/packages/studio/src/pages/BookDetail.tsx +++ b/packages/studio/src/pages/BookDetail.tsx @@ -1,8 +1,30 @@ -import { useApi, postApi } from "../hooks/use-api"; -import { useState } from "react"; +import { fetchJson, useApi, postApi } from "../hooks/use-api"; +import { useEffect, useMemo, useState } from "react"; import type { Theme } from "../hooks/use-theme"; import type { TFunction } from "../hooks/use-i18n"; +import type { SSEMessage } from "../hooks/use-sse"; import { useColors } from "../hooks/use-colors"; +import { deriveBookActivity, shouldRefetchBookView } from "../hooks/use-book-activity"; +import { ConfirmDialog } from "../components/ConfirmDialog"; +import { + ChevronLeft, + Zap, + FileText, + CheckCheck, + BarChart2, + Download, + Search, + Wand2, + Eye, + Database, + Check, + X, + ShieldCheck, + RotateCcw, + Sparkles, + Trash2, + Save +} from "lucide-react"; interface ChapterMeta { readonly number: number; @@ -18,6 +40,7 @@ interface BookData { readonly genre: string; readonly status: string; readonly chapterWordCount: number; + readonly targetChapters?: number; readonly language?: string; readonly fanficMode?: string; }; @@ -25,47 +48,174 @@ interface BookData { readonly nextChapter: number; } +type ReviseMode = "spot-fix" | "polish" | "rewrite" | "rework" | "anti-detect"; +type ExportFormat = "txt" | "md" | "epub"; +type BookStatus = "active" | "paused" | "outlining" | "completed" | "dropped"; + interface Nav { toDashboard: () => void; toChapter: (bookId: string, num: number) => void; toAnalytics: (bookId: string) => void; } -const STATUS_COLORS: Record = { - "ready-for-review": "text-amber-400", - approved: "text-emerald-400", - drafted: "text-zinc-400", - "needs-revision": "text-red-400", - imported: "text-blue-400", +function translateChapterStatus(status: string, t: TFunction): string { + const map: Record string> = { + "ready-for-review": () => t("chapter.readyForReview"), + "approved": () => t("chapter.approved"), + "drafted": () => t("chapter.drafted"), + "needs-revision": () => t("chapter.needsRevision"), + "imported": () => t("chapter.imported"), + "audit-failed": () => t("chapter.auditFailed"), + }; + return map[status]?.() ?? status; +} + +const STATUS_CONFIG: Record = { + "ready-for-review": { color: "text-amber-500 bg-amber-500/10", icon: }, + approved: { color: "text-emerald-500 bg-emerald-500/10", icon: }, + drafted: { color: "text-muted-foreground bg-muted/20", icon: }, + "needs-revision": { color: "text-destructive bg-destructive/10", icon: }, + imported: { color: "text-blue-500 bg-blue-500/10", icon: }, }; -export function BookDetail({ bookId, nav, theme, t }: { bookId: string; nav: Nav; theme: Theme; t: TFunction }) { +export function BookDetail({ + bookId, + nav, + theme, + t, + sse, +}: { + bookId: string; + nav: Nav; + theme: Theme; + t: TFunction; + sse: { messages: ReadonlyArray }; +}) { const c = useColors(theme); const { data, loading, error, refetch } = useApi(`/books/${bookId}`); - const [writing, setWriting] = useState(false); - const [drafting, setDrafting] = useState(false); + const [writeRequestPending, setWriteRequestPending] = useState(false); + const [draftRequestPending, setDraftRequestPending] = useState(false); + const [deleting, setDeleting] = useState(false); + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const [rewritingChapters, setRewritingChapters] = useState>([]); + const [revisingChapters, setRevisingChapters] = useState>([]); + const [savingSettings, setSavingSettings] = useState(false); + const [settingsWordCount, setSettingsWordCount] = useState(null); + const [settingsTargetChapters, setSettingsTargetChapters] = useState(null); + const [settingsStatus, setSettingsStatus] = useState(null); + const [exportFormat, setExportFormat] = useState("txt"); + const [exportApprovedOnly, setExportApprovedOnly] = useState(false); + const activity = useMemo(() => deriveBookActivity(sse.messages, bookId), [bookId, sse.messages]); + const writing = writeRequestPending || activity.writing; + const drafting = draftRequestPending || activity.drafting; + + useEffect(() => { + const recent = sse.messages.at(-1); + if (!recent) return; + + const data = recent.data as { bookId?: string } | null; + if (data?.bookId !== bookId) return; + + if (recent.event === "write:start") { + setWriteRequestPending(false); + return; + } + + if (recent.event === "draft:start") { + setDraftRequestPending(false); + return; + } + + if (shouldRefetchBookView(recent, bookId)) { + setWriteRequestPending(false); + setDraftRequestPending(false); + refetch(); + } + }, [bookId, refetch, sse.messages]); const handleWriteNext = async () => { - setWriting(true); + setWriteRequestPending(true); try { await postApi(`/books/${bookId}/write-next`); - refetch(); } catch (e) { + setWriteRequestPending(false); alert(e instanceof Error ? e.message : "Failed"); - } finally { - setWriting(false); } }; const handleDraft = async () => { - setDrafting(true); + setDraftRequestPending(true); try { await postApi(`/books/${bookId}/draft`); + } catch (e) { + setDraftRequestPending(false); + alert(e instanceof Error ? e.message : "Failed"); + } + }; + + const handleDeleteBook = async () => { + setConfirmDeleteOpen(false); + setDeleting(true); + try { + const res = await fetch(`/api/books/${bookId}`, { method: "DELETE" }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error((json as { error?: string }).error ?? `${res.status}`); + } + nav.toDashboard(); + } catch (e) { + alert(e instanceof Error ? e.message : "Delete failed"); + } finally { + setDeleting(false); + } + }; + + const handleRewrite = async (chapterNum: number) => { + setRewritingChapters((prev) => [...prev, chapterNum]); + try { + await postApi(`/books/${bookId}/rewrite/${chapterNum}`); refetch(); } catch (e) { - alert(e instanceof Error ? e.message : "Failed"); + alert(e instanceof Error ? e.message : "Rewrite failed"); } finally { - setDrafting(false); + setRewritingChapters((prev) => prev.filter((n) => n !== chapterNum)); + } + }; + + const handleRevise = async (chapterNum: number, mode: ReviseMode) => { + setRevisingChapters((prev) => [...prev, chapterNum]); + try { + await fetchJson(`/books/${bookId}/revise/${chapterNum}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode }), + }); + refetch(); + } catch (e) { + alert(e instanceof Error ? e.message : "Revision failed"); + } finally { + setRevisingChapters((prev) => prev.filter((n) => n !== chapterNum)); + } + }; + + const handleSaveSettings = async () => { + if (!data) return; + setSavingSettings(true); + try { + const body: Record = {}; + if (settingsWordCount !== null) body.chapterWordCount = settingsWordCount; + if (settingsTargetChapters !== null) body.targetChapters = settingsTargetChapters; + if (settingsStatus !== null) body.status = settingsStatus; + await fetchJson(`/books/${bookId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + refetch(); + } catch (e) { + alert(e instanceof Error ? e.message : "Save failed"); + } finally { + setSavingSettings(false); } }; @@ -78,172 +228,352 @@ export function BookDetail({ bookId, nav, theme, t }: { bookId: string; nav: Nav refetch(); }; - if (loading) return
Loading...
; - if (error) return
Error: {error}
; + if (loading) return ( +
+
+ {t("common.loading")} +
+ ); + + if (error) return
Error: {error}
; if (!data) return null; const { book, chapters } = data; const totalWords = chapters.reduce((sum, ch) => sum + (ch.wordCount ?? 0), 0); const reviewCount = chapters.filter((ch) => ch.status === "ready-for-review").length; - return ( -
-
- - / - {book.title} -
+ const currentWordCount = settingsWordCount ?? book.chapterWordCount; + const currentTargetChapters = settingsTargetChapters ?? book.targetChapters ?? 0; + const currentStatus = settingsStatus ?? (book.status as BookStatus); -
-
-

{book.title}

-
- {book.genre} - {chapters.length} chapters - {totalWords.toLocaleString()} words - {book.language === "en" && EN} - {book.fanficMode && fanfic:{book.fanficMode}} + const exportHref = `/api/books/${bookId}/export?format=${exportFormat}${exportApprovedOnly ? "&approvedOnly=true" : ""}`; + + return ( +
+ {/* Breadcrumbs */} + + + {/* Header Section */} +
+
+
+

{book.title}

+ {book.language === "en" && ( + EN + )} +
+
+ {book.genre} +
+ + {chapters.length} {t("dash.chapters")} +
+
+ + {totalWords.toLocaleString()} {t("book.words")} +
+ {book.fanficMode && ( + + + fanfic:{book.fanficMode} + + )}
-
+
+ +
+
+ + {(writing || drafting || activity.lastError) && ( +
+ {activity.lastError ? ( + + {t("book.pipelineFailed")}: {activity.lastError} + + ) : writing ? ( + {t("book.pipelineWriting")} + ) : ( + {t("book.pipelineDrafting")} + )} +
+ )} + + {/* Tool Strip */} +
{reviewCount > 0 && ( )} - + + + +
+
+ + {/* Book Settings */} +
+

{t("book.settings")}

+
+
+ + setSettingsWordCount(Number(e.target.value))} + className="px-3 py-2 text-sm rounded-lg border border-border/50 bg-secondary/30 outline-none focus:border-primary/50 w-32" + /> +
+
+ + setSettingsTargetChapters(Number(e.target.value))} + className="px-3 py-2 text-sm rounded-lg border border-border/50 bg-secondary/30 outline-none focus:border-primary/50 w-32" + /> +
+
+ + +
+
-
- - - - - - - - - - - - {chapters.map((ch) => ( - - - - - - + {/* Chapters Table */} +
+
+
#TitleWordsStatusActions
{ch.number} - - {(ch.wordCount ?? 0).toLocaleString()} - {ch.status} - -
- {ch.status === "ready-for-review" && ( - <> - - - - )} - - - -
-
+ + + + + + + - ))} - -
#{t("book.manuscriptTitle")}{t("book.words")}{t("book.status")}{t("book.curate")}
+ + + {chapters.map((ch, index) => { + const staggerClass = `stagger-${Math.min(index + 1, 5)}`; + return ( + + {ch.number.toString().padStart(2, '0')} + + + + {(ch.wordCount ?? 0).toLocaleString()} + +
+ {STATUS_CONFIG[ch.status]?.icon} + {translateChapterStatus(ch.status, t)} +
+ + +
+ {ch.status === "ready-for-review" && ( + <> + + + + )} + + + +
+ + + ); + })} + + +
{chapters.length === 0 && ( -
- {t("book.noChapters")} +
+
+ +
+

+ {t("book.noChapters")} +

)}
+ + setConfirmDeleteOpen(false)} + />
); } diff --git a/packages/studio/src/pages/ChapterReader.tsx b/packages/studio/src/pages/ChapterReader.tsx index 02def0e..19efee1 100644 --- a/packages/studio/src/pages/ChapterReader.tsx +++ b/packages/studio/src/pages/ChapterReader.tsx @@ -1,7 +1,24 @@ -import { useApi, postApi } from "../hooks/use-api"; +import { useState } from "react"; +import { fetchJson, useApi, postApi } from "../hooks/use-api"; import type { Theme } from "../hooks/use-theme"; import type { TFunction } from "../hooks/use-i18n"; import { useColors } from "../hooks/use-colors"; +import { + ChevronLeft, + Check, + X, + List, + RotateCcw, + BookOpen, + CheckCircle2, + XCircle, + Hash, + Type, + Clock, + Pencil, + Save, + Eye, +} from "lucide-react"; interface ChapterData { readonly chapterNumber: number; @@ -22,12 +39,49 @@ export function ChapterReader({ bookId, chapterNumber, nav, theme, t }: { t: TFunction; }) { const c = useColors(theme); - const { data, loading, error } = useApi( + const { data, loading, error, refetch } = useApi( `/books/${bookId}/chapters/${chapterNumber}`, ); + const [editing, setEditing] = useState(false); + const [editContent, setEditContent] = useState(""); + const [saving, setSaving] = useState(false); - if (loading) return
Loading...
; - if (error) return
Error: {error}
; + const handleStartEdit = () => { + if (!data) return; + setEditContent(data.content); + setEditing(true); + }; + + const handleCancelEdit = () => { + setEditing(false); + setEditContent(""); + }; + + const handleSave = async () => { + setSaving(true); + try { + await fetchJson(`/books/${bookId}/chapters/${chapterNumber}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: editContent }), + }); + setEditing(false); + refetch(); + } catch (e) { + alert(e instanceof Error ? e.message : "Save failed"); + } finally { + setSaving(false); + } + }; + + if (loading) return ( +
+
+ {t("reader.openingManuscript")} +
+ ); + + if (error) return
Error: {error}
; if (!data) return null; // Split markdown content into title and body @@ -49,67 +103,155 @@ export function ChapterReader({ bookId, chapterNumber, nav, theme, t }: { nav.toBook(bookId); }; - // Simple paragraph rendering const paragraphs = body.split(/\n\n+/).filter(Boolean); return ( -
-
- - / - - / - {t("bread.chapter").replace("{n}", String(chapterNumber))} -
+
+ {/* Navigation & Actions */} +
+ -
-

{title}

- {chapterNumber > 1 && ( + + + {/* Edit / Preview toggle */} + {editing ? ( + <> + + + + ) : ( )} +
-
- {paragraphs.map((para, i) => ( -

- {para} -

- ))} -
+ {/* Manuscript Sheet */} +
+ {/* Physical Paper Details */} +
+
+ +
+
+
+ +
+
+

+ {title} +

+
+ {t("reader.manuscriptPage")} + · + {chapterNumber.toString().padStart(2, '0')} +
+
-
+ {editing ? ( +