Просмотр исходного кода

feat(studio): 新建会话延迟到第一条消息才持久化(draft session)

用户体验上"点新建会话立即出现",但磁盘层面不立即产生 session 文件:
- SessionRuntime 新增 isDraft 字段,标记仅存在前端未落盘的草稿会话
- 新增 createDraftSession action:客户端生成 sessionId(与后端同格式,
  timestamp-random),在 store 里创建 runtime 但不发 POST /sessions
- sendMessage 检测 isDraft:发消息前先 POST /sessions { sessionId, bookId }
  用同一 id 把 session 持久化,成功后把 isDraft 翻成 false 再发 /agent
- deleteSession 跳过 draft(磁盘上还没文件,省一次 404)
- loadSessionDetail 跳过 draft(远端还不存在)

这样用户点新建会话但没聊就切走的情况不会在磁盘产生孤儿文件;同时因为
sessionId 从一开始就固定,React key 和 URL 不用切换,切换顺滑。

同时:
- use-sse.ts 新增 session:title 事件类型,让前端订阅后端"第一条消息写入
  title"的广播,实时刷新侧边栏标题
- chat-page-state.ts 加 localStorage helpers 记录当前新建书籍讨论的 sessionId
- ChatPage 初始化时根据 activeBookId 路由决定激活哪条 session

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fanghanjun 6 месяцев назад
Родитель
Сommit
70e440b2f1

+ 1 - 0
packages/studio/src/hooks/use-sse.ts

@@ -24,6 +24,7 @@ export const STUDIO_SSE_EVENTS = [
   "agent:start",
   "agent:start",
   "agent:complete",
   "agent:complete",
   "agent:error",
   "agent:error",
+  "session:title",
   "audit:start",
   "audit:start",
   "audit:complete",
   "audit:complete",
   "audit:error",
   "audit:error",

+ 80 - 20
packages/studio/src/pages/ChatPage.tsx

@@ -2,7 +2,8 @@ import { useRef, useEffect, useMemo, useState } from "react";
 import type { Theme } from "../hooks/use-theme";
 import type { Theme } from "../hooks/use-theme";
 import type { TFunction } from "../hooks/use-i18n";
 import type { TFunction } from "../hooks/use-i18n";
 import type { SSEMessage } from "../hooks/use-sse";
 import type { SSEMessage } from "../hooks/use-sse";
-import { useChatStore } from "../store/chat";
+import { useApi } from "../hooks/use-api";
+import { chatSelectors, useChatStore } from "../store/chat";
 import { useServiceStore } from "../store/service";
 import { useServiceStore } from "../store/service";
 import {
 import {
   DropdownMenu,
   DropdownMenu,
@@ -30,7 +31,12 @@ import {
   Message,
   Message,
   MessageContent,
   MessageContent,
 } from "../components/ai-elements/message";
 } from "../components/ai-elements/message";
-import { filterModelGroups, shouldUseFreshBookCreateSession } from "./chat-page-state";
+import {
+  clearBookCreateSessionId,
+  filterModelGroups,
+  getBookCreateSessionId,
+  setBookCreateSessionId,
+} from "./chat-page-state";
 
 
 // -- Types --
 // -- Types --
 
 
@@ -52,10 +58,12 @@ export interface ChatPageProps {
 
 
 export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPageProps) {
 export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPageProps) {
   // -- Store selectors --
   // -- Store selectors --
-  const messages = useChatStore((s) => s.messages);
+  const activeSession = useChatStore(chatSelectors.activeSession);
+  const messages = useChatStore(chatSelectors.activeMessages);
+  const activeSessionId = useChatStore((s) => s.activeSessionId);
   const input = useChatStore((s) => s.input);
   const input = useChatStore((s) => s.input);
-  const loading = useChatStore((s) => s.loading);
-  const pendingBookArgs = useChatStore((s) => s.pendingBookArgs);
+  const loading = useChatStore(chatSelectors.isActiveSessionStreaming);
+  const pendingBookArgs = activeSession?.pendingBookArgs ?? null;
   const bookCreating = useChatStore((s) => s.bookCreating);
   const bookCreating = useChatStore((s) => s.bookCreating);
   const createProgress = useChatStore((s) => s.createProgress);
   const createProgress = useChatStore((s) => s.createProgress);
   const selectedModel = useChatStore((s) => s.selectedModel);
   const selectedModel = useChatStore((s) => s.selectedModel);
@@ -67,6 +75,10 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
   const handleCreateBook = useChatStore((s) => s.handleCreateBook);
   const handleCreateBook = useChatStore((s) => s.handleCreateBook);
   const setCreateProgress = useChatStore((s) => s.setCreateProgress);
   const setCreateProgress = useChatStore((s) => s.setCreateProgress);
   const setSelectedModel = useChatStore((s) => s.setSelectedModel);
   const setSelectedModel = useChatStore((s) => s.setSelectedModel);
+  const loadSessionList = useChatStore((s) => s.loadSessionList);
+  const createSession = useChatStore((s) => s.createSession);
+  const loadSessionDetail = useChatStore((s) => s.loadSessionDetail);
+  const activateSession = useChatStore((s) => s.activateSession);
 
 
   const scrollRef = useRef<HTMLDivElement>(null);
   const scrollRef = useRef<HTMLDivElement>(null);
   const textareaRef = useRef<HTMLTextAreaElement>(null);
   const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -154,27 +166,73 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
     return () => { es.close(); };
     return () => { es.close(); };
   }, [bookCreating, setCreateProgress]);
   }, [bookCreating, setCreateProgress]);
 
 
