|
@@ -136,12 +136,22 @@ interface CollectedToolExec {
|
|
|
status: "running" | "completed" | "error";
|
|
status: "running" | "completed" | "error";
|
|
|
args?: Record<string, unknown>;
|
|
args?: Record<string, unknown>;
|
|
|
result?: string;
|
|
result?: string;
|
|
|
|
|
+ details?: unknown;
|
|
|
error?: string;
|
|
error?: string;
|
|
|
stages?: Array<{ label: string; status: "pending" | "completed" }>;
|
|
stages?: Array<{ label: string; status: "pending" | "completed" }>;
|
|
|
startedAt: number;
|
|
startedAt: number;
|
|
|
completedAt?: number;
|
|
completedAt?: number;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+interface StudioBookListSummary {
|
|
|
|
|
+ readonly id: string;
|
|
|
|
|
+ readonly title: string;
|
|
|
|
|
+ readonly genre: string;
|
|
|
|
|
+ readonly status: string;
|
|
|
|
|
+ readonly chaptersWritten: number;
|
|
|
|
|
+ readonly [key: string]: unknown;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// --- Event bus for SSE ---
|
|
// --- Event bus for SSE ---
|
|
|
|
|
|
|
|
type EventHandler = (event: string, data: unknown) => void;
|
|
type EventHandler = (event: string, data: unknown) => void;
|
|
@@ -194,6 +204,50 @@ function broadcast(event: string, data: unknown): void {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function deriveBookIdFromTitle(title: string): string {
|
|
|
|
|
+ return title
|
|
|
|
|
+ .trim()
|
|
|
|
|
+ .toLowerCase()
|
|
|
|
|
+ .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
|
|
|
|
|
+ .replace(/-+/g, "-")
|
|
|
|
|
+ .replace(/^-+|-+$/g, "")
|
|
|
|
|
+ .slice(0, 30);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function resolveArchitectBookIdFromArgs(args?: Record<string, unknown>): string | null {
|
|
|
|
|
+ if (!args || args.agent !== "architect" || args.revise === true) return null;
|
|
|
|
|
+ if (typeof args.bookId === "string" && args.bookId.trim()) return args.bookId.trim();
|
|
|
|
|
+ if (typeof args.title === "string" && args.title.trim()) {
|
|
|
|
|
+ return deriveBookIdFromTitle(args.title) || null;
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function resolveCreatedBookIdFromToolExecs(execs: ReadonlyArray<CollectedToolExec>): string | null {
|
|
|
|
|
+ for (let i = execs.length - 1; i >= 0; i -= 1) {
|
|
|
|
|
+ const exec = execs[i];
|
|
|
|
|
+ if (exec.tool !== "sub_agent" || exec.agent !== "architect" || exec.status !== "completed") continue;
|
|
|
|
|
+
|
|
|
|
|
+ const details = exec.details as { kind?: unknown; bookId?: unknown } | undefined;
|
|
|
|
|
+ if (details?.kind === "book_created" && typeof details.bookId === "string" && details.bookId.trim()) {
|
|
|
|
|
+ return details.bookId.trim();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const fromArgs = resolveArchitectBookIdFromArgs(exec.args);
|
|
|
|
|
+ if (fromArgs) return fromArgs;
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function loadStudioBookListSummary(
|
|
|
|
|
+ state: StateManager,
|
|
|
|
|
+ bookId: string,
|
|
|
|
|
+): Promise<StudioBookListSummary> {
|
|
|
|
|
+ const book = await state.loadBookConfig(bookId);
|
|
|
|
|
+ const nextChapter = await state.getNextChapterNumber(bookId);
|
|
|
|
|
+ return { ...book, chaptersWritten: nextChapter - 1 };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function isCustomServiceId(serviceId: string): boolean {
|
|
function isCustomServiceId(serviceId: string): boolean {
|
|
|
return serviceId === "custom" || serviceId.startsWith("custom:");
|
|
return serviceId === "custom" || serviceId.startsWith("custom:");
|
|
|
}
|
|
}
|
|
@@ -728,13 +782,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
|
|
|
|
|
|
|
|
app.get("/api/v1/books", async (c) => {
|
|
app.get("/api/v1/books", async (c) => {
|
|
|
const bookIds = await state.listBooks();
|
|
const bookIds = await state.listBooks();
|
|
|
- const books = await Promise.all(
|
|
|
|
|
- bookIds.map(async (id) => {
|
|
|
|
|
- const book = await state.loadBookConfig(id);
|
|
|
|
|
- const nextChapter = await state.getNextChapterNumber(id);
|
|
|
|
|
- return { ...book, chaptersWritten: nextChapter - 1 };
|
|
|
|
|
- }),
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ const books = await Promise.all(bookIds.map((id) => loadStudioBookListSummary(state, id)));
|
|
|
return c.json({ books });
|
|
return c.json({ books });
|
|
|
});
|
|
});
|
|
|
|
|
|
|
@@ -811,13 +859,14 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
|
|
|
},
|
|
},
|
|
|
tools,
|
|
tools,
|
|
|
}).then(
|
|
}).then(
|
|
|
- (result: {
|
|
|
|
|
|
|
+ async (result: {
|
|
|
readonly session: { readonly activeBookId?: string };
|
|
readonly session: { readonly activeBookId?: string };
|
|
|
readonly details?: Readonly<Record<string, unknown>>;
|
|
readonly details?: Readonly<Record<string, unknown>>;
|
|
|
}) => {
|
|
}) => {
|
|
|
const createdBookId = (result.details?.bookId as string | undefined) ?? result.session.activeBookId ?? bookId;
|
|
const createdBookId = (result.details?.bookId as string | undefined) ?? result.session.activeBookId ?? bookId;
|
|
|
|
|
+ const book = await loadStudioBookListSummary(state, createdBookId).catch(() => undefined);
|
|
|
bookCreateStatus.delete(createdBookId);
|
|
bookCreateStatus.delete(createdBookId);
|
|
|
- broadcast("book:created", { bookId: createdBookId });
|
|
|
|
|
|
|
+ broadcast("book:created", { bookId: createdBookId, ...(book ? { book } : {}) });
|
|
|
},
|
|
},
|
|
|
(e: unknown) => {
|
|
(e: unknown) => {
|
|
|
const error = e instanceof Error ? e.message : String(e);
|
|
const error = e instanceof Error ? e.message : String(e);
|
|
@@ -1780,6 +1829,17 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
|
|
|
startedAt: Date.now(),
|
|
startedAt: Date.now(),
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ if (!activeBookId && event.toolName === "sub_agent" && agent === "architect") {
|
|
|
|
|
+ const bookId = resolveArchitectBookIdFromArgs(args);
|
|
|
|
|
+ if (bookId) {
|
|
|
|
|
+ const title = typeof args?.title === "string" && args.title.trim()
|
|
|
|
|
+ ? args.title.trim()
|
|
|
|
|
+ : bookId;
|
|
|
|
|
+ bookCreateStatus.set(bookId, { status: "creating" });
|
|
|
|
|
+ broadcast("book:creating", { bookId, title, sessionId: streamSessionId });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
broadcast("tool:start", {
|
|
broadcast("tool:start", {
|
|
|
sessionId: streamSessionId,
|
|
sessionId: streamSessionId,
|
|
|
id: event.toolCallId,
|
|
id: event.toolCallId,
|
|
@@ -1803,6 +1863,20 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
|
|
|
exec.stages = exec.stages?.map(s => ({ ...s, status: "completed" as const }));
|
|
exec.stages = exec.stages?.map(s => ({ ...s, status: "completed" as const }));
|
|
|
if (event.isError) exec.error = extractToolError(event.result);
|
|
if (event.isError) exec.error = extractToolError(event.result);
|
|
|
else exec.result = summarizeResult(event.result);
|
|
else exec.result = summarizeResult(event.result);
|
|
|
|
|
+ exec.details = (event.result as { details?: unknown } | undefined)?.details;
|
|
|
|
|
+ if (
|
|
|
|
|
+ event.isError &&
|
|
|
|
|
+ !activeBookId &&
|
|
|
|
|
+ exec.tool === "sub_agent" &&
|
|
|
|
|
+ exec.agent === "architect"
|
|
|
|
|
+ ) {
|
|
|
|
|
+ const bookId = resolveArchitectBookIdFromArgs(exec.args);
|
|
|
|
|
+ if (bookId) {
|
|
|
|
|
+ const error = exec.error ?? "Book creation failed";
|
|
|
|
|
+ bookCreateStatus.set(bookId, { status: "error", error });
|
|
|
|
|
+ broadcast("book:error", { bookId, sessionId: streamSessionId, error });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
broadcast("tool:end", {
|
|
broadcast("tool:end", {
|
|
|
sessionId: streamSessionId,
|
|
sessionId: streamSessionId,
|
|
@@ -1818,6 +1892,35 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
|
|
|
initialMessages,
|
|
initialMessages,
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
|
|
+ let broadcastedCreatedBookId: string | null = null;
|
|
|
|
|
+ const finalizeCreatedBook = async (): Promise<string | null> => {
|
|
|
|
|
+ if (activeBookId) return null;
|
|
|
|
|
+ const createdBookId = resolveCreatedBookIdFromToolExecs(collectedToolExecs);
|
|
|
|
|
+ if (!createdBookId) return null;
|
|
|
|
|
+ if (broadcastedCreatedBookId === createdBookId) return createdBookId;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const migratedSession = await migrateBookSession(root, bookSession.sessionId, createdBookId);
|
|
|
|
|
+ if (migratedSession) {
|
|
|
|
|
+ bookSession = migratedSession;
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ if (!(e instanceof SessionAlreadyMigratedError)) {
|
|
|
|
|
+ throw e;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const book = await loadStudioBookListSummary(state, createdBookId).catch(() => undefined);
|
|
|
|
|
+ bookCreateStatus.delete(createdBookId);
|
|
|
|
|
+ broadcast("book:created", {
|
|
|
|
|
+ bookId: createdBookId,
|
|
|
|
|
+ sessionId: bookSession.sessionId,
|
|
|
|
|
+ ...(book ? { book } : {}),
|
|
|
|
|
+ });
|
|
|
|
|
+ broadcastedCreatedBookId = createdBookId;
|
|
|
|
|
+ return createdBookId;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
// Persist user + assistant messages to BookSession
|
|
// Persist user + assistant messages to BookSession
|
|
|
bookSession = appendBookSessionMessage(bookSession, {
|
|
bookSession = appendBookSessionMessage(bookSession, {
|
|
|
role: "user",
|
|
role: "user",
|
|
@@ -1872,9 +1975,13 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
|
|
|
timestamp: Date.now() + 1,
|
|
timestamp: Date.now() + 1,
|
|
|
});
|
|
});
|
|
|
await persistBookSession(root, bookSession);
|
|
await persistBookSession(root, bookSession);
|
|
|
|
|
+ const createdBookId = await finalizeCreatedBook();
|
|
|
return c.json({
|
|
return c.json({
|
|
|
response: fallback.content,
|
|
response: fallback.content,
|
|
|
- session: { sessionId: bookSession.sessionId },
|
|
|
|
|
|
|
+ session: {
|
|
|
|
|
+ sessionId: bookSession.sessionId,
|
|
|
|
|
+ ...(createdBookId ? { activeBookId: createdBookId } : {}),
|
|
|
|
|
+ },
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
} catch {
|
|
} catch {
|
|
@@ -1899,6 +2006,10 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
|
|
|
);
|
|
);
|
|
|
} catch (probeError) {
|
|
} catch (probeError) {
|
|
|
const probeMessage = probeError instanceof Error ? probeError.message : String(probeError);
|
|
const probeMessage = probeError instanceof Error ? probeError.message : String(probeError);
|
|
|
|
|
+ if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
|
|
|
|
|
+ await persistBookSession(root, bookSession);
|
|
|
|
|
+ await finalizeCreatedBook();
|
|
|
|
|
+ }
|
|
|
return c.json({
|
|
return c.json({
|
|
|
error: { code: "AGENT_EMPTY_RESPONSE", message: probeMessage },
|
|
error: { code: "AGENT_EMPTY_RESPONSE", message: probeMessage },
|
|
|
response: probeMessage,
|
|
response: probeMessage,
|
|
@@ -1906,35 +2017,20 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const emptyMessage = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
|
|
const emptyMessage = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
|
|
|
|
|
+ if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
|
|
|
|
|
+ await persistBookSession(root, bookSession);
|
|
|
|
|
+ await finalizeCreatedBook();
|
|
|
|
|
+ }
|
|
|
return c.json({
|
|
return c.json({
|
|
|
error: { code: "AGENT_EMPTY_RESPONSE", message: emptyMessage },
|
|
error: { code: "AGENT_EMPTY_RESPONSE", message: emptyMessage },
|
|
|
response: emptyMessage,
|
|
response: emptyMessage,
|
|
|
}, 502);
|
|
}, 502);
|
|
|
}
|
|
}
|
|
|
await persistBookSession(root, bookSession);
|
|
await persistBookSession(root, bookSession);
|
|
|
|
|
+ await finalizeCreatedBook();
|
|
|
|
|
|
|
|
broadcast("agent:complete", { instruction, activeBookId, sessionId: bookSession.sessionId });
|
|
broadcast("agent:complete", { instruction, activeBookId, sessionId: bookSession.sessionId });
|
|
|
|
|
|
|
|
- // If a sub_agent created a new book during this session, broadcast book:created
|
|
|
|
|
- // so the sidebar refreshes.
|
|
|
|
|
- if (!activeBookId && collectedToolExecs.some((t) => t.agent === "architect" && t.status === "completed")) {
|
|
|
|
|
- const books = await state.listBooks();
|
|
|
|
|
- const latestBook = books.at(-1);
|
|
|
|
|
- if (latestBook) {
|
|
|
|
|
- try {
|
|
|
|
|
- const migratedSession = await migrateBookSession(root, bookSession.sessionId, latestBook);
|
|
|
|
|
- if (migratedSession) {
|
|
|
|
|
- bookSession = migratedSession;
|
|
|
|
|
- }
|
|
|
|
|
- } catch (e) {
|
|
|
|
|
- if (!(e instanceof SessionAlreadyMigratedError)) {
|
|
|
|
|
- throw e;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- broadcast("book:created", { bookId: latestBook, sessionId: bookSession.sessionId });
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
return c.json({
|
|
return c.json({
|
|
|
response: result.responseText,
|
|
response: result.responseText,
|
|
|
session: {
|
|
session: {
|