fix(studio): extract public stability fixes from drama branch

This commit is contained in:
Ma
2026-06-10 19:31:44 +08:00
parent 756f54a8ec
commit fb6da5eb77
12 changed files with 204 additions and 77 deletions
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
// CLI integration tests spawn real child processes; the default 5s timeout
// is too aggressive when the whole workspace runs in parallel.
testTimeout: 60_000,
},
});
+3
View File
@@ -3,5 +3,8 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
// Some pipeline-runner tests can approach Vitest's default 5s timeout
// under full parallel runs; keep this high enough to avoid false kills.
testTimeout: 30_000,
},
});
+9 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { deriveActiveBookId, isBookCreateChatRoute } from "./App";
import { deriveActiveBookId, deriveStartupGate, isBookCreateChatRoute } from "./App";
describe("deriveActiveBookId", () => {
it("returns the current book across book-centered routes", () => {
@@ -23,3 +23,11 @@ describe("isBookCreateChatRoute", () => {
expect(isBookCreateChatRoute({ page: "book", bookId: "alpha" })).toBe(false);
});
});
describe("deriveStartupGate", () => {
it("shows startup errors instead of spinning forever before the project is ready", () => {
expect(deriveStartupGate({ ready: false, projectError: null })).toBe("loading");
expect(deriveStartupGate({ ready: false, projectError: "bad inkos.json" })).toBe("error");
expect(deriveStartupGate({ ready: true, projectError: "later refetch failed" })).toBe("ready");
});
});
+35 -2
View File
@@ -39,12 +39,20 @@ export function isBookCreateChatRoute(route: HashRoute): boolean {
return route.page === "book-create";
}
export function deriveStartupGate(input: {
readonly ready: boolean;
readonly projectError: string | null;
}): "ready" | "loading" | "error" {
if (input.ready) return "ready";
return input.projectError ? "error" : "loading";
}
export function App() {
const { route, setRoute } = useHashRoute();
const sse = useSSE();
const { theme, setTheme } = useTheme();
const { t, lang: currentLang } = useI18n();
const { data: project, refetch: refetchProject } = useApi<{ language: string; languageExplicit: boolean }>("/project");
const { data: project, error: projectError, refetch: refetchProject } = useApi<{ language: string; languageExplicit: boolean }>("/project");
const [showLanguageSelector, setShowLanguageSelector] = useState(false);
const [ready, setReady] = useState(false);
@@ -95,7 +103,32 @@ export function App() {
? "services"
: route.page;
if (!ready) {
const startupGate = deriveStartupGate({ ready, projectError });
if (startupGate === "error") {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-6">
<div className="max-w-md w-full rounded-2xl border border-destructive/30 bg-destructive/5 p-6 space-y-4">
<div>
<h1 className="text-lg font-semibold text-destructive"> / Failed to load project config</h1>
<p className="mt-2 text-sm text-muted-foreground break-all">{projectError}</p>
</div>
<p className="text-sm text-muted-foreground">
inkos.json JSON
</p>
<button
type="button"
onClick={() => refetchProject()}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
>
/ Retry
</button>
</div>
</div>
);
}
if (startupGate === "loading") {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
+13
View File
@@ -724,6 +724,19 @@ describe("createStudioServer daemon lifecycle", () => {
});
});
it("returns a structured config error when inkos.json is corrupt", async () => {
await writeFile(join(root, "inkos.json"), "{ this is not valid json", "utf-8");
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/v1/project");
expect(response.status).toBe(500);
const body = await response.json() as { error: { code: string; message: string } };
expect(body.error.code).toBe("PROJECT_CONFIG_INVALID");
expect(body.error.message).toContain("inkos.json");
});
it("reloads latest llm config for doctor checks without restarting the studio server", async () => {
const startupConfig = {
...cloneProjectConfig(),
+13 -3
View File
@@ -2678,9 +2678,19 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
// --- Project info ---
app.get("/api/v1/project", async (c) => {
const currentConfig = await loadCurrentProjectConfig({ requireApiKey: false });
// Check if language was explicitly set in inkos.json (not just the schema default)
const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
let currentConfig: ProjectConfig;
let raw: Record<string, unknown>;
try {
currentConfig = await loadCurrentProjectConfig({ requireApiKey: false });
// Check if language was explicitly set in inkos.json (not just the schema default)
raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8")) as Record<string, unknown>;
} catch (error) {
throw new ApiError(
500,
"PROJECT_CONFIG_INVALID",
`Failed to load inkos.json: ${error instanceof Error ? error.message : String(error)}`,
);
}
const languageExplicit = "language" in raw && raw.language !== "";
return c.json({
@@ -10,7 +10,7 @@ import {
} from "./use-book-activity";
function msg(event: string, data: unknown, timestamp: number): SSEMessage {
return { event, data, timestamp };
return { event, data, timestamp, seq: timestamp };
}
describe("deriveBookActivity", () => {
@@ -1,28 +1,26 @@
import { describe, expect, it } from "vitest";
import type { SSEMessage } from "./use-sse";
import { takeUnprocessedSessionMessages } from "./use-session-events";
import { collectNewSSEMessages } from "./use-sse";
function msg(event: string, timestamp: number, data: unknown = {}): SSEMessage {
return { event, timestamp, data };
return { event, timestamp, data, seq: timestamp };
}
describe("takeUnprocessedSessionMessages", () => {
it("returns every newly appended message instead of only the last one", () => {
const seen = new WeakSet<SSEMessage>();
describe("collectNewSSEMessages for session events", () => {
it("returns every event after the cursor instead of only the last one", () => {
const created = msg("book:created", 1, { sessionId: "s1", bookId: "b1" });
const complete = msg("agent:complete", 2, { sessionId: "s1" });
expect(takeUnprocessedSessionMessages([created, complete], seen)).toEqual([created, complete]);
expect(takeUnprocessedSessionMessages([created, complete], seen)).toEqual([]);
expect(collectNewSSEMessages([created, complete], 0).fresh).toEqual([created, complete]);
expect(collectNewSSEMessages([created, complete], 2).fresh).toEqual([]);
});
it("still sees new events when the SSE ring buffer keeps the same length", () => {
const seen = new WeakSet<SSEMessage>();
const old1 = msg("agent:start", 1);
const old2 = msg("agent:complete", 2);
const next = msg("book:created", 3, { sessionId: "s1", bookId: "b1" });
expect(takeUnprocessedSessionMessages([old1, old2], seen)).toEqual([old1, old2]);
expect(takeUnprocessedSessionMessages([old2, next], seen)).toEqual([next]);
expect(collectNewSSEMessages([old1, old2], 0).fresh).toEqual([old1, old2]);
expect(collectNewSSEMessages([old2, next], 2).fresh).toEqual([next]);
});
});
+41 -58
View File
@@ -1,81 +1,64 @@
import { useEffect, useRef } from "react";
import type { SSEMessage } from "./use-sse";
import { useNewSSEMessages } from "./use-sse";
import type { HashRoute } from "./use-hash-route";
import { useChatStore } from "../store/chat";
import { bookKey, mergeSessionIds, updateSession } from "../store/chat/slices/message/runtime";
import { clearBookCreateSessionId, getBookCreateSessionId } from "../pages/chat-page-state";
export function takeUnprocessedSessionMessages(
messages: ReadonlyArray<SSEMessage>,
seen: WeakSet<SSEMessage>,
): ReadonlyArray<SSEMessage> {
const pending: SSEMessage[] = [];
for (const message of messages) {
if (seen.has(message)) continue;
seen.add(message);
pending.push(message);
}
return pending;
}
/**
* 监听全局 SSE 事件中与 session 有关的两类消息:
* - session:title — AI 自动生成标题后推送,更新侧边栏显示
* - book:created — 新建书籍成功后推送,把 session 从 null 迁移到新书籍、清 localStorage、跳转
*
* Cursor-based consumption matters because React may batch multiple SSE state
* updates into one render; looking only at messages.at(-1) drops middle events.
*/
export function useSessionEvents(
sse: { messages: ReadonlyArray<SSEMessage> },
route: HashRoute,
setRoute: (route: HashRoute) => void,
): void {
const seenMessages = useRef<WeakSet<SSEMessage>>(new WeakSet());
useNewSSEMessages(sse.messages, (recent) => {
if (recent.event === "session:title") {
const data = recent.data as { sessionId?: string; title?: string } | null;
if (!data?.sessionId || !data.title) return;
const { sessionId, title } = data;
useChatStore.setState((state) => {
const session = state.sessions[sessionId];
if (!session) return {};
return {
sessions: updateSession(state.sessions, sessionId, () => ({ title })),
};
});
return;
}
useEffect(() => {
const pendingMessages = takeUnprocessedSessionMessages(sse.messages, seenMessages.current);
if (pendingMessages.length === 0) return;
if (recent.event === "book:created") {
const data = recent.data as { sessionId?: string; bookId?: string } | null;
if (!data?.sessionId || !data.bookId) return;
const { sessionId, bookId } = data;
for (const recent of pendingMessages) {
if (recent.event === "session:title") {
const data = recent.data as { sessionId?: string; title?: string } | null;
if (!data?.sessionId || !data.title) continue;
const { sessionId, title } = data;
useChatStore.setState((state) => {
const session = state.sessions[sessionId];
if (!session) return {};
return {
sessions: updateSession(state.sessions, sessionId, () => ({ title })),
};
});
continue;
}
useChatStore.setState((state) => {
const session = state.sessions[sessionId];
if (!session) return {};
const previousKey = bookKey(session.bookId);
const nextKey = bookKey(bookId);
return {
sessions: updateSession(state.sessions, sessionId, () => ({ bookId })),
sessionIdsByBook: {
...state.sessionIdsByBook,
[previousKey]: (state.sessionIdsByBook[previousKey] ?? []).filter((id) => id !== sessionId),
[nextKey]: mergeSessionIds(state.sessionIdsByBook[nextKey], [sessionId]),
},
};
});
if (recent.event === "book:created") {
const data = recent.data as { sessionId?: string; bookId?: string } | null;
if (!data?.sessionId || !data.bookId) continue;
const { sessionId, bookId } = data;
useChatStore.setState((state) => {
const session = state.sessions[sessionId];
if (!session) return {};
const previousKey = bookKey(session.bookId);
const nextKey = bookKey(bookId);
return {
sessions: updateSession(state.sessions, sessionId, () => ({ bookId })),
sessionIdsByBook: {
...state.sessionIdsByBook,
[previousKey]: (state.sessionIdsByBook[previousKey] ?? []).filter((id) => id !== sessionId),
[nextKey]: mergeSessionIds(state.sessionIdsByBook[nextKey], [sessionId]),
},
};
});
if (getBookCreateSessionId() === sessionId) {
clearBookCreateSessionId();
if (route.page === "book-create") {
setRoute({ page: "book", bookId });
}
if (getBookCreateSessionId() === sessionId) {
clearBookCreateSessionId();
if (route.page === "book-create") {
setRoute({ page: "book", bookId });
}
}
}
}, [route.page, setRoute, sse.messages]);
});
}
+35 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { STUDIO_SSE_EVENTS } from "./use-sse";
import { STUDIO_SSE_EVENTS, collectNewSSEMessages } from "./use-sse";
import type { SSEMessage } from "./use-sse";
describe("STUDIO_SSE_EVENTS", () => {
it("covers the server lifecycle events that drive the UI", () => {
@@ -50,3 +51,36 @@ describe("STUDIO_SSE_EVENTS", () => {
]));
});
});
function msg(seq: number, event = "log"): SSEMessage {
return { event, data: null, timestamp: 1000 + seq, seq };
}
describe("collectNewSSEMessages", () => {
it("returns every message after the cursor, not just the last one", () => {
const messages = [msg(1), msg(2, "book:created"), msg(3, "session:title"), msg(4)];
const { fresh, nextCursor } = collectNewSSEMessages(messages, 1);
expect(fresh.map((message) => message.seq)).toEqual([2, 3, 4]);
expect(nextCursor).toBe(4);
});
it("skips the backlog on first subscription and only sets the cursor", () => {
const messages = [msg(1), msg(2)];
const { fresh, nextCursor } = collectNewSSEMessages(messages, null);
expect(fresh).toEqual([]);
expect(nextCursor).toBe(2);
});
it("keeps a null cursor while the buffer is empty", () => {
const { fresh, nextCursor } = collectNewSSEMessages([], null);
expect(fresh).toEqual([]);
expect(nextCursor).toBeNull();
});
it("survives buffer trimming as long as retained messages are after the cursor", () => {
const messages = [msg(50), msg(51), msg(52)];
const { fresh, nextCursor } = collectNewSSEMessages(messages, 49);
expect(fresh.map((message) => message.seq)).toEqual([50, 51, 52]);
expect(nextCursor).toBe(52);
});
});
+34 -1
View File
@@ -4,6 +4,8 @@ export interface SSEMessage {
readonly event: string;
readonly data: unknown;
readonly timestamp: number;
/** Monotonic sequence for cursor-based consumers; survives ring-buffer trimming. */
readonly seq: number;
}
export const STUDIO_SSE_EVENTS = [
@@ -55,10 +57,37 @@ export const STUDIO_SSE_EVENTS = [
"ping",
] as const;
export function collectNewSSEMessages(
messages: ReadonlyArray<SSEMessage>,
cursor: number | null,
): { readonly fresh: ReadonlyArray<SSEMessage>; readonly nextCursor: number | null } {
if (messages.length === 0) return { fresh: [], nextCursor: cursor };
const latest = messages[messages.length - 1]!.seq;
if (cursor === null) return { fresh: [], nextCursor: latest };
if (latest <= cursor) return { fresh: [], nextCursor: cursor };
return { fresh: messages.filter((message) => message.seq > cursor), nextCursor: latest };
}
export function useNewSSEMessages(
messages: ReadonlyArray<SSEMessage>,
handler: (message: SSEMessage) => void,
): void {
const cursorRef = useRef<number | null>(null);
useEffect(() => {
const { fresh, nextCursor } = collectNewSSEMessages(messages, cursorRef.current);
cursorRef.current = nextCursor;
for (const message of fresh) {
handler(message);
}
}, [handler, messages]);
}
export function useSSE(url = "/api/v1/events") {
const [messages, setMessages] = useState<ReadonlyArray<SSEMessage>>([]);
const [connected, setConnected] = useState(false);
const esRef = useRef<EventSource | null>(null);
const seqRef = useRef(0);
useEffect(() => {
const es = new EventSource(url);
@@ -70,7 +99,11 @@ export function useSSE(url = "/api/v1/events") {
const handleEvent = (e: MessageEvent) => {
try {
const data = e.data ? JSON.parse(e.data) : null;
setMessages((prev) => [...prev.slice(-99), { event: e.type, data, timestamp: Date.now() }]);
// Compute outside the state updater: React StrictMode may invoke
// updaters twice to verify purity.
seqRef.current += 1;
const message: SSEMessage = { event: e.type, data, timestamp: Date.now(), seq: seqRef.current };
setMessages((prev) => [...prev.slice(-99), message]);
} catch {
// ignore parse errors
}
+3
View File
@@ -11,5 +11,8 @@ export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
fileParallelism: false,
// server.ts is large enough that first-load esbuild transforms can exceed
// Vitest's default 5s timeout on a cold full-suite run.
testTimeout: 30_000,
},
});