mirror of
https://github.com/Narcooo/inkos
synced 2026-04-21 14:37:16 +00:00
fix(studio): unblock daemon start and surface status feedback
This commit is contained in:
parent
44a3a1fbda
commit
9ef1e28b6b
9 changed files with 475 additions and 11 deletions
141
packages/studio/src/api/server.test.ts
Normal file
141
packages/studio/src/api/server.test.ts
Normal 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?.();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface SSEMessage {
|
|||
export const STUDIO_SSE_EVENTS = [
|
||||
"book:creating",
|
||||
"book:created",
|
||||
"book:deleted",
|
||||
"book:error",
|
||||
"write:start",
|
||||
"write:complete",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
225
packages/studio/src/pages/StyleManager.tsx
Normal file
225
packages/studio/src/pages/StyleManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
packages/studio/src/pages/style-manager-state.test.ts
Normal file
22
packages/studio/src/pages/style-manager-state.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue