Quellcode durchsuchen

merge origin/master into v13-refactor

Strategy: v13 writing pipeline (architect/planner/writer/reviewer/
polisher/hook-ledger) stays authoritative; interactive-agent layer
and studio session management take master's newer versions.

Conflict resolution per file:
- agents/architect.ts → ours (v13 Phase 5 five-section schema)
- agent/agent-session|system-prompt|tools|index.ts → theirs
  (master's transformContext, drop-revise_chapter, sub_agent-params
  alignment, deterministic writing tools, agent system prompt rewrite)
- llm/service-presets.ts, service-resolver.ts → ours
  (cosmetic diff; v13 keeps 4b3412a fast-model fallback fix)
- studio/pages/ChatPage, chat-page-state, store/chat/slices/message
  → theirs (master's draft sessions, per-book session list, SSE
  stream-events extraction)
- studio/api/server.ts → manual: kept v13 /api/v1/* routes and
  truth file handling; took master's baseUrl-keyed model list
  cache and 50-model cap removal
- studio/api/server.test.ts → theirs + isNewLayoutBook mock re-added
- tests/architect.test.ts → ours (maxTokens 20480 from Phase 5)
- tests/agent-system-prompt|agent-tools.test.ts → theirs

Post-merge test state:
- core 926/926
- studio 162/162
- cli 164/169 (5 pre-existing INKOS_LLM_API_KEY env issues)
Ma vor 6 Monaten
Ursprung
Commit
dab838da49
51 geänderte Dateien mit 3407 neuen und 725 gelöschten Zeilen
  1. 6 3
      .gitignore
  2. 85 0
      CHANGELOG.md
  3. 59 0
      CLAUDE.md
  4. 1 1
      README.md
  5. BIN
      assets/wechat-group-v6.jpg
  6. BIN
      assets/wechat-group-v7.jpg
  7. 8 2
      package.json
  8. 5 1
      packages/cli/package.json
  9. 64 41
      packages/cli/src/__tests__/cli-integration.test.ts
  10. 1 1
      packages/cli/src/commands/interact.ts
  11. 2 1
      packages/cli/src/tui/tools.ts
  12. 1 1
      packages/core/package.json
  13. 177 0
      packages/core/src/__tests__/agent-session.test.ts
  14. 28 4
      packages/core/src/__tests__/agent-system-prompt.test.ts
  15. 150 0
      packages/core/src/__tests__/agent-tools-params.test.ts
  16. 74 22
      packages/core/src/__tests__/agent-tools.test.ts
  17. 218 12
      packages/core/src/__tests__/book-session-store.test.ts
  18. 38 0
      packages/core/src/__tests__/config-loader.test.ts
  19. 82 0
      packages/core/src/__tests__/context-transform.test.ts
  20. 40 0
      packages/core/src/__tests__/service-presets-regression.test.ts
  21. 77 0
      packages/core/src/__tests__/service-resolver-regression.test.ts
  22. 28 6
      packages/core/src/agent/agent-session.ts
  23. 78 18
      packages/core/src/agent/agent-system-prompt.ts
  24. 105 59
      packages/core/src/agent/agent-tools.ts
  25. 73 0
      packages/core/src/agent/context-transform.ts
  26. 2 1
      packages/core/src/agent/index.ts
  27. 10 1
      packages/core/src/index.ts
  28. 140 18
      packages/core/src/interaction/book-session-store.ts
  29. 4 2
      packages/core/src/interaction/session.ts
  30. 10 4
      packages/core/src/utils/config-loader.ts
  31. 1 1
      packages/studio/package.json
  32. 3 0
      packages/studio/src/App.tsx
  33. 40 0
      packages/studio/src/api/__tests__/normalize-base-url.test.ts
  34. 203 15
      packages/studio/src/api/server.test.ts
  35. 133 37
      packages/studio/src/api/server.ts
  36. 5 2
      packages/studio/src/components/ConfirmDialog.tsx
  37. 293 26
      packages/studio/src/components/Sidebar.tsx
  38. 64 0
      packages/studio/src/hooks/use-session-events.ts
  39. 1 0
      packages/studio/src/hooks/use-sse.ts
  40. 80 20
      packages/studio/src/pages/ChatPage.tsx
  41. 53 8
      packages/studio/src/pages/chat-page-state.test.ts
  42. 12 2
      packages/studio/src/pages/chat-page-state.ts
  43. 11 2
      packages/studio/src/store/chat/selectors.ts
  44. 59 6
      packages/studio/src/store/chat/slices/create/action.ts
  45. 0 1
      packages/studio/src/store/chat/slices/create/initialState.ts
  46. 344 385
      packages/studio/src/store/chat/slices/message/action.ts
  47. 3 4
      packages/studio/src/store/chat/slices/message/initialState.ts
  48. 216 0
      packages/studio/src/store/chat/slices/message/runtime.ts
  49. 269 0
      packages/studio/src/store/chat/slices/message/stream-events.ts
  50. 45 16
      packages/studio/src/store/chat/types.ts
  51. 6 2
      pnpm-lock.yaml

+ 6 - 3
.gitignore

@@ -14,8 +14,6 @@ test-project/
 .gstack/
 .gstack/
 .worktrees/
 .worktrees/
 docs/*
 docs/*
-!docs/plans/
-!docs/plans/**
 novel-to-comic-research.docx
 novel-to-comic-research.docx
 .pnpm-store/
 .pnpm-store/
 tmp/
 tmp/
@@ -23,4 +21,9 @@ autoresearch/
 .playwright-cli/
 .playwright-cli/
 .superpowers/
 .superpowers/
 _*.md
 _*.md
-docs/plans/
+
+# 用户运行时数据(会话记录、书籍内容、项目密钥、提示词模板不属于项目代码)
+.inkos/
+books/
+inkos.json
+prompt/

+ 85 - 0
CHANGELOG.md

@@ -1,5 +1,90 @@
 # Changelog
 # Changelog
 
 
+## v1.3.5
+
+### Improvements
+
+- **Session / Sidebar 体验重构**:Studio 引入 per-session runtime,`pendingBookArgs` 下沉到 session 级,session SSE 监听从 `App.tsx` 抽离;sidebar 支持按书折叠、草稿会话延迟展示、会话列表不再点击重排
+- **会话标题简化**:不再走 LLM 生成标题;第一条用户消息直接成为 session title,并对历史 session 做 lazy migration
+- **Draft Session 工作流**:新建会话延迟到第一条消息才持久化,未发送消息的草稿会话不会落盘,也不会在侧边栏出现
+- **Session 列表性能提升**:`listBookSessions` 改为并发读取并返回轻量 summary,避免侧边栏一次读取大量完整 session 文件
+
+### Bug Fixes
+
+- **模型列表缓存修复**:`/services/:service/models` 的缓存 key 现在包含 `resolvedBaseUrl`,custom 服务切换端点后不再错误复用旧模型列表
+- **会话删除确认弹窗定位**:`ConfirmDialog` 改走 portal,避免被 sidebar 的 containing block 锁在侧栏内
+- **测试清理**:移除 `server.test.ts` 里已废弃的 `updateSessionTitle` mock 残留
+
+## v1.3.4
+
+### Bug Fixes
+
+- **依赖版本钉死**:固定 `@mariozechner/pi-ai` / `pi-agent-core` 到 `0.67.1`,降低 npm 镜像滞后导致全局安装失败的概率
+- **服务探测与模型列表提速**:`GET /models` 回到快路径,`knownModels` 服务不再走慢 probe;`/models` 不可用时会返回服务自己的 `knownModels`
+- **服务验证更可靠**:`/models` 返回 `401/403` 时直接短路;服务详情页保存前先走 `/test` 验 key,页面加载时也会用 `/test` 校验真实连接状态
+- **完整模型列表返回**:服务测试接口不再默认裁成 50 个模型
+
+### Improvements
+
+- **agent 通用文件工具面恢复**:`edit` 回归正常工具面,并新增 `write` 工具用于创建/覆盖写文件,路径仍限制在 `books/` 下
+- **`sub_agent` 最小控制面扩展**:新增 `writer.chapterWordCount`、`reviser.mode`、`exporter.format`、`exporter.approvedOnly`
+- **修订入口统一**:book-mode 下整章修订收敛到 `sub_agent(reviser)`,减少模型在 `revise_chapter` 与 `sub_agent` 之间摇摆
+
+## v1.3.3
+
+### Bug Fixes
+
+- **聊天建书标题显式化**:agent 建书现在要求显式传入 `title`,`initBook` / `book.json` 直接吃结构化标题,不再允许空标题初始化
+- **真实 EPUB 导出统一**:CLI、Studio 下载、共享交互层与 agent exporter 统一复用同一套真实 EPUB 实现,不再出现一条真 EPUB、一条假 HTML、一条未实现的分裂状态
+- **高风险写作动作收口**:book-mode agent 对改设定、改名、局部修文、章节重写/精修优先使用 deterministic 工具,不再默认退回脆弱的通用 `edit`
+
+### Improvements
+
+- **TUI 普通聊天对齐 agent/session**:TUI 的普通输入改走本地 agent session 形式,保留少量本地控制命令 fast-path,进一步向 Studio 的交互模型靠拢
+- **写作控制面更清晰**:agent prompt 明确区分重操作子代理与高风险 deterministic 写作工具,减少“模型理解了,但工具接不住”这类断层
+
+## v1.3.2
+
+### Bug Fixes
+
+- **恢复 `architect` foundation 输出预算**:重新固定 `maxTokens: 16384`,降低本地模型与 LM Studio 在建书阶段因输出截断导致 foundation 缺段的概率
+- **恢复旧的 OpenAI-compatible 兼容路径**:`provider=openai + 自定义兼容 baseUrl` 不再被错误送入更激进的 `custom fetch` 路径,Google/Gemma 一类旧兼容场景回归
+- **自定义 Anthropic-compatible 原生 transport**:`service=custom` 且 `provider=anthropic` 也改走原生请求链,不再强绑 SDK
+- **Windows Studio 启动修复**:`inkos studio` 在 Windows 下不再因绝对路径 loader 被当成非法 ESM URL 而崩溃
+- **Bootstrap 项目回退到 env 配置**:空目录 auto-init 后的 Studio 项目,在未配置服务时会回退到全局 `.inkos/.env`,`book create` 不再先死在缺 key
+- **统一服务路由真相**:`config-loader`、`service-resolver`、Studio 服务探测、`doctor` 统一从同一份 `service-presets` 读取 provider/api/chatBaseUrl/modelsBaseUrl,减少同一服务在不同链路上各猜一遍的问题
+
+### 改进
+
+- **空目录直接启动**:`inkos` / `inkos studio` 现在会自动初始化最小项目骨架并启动 Studio,不再要求显式先跑 `init`
+- **Studio 自动探测 transport**:服务测试会自动尝试候选模型、`chat/responses` 与流式开关组合,尽量自动匹配可用配置
+- **`doctor` 增强**:不再只死盯当前单一模型/单一组合,支持多 model、多协议、多流式探测
+- **建书聊天 fresh session**:再次进入“创建书籍”时会清空旧对话,不再沿用上一次建书聊天记录
+- **聊天模型选择器搜索**:Studio model picker 支持搜索过滤
+- **侧栏刷新更克制**:读操作不再触发无意义 sidebar 刷新,只在写操作后刷新
+- **服务保存流程更真实**:保存 API Key 后会走真实 `/test` 探测,而不是只靠 `/models`
+
+## v1.3.1
+
+### Bug Fixes
+
+- **MiniMax baseUrl 修正**:从 `api.minimax.chat` 更正为 `api.minimaxi.com`(当前 OpenAI 兼容端点)
+- **多服务 baseUrl 隔离**:agent 对话中选择非默认服务时,不再泄漏默认服务的 baseUrl(如 moonshot URL 被错误用于 minimax 请求)
+- **resolveServiceModel 始终使用 preset**:不再直接使用 pi-ai 内置 model 对象(可能指向国际端点或错误的 API 格式),始终用 preset 的 baseUrl 和 api 格式构造 model
+- **agent 建书后侧边栏刷新**:通过 agent 对话建书后,侧边栏书籍列表自动刷新(之前只有 POST /books/create 才广播 `book:created`)
+- **`pnpm dev` 并行启动**:加 `--parallel`,解决 core tsc --watch 阻塞 studio 启动的问题
+
+### 改进
+
+- **MiniMax knownModels**:MiniMax 不支持 `GET /models`,改为硬编码 7 个模型(M2.7/M2.5/M2.1 及其 highspeed 版本 + M2)
+- **测试连接不再发消息**:移除 chat completion 测试,只通过 `/models` + fallback 验证,秒回
+- **custom 服务 URL 自动补 /v1**:`https://example.com`、`https://example.com/`、`https://example.com/v1` 三种写法等价
+- **agent 系统提示词**:禁止 emoji、结构化内容用列表/表格、章节索引管理指引
+
+### 测试
+
+- 新增回归测试:service-presets(MiniMax baseUrl + knownModels)、service-resolver(preset 覆盖 pi-ai)、normalizeBaseUrl
+
 ## v1.3.0
 ## v1.3.0
 
 
 ### Release Focus
 ### Release Focus

+ 59 - 0
CLAUDE.md

@@ -0,0 +1,59 @@
+# InkOS
+
+AI 长篇小说写作平台。monorepo: `packages/core`(运行时)、`packages/cli`(TUI)、`packages/studio`(Web UI / Hono + Vite)。
+
+## 常用命令
+
+```bash
+pnpm dev              # 从根目录启动!core tsc --watch + studio Vite HMR + API server
+pnpm build            # 全量构建
+pnpm test             # vitest 全仓测试
+pnpm --filter @actalk/inkos-core build     # 改了 core 后必须重建(或用 pnpm dev 自动 watch)
+```
+
+## 设计文档
+
+| 文档 | 范围 | 何时读 |
+|------|------|--------|
+| `docs/infra/studio-state-management.md` | Studio Zustand store 架构、slice 约定、目录结构 | 修改 `packages/studio/src/store/` 时 |
+| `docs/infra/studio-routing-and-session.md` | URL hash 路由 + 消息隔离 + pi-ai/pi-agent 集成 | 修改路由、session、LLM provider 时 |
+| `docs/infra/studio-service-management.md` | 服务商管理页设计 | 修改 ConfigView / 服务商配置时 |
+| `docs/superpowers/specs/2026-04-15-sub-agent-params-alignment.md` | sub_agent 工具参数对齐设计 | 修改 agent-tools.ts / sub_agent 工具时 |
+
+## 工作规范
+
+- **不要推测错误** — 遇到报错先读完整错误信息,定位根因后再修,不凭猜测改代码
+- **原子化提交** — 每个 commit 只做一件事,功能和修复分开提交;不要把不相关的改动塞进同一个 commit
+- **每步都跑测试** — 每个 task 完成后必须 `pnpm test` 全量通过,不能攒到最后才跑
+- **防止 maxTokens 回归** — 替换 LLM provider 时必须确认 maxTokens 参数正确传递,不能丢失或硬编码;写测试验证
+- **禁止简写/比喻式表达** — 不用"记账""接住""一等公民""跑全仓""闭环""抓手""赋能""落""落盘""落地""打(接口)"这类简写、借喻或商务黑话。一律用完整、直白的中文句子说清楚到底在做什么。示例:
+  - "打 OpenAI 的 /models" → "向 OpenAI 的 /models 接口**发送请求**"(所有"打 XX 接口 / 打 API"都改为"向 XX 发送请求"或"调用 XX 接口")
+  - "接住错误" → "catch 住异常并写日志"
+  - "三件事分别落到哪里" → "三件事分别存到了项目文件、用户级记忆、本次会话里"
+  - "XX 是一等公民" → "XX 可以像普通值一样被赋值、传参、返回"
+- **说人话,先说清楚再说精简** — 把一件事的来龙去脉先讲明白,比追求"简洁"更重要。宁可多说一句让对方一次看懂(人在做什么、为什么、放到了哪里),也不要为了省字写得像电报,让对方还得反过来问"你说的这个是什么意思"
+- **禁止奉承式开场和英文直译套话** — 不说"你抓得好"("good catch" 直译)、"你说得对"、"好问题"、"非常棒的观察"这类开头。它们既是套话又像拍马屁,没信息量。要承认对方点出的问题就直接说"对,这个我刚也没注意到"或干脆进入正题
+
+## pi-agent 工具设计规范
+
+修改 `packages/core/src/agent/agent-tools.ts` 时必须遵守:
+
+- **schema 即文档** — TypeBox schema 的 `description` 直接传给 LLM,模型靠它理解参数含义;每个字段的 description 必须标注适用的 agent(如 `"reviser only: revision mode"`)
+- **枚举用 Union+Literal** — 有限合法值用 `Type.Union([Type.Literal("a"), Type.Literal("b")])` 而非 `Type.String()`,模型能在 JSON Schema 里看到 `const` 约束,不会传错
+- **开放文本用 String** — 书名、题材等无法穷举的值用 `Type.String({ description: "..." })`
+- **Optional 包裹可选字段** — `Type.Optional(...)` 标记非必填参数
+- **不硬编码 pipeline 参数** — sub_agent 的参数必须与 pipeline runner 方法签名对齐,不能在 execute 里写死值(如 `genre: "general"`)
+- **返回值要可操作** — agent 收到的返回文本必须包含足够信息让它决定下一步(如 auditor 返回 issue 列表而非只返回数量)
+- **错误抛异常** — execute 里不要 catch-and-encode 错误到返回值,throw 由 agent loop 统一处理
+
+## 关键路径
+
+- **Agent 工具**: `core/src/agent/agent-tools.ts` → `SubAgentParams` schema → `createSubAgentTool` → 调用 `PipelineRunner` 方法
+- **建书流程**: `core/interaction/project-tools.ts` → `CREATE_BOOK_TOOL` + `chatWithTools` → Studio `ChatPage` → `BookFormCard`
+- **Studio 入口**: `studio/src/App.tsx` → route 驱动页面切换
+- **状态管理**: Zustand store,LobeHub slice 约定
+  - `studio/src/store/chat/` — 对话消息、创建流程、侧边栏状态
+  - `studio/src/store/service/` — 服务商连接状态、模型列表缓存、model picker 三态(loading/no-models/ready)
+  - **原则**: Zustand selector 只返回 store 中已有的原始值。如果需要 `.filter()` / `.map()` 等产生新数组的派生,用组件内 `useMemo` 而不是 store selector — 否则会造成无限渲染循环
+- **SSE 事件**: `studio/src/hooks/use-sse.ts`(共享 buffer)+ 组件内直连 EventSource(流式渲染)
+- **服务商 preset**: `core/src/llm/service-presets.ts` → baseUrl/api/knownModels/modelsBaseUrl;Anthropic 兼容端点用于 MiniMax 和百炼

+ 1 - 1
README.md

@@ -33,7 +33,7 @@ AI Agent 自主写小说——写、审、改,全程接管。覆盖玄幻、
 > 欢迎加群反馈问题、提出需求,也欢迎关注项目动态 — 我们的目标是做最强的基于小说的内容生态创作 AI Agent。
 > 欢迎加群反馈问题、提出需求,也欢迎关注项目动态 — 我们的目标是做最强的基于小说的内容生态创作 AI Agent。
 
 
 <p align="center">
 <p align="center">
-  <img src="assets/wechat-group-v6.jpg" width="300" alt="微信交流群">
+  <img src="assets/wechat-group-v7.jpg" width="300" alt="微信交流群">
 </p>
 </p>
 
 
 ## 快速开始
 ## 快速开始

BIN
assets/wechat-group-v6.jpg


BIN
assets/wechat-group-v7.jpg


+ 8 - 2
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "inkos",
   "name": "inkos",
-  "version": "1.3.0",
+  "version": "1.3.5",
   "private": true,
   "private": true,
   "description": "Autonomous AI novel writing CLI agent — 10-agent pipeline that writes, audits, and revises novels with continuity tracking",
   "description": "Autonomous AI novel writing CLI agent — 10-agent pipeline that writes, audits, and revises novels with continuity tracking",
   "keywords": [
   "keywords": [
@@ -22,7 +22,7 @@
   },
   },
   "scripts": {
   "scripts": {
     "build": "pnpm -r build",
     "build": "pnpm -r build",
-    "dev": "pnpm -r dev",
+    "dev": "pnpm -r --parallel dev",
     "test": "pnpm -r test",
     "test": "pnpm -r test",
     "lint": "pnpm -r lint",
     "lint": "pnpm -r lint",
     "typecheck": "pnpm -r typecheck",
     "typecheck": "pnpm -r typecheck",
@@ -32,5 +32,11 @@
   "engines": {
   "engines": {
     "node": ">=20.0.0",
     "node": ">=20.0.0",
     "pnpm": ">=9.0.0"
     "pnpm": ">=9.0.0"
+  },
+  "pnpm": {
+    "overrides": {
+      "@mariozechner/pi-ai": "0.67.1",
+      "@mariozechner/pi-agent-core": "0.67.1"
+    }
   }
   }
 }
 }

+ 5 - 1
packages/cli/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@actalk/inkos",
   "name": "@actalk/inkos",
-  "version": "1.3.0",
+  "version": "1.3.5",
   "description": "Autonomous AI novel writing CLI agent — 10-agent pipeline that writes, audits, and revises novels with continuity tracking. Supports LitRPG, Progression Fantasy, Isekai, Romantasy, Sci-Fi and more.",
   "description": "Autonomous AI novel writing CLI agent — 10-agent pipeline that writes, audits, and revises novels with continuity tracking. Supports LitRPG, Progression Fantasy, Isekai, Romantasy, Sci-Fi and more.",
   "keywords": [
   "keywords": [
     "ai-novel-writing",
     "ai-novel-writing",
@@ -61,5 +61,9 @@
     "ink-testing-library": "^4.0.0",
     "ink-testing-library": "^4.0.0",
     "typescript": "^5.8.0",
     "typescript": "^5.8.0",
     "vitest": "^3.0.0"
     "vitest": "^3.0.0"
+  },
+  "overrides": {
+    "@mariozechner/pi-ai": "0.67.1",
+    "@mariozechner/pi-agent-core": "0.67.1"
   }
   }
 }
 }

+ 64 - 41
packages/cli/src/__tests__/cli-integration.test.ts

@@ -12,16 +12,29 @@ const cliEntry = resolve(cliDir, "dist", "index.js");
 
 
 let projectDir: string;
 let projectDir: string;
 
 
+function buildTestEnv(overrides?: Record<string, string>) {
+  const baseEnv = Object.fromEntries(
+    Object.entries(process.env).filter(([key]) =>
+      !key.startsWith("INKOS_")
+      && !key.startsWith("OPENAI_")
+      && !key.startsWith("ANTHROPIC_")
+      && key !== "TAVILY_API_KEY",
+    ),
+  );
+
+  return {
+    ...baseEnv,
+    // Prevent global config from leaking into tests
+    HOME: projectDir,
+    ...overrides,
+  };
+}
+
 function run(args: string[], options?: { env?: Record<string, string> }): string {
 function run(args: string[], options?: { env?: Record<string, string> }): string {
   return execFileSync("node", [cliEntry, ...args], {
   return execFileSync("node", [cliEntry, ...args], {
     cwd: projectDir,
     cwd: projectDir,
     encoding: "utf-8",
     encoding: "utf-8",
-    env: {
-      ...process.env,
-      // Prevent global config from leaking into tests
-      HOME: projectDir,
-      ...options?.env,
-    },
+    env: buildTestEnv(options?.env),
     timeout: 10_000,
     timeout: 10_000,
   });
   });
 }
 }
@@ -31,7 +44,7 @@ function runStderr(args: string[], options?: { env?: Record<string, string> }):
     const stdout = execFileSync("node", [cliEntry, ...args], {
     const stdout = execFileSync("node", [cliEntry, ...args], {
       cwd: projectDir,
       cwd: projectDir,
       encoding: "utf-8",
       encoding: "utf-8",
-      env: { ...process.env, HOME: projectDir, ...options?.env },
+      env: buildTestEnv(options?.env),
       timeout: 10_000,
       timeout: 10_000,
     });
     });
     return { stdout, stderr: "", exitCode: 0 };
     return { stdout, stderr: "", exitCode: 0 };
@@ -195,48 +208,58 @@ describe("CLI integration", () => {
     it("returns structured JSON for shared interaction mode switches", async () => {
     it("returns structured JSON for shared interaction mode switches", async () => {
       const initialized = await stat(join(projectDir, "inkos.json")).then(() => true).catch(() => false);
       const initialized = await stat(join(projectDir, "inkos.json")).then(() => true).catch(() => false);
       if (!initialized) run(["init"]);
       if (!initialized) run(["init"]);
-      await writeFile(
-        join(projectDir, ".env"),
-        Object.entries(failingLlmEnv).map(([key, value]) => `${key}=${value}`).join("\n"),
-        "utf-8",
-      );
-      const output = run(["interact", "--json", "--message", "切换到全自动"]);
-      const data = JSON.parse(output);
+      const envPath = join(projectDir, ".env");
+      const originalEnv = await readFile(envPath, "utf-8");
+      try {
+        await writeFile(
+          envPath,
+          Object.entries(failingLlmEnv).map(([key, value]) => `${key}=${value}`).join("\n"),
+          "utf-8",
+        );
+        const output = run(["interact", "--json", "--message", "切换到全自动"]);
+        const data = JSON.parse(output);
 
 
-      expect(data.request.intent).toBe("switch_mode");
-      expect(data.request.mode).toBe("auto");
-      expect(data.session.automationMode).toBe("auto");
+        expect(data.request.intent).toBe("switch_mode");
+        expect(data.request.mode).toBe("auto");
+        expect(data.session.automationMode).toBe("auto");
+      } finally {
+        await writeFile(envPath, originalEnv, "utf-8");
+      }
     });
     });
 
 
     it("binds the requested book when interact is called with --book", async () => {
     it("binds the requested book when interact is called with --book", async () => {
       const initialized = await stat(join(projectDir, "inkos.json")).then(() => true).catch(() => false);
       const initialized = await stat(join(projectDir, "inkos.json")).then(() => true).catch(() => false);
       if (!initialized) run(["init"]);
       if (!initialized) run(["init"]);
-      await writeFile(
-        join(projectDir, ".env"),
-        Object.entries(failingLlmEnv).map(([key, value]) => `${key}=${value}`).join("\n"),
-        "utf-8",
-      );
-      const state = new StateManager(projectDir);
-      await state.saveBookConfig("harbor", {
-        id: "harbor",
-        title: "Harbor",
-        platform: "tomato",
-        genre: "other",
-        status: "active",
-        targetChapters: 20,
-        chapterWordCount: 3000,
-        createdAt: "2026-04-07T00:00:00.000Z",
-        updatedAt: "2026-04-07T00:00:00.000Z",
-      });
-
-      const output = run(["interact", "--json", "--book", "harbor", "--message", "/books"]);
-      const data = JSON.parse(output);
+      const envPath = join(projectDir, ".env");
+      const originalEnv = await readFile(envPath, "utf-8");
+      try {
+        await writeFile(
+          envPath,
+          Object.entries(failingLlmEnv).map(([key, value]) => `${key}=${value}`).join("\n"),
+          "utf-8",
+        );
+        const state = new StateManager(projectDir);
+        await state.saveBookConfig("harbor", {
+          id: "harbor",
+          title: "Harbor",
+          platform: "tomato",
+          genre: "other",
+          status: "active",
+          targetChapters: 20,
+          chapterWordCount: 3000,
+          createdAt: "2026-04-07T00:00:00.000Z",
+          updatedAt: "2026-04-07T00:00:00.000Z",
+        });
 
 
-      expect(data.session.activeBookId).toBe("harbor");
+        const output = run(["interact", "--json", "--book", "harbor", "--message", "/books"]);
+        const data = JSON.parse(output);
 
 
-      // Clean up harbor book and session so subsequent tests start with an empty project
-      await rm(join(projectDir, "books", "harbor"), { recursive: true, force: true });
-      await rm(join(projectDir, ".inkos-session.json"), { force: true }).catch(() => {});
+        expect(data.session.activeBookId).toBe("harbor");
+      } finally {
+        await writeFile(envPath, originalEnv, "utf-8");
+        await rm(join(projectDir, "books", "harbor"), { recursive: true, force: true });
+        await rm(join(projectDir, ".inkos-session.json"), { force: true }).catch(() => {});
+      }
     });
     });
   });
   });
 
 

+ 1 - 1
packages/cli/src/commands/interact.ts

@@ -77,7 +77,7 @@ export function createInteractCommand(hooks: InteractCommandHooks = {}): Command
         ? await hooks.createTools(projectRoot)
         ? await hooks.createTools(projectRoot)
         : hooks.runInteraction
         : hooks.runInteraction
           ? ({} as InteractionRuntimeTools)
           ? ({} as InteractionRuntimeTools)
-          : await createInteractionTools(projectRoot);
+          : await createInteractionTools(projectRoot, undefined, { requireApiKey: false });
       const runInteraction = hooks.runInteraction ?? processProjectInteractionInput;
       const runInteraction = hooks.runInteraction ?? processProjectInteractionInput;
       const result = await runInteraction({
       const result = await runInteraction({
         projectRoot,
         projectRoot,

+ 2 - 1
packages/cli/src/tui/tools.ts

@@ -40,8 +40,9 @@ export { createInteractionToolsFromDepsCompat as createInteractionToolsFromDeps
 export async function createInteractionTools(
 export async function createInteractionTools(
   projectRoot: string,
   projectRoot: string,
   hooks?: CliInteractionToolHooks,
   hooks?: CliInteractionToolHooks,
+  options?: { readonly requireApiKey?: boolean },
 ): Promise<InteractionRuntimeTools> {
 ): Promise<InteractionRuntimeTools> {
-  const config = await loadConfig({ projectRoot });
+  const config = await loadConfig({ projectRoot, requireApiKey: options?.requireApiKey });
   const pipeline = new PipelineRunner(buildPipelineConfig(config, projectRoot));
   const pipeline = new PipelineRunner(buildPipelineConfig(config, projectRoot));
   const state = new StateManager(projectRoot);
   const state = new StateManager(projectRoot);
   return createInteractionToolsFromDeps(pipeline, state, hooks);
   return createInteractionToolsFromDeps(pipeline, state, hooks);

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@actalk/inkos-core",
   "name": "@actalk/inkos-core",
-  "version": "1.3.0",
+  "version": "1.3.5",
   "description": "InkOS core engine — multi-agent novel writing pipeline with 33-dimension continuity audit, style cloning, and de-AI-ification",
   "description": "InkOS core engine — multi-agent novel writing pipeline with 33-dimension continuity audit, style cloning, and de-AI-ification",
   "keywords": [
   "keywords": [
     "ai-novel-writing",
     "ai-novel-writing",

+ 177 - 0
packages/core/src/__tests__/agent-session.test.ts

@@ -0,0 +1,177 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+const { agentInstances } = vi.hoisted(() => ({
+  agentInstances: [] as any[],
+}));
+
+vi.mock("@mariozechner/pi-agent-core", async () => {
+  const actual = await vi.importActual<any>("@mariozechner/pi-agent-core");
+  class FakeAgent {
+    state: any;
+    transformContext: any;
+    streamFn: any;
+    getApiKey: any;
+    constructor(options: any) {
+      this.state = {
+        model: options.initialState?.model,
+        systemPrompt: options.initialState?.systemPrompt,
+        tools: options.initialState?.tools ?? [],
+        messages: [],
+      };
+      this.transformContext = options.transformContext;
+      this.streamFn = options.streamFn;
+      this.getApiKey = options.getApiKey;
+      agentInstances.push(this);
+    }
+    subscribe() {
+      return () => {};
+    }
+    async prompt(userMessage: string) {
+      const now = Date.now();
+      this.state.messages.push({ role: "user", content: userMessage, timestamp: now });
+      this.state.messages.push({
+        role: "assistant",
+        content: [{ type: "text", text: "ok" }],
+        api: "anthropic-messages",
+        provider: "anthropic",
+        model: "fake",
+        usage: {
+          input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
+          cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+        },
+        stopReason: "stop",
+        timestamp: now + 1,
+      });
+    }
+  }
+  return { ...actual, Agent: FakeAgent };
+});
+
+vi.mock("@mariozechner/pi-ai", async () => {
+  const actual = await vi.importActual<any>("@mariozechner/pi-ai");
+  return {
+    ...actual,
+    streamSimple: vi.fn(),
+    getEnvApiKey: vi.fn(() => "fake-key"),
+    getModel: vi.fn((provider: string, id: string) => ({
+      provider,
+      id,
+      api: "anthropic-messages",
+    })),
+  };
+});
+
+import { runAgentSession, evictAgentCache } from "../agent/agent-session.js";
+
+describe("runAgentSession cache — bookId switch", () => {
+  let projectRoot: string;
+
+  beforeEach(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), "inkos-agent-cache-"));
+    await mkdir(join(projectRoot, "books", "book-a", "story"), { recursive: true });
+    await writeFile(
+      join(projectRoot, "books", "book-a", "story", "story_bible.md"),
+      "书A 的真相",
+    );
+    await mkdir(join(projectRoot, "books", "book-b", "story"), { recursive: true });
+    await writeFile(
+      join(projectRoot, "books", "book-b", "story", "story_bible.md"),
+      "书B 的真相",
+    );
+    agentInstances.length = 0;
+  });
+
+  afterEach(async () => {
+    evictAgentCache("s1");
+    await rm(projectRoot, { recursive: true, force: true });
+  });
+
+  it("rebuilds Agent when bookId changes for same sessionId", async () => {
+    const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
+    const pipeline = {} as any;
+
+    await runAgentSession(
+      { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
+      "earlier question about book A",
+    );
+    expect(agentInstances).toHaveLength(1);
+
+    await runAgentSession(
+      { sessionId: "s1", bookId: "book-b", language: "zh", pipeline, projectRoot, model },
+      "new question",
+    );
+
+    expect(agentInstances).toHaveLength(2);
+
+    const injected = await agentInstances[1].transformContext([]);
+    const body = JSON.stringify(injected);
+    expect(body).toContain("书B 的真相");
+    expect(body).not.toContain("书A 的真相");
+
+    // Prior conversation must be replayed into the rebuilt Agent via initialMessages,
+    // not silently dropped — this is the whole reason eviction preserves messages.
+    const preservedUser = agentInstances[1].state.messages.find(
+      (m: any) => m.role === "user" && typeof m.content === "string" && m.content.includes("earlier question about book A"),
+    );
+    expect(preservedUser).toBeDefined();
+  });
+
+  it("rebuilds Agent when bookId goes from null to a real book", async () => {
+    const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
+    const pipeline = {} as any;
+
+    await runAgentSession(
+      { sessionId: "s1", bookId: null, language: "zh", pipeline, projectRoot, model },
+      "hi",
+    );
+    expect(agentInstances).toHaveLength(1);
+
+    await runAgentSession(
+      { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
+      "hi",
+    );
+
+    expect(agentInstances).toHaveLength(2);
+    const injected = await agentInstances[1].transformContext([]);
+    expect(JSON.stringify(injected)).toContain("书A 的真相");
+  });
+
+  it("treats undefined bookId as null (no spurious rebuild)", async () => {
+    const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
+    const pipeline = {} as any;
+
+    await runAgentSession(
+      { sessionId: "s1", bookId: null, language: "zh", pipeline, projectRoot, model },
+      "hi",
+    );
+    expect(agentInstances).toHaveLength(1);
+
+    // A caller passing `undefined` (e.g., `activeBookId` not set and no `?? null`
+    // guard) must not cause an eviction when the cached agent already holds null.
+    await runAgentSession(
+      { sessionId: "s1", bookId: undefined as any, language: "zh", pipeline, projectRoot, model },
+      "hi",
+    );
+
+    expect(agentInstances).toHaveLength(1);
+  });
+
+  it("reuses Agent when bookId unchanged on same sessionId", async () => {
+    const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
+    const pipeline = {} as any;
+
+    await runAgentSession(
+      { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
+      "hi",
+    );
+    await runAgentSession(
+      { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
+      "hi2",
+    );
+
+    expect(agentInstances).toHaveLength(1);
+  });
+});

+ 28 - 4
packages/core/src/__tests__/agent-system-prompt.test.ts

@@ -51,22 +51,36 @@ describe("buildAgentSystemPrompt", () => {
       expect(prompt).toContain("writer");
       expect(prompt).toContain("writer");
       expect(prompt).toContain("auditor");
       expect(prompt).toContain("auditor");
       expect(prompt).toContain("reviser");
       expect(prompt).toContain("reviser");
+      expect(prompt).toContain("chapterWordCount");
+      expect(prompt).toContain("mode");
+      expect(prompt).toContain("approvedOnly");
       expect(prompt).toContain("read");
       expect(prompt).toContain("read");
-      expect(prompt).toContain("revise_chapter");
       expect(prompt).toContain("write_truth_file");
       expect(prompt).toContain("write_truth_file");
       expect(prompt).toContain("rename_entity");
       expect(prompt).toContain("rename_entity");
       expect(prompt).toContain("patch_chapter_text");
       expect(prompt).toContain("patch_chapter_text");
+      expect(prompt).toContain("edit");
+      expect(prompt).toContain("write");
       expect(prompt).toContain("grep");
       expect(prompt).toContain("grep");
       expect(prompt).toContain("ls");
       expect(prompt).toContain("ls");
     });
     });
 
 
-    it("with-book prompt steers high-risk edits to dedicated deterministic tools", () => {
+    it("Chinese prompt does NOT mention revise_chapter (merged into sub_agent reviser)", () => {
+      const prompt = buildAgentSystemPrompt("my-book", "zh");
+      expect(prompt).not.toContain("revise_chapter");
+    });
+
+    it("English prompt does NOT mention revise_chapter (merged into sub_agent reviser)", () => {
+      const prompt = buildAgentSystemPrompt("novel", "en");
+      expect(prompt).not.toContain("revise_chapter");
+    });
+
+    it("with-book prompt steers chapter rewrites to sub_agent reviser, not a separate tool", () => {
       const prompt = buildAgentSystemPrompt("my-book", "zh");
       const prompt = buildAgentSystemPrompt("my-book", "zh");
       expect(prompt).toContain("改设定/改真相文件");
       expect(prompt).toContain("改设定/改真相文件");
       expect(prompt).toContain("write_truth_file");
       expect(prompt).toContain("write_truth_file");
       expect(prompt).toContain("用户要求重写/精修已有章节");
       expect(prompt).toContain("用户要求重写/精修已有章节");
-      expect(prompt).toContain("revise_chapter");
-      expect(prompt).not.toContain("edit");
+      expect(prompt).toContain("reviser");
+      expect(prompt).toContain("也可以直接使用 edit / write");
     });
     });
 
 
     it("Chinese prompt warns NOT to call architect", () => {
     it("Chinese prompt warns NOT to call architect", () => {
@@ -94,5 +108,15 @@ describe("buildAgentSystemPrompt", () => {
       // architect 不在可用工具列表里
       // architect 不在可用工具列表里
       expect(prompt).not.toMatch(/agent="architect"/);
       expect(prompt).not.toMatch(/agent="architect"/);
     });
     });
+
+    it("book-mode prompt documents all sub_agent params", () => {
+      const prompt = buildAgentSystemPrompt("test-book", "zh");
+      expect(prompt).toContain("chapterWordCount");
+      expect(prompt).toContain("chapterNumber");
+      expect(prompt).toContain("mode");
+      expect(prompt).toContain("anti-detect");
+      expect(prompt).toContain("format");
+      expect(prompt).toContain("approvedOnly");
+    });
   });
   });
 });
 });

+ 150 - 0
packages/core/src/__tests__/agent-tools-params.test.ts

@@ -0,0 +1,150 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { createSubAgentTool } from "../agent/agent-tools.js";
+
+describe("SubAgentParams schema", () => {
+  const mockPipeline = {} as any;
+  const tool = createSubAgentTool(mockPipeline, null);
+  const schema = tool.parameters;
+  const props = (schema as any).properties;
+
+  it("has architect params: title, genre, platform, language, targetChapters", () => {
+    expect(props.title).toBeDefined();
+    expect(props.genre).toBeDefined();
+    expect(props.platform).toBeDefined();
+    expect(props.language).toBeDefined();
+    expect(props.targetChapters).toBeDefined();
+  });
+
+  it("has writer/architect param: chapterWordCount", () => {
+    expect(props.chapterWordCount).toBeDefined();
+  });
+
+  it("has reviser param: mode", () => {
+    expect(props.mode).toBeDefined();
+  });
+
+  it("has exporter params: format, approvedOnly", () => {
+    expect(props.format).toBeDefined();
+    expect(props.approvedOnly).toBeDefined();
+  });
+
+  it("has existing params: agent, instruction, bookId, chapterNumber", () => {
+    expect(props.agent).toBeDefined();
+    expect(props.instruction).toBeDefined();
+    expect(props.bookId).toBeDefined();
+    expect(props.chapterNumber).toBeDefined();
+  });
+
+  it("all new params have description with agent scope", () => {
+    expect(props.title.description).toMatch(/architect/i);
+    expect(props.genre.description).toMatch(/architect/i);
+    expect(props.mode.description).toMatch(/reviser/i);
+    expect(props.format.description).toMatch(/exporter/i);
+  });
+});
+
+describe("architect agent — BookConfig construction", () => {
+  let initBookMock: ReturnType<typeof vi.fn>;
+  let tool: ReturnType<typeof createSubAgentTool>;
+
+  beforeEach(() => {
+    initBookMock = vi.fn(async () => {});
+    const mockPipeline = { initBook: initBookMock } as any;
+    tool = createSubAgentTool(mockPipeline, null);
+  });
+
+  it("passes complete BookConfig with schema params", async () => {
+    await tool.execute("tc1", {
+      agent: "architect",
+      instruction: "Create a xuanhuan novel",
+      title: "天道独行",
+      genre: "xuanhuan",
+      platform: "tomato",
+      language: "zh",
+      targetChapters: 100,
+      chapterWordCount: 4000,
+    });
+    expect(initBookMock).toHaveBeenCalledOnce();
+    const [bookConfig, options] = initBookMock.mock.calls[0];
+    expect(bookConfig.title).toBe("天道独行");
+    expect(bookConfig.genre).toBe("xuanhuan");
+    expect(bookConfig.platform).toBe("tomato");
+    expect(bookConfig.language).toBe("zh");
+    expect(bookConfig.targetChapters).toBe(100);
+    expect(bookConfig.chapterWordCount).toBe(4000);
+    expect(bookConfig.status).toBe("outlining");
+    expect(bookConfig.createdAt).toBeDefined();
+    expect(options.externalContext).toBe("Create a xuanhuan novel");
+  });
+
+  it("uses defaults when optional params are omitted", async () => {
+    await tool.execute("tc2", { agent: "architect", instruction: "Create a book", title: "Test Book" });
+    const [bookConfig] = initBookMock.mock.calls[0];
+    expect(bookConfig.genre).toBe("general");
+    expect(bookConfig.platform).toBe("other");
+    expect(bookConfig.language).toBe("zh");
+    expect(bookConfig.targetChapters).toBe(200);
+    expect(bookConfig.chapterWordCount).toBe(3000);
+  });
+});
+
+describe("writer agent — wordCount passthrough", () => {
+  let writeNextChapterMock: ReturnType<typeof vi.fn>;
+  let tool: ReturnType<typeof createSubAgentTool>;
+
+  beforeEach(() => {
+    writeNextChapterMock = vi.fn(async () => ({ wordCount: 3000 }));
+    const mockPipeline = { writeNextChapter: writeNextChapterMock } as any;
+    tool = createSubAgentTool(mockPipeline, "existing-book");
+  });
+
+  it("passes chapterWordCount as wordCount", async () => {
+    await tool.execute("tc1", { agent: "writer", instruction: "Write", bookId: "my-book", chapterWordCount: 5000 });
+    expect(writeNextChapterMock).toHaveBeenCalledWith("my-book", 5000);
+  });
+
+  it("passes undefined when chapterWordCount omitted", async () => {
+    await tool.execute("tc2", { agent: "writer", instruction: "Write", bookId: "my-book" });
+    expect(writeNextChapterMock).toHaveBeenCalledWith("my-book", undefined);
+  });
+});
+
+describe("auditor agent — rich return value", () => {
+  it("returns issue details with severity", async () => {
+    const auditDraftMock = vi.fn(async () => ({
+      chapterNumber: 3, passed: false,
+      issues: [
+        { severity: "warning", description: "Pacing too fast" },
+        { severity: "critical", description: "Name inconsistency" },
+      ],
+    }));
+    const tool = createSubAgentTool({ auditDraft: auditDraftMock } as any, "book");
+    const result = await tool.execute("tc1", { agent: "auditor", instruction: "Audit", bookId: "my-book", chapterNumber: 3 });
+    const text = (result.content[0] as { type: "text"; text: string }).text;
+    expect(text).toContain("FAILED");
+    expect(text).toContain("2 issue(s)");
+    expect(text).toContain("[warning]");
+    expect(text).toContain("[critical]");
+    expect(text).toContain("Pacing too fast");
+  });
+});
+
+describe("reviser agent — mode field", () => {
+  let reviseDraftMock: ReturnType<typeof vi.fn>;
+  let tool: ReturnType<typeof createSubAgentTool>;
+
+  beforeEach(() => {
+    reviseDraftMock = vi.fn(async () => ({}));
+    tool = createSubAgentTool({ reviseDraft: reviseDraftMock } as any, "book");
+  });
+
+  it("uses mode param directly", async () => {
+    await tool.execute("tc1", { agent: "reviser", instruction: "Fix", bookId: "my-book", chapterNumber: 5, mode: "anti-detect" });
+    expect(reviseDraftMock).toHaveBeenCalledWith("my-book", 5, "anti-detect");
+  });
+
+  it("defaults to spot-fix", async () => {
+    await tool.execute("tc2", { agent: "reviser", instruction: "Fix", bookId: "my-book" });
+    expect(reviseDraftMock).toHaveBeenCalledWith("my-book", undefined, "spot-fix");
+  });
+});

+ 74 - 22
packages/core/src/__tests__/agent-tools.test.ts

@@ -7,7 +7,7 @@ import {
   createSubAgentTool,
   createSubAgentTool,
   createPatchChapterTextTool,
   createPatchChapterTextTool,
   createRenameEntityTool,
   createRenameEntityTool,
-  createReviseChapterTool,
+  createWriteFileTool,
   createWriteTruthFileTool,
   createWriteTruthFileTool,
 } from "../agent/agent-tools.js";
 } from "../agent/agent-tools.js";
 
 
@@ -64,27 +64,6 @@ describe("agent deterministic writing tools", () => {
       .resolves.toContain("distrusts the guild");
       .resolves.toContain("distrusts the guild");
   });
   });
 
 
-  it("routes chapter rewrite requests through reviseDraft with the requested mode", async () => {
-    const pipeline = {
-      reviseDraft: vi.fn(async () => ({
-        chapterNumber: 3,
-        wordCount: 1300,
-        fixedIssues: [],
-        applied: true,
-        status: "ready-for-review" as const,
-      })),
-    };
-    const tool = createReviseChapterTool(pipeline as never, "harbor");
-
-    const result = await tool.execute("tool-2", {
-      chapterNumber: 3,
-      mode: "rewrite",
-    });
-
-    expect(pipeline.reviseDraft).toHaveBeenCalledWith("harbor", 3, "rewrite");
-    expect(result.content[0]?.type).toBe("text");
-  });
-
   it("renames entities through the deterministic edit controller", async () => {
   it("renames entities through the deterministic edit controller", async () => {
     const tool = createRenameEntityTool({} as never, root, "harbor");
     const tool = createRenameEntityTool({} as never, root, "harbor");
 
 
@@ -160,4 +139,77 @@ describe("agent deterministic writing tools", () => {
       }),
       }),
     );
     );
   });
   });
+
+  it("passes chapterWordCount through the writer sub-agent", async () => {
+    const pipeline = {
+      writeNextChapter: vi.fn(async () => ({
+        chapterNumber: 4,
+        wordCount: 2600,
+      })),
+    };
+    const tool = createSubAgentTool(pipeline as never, "harbor");
+
+    await tool.execute("tool-7", {
+      agent: "writer",
+      bookId: "harbor",
+      chapterWordCount: 2600,
+      instruction: "继续写,控制在 2600 字",
+    } as any);
+
+    expect(pipeline.writeNextChapter).toHaveBeenCalledWith("harbor", 2600);
+  });
+
+  it("prefers explicit reviser mode over instruction guessing", async () => {
+    const pipeline = {
+      reviseDraft: vi.fn(async () => ({
+        chapterNumber: 3,
+        wordCount: 120,
+        fixedIssues: [],
+        applied: true,
+        status: "ready-for-review" as const,
+      })),
+    };
+    const tool = createSubAgentTool(pipeline as never, "harbor");
+
+    await tool.execute("tool-8", {
+      agent: "reviser",
+      bookId: "harbor",
+      chapterNumber: 3,
+      mode: "spot-fix",
+      instruction: "重写第3章",
+    } as any);
+
+    expect(pipeline.reviseDraft).toHaveBeenCalledWith("harbor", 3, "spot-fix");
+  });
+
+  it("uses explicit exporter params instead of guessing from instruction", async () => {
+    const pipeline = {};
+    const tool = createSubAgentTool(pipeline as never, "harbor", root);
+
+    const result = await tool.execute("tool-9", {
+      agent: "exporter",
+      bookId: "harbor",
+      format: "md",
+      approvedOnly: false,
+      instruction: "导出成 epub",
+    } as any);
+
+    expect(result.content[0]?.type).toBe("text");
+    if (result.content[0]?.type === "text") {
+      expect(result.content[0].text).toContain(".md");
+    }
+  });
+
+  it("creates nested files through the generic write tool", async () => {
+    const tool = createWriteFileTool(root);
+
+    const result = await tool.execute("tool-10", {
+      path: "harbor/story/runtime/notes.md",
+      content: "# Notes\n\nWatch the harbor ledger.\n",
+    });
+
+    expect(result.content[0]?.type).toBe("text");
+    await expect(readFile(join(state.bookDir("harbor"), "story", "runtime", "notes.md"), "utf-8"))
+      .resolves.toContain("Watch the harbor ledger");
+  });
 });
 });

+ 218 - 12
packages/core/src/__tests__/book-session-store.test.ts

@@ -6,9 +6,14 @@ import {
   loadBookSession,
   loadBookSession,
   persistBookSession,
   persistBookSession,
   listBookSessions,
   listBookSessions,
-  findOrCreateBookSession,
+  renameBookSession,
+  deleteBookSession,
+  migrateBookSession,
+  extractFirstUserMessageTitle,
+  SessionAlreadyMigratedError,
 } from "../interaction/book-session-store.js";
 } from "../interaction/book-session-store.js";
 import { createBookSession, appendBookSessionMessage } from "../interaction/session.js";
 import { createBookSession, appendBookSessionMessage } from "../interaction/session.js";
+import { mkdir, writeFile } from "node:fs/promises";
 
 
 describe("book-session-store", () => {
 describe("book-session-store", () => {
   let tempDir: string;
   let tempDir: string;
@@ -44,6 +49,39 @@ describe("book-session-store", () => {
       expect(loaded!.messages).toHaveLength(1);
       expect(loaded!.messages).toHaveLength(1);
       expect(loaded!.messages[0].content).toBe("test");
       expect(loaded!.messages[0].content).toBe("test");
     });
     });
+
+    it("createBookSession initializes title as null", () => {
+      const session = createBookSession("book");
+      expect(session.title).toBeNull();
+    });
+
+    it("parses old session files without title field", async () => {
+      const oldFormat = {
+        sessionId: "old-session",
+        bookId: "book",
+        messages: [],
+        draftRounds: [],
+        events: [],
+        createdAt: 1000,
+        updatedAt: 1000,
+      };
+      const dir = join(tempDir, ".inkos", "sessions");
+      await mkdir(dir, { recursive: true });
+      await writeFile(join(dir, "old-session.json"), JSON.stringify(oldFormat));
+
+      const loaded = await loadBookSession(tempDir, "old-session");
+      expect(loaded).not.toBeNull();
+      expect(loaded!.title).toBeNull();
+    });
+
+    it("round-trips title through persist/load", async () => {
+      let session = createBookSession("book");
+      session = { ...session, title: "测试标题" };
+      await persistBookSession(tempDir, session);
+
+      const loaded = await loadBookSession(tempDir, session.sessionId);
+      expect(loaded!.title).toBe("测试标题");
+    });
   });
   });
 
 
   describe("listBookSessions", () => {
   describe("listBookSessions", () => {
@@ -90,20 +128,188 @@ describe("book-session-store", () => {
     });
     });
   });
   });
 
 
-  describe("findOrCreateBookSession", () => {
-    it("creates new if none exist", async () => {
-      const session = await findOrCreateBookSession(tempDir, "new-book");
-      expect(session.bookId).toBe("new-book");
-      // Verify it was persisted
+  describe("renameBookSession", () => {
+    it("sets title and updates updatedAt", async () => {
+      const session = createBookSession("book");
+      await persistBookSession(tempDir, session);
+      const oldUpdatedAt = session.updatedAt;
+
+      await new Promise((resolve) => setTimeout(resolve, 5));
+      await renameBookSession(tempDir, session.sessionId, "新标题");
+
       const loaded = await loadBookSession(tempDir, session.sessionId);
       const loaded = await loadBookSession(tempDir, session.sessionId);
-      expect(loaded).not.toBeNull();
+      expect(loaded!.title).toBe("新标题");
+      expect(loaded!.updatedAt).toBeGreaterThan(oldUpdatedAt);
     });
     });
 
 
-    it("returns existing if found", async () => {
-      const existing = createBookSession("book");
-      await persistBookSession(tempDir, existing);
-      const found = await findOrCreateBookSession(tempDir, "book");
-      expect(found.sessionId).toBe(existing.sessionId);
+    it("returns null for non-existent session", async () => {
+      const result = await renameBookSession(tempDir, "nonexistent", "title");
+      expect(result).toBeNull();
+    });
+  });
+
+  describe("deleteBookSession", () => {
+    it("removes session file", async () => {
+      const session = createBookSession("book");
+      await persistBookSession(tempDir, session);
+
+      await deleteBookSession(tempDir, session.sessionId);
+
+      const loaded = await loadBookSession(tempDir, session.sessionId);
+      expect(loaded).toBeNull();
+    });
+
+    it("does nothing for non-existent session", async () => {
+      await expect(deleteBookSession(tempDir, "nonexistent")).resolves.toBeUndefined();
+    });
+  });
+
+  describe("extractFirstUserMessageTitle", () => {
+    it("returns null when messages array is empty", () => {
+      expect(extractFirstUserMessageTitle([])).toBeNull();
+    });
+
+    it("returns null when no user message exists", () => {
+      expect(extractFirstUserMessageTitle([
+        { role: "assistant", content: "hi" },
+        { role: "system", content: "prompt" },
+      ])).toBeNull();
+    });
+
+    it("picks the first user message content", () => {
+      expect(extractFirstUserMessageTitle([
+        { role: "system", content: "sys" },
+        { role: "user", content: "第一条提问" },
+        { role: "assistant", content: "回答" },
+        { role: "user", content: "第二条提问" },
+      ])).toBe("第一条提问");
+    });
+
+    it("collapses whitespace into single spaces", () => {
+      expect(extractFirstUserMessageTitle([
+        { role: "user", content: "多行\n\n内容   有空格" },
+      ])).toBe("多行 内容 有空格");
+    });
+
+    it("truncates content longer than 20 chars with ellipsis", () => {
+      expect(extractFirstUserMessageTitle([
+        { role: "user", content: "这是一段超过二十个字符的很长的提问内容会被截断" },
+      ])).toBe("这是一段超过二十个字符的很长的提问内容会…");
+    });
+
+    it("returns null when content is only whitespace", () => {
+      expect(extractFirstUserMessageTitle([
+        { role: "user", content: "   \n\t   " },
+      ])).toBeNull();
+    });
+
+    it("returns null for non-array input", () => {
+      expect(extractFirstUserMessageTitle(null)).toBeNull();
+      expect(extractFirstUserMessageTitle(undefined)).toBeNull();
+      expect(extractFirstUserMessageTitle("not array")).toBeNull();
+    });
+  });
+
+  describe("listBookSessions: 老 session lazy migration", () => {
+    it("把 title 为 null 但已有用户消息的老 session 补写 title 并持久化", async () => {
+      const session = {
+        ...createBookSession("book-a"),
+        title: null,
+        messages: [
+          { role: "user" as const, content: "帮我写下一章", timestamp: 100 },
+          { role: "assistant" as const, content: "好的,正在构思...", timestamp: 200 },
+        ],
+      };
+      await persistBookSession(tempDir, session);
+
+      // 触发 list → 应该迁移
+      const list = await listBookSessions(tempDir, "book-a");
+      expect(list).toHaveLength(1);
+      expect(list[0].title).toBe("帮我写下一章");
+
+      // 验证已经落盘
+      const reloaded = await loadBookSession(tempDir, session.sessionId);
+      expect(reloaded!.title).toBe("帮我写下一章");
+    });
+
+    it("不覆盖已有 title 的 session", async () => {
+      let session = createBookSession("book-a");
+      session = {
+        ...session,
+        title: "原有标题",
+        messages: [
+          { role: "user" as const, content: "后来的消息", timestamp: 100 },
+        ],
+      };
+      await persistBookSession(tempDir, session);
+
+      const list = await listBookSessions(tempDir, "book-a");
+      expect(list[0].title).toBe("原有标题");
+
+      const reloaded = await loadBookSession(tempDir, session.sessionId);
+      expect(reloaded!.title).toBe("原有标题");
+    });
+
+    it("没有用户消息的 session:title 保持 null,不 persist", async () => {
+      const session = createBookSession("book-a");
+      await persistBookSession(tempDir, session);
+      const originalUpdatedAt = session.updatedAt;
+
+      const list = await listBookSessions(tempDir, "book-a");
+      expect(list[0].title).toBeNull();
+
+      const reloaded = await loadBookSession(tempDir, session.sessionId);
+      expect(reloaded!.title).toBeNull();
+      expect(reloaded!.updatedAt).toBe(originalUpdatedAt);
+    });
+
+    it("多条老 session 同时迁移", async () => {
+      const s1 = {
+        ...createBookSession("book-b"),
+        title: null,
+        messages: [{ role: "user" as const, content: "问题一", timestamp: 1 }],
+      };
+      const s2 = {
+        ...createBookSession("book-b"),
+        title: null,
+        messages: [{ role: "user" as const, content: "问题二", timestamp: 1 }],
+      };
+      await persistBookSession(tempDir, s1);
+      await persistBookSession(tempDir, s2);
+
+      const list = await listBookSessions(tempDir, "book-b");
+      expect(list).toHaveLength(2);
+      const titles = new Set(list.map((s) => s.title));
+      expect(titles).toEqual(new Set(["问题一", "问题二"]));
+    });
+  });
+
+  describe("migrateBookSession", () => {
+    it("binds an orphan session to a book", async () => {
+      const session = createBookSession(null);
+      await persistBookSession(tempDir, session);
+      const oldUpdatedAt = session.updatedAt;
+
+      await new Promise((resolve) => setTimeout(resolve, 5));
+      const migrated = await migrateBookSession(tempDir, session.sessionId, "book-1");
+
+      expect(migrated).not.toBeNull();
+      expect(migrated!.bookId).toBe("book-1");
+      expect(migrated!.updatedAt).toBeGreaterThan(oldUpdatedAt);
+    });
+
+    it("returns null for non-existent session", async () => {
+      const result = await migrateBookSession(tempDir, "nonexistent", "book-1");
+      expect(result).toBeNull();
+    });
+
+    it("throws when session is already bound to a book", async () => {
+      const session = createBookSession("book-1");
+      await persistBookSession(tempDir, session);
+
+      await expect(migrateBookSession(tempDir, session.sessionId, "book-2")).rejects.toBeInstanceOf(
+        SessionAlreadyMigratedError,
+      );
     });
     });
   });
   });
 });
 });

+ 38 - 0
packages/core/src/__tests__/config-loader.test.ts

@@ -229,4 +229,42 @@ describe("loadProjectConfig local provider auth", () => {
     expect(config.llm.model).toBe("corp-chat");
     expect(config.llm.model).toBe("corp-chat");
     expect(config.llm.apiKey).toBe("sk-corp");
     expect(config.llm.apiKey).toBe("sk-corp");
   });
   });
+
+  it("falls back to env when Studio config is still the empty bootstrap state", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-config-loader-studio-bootstrap-"));
+    for (const key of ENV_KEYS) {
+      previousEnv.set(key, process.env[key]);
+      process.env[key] = "";
+    }
+
+    process.env.INKOS_LLM_PROVIDER = "openai";
+    process.env.INKOS_LLM_BASE_URL = "https://api-vip.codex-for.me/v1";
+    process.env.INKOS_LLM_MODEL = "gpt-5.4";
+    process.env.INKOS_LLM_API_KEY = "sk-env";
+
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      name: "studio-bootstrap-project",
+      version: "0.1.0",
+      language: "zh",
+      llm: {
+        provider: "openai",
+        service: "custom",
+        configSource: "studio",
+        baseUrl: "",
+        model: "",
+        apiFormat: "chat",
+        stream: true,
+      },
+      notify: [],
+    }, null, 2), "utf-8");
+    await writeFile(join(root, ".env"), "", "utf-8");
+
+    const config = await loadProjectConfig(root);
+
+    expect(config.llm.configSource).toBe("studio");
+    expect(config.llm.provider).toBe("openai");
+    expect(config.llm.baseUrl).toBe("https://api-vip.codex-for.me/v1");
+    expect(config.llm.model).toBe("gpt-5.4");
+    expect(config.llm.apiKey).toBe("sk-env");
+  });
 });
 });

+ 82 - 0
packages/core/src/__tests__/context-transform.test.ts

@@ -0,0 +1,82 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { createBookContextTransform } from "../agent/context-transform.js";
+
+describe("createBookContextTransform", () => {
+  let projectRoot: string;
+  const bookId = "test-book";
+
+  beforeEach(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), "ctx-test-"));
+    const storyDir = join(projectRoot, "books", bookId, "story");
+    await mkdir(storyDir, { recursive: true });
+    await writeFile(join(storyDir, "story_bible.md"), "# Story Bible\nA hero's journey.");
+    await writeFile(join(storyDir, "current_focus.md"), "Focus on chapter 3.");
+  });
+
+  afterEach(async () => {
+    await rm(projectRoot, { recursive: true, force: true });
+  });
+
+  it("returns messages unchanged when bookId is null", async () => {
+    const transform = createBookContextTransform(null, projectRoot);
+    const messages = [
+      { role: "user" as const, content: "hello", timestamp: Date.now() },
+    ];
+    const result = await transform(messages);
+    expect(result).toBe(messages);
+  });
+
+  it("prepends a user message with truth file contents", async () => {
+    const transform = createBookContextTransform(bookId, projectRoot);
+    const original = [
+      { role: "user" as const, content: "写下一章", timestamp: Date.now() },
+    ];
+    const result = await transform(original);
+
+    expect(original).toHaveLength(1);
+    expect(result).toHaveLength(2);
+    const injected = result[0] as { role: string; content: string };
+    expect(injected.role).toBe("user");
+    expect(injected.content).toContain("story_bible.md");
+    expect(injected.content).toContain("A hero's journey.");
+    expect(injected.content).toContain("current_focus.md");
+    expect(injected.content).toContain("Focus on chapter 3.");
+    expect(result[1]).toBe(original[0]);
+  });
+
+  it("sorts truth files in priority order", async () => {
+    const storyDir = join(projectRoot, "books", bookId, "story");
+    await writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline");
+    await writeFile(join(storyDir, "book_rules.md"), "# Book Rules");
+    await writeFile(join(storyDir, "extra_notes.md"), "# Extra");
+
+    const transform = createBookContextTransform(bookId, projectRoot);
+    const result = await transform([
+      { role: "user" as const, content: "test", timestamp: Date.now() },
+    ]);
+    const content = (result[0] as { content: string }).content;
+
+    const bibleIdx = content.indexOf("story_bible.md");
+    const outlineIdx = content.indexOf("volume_outline.md");
+    const rulesIdx = content.indexOf("book_rules.md");
+    const focusIdx = content.indexOf("current_focus.md");
+    const extraIdx = content.indexOf("extra_notes.md");
+
+    expect(bibleIdx).toBeLessThan(outlineIdx);
+    expect(outlineIdx).toBeLessThan(rulesIdx);
+    expect(rulesIdx).toBeLessThan(focusIdx);
+    expect(focusIdx).toBeLessThan(extraIdx);
+  });
+
+  it("returns original messages when story/ directory does not exist", async () => {
+    const transform = createBookContextTransform("nonexistent-book", projectRoot);
+    const original = [
+      { role: "user" as const, content: "test", timestamp: Date.now() },
+    ];
+    const result = await transform(original);
+    expect(result).toBe(original);
+  });
+});

+ 40 - 0
packages/core/src/__tests__/service-presets-regression.test.ts

@@ -0,0 +1,40 @@
+import { describe, it, expect } from "vitest";
+import { resolveServicePreset, listModelsForService } from "../llm/service-presets.js";
+
+describe("service-presets regression", () => {
+  describe("MiniMax preset", () => {
+    it("has correct domestic MiniMax baseUrl (api.minimaxi.com/anthropic)", () => {
+      const preset = resolveServicePreset("minimax");
+      expect(preset).toBeDefined();
+      expect(preset!.baseUrl).toBe("https://api.minimaxi.com/anthropic");
+    });
+
+    it("uses anthropic-messages api format", () => {
+      const preset = resolveServicePreset("minimax");
+      expect(preset!.api).toBe("anthropic-messages");
+    });
+
+    it("has knownModels with all 7 MiniMax models", () => {
+      const preset = resolveServicePreset("minimax");
+      expect(preset!.knownModels).toBeDefined();
+      expect(preset!.knownModels).toHaveLength(7);
+      expect(preset!.knownModels).toContain("MiniMax-M2.7");
+      expect(preset!.knownModels).toContain("MiniMax-M2.7-highspeed");
+      expect(preset!.knownModels).toContain("MiniMax-M2.5");
+      expect(preset!.knownModels).toContain("MiniMax-M2");
+    });
+  });
+
+  describe("listModelsForService", () => {
+    it("returns knownModels immediately for minimax without calling /models API", async () => {
+      const models = await listModelsForService("minimax");
+      expect(models.length).toBe(7);
+      expect(models[0].id).toBe("MiniMax-M2.7");
+    });
+
+    it("returns empty for custom service", async () => {
+      const models = await listModelsForService("custom");
+      expect(models).toEqual([]);
+    });
+  });
+});

+ 77 - 0
packages/core/src/__tests__/service-resolver-regression.test.ts

@@ -0,0 +1,77 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+// Simulate pi-ai returning a MiniMax model with WRONG baseUrl (international anthropic endpoint)
+// Our resolveServiceModel should override this with the preset baseUrl
+vi.mock("@mariozechner/pi-ai", () => ({
+  getModel: vi.fn((provider: string, modelId: string) => {
+    if (modelId === "MiniMax-M2.7") {
+      return {
+        id: "MiniMax-M2.7",
+        name: "MiniMax-M2.7",
+        api: "anthropic-messages",        // pi-ai uses anthropic format
+        provider: "minimax",
+        baseUrl: "https://api.minimax.io/anthropic",  // pi-ai uses international endpoint
+        reasoning: true,
+        input: ["text"],
+        cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 },
+        contextWindow: 204800,
+        maxTokens: 131072,
+      };
+    }
+    return undefined;
+  }),
+  getEnvApiKey: vi.fn(() => undefined),
+}));
+
+import { resolveServiceModel } from "../llm/service-resolver.js";
+
+describe("resolveServiceModel regression — preset baseUrl override", () => {
+  let root: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-resolver-reg-"));
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { minimax: { apiKey: "sk-minimax-test" } } }),
+    );
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("uses preset baseUrl, not pi-ai built-in baseUrl", async () => {
+    const result = await resolveServiceModel("minimax", "MiniMax-M2.7", root);
+
+    // Must use our preset (api.minimaxi.com/anthropic), NOT pi-ai's (api.minimax.io/anthropic)
+    expect(result.model.baseUrl).toBe("https://api.minimaxi.com/anthropic");
+  });
+
+  it("uses preset api format, not pi-ai built-in api format", async () => {
+    const result = await resolveServiceModel("minimax", "MiniMax-M2.7", root);
+
+    // Must use our preset semantics instead of pi-ai's built-in minimax route.
+    expect(result.model.api).toBe("anthropic-messages");
+  });
+
+  it("inherits metadata from pi-ai (reasoning, cost, contextWindow)", async () => {
+    const result = await resolveServiceModel("minimax", "MiniMax-M2.7", root);
+
+    expect(result.model.reasoning).toBe(true);
+    expect(result.model.contextWindow).toBe(204800);
+    expect(result.model.maxTokens).toBe(131072);
+  });
+
+  it("customBaseUrl overrides both preset and pi-ai", async () => {
+    const result = await resolveServiceModel(
+      "minimax", "MiniMax-M2.7", root,
+      "https://custom-proxy.example.com/v1",
+    );
+
+    expect(result.model.baseUrl).toBe("https://custom-proxy.example.com/v1");
+  });
+});

+ 28 - 6
packages/core/src/agent/agent-session.ts

@@ -8,13 +8,14 @@ import {
   createPatchChapterTextTool,
   createPatchChapterTextTool,
   createRenameEntityTool,
   createRenameEntityTool,
   createSubAgentTool,
   createSubAgentTool,
-  createReviseChapterTool,
   createReadTool,
   createReadTool,
   createEditTool,
   createEditTool,
+  createWriteFileTool,
   createGrepTool,
   createGrepTool,
   createLsTool,
   createLsTool,
   createWriteTruthFileTool,
   createWriteTruthFileTool,
 } from "./agent-tools.js";
 } from "./agent-tools.js";
+import { createBookContextTransform } from "./context-transform.js";
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // Types
 // Types
@@ -50,8 +51,14 @@ export interface AgentSessionResult {
 // Cache
 // Cache
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
+// We only record fields that can realistically change between turns on the
+// same sessionId and are captured into the Agent at construction time.
+// `projectRoot`, `language`, and `pipeline` are also closure-captured by the
+// Agent (into systemPrompt / tools / transformContext), but within a single
+// server process they're treated as stable — we don't re-check them.
 interface CachedAgent {
 interface CachedAgent {
   agent: Agent;
   agent: Agent;
+  bookId: string | null;
   lastActive: number;
   lastActive: number;
 }
 }
 
 
@@ -204,18 +211,31 @@ export async function runAgentSession(
   userMessage: string,
   userMessage: string,
   initialMessages?: Array<{ role: string; content: string }>,
   initialMessages?: Array<{ role: string; content: string }>,
 ): Promise<AgentSessionResult> {
 ): Promise<AgentSessionResult> {
-  const { sessionId, bookId, language, pipeline, projectRoot, onEvent } = config;
+  const { sessionId, language, pipeline, projectRoot, onEvent } = config;
+  // Normalize at the entry point so downstream comparisons, closures, and
+  // fs paths never see `undefined`. The type is already `string | null`, but
+  // some callers may bypass the type system (e.g. `activeBookId ?? null` gets
+  // skipped) and we don't want that to (a) throw in path.join or (b) trigger
+  // a spurious cache eviction because `null !== undefined`.
+  const bookId: string | null = config.bookId ?? null;
 
 
   // ----- Resolve or create Agent -----
   // ----- Resolve or create Agent -----
   let cached = agentCache.get(sessionId);
   let cached = agentCache.get(sessionId);
 
 
   if (cached) {
   if (cached) {
-    // Check if model changed — evict and rebuild if so
+    // Evict and rebuild if model OR bookId changed. Both are captured into the
+    // Agent at construction time (model via initialState, bookId via closures
+    // in systemPrompt / tools / transformContext), so a mismatch means the
+    // cached Agent would keep using stale context — including reading truth
+    // files from the wrong book's story/ directory.
     const currentModelId = (cached.agent.state.model as any)?.id;
     const currentModelId = (cached.agent.state.model as any)?.id;
     const newModelId = typeof config.model === 'object' && 'id' in config.model
     const newModelId = typeof config.model === 'object' && 'id' in config.model
       ? (config.model as any).id
       ? (config.model as any).id
       : undefined;
       : undefined;
-    if (currentModelId && newModelId && currentModelId !== newModelId) {
+    const modelChanged = !!(currentModelId && newModelId && currentModelId !== newModelId);
+    const bookChanged = cached.bookId !== bookId;
+
+    if (modelChanged || bookChanged) {
       // Preserve conversation messages for re-injection
       // Preserve conversation messages for re-injection
       const preservedMessages = agentMessagesToPlain(cached.agent.state.messages);
       const preservedMessages = agentMessagesToPlain(cached.agent.state.messages);
       agentCache.delete(sessionId);
       agentCache.delete(sessionId);
@@ -236,14 +256,16 @@ export async function runAgentSession(
         tools: [
         tools: [
           createSubAgentTool(pipeline, bookId, projectRoot),
           createSubAgentTool(pipeline, bookId, projectRoot),
           createReadTool(projectRoot),
           createReadTool(projectRoot),
-          createReviseChapterTool(pipeline, bookId),
           createWriteTruthFileTool(pipeline, projectRoot, bookId),
           createWriteTruthFileTool(pipeline, projectRoot, bookId),
           createRenameEntityTool(pipeline, projectRoot, bookId),
           createRenameEntityTool(pipeline, projectRoot, bookId),
           createPatchChapterTextTool(pipeline, projectRoot, bookId),
           createPatchChapterTextTool(pipeline, projectRoot, bookId),
+          createEditTool(projectRoot),
+          createWriteFileTool(projectRoot),
           createGrepTool(projectRoot),
           createGrepTool(projectRoot),
           createLsTool(projectRoot),
           createLsTool(projectRoot),
         ],
         ],
       },
       },
+      transformContext: createBookContextTransform(bookId, projectRoot),
       streamFn: streamSimple,
       streamFn: streamSimple,
       getApiKey: (provider: string) => {
       getApiKey: (provider: string) => {
         if (config.apiKey) return config.apiKey;
         if (config.apiKey) return config.apiKey;
@@ -256,7 +278,7 @@ export async function runAgentSession(
       agent.state.messages = plainToAgentMessages(initialMessages);
       agent.state.messages = plainToAgentMessages(initialMessages);
     }
     }
 
 
-    cached = { agent, lastActive: Date.now() };
+    cached = { agent, bookId, lastActive: Date.now() };
     agentCache.set(sessionId, cached);
     agentCache.set(sessionId, cached);
     ensureCleanupTimer();
     ensureCleanupTimer();
   }
   }

+ 78 - 18
packages/core/src/agent/agent-system-prompt.ts

@@ -17,6 +17,7 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
 
 
 2. **确认建书**(调用阶段)— 当信息足够时,调用 sub_agent 工具委托 architect 子智能体建书:
 2. **确认建书**(调用阶段)— 当信息足够时,调用 sub_agent 工具委托 architect 子智能体建书:
    - 必须显式传入 "title" 参数,不能留空
    - 必须显式传入 "title" 参数,不能留空
+   - 同时传入结构化参数:genre(题材)、platform(平台)、language(语言)、targetChapters(章数)、chapterWordCount(每章字数)
    - instruction 中包含收集到的所有信息(题材、世界观、主角、冲突等)
    - instruction 中包含收集到的所有信息(题材、世界观、主角、冲突等)
    - architect 会生成完整的 foundation(世界观设定、卷纲规划、叙事规则等)
    - architect 会生成完整的 foundation(世界观设定、卷纲规划、叙事规则等)
 
 
@@ -26,7 +27,13 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
 - 用户回答模糊时,给出 2-3 个具体选项引导
 - 用户回答模糊时,给出 2-3 个具体选项引导
 - 当信息基本齐了,主动提议建书,不要无限追问
 - 当信息基本齐了,主动提议建书,不要无限追问
 - 保持简短、自然
 - 保持简短、自然
-- **不要在回复中添加表情符号**`
+- **不要在回复中添加表情符号**
+
+## 输出格式
+
+- 禁止使用表情符号(emoji)
+- 梳理结构化内容时使用无序列表或表格,不要用纯文本段落堆砌
+- 回复简洁,不说废话`
       : `You are the InkOS book creation assistant. Help the user create a new book from scratch.
       : `You are the InkOS book creation assistant. Help the user create a new book from scratch.
 
 
 ## Workflow
 ## Workflow
@@ -41,6 +48,7 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
 
 
 2. **Create book** — When you have enough info, call the sub_agent tool with agent="architect":
 2. **Create book** — When you have enough info, call the sub_agent tool with agent="architect":
    - Pass the explicit "title" parameter; do not leave it empty
    - Pass the explicit "title" parameter; do not leave it empty
+   - Pass structured params: genre, platform, language, targetChapters, chapterWordCount
    - Include all collected info in the instruction
    - Include all collected info in the instruction
    - The architect will generate the complete foundation
    - The architect will generate the complete foundation
 
 
@@ -50,7 +58,13 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
 - Offer 2-3 concrete options when the user is vague
 - Offer 2-3 concrete options when the user is vague
 - Proactively suggest creating the book when enough info is collected
 - Proactively suggest creating the book when enough info is collected
 - Keep responses brief and natural
 - Keep responses brief and natural
-- **Do NOT use emoji in your responses**`;
+- **Do NOT use emoji in your responses**
+
+## Output Format
+
+- No emoji
+- Use bullet lists or tables for structured content, not prose paragraphs
+- Keep responses concise`;
   }
   }
 
 
   return isZh
   return isZh
@@ -59,16 +73,20 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
 ## 可用工具
 ## 可用工具
 
 
 - **sub_agent** — 委托子智能体执行重操作:
 - **sub_agent** — 委托子智能体执行重操作:
-  - agent="writer" 写下一章
-  - agent="auditor" 审计章节质量(可指定 chapterNumber)
-  - agent="reviser" 修订章节(可指定 chapterNumber 和修订模式)
-  - agent="exporter" 导出书籍
-  - **chapterNumber 参数**:auditor 和 reviser 支持指定章节号,不指定则默认最新章节
+  - agent="writer" **续写下一章**(接着已写的最后一章往下写,无法指定章节号。参数:chapterWordCount)
+  - agent="auditor" 审计**已有章节**(参数:chapterNumber 指定第几章,不传则审最新一章)
+  - agent="reviser" 修改**已有章节**(**必须传 chapterNumber 指明改第几章**。参数:chapterNumber, mode: spot-fix/polish/rewrite/rework/anti-detect)
+  - agent="exporter" 导出书籍(参数:format: txt/md/epub, approvedOnly: true/false)
+  - **writer vs reviser 选择规则**(极易出错,看清楚):
+    - 用户说"改/修订/重写第 N 章"、"第 N 章 xxx 写得不好" → **reviser** + chapterNumber=N(绝不能用 writer,writer 会写新的第 N+1 章)
+    - 用户说"写下一章"、"继续写"、"再来一章" → **writer**(不要用 reviser,更不要不带 chapterNumber 调 reviser)
+    - 用户没说章节号、只说"改一下刚才那章" → **reviser** + chapterNumber=最新已写章节号
 - **read** — 读取书籍的设定文件或章节内容
 - **read** — 读取书籍的设定文件或章节内容
-- **revise_chapter** — 对已有章节做精修/重写/返工
 - **write_truth_file** — 整文件覆盖真相文件(story_bible、volume_outline、book_rules、current_focus 等)
 - **write_truth_file** — 整文件覆盖真相文件(story_bible、volume_outline、book_rules、current_focus 等)
 - **rename_entity** — 统一改角色/实体名
 - **rename_entity** — 统一改角色/实体名
 - **patch_chapter_text** — 对已有章节做局部定点修补
 - **patch_chapter_text** — 对已有章节做局部定点修补
+- **edit** — 在设定文件里做精确字符串替换(章节正文请用 patch_chapter_text)
+- **write** — 新建文件,或者重写整个文件(已有内容会被覆盖;真相文件优先用 write_truth_file,整章精修/重写请用 sub_agent 的 reviser)
 - **grep** — 搜索内容(如"哪一章提到了某个角色")
 - **grep** — 搜索内容(如"哪一章提到了某个角色")
 - **ls** — 列出文件或章节
 - **ls** — 列出文件或章节
 
 
@@ -77,27 +95,50 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
 - 写章节、修订、审计等重操作 → 使用 sub_agent 委托对应子智能体
 - 写章节、修订、审计等重操作 → 使用 sub_agent 委托对应子智能体
 - 用户问设定相关问题 → 先用 read 读取对应文件再回答
 - 用户问设定相关问题 → 先用 read 读取对应文件再回答
 - 用户想改设定/改真相文件 → 优先用 write_truth_file
 - 用户想改设定/改真相文件 → 优先用 write_truth_file
-- 用户要求重写/精修已有章节 → 用 revise_chapter
+- 用户要求重写/精修已有章节 → sub_agent(agent="reviser", chapterNumber=N, mode=...)
 - 用户要求角色或实体改名 → 用 rename_entity
 - 用户要求角色或实体改名 → 用 rename_entity
 - 用户要求对某一章做局部小修 → 用 patch_chapter_text
 - 用户要求对某一章做局部小修 → 用 patch_chapter_text
+- 当你已经明确目标文件和内容时,也可以直接使用 edit / write
 - 其他情况 → 直接对话回答
 - 其他情况 → 直接对话回答
 - **注意:不要调用 architect,当前已有书籍,不需要建书**
 - **注意:不要调用 architect,当前已有书籍,不需要建书**
-- **不要在回复中添加表情符号**`
+- **不要在回复中添加表情符号**
+
+## 章节索引管理
+
+章节索引文件位于 \`books/${bookId}/chapters/index.json\`,记录所有章节的元信息(编号、标题、状态、字数等)。
+章节文件位于 \`books/${bookId}/chapters/\`,命名格式为 \`0001_标题.md\`。
+
+如果你发现索引和磁盘文件不一致(例如侧边栏章节数和实际不符),请主动修复:
+1. 用 \`ls\` 列出 \`books/${bookId}/chapters/\` 下所有 \`.md\` 文件
+2. 用 \`read\` 读取当前 \`index.json\`
+3. 对比两者,找出磁盘上有但索引中缺失的章节
+4. 同一章号有多个文件时(重写),取文件名排序最后的那个(最新版本)
+5. 用 \`edit\` 更新 \`index.json\`,补上缺失条目(status 设为 "ready-for-review",wordCount 通过读取文件内容统计中文字符数)
+
+## 输出格式
+
+- 禁止使用表情符号(emoji)
+- 梳理结构化内容时使用无序列表或表格,不要用纯文本段落堆砌
+- 回复简洁,不说废话`
     : `You are the InkOS writing assistant, working on book "${bookId}".
     : `You are the InkOS writing assistant, working on book "${bookId}".
 
 
 ## Available Tools
 ## Available Tools
 
 
 - **sub_agent** — Delegate to sub-agents:
 - **sub_agent** — Delegate to sub-agents:
-  - agent="writer" for writing next chapter
-  - agent="auditor" for chapter quality audit (supports chapterNumber)
-  - agent="reviser" for chapter revision (supports chapterNumber + mode via instruction)
-  - agent="exporter" for book export
-  - **chapterNumber param**: auditor and reviser accept an explicit chapter number; omit for latest
+  - agent="writer" **continue writing the NEXT chapter** (always appends after the latest written chapter; cannot target a specific number. params: chapterWordCount)
+  - agent="auditor" audit an **EXISTING chapter** (params: chapterNumber to target a specific chapter; omit for the latest)
+  - agent="reviser" modify an **EXISTING chapter** (**chapterNumber is required to identify which chapter**. params: chapterNumber, mode: spot-fix/polish/rewrite/rework/anti-detect)
+  - agent="exporter" export book (params: format: txt/md/epub, approvedOnly: true/false)
+  - **writer vs reviser — common mistake, read carefully**:
+    - User says "revise/rewrite/fix chapter N" or "chapter N has issues" → **reviser** with chapterNumber=N (never writer — writer would produce a new chapter N+1)
+    - User says "write the next chapter" / "continue" / "one more chapter" → **writer** (never reviser, and never call reviser without chapterNumber)
+    - User refers to "that chapter we just did" without a number → **reviser** with chapterNumber=latest-written
 - **read** — Read truth files or chapter content
 - **read** — Read truth files or chapter content
-- **revise_chapter** — Rewrite or polish an existing chapter
 - **write_truth_file** — Replace a canonical truth file in story/
 - **write_truth_file** — Replace a canonical truth file in story/
 - **rename_entity** — Rename a character or entity across the book
 - **rename_entity** — Rename a character or entity across the book
 - **patch_chapter_text** — Apply a local deterministic patch to a chapter
 - **patch_chapter_text** — Apply a local deterministic patch to a chapter
+- **edit** — Exact string replacement on setting files (use patch_chapter_text for chapter text)
+- **write** — Create a new file, or fully replace an existing file's content (prefer write_truth_file for canonical truth files; for whole-chapter rewrites call sub_agent with agent="reviser")
 - **grep** — Search content across chapters
 - **grep** — Search content across chapters
 - **ls** — List files or chapters
 - **ls** — List files or chapters
 
 
@@ -106,10 +147,29 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
 - Use sub_agent for heavy operations (writing, revision, auditing)
 - Use sub_agent for heavy operations (writing, revision, auditing)
 - Use read first for settings inquiries
 - Use read first for settings inquiries
 - Use write_truth_file for truth files and setting changes
 - Use write_truth_file for truth files and setting changes
-- Use revise_chapter for rewrite/polish/rework of existing chapters
+- For rewrite/polish/rework of an existing chapter → sub_agent(agent="reviser", chapterNumber=N, mode=...)
 - Use rename_entity for character/entity renames
 - Use rename_entity for character/entity renames
 - Use patch_chapter_text for local chapter fixes
 - Use patch_chapter_text for local chapter fixes
+- Use edit / write directly when you already know the exact target file and replacement content
 - Chat directly for other questions
 - Chat directly for other questions
 - **Do NOT call architect — a book already exists**
 - **Do NOT call architect — a book already exists**
-- **Do NOT use emoji in your responses**`;
+- **Do NOT use emoji in your responses**
+
+## Chapter Index Management
+
+The chapter index is at \`books/${bookId}/chapters/index.json\` (metadata: number, title, status, wordCount, etc.).
+Chapter files are at \`books/${bookId}/chapters/\`, named \`0001_Title.md\`.
+
+If you notice the index is inconsistent with the actual files on disk (e.g. sidebar shows fewer chapters than exist), fix it proactively:
+1. \`ls\` the chapters directory to list all \`.md\` files
+2. \`read\` the current \`index.json\`
+3. Compare and find chapters on disk but missing from the index
+4. When multiple files exist for the same chapter number (rewrites), use the last one alphabetically (latest version)
+5. \`edit\` the \`index.json\` to add missing entries (status: "ready-for-review", wordCount: count Chinese characters from the file content)
+
+## Output Format
+
+- No emoji
+- Use bullet lists or tables for structured content, not prose paragraphs
+- Keep responses concise`;
 }
 }

+ 105 - 59
packages/core/src/agent/agent-tools.ts

@@ -1,7 +1,7 @@
 import { Type, type Static } from "@mariozechner/pi-ai";
 import { Type, type Static } from "@mariozechner/pi-ai";
 import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
 import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
 import type { PipelineRunner } from "../pipeline/runner.js";
 import type { PipelineRunner } from "../pipeline/runner.js";
-import { DEFAULT_REVISE_MODE, type ReviseMode } from "../agents/reviser.js";
+import { type ReviseMode } from "../agents/reviser.js";
 import { readFile, writeFile, readdir, stat } from "node:fs/promises";
 import { readFile, writeFile, readdir, stat } from "node:fs/promises";
 import { join, normalize, resolve } from "node:path";
 import { join, normalize, resolve } from "node:path";
 import { StateManager } from "../state/manager.js";
 import { StateManager } from "../state/manager.js";
@@ -57,10 +57,39 @@ const SubAgentParams = Type.Object({
     Type.Literal("reviser"),
     Type.Literal("reviser"),
     Type.Literal("exporter"),
     Type.Literal("exporter"),
   ]),
   ]),
-  instruction: Type.String({ description: "Natural language instruction from the main Agent" }),
+  instruction: Type.String({ description: "Natural language instruction for the sub-agent" }),
   bookId: Type.Optional(Type.String({ description: "Book ID — required for all agents except architect" })),
   bookId: Type.Optional(Type.String({ description: "Book ID — required for all agents except architect" })),
-  title: Type.Optional(Type.String({ description: "Architect only: explicit book title. Required when creating a book." })),
-  chapterNumber: Type.Optional(Type.Number({ description: "Target chapter number for auditor/reviser. Omit to use the latest chapter." })),
+  chapterNumber: Type.Optional(Type.Number({ description: "auditor/reviser: target chapter number. Omit to use the latest chapter." })),
+  // -- architect params --
+  title: Type.Optional(Type.String({ description: "architect only: explicit book title. Required when creating a book." })),
+  genre: Type.Optional(Type.String({ description: "architect only: genre (xuanhuan, urban, mystery, romance, scifi, fantasy, wuxia, general, etc.)" })),
+  platform: Type.Optional(Type.Union([
+    Type.Literal("tomato"),
+    Type.Literal("qidian"),
+    Type.Literal("feilu"),
+    Type.Literal("other"),
+  ], { description: "architect only: target platform. Default: other" })),
+  language: Type.Optional(Type.Union([
+    Type.Literal("zh"),
+    Type.Literal("en"),
+  ], { description: "architect only: writing language. Default: zh" })),
+  targetChapters: Type.Optional(Type.Number({ description: "architect only: total chapter count. Default: 200" })),
+  chapterWordCount: Type.Optional(Type.Number({ description: "architect/writer: words per chapter. Default: 3000" })),
+  // -- reviser params --
+  mode: Type.Optional(Type.Union([
+    Type.Literal("spot-fix"),
+    Type.Literal("polish"),
+    Type.Literal("rewrite"),
+    Type.Literal("rework"),
+    Type.Literal("anti-detect"),
+  ], { description: "reviser only: revision mode. Default: spot-fix" })),
+  // -- exporter params --
+  format: Type.Optional(Type.Union([
+    Type.Literal("txt"),
+    Type.Literal("md"),
+    Type.Literal("epub"),
+  ], { description: "exporter only: export format. Default: txt" })),
+  approvedOnly: Type.Optional(Type.Boolean({ description: "exporter only: export only approved chapters. Default: false" })),
 });
 });
 
 
 function deriveBookIdFromTitle(title: string): string {
 function deriveBookIdFromTitle(title: string): string {
@@ -91,7 +120,7 @@ export function createSubAgentTool(
       _signal?: AbortSignal,
       _signal?: AbortSignal,
       onUpdate?: AgentToolUpdateCallback,
       onUpdate?: AgentToolUpdateCallback,
     ): Promise<AgentToolResult<undefined>> {
     ): Promise<AgentToolResult<undefined>> {
-      const { agent, instruction, bookId, title, chapterNumber } = params;
+      const { agent, instruction, bookId, title, chapterNumber, genre, platform, language, targetChapters, chapterWordCount, mode, format, approvedOnly } = params;
 
 
       const progress = (msg: string) => {
       const progress = (msg: string) => {
         onUpdate?.(textResult(msg));
         onUpdate?.(textResult(msg));
@@ -100,7 +129,6 @@ export function createSubAgentTool(
       try {
       try {
         switch (agent) {
         switch (agent) {
           case "architect": {
           case "architect": {
-            // architect 只在没有书的时候可用(建书流程)
             if (activeBookId) {
             if (activeBookId) {
               return textResult("当前已有书籍,不需要建书。如果你想创建新书,请先回到首页。");
               return textResult("当前已有书籍,不需要建书。如果你想创建新书,请先回到首页。");
             }
             }
@@ -109,9 +137,21 @@ export function createSubAgentTool(
               return textResult('Error: title is required for the architect agent.');
               return textResult('Error: title is required for the architect agent.');
             }
             }
             const id = bookId || deriveBookIdFromTitle(resolvedTitle) || `book-${Date.now().toString(36)}`;
             const id = bookId || deriveBookIdFromTitle(resolvedTitle) || `book-${Date.now().toString(36)}`;
+            const now = new Date().toISOString();
             progress(`Starting architect for book "${id}"...`);
             progress(`Starting architect for book "${id}"...`);
             await pipeline.initBook(
             await pipeline.initBook(
-              { id, genre: "general", title: resolvedTitle, language: "zh" } as any,
+              {
+                id,
+                title: resolvedTitle,
+                genre: genre ?? "general",
+                platform: (platform ?? "other") as any,
+                language: (language ?? "zh") as any,
+                status: "outlining" as any,
+                targetChapters: targetChapters ?? 200,
+                chapterWordCount: chapterWordCount ?? 3000,
+                createdAt: now,
+                updatedAt: now,
+              },
               { externalContext: instruction },
               { externalContext: instruction },
             );
             );
             progress(`Architect finished — book "${id}" foundation created.`);
             progress(`Architect finished — book "${id}" foundation created.`);
@@ -121,7 +161,7 @@ export function createSubAgentTool(
           case "writer": {
           case "writer": {
             if (!bookId) return textResult("Error: bookId is required for the writer agent.");
             if (!bookId) return textResult("Error: bookId is required for the writer agent.");
             progress(`Writing next chapter for "${bookId}"...`);
             progress(`Writing next chapter for "${bookId}"...`);
-            const result = await pipeline.writeNextChapter(bookId);
+            const result = await pipeline.writeNextChapter(bookId, chapterWordCount);
             progress(`Writer finished chapter for "${bookId}".`);
             progress(`Writer finished chapter for "${bookId}".`);
             return textResult(
             return textResult(
               `Chapter written for "${bookId}". ` +
               `Chapter written for "${bookId}". ` +
@@ -134,42 +174,37 @@ export function createSubAgentTool(
             progress(`Auditing chapter ${chapterNumber ?? "latest"} for "${bookId}"...`);
             progress(`Auditing chapter ${chapterNumber ?? "latest"} for "${bookId}"...`);
             const audit = await pipeline.auditDraft(bookId, chapterNumber);
             const audit = await pipeline.auditDraft(bookId, chapterNumber);
             progress(`Audit complete for "${bookId}".`);
             progress(`Audit complete for "${bookId}".`);
-            const issueCount = audit.issues?.length ?? 0;
+            const issueLines = (audit.issues ?? [])
+              .map((i: any) => `[${i.severity}] ${i.description}`)
+              .join("\n");
             return textResult(
             return textResult(
-              `Audit complete for "${bookId}": ${issueCount} issue(s) found. ` +
-              `Chapter ${audit.chapterNumber}.`,
+              `Audit chapter ${audit.chapterNumber}: ${audit.passed ? "PASSED" : "FAILED"}, ${(audit.issues ?? []).length} issue(s).` +
+              (issueLines ? `\n${issueLines}` : ""),
             );
             );
           }
           }
 
 
           case "reviser": {
           case "reviser": {
             if (!bookId) return textResult("Error: bookId is required for the reviser agent.");
             if (!bookId) return textResult("Error: bookId is required for the reviser agent.");
-            // Detect revision mode from instruction keywords
-            const mode: ReviseMode = /rewrite|改写|重写/.test(instruction)
-              ? "rewrite"
-              : /polish|润色/.test(instruction)
-                ? "polish"
-                : /rework|返工/.test(instruction)
-                  ? "rework"
-                  : "spot-fix";
-            progress(`Revising "${bookId}" chapter ${chapterNumber ?? "latest"} in ${mode} mode...`);
-            await pipeline.reviseDraft(bookId, chapterNumber, mode);
+            const resolvedMode: ReviseMode = (mode as ReviseMode) ?? "spot-fix";
+            progress(`Revising "${bookId}" chapter ${chapterNumber ?? "latest"} in ${resolvedMode} mode...`);
+            await pipeline.reviseDraft(bookId, chapterNumber, resolvedMode);
             progress(`Revision complete for "${bookId}".`);
             progress(`Revision complete for "${bookId}".`);
-            return textResult(`Revision (${mode}) complete for "${bookId}" chapter ${chapterNumber ?? "latest"}.`);
+            return textResult(`Revision (${resolvedMode}) complete for "${bookId}" chapter ${chapterNumber ?? "latest"}.`);
           }
           }
 
 
           case "exporter": {
           case "exporter": {
             if (!bookId) return textResult("Error: bookId is required for the exporter agent.");
             if (!bookId) return textResult("Error: bookId is required for the exporter agent.");
             if (!projectRoot) return textResult("Error: exporter requires projectRoot.");
             if (!projectRoot) return textResult("Error: exporter requires projectRoot.");
-            const inferredFormat = /epub/i.test(instruction)
+            const inferredFormat = format ?? (/epub/i.test(instruction)
               ? "epub"
               ? "epub"
               : /markdown|\bmd\b/i.test(instruction)
               : /markdown|\bmd\b/i.test(instruction)
                 ? "md"
                 ? "md"
-                : "txt";
-            const approvedOnly = /approved|已通过|通过章节/.test(instruction);
+                : "txt");
+            const exportApprovedOnly = approvedOnly ?? /approved|已通过|通过章节/.test(instruction);
             const state = new StateManager(projectRoot);
             const state = new StateManager(projectRoot);
             const result = await writeExportArtifact(state, bookId, {
             const result = await writeExportArtifact(state, bookId, {
               format: inferredFormat,
               format: inferredFormat,
-              approvedOnly,
+              approvedOnly: exportApprovedOnly,
             });
             });
             return textResult(
             return textResult(
               `Exported "${bookId}": ${result.chaptersExported} chapters, ${result.totalWords} words → ${result.outputPath}`,
               `Exported "${bookId}": ${result.chaptersExported} chapters, ${result.totalWords} words → ${result.outputPath}`,
@@ -191,36 +226,6 @@ export function createSubAgentTool(
 // 2. Deterministic writing tools
 // 2. Deterministic writing tools
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
-const ReviseChapterParams = Type.Object({
-  bookId: Type.Optional(Type.String({ description: "Book ID. Omit to use the active book." })),
-  chapterNumber: Type.Number({ description: "Chapter number to revise." }),
-  mode: Type.Optional(Type.Union([
-    Type.Literal("spot-fix"),
-    Type.Literal("polish"),
-    Type.Literal("rewrite"),
-    Type.Literal("rework"),
-    Type.Literal("anti-detect"),
-  ])),
-});
-
-export function createReviseChapterTool(
-  pipeline: PipelineRunner,
-  activeBookId: string | null,
-): AgentTool<typeof ReviseChapterParams> {
-  return {
-    name: "revise_chapter",
-    description: "Revise a specific chapter through the deterministic revision pipeline.",
-    label: "Revise Chapter",
-    parameters: ReviseChapterParams,
-    async execute(_toolCallId, params): Promise<AgentToolResult<undefined>> {
-      const bookId = resolveToolBookId("revise_chapter", params.bookId, activeBookId);
-      const mode = params.mode ?? DEFAULT_REVISE_MODE;
-      await pipeline.reviseDraft(bookId, params.chapterNumber, mode);
-      return textResult(`Revision (${mode}) complete for "${bookId}" chapter ${params.chapterNumber}.`);
-    },
-  };
-}
-
 const WriteTruthFileParams = Type.Object({
 const WriteTruthFileParams = Type.Object({
   bookId: Type.Optional(Type.String({ description: "Book ID. Omit to use the active book." })),
   bookId: Type.Optional(Type.String({ description: "Book ID. Omit to use the active book." })),
   fileName: Type.String({ description: "Truth file name under story/, e.g. story_bible.md or current_focus.md." }),
   fileName: Type.String({ description: "Truth file name under story/, e.g. story_bible.md or current_focus.md." }),
@@ -358,8 +363,10 @@ export function createEditTool(projectRoot: string): AgentTool<typeof EditParams
   return {
   return {
     name: "edit",
     name: "edit",
     description:
     description:
-      "Edit a file using exact string replacement. " +
-      "old_string must appear exactly once in the file. Path is relative to books/.",
+      "Edit a file under books/ via exact string replacement. " +
+      "old_string must appear exactly once in the file. " +
+      "For chapter text use patch_chapter_text; for canonical truth files (story_bible/volume_outline/book_rules/current_focus) prefer write_truth_file; " +
+      "to rewrite or polish a whole chapter call sub_agent with agent=\"reviser\".",
     label: "Edit File",
     label: "Edit File",
     parameters: EditParams,
     parameters: EditParams,
     async execute(
     async execute(
@@ -387,7 +394,46 @@ export function createEditTool(projectRoot: string): AgentTool<typeof EditParams
 }
 }
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
-// 4. Grep Tool
+// 4. Write Tool
+// ---------------------------------------------------------------------------
+
+const WriteFileParams = Type.Object({
+  path: Type.String({ description: "File path relative to books/" }),
+  content: Type.String({ description: "Full file content to write" }),
+});
+
+export function createWriteFileTool(projectRoot: string): AgentTool<typeof WriteFileParams> {
+  const booksRoot = join(projectRoot, "books");
+
+  return {
+    name: "write",
+    description:
+      "Create a new file, or fully replace an existing file's content under books/. " +
+      "Parent directories are created automatically. Existing content is overwritten silently — " +
+      "for canonical truth files prefer write_truth_file; " +
+      "for whole-chapter rewrites/polishing call sub_agent with agent=\"reviser\".",
+    label: "Write File",
+    parameters: WriteFileParams,
+    async execute(
+      _toolCallId: string,
+      params: Static<typeof WriteFileParams>,
+    ): Promise<AgentToolResult<undefined>> {
+      try {
+        const filePath = safeBooksPath(booksRoot, params.path);
+        const parentDir = resolve(filePath, "..");
+        const { mkdir } = await import("node:fs/promises");
+        await mkdir(parentDir, { recursive: true });
+        await writeFile(filePath, params.content, "utf-8");
+        return textResult(`File "${params.path}" written successfully.`);
+      } catch (err: any) {
+        return textResult(`Failed to write "${params.path}": ${err?.message ?? String(err)}`);
+      }
+    },
+  };
+}
+
+// ---------------------------------------------------------------------------
+// 5. Grep Tool
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
 const GrepParams = Type.Object({
 const GrepParams = Type.Object({

+ 73 - 0
packages/core/src/agent/context-transform.ts

@@ -0,0 +1,73 @@
+import type { AgentMessage } from "@mariozechner/pi-agent-core";
+import type { UserMessage } from "@mariozechner/pi-ai";
+import { readdir, readFile } from "node:fs/promises";
+import { join } from "node:path";
+
+/** Files read in this order; anything else in story/ comes after, sorted alphabetically. */
+const PRIORITY_FILES = [
+  "story_bible.md",
+  "volume_outline.md",
+  "book_rules.md",
+  "current_focus.md",
+];
+
+export function createBookContextTransform(
+  bookId: string | null,
+  projectRoot: string,
+): (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]> {
+  if (bookId === null) {
+    return async (messages) => messages;
+  }
+
+  const storyDir = join(projectRoot, "books", bookId, "story");
+
+  return async (messages) => {
+    const sections = await readTruthFiles(storyDir);
+    if (sections.length === 0) return messages;
+
+    const body =
+      "[以下是当前书籍的真相文件,每次对话时自动从磁盘读取注入。请基于这些内容进行创作和判断。]\n\n" +
+      sections.map((s) => `=== ${s.name} ===\n${s.content}`).join("\n\n");
+
+    const injected: UserMessage = {
+      role: "user",
+      content: body,
+      timestamp: Date.now(),
+    };
+
+    return [injected, ...messages];
+  };
+}
+
+interface TruthFileSection {
+  name: string;
+  content: string;
+}
+
+async function readTruthFiles(storyDir: string): Promise<TruthFileSection[]> {
+  let entries: string[];
+  try {
+    entries = await readdir(storyDir);
+  } catch {
+    return [];
+  }
+
+  const mdFiles = entries.filter((f) => f.endsWith(".md"));
+  if (mdFiles.length === 0) return [];
+
+  const prioritySet = new Set(PRIORITY_FILES);
+  const prioritized = PRIORITY_FILES.filter((f) => mdFiles.includes(f));
+  const rest = mdFiles.filter((f) => !prioritySet.has(f)).sort();
+  const ordered = [...prioritized, ...rest];
+
+  const sections: TruthFileSection[] = [];
+  for (const fileName of ordered) {
+    try {
+      const content = await readFile(join(storyDir, fileName), "utf-8");
+      sections.push({ name: fileName, content });
+    } catch {
+      // skip unreadable files
+    }
+  }
+  return sections;
+}

