mirror of
https://github.com/Narcooo/inkos
synced 2026-04-21 14:37:16 +00:00
wip(studio): preserve detached studio worktree changes
This commit is contained in:
parent
9ef1e28b6b
commit
c7ac2f2324
29 changed files with 3720 additions and 574 deletions
71
packages/core/src/__tests__/pipeline-runner.test.ts
Normal file
71
packages/core/src/__tests__/pipeline-runner.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
20
packages/studio/src/api/errors.ts
Normal file
20
packages/studio/src/api/errors.ts
Normal 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";
|
||||
}
|
||||
177
packages/studio/src/api/lib/run-store.ts
Normal file
177
packages/studio/src/api/lib/run-store.ts
Normal 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";
|
||||
}
|
||||
50
packages/studio/src/api/lib/sse.ts
Normal file
50
packages/studio/src/api/lib/sse.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
76
packages/studio/src/api/safety.ts
Normal file
76
packages/studio/src/api/safety.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
92
packages/studio/src/components/ConfirmDialog.tsx
Normal file
92
packages/studio/src/components/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
82
packages/studio/src/pages/DoctorView.tsx
Normal file
82
packages/studio/src/pages/DoctorView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
216
packages/studio/src/pages/ImportManager.tsx
Normal file
216
packages/studio/src/pages/ImportManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
packages/studio/src/pages/RadarView.tsx
Normal file
114
packages/studio/src/pages/RadarView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
143
packages/studio/src/shared/contracts.ts
Normal file
143
packages/studio/src/shared/contracts.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
7
packages/studio/vitest.config.ts
Normal file
7
packages/studio/vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
Loading…
Reference in a new issue