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

This commit is contained in:
Ma
2026-03-30 12:38:07 +08:00
parent e1af64054c
commit 67197d322e
9 changed files with 215 additions and 39 deletions

View File

@@ -16,7 +16,7 @@ import { LanguageSelector } from "./pages/LanguageSelector";
import { useSSE } from "./hooks/use-sse";
import { useTheme } from "./hooks/use-theme";
import { useI18n } from "./hooks/use-i18n";
import { useApi } from "./hooks/use-api";
import { postApi, useApi } from "./hooks/use-api";
type Route =
| { page: "dashboard" }
@@ -75,6 +75,10 @@ export function App() {
route.page === "book" || route.page === "chapter" || route.page === "truth" || route.page === "analytics"
? `book:${(route as { bookId: string }).bookId}`
: route.page;
const activeBookId =
route.page === "book" || route.page === "chapter" || route.page === "truth" || route.page === "analytics"
? route.bookId
: undefined;
if (!ready) {
return <div className="min-h-screen bg-background" />;
@@ -84,11 +88,7 @@ export function App() {
return (
<LanguageSelector
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);
refetchProject();
}}
@@ -134,7 +134,7 @@ export function App() {
</div>
{/* Chat bar — inset to match content width */}
<ChatBar t={t} sse={sse} />
<ChatBar t={t} sse={sse} activeBookId={activeBookId} />
</div>
</div>
);

View File

@@ -1,5 +1,5 @@
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 { tmpdir } from "node:os";
@@ -98,11 +98,16 @@ const projectConfig = {
notify: [],
} as const;
function cloneProjectConfig() {
return structuredClone(projectConfig);
}
describe("createStudioServer daemon lifecycle", () => {
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "inkos-studio-server-"));
await writeFile(join(root, "inkos.json"), JSON.stringify(projectConfig, null, 2), "utf-8");
schedulerStartMock.mockReset();
});
@@ -120,7 +125,7 @@ describe("createStudioServer daemon lifecycle", () => {
);
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([
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 () => {
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", {
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,
});
});
});

View File

@@ -372,10 +372,22 @@ export function createStudioServer(config: ProjectConfig, root: string) {
const raw = await readFile(configPath, "utf-8");
const existing = JSON.parse(raw);
// 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");
await writeFileFs(configPath, JSON.stringify(existing, null, 2), "utf-8");
return c.json({ ok: true });
@@ -516,6 +528,7 @@ export function createStudioServer(config: ProjectConfig, root: string) {
const raw = await readFile(configPath, "utf-8");
const existing = JSON.parse(raw);
existing.language = language;
config.language = language;
const { writeFile: writeFileFs } = await import("node:fs/promises");
await writeFileFs(configPath, JSON.stringify(existing, null, 2), "utf-8");
return c.json({ ok: true, language });

View File

@@ -8,9 +8,30 @@ interface ChatMessage {
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;
sse: { messages: ReadonlyArray<SSEMessage>; connected: boolean };
activeBookId?: string;
}) {
const [input, setInput] = useState("");
const [expanded, setExpanded] = useState(false);
@@ -79,15 +100,30 @@ export function ChatBar({ t, sse }: {
try {
if (lower.match(/^(写下一章|write next)/)) {
// Extract book id from context or use first book
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;
}
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

View File

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

View File

@@ -37,6 +37,17 @@ describe("fetchJson", () => {
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", () => {
@@ -59,4 +70,9 @@ describe("deriveInvalidationPaths", () => {
expect(deriveInvalidationPaths("/daemon/start")).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"]);
});
});

View File

@@ -24,6 +24,14 @@ export function deriveInvalidationPaths(path: string): ReadonlyArray<string> {
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)$/);
if (bookAction) {
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()) {
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 {
// fall through
}
@@ -116,10 +133,8 @@ export function useApi<T>(path: string) {
setLoading(true);
setError(null);
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) {
setError(e instanceof Error ? e.message : String(e));
} finally {
@@ -161,3 +176,13 @@ export async function postApi<T>(path: string, body?: unknown): Promise<T> {
invalidateApiPaths(deriveInvalidationPaths(path));
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;
}

View File

@@ -1,10 +1,10 @@
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 { TFunction } from "../hooks/use-i18n";
import { useColors } from "../hooks/use-colors";
import type { SSEMessage } from "../hooks/use-sse";
import { shouldRefetchBookView } from "../hooks/use-book-activity";
import { deriveBookActivity, shouldRefetchBookView } from "../hooks/use-book-activity";
interface ChapterMeta {
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 [writing, setWriting] = 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(() => {
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">
<button
onClick={handleWriteNext}
disabled={writing || drafting}
disabled={writePending || draftPending}
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
onClick={handleDraft}
disabled={writing || drafting}
disabled={writePending || draftPending}
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>
{reviewCount > 0 && (
<button

View File

@@ -1,4 +1,4 @@
import { useApi, postApi } from "../hooks/use-api";
import { useApi, putApi } from "../hooks/use-api";
import { useState } from "react";
import type { Theme } from "../hooks/use-theme";
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 }) {
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 [saving, setSaving] = useState(false);
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 () => {
setSaving(true);
try {
await fetch("/api/project", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
await putApi("/project", form);
setEditing(false);
refetch();
} catch (e) {
alert(e instanceof Error ? e.message : "Failed");
} finally {