-  // Load session messages on mount or when activeBookId changes.
-  // Book-create mode should always start from a fresh chat.
+  // Entering a book loads its latest session; book-create mode persists its orphan session in localStorage.
   useEffect(() => {
   useEffect(() => {
-    if (shouldUseFreshBookCreateSession(activeBookId)) {
-      useChatStore.setState({ currentSessionId: null, messages: [], input: "", _activeStream: null });
-    } else {
-      useChatStore.getState().loadSession(activeBookId);
-    }
-  }, [activeBookId]);
+    let cancelled = false;
+
+    void (async () => {
+      if (activeBookId) {
+        await loadSessionList(activeBookId);
+        if (cancelled) return;
+
+        const state = useChatStore.getState();
+        const currentSession = state.activeSessionId ? state.sessions[state.activeSessionId] : null;
+        if (currentSession?.bookId === activeBookId) {
+          await loadSessionDetail(currentSession.sessionId);
+          return;
+        }
+        const ids = state.sessionIdsByBook[activeBookId] ?? [];
+        if (ids.length > 0) {
+          activateSession(ids[0]);
+          await loadSessionDetail(ids[0]);
+          return;
+        }
+
+        await createSession(activeBookId);
+        return;
+      }
+
+      const existingId = getBookCreateSessionId();
+      if (existingId) {
+        await loadSessionDetail(existingId);
+        if (cancelled) return;
+
+        const state = useChatStore.getState();
+        const session = state.sessions[existingId];
+        if (session && session.bookId === null) {
+          activateSession(existingId);
+          return;
+        }
+      }
+
+      const newSessionId = await createSession(null);
+      if (!cancelled) {
+        setBookCreateSessionId(newSessionId);
+      }
+    })();
+
+    return () => {
+      cancelled = true;
+    };
+  }, [activeBookId, activateSession, createSession, loadSessionDetail, loadSessionList]);
 
 
   const onSend = (text: string) => {
   const onSend = (text: string) => {
-    void sendMessage(text, activeBookId);
+    if (!activeSessionId) return;
+    void sendMessage(activeSessionId, text, activeBookId);
   };
   };
 
 
   const onCreateBook = async () => {
   const onCreateBook = async () => {
-    const newBookId = await handleCreateBook(activeBookId);
-    if (newBookId) nav.toBook(newBookId);
+    if (!activeSessionId) return;
+    const newBookId = await handleCreateBook(activeSessionId, activeBookId);
+    if (newBookId) {
+      clearBookCreateSessionId();
+      nav.toBook(newBookId);
+    }
   };
   };
 
 
   const handleQuickAction = (command: string) => {
   const handleQuickAction = (command: string) => {
-    void sendMessage(command, activeBookId);
+    if (!activeSessionId) return;
+    void sendMessage(activeSessionId, command, activeBookId);
   };
   };
 
 
   const emptyGuidance = isZh
   const emptyGuidance = isZh
