Ver código fonte

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

Ma 7 meses atrás
pai
commit
9ef1e28b6b

+ 141 - 0
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<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?.();
+  });
+});

+ 12 - 2
packages/studio/src/api/server.ts

@@ -421,7 +421,7 @@ export function createStudioServer(config: ProjectConfig, root: string) {
     }
     }
     try {
     try {
       const { Scheduler } = await import("@actalk/inkos-core");
       const { Scheduler } = await import("@actalk/inkos-core");
-      schedulerInstance = new Scheduler({
+      const scheduler = new Scheduler({
         ...buildPipelineConfig(),
         ...buildPipelineConfig(),
         radarCron: config.daemon.schedule.radarCron,
         radarCron: config.daemon.schedule.radarCron,
         writeCron: config.daemon.schedule.writeCron,
         writeCron: config.daemon.schedule.writeCron,
@@ -437,8 +437,17 @@ export function createStudioServer(config: ProjectConfig, root: string) {
           broadcast("daemon:error", { bookId, error: error.message });
           broadcast("daemon:error", { bookId, error: error.message });
         },
         },
       });
       });
-      await schedulerInstance.start();
+      schedulerInstance = scheduler;
       broadcast("daemon:started", {});
       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 });
       return c.json({ ok: true, running: true });
     } catch (e) {
     } catch (e) {
       return c.json({ error: String(e) }, 500);
       return c.json({ error: String(e) }, 500);
@@ -833,6 +842,7 @@ export function createStudioServer(config: ProjectConfig, root: string) {
     try {
     try {
       const { rm } = await import("node:fs/promises");
       const { rm } = await import("node:fs/promises");
       await rm(bookDir, { recursive: true, force: true });
       await rm(bookDir, { recursive: true, force: true });
+      broadcast("book:deleted", { bookId: id });
       return c.json({ ok: true, bookId: id });
       return c.json({ ok: true, bookId: id });
     } catch (e) {
     } catch (e) {
       return c.json({ error: String(e) }, 500);
       return c.json({ error: String(e) }, 500);

+ 28 - 1
packages/studio/src/hooks/use-book-activity.test.ts

@@ -1,6 +1,12 @@
 import { describe, expect, it } from "vitest";
 import { describe, expect, it } from "vitest";
 import type { SSEMessage } from "./use-sse";
 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 {
 function msg(event: string, data: unknown, timestamp: number): SSEMessage {
   return { event, data, timestamp };
   return { event, data, timestamp };
@@ -79,3 +85,24 @@ describe("shouldRefetchBookView", () => {
     expect(shouldRefetchBookView(msg("rewrite:complete", { bookId: "beta" }, 1), "alpha")).toBe(false);
     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);
+  });
+});

+ 30 - 0
packages/studio/src/hooks/use-book-activity.ts

@@ -15,6 +15,28 @@ const BOOK_REFRESH_EVENTS = new Set([
   "audit:error",
   "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 {
 export interface BookActivity {
   readonly writing: boolean;
   readonly writing: boolean;
   readonly drafting: boolean;
   readonly drafting: boolean;
@@ -92,3 +114,11 @@ export function deriveBookActivity(messages: ReadonlyArray<SSEMessage>, bookId:
 export function shouldRefetchBookView(message: SSEMessage, bookId: string): boolean {
 export function shouldRefetchBookView(message: SSEMessage, bookId: string): boolean {
   return getBookId(message) === bookId && BOOK_REFRESH_EVENTS.has(message.event);
   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));
+}

+ 1 - 0
packages/studio/src/hooks/use-sse.test.ts

@@ -6,6 +6,7 @@ describe("STUDIO_SSE_EVENTS", () => {
     expect(STUDIO_SSE_EVENTS).toEqual(expect.arrayContaining([
     expect(STUDIO_SSE_EVENTS).toEqual(expect.arrayContaining([
       "book:creating",
       "book:creating",
       "book:created",
       "book:created",
+      "book:deleted",
       "book:error",
       "book:error",
       "write:start",
       "write:start",
       "write:complete",
       "write:complete",

+ 1 - 0
packages/studio/src/hooks/use-sse.ts

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

+ 15 - 8
packages/studio/src/pages/DaemonControl.tsx

@@ -1,9 +1,10 @@
 import { useApi, postApi } from "../hooks/use-api";
 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 { Theme } from "../hooks/use-theme";
 import type { TFunction } from "../hooks/use-i18n";
 import type { TFunction } from "../hooks/use-i18n";
 import { useColors } from "../hooks/use-colors";
 import { useColors } from "../hooks/use-colors";
 import type { SSEMessage } from "../hooks/use-sse";
 import type { SSEMessage } from "../hooks/use-sse";
+import { shouldRefetchDaemonStatus } from "../hooks/use-book-activity";
 
 
 interface Nav {
 interface Nav {
   toDashboard: () => void;
   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 { data, refetch } = useApi<{ running: boolean }>("/daemon");
   const [loading, setLoading] = useState(false);
   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
   const daemonEvents = sse.messages
     .filter((m) => m.event.startsWith("daemon:") || m.event === "log")
     .filter((m) => m.event.startsWith("daemon:") || m.event === "log")
     .slice(-20);
     .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">
       <div className="flex items-center gap-2 text-sm text-muted-foreground">
         <button onClick={nav.toDashboard} className={c.link}>{t("bread.home")}</button>
         <button onClick={nav.toDashboard} className={c.link}>{t("bread.home")}</button>
         <span className="text-border">/</span>
         <span className="text-border">/</span>
-        <span className="text-foreground">Daemon</span>
+        <span className="text-foreground">{t("nav.daemon")}</span>
       </div>
       </div>
 
 
       <div className="flex items-baseline justify-between">
       <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">
         <div className="flex items-center gap-3">
           <span className={`text-sm uppercase tracking-wide font-medium ${isRunning ? "text-emerald-500" : "text-muted-foreground"}`}>
           <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>
           </span>
           {isRunning ? (
           {isRunning ? (
             <button
             <button
@@ -64,7 +71,7 @@ export function DaemonControl({ nav, theme, t, sse }: { nav: Nav; theme: Theme;
               disabled={loading}
               disabled={loading}
               className={`px-4 py-2.5 text-sm rounded-md ${c.btnDanger} disabled:opacity-50`}
               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>
           ) : (
           ) : (
             <button
             <button
@@ -72,7 +79,7 @@ export function DaemonControl({ nav, theme, t, sse }: { nav: Nav; theme: Theme;
               disabled={loading}
               disabled={loading}
               className={`px-4 py-2.5 text-sm rounded-md ${c.btnPrimary} disabled:opacity-50`}
               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>
             </button>
           )}
           )}
         </div>
         </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">
                   <div key={i} className="leading-relaxed text-muted-foreground">
                     <span className="text-primary/50">{msg.event}</span>
                     <span className="text-primary/50">{msg.event}</span>
                     <span className="text-border mx-1.5">›</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>
             </div>
           ) : (
           ) : (
             <div className="text-muted-foreground text-sm italic py-8 text-center">
             <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>
           )}
           )}
         </div>
         </div>

+ 225 - 0
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<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 - 0
packages/studio/src/pages/style-manager-state.test.ts

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