mirror of
https://github.com/Narcooo/inkos.git
synced 2026-08-30 17:22:02 +08:00
fix(studio): extract public stability fixes from drama branch
This commit is contained in:
@@ -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,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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user