@@ -329,7 +387,7 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
         <div className="shrink-0 max-w-3xl mx-auto w-full px-4">
         <div className="shrink-0 max-w-3xl mx-auto w-full px-4">
           <QuickActions
           <QuickActions
             onAction={handleQuickAction}
             onAction={handleQuickAction}
-            disabled={loading}
+            disabled={loading || !activeSessionId}
             isZh={isZh}
             isZh={isZh}
           />
           />
         </div>
         </div>
@@ -357,12 +415,14 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
                   onChange={(e) => setInput(e.target.value)}
                   onChange={(e) => setInput(e.target.value)}
                   onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onSend(input); } }}
                   onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onSend(input); } }}
                   placeholder={isZh ? "或输入修改要求…" : "Or type changes..."}
                   placeholder={isZh ? "或输入修改要求…" : "Or type changes..."}
+                  disabled={!activeSessionId}
                   className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/50"
                   className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/50"
                 />
                 />
                 {input.trim() && (
                 {input.trim() && (
                   <button
                   <button
                     type="button"
                     type="button"
                     onClick={() => onSend(input)}
                     onClick={() => onSend(input)}
+                    disabled={!activeSessionId}
                     className="w-7 h-7 rounded-lg bg-primary text-primary-foreground flex items-center justify-center shrink-0 hover:scale-105 active:scale-95 transition-all"
                     className="w-7 h-7 rounded-lg bg-primary text-primary-foreground flex items-center justify-center shrink-0 hover:scale-105 active:scale-95 transition-all"
                   >
                   >
                     <ArrowUp size={12} strokeWidth={2.5} />
                     <ArrowUp size={12} strokeWidth={2.5} />
@@ -380,14 +440,14 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
                   onChange={(e) => setInput(e.target.value)}
                   onChange={(e) => setInput(e.target.value)}
                   onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onSend(input); } }}
                   onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onSend(input); } }}
                   placeholder={isZh ? "输入指令..." : "Enter command..."}
                   placeholder={isZh ? "输入指令..." : "Enter command..."}
-                  disabled={loading}
+                  disabled={loading || !activeSessionId}
                   rows={1}
                   rows={1}
                   className="flex-1 bg-transparent text-sm leading-6 placeholder:text-muted-foreground/50 outline-none! border-none! ring-0! shadow-none focus:outline-none! focus:ring-0! focus:border-none! resize-none disabled:opacity-50 max-h-[200px] overflow-y-auto"
                   className="flex-1 bg-transparent text-sm leading-6 placeholder:text-muted-foreground/50 outline-none! border-none! ring-0! shadow-none focus:outline-none! focus:ring-0! focus:border-none! resize-none disabled:opacity-50 max-h-[200px] overflow-y-auto"
                 />
                 />
                 <button
                 <button
                   type="button"
                   type="button"
                   onClick={() => onSend(input)}
                   onClick={() => onSend(input)}
-                  disabled={!input.trim() || loading}
+                  disabled={!input.trim() || loading || !activeSessionId}
                   className="w-8 h-8 rounded-lg bg-primary text-primary-foreground flex items-center justify-center shrink-0 hover:scale-105 active:scale-95 transition-all disabled:opacity-20 disabled:scale-100 shadow-sm shadow-primary/20"
                   className="w-8 h-8 rounded-lg bg-primary text-primary-foreground flex items-center justify-center shrink-0 hover:scale-105 active:scale-95 transition-all disabled:opacity-20 disabled:scale-100 shadow-sm shadow-primary/20"
                 >
                 >
                   {loading ? <Loader2 size={14} className="animate-spin" /> : <ArrowUp size={14} strokeWidth={2.5} />}
                   {loading ? <Loader2 size={14} className="animate-spin" /> : <ArrowUp size={14} strokeWidth={2.5} />}

