فهرست منبع

fix(studio): keep config and book actions in sync

Ma 7 ماه پیش
والد
کامیت
67197d322e

+ 7 - 7
packages/studio/src/App.tsx

@@ -16,7 +16,7 @@ import { LanguageSelector } from "./pages/LanguageSelector";
 import { useSSE } from "./hooks/use-sse";
 import { useSSE } from "./hooks/use-sse";
 import { useTheme } from "./hooks/use-theme";
 import { useTheme } from "./hooks/use-theme";
 import { useI18n } from "./hooks/use-i18n";
 import { useI18n } from "./hooks/use-i18n";
-import { useApi } from "./hooks/use-api";
+import { postApi, useApi } from "./hooks/use-api";
 
 
 type Route =
 type Route =
   | { page: "dashboard" }
   | { page: "dashboard" }
@@ -75,6 +75,10 @@ export function App() {
     route.page === "book" || route.page === "chapter" || route.page === "truth" || route.page === "analytics"
     route.page === "book" || route.page === "chapter" || route.page === "truth" || route.page === "analytics"
       ? `book:${(route as { bookId: string }).bookId}`
       ? `book:${(route as { bookId: string }).bookId}`
       : route.page;
       : route.page;
+  const activeBookId =
+    route.page === "book" || route.page === "chapter" || route.page === "truth" || route.page === "analytics"
+      ? route.bookId
+      : undefined;
 
 
   if (!ready) {
   if (!ready) {
     return <div className="min-h-screen bg-background" />;
     return <div className="min-h-screen bg-background" />;
@@ -84,11 +88,7 @@ export function App() {
     return (
     return (
       <LanguageSelector
       <LanguageSelector
         onSelect={async (lang) => {
         onSelect={async (lang) => {
-          await fetch("/api/project/language", {
-            method: "POST",
-            headers: { "Content-Type": "application/json" },
-            body: JSON.stringify({ language: lang }),
-          });
+          await postApi("/project/language", { language: lang });
           setShowLanguageSelector(false);
           setShowLanguageSelector(false);
           refetchProject();
           refetchProject();
         }}
         }}
@@ -134,7 +134,7 @@ export function App() {
         </div>
         </div>
 
 
         {/* Chat bar — inset to match content width */}
         {/* Chat bar — inset to match content width */}
-        <ChatBar t={t} sse={sse} />
+        <ChatBar t={t} sse={sse} activeBookId={activeBookId} />
       </div>
       </div>
     </div>
     </div>
   );
   );

+ 53 - 3
packages/studio/src/api/server.test.ts

@@ -1,5 +1,5 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { mkdtemp, rm } from "node:fs/promises";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
 import { join } from "node:path";
 import { join } from "node:path";
 import { tmpdir } from "node:os";
 import { tmpdir } from "node:os";
 
 
@@ -98,11 +98,16 @@ const projectConfig = {
   notify: [],
   notify: [],
 } as const;
 } as const;
 
 
+function cloneProjectConfig() {
+  return structuredClone(projectConfig);
+}
+
 describe("createStudioServer daemon lifecycle", () => {
 describe("createStudioServer daemon lifecycle", () => {
   let root: string;
   let root: string;
 
 
   beforeEach(async () => {
   beforeEach(async () => {
     root = await mkdtemp(join(tmpdir(), "inkos-studio-server-"));
     root = await mkdtemp(join(tmpdir(), "inkos-studio-server-"));
+    await writeFile(join(root, "inkos.json"), JSON.stringify(projectConfig, null, 2), "utf-8");
     schedulerStartMock.mockReset();
     schedulerStartMock.mockReset();
   });
   });
 
 
@@ -120,7 +125,7 @@ describe("createStudioServer daemon lifecycle", () => {
     );
     );
 
 
     const { createStudioServer } = await import("./server.js");
     const { createStudioServer } = await import("./server.js");
-    const app = createStudioServer(projectConfig as never, root);
+    const app = createStudioServer(cloneProjectConfig() as never, root);
 
 
     const responseOrTimeout = await Promise.race([
     const responseOrTimeout = await Promise.race([
       app.request("http://localhost/api/daemon/start", { method: "POST" }),
       app.request("http://localhost/api/daemon/start", { method: "POST" }),
@@ -141,7 +146,7 @@ describe("createStudioServer daemon lifecycle", () => {
 
 
   it("rejects book routes with path traversal ids", async () => {
   it("rejects book routes with path traversal ids", async () => {
     const { createStudioServer } = await import("./server.js");
     const { createStudioServer } = await import("./server.js");
-    const app = createStudioServer(projectConfig as never, root);
+    const app = createStudioServer(cloneProjectConfig() as never, root);
 
 
     const response = await app.request("http://localhost/api/books/..%2Fetc%2Fpasswd", {
     const response = await app.request("http://localhost/api/books/..%2Fetc%2Fpasswd", {
       method: "GET",
       method: "GET",
@@ -155,4 +160,49 @@ describe("createStudioServer daemon lifecycle", () => {
       },
       },
     });
     });
   });
   });
+
+  it("reflects project edits immediately without restarting the studio server", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const save = await app.request("http://localhost/api/project", {
+      method: "PUT",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        language: "en",
+        temperature: 0.2,
+        maxTokens: 2048,
+        stream: true,
+      }),
+    });
+
+    expect(save.status).toBe(200);
+
+    const project = await app.request("http://localhost/api/project");
+    await expect(project.json()).resolves.toMatchObject({
+      language: "en",
+      temperature: 0.2,
+      maxTokens: 2048,
+      stream: true,
+    });
+  });
+
+  it("updates the first-run language immediately after the language selector saves", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const save = await app.request("http://localhost/api/project/language", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ language: "en" }),
+    });
+
+    expect(save.status).toBe(200);
+
+    const project = await app.request("http://localhost/api/project");
+    await expect(project.json()).resolves.toMatchObject({
+      language: "en",
+      languageExplicit: true,
+    });
+  });
 });
 });

+ 17 - 4
packages/studio/src/api/server.ts

@@ -372,10 +372,22 @@ export function createStudioServer(config: ProjectConfig, root: string) {
       const raw = await readFile(configPath, "utf-8");
       const raw = await readFile(configPath, "utf-8");
       const existing = JSON.parse(raw);
       const existing = JSON.parse(raw);
       // Merge LLM settings
       // Merge LLM settings
-      if (updates.temperature !== undefined) existing.llm.temperature = updates.temperature;
-      if (updates.maxTokens !== undefined) existing.llm.maxTokens = updates.maxTokens;
-      if (updates.stream !== undefined) existing.llm.stream = updates.stream;
-      if (updates.language !== undefined) existing.language = updates.language;
+      if (updates.temperature !== undefined) {
+        existing.llm.temperature = updates.temperature;
+        config.llm.temperature = Number(updates.temperature);
+      }
+      if (updates.maxTokens !== undefined) {
+        existing.llm.maxTokens = updates.maxTokens;
+        config.llm.maxTokens = Number(updates.maxTokens);
+      }
+      if (updates.stream !== undefined) {
+        existing.llm.stream = updates.stream;
+        config.llm.stream = Boolean(updates.stream);
+      }
+      if (updates.language === "zh" || updates.language === "en") {
+        existing.language = updates.language;
+        config.language = updates.language;
+      }
       const { writeFile: writeFileFs } = await import("node:fs/promises");
       const { writeFile: writeFileFs } = await import("node:fs/promises");
       await writeFileFs(configPath, JSON.stringify(existing, null, 2), "utf-8");
       await writeFileFs(configPath, JSON.stringify(existing, null, 2), "utf-8");
       return c.json({ ok: true });
       return c.json({ ok: true });
@@ -516,6 +528,7 @@ export function createStudioServer(config: ProjectConfig, root: string) {
       const raw = await readFile(configPath, "utf-8");
       const raw = await readFile(configPath, "utf-8");
       const existing = JSON.parse(raw);
       const existing = JSON.parse(raw);
       existing.language = language;
       existing.language = language;
+      config.language = language;
       const { writeFile: writeFileFs } = await import("node:fs/promises");
       const { writeFile: writeFileFs } = await import("node:fs/promises");
       await writeFileFs(configPath, JSON.stringify(existing, null, 2), "utf-8");
       await writeFileFs(configPath, JSON.stringify(existing, null, 2), "utf-8");
       return c.json({ ok: true, language });
       return c.json({ ok: true, language });

+ 43 - 7
packages/studio/src/components/ChatBar.tsx

@@ -8,9 +8,30 @@ interface ChatMessage {
   readonly timestamp: number;
   readonly timestamp: number;
 }
 }
 
 
-export function ChatBar({ t, sse }: {
+interface BookRef {
+  readonly id: string;
+}
+
+export function resolveDirectWriteTarget(
+  activeBookId: string | undefined,
+  books: ReadonlyArray<BookRef>,
+): { bookId: string | null; reason: "active" | "single" | "missing" | "ambiguous" } {
+  if (activeBookId && books.some((book) => book.id === activeBookId)) {
+    return { bookId: activeBookId, reason: "active" };
+  }
+  if (books.length === 1) {
+    return { bookId: books[0]!.id, reason: "single" };
+  }
+  if (books.length === 0) {
+    return { bookId: null, reason: "missing" };
+  }
+  return { bookId: null, reason: "ambiguous" };
+}
+
+export function ChatBar({ t, sse, activeBookId }: {
   t: TFunction;
   t: TFunction;
   sse: { messages: ReadonlyArray<SSEMessage>; connected: boolean };
   sse: { messages: ReadonlyArray<SSEMessage>; connected: boolean };
+  activeBookId?: string;
 }) {
 }) {
   const [input, setInput] = useState("");
   const [input, setInput] = useState("");
   const [expanded, setExpanded] = useState(false);
   const [expanded, setExpanded] = useState(false);
@@ -79,15 +100,30 @@ export function ChatBar({ t, sse }: {
 
 
     try {
     try {
       if (lower.match(/^(写下一章|write next)/)) {
       if (lower.match(/^(写下一章|write next)/)) {
-        // Extract book id from context or use first book
         const res = await fetch("/api/books");
         const res = await fetch("/api/books");
-        const { books } = await res.json() as { books: ReadonlyArray<{ id: string }> };
-        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
+        const { books } = await res.json() as { books: ReadonlyArray<BookRef> };
+        const target = resolveDirectWriteTarget(activeBookId, books);
+
+        if (target.bookId) {
+          setMessages((prev) => [...prev, {
+            role: "assistant",
+            content: isZh ? `⋯ 开始处理《${target.bookId}》...` : `⋯ Starting ${target.bookId}...`,
+            timestamp: Date.now(),
+          }]);
+          await fetch(`/api/books/${target.bookId}/write-next`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
           return;
           return;
         }
         }
+
+        setLoading(false);
+        setMessages((prev) => [...prev, {
+          role: "assistant",
+          content:
+            target.reason === "missing"
+              ? (isZh ? "✗ 还没有书,先创建一本再写。" : "✗ No books yet. Create one first.")
+              : (isZh ? "✗ 当前有多本书,请先打开目标书籍后再执行“写下一章”。" : '✗ Multiple books found. Open the target book first, then run "write next".'),
+          timestamp: Date.now(),
+        }]);
+        return;
       }
       }
 
 
       // Fallback: send to agent API
       // Fallback: send to agent API

+ 38 - 0
packages/studio/src/components/chatbar-state.test.ts

@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import { resolveDirectWriteTarget } from "./ChatBar";
+
+describe("resolveDirectWriteTarget", () => {
+  it("prefers the active book when the user is already inside a book flow", () => {
+    expect(resolveDirectWriteTarget("beta", [
+      { id: "alpha" },
+      { id: "beta" },
+    ])).toEqual({
+      bookId: "beta",
+      reason: "active",
+    });
+  });
+
+  it("falls back to the only book when there is no active context", () => {
+    expect(resolveDirectWriteTarget(undefined, [{ id: "solo" }])).toEqual({
+      bookId: "solo",
+      reason: "single",
+    });
+  });
+
+  it("reports when there is no available target book", () => {
+    expect(resolveDirectWriteTarget(undefined, [])).toEqual({
+      bookId: null,
+      reason: "missing",
+    });
+  });
+
+  it("does not guess when multiple books exist without an active context", () => {
+    expect(resolveDirectWriteTarget(undefined, [
+      { id: "alpha" },
+      { id: "beta" },
+    ])).toEqual({
+      bookId: null,
+      reason: "ambiguous",
+    });
+  });
+});

+ 16 - 0
packages/studio/src/hooks/use-api.test.ts

@@ -37,6 +37,17 @@ describe("fetchJson", () => {
 
 
     await expect(fetchJson("/books", {}, { fetchImpl })).rejects.toThrow("500 Internal Server Error");
     await expect(fetchJson("/books", {}, { fetchImpl })).rejects.toThrow("500 Internal Server Error");
   });
   });
+
+  it("surfaces nested api error messages from structured error payloads", async () => {
+    const fetchImpl = vi.fn(async () =>
+      new Response(JSON.stringify({ error: { code: "INVALID_BOOK_ID", message: "Invalid book ID: ../bad" } }), {
+        status: 400,
+        headers: { "Content-Type": "application/json" },
+      }),
+    );
+
+    await expect(fetchJson("/books/../bad", {}, { fetchImpl })).rejects.toThrow("Invalid book ID: ../bad");
+  });
 });
 });
 
 
 describe("deriveInvalidationPaths", () => {
 describe("deriveInvalidationPaths", () => {
@@ -59,4 +70,9 @@ describe("deriveInvalidationPaths", () => {
     expect(deriveInvalidationPaths("/daemon/start")).toEqual(["/api/daemon"]);
     expect(deriveInvalidationPaths("/daemon/start")).toEqual(["/api/daemon"]);
     expect(deriveInvalidationPaths("/daemon/stop")).toEqual(["/api/daemon"]);
     expect(deriveInvalidationPaths("/daemon/stop")).toEqual(["/api/daemon"]);
   });
   });
+
+  it("refreshes project data after project mutations", () => {
+    expect(deriveInvalidationPaths("/project")).toEqual(["/api/project"]);
+    expect(deriveInvalidationPaths("/project/language")).toEqual(["/api/project", "/api/project/language"]);
+  });
 });
 });

+ 29 - 4
packages/studio/src/hooks/use-api.ts

@@ -24,6 +24,14 @@ export function deriveInvalidationPaths(path: string): ReadonlyArray<string> {
     return ["/api/books"];
     return ["/api/books"];
   }
   }
 
 
+  if (normalized === "/api/project") {
+    return ["/api/project"];
+  }
+
+  if (normalized.startsWith("/api/project/")) {
+    return ["/api/project", normalized];
+  }
+
   const bookAction = normalized.match(/^\/api\/books\/([^/]+)\/(write-next|draft)$/);
   const bookAction = normalized.match(/^\/api\/books\/([^/]+)\/(write-next|draft)$/);
   if (bookAction) {
   if (bookAction) {
     return ["/api/books", `/api/books/${bookAction[1]}`];
     return ["/api/books", `/api/books/${bookAction[1]}`];
@@ -59,6 +67,15 @@ async function readErrorMessage(res: Response): Promise<string> {
       if (typeof json.error === "string" && json.error.trim()) {
       if (typeof json.error === "string" && json.error.trim()) {
         return json.error;
         return json.error;
       }
       }
+      if (
+        json.error &&
+        typeof json.error === "object" &&
+        "message" in json.error &&
+        typeof (json.error as { message?: unknown }).message === "string" &&
+        (json.error as { message: string }).message.trim()
+      ) {
+        return (json.error as { message: string }).message;
+      }
     } catch {
     } catch {
       // fall through
       // fall through
     }
     }
@@ -116,10 +133,8 @@ export function useApi<T>(path: string) {
     setLoading(true);
     setLoading(true);
     setError(null);
     setError(null);
     try {
     try {
-      const res = await fetch(url);
-      if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
-      const json = await res.json();
-      setData(json as T);
+      const json = await fetchJson<T>(url);
+      setData(json);
     } catch (e) {
     } catch (e) {
       setError(e instanceof Error ? e.message : String(e));
       setError(e instanceof Error ? e.message : String(e));
     } finally {
     } finally {
@@ -161,3 +176,13 @@ export async function postApi<T>(path: string, body?: unknown): Promise<T> {
   invalidateApiPaths(deriveInvalidationPaths(path));
   invalidateApiPaths(deriveInvalidationPaths(path));
   return result;
   return result;
 }
 }
+
+export async function putApi<T>(path: string, body?: unknown): Promise<T> {
+  const result = await fetchJson<T>(path, {
+    method: "PUT",
+    headers: { "Content-Type": "application/json" },
+    body: body ? JSON.stringify(body) : undefined,
+  });
+  invalidateApiPaths(deriveInvalidationPaths(path));
+  return result;
+}

+ 9 - 6
packages/studio/src/pages/BookDetail.tsx

@@ -1,10 +1,10 @@
 import { useApi, postApi } from "../hooks/use-api";
 import { useApi, postApi } from "../hooks/use-api";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, 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 { shouldRefetchBookView } from "../hooks/use-book-activity";
+import { deriveBookActivity, shouldRefetchBookView } from "../hooks/use-book-activity";
 
 
 interface ChapterMeta {
 interface ChapterMeta {
   readonly number: number;
   readonly number: number;
@@ -46,6 +46,9 @@ export function BookDetail({ bookId, nav, theme, t, sse }: { bookId: string; nav
   const { data, loading, error, refetch } = useApi<BookData>(`/books/${bookId}`);
   const { data, loading, error, refetch } = useApi<BookData>(`/books/${bookId}`);
   const [writing, setWriting] = useState(false);
   const [writing, setWriting] = useState(false);
   const [drafting, setDrafting] = useState(false);
   const [drafting, setDrafting] = useState(false);
+  const activity = useMemo(() => deriveBookActivity(sse.messages, bookId), [bookId, sse.messages]);
+  const writePending = writing || activity.writing;
+  const draftPending = drafting || activity.drafting;
 
 
   useEffect(() => {
   useEffect(() => {
     const recent = sse.messages.at(-1);
     const recent = sse.messages.at(-1);
@@ -117,17 +120,17 @@ export function BookDetail({ bookId, nav, theme, t, sse }: { bookId: string; nav
         <div className="flex gap-2">
         <div className="flex gap-2">
           <button
           <button
             onClick={handleWriteNext}
             onClick={handleWriteNext}
-            disabled={writing || drafting}
+            disabled={writePending || draftPending}
             className={`px-4 py-2 text-sm ${c.btnPrimary} rounded-md transition-colors disabled:opacity-50`}
             className={`px-4 py-2 text-sm ${c.btnPrimary} rounded-md transition-colors disabled:opacity-50`}
           >
           >
-            {writing ? "Writing..." : t("book.writeNext")}
+            {writePending ? "Writing..." : t("book.writeNext")}
           </button>
           </button>
           <button
           <button
             onClick={handleDraft}
             onClick={handleDraft}
-            disabled={writing || drafting}
+            disabled={writePending || draftPending}
             className={`px-4 py-2 text-sm ${c.btnSecondary} rounded-md transition-colors disabled:opacity-50`}
             className={`px-4 py-2 text-sm ${c.btnSecondary} rounded-md transition-colors disabled:opacity-50`}
           >
           >
-            {drafting ? "Drafting..." : t("book.draftOnly")}
+            {draftPending ? "Drafting..." : t("book.draftOnly")}
           </button>
           </button>
           {reviewCount > 0 && (
           {reviewCount > 0 && (
             <button
             <button

+ 3 - 8
packages/studio/src/pages/ConfigView.tsx

@@ -1,4 +1,4 @@
-import { useApi, postApi } from "../hooks/use-api";
+import { useApi, putApi } from "../hooks/use-api";
 import { useState } from "react";
 import { 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";
@@ -21,7 +21,7 @@ interface Nav {
 
 
 export function ConfigView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
 export function ConfigView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
   const c = useColors(theme);
   const c = useColors(theme);
-  const { data, loading, error, refetch } = useApi<ProjectInfo>("/project");
+  const { data, loading, error } = useApi<ProjectInfo>("/project");
   const [editing, setEditing] = useState(false);
   const [editing, setEditing] = useState(false);
   const [saving, setSaving] = useState(false);
   const [saving, setSaving] = useState(false);
   const [form, setForm] = useState<Record<string, unknown>>({});
   const [form, setForm] = useState<Record<string, unknown>>({});
@@ -43,13 +43,8 @@ export function ConfigView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
   const handleSave = async () => {
   const handleSave = async () => {
     setSaving(true);
     setSaving(true);
     try {
     try {
-      await fetch("/api/project", {
-        method: "PUT",
-        headers: { "Content-Type": "application/json" },
-        body: JSON.stringify(form),
-      });
+      await putApi("/project", form);
       setEditing(false);
       setEditing(false);
-      refetch();
     } catch (e) {
     } catch (e) {
       alert(e instanceof Error ? e.message : "Failed");
       alert(e instanceof Error ? e.message : "Failed");
     } finally {
     } finally {