mirror of
https://github.com/Narcooo/inkos.git
synced 2026-05-06 21:43:26 +08:00
fix(studio): keep config and book actions in sync
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
38
packages/studio/src/components/chatbar-state.test.ts
Normal file
38
packages/studio/src/components/chatbar-state.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user