+ 53 - 8
packages/studio/src/pages/chat-page-state.test.ts

@@ -1,14 +1,59 @@
-import { describe, expect, it } from "vitest";
-import { filterModelGroups, shouldUseFreshBookCreateSession } from "./chat-page-state";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+  clearBookCreateSessionId,
+  filterModelGroups,
+  getBookCreateSessionId,
+  setBookCreateSessionId,
+} from "./chat-page-state";
 
 
-describe("shouldUseFreshBookCreateSession", () => {
-  it("uses a fresh session when there is no active book", () => {
-    expect(shouldUseFreshBookCreateSession(undefined)).toBe(true);
-    expect(shouldUseFreshBookCreateSession("")).toBe(true);
+describe("book-create session localStorage helpers", () => {
+  const storage = new Map<string, string>();
+
+  beforeEach(() => {
+    storage.clear();
+    vi.stubGlobal("localStorage", {
+      getItem: (key: string) => storage.get(key) ?? null,
+      setItem: (key: string, value: string) => {
+        storage.set(key, value);
+      },
+      removeItem: (key: string) => {
+        storage.delete(key);
+      },
+      clear: () => {
+        storage.clear();
+      },
+    });
+  });
+
+  afterEach(() => {
+    storage.clear();
+    vi.unstubAllGlobals();
+  });
+
+  it("getBookCreateSessionId returns null when empty", () => {
+    expect(getBookCreateSessionId()).toBeNull();
+  });
+
+  it("setBookCreateSessionId + get round-trips", () => {
+    setBookCreateSessionId("sess-123");
+    expect(getBookCreateSessionId()).toBe("sess-123");
+  });
+
+  it("setBookCreateSessionId overwrites previous value", () => {
+    setBookCreateSessionId("sess-old");
+    setBookCreateSessionId("sess-new");
+    expect(getBookCreateSessionId()).toBe("sess-new");
+  });
+
+  it("clearBookCreateSessionId removes the key", () => {
+    setBookCreateSessionId("sess-123");
+    clearBookCreateSessionId();
+    expect(getBookCreateSessionId()).toBeNull();
   });
   });
 
 
-  it("reuses the persisted session when a book is active", () => {
-    expect(shouldUseFreshBookCreateSession("demo-book")).toBe(false);
+  it("clearBookCreateSessionId is safe when key doesn't exist", () => {
+    clearBookCreateSessionId();
+    expect(getBookCreateSessionId()).toBeNull();
   });
   });
 });
 });
 
 

+ 12 - 2
packages/studio/src/pages/chat-page-state.ts

@@ -9,8 +9,18 @@ export interface ChatPageModelGroup {
   readonly models: ReadonlyArray<ChatPageModelInfo>;
   readonly models: ReadonlyArray<ChatPageModelInfo>;
 }
 }
 
 