+ 2 - 1
packages/core/src/agent/index.ts

@@ -2,12 +2,13 @@ export { buildAgentSystemPrompt } from "./agent-system-prompt.js";
 export {
 export {
   createSubAgentTool,
   createSubAgentTool,
   createReadTool,
   createReadTool,
-  createReviseChapterTool,
   createWriteTruthFileTool,
   createWriteTruthFileTool,
   createRenameEntityTool,
   createRenameEntityTool,
   createPatchChapterTextTool,
   createPatchChapterTextTool,
   createEditTool,
   createEditTool,
+  createWriteFileTool,
   createGrepTool,
   createGrepTool,
   createLsTool,
   createLsTool,
 } from "./agent-tools.js";
 } from "./agent-tools.js";
 export { runAgentSession, evictAgentCache, type AgentSessionConfig, type AgentSessionResult } from "./agent-session.js";
 export { runAgentSession, evictAgentCache, type AgentSessionConfig, type AgentSessionResult } from "./agent-session.js";
+export { createBookContextTransform } from "./context-transform.js";

+ 10 - 1
packages/core/src/index.ts

@@ -132,7 +132,16 @@ export {
   loadGlobalSession,
   loadGlobalSession,
   persistGlobalSession,
   persistGlobalSession,
 } from "./interaction/project-session-store.js";
 } from "./interaction/project-session-store.js";
