fix(studio): unblock daemon start and surface status feedback

This commit is contained in:
Ma 2026-03-30 10:55:12 +08:00
parent 44a3a1fbda
commit 9ef1e28b6b
9 changed files with 475 additions and 11 deletions

View file

@ -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<void>>();
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<string[]> {
return [];
}
async loadBookConfig(): Promise<never> {
throw new Error("not implemented");
}
async loadChapterIndex(): Promise<[]> {
return [];
}
async getNextChapterNumber(): Promise<number> {
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<void> {
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<void>((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?.();
});
});

View file

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

View file

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

View file

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

View file

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

View file

@ -9,6 +9,7 @@ export interface SSEMessage {
export const STUDIO_SSE_EVENTS = [
"book:creating",
"book:created",
"book:deleted",
"book:error",
"write:start",
"write:complete",

View file

@ -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;
<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 className="text-foreground">Daemon</span>
<span className="text-foreground">{t("nav.daemon")}</span>
</div>
<div className="flex items-baseline justify-between">
<h1 className="font-serif text-3xl">Daemon</h1>
<h1 className="font-serif text-3xl">{t("daemon.title")}</h1>
<div className="flex items-center gap-3">
<span className={`text-sm uppercase tracking-wide font-medium ${isRunning ? "text-emerald-500" : "text-muted-foreground"}`}>
{isRunning ? "Running" : "Stopped"}
{isRunning ? t("daemon.running") : t("daemon.stopped")}
</span>
{isRunning ? (
<button
@ -64,7 +71,7 @@ export function DaemonControl({ nav, theme, t, sse }: { nav: Nav; theme: Theme;
disabled={loading}
className={`px-4 py-2.5 text-sm rounded-md ${c.btnDanger} disabled:opacity-50`}
>
{loading ? "Stopping..." : "Stop"}
{loading ? t("daemon.stopping") : t("daemon.stop")}
</button>
) : (
<button
@ -72,7 +79,7 @@ export function DaemonControl({ nav, theme, t, sse }: { nav: Nav; theme: Theme;
disabled={loading}
className={`px-4 py-2.5 text-sm rounded-md ${c.btnPrimary} disabled:opacity-50`}
>
{loading ? "Starting..." : "Start"}
{loading ? t("daemon.starting") : t("daemon.start")}
</button>
)}
</div>
@ -92,14 +99,14 @@ export function DaemonControl({ nav, theme, t, sse }: { nav: Nav; theme: Theme;
<div key={i} className="leading-relaxed text-muted-foreground">
<span className="text-primary/50">{msg.event}</span>
<span className="text-border mx-1.5"></span>
<span>{d.message ?? d.bookId ?? JSON.stringify(d)}</span>
<span>{String(d.message ?? d.bookId ?? JSON.stringify(d))}</span>
</div>
);
})}
</div>
) : (
<div className="text-muted-foreground text-sm italic py-8 text-center">
{isRunning ? "Waiting for events..." : "Start the daemon to see events"}
{isRunning ? t("daemon.waitingEvents") : t("daemon.startHint")}
</div>
)}
</div>

View file

@ -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<string>;
readonly rhetoricalFeatures: ReadonlyArray<string>;
}
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<StyleProfile | null>(null);
const [loading, setLoading] = useState(false);
const [analyzeStatus, setAnalyzeStatus] = useState("");
const [importBookId, setImportBookId] = useState("");
const [importStatus, setImportStatus] = useState("");
const { data: booksData } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
const statusNotice = buildStyleStatusNotice(analyzeStatus, importStatus);
const handleAnalyze = async () => {
if (!text.trim()) return;
setLoading(true);
setProfile(null);
setAnalyzeStatus("");
try {
const data = await fetchJson<StyleProfile>("/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 (
<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.style")}</span>
</div>
<h1 className="font-serif text-3xl flex items-center gap-3">
<Wand2 size={28} className="text-primary" />
{t("style.title")}
</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Input */}
<div className="space-y-4">
<div>
<label className="text-xs font-bold uppercase tracking-wider text-muted-foreground block mb-2">{t("style.sourceName")}</label>
<input
type="text"
value={sourceName}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs font-bold uppercase tracking-wider text-muted-foreground block mb-2">{t("style.textSample")}</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={12}
placeholder={t("style.pasteHint")}
className="w-full px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm focus:outline-none focus:border-primary resize-none font-mono"
/>
</div>
<div className="flex gap-3">
<button
onClick={handleAnalyze}
disabled={!text.trim() || loading}
className={`px-4 py-2 text-sm rounded-lg ${c.btnPrimary} disabled:opacity-30 flex items-center gap-2`}
>
<BarChart3 size={14} />
{loading ? t("style.analyzing") : t("style.analyze")}
</button>
</div>
</div>
{/* Results */}
<div className="space-y-4">
{profile && (
<div className={`border ${c.cardStatic} rounded-lg p-5 space-y-4`}>
<h3 className="font-semibold text-sm uppercase tracking-wider text-muted-foreground">{t("style.results")}</h3>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="bg-secondary/30 rounded-lg p-3">
<div className="text-muted-foreground text-xs">{t("style.avgSentence")}</div>
<div className="text-xl font-bold">{profile.avgSentenceLength.toFixed(1)}</div>
</div>
<div className="bg-secondary/30 rounded-lg p-3">
<div className="text-muted-foreground text-xs">{t("style.vocabDiversity")}</div>
<div className="text-xl font-bold">{(profile.vocabularyDiversity * 100).toFixed(0)}%</div>
</div>
<div className="bg-secondary/30 rounded-lg p-3">
<div className="text-muted-foreground text-xs">{t("style.avgParagraph")}</div>
<div className="text-xl font-bold">{profile.avgParagraphLength.toFixed(0)}</div>
</div>
<div className="bg-secondary/30 rounded-lg p-3">
<div className="text-muted-foreground text-xs">{t("style.sentenceStdDev")}</div>
<div className="text-xl font-bold">{profile.sentenceLengthStdDev.toFixed(1)}</div>
</div>
</div>
{profile.topPatterns.length > 0 && (
<div>
<div className="text-xs text-muted-foreground uppercase tracking-wide mb-2">{t("style.topPatterns")}</div>
<div className="flex gap-2 flex-wrap">
{profile.topPatterns.map((p) => (
<span key={p} className="px-2 py-1 text-xs bg-secondary rounded">{p}</span>
))}
</div>
</div>
)}
{profile.rhetoricalFeatures.length > 0 && (
<div>
<div className="text-xs text-muted-foreground uppercase tracking-wide mb-2">{t("style.rhetoricalFeatures")}</div>
<div className="flex gap-2 flex-wrap">
{profile.rhetoricalFeatures.map((f) => (
<span key={f} className="px-2 py-1 text-xs bg-primary/10 text-primary rounded">{f}</span>
))}
</div>
</div>
)}
{/* Import to book */}
<div className="border-t border-border pt-4 mt-4 space-y-3">
<h4 className="font-semibold text-sm flex items-center gap-2">
<Upload size={14} />
{t("style.importToBook")}
</h4>
<select
value={importBookId}
onChange={(e) => setImportBookId(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm"
>
<option value="">{t("style.selectBook")}</option>
{booksData?.books.map((b) => (
<option key={b.id} value={b.id}>{b.title}</option>
))}
</select>
<button
onClick={handleImport}
disabled={!importBookId}
className={`px-4 py-2 text-sm rounded-lg ${c.btnSecondary} disabled:opacity-30`}
>
{t("style.importGuide")}
</button>
{importStatus && <div className="text-xs text-muted-foreground">{importStatus}</div>}
</div>
</div>
)}
{!profile && !loading && (
<div className={`border border-dashed ${c.cardStatic} rounded-lg p-8 text-center text-muted-foreground text-sm italic`}>
{t("style.emptyHint")}
</div>
)}
</div>
</div>
{statusNotice && (
<div
className={`px-4 py-3 rounded-lg text-sm ${
statusNotice.tone === "error"
? "bg-destructive/10 text-destructive"
: statusNotice.tone === "info"
? "bg-secondary text-muted-foreground"
: "bg-emerald-500/10 text-emerald-600"
}`}
>
{statusNotice.message}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { buildStyleStatusNotice } from "./StyleManager";
describe("buildStyleStatusNotice", () => {
it("surfaces analyze errors even when no profile is available yet", () => {
expect(buildStyleStatusNotice("Error: analyze failed", "")).toEqual({
tone: "error",
message: "Error: analyze failed",
});
});
it("falls back to import status when there is no analyze error", () => {
expect(buildStyleStatusNotice("", "Style guide imported successfully!")).toEqual({
tone: "success",
message: "Style guide imported successfully!",
});
});
it("returns null when there is nothing to show", () => {
expect(buildStyleStatusNotice("", "")).toBeNull();
});
});