diff --git a/packages/studio/src/api/server.test.ts b/packages/studio/src/api/server.test.ts new file mode 100644 index 0000000..cef28c8 --- /dev/null +++ b/packages/studio/src/api/server.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const schedulerStartMock = vi.fn<() => Promise>(); + +const logger = { + child: () => logger, + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; + +vi.mock("@actalk/inkos-core", () => { + class MockStateManager { + constructor(private readonly root: string) {} + + async listBooks(): Promise { + return []; + } + + async loadBookConfig(): Promise { + throw new Error("not implemented"); + } + + async loadChapterIndex(): Promise<[]> { + return []; + } + + async getNextChapterNumber(): Promise { + return 1; + } + + bookDir(id: string): string { + return join(this.root, "books", id); + } + } + + class MockPipelineRunner { + constructor(_config: unknown) {} + } + + class MockScheduler { + private running = false; + + constructor(_config: unknown) {} + + async start(): Promise { + this.running = true; + await schedulerStartMock(); + } + + stop(): void { + this.running = false; + } + + get isRunning(): boolean { + return this.running; + } + } + + return { + StateManager: MockStateManager, + PipelineRunner: MockPipelineRunner, + Scheduler: MockScheduler, + createLLMClient: vi.fn(() => ({})), + createLogger: vi.fn(() => logger), + computeAnalytics: vi.fn(() => ({})), + }; +}); + +const projectConfig = { + name: "studio-test", + version: "0.1.0", + language: "zh", + llm: { + provider: "openai", + baseUrl: "https://api.example.com/v1", + apiKey: "sk-test", + model: "gpt-5.4", + temperature: 0.7, + maxTokens: 4096, + stream: false, + }, + daemon: { + schedule: { + radarCron: "0 */6 * * *", + writeCron: "*/15 * * * *", + }, + maxConcurrentBooks: 1, + chaptersPerCycle: 1, + retryDelayMs: 30000, + cooldownAfterChapterMs: 0, + maxChaptersPerDay: 50, + }, + modelOverrides: {}, + notify: [], +} as const; + +describe("createStudioServer daemon lifecycle", () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "inkos-studio-server-")); + schedulerStartMock.mockReset(); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it("returns from /api/daemon/start before the first write cycle finishes", async () => { + let resolveStart: (() => void) | undefined; + schedulerStartMock.mockImplementation( + () => + new Promise((resolve) => { + resolveStart = resolve; + }), + ); + + const { createStudioServer } = await import("./server.js"); + const app = createStudioServer(projectConfig as never, root); + + const responseOrTimeout = await Promise.race([ + app.request("http://localhost/api/daemon/start", { method: "POST" }), + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 30)), + ]); + + expect(responseOrTimeout).not.toBe("timeout"); + + const response = responseOrTimeout as Response; + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ ok: true, running: true }); + + const status = await app.request("http://localhost/api/daemon"); + await expect(status.json()).resolves.toEqual({ running: true }); + + resolveStart?.(); + }); +}); diff --git a/packages/studio/src/api/server.ts b/packages/studio/src/api/server.ts index eddf29c..e1f734b 100644 --- a/packages/studio/src/api/server.ts +++ b/packages/studio/src/api/server.ts @@ -421,7 +421,7 @@ export function createStudioServer(config: ProjectConfig, root: string) { } try { const { Scheduler } = await import("@actalk/inkos-core"); - schedulerInstance = new Scheduler({ + const scheduler = new Scheduler({ ...buildPipelineConfig(), radarCron: config.daemon.schedule.radarCron, writeCron: config.daemon.schedule.writeCron, @@ -437,8 +437,17 @@ export function createStudioServer(config: ProjectConfig, root: string) { broadcast("daemon:error", { bookId, error: error.message }); }, }); - await schedulerInstance.start(); + schedulerInstance = scheduler; broadcast("daemon:started", {}); + void scheduler.start().catch((e) => { + const error = e instanceof Error ? e : new Error(String(e)); + if (schedulerInstance === scheduler) { + scheduler.stop(); + schedulerInstance = null; + broadcast("daemon:stopped", {}); + } + broadcast("daemon:error", { bookId: "scheduler", error: error.message }); + }); return c.json({ ok: true, running: true }); } catch (e) { return c.json({ error: String(e) }, 500); @@ -833,6 +842,7 @@ export function createStudioServer(config: ProjectConfig, root: string) { try { const { rm } = await import("node:fs/promises"); await rm(bookDir, { recursive: true, force: true }); + broadcast("book:deleted", { bookId: id }); return c.json({ ok: true, bookId: id }); } catch (e) { return c.json({ error: String(e) }, 500); diff --git a/packages/studio/src/hooks/use-book-activity.test.ts b/packages/studio/src/hooks/use-book-activity.test.ts index f7d4556..ee28e16 100644 --- a/packages/studio/src/hooks/use-book-activity.test.ts +++ b/packages/studio/src/hooks/use-book-activity.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; import type { SSEMessage } from "./use-sse"; -import { deriveActiveBookIds, deriveBookActivity, shouldRefetchBookView } from "./use-book-activity"; +import { + deriveActiveBookIds, + deriveBookActivity, + shouldRefetchBookCollections, + shouldRefetchBookView, + shouldRefetchDaemonStatus, +} from "./use-book-activity"; function msg(event: string, data: unknown, timestamp: number): SSEMessage { return { event, data, timestamp }; @@ -79,3 +85,24 @@ describe("shouldRefetchBookView", () => { expect(shouldRefetchBookView(msg("rewrite:complete", { bookId: "beta" }, 1), "alpha")).toBe(false); }); }); + +describe("shouldRefetchBookCollections", () => { + it("refreshes book lists for create/delete and chapter-changing terminal events", () => { + expect(shouldRefetchBookCollections(msg("book:created", { bookId: "alpha" }, 1))).toBe(true); + expect(shouldRefetchBookCollections(msg("book:deleted", { bookId: "alpha" }, 1))).toBe(true); + expect(shouldRefetchBookCollections(msg("write:complete", { bookId: "alpha" }, 1))).toBe(true); + expect(shouldRefetchBookCollections(msg("draft:error", { bookId: "alpha" }, 1))).toBe(true); + expect(shouldRefetchBookCollections(msg("rewrite:complete", { bookId: "alpha" }, 1))).toBe(true); + expect(shouldRefetchBookCollections(msg("audit:start", { bookId: "alpha" }, 1))).toBe(false); + expect(shouldRefetchBookCollections(undefined)).toBe(false); + }); +}); + +describe("shouldRefetchDaemonStatus", () => { + it("refreshes daemon status for daemon terminal events", () => { + expect(shouldRefetchDaemonStatus(msg("daemon:started", {}, 1))).toBe(true); + expect(shouldRefetchDaemonStatus(msg("daemon:stopped", {}, 1))).toBe(true); + expect(shouldRefetchDaemonStatus(msg("daemon:error", {}, 1))).toBe(true); + expect(shouldRefetchDaemonStatus(msg("daemon:chapter", {}, 1))).toBe(false); + }); +}); diff --git a/packages/studio/src/hooks/use-book-activity.ts b/packages/studio/src/hooks/use-book-activity.ts index fccd44b..6a2d545 100644 --- a/packages/studio/src/hooks/use-book-activity.ts +++ b/packages/studio/src/hooks/use-book-activity.ts @@ -15,6 +15,28 @@ const BOOK_REFRESH_EVENTS = new Set([ "audit:error", ]); +const BOOK_COLLECTION_REFRESH_EVENTS = new Set([ + "book:created", + "book:deleted", + "book:error", + "write:complete", + "write:error", + "draft:complete", + "draft:error", + "rewrite:complete", + "rewrite:error", + "revise:complete", + "revise:error", + "audit:complete", + "audit:error", +]); + +const DAEMON_STATUS_REFRESH_EVENTS = new Set([ + "daemon:started", + "daemon:stopped", + "daemon:error", +]); + export interface BookActivity { readonly writing: boolean; readonly drafting: boolean; @@ -92,3 +114,11 @@ export function deriveBookActivity(messages: ReadonlyArray, bookId: export function shouldRefetchBookView(message: SSEMessage, bookId: string): boolean { return getBookId(message) === bookId && BOOK_REFRESH_EVENTS.has(message.event); } + +export function shouldRefetchBookCollections(message: SSEMessage | undefined): boolean { + return Boolean(message && BOOK_COLLECTION_REFRESH_EVENTS.has(message.event)); +} + +export function shouldRefetchDaemonStatus(message: SSEMessage | undefined): boolean { + return Boolean(message && DAEMON_STATUS_REFRESH_EVENTS.has(message.event)); +} diff --git a/packages/studio/src/hooks/use-sse.test.ts b/packages/studio/src/hooks/use-sse.test.ts index 8696032..56b3492 100644 --- a/packages/studio/src/hooks/use-sse.test.ts +++ b/packages/studio/src/hooks/use-sse.test.ts @@ -6,6 +6,7 @@ describe("STUDIO_SSE_EVENTS", () => { expect(STUDIO_SSE_EVENTS).toEqual(expect.arrayContaining([ "book:creating", "book:created", + "book:deleted", "book:error", "write:start", "write:complete", diff --git a/packages/studio/src/hooks/use-sse.ts b/packages/studio/src/hooks/use-sse.ts index 1c85cd1..e472db0 100644 --- a/packages/studio/src/hooks/use-sse.ts +++ b/packages/studio/src/hooks/use-sse.ts @@ -9,6 +9,7 @@ export interface SSEMessage { export const STUDIO_SSE_EVENTS = [ "book:creating", "book:created", + "book:deleted", "book:error", "write:start", "write:complete", diff --git a/packages/studio/src/pages/DaemonControl.tsx b/packages/studio/src/pages/DaemonControl.tsx index ba4b492..bcd4c6b 100644 --- a/packages/studio/src/pages/DaemonControl.tsx +++ b/packages/studio/src/pages/DaemonControl.tsx @@ -1,9 +1,10 @@ import { useApi, postApi } from "../hooks/use-api"; -import { useState } from "react"; +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"; import type { SSEMessage } from "../hooks/use-sse"; +import { shouldRefetchDaemonStatus } from "../hooks/use-book-activity"; interface Nav { toDashboard: () => void; @@ -14,6 +15,12 @@ export function DaemonControl({ nav, theme, t, sse }: { nav: Nav; theme: Theme; const { data, refetch } = useApi<{ running: boolean }>("/daemon"); const [loading, setLoading] = useState(false); + useEffect(() => { + const recent = sse.messages.at(-1); + if (!shouldRefetchDaemonStatus(recent)) return; + void refetch(); + }, [refetch, sse.messages]); + const daemonEvents = sse.messages .filter((m) => m.event.startsWith("daemon:") || m.event === "log") .slice(-20); @@ -49,14 +56,14 @@ export function DaemonControl({ nav, theme, t, sse }: { nav: Nav; theme: Theme;
/ - Daemon + {t("nav.daemon")}
-

Daemon

+

{t("daemon.title")}

- {isRunning ? "Running" : "Stopped"} + {isRunning ? t("daemon.running") : t("daemon.stopped")} {isRunning ? ( ) : ( )}
@@ -92,14 +99,14 @@ export function DaemonControl({ nav, theme, t, sse }: { nav: Nav; theme: Theme;
{msg.event} - {d.message ?? d.bookId ?? JSON.stringify(d)} + {String(d.message ?? d.bookId ?? JSON.stringify(d))}
); })}
) : (
- {isRunning ? "Waiting for events..." : "Start the daemon to see events"} + {isRunning ? t("daemon.waitingEvents") : t("daemon.startHint")}
)} diff --git a/packages/studio/src/pages/StyleManager.tsx b/packages/studio/src/pages/StyleManager.tsx new file mode 100644 index 0000000..dd3d74c --- /dev/null +++ b/packages/studio/src/pages/StyleManager.tsx @@ -0,0 +1,225 @@ +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 { Wand2, Upload, BarChart3 } from "lucide-react"; + +interface StyleProfile { + readonly sourceName: string; + readonly avgSentenceLength: number; + readonly sentenceLengthStdDev: number; + readonly avgParagraphLength: number; + readonly vocabularyDiversity: number; + readonly topPatterns: ReadonlyArray; + readonly rhetoricalFeatures: ReadonlyArray; +} + +interface BookSummary { + readonly id: string; + readonly title: string; +} + +interface Nav { toDashboard: () => void } + +export interface StyleStatusNotice { + readonly tone: "error" | "success" | "info"; + readonly message: string; +} + +export function buildStyleStatusNotice(analyzeStatus: string, importStatus: string): StyleStatusNotice | null { + const message = analyzeStatus.trim() || importStatus.trim(); + if (!message) return null; + if (message.startsWith("Error:")) { + return { tone: "error", message }; + } + if (message.endsWith("...")) { + return { tone: "info", message }; + } + return { tone: "success", message }; +} + +export function StyleManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) { + const c = useColors(theme); + const [text, setText] = useState(""); + const [sourceName, setSourceName] = useState(""); + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(false); + const [analyzeStatus, setAnalyzeStatus] = useState(""); + const [importBookId, setImportBookId] = useState(""); + const [importStatus, setImportStatus] = useState(""); + const { data: booksData } = useApi<{ books: ReadonlyArray }>("/books"); + const statusNotice = buildStyleStatusNotice(analyzeStatus, importStatus); + + const handleAnalyze = async () => { + if (!text.trim()) return; + setLoading(true); + setProfile(null); + setAnalyzeStatus(""); + try { + const data = await fetchJson("/style/analyze", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text, sourceName: sourceName || "sample" }), + }); + setProfile(data); + } catch (e) { + setAnalyzeStatus(`Error: ${e instanceof Error ? e.message : String(e)}`); + } + setLoading(false); + }; + + const handleImport = async () => { + if (!importBookId || !text.trim()) return; + setImportStatus("Importing..."); + try { + await postApi(`/books/${importBookId}/style/import`, { text, sourceName: sourceName || "sample" }); + setImportStatus("Style guide imported successfully!"); + } catch (e) { + setImportStatus(`Error: ${e instanceof Error ? e.message : String(e)}`); + } + }; + + return ( +
+
+ + / + {t("nav.style")} +
+ +

+ + {t("style.title")} +

+ +
+ {/* Input */} +
+
+ + setSourceName(e.target.value)} + placeholder={t("style.sourceExample")} + className="w-full px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm focus:outline-none focus:border-primary" + /> +
+
+ +