-export { loadBookSession, persistBookSession, listBookSessions, findOrCreateBookSession } from "./interaction/book-session-store.js";
+export {
+  loadBookSession,
+  persistBookSession,
+  listBookSessions,
+  renameBookSession,
+  deleteBookSession,
+  migrateBookSession,
+  createAndPersistBookSession,
+  SessionAlreadyMigratedError,
+} from "./interaction/book-session-store.js";
 export { routeInteractionRequest } from "./interaction/request-router.js";
 export { routeInteractionRequest } from "./interaction/request-router.js";
 export {
 export {
   routeNaturalLanguageIntent,
   routeNaturalLanguageIntent,

+ 140 - 18
packages/core/src/interaction/book-session-store.ts

@@ -1,4 +1,4 @@
-import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
+import { readFile, writeFile, readdir, mkdir, unlink } from "node:fs/promises";
 import { join } from "node:path";
 import { join } from "node:path";
 import { BookSessionSchema, createBookSession } from "./session.js";
 import { BookSessionSchema, createBookSession } from "./session.js";
 import type { BookSession } from "./session.js";
 import type { BookSession } from "./session.js";
@@ -13,6 +13,31 @@ function sessionPath(projectRoot: string, sessionId: string): string {
   return join(sessionsDir(projectRoot), `${sessionId}.json`);
   return join(sessionsDir(projectRoot), `${sessionId}.json`);
 }
 }
 
 
+/**
+ * 从 messages 数组里取第一条 user 消息,裁剪成 ≤20 字的单行字符串。
+ * 用于把用户首条提问作为会话标题。
+ */
+export function extractFirstUserMessageTitle(messages: unknown): string | null {
+  if (!Array.isArray(messages)) return null;
+  for (const message of messages) {
+    if (!message || typeof message !== "object") continue;
+    if ((message as { role?: unknown }).role !== "user") continue;
+    const content = (message as { content?: unknown }).content;
+    if (typeof content !== "string") return null;
+    const oneLine = content.trim().replace(/\s+/g, " ");
+    if (oneLine.length === 0) return null;
+    return oneLine.length > 20 ? `${oneLine.slice(0, 20)}…` : oneLine;
+  }
+  return null;
+}
+
+export class SessionAlreadyMigratedError extends Error {
+  constructor(sessionId: string, currentBookId: string) {
+    super(`Session "${sessionId}" is already bound to book "${currentBookId}"`);
+    this.name = "SessionAlreadyMigratedError";
+  }
+}
+
 export async function loadBookSession(
 export async function loadBookSession(
   projectRoot: string,
   projectRoot: string,
   sessionId: string,
   sessionId: string,
@@ -37,10 +62,19 @@ export async function persistBookSession(
   );
   );
 }
 }
 
 