-export function shouldUseFreshBookCreateSession(activeBookId?: string): boolean {
-  return !activeBookId;
+const BOOK_CREATE_SESSION_KEY = "inkos.book-create.session-id";
+
+export function getBookCreateSessionId(): string | null {
+  return globalThis.localStorage?.getItem(BOOK_CREATE_SESSION_KEY) ?? null;
+}
+
+export function setBookCreateSessionId(sessionId: string): void {
+  globalThis.localStorage?.setItem(BOOK_CREATE_SESSION_KEY, sessionId);
+}
+
+export function clearBookCreateSessionId(): void {
+  globalThis.localStorage?.removeItem(BOOK_CREATE_SESSION_KEY);
 }
 }
 
 
 export function filterModelGroups(
 export function filterModelGroups(

+ 11 - 2
packages/studio/src/store/chat/selectors.ts

@@ -1,7 +1,16 @@
 import type { ChatState } from "./types";
 import type { ChatState } from "./types";
 
 
+const EMPTY_MESSAGES: readonly [] = [];
+
 export const chatSelectors = {
 export const chatSelectors = {
-  hasPendingTool: (s: ChatState) => s.pendingBookArgs !== null,
+  hasPendingTool: (s: ChatState) =>
+    Boolean(s.activeSessionId && s.sessions[s.activeSessionId]?.pendingBookArgs),
   isCreating: (s: ChatState) => s.bookCreating,
   isCreating: (s: ChatState) => s.bookCreating,
-  isEmpty: (s: ChatState) => s.messages.length === 0 && !s.loading,
+  activeSession: (s: ChatState) => (s.activeSessionId ? s.sessions[s.activeSessionId] ?? null : null),
+  activeMessages: (s: ChatState) =>
+    (s.activeSessionId ? s.sessions[s.activeSessionId]?.messages : undefined) ?? EMPTY_MESSAGES,
+  isActiveSessionStreaming: (s: ChatState) => Boolean(s.activeSessionId && s.sessions[s.activeSessionId]?.isStreaming),
+  isEmpty: (s: ChatState) =>
+    ((s.activeSessionId ? s.sessions[s.activeSessionId]?.messages.length : 0) ?? 0) === 0
+    && !Boolean(s.activeSessionId && s.sessions[s.activeSessionId]?.isStreaming),
 };
 };

+ 58 - 5
packages/studio/src/store/chat/slices/message/action.ts

@@ -155,6 +155,35 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
     return sessionId;
     return sessionId;
   },
   },
 
 
