wip(studio): preserve detached studio worktree changes

This commit is contained in:
Ma 2026-03-30 14:09:09 +08:00
parent 9ef1e28b6b
commit c7ac2f2324
29 changed files with 3720 additions and 574 deletions

View file

@ -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();
});
});

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<DraftResult> {
const releaseLock = await this.state.acquireBookLock(bookId);

View file

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📜</text></svg>">
<title>InkOS Studio</title>
</head>
<body>

View file

@ -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"
}
}

View file

@ -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<Route>({ 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 <div className="min-h-screen bg-background" />;
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
</div>
);
}
if (showLanguageSelector) {
@ -94,30 +111,55 @@ export function App() {
}
return (
<div className="h-screen bg-background text-foreground flex overflow-hidden">
<Sidebar nav={nav} activePage={activePage} t={t} />
<div className="h-screen bg-background text-foreground flex overflow-hidden font-sans">
{/* Left Sidebar */}
<Sidebar nav={nav} activePage={activePage} sse={sse} t={t} />
<div className="flex-1 flex flex-col min-w-0">
{/* Thin utility strip — pushed inward */}
<div className="h-12 shrink-0 flex items-center justify-end px-8 gap-4">
<button
onClick={() => setTheme(isDark ? "light" : "dark")}
className="w-8 h-8 flex items-center justify-center rounded-full bg-secondary text-muted-foreground hover:text-foreground hover:bg-secondary/80 transition-all text-sm"
>
{isDark ? "☀" : "☽"}
</button>
{sse.connected && (
<span className="text-xs text-muted-foreground uppercase tracking-wide">
{t("nav.connected")}
</span>
)}
</div>
{/* Center Content */}
<div className="flex-1 flex flex-col min-w-0 bg-background/30 backdrop-blur-sm">
{/* Header Strip */}
<header className="h-14 shrink-0 flex items-center justify-between px-8 border-b border-border/40">
<div className="flex items-center gap-2">
<span className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-bold">
InkOS Studio
</span>
</div>
{/* Scrollable main — centered with generous inset */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto px-10 py-12">
<div className="flex items-center gap-3">
<button
onClick={() => setTheme(isDark ? "light" : "dark")}
className="w-8 h-8 flex items-center justify-center rounded-lg bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all shadow-sm"
title={isDark ? "Switch to Light Mode" : "Switch to Dark Mode"}
>
{isDark ? <Sun size={16} /> : <Moon size={16} />}
</button>
<button className="w-8 h-8 flex items-center justify-center rounded-lg bg-secondary text-muted-foreground hover:text-foreground transition-all relative">
<Bell size={16} />
<span className="absolute top-1 right-1 w-2 h-2 bg-primary rounded-full border-2 border-background" />
</button>
{/* Chat Panel Toggle */}
<button
onClick={() => setChatOpen((prev) => !prev)}
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all shadow-sm ${
chatOpen
? "bg-primary text-primary-foreground shadow-primary/20"
: "bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10"
}`}
title="Toggle AI Assistant"
>
<MessageSquare size={16} />
</button>
</div>
</header>
{/* Main Content Area */}
<main className="flex-1 overflow-y-auto scroll-smooth">
<div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
{route.page === "dashboard" && <Dashboard nav={nav} sse={sse} theme={theme} t={t} />}
{route.page === "book" && <BookDetail bookId={route.bookId} nav={nav} theme={theme} t={t} />}
{route.page === "book" && <BookDetail bookId={route.bookId} nav={nav} theme={theme} t={t} sse={sse} />}
{route.page === "book-create" && <BookCreate nav={nav} theme={theme} t={t} />}
{route.page === "chapter" && <ChapterReader bookId={route.bookId} chapterNumber={route.chapterNumber} nav={nav} theme={theme} t={t} />}
{route.page === "analytics" && <Analytics bookId={route.bookId} nav={nav} theme={theme} t={t} />}
@ -126,12 +168,21 @@ export function App() {
{route.page === "daemon" && <DaemonControl nav={nav} theme={theme} t={t} sse={sse} />}
{route.page === "logs" && <LogViewer nav={nav} theme={theme} t={t} />}
{route.page === "genres" && <GenreManager nav={nav} theme={theme} t={t} />}
{route.page === "style" && <StyleManager nav={nav} theme={theme} t={t} />}
{route.page === "import" && <ImportManager nav={nav} theme={theme} t={t} />}
{route.page === "radar" && <RadarView nav={nav} theme={theme} t={t} />}
{route.page === "doctor" && <DoctorView nav={nav} theme={theme} t={t} />}
</div>
</div>
{/* Chat bar — inset to match content width */}
<ChatBar t={t} sse={sse} />
</main>
</div>
{/* Right Chat Panel */}
<ChatPanel
open={chatOpen}
onClose={() => setChatOpen(false)}
t={t}
sse={sse}
/>
</div>
);
}

View file

@ -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<typeof fetch>()
.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<typeof fetch>().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.');
});
});

View file

@ -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";
}

View file

@ -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<string, StudioRun>();
private readonly subscribers = new Map<string, Set<RunSubscriber>>();
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<StudioRun> {
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<RunSubscriber>();
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<StudioRun> | ((run: StudioRun) => Partial<StudioRun>),
events: ReadonlyArray<RunStreamEvent>,
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";
}

View file

@ -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<Uint8Array>({
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",
},
});
}

View file

@ -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<string, string | undefined> = 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;
}

View file

@ -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<void>>();
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();
});
});

View file

@ -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<SSEMessage>; connected: boolean };
// ── Sub-components ──
function StatusIcon({ phase }: { readonly phase: string }) {
const lower = phase.toLowerCase();
if (lower.includes("think") || lower.includes("plan"))
return <Brain size={14} className="text-purple-500 animate-pulse" />;
if (lower.includes("writ") || lower.includes("draft") || lower.includes("stream"))
return <PenTool size={14} className="text-blue-500 chat-icon-write" />;
if (lower.includes("audit") || lower.includes("review"))
return <Shield size={14} className="text-amber-500 animate-pulse" />;
if (lower.includes("revis") || lower.includes("fix") || lower.includes("spot"))
return <Wrench size={14} className="text-orange-500 chat-icon-spin-slow" />;
if (lower.includes("complet") || lower.includes("done") || lower.includes("success"))
return <CheckCircle2 size={14} className="text-emerald-500 chat-icon-pop" />;
if (lower.includes("error") || lower.includes("fail"))
return <AlertTriangle size={14} className="text-destructive animate-pulse" />;
return <Loader2 size={14} className="text-primary animate-spin" />;
}
function EmptyState() {
return (
<div className="h-full flex flex-col items-center justify-center text-center opacity-40 select-none fade-in">
<div className="w-14 h-14 rounded-2xl border border-dashed border-border flex items-center justify-center mb-4 bg-secondary/30">
<BotMessageSquare size={24} className="text-muted-foreground" />
</div>
<p className="text-sm italic font-serif mb-1">How shall we proceed today?</p>
<p className="text-[10px] text-muted-foreground/60 uppercase tracking-widest">Type a command below</p>
</div>
);
}
function ThinkingBubble() {
return (
<div className="flex gap-2.5 chat-msg-assistant">
<div className="w-7 h-7 rounded-lg bg-secondary flex items-center justify-center shrink-0 chat-thinking-glow">
<Brain size={14} className="text-primary animate-pulse" />
</div>
<div className="bg-card border border-border/50 px-3.5 py-2.5 rounded-2xl rounded-tl-sm flex gap-1.5 items-center">
<span className="w-1.5 h-1.5 bg-primary/50 rounded-full chat-typing-dot" />
<span className="w-1.5 h-1.5 bg-primary/50 rounded-full chat-typing-dot" />
<span className="w-1.5 h-1.5 bg-primary/50 rounded-full chat-typing-dot" />
</div>
</div>
);
}
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 (
<div className={cn(
"flex gap-2.5",
isUser ? "flex-row-reverse chat-msg-user" : "chat-msg-assistant",
)}>
{/* Avatar */}
<div className={cn(
"w-7 h-7 rounded-lg flex items-center justify-center shrink-0 mt-0.5 transition-colors",
isUser ? "bg-primary/10" : "bg-secondary",
)}>
{isUser ? (
<User size={14} className="text-primary" />
) : isSuccess ? (
<CheckCircle2 size={14} className="text-emerald-500 chat-icon-pop" />
) : isError ? (
<XCircle size={14} className="text-destructive" />
) : isStatus ? (
<Loader2 size={14} className="text-primary animate-spin" />
) : (
<Sparkles size={14} className="text-primary" />
)}
</div>
{/* Bubble */}
<div className={cn(
"max-w-[80%] rounded-2xl px-3.5 py-2 text-sm leading-relaxed shadow-sm",
isUser
? "bg-primary text-primary-foreground font-medium rounded-tr-sm"
: isStatus
? "bg-secondary/50 border border-border/30 text-muted-foreground font-mono text-xs rounded-tl-sm"
: "bg-card border border-border/50 text-foreground font-serif rounded-tl-sm",
)}>
{/* Status badge */}
{isSuccess && (
<span className="flex items-center gap-1.5 text-emerald-600 dark:text-emerald-400 font-sans font-medium text-[10px] mb-1 uppercase tracking-wider">
<BadgeCheck size={11} />
Complete
</span>
)}
{isError && (
<span className="flex items-center gap-1.5 text-destructive font-sans font-medium text-[10px] mb-1 uppercase tracking-wider">
<CircleAlert size={11} />
Error
</span>
)}
<div>{msg.content}</div>
{/* Timestamp */}
<div className={cn(
"text-[9px] mt-1.5 font-mono",
isUser ? "text-primary-foreground/40" : "text-muted-foreground/40",
)}>
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</div>
</div>
</div>
);
}
function QuickChip({ icon, label, onClick }: {
readonly icon: React.ReactNode;
readonly label: string;
readonly onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="shrink-0 flex items-center gap-1 px-2.5 py-1 rounded-lg bg-secondary/50 border border-border/30 text-[10px] font-medium text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/5 transition-all group"
>
<span className="group-hover:scale-110 transition-transform">{icon}</span>
{label}
</button>
);
}
// ── Main Component ──
export function ChatPanel({ open, onClose, t, sse }: {
readonly open: boolean;
readonly onClose: () => void;
readonly t: TFunction;
readonly sse: { messages: ReadonlyArray<SSEMessage>; connected: boolean };
}) {
const [input, setInput] = useState("");
const [expanded, setExpanded] = useState(false);
const [messages, setMessages] = useState<ReadonlyArray<ChatMessage>>([]);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(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 (
<div className="border border-border/60 bg-card/40 mx-6 mb-3 rounded-md">
{/* Expanded message area */}
{expanded && messages.length > 0 && (
<div>
<aside
className={cn(
"h-full flex flex-col border-l border-border/40 bg-background/80 backdrop-blur-md chat-panel-enter shrink-0 overflow-hidden",
open ? "w-[380px] opacity-100" : "w-0 opacity-0",
)}
>
{open && (
<>
{/* ── Section 1: Header ── */}
<div className="h-12 shrink-0 px-4 flex items-center justify-between border-b border-border/40">
<div className="flex items-center gap-2.5">
<div className="relative">
<Sparkles size={15} className="text-primary chat-icon-glow" />
{loading && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-primary rounded-full animate-ping" />
)}
</div>
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-foreground">
InkOS Assistant
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setMessages([])}
className="p-1.5 rounded-md text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors group"
title="Clear conversation"
>
<Trash2 size={14} className="group-hover:animate-[shake_0.3s_ease-in-out]" />
</button>
<button
onClick={onClose}
className="p-1.5 rounded-md text-muted-foreground hover:bg-secondary transition-colors group"
title="Close panel"
>
<PanelRightClose size={14} className="group-hover:translate-x-0.5 transition-transform" />
</button>
</div>
</div>
{/* ── Section 2: Status Bar (when loading) ── */}
{loading && (
<div className="shrink-0 px-4 py-2 border-b border-border/30 bg-primary/[0.03] fade-in">
<div className="flex items-center gap-2.5">
<StatusIcon phase={currentPhase} />
<span className="text-xs font-medium text-primary truncate flex-1">
{currentPhase}
</span>
<div className="flex gap-1">
<span className="w-1 h-1 bg-primary/40 rounded-full chat-typing-dot" />
<span className="w-1 h-1 bg-primary/40 rounded-full chat-typing-dot" />
<span className="w-1 h-1 bg-primary/40 rounded-full chat-typing-dot" />
</div>
</div>
</div>
)}
{/* ── Section 3: Messages ── */}
<div
ref={scrollRef}
className="max-h-[180px] overflow-y-auto px-4 py-3 space-y-2"
className="flex-1 overflow-y-auto px-4 py-4 space-y-3"
>
{messages.map((msg, i) => (
<div
key={i}
className={`text-sm leading-relaxed ${
msg.role === "user"
? "text-foreground"
: msg.content.startsWith("✗")
? "text-destructive"
: msg.content.startsWith("⋯")
? "text-muted-foreground"
: "text-primary"
}`}
>
{msg.role === "user" && <span className="text-muted-foreground mr-2"></span>}
{msg.content}
</div>
{messages.length === 0 && !loading && <EmptyState />}
{messages.map((msg) => (
<MessageBubble key={msg.timestamp} msg={msg} />
))}
{loading && !messages.some((m) => m.content.startsWith("⋯")) && (
<div className="text-sm text-muted-foreground animate-pulse"></div>
<ThinkingBubble />
)}
</div>
</div>
)}
{/* Input bar — centered to match main content */}
<div className="px-4 py-2 flex items-center gap-3">
<div className="flex-1 relative">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center gap-3 shrink-0">
{expanded && messages.length > 0 && (
<button
onClick={() => { setExpanded(false); setMessages([]); }}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
)}
<button
onClick={handleSubmit}
disabled={!input.trim() || loading}
className="w-7 h-7 rounded-md bg-primary/70 text-primary-foreground flex items-center justify-center text-xs hover:bg-primary transition-all disabled:opacity-15"
>
</button>
</div>
</div>
</div>
{/* ── Section 4: Quick Commands ── */}
<div className="shrink-0 px-3 py-2 border-t border-border/30 flex gap-1.5 overflow-x-auto">
<QuickChip
icon={<Zap size={11} />}
label={t("dash.writeNext")}
onClick={() => handleQuickCommand(isZh ? "写下一章" : "write next")}
/>
<QuickChip
icon={<Search size={11} />}
label={t("book.audit")}
onClick={() => handleQuickCommand(isZh ? "审计第1章" : "audit chapter 1")}
/>
<QuickChip
icon={<FileOutput size={11} />}
label={t("book.export")}
onClick={() => handleQuickCommand(isZh ? "导出全书" : "export book as epub")}
/>
<QuickChip
icon={<TrendingUp size={11} />}
label={t("nav.radar")}
onClick={() => handleQuickCommand(isZh ? "扫描市场趋势" : "scan market trends")}
/>
</div>
{/* ── Section 5: Input ── */}
<div className="shrink-0 p-3 border-t border-border/40">
<div className="flex items-center gap-2 rounded-xl bg-secondary/30 border border-border/40 px-3 py-1.5 focus-within:border-primary/40 focus-within:ring-2 focus-within:ring-primary/10 transition-all">
<MessageSquare size={14} className="text-muted-foreground/50 shrink-0" />
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => 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" }}
/>
<button
onClick={handleSubmit}
disabled={!input.trim() || loading}
className="w-7 h-7 rounded-lg bg-primary text-primary-foreground flex items-center justify-center hover:scale-105 active:scale-95 transition-all disabled:opacity-20 disabled:scale-100 shadow-sm shadow-primary/20"
>
{loading ? (
<Loader2 size={14} className="animate-spin" />
) : (
<ArrowUp size={14} strokeWidth={2.5} />
)}
</button>
</div>
{/* Rotating tip */}
{!input && (
<div className="mt-1.5 px-1 flex items-center gap-1.5">
<Lightbulb size={10} className="text-muted-foreground/30 shrink-0" />
<span className="text-[9px] text-muted-foreground/40 truncate fade-in" key={tipIndex}>
{tips[tipIndex]}
</span>
</div>
)}
</div>
</>
)}
</aside>
);
}

View file

@ -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<HTMLDivElement>(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 (
<div
ref={overlayRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm fade-in"
onClick={(e) => { if (e.target === overlayRef.current) onCancel(); }}
>
<div className="bg-card border border-border rounded-2xl shadow-2xl shadow-primary/10 w-full max-w-md mx-4 overflow-hidden chat-msg-assistant">
{/* Header */}
<div className="flex items-center justify-between px-6 pt-6 pb-2">
<div className="flex items-center gap-3">
{isDanger && (
<div className="w-10 h-10 rounded-xl bg-destructive/10 flex items-center justify-center">
<AlertTriangle size={20} className="text-destructive" />
</div>
)}
<h3 className="text-lg font-semibold">{title}</h3>
</div>
<button
onClick={onCancel}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
<X size={16} />
</button>
</div>
{/* Body */}
<div className="px-6 py-4">
<p className="text-sm text-muted-foreground leading-relaxed">{message}</p>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 px-6 pb-6">
<button
onClick={onCancel}
className="px-4 py-2.5 text-sm font-medium rounded-xl bg-secondary text-foreground hover:bg-secondary/80 transition-all border border-border/50"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={`px-4 py-2.5 text-sm font-bold rounded-xl transition-all hover:scale-105 active:scale-95 shadow-sm ${
isDanger
? "bg-destructive text-white hover:shadow-destructive/20"
: "bg-primary text-primary-foreground hover:shadow-primary/20"
}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}

View file

@ -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<SSEMessage> };
t: TFunction;
}) {
const { data } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
const { data: daemon } = useApi<{ running: boolean }>("/daemon");
const { data, refetch: refetchBooks } = useApi<{ books: ReadonlyArray<BookSummary> }>("/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 (
<aside className="w-[240px] shrink-0 border-r border-border bg-card/40 flex flex-col h-full overflow-y-auto">
{/* Logo — generous vertical breathing room */}
<div className="px-6 pt-7 pb-6">
<button onClick={nav.toDashboard} className="flex items-baseline gap-0.5 hover:opacity-70 transition-opacity">
<span className="font-serif text-2xl italic text-primary">Ink</span>
<span className="text-lg font-semibold tracking-tight">OS</span>
<aside className="w-[260px] shrink-0 border-r border-border bg-background/80 backdrop-blur-md flex flex-col h-full overflow-hidden select-none">
{/* Logo Area */}
<div className="px-6 py-8">
<button
onClick={nav.toDashboard}
className="group flex items-center gap-2 hover:opacity-80 transition-all duration-300"
>
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center text-primary-foreground shadow-lg shadow-primary/20 group-hover:scale-105 transition-transform">
<ScrollText size={18} />
</div>
<div className="flex flex-col">
<span className="font-serif text-xl leading-none italic font-medium">InkOS</span>
<span className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-bold mt-1">Studio</span>
</div>
</button>
</div>
{/* Books section */}
<div className="flex-1 px-4">
<div className="px-2 mb-3 flex items-center justify-between">
<span className="text-sm uppercase tracking-wide text-muted-foreground font-medium">{t("nav.books")}</span>
<button
onClick={nav.toBookCreate}
className="w-6 h-6 flex items-center justify-center rounded text-sm text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all"
>
+
</button>
{/* Main Navigation */}
<div className="flex-1 overflow-y-auto px-4 py-2 space-y-6">
{/* Books Section */}
<div>
<div className="px-3 mb-3 flex items-center justify-between">
<span className="text-[11px] uppercase tracking-widest text-muted-foreground font-bold">
{t("nav.books")}
</span>
<button
onClick={nav.toBookCreate}
className="p-1 rounded-md text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all group"
title={t("nav.newBook")}
>
<Plus size={14} className="group-hover:rotate-90 transition-transform duration-300" />
</button>
</div>
<div className="space-y-1">
{data?.books.map((book) => (
<button
key={book.id}
onClick={() => nav.toBook(book.id)}
className={`w-full group flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all duration-200 ${
activePage === `book:${book.id}`
? "bg-primary/10 text-primary font-semibold"
: "text-foreground font-medium hover:text-foreground hover:bg-secondary/50"
}`}
>
<Book size={16} className={activePage === `book:${book.id}` ? "text-primary" : "text-muted-foreground group-hover:text-foreground"} />
<span className="truncate flex-1 text-left">{book.title}</span>
{book.chaptersWritten > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground group-hover:bg-primary/20 group-hover:text-primary transition-colors">
{book.chaptersWritten}
</span>
)}
</button>
))}
{(!data?.books || data.books.length === 0) && (
<div className="px-3 py-6 text-xs text-muted-foreground/70 italic text-center border border-dashed border-border rounded-lg">
{t("dash.noBooks")}
</div>
)}
</div>
</div>
<div className="space-y-1">
{data?.books.map((book) => (
<button
key={book.id}
onClick={() => nav.toBook(book.id)}
className={`w-full text-left px-3 py-2.5 rounded-md text-base truncate transition-all duration-150 ${
activePage === `book:${book.id}`
? "bg-primary/10 text-primary font-medium"
: "text-foreground/80 hover:text-foreground hover:bg-muted/40"
}`}
>
{book.title}
</button>
))}
{/* System Section */}
<div>
<div className="px-3 mb-3">
<span className="text-[11px] uppercase tracking-widest text-muted-foreground font-bold">
{t("nav.system")}
</span>
</div>
<div className="space-y-1">
<SidebarItem
label={t("create.genre")}
icon={<Boxes size={16} />}
active={activePage === "genres"}
onClick={nav.toGenres}
/>
<SidebarItem
label={t("nav.config")}
icon={<Settings size={16} />}
active={activePage === "config"}
onClick={nav.toConfig}
/>
<SidebarItem
label={t("nav.daemon")}
icon={<Zap size={16} />}
active={activePage === "daemon"}
onClick={nav.toDaemon}
badge={daemon?.running ? t("nav.running") : undefined}
badgeColor={daemon?.running ? "bg-emerald-500/10 text-emerald-500" : "bg-muted text-muted-foreground"}
/>
<SidebarItem
label={t("nav.logs")}
icon={<Terminal size={16} />}
active={activePage === "logs"}
onClick={nav.toLogs}
/>
</div>
</div>
{(!data?.books || data.books.length === 0) && (
<div className="px-3 py-4 text-xs text-muted-foreground/50 italic leading-relaxed">
{t("dash.noBooks")}
</div>
)}
{/* Tools Section */}
<div>
<div className="px-3 mb-3">
<span className="text-[11px] uppercase tracking-widest text-muted-foreground font-bold">
{t("nav.tools")}
</span>
</div>
<div className="space-y-1">
<SidebarItem
label={t("nav.style")}
icon={<Wand2 size={16} />}
active={activePage === "style"}
onClick={nav.toStyle}
/>
<SidebarItem
label={t("nav.import")}
icon={<FileInput size={16} />}
active={activePage === "import"}
onClick={nav.toImport}
/>
<SidebarItem
label={t("nav.radar")}
icon={<TrendingUp size={16} />}
active={activePage === "radar"}
onClick={nav.toRadar}
/>
<SidebarItem
label={t("nav.doctor")}
icon={<Stethoscope size={16} />}
active={activePage === "doctor"}
onClick={nav.toDoctor}
/>
</div>
</div>
</div>
{/* System nav — generous spacing from book list */}
<div className="border-t border-border mt-4 pt-4 pb-5 px-4 space-y-1">
<SidebarItem
label={t("create.genre")}
icon="◈"
active={activePage === "genres"}
onClick={nav.toGenres}
/>
<SidebarItem
label={t("nav.config")}
icon="⚙"
active={activePage === "config"}
onClick={nav.toConfig}
/>
<SidebarItem
label="Daemon"
icon="⟳"
active={activePage === "daemon"}
onClick={nav.toDaemon}
badge={daemon?.running ? "●" : undefined}
badgeColor={daemon?.running ? "text-emerald-500" : undefined}
/>
<SidebarItem
label="Logs"
icon="☰"
active={activePage === "logs"}
onClick={nav.toLogs}
/>
{/* Footer / Status Area */}
<div className="p-4 border-t border-border bg-secondary/40">
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-card border border-border shadow-sm">
<div className={`w-2 h-2 rounded-full ${daemon?.running ? "bg-emerald-500 animate-pulse" : "bg-muted-foreground/40"}`} />
<span className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider">
{daemon?.running ? t("nav.agentOnline") : t("nav.agentOffline")}
</span>
</div>
</div>
</aside>
);
@ -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 (
<button
onClick={onClick}
className={`w-full text-left px-3 py-2.5 rounded-md text-base flex items-center gap-2.5 transition-all duration-150 ${
className={`w-full group flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all duration-200 ${
active
? "bg-secondary text-foreground font-medium"
: "text-foreground/70 hover:text-foreground hover:bg-muted/40"
? "bg-secondary text-foreground font-semibold shadow-sm border border-border"
: "text-foreground font-medium hover:text-foreground hover:bg-secondary/50"
}`}
>
<span className="text-sm w-4 text-center opacity-60">{icon}</span>
<span className="flex-1">{label}</span>
{badge && <span className={`text-xs ${badgeColor ?? ""}`}>{badge}</span>}
<span className={`transition-colors ${active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"}`}>
{icon}
</span>
<span className="flex-1 text-left">{label}</span>
{badge && (
<span className={`text-[9px] px-1.5 py-0.5 rounded-full font-bold uppercase tracking-tight ${badgeColor}`}>
{badge}
</span>
)}
</button>
);
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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<string, string> = {
"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, () => 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<string, { color: string; icon: React.ReactNode }> = {
"ready-for-review": { color: "text-amber-500 bg-amber-500/10", icon: <Eye size={12} /> },
approved: { color: "text-emerald-500 bg-emerald-500/10", icon: <Check size={12} /> },
drafted: { color: "text-muted-foreground bg-muted/20", icon: <FileText size={12} /> },
"needs-revision": { color: "text-destructive bg-destructive/10", icon: <RotateCcw size={12} /> },
imported: { color: "text-blue-500 bg-blue-500/10", icon: <Download size={12} /> },
};
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<SSEMessage> };
}) {
const c = useColors(theme);
const { data, loading, error, refetch } = useApi<BookData>(`/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<ReadonlyArray<number>>([]);
const [revisingChapters, setRevisingChapters] = useState<ReadonlyArray<number>>([]);
const [savingSettings, setSavingSettings] = useState(false);
const [settingsWordCount, setSettingsWordCount] = useState<number | null>(null);
const [settingsTargetChapters, setSettingsTargetChapters] = useState<number | null>(null);
const [settingsStatus, setSettingsStatus] = useState<BookStatus | null>(null);
const [exportFormat, setExportFormat] = useState<ExportFormat>("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<string, unknown> = {};
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 <div className={c.muted}>Loading...</div>;
if (error) return <div className="text-red-400">Error: {error}</div>;
if (loading) return (
<div className="flex flex-col items-center justify-center py-32 space-y-4">
<div className="w-8 h-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
<span className="text-sm text-muted-foreground">{t("common.loading")}</span>
</div>
);
if (error) return <div className="text-destructive p-8 bg-destructive/5 rounded-xl border border-destructive/20">Error: {error}</div>;
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 (
<div className="space-y-6">
<div className={`flex items-center gap-2 text-sm ${c.muted}`}>
<button onClick={nav.toDashboard} className={c.link}>{t("bread.books")}</button>
<span>/</span>
<span className={c.subtle}>{book.title}</span>
</div>
const currentWordCount = settingsWordCount ?? book.chapterWordCount;
const currentTargetChapters = settingsTargetChapters ?? book.targetChapters ?? 0;
const currentStatus = settingsStatus ?? (book.status as BookStatus);
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold">{book.title}</h1>
<div className={`flex gap-3 mt-1 text-sm ${c.muted}`}>
<span>{book.genre}</span>
<span>{chapters.length} chapters</span>
<span>{totalWords.toLocaleString()} words</span>
{book.language === "en" && <span className="text-blue-400">EN</span>}
{book.fanficMode && <span className="text-purple-400">fanfic:{book.fanficMode}</span>}
const exportHref = `/api/books/${bookId}/export?format=${exportFormat}${exportApprovedOnly ? "&approvedOnly=true" : ""}`;
return (
<div className="space-y-8 fade-in">
{/* Breadcrumbs */}
<nav className="flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
<button
onClick={nav.toDashboard}
className="hover:text-primary transition-colors flex items-center gap-1"
>
<ChevronLeft size={14} />
{t("bread.books")}
</button>
<span className="text-border">/</span>
<span className="text-foreground">{book.title}</span>
</nav>
{/* Header Section */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 border-b border-border/40 pb-8">
<div className="space-y-2">
<div className="flex items-center gap-3">
<h1 className="text-4xl font-serif font-medium">{book.title}</h1>
{book.language === "en" && (
<span className="px-1.5 py-0.5 rounded border border-primary/20 text-primary text-[10px] font-bold">EN</span>
)}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground font-medium">
<span className="px-2 py-0.5 rounded bg-secondary/50 text-foreground/70 uppercase tracking-wider text-xs">{book.genre}</span>
<div className="flex items-center gap-1.5">
<FileText size={14} />
<span>{chapters.length} {t("dash.chapters")}</span>
</div>
<div className="flex items-center gap-1.5">
<Zap size={14} />
<span>{totalWords.toLocaleString()} {t("book.words")}</span>
</div>
{book.fanficMode && (
<span className="flex items-center gap-1 text-purple-500">
<Sparkles size={12} />
<span className="italic">fanfic:{book.fanficMode}</span>
</span>
)}
</div>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<button
onClick={handleWriteNext}
disabled={writing || drafting}
className={`px-4 py-2 text-sm ${c.btnPrimary} rounded-md transition-colors disabled:opacity-50`}
className="flex items-center gap-2 px-5 py-2.5 text-sm font-bold bg-primary text-primary-foreground rounded-xl hover:scale-105 active:scale-95 transition-all shadow-lg shadow-primary/20 disabled:opacity-50"
>
{writing ? "Writing..." : t("book.writeNext")}
{writing ? <div className="w-4 h-4 border-2 border-primary-foreground/20 border-t-primary-foreground rounded-full animate-spin" /> : <Zap size={16} />}
{writing ? t("dash.writing") : t("book.writeNext")}
</button>
<button
onClick={handleDraft}
disabled={writing || drafting}
className={`px-4 py-2 text-sm ${c.btnSecondary} rounded-md transition-colors disabled:opacity-50`}
className="flex items-center gap-2 px-5 py-2.5 text-sm font-bold bg-secondary text-foreground rounded-xl hover:bg-secondary/80 transition-all border border-border/50 disabled:opacity-50"
>
{drafting ? "Drafting..." : t("book.draftOnly")}
{drafting ? <div className="w-4 h-4 border-2 border-muted-foreground/20 border-t-muted-foreground rounded-full animate-spin" /> : <Wand2 size={16} />}
{drafting ? t("book.drafting") : t("book.draftOnly")}
</button>
<button
onClick={() => setConfirmDeleteOpen(true)}
disabled={deleting}
className="flex items-center gap-2 px-5 py-2.5 text-sm font-bold bg-destructive/10 text-destructive rounded-xl hover:bg-destructive hover:text-white transition-all border border-destructive/20 disabled:opacity-50"
>
{deleting ? <div className="w-4 h-4 border-2 border-destructive/20 border-t-destructive rounded-full animate-spin" /> : <Trash2 size={16} />}
{deleting ? t("common.loading") : t("book.deleteBook")}
</button>
</div>
</div>
{(writing || drafting || activity.lastError) && (
<div
className={`rounded-2xl border px-4 py-3 text-sm ${
activity.lastError
? "border-destructive/30 bg-destructive/5 text-destructive"
: "border-primary/20 bg-primary/[0.04] text-foreground"
}`}
>
{activity.lastError ? (
<span>
{t("book.pipelineFailed")}: {activity.lastError}
</span>
) : writing ? (
<span>{t("book.pipelineWriting")}</span>
) : (
<span>{t("book.pipelineDrafting")}</span>
)}
</div>
)}
{/* Tool Strip */}
<div className="flex flex-wrap items-center gap-2 py-1">
{reviewCount > 0 && (
<button
onClick={handleApproveAll}
className={`px-4 py-2 text-sm ${c.btnSuccess} rounded-md transition-colors`}
className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-emerald-500/10 text-emerald-600 rounded-lg hover:bg-emerald-500/20 transition-all border border-emerald-500/20"
>
<CheckCheck size={14} />
{t("book.approveAll")} ({reviewCount})
</button>
)}
<button
onClick={() => (nav as { toTruth?: (id: string) => void }).toTruth?.(bookId)}
className={`px-4 py-2 text-sm ${c.btnSecondary} rounded-md transition-colors`}
className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary/50 text-muted-foreground rounded-lg hover:text-foreground hover:bg-secondary transition-all border border-border/50"
>
Truth Files
<Database size={14} />
{t("book.truthFiles")}
</button>
<button
onClick={() => nav.toAnalytics(bookId)}
className={`px-4 py-2 text-sm ${c.btnSecondary} rounded-md transition-colors`}
className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary/50 text-muted-foreground rounded-lg hover:text-foreground hover:bg-secondary transition-all border border-border/50"
>
<BarChart2 size={14} />
{t("book.analytics")}
</button>
<a
href={`/api/books/${bookId}/export?format=txt`}
download
className={`px-4 py-2 text-sm ${c.btnSecondary} rounded-md transition-colors inline-flex items-center`}
<div className="flex items-center gap-2">
<select
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value as ExportFormat)}
className="px-2 py-2 text-xs font-bold bg-secondary/50 text-muted-foreground rounded-lg border border-border/50 outline-none"
>
<option value="txt">TXT</option>
<option value="md">MD</option>
<option value="epub">EPUB</option>
</select>
<label className="flex items-center gap-1.5 text-xs font-bold text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={exportApprovedOnly}
onChange={(e) => setExportApprovedOnly(e.target.checked)}
className="rounded border-border/50"
/>
{t("book.approvedOnly")}
</label>
<button
onClick={async () => {
try {
const data = await fetchJson<{ path?: string; chapters?: number }>(`/books/${bookId}/export-save`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ format: exportFormat, approvedOnly: exportApprovedOnly }),
});
alert(`${t("common.exportSuccess")}\n${data.path}\n(${data.chapters} ${t("dash.chapters")})`);
} catch (e) {
alert(e instanceof Error ? e.message : "Export failed");
}
}}
className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary/50 text-muted-foreground rounded-lg hover:text-foreground hover:bg-secondary transition-all border border-border/50"
>
<Download size={14} />
{t("book.export")}
</button>
</div>
</div>
{/* Book Settings */}
<div className="paper-sheet rounded-2xl border border-border/40 shadow-sm p-6">
<h2 className="text-sm font-bold uppercase tracking-widest text-muted-foreground mb-4">{t("book.settings")}</h2>
<div className="flex flex-wrap items-end gap-4">
<div className="flex flex-col gap-1">
<label className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground">{t("create.wordsPerChapter")}</label>
<input
type="number"
value={currentWordCount}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground">{t("create.targetChapters")}</label>
<input
type="number"
value={currentTargetChapters}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground">{t("book.status")}</label>
<select
value={currentStatus}
onChange={(e) => setSettingsStatus(e.target.value as BookStatus)}
className="px-3 py-2 text-sm rounded-lg border border-border/50 bg-secondary/30 outline-none focus:border-primary/50"
>
<option value="active">{t("book.statusActive")}</option>
<option value="paused">{t("book.statusPaused")}</option>
<option value="outlining">{t("book.statusOutlining")}</option>
<option value="completed">{t("book.statusCompleted")}</option>
<option value="dropped">{t("book.statusDropped")}</option>
</select>
</div>
<button
onClick={handleSaveSettings}
disabled={savingSettings}
className="flex items-center gap-2 px-4 py-2 text-sm font-bold bg-primary text-primary-foreground rounded-lg hover:scale-105 active:scale-95 transition-all disabled:opacity-50"
>
Export
</a>
{savingSettings ? <div className="w-4 h-4 border-2 border-primary-foreground/20 border-t-primary-foreground rounded-full animate-spin" /> : <Save size={14} />}
{savingSettings ? t("book.saving") : t("book.save")}
</button>
</div>
</div>
<div className={`border ${c.cardStatic} rounded-lg overflow-hidden`}>
<table className="w-full text-sm">
<thead className={c.tableHeader}>
<tr>
<th className="text-left px-4 py-3 font-medium w-16">#</th>
<th className="text-left px-4 py-3 font-medium">Title</th>
<th className="text-left px-4 py-3 font-medium w-24">Words</th>
<th className="text-left px-4 py-3 font-medium w-32">Status</th>
<th className="text-right px-4 py-3 font-medium w-32">Actions</th>
</tr>
</thead>
<tbody className={`divide-y ${c.tableDivide}`}>
{chapters.map((ch) => (
<tr key={ch.number} className={`${c.tableHover} transition-colors`}>
<td className={`px-4 py-3 ${c.muted}`}>{ch.number}</td>
<td className="px-4 py-3">
<button
onClick={() => nav.toChapter(bookId, ch.number)}
className={`${c.link} transition-colors`}
>
{ch.title || `Chapter ${ch.number}`}
</button>
</td>
<td className={`px-4 py-3 ${c.subtle} tabular-nums`}>{(ch.wordCount ?? 0).toLocaleString()}</td>
<td className={`px-4 py-3 ${STATUS_COLORS[ch.status] ?? c.subtle}`}>
{ch.status}
</td>
<td className="px-4 py-3 text-right">
<div className="flex gap-1 justify-end">
{ch.status === "ready-for-review" && (
<>
<button
onClick={async () => { await postApi(`/books/${bookId}/chapters/${ch.number}/approve`); refetch(); }}
className={`px-2 py-1 text-xs ${c.btnSuccess} rounded`}
>
{t("book.approve")}
</button>
<button
onClick={async () => { await postApi(`/books/${bookId}/chapters/${ch.number}/reject`); refetch(); }}
className={`px-2 py-1 text-xs ${c.btnDanger} rounded`}
>
{t("book.reject")}
</button>
</>
)}
<button
onClick={async () => {
const r = await fetch(`/api/books/${bookId}/audit/${ch.number}`, { method: "POST" });
const data = await r.json();
alert(data.passed ? "Audit passed" : `Audit failed: ${data.issues?.length ?? 0} issues`);
refetch();
}}
className={`px-2 py-1 text-xs ${c.btnSecondary} rounded`}
>
Audit
</button>
<button
onClick={async () => {
await fetch(`/api/books/${bookId}/revise/${ch.number}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: "spot-fix" }),
});
alert("Revision started");
refetch();
}}
className={`px-2 py-1 text-xs ${c.btnSecondary} rounded`}
>
Revise
</button>
<button
onClick={async () => {
const r = await fetch(`/api/books/${bookId}/detect/${ch.number}`, { method: "POST" });
const data = await r.json();
alert(`AI-tell: ${data.issues?.length ?? 0} issues found`);
}}
className={`px-2 py-1 text-xs ${c.btnSecondary} rounded`}
>
Detect
</button>
</div>
</td>
{/* Chapters Table */}
<div className="paper-sheet rounded-2xl overflow-hidden border border-border/40 shadow-xl shadow-primary/5">
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-muted/30 border-b border-border/50">
<th className="text-left px-6 py-4 font-bold text-[11px] uppercase tracking-widest text-muted-foreground w-16">#</th>
<th className="text-left px-6 py-4 font-bold text-[11px] uppercase tracking-widest text-muted-foreground">{t("book.manuscriptTitle")}</th>
<th className="text-left px-6 py-4 font-bold text-[11px] uppercase tracking-widest text-muted-foreground w-28">{t("book.words")}</th>
<th className="text-left px-6 py-4 font-bold text-[11px] uppercase tracking-widest text-muted-foreground w-36">{t("book.status")}</th>
<th className="text-right px-6 py-4 font-bold text-[11px] uppercase tracking-widest text-muted-foreground">{t("book.curate")}</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="divide-y divide-border/30">
{chapters.map((ch, index) => {
const staggerClass = `stagger-${Math.min(index + 1, 5)}`;
return (
<tr key={ch.number} className={`group hover:bg-primary/[0.02] transition-colors fade-in ${staggerClass}`}>
<td className="px-6 py-4 text-muted-foreground/60 font-mono text-xs">{ch.number.toString().padStart(2, '0')}</td>
<td className="px-6 py-4">
<button
onClick={() => nav.toChapter(bookId, ch.number)}
className="font-serif text-lg font-medium hover:text-primary transition-colors text-left"
>
{ch.title || t("chapter.label").replace("{n}", String(ch.number))}
</button>
</td>
<td className="px-6 py-4 text-muted-foreground font-medium tabular-nums text-xs">{(ch.wordCount ?? 0).toLocaleString()}</td>
<td className="px-6 py-4">
<div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-tight ${STATUS_CONFIG[ch.status]?.color ?? "bg-muted text-muted-foreground"}`}>
{STATUS_CONFIG[ch.status]?.icon}
{translateChapterStatus(ch.status, t)}
</div>
</td>
<td className="px-6 py-4 text-right">
<div className="flex gap-1.5 justify-end opacity-0 group-hover:opacity-100 transition-opacity">
{ch.status === "ready-for-review" && (
<>
<button
onClick={async () => { await postApi(`/books/${bookId}/chapters/${ch.number}/approve`); refetch(); }}
className="p-2 rounded-lg bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500 hover:text-white transition-all shadow-sm"
title={t("book.approve")}
>
<Check size={14} />
</button>
<button
onClick={async () => { await postApi(`/books/${bookId}/chapters/${ch.number}/reject`); refetch(); }}
className="p-2 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive hover:text-white transition-all shadow-sm"
title={t("book.reject")}
>
<X size={14} />
</button>
</>
)}
<button
onClick={async () => {
const auditResult = await fetchJson<{ passed?: boolean; issues?: unknown[] }>(`/books/${bookId}/audit/${ch.number}`, { method: "POST" });
alert(auditResult.passed ? "Audit passed" : `Audit failed: ${auditResult.issues?.length ?? 0} issues`);
refetch();
}}
className="p-2 rounded-lg bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all shadow-sm"
title={t("book.audit")}
>
<ShieldCheck size={14} />
</button>
<button
onClick={() => handleRewrite(ch.number)}
disabled={rewritingChapters.includes(ch.number)}
className="p-2 rounded-lg bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all shadow-sm disabled:opacity-50"
title={t("book.rewrite")}
>
{rewritingChapters.includes(ch.number)
? <div className="w-3.5 h-3.5 border-2 border-muted-foreground/20 border-t-muted-foreground rounded-full animate-spin" />
: <RotateCcw size={14} />}
</button>
<select
disabled={revisingChapters.includes(ch.number)}
value=""
onChange={(e) => {
const mode = e.target.value as ReviseMode;
if (mode) handleRevise(ch.number, mode);
}}
className="px-2 py-1.5 text-[11px] font-bold rounded-lg bg-secondary text-muted-foreground border border-border/50 outline-none hover:text-primary hover:bg-primary/10 transition-all disabled:opacity-50 cursor-pointer"
title="Revise with AI"
>
<option value="" disabled>{revisingChapters.includes(ch.number) ? t("common.loading") : t("book.curate")}</option>
<option value="spot-fix">{t("book.spotFix")}</option>
<option value="polish">{t("book.polish")}</option>
<option value="rewrite">{t("book.rewrite")}</option>
<option value="rework">{t("book.rework")}</option>
<option value="anti-detect">{t("book.antiDetect")}</option>
</select>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{chapters.length === 0 && (
<div className={`text-center py-12 ${c.muted}`}>
{t("book.noChapters")}
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-12 h-12 rounded-full bg-muted/20 flex items-center justify-center mb-4">
<FileText size={20} className="text-muted-foreground/40" />
</div>
<p className="text-sm italic font-serif text-muted-foreground">
{t("book.noChapters")}
</p>
</div>
)}
</div>
<ConfirmDialog
open={confirmDeleteOpen}
title={t("book.deleteBook")}
message={t("book.confirmDelete")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
variant="danger"
onConfirm={handleDeleteBook}
onCancel={() => setConfirmDeleteOpen(false)}
/>
</div>
);
}

View file

@ -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<ChapterData>(
const { data, loading, error, refetch } = useApi<ChapterData>(
`/books/${bookId}/chapters/${chapterNumber}`,
);
const [editing, setEditing] = useState(false);
const [editContent, setEditContent] = useState("");
const [saving, setSaving] = useState(false);
if (loading) return <div className={c.muted}>Loading...</div>;
if (error) return <div className="text-red-400">Error: {error}</div>;
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 (
<div className="flex flex-col items-center justify-center py-32 space-y-4">
<div className="w-8 h-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
<span className="text-sm text-muted-foreground">{t("reader.openingManuscript")}</span>
</div>
);
if (error) return <div className="text-destructive p-8 bg-destructive/5 rounded-xl border border-destructive/20">Error: {error}</div>;
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 (
<div className="max-w-3xl mx-auto space-y-6">
<div className={`flex items-center gap-2 text-sm ${c.muted}`}>
<button onClick={nav.toDashboard} className={c.link}>{t("bread.books")}</button>
<span>/</span>
<button onClick={() => nav.toBook(bookId)} className={c.link}>{bookId}</button>
<span>/</span>
<span className={c.subtle}>{t("bread.chapter").replace("{n}", String(chapterNumber))}</span>
</div>
<div className="max-w-4xl mx-auto space-y-10 fade-in">
{/* Navigation & Actions */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<nav className="flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
<button
onClick={nav.toDashboard}
className="hover:text-primary transition-colors flex items-center gap-1"
>
{t("bread.books")}
</button>
<span className="text-border">/</span>
<button
onClick={() => nav.toBook(bookId)}
className="hover:text-primary transition-colors truncate max-w-[120px]"
>
{bookId}
</button>
<span className="text-border">/</span>
<span className="text-foreground flex items-center gap-1">
<Hash size={12} />
{chapterNumber}
</span>
</nav>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">{title}</h1>
<div className="flex gap-2">
{chapterNumber > 1 && (
<button
onClick={() => nav.toBook(bookId)}
className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary text-muted-foreground rounded-xl hover:text-foreground hover:bg-secondary/80 transition-all border border-border/50"
>
<List size={14} />
{t("reader.backToList")}
</button>
{/* Edit / Preview toggle */}
{editing ? (
<>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-primary text-primary-foreground rounded-xl hover:scale-105 active:scale-95 transition-all shadow-sm disabled:opacity-50"
>
{saving ? <div className="w-3.5 h-3.5 border-2 border-primary-foreground/20 border-t-primary-foreground rounded-full animate-spin" /> : <Save size={14} />}
{saving ? t("book.saving") : t("book.save")}
</button>
<button
onClick={handleCancelEdit}
className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary text-muted-foreground rounded-xl hover:text-foreground transition-all border border-border/50"
>
<Eye size={14} />
{t("reader.preview")}
</button>
</>
) : (
<button
onClick={() => nav.toBook(bookId)}
className={`px-3 py-1.5 text-sm ${c.btnSecondary} rounded-md`}
onClick={handleStartEdit}
className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary text-muted-foreground rounded-xl hover:text-primary hover:bg-primary/10 transition-all border border-border/50"
>
{t("reader.backToList")}
<Pencil size={14} />
{t("reader.edit")}
</button>
)}
<button
onClick={handleApprove}
className={`px-3 py-1.5 text-sm ${c.btnSuccess} rounded-md`}
className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-emerald-500/10 text-emerald-600 rounded-xl hover:bg-emerald-500 hover:text-white transition-all border border-emerald-500/20 shadow-sm"
>
<CheckCircle2 size={14} />
{t("reader.approve")}
</button>
<button
onClick={handleReject}
className={`px-3 py-1.5 text-sm ${c.btnDanger} rounded-md`}
className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-destructive/10 text-destructive rounded-xl hover:bg-destructive hover:text-white transition-all border border-destructive/20 shadow-sm"
>
<XCircle size={14} />
{t("reader.reject")}
</button>
</div>
</div>
<article className="prose prose-invert prose-zinc max-w-none">
{paragraphs.map((para, i) => (
<p key={i} className={`${c.subtle} leading-relaxed text-base mb-4`}>
{para}
</p>
))}
</article>
{/* Manuscript Sheet */}
<div className="paper-sheet rounded-2xl p-8 md:p-16 lg:p-24 shadow-2xl shadow-primary/5 min-h-[80vh] relative overflow-hidden">
{/* Physical Paper Details */}
<div className="absolute top-0 left-8 w-px h-full bg-primary/5 hidden md:block" />
<div className="absolute top-0 right-8 w-px h-full bg-primary/5 hidden md:block" />
<header className="mb-16 text-center">
<div className="flex items-center justify-center gap-2 text-muted-foreground/30 mb-8 select-none">
<div className="h-px w-12 bg-border/40" />
<BookOpen size={20} />
<div className="h-px w-12 bg-border/40" />
</div>
<h1 className="text-4xl md:text-5xl font-serif font-medium italic text-foreground tracking-tight leading-tight">
{title}
</h1>
<div className="mt-8 flex items-center justify-center gap-4 text-[10px] font-bold uppercase tracking-[0.2em] text-muted-foreground/60">
<span>{t("reader.manuscriptPage")}</span>
<span className="text-border">·</span>
<span>{chapterNumber.toString().padStart(2, '0')}</span>
</div>
</header>
<div className={`flex justify-between pt-8 border-t ${c.cardStatic} text-sm`}>
{editing ? (
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full min-h-[60vh] bg-transparent font-serif text-lg leading-[1.8] text-foreground/90 focus:outline-none resize-none border border-border/30 rounded-lg p-6 focus:border-primary/40 focus:ring-2 focus:ring-primary/10 transition-all"
autoFocus
/>
) : (
<article className="prose prose-zinc dark:prose-invert max-w-none">
{paragraphs.map((para, i) => (
<p key={i} className="font-serif text-lg md:text-xl leading-[1.8] text-foreground/90 mb-8 first-letter:text-2xl first-letter:font-bold first-letter:text-primary/40">
{para}
</p>
))}
</article>
)}
<footer className="mt-24 pt-12 border-t border-border/20 flex flex-col items-center gap-6 text-center">
<div className="flex items-center gap-4 text-xs font-medium text-muted-foreground">
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-secondary/50">
<Type size={14} className="text-primary/60" />
<span>{body.length.toLocaleString()} {t("reader.characters")}</span>
</div>
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-secondary/50">
<Clock size={14} className="text-primary/60" />
<span>{Math.ceil(body.length / 500)} {t("reader.minRead")}</span>
</div>
</div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground/40 font-bold">{t("reader.endOfChapter")}</p>
</footer>
</div>
{/* Footer Navigation */}
<div className="flex justify-between items-center py-8">
{chapterNumber > 1 ? (
<button
onClick={() => nav.toBook(bookId)}
className={`${c.subtle} ${c.link}`}
className="flex items-center gap-2 text-sm font-bold text-muted-foreground hover:text-primary transition-all group"
>
<RotateCcw size={16} className="group-hover:-rotate-45 transition-transform" />
{t("reader.chapterList")}
</button>
) : (
<div />
)}
<div className={c.muted}>
{body.length.toLocaleString()} {t("reader.characters")}
</div>
</div>
</div>
);

View file

@ -1,9 +1,26 @@
import { useApi, postApi } from "../hooks/use-api";
import { useState } from "react";
import { fetchJson, useApi } from "../hooks/use-api";
import { useEffect, useState } from "react";
import type { Theme } from "../hooks/use-theme";
import type { TFunction } from "../hooks/use-i18n";
import { useColors } from "../hooks/use-colors";
const ROUTING_AGENTS = [
"writer",
"auditor",
"reviser",
"architect",
"radar",
"chapter-analyzer",
] as const;
interface AgentOverride {
readonly model: string;
readonly provider: string;
readonly baseUrl: string;
}
type OverridesMap = Record<string, AgentOverride>;
interface ProjectInfo {
readonly name: string;
readonly language: string;
@ -19,6 +36,17 @@ interface Nav {
toDashboard: () => void;
}
export function normalizeOverridesDraft(
data?: { readonly overrides?: OverridesMap } | null,
): OverridesMap {
return Object.fromEntries(
Object.entries(data?.overrides ?? {}).map(([agent, override]) => [
agent,
{ ...override },
]),
) as OverridesMap;
}
export function ConfigView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
const c = useColors(theme);
const { data, loading, error, refetch } = useApi<ProjectInfo>("/project");
@ -43,7 +71,7 @@ export function ConfigView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
const handleSave = async () => {
setSaving(true);
try {
await fetch("/api/project", {
await fetchJson("/project", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
@ -78,7 +106,7 @@ export function ConfigView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
<Row label={t("config.project")} value={data.name} />
<Row label={t("config.provider")} value={data.provider} />
<Row label={t("config.model")} value={data.model} />
<Row label="Base URL" value={data.baseUrl} mono />
<Row label={t("config.baseUrl")} value={data.baseUrl} mono />
{editing ? (
<>
@ -87,38 +115,38 @@ export function ConfigView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
value={form.language as string}
onChange={(v) => setForm({ ...form, language: v })}
type="select"
options={[{ value: "zh", label: "Chinese" }, { value: "en", label: "English" }]}
options={[{ value: "zh", label: t("config.chinese") }, { value: "en", label: t("config.english") }]}
c={c}
/>
<EditRow
label="Temperature"
label={t("config.temperature")}
value={String(form.temperature)}
onChange={(v) => setForm({ ...form, temperature: parseFloat(v) })}
type="number"
c={c}
/>
<EditRow
label="Max Tokens"
label={t("config.maxTokens")}
value={String(form.maxTokens)}
onChange={(v) => setForm({ ...form, maxTokens: parseInt(v, 10) })}
type="number"
c={c}
/>
<EditRow
label="Stream"
label={t("config.stream")}
value={String(form.stream)}
onChange={(v) => setForm({ ...form, stream: v === "true" })}
type="select"
options={[{ value: "true", label: "Enabled" }, { value: "false", label: "Disabled" }]}
options={[{ value: "true", label: t("config.enabled") }, { value: "false", label: t("config.disabled") }]}
c={c}
/>
</>
) : (
<>
<Row label={t("config.language")} value={data.language === "en" ? "English" : "Chinese"} />
<Row label="Temperature" value={String(data.temperature)} mono />
<Row label="Max Tokens" value={String(data.maxTokens)} mono />
<Row label="Stream" value={data.stream ? "Enabled" : "Disabled"} />
<Row label={t("config.language")} value={data.language === "en" ? t("config.english") : t("config.chinese")} />
<Row label={t("config.temperature")} value={String(data.temperature)} mono />
<Row label={t("config.maxTokens")} value={String(data.maxTokens)} mono />
<Row label={t("config.stream")} value={data.stream ? t("config.enabled") : t("config.disabled")} />
</>
)}
</div>
@ -126,17 +154,129 @@ export function ConfigView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
{editing && (
<div className="flex gap-2 justify-end">
<button onClick={() => setEditing(false)} className={`px-4 py-2.5 text-sm rounded-md ${c.btnSecondary}`}>
Cancel
{t("config.cancel")}
</button>
<button onClick={handleSave} disabled={saving} className={`px-4 py-2.5 text-sm rounded-md ${c.btnPrimary} disabled:opacity-50`}>
{saving ? "Saving..." : "Save"}
{saving ? t("config.saving") : t("config.save")}
</button>
</div>
)}
<ModelRoutingSection theme={theme} t={t} />
</div>
);
}
function emptyOverride(): AgentOverride {
return { model: "", provider: "", baseUrl: "" };
}
function ModelRoutingSection({ theme, t }: { theme: Theme; t: TFunction }) {
const c = useColors(theme);
const { data, loading, error, refetch } = useApi<{ overrides: OverridesMap }>(
"/project/model-overrides",
);
const [overrides, setOverrides] = useState<OverridesMap>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
setOverrides(normalizeOverridesDraft(data));
}, [data]);
if (loading) return <div className="text-muted-foreground py-8 text-center text-sm">Loading model overrides...</div>;
if (error) return <div className="text-destructive py-8 text-center text-sm">Error: {error}</div>;
const updateAgent = (agent: string, field: keyof AgentOverride, value: string) => {
const current = overrides[agent] ?? emptyOverride();
setOverrides({
...overrides,
[agent]: { ...current, [field]: value },
});
};
const handleSave = async () => {
setSaving(true);
try {
await fetchJson("/project/model-overrides", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ overrides }),
});
refetch();
} catch (e) {
alert(e instanceof Error ? e.message : "Failed to save model overrides");
} finally {
setSaving(false);
}
};
return (
<>
<h2 className="font-serif text-xl mt-4">{t("config.modelRouting")}</h2>
<div className={`border ${c.cardStatic} rounded-lg overflow-hidden`}>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/40 text-muted-foreground text-left">
<th className="px-4 py-2.5 font-medium">{t("config.agent")}</th>
<th className="px-4 py-2.5 font-medium">{t("config.model")}</th>
<th className="px-4 py-2.5 font-medium">{t("config.provider")}</th>
<th className="px-4 py-2.5 font-medium">{t("config.baseUrl")}</th>
</tr>
</thead>
<tbody>
{ROUTING_AGENTS.map((agent) => {
const row = overrides[agent] ?? emptyOverride();
return (
<tr key={agent} className="border-b border-border/40 last:border-b-0">
<td className="px-4 py-2 font-mono text-foreground/80">{agent}</td>
<td className="px-4 py-2">
<input
type="text"
value={row.model}
onChange={(e) => updateAgent(agent, "model", e.target.value)}
placeholder={t("config.default")}
className={`${c.input} rounded px-2 py-1 text-sm w-full`}
/>
</td>
<td className="px-4 py-2">
<input
type="text"
value={row.provider}
onChange={(e) => updateAgent(agent, "provider", e.target.value)}
placeholder={t("config.optional")}
className={`${c.input} rounded px-2 py-1 text-sm w-full`}
/>
</td>
<td className="px-4 py-2">
<input
type="text"
value={row.baseUrl}
onChange={(e) => updateAgent(agent, "baseUrl", e.target.value)}
placeholder={t("config.optional")}
className={`${c.input} rounded px-2 py-1 text-sm w-full`}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex justify-end">
<button
onClick={handleSave}
disabled={saving}
className={`px-4 py-2.5 text-sm rounded-md ${c.btnPrimary} disabled:opacity-50`}
>
{saving ? t("config.saving") : t("config.saveOverrides")}
</button>
</div>
</>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex justify-between px-4 py-3">

View file

@ -1,9 +1,27 @@
import { useApi, postApi } from "../hooks/use-api";
import { useState, useMemo } from "react";
import { fetchJson, useApi, postApi } from "../hooks/use-api";
import { useEffect, useMemo, useState, useRef } from "react";
import type { SSEMessage } from "../hooks/use-sse";
import type { Theme } from "../hooks/use-theme";
import type { TFunction } from "../hooks/use-i18n";
import { useColors } from "../hooks/use-colors";
import { deriveActiveBookIds, shouldRefetchBookCollections } from "../hooks/use-book-activity";
import { ConfirmDialog } from "../components/ConfirmDialog";
import {
Plus,
BookOpen,
BarChart2,
Zap,
Clock,
CheckCircle2,
AlertCircle,
MoreVertical,
ChevronRight,
Flame,
Trash2,
Settings,
Download,
FileInput,
} from "lucide-react";
interface BookSummary {
readonly id: string;
@ -21,150 +39,307 @@ interface Nav {
toBookCreate: () => void;
}
function BookMenu({ bookId, bookTitle, nav, t, onDelete }: {
readonly bookId: string;
readonly bookTitle: string;
readonly nav: Nav;
readonly t: TFunction;
readonly onDelete: () => void;
}) {
const [open, setOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
const handleDelete = async () => {
setConfirmDelete(false);
setOpen(false);
await fetchJson(`/books/${bookId}`, { method: "DELETE" });
onDelete();
};
return (
<div ref={menuRef} className="relative">
<button
onClick={() => setOpen((prev) => !prev)}
className="p-3 rounded-xl text-muted-foreground hover:text-primary hover:bg-primary/10 hover:scale-105 active:scale-95 transition-all cursor-pointer"
>
<MoreVertical size={18} />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 w-44 bg-card border border-border rounded-xl shadow-lg shadow-primary/5 py-1 z-50 fade-in">
<button
onClick={() => { setOpen(false); nav.toBook(bookId); }}
className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-foreground hover:bg-secondary/50 transition-colors cursor-pointer"
>
<Settings size={14} className="text-muted-foreground" />
{t("book.settings")}
</button>
<a
href={`/api/books/${bookId}/export?format=txt`}
download
onClick={() => setOpen(false)}
className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-foreground hover:bg-secondary/50 transition-colors cursor-pointer"
>
<Download size={14} className="text-muted-foreground" />
{t("book.export")}
</a>
<div className="border-t border-border/50 my-1" />
<button
onClick={() => { setOpen(false); setConfirmDelete(true); }}
className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-destructive hover:bg-destructive/10 transition-colors cursor-pointer"
>
<Trash2 size={14} />
{t("book.deleteBook")}
</button>
</div>
)}
<ConfirmDialog
open={confirmDelete}
title={t("book.deleteBook")}
message={`${t("book.confirmDelete")}\n\n"${bookTitle}"`}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
variant="danger"
onConfirm={handleDelete}
onCancel={() => setConfirmDelete(false)}
/>
</div>
);
}
export function Dashboard({ nav, sse, theme, t }: { nav: Nav; sse: { messages: ReadonlyArray<SSEMessage> }; theme: Theme; t: TFunction }) {
const c = useColors(theme);
const { data, loading, error, refetch } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
const [writingBooks, setWritingBooks] = useState<Set<string>>(new Set());
const writingBooks = useMemo(() => deriveActiveBookIds(sse.messages), [sse.messages]);
const logEvents = sse.messages.filter((m) => m.event === "log").slice(-8);
const progressEvent = sse.messages.filter((m) => m.event === "llm:progress").slice(-1)[0];
useMemo(() => {
for (const msg of sse.messages) {
const bookId = (msg.data as { bookId?: string })?.bookId;
if (!bookId) continue;
if (msg.event === "write:start" || msg.event === "draft:start") {
setWritingBooks((prev) => new Set([...prev, bookId]));
}
if (msg.event === "write:complete" || msg.event === "write:error" ||
msg.event === "draft:complete" || msg.event === "draft:error") {
setWritingBooks((prev) => { const next = new Set(prev); next.delete(bookId); return next; });
refetch();
}
useEffect(() => {
const recent = sse.messages.at(-1);
if (!recent) return;
if (shouldRefetchBookCollections(recent)) {
refetch();
}
}, [sse.messages.length]);
}, [refetch, sse.messages]);
if (loading) return <div className="text-muted-foreground py-20 text-center text-sm">Loading...</div>;
if (error) return <div className="text-destructive py-20 text-center">Error: {error}</div>;
if (loading) return (
<div className="flex flex-col items-center justify-center py-32 space-y-4">
<div className="w-8 h-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
<span className="text-sm text-muted-foreground animate-pulse">Gathering manuscripts...</span>
</div>
);
if (error) return (
<div className="flex flex-col items-center justify-center py-20 bg-destructive/5 border border-destructive/20 rounded-2xl">
<AlertCircle className="text-destructive mb-4" size={32} />
<h2 className="text-lg font-semibold text-destructive">Failed to load library</h2>
<p className="text-sm text-muted-foreground mt-1">{error}</p>
</div>
);
/* ── Empty state — vertically centered in the available viewport ── */
if (!data?.books.length) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh]">
<div className="text-5xl mb-8 opacity-15 select-none"></div>
<h2 className="font-serif text-3xl italic text-foreground/70 mb-3">{t("dash.noBooks")}</h2>
<p className="text-sm text-muted-foreground mb-10">{t("dash.createFirst")}</p>
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center fade-in">
<div className="w-20 h-20 rounded-full bg-primary/5 flex items-center justify-center mb-8">
<BookOpen size={40} className="text-primary/20" />
</div>
<h2 className="font-serif text-3xl italic text-foreground/80 mb-3">{t("dash.noBooks")}</h2>
<p className="text-sm text-muted-foreground max-w-xs leading-relaxed mb-10">
{t("dash.createFirst")}
</p>
<button
onClick={nav.toBookCreate}
className="px-7 py-3 rounded-md text-sm font-medium bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
className="group flex items-center gap-2 px-8 py-3.5 rounded-xl text-sm font-bold bg-primary text-primary-foreground hover:scale-105 active:scale-95 transition-all shadow-lg shadow-primary/20"
>
<Plus size={18} />
{t("nav.newBook")}
</button>
</div>
);
}
/* ── Book list ── */
return (
<div className="space-y-10">
<div className="flex items-baseline justify-between">
<h1 className="font-serif text-3xl">{t("dash.title")}</h1>
<div className="space-y-12">
<div className="flex items-end justify-between border-b border-border/40 pb-8">
<div>
<h1 className="font-serif text-4xl mb-2">{t("dash.title")}</h1>
<p className="text-sm text-muted-foreground">{t("dash.subtitle")}</p>
</div>
<button
onClick={nav.toBookCreate}
className="px-4 py-2.5 rounded-md text-sm font-medium bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
className="group flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-bold bg-primary text-primary-foreground hover:scale-105 active:scale-95 transition-all shadow-lg shadow-primary/20"
>
<Plus size={16} />
{t("nav.newBook")}
</button>
</div>
<div className="space-y-3">
{data.books.map((book) => {
<div className="grid gap-6">
{data.books.map((book, index) => {
const isWriting = writingBooks.has(book.id);
const staggerClass = `stagger-${Math.min(index + 1, 5)}`;
return (
<div
key={book.id}
className={`group border ${c.card} rounded-lg overflow-hidden`}
className={`paper-sheet group relative rounded-2xl overflow-hidden fade-in ${staggerClass}`}
>
<div className="px-6 py-5 flex items-start justify-between">
<div className="min-w-0">
<button
onClick={() => nav.toBook(book.id)}
className="font-serif text-lg hover:text-primary transition-colors text-left truncate block"
>
{book.title}
</button>
<div className="flex items-center gap-3 mt-2 text-sm text-muted-foreground">
<span className="uppercase tracking-wider">{book.genre}</span>
<span className="text-border">|</span>
<span>{book.chaptersWritten} {t("dash.chapters")}</span>
<span className="text-border">|</span>
<span className={
book.status === "active" ? "text-emerald-500" :
book.status === "paused" ? "text-amber-500" :
"text-muted-foreground"
}>
{book.status}
</span>
{book.language === "en" && <span className="text-primary/70">EN</span>}
{book.fanficMode && <span className="text-purple-400">{book.fanficMode}</span>}
<div className="p-8 flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-lg bg-primary/5 text-primary">
<BookOpen size={20} />
</div>
<button
onClick={() => nav.toBook(book.id)}
className="font-serif text-2xl hover:text-primary transition-all text-left truncate block font-medium hover:underline underline-offset-4 decoration-primary/30"
>
{book.title}
</button>
</div>
<div className="flex flex-wrap items-center gap-y-2 gap-x-4 text-[13px] text-muted-foreground font-medium">
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded bg-secondary/50">
<span className="uppercase tracking-wider">{book.genre}</span>
</div>
<div className="flex items-center gap-1.5">
<Clock size={14} />
<span>{book.chaptersWritten} {t("dash.chapters")}</span>
</div>
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${
book.status === "active" ? "bg-emerald-500" :
book.status === "paused" ? "bg-amber-500" :
"bg-muted-foreground"
}`} />
<span>{
book.status === "active" ? t("book.statusActive") :
book.status === "paused" ? t("book.statusPaused") :
book.status === "outlining" ? t("book.statusOutlining") :
book.status === "completed" ? t("book.statusCompleted") :
book.status === "dropped" ? t("book.statusDropped") :
book.status
}</span>
</div>
{book.language === "en" && (
<span className="px-1.5 py-0.5 rounded border border-primary/20 text-primary text-[10px] font-bold">EN</span>
)}
{book.fanficMode && (
<span className="flex items-center gap-1 text-purple-500">
<Zap size={12} />
<span className="italic">{book.fanficMode}</span>
</span>
)}
</div>
</div>
<div className="flex gap-2 shrink-0 ml-4">
<div className="flex items-center gap-3 shrink-0 ml-6">
<button
onClick={() => postApi(`/books/${book.id}/write-next`)}
disabled={isWriting}
className={`px-4 py-2.5 text-sm rounded-md transition-all ${
className={`flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-bold transition-all shadow-sm ${
isWriting
? "bg-primary/20 text-primary cursor-wait"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
? "bg-primary/20 text-primary cursor-wait animate-pulse"
: "bg-secondary text-foreground hover:bg-primary hover:text-primary-foreground hover:shadow-lg hover:shadow-primary/20 hover:scale-105 active:scale-95"
}`}
>
{isWriting ? t("dash.writing") : t("dash.writeNext")}
{isWriting ? (
<>
<div className="w-4 h-4 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
{t("dash.writing")}
</>
) : (
<>
<Zap size={16} />
{t("dash.writeNext")}
</>
)}
</button>
<button
onClick={() => nav.toAnalytics(book.id)}
className="px-4 py-2.5 text-sm rounded-md bg-secondary text-secondary-foreground hover:bg-secondary/80 transition-colors"
className="p-3 rounded-xl bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10 hover:border-primary/30 hover:shadow-md hover:scale-105 active:scale-95 transition-all border border-border/50 shadow-sm"
title={t("dash.stats")}
>
{t("dash.stats")}
<BarChart2 size={18} />
</button>
<BookMenu
bookId={book.id}
bookTitle={book.title}
nav={nav}
t={t}
onDelete={() => refetch()}
/>
</div>
</div>
{/* Writing progress bar for active writes */}
{/* Enhanced progress indicator */}
{isWriting && (
<div className="h-0.5 bg-gradient-to-r from-primary/0 via-primary to-primary/0 animate-pulse" />
<div className="absolute bottom-0 left-0 right-0 h-1 bg-secondary overflow-hidden">
<div className="h-full bg-primary w-1/3 animate-[progress_2s_ease-in-out_infinite]" />
</div>
)}
</div>
);
})}
</div>
{/* Live writing progress panel */}
{/* Modern writing progress panel */}
{writingBooks.size > 0 && logEvents.length > 0 && (
<div className="border border-primary/30 bg-primary/5 rounded-lg p-6">
<h3 className="text-sm uppercase tracking-wide text-primary font-medium mb-4">{t("dash.writingProgress")}</h3>
<div className="space-y-1.5 text-sm font-mono text-muted-foreground">
{logEvents.map((msg, i) => {
const d = msg.data as { tag?: string; message?: string };
return (
<div key={i} className="leading-relaxed">
<span className="text-primary/50">{d.tag}</span>
<span className="text-border mx-1.5"></span>
<span>{d.message}</span>
</div>
);
})}
<div className="glass-panel rounded-2xl p-8 border-primary/20 bg-primary/[0.02] shadow-2xl shadow-primary/5 fade-in">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary text-primary-foreground shadow-lg shadow-primary/20">
<Flame size={18} className="animate-pulse" />
</div>
<div>
<h3 className="text-sm font-bold uppercase tracking-widest text-primary"> Manuscript Foundry</h3>
<p className="text-xs text-muted-foreground mt-0.5">Real-time LLM generation tracking</p>
</div>
</div>
{progressEvent && (
<div className="text-primary mt-3 pt-3 border-t border-primary/10">
streaming {Math.round(((progressEvent.data as { elapsedMs?: number })?.elapsedMs ?? 0) / 1000)}s
<span className="text-border mx-1.5">·</span>
{((progressEvent.data as { totalChars?: number })?.totalChars ?? 0).toLocaleString()} chars
<div className="flex items-center gap-4 text-xs font-bold text-primary px-4 py-2 rounded-full bg-primary/10 border border-primary/20">
<div className="flex items-center gap-2">
<Clock size={12} />
<span>{Math.round(((progressEvent.data as { elapsedMs?: number })?.elapsedMs ?? 0) / 1000)}s</span>
</div>
<div className="w-px h-3 bg-primary/20" />
<div className="flex items-center gap-2">
<Zap size={12} />
<span>{((progressEvent.data as { totalChars?: number })?.totalChars ?? 0).toLocaleString()} Chars</span>
</div>
</div>
)}
</div>
<div className="space-y-2 font-mono text-xs bg-black/5 dark:bg-black/20 p-6 rounded-xl border border-border/50 max-h-[200px] overflow-y-auto scrollbar-thin">
{logEvents.map((msg, i) => {
const d = msg.data as { tag?: string; message?: string };
return (
<div key={i} className="flex gap-3 leading-relaxed animate-in fade-in slide-in-from-left-2 duration-300">
<span className="text-primary/60 font-bold shrink-0">[{d.tag}]</span>
<span className="text-muted-foreground">{d.message}</span>
</div>
);
})}
</div>
</div>
)}
<style>{`
@keyframes progress {
0% { transform: translateX(-100%); }
100% { transform: translateX(300%); }
}
`}</style>
</div>
);
}

View file

@ -0,0 +1,82 @@
import { useApi } from "../hooks/use-api";
import type { Theme } from "../hooks/use-theme";
import type { TFunction } from "../hooks/use-i18n";
import { useColors } from "../hooks/use-colors";
import { Stethoscope, CheckCircle2, XCircle, Loader2 } from "lucide-react";
interface DoctorChecks {
readonly inkosJson: boolean;
readonly projectEnv: boolean;
readonly globalEnv: boolean;
readonly booksDir: boolean;
readonly llmConnected: boolean;
readonly bookCount: number;
}
interface Nav { toDashboard: () => void }
function CheckRow({ label, ok, detail }: { label: string; ok: boolean; detail?: string }) {
return (
<div className="flex items-center gap-3 py-3 border-b border-border/30 last:border-0">
{ok ? (
<CheckCircle2 size={18} className="text-emerald-500 shrink-0" />
) : (
<XCircle size={18} className="text-destructive shrink-0" />
)}
<span className="text-sm font-medium flex-1">{label}</span>
{detail && <span className="text-xs text-muted-foreground">{detail}</span>}
</div>
);
}
export function DoctorView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
const c = useColors(theme);
const { data, refetch } = useApi<DoctorChecks>("/doctor");
return (
<div className="space-y-8">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={nav.toDashboard} className={c.link}>{t("bread.home")}</button>
<span className="text-border">/</span>
<span>{t("nav.doctor")}</span>
</div>
<div className="flex items-center justify-between">
<h1 className="font-serif text-3xl flex items-center gap-3">
<Stethoscope size={28} className="text-primary" />
{t("doctor.title")}
</h1>
<button onClick={() => refetch()} className={`px-4 py-2 text-sm rounded-lg ${c.btnSecondary}`}>
{t("doctor.recheck")}
</button>
</div>
{!data ? (
<div className="flex items-center justify-center py-12">
<Loader2 size={24} className="animate-spin text-primary" />
</div>
) : (
<div className={`border ${c.cardStatic} rounded-lg p-5`}>
<CheckRow label={t("doctor.inkosJson")} ok={data.inkosJson} />
<CheckRow label={t("doctor.projectEnv")} ok={data.projectEnv} />
<CheckRow label={t("doctor.globalEnv")} ok={data.globalEnv} />
<CheckRow label={t("doctor.booksDir")} ok={data.booksDir} detail={`${data.bookCount} book(s)`} />
<CheckRow label={t("doctor.llmApi")} ok={data.llmConnected} detail={data.llmConnected ? t("doctor.connected") : t("doctor.failed")} />
</div>
)}
{data && (
<div className={`px-4 py-3 rounded-lg text-sm font-medium ${
data.inkosJson && (data.projectEnv || data.globalEnv) && data.llmConnected
? "bg-emerald-500/10 text-emerald-600"
: "bg-amber-500/10 text-amber-600"
}`}>
{data.inkosJson && (data.projectEnv || data.globalEnv) && data.llmConnected
? t("doctor.allPassed")
: t("doctor.someFailed")
}
</div>
)}
</div>
);
}

View file

@ -1,8 +1,10 @@
import { useApi, postApi } from "../hooks/use-api";
import { fetchJson, useApi, postApi } from "../hooks/use-api";
import { useState } from "react";
import type { Theme } from "../hooks/use-theme";
import type { TFunction } from "../hooks/use-i18n";
import { useI18n } from "../hooks/use-i18n";
import { useColors } from "../hooks/use-colors";
import { Plus, Pencil, Trash2 } from "lucide-react";
interface GenreInfo {
readonly id: string;
@ -20,21 +22,200 @@ interface GenreDetail {
readonly fatigueWords: ReadonlyArray<string>;
readonly numericalSystem: boolean;
readonly powerScaling: boolean;
readonly eraResearch: boolean;
readonly pacingRule: string;
readonly auditDimensions: ReadonlyArray<number>;
};
readonly body: string;
}
interface GenreFormData {
readonly id: string;
readonly name: string;
readonly language: "zh" | "en";
readonly chapterTypes: string;
readonly fatigueWords: string;
readonly numericalSystem: boolean;
readonly powerScaling: boolean;
readonly eraResearch: boolean;
readonly pacingRule: string;
readonly body: string;
}
const EMPTY_FORM: GenreFormData = {
id: "",
name: "",
language: "zh",
chapterTypes: "",
fatigueWords: "",
numericalSystem: false,
powerScaling: false,
eraResearch: false,
pacingRule: "",
body: "",
};
function parseCommaSeparated(value: string): ReadonlyArray<string> {
return value.split(",").map((s) => s.trim()).filter(Boolean);
}
function GenreForm({
form,
onChange,
onSubmit,
onCancel,
isEdit,
c,
t,
}: {
readonly form: GenreFormData;
readonly onChange: (next: GenreFormData) => void;
readonly onSubmit: () => void;
readonly onCancel: () => void;
readonly isEdit: boolean;
readonly c: ReturnType<typeof useColors>;
readonly t: TFunction;
}) {
const set = <K extends keyof GenreFormData>(key: K, value: GenreFormData[K]) =>
onChange({ ...form, [key]: value });
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wide">ID</label>
<input
type="text"
value={form.id}
onChange={(e) => set("id", e.target.value)}
disabled={isEdit}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm disabled:opacity-50"
/>
</div>
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wide">Name</label>
<input
type="text"
value={form.name}
onChange={(e) => set("name", e.target.value)}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
/>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wide">Language</label>
<select
value={form.language}
onChange={(e) => set("language", e.target.value as "zh" | "en")}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
>
<option value="zh">zh</option>
<option value="en">en</option>
</select>
</div>
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wide">
Chapter Types (comma-separated)
</label>
<input
type="text"
value={form.chapterTypes}
onChange={(e) => set("chapterTypes", e.target.value)}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wide">
Fatigue Words (comma-separated)
</label>
<input
type="text"
value={form.fatigueWords}
onChange={(e) => set("fatigueWords", e.target.value)}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
/>
</div>
<div className="flex gap-6">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.numericalSystem}
onChange={(e) => set("numericalSystem", e.target.checked)}
/>
Numerical System
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.powerScaling}
onChange={(e) => set("powerScaling", e.target.checked)}
/>
Power Scaling
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.eraResearch}
onChange={(e) => set("eraResearch", e.target.checked)}
/>
Era Research
</label>
</div>
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wide">Pacing Rule</label>
<input
type="text"
value={form.pacingRule}
onChange={(e) => set("pacingRule", e.target.value)}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wide">Rules (Markdown)</label>
<textarea
value={form.body}
onChange={(e) => set("body", e.target.value)}
rows={6}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm font-mono"
/>
</div>
<div className="flex gap-2">
<button onClick={onSubmit} className={`px-4 py-2 text-sm rounded-md ${c.btnPrimary}`}>
{isEdit ? t("genre.saveChanges") : t("genre.createNew")}
</button>
<button onClick={onCancel} className={`px-4 py-2 text-sm rounded-md ${c.btnSecondary}`}>
{t("genre.cancel")}
</button>
</div>
</div>
);
}
interface Nav {
toDashboard: () => void;
}
export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
const c = useColors(theme);
const { lang } = useI18n();
const { data, refetch } = useApi<{ genres: ReadonlyArray<GenreInfo> }>("/genres");
const [selected, setSelected] = useState<string | null>(null);
const { data: detail } = useApi<GenreDetail>(selected ? `/genres/${selected}` : "");
const [formMode, setFormMode] = useState<"hidden" | "create" | "edit">("hidden");
const [form, setForm] = useState<GenreFormData>(EMPTY_FORM);
// Only show genres matching current language, plus custom project genres
const filteredGenres = data?.genres.filter((g) => g.language === lang || g.source === "project") ?? [];
const validSelected = selected && filteredGenres.some((g) => g.id === selected) ? selected : null;
const selectedGenre = filteredGenres.find((g) => g.id === validSelected) ?? null;
const { data: detail } = useApi<GenreDetail>(validSelected ? `/genres/${validSelected}` : "");
const handleCopy = async (id: string) => {
await postApi(`/genres/${id}/copy`);
@ -42,6 +223,97 @@ export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFu
refetch();
};
const openCreateForm = () => {
setForm(EMPTY_FORM);
setFormMode("create");
};
const openEditForm = () => {
if (!detail) return;
setForm({
id: detail.profile.id,
name: detail.profile.name,
language: detail.profile.language as "zh" | "en",
chapterTypes: detail.profile.chapterTypes.join(", "),
fatigueWords: detail.profile.fatigueWords.join(", "),
numericalSystem: detail.profile.numericalSystem,
powerScaling: detail.profile.powerScaling,
eraResearch: detail.profile.eraResearch ?? false,
pacingRule: detail.profile.pacingRule,
body: detail.body,
});
setFormMode("edit");
};
const closeForm = () => {
setFormMode("hidden");
};
const handleCreate = async () => {
try {
await postApi("/genres/create", {
id: form.id,
name: form.name,
language: form.language,
chapterTypes: parseCommaSeparated(form.chapterTypes),
fatigueWords: parseCommaSeparated(form.fatigueWords),
numericalSystem: form.numericalSystem,
powerScaling: form.powerScaling,
eraResearch: form.eraResearch,
pacingRule: form.pacingRule,
body: form.body,
});
setFormMode("hidden");
refetch();
} catch (e) {
alert(e instanceof Error ? e.message : "Failed to create genre");
}
};
const handleEdit = async () => {
if (!validSelected) return;
try {
await fetchJson(`/genres/${validSelected}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
profile: {
id: form.id,
name: form.name,
language: form.language,
chapterTypes: parseCommaSeparated(form.chapterTypes),
fatigueWords: parseCommaSeparated(form.fatigueWords),
numericalSystem: form.numericalSystem,
powerScaling: form.powerScaling,
eraResearch: form.eraResearch,
pacingRule: form.pacingRule,
},
body: form.body,
}),
});
setFormMode("hidden");
refetch();
} catch (e) {
alert(e instanceof Error ? e.message : "Failed to update genre");
}
};
const handleDelete = async () => {
if (!validSelected) return;
if (!window.confirm(`Delete genre "${validSelected}"?`)) return;
try {
const res = await fetch(`/api/genres/${validSelected}`, { method: "DELETE" });
if (!res.ok) {
const json = await res.json() as { error?: string };
throw new Error(json.error ?? `${res.status}`);
}
setSelected(null);
refetch();
} catch (e) {
alert(e instanceof Error ? e.message : "Failed to delete genre");
}
};
return (
<div className="space-y-8">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
@ -50,17 +322,43 @@ export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFu
<span>{t("create.genre")}</span>
</div>
<h1 className="font-serif text-3xl">{t("create.genre")}</h1>
<div className="flex items-center justify-between">
<h1 className="font-serif text-3xl">{t("create.genre")}</h1>
<button
onClick={openCreateForm}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md ${c.btnPrimary}`}
>
<Plus size={16} />
Create Genre
</button>
</div>
{formMode !== "hidden" && (
<div className={`border ${c.cardStatic} rounded-lg p-6`}>
<h2 className="text-lg font-medium mb-4">
{formMode === "create" ? "Create New Genre" : `Edit: ${form.id}`}
</h2>
<GenreForm
form={form}
onChange={setForm}
onSubmit={formMode === "create" ? handleCreate : handleEdit}
onCancel={closeForm}
isEdit={formMode === "edit"}
c={c}
t={t}
/>
</div>
)}
<div className="grid grid-cols-[250px_1fr] gap-6">
{/* Genre list */}
<div className={`border ${c.cardStatic} rounded-lg overflow-hidden`}>
{data?.genres.map((g) => (
{filteredGenres.map((g) => (
<button
key={g.id}
onClick={() => setSelected(g.id)}
className={`w-full text-left px-4 py-3 border-b border-border/40 transition-colors ${
selected === g.id ? "bg-primary/10 text-primary" : "hover:bg-muted/30"
validSelected === g.id ? "bg-primary/10 text-primary" : "hover:bg-muted/30"
}`}
>
<div className="text-sm font-medium">{g.name}</div>
@ -73,7 +371,7 @@ export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFu
{/* Detail panel */}
<div className={`border ${c.cardStatic} rounded-lg p-6 min-h-[400px]`}>
{selected && detail ? (
{validSelected && detail ? (
<div className="space-y-6">
<div className="flex items-start justify-between">
<div>
@ -82,21 +380,40 @@ export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFu
{detail.profile.id} · {detail.profile.language} ·
{detail.profile.numericalSystem ? " Numerical" : ""}
{detail.profile.powerScaling ? " Power" : ""}
{detail.profile.eraResearch ? " Era" : ""}
</div>
</div>
<button
onClick={() => handleCopy(selected)}
className={`px-3 py-1.5 text-sm ${c.btnSecondary} rounded-md`}
>
Copy to Project
</button>
<div className="flex gap-2">
<button
onClick={openEditForm}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm ${c.btnSecondary} rounded-md`}
>
<Pencil size={14} />
Edit
</button>
{selectedGenre?.source === "project" && (
<button
onClick={handleDelete}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm ${c.btnDanger} rounded-md`}
>
<Trash2 size={14} />
Delete
</button>
)}
<button
onClick={() => validSelected && handleCopy(validSelected)}
className={`px-3 py-1.5 text-sm ${c.btnSecondary} rounded-md`}
>
Copy to Project
</button>
</div>
</div>
<div>
<div className="text-xs text-muted-foreground uppercase tracking-wide mb-2">Chapter Types</div>
<div className="flex gap-2 flex-wrap">
{detail.profile.chapterTypes.map((t) => (
<span key={t} className="px-2 py-1 text-xs bg-secondary rounded">{t}</span>
{detail.profile.chapterTypes.map((ct) => (
<span key={ct} className="px-2 py-1 text-xs bg-secondary rounded">{ct}</span>
))}
</div>
</div>

View file

@ -0,0 +1,216 @@
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 { useI18n } from "../hooks/use-i18n";
import { useColors } from "../hooks/use-colors";
import { FileInput, BookCopy, Feather } from "lucide-react";
interface BookSummary {
readonly id: string;
readonly title: string;
}
interface Nav { toDashboard: () => void }
type Tab = "chapters" | "canon" | "fanfic";
export function ImportManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
const c = useColors(theme);
const { lang } = useI18n();
const { data: booksData } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
const [tab, setTab] = useState<Tab>("chapters");
const [status, setStatus] = useState("");
const [loading, setLoading] = useState(false);
// Chapters state
const [chText, setChText] = useState("");
const [chBookId, setChBookId] = useState("");
const [chSplitRegex, setChSplitRegex] = useState("");
// Canon state
const [canonTarget, setCanonTarget] = useState("");
const [canonFrom, setCanonFrom] = useState("");
// Fanfic state
const [ffTitle, setFfTitle] = useState("");
const [ffText, setFfText] = useState("");
const [ffMode, setFfMode] = useState("canon");
const [ffGenre, setFfGenre] = useState("other");
const [ffLang, setFfLang] = useState(lang);
const handleImportChapters = async () => {
if (!chText.trim() || !chBookId) return;
setLoading(true);
setStatus("");
try {
const data = await fetchJson<{ importedCount?: number }>(`/books/${chBookId}/import/chapters`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: chText, splitRegex: chSplitRegex || undefined }),
});
setStatus(`Imported ${data.importedCount} chapters`);
} catch (e) {
setStatus(`Error: ${e instanceof Error ? e.message : String(e)}`);
}
setLoading(false);
};
const handleImportCanon = async () => {
if (!canonTarget || !canonFrom) return;
setLoading(true);
setStatus("");
try {
await postApi(`/books/${canonTarget}/import/canon`, { fromBookId: canonFrom });
setStatus("Canon imported successfully!");
} catch (e) {
setStatus(`Error: ${e instanceof Error ? e.message : String(e)}`);
}
setLoading(false);
};
const handleFanficInit = async () => {
if (!ffTitle.trim() || !ffText.trim()) return;
setLoading(true);
setStatus("");
try {
const data = await fetchJson<{ bookId?: string }>("/fanfic/init", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: ffTitle, sourceText: ffText, mode: ffMode,
genre: ffGenre, language: ffLang,
}),
});
setStatus(`Fanfic created: ${data.bookId}`);
} catch (e) {
setStatus(`Error: ${e instanceof Error ? e.message : String(e)}`);
}
setLoading(false);
};
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: "chapters", label: t("import.chapters"), icon: <FileInput size={14} /> },
{ id: "canon", label: t("import.canon"), icon: <BookCopy size={14} /> },
{ id: "fanfic", label: t("import.fanfic"), icon: <Feather size={14} /> },
];
return (
<div className="space-y-8">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={nav.toDashboard} className={c.link}>{t("bread.home")}</button>
<span className="text-border">/</span>
<span>{t("nav.import")}</span>
</div>
<h1 className="font-serif text-3xl flex items-center gap-3">
<FileInput size={28} className="text-primary" />
{t("import.title")}
</h1>
{/* Tabs */}
<div className="flex gap-1 bg-secondary/30 rounded-lg p-1 w-fit">
{tabs.map((tb) => (
<button
key={tb.id}
onClick={() => { setTab(tb.id); setStatus(""); }}
className={`px-4 py-2 rounded-md text-sm font-medium flex items-center gap-2 transition-all ${
tab === tb.id ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{tb.icon} {tb.label}
</button>
))}
</div>
{/* Tab content */}
<div className={`border ${c.cardStatic} rounded-lg p-6 space-y-4`}>
{tab === "chapters" && (
<>
<select value={chBookId} onChange={(e) => setChBookId(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm">
<option value="">{t("import.selectTarget")}</option>
{booksData?.books.map((b) => <option key={b.id} value={b.id}>{b.title}</option>)}
</select>
<input
type="text" value={chSplitRegex} onChange={(e) => setChSplitRegex(e.target.value)}
placeholder={t("import.splitRegex")}
className="w-full px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm font-mono"
/>
<textarea value={chText} onChange={(e) => setChText(e.target.value)} rows={10}
placeholder={t("import.pasteChapters")}
className="w-full px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm resize-none font-mono"
/>
<button onClick={handleImportChapters} disabled={loading || !chBookId || !chText.trim()}
className={`px-4 py-2 text-sm rounded-lg ${c.btnPrimary} disabled:opacity-30`}>
{loading ? t("import.importing") : t("import.chapters")}
</button>
</>
)}
{tab === "canon" && (
<>
<select value={canonFrom} onChange={(e) => setCanonFrom(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm">
<option value="">{t("import.selectSource")}</option>
{booksData?.books.map((b) => <option key={b.id} value={b.id}>{b.title}</option>)}
</select>
<select value={canonTarget} onChange={(e) => setCanonTarget(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm">
<option value="">{t("import.selectDerivative")}</option>
{booksData?.books.map((b) => <option key={b.id} value={b.id}>{b.title}</option>)}
</select>
<button onClick={handleImportCanon} disabled={loading || !canonTarget || !canonFrom}
className={`px-4 py-2 text-sm rounded-lg ${c.btnPrimary} disabled:opacity-30`}>
{loading ? t("import.importing") : t("import.canon")}
</button>
</>
)}
{tab === "fanfic" && (
<>
<input type="text" value={ffTitle} onChange={(e) => setFfTitle(e.target.value)}
placeholder={t("import.fanficTitle")}
className="w-full px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm"
/>
<div className="grid grid-cols-3 gap-3">
<select value={ffMode} onChange={(e) => setFfMode(e.target.value)}
className="px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm">
<option value="canon">Canon</option>
<option value="au">AU</option>
<option value="ooc">OOC</option>
<option value="cp">CP</option>
</select>
<select value={ffGenre} onChange={(e) => setFfGenre(e.target.value)}
className="px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm">
<option value="other">Other</option>
<option value="xuanhuan"></option>
<option value="urban"></option>
<option value="xianxia"></option>
</select>
<select value={ffLang} onChange={(e) => setFfLang(e.target.value as "zh" | "en")}
className="px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm">
<option value="zh"></option>
<option value="en">English</option>
</select>
</div>
<textarea value={ffText} onChange={(e) => setFfText(e.target.value)} rows={10}
placeholder={t("import.pasteMaterial")}
className="w-full px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm resize-none font-mono"
/>
<button onClick={handleFanficInit} disabled={loading || !ffTitle.trim() || !ffText.trim()}
className={`px-4 py-2 text-sm rounded-lg ${c.btnPrimary} disabled:opacity-30`}>
{loading ? t("import.creating") : t("import.fanfic")}
</button>
</>
)}
{status && (
<div className={`text-sm px-3 py-2 rounded-lg ${status.startsWith("Error") ? "bg-destructive/10 text-destructive" : "bg-emerald-500/10 text-emerald-600"}`}>
{status}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,114 @@
import { useState } from "react";
import type { Theme } from "../hooks/use-theme";
import type { TFunction } from "../hooks/use-i18n";
import { useColors } from "../hooks/use-colors";
import { fetchJson } from "../hooks/use-api";
import { TrendingUp, Loader2, Target } from "lucide-react";
interface Recommendation {
readonly confidence: number;
readonly platform: string;
readonly genre: string;
readonly concept: string;
readonly reasoning: string;
readonly benchmarkTitles: ReadonlyArray<string>;
}
interface RadarResult {
readonly marketSummary: string;
readonly recommendations: ReadonlyArray<Recommendation>;
}
interface Nav { toDashboard: () => void }
export function RadarView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
const c = useColors(theme);
const [result, setResult] = useState<RadarResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleScan = async () => {
setLoading(true);
setError("");
setResult(null);
try {
const data = await fetchJson<RadarResult>("/radar/scan", { method: "POST" });
setResult(data);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
setLoading(false);
};
return (
<div className="space-y-8">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button onClick={nav.toDashboard} className={c.link}>{t("bread.home")}</button>
<span className="text-border">/</span>
<span>{t("nav.radar")}</span>
</div>
<div className="flex items-center justify-between">
<h1 className="font-serif text-3xl flex items-center gap-3">
<TrendingUp size={28} className="text-primary" />
{t("radar.title")}
</h1>
<button
onClick={handleScan}
disabled={loading}
className={`px-5 py-2.5 text-sm rounded-lg ${c.btnPrimary} disabled:opacity-30 flex items-center gap-2`}
>
{loading ? <Loader2 size={14} className="animate-spin" /> : <Target size={14} />}
{loading ? t("radar.scanning") : t("radar.scan")}
</button>
</div>
{error && (
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-lg text-sm">{error}</div>
)}
{result && (
<div className="space-y-6">
<div className={`border ${c.cardStatic} rounded-lg p-5`}>
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">{t("radar.summary")}</h3>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{result.marketSummary}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{result.recommendations.map((rec, i) => (
<div key={i} className={`border ${c.cardStatic} rounded-lg p-5 space-y-3`}>
<div className="flex items-center justify-between">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{rec.platform} · {rec.genre}
</span>
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${
rec.confidence >= 0.7 ? "bg-emerald-500/10 text-emerald-600" :
rec.confidence >= 0.4 ? "bg-amber-500/10 text-amber-600" :
"bg-muted text-muted-foreground"
}`}>
{(rec.confidence * 100).toFixed(0)}%
</span>
</div>
<p className="text-sm font-semibold">{rec.concept}</p>
<p className="text-xs text-muted-foreground leading-relaxed">{rec.reasoning}</p>
{rec.benchmarkTitles.length > 0 && (
<div className="flex gap-2 flex-wrap">
{rec.benchmarkTitles.map((bt) => (
<span key={bt} className="px-2 py-0.5 text-[10px] bg-secondary rounded">{bt}</span>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
{!result && !loading && !error && (
<div className={`border border-dashed ${c.cardStatic} rounded-lg p-12 text-center text-muted-foreground text-sm italic`}>
{t("radar.emptyHint")}
</div>
)}
</div>
);
}

View file

@ -1,8 +1,9 @@
import { useApi } from "../hooks/use-api";
import { fetchJson, useApi } from "../hooks/use-api";
import { useState } from "react";
import type { Theme } from "../hooks/use-theme";
import type { TFunction } from "../hooks/use-i18n";
import { useColors } from "../hooks/use-colors";
import { Pencil, Save, X } from "lucide-react";
interface TruthFile {
readonly name: string;
@ -19,10 +20,40 @@ export function TruthFiles({ bookId, nav, theme, t }: { bookId: string; nav: Nav
const c = useColors(theme);
const { data } = useApi<{ files: ReadonlyArray<TruthFile> }>(`/books/${bookId}/truth`);
const [selected, setSelected] = useState<string | null>(null);
const { data: fileData } = useApi<{ file: string; content: string | null }>(
const [editMode, setEditMode] = useState(false);
const [editText, setEditText] = useState("");
const [savingEdit, setSavingEdit] = useState(false);
const { data: fileData, refetch: refetchFile } = useApi<{ file: string; content: string | null }>(
selected ? `/books/${bookId}/truth/${selected}` : "",
);
const startEdit = () => {
setEditText(fileData?.content ?? "");
setEditMode(true);
};
const cancelEdit = () => {
setEditMode(false);
};
const handleSaveEdit = async () => {
if (!selected) return;
setSavingEdit(true);
try {
await fetchJson(`/books/${bookId}/truth/${selected}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: editText }),
});
setEditMode(false);
refetchFile();
} catch (e) {
alert(e instanceof Error ? e.message : "Failed to save");
} finally {
setSavingEdit(false);
}
};
return (
<div className="space-y-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
@ -41,7 +72,7 @@ export function TruthFiles({ bookId, nav, theme, t }: { bookId: string; nav: Nav
{data?.files.map((f) => (
<button
key={f.name}
onClick={() => setSelected(f.name)}
onClick={() => { setSelected(f.name); setEditMode(false); }}
className={`w-full text-left px-3 py-2.5 text-sm border-b border-border/40 transition-colors ${
selected === f.name
? "bg-primary/10 text-primary"
@ -58,9 +89,48 @@ export function TruthFiles({ bookId, nav, theme, t }: { bookId: string; nav: Nav
</div>
{/* Content viewer */}
<div className={`border ${c.cardStatic} rounded-lg p-5 min-h-[400px]`}>
{selected && fileData?.content ? (
<pre className="text-sm leading-relaxed whitespace-pre-wrap font-mono text-foreground/80">{fileData.content}</pre>
<div className={`border ${c.cardStatic} rounded-lg p-5 min-h-[400px] flex flex-col`}>
{selected && fileData?.content != null ? (
<>
<div className="flex items-center justify-end gap-2 mb-3">
{editMode ? (
<>
<button
onClick={cancelEdit}
className={`inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded-md ${c.btnSecondary}`}
>
<X size={14} />
Cancel
</button>
<button
onClick={handleSaveEdit}
disabled={savingEdit}
className={`inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded-md ${c.btnPrimary} disabled:opacity-50`}
>
<Save size={14} />
{savingEdit ? t("truth.saving") : t("truth.save")}
</button>
</>
) : (
<button
onClick={startEdit}
className={`inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded-md ${c.btnSecondary}`}
>
<Pencil size={14} />
Edit
</button>
)}
</div>
{editMode ? (
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
className={`${c.input} flex-1 rounded-md p-3 text-sm font-mono leading-relaxed resize-none min-h-[360px]`}
/>
) : (
<pre className="text-sm leading-relaxed whitespace-pre-wrap font-mono text-foreground/80">{fileData.content}</pre>
)}
</>
) : selected && fileData?.content === null ? (
<div className="text-muted-foreground text-sm">File not found</div>
) : (

View file

@ -0,0 +1,143 @@
/**
* Shared TypeScript contracts for Studio API/UI communication.
* Ported from PR #96 (Te9ui1a) prevents client/server type drift.
*/
// --- Health ---
export interface HealthStatus {
readonly status: "ok";
readonly projectRoot: string;
readonly projectConfigFound: boolean;
readonly envFound: boolean;
readonly projectEnvFound: boolean;
readonly globalConfigFound: boolean;
readonly bookCount: number;
readonly provider: string | null;
readonly model: string | null;
}
// --- Books ---
export interface BookSummary {
readonly id: string;
readonly title: string;
readonly status: string;
readonly platform: string;
readonly genre: string;
readonly targetChapters: number;
readonly chapters: number;
readonly chapterCount: number;
readonly lastChapterNumber: number;
readonly totalWords: number;
readonly approvedChapters: number;
readonly pendingReview: number;
readonly pendingReviewChapters: number;
readonly failedReview: number;
readonly failedChapters: number;
readonly recentRunStatus?: string | null;
readonly updatedAt: string;
}
export interface BookDetail extends BookSummary {
readonly createdAt: string;
readonly chapterWordCount: number;
readonly language: "zh" | "en" | null;
}
// --- Chapters ---
export interface ChapterSummary {
readonly number: number;
readonly title: string;
readonly status: string;
readonly wordCount: number;
readonly auditIssueCount: number;
readonly updatedAt: string;
readonly fileName: string | null;
}
export interface ChapterDetail extends ChapterSummary {
readonly auditIssues: ReadonlyArray<string>;
readonly reviewNote?: string;
readonly content: string;
}
export interface SaveChapterPayload {
readonly content: string;
}
// --- Truth Files ---
export interface TruthFileSummary {
readonly name: string;
readonly label: string;
readonly exists: boolean;
readonly path: string;
readonly optional: boolean;
readonly available: boolean;
}
export interface TruthFileDetail extends TruthFileSummary {
readonly content: string | null;
}
// --- Review ---
export interface ReviewActionPayload {
readonly chapterNumber: number;
readonly reason?: string;
}
// --- Runs ---
export type RunAction = "draft" | "audit" | "revise" | "write-next";
export type RunStatus = "queued" | "running" | "succeeded" | "failed";
export interface RunLogEntry {
readonly timestamp: string;
readonly level: "info" | "warn" | "error";
readonly message: string;
}
export interface RunActionPayload {
readonly chapterNumber?: number;
}
export interface StudioRun {
readonly id: string;
readonly bookId: string;
readonly chapter: number | null;
readonly chapterNumber: number | null;
readonly action: RunAction;
readonly status: RunStatus;
readonly stage: string;
readonly createdAt: string;
readonly updatedAt: string;
readonly startedAt: string | null;
readonly finishedAt: string | null;
readonly logs: ReadonlyArray<RunLogEntry>;
readonly result?: unknown;
readonly error?: string;
}
export interface RunStreamEvent {
readonly type: "snapshot" | "status" | "stage" | "log";
readonly runId: string;
readonly run?: StudioRun;
readonly status?: RunStatus;
readonly stage?: string;
readonly log?: RunLogEntry;
readonly result?: unknown;
readonly error?: string;
}
// --- API Error Response ---
export interface ApiErrorResponse {
readonly error: {
readonly code: string;
readonly message: string;
};
}

View file

@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
},
});

View file

@ -133,6 +133,9 @@ importers:
vite:
specifier: ^6.0.0
version: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.13(@types/node@22.19.15)(typescript@5.9.3))
packages:

View file

@ -4,7 +4,8 @@
"llm": {
"provider": "openai",
"baseUrl": "https://api.openai.com/v1",
"model": "gpt-4o"
"model": "gpt-4o",
"stream": false
},
"notify": [],
"daemon": {
@ -13,5 +14,6 @@
"writeCron": "*/15 * * * *"
},
"maxConcurrentBooks": 3
}
},
"language": "zh"
}