+export interface BookSessionSummary {
+  readonly sessionId: string;
+  readonly bookId: string | null;
+  readonly title: string | null;
+  readonly messageCount: number;
+  readonly createdAt: number;
+  readonly updatedAt: number;
+}
+
 export async function listBookSessions(
 export async function listBookSessions(
   projectRoot: string,
   projectRoot: string,
   bookId: string | null,
   bookId: string | null,
-): Promise<ReadonlyArray<BookSession>> {
+): Promise<ReadonlyArray<BookSessionSummary>> {
   const dir = sessionsDir(projectRoot);
   const dir = sessionsDir(projectRoot);
   let files: string[];
   let files: string[];
   try {
   try {
@@ -49,30 +83,118 @@ export async function listBookSessions(
     return [];
     return [];
   }
   }
 
 
-  const sessions: BookSession[] = [];
-  for (const file of files) {
-    if (!file.endsWith(".json")) continue;
-    try {
-      const raw = await readFile(join(dir, file), "utf-8");
-      const session = BookSessionSchema.parse(JSON.parse(raw));
-      if (session.bookId === bookId) {
-        sessions.push(session);
+  const jsonFiles = files.filter((file) => file.endsWith(".json"));
+  const summaries = await Promise.all(
+    jsonFiles.map(async (file): Promise<BookSessionSummary | null> => {
+      try {
+        const raw = await readFile(join(dir, file), "utf-8");
+        const data = JSON.parse(raw) as {
+          sessionId?: unknown;
+          bookId?: unknown;
+          title?: unknown;
+          messages?: unknown;
+          createdAt?: unknown;
+          updatedAt?: unknown;
+        };
+        if (typeof data.sessionId !== "string") return null;
+        const parsedBookId = data.bookId === null || typeof data.bookId === "string"
+          ? (data.bookId as string | null)
+          : null;
+        if (parsedBookId !== bookId) return null;
+
+        let persistedTitle = typeof data.title === "string" ? data.title : null;
+
+        // Lazy migration:老 session 的 title 字段是 null 但已经有用户消息的,
+        // 一次性把第一条用户消息补写成 title 并 persist 回磁盘。用户在新流程中
+        // 发消息时会立即写 title,此 migration 只对历史数据生效一次。
+        if (persistedTitle === null) {
+          const recoveredTitle = extractFirstUserMessageTitle(data.messages);
+          if (recoveredTitle) {
+            try {
+              const fullSession = await loadBookSession(projectRoot, data.sessionId);
+              if (fullSession && fullSession.title === null) {
+                await persistBookSession(projectRoot, { ...fullSession, title: recoveredTitle });
+                persistedTitle = recoveredTitle;
+              }
+            } catch {
+              // 读不出完整 session 就忽略;下次再试
+            }
+          }
+        }
+
+        return {
+          sessionId: data.sessionId,
+          bookId: parsedBookId,
+          title: persistedTitle,
+          messageCount: Array.isArray(data.messages) ? data.messages.length : 0,
+          createdAt: typeof data.createdAt === "number" ? data.createdAt : 0,
+          updatedAt: typeof data.updatedAt === "number" ? data.updatedAt : 0,
+        };
+      } catch {
+        return null;
       }
       }
-    } catch {
-      // skip corrupt files
-    }
+    }),
+  );
+
+  return summaries
+    .filter((summary): summary is BookSessionSummary => summary !== null)
+    .sort((a, b) => b.updatedAt - a.updatedAt);
+}
+
+export async function renameBookSession(
+  projectRoot: string,
+  sessionId: string,
+  title: string,
+): Promise<BookSession | null> {
+  const session = await loadBookSession(projectRoot, sessionId);
+  if (!session) return null;
+  const updated = { ...session, title, updatedAt: Date.now() };
+  await persistBookSession(projectRoot, updated);
+  return updated;
+}
+
+export async function deleteBookSession(
+  projectRoot: string,
+  sessionId: string,
+): Promise<void> {
+  try {
+    await unlink(sessionPath(projectRoot, sessionId));
+  } catch {
+    // Session file is already absent; treat delete as idempotent.
+  }
+}
+
+export async function migrateBookSession(
+  projectRoot: string,
+  sessionId: string,
+  newBookId: string,
+): Promise<BookSession | null> {
+  const session = await loadBookSession(projectRoot, sessionId);
+  if (!session) return null;
+  if (session.bookId !== null) {
+    throw new SessionAlreadyMigratedError(sessionId, session.bookId);
   }
   }
 
 
-  return sessions.sort((a, b) => b.updatedAt - a.updatedAt);
+  const updated = {
+    ...session,
+    bookId: newBookId,
+    updatedAt: Date.now(),
+  };
+  await persistBookSession(projectRoot, updated);
+  return updated;
 }
 }
 
 
-export async function findOrCreateBookSession(
+export async function createAndPersistBookSession(
   projectRoot: string,
   projectRoot: string,
   bookId: string | null,
   bookId: string | null,
+  sessionId?: string,
 ): Promise<BookSession> {
 ): Promise<BookSession> {
-  const existing = await listBookSessions(projectRoot, bookId);
-  if (existing.length > 0) return existing[0];
-  const session = createBookSession(bookId);
+  // 如果指定了 sessionId 且对应文件已存在,视为幂等操作直接返回(支持"用户发消息时才持久化 draft"流程)
+  if (sessionId) {
+    const existing = await loadBookSession(projectRoot, sessionId);
+    if (existing) return existing;
+  }
+  const session = createBookSession(bookId, sessionId);
   await persistBookSession(projectRoot, session);
   await persistBookSession(projectRoot, session);
   return session;
   return session;
 }
 }

+ 4 - 2
packages/core/src/interaction/session.ts

@@ -101,6 +101,7 @@ export type InteractionSession = z.infer<typeof InteractionSessionSchema>;
 export const BookSessionSchema = z.object({
 export const BookSessionSchema = z.object({
   sessionId: z.string().min(1),
   sessionId: z.string().min(1),
   bookId: z.string().nullable(),
   bookId: z.string().nullable(),
+  title: z.string().nullable().default(null),
   messages: z.array(InteractionMessageSchema).default([]),
   messages: z.array(InteractionMessageSchema).default([]),
   creationDraft: BookCreationDraftSchema.optional(),
   creationDraft: BookCreationDraftSchema.optional(),
   draftRounds: z.array(DraftRoundSchema).default([]),
   draftRounds: z.array(DraftRoundSchema).default([]),
@@ -121,11 +122,12 @@ export const GlobalSessionSchema = z.object({
 
 
 export type GlobalSession = z.infer<typeof GlobalSessionSchema>;
 export type GlobalSession = z.infer<typeof GlobalSessionSchema>;
 
 
-export function createBookSession(bookId: string | null): BookSession {
+export function createBookSession(bookId: string | null, sessionId?: string): BookSession {
   const now = Date.now();
   const now = Date.now();
   return {
   return {
-    sessionId: `${now}-${Math.random().toString(36).slice(2, 8)}`,
+    sessionId: sessionId ?? `${now}-${Math.random().toString(36).slice(2, 8)}`,
     bookId,
     bookId,
+    title: null,
     messages: [],
     messages: [],
     draftRounds: [],
     draftRounds: [],
     events: [],
     events: [],

+ 10 - 4
packages/core/src/utils/config-loader.ts

@@ -134,7 +134,13 @@ export async function loadProjectConfig(
     }
     }
   }
   }
 
 
-  if (configSource === "env") {
+  const shouldBootstrapStudioFallbackToEnv = configSource === "studio"
+    && normalizedServices.length === 0
+    && !(typeof llm.apiKey === "string" && llm.apiKey.length > 0)
+    && !(typeof llm.model === "string" && llm.model.length > 0)
+    && !(typeof llm.baseUrl === "string" && llm.baseUrl.length > 0);
+
+  if (configSource === "env" || shouldBootstrapStudioFallbackToEnv) {
     if (env.INKOS_LLM_PROVIDER) llm.provider = env.INKOS_LLM_PROVIDER;
     if (env.INKOS_LLM_PROVIDER) llm.provider = env.INKOS_LLM_PROVIDER;
     if (env.INKOS_LLM_BASE_URL) llm.baseUrl = env.INKOS_LLM_BASE_URL;
     if (env.INKOS_LLM_BASE_URL) llm.baseUrl = env.INKOS_LLM_BASE_URL;
     if (env.INKOS_LLM_MODEL) llm.model = env.INKOS_LLM_MODEL;
     if (env.INKOS_LLM_MODEL) llm.model = env.INKOS_LLM_MODEL;
@@ -144,7 +150,7 @@ export async function loadProjectConfig(
   }
   }
   // Extra params from env: INKOS_LLM_EXTRA_<key>=<value>
   // Extra params from env: INKOS_LLM_EXTRA_<key>=<value>
   const extraFromEnv: Record<string, unknown> = {};
   const extraFromEnv: Record<string, unknown> = {};
-  if (configSource === "env") {
+  if (configSource === "env" || shouldBootstrapStudioFallbackToEnv) {
     for (const [key, value] of Object.entries(env)) {
     for (const [key, value] of Object.entries(env)) {
       if (key.startsWith("INKOS_LLM_EXTRA_") && value) {
       if (key.startsWith("INKOS_LLM_EXTRA_") && value) {
         const paramName = key.slice("INKOS_LLM_EXTRA_".length);
         const paramName = key.slice("INKOS_LLM_EXTRA_".length);
@@ -162,14 +168,14 @@ export async function loadProjectConfig(
   if (Object.keys(extraFromEnv).length > 0) {
   if (Object.keys(extraFromEnv).length > 0) {
     llm.extra = { ...(llm.extra as Record<string, unknown> ?? {}), ...extraFromEnv };
     llm.extra = { ...(llm.extra as Record<string, unknown> ?? {}), ...extraFromEnv };
   }
   }
-  if (configSource === "env" && env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT;
+  if ((configSource === "env" || shouldBootstrapStudioFallbackToEnv) && env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT;
   config.llm = llm;
   config.llm = llm;
 
 
   // Global language override
   // Global language override
   if (env.INKOS_DEFAULT_LANGUAGE) config.language = env.INKOS_DEFAULT_LANGUAGE;
   if (env.INKOS_DEFAULT_LANGUAGE) config.language = env.INKOS_DEFAULT_LANGUAGE;
 
 
   // API key ONLY from env — never stored in inkos.json
   // API key ONLY from env — never stored in inkos.json
-  const apiKey = configSource === "env"
+  const apiKey = (configSource === "env" || shouldBootstrapStudioFallbackToEnv)
     ? env.INKOS_LLM_API_KEY || (typeof llm.apiKey === "string" ? llm.apiKey : "")
     ? env.INKOS_LLM_API_KEY || (typeof llm.apiKey === "string" ? llm.apiKey : "")
     : (typeof llm.apiKey === "string" ? llm.apiKey : "");
     : (typeof llm.apiKey === "string" ? llm.apiKey : "");
   const provider = typeof llm.provider === "string" ? llm.provider : undefined;
   const provider = typeof llm.provider === "string" ? llm.provider : undefined;

+ 1 - 1
packages/studio/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@actalk/inkos-studio",
   "name": "@actalk/inkos-studio",
-  "version": "1.3.0",
+  "version": "1.3.5",
   "description": "InkOS Studio — Web workbench for novel writing",
   "description": "InkOS Studio — Web workbench for novel writing",
   "type": "module",
   "type": "module",
   "main": "dist/api/index.js",
   "main": "dist/api/index.js",

+ 3 - 0
packages/studio/src/App.tsx

@@ -19,6 +19,7 @@ import { DoctorView } from "./pages/DoctorView";
 import { LanguageSelector } from "./pages/LanguageSelector";
 import { LanguageSelector } from "./pages/LanguageSelector";
 import { BookSidebar, BookSidebarToggle } from "./components/chat/BookSidebar";
 import { BookSidebar, BookSidebarToggle } from "./components/chat/BookSidebar";
 import { useSSE } from "./hooks/use-sse";
 import { useSSE } from "./hooks/use-sse";
+import { useSessionEvents } from "./hooks/use-session-events";
 import { useTheme } from "./hooks/use-theme";
 import { useTheme } from "./hooks/use-theme";
 import { useI18n } from "./hooks/use-i18n";
 import { useI18n } from "./hooks/use-i18n";
 import { postApi, putApi, useApi } from "./hooks/use-api";
 import { postApi, putApi, useApi } from "./hooks/use-api";
@@ -56,6 +57,8 @@ export function App() {
     }
     }
   }, [project]);
   }, [project]);
 
 
+  useSessionEvents(sse, route, setRoute);
+
   const nav = {
   const nav = {
     toDashboard: () => setRoute({ page: "dashboard" }),
     toDashboard: () => setRoute({ page: "dashboard" }),
     toBook: (bookId: string) => setRoute({ page: "book", bookId }),
     toBook: (bookId: string) => setRoute({ page: "book", bookId }),

+ 40 - 0
packages/studio/src/api/__tests__/normalize-base-url.test.ts

@@ -0,0 +1,40 @@
+import { describe, it, expect } from "vitest";
+
+// normalizeBaseUrl is not exported, so we test the behavior through the
+// server module's internal logic. We replicate the function here for unit testing.
+// If the implementation changes, this test will catch regressions.
+function normalizeBaseUrl(url: string): string {
+  const trimmed = url.replace(/\/+$/, "");
+  if (/\/v\d+$/.test(trimmed)) return trimmed;
+  return trimmed + "/v1";
+}
+
+describe("normalizeBaseUrl", () => {
+  it("appends /v1 when missing", () => {
+    expect(normalizeBaseUrl("https://timesniper.club")).toBe("https://timesniper.club/v1");
+  });
+
+  it("strips trailing slash then appends /v1", () => {
+    expect(normalizeBaseUrl("https://timesniper.club/")).toBe("https://timesniper.club/v1");
+  });
+
+  it("strips multiple trailing slashes", () => {
+    expect(normalizeBaseUrl("https://timesniper.club///")).toBe("https://timesniper.club/v1");
+  });
+
+  it("preserves existing /v1", () => {
+    expect(normalizeBaseUrl("https://timesniper.club/v1")).toBe("https://timesniper.club/v1");
+  });
+
+  it("preserves other version paths like /v2", () => {
+    expect(normalizeBaseUrl("https://api.example.com/v2")).toBe("https://api.example.com/v2");
+  });
+
+  it("all three forms are equivalent", () => {
+    const a = normalizeBaseUrl("https://timesniper.club/v1");
+    const b = normalizeBaseUrl("https://timesniper.club/");
+    const c = normalizeBaseUrl("https://timesniper.club");
+    expect(a).toBe(b);
+    expect(b).toBe(c);
+  });
+});

+ 203 - 15
packages/studio/src/api/server.test.ts

@@ -23,10 +23,13 @@ const createInteractionToolsFromDepsMock = vi.fn(() => ({}));
 const loadProjectSessionMock = vi.fn();
 const loadProjectSessionMock = vi.fn();
 const resolveSessionActiveBookMock = vi.fn();
 const resolveSessionActiveBookMock = vi.fn();
 const runAgentSessionMock = vi.fn();
 const runAgentSessionMock = vi.fn();
-const findOrCreateBookSessionMock = vi.fn();
+const createAndPersistBookSessionMock = vi.fn();
 const loadBookSessionMock = vi.fn();
 const loadBookSessionMock = vi.fn();
 const persistBookSessionMock = vi.fn();
 const persistBookSessionMock = vi.fn();
 const appendBookSessionMessageMock = vi.fn();
 const appendBookSessionMessageMock = vi.fn();
+const renameBookSessionMock = vi.fn();
+const deleteBookSessionMock = vi.fn();
+const migrateBookSessionMock = vi.fn();
 const resolveServiceModelMock = vi.fn();
 const resolveServiceModelMock = vi.fn();
 const loadSecretsMock = vi.fn();
 const loadSecretsMock = vi.fn();
 const saveSecretsMock = vi.fn();
 const saveSecretsMock = vi.fn();
@@ -34,7 +37,7 @@ const getServiceApiKeyMock = vi.fn();
 type ServicePresetMock = {
 type ServicePresetMock = {
   providerFamily: "openai" | "anthropic";
   providerFamily: "openai" | "anthropic";
   baseUrl: string;
   baseUrl: string;
-  modelsBaseUrl: string;
+  modelsBaseUrl?: string;
   knownModels: string[];
   knownModels: string[];
 };
 };
 const SERVICE_PRESETS_MOCK: Record<string, ServicePresetMock> = {
 const SERVICE_PRESETS_MOCK: Record<string, ServicePresetMock> = {
@@ -42,7 +45,7 @@ const SERVICE_PRESETS_MOCK: Record<string, ServicePresetMock> = {
   anthropic: { providerFamily: "anthropic", baseUrl: "https://api.anthropic.com", modelsBaseUrl: "https://api.anthropic.com", knownModels: [] as string[] },
   anthropic: { providerFamily: "anthropic", baseUrl: "https://api.anthropic.com", modelsBaseUrl: "https://api.anthropic.com", knownModels: [] as string[] },
   minimax: { providerFamily: "anthropic", baseUrl: "https://api.minimaxi.com/anthropic", modelsBaseUrl: "https://api.minimaxi.com/anthropic", knownModels: [] as string[] },
   minimax: { providerFamily: "anthropic", baseUrl: "https://api.minimaxi.com/anthropic", modelsBaseUrl: "https://api.minimaxi.com/anthropic", knownModels: [] as string[] },
   bailian: { providerFamily: "anthropic", baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic", modelsBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", knownModels: [] as string[] },
   bailian: { providerFamily: "anthropic", baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic", modelsBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", knownModels: [] as string[] },
-  custom: { providerFamily: "openai", baseUrl: "", modelsBaseUrl: "", knownModels: [] as string[] },
+  custom: { providerFamily: "openai", baseUrl: "", knownModels: [] as string[] },
 };
 };
 const resolveServicePresetMock = vi.fn((service: string) => SERVICE_PRESETS_MOCK[service]);
 const resolveServicePresetMock = vi.fn((service: string) => SERVICE_PRESETS_MOCK[service]);
 const resolveServiceProviderFamilyMock = vi.fn((service: string) => resolveServicePresetMock(service)?.providerFamily);
 const resolveServiceProviderFamilyMock = vi.fn((service: string) => resolveServicePresetMock(service)?.providerFamily);
@@ -80,6 +83,13 @@ const logger = {
 };
 };
 
 
 vi.mock("@actalk/inkos-core", () => {
 vi.mock("@actalk/inkos-core", () => {
+  class MockSessionAlreadyMigratedError extends Error {
+    constructor(message = "Session already migrated") {
+      super(message);
+      this.name = "SessionAlreadyMigratedError";
+    }
+  }
+
   class MockStateManager {
   class MockStateManager {
     constructor(private readonly root: string) {}
     constructor(private readonly root: string) {}
 
 
@@ -163,10 +173,15 @@ vi.mock("@actalk/inkos-core", () => {
     resolveSessionActiveBook: resolveSessionActiveBookMock,
     resolveSessionActiveBook: resolveSessionActiveBookMock,
     runAgentSession: runAgentSessionMock,
     runAgentSession: runAgentSessionMock,
     buildAgentSystemPrompt: vi.fn(() => "You are helpful."),
     buildAgentSystemPrompt: vi.fn(() => "You are helpful."),
-    findOrCreateBookSession: findOrCreateBookSessionMock,
+    createAndPersistBookSession: createAndPersistBookSessionMock,
     loadBookSession: loadBookSessionMock,
     loadBookSession: loadBookSessionMock,
     persistBookSession: persistBookSessionMock,
     persistBookSession: persistBookSessionMock,
     appendBookSessionMessage: appendBookSessionMessageMock,
     appendBookSessionMessage: appendBookSessionMessageMock,
+    isNewLayoutBook: vi.fn(async () => false),
+    renameBookSession: renameBookSessionMock,
+    deleteBookSession: deleteBookSessionMock,
+    migrateBookSession: migrateBookSessionMock,
+    SessionAlreadyMigratedError: MockSessionAlreadyMigratedError,
     resolveServicePreset: resolveServicePresetMock,
     resolveServicePreset: resolveServicePresetMock,
     resolveServiceProviderFamily: resolveServiceProviderFamilyMock,
     resolveServiceProviderFamily: resolveServiceProviderFamilyMock,
     resolveServiceModelsBaseUrl: resolveServiceModelsBaseUrlMock,
     resolveServiceModelsBaseUrl: resolveServiceModelsBaseUrlMock,
@@ -176,7 +191,6 @@ vi.mock("@actalk/inkos-core", () => {
     getServiceApiKey: getServiceApiKeyMock,
     getServiceApiKey: getServiceApiKeyMock,
     listModelsForService: listModelsForServiceMock,
     listModelsForService: listModelsForServiceMock,
     GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),
     GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),
-    isNewLayoutBook: vi.fn(async () => false),
   };
   };
 });
 });
 
 
@@ -327,10 +341,13 @@ describe("createStudioServer daemon lifecycle", () => {
     rollbackToChapterMock.mockResolvedValue([]);
     rollbackToChapterMock.mockResolvedValue([]);
     pipelineConfigs.length = 0;
     pipelineConfigs.length = 0;
     runAgentSessionMock.mockReset();
     runAgentSessionMock.mockReset();
-    findOrCreateBookSessionMock.mockReset();
+    createAndPersistBookSessionMock.mockReset();
     loadBookSessionMock.mockReset();
     loadBookSessionMock.mockReset();
     persistBookSessionMock.mockReset();
     persistBookSessionMock.mockReset();
     appendBookSessionMessageMock.mockReset();
     appendBookSessionMessageMock.mockReset();
+    renameBookSessionMock.mockReset();
+    deleteBookSessionMock.mockReset();
+    migrateBookSessionMock.mockReset();
     resolveServiceModelMock.mockReset();
     resolveServiceModelMock.mockReset();
     loadSecretsMock.mockReset();
     loadSecretsMock.mockReset();
     saveSecretsMock.mockReset();
     saveSecretsMock.mockReset();
@@ -342,17 +359,26 @@ describe("createStudioServer daemon lifecycle", () => {
     // Default BookSession for agent tests
     // Default BookSession for agent tests
     const defaultBookSession = {
     const defaultBookSession = {
       sessionId: "agent-session-1",
       sessionId: "agent-session-1",
-      projectRoot: root,
-      activeBookId: "demo-book",
+      bookId: "demo-book",
+      title: null,
       messages: [],
       messages: [],
       events: [],
       events: [],
+      draftRounds: [],
+      createdAt: 1,
+      updatedAt: 1,
     };
     };
-    findOrCreateBookSessionMock.mockResolvedValue(defaultBookSession);
-    loadBookSessionMock.mockResolvedValue(null);
+    createAndPersistBookSessionMock.mockResolvedValue(defaultBookSession);
+    loadBookSessionMock.mockResolvedValue(defaultBookSession);
     persistBookSessionMock.mockResolvedValue(undefined);
     persistBookSessionMock.mockResolvedValue(undefined);
     appendBookSessionMessageMock.mockImplementation(
     appendBookSessionMessageMock.mockImplementation(
       (session: unknown, _msg: unknown) => session,
       (session: unknown, _msg: unknown) => session,
     );
     );
+    renameBookSessionMock.mockResolvedValue(null);
+    deleteBookSessionMock.mockResolvedValue(undefined);
+    migrateBookSessionMock.mockImplementation(async (_root: string, _sessionId: string, bookId: string) => ({
+      ...defaultBookSession,
+      bookId,
+    }));
     runAgentSessionMock.mockResolvedValue({
     runAgentSessionMock.mockResolvedValue({
       responseText: "Agent response.",
       responseText: "Agent response.",
       messages: [],
       messages: [],
@@ -490,6 +516,14 @@ describe("createStudioServer daemon lifecycle", () => {
     };
     };
     loadProjectConfigMock.mockResolvedValue(freshConfig);
     loadProjectConfigMock.mockResolvedValue(freshConfig);
 
 
+    // Stub /models so probe doesn't hit the real OpenAI endpoint and short-circuit on 401.
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: false,
+      status: 404,
+      text: async () => "Not Found",
+    });
+    vi.stubGlobal("fetch", fetchMock as typeof fetch);
+
     const { createStudioServer } = await import("./server.js");
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(startupConfig as never, root);
     const app = createStudioServer(startupConfig as never, root);
 
 
@@ -504,7 +538,7 @@ describe("createStudioServer daemon lifecycle", () => {
       expect.anything(),
       expect.anything(),
       "fresh-model",
       "fresh-model",
       expect.any(Array),
       expect.any(Array),
-      expect.objectContaining({ maxTokens: 5 }),
+      expect.objectContaining({ maxTokens: expect.any(Number) }),
     );
     );
   });
   });
 
 
@@ -520,6 +554,13 @@ describe("createStudioServer daemon lifecycle", () => {
       },
       },
     };
     };
     loadProjectConfigMock.mockResolvedValue(freshConfig);
     loadProjectConfigMock.mockResolvedValue(freshConfig);
+    // Stub /models so probe doesn't hit the real OpenAI endpoint and short-circuit on 401.
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: false,
+      status: 404,
+      text: async () => "Not Found",
+    });
+    vi.stubGlobal("fetch", fetchMock as typeof fetch);
     createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
     createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
     chatCompletionMock.mockImplementation(async (client: any) => {
     chatCompletionMock.mockImplementation(async (client: any) => {
       if (client.stream === false) {
       if (client.stream === false) {
@@ -1005,6 +1046,64 @@ describe("createStudioServer daemon lifecycle", () => {
     );
     );
   });
   });
 
 
+  it("keys cached model lists by baseUrl so custom endpoints do not leak stale results", async () => {
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        services: [
+          { service: "custom", name: "Switcher", baseUrl: "https://a.example.com/v1" },
+        ],
+      },
+    }, null, 2), "utf-8");
+    const fetchMock = vi.fn(async (input: string | URL | Request) => {
+      const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
+      if (url === "https://a.example.com/v1/models") {
+        return {
+          ok: true,
+          json: async () => ({ data: [{ id: "model-a" }] }),
+          text: async () => "",
+        };
+      }
+      if (url === "https://b.example.com/v1/models") {
+        return {
+          ok: true,
+          json: async () => ({ data: [{ id: "model-b" }] }),
+          text: async () => "",
+        };
+      }
+      return {
+        ok: false,
+        status: 404,
+        text: async () => "404 page not found",
+      };
+    });
+    vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const first = await app.request("http://localhost/api/v1/services/custom%3ASwitcher/models?apiKey=sk-shared-tail");
+    expect(first.status).toBe(200);
+    await expect(first.json()).resolves.toMatchObject({
+      models: [{ id: "model-a", name: "model-a" }],
+    });
+
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        services: [
+          { service: "custom", name: "Switcher", baseUrl: "https://b.example.com/v1" },
+        ],
+      },
+    }, null, 2), "utf-8");
+
+    const second = await app.request("http://localhost/api/v1/services/custom%3ASwitcher/models?apiKey=sk-shared-tail");
+    expect(second.status).toBe(200);
+    await expect(second.json()).resolves.toMatchObject({
+      models: [{ id: "model-b", name: "model-b" }],
+    });
+  });
+
   it("returns stored service secret for detail page rehydration", async () => {
   it("returns stored service secret for detail page rehydration", async () => {
     loadSecretsMock.mockResolvedValue({
     loadSecretsMock.mockResolvedValue({
       services: {
       services: {
@@ -1209,6 +1308,75 @@ describe("createStudioServer daemon lifecycle", () => {
     });
     });
   });
   });
 
 
+  it("creates a fresh book session on POST /api/v1/sessions", async () => {
+    createAndPersistBookSessionMock.mockResolvedValueOnce({
+      sessionId: "fresh-session",
+      bookId: "demo-book",
+      title: null,
+      messages: [],
+      events: [],
+      draftRounds: [],
+      createdAt: 10,
+      updatedAt: 10,
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/sessions", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ bookId: "demo-book" }),
+    });
+
+    expect(response.status).toBe(200);
+    expect(createAndPersistBookSessionMock).toHaveBeenCalledWith(root, "demo-book", undefined);
+    await expect(response.json()).resolves.toMatchObject({
+      session: { sessionId: "fresh-session", bookId: "demo-book", title: null },
+    });
+  });
+
+  it("renames a session through PUT /api/v1/sessions/:sessionId", async () => {
+    renameBookSessionMock.mockResolvedValueOnce({
+      sessionId: "agent-session-1",
+      bookId: "demo-book",
+      title: "新标题",
+      messages: [],
+      events: [],
+      draftRounds: [],
+      createdAt: 1,
+      updatedAt: 2,
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/sessions/agent-session-1", {
+      method: "PUT",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ title: "  新标题  " }),
+    });
+
+    expect(response.status).toBe(200);
+    expect(renameBookSessionMock).toHaveBeenCalledWith(root, "agent-session-1", "新标题");
+    await expect(response.json()).resolves.toMatchObject({
+      session: { sessionId: "agent-session-1", title: "新标题" },
+    });
+  });
+
+  it("deletes a session through DELETE /api/v1/sessions/:sessionId", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/sessions/agent-session-1", {
+      method: "DELETE",
+    });
+
+    expect(response.status).toBe(200);
+    expect(deleteBookSessionMock).toHaveBeenCalledWith(root, "agent-session-1");
+    await expect(response.json()).resolves.toEqual({ ok: true });
+  });
+
   it("routes /api/agent through runAgentSession and returns response + sessionId", async () => {
   it("routes /api/agent through runAgentSession and returns response + sessionId", async () => {
     runAgentSessionMock.mockResolvedValueOnce({
     runAgentSessionMock.mockResolvedValueOnce({
       responseText: "Completed write_next for demo-book.",
       responseText: "Completed write_next for demo-book.",
@@ -1224,7 +1392,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const response = await app.request("http://localhost/api/v1/agent", {
     const response = await app.request("http://localhost/api/v1/agent", {
       method: "POST",
       method: "POST",
       headers: { "Content-Type": "application/json" },
       headers: { "Content-Type": "application/json" },
-      body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
+      body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book", sessionId: "agent-session-1" }),
     });
     });
 
 
     expect(response.status).toBe(200);
     expect(response.status).toBe(200);
@@ -1293,6 +1461,7 @@ describe("createStudioServer daemon lifecycle", () => {
         instruction: "nihao",
         instruction: "nihao",
         service: "custom:CodexForMe",
         service: "custom:CodexForMe",
         model: "gpt-5.4",
         model: "gpt-5.4",
+        sessionId: "agent-session-1",
       }),
       }),
     });
     });
 
 
@@ -1311,7 +1480,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const response = await app.request("http://localhost/api/v1/agent", {
     const response = await app.request("http://localhost/api/v1/agent", {
       method: "POST",
       method: "POST",
       headers: { "Content-Type": "application/json" },
       headers: { "Content-Type": "application/json" },
-      body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
+      body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book", sessionId: "agent-session-1" }),
     });
     });
 
 
     expect(response.status).toBe(500);
     expect(response.status).toBe(500);
@@ -1336,7 +1505,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const response = await app.request("http://localhost/api/v1/agent", {
     const response = await app.request("http://localhost/api/v1/agent", {
       method: "POST",
       method: "POST",
       headers: { "Content-Type": "application/json" },
       headers: { "Content-Type": "application/json" },
-      body: JSON.stringify({ instruction: "nihao", activeBookId: "demo-book" }),
+      body: JSON.stringify({ instruction: "nihao", activeBookId: "demo-book", sessionId: "agent-session-1" }),
     });
     });
 
 
     expect(response.status).toBe(502);
     expect(response.status).toBe(502);
@@ -1365,7 +1534,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const response = await app.request("http://localhost/api/v1/agent", {
     const response = await app.request("http://localhost/api/v1/agent", {
       method: "POST",
       method: "POST",
       headers: { "Content-Type": "application/json" },
       headers: { "Content-Type": "application/json" },
-      body: JSON.stringify({ instruction: "nihao", activeBookId: "demo-book" }),
+      body: JSON.stringify({ instruction: "nihao", activeBookId: "demo-book", sessionId: "agent-session-1" }),
     });
     });
 
 
     expect(response.status).toBe(200);
     expect(response.status).toBe(200);
@@ -1375,6 +1544,25 @@ describe("createStudioServer daemon lifecycle", () => {
     });
     });
   });
   });
 
 
+  it("rejects /api/v1/agent requests without sessionId", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
+    });
+
+    expect(response.status).toBe(400);
+    await expect(response.json()).resolves.toEqual({
+      error: {
+        code: "SESSION_ID_REQUIRED",
+        message: "sessionId is required",
+      },
+    });
+  });
+
   it("returns the shared interaction session state", async () => {
   it("returns the shared interaction session state", async () => {
     loadProjectSessionMock.mockResolvedValue({
     loadProjectSessionMock.mockResolvedValue({
       sessionId: "session-2",
       sessionId: "session-2",

+ 133 - 37
packages/studio/src/api/server.ts

@@ -13,11 +13,15 @@ import {
   loadProjectSession,
   loadProjectSession,
   processProjectInteractionRequest,
   processProjectInteractionRequest,
   resolveSessionActiveBook,
   resolveSessionActiveBook,
-  findOrCreateBookSession,
   listBookSessions,
   listBookSessions,
   loadBookSession,
   loadBookSession,
   persistBookSession,
   persistBookSession,
   appendBookSessionMessage,
   appendBookSessionMessage,
+  createAndPersistBookSession,
+  renameBookSession,
+  deleteBookSession,
+  migrateBookSession,
+  SessionAlreadyMigratedError,
   runAgentSession,
   runAgentSession,
   buildAgentSystemPrompt,
   buildAgentSystemPrompt,
   resolveServicePreset,
   resolveServicePreset,
@@ -36,7 +40,6 @@ import {
   type ProjectConfig,
   type ProjectConfig,
   type LogSink,
   type LogSink,
   type LogEntry,
   type LogEntry,
-  type BookSession,
 } from "@actalk/inkos-core";
 } from "@actalk/inkos-core";
 import { access, readFile, readdir, writeFile } from "node:fs/promises";
 import { access, readFile, readdir, writeFile } from "node:fs/promises";
 import { join } from "node:path";
 import { join } from "node:path";
@@ -119,6 +122,9 @@ type EventHandler = (event: string, data: unknown) => void;
 const subscribers = new Set<EventHandler>();
 const subscribers = new Set<EventHandler>();
 const bookCreateStatus = new Map<string, { status: "creating" | "error"; error?: string }>();
 const bookCreateStatus = new Map<string, { status: "creating" | "error"; error?: string }>();
 
 
+// 内存缓存:service -> 模型列表 + 更新时间戳;避免每次 sidebar 挂载时都打真实 LLM /models
+const modelListCache = new Map<string, { models: Array<{ id: string; name: string }>; at: number }>();
+
 interface ServiceConfigEntry {
 interface ServiceConfigEntry {
   service: string;
   service: string;
   name?: string;
   name?: string;
@@ -569,10 +575,23 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
   async function buildPipelineConfig(
   async function buildPipelineConfig(
     overrides?: Partial<Pick<PipelineConfig, "externalContext" | "client" | "model">> & {
     overrides?: Partial<Pick<PipelineConfig, "externalContext" | "client" | "model">> & {
       readonly currentConfig?: ProjectConfig;
       readonly currentConfig?: ProjectConfig;
+      readonly sessionIdForSSE?: string;
     },
     },
   ): Promise<PipelineConfig> {
   ): Promise<PipelineConfig> {
     const currentConfig = overrides?.currentConfig ?? await loadCurrentProjectConfig();
     const currentConfig = overrides?.currentConfig ?? await loadCurrentProjectConfig();
-    const logger = createLogger({ tag: "studio", sinks: [sseSink, consoleSink] });
+    const scopedSseSink: LogSink = overrides?.sessionIdForSSE
+      ? {
+          write(entry) {
+            broadcast("log", {
+              sessionId: overrides.sessionIdForSSE,
+              level: entry.level,
+              tag: entry.tag,
+              message: entry.message,
+            });
+          },
+        }
+      : sseSink;
+    const logger = createLogger({ tag: "studio", sinks: [scopedSseSink, consoleSink] });
     return {
     return {
       client: overrides?.client ?? createLLMClient(currentConfig.llm),
       client: overrides?.client ?? createLLMClient(currentConfig.llm),
       model: overrides?.model ?? currentConfig.llm.model,
       model: overrides?.model ?? currentConfig.llm.model,
@@ -583,6 +602,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       logger,
       logger,
       onStreamProgress: (progress) => {
       onStreamProgress: (progress) => {
         broadcast("llm:progress", {
         broadcast("llm:progress", {
+          ...(overrides?.sessionIdForSSE ? { sessionId: overrides.sessionIdForSSE } : {}),
           status: progress.status,
           status: progress.status,
           elapsedMs: progress.elapsedMs,
           elapsedMs: progress.elapsedMs,
           totalChars: progress.totalChars,
           totalChars: progress.totalChars,
@@ -1083,7 +1103,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     return c.json({
     return c.json({
       ok: true,
       ok: true,
       modelCount: probe.models.length,
       modelCount: probe.models.length,
-      models: probe.models.slice(0, 50),
+      models: probe.models,
       selectedModel: probe.selectedModel,
       selectedModel: probe.selectedModel,
       detected: {
       detected: {
         apiFormat: probe.apiFormat,
         apiFormat: probe.apiFormat,
@@ -1117,21 +1137,32 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 
 
   app.get("/api/v1/services/:service/models", async (c) => {
   app.get("/api/v1/services/:service/models", async (c) => {
     const service = c.req.param("service");
     const service = c.req.param("service");
+    const refresh = c.req.query("refresh") === "1";
     const apiKey = c.req.query("apiKey") || await getServiceApiKey(root, service);
     const apiKey = c.req.query("apiKey") || await getServiceApiKey(root, service);
 
 
     // No key = no models
     // No key = no models
     if (!apiKey) return c.json({ models: [] });
     if (!apiKey) return c.json({ models: [] });
 
 
-    // Fast path: services with knownModels return immediately
     const preset = resolveServicePreset(isCustomServiceId(service) ? "custom" : service);
     const preset = resolveServicePreset(isCustomServiceId(service) ? "custom" : service);
+    const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service);
+
+    // Cache by service + resolved baseUrl + apiKey fingerprint; valid for 10 min unless ?refresh=1
+    const cacheKey = `${service}::${resolvedBaseUrl ?? ""}::${apiKey.slice(-8)}`;
+    if (!refresh) {
+      const cached = modelListCache.get(cacheKey);
+      if (cached && Date.now() - cached.at < 10 * 60 * 1000) {
+        return c.json({ models: cached.models });
+      }
+    }
+
+    // Fast path: services with knownModels return immediately
     if (preset?.knownModels && preset.knownModels.length > 0) {
     if (preset?.knownModels && preset.knownModels.length > 0) {
-      return c.json({
-        models: preset.knownModels.map((id) => ({ id, name: id })),
-      });
+      const models = preset.knownModels.map((id) => ({ id, name: id }));
+      modelListCache.set(cacheKey, { models, at: Date.now() });
+      return c.json({ models });
     }
     }
 
 
     // Simple /models API call + fallback to pi-ai built-in list (no slow probe)
     // Simple /models API call + fallback to pi-ai built-in list (no slow probe)
-    const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service);
     if (!resolvedBaseUrl) return c.json({ models: [] });
     if (!resolvedBaseUrl) return c.json({ models: [] });
 
 
     const modelsBase = preset?.modelsBaseUrl ?? resolvedBaseUrl;
     const modelsBase = preset?.modelsBaseUrl ?? resolvedBaseUrl;
@@ -1151,6 +1182,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       const builtIn = await listModelsForService(service, apiKey);
       const builtIn = await listModelsForService(service, apiKey);
       models = builtIn.map((m) => ({ id: m.id, name: m.name }));
       models = builtIn.map((m) => ({ id: m.id, name: m.name }));
     }
     }
+    modelListCache.set(cacheKey, { models, at: Date.now() });
     return c.json({ models });
     return c.json({ models });
   });
   });
 
 
@@ -1360,13 +1392,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
   app.get("/api/v1/sessions", async (c) => {
   app.get("/api/v1/sessions", async (c) => {
     const bookId = c.req.query("bookId");
     const bookId = c.req.query("bookId");
     const sessions = await listBookSessions(root, bookId === undefined ? null : bookId === "null" ? null : bookId);
     const sessions = await listBookSessions(root, bookId === undefined ? null : bookId === "null" ? null : bookId);
-    return c.json({ sessions: sessions.map((s) => ({
-      sessionId: s.sessionId,
-      bookId: s.bookId,
-      messageCount: s.messages.length,
-      createdAt: s.createdAt,
-      updatedAt: s.updatedAt,
-    })) });
+    return c.json({ sessions });
   });
   });
 
 
   app.get("/api/v1/sessions/:sessionId", async (c) => {
   app.get("/api/v1/sessions/:sessionId", async (c) => {
@@ -1376,12 +1402,35 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
   });
   });
 
 
   app.post("/api/v1/sessions", async (c) => {
   app.post("/api/v1/sessions", async (c) => {
-    const body = await c.req.json<{ bookId?: string | null }>().catch(() => ({}));
+    const body = await c.req.json<{ bookId?: string | null; sessionId?: string }>().catch(() => ({}));
     const bookId = (body as { bookId?: string | null }).bookId ?? null;
     const bookId = (body as { bookId?: string | null }).bookId ?? null;
-    const session = await findOrCreateBookSession(root, bookId);
+    const sessionId = (body as { sessionId?: string }).sessionId;
+    // sessionId 只允许 timestamp-random 格式;防止注入任意文件名
+    const safeSessionId = sessionId && /^[0-9]+-[a-z0-9]+$/.test(sessionId) ? sessionId : undefined;
+    const session = await createAndPersistBookSession(root, bookId, safeSessionId);
+    return c.json({ session });
+  });
+
+  app.put("/api/v1/sessions/:sessionId", async (c) => {
+    const sessionId = c.req.param("sessionId");
+    const body = await c.req.json<{ title?: string }>().catch(() => ({}) as { title?: string });
+    const title = body.title?.trim();
+    if (!title) {
+      throw new ApiError(400, "INVALID_SESSION_TITLE", "Session title is required");
+    }
+
+    const session = await renameBookSession(root, sessionId, title);
+    if (!session) {
+      return c.json({ error: "Session not found" }, 404);
+    }
     return c.json({ session });
     return c.json({ session });
   });
   });
 
 
+  app.delete("/api/v1/sessions/:sessionId", async (c) => {
+    await deleteBookSession(root, c.req.param("sessionId"));
+    return c.json({ ok: true });
+  });
+
   app.post("/api/v1/agent", async (c) => {
   app.post("/api/v1/agent", async (c) => {
     const { instruction, activeBookId, sessionId: reqSessionId, model: reqModel, service: reqService } = await c.req.json<{
     const { instruction, activeBookId, sessionId: reqSessionId, model: reqModel, service: reqService } = await c.req.json<{
       instruction: string;
       instruction: string;
@@ -1394,23 +1443,23 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     if (!instruction?.trim()) {
     if (!instruction?.trim()) {
       return c.json({ error: "No instruction provided" }, 400);
       return c.json({ error: "No instruction provided" }, 400);
     }
     }
+    if (!sessionId?.trim()) {
+      throw new ApiError(400, "SESSION_ID_REQUIRED", "sessionId is required");
+    }
 
 
-    broadcast("agent:start", { instruction, activeBookId });
+    broadcast("agent:start", { instruction, activeBookId, sessionId });
 
 
     try {
     try {
       // Load config + create LLM client (pipeline created after model resolution)
       // Load config + create LLM client (pipeline created after model resolution)
       const config = await loadCurrentProjectConfig({ requireApiKey: false });
       const config = await loadCurrentProjectConfig({ requireApiKey: false });
       const client = createLLMClient(config.llm);
       const client = createLLMClient(config.llm);
 
 
-      // Resolve or create BookSession for history
-      let bookSession: BookSession;
-      if (sessionId) {
-        bookSession =
-          (await loadBookSession(root, sessionId)) ??
-          (await findOrCreateBookSession(root, activeBookId ?? null));
-      } else {
-        bookSession = await findOrCreateBookSession(root, activeBookId ?? null);
+      const loadedBookSession = await loadBookSession(root, sessionId);
+      if (!loadedBookSession) {
+        throw new ApiError(404, "SESSION_NOT_FOUND", `Session not found: ${sessionId}`);
       }
       }
+      let bookSession = loadedBookSession;
+      const streamSessionId = loadedBookSession.sessionId;
 
 
       // Build initial message context from persisted session
       // Build initial message context from persisted session
       const initialMessages = bookSession.messages
       const initialMessages = bookSession.messages
@@ -1522,6 +1571,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         client: pipelineClient,
         client: pipelineClient,
         model: reqModel ?? config.llm.model,
         model: reqModel ?? config.llm.model,
         currentConfig: config,
         currentConfig: config,
+        sessionIdForSSE: bookSession.sessionId,
       }));
       }));
 
 
       // Run pi-agent session
       // Run pi-agent session
@@ -1539,13 +1589,13 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
             if (event.type === "message_update") {
             if (event.type === "message_update") {
               const ame = event.assistantMessageEvent;
               const ame = event.assistantMessageEvent;
               if (ame.type === "text_delta") {
               if (ame.type === "text_delta") {
-                broadcast("draft:delta", { text: ame.delta });
+                broadcast("draft:delta", { sessionId: streamSessionId, text: ame.delta });
               } else if (ame.type === "thinking_delta") {
               } else if (ame.type === "thinking_delta") {
-                broadcast("thinking:delta", { text: (ame as any).delta });
+                broadcast("thinking:delta", { sessionId: streamSessionId, text: (ame as any).delta });
               } else if (ame.type === "thinking_start") {
               } else if (ame.type === "thinking_start") {
-                broadcast("thinking:start", {});
+                broadcast("thinking:start", { sessionId: streamSessionId });
               } else if (ame.type === "thinking_end") {
               } else if (ame.type === "thinking_end") {
-                broadcast("thinking:end", {});
+                broadcast("thinking:end", { sessionId: streamSessionId });
               }
               }
             }
             }
             if (event.type === "tool_execution_start") {
             if (event.type === "tool_execution_start") {
@@ -1567,6 +1617,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
               });
               });
 
 
               broadcast("tool:start", {
               broadcast("tool:start", {
+                sessionId: streamSessionId,
                 id: event.toolCallId,
                 id: event.toolCallId,
                 tool: event.toolName,
                 tool: event.toolName,
                 args,
                 args,
@@ -1574,7 +1625,11 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
               });
               });
             }
             }
             if (event.type === "tool_execution_update") {
             if (event.type === "tool_execution_update") {
-              broadcast("tool:update", { tool: event.toolName, partialResult: event.partialResult });
+              broadcast("tool:update", {
+                sessionId: streamSessionId,
+                tool: event.toolName,
+                partialResult: event.partialResult,
+              });
             }
             }
             if (event.type === "tool_execution_end") {
             if (event.type === "tool_execution_end") {
               const exec = collectedToolExecs.find(t => t.id === event.toolCallId);
               const exec = collectedToolExecs.find(t => t.id === event.toolCallId);
@@ -1586,6 +1641,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
                 else exec.result = summarizeResult(event.result);
                 else exec.result = summarizeResult(event.result);
               }
               }
               broadcast("tool:end", {
               broadcast("tool:end", {
+                sessionId: streamSessionId,
                 id: event.toolCallId,
                 id: event.toolCallId,
                 tool: event.toolName,
                 tool: event.toolName,
                 result: event.result,
                 result: event.result,
@@ -1604,6 +1660,16 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         content: instruction,
         content: instruction,
         timestamp: Date.now(),
         timestamp: Date.now(),
       });
       });
+      // 第一条用户消息就是 session 的标题:如果 title 还是 null,用消息内容(单行、≤20字)写入。
+      // 后续消息不覆盖;用户手动改名通过 renameBookSession 覆盖。
+      if (bookSession.title === null) {
+        const oneLine = instruction.trim().replace(/\s+/g, " ");
+        const title = oneLine.length > 20 ? `${oneLine.slice(0, 20)}…` : oneLine;
+        if (title) {
+          bookSession = { ...bookSession, title };
+          broadcast("session:title", { sessionId: bookSession.sessionId, title });
+        }
+      }
       if (result.responseText) {
       if (result.responseText) {
         const lastAssistant = result.messages?.filter((m: any) => m.role === "assistant").pop();
         const lastAssistant = result.messages?.filter((m: any) => m.role === "assistant").pop();
         const thinking = lastAssistant?.thinking;
         const thinking = lastAssistant?.thinking;
@@ -1622,7 +1688,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
             service: configuredEntry?.service ?? reqService ?? config.llm.service,
             service: configuredEntry?.service ?? reqService ?? config.llm.service,
             model: reqModel ?? config.llm.model,
             model: reqModel ?? config.llm.model,
             apiKey: agentApiKey ?? config.llm.apiKey,
             apiKey: agentApiKey ?? config.llm.apiKey,
-            baseUrl: configuredEntry?.baseUrl ?? config.llm.baseUrl,
+            baseUrl: configuredEntry?.baseUrl ?? "",
             ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
             ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
             ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
             ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
           } as ProjectConfig["llm"]);
           } as ProjectConfig["llm"]);
@@ -1657,7 +1723,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
             service: configuredEntry?.service ?? reqService ?? config.llm.service,
             service: configuredEntry?.service ?? reqService ?? config.llm.service,
             model: reqModel ?? config.llm.model,
             model: reqModel ?? config.llm.model,
             apiKey: agentApiKey ?? config.llm.apiKey,
             apiKey: agentApiKey ?? config.llm.apiKey,
-            baseUrl: configuredEntry?.baseUrl ?? config.llm.baseUrl,
+            baseUrl: configuredEntry?.baseUrl ?? "",
             ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
             ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
             ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
             ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
           } as ProjectConfig["llm"]);
           } as ProjectConfig["llm"]);
@@ -1683,15 +1749,45 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       }
       }
       await persistBookSession(root, bookSession);
       await persistBookSession(root, bookSession);
 
 
-      broadcast("agent:complete", { instruction, activeBookId });
+      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: { sessionId: bookSession.sessionId },
+        session: {
+          sessionId: bookSession.sessionId,
+          ...(bookSession.bookId ? { activeBookId: bookSession.bookId } : {}),
+        },
       });
       });
     } catch (e) {
     } catch (e) {
+      if (e instanceof ApiError) {
+        throw e;
+      }
+      if (e instanceof SessionAlreadyMigratedError) {
+        const migratedMessage = e instanceof Error ? e.message : String(e);
+        throw new ApiError(409, "SESSION_ALREADY_MIGRATED", migratedMessage);
+      }
       const msg = e instanceof Error ? e.message : String(e);
       const msg = e instanceof Error ? e.message : String(e);
-      broadcast("agent:error", { instruction, activeBookId, error: msg });
+      broadcast("agent:error", { instruction, activeBookId, sessionId, error: msg });
 
 
       // Agent busy — return 429 with user-friendly message
       // Agent busy — return 429 with user-friendly message
       if (/already processing|prompt.*queue/i.test(msg)) {
       if (/already processing|prompt.*queue/i.test(msg)) {

+ 5 - 2
packages/studio/src/components/ConfirmDialog.tsx

@@ -1,4 +1,5 @@
 import { useEffect, useRef } from "react";
 import { useEffect, useRef } from "react";
+import { createPortal } from "react-dom";
 import { AlertTriangle, X } from "lucide-react";
 import { AlertTriangle, X } from "lucide-react";
 
 
 interface ConfirmDialogProps {
 interface ConfirmDialogProps {
@@ -34,10 +35,11 @@ export function ConfirmDialog({
   }, [open, onCancel]);
   }, [open, onCancel]);
 
 
   if (!open) return null;
   if (!open) return null;
+  if (typeof document === "undefined") return null;
 
 
   const isDanger = variant === "danger";
   const isDanger = variant === "danger";
 
 
-  return (
+  return createPortal(
     <div
     <div
       ref={overlayRef}
       ref={overlayRef}
       className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm fade-in"
       className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm fade-in"
@@ -87,6 +89,7 @@ export function ConfirmDialog({
           </button>
           </button>
         </div>
         </div>
       </div>
       </div>
-    </div>
+    </div>,
+    document.body,
   );
   );
 }
 }

+ 293 - 26
packages/studio/src/components/Sidebar.tsx

@@ -1,20 +1,40 @@
-import { useEffect } from "react";
+import { useEffect, useMemo, useState } from "react";
 import { useApi } from "../hooks/use-api";
 import { useApi } from "../hooks/use-api";
 import type { SSEMessage } from "../hooks/use-sse";
 import type { SSEMessage } from "../hooks/use-sse";
 import { shouldRefetchBookCollections, shouldRefetchDaemonStatus } from "../hooks/use-book-activity";
 import { shouldRefetchBookCollections, shouldRefetchDaemonStatus } from "../hooks/use-book-activity";
 import type { TFunction } from "../hooks/use-i18n";
 import type { TFunction } from "../hooks/use-i18n";
+import { useChatStore } from "../store/chat";
+import { ConfirmDialog } from "./ConfirmDialog";
+import {
+  Dialog,
+  DialogContent,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "./ui/dialog";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuSeparator,
+  DropdownMenuTrigger,
+} from "./ui/dropdown-menu";
 import {
 import {
-  Book,
   Settings,
   Settings,
   Terminal,
   Terminal,
   Plus,
   Plus,
   ScrollText,
   ScrollText,
   Boxes,
   Boxes,
-  Zap,
   Wand2,
   Wand2,
   FileInput,
   FileInput,
   TrendingUp,
   TrendingUp,
   Stethoscope,
   Stethoscope,
+  FolderOpen,
+  ChevronRight,
+  Loader2,
+  MoreHorizontal,
+  Pencil,
+  Trash2,
 } from "lucide-react";
 } from "lucide-react";
 
 
 interface BookSummary {
 interface BookSummary {
@@ -47,6 +67,22 @@ export function Sidebar({ nav, activePage, sse, t }: {
 }) {
 }) {
   const { data, refetch: refetchBooks } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
   const { data, refetch: refetchBooks } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
   const { data: daemon, refetch: refetchDaemon } = useApi<{ running: boolean }>("/daemon");
   const { data: daemon, refetch: refetchDaemon } = useApi<{ running: boolean }>("/daemon");
+  const sessions = useChatStore((s) => s.sessions);
+  const sessionIdsByBook = useChatStore((s) => s.sessionIdsByBook);
+  const activeSessionId = useChatStore((s) => s.activeSessionId);
+  const bookDataVersion = useChatStore((s) => s.bookDataVersion);
+  const loadSessionList = useChatStore((s) => s.loadSessionList);
+  const loadSessionDetail = useChatStore((s) => s.loadSessionDetail);
+  const activateSession = useChatStore((s) => s.activateSession);
+  const createDraftSession = useChatStore((s) => s.createDraftSession);
+  const renameSession = useChatStore((s) => s.renameSession);
+  const deleteSession = useChatStore((s) => s.deleteSession);
+  const [renameTarget, setRenameTarget] = useState<{ sessionId: string; currentTitle: string } | null>(null);
+  const [renameValue, setRenameValue] = useState("");
+  const [deleteTarget, setDeleteTarget] = useState<{ sessionId: string; title: string } | null>(null);
+  const [expandedBooks, setExpandedBooks] = useState<Set<string>>(new Set());
+
+  const books = data?.books ?? [];
 
 
   useEffect(() => {
   useEffect(() => {
     const recent = sse.messages.at(-1);
     const recent = sse.messages.at(-1);
@@ -59,6 +95,73 @@ export function Sidebar({ nav, activePage, sse, t }: {
     }
     }
   }, [refetchBooks, refetchDaemon, sse.messages]);
   }, [refetchBooks, refetchDaemon, sse.messages]);
 
 
+  // bookDataVersion 变化(外部数据信号)时才重拉当前已展开书的 session 列表;
+  // 展开/折叠本身不触发请求(展开由 toggleBook 驱动,已带"首次加载"判断)。
+  useEffect(() => {
+    for (const bookId of expandedBooks) {
+      void loadSessionList(bookId);
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [bookDataVersion, loadSessionList]);
+
+  const toggleBook = (bookId: string) => {
+    setExpandedBooks((prev) => {
+      const next = new Set(prev);
+      if (next.has(bookId)) {
+        next.delete(bookId);
+        return next;
+      }
+      next.add(bookId);
+      // 首次展开才拉:已有 sessionIdsByBook 数据就直接用缓存
+      if (sessionIdsByBook[bookId] === undefined) {
+        void loadSessionList(bookId);
+      }
+      return next;
+    });
+  };
+
+  const sessionsByBook = useMemo(
+    () =>
+      Object.fromEntries(
+        books.map((book) => [
+          book.id,
+          (sessionIdsByBook[book.id] ?? [])
+            .map((sessionId) => sessions[sessionId])
+            .filter(Boolean),
+        ]),
+      ) as Record<string, Array<(typeof sessions)[string]>>,
+    [books, sessionIdsByBook, sessions],
+  );
+
+  const openSession = (bookId: string, sessionId: string) => {
+    activateSession(sessionId);
+    nav.toBook(bookId);
+    void loadSessionDetail(sessionId);
+  };
+
+  const handleCreateSession = (bookId: string) => {
+    // 前端创建草稿会话:对话区立即变空,但 session 文件不落盘;
+    // 发第一条消息时 sendMessage 会调 POST /sessions 真正创建。
+    setExpandedBooks((prev) => new Set(prev).add(bookId));
+    createDraftSession(bookId);
+    nav.toBook(bookId);
+  };
+
+  const handleRenameConfirm = async () => {
+    if (!renameTarget) return;
+    const nextTitle = renameValue.trim();
+    if (!nextTitle) return;
+    await renameSession(renameTarget.sessionId, nextTitle);
+    setRenameTarget(null);
+    setRenameValue("");
+  };
+
+  const handleDeleteConfirm = async () => {
+    if (!deleteTarget) return;
+    await deleteSession(deleteTarget.sessionId);
+    setDeleteTarget(null);
+  };
+
   return (
   return (
     <aside className="w-[260px] shrink-0 border-r border-border bg-background/80 backdrop-blur-md flex flex-col h-full overflow-hidden select-none">
     <aside className="w-[260px] shrink-0 border-r border-border bg-background/80 backdrop-blur-md flex flex-col h-full overflow-hidden select-none">
       {/* Logo Area */}
       {/* Logo Area */}
@@ -94,29 +197,102 @@ export function Sidebar({ nav, activePage, sse, t }: {
             </button>
             </button>
           </div>
           </div>
 
 
-          <div className="space-y-1">
-            {data?.books.map((book) => (
-              <button
-                key={book.id}
-                onClick={() => nav.toBook(book.id)}
-                className={`w-full group flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all duration-200 ${
-                  activePage === `book:${book.id}`
-                    ? "bg-primary/10 text-primary font-medium"
-                    : "text-foreground font-medium hover:text-foreground hover:bg-secondary/50"
-                }`}
-              >
-                <Book size={16} className={activePage === `book:${book.id}` ? "text-primary" : "text-muted-foreground group-hover:text-foreground"} />
-                <span className="truncate flex-1 text-left">{book.title}</span>
-                {book.chaptersWritten > 0 && (
-                  <span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground group-hover:bg-primary/20 group-hover:text-primary transition-colors">
-                    {book.chaptersWritten}
-                  </span>
-                )}
-              </button>
-            ))}
-
-            {(!data?.books || data.books.length === 0) && (
-              <div className="px-3 py-6 text-xs text-muted-foreground/70 italic text-center border border-dashed border-border rounded-lg">
+          <div className="space-y-0.5">
+            {books.map((book) => {
+              const bookSessions = sessionsByBook[book.id] ?? [];
+              const isActiveBook = activePage === `book:${book.id}`;
+              const isExpanded = expandedBooks.has(book.id);
+              return (
+                <div key={book.id}>
+                  {/* 书名行:点击展开折叠,双击进入书 */}
+                  <div className="group/book flex items-center">
+                    <button
+                      type="button"
+                      onClick={() => toggleBook(book.id)}
+                      className={`flex min-w-0 flex-1 items-center gap-1.5 px-2 py-1.5 rounded-md text-sm transition-colors ${
+                        isActiveBook ? "text-foreground font-medium" : "text-muted-foreground hover:text-foreground hover:bg-secondary/30"
+                      }`}
+                    >
+                      <ChevronRight
+                        size={12}
+                        className={`shrink-0 text-muted-foreground/60 transition-transform ${isExpanded ? "rotate-90" : ""}`}
+                      />
+                      <FolderOpen size={14} className="shrink-0 text-muted-foreground/60" />
+                      <span className="truncate flex-1 text-left">{book.title}</span>
+                    </button>
+                  </div>
+
+                  {/* 展开后才显示 session 列表 + 新建按钮 */}
+                  {isExpanded && (
+                    <div className="mt-0.5">
+                      {bookSessions.map((session) => {
+                        const isActiveSession = isActiveBook && activeSessionId === session.sessionId;
+                        const label = getSessionLabel(session);
+                        return (
+                          <div
+                            key={session.sessionId}
+                            className={`group/session flex items-center rounded-md ${isActiveSession ? "bg-secondary/50" : "hover:bg-secondary/30"}`}
+                          >
+                            <button
+                              type="button"
+                              onClick={() => openSession(book.id, session.sessionId)}
+                              className="flex min-w-0 flex-1 items-center gap-2 pl-9 pr-2 py-1 text-left text-[13px] transition-colors"
+                            >
+                              <span className={`truncate flex-1 ${isActiveSession ? "text-foreground" : "text-muted-foreground group-hover/session:text-foreground"}`}>
+                                {label}
+                              </span>
+                              {session.isStreaming ? (
+                                <Loader2 size={12} className="shrink-0 animate-spin text-primary" />
+                              ) : (
+                                <span className="shrink-0 text-[11px] text-muted-foreground/40">
+                                  {formatRelativeTime(session.sessionId)}
+                                </span>
+                              )}
+                            </button>
+
+                            <DropdownMenu>
+                              <DropdownMenuTrigger className="flex h-6 w-6 shrink-0 items-center justify-center rounded opacity-0 group-hover/session:opacity-100 text-muted-foreground hover:text-foreground transition-opacity">
+                                <MoreHorizontal size={14} />
+                              </DropdownMenuTrigger>
+                              <DropdownMenuContent side="right" align="start" className="w-36">
+                                <DropdownMenuItem
+                                  onClick={() => {
+                                    setRenameTarget({ sessionId: session.sessionId, currentTitle: label });
+                                    setRenameValue(session.title ?? "");
+                                  }}
+                                >
+                                  <Pencil size={14} />
+                                  <span>改名</span>
+                                </DropdownMenuItem>
+                                <DropdownMenuSeparator />
+                                <DropdownMenuItem
+                                  variant="destructive"
+                                  onClick={() => setDeleteTarget({ sessionId: session.sessionId, title: label })}
+                                >
+                                  <Trash2 size={14} />
+                                  <span>删除</span>
+                                </DropdownMenuItem>
+                              </DropdownMenuContent>
+                            </DropdownMenu>
+                          </div>
+                        );
+                      })}
+                      <button
+                        type="button"
+                        onClick={() => void handleCreateSession(book.id)}
+                        className="w-full flex items-center gap-2 pl-9 pr-2 py-1 text-xs text-muted-foreground/50 hover:text-foreground transition-colors"
+                      >
+                        <Plus size={12} />
+                        <span>新建会话</span>
+                      </button>
+                    </div>
+                  )}
+                </div>
+              );
+            })}
+
+            {books.length === 0 && (
+              <div className="px-3 py-6 text-xs text-muted-foreground/50 italic text-center">
                 {t("dash.noBooks")}
                 {t("dash.noBooks")}
               </div>
               </div>
             )}
             )}
@@ -207,10 +383,101 @@ export function Sidebar({ nav, activePage, sse, t }: {
           </div>
           </div>
         </div>
         </div>
       )}
       )}
+
+      <Dialog
+        open={renameTarget !== null}
+        onOpenChange={(open) => {
+          if (!open) {
+            setRenameTarget(null);
+            setRenameValue("");
+          }
+        }}
+      >
+        <DialogContent
+          showCloseButton={false}
+          className="sm:max-w-[360px] p-4 gap-3"
+        >
+          <DialogHeader className="space-y-0 gap-0">
+            <DialogTitle className="font-sans text-sm font-medium">重命名会话</DialogTitle>
+          </DialogHeader>
+          <input
+            id="session-rename-input"
+            autoFocus
+            value={renameValue}
+            onChange={(event) => setRenameValue(event.target.value)}
+            onKeyDown={(event) => {
+              if (event.key === "Enter") {
+                event.preventDefault();
+                void handleRenameConfirm();
+              }
+            }}
+            placeholder="输入新标题"
+            className="w-full rounded-md border border-border/60 bg-background px-3 py-1.5 text-sm outline-none focus:border-border"
+          />
+          <DialogFooter className="gap-1 sm:gap-1">
+            <button
+              type="button"
+              onClick={() => {
+                setRenameTarget(null);
+                setRenameValue("");
+              }}
+              className="px-3 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
+            >
+              取消
+            </button>
+            <button
+              type="button"
+              onClick={() => void handleRenameConfirm()}
+              disabled={!renameValue.trim()}
+              className="px-3 py-1 text-xs font-medium rounded-md bg-foreground text-background hover:opacity-90 transition-opacity disabled:opacity-30"
+            >
+              保存
+            </button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+
+      <ConfirmDialog
+        open={deleteTarget !== null}
+        title="删除会话"
+        message={`确认删除“${deleteTarget?.title ?? ""}”吗?该操作只删除这条会话,不影响书籍内容。`}
+        confirmLabel="删除"
+        cancelLabel="取消"
+        variant="danger"
+        onConfirm={() => void handleDeleteConfirm()}
+        onCancel={() => setDeleteTarget(null)}
+      />
     </aside>
     </aside>
   );
   );
 }
 }
 
 
+function getSessionLabel(session: { sessionId: string; title: string | null; messages: ReadonlyArray<{ role: string; content: string }> }): string {
+  if (session.title) return session.title;
+  // 后端会在第一条用户消息发送时立即把消息内容持久化为占位标题。
+  // 这里处理的是"已有消息但标题还没同步回来"的短暂中间态(乐观显示)。
+  const firstUserMsg = session.messages.find((m) => m.role === "user")?.content?.trim();
+  if (firstUserMsg) {
+    const oneLine = firstUserMsg.replace(/\s+/g, " ");
+    return oneLine.length > 20 ? `${oneLine.slice(0, 20)}…` : oneLine;
+  }
+  return "新会话";
+}
+
+function formatRelativeTime(sessionId: string): string {
+  const rawTs = Number(sessionId.split("-")[0]);
+  if (!Number.isFinite(rawTs)) return "";
+  const diff = Date.now() - rawTs;
+  const minutes = Math.floor(diff / 60_000);
+  if (minutes < 1) return "刚刚";
+  if (minutes < 60) return `${minutes} 分钟`;
+  const hours = Math.floor(minutes / 60);
+  if (hours < 24) return `${hours} 小时`;
+  const days = Math.floor(hours / 24);
+  if (days < 30) return `${days} 天`;
+  const months = Math.floor(days / 30);
+  return `${months} 个月`;
+}
+
 function SidebarItem({ label, icon, active, onClick, badge, badgeColor }: {
 function SidebarItem({ label, icon, active, onClick, badge, badgeColor }: {
   label: string;
   label: string;
   icon: React.ReactNode;
   icon: React.ReactNode;

+ 64 - 0
packages/studio/src/hooks/use-session-events.ts

@@ -0,0 +1,64 @@
+import { useEffect } from "react";
+import type { SSEMessage } 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";
+
+/**
+ * 监听全局 SSE 事件中与 session 有关的两类消息:
+ * - session:title — AI 自动生成标题后推送,更新侧边栏显示
+ * - book:created  — 新建书籍成功后推送,把 session 从 null 迁移到新书籍、清 localStorage、跳转
+ */
+export function useSessionEvents(
+  sse: { messages: ReadonlyArray<SSEMessage> },
+  route: HashRoute,
+  setRoute: (route: HashRoute) => void,
+): void {
+  useEffect(() => {
+    const recent = sse.messages.at(-1);
+    if (!recent) return;
+
+    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;
+    }
+
+    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;
+
+      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 });
+        }
+      }
+    }
+  }, [route.page, setRoute, sse.messages]);
+}

+ 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),
 };
 };

+ 59 - 6
packages/studio/src/store/chat/slices/create/action.ts

@@ -1,9 +1,22 @@
 import type { StateCreator } from "zustand";
 import type { StateCreator } from "zustand";
 import type { ChatStore, CreateActions, AgentResponse } from "../../types";
 import type { ChatStore, CreateActions, AgentResponse } from "../../types";
 import { fetchJson } from "../../../../hooks/use-api";
 import { fetchJson } from "../../../../hooks/use-api";
+import { bookKey } from "../message/runtime";
 
 
 export const createCreateSlice: StateCreator<ChatStore, [], [], CreateActions> = (set, get) => ({
 export const createCreateSlice: StateCreator<ChatStore, [], [], CreateActions> = (set, get) => ({
-  setPendingBookArgs: (args) => set({ pendingBookArgs: args }),
+  setPendingBookArgs: (args) =>
+    set((state) => {
+      if (!state.activeSessionId) return {};
+      return {
+        sessions: {
+          ...state.sessions,
+          [state.activeSessionId]: {
+            ...state.sessions[state.activeSessionId],
+            pendingBookArgs: args,
+          },
+        },
+      };
+    }),
   setBookCreating: (creating) => set({ bookCreating: creating }),
   setBookCreating: (creating) => set({ bookCreating: creating }),
   setCreateProgress: (progress) => set({ createProgress: progress }),
   setCreateProgress: (progress) => set({ createProgress: progress }),
 
 
@@ -13,21 +26,61 @@ export const createCreateSlice: StateCreator<ChatStore, [], [], CreateActions> =
   closeArtifact: () => set({ sidebarView: "panel", artifactFile: null, artifactChapter: null }),
   closeArtifact: () => set({ sidebarView: "panel", artifactFile: null, artifactChapter: null }),
   setBookSummary: (summary) => set({ bookSummary: summary }),
   setBookSummary: (summary) => set({ bookSummary: summary }),
 
 
-  handleCreateBook: async (activeBookId) => {
-    if (!get().pendingBookArgs) return null;
+  handleCreateBook: async (sessionId, activeBookId) => {
+    const session = get().sessions[sessionId];
+    if (!session?.pendingBookArgs) return null;
 
 
     set({ bookCreating: true });
     set({ bookCreating: true });
     try {
     try {
       const data = await fetchJson<AgentResponse>("/agent", {
       const data = await fetchJson<AgentResponse>("/agent", {
         method: "POST",
         method: "POST",
         headers: { "Content-Type": "application/json" },
         headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({ instruction: "/create", activeBookId }),
+        body: JSON.stringify({ instruction: "/create", activeBookId, sessionId }),
       });
       });
       const newBookId = data.session?.activeBookId ?? null;
       const newBookId = data.session?.activeBookId ?? null;
-      if (newBookId) get().bumpBookDataVersion();
+      if (newBookId) {
+        set((state) => {
+          const session = state.sessions[sessionId];
+          if (!session) {
+            return {};
+          }
+
+          const previousIds = state.sessionIdsByBook[bookKey(session.bookId)] ?? [];
+          const nextIds = state.sessionIdsByBook[newBookId] ?? [];
+
+          return {
+            sessions: {
+              ...state.sessions,
+              [sessionId]: {
+                ...session,
+                bookId: newBookId,
+                pendingBookArgs: null,
+              },
+            },
+            sessionIdsByBook: {
+              ...state.sessionIdsByBook,
+              [bookKey(session.bookId)]: previousIds.filter((id) => id !== sessionId),
+              [newBookId]: nextIds.includes(sessionId) ? nextIds : [sessionId, ...nextIds],
+            },
+          };
+        });
+        get().bumpBookDataVersion();
+      }
+      set((state) => {
+        if (!state.sessions[sessionId]) return {};
+        return {
+          sessions: {
+            ...state.sessions,
+            [sessionId]: {
+              ...state.sessions[sessionId],
+              pendingBookArgs: null,
+            },
+          },
+        };
+      });
       return newBookId;
       return newBookId;
     } catch (e) {
     } catch (e) {
-      get().addErrorMessage(e instanceof Error ? e.message : String(e));
+      get().addErrorMessage(sessionId, e instanceof Error ? e.message : String(e));
       return null;
       return null;
     } finally {
     } finally {
       set({ bookCreating: false });
       set({ bookCreating: false });

+ 0 - 1
packages/studio/src/store/chat/slices/create/initialState.ts

@@ -1,7 +1,6 @@
 import type { CreateState } from "../../types";
 import type { CreateState } from "../../types";
 
 
 export const initialCreateState: CreateState = {
 export const initialCreateState: CreateState = {
-  pendingBookArgs: null,
   bookCreating: false,
   bookCreating: false,
   createProgress: "",
   createProgress: "",
   bookDataVersion: 0,
   bookDataVersion: 0,

+ 344 - 385
packages/studio/src/store/chat/slices/message/action.ts

@@ -1,406 +1,343 @@
 import type { StateCreator } from "zustand";
 import type { StateCreator } from "zustand";
-import type { ChatStore, MessageActions, AgentResponse, SessionMessage, Message, MessagePart, ToolExecution, PipelineStage } from "../../types";
+import type {
+  AgentResponse,
+  ChatStore,
+  MessageActions,
+  SessionResponse,
+  SessionSummary,
+} from "../../types";
 import { fetchJson } from "../../../../hooks/use-api";
 import { fetchJson } from "../../../../hooks/use-api";
-import { shouldRefreshSidebarForTool } from "../../message-policy";
-
-function extractErrorMessage(error: string | { code?: string; message?: string }): string {
-  if (typeof error === "string") return error;
-  return error.message ?? "Unknown error";
-}
-
-// -- Tool label helpers --
-
-const AGENT_LABELS: Record<string, string> = {
-  architect: "建书", writer: "写作", auditor: "审计",
-  reviser: "修订", exporter: "导出",
-};
-const TOOL_LABELS: Record<string, string> = {
-  read: "读取文件", edit: "编辑文件", grep: "搜索", ls: "列目录",
-};
-
-function resolveToolLabel(tool: string, agent?: string): string {
-  if (tool === "sub_agent" && agent) return AGENT_LABELS[agent] ?? agent;
-  return TOOL_LABELS[tool] ?? tool;
-}
-
-function summarizeResult(result: unknown): string {
-  if (typeof result === "string") return result.slice(0, 200);
-  if (result && typeof result === "object") {
-    const r = result as Record<string, unknown>;
-    if (typeof r.content === "string") return r.content.slice(0, 200);
-  }
-  return String(result).slice(0, 200);
-}
-
-function extractToolError(result: unknown): string {
-  if (typeof result === "string") return result.slice(0, 500);
-  if (result && typeof result === "object") {
-    const r = result as Record<string, unknown>;
-    if (typeof r.content === "string") return r.content.slice(0, 500);
-    if (r.content && Array.isArray(r.content)) {
-      const textPart = r.content.find((c: any) => c.type === "text");
-      if (textPart) return (textPart as any).text?.slice(0, 500) ?? "";
-    }
-  }
-  return String(result).slice(0, 500);
-}
-
-// -- Parts helpers --
-
-/** Get or create the streaming assistant message, returning [updatedMessages, streamMsg]. */
-function getOrCreateStream(messages: ReadonlyArray<Message>, streamTs: number): [ReadonlyArray<Message>, Message] {
-  const last = messages[messages.length - 1];
-  if (last?.timestamp === streamTs && last.role === "assistant") {
-    return [messages, last];
-  }
-  const newMsg: Message = { role: "assistant", content: "", timestamp: streamTs, parts: [] };
-  return [[...messages, newMsg], newMsg];
-}
-
-/** Replace the last message with an updated version. */
-function replaceLast(messages: ReadonlyArray<Message>, updated: Message): ReadonlyArray<Message> {
-  return [...messages.slice(0, -1), updated];
-}
-
-/** Find the last tool part that is "running" in a parts array. */
-function findRunningToolPart(parts: MessagePart[]): (MessagePart & { type: "tool" }) | undefined {
-  for (let i = parts.length - 1; i >= 0; i--) {
-    const p = parts[i];
-    if (p.type === "tool" && p.execution.status === "running") return p as MessagePart & { type: "tool" };
-  }
-  return undefined;
-}
-
-/** Derive flat fields (content, thinking, toolExecutions) from parts for persistence compatibility. */
-function deriveFlat(parts: MessagePart[]): { content: string; thinking?: string; thinkingStreaming?: boolean; toolExecutions?: ToolExecution[] } {
-  let content = "";
-  let thinking = "";
-  let thinkingStreaming = false;
-  const toolExecutions: ToolExecution[] = [];
-
-  for (const p of parts) {
-    if (p.type === "thinking") {
-      if (thinking) thinking += "\n\n---\n\n";
-      thinking += p.content;
-      if (p.streaming) thinkingStreaming = true;
-    } else if (p.type === "text") {
-      content += p.content;
-    } else if (p.type === "tool") {
-      toolExecutions.push(p.execution);
-    }
-  }
-
-  return {
-    content,
-    ...(thinking ? { thinking } : {}),
-    ...(thinkingStreaming ? { thinkingStreaming: true } : {}),
-    ...(toolExecutions.length > 0 ? { toolExecutions } : {}),
-  };
-}
+import { attachSessionStreamListeners } from "./stream-events";
+import {
+  bookKey,
+  createSessionRuntime,
+  deserializeMessages,
+  extractErrorMessage,
+  mergeSessionIds,
+  updateSession,
+  upsertSessionSummary,
+} from "./runtime";
 
 
 export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions> = (set, get) => ({
 export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions> = (set, get) => ({
-  setInput: (text) => set({ input: text }),
-
-  addUserMessage: (content) => set((s) => ({
-    messages: [...s.messages, { role: "user" as const, content, timestamp: Date.now() }],
-  })),
-
-  appendStreamChunk: (text, streamTs) => set((s) => {
-    const last = s.messages[s.messages.length - 1];
-    if (last?.timestamp === streamTs && last.role === "assistant") {
-      return { messages: [...s.messages.slice(0, -1), { ...last, content: last.content + text }] };
-    }
-    return { messages: [...s.messages, { role: "assistant" as const, content: text, timestamp: streamTs }] };
-  }),
-
-  finalizeStream: (streamTs, content, toolCall) => set((s) => ({
-    messages: s.messages.map((m) => {
-      if (m.timestamp !== streamTs || m.role !== "assistant") return m;
-      // Update the last text part in parts, or add one
-      const parts = [...(m.parts ?? [])];
-      const lastPart = parts[parts.length - 1];
-      if (lastPart?.type === "text") {
-        parts[parts.length - 1] = { ...lastPart, content };
-      } else if (content) {
-        parts.push({ type: "text", content });
-      }
-      return { ...m, content, toolCall, parts };
-    }),
-  })),
-
-  replaceStreamWithError: (streamTs, errorMsg) => set((s) => ({
-    messages: [
-      ...s.messages.filter((m) => !(m.timestamp === streamTs && m.role === "assistant")),
-      { role: "assistant" as const, content: `\u2717 ${errorMsg}`, timestamp: Date.now() },
-    ],
-  })),
-
-  addErrorMessage: (errorMsg) => set((s) => ({
-    messages: [...s.messages, { role: "assistant" as const, content: `\u2717 ${errorMsg}`, timestamp: Date.now() }],
-  })),
-
-  setLoading: (loading) => set({ loading }),
+  activateSession: (sessionId) =>
+    set({ activeSessionId: sessionId }),
 
 
-  setSelectedModel: (model, service) => set({ selectedModel: model, selectedService: service }),
+  setInput: (text) => set({ input: text }),
 
 
-  loadSessionMessages: (msgs) => set((s) => {
-    if (s.messages.length > 0) return s;
-    return {
-      messages: msgs
-        .filter((m) => m.role === "user" || m.role === "assistant")
-        .map((m) => {
-          const toolExecs = (m as any).toolExecutions as ToolExecution[] | undefined;
-          // Rebuild parts from flat fields for historical messages
-          const parts: MessagePart[] = [];
-          if (m.thinking) parts.push({ type: "thinking", content: m.thinking, streaming: false });
-          if (toolExecs) {
-            for (const exec of toolExecs) parts.push({ type: "tool", execution: exec });
-          }
-          if (m.content) parts.push({ type: "text", content: m.content });
+  addUserMessage: (sessionId, content) =>
+    set((state) => ({
+      sessions: updateSession(state.sessions, sessionId, (session) => ({
+        messages: [...session.messages, { role: "user", content, timestamp: Date.now() }],
+        lastError: null,
+      })),
+    })),
+
+  appendStreamChunk: (sessionId, text, streamTs) =>
+    set((state) => ({
+      sessions: updateSession(state.sessions, sessionId, (session) => {
+        const last = session.messages[session.messages.length - 1];
+        if (last?.timestamp === streamTs && last.role === "assistant") {
           return {
           return {
-            role: m.role as "user" | "assistant",
-            content: m.content,
-            thinking: m.thinking,
-            toolExecutions: toolExecs,
-            timestamp: m.timestamp,
-            parts: parts.length > 0 ? parts : undefined,
+            messages: [...session.messages.slice(0, -1), { ...last, content: last.content + text }],
           };
           };
+        }
+        return {
+          messages: [...session.messages, { role: "assistant", content: text, timestamp: streamTs }],
+        };
+      }),
+    })),
+
+  finalizeStream: (sessionId, streamTs, content, toolCall) =>
+    set((state) => ({
+      sessions: updateSession(state.sessions, sessionId, (session) => ({
+        messages: session.messages.map((message) => {
+          if (message.timestamp !== streamTs || message.role !== "assistant") return message;
+          const parts = [...(message.parts ?? [])];
+          const lastPart = parts[parts.length - 1];
+          if (lastPart?.type === "text") {
+            parts[parts.length - 1] = { ...lastPart, content };
+          } else if (content) {
+            parts.push({ type: "text", content });
+          }
+          return { ...message, content, toolCall, parts };
         }),
         }),
-    };
-  }),
+      })),
+    })),
+
+  replaceStreamWithError: (sessionId, streamTs, errorMsg) =>
+    set((state) => ({
+      sessions: updateSession(state.sessions, sessionId, (session) => ({
+        messages: [
+          ...session.messages.filter(
+            (message) => !(message.timestamp === streamTs && message.role === "assistant"),
+          ),
+          { role: "assistant", content: `\u2717 ${errorMsg}`, timestamp: Date.now() },
+        ],
+        isStreaming: false,
+        lastError: errorMsg,
+        stream: null,
+      })),
+    })),
+
+  addErrorMessage: (sessionId, errorMsg) =>
+    set((state) => ({
+      sessions: updateSession(state.sessions, sessionId, (session) => ({
+        messages: [...session.messages, { role: "assistant", content: `\u2717 ${errorMsg}`, timestamp: Date.now() }],
+        lastError: errorMsg,
+      })),
+    })),
+
+  loadSessionMessages: (sessionId, msgs) =>
+    set((state) => ({
+      sessions: updateSession(state.sessions, sessionId, (session) => {
+        if (session.messages.length > 0) return {};
+        return { messages: deserializeMessages(msgs) };
+      }),
+    })),
 
 
-  loadSession: async (bookId) => {
+  setSelectedModel: (model, service) => set({ selectedModel: model, selectedService: service }),
+
+  loadSessionList: async (bookId) => {
+    const query = bookId === null ? "null" : encodeURIComponent(bookId);
     try {
     try {
-      const data = await fetchJson<{ session: { sessionId: string; messages?: SessionMessage[] } }>("/sessions", {
-        method: "POST",
-        headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({ bookId: bookId ?? null }),
+      const data = await fetchJson<{ sessions: ReadonlyArray<SessionSummary> }>(`/sessions?bookId=${query}`);
+      set((state) => {
+        let sessions = state.sessions;
+        for (const summary of data.sessions) {
+          sessions = upsertSessionSummary(sessions, summary);
+        }
+        return {
+          sessions,
+          sessionIdsByBook: {
+            ...state.sessionIdsByBook,
+            [bookKey(bookId)]: data.sessions.map((session) => session.sessionId),
+          },
+        };
       });
       });
-      const session = data.session;
-      const prevSessionId = get().currentSessionId;
-
-      if (prevSessionId === session.sessionId && get().messages.length > 0) {
-        return;
-      }
-
-      get()._activeStream?.close();
-      set({ currentSessionId: session.sessionId, messages: [], loading: false, _activeStream: null });
-      if (session.messages && session.messages.length > 0) {
-        get().loadSessionMessages(session.messages);
-      }
     } catch {
     } catch {
-      set({ currentSessionId: null, messages: [], loading: false });
+      // ignore
     }
     }
   },
   },
 
 
-  sendMessage: async (text, activeBookId) => {
-    const trimmed = text.trim();
-    if (!trimmed || get().loading) return;
-
-    if (!get().selectedModel) {
-      get().addUserMessage(trimmed);
-      get().addErrorMessage("请先选择一个模型");
-      return;
+  createSession: async (bookId) => {
+    const data = await fetchJson<SessionResponse>("/sessions", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ bookId }),
+    });
+    const sessionId = data.session?.sessionId;
+    if (!sessionId) {
+      throw new Error("Failed to create session");
     }
     }
 
 
-    const hasBook = Boolean(activeBookId);
-    const instruction = hasBook ? trimmed : `/new ${trimmed}`;
-    const streamTs = Date.now() + 1;
-
-    set({ input: "", loading: true });
-    get().addUserMessage(trimmed);
-
-    get()._activeStream?.close();
-    const streamEs = new EventSource("/api/v1/events");
-    set({ _activeStream: streamEs });
-
-    // ---------------------------------------------------------------
-    // SSE listeners — each event updates `parts` as source of truth,
-    // then derives flat fields (content/thinking/toolExecutions) for
-    // persistence compatibility.
-    // ---------------------------------------------------------------
-
-    streamEs.addEventListener("thinking:start", () => {
-      set((s) => {
-        const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
-        const parts = [...(stream.parts ?? [])];
-        parts.push({ type: "thinking", content: "", streaming: true });
-        const flat = deriveFlat(parts);
-        return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
+    set((state) => {
+      const runtime = createSessionRuntime({
+        sessionId,
+        bookId: data.session?.bookId ?? bookId ?? null,
+        title: data.session?.title ?? null,
       });
       });
+      return {
+        sessions: {
+          ...state.sessions,
+          [sessionId]: runtime,
+        },
+        sessionIdsByBook: {
+          ...state.sessionIdsByBook,
+          [bookKey(runtime.bookId)]: mergeSessionIds(
+            state.sessionIdsByBook[bookKey(runtime.bookId)],
+            [sessionId],
+          ),
+        },
+        activeSessionId: sessionId,
+      };
     });
     });
 
 
-    streamEs.addEventListener("thinking:delta", (e: MessageEvent) => {
-      try {
-        const d = e.data ? JSON.parse(e.data) : null;
-        if (!d?.text) return;
-        set((s) => {
-          const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
-          const parts = [...(stream.parts ?? [])];
-          const last = parts[parts.length - 1];
-          if (last?.type === "thinking") {
-            parts[parts.length - 1] = { ...last, content: last.content + d.text };
-          }
-          const flat = deriveFlat(parts);
-          return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
-        });
-      } catch { /* ignore */ }
-    });
+    return sessionId;
+  },
 
 
-    streamEs.addEventListener("thinking:end", () => {
-      set((s) => {
-        const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
-        const parts = [...(stream.parts ?? [])];
-        const last = parts[parts.length - 1];
-        if (last?.type === "thinking") {
-          parts[parts.length - 1] = { ...last, streaming: false };
-        }
-        const flat = deriveFlat(parts);
-        return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
+  createDraftSession: (bookId) => {
+    // 前端生成 sessionId(与后端 createBookSession 同格式),暂不持久化到磁盘,
+    // 也暂不写入 sessionIdsByBook——侧边栏看不到这条 draft。
+    // 发送第一条消息时 sendMessage 会调 POST /sessions { sessionId, bookId } 落盘
+    // 并把 id 追加进 sessionIdsByBook,那一刻侧边栏才出现该会话(带着 title)。
+    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,
+        },
+        activeSessionId: sessionId,
+      };
     });
     });
+    return sessionId;
+  },
 
 
-    streamEs.addEventListener("draft:delta", (e: MessageEvent) => {
-      try {
-        const d = e.data ? JSON.parse(e.data) : null;
-        if (!d?.text) return;
-        set((s) => {
-          const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
-          const parts = [...(stream.parts ?? [])];
-          const last = parts[parts.length - 1];
-          if (last?.type === "text") {
-            parts[parts.length - 1] = { ...last, content: last.content + d.text };
-          } else {
-            parts.push({ type: "text", content: d.text });
-          }
-          const flat = deriveFlat(parts);
-          return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
-        });
-      } catch { /* ignore */ }
-    });
+  renameSession: async (sessionId, title) => {
+    const previous = get().sessions[sessionId]?.title ?? null;
+    set((state) => ({
+      sessions: updateSession(state.sessions, sessionId, () => ({ title })),
+    }));
 
 
-    streamEs.addEventListener("tool:start", (e: MessageEvent) => {
+    try {
+      await fetchJson(`/sessions/${sessionId}`, {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ title }),
+      });
+    } catch {
+      set((state) => ({
+        sessions: updateSession(state.sessions, sessionId, () => ({ title: previous })),
+      }));
+    }
+  },
+
+  deleteSession: async (sessionId) => {
+    const session = get().sessions[sessionId];
+    session?.stream?.close();
+    // 草稿会话还没写到磁盘,跳过 DELETE 请求避免后端返回 404
+    if (session && !session.isDraft) {
       try {
       try {
-        const d = e.data ? JSON.parse(e.data) : null;
-        if (!d?.tool) return;
-
-        set((s) => {
-          const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
-          const parts = [...(stream.parts ?? [])];
-
-          // For pipeline ops (sub_agent), move trailing text to thinking
-          if (d.tool === "sub_agent") {
-            const last = parts[parts.length - 1];
-            if (last?.type === "text" && last.content) {
-              parts.pop();
-              const prev = parts[parts.length - 1];
-              if (prev?.type === "thinking") {
-                parts[parts.length - 1] = { ...prev, content: prev.content + (prev.content ? "\n\n" : "") + last.content };
-              } else {
-                parts.push({ type: "thinking", content: last.content, streaming: false });
-              }
-            }
-          }
+        await fetchJson(`/sessions/${sessionId}`, { method: "DELETE" });
+      } catch {
+        // ignore
+      }
+    }
 
 
-          const agent = d.tool === "sub_agent" ? (d.args?.agent as string | undefined) : undefined;
-          const stages: PipelineStage[] | undefined = (d.stages as string[] | undefined)?.length
-            ? (d.stages as string[]).map((label) => ({ label, status: "pending" as const }))
-            : undefined;
-
-          const exec: ToolExecution = {
-            id: d.id as string,
-            tool: d.tool as string,
-            agent,
-            label: resolveToolLabel(d.tool, agent),
-            status: "running",
-            args: d.args as Record<string, unknown> | undefined,
-            stages,
-            startedAt: Date.now(),
-          };
+    set((state) => {
+      const { [sessionId]: deleted, ...rest } = state.sessions;
+      const sessionIdsByBook = Object.fromEntries(
+        Object.entries(state.sessionIdsByBook).map(([key, ids]) => [
+          key,
+          ids.filter((id) => id !== sessionId),
+        ]),
+      );
+
+      let activeSessionId = state.activeSessionId;
+      if (activeSessionId === sessionId) {
+        const fallbackKey = bookKey(session?.bookId ?? null);
+        activeSessionId = sessionIdsByBook[fallbackKey]?.[0] ?? null;
+      }
 
 
-          parts.push({ type: "tool", execution: exec });
-          const flat = deriveFlat(parts);
-          return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
-        });
-      } catch { /* ignore */ }
+      return {
+        sessions: rest,
+        sessionIdsByBook,
+        activeSessionId,
+      };
     });
     });
+  },
 
 
-    streamEs.addEventListener("tool:end", (e: MessageEvent) => {
-      try {
-        const d = e.data ? JSON.parse(e.data) : null;
-        if (!d?.tool) return;
-
-        set((s) => {
-          const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
-          const parts = (stream.parts ?? []).map((p) => {
-            if (p.type !== "tool" || p.execution.id !== d.id) return p;
-            const exec = { ...p.execution };
-            exec.status = d.isError ? "error" : "completed";
-            exec.completedAt = Date.now();
-            if (d.isError) exec.error = extractToolError(d.result);
-            else exec.result = summarizeResult(d.result);
-            exec.stages = exec.stages?.map((s) =>
-              s.status !== "completed" ? { ...s, status: "completed" as const, progress: undefined } : s
-            );
-            return { type: "tool" as const, execution: exec };
-          });
-          const flat = deriveFlat(parts);
-          return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
-        });
+  loadSessionDetail: async (sessionId) => {
+    // 草稿会话:磁盘上还没有文件,直接跳过远端拉取。
+    // 本地已有消息:不拉取远端,避免流式中或未持久化的消息被覆盖。
+    const existing = get().sessions[sessionId];
+    if (existing?.isDraft) return;
+    if (existing && existing.messages.length > 0) return;
 
 
-        if (shouldRefreshSidebarForTool(d.tool)) {
-          get().bumpBookDataVersion();
-        }
-      } catch { /* ignore */ }
-    });
+    try {
+      const data = await fetchJson<SessionResponse>(`/sessions/${sessionId}`);
+      const detail = data.session;
+      if (!detail?.sessionId) return;
+      const detailSessionId = detail.sessionId;
+      const messages = detail.messages ? deserializeMessages(detail.messages) : [];
+
+      set((state) => {
+        const runtime = state.sessions[detailSessionId];
+        // set 执行到这里可能已有本地消息写入(比如并发 sendMessage),再查一次。
+        if (runtime && runtime.messages.length > 0) return {};
+        const nextBookId = detail.bookId ?? runtime?.bookId ?? null;
+        return {
+          sessions: {
+            ...state.sessions,
+            [detailSessionId]: {
+              ...(runtime ?? createSessionRuntime({
+                sessionId: detailSessionId,
+                bookId: nextBookId,
+                title: detail.title ?? null,
+              })),
+              bookId: nextBookId,
+              title: detail.title ?? runtime?.title ?? null,
+              messages,
+            },
+          },
+          sessionIdsByBook: {
+            ...state.sessionIdsByBook,
+            [bookKey(nextBookId)]: mergeSessionIds(
+              state.sessionIdsByBook[bookKey(nextBookId)],
+              [detailSessionId],
+            ),
+          },
+        };
+      });
+    } catch {
+      // ignore
+    }
+  },
 
 
-    streamEs.addEventListener("log", (e: MessageEvent) => {
-      try {
-        const d = e.data ? JSON.parse(e.data) : null;
-        const msg = d?.message as string | undefined;
-        if (!msg) return;
-
-        set((s) => {
-          const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
-          const runningTool = findRunningToolPart([...(stream.parts ?? [])]);
-          if (!runningTool) return s;
-
-          const parts = (stream.parts ?? []).map((p) => {
-            if (p.type !== "tool" || p.execution.id !== runningTool.execution.id) return p;
-            return { type: "tool" as const, execution: { ...p.execution, logs: [...(p.execution.logs ?? []), msg] } };
-          });
-          const flat = deriveFlat(parts);
-          return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
-        });
-      } catch { /* ignore */ }
-    });
+  sendMessage: async (sessionId, text, activeBookId) => {
+    const trimmed = text.trim();
+    const session = get().sessions[sessionId];
+    if (!trimmed || !session || session.isStreaming) return;
 
 
-    streamEs.addEventListener("llm:progress", (e: MessageEvent) => {
+    if (!get().selectedModel) {
+      get().addUserMessage(sessionId, trimmed);
+      get().addErrorMessage(sessionId, "请先选择一个模型");
+      return;
+    }
+
+    // 草稿会话:第一条消息发送时才真正把 session 文件写到磁盘。
+    // 后端 POST /sessions 支持接受客户端传入的 sessionId,所以 id 保持一致,
+    // 前端 store 里的 runtime 不用 remount,只需要把 isDraft 翻成 false。
+    if (session.isDraft) {
       try {
       try {
-        const d = e.data ? JSON.parse(e.data) : null;
-        if (!d) return;
-
-        set((s) => {
-          const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
-          const runningTool = findRunningToolPart([...(stream.parts ?? [])]);
-          if (!runningTool?.execution.stages) return s;
-
-          const parts = (stream.parts ?? []).map((p) => {
-            if (p.type !== "tool" || p.execution.id !== runningTool.execution.id) return p;
-            const stages = p.execution.stages!.map((stage) =>
-              stage.status === "active"
-                ? { ...stage, progress: { status: d.status, elapsedMs: d.elapsedMs, totalChars: d.totalChars, chineseChars: d.chineseChars } }
-                : stage
-            );
-            return { type: "tool" as const, execution: { ...p.execution, stages } };
-          });
-          const flat = deriveFlat(parts);
-          return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
+        await fetchJson<SessionResponse>("/sessions", {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ sessionId, bookId: session.bookId }),
         });
         });
-      } catch { /* ignore */ }
-    });
+        // 落盘成功:把 isDraft 翻成 false,同时把 sessionId 追加进 sessionIdsByBook
+        // 让侧边栏现在才看到这条会话。
+        set((state) => ({
+          sessions: updateSession(state.sessions, sessionId, () => ({ isDraft: false })),
+          sessionIdsByBook: {
+            ...state.sessionIdsByBook,
+            [bookKey(session.bookId)]: mergeSessionIds(
+              state.sessionIdsByBook[bookKey(session.bookId)],
+              [sessionId],
+            ),
+          },
+        }));
+      } catch (err) {
+        get().addErrorMessage(sessionId, err instanceof Error ? err.message : String(err));
+        return;
+      }
+    }
 
 
-    // -- API call + finalize --
+    const instruction = activeBookId ? trimmed : `/new ${trimmed}`;
+    const streamTs = Date.now() + 1;
+
+    set((state) => ({
+      input: "",
+      activeSessionId: sessionId,
+      sessions: updateSession(state.sessions, sessionId, () => ({
+        isStreaming: true,
+        lastError: null,
+      })),
+    }));
+
+    get().addUserMessage(sessionId, trimmed);
+    session.stream?.close();
+    const streamEs = new EventSource("/api/v1/events");
+    set((state) => ({
+      sessions: updateSession(state.sessions, sessionId, () => ({ stream: streamEs })),
+    }));
+    attachSessionStreamListeners({ sessionId, streamTs, streamEs, set, get });
 
 
     try {
     try {
       const data = await fetchJson<AgentResponse>("/agent", {
       const data = await fetchJson<AgentResponse>("/agent", {
@@ -409,7 +346,7 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
         body: JSON.stringify({
         body: JSON.stringify({
           instruction,
           instruction,
           activeBookId,
           activeBookId,
-          sessionId: get().currentSessionId,
+          sessionId,
           model: get().selectedModel ?? undefined,
           model: get().selectedModel ?? undefined,
           service: get().selectedService ?? undefined,
           service: get().selectedService ?? undefined,
         }),
         }),
@@ -419,46 +356,68 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
 
 
       const finalContent = data.details?.draftRaw || data.response || "";
       const finalContent = data.details?.draftRaw || data.response || "";
       const toolCall = data.details?.toolCall ?? undefined;
       const toolCall = data.details?.toolCall ?? undefined;
-      const hasStream = get().messages.some((m) => m.timestamp === streamTs);
+      const hasStream = Boolean(
+        get().sessions[sessionId]?.messages.some((message) => message.timestamp === streamTs),
+      );
 
 
       if (data.error) {
       if (data.error) {
+        const errorMessage = extractErrorMessage(data.error);
         if (hasStream) {
         if (hasStream) {
-          get().replaceStreamWithError(streamTs, extractErrorMessage(data.error));
+          get().replaceStreamWithError(sessionId, streamTs, errorMessage);
         } else {
         } else {
-          get().addErrorMessage(extractErrorMessage(data.error));
+          get().addErrorMessage(sessionId, errorMessage);
         }
         }
       } else if (finalContent) {
       } else if (finalContent) {
         if (hasStream) {
         if (hasStream) {
-          get().finalizeStream(streamTs, finalContent, toolCall);
+          get().finalizeStream(sessionId, streamTs, finalContent, toolCall);
         } else {
         } else {
-          set((s) => ({
-            messages: [...s.messages, {
-              role: "assistant" as const, content: finalContent, timestamp: Date.now(), toolCall,
-            }],
+          set((state) => ({
+            sessions: updateSession(state.sessions, sessionId, (runtime) => ({
+              messages: [
+                ...runtime.messages,
+                {
+                  role: "assistant",
+                  content: finalContent,
+                  timestamp: Date.now(),
+                  toolCall,
+                },
+              ],
+            })),
           }));
           }));
         }
         }
         if (toolCall?.name === "create_book") {
         if (toolCall?.name === "create_book") {
-          get().setPendingBookArgs({ ...toolCall.arguments });
+          set((state) => ({
+            sessions: updateSession(state.sessions, sessionId, () => ({
+              pendingBookArgs: { ...toolCall.arguments },
+            })),
+          }));
         }
         }
       } else {
       } else {
-        const emptyMsg = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
+        const emptyMessage = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
         if (hasStream) {
         if (hasStream) {
-          get().replaceStreamWithError(streamTs, emptyMsg);
+          get().replaceStreamWithError(sessionId, streamTs, emptyMessage);
         } else {
         } else {
-          get().addErrorMessage(emptyMsg);
+          get().addErrorMessage(sessionId, emptyMessage);
         }
         }
       }
       }
-    } catch (e) {
+    } catch (error) {
       streamEs.close();
       streamEs.close();
-      const errorMsg = e instanceof Error ? e.message : String(e);
-      const hasStream = get().messages.some((m) => m.timestamp === streamTs);
+      const errorMessage = error instanceof Error ? error.message : String(error);
+      const hasStream = Boolean(
+        get().sessions[sessionId]?.messages.some((message) => message.timestamp === streamTs),
+      );
       if (hasStream) {
       if (hasStream) {
-        get().replaceStreamWithError(streamTs, errorMsg);
+        get().replaceStreamWithError(sessionId, streamTs, errorMessage);
       } else {
       } else {
-        get().addErrorMessage(errorMsg);
+        get().addErrorMessage(sessionId, errorMessage);
       }
       }
     } finally {
     } finally {
-      set({ loading: false, _activeStream: null });
+      set((state) => ({
+        sessions: updateSession(state.sessions, sessionId, (runtime) => ({
+          isStreaming: false,
+          stream: runtime.stream === streamEs ? null : runtime.stream,
+        })),
+      }));
     }
     }
   },
   },
 });
 });

+ 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,
 };
 };

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

@@ -0,0 +1,216 @@
+import type {
+  Message,
+  MessagePart,
+  PipelineStage,
+  SessionMessage,
+  SessionRuntime,
+  SessionSummary,
+  ToolExecution,
+} from "../../types";
+
+const NULL_BOOK_KEY = "__null__";
+
+const AGENT_LABELS: Record<string, string> = {
+  architect: "建书",
+  writer: "写作",
+  auditor: "审计",
+  reviser: "修订",
+  exporter: "导出",
+};
+
+const TOOL_LABELS: Record<string, string> = {
+  read: "读取文件",
+  edit: "编辑文件",
+  grep: "搜索",
+  ls: "列目录",
+};
+
+export function bookKey(bookId: string | null | undefined): string {
+  return bookId ?? NULL_BOOK_KEY;
+}
+
+export function extractErrorMessage(error: string | { code?: string; message?: string }): string {
+  if (typeof error === "string") return error;
+  return error.message ?? "Unknown error";
+}
+
+export function resolveToolLabel(tool: string, agent?: string): string {
+  if (tool === "sub_agent" && agent) return AGENT_LABELS[agent] ?? agent;
+  return TOOL_LABELS[tool] ?? tool;
+}
+
+export function summarizeResult(result: unknown): string {
+  if (typeof result === "string") return result.slice(0, 200);
+  if (result && typeof result === "object") {
+    const record = result as Record<string, unknown>;
+    if (typeof record.content === "string") return record.content.slice(0, 200);
+  }
+  return String(result).slice(0, 200);
+}
+
+export function extractToolError(result: unknown): string {
+  if (typeof result === "string") return result.slice(0, 500);
+  if (result && typeof result === "object") {
+    const record = result as Record<string, unknown>;
+    if (typeof record.content === "string") return record.content.slice(0, 500);
+    if (record.content && Array.isArray(record.content)) {
+      const textPart = record.content.find((content: any) => content.type === "text");
+      if (textPart) return (textPart as any).text?.slice(0, 500) ?? "";
+    }
+  }
+  return String(result).slice(0, 500);
+}
+
+export function getOrCreateStream(
+  messages: ReadonlyArray<Message>,
+  streamTs: number,
+): [ReadonlyArray<Message>, Message] {
+  const last = messages[messages.length - 1];
+  if (last?.timestamp === streamTs && last.role === "assistant") {
+    return [messages, last];
+  }
+  const message: Message = { role: "assistant", content: "", timestamp: streamTs, parts: [] };
+  return [[...messages, message], message];
+}
+
+export function replaceLast(
+  messages: ReadonlyArray<Message>,
+  updated: Message,
+): ReadonlyArray<Message> {
+  return [...messages.slice(0, -1), updated];
+}
+
+export function findRunningToolPart(
+  parts: MessagePart[],
+): (MessagePart & { type: "tool" }) | undefined {
+  for (let i = parts.length - 1; i >= 0; i -= 1) {
+    const part = parts[i];
+    if (part.type === "tool" && part.execution.status === "running") {
+      return part as MessagePart & { type: "tool" };
+    }
+  }
+  return undefined;
+}
+
+export function deriveFlat(
+  parts: MessagePart[],
+): { content: string; thinking?: string; thinkingStreaming?: boolean; toolExecutions?: ToolExecution[] } {
+  let content = "";
+  let thinking = "";
+  let thinkingStreaming = false;
+  const toolExecutions: ToolExecution[] = [];
+
+  for (const part of parts) {
+    if (part.type === "thinking") {
+      if (thinking) thinking += "\n\n---\n\n";
+      thinking += part.content;
+      if (part.streaming) thinkingStreaming = true;
+      continue;
+    }
+
+    if (part.type === "text") {
+      content += part.content;
+      continue;
+    }
+
+    toolExecutions.push(part.execution);
+  }
+
+  return {
+    content,
+    ...(thinking ? { thinking } : {}),
+    ...(thinkingStreaming ? { thinkingStreaming: true } : {}),
+    ...(toolExecutions.length > 0 ? { toolExecutions } : {}),
+  };
+}
+
+export function createSessionRuntime(input: {
+  sessionId: string;
+  bookId: string | null;
+  title: string | null;
+  messages?: ReadonlyArray<Message>;
+  isDraft?: boolean;
+}): SessionRuntime {
+  return {
+    sessionId: input.sessionId,
+    bookId: input.bookId,
+    title: input.title,
+    messages: input.messages ?? [],
+    stream: null,
+    isStreaming: false,
+    lastError: null,
+    pendingBookArgs: null,
+    isDraft: input.isDraft ?? false,
+  };
+}
+
+export function deserializeMessages(
+  msgs: ReadonlyArray<SessionMessage>,
+): ReadonlyArray<Message> {
+  return msgs
+    .filter((message) => message.role === "user" || message.role === "assistant")
+    .map((message) => {
+      const toolExecutions = (message as any).toolExecutions as ToolExecution[] | undefined;
+      const parts: MessagePart[] = [];
+      if (message.thinking) parts.push({ type: "thinking", content: message.thinking, streaming: false });
+      if (toolExecutions) {
+        for (const execution of toolExecutions) {
+          parts.push({ type: "tool", execution });
+        }
+      }
+      if (message.content) parts.push({ type: "text", content: message.content });
+      return {
+        role: message.role as "user" | "assistant",
+        content: message.content,
+        thinking: message.thinking,
+        toolExecutions,
+        timestamp: message.timestamp,
+        parts: parts.length > 0 ? parts : undefined,
+      };
+    });
+}
+
+export function updateSession(
+  sessions: Record<string, SessionRuntime>,
+  sessionId: string,
+  updater: (session: SessionRuntime) => Partial<SessionRuntime>,
+): Record<string, SessionRuntime> {
+  const existing = sessions[sessionId];
+  if (!existing) return sessions;
+  return {
+    ...sessions,
+    [sessionId]: {
+      ...existing,
+      ...updater(existing),
+    },
+  };
+}
+
+export function upsertSessionSummary(
+  sessions: Record<string, SessionRuntime>,
+  summary: Pick<SessionSummary, "sessionId" | "bookId" | "title">,
+): Record<string, SessionRuntime> {
+  const existing = sessions[summary.sessionId];
+  return {
+    ...sessions,
+    [summary.sessionId]: existing
+      ? { ...existing, bookId: summary.bookId, title: summary.title }
+      : createSessionRuntime(summary),
+  };
+}
+
+export function mergeSessionIds(
+  existing: ReadonlyArray<string> | undefined,
+  incoming: ReadonlyArray<string>,
+): ReadonlyArray<string> {
+  if (!existing?.length) return [...incoming];
+  const seen = new Set(existing);
+  const appended = incoming.filter((id) => !seen.has(id));
+  if (appended.length === 0) return existing as string[];
+  return [...existing, ...appended];
+}
+
+export function sessionMatchesEvent(sessionId: string, data: unknown): boolean {
+  if (!data || typeof data !== "object") return false;
+  return (data as { sessionId?: unknown }).sessionId === sessionId;
+}

+ 269 - 0
packages/studio/src/store/chat/slices/message/stream-events.ts

@@ -0,0 +1,269 @@
+import type { StateCreator } from "zustand";
+import type { ChatStore, MessageActions, PipelineStage } from "../../types";
+import { shouldRefreshSidebarForTool } from "../../message-policy";
+import {
+  deriveFlat,
+  extractToolError,
+  findRunningToolPart,
+  getOrCreateStream,
+  replaceLast,
+  resolveToolLabel,
+  sessionMatchesEvent,
+  summarizeResult,
+  updateSession,
+} from "./runtime";
+
+type SliceSet = Parameters<StateCreator<ChatStore, [], [], MessageActions>>[0];
+type SliceGet = Parameters<StateCreator<ChatStore, [], [], MessageActions>>[1];
+
+interface AttachSessionStreamListenersInput {
+  sessionId: string;
+  streamTs: number;
+  streamEs: EventSource;
+  set: SliceSet;
+  get: SliceGet;
+}
+
+export function attachSessionStreamListeners({
+  sessionId,
+  streamTs,
+  streamEs,
+  set,
+  get,
+}: AttachSessionStreamListenersInput): void {
+  streamEs.addEventListener("thinking:start", (event: MessageEvent) => {
+    try {
+      const data = event.data ? JSON.parse(event.data) : null;
+      if (!sessionMatchesEvent(sessionId, data)) return;
+      set((state) => ({
+        sessions: updateSession(state.sessions, sessionId, (runtime) => {
+          const [messages, stream] = getOrCreateStream(runtime.messages, streamTs);
+          const parts = [...(stream.parts ?? []), { type: "thinking" as const, content: "", streaming: true }];
+          const flat = deriveFlat(parts);
+          return { messages: replaceLast(messages, { ...stream, ...flat, parts }) };
+        }),
+      }));
+    } catch {
+      // ignore
+    }
+  });
+
+  streamEs.addEventListener("thinking:delta", (event: MessageEvent) => {
+    try {
+      const data = event.data ? JSON.parse(event.data) : null;
+      if (!sessionMatchesEvent(sessionId, data) || !data?.text) return;
+      set((state) => ({
+        sessions: updateSession(state.sessions, sessionId, (runtime) => {
+          const [messages, stream] = getOrCreateStream(runtime.messages, streamTs);
+          const parts = [...(stream.parts ?? [])];
+          const last = parts[parts.length - 1];
+          if (last?.type === "thinking") {
+            parts[parts.length - 1] = { ...last, content: last.content + data.text };
+          }
+          const flat = deriveFlat(parts);
+          return { messages: replaceLast(messages, { ...stream, ...flat, parts }) };
+        }),
+      }));
+    } catch {
+      // ignore
+    }
+  });
+
+  streamEs.addEventListener("thinking:end", (event: MessageEvent) => {
+    try {
+      const data = event.data ? JSON.parse(event.data) : null;
+      if (!sessionMatchesEvent(sessionId, data)) return;
+      set((state) => ({
+        sessions: updateSession(state.sessions, sessionId, (runtime) => {
+          const [messages, stream] = getOrCreateStream(runtime.messages, streamTs);
+          const parts = [...(stream.parts ?? [])];
+          const last = parts[parts.length - 1];
+          if (last?.type === "thinking") {
+            parts[parts.length - 1] = { ...last, streaming: false };
+          }
+          const flat = deriveFlat(parts);
+          return { messages: replaceLast(messages, { ...stream, ...flat, parts }) };
+        }),
+      }));
+    } catch {
+      // ignore
+    }
+  });
+
+  streamEs.addEventListener("draft:delta", (event: MessageEvent) => {
+    try {
+      const data = event.data ? JSON.parse(event.data) : null;
+      if (!sessionMatchesEvent(sessionId, data) || !data?.text) return;
+      set((state) => ({
+        sessions: updateSession(state.sessions, sessionId, (runtime) => {
+          const [messages, stream] = getOrCreateStream(runtime.messages, streamTs);
+          const parts = [...(stream.parts ?? [])];
+          const last = parts[parts.length - 1];
+          if (last?.type === "text") {
+            parts[parts.length - 1] = { ...last, content: last.content + data.text };
+          } else {
+            parts.push({ type: "text", content: data.text });
+          }
+          const flat = deriveFlat(parts);
+          return { messages: replaceLast(messages, { ...stream, ...flat, parts }) };
+        }),
+      }));
+    } catch {
+      // ignore
+    }
+  });
+
+  streamEs.addEventListener("tool:start", (event: MessageEvent) => {
+    try {
+      const data = event.data ? JSON.parse(event.data) : null;
+      if (!sessionMatchesEvent(sessionId, data) || !data?.tool) return;
+      set((state) => ({
+        sessions: updateSession(state.sessions, sessionId, (runtime) => {
+          const [messages, stream] = getOrCreateStream(runtime.messages, streamTs);
+          const parts = [...(stream.parts ?? [])];
+
+          if (data.tool === "sub_agent") {
+            const last = parts[parts.length - 1];
+            if (last?.type === "text" && last.content) {
+              parts.pop();
+              const prev = parts[parts.length - 1];
+              if (prev?.type === "thinking") {
+                parts[parts.length - 1] = {
+                  ...prev,
+                  content: prev.content + (prev.content ? "\n\n" : "") + last.content,
+                };
+              } else {
+                parts.push({ type: "thinking", content: last.content, streaming: false });
+              }
+            }
+          }
+
+          const agent = data.tool === "sub_agent" ? (data.args?.agent as string | undefined) : undefined;
+          const stages: PipelineStage[] | undefined = Array.isArray(data.stages) && data.stages.length > 0
+            ? (data.stages as string[]).map((label) => ({ label, status: "pending" as const }))
+            : undefined;
+
+          parts.push({
+            type: "tool",
+            execution: {
+              id: data.id as string,
+              tool: data.tool as string,
+              agent,
+              label: resolveToolLabel(data.tool as string, agent),
+              status: "running",
+              args: data.args as Record<string, unknown> | undefined,
+              stages,
+              startedAt: Date.now(),
+            },
+          });
+
+          const flat = deriveFlat(parts);
+          return { messages: replaceLast(messages, { ...stream, ...flat, parts }) };
+        }),
+      }));
+    } catch {
+      // ignore
+    }
+  });
+
+  streamEs.addEventListener("tool:end", (event: MessageEvent) => {
+    try {
+      const data = event.data ? JSON.parse(event.data) : null;
+      if (!sessionMatchesEvent(sessionId, data) || !data?.tool) return;
+      set((state) => ({
+        sessions: updateSession(state.sessions, sessionId, (runtime) => {
+          const [messages, stream] = getOrCreateStream(runtime.messages, streamTs);
+          const parts = (stream.parts ?? []).map((part) => {
+            if (part.type !== "tool" || part.execution.id !== data.id) return part;
+            const execution = { ...part.execution };
+            execution.status = data.isError ? "error" : "completed";
+            execution.completedAt = Date.now();
+            execution.stages = execution.stages?.map((stage) =>
+              stage.status !== "completed"
+                ? { ...stage, status: "completed" as const, progress: undefined }
+                : stage,
+            );
+            if (data.isError) execution.error = extractToolError(data.result);
+            else execution.result = summarizeResult(data.result);
+            return { type: "tool" as const, execution };
+          });
+          const flat = deriveFlat(parts);
+          return { messages: replaceLast(messages, { ...stream, ...flat, parts }) };
+        }),
+      }));
+
+      if (shouldRefreshSidebarForTool(data.tool as string)) {
+        get().bumpBookDataVersion();
+      }
+    } catch {
+      // ignore
+    }
+  });
+
+  streamEs.addEventListener("log", (event: MessageEvent) => {
+    try {
+      const data = event.data ? JSON.parse(event.data) : null;
+      if (!sessionMatchesEvent(sessionId, data)) return;
+      const message = data?.message as string | undefined;
+      if (!message) return;
+      set((state) => ({
+        sessions: updateSession(state.sessions, sessionId, (runtime) => {
+          const [messages, stream] = getOrCreateStream(runtime.messages, streamTs);
+          const runningTool = findRunningToolPart([...(stream.parts ?? [])]);
+          if (!runningTool) return {};
+          const parts = (stream.parts ?? []).map((part) => {
+            if (part.type !== "tool" || part.execution.id !== runningTool.execution.id) return part;
+            return {
+              type: "tool" as const,
+              execution: { ...part.execution, logs: [...(part.execution.logs ?? []), message] },
+            };
+          });
+          const flat = deriveFlat(parts);
+          return { messages: replaceLast(messages, { ...stream, ...flat, parts }) };
+        }),
+      }));
+    } catch {
+      // ignore
+    }
+  });
+
+  streamEs.addEventListener("llm:progress", (event: MessageEvent) => {
+    try {
+      const data = event.data ? JSON.parse(event.data) : null;
+      if (!sessionMatchesEvent(sessionId, data)) return;
+      set((state) => ({
+        sessions: updateSession(state.sessions, sessionId, (runtime) => {
+          const [messages, stream] = getOrCreateStream(runtime.messages, streamTs);
+          const runningTool = findRunningToolPart([...(stream.parts ?? [])]);
+          if (!runningTool?.execution.stages) return {};
+          const parts = (stream.parts ?? []).map((part) => {
+            if (part.type !== "tool" || part.execution.id !== runningTool.execution.id) return part;
+            return {
+              type: "tool" as const,
+              execution: {
+                ...part.execution,
+                stages: part.execution.stages?.map((stage) =>
+                  stage.status === "active"
+                    ? {
+                        ...stage,
+                        progress: {
+                          status: data.status,
+                          elapsedMs: data.elapsedMs,
+                          totalChars: data.totalChars,
+                          chineseChars: data.chineseChars,
+                        },
+                      }
+                    : stage,
+                ),
+              },
+            };
+          });
+          const flat = deriveFlat(parts);
+          return { messages: replaceLast(messages, { ...stream, ...flat, parts }) };
+        }),
+      }));
+    } catch {
+      // ignore
+    }
+  });
+}

+ 45 - 16
packages/studio/src/store/chat/types.ts

@@ -56,6 +56,15 @@ export interface SessionMessage {
   readonly timestamp: number;
   readonly timestamp: number;
 }
 }
 
 
+export interface SessionSummary {
+  readonly sessionId: string;
+  readonly bookId: string | null;
+  readonly title: string | null;
+  readonly messageCount: number;
+  readonly createdAt: number;
+  readonly updatedAt: number;
+}
+
 export interface AgentResponse {
 export interface AgentResponse {
   readonly response?: string;
   readonly response?: string;
   readonly error?: string | { code?: string; message?: string };
   readonly error?: string | { code?: string; message?: string };
@@ -64,6 +73,9 @@ export interface AgentResponse {
     readonly toolCall?: ToolCall;
     readonly toolCall?: ToolCall;
   };
   };
   readonly session?: {
   readonly session?: {
+    readonly sessionId?: string;
+    readonly bookId?: string | null;
+    readonly title?: string | null;
     readonly activeBookId?: string;
     readonly activeBookId?: string;
     readonly creationDraft?: unknown;
     readonly creationDraft?: unknown;
     readonly messages?: ReadonlyArray<SessionMessage>;
     readonly messages?: ReadonlyArray<SessionMessage>;
@@ -74,6 +86,8 @@ export interface AgentResponse {
 export interface SessionResponse {
 export interface SessionResponse {
   readonly session?: {
   readonly session?: {
     readonly sessionId?: string;
     readonly sessionId?: string;
+    readonly bookId?: string | null;
+    readonly title?: string | null;
     readonly activeBookId?: string;
     readonly activeBookId?: string;
     readonly messages?: ReadonlyArray<SessionMessage>;
     readonly messages?: ReadonlyArray<SessionMessage>;
   };
   };
@@ -88,19 +102,29 @@ export interface BookSummary {
   cast: string;
   cast: string;
 }
 }
 
 
+export interface SessionRuntime {
+  readonly sessionId: string;
+  readonly bookId: string | null;
+  readonly title: string | null;
+  readonly messages: ReadonlyArray<Message>;
+  readonly stream: EventSource | null;
+  readonly isStreaming: boolean;
+  readonly lastError: string | null;
+  readonly pendingBookArgs: Record<string, unknown> | null;
+  // 仅前端存在、尚未持久化到磁盘的草稿会话。发送第一条消息时才调 POST /sessions 把它落盘。
+  readonly isDraft: boolean;
+}
+
 export interface MessageState {
 export interface MessageState {
-  messages: ReadonlyArray<Message>;
+  sessions: Record<string, SessionRuntime>;
+  sessionIdsByBook: Record<string, ReadonlyArray<string>>;
+  activeSessionId: string | null;
   input: string;
   input: string;
-  loading: boolean;
-  currentSessionId: string | null;
   selectedModel: string | null;
   selectedModel: string | null;
   selectedService: string | null;
   selectedService: string | null;
-  /** Active EventSource ref — closed on session switch */
-  _activeStream: EventSource | null;
 }
 }
 
 
 export interface CreateState {
 export interface CreateState {
-  pendingBookArgs: Record<string, unknown> | null;
   bookCreating: boolean;
   bookCreating: boolean;
   createProgress: string;
   createProgress: string;
   bookDataVersion: number;
   bookDataVersion: number;
@@ -115,16 +139,21 @@ export type ChatState = MessageState & CreateState;
 // -- Action interfaces --
 // -- Action interfaces --
 
 
 export interface MessageActions {
 export interface MessageActions {
+  activateSession: (sessionId: string | null) => void;
   setInput: (text: string) => void;
   setInput: (text: string) => void;
-  addUserMessage: (content: string) => void;
-  appendStreamChunk: (text: string, streamTs: number) => void;
-  finalizeStream: (streamTs: number, content: string, toolCall?: ToolCall) => void;
-  replaceStreamWithError: (streamTs: number, errorMsg: string) => void;
-  addErrorMessage: (errorMsg: string) => void;
-  setLoading: (loading: boolean) => void;
-  loadSessionMessages: (msgs: ReadonlyArray<SessionMessage>) => void;
-  loadSession: (bookId?: string) => Promise<void>;
-  sendMessage: (text: string, activeBookId?: string) => Promise<void>;
+  addUserMessage: (sessionId: string, content: string) => void;
+  appendStreamChunk: (sessionId: string, text: string, streamTs: number) => void;
+  finalizeStream: (sessionId: string, streamTs: number, content: string, toolCall?: ToolCall) => void;
+  replaceStreamWithError: (sessionId: string, streamTs: number, errorMsg: string) => void;
+  addErrorMessage: (sessionId: string, errorMsg: string) => void;
+  loadSessionMessages: (sessionId: string, msgs: ReadonlyArray<SessionMessage>) => void;
+  loadSessionList: (bookId: string | null) => Promise<void>;
+  createSession: (bookId: string | null) => Promise<string>;
+  createDraftSession: (bookId: string | null) => string;
+  renameSession: (sessionId: string, title: string) => Promise<void>;
+  deleteSession: (sessionId: string) => Promise<void>;
+  loadSessionDetail: (sessionId: string) => Promise<void>;
+  sendMessage: (sessionId: string, text: string, activeBookId?: string) => Promise<void>;
   setSelectedModel: (model: string, service: string) => void;
   setSelectedModel: (model: string, service: string) => void;
 }
 }
 
 
@@ -132,7 +161,7 @@ export interface CreateActions {
   setPendingBookArgs: (args: Record<string, unknown> | null) => void;
   setPendingBookArgs: (args: Record<string, unknown> | null) => void;
   setBookCreating: (creating: boolean) => void;
   setBookCreating: (creating: boolean) => void;
   setCreateProgress: (progress: string) => void;
   setCreateProgress: (progress: string) => void;
-  handleCreateBook: (activeBookId?: string) => Promise<string | null>;
+  handleCreateBook: (sessionId: string, activeBookId?: string) => Promise<string | null>;
   bumpBookDataVersion: () => void;
   bumpBookDataVersion: () => void;
   openArtifact: (file: string) => void;
   openArtifact: (file: string) => void;
   openChapterArtifact: (chapterNum: number) => void;
   openChapterArtifact: (chapterNum: number) => void;

+ 6 - 2
pnpm-lock.yaml

@@ -4,6 +4,10 @@ settings:
   autoInstallPeers: true
   autoInstallPeers: true
   excludeLinksFromLockfile: false
   excludeLinksFromLockfile: false
 
 
+overrides:
+  '@mariozechner/pi-ai': 0.67.1
+  '@mariozechner/pi-agent-core': 0.67.1
+
 importers:
 importers:
 
 
   .: {}
   .: {}
@@ -63,10 +67,10 @@ importers:
   packages/core:
   packages/core:
     dependencies:
     dependencies:
       '@mariozechner/pi-agent-core':
       '@mariozechner/pi-agent-core':
-        specifier: ^0.67.1
+        specifier: 0.67.1
         version: 0.67.1(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
         version: 0.67.1(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
       '@mariozechner/pi-ai':
       '@mariozechner/pi-ai':
-        specifier: ^0.67.1
+        specifier: 0.67.1
         version: 0.67.1(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
         version: 0.67.1(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
       '@sinclair/typebox':
       '@sinclair/typebox':
         specifier: ^0.34.49
         specifier: ^0.34.49