+  createDraftSession: (bookId) => {
+    // 前端生成 sessionId(与后端 createBookSession 同格式),暂不持久化到磁盘。
+    // 发送第一条消息时,sendMessage 会调 POST /sessions { sessionId, bookId } 落盘。
+    const sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+    set((state) => {
+      const runtime = createSessionRuntime({
+        sessionId,
+        bookId,
+        title: null,
+        isDraft: true,
+      });
+      return {
+        sessions: {
+          ...state.sessions,
+          [sessionId]: runtime,
+        },
+        sessionIdsByBook: {
+          ...state.sessionIdsByBook,
+          [bookKey(bookId)]: mergeSessionIds(
+            state.sessionIdsByBook[bookKey(bookId)],
+            [sessionId],
+          ),
+        },
+        activeSessionId: sessionId,
+      };
+    });
+    return sessionId;
+  },
+
   renameSession: async (sessionId, title) => {
   renameSession: async (sessionId, title) => {
     const previous = get().sessions[sessionId]?.title ?? null;
     const previous = get().sessions[sessionId]?.title ?? null;
     set((state) => ({
     set((state) => ({
@@ -177,10 +206,13 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
   deleteSession: async (sessionId) => {
   deleteSession: async (sessionId) => {
     const session = get().sessions[sessionId];
     const session = get().sessions[sessionId];
     session?.stream?.close();
     session?.stream?.close();
-    try {
-      await fetchJson(`/sessions/${sessionId}`, { method: "DELETE" });
-    } catch {
-      // ignore
+    // 草稿会话还没写到磁盘,跳过 DELETE 请求避免后端返回 404
+    if (session && !session.isDraft) {
+      try {
+        await fetchJson(`/sessions/${sessionId}`, { method: "DELETE" });
+      } catch {
+        // ignore
+      }
     }
     }
 
 
     set((state) => {
     set((state) => {
@@ -207,8 +239,10 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
   },
   },
 
 
   loadSessionDetail: async (sessionId) => {
   loadSessionDetail: async (sessionId) => {
-    // 本地已有消息 → 不拉取远端,避免流式中或未持久化的消息被覆盖。
+    // 草稿会话:磁盘上还没有文件,直接跳过远端拉取。
+    // 本地已有消息:不拉取远端,避免流式中或未持久化的消息被覆盖。
     const existing = get().sessions[sessionId];
     const existing = get().sessions[sessionId];
+    if (existing?.isDraft) return;
     if (existing && existing.messages.length > 0) return;
     if (existing && existing.messages.length > 0) return;
 
 
     try {
     try {
@@ -262,6 +296,25 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
       return;
       return;
     }
     }
 
 
+    // 草稿会话:第一条消息发送时才真正把 session 文件写到磁盘。
+    // 后端 POST /sessions 支持接受客户端传入的 sessionId,所以 id 保持一致,
+    // 前端 store 里的 runtime 不用 remount,只需要把 isDraft 翻成 false。
+    if (session.isDraft) {
+      try {
+        await fetchJson<SessionResponse>("/sessions", {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ sessionId, bookId: session.bookId }),
+        });
+        set((state) => ({
+          sessions: updateSession(state.sessions, sessionId, () => ({ isDraft: false })),
+        }));
+      } catch (err) {
+        get().addErrorMessage(sessionId, err instanceof Error ? err.message : String(err));
+        return;
+      }
+    }
+
     const instruction = activeBookId ? trimmed : `/new ${trimmed}`;
     const instruction = activeBookId ? trimmed : `/new ${trimmed}`;
     const streamTs = Date.now() + 1;
     const streamTs = Date.now() + 1;
 
 

+ 3 - 4
packages/studio/src/store/chat/slices/message/initialState.ts

@@ -1,11 +1,10 @@
 import type { MessageState } from "../../types";
 import type { MessageState } from "../../types";
 
 
 export const initialMessageState: MessageState = {
 export const initialMessageState: MessageState = {
-  messages: [],
+  sessions: {},
+  sessionIdsByBook: {},
+  activeSessionId: null,
   input: "",
   input: "",
-  loading: false,
-  currentSessionId: null,
   selectedModel: null,
   selectedModel: null,
   selectedService: null,
   selectedService: null,
-  _activeStream: null,
 };
 };

+ 2 - 0
packages/studio/src/store/chat/slices/message/runtime.ts

@@ -129,6 +129,7 @@ export function createSessionRuntime(input: {
   bookId: string | null;
   bookId: string | null;
   title: string | null;
   title: string | null;
   messages?: ReadonlyArray<Message>;
   messages?: ReadonlyArray<Message>;
+  isDraft?: boolean;
 }): SessionRuntime {
 }): SessionRuntime {
   return {
   return {
     sessionId: input.sessionId,
     sessionId: input.sessionId,
@@ -139,6 +140,7 @@ export function createSessionRuntime(input: {
     isStreaming: false,
     isStreaming: false,
     lastError: null,
     lastError: null,
     pendingBookArgs: null,
     pendingBookArgs: null,
+    isDraft: input.isDraft ?? false,
   };
   };
 }
 }
 
 

+ 3 - 0
packages/studio/src/store/chat/types.ts

@@ -111,6 +111,8 @@ export interface SessionRuntime {
   readonly isStreaming: boolean;
   readonly isStreaming: boolean;
   readonly lastError: string | null;
   readonly lastError: string | null;
   readonly pendingBookArgs: Record<string, unknown> | null;
   readonly pendingBookArgs: Record<string, unknown> | null;
+  // 仅前端存在、尚未持久化到磁盘的草稿会话。发送第一条消息时才调 POST /sessions 把它落盘。
+  readonly isDraft: boolean;
 }
 }
 
 
 export interface MessageState {
 export interface MessageState {
@@ -147,6 +149,7 @@ export interface MessageActions {
   loadSessionMessages: (sessionId: string, msgs: ReadonlyArray<SessionMessage>) => void;
   loadSessionMessages: (sessionId: string, msgs: ReadonlyArray<SessionMessage>) => void;
   loadSessionList: (bookId: string | null) => Promise<void>;
   loadSessionList: (bookId: string | null) => Promise<void>;
   createSession: (bookId: string | null) => Promise<string>;
   createSession: (bookId: string | null) => Promise<string>;
+  createDraftSession: (bookId: string | null) => string;
   renameSession: (sessionId: string, title: string) => Promise<void>;
   renameSession: (sessionId: string, title: string) => Promise<void>;
   deleteSession: (sessionId: string) => Promise<void>;
   deleteSession: (sessionId: string) => Promise<void>;
   loadSessionDetail: (sessionId: string) => Promise<void>;
   loadSessionDetail: (sessionId: string) => Promise<void>;