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

Merge branches 'split/pr182-backend-final' and 'split/pr182-studio-ui-final' into split/pr182-integrated

Ma 6 месяцев назад
Родитель
Сommit
2216d820a6
100 измененных файлов с 10272 добавлено и 1856 удалено
  1. 4 7
      README.en.md
  2. 3 6
      README.ja.md
  3. 4 7
      README.md
  4. 1 0
      packages/cli/src/__tests__/tui-chat-draft.test.ts
  5. 1 0
      packages/cli/src/__tests__/tui-dashboard.test.tsx
  6. 3 0
      packages/cli/src/tui/app.ts
  7. 11 6
      packages/cli/src/tui/dashboard.tsx
  8. 7 4
      packages/cli/src/tui/i18n.ts
  9. 1 1
      packages/cli/src/tui/slash-autocomplete.ts
  10. 1 0
      packages/cli/src/tui/tools.ts
  11. 3 2
      packages/core/package.json
  12. 84 0
      packages/core/src/__tests__/agent-system-prompt.test.ts
  13. 109 0
      packages/core/src/__tests__/book-session-store.test.ts
  14. 108 0
      packages/core/src/__tests__/book-session.test.ts
  15. 46 0
      packages/core/src/__tests__/chapter-persistence.test.ts
  16. 48 0
      packages/core/src/__tests__/chapter-truth-validation.test.ts
  17. 102 0
      packages/core/src/__tests__/config-migration.test.ts
  18. 386 0
      packages/core/src/__tests__/draft-directive-parser.test.ts
  19. 69 12
      packages/core/src/__tests__/interaction-chat-tokens.test.ts
  20. 10 5
      packages/core/src/__tests__/pipeline-runner.test.ts
  21. 229 150
      packages/core/src/__tests__/provider.test.ts
  22. 95 0
      packages/core/src/__tests__/secrets.test.ts
  23. 122 0
      packages/core/src/__tests__/service-resolver.test.ts
  24. 16 0
      packages/core/src/__tests__/state-manager.test.ts
  25. 30 0
      packages/core/src/__tests__/state-validator-agent.test.ts
  26. 57 0
      packages/core/src/__tests__/temperature-constraints.test.ts
  27. 304 0
      packages/core/src/agent/agent-session.ts
  28. 98 0
      packages/core/src/agent/agent-system-prompt.ts
  29. 338 0
      packages/core/src/agent/agent-tools.ts
  30. 3 0
      packages/core/src/agent/index.ts
  31. 5 5
      packages/core/src/agents/architect.ts
  32. 32 4
      packages/core/src/agents/chapter-analyzer.ts
  33. 1 1
      packages/core/src/agents/consolidator.ts
  34. 1 1
      packages/core/src/agents/continuity.ts
  35. 1 1
      packages/core/src/agents/fanfic-canon-importer.ts
  36. 1 1
      packages/core/src/agents/foundation-reviewer.ts
  37. 1 1
      packages/core/src/agents/radar.ts
  38. 1 1
      packages/core/src/agents/state-validator.ts
  39. 13 13
      packages/core/src/agents/writer-prompts.ts
  40. 1 1
      packages/core/src/agents/writer.ts
  41. 23 0
      packages/core/src/index.ts
  42. 78 0
      packages/core/src/interaction/book-session-store.ts
  43. 266 0
      packages/core/src/interaction/draft-directive-parser.ts
  44. 23 1
      packages/core/src/interaction/project-session-store.ts
  45. 175 120
      packages/core/src/interaction/project-tools.ts
  46. 15 2
      packages/core/src/interaction/runtime.ts
  47. 87 0
      packages/core/src/interaction/session.ts
  48. 58 0
      packages/core/src/llm/config-migration.ts
  49. 177 660
      packages/core/src/llm/provider.ts
  50. 50 0
      packages/core/src/llm/secrets.ts
  51. 143 0
      packages/core/src/llm/service-presets.ts
  52. 66 0
      packages/core/src/llm/service-resolver.ts
  53. 1 0
      packages/core/src/models/project.ts
  54. 5 1
      packages/core/src/pipeline/chapter-persistence.ts
  55. 27 1
      packages/core/src/pipeline/chapter-truth-validation.ts
  56. 8 3
      packages/core/src/pipeline/runner.ts
  57. 9 1
      packages/core/src/state/manager.ts
  58. 25 0
      packages/studio/components.json
  59. 20 2
      packages/studio/package.json
  60. 1 1
      packages/studio/src/App.test.ts
  61. 126 75
      packages/studio/src/App.tsx
  62. 1 1
      packages/studio/src/api/book-create.ts
  63. 1 1
      packages/studio/src/api/index.ts
  64. 89 47
      packages/studio/src/api/server.test.ts
  65. 546 78
      packages/studio/src/api/server.ts
  66. 0 614
      packages/studio/src/components/ChatBar.tsx
  67. 20 18
      packages/studio/src/components/Sidebar.tsx
  68. 562 0
      packages/studio/src/components/ai-elements/code-block.tsx
  69. 174 0
      packages/studio/src/components/ai-elements/confirmation.tsx
  70. 360 0
      packages/studio/src/components/ai-elements/message.tsx
  71. 1457 0
      packages/studio/src/components/ai-elements/prompt-input.tsx
  72. 226 0
      packages/studio/src/components/ai-elements/reasoning.tsx
  73. 77 0
      packages/studio/src/components/ai-elements/shimmer.tsx
  74. 173 0
      packages/studio/src/components/ai-elements/tool.tsx
  75. 56 0
      packages/studio/src/components/chat-utils.ts
  76. 199 0
      packages/studio/src/components/chat/BookFormCard.tsx
  77. 291 0
      packages/studio/src/components/chat/BookSidebar.tsx
  78. 75 0
      packages/studio/src/components/chat/ChatMessage.tsx
  79. 73 0
      packages/studio/src/components/chat/QuickActions.tsx
  80. 236 0
      packages/studio/src/components/chat/ToolExecutionSteps.tsx
  81. 103 0
      packages/studio/src/components/chat/__tests__/ToolExecutionSteps.test.ts
  82. 1 1
      packages/studio/src/components/chatbar-state.test.ts
  83. 66 0
      packages/studio/src/components/sidebar/ChaptersSection.tsx
  84. 129 0
      packages/studio/src/components/sidebar/CharacterSection.tsx
  85. 61 0
      packages/studio/src/components/sidebar/FoundationSection.tsx
  86. 124 0
      packages/studio/src/components/sidebar/ProgressSection.tsx
  87. 30 0
      packages/studio/src/components/sidebar/SidebarCard.tsx
  88. 71 0
      packages/studio/src/components/sidebar/SummarySection.tsx
  89. 76 0
      packages/studio/src/components/ui/alert.tsx
  90. 52 0
      packages/studio/src/components/ui/badge.tsx
  91. 87 0
      packages/studio/src/components/ui/button-group.tsx
  92. 58 0
      packages/studio/src/components/ui/button.tsx
  93. 19 0
      packages/studio/src/components/ui/collapsible.tsx
  94. 194 0
      packages/studio/src/components/ui/command.tsx
  95. 158 0
      packages/studio/src/components/ui/dialog.tsx
  96. 266 0
      packages/studio/src/components/ui/dropdown-menu.tsx
  97. 51 0
      packages/studio/src/components/ui/hover-card.tsx
  98. 158 0
      packages/studio/src/components/ui/input-group.tsx
  99. 20 0
      packages/studio/src/components/ui/input.tsx
  100. 199 0
      packages/studio/src/components/ui/select.tsx

+ 4 - 7
README.en.md

@@ -3,14 +3,13 @@
   <img src="assets/inkos-text.svg" width="240" height="65" alt="InkOS">
 </p>
 
-<h1 align="center">Autonomous Novel Writing AI Agent</h1>
+<h1 align="center">Autonomous Novel Writing CLI AI Agent</h1>
 
 <p align="center">
   <a href="https://www.npmjs.com/package/@actalk/inkos"><img src="https://img.shields.io/npm/v/@actalk/inkos.svg?color=cb3837&logo=npm" alt="npm version"></a>
   <a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL-3.0"></a>
-  <a href="https://github.com/Narcooo/inkos/stargazers"><img src="https://img.shields.io/github/stars/Narcooo/inkos?style=flat&logo=github&color=yellow" alt="GitHub stars"></a>
-  <a href="https://www.npmjs.com/package/@actalk/inkos"><img src="https://img.shields.io/npm/dm/@actalk/inkos?color=cb3837&logo=npm&label=downloads" alt="npm downloads"></a>
-  <a href="https://clawhub.ai/narcooo/inkos"><img src="https://img.shields.io/badge/🦞%20ClawHub-Skill-FF6B35?labelColor=1a1a1a" alt="ClawHub Skill"></a>
+  <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen.svg" alt="Node.js"></a>
+  <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.x-3178C6.svg?logo=typescript&logoColor=white" alt="TypeScript"></a>
 </p>
 
 <p align="center">
@@ -19,9 +18,7 @@
 
 ---
 
-Open-source AI Agent that autonomously writes, audits, and revises novels — with human review gates that keep you in control. Supports LitRPG, Progression Fantasy, Isekai, Romantasy, Sci-Fi, and more. Continuation, spinoff, fanfic, and style imitation workflows built in.
-
-**InkOS TUI is here!** — run `inkos` to launch a full-screen interactive dashboard. Conversational creation, natural-language book operations, slash command autocomplete, themed animations — TUI, Studio, and OpenClaw share the same interaction kernel.
+Open-source CLI AI Agent that autonomously writes, audits, and revises novels — with human review gates that keep you in control. Supports LitRPG, Progression Fantasy, Isekai, Romantasy, Sci-Fi, and more. Continuation, spinoff, fanfic, and style imitation workflows built in.
 
 **InkOS Studio is here!** — run `inkos studio` to launch a local web workbench. Book management, chapter review & editing, real-time writing progress, market radar, analytics, AI detection, style analysis, genre management, daemon control, truth file editing — everything the CLI does, now visual.
 

+ 3 - 6
README.ja.md

@@ -3,14 +3,13 @@
   <img src="assets/inkos-text.svg" width="240" height="65" alt="InkOS">
 </p>
 
-<h1 align="center">自律型小説執筆 AIエージェント</h1>
+<h1 align="center">自律型小説執筆 CLI AIエージェント</h1>
 
 <p align="center">
   <a href="https://www.npmjs.com/package/@actalk/inkos"><img src="https://img.shields.io/npm/v/@actalk/inkos.svg?color=cb3837&logo=npm" alt="npm version"></a>
   <a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL-3.0"></a>
-  <a href="https://github.com/Narcooo/inkos/stargazers"><img src="https://img.shields.io/github/stars/Narcooo/inkos?style=flat&logo=github&color=yellow" alt="GitHub stars"></a>
-  <a href="https://www.npmjs.com/package/@actalk/inkos"><img src="https://img.shields.io/npm/dm/@actalk/inkos?color=cb3837&logo=npm&label=downloads" alt="npm downloads"></a>
-  <a href="https://clawhub.ai/narcooo/inkos"><img src="https://img.shields.io/badge/🦞%20ClawHub-Skill-FF6B35?labelColor=1a1a1a" alt="ClawHub Skill"></a>
+  <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen.svg" alt="Node.js"></a>
+  <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.x-3178C6.svg?logo=typescript&logoColor=white" alt="TypeScript"></a>
 </p>
 
 <p align="center">
@@ -21,8 +20,6 @@
 
 小説の執筆・監査・修正を自律的に行うオープンソースCLIエージェント。人間によるレビューゲートにより、常にコントロールを維持できます。LitRPG、プログレッションファンタジー、異世界転生、ロマンタジー、SF など多数のジャンルに対応。続編・スピンオフ・二次創作・文体模倣ワークフローを内蔵。
 
-**InkOS TUI 正式リリース!** — `inkos` を実行してフルスクリーンのインタラクティブダッシュボードを起動。会話型創作、自然言語での書籍操作、スラッシュコマンド自動補完、テーマアニメーション——TUI、Studio、OpenClaw が同一のインタラクションカーネルを共有。
-
 **InkOS Studio 正式リリース!** — `inkos studio` でローカルWebワークベンチを起動。書籍管理、章のレビュー&編集、リアルタイム執筆進捗、市場レーダー、アナリティクス、AI検出、スタイル分析、ジャンル管理、デーモン制御、真実ファイル編集 — CLIの全機能をビジュアルで利用可能。
 
 **英語ネイティブ小説執筆に対応!** — 10種類の英語ジャンルプロファイルを内蔵し、専用のペーシングルール、疲労語リスト、監査ディメンションを搭載。`--lang en` を設定するだけですぐに始められます。

+ 4 - 7
README.md

@@ -3,14 +3,13 @@
   <img src="assets/inkos-text.svg" width="240" height="65" alt="InkOS">
 </p>
 
-<h1 align="center">Autonomous Novel Writing AI Agent<br><sub>自动化小说写作 AI Agent</sub></h1>
+<h1 align="center">Autonomous Novel Writing CLI AI Agent<br><sub>自动化小说写作 CLI AI Agent</sub></h1>
 
 <p align="center">
   <a href="https://www.npmjs.com/package/@actalk/inkos"><img src="https://img.shields.io/npm/v/@actalk/inkos.svg?color=cb3837&logo=npm" alt="npm version"></a>
   <a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL-3.0"></a>
-  <a href="https://github.com/Narcooo/inkos/stargazers"><img src="https://img.shields.io/github/stars/Narcooo/inkos?style=flat&logo=github&color=yellow" alt="GitHub stars"></a>
-  <a href="https://www.npmjs.com/package/@actalk/inkos"><img src="https://img.shields.io/npm/dm/@actalk/inkos?color=cb3837&logo=npm&label=downloads" alt="npm downloads"></a>
-  <a href="https://clawhub.ai/narcooo/inkos"><img src="https://img.shields.io/badge/🦞%20ClawHub-Skill-FF6B35?labelColor=1a1a1a" alt="ClawHub Skill"></a>
+  <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen.svg" alt="Node.js"></a>
+  <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.x-3178C6.svg?logo=typescript&logoColor=white" alt="TypeScript"></a>
 </p>
 
 <p align="center">
@@ -21,8 +20,6 @@
 
 AI Agent 自主写小说——写、审、改,全程接管。覆盖玄幻、仙侠、都市、科幻等多种风格,支持续写、番外、同人、仿写等创作形式。人工审核门控确保你始终掌控全局。已发布为 [OpenClaw](https://clawhub.ai/narcooo/inkos) skill。
 
-**InkOS TUI 正式发布!** — 直接运行 `inkos` 进入全屏交互仪表盘。对话式创作、自然语言操作书籍、slash 命令补全、主题动效——TUI、Studio、OpenClaw 共享同一套交互内核。
-
 **InkOS Studio 正式发布!** — `inkos studio` 启动本地 Web 工作台。书籍管理、章节审阅编辑、实时写作进度、市场雷达、数据分析、AI 检测、文风分析、题材管理、守护进程控制、真相文件编辑——CLI 能做的,Studio 全部可视化。
 
 **Native English novel writing now supported!** Set `--lang en` to write in English. See [English README](README.en.md) for details.
@@ -33,7 +30,7 @@ AI Agent 自主写小说——写、审、改,全程接管。覆盖玄幻、
 > 欢迎加群反馈问题、提出需求,也欢迎关注项目动态 — 我们的目标是做最强的基于小说的内容生态创作 AI Agent。
 
 <p align="center">
-  <img src="assets/wechat-group-v6.jpg" width="300" alt="微信交流群">
+  <img src="assets/wechat-group-v5.jpg" width="300" alt="微信交流群">
 </p>
 
 ## 快速开始

+ 1 - 0
packages/cli/src/__tests__/tui-chat-draft.test.ts

@@ -13,6 +13,7 @@ function createSession(): InteractionSession {
     automationMode: "semi",
     messages: [],
     events: [],
+    draftRounds: [],
   };
 }
 

+ 1 - 0
packages/cli/src/__tests__/tui-dashboard.test.tsx

@@ -36,6 +36,7 @@ function createSession(): InteractionSession {
         detail: "Preparing chapter 12.",
       },
     ],
+    draftRounds: [],
   };
 }
 

+ 3 - 0
packages/cli/src/tui/app.ts

@@ -140,6 +140,9 @@ export async function launchTui(
       onChatTextDelta: (text) => {
         chatStreamBridge.onTextDelta?.(text);
       },
+      onDraftTextDelta: (text) => {
+        chatStreamBridge.onTextDelta?.(text);
+      },
     }));
   } catch (error) {
     const message = error instanceof Error ? error.message : String(error);

+ 11 - 6
packages/cli/src/tui/dashboard.tsx

@@ -105,13 +105,9 @@ export function InkTuiDashboard(props: InkTuiDashboardProps): React.JSX.Element
         )}
       </Box>
 
-      {/* Status strip */}
+      {/* Composer area */}
       <Box flexDirection="column" marginTop={1}>
         <Text color={WARM_BORDER}>{thinRule}</Text>
-        <Box marginTop={1}>
-          <ExecutionBadge status={model.executionStatus} color={activeAccent} />
-          <Text color={activeAccent}> {model.statusPrimaryLine}</Text>
-        </Box>
 
         {/* Composer input */}
         <Box
@@ -159,6 +155,10 @@ export function InkTuiDashboard(props: InkTuiDashboardProps): React.JSX.Element
             </Box>
           ) : null}
         </Box>
+        <Box marginTop={1}>
+          <ExecutionBadge status={model.executionStatus} color={activeAccent} />
+          <Text color={activeAccent}> {model.statusPrimaryLine}</Text>
+        </Box>
       </Box>
     </Box>
   );
@@ -358,7 +358,8 @@ export function InkTuiApp(props: InkTuiAppProps): React.JSX.Element {
         hasFailed: session.currentExecution?.status === "failed",
       });
       const userTimestamp = Date.now();
-      const assistantDraftTimestamp = routed.intent === "chat" ? userTimestamp + 1 : null;
+      const assistantDraftTimestamp = (routed.intent === "chat" || routed.intent === "develop_book")
+        ? userTimestamp + 1 : null;
       assistantDraftTimestampRef.current = assistantDraftTimestamp;
       setActivityIntent(routed.intent);
       setIsSubmitting(true);
@@ -368,6 +369,10 @@ export function InkTuiApp(props: InkTuiAppProps): React.JSX.Element {
       setHistoryState({ cursor: null, draft: "" });
       setSession((current) => createOptimisticUserMessageSession(current, input, userTimestamp));
 
+      if (routed.intent === "develop_book" && !session.creationDraft) {
+        appendSystemNote(copy.notes.newBookGuide);
+      }
+
       const result = await processProjectInteractionInput({
         projectRoot: props.projectRoot,
         input,

+ 7 - 4
packages/cli/src/tui/i18n.ts

@@ -36,6 +36,7 @@ export interface TuiCopy {
     readonly status: (stage: string, mode: string) => string;
     readonly config: string;
     readonly depthSet: (depthLabel: string) => string;
+    readonly newBookGuide: string;
     readonly noLlmConfig: string;
     readonly setupProvider: string;
     readonly toolInitFailed: (message: string) => string;
@@ -93,16 +94,17 @@ const ZH_CN: TuiCopy = {
   composer: {
     placeholder: "告诉 InkOS 要写什么、修改什么,或解释什么…",
     emptyConversation: "先告诉 InkOS 你要做什么。",
-    helper: "回车发送 • /new • /draft • /create • /write • /books • /open • /mode • /depth • /help",
+    helper: "回车发送 • /new 输入你的想法,自动构建新书 • /draft • /create • /write • /books • /open • /mode • /depth • /help",
     submitting: "处理中…",
     failed: "上次请求失败",
     ready: "就绪",
   },
   notes: {
-    help: "可用命令:/new、/draft、/create、/discard、/write、/books、/open、/mode、/rewrite、/focus、/truth、/rename、/replace、/export、/status、/clear、/depth、/quit。也支持直接输入自然语言。",
+    help: "可用命令:/new(输入想法,自动构建新书)、/draft、/create、/discard、/write、/books、/open、/mode、/rewrite、/focus、/truth、/rename、/replace、/export、/status、/clear、/depth、/quit。也支持直接输入自然语言。",
     status: (stage, mode) => `当前状态:${stage}(${mode})。`,
     config: "当前 Ink 仪表盘里还不支持交互式 /config。请使用 inkos config set-global。",
     depthSet: (depthLabel) => `思考深度已切换为 ${depthLabel}。`,
+    newBookGuide: "开始构思新书。直接描述你的想法——题材、世界观、主角、核心冲突都可以。AI 会逐步引导你完善草案,随时用 /draft 查看进度,/create 建书。",
     noLlmConfig: "未发现 LLM 配置。",
     setupProvider: "先配置 API 提供方。",
     toolInitFailed: (message) => `初始化 TUI 工具失败:${message}`,
@@ -181,16 +183,17 @@ const EN: TuiCopy = {
   composer: {
     placeholder: "Ask InkOS to write, revise, or explain…",
     emptyConversation: "Start by asking InkOS what to do.",
-    helper: "Enter to send • /new • /draft • /create • /write • /books • /open • /mode • /depth • /help",
+    helper: "Enter to send • /new describe your idea to start a book • /draft • /create • /write • /books • /open • /mode • /depth • /help",
     submitting: "Submitting…",
     failed: "Last request failed",
     ready: "Ready",
   },
   notes: {
-    help: "Commands: /new, /draft, /create, /discard, /write, /books, /open, /mode, /rewrite, /focus, /truth, /rename, /replace, /export, /status, /clear, /depth, /quit. Natural language still works.",
+    help: "Commands: /new (describe your idea to start a book), /draft, /create, /discard, /write, /books, /open, /mode, /rewrite, /focus, /truth, /rename, /replace, /export, /status, /clear, /depth, /quit. Natural language still works.",
     status: (stage, mode) => `Status: ${stage} (${mode}).`,
     config: "Interactive /config is not available inside the Ink dashboard yet. Use inkos config set-global.",
     depthSet: (depthLabel) => `Thinking depth set to ${depthLabel}.`,
+    newBookGuide: "Starting a new book. Describe your idea — genre, world, protagonist, core conflict, anything. The AI will guide you step by step. Use /draft to check progress, /create to finalize.",
     noLlmConfig: "No LLM configuration found.",
     setupProvider: "Let's set up your API provider first.",
     toolInitFailed: (message) => `Failed to initialize TUI tools: ${message}`,

+ 1 - 1
packages/cli/src/tui/slash-autocomplete.ts

@@ -1,5 +1,5 @@
 export const SLASH_COMMANDS = [
-  "/new <idea>",
+  "/new 输入你的想法",
   "/draft",
   "/create",
   "/discard",

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

@@ -10,6 +10,7 @@ type CliPipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft">;
 type CliStateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadBookConfig" | "loadChapterIndex" | "saveChapterIndex" | "listBooks">;
 type CliInteractionToolHooks = {
   readonly onChatTextDelta?: (text: string) => void;
+  readonly onDraftTextDelta?: (text: string) => void;
   readonly getChatRequestOptions?: () => {
     readonly temperature?: number;
     readonly maxTokens?: number;

+ 3 - 2
packages/core/package.json

@@ -44,10 +44,11 @@
     "typecheck": "tsc --noEmit"
   },
   "dependencies": {
-    "@anthropic-ai/sdk": "^0.78.0",
+    "@mariozechner/pi-agent-core": "^0.67.1",
+    "@mariozechner/pi-ai": "^0.67.1",
+    "@sinclair/typebox": "^0.34.49",
     "dotenv": "^16.4.0",
     "js-yaml": "^4.1.1",
-    "openai": "^4.80.0",
     "zod": "^3.24.0"
   },
   "devDependencies": {

+ 84 - 0
packages/core/src/__tests__/agent-system-prompt.test.ts

@@ -0,0 +1,84 @@
+import { describe, expect, it } from "vitest";
+import { buildAgentSystemPrompt } from "../agent/agent-system-prompt.js";
+
+describe("buildAgentSystemPrompt", () => {
+  describe("no book (creation flow)", () => {
+    it("Chinese prompt includes info collection workflow", () => {
+      const prompt = buildAgentSystemPrompt(null, "zh");
+      expect(prompt).toContain("建书助手");
+      expect(prompt).toContain("收集信息");
+      expect(prompt).toContain("题材");
+      expect(prompt).toContain("世界观");
+      expect(prompt).toContain("主角");
+      expect(prompt).toContain("核心冲突");
+      expect(prompt).toContain("architect");
+      expect(prompt).toContain("sub_agent");
+    });
+
+    it("English prompt includes info collection workflow", () => {
+      const prompt = buildAgentSystemPrompt(null, "en");
+      expect(prompt).toContain("book creation");
+      expect(prompt).toContain("architect");
+      expect(prompt).toContain("Genre");
+      expect(prompt).toContain("Protagonist");
+      expect(prompt).toContain("Core conflict");
+    });
+
+    it("Chinese prompt forbids emoji", () => {
+      const prompt = buildAgentSystemPrompt(null, "zh");
+      expect(prompt).toContain("不要在回复中添加表情符号");
+    });
+
+    it("English prompt forbids emoji", () => {
+      const prompt = buildAgentSystemPrompt(null, "en");
+      expect(prompt).toContain("Do NOT use emoji");
+    });
+
+    it("no-book prompt does NOT mention read/edit/grep/ls", () => {
+      const prompt = buildAgentSystemPrompt(null, "zh");
+      expect(prompt).not.toMatch(/\bread\b.*读取/);
+      expect(prompt).not.toContain("edit");
+    });
+  });
+
+  describe("with book (writing flow)", () => {
+    it("Chinese prompt includes all tools except architect", () => {
+      const prompt = buildAgentSystemPrompt("my-book", "zh");
+      expect(prompt).toContain("my-book");
+      expect(prompt).toContain("sub_agent");
+      expect(prompt).toContain("writer");
+      expect(prompt).toContain("auditor");
+      expect(prompt).toContain("reviser");
+      expect(prompt).toContain("read");
+      expect(prompt).toContain("edit");
+      expect(prompt).toContain("grep");
+      expect(prompt).toContain("ls");
+    });
+
+    it("Chinese prompt warns NOT to call architect", () => {
+      const prompt = buildAgentSystemPrompt("my-book", "zh");
+      expect(prompt).toContain("不要调用 architect");
+    });
+
+    it("English prompt warns NOT to call architect", () => {
+      const prompt = buildAgentSystemPrompt("novel", "en");
+      expect(prompt).toContain("Do NOT call architect");
+    });
+
+    it("Chinese with-book prompt forbids emoji", () => {
+      const prompt = buildAgentSystemPrompt("my-book", "zh");
+      expect(prompt).toContain("不要在回复中添加表情符号");
+    });
+
+    it("English with-book prompt forbids emoji", () => {
+      const prompt = buildAgentSystemPrompt("novel", "en");
+      expect(prompt).toContain("Do NOT use emoji");
+    });
+
+    it("with-book prompt does NOT list architect as available", () => {
+      const prompt = buildAgentSystemPrompt("my-book", "zh");
+      // architect 不在可用工具列表里
+      expect(prompt).not.toMatch(/agent="architect"/);
+    });
+  });
+});

+ 109 - 0
packages/core/src/__tests__/book-session-store.test.ts

@@ -0,0 +1,109 @@
+import { describe, expect, it, beforeEach, afterEach } from "vitest";
+import { mkdtemp, rm } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import {
+  loadBookSession,
+  persistBookSession,
+  listBookSessions,
+  findOrCreateBookSession,
+} from "../interaction/book-session-store.js";
+import { createBookSession, appendBookSessionMessage } from "../interaction/session.js";
+
+describe("book-session-store", () => {
+  let tempDir: string;
+
+  beforeEach(async () => {
+    tempDir = await mkdtemp(join(tmpdir(), "inkos-test-"));
+  });
+
+  afterEach(async () => {
+    await rm(tempDir, { recursive: true, force: true });
+  });
+
+  describe("persistBookSession + loadBookSession", () => {
+    it("round-trips a session", async () => {
+      const session = createBookSession("my-book");
+      await persistBookSession(tempDir, session);
+      const loaded = await loadBookSession(tempDir, session.sessionId);
+      expect(loaded).not.toBeNull();
+      expect(loaded!.sessionId).toBe(session.sessionId);
+      expect(loaded!.bookId).toBe("my-book");
+    });
+
+    it("returns null for non-existent session", async () => {
+      const loaded = await loadBookSession(tempDir, "nonexistent");
+      expect(loaded).toBeNull();
+    });
+
+    it("persists messages", async () => {
+      let session = createBookSession("book");
+      session = appendBookSessionMessage(session, { role: "user" as const, content: "test", timestamp: 100 });
+      await persistBookSession(tempDir, session);
+      const loaded = await loadBookSession(tempDir, session.sessionId);
+      expect(loaded!.messages).toHaveLength(1);
+      expect(loaded!.messages[0].content).toBe("test");
+    });
+  });
+
+  describe("listBookSessions", () => {
+    it("returns empty for no sessions", async () => {
+      const list = await listBookSessions(tempDir, "no-book");
+      expect(list).toEqual([]);
+    });
+
+    it("filters by bookId", async () => {
+      const s1 = createBookSession("book-a");
+      const s2 = createBookSession("book-b");
+      const s3 = createBookSession("book-a");
+      await persistBookSession(tempDir, s1);
+      await persistBookSession(tempDir, s2);
+      await persistBookSession(tempDir, s3);
+
+      const listA = await listBookSessions(tempDir, "book-a");
+      expect(listA).toHaveLength(2);
+      expect(listA.every((s) => s.bookId === "book-a")).toBe(true);
+
+      const listB = await listBookSessions(tempDir, "book-b");
+      expect(listB).toHaveLength(1);
+    });
+
+    it("sorts by updatedAt descending", async () => {
+      const s1 = { ...createBookSession("book"), updatedAt: 100 };
+      const s2 = { ...createBookSession("book"), updatedAt: 300 };
+      const s3 = { ...createBookSession("book"), updatedAt: 200 };
+      await persistBookSession(tempDir, s1);
+      await persistBookSession(tempDir, s2);
+      await persistBookSession(tempDir, s3);
+
+      const list = await listBookSessions(tempDir, "book");
+      expect(list[0].updatedAt).toBe(300);
+      expect(list[1].updatedAt).toBe(200);
+      expect(list[2].updatedAt).toBe(100);
+    });
+
+    it("lists null bookId sessions", async () => {
+      const s = createBookSession(null);
+      await persistBookSession(tempDir, s);
+      const list = await listBookSessions(tempDir, null);
+      expect(list).toHaveLength(1);
+    });
+  });
+
+  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
+      const loaded = await loadBookSession(tempDir, session.sessionId);
+      expect(loaded).not.toBeNull();
+    });
+
+    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);
+    });
+  });
+});

+ 108 - 0
packages/core/src/__tests__/book-session.test.ts

@@ -0,0 +1,108 @@
+import { describe, expect, it } from "vitest";
+import {
+  BookSessionSchema,
+  GlobalSessionSchema,
+  createBookSession,
+  appendBookSessionMessage,
+} from "../interaction/session.js";
+
+describe("BookSession", () => {
+  describe("BookSessionSchema", () => {
+    it("parses a valid session", () => {
+      const raw = {
+        sessionId: "123-abc",
+        bookId: "my-book",
+        messages: [],
+        draftRounds: [],
+        events: [],
+        createdAt: 1000,
+        updatedAt: 1000,
+      };
+      const result = BookSessionSchema.parse(raw);
+      expect(result.sessionId).toBe("123-abc");
+      expect(result.bookId).toBe("my-book");
+    });
+
+    it("accepts null bookId for draft sessions", () => {
+      const raw = {
+        sessionId: "123-abc",
+        bookId: null,
+        messages: [],
+        draftRounds: [],
+        events: [],
+        createdAt: 1000,
+        updatedAt: 1000,
+      };
+      const result = BookSessionSchema.parse(raw);
+      expect(result.bookId).toBeNull();
+    });
+
+    it("defaults empty arrays", () => {
+      const raw = {
+        sessionId: "123-abc",
+        bookId: null,
+        createdAt: 1000,
+        updatedAt: 1000,
+      };
+      const result = BookSessionSchema.parse(raw);
+      expect(result.messages).toEqual([]);
+      expect(result.draftRounds).toEqual([]);
+      expect(result.events).toEqual([]);
+    });
+  });
+
+  describe("GlobalSessionSchema", () => {
+    it("parses with defaults", () => {
+      const result = GlobalSessionSchema.parse({});
+      expect(result.automationMode).toBe("semi");
+      expect(result.activeBookId).toBeUndefined();
+    });
+
+    it("parses with values", () => {
+      const result = GlobalSessionSchema.parse({ activeBookId: "book-1", automationMode: "auto" });
+      expect(result.activeBookId).toBe("book-1");
+      expect(result.automationMode).toBe("auto");
+    });
+  });
+
+  describe("createBookSession", () => {
+    it("creates session with bookId", () => {
+      const session = createBookSession("my-book");
+      expect(session.bookId).toBe("my-book");
+      expect(session.sessionId).toBeTruthy();
+      expect(session.messages).toEqual([]);
+      expect(session.createdAt).toBeGreaterThan(0);
+      expect(session.updatedAt).toBe(session.createdAt);
+    });
+
+    it("creates session with null bookId", () => {
+      const session = createBookSession(null);
+      expect(session.bookId).toBeNull();
+    });
+
+    it("generates unique sessionIds", () => {
+      const a = createBookSession("book");
+      const b = createBookSession("book");
+      expect(a.sessionId).not.toBe(b.sessionId);
+    });
+  });
+
+  describe("appendBookSessionMessage", () => {
+    it("appends message and updates timestamp", () => {
+      const session = createBookSession("book");
+      const msg = { role: "user" as const, content: "hello", timestamp: Date.now() };
+      const updated = appendBookSessionMessage(session, msg);
+      expect(updated.messages).toHaveLength(1);
+      expect(updated.messages[0].content).toBe("hello");
+      expect(updated.updatedAt).toBeGreaterThanOrEqual(session.updatedAt);
+    });
+
+    it("sorts messages by timestamp", () => {
+      let session = createBookSession("book");
+      session = appendBookSessionMessage(session, { role: "user" as const, content: "second", timestamp: 200 });
+      session = appendBookSessionMessage(session, { role: "assistant" as const, content: "first", timestamp: 100 });
+      expect(session.messages[0].content).toBe("first");
+      expect(session.messages[1].content).toBe("second");
+    });
+  });
+});

+ 46 - 0
packages/core/src/__tests__/chapter-persistence.test.ts

@@ -149,4 +149,50 @@ describe("persistChapterArtifacts", () => {
     expect(snapshotState).not.toHaveBeenCalled();
     expect(syncCurrentStateFactHistory).not.toHaveBeenCalled();
   });
+
+  it("replaces existing entry for the same chapter number instead of appending", async () => {
+    const saveChapterIndex = vi.fn().mockResolvedValue(undefined);
+    const existingEntry: ChapterMeta = {
+      number: 1,
+      title: "Old Title",
+      status: "drafted",
+      wordCount: 500,
+      createdAt: "2026-01-01T00:00:00.000Z",
+      updatedAt: "2026-01-01T00:00:00.000Z",
+      auditIssues: [],
+      lengthWarnings: [],
+    };
+
+    await persistChapterArtifacts({
+      chapterNumber: 1,
+      chapterTitle: "New Title",
+      status: "ready-for-review",
+      auditResult: createAuditResult(),
+      finalWordCount: 2000,
+      lengthWarnings: [],
+      degradedIssues: [],
+      tokenUsage: ZERO_USAGE,
+      loadChapterIndex: async () => [existingEntry],
+      saveChapter: vi.fn().mockResolvedValue(undefined),
+      saveTruthFiles: vi.fn().mockResolvedValue(undefined),
+      saveChapterIndex,
+      markBookActiveIfNeeded: vi.fn().mockResolvedValue(undefined),
+      persistAuditDriftGuidance: vi.fn().mockResolvedValue(undefined),
+      snapshotState: vi.fn().mockResolvedValue(undefined),
+      syncCurrentStateFactHistory: vi.fn().mockResolvedValue(undefined),
+      logSnapshotStage: vi.fn(),
+      now: () => "2026-04-01T00:00:00.000Z",
+    });
+
+    const savedIndex = saveChapterIndex.mock.calls[0][0] as ChapterMeta[];
+    // Must have exactly 1 entry, not 2
+    expect(savedIndex).toHaveLength(1);
+    expect(savedIndex[0].number).toBe(1);
+    expect(savedIndex[0].title).toBe("New Title");
+    expect(savedIndex[0].wordCount).toBe(2000);
+    expect(savedIndex[0].status).toBe("ready-for-review");
+    // Must preserve original createdAt
+    expect(savedIndex[0].createdAt).toBe("2026-01-01T00:00:00.000Z");
+    expect(savedIndex[0].updatedAt).toBe("2026-04-01T00:00:00.000Z");
+  });
 });

+ 48 - 0
packages/core/src/__tests__/chapter-truth-validation.test.ts

@@ -125,6 +125,54 @@ describe("validateChapterTruthPersistence", () => {
     expect(logger.warn).toHaveBeenCalledWith("  [unsupported_change] 正文写铜牌在怀里,但 state 说未携带。");
   });
 
+  it("degrades gracefully when validator throws (e.g. LLM returned empty response)", async () => {
+    const validator = {
+      validate: vi.fn().mockRejectedValue(new Error("LLM returned empty response")),
+    };
+    const writer = {
+      settleChapterState: vi.fn(),
+    };
+    const logWarn = vi.fn();
+    const logger = { warn: vi.fn() };
+
+    const result = await validateChapterTruthPersistence({
+      writer,
+      validator,
+      book: BOOK,
+      bookDir: "/tmp/book",
+      chapterNumber: 1,
+      title: "Test Chapter",
+      content: "Chapter content.",
+      persistenceOutput: createWriterOutput({
+        updatedState: "new state",
+        updatedHooks: "new hooks",
+        updatedLedger: "new ledger",
+      }),
+      auditResult: createAuditResult(),
+      previousTruth: {
+        oldState: "old state",
+        oldHooks: "old hooks",
+        oldLedger: "old ledger",
+      },
+      language: "zh",
+      logWarn,
+      logger,
+    });
+
+    expect(result.chapterStatus).toBe("state-degraded");
+    expect(result.persistenceOutput.updatedState).toBe("old state");
+    expect(result.persistenceOutput.updatedHooks).toBe("old hooks");
+    expect(result.persistenceOutput.updatedLedger).toBe("old ledger");
+    expect(result.degradedIssues).toEqual([
+      expect.objectContaining({
+        severity: "warning",
+        category: "state-validation",
+      }),
+    ]);
+    // Should NOT have attempted settlement retry
+    expect(writer.settleChapterState).not.toHaveBeenCalled();
+  });
+
   it("degrades persistence output and appends audit issues when retry still fails", async () => {
     const validator = {
       validate: vi.fn()

+ 102 - 0
packages/core/src/__tests__/config-migration.test.ts

@@ -0,0 +1,102 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { migrateConfig } from "../llm/config-migration.js";
+import { loadSecrets } from "../llm/secrets.js";
+import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+describe("config migration", () => {
+  let root: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-migrate-"));
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("migrates old llm.provider+model+apiKey to services[] + secrets", async () => {
+    const oldConfig = {
+      name: "mybook",
+      llm: {
+        provider: "openai",
+        model: "kimi-k2.5",
+        baseUrl: "https://api.moonshot.cn/v1",
+        apiKey: "sk-old-key",
+      },
+      language: "zh",
+    };
+    await writeFile(join(root, "inkos.json"), JSON.stringify(oldConfig));
+
+    const result = await migrateConfig(root);
+
+    expect(result.migrated).toBe(true);
+
+    const raw = await readFile(join(root, "inkos.json"), "utf-8");
+    const config = JSON.parse(raw);
+    expect(config.llm.services).toHaveLength(1);
+    expect(config.llm.services[0].service).toBe("moonshot");
+    expect(config.llm.services[0].apiKey).toBeUndefined();
+    expect(config.llm.defaultModel).toBe("kimi-k2.5");
+    expect(config.llm.provider).toBeUndefined();
+    expect(config.llm.model).toBeUndefined();
+    expect(config.llm.apiKey).toBeUndefined();
+
+    const secrets = await loadSecrets(root);
+    expect(secrets.services.moonshot.apiKey).toBe("sk-old-key");
+  });
+
+  it("does nothing if already in new format", async () => {
+    const newConfig = {
+      name: "mybook",
+      llm: {
+        services: [{ service: "moonshot" }],
+        defaultModel: "kimi-k2.5",
+      },
+      language: "zh",
+    };
+    await writeFile(join(root, "inkos.json"), JSON.stringify(newConfig));
+
+    const result = await migrateConfig(root);
+    expect(result.migrated).toBe(false);
+  });
+
+  it("guesses service from baseUrl", async () => {
+    const oldConfig = {
+      llm: {
+        provider: "openai",
+        model: "deepseek-chat",
+        baseUrl: "https://api.deepseek.com/v1",
+        apiKey: "sk-deep",
+      },
+    };
+    await writeFile(join(root, "inkos.json"), JSON.stringify(oldConfig));
+
+    await migrateConfig(root);
+
+    const raw = await readFile(join(root, "inkos.json"), "utf-8");
+    const config = JSON.parse(raw);
+    expect(config.llm.services[0].service).toBe("deepseek");
+  });
+
+  it("creates custom service when baseUrl is unrecognized", async () => {
+    const oldConfig = {
+      llm: {
+        provider: "openai",
+        model: "my-model",
+        baseUrl: "https://llm.internal.corp/v1",
+        apiKey: "sk-corp",
+      },
+    };
+    await writeFile(join(root, "inkos.json"), JSON.stringify(oldConfig));
+
+    await migrateConfig(root);
+
+    const raw = await readFile(join(root, "inkos.json"), "utf-8");
+    const config = JSON.parse(raw);
+    expect(config.llm.services[0].service).toBe("custom");
+    expect(config.llm.services[0].baseUrl).toBe("https://llm.internal.corp/v1");
+    expect(config.llm.services[0].name).toBe("Custom");
+  });
+});

+ 386 - 0
packages/core/src/__tests__/draft-directive-parser.test.ts

@@ -0,0 +1,386 @@
+import { describe, it, expect } from "vitest";
+import {
+  parseDraftDirectives,
+  createDirectiveStreamFilter,
+} from "../interaction/draft-directive-parser.js";
+
+// ---------------------------------------------------------------------------
+// 1. Pure markdown — no directives
+// ---------------------------------------------------------------------------
+
+describe("parseDraftDirectives", () => {
+  it("returns empty fields and full text when input has no directives", () => {
+    const raw = "# 欢迎\n\n这是一段普通的 markdown,没有任何表单标记。";
+    const result = parseDraftDirectives(raw);
+
+    expect(result.fields).toEqual({});
+    expect(result.textContent).toBe(raw);
+    expect(result.summary).toBe("");
+    expect(result.raw).toBe(raw);
+  });
+
+  // ---------------------------------------------------------------------------
+  // 2. Single :::field extraction
+  // ---------------------------------------------------------------------------
+
+  it("extracts a single :::field block", () => {
+    const raw = [
+      "请为你的小说起一个名字:",
+      "",
+      ':::field{key="title" label="书名"}',
+      "星河彼岸",
+      ":::",
+      "",
+      "很好的名字!",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+
+    expect(result.fields["title"]).toBe("星河彼岸");
+    expect(result.textContent).toBe(
+      ["请为你的小说起一个名字:", "", "", "很好的名字!"].join("\n"),
+    );
+    expect(result.raw).toBe(raw);
+  });
+
+  // ---------------------------------------------------------------------------
+  // 3. Multiple fields of different types
+  // ---------------------------------------------------------------------------
+
+  it("extracts multiple fields of different types", () => {
+    const raw = [
+      "以下是你的创作信息:",
+      "",
+      ':::field{key="title" label="书名"}',
+      "星河彼岸",
+      ":::",
+      "",
+      ':::field{key="worldPremise" label="世界观" type="textarea"}',
+      "一个被星际战争撕裂的宇宙",
+      ":::",
+      "",
+      ':::pick{key="platform" label="目标平台"}',
+      "- 起点中文网",
+      "- 番茄小说",
+      "- 七猫",
+      ":::",
+      "",
+      ':::number{key="targetChapters" label="目标章数"}',
+      "300",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+
+    expect(result.fields["title"]).toBe("星河彼岸");
+    expect(result.fields["worldPremise"]).toBe("一个被星际战争撕裂的宇宙");
+    expect(result.fields["platform"]).toBe("起点中文网");
+    expect(result.fields["targetChapters"]).toBe("300");
+  });
+
+  // ---------------------------------------------------------------------------
+  // 4. Nested :::group containing multiple fields
+  // ---------------------------------------------------------------------------
+
+  it("extracts fields nested inside a :::group", () => {
+    const raw = [
+      "请确认篇幅设置:",
+      "",
+      ':::group{label="篇幅"}',
+      ':::number{key="targetChapters" label="目标章数"}',
+      "300",
+      ":::",
+      ':::number{key="chapterLength" label="每章字数"}',
+      "3000",
+      ":::",
+      ":::",
+      "",
+      "确认无误!",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+
+    expect(result.fields["targetChapters"]).toBe("300");
+    expect(result.fields["chapterLength"]).toBe("3000");
+    // group itself should not appear in textContent
+    expect(result.textContent).toBe(
+      ["请确认篇幅设置:", "", "", "确认无误!"].join("\n"),
+    );
+  });
+
+  // ---------------------------------------------------------------------------
+  // 5. Mixed content: markdown paragraphs interspersed with directives
+  // ---------------------------------------------------------------------------
+
+  it("handles mixed markdown and directives", () => {
+    const raw = [
+      "# 创建新书",
+      "",
+      "让我们开始吧。首先需要一个书名:",
+      "",
+      ':::field{key="title" label="书名"}',
+      "星河彼岸",
+      ":::",
+      "",
+      "好的!接下来设定你的世界观:",
+      "",
+      ':::field{key="worldPremise" label="世界观" type="textarea"}',
+      "宇宙分裂为光暗两域",
+      ":::",
+      "",
+      "让我们继续完善细节。",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+
+    expect(result.fields["title"]).toBe("星河彼岸");
+    expect(result.fields["worldPremise"]).toBe("宇宙分裂为光暗两域");
+    expect(result.textContent).toContain("# 创建新书");
+    expect(result.textContent).toContain("让我们开始吧。首先需要一个书名:");
+    expect(result.textContent).toContain("好的!接下来设定你的世界观:");
+    expect(result.textContent).toContain("让我们继续完善细节。");
+    expect(result.textContent).not.toContain(":::field");
+    expect(result.textContent).not.toContain("星河彼岸");
+  });
+
+  // ---------------------------------------------------------------------------
+  // 6. :::pick extracts first option as default value
+  // ---------------------------------------------------------------------------
+
+  it("extracts first option from :::pick as default value", () => {
+    const raw = [
+      ':::pick{key="genre" label="题材"}',
+      "- 玄幻",
+      "- 仙侠",
+      "- 都市",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.fields["genre"]).toBe("玄幻");
+  });
+
+  it("handles :::pick with no options gracefully", () => {
+    const raw = [
+      ':::pick{key="genre" label="题材"}',
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.fields["genre"]).toBe("");
+  });
+
+  // ---------------------------------------------------------------------------
+  // 7. Summary generation from field labels
+  // ---------------------------------------------------------------------------
+
+  it("generates summary from field labels", () => {
+    const raw = [
+      ':::field{key="title" label="书名"}',
+      "星河彼岸",
+      ":::",
+      ':::field{key="worldPremise" label="世界观"}',
+      "一个宇宙",
+      ":::",
+      ':::field{key="protagonist" label="主角"}',
+      "陈风",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.summary).toBe("确立了书名、世界观和主角");
+  });
+
+  it("generates summary with single field", () => {
+    const raw = [
+      ':::field{key="title" label="书名"}',
+      "星河彼岸",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.summary).toBe("确立了书名");
+  });
+
+  it("generates summary with two fields", () => {
+    const raw = [
+      ':::field{key="title" label="书名"}',
+      "星河彼岸",
+      ":::",
+      ':::field{key="worldPremise" label="世界观"}',
+      "一个宇宙",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.summary).toBe("确立了书名和世界观");
+  });
+
+  // ---------------------------------------------------------------------------
+  // 8. Edge case: ::: in code blocks should NOT be parsed as directives
+  // ---------------------------------------------------------------------------
+
+  it("does not parse ::: inside fenced code blocks", () => {
+    const raw = [
+      "下面是一个示例:",
+      "",
+      "```markdown",
+      ':::field{key="demo" label="示例"}',
+      "这不是真正的字段",
+      ":::",
+      "```",
+      "",
+      "以上只是演示。",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+
+    expect(result.fields).toEqual({});
+    expect(result.textContent).toBe(raw);
+  });
+
+  it("does not parse ::: inside indented code blocks with backtick fences", () => {
+    const raw = [
+      "示例代码:",
+      "",
+      "````",
+      ':::field{key="demo" label="示例"}',
+      "不是字段",
+      ":::",
+      "````",
+      "",
+      "结束。",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.fields).toEqual({});
+    expect(result.textContent).toBe(raw);
+  });
+
+  // ---------------------------------------------------------------------------
+  // Multi-line field value
+  // ---------------------------------------------------------------------------
+
+  it("extracts multi-line field value from textarea type", () => {
+    const raw = [
+      ':::field{key="outline" label="大纲" type="textarea"}',
+      "第一卷:起源",
+      "第二卷:征途",
+      "第三卷:终局",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.fields["outline"]).toBe(
+      "第一卷:起源\n第二卷:征途\n第三卷:终局",
+    );
+  });
+
+  // ---------------------------------------------------------------------------
+  // group label appears in summary
+  // ---------------------------------------------------------------------------
+
+  it("does not include group labels in summary (only leaf fields)", () => {
+    const raw = [
+      ':::group{label="篇幅设置"}',
+      ':::number{key="chapterCount" label="总章数"}',
+      "200",
+      ":::",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    // summary should mention "总章数", not "篇幅设置"
+    expect(result.summary).toBe("确立了总章数");
+  });
+
+  // ---------------------------------------------------------------------------
+  // Attribute parsing edge cases
+  // ---------------------------------------------------------------------------
+
+  it("handles single-quoted attributes", () => {
+    const raw = [
+      ":::field{key='title' label='书名'}",
+      "星河彼岸",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.fields["title"]).toBe("星河彼岸");
+  });
+
+  it("handles attributes with extra spaces", () => {
+    const raw = [
+      ':::field{ key="title"  label="书名" }',
+      "星河彼岸",
+      ":::",
+    ].join("\n");
+
+    const result = parseDraftDirectives(raw);
+    expect(result.fields["title"]).toBe("星河彼岸");
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Streaming filter
+// ---------------------------------------------------------------------------
+
+describe("createDirectiveStreamFilter", () => {
+  it("passes through pure text unchanged", () => {
+    const filter = createDirectiveStreamFilter();
+    expect(filter("你好世界")).toBe("你好世界");
+    expect(filter("第二段文字")).toBe("第二段文字");
+  });
+
+  it("filters out a complete directive block arriving in one chunk", () => {
+    const filter = createDirectiveStreamFilter();
+    const chunk = ':::field{key="title" label="书名"}\n星河彼岸\n:::\n';
+    expect(filter(chunk)).toBe("");
+  });
+
+  it("filters directive blocks arriving across multiple chunks", () => {
+    const filter = createDirectiveStreamFilter();
+
+    const out1 = filter("欢迎!\n");
+    expect(out1).toBe("欢迎!\n");
+
+    // directive opening arrives
+    const out2 = filter(':::field{key="title" label="书名"}\n');
+    expect(out2).toBe("");
+
+    // content inside directive
+    const out3 = filter("星河彼岸\n");
+    expect(out3).toBe("");
+
+    // directive close
+    const out4 = filter(":::\n");
+    expect(out4).toBe("");
+
+    // back to normal text
+    const out5 = filter("继续对话。\n");
+    expect(out5).toBe("继续对话。\n");
+  });
+
+  it("handles nested group directives in stream", () => {
+    const filter = createDirectiveStreamFilter();
+
+    expect(filter("开始\n")).toBe("开始\n");
+    expect(filter(':::group{label="篇幅"}\n')).toBe("");
+    expect(filter(':::number{key="ch" label="章数"}\n')).toBe("");
+    expect(filter("300\n")).toBe("");
+    expect(filter(":::\n")).toBe(""); // closes number
+    expect(filter(":::\n")).toBe(""); // closes group
+    expect(filter("结束\n")).toBe("结束\n");
+  });
+
+  it("does not filter ::: inside code blocks during streaming", () => {
+    const filter = createDirectiveStreamFilter();
+
+    expect(filter("```\n")).toBe("```\n");
+    expect(filter(':::field{key="x" label="y"}\n')).toBe(
+      ':::field{key="x" label="y"}\n',
+    );
+    expect(filter(":::\n")).toBe(":::\n");
+    expect(filter("```\n")).toBe("```\n");
+  });
+});

+ 69 - 12
packages/core/src/__tests__/interaction-chat-tokens.test.ts

@@ -2,10 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
 import { createInteractionToolsFromDeps } from "../interaction/project-tools.js";
 
 const mockChatCompletion = vi.hoisted(() => vi.fn());
+const mockChatWithTools = vi.hoisted(() => vi.fn());
 
 vi.mock("../index.js", async (importOriginal) => {
   const actual = await importOriginal<Record<string, unknown>>();
-  return { ...actual, chatCompletion: mockChatCompletion };
+  return { ...actual, chatCompletion: mockChatCompletion, chatWithTools: mockChatWithTools };
 });
 
 const fakePipeline = {
@@ -26,14 +27,28 @@ const fakeState = {
   listBooks: vi.fn(async () => []),
 };
 
-const MOCK_RESPONSE = {
-  content: JSON.stringify({
-    assistantReply: "好的,你想写都市异能,请问主角是什么类型的能力?",
-    draft: { concept: "都市异能", missingFields: ["title", "genre"], readyToCreate: false },
-  }),
+const MOCK_CHAT_RESPONSE = {
+  content: [
+    "好的,你想写都市异能,请问主角是什么类型的能力?",
+    "",
+    ':::field{key="title" label="书名"}',
+    "都市异能",
+    ":::",
+  ].join("\n"),
   tokensUsed: { prompt: 5, completion: 80, total: 85 },
 };
 
+const MOCK_TOOL_RESPONSE = {
+  content: "好的,已根据你的描述生成建书参数。",
+  toolCalls: [
+    {
+      id: "call_1",
+      name: "create_book",
+      arguments: JSON.stringify({ title: "都市异能", genre: "urban", platform: "tomato", brief: "都市异能题材" }),
+    },
+  ],
+};
+
 describe("chat tool – maxTokens forwarding", () => {
   beforeEach(() => {
     mockChatCompletion.mockResolvedValue({
@@ -92,13 +107,13 @@ describe("chat tool – maxTokens forwarding", () => {
   });
 });
 
-describe("developBookDraft – maxTokens not capped", () => {
+describe("developBookDraft – uses chatWithTools", () => {
   beforeEach(() => {
-    mockChatCompletion.mockResolvedValue(MOCK_RESPONSE);
-    mockChatCompletion.mockClear();
+    mockChatWithTools.mockResolvedValue(MOCK_TOOL_RESPONSE);
+    mockChatWithTools.mockClear();
   });
 
-  it("does not pass maxTokens to chatCompletion so thinking models are not truncated", async () => {
+  it("calls chatWithTools with create_book tool and does not pass maxTokens", async () => {
     const tools = createInteractionToolsFromDeps(
       fakePipeline as never,
       fakeState as never,
@@ -106,8 +121,50 @@ describe("developBookDraft – maxTokens not capped", () => {
 
     await tools.developBookDraft?.("我想写都市异能", undefined);
 
-    expect(mockChatCompletion).toHaveBeenCalledOnce();
-    const options = mockChatCompletion.mock.calls[0]?.[3] as Record<string, unknown> | undefined;
+    expect(mockChatWithTools).toHaveBeenCalledOnce();
+    const options = mockChatWithTools.mock.calls[0]?.[4] as Record<string, unknown> | undefined;
     expect(options).not.toHaveProperty("maxTokens");
   });
+
+  it("extracts tool call arguments into the creation draft", async () => {
+    const tools = createInteractionToolsFromDeps(
+      fakePipeline as never,
+      fakeState as never,
+    );
+
+    const result = await tools.developBookDraft?.("我想写都市异能", undefined) as Record<string, unknown>;
+    const interaction = (result as { __interaction: Record<string, unknown> }).__interaction;
+    const details = interaction.details as Record<string, unknown>;
+
+    expect(details.creationDraft).toEqual(expect.objectContaining({
+      title: "都市异能",
+      genre: "urban",
+      platform: "tomato",
+      blurb: "都市异能题材",
+      readyToCreate: true,
+    }));
+    expect(details.toolCall).toEqual({
+      name: "create_book",
+      arguments: { title: "都市异能", genre: "urban", platform: "tomato", brief: "都市异能题材" },
+    });
+  });
+
+  it("returns fallback when no LLM is configured", async () => {
+    const noLlmPipeline = {
+      config: {},
+      writeNextChapter: vi.fn(),
+      reviseDraft: vi.fn(),
+    };
+
+    const tools = createInteractionToolsFromDeps(
+      noLlmPipeline as never,
+      fakeState as never,
+    );
+
+    const result = await tools.developBookDraft?.("我想写都市异能", undefined) as Record<string, unknown>;
+    const interaction = (result as { __interaction: Record<string, unknown> }).__interaction;
+
+    expect(mockChatWithTools).not.toHaveBeenCalled();
+    expect(interaction.responseText).toContain("请先配置 LLM 模型");
+  });
 });

+ 10 - 5
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -247,6 +247,7 @@ describe("PipelineRunner", () => {
         projectRoot: process.cwd(),
         defaultLLMConfig: {
           provider: "custom",
+          service: "custom",
           baseUrl: "https://base.example/v1",
           apiKey: "base-key",
           model: "base-model",
@@ -895,6 +896,7 @@ describe("PipelineRunner", () => {
       const result = await runner.writeDraft(bookId);
 
       expect(result.chapterNumber).toBe(1);
+      console.log("DEBUG warnings:", JSON.stringify(warnings, null, 2));
       expect(warnings).toContain(
         "当前 Node 运行时不支持 SQLite 记忆索引,继续使用 Markdown 回退方案。",
       );
@@ -2279,11 +2281,10 @@ describe("PipelineRunner", () => {
     await rm(root, { recursive: true, force: true });
   });
 
-  it("does not persist chapter files or index entries when state validation errors before save", async () => {
+  it("degrades to state-degraded when state validation errors instead of aborting", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture({
       inputGovernanceMode: "legacy",
     });
-    const chaptersDir = join(state.bookDir(bookId), "chapters");
 
     vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
       createWriterOutput({
@@ -2302,9 +2303,13 @@ describe("PipelineRunner", () => {
       new Error("LLM returned empty response"),
     );
 
-    await expect(runner.writeNextChapter(bookId)).rejects.toThrow("LLM returned empty response");
-    await expect(readdir(chaptersDir)).resolves.toEqual([]);
-    await expect(state.loadChapterIndex(bookId)).resolves.toEqual([]);
+    const result = await runner.writeNextChapter(bookId);
+    expect(result.status).toBe("state-degraded");
+
+    // Chapter should be saved (content is fine, only truth files are degraded)
+    const index = await state.loadChapterIndex(bookId);
+    expect(index).toHaveLength(1);
+    expect(index[0]!.status).toBe("state-degraded");
 
     await rm(root, { recursive: true, force: true });
   });

+ 229 - 150
packages/core/src/__tests__/provider.test.ts

@@ -1,16 +1,125 @@
 import { beforeEach, describe, expect, it, vi } from "vitest";
-import type OpenAI from "openai";
+import type { AssistantMessage, Model, Api } from "@mariozechner/pi-ai";
 import {
   __resetFixedTemperatureWarnings,
   chatCompletion,
   type LLMClient,
 } from "../llm/provider.js";
 
-const ZERO_USAGE = {
-  prompt_tokens: 11,
-  completion_tokens: 7,
-  total_tokens: 18,
-} as const;
+// ── Mock @mariozechner/pi-ai ──────────────────────────────────────────────────
+// We intercept streamSimple so tests don't hit the network.
+
+const mockStreamSimple = vi.fn();
+
+vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
+  const original = await importOriginal<typeof import("@mariozechner/pi-ai")>();
+  return {
+    ...original,
+    streamSimple: (...args: unknown[]) => mockStreamSimple(...args),
+  };
+});
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+const MOCK_USAGE = {
+  input: 11,
+  output: 7,
+  cacheRead: 0,
+  cacheWrite: 0,
+  totalTokens: 18,
+  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+};
+
+function makeAssistantMessage(text: string): AssistantMessage {
+  return {
+    role: "assistant",
+    content: [{ type: "text", text }],
+    api: "openai-completions" as Api,
+    provider: "openai",
+    model: "test-model",
+    usage: MOCK_USAGE,
+    stopReason: "stop",
+    timestamp: Date.now(),
+  };
+}
+
+/** Builds an async iterable that emits the given events. */
+function makeEventStream(
+  events: Array<Record<string, unknown>>,
+): AsyncIterable<Record<string, unknown>> {
+  return {
+    [Symbol.asyncIterator](): AsyncIterator<Record<string, unknown>> {
+      let i = 0;
+      return {
+        async next() {
+          if (i < events.length) return { value: events[i++]!, done: false };
+          return { value: undefined as unknown as Record<string, unknown>, done: true };
+        },
+      };
+    },
+  };
+}
+
+/** Stream that emits one text_delta and then done. */
+function makeTextStream(text: string): AsyncIterable<Record<string, unknown>> {
+  const msg = makeAssistantMessage(text);
+  return makeEventStream([
+    { type: "text_delta", contentIndex: 0, delta: text, partial: msg },
+    { type: "done", reason: "stop", message: msg },
+  ]);
+}
+
+/** Stream that emits only done with empty content. */
+function makeEmptyStream(): AsyncIterable<Record<string, unknown>> {
+  const msg = makeAssistantMessage("");
+  return makeEventStream([
+    { type: "done", reason: "stop", message: msg },
+  ]);
+}
+
+/** Stream that throws immediately. */
+function makeErrorStream(message: string): AsyncIterable<Record<string, unknown>> {
+  return {
+    [Symbol.asyncIterator](): AsyncIterator<Record<string, unknown>> {
+      return {
+        async next() {
+          throw new Error(message);
+        },
+      };
+    },
+  };
+}
+
+const MOCK_PI_MODEL: Model<Api> = {
+  id: "test-model",
+  name: "test-model",
+  api: "openai-completions",
+  provider: "openai",
+  baseUrl: "https://api.openai.com/v1",
+  reasoning: false,
+  input: ["text"],
+  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+  contextWindow: 128000,
+  maxTokens: 8192,
+};
+
+function makeClient(temperature = 0.7, extra: Partial<LLMClient> = {}): LLMClient {
+  return {
+    provider: "openai",
+    apiFormat: "chat",
+    stream: true,
+    _piModel: MOCK_PI_MODEL,
+    _apiKey: "test-key",
+    defaults: {
+      temperature,
+      maxTokens: 512,
+      thinkingBudget: 0,
+      maxTokensCap: null,
+      extra: {},
+    },
+    ...extra,
+  };
+}
 
 async function captureError(task: Promise<unknown>): Promise<Error> {
   try {
@@ -21,169 +130,139 @@ async function captureError(task: Promise<unknown>): Promise<Error> {
   throw new Error("Expected promise to reject");
 }
 
-describe("chatCompletion stream fallback", () => {
-  it("falls back to sync chat completion when streamed chat returns no chunks", async () => {
-    const create = vi.fn()
-      .mockResolvedValueOnce({
-        async *[Symbol.asyncIterator](): AsyncIterableIterator<unknown> {
-          return;
-        },
-      })
-      .mockResolvedValueOnce({
-        choices: [{ message: { content: "fallback content" } }],
-        usage: ZERO_USAGE,
-      });
-
-    const client: LLMClient = {
-      provider: "openai",
-      apiFormat: "chat",
-      stream: true,
-      _openai: {
-        chat: {
-          completions: {
-            create,
-          },
-        },
-      } as unknown as OpenAI,
-      defaults: {
-        temperature: 0.7,
-        maxTokens: 512,
-        thinkingBudget: 0, maxTokensCap: null,
-        extra: {},
-      },
-    };
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe("chatCompletion via pi-ai", () => {
+  beforeEach(() => {
+    mockStreamSimple.mockReset();
+  });
 
+  it("returns text content from a successful stream", async () => {
+    mockStreamSimple.mockReturnValue(makeTextStream("hello world"));
+
+    const client = makeClient();
     const result = await chatCompletion(client, "test-model", [
       { role: "user", content: "ping" },
     ]);
 
-    expect(result.content).toBe("fallback content");
-    expect(result.usage).toEqual({
-      promptTokens: 11,
-      completionTokens: 7,
-      totalTokens: 18,
-    });
-    expect(create).toHaveBeenCalledTimes(2);
-    expect(create.mock.calls[0]?.[0]).toMatchObject({ stream: true });
-    expect(create.mock.calls[1]?.[0]).toMatchObject({ stream: false });
-  });
-
-  it("does not blindly suggest stream false for generic 400 errors", async () => {
-    const create = vi.fn().mockRejectedValue(new Error("400 Bad Request"));
-
-    const client: LLMClient = {
-      provider: "openai",
-      apiFormat: "chat",
-      stream: false,
-      _openai: {
-        chat: {
-          completions: {
-            create,
-          },
-        },
-      } as unknown as OpenAI,
-      defaults: {
-        temperature: 0.7,
-        maxTokens: 512,
-        thinkingBudget: 0, maxTokensCap: null,
-        extra: {},
-      },
-    };
-
-    const error = await captureError(chatCompletion(client, "test-model", [
-      { role: "user", content: "ping" },
-    ]));
+    expect(result.content).toBe("hello world");
+    expect(result.usage.promptTokens).toBe(11);
+    expect(result.usage.completionTokens).toBe(7);
+    expect(result.usage.totalTokens).toBe(18);
+    expect(mockStreamSimple).toHaveBeenCalledOnce();
+  });
+
+  it("throws when stream produces no text content", async () => {
+    mockStreamSimple.mockReturnValue(makeEmptyStream());
+
+    const client = makeClient();
+    const error = await captureError(
+      chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
+    );
+
+    expect(error.message).toContain("empty response");
+  });
+
+  it("wraps 400 API errors with a user-friendly message", async () => {
+    mockStreamSimple.mockReturnValue(makeErrorStream("400 Bad Request"));
+
+    const client = makeClient();
+    const error = await captureError(
+      chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
+    );
 
     expect(error.message).toContain("API 返回 400");
-    expect(error.message).not.toContain("\"stream\": false");
     expect(error.message).toContain("检查提供方文档");
   });
 
-  it("reports when sync fallback is rejected because provider requires streaming", async () => {
-    const create = vi.fn()
-      .mockResolvedValueOnce({
-        async *[Symbol.asyncIterator](): AsyncIterableIterator<unknown> {
-          return;
-        },
-      })
-      .mockRejectedValueOnce(new Error("400 {\"detail\":\"Stream must be set to true\"}"));
-
-    const client: LLMClient = {
-      provider: "openai",
-      apiFormat: "chat",
-      stream: true,
-      _openai: {
-        chat: {
-          completions: {
-            create,
-          },
-        },
-      } as unknown as OpenAI,
-      defaults: {
-        temperature: 0.7,
-        maxTokens: 512,
-        thinkingBudget: 0, maxTokensCap: null,
-        extra: {},
-      },
-    };
-
-    const error = await captureError(chatCompletion(client, "test-model", [
-      { role: "user", content: "ping" },
+  it("wraps 401 errors with an unauthorized message", async () => {
+    mockStreamSimple.mockReturnValue(makeErrorStream("401 Unauthorized"));
+
+    const client = makeClient();
+    const error = await captureError(
+      chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
+    );
+
+    expect(error.message).toContain("API 返回 401");
+  });
+
+  it("wraps connection errors with a friendly message", async () => {
+    mockStreamSimple.mockReturnValue(makeErrorStream("fetch failed: ECONNREFUSED"));
+
+    const client = makeClient();
+    const error = await captureError(
+      chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
+    );
+
+    expect(error.message).toContain("无法连接到 API 服务");
+  });
+
+  it("passes temperature and maxTokens to streamSimple", async () => {
+    mockStreamSimple.mockReturnValue(makeTextStream("ok"));
+
+    const client = makeClient(0.5);
+    await chatCompletion(client, "test-model", [{ role: "user", content: "hi" }], {
+      temperature: 0.3,
+      maxTokens: 256,
+    });
+
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(0.3);
+    expect(opts.maxTokens).toBe(256);
+  });
+
+  it("uses client defaults when no per-call overrides are provided", async () => {
+    mockStreamSimple.mockReturnValue(makeTextStream("ok"));
+
+    const client = makeClient(0.8);
+    await chatCompletion(client, "test-model", [{ role: "user", content: "hi" }]);
+
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(0.8);
+    expect(opts.maxTokens).toBe(512);
+  });
+
+  it("calls onTextDelta for each text chunk", async () => {
+    const msg = makeAssistantMessage("abc");
+    mockStreamSimple.mockReturnValue(makeEventStream([
+      { type: "text_delta", contentIndex: 0, delta: "a", partial: msg },
+      { type: "text_delta", contentIndex: 0, delta: "b", partial: msg },
+      { type: "text_delta", contentIndex: 0, delta: "c", partial: msg },
+      { type: "done", reason: "stop", message: msg },
     ]));
 
-    expect(create).toHaveBeenCalledTimes(2);
-    expect(create.mock.calls[0]?.[0]).toMatchObject({ stream: true });
-    expect(create.mock.calls[1]?.[0]).toMatchObject({ stream: false });
-    expect(error.message).toContain("stream:true");
-    expect(error.message).not.toContain("\"stream\": false");
+    const deltas: string[] = [];
+    const client = makeClient();
+    await chatCompletion(client, "test-model", [{ role: "user", content: "hi" }], {
+      onTextDelta: (d) => deltas.push(d),
+    });
+
+    expect(deltas).toEqual(["a", "b", "c"]);
   });
 });
 
 describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
   beforeEach(() => {
     __resetFixedTemperatureWarnings();
+    mockStreamSimple.mockReset();
+    mockStreamSimple.mockReturnValue(makeTextStream("ok"));
   });
 
-  function makeSyncClient(create: ReturnType<typeof vi.fn>, temperature: number): LLMClient {
-    return {
-      provider: "openai",
-      apiFormat: "chat",
-      stream: false,
-      _openai: {
-        chat: { completions: { create } },
-      } as unknown as OpenAI,
-      defaults: {
-        temperature,
-        maxTokens: 512,
-        thinkingBudget: 0,
-        maxTokensCap: null,
-        extra: {},
-      },
-    };
-  }
-
-  const OK_RESPONSE = {
-    choices: [{ message: { content: "ok" } }],
-    usage: ZERO_USAGE,
-  };
-
   it("forces temperature=1 for kimi-k2.5 even when client default is 0.7", async () => {
-    const create = vi.fn().mockResolvedValue(OK_RESPONSE);
-    const client = makeSyncClient(create, 0.7);
+    const client = makeClient(0.7);
     const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
 
     await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "hi" }]);
 
-    expect(create).toHaveBeenCalledTimes(1);
-    expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 1 });
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(1);
     expect(warn).toHaveBeenCalledOnce();
     expect(warn.mock.calls[0]?.[0]).toContain("kimi-k2.5");
     warn.mockRestore();
   });
 
   it("clamps per-call temperature override (0.3) to 1 for kimi-k2.5", async () => {
-    const create = vi.fn().mockResolvedValue(OK_RESPONSE);
-    const client = makeSyncClient(create, 0.7);
+    const client = makeClient(0.7);
     vi.spyOn(console, "warn").mockImplementation(() => {});
 
     await chatCompletion(
@@ -193,12 +272,12 @@ describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
       { temperature: 0.3 },
     );
 
-    expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 1 });
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(1);
   });
 
   it("only warns once per model name across multiple calls", async () => {
-    const create = vi.fn().mockResolvedValue(OK_RESPONSE);
-    const client = makeSyncClient(create, 0.7);
+    const client = makeClient(0.7);
     const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
 
     await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "a" }]);
@@ -210,20 +289,19 @@ describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
   });
 
   it("also clamps any model name containing 'thinking'", async () => {
-    const create = vi.fn().mockResolvedValue(OK_RESPONSE);
-    const client = makeSyncClient(create, 0.5);
+    const client = makeClient(0.5);
     vi.spyOn(console, "warn").mockImplementation(() => {});
 
     await chatCompletion(client, "kimi-thinking-preview", [
       { role: "user", content: "hi" },
     ]);
 
-    expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 1 });
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(1);
   });
 
   it("leaves regular models untouched (no clamp, no warning)", async () => {
-    const create = vi.fn().mockResolvedValue(OK_RESPONSE);
-    const client = makeSyncClient(create, 0.7);
+    const client = makeClient(0.7);
     const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
 
     await chatCompletion(
@@ -233,19 +311,20 @@ describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
       { temperature: 0.3 },
     );
 
-    expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 0.3 });
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(0.3);
     expect(warn).not.toHaveBeenCalled();
     warn.mockRestore();
   });
 
   it("does not warn when requested temperature is already 1", async () => {
-    const create = vi.fn().mockResolvedValue(OK_RESPONSE);
-    const client = makeSyncClient(create, 1);
+    const client = makeClient(1);
     const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
 
     await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "hi" }]);
 
-    expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 1 });
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.temperature).toBe(1);
     expect(warn).not.toHaveBeenCalled();
     warn.mockRestore();
   });

+ 95 - 0
packages/core/src/__tests__/secrets.test.ts

@@ -0,0 +1,95 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { loadSecrets, saveSecrets, getServiceApiKey } from "../llm/secrets.js";
+import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+describe("secrets", () => {
+  let root: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-secrets-"));
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  describe("loadSecrets", () => {
+    it("returns empty when .inkos/secrets.json does not exist", async () => {
+      const secrets = await loadSecrets(root);
+      expect(secrets).toEqual({ services: {} });
+    });
+
+    it("reads existing secrets file", async () => {
+      await mkdir(join(root, ".inkos"), { recursive: true });
+      await writeFile(
+        join(root, ".inkos", "secrets.json"),
+        JSON.stringify({ services: { moonshot: { apiKey: "sk-test" } } }),
+      );
+      const secrets = await loadSecrets(root);
+      expect(secrets.services.moonshot.apiKey).toBe("sk-test");
+    });
+  });
+
+  describe("saveSecrets", () => {
+    it("creates .inkos dir and writes secrets file", async () => {
+      await saveSecrets(root, {
+        services: { deepseek: { apiKey: "sk-deep" } },
+      });
+      const raw = await readFile(join(root, ".inkos", "secrets.json"), "utf-8");
+      const parsed = JSON.parse(raw);
+      expect(parsed.services.deepseek.apiKey).toBe("sk-deep");
+    });
+
+    it("overwrites existing secrets file", async () => {
+      await mkdir(join(root, ".inkos"), { recursive: true });
+      await writeFile(
+        join(root, ".inkos", "secrets.json"),
+        JSON.stringify({ services: { old: { apiKey: "old-key" } } }),
+      );
+      await saveSecrets(root, {
+        services: { new: { apiKey: "new-key" } },
+      });
+      const secrets = await loadSecrets(root);
+      expect(secrets.services.new.apiKey).toBe("new-key");
+      expect(secrets.services.old).toBeUndefined();
+    });
+  });
+
+  describe("getServiceApiKey", () => {
+    it("returns key from secrets.json first", async () => {
+      await mkdir(join(root, ".inkos"), { recursive: true });
+      await writeFile(
+        join(root, ".inkos", "secrets.json"),
+        JSON.stringify({ services: { moonshot: { apiKey: "sk-from-file" } } }),
+      );
+      const key = await getServiceApiKey(root, "moonshot");
+      expect(key).toBe("sk-from-file");
+    });
+
+    it("falls back to environment variable", async () => {
+      vi.stubEnv("MOONSHOT_API_KEY", "sk-from-env");
+      const key = await getServiceApiKey(root, "moonshot");
+      expect(key).toBe("sk-from-env");
+      vi.unstubAllEnvs();
+    });
+
+    it("returns null when neither secrets nor env exists", async () => {
+      const key = await getServiceApiKey(root, "moonshot");
+      expect(key).toBeNull();
+    });
+
+    it("handles custom service with colon key format", async () => {
+      await mkdir(join(root, ".inkos"), { recursive: true });
+      await writeFile(
+        join(root, ".inkos", "secrets.json"),
+        JSON.stringify({
+          services: { "custom:内网GPT": { apiKey: "sk-custom" } },
+        }),
+      );
+      const key = await getServiceApiKey(root, "custom:内网GPT");
+      expect(key).toBe("sk-custom");
+    });
+  });
+});

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

@@ -0,0 +1,122 @@
+// packages/core/src/__tests__/service-resolver.test.ts
+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";
+
+// Models that exist in pi-ai's built-in registry (simulated)
+const KNOWN_MODELS = new Set(["gpt-4o", "kimi-k2.5"]);
+
+// Mock pi-ai's getModel — returns undefined for models not in registry (like the real implementation)
+vi.mock("@mariozechner/pi-ai", () => ({
+  getModel: vi.fn((provider: string, modelId: string) => {
+    if (!KNOWN_MODELS.has(modelId)) return undefined;
+    return {
+      id: modelId,
+      name: modelId,
+      api: "openai-completions",
+      provider,
+      baseUrl: "https://api.openai.com/v1",
+      reasoning: false,
+      input: ["text"],
+      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+      contextWindow: 128000,
+      maxTokens: 16384,
+    };
+  }),
+  getEnvApiKey: vi.fn(() => undefined),
+}));
+
+import { resolveServiceModel } from "../llm/service-resolver.js";
+
+describe("resolveServiceModel", () => {
+  let root: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-resolver-"));
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+    vi.unstubAllEnvs();
+  });
+
+  it("resolves built-in service with key from secrets", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { moonshot: { apiKey: "sk-moon" } } }),
+    );
+
+    const result = await resolveServiceModel("moonshot", "kimi-k2.5", root);
+
+    expect(result.model.id).toBe("kimi-k2.5");
+    expect(result.apiKey).toBe("sk-moon");
+    expect(result.writingTemperature).toBe(1.0);
+    expect(result.temperatureRange).toEqual([0, 1]);
+  });
+
+  it("resolves deepseek with correct temperature", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { deepseek: { apiKey: "sk-deep" } } }),
+    );
+
+    const result = await resolveServiceModel("deepseek", "deepseek-chat", root);
+
+    expect(result.apiKey).toBe("sk-deep");
+    expect(result.writingTemperature).toBe(1.5);
+    expect(result.temperatureRange).toEqual([0, 2]);
+  });
+
+  it("constructs model from preset when getModel returns undefined", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { deepseek: { apiKey: "sk-deep" } } }),
+    );
+
+    // "deepseek-chat" is NOT in KNOWN_MODELS, so getModel returns undefined
+    const result = await resolveServiceModel("deepseek", "deepseek-chat", root);
+
+    expect(result.model).toBeDefined();
+    expect(result.model.id).toBe("deepseek-chat");
+    expect(result.model.api).toBe("openai-completions");
+    expect(result.model.baseUrl).toBe("https://api.deepseek.com");
+    expect(result.model.provider).toBe("openai");
+    expect(result.apiKey).toBe("sk-deep");
+  });
+
+  it("falls back to env var when no secrets file", async () => {
+    vi.stubEnv("DEEPSEEK_API_KEY", "sk-env");
+
+    const result = await resolveServiceModel("deepseek", "deepseek-chat", root);
+
+    expect(result.apiKey).toBe("sk-env");
+  });
+
+  it("throws when no key found", async () => {
+    await expect(
+      resolveServiceModel("moonshot", "kimi-k2.5", root),
+    ).rejects.toThrow(/API key/i);
+  });
+
+  it("resolves custom service with baseUrl", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { "custom:内网GPT": { apiKey: "sk-corp" } } }),
+    );
+
+    const result = await resolveServiceModel(
+      "custom:内网GPT",
+      "gpt-4o",
+      root,
+      "https://llm.internal.corp/v1",
+    );
+
+    expect(result.apiKey).toBe("sk-corp");
+    expect(result.model.id).toBe("gpt-4o");
+  });
+});

+ 16 - 0
packages/core/src/__tests__/state-manager.test.ts

@@ -678,6 +678,22 @@ describe("StateManager", () => {
       expect(String(rejected[0]?.reason)).toMatch(/is locked/);
     });
 
+    it("reclaims same-process stale lock when no active write is in progress", async () => {
+      await mkdir(manager.bookDir("lock-book-self"), { recursive: true });
+      const lockPath = join(manager.bookDir("lock-book-self"), ".write.lock");
+      // Simulate a stale lock left by our own process (e.g. after a failed pipeline)
+      await writeFile(lockPath, `pid:${process.pid} ts:${Date.now() - 60000}`, "utf-8");
+
+      // Should auto-reclaim since our process knows it's not actively writing this book
+      const release = await manager.acquireBookLock("lock-book-self");
+      expect(typeof release).toBe("function");
+
+      const lockData = await readFile(lockPath, "utf-8");
+      expect(lockData).toContain(`pid:${process.pid}`);
+
+      await release();
+    });
+
     it("reclaims a stale lock when the recorded pid is no longer alive", async () => {
       await mkdir(manager.bookDir("lock-book-5"), { recursive: true });
       const lockPath = join(manager.bookDir("lock-book-5"), ".write.lock");

+ 30 - 0
packages/core/src/__tests__/state-validator-agent.test.ts

@@ -55,6 +55,36 @@ describe("StateValidatorAgent", () => {
     });
   });
 
+  it("passes maxTokens large enough for thinking models to chat()", async () => {
+    const agent = new StateValidatorAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 8192,
+          thinkingBudget: 0,
+          maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const chatSpy = vi.spyOn(
+      agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> },
+      "chat",
+    ).mockResolvedValue({ content: "PASS", usage: ZERO_USAGE });
+
+    await agent.validate("Body.", 1, "old", "new state", "old hooks", "new hooks", "zh");
+
+    const options = chatSpy.mock.calls[0]?.[1] as { maxTokens?: number } | undefined;
+    // Must not hardcode a small value like 2048 that starves thinking models
+    expect(options?.maxTokens).toBeUndefined();
+  });
+
   it("throws when the validator model returns an empty response", async () => {
     const agent = new StateValidatorAgent({
       client: {

+ 57 - 0
packages/core/src/__tests__/temperature-constraints.test.ts

@@ -0,0 +1,57 @@
+import { describe, it, expect } from "vitest";
+import { resolveServicePreset, clampTemperature, getWritingTemperature } from "../llm/service-presets.js";
+
+describe("temperature constraints per service", () => {
+  it("moonshot has range [0, 1] and writingTemperature 1.0", () => {
+    const preset = resolveServicePreset("moonshot");
+    expect(preset?.temperatureRange).toEqual([0, 1]);
+    expect(preset?.writingTemperature).toBe(1.0);
+  });
+
+  it("deepseek has range [0, 2] and writingTemperature 1.5", () => {
+    const preset = resolveServicePreset("deepseek");
+    expect(preset?.temperatureRange).toEqual([0, 2]);
+    expect(preset?.writingTemperature).toBe(1.5);
+  });
+
+  it("anthropic has range [0, 1] and writingTemperature 1.0", () => {
+    const preset = resolveServicePreset("anthropic");
+    expect(preset?.temperatureRange).toEqual([0, 1]);
+    expect(preset?.writingTemperature).toBe(1.0);
+  });
+
+  it("openai has range [0, 2] and writingTemperature 1.0", () => {
+    const preset = resolveServicePreset("openai");
+    expect(preset?.temperatureRange).toEqual([0, 2]);
+    expect(preset?.writingTemperature).toBe(1.0);
+  });
+
+  it("zhipu has range [0, 1]", () => {
+    const preset = resolveServicePreset("zhipu");
+    expect(preset?.temperatureRange).toEqual([0, 1]);
+  });
+
+  it("bailian has range [0, 2]", () => {
+    const preset = resolveServicePreset("bailian");
+    expect(preset?.temperatureRange).toEqual([0, 2]);
+  });
+
+  it("minimax has range [0, 2]", () => {
+    const preset = resolveServicePreset("minimax");
+    expect(preset?.temperatureRange).toEqual([0, 2]);
+  });
+
+  it("clampTemperature respects service range", () => {
+    expect(clampTemperature("moonshot", 1.5)).toBe(1.0);
+    expect(clampTemperature("moonshot", 0.7)).toBe(0.7);
+    expect(clampTemperature("deepseek", 1.5)).toBe(1.5);
+    expect(clampTemperature("deepseek", 2.5)).toBe(2.0);
+    expect(clampTemperature("unknown-service", 1.5)).toBe(1.5);
+  });
+
+  it("getWritingTemperature returns service-specific value", () => {
+    expect(getWritingTemperature("moonshot")).toBe(1.0);
+    expect(getWritingTemperature("deepseek")).toBe(1.5);
+    expect(getWritingTemperature("anthropic")).toBe(1.0);
+  });
+});

+ 304 - 0
packages/core/src/agent/agent-session.ts

@@ -0,0 +1,304 @@
+import { Agent } from "@mariozechner/pi-agent-core";
+import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
+import { streamSimple, getModel, getEnvApiKey } from "@mariozechner/pi-ai";
+import type { Model, Api, AssistantMessage, UserMessage } from "@mariozechner/pi-ai";
+import type { PipelineRunner } from "../pipeline/runner.js";
+import { buildAgentSystemPrompt } from "./agent-system-prompt.js";
+import {
+  createSubAgentTool,
+  createReadTool,
+  createEditTool,
+  createGrepTool,
+  createLsTool,
+} from "./agent-tools.js";
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export interface AgentSessionConfig {
+  /** Unique session identifier (typically the BookSession id). */
+  sessionId: string;
+  /** Book ID, or null if in "new book" mode. */
+  bookId: string | null;
+  /** Language for the system prompt. */
+  language: string;
+  /** PipelineRunner for sub-agent tool delegation. */
+  pipeline: PipelineRunner;
+  /** Project root directory (books/ lives under this). */
+  projectRoot: string;
+  /** pi-ai Model to use, or provider+modelId to resolve via getModel. */
+  model: Model<Api> | { provider: string; modelId: string };
+  /** Optional API key. When omitted, falls back to env-based key lookup. */
+  apiKey?: string;
+  /** Optional listener for streaming events (for SSE forwarding). */
+  onEvent?: (event: AgentEvent) => void;
+}
+
+export interface AgentSessionResult {
+  /** Extracted text from the final assistant message. */
+  responseText: string;
+  /** Full conversation history for persistence. */
+  messages: Array<{ role: string; content: string; thinking?: string }>;
+}
+
+// ---------------------------------------------------------------------------
+// Cache
+// ---------------------------------------------------------------------------
+
+interface CachedAgent {
+  agent: Agent;
+  lastActive: number;
+}
+
+const agentCache = new Map<string, CachedAgent>();
+
+/** TTL for cached agents: 5 minutes. */
+const CACHE_TTL_MS = 5 * 60 * 1000;
+
+/** Cleanup interval handle (lazy-started). */
+let cleanupTimer: ReturnType<typeof setInterval> | null = null;
+
+function ensureCleanupTimer(): void {
+  if (cleanupTimer) return;
+  cleanupTimer = setInterval(() => {
+    const now = Date.now();
+    for (const [id, entry] of agentCache) {
+      if (now - entry.lastActive > CACHE_TTL_MS) {
+        agentCache.delete(id);
+      }
+    }
+    // Stop the timer when nothing left to watch.
+    if (agentCache.size === 0 && cleanupTimer) {
+      clearInterval(cleanupTimer);
+      cleanupTimer = null;
+    }
+  }, 60_000); // run every 60 s
+  // Allow the process to exit even if this timer is alive.
+  if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
+    cleanupTimer.unref();
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function resolveModel(spec: AgentSessionConfig["model"]): Model<Api> {
+  if (!spec) {
+    throw new Error("Model is required but was undefined. Check LLM configuration.");
+  }
+  if (typeof spec === "object" && "id" in spec && "api" in spec) {
+    // Already a Model object.
+    return spec as Model<Api>;
+  }
+  const { provider, modelId } = spec as { provider: string; modelId: string };
+  if (!provider || !modelId) {
+    throw new Error(`Invalid model spec: provider=${provider}, modelId=${modelId}`);
+  }
+  return getModel(provider as any, modelId as any);
+}
+
+/**
+ * Extract readable text from an AssistantMessage's content array.
+ * Filters out tool-call blocks; concatenates text blocks.
+ */
+function extractTextFromAssistant(msg: AssistantMessage): string {
+  return msg.content
+    .filter((c): c is { type: "text"; text: string } => c.type === "text")
+    .map((c) => c.text)
+    .join("");
+}
+
+/**
+ * Extract thinking/reasoning text from an AssistantMessage's content array.
+ */
+function extractThinkingFromAssistant(msg: AssistantMessage): string {
+  return msg.content
+    .filter((c: any) => c.type === "thinking")
+    .map((c: any) => c.thinking ?? "")
+    .join("");
+}
+
+/**
+ * Convert plain `{ role, content }` messages (from BookSession disk storage)
+ * back into pi-agent AgentMessage format so they can be loaded into an Agent.
+ */
+function plainToAgentMessages(
+  plain: Array<{ role: string; content: string }>,
+): AgentMessage[] {
+  return plain.map((m) => {
+    const ts = Date.now();
+    if (m.role === "user") {
+      return { role: "user", content: m.content, timestamp: ts } satisfies UserMessage;
+    }
+    // For stored assistant messages we only have the text.
+    // Re-wrap as a minimal AssistantMessage with a single TextContent.
+    return {
+      role: "assistant",
+      content: [{ type: "text", text: m.content }],
+      api: "anthropic-messages",
+      provider: "anthropic",
+      model: "unknown",
+      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: ts,
+    } satisfies AssistantMessage;
+  });
+}
+
+/**
+ * Flatten the Agent's in-memory messages to plain `{ role, content }` pairs
+ * suitable for BookSession persistence.
+ */
+function agentMessagesToPlain(
+  messages: AgentMessage[],
+): Array<{ role: string; content: string; thinking?: string }> {
+  const out: Array<{ role: string; content: string; thinking?: string }> = [];
+  for (const msg of messages) {
+    if (!msg || typeof msg !== "object" || !("role" in msg)) continue;
+
+    const m = msg as { role: string; [k: string]: any };
+
+    if (m.role === "user") {
+      const content = typeof m.content === "string"
+        ? m.content
+        : Array.isArray(m.content)
+          ? m.content
+              .filter((c: any) => c.type === "text")
+              .map((c: any) => c.text)
+              .join("")
+          : "";
+      if (content) out.push({ role: "user", content });
+    } else if (m.role === "assistant") {
+      const text = extractTextFromAssistant(m as AssistantMessage);
+      const thinking = extractThinkingFromAssistant(m as AssistantMessage);
+      if (text || thinking) {
+        const entry: { role: string; content: string; thinking?: string } = { role: "assistant", content: text };
+        if (thinking) entry.thinking = thinking;
+        out.push(entry);
+      }
+    }
+    // ToolResult messages are internal; skip them for persistence.
+  }
+  return out;
+}
+
+// ---------------------------------------------------------------------------
+// Main entry point
+// ---------------------------------------------------------------------------
+
+/**
+ * Run a single conversation turn within a cached Agent session.
+ *
+ * If the session already exists in the cache, reuses the Agent (with its full
+ * in-memory message history including tool calls). Otherwise creates a new
+ * Agent, optionally restoring messages from `initialMessages`.
+ */
+export async function runAgentSession(
+  config: AgentSessionConfig,
+  userMessage: string,
+  initialMessages?: Array<{ role: string; content: string }>,
+): Promise<AgentSessionResult> {
+  const { sessionId, bookId, language, pipeline, projectRoot, onEvent } = config;
+
+  // ----- Resolve or create Agent -----
+  let cached = agentCache.get(sessionId);
+
+  if (cached) {
+    // Check if model changed — evict and rebuild if so
+    const currentModelId = (cached.agent.state.model as any)?.id;
+    const newModelId = typeof config.model === 'object' && 'id' in config.model
+      ? (config.model as any).id
+      : undefined;
+    if (currentModelId && newModelId && currentModelId !== newModelId) {
+      // Preserve conversation messages for re-injection
+      const preservedMessages = agentMessagesToPlain(cached.agent.state.messages);
+      agentCache.delete(sessionId);
+      cached = undefined;
+      // Pass preserved messages as initialMessages if none were provided
+      if (!initialMessages || initialMessages.length === 0) {
+        initialMessages = preservedMessages;
+      }
+    }
+  }
+
+  if (!cached) {
+    const model = resolveModel(config.model);
+    const agent = new Agent({
+      initialState: {
+        model,
+        systemPrompt: buildAgentSystemPrompt(bookId, language),
+        tools: [
+          createSubAgentTool(pipeline, bookId),
+          createReadTool(projectRoot),
+          createEditTool(projectRoot),
+          createGrepTool(projectRoot),
+          createLsTool(projectRoot),
+        ],
+      },
+      streamFn: streamSimple,
+      getApiKey: (provider: string) => {
+        if (config.apiKey) return config.apiKey;
+        return getEnvApiKey(provider);
+      },
+    });
+
+    // Restore prior conversation if provided.
+    if (initialMessages && initialMessages.length > 0) {
+      agent.state.messages = plainToAgentMessages(initialMessages);
+    }
+
+    cached = { agent, lastActive: Date.now() };
+    agentCache.set(sessionId, cached);
+    ensureCleanupTimer();
+  }
+
+  cached.lastActive = Date.now();
+  const { agent } = cached;
+
+  // ----- Subscribe to events (for SSE streaming to frontend) -----
+  let unsubscribe: (() => void) | undefined;
+  if (onEvent) {
+    unsubscribe = agent.subscribe((event: AgentEvent) => {
+      onEvent(event);
+    });
+  }
+
+  // ----- Execute the turn -----
+  try {
+    await agent.prompt(userMessage);
+  } finally {
+    unsubscribe?.();
+  }
+
+  // ----- Extract result -----
+  const allMessages = agent.state.messages;
+  const responseText = extractResponseText(allMessages);
+  const plainMessages = agentMessagesToPlain(allMessages);
+
+  return { responseText, messages: plainMessages };
+}
+
+/**
+ * Walk backward through messages to find the last assistant message and
+ * extract its text content.
+ */
+function extractResponseText(messages: AgentMessage[]): string {
+  for (let i = messages.length - 1; i >= 0; i--) {
+    const msg = messages[i];
+    if (msg && typeof msg === "object" && "role" in msg && (msg as any).role === "assistant") {
+      return extractTextFromAssistant(msg as AssistantMessage);
+    }
+  }
+  return "";
+}
+
+// ---------------------------------------------------------------------------
+// Cache management
+// ---------------------------------------------------------------------------
+
+/** Manually evict a cached Agent session. */
+export function evictAgentCache(sessionId: string): boolean {
+  return agentCache.delete(sessionId);
+}

+ 98 - 0
packages/core/src/agent/agent-system-prompt.ts

@@ -0,0 +1,98 @@
+export function buildAgentSystemPrompt(bookId: string | null, language: string): string {
+  const isZh = language === "zh";
+
+  if (!bookId) {
+    return isZh
+      ? `你是 InkOS 建书助手。你的任务是帮用户从零开始创建一本新书。
+
+## 工作流程
+
+1. **收集信息**(对话阶段)— 通过自然对话逐步了解:
+   - 题材/类型(如玄幻、都市、悬疑、言情等)
+   - 目标平台(番茄小说、起点中文网、飞卢等)
+   - 世界观设定(什么样的世界?有什么特殊规则?)
+   - 主角设定(谁?什么背景?什么性格?)
+   - 核心冲突(主线矛盾是什么?)
+   - 写作语言(中文/English)
+
+2. **确认建书**(调用阶段)— 当信息足够时,调用 sub_agent 工具委托 architect 子智能体建书:
+   - instruction 中包含收集到的所有信息(题材、世界观、主角、冲突等)
+   - architect 会生成完整的 foundation(世界观设定、卷纲规划、叙事规则等)
+
+## 对话风格
+
+- 每次只问一个问题,不要一次问太多
+- 用户回答模糊时,给出 2-3 个具体选项引导
+- 当信息基本齐了,主动提议建书,不要无限追问
+- 保持简短、自然
+- **不要在回复中添加表情符号**`
+      : `You are the InkOS book creation assistant. Help the user create a new book from scratch.
+
+## Workflow
+
+1. **Collect information** — Through conversation, gradually learn:
+   - Genre (fantasy, urban, mystery, romance, etc.)
+   - Target platform
+   - World setting
+   - Protagonist
+   - Core conflict
+   - Writing language
+
+2. **Create book** — When you have enough info, call the sub_agent tool with agent="architect":
+   - Include all collected info in the instruction
+   - The architect will generate the complete foundation
+
+## Style
+
+- Ask one question at a time
+- Offer 2-3 concrete options when the user is vague
+- Proactively suggest creating the book when enough info is collected
+- Keep responses brief and natural
+- **Do NOT use emoji in your responses**`;
+  }
+
+  return isZh
+    ? `你是 InkOS 写作助手,当前正在处理书籍「${bookId}」。
+
+## 可用工具
+
+- **sub_agent** — 委托子智能体执行重操作:
+  - agent="writer" 写下一章
+  - agent="auditor" 审计章节质量
+  - agent="reviser" 修订章节
+  - agent="exporter" 导出书籍
+- **read** — 读取书籍的设定文件或章节内容
+- **edit** — 编辑设定文件(如修改角色名、调整世界观)
+- **grep** — 搜索内容(如"哪一章提到了某个角色")
+- **ls** — 列出文件或章节
+
+## 使用原则
+
+- 写章节、修订、审计等重操作 → 使用 sub_agent 委托对应子智能体
+- 用户问设定相关问题 → 先用 read 读取对应文件再回答
+- 用户想做小修改(改名字、调设定)→ 用 edit 直接修改
+- 其他情况 → 直接对话回答
+- **注意:不要调用 architect,当前已有书籍,不需要建书**
+- **不要在回复中添加表情符号**`
+    : `You are the InkOS writing assistant, working on book "${bookId}".
+
+## Available Tools
+
+- **sub_agent** — Delegate to sub-agents:
+  - agent="writer" for writing next chapter
+  - agent="auditor" for chapter quality audit
+  - agent="reviser" for chapter revision
+  - agent="exporter" for book export
+- **read** — Read truth files or chapter content
+- **edit** — Edit truth files (rename characters, adjust world settings)
+- **grep** — Search content across chapters
+- **ls** — List files or chapters
+
+## Guidelines
+
+- Use sub_agent for heavy operations (writing, revision, auditing)
+- Use read/edit for settings inquiries and small changes
+- Chat directly for other questions
+- **Do NOT call architect — a book already exists**
+- **Do NOT use emoji in your responses**`;
+}

+ 338 - 0
packages/core/src/agent/agent-tools.ts

@@ -0,0 +1,338 @@
+import { Type, type Static } from "@mariozechner/pi-ai";
+import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
+import type { PipelineRunner } from "../pipeline/runner.js";
+import type { ReviseMode } from "../agents/reviser.js";
+import { readFile, writeFile, readdir, stat } from "node:fs/promises";
+import { join, normalize, resolve } from "node:path";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function textResult(text: string): AgentToolResult<undefined> {
+  return { content: [{ type: "text", text }], details: undefined };
+}
+
+/**
+ * Resolve a user-supplied relative path against the books root and guard
+ * against path-traversal (../ etc.).
+ */
+function safeBooksPath(booksRoot: string, relativePath: string): string {
+  const resolved = resolve(booksRoot, normalize(relativePath));
+  if (!resolved.startsWith(booksRoot)) {
+    throw new Error(`Path traversal blocked: ${relativePath}`);
+  }
+  return resolved;
+}
+
+// ---------------------------------------------------------------------------
+// 1. SubAgentTool (sub_agent)
+// ---------------------------------------------------------------------------
+
+const SubAgentParams = Type.Object({
+  agent: Type.Union([
+    Type.Literal("architect"),
+    Type.Literal("writer"),
+    Type.Literal("auditor"),
+    Type.Literal("reviser"),
+    Type.Literal("exporter"),
+  ]),
+  instruction: Type.String({ description: "Natural language instruction from the main Agent" }),
+  bookId: Type.Optional(Type.String({ description: "Book ID — required for all agents except architect" })),
+});
+
+export function createSubAgentTool(pipeline: PipelineRunner, activeBookId: string | null): AgentTool<typeof SubAgentParams> {
+  return {
+    name: "sub_agent",
+    description:
+      "Delegate a heavy operation to a specialised sub-agent. " +
+      "Use agent='architect' to initialise a new book, 'writer' to write the next chapter, " +
+      "'auditor' to audit quality, 'reviser' to revise a chapter, 'exporter' to export.",
+    label: "Sub-Agent",
+    parameters: SubAgentParams,
+    async execute(
+      _toolCallId: string,
+      params: Static<typeof SubAgentParams>,
+      _signal?: AbortSignal,
+      onUpdate?: AgentToolUpdateCallback,
+    ): Promise<AgentToolResult<undefined>> {
+      const { agent, instruction, bookId } = params;
+
+      const progress = (msg: string) => {
+        onUpdate?.(textResult(msg));
+      };
+
+      try {
+        switch (agent) {
+          case "architect": {
+            // architect 只在没有书的时候可用(建书流程)
+            if (activeBookId) {
+              return textResult("当前已有书籍,不需要建书。如果你想创建新书,请先回到首页。");
+            }
+            const id = bookId || `book-${Date.now().toString(36)}`;
+            progress(`Starting architect for book "${id}"...`);
+            await pipeline.initBook(
+              { id, genre: "general", title: "", language: "zh" } as any,
+              { externalContext: instruction },
+            );
+            progress(`Architect finished — book "${id}" foundation created.`);
+            return textResult(`Book "${id}" initialised successfully. Foundation files are ready.`);
+          }
+
+          case "writer": {
+            if (!bookId) return textResult("Error: bookId is required for the writer agent.");
+            progress(`Writing next chapter for "${bookId}"...`);
+            const result = await pipeline.writeNextChapter(bookId);
+            progress(`Writer finished chapter for "${bookId}".`);
+            return textResult(
+              `Chapter written for "${bookId}". ` +
+              `Word count: ${(result as any).wordCount ?? "unknown"}.`,
+            );
+          }
+
+          case "auditor": {
+            if (!bookId) return textResult("Error: bookId is required for the auditor agent.");
+            progress(`Auditing draft for "${bookId}"...`);
+            const audit = await pipeline.auditDraft(bookId);
+            progress(`Audit complete for "${bookId}".`);
+            const issueCount = audit.issues?.length ?? 0;
+            return textResult(
+              `Audit complete for "${bookId}": ${issueCount} issue(s) found. ` +
+              `Chapter ${audit.chapterNumber}.`,
+            );
+          }
+
+          case "reviser": {
+            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}" in ${mode} mode...`);
+            await pipeline.reviseDraft(bookId, undefined, mode);
+            progress(`Revision complete for "${bookId}".`);
+            return textResult(`Revision (${mode}) complete for "${bookId}".`);
+          }
+
+          case "exporter": {
+            return textResult("Export is not yet implemented. Coming soon.");
+          }
+
+          default:
+            return textResult(`Unknown agent: ${agent}`);
+        }
+      } catch (err: any) {
+        console.error(`[sub_agent] "${agent}" failed:`, err);
+        return textResult(`Sub-agent "${agent}" failed: ${err?.message ?? String(err)}`);
+      }
+    },
+  };
+}
+
+// ---------------------------------------------------------------------------
+// 2. Read Tool
+// ---------------------------------------------------------------------------
+
+const ReadParams = Type.Object({
+  path: Type.String({ description: "File path relative to books/, e.g. {bookId}/story/story_bible.md" }),
+});
+
+export function createReadTool(projectRoot: string): AgentTool<typeof ReadParams> {
+  const booksRoot = join(projectRoot, "books");
+
+  return {
+    name: "read",
+    description: "Read a file from the book directory. Path is relative to books/.",
+    label: "Read File",
+    parameters: ReadParams,
+    async execute(
+      _toolCallId: string,
+      params: Static<typeof ReadParams>,
+    ): Promise<AgentToolResult<undefined>> {
+      try {
+        const filePath = safeBooksPath(booksRoot, params.path);
+        let content = await readFile(filePath, "utf-8");
+        if (content.length > 10_000) {
+          content = content.slice(0, 10_000) + "\n\n... [truncated at 10 000 chars]";
+        }
+        return textResult(content);
+      } catch (err: any) {
+        return textResult(`Failed to read "${params.path}": ${err?.message ?? String(err)}`);
+      }
+    },
+  };
+}
+
+// ---------------------------------------------------------------------------
+// 3. Edit Tool
+// ---------------------------------------------------------------------------
+
+const EditParams = Type.Object({
+  path: Type.String({ description: "File path relative to books/" }),
+  old_string: Type.String({ description: "Exact string to find in the file" }),
+  new_string: Type.String({ description: "Replacement string" }),
+});
+
+export function createEditTool(projectRoot: string): AgentTool<typeof EditParams> {
+  const booksRoot = join(projectRoot, "books");
+
+  return {
+    name: "edit",
+    description:
+      "Edit a file using exact string replacement. " +
+      "old_string must appear exactly once in the file. Path is relative to books/.",
+    label: "Edit File",
+    parameters: EditParams,
+    async execute(
+      _toolCallId: string,
+      params: Static<typeof EditParams>,
+    ): Promise<AgentToolResult<undefined>> {
+      try {
+        const filePath = safeBooksPath(booksRoot, params.path);
+        const content = await readFile(filePath, "utf-8");
+        const idx = content.indexOf(params.old_string);
+        if (idx === -1) {
+          return textResult(`old_string not found in "${params.path}".`);
+        }
+        if (content.indexOf(params.old_string, idx + 1) !== -1) {
+          return textResult(`old_string appears more than once in "${params.path}". Provide a more specific match.`);
+        }
+        const updated = content.slice(0, idx) + params.new_string + content.slice(idx + params.old_string.length);
+        await writeFile(filePath, updated, "utf-8");
+        return textResult(`File "${params.path}" updated successfully.`);
+      } catch (err: any) {
+        return textResult(`Failed to edit "${params.path}": ${err?.message ?? String(err)}`);
+      }
+    },
+  };
+}
+
+// ---------------------------------------------------------------------------
+// 4. Grep Tool
+// ---------------------------------------------------------------------------
+
+const GrepParams = Type.Object({
+  bookId: Type.String({ description: "Book ID to search within" }),
+  pattern: Type.String({ description: "Search pattern (plain text or regex)" }),
+});
+
+export function createGrepTool(projectRoot: string): AgentTool<typeof GrepParams> {
+  const booksRoot = join(projectRoot, "books");
+
+  return {
+    name: "grep",
+    description:
+      "Search for a text pattern across a book's story/ and chapters/ directories. Returns matching lines.",
+    label: "Search",
+    parameters: GrepParams,
+    async execute(
+      _toolCallId: string,
+      params: Static<typeof GrepParams>,
+    ): Promise<AgentToolResult<undefined>> {
+      try {
+        const bookDir = safeBooksPath(booksRoot, params.bookId);
+        const regex = new RegExp(params.pattern, "gi");
+        const results: string[] = [];
+
+        async function searchDir(dir: string, prefix: string) {
+          let entries: string[];
+          try {
+            entries = await readdir(dir);
+          } catch {
+            return; // directory doesn't exist
+          }
+          for (const entry of entries) {
+            const fullPath = join(dir, entry);
+            const entryStat = await stat(fullPath);
+            if (entryStat.isDirectory()) {
+              await searchDir(fullPath, `${prefix}${entry}/`);
+            } else if (entry.endsWith(".md") || entry.endsWith(".txt") || entry.endsWith(".json")) {
+              const content = await readFile(fullPath, "utf-8");
+              const lines = content.split("\n");
+              for (let i = 0; i < lines.length; i++) {
+                if (regex.test(lines[i])) {
+                  results.push(`${prefix}${entry}:${i + 1}: ${lines[i]}`);
+                  regex.lastIndex = 0; // reset for next test
+                }
+              }
+            }
+          }
+        }
+
+        await Promise.all([
+          searchDir(join(bookDir, "story"), "story/"),
+          searchDir(join(bookDir, "chapters"), "chapters/"),
+        ]);
+
+        if (results.length === 0) {
+          return textResult(`No matches for "${params.pattern}" in book "${params.bookId}".`);
+        }
+
+        const truncated = results.length > 100
+          ? results.slice(0, 100).join("\n") + `\n\n... [${results.length - 100} more matches]`
+          : results.join("\n");
+
+        return textResult(truncated);
+      } catch (err: any) {
+        return textResult(`Grep failed: ${err?.message ?? String(err)}`);
+      }
+    },
+  };
+}
+
+// ---------------------------------------------------------------------------
+// 5. Ls Tool
+// ---------------------------------------------------------------------------
+
+const LsParams = Type.Object({
+  bookId: Type.String({ description: "Book ID" }),
+  subdir: Type.Optional(
+    Type.String({ description: "Subdirectory within the book, e.g. 'story', 'chapters', 'story/runtime'" }),
+  ),
+});
+
+export function createLsTool(projectRoot: string): AgentTool<typeof LsParams> {
+  const booksRoot = join(projectRoot, "books");
+
+  return {
+    name: "ls",
+    description: "List files in a book directory. Optionally specify a subdirectory like 'story' or 'chapters'.",
+    label: "List Files",
+    parameters: LsParams,
+    async execute(
+      _toolCallId: string,
+      params: Static<typeof LsParams>,
+    ): Promise<AgentToolResult<undefined>> {
+      try {
+        const base = safeBooksPath(booksRoot, params.bookId);
+        const target = params.subdir ? safeBooksPath(base, params.subdir) : base;
+
+        const entries = await readdir(target);
+        const details: string[] = [];
+
+        for (const entry of entries) {
+          const fullPath = join(target, entry);
+          try {
+            const entryStat = await stat(fullPath);
+            const suffix = entryStat.isDirectory() ? "/" : ` (${entryStat.size} bytes)`;
+            details.push(`${entry}${suffix}`);
+          } catch {
+            details.push(entry);
+          }
+        }
+
+        if (details.length === 0) {
+          return textResult(`Directory is empty: ${params.bookId}/${params.subdir ?? ""}`);
+        }
+
+        return textResult(details.join("\n"));
+      } catch (err: any) {
+        return textResult(`Failed to list "${params.bookId}/${params.subdir ?? ""}": ${err?.message ?? String(err)}`);
+      }
+    },
+  };
+}

+ 3 - 0
packages/core/src/agent/index.ts

@@ -0,0 +1,3 @@
+export { buildAgentSystemPrompt } from "./agent-system-prompt.js";
+export { createSubAgentTool, createReadTool, createEditTool, createGrepTool, createLsTool } from "./agent-tools.js";
+export { runAgentSession, evictAgentCache, type AgentSessionConfig, type AgentSessionResult } from "./agent-session.js";

+ 5 - 5
packages/core/src/agents/architect.ts

@@ -279,7 +279,7 @@ ${finalRequirementsPrompt}`;
     const response = await this.chat([
       { role: "system", content: langPrefix + systemPrompt },
       { role: "user", content: userMessage },
-    ], { maxTokens: 16384, temperature: 0.8 });
+    ], { temperature: 0.8 });
 
     return this.parseSections(response.content);
   }
@@ -332,8 +332,8 @@ ${finalRequirementsPrompt}`;
       writeFile(
         join(storyDir, "character_matrix.md"),
         language === "en"
-          ? "# Character Matrix\n\n### Character Profiles\n| Character | Core Tags | Contrast Detail | Speech Style | Personality Core | Relationship to Protagonist | Core Motivation | Current Goal |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n\n### Encounter Log\n| Character A | Character B | First Meeting Chapter | Latest Interaction Chapter | Relationship Type | Relationship Change |\n| --- | --- | --- | --- | --- | --- |\n\n### Information Boundaries\n| Character | Known Information | Unknown Information | Source Chapter |\n| --- | --- | --- | --- |\n"
-          : "# 角色交互矩阵\n\n### 角色档案\n| 角色 | 核心标签 | 反差细节 | 说话风格 | 性格底色 | 与主角关系 | 核心动机 | 当前目标 |\n|------|----------|----------|----------|----------|------------|----------|----------|\n\n### 相遇记录\n| 角色A | 角色B | 首次相遇章 | 最近交互章 | 关系性质 | 关系变化 |\n|-------|-------|------------|------------|----------|----------|\n\n### 信息边界\n| 角色 | 已知信息 | 未知信息 | 信息来源章 |\n|------|----------|----------|------------|\n",
+          ? "# Character Matrix\n\n<!-- One ## section per character. Add new characters as new ## blocks. -->\n"
+          : "# 角色矩阵\n\n<!-- 每个角色一个 ## 块,新角色追加新 ## 即可。 -->\n",
         "utf-8",
       ),
     );
@@ -676,7 +676,7 @@ ${keyPrinciplesPrompt}`;
         role: "user",
         content: userMessage,
       },
-    ], { maxTokens: 16384, temperature: 0.5 });
+    ], { temperature: 0.5 });
 
     return this.parseSections(response.content);
   }
@@ -765,7 +765,7 @@ prohibitions:
         role: "user",
         content: `请为标题为"${book.title}"的${fanficMode}模式同人小说生成基础设定。目标${book.targetChapters}章,每章${book.chapterWordCount}字。`,
       },
-    ], { maxTokens: 16384, temperature: 0.7 });
+    ], { temperature: 0.7 });
 
     return this.parseSections(response.content);
   }

+ 32 - 4
packages/core/src/agents/chapter-analyzer.ts

@@ -169,7 +169,7 @@ export class ChapterAnalyzerAgent extends BaseAgent {
         { role: "system", content: systemPrompt },
         { role: "user", content: userPrompt },
       ],
-      { maxTokens: 16384, temperature: 0.3 },
+      { temperature: 0.3 },
     );
 
     const countingMode = resolveLengthCountingMode(book.language ?? genreProfile.language);
@@ -292,7 +292,21 @@ Updated subplot board (Markdown table)
 Updated emotional arcs (Markdown table)
 
 === UPDATED_CHARACTER_MATRIX ===
-Updated character interaction matrix (Markdown table)
+Updated character matrix (one ## section per character, bullet-list fields):
+
+## Character Name
+- **Role**: protagonist / antagonist / ally / minor / mentioned
+- **Tags**: core identity tags
+- **Contrast**: distinctive details that defy expectations
+- **Speech**: speaking style summary
+- **Personality**: core personality traits
+- **Motivation**: fundamental driving force
+- **Current**: immediate goal this chapter
+- **Relationships**: OtherChar(type/Ch#) | ...
+- **Known**: what this character knows (only witnessed or told)
+- **Unknown**: what this character does not know
+
+(Repeat for each character. Add new characters; keep existing ones updated.)
 
 ## Rules
 
@@ -385,14 +399,28 @@ ${bookRulesBody ? `## 本书规则\n\n${bookRulesBody}` : ""}
 更新后的情感弧线(Markdown表格)
 
 === UPDATED_CHARACTER_MATRIX ===
-更新后的角色交互矩阵(Markdown表格)
+更新后的角色矩阵(每个角色一个 ## 块,字段用 bullet list):
+
+## 角色名
+- **定位**: 主角 / 反派 / 盟友 / 配角 / 提及
+- **标签**: 核心身份标签
+- **反差**: 打破刻板印象的独特细节
+- **说话**: 说话风格概述
+- **性格**: 性格底色
+- **动机**: 根本驱动力
+- **当前**: 本章即时目标
+- **关系**: 某角色(关系性质/Ch#) | ...
+- **已知**: 该角色已知的信息(仅限亲历或被告知)
+- **未知**: 该角色不知道的信息
+
+(每个角色重复以上格式。新角色追加新 ## 块,已有角色做增量更新。)
 
 ## 关键规则
 
 1. 状态卡和伏笔池必须基于"当前追踪文件"做增量更新,不是从零开始
 2. 正文中的每一个事实性变化都必须反映在对应的追踪文件中
 3. 不要遗漏细节:数值变化、位置变化、关系变化、信息变化都要记录
-4. 角色交互矩阵中的"信息边界"要准确——角色只知道他在场时发生的事`;
+4. 角色矩阵中的"已知/未知"要准确——角色只知道他在场时发生的事`;
   }
 
   private buildUserPrompt(params: {

+ 1 - 1
packages/core/src/agents/consolidator.ts

@@ -96,7 +96,7 @@ export class ConsolidatorAgent extends BaseAgent {
           role: "user",
           content: `Volume: ${vol.name} (Chapters ${vol.startCh}-${vol.endCh})\n\nChapter summaries:\n${header}\n${volSummaryRows}`,
         },
-      ], { temperature: 0.3, maxTokens: 1024 });
+      ], { temperature: 0.3 });
 
       newSummaries.push(`\n## ${vol.name} (Ch.${vol.startCh}-${vol.endCh})\n\n${response.content.trim()}`);
     }

+ 1 - 1
packages/core/src/agents/continuity.ts

@@ -536,7 +536,7 @@ ${chapterContent}`;
       { role: "system" as const, content: systemPrompt },
       { role: "user" as const, content: userPrompt },
     ];
-    const chatOptions = { temperature: options?.temperature ?? 0.3, maxTokens: 8192 };
+    const chatOptions = { temperature: options?.temperature ?? 0.3 };
 
     // Use web search for fact verification when eraResearch is enabled
     const response = gp.eraResearch

+ 1 - 1
packages/core/src/agents/fanfic-canon-importer.ts

@@ -94,7 +94,7 @@ ${truncated ? "\n注意:原作素材过长,已截断。请基于已有部分
         { role: "system", content: systemPrompt },
         { role: "user", content: `以下是原作《${sourceName}》的素材:\n\n${text}` },
       ],
-      { maxTokens: 8192, temperature: 0.3 },
+      { temperature: 0.3 },
     );
 
     const content = response.content;

+ 1 - 1
packages/core/src/agents/foundation-reviewer.ts

@@ -47,7 +47,7 @@ export class FoundationReviewerAgent extends BaseAgent {
     const response = await this.chat([
       { role: "system", content: systemPrompt },
       { role: "user", content: userPrompt },
-    ], { maxTokens: 4096, temperature: 0.3 });
+    ], { temperature: 0.3 });
 
     return this.parseReviewResult(response.content, dimensions);
   }

+ 1 - 1
packages/core/src/agents/radar.ts

@@ -94,7 +94,7 @@ ${rankingsText}
           content: `请基于上面的实时排行榜数据,分析当前网文市场热度,给出开书建议。`,
         },
       ],
-      { temperature: 0.6, maxTokens: 4096 },
+      { temperature: 0.6 },
     );
 
     return this.parseResult(response.content);

+ 1 - 1
packages/core/src/agents/state-validator.ts

@@ -93,7 +93,7 @@ ${chapterContent.slice(0, 6000)}`;
           { role: "system", content: systemPrompt },
           { role: "user", content: userPrompt },
         ],
-        { temperature: 0.1, maxTokens: 2048 },
+        { temperature: 0.1 },
       );
 
       return this.parseResult(response.content);

+ 13 - 13
packages/core/src/agents/writer-prompts.ts

@@ -624,17 +624,17 @@ ${updatedLedger}
 |------|------|----------|----------|------------|----------|
 
 === UPDATED_CHARACTER_MATRIX ===
-(更新后的角色交互矩阵,分三个子表)
-
-### 角色档案
-| 角色 | 核心标签 | 反差细节 | 说话风格 | 性格底色 | 与主角关系 | 核心动机 | 当前目标 |
-|------|----------|----------|----------|----------|------------|----------|----------|
-
-### 相遇记录
-| 角色A | 角色B | 首次相遇章 | 最近交互章 | 关系性质 | 关系变化 |
-|-------|-------|------------|------------|----------|----------|
-
-### 信息边界
-| 角色 | 已知信息 | 未知信息 | 信息来源章 |
-|------|----------|----------|------------|`;
+(更新后的角色矩阵,每个角色一个 ## 块)
+
+## 角色名
+- **定位**: 主角 / 反派 / 盟友 / 配角 / 提及
+- **标签**: 核心身份标签
+- **反差**: 打破刻板印象的独特细节
+- **说话**: 说话风格概述
+- **性格**: 性格底色
+- **动机**: 根本驱动力
+- **当前**: 本章即时目标
+- **关系**: 某角色(关系性质/Ch#) | ...
+- **已知**: 该角色已知的信息(仅限亲历或被告知)
+- **未知**: 该角色不知道的信息`;
 }

+ 1 - 1
packages/core/src/agents/writer.ts

@@ -546,7 +546,7 @@ export class WriterAgent extends BaseAgent {
         { role: "system", content: observerSystem },
         { role: "user", content: observerUser },
       ],
-      { maxTokens: 4096, temperature: 0.5 },
+      { temperature: 0.5 },
     );
     const observations = observerResponse.content;
 

+ 23 - 0
packages/core/src/index.ts

@@ -90,10 +90,12 @@ export {
 } from "./interaction/events.js";
 export {
   BookCreationDraftSchema,
+  DraftRoundSchema,
   PendingDecisionSchema,
   InteractionMessageSchema,
   InteractionSessionSchema,
   type BookCreationDraft,
+  type DraftRound,
   type PendingDecision,
   type InteractionMessage,
   type InteractionSession,
@@ -104,6 +106,12 @@ export {
   updateCreationDraft,
   appendInteractionMessage,
   appendInteractionEvent,
+  BookSessionSchema,
+  GlobalSessionSchema,
+  type BookSession,
+  type GlobalSession,
+  createBookSession,
+  appendBookSessionMessage,
 } from "./interaction/session.js";
 export {
   resolveProjectSessionPath,
@@ -111,7 +119,10 @@ export {
   loadProjectSession,
   persistProjectSession,
   resolveSessionActiveBook,
+  loadGlobalSession,
+  persistGlobalSession,
 } from "./interaction/project-session-store.js";
+export { loadBookSession, persistBookSession, listBookSessions, findOrCreateBookSession } from "./interaction/book-session-store.js";
 export { routeInteractionRequest } from "./interaction/request-router.js";
 export {
   routeNaturalLanguageIntent,
@@ -140,9 +151,21 @@ export {
   type InteractionRuntimeTools,
   type InteractionRuntimeResult,
 } from "./interaction/runtime.js";
+export {
+  parseDraftDirectives,
+  createDirectiveStreamFilter,
+  type ParsedDraftResponse,
+} from "./interaction/draft-directive-parser.js";
+
+// Agent (pi-agent integration)
+export * from "./agent/index.js";
 
 // LLM
 export { createLLMClient, chatCompletion, chatWithTools, createStreamMonitor, PartialResponseError, type LLMClient, type LLMResponse, type LLMMessage, type ToolDefinition, type ToolCall, type AgentMessage, type ChatWithToolsResult, type StreamProgress, type OnStreamProgress } from "./llm/provider.js";
+export { SERVICE_PRESETS, SERVICE_TO_PI_PROVIDER, resolveServicePreset, guessServiceFromBaseUrl, listModelsForService, listServicesWithModelCount, type ServicePreset, type ModelInfo } from "./llm/service-presets.js";
+export { resolveServiceModel, type ResolvedModel } from "./llm/service-resolver.js";
+export { loadSecrets, saveSecrets, getServiceApiKey, type SecretsFile } from "./llm/secrets.js";
+export { migrateConfig, type MigrationResult } from "./llm/config-migration.js";
 
 // Agents
 export { BaseAgent, type AgentContext } from "./agents/base.js";

+ 78 - 0
packages/core/src/interaction/book-session-store.ts

@@ -0,0 +1,78 @@
+import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
+import { join } from "node:path";
+import { BookSessionSchema, createBookSession } from "./session.js";
+import type { BookSession } from "./session.js";
+
+const SESSIONS_DIR = ".inkos/sessions";
+
+function sessionsDir(projectRoot: string): string {
+  return join(projectRoot, SESSIONS_DIR);
+}
+
+function sessionPath(projectRoot: string, sessionId: string): string {
+  return join(sessionsDir(projectRoot), `${sessionId}.json`);
+}
+
+export async function loadBookSession(
+  projectRoot: string,
+  sessionId: string,
+): Promise<BookSession | null> {
+  try {
+    const raw = await readFile(sessionPath(projectRoot, sessionId), "utf-8");
+    return BookSessionSchema.parse(JSON.parse(raw));
+  } catch {
+    return null;
+  }
+}
+
+export async function persistBookSession(
+  projectRoot: string,
+  session: BookSession,
+): Promise<void> {
+  const dir = sessionsDir(projectRoot);
+  await mkdir(dir, { recursive: true });
+  await writeFile(
+    sessionPath(projectRoot, session.sessionId),
+    JSON.stringify(session, null, 2),
+  );
+}
+
+export async function listBookSessions(
+  projectRoot: string,
+  bookId: string | null,
+): Promise<ReadonlyArray<BookSession>> {
+  const dir = sessionsDir(projectRoot);
+  let files: string[];
+  try {
+    files = await readdir(dir);
+  } catch {
+    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);
+      }
+    } catch {
+      // skip corrupt files
+    }
+  }
+
+  return sessions.sort((a, b) => b.updatedAt - a.updatedAt);
+}
+
+export async function findOrCreateBookSession(
+  projectRoot: string,
+  bookId: string | null,
+): Promise<BookSession> {
+  const existing = await listBookSessions(projectRoot, bookId);
+  if (existing.length > 0) return existing[0];
+  const session = createBookSession(bookId);
+  await persistBookSession(projectRoot, session);
+  return session;
+}

+ 266 - 0
packages/core/src/interaction/draft-directive-parser.ts

@@ -0,0 +1,266 @@
+/**
+ * Draft directive parser — extracts structured form data from LLM output
+ * that uses markdown directive syntax (:::type{attrs}...:::).
+ *
+ * Used by both TUI (textContent) and Studio (raw + fields).
+ */
+
+// ---------------------------------------------------------------------------
+// Public types
+// ---------------------------------------------------------------------------
+
+export interface ParsedDraftResponse {
+  /** key → value extracted from directive blocks */
+  fields: Record<string, string>;
+  /** Raw text with all ::: directive blocks stripped (for TUI display) */
+  textContent: string;
+  /** Auto-generated turn summary, e.g. "确立了书名、世界观和主角" */
+  summary: string;
+  /** Original LLM output, untouched */
+  raw: string;
+}
+
+// ---------------------------------------------------------------------------
+// Attribute parsing
+// ---------------------------------------------------------------------------
+
+interface DirectiveAttrs {
+  type: string; // "field" | "pick" | "number" | "group"
+  key?: string;
+  label?: string;
+  fieldType?: string; // the `type` attribute on field directives
+}
+
+const DIRECTIVE_OPEN_RE = /^:::(field|pick|number|group)\{(.+)\}\s*$/;
+const DIRECTIVE_CLOSE_RE = /^:::\s*$/;
+const CODE_FENCE_RE = /^(`{3,}|~{3,})/;
+const LIST_ITEM_RE = /^-\s+(.+)$/;
+
+function parseAttrs(attrStr: string): Record<string, string> {
+  const attrs: Record<string, string> = {};
+  // Match key="value" or key='value'
+  const re = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
+  let m: RegExpExecArray | null;
+  while ((m = re.exec(attrStr)) !== null) {
+    attrs[m[1]!] = m[2] ?? m[3] ?? "";
+  }
+  return attrs;
+}
+
+function parseDirectiveOpen(line: string): DirectiveAttrs | null {
+  const m = DIRECTIVE_OPEN_RE.exec(line);
+  if (!m) return null;
+  const type = m[1]!;
+  const rawAttrs = parseAttrs(m[2]!);
+  return {
+    type,
+    key: rawAttrs["key"],
+    label: rawAttrs["label"],
+    fieldType: rawAttrs["type"],
+  };
+}
+
+// ---------------------------------------------------------------------------
+// State machine for full-text parsing
+// ---------------------------------------------------------------------------
+
+type ParserMode = "text" | "directive" | "codeblock";
+
+interface DirectiveFrame {
+  attrs: DirectiveAttrs;
+  contentLines: string[];
+}
+
+/**
+ * Parse raw LLM output containing markdown directive blocks.
+ *
+ * State machine:
+ *   text → directive (on :::type{...})
+ *   text → codeblock (on ``` or ~~~)
+ *   directive → text (on standalone :::)
+ *   directive → directive (on nested :::type{...} inside group)
+ *   codeblock → text (on matching fence close)
+ */
+export function parseDraftDirectives(raw: string): ParsedDraftResponse {
+  const lines = raw.split("\n");
+  const fields: Record<string, string> = {};
+  const labels: string[] = [];
+  const textLines: string[] = [];
+
+  let mode: ParserMode = "text";
+  let codeFenceMarker = "";
+  // Stack of open directives — supports nesting (group > field/number).
+  const stack: DirectiveFrame[] = [];
+
+  for (const line of lines) {
+    // --- Code-block handling (highest priority) ---
+    if (mode === "codeblock") {
+      textLines.push(line);
+      if (CODE_FENCE_RE.test(line) && line.trimStart().startsWith(codeFenceMarker)) {
+        mode = "text";
+        codeFenceMarker = "";
+      }
+      continue;
+    }
+
+    if (mode === "text") {
+      const fenceMatch = CODE_FENCE_RE.exec(line);
+      if (fenceMatch) {
+        codeFenceMarker = fenceMatch[1]!;
+        mode = "codeblock";
+        textLines.push(line);
+        continue;
+      }
+    }
+
+    // --- Directive close (standalone :::) ---
+    if (DIRECTIVE_CLOSE_RE.test(line) && stack.length > 0) {
+      const frame = stack.pop()!;
+      const { attrs, contentLines } = frame;
+
+      if (attrs.type !== "group" && attrs.key) {
+        const value = extractValue(attrs.type, contentLines);
+        fields[attrs.key] = value;
+        if (attrs.label) {
+          labels.push(attrs.label);
+        }
+      }
+
+      // If we just closed the last frame, we're back in text mode
+      if (stack.length === 0) {
+        mode = "text";
+      }
+      continue;
+    }
+
+    // --- Directive open ---
+    const directiveOpen = parseDirectiveOpen(line);
+    if (directiveOpen) {
+      stack.push({ attrs: directiveOpen, contentLines: [] });
+      mode = "directive";
+      continue;
+    }
+
+    // --- Inside a directive: collect content ---
+    if (mode === "directive" && stack.length > 0) {
+      stack[stack.length - 1]!.contentLines.push(line);
+      continue;
+    }
+
+    // --- Normal text ---
+    textLines.push(line);
+  }
+
+  return {
+    fields,
+    textContent: textLines.join("\n"),
+    summary: buildSummary(labels),
+    raw,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// Value extraction per directive type
+// ---------------------------------------------------------------------------
+
+function extractValue(type: string, contentLines: string[]): string {
+  if (type === "pick") {
+    // Extract first list item value
+    for (const line of contentLines) {
+      const m = LIST_ITEM_RE.exec(line.trim());
+      if (m) return m[1]!.trim();
+    }
+    return "";
+  }
+
+  // field, number — join all content lines, trim surrounding whitespace
+  return contentLines.join("\n").trim();
+}
+
+// ---------------------------------------------------------------------------
+// Summary builder
+// ---------------------------------------------------------------------------
+
+function buildSummary(labels: string[]): string {
+  if (labels.length === 0) return "";
+  if (labels.length === 1) return `确立了${labels[0]}`;
+  if (labels.length === 2) return `确立了${labels[0]}和${labels[1]}`;
+  // 3+: 确立了A、B和C
+  const allButLast = labels.slice(0, -1).join("、");
+  return `确立了${allButLast}和${labels[labels.length - 1]}`;
+}
+
+// ---------------------------------------------------------------------------
+// Streaming filter
+// ---------------------------------------------------------------------------
+
+/**
+ * Creates a stateful filter function for streaming LLM output.
+ * Text portions pass through immediately; directive blocks (:::...:::)
+ * are buffered and suppressed.
+ *
+ * Usage:
+ *   const filter = createDirectiveStreamFilter();
+ *   onChunk(chunk => { const visible = filter(chunk); display(visible); });
+ */
+export function createDirectiveStreamFilter(): (chunk: string) => string {
+  let depth = 0; // nesting depth of open directives
+  let inCodeBlock = false;
+  let codeFenceMarker = "";
+
+  return (chunk: string): string => {
+    const lines = chunk.split("\n");
+    const outputParts: string[] = [];
+
+    for (let i = 0; i < lines.length; i++) {
+      const line = lines[i]!;
+      const isLastLine = i === lines.length - 1;
+
+      // --- Code-block toggle ---
+      if (inCodeBlock) {
+        if (CODE_FENCE_RE.test(line) && line.trimStart().startsWith(codeFenceMarker)) {
+          inCodeBlock = false;
+          codeFenceMarker = "";
+        }
+        outputParts.push(line);
+        if (!isLastLine) outputParts.push("\n");
+        continue;
+      }
+
+      // Detect code-fence opening (only outside directives)
+      if (depth === 0) {
+        const fenceMatch = CODE_FENCE_RE.exec(line);
+        if (fenceMatch) {
+          inCodeBlock = true;
+          codeFenceMarker = fenceMatch[1]!;
+          outputParts.push(line);
+          if (!isLastLine) outputParts.push("\n");
+          continue;
+        }
+      }
+
+      // --- Directive open ---
+      if (parseDirectiveOpen(line)) {
+        depth++;
+        continue;
+      }
+
+      // --- Directive close ---
+      if (DIRECTIVE_CLOSE_RE.test(line) && depth > 0) {
+        depth--;
+        continue;
+      }
+
+      // --- Inside directive: suppress ---
+      if (depth > 0) {
+        continue;
+      }
+
+      // --- Normal text: pass through ---
+      outputParts.push(line);
+      if (!isLastLine) outputParts.push("\n");
+    }
+
+    return outputParts.join("");
+  };
+}

+ 23 - 1
packages/core/src/interaction/project-session-store.ts

@@ -1,6 +1,6 @@
 import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
 import { join } from "node:path";
-import { InteractionSessionSchema, type InteractionSession } from "./session.js";
+import { InteractionSessionSchema, type InteractionSession, GlobalSessionSchema, type GlobalSession } from "./session.js";
 
 const SESSION_DIR = ".inkos";
 const SESSION_FILE = "session.json";
@@ -36,6 +36,28 @@ export async function persistProjectSession(
   await writeFile(resolveProjectSessionPath(projectRoot), JSON.stringify(session, null, 2), "utf-8");
 }
 
+export async function loadGlobalSession(projectRoot: string): Promise<GlobalSession> {
+  try {
+    const raw = await readFile(join(projectRoot, SESSION_DIR, SESSION_FILE), "utf-8");
+    const data = JSON.parse(raw);
+    return GlobalSessionSchema.parse({
+      activeBookId: data.activeBookId,
+      automationMode: data.automationMode ?? "semi",
+    });
+  } catch {
+    return { automationMode: "semi" };
+  }
+}
+
+export async function persistGlobalSession(
+  projectRoot: string,
+  global: GlobalSession,
+): Promise<void> {
+  const dir = join(projectRoot, SESSION_DIR);
+  await mkdir(dir, { recursive: true });
+  await writeFile(join(dir, SESSION_FILE), JSON.stringify(global, null, 2));
+}
+
 export async function resolveSessionActiveBook(
   projectRoot: string,
   session: InteractionSession,

+ 175 - 120
packages/core/src/interaction/project-tools.ts

@@ -9,8 +9,9 @@ import type {
   LLMClient,
   BookConfig,
   Platform,
+  ToolDefinition,
 } from "../index.js";
-import { chatCompletion } from "../index.js";
+import { chatCompletion, chatWithTools } from "../index.js";
 import { executeEditTransaction } from "./edit-controller.js";
 import type { InteractionRuntimeTools } from "./runtime.js";
 import type { BookCreationDraft } from "./session.js";
@@ -45,84 +46,6 @@ function normalizePlatform(platform?: string): Platform {
   }
 }
 
-function extractBalancedJsonObject(text: string): string | null {
-  const start = text.indexOf("{");
-  if (start < 0) {
-    return null;
-  }
-
-  let depth = 0;
-  let inString = false;
-  let escaped = false;
-
-  for (let index = start; index < text.length; index += 1) {
-    const char = text[index]!;
-
-    if (inString) {
-      if (escaped) {
-        escaped = false;
-        continue;
-      }
-      if (char === "\\") {
-        escaped = true;
-        continue;
-      }
-      if (char === "\"") {
-        inString = false;
-      }
-      continue;
-    }
-
-    if (char === "\"") {
-      inString = true;
-      continue;
-    }
-
-    if (char === "{") {
-      depth += 1;
-      continue;
-    }
-
-    if (char === "}") {
-      depth -= 1;
-      if (depth === 0) {
-        return text.slice(start, index + 1);
-      }
-      if (depth < 0) {
-        return null;
-      }
-    }
-  }
-
-  return null;
-}
-
-function parseCreationDraftResult(text: string): {
-  readonly assistantReply: string;
-  readonly draft: BookCreationDraft;
-} | null {
-  const candidate = extractBalancedJsonObject(text);
-  if (!candidate) {
-    return null;
-  }
-
-  try {
-    const parsed = JSON.parse(candidate) as {
-      assistantReply?: string;
-      draft?: BookCreationDraft;
-    };
-    if (!parsed.assistantReply || !parsed.draft) {
-      return null;
-    }
-    return {
-      assistantReply: parsed.assistantReply,
-      draft: parsed.draft,
-    };
-  } catch {
-    return null;
-  }
-}
-
 function deriveBookId(title: string): string {
   return title
     .toLowerCase()
@@ -437,11 +360,142 @@ async function withPipelineInteractionTelemetry<T extends { chapterNumber?: numb
   }
 }
 
+const CREATE_BOOK_TOOL: ToolDefinition = {
+  name: "create_book",
+  description: "根据用户描述生成建书参数。系统会将参数渲染为可编辑表单,用户确认后建书。",
+  parameters: {
+    type: "object",
+    properties: {
+      title: { type: "string", description: "书名" },
+      genre: { type: "string", description: "题材标识,如 xuanhuan, urban, romance, scifi, mystery" },
+      platform: { type: "string", enum: ["tomato", "qidian", "feilu", "other"], description: "发布平台" },
+      targetChapters: { type: "number", description: "目标章数,默认 200" },
+      chapterWordCount: { type: "number", description: "每章字数,默认 3000" },
+      language: { type: "string", enum: ["zh", "en"], description: "写作语言,默认 zh" },
+      brief: { type: "string", description: "创意简述,会传给 Architect 智能体生成完整的世界观、主角、冲突等 foundation 文件。把用户提到的所有创意要素都写进这里。" },
+    },
+    required: ["title", "genre", "platform", "brief"],
+  },
+};
+
+const BOOK_DRAFT_SYSTEM_PROMPT = [
+  "你是 InkOS 的建书助手。用户会描述想写的书,你需要调用 create_book 工具来生成建书参数。",
+  "",
+  "规则:",
+  "1. 从用户描述中推断所有字段,大胆预填合理默认值。",
+  "2. brief 字段要详细——它会传给 Architect 智能体生成完整的世界观、主角、冲突等 foundation 文件。把用户提到的所有创意要素都写进 brief。",
+  "3. 如果用户后续要求修改某些字段,重新调用 create_book 工具,只更新被提到的字段,其余保持不变。",
+  "4. 不要只回复文字讨论——必须调用 create_book 工具输出结构化参数。",
+].join("\n");
+
+/** Map directive field keys to BookCreationDraft property names. */
+function applyFieldsToDraft(
+  existing: BookCreationDraft | undefined,
+  fields: Readonly<Record<string, string>>,
+  concept: string,
+): BookCreationDraft {
+  const draft: BookCreationDraft = {
+    concept,
+    missingFields: [],
+    readyToCreate: false,
+    ...(existing ?? {}),
+  };
+
+  for (const [key, value] of Object.entries(fields)) {
+    if (!value) continue;
+
+    switch (key) {
+      case "title":
+        draft.title = value;
+        break;
+      case "genre":
+        draft.genre = value;
+        break;
+      case "platform":
+        draft.platform = value;
+        break;
+      case "language":
+        if (value === "zh" || value === "en") draft.language = value;
+        break;
+      case "targetChapters": {
+        const n = parseInt(value, 10);
+        if (!Number.isNaN(n) && n > 0) draft.targetChapters = n;
+        break;
+      }
+      case "chapterWordCount":
+      case "chapterLength": {
+        const n = parseInt(value, 10);
+        if (!Number.isNaN(n) && n > 0) draft.chapterWordCount = n;
+        break;
+      }
+      case "blurb":
+        draft.blurb = value;
+        break;
+      case "worldPremise":
+        draft.worldPremise = value;
+        break;
+      case "settingNotes":
+        draft.settingNotes = value;
+        break;
+      case "protagonist":
+        draft.protagonist = value;
+        break;
+      case "supportingCast":
+        draft.supportingCast = value;
+        break;
+      case "conflictCore":
+        draft.conflictCore = value;
+        break;
+      case "volumeOutline":
+        draft.volumeOutline = value;
+        break;
+      case "constraints":
+        draft.constraints = value;
+        break;
+      case "authorIntent":
+        draft.authorIntent = value;
+        break;
+      case "currentFocus":
+        draft.currentFocus = value;
+        break;
+      // Unknown keys are silently ignored — the LLM may emit
+      // application-level keys we don't map to the draft struct.
+    }
+  }
+
+  return draft;
+}
+
+function formatDraftForUserMessage(
+  existingDraft: BookCreationDraft | undefined,
+  userMessage: string,
+): string {
+  const parts: string[] = [];
+
+  if (existingDraft) {
+    parts.push("## 当前草案状态");
+    const entries = Object.entries(existingDraft).filter(
+      ([, v]) => v !== undefined && v !== "" && !(Array.isArray(v) && v.length === 0),
+    );
+    for (const [key, value] of entries) {
+      parts.push(`- **${key}**: ${typeof value === "object" ? JSON.stringify(value) : String(value)}`);
+    }
+    parts.push("");
+  }
+
+  parts.push("## 用户输入");
+  parts.push(userMessage);
+
+  return parts.join("\n");
+}
+
 export function createInteractionToolsFromDeps(
   pipeline: PipelineLike,
   state: StateLike,
   hooks?: {
     readonly onChatTextDelta?: (text: string) => void;
+    readonly onDraftTextDelta?: (text: string) => void;
+    readonly onDraftRawDelta?: (text: string) => void;
     readonly getChatRequestOptions?: () => {
       readonly temperature?: number;
       readonly maxTokens?: number;
@@ -453,70 +507,71 @@ export function createInteractionToolsFromDeps(
   return {
     listBooks: () => state.listBooks(),
     developBookDraft: async (input, existingDraft) => {
+      const concept = existingDraft?.concept ?? input;
+
       if (!instrumentedPipeline.config?.client || !instrumentedPipeline.config?.model) {
-        const concept = existingDraft?.concept ?? input;
+        // Fallback: no LLM configured
         return {
           __interaction: {
-            responseText: "先把这本书的大概方向收住。你更想写长篇连载,还是十来章能收住的版本?",
+            responseText: "请先配置 LLM 模型,然后再创建书籍。",
             details: {
               creationDraft: {
                 concept,
-                title: existingDraft?.title,
-                genre: existingDraft?.genre,
-                platform: existingDraft?.platform,
-                language: existingDraft?.language,
-                targetChapters: existingDraft?.targetChapters,
-                chapterWordCount: existingDraft?.chapterWordCount,
-                blurb: existingDraft?.blurb,
-                authorIntent: existingDraft?.authorIntent,
-                currentFocus: existingDraft?.currentFocus,
-                nextQuestion: "你更想写长篇连载,还是十来章能收住的版本?",
-                missingFields: existingDraft?.missingFields ?? ["title", "genre", "targetChapters"],
-                readyToCreate: existingDraft?.readyToCreate ?? false,
-              } satisfies BookCreationDraft,
+                missingFields: ["title", "genre", "targetChapters"],
+                readyToCreate: false,
+              },
             },
           },
         };
       }
 
-      const response = await chatCompletion(
+      // Build messages - include existing draft context if present
+      const userContent = existingDraft
+        ? `当前草案参数:${JSON.stringify(existingDraft, null, 2)}\n\n用户输入:${input}`
+        : input;
+
+      const result = await chatWithTools(
         instrumentedPipeline.config.client,
         instrumentedPipeline.config.model,
         [
-          {
-            role: "system",
-            content: [
-              "You are InkOS book ideation assistant.",
-              "Turn the user's latest message and the current draft into a tighter book creation draft.",
-              "Ask at most one sharp next question.",
-              "Default to concise Chinese unless the draft language is clearly English.",
-              "Return JSON only with keys assistantReply and draft.",
-              "draft must include concept and may include title, genre, platform, language, targetChapters, chapterWordCount, blurb, worldPremise, settingNotes, protagonist, supportingCast, conflictCore, volumeOutline, constraints, authorIntent, currentFocus, nextQuestion, missingFields, readyToCreate.",
-              "Help the user decide and revise worldview, setting, protagonist, supporting cast, core conflict, blurb, and volume direction.",
-              "Be conservative: only mark readyToCreate=true when the draft already has a workable title, genre, targetChapters, chapterWordCount, and enough setting/conflict detail to generate a foundation.",
-            ].join(" "),
-          },
-          {
-            role: "user",
-            content: JSON.stringify({
-              currentDraft: existingDraft ?? null,
-              latestMessage: input,
-            }, null, 2),
-          },
+          { role: "system", content: BOOK_DRAFT_SYSTEM_PROMPT },
+          { role: "user", content: userContent },
         ],
+        [CREATE_BOOK_TOOL],
         { temperature: 0.4 },
       );
 
-      const parsed = parseCreationDraftResult(response.content);
-      if (!parsed) {
-        throw new Error("Book draft assistant returned invalid JSON.");
+      // Extract tool call if present
+      const toolCall = result.toolCalls[0];
+      let parsedArgs: Record<string, unknown> = {};
+      if (toolCall) {
+        try {
+          parsedArgs = JSON.parse(toolCall.arguments);
+        } catch {
+          // If parsing fails, use empty args
+        }
       }
 
+      // Build a draft from tool call arguments
+      const draft: BookCreationDraft = {
+        concept,
+        title: (parsedArgs.title as string) ?? existingDraft?.title,
+        genre: (parsedArgs.genre as string) ?? existingDraft?.genre,
+        platform: (parsedArgs.platform as string) ?? existingDraft?.platform,
+        language: (parsedArgs.language as "zh" | "en") ?? existingDraft?.language,
+        targetChapters: (parsedArgs.targetChapters as number) ?? existingDraft?.targetChapters,
+        chapterWordCount: (parsedArgs.chapterWordCount as number) ?? existingDraft?.chapterWordCount,
+        blurb: (parsedArgs.brief as string) ?? existingDraft?.blurb,
+        missingFields: [],
+        readyToCreate: Boolean(parsedArgs.title && parsedArgs.genre && parsedArgs.platform),
+      };
+
       return {
         __interaction: {
-          responseText: parsed.assistantReply,
+          responseText: result.content || "已生成建书参数,请确认或修改。",
           details: {
-            creationDraft: parsed.draft,
+            creationDraft: draft,
+            toolCall: toolCall ? { name: toolCall.name, arguments: parsedArgs } : undefined,
           },
         },
       };

+ 15 - 2
packages/core/src/interaction/runtime.ts

@@ -2,7 +2,7 @@ import type { AutomationMode } from "./modes.js";
 import { routeInteractionRequest } from "./request-router.js";
 import type { InteractionRequest } from "./intents.js";
 import type { ExecutionState, InteractionEvent } from "./events.js";
-import type { PendingDecision, InteractionSession } from "./session.js";
+import type { PendingDecision, InteractionSession, DraftRound } from "./session.js";
 import {
   appendInteractionEvent,
   bindActiveBook,
@@ -380,7 +380,20 @@ async function handleDraftLifecycleRequest(params: {
           en: "Book-draft tool did not return draft data.",
         }));
       }
-      const nextSession = appendToolEvents(updateCreationDraft(session, draft), metadata.events);
+      const newRound: DraftRound = {
+        roundId: (session.draftRounds?.length ?? 0) + 1,
+        userMessage: request.instruction ?? "",
+        assistantRaw: metadata.details?.draftRaw as string ?? "",
+        fieldsUpdated: (metadata.details?.fieldsUpdated as string[]) ?? [],
+        summary: metadata.details?.draftSummary as string ?? "",
+        timestamp: Date.now(),
+      };
+      const withDraft = updateCreationDraft(session, draft);
+      const withRounds = {
+        ...withDraft,
+        draftRounds: [...(withDraft.draftRounds ?? []), newRound],
+      };
+      const nextSession = appendToolEvents(withRounds, metadata.events);
       const completed = {
         ...markCompleted(nextSession),
         currentExecution: metadata.currentExecution ?? markCompleted(nextSession).currentExecution,

+ 87 - 0
packages/core/src/interaction/session.ts

@@ -11,9 +11,34 @@ export const PendingDecisionSchema = z.object({
 
 export type PendingDecision = z.infer<typeof PendingDecisionSchema>;
 
+export const PipelineStageSchema = z.object({
+  label: z.string(),
+  status: z.enum(["pending", "active", "completed"]),
+});
+
+export type PipelineStage = z.infer<typeof PipelineStageSchema>;
+
+export const ToolExecutionSchema = z.object({
+  id: z.string(),
+  tool: z.string(),
+  agent: z.string().optional(),
+  label: z.string(),
+  status: z.enum(["running", "processing", "completed", "error"]),
+  args: z.record(z.unknown()).optional(),
+  result: z.string().optional(),
+  error: z.string().optional(),
+  stages: z.array(PipelineStageSchema).optional(),
+  startedAt: z.number(),
+  completedAt: z.number().optional(),
+});
+
+export type ToolExecution = z.infer<typeof ToolExecutionSchema>;
+
 export const InteractionMessageSchema = z.object({
   role: z.enum(["user", "assistant", "system"]),
   content: z.string().min(1),
+  thinking: z.string().optional(),
+  toolExecutions: z.array(ToolExecutionSchema).optional(),
   timestamp: z.number().int().nonnegative(),
 });
 
@@ -44,12 +69,24 @@ export const BookCreationDraftSchema = z.object({
 
 export type BookCreationDraft = z.infer<typeof BookCreationDraftSchema>;
 
+export const DraftRoundSchema = z.object({
+  roundId: z.number().int().min(1),
+  userMessage: z.string(),
+  assistantRaw: z.string(),
+  fieldsUpdated: z.array(z.string()).default([]),
+  summary: z.string().default(""),
+  timestamp: z.number().int().nonnegative(),
+});
+
+export type DraftRound = z.infer<typeof DraftRoundSchema>;
+
 export const InteractionSessionSchema = z.object({
   sessionId: z.string().min(1),
   projectRoot: z.string().min(1),
   activeBookId: z.string().min(1).optional(),
   activeChapterNumber: z.number().int().min(1).optional(),
   creationDraft: BookCreationDraftSchema.optional(),
+  draftRounds: z.array(DraftRoundSchema).default([]),
   automationMode: AutomationModeSchema.default("semi"),
   messages: z.array(InteractionMessageSchema).default([]),
   events: z.array(InteractionEventSchema).default([]),
@@ -59,6 +96,55 @@ export const InteractionSessionSchema = z.object({
 
 export type InteractionSession = z.infer<typeof InteractionSessionSchema>;
 
+// -- Per-book session --
+
+export const BookSessionSchema = z.object({
+  sessionId: z.string().min(1),
+  bookId: z.string().nullable(),
+  messages: z.array(InteractionMessageSchema).default([]),
+  creationDraft: BookCreationDraftSchema.optional(),
+  draftRounds: z.array(DraftRoundSchema).default([]),
+  events: z.array(InteractionEventSchema).default([]),
+  currentExecution: ExecutionStateSchema.optional(),
+  createdAt: z.number().int().nonnegative(),
+  updatedAt: z.number().int().nonnegative(),
+});
+
+export type BookSession = z.infer<typeof BookSessionSchema>;
+
+// -- Global session (simplified) --
+
+export const GlobalSessionSchema = z.object({
+  activeBookId: z.string().min(1).optional(),
+  automationMode: AutomationModeSchema.default("semi"),
+});
+
+export type GlobalSession = z.infer<typeof GlobalSessionSchema>;
+
+export function createBookSession(bookId: string | null): BookSession {
+  const now = Date.now();
+  return {
+    sessionId: `${now}-${Math.random().toString(36).slice(2, 8)}`,
+    bookId,
+    messages: [],
+    draftRounds: [],
+    events: [],
+    createdAt: now,
+    updatedAt: now,
+  };
+}
+
+export function appendBookSessionMessage(
+  session: BookSession,
+  message: InteractionMessage,
+): BookSession {
+  return {
+    ...session,
+    messages: [...session.messages, message].sort((a, b) => a.timestamp - b.timestamp),
+    updatedAt: Date.now(),
+  };
+}
+
 export function bindActiveBook(
   session: InteractionSession,
   bookId: string,
@@ -100,6 +186,7 @@ export function clearCreationDraft(session: InteractionSession): InteractionSess
   return {
     ...session,
     creationDraft: undefined,
+    draftRounds: [],
   };
 }
 

+ 58 - 0
packages/core/src/llm/config-migration.ts

@@ -0,0 +1,58 @@
+import { readFile, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { saveSecrets, loadSecrets } from "./secrets.js";
+import { guessServiceFromBaseUrl } from "./service-presets.js";
+
+export interface MigrationResult {
+  migrated: boolean;
+}
+
+export async function migrateConfig(projectRoot: string): Promise<MigrationResult> {
+  const configPath = join(projectRoot, "inkos.json");
+  let raw: string;
+  try {
+    raw = await readFile(configPath, "utf-8");
+  } catch {
+    return { migrated: false };
+  }
+
+  const config = JSON.parse(raw);
+  const llm = config.llm;
+  if (!llm) return { migrated: false };
+
+  // Already new format
+  if (Array.isArray(llm.services)) return { migrated: false };
+
+  // Old format: llm.provider, llm.model, llm.baseUrl, llm.apiKey
+  const { provider, model, baseUrl, apiKey, ...restLlm } = llm;
+  if (!model && !provider) return { migrated: false };
+
+  // Determine service from baseUrl
+  const guessedService = baseUrl ? guessServiceFromBaseUrl(baseUrl) : null;
+  const service = guessedService ?? "custom";
+
+  // Build new service entry
+  const serviceEntry: Record<string, string> = { service };
+  if (service === "custom") {
+    serviceEntry.name = "Custom";
+    if (baseUrl) serviceEntry.baseUrl = baseUrl;
+  }
+
+  // Write new config (no apiKey)
+  config.llm = {
+    ...restLlm,
+    services: [serviceEntry],
+    defaultModel: model,
+  };
+  await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
+
+  // Move apiKey to secrets
+  if (apiKey) {
+    const secrets = await loadSecrets(projectRoot);
+    const secretKey = service === "custom" ? `custom:${serviceEntry.name}` : service;
+    secrets.services[secretKey] = { apiKey };
+    await saveSecrets(projectRoot, secrets);
+  }
+
+  return { migrated: true };
+}

+ 177 - 660
packages/core/src/llm/provider.ts

@@ -1,6 +1,18 @@
-import OpenAI from "openai";
-import Anthropic from "@anthropic-ai/sdk";
 import type { LLMConfig } from "../models/project.js";
+import {
+  streamSimple as piStreamSimple,
+  stream as piStream,
+} from "@mariozechner/pi-ai";
+import type {
+  Api as PiApi,
+  Model as PiModel,
+  Context as PiContext,
+  AssistantMessageEvent,
+  Tool as PiTool,
+  TextContent as PiTextContent,
+  ToolCall as PiToolCall,
+} from "@mariozechner/pi-ai";
+import { resolveServicePreset } from "./service-presets.js";
 
 // === Streaming Monitor Types ===
 
@@ -73,8 +85,8 @@ export interface LLMClient {
   readonly provider: "openai" | "anthropic";
   readonly apiFormat: "chat" | "responses";
   readonly stream: boolean;
-  readonly _openai?: OpenAI;
-  readonly _anthropic?: Anthropic;
+  readonly _piModel?: PiModel<PiApi>;
+  readonly _apiKey?: string;
   readonly defaults: {
     readonly temperature: number;
     readonly maxTokens: number;
@@ -123,28 +135,34 @@ export function createLLMClient(config: LLMConfig): LLMClient {
   const apiFormat = config.apiFormat ?? "chat";
   const stream = config.stream ?? true;
 
-  if (config.provider === "anthropic") {
-    // Anthropic SDK appends /v1/ internally — strip if user included it
-    const baseURL = config.baseUrl.replace(/\/v1\/?$/, "");
-    return {
-      provider: "anthropic",
-      apiFormat,
-      stream,
-      _anthropic: new Anthropic({ apiKey: config.apiKey, baseURL }),
-      defaults,
-    };
-  }
-  // openai or custom — both use OpenAI SDK
+  // --- Build pi-ai Model object ---
+  const serviceName = config.service ?? "custom";
+  const preset = resolveServicePreset(serviceName);
+  const piApi = (preset?.api ?? "openai-completions") as PiApi;
+  const baseUrl = config.baseUrl || preset?.baseUrl || "";
   const extraHeaders = config.headers ?? parseEnvHeaders();
+
+  const piModel: PiModel<PiApi> = {
+    id: config.model,
+    name: config.model,
+    api: piApi,
+    provider: serviceName,
+    baseUrl,
+    reasoning: (config.thinkingBudget ?? 0) > 0,
+    input: ["text"] as ("text" | "image")[],
+    cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+    contextWindow: 128_000,
+    maxTokens: config.maxTokens ?? 8192,
+    ...(extraHeaders ? { headers: extraHeaders } : {}),
+  };
+
+  const provider = config.provider === "anthropic" ? "anthropic" : "openai";
   return {
-    provider: "openai",
+    provider,
     apiFormat,
     stream,
-    _openai: new OpenAI({
-      apiKey: config.apiKey,
-      baseURL: config.baseUrl,
-      ...(extraHeaders ? { defaultHeaders: extraHeaders } : {}),
-    }),
+    _piModel: piModel,
+    _apiKey: config.apiKey,
     defaults,
   };
 }
@@ -273,23 +291,6 @@ function wrapLLMError(error: unknown, context?: { readonly baseUrl?: string; rea
   return error instanceof Error ? error : new Error(msg);
 }
 
-function wrapStreamRequiredError(
-  streamError: unknown,
-  syncError: unknown,
-  context?: { readonly baseUrl?: string; readonly model?: string },
-): Error {
-  const ctxLine = context
-    ? `\n  (baseUrl: ${context.baseUrl}, model: ${context.model})`
-    : "";
-  return new Error(
-    `API 提供方要求使用流式请求(stream:true),不能回退到同步模式。` +
-    `\n  这次失败不是模型名错误,而是前一次流式请求先失败了,随后同步回退又被提供方拒绝。` +
-    `\n  建议:保持 stream:true,并检查该提供方/代理的 SSE 流是否稳定。` +
-    `\n  原始流式错误:${String(streamError)}` +
-    `\n  同步回退错误:${String(syncError)}${ctxLine}`,
-  );
-}
-
 // === Simple Chat (used by all agents via BaseAgent.chat()) ===
 
 export async function chatCompletion(
@@ -316,22 +317,10 @@ export async function chatCompletion(
   };
   const onStreamProgress = options?.onStreamProgress;
   const onTextDelta = options?.onTextDelta;
-  const errorCtx = { baseUrl: client._openai?.baseURL ?? "(anthropic)", model };
+  const errorCtx = { baseUrl: client._piModel?.baseUrl ?? "(unknown)", model };
 
   try {
-    if (client.provider === "anthropic") {
-      return client.stream
-        ? await chatCompletionAnthropic(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget, onStreamProgress, onTextDelta)
-        : await chatCompletionAnthropicSync(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget, onTextDelta);
-    }
-    if (client.apiFormat === "responses") {
-      return client.stream
-        ? await chatCompletionOpenAIResponses(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress, onTextDelta)
-        : await chatCompletionOpenAIResponsesSync(client._openai!, model, messages, resolved, options?.webSearch, onTextDelta);
-    }
-    return client.stream
-      ? await chatCompletionOpenAIChat(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress, onTextDelta)
-      : await chatCompletionOpenAIChatSync(client._openai!, model, messages, resolved, options?.webSearch, onTextDelta);
+    return await chatCompletionViaPiAi(client, model, messages, resolved, onStreamProgress, onTextDelta);
   } catch (error) {
     // Stream interrupted but partial content is usable — return truncated response
     if (error instanceof PartialResponseError) {
@@ -340,59 +329,10 @@ export async function chatCompletion(
         usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
       };
     }
-
-    // Auto-fallback: if streaming failed, retry with sync (many proxies don't support SSE)
-    if (client.stream) {
-      const isStreamRelated = isLikelyStreamError(error);
-      if (isStreamRelated) {
-        try {
-          if (client.provider === "anthropic") {
-            return await chatCompletionAnthropicSync(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget);
-          }
-          if (client.apiFormat === "responses") {
-            return await chatCompletionOpenAIResponsesSync(client._openai!, model, messages, resolved, options?.webSearch);
-          }
-          return await chatCompletionOpenAIChatSync(client._openai!, model, messages, resolved, options?.webSearch);
-        } catch (syncError) {
-          if (isStreamRequiredError(syncError)) {
-            throw wrapStreamRequiredError(error, syncError, errorCtx);
-          }
-          throw wrapLLMError(syncError, errorCtx);
-        }
-      }
-    }
-
     throw wrapLLMError(error, errorCtx);
   }
 }
 
-function isLikelyStreamError(error: unknown): boolean {
-  const msg = String(error).toLowerCase();
-  // Common indicators that streaming specifically is the problem:
-  // - SSE parse errors, chunked transfer issues, content-type mismatches
-  // - Some proxies return 400/415 when stream=true
-  // - "stream" mentioned in error, or generic network errors during streaming
-  return (
-    msg.includes("stream") ||
-    msg.includes("text/event-stream") ||
-    msg.includes("chunked") ||
-    msg.includes("unexpected end") ||
-    msg.includes("premature close") ||
-    msg.includes("terminated") ||
-    msg.includes("econnreset") ||
-    (msg.includes("400") && !msg.includes("content"))
-  );
-}
-
-function isStreamRequiredError(error: unknown): boolean {
-  const msg = String(error).toLowerCase();
-  return (
-    msg.includes("stream must be set to true") ||
-    (msg.includes("stream") && msg.includes("must be set to true")) ||
-    (msg.includes("stream") && msg.includes("required"))
-  );
-}
-
 // === Tool-calling Chat (used by agent loop) ===
 
 export async function chatWithTools(
@@ -413,460 +353,158 @@ export async function chatWithTools(
       ),
       maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
     };
-    // Tool-calling always uses streaming (only used by agent loop, not by writer/auditor)
-    if (client.provider === "anthropic") {
-      return await chatWithToolsAnthropic(client._anthropic!, model, messages, tools, resolved, client.defaults.thinkingBudget);
-    }
-    if (client.apiFormat === "responses") {
-      return await chatWithToolsOpenAIResponses(client._openai!, model, messages, tools, resolved);
-    }
-    return await chatWithToolsOpenAIChat(client._openai!, model, messages, tools, resolved);
+    return await chatWithToolsViaPiAi(client, model, messages, tools, resolved);
   } catch (error) {
     throw wrapLLMError(error);
   }
 }
 
-// === OpenAI Chat Completions API Implementation (default) ===
-
-async function chatCompletionOpenAIChat(
-  client: OpenAI,
-  model: string,
-  messages: ReadonlyArray<LLMMessage>,
-  options: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
-  webSearch?: boolean,
-  onStreamProgress?: OnStreamProgress,
-  onTextDelta?: (text: string) => void,
-): Promise<LLMResponse> {
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  const createParams: any = {
-    model,
-    messages: messages.map((m) => ({ role: m.role, content: m.content })),
-    temperature: options.temperature,
-    max_tokens: options.maxTokens,
-    stream: true,
-    ...(webSearch ? { web_search_options: { search_context_size: "medium" as const } } : {}),
-    ...stripReservedKeys(options.extra),
-  };
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  const stream = await client.chat.completions.create(createParams) as any;
-
-  const chunks: string[] = [];
-  let inputTokens = 0;
-  let outputTokens = 0;
-  const monitor = createStreamMonitor(onStreamProgress);
-
-  try {
-    for await (const chunk of stream) {
-      const delta = chunk.choices[0]?.delta?.content;
-      if (delta) {
-        chunks.push(delta);
-        monitor.onChunk(delta);
-        onTextDelta?.(delta);
-      }
-      if (chunk.usage) {
-        inputTokens = chunk.usage.prompt_tokens ?? 0;
-        outputTokens = chunk.usage.completion_tokens ?? 0;
-      }
-    }
-  } catch (streamError) {
-    monitor.stop();
-    const partial = chunks.join("");
-    if (partial.length >= MIN_SALVAGEABLE_CHARS) {
-      throw new PartialResponseError(partial, streamError);
-    }
-    throw streamError;
-  } finally {
-    monitor.stop();
-  }
-
-  const content = chunks.join("");
-  if (!content) throw new Error("LLM returned empty response from stream");
-
-  return {
-    content,
-    usage: {
-      promptTokens: inputTokens,
-      completionTokens: outputTokens,
-      totalTokens: inputTokens + outputTokens,
-    },
-  };
-}
-
-async function chatCompletionOpenAIChatSync(
-  client: OpenAI,
-  model: string,
-  messages: ReadonlyArray<LLMMessage>,
-  options: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
-  _webSearch?: boolean,
-  onTextDelta?: (text: string) => void,
-): Promise<LLMResponse> {
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  const syncParams: any = {
-    model,
-    messages: messages.map((m) => ({ role: m.role, content: m.content })),
-    temperature: options.temperature,
-    max_tokens: options.maxTokens,
-    stream: false,
-    ...stripReservedKeys(options.extra),
-  };
-  const response = await client.chat.completions.create(syncParams);
-
-  const content = response.choices[0]?.message?.content ?? "";
-  if (!content) throw new Error("LLM returned empty response");
-  onTextDelta?.(content);
-
-  return {
-    content,
-    usage: {
-      promptTokens: response.usage?.prompt_tokens ?? 0,
-      completionTokens: response.usage?.completion_tokens ?? 0,
-      totalTokens: response.usage?.total_tokens ?? 0,
-    },
-  };
-}
-
-async function chatWithToolsOpenAIChat(
-  client: OpenAI,
-  model: string,
-  messages: ReadonlyArray<AgentMessage>,
-  tools: ReadonlyArray<ToolDefinition>,
-  options: { readonly temperature: number; readonly maxTokens: number },
-): Promise<ChatWithToolsResult> {
-  const openaiMessages = agentMessagesToOpenAIChat(messages);
-  const openaiTools: OpenAI.Chat.Completions.ChatCompletionTool[] = tools.map((t) => ({
-    type: "function" as const,
-    function: {
-      name: t.name,
-      description: t.description,
-      parameters: t.parameters,
-    },
-  }));
-
-  const stream = await client.chat.completions.create({
-    model,
-    messages: openaiMessages,
-    tools: openaiTools,
-    temperature: options.temperature,
-    max_tokens: options.maxTokens,
-    stream: true,
-  });
-
-  let content = "";
-  const toolCallMap = new Map<number, { id: string; name: string; arguments: string }>();
-
-  for await (const chunk of stream) {
-    const delta = chunk.choices[0]?.delta;
-    if (delta?.content) content += delta.content;
-    if (delta?.tool_calls) {
-      for (const tc of delta.tool_calls) {
-        const existing = toolCallMap.get(tc.index);
-        if (existing) {
-          existing.arguments += tc.function?.arguments ?? "";
-        } else {
-          toolCallMap.set(tc.index, {
-            id: tc.id ?? "",
-            name: tc.function?.name ?? "",
-            arguments: tc.function?.arguments ?? "",
-          });
-        }
+// === pi-ai Unified Implementation ===
+
+/**
+ * Build a pi-ai Model<Api> for a specific per-call model name.
+ * The base template comes from client._piModel (created in createLLMClient);
+ * we override .id / .name when the caller passes a different model string
+ * (e.g. agent overrides).
+ */
+function resolvePiModel(client: LLMClient, model: string): PiModel<PiApi> {
+  const base = client._piModel!;
+  if (base.id === model) return base;
+  return { ...base, id: model, name: model };
+}
+
+/** Convert inkos LLMMessage[] to pi-ai Context. */
+function toPiContext(messages: ReadonlyArray<LLMMessage>): PiContext {
+  const systemParts = messages.filter((m) => m.role === "system").map((m) => m.content);
+  const systemPrompt = systemParts.length > 0 ? systemParts.join("\n\n") : undefined;
+  const piMessages = messages
+    .filter((m) => m.role !== "system")
+    .map((m) => {
+      if (m.role === "user") {
+        return { role: "user" as const, content: m.content, timestamp: Date.now() };
       }
-    }
-  }
-
-  const toolCalls: ToolCall[] = [...toolCallMap.values()];
-  return { content, toolCalls };
-}
-
-function agentMessagesToOpenAIChat(
-  messages: ReadonlyArray<AgentMessage>,
-): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
-  const result: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [];
-
-  for (const msg of messages) {
-    if (msg.role === "system") {
-      result.push({ role: "system", content: msg.content });
-      continue;
-    }
-    if (msg.role === "user") {
-      result.push({ role: "user", content: msg.content });
-      continue;
-    }
-    if (msg.role === "assistant") {
-      const assistantMsg: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam = {
-        role: "assistant",
-        content: msg.content ?? null,
+      // assistant
+      return {
+        role: "assistant" as const,
+        content: [{ type: "text" as const, text: m.content }],
+        api: "openai-completions" as PiApi,
+        provider: "openai",
+        model: "",
+        usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
+        stopReason: "stop" as const,
+        timestamp: Date.now(),
       };
-      if (msg.toolCalls && msg.toolCalls.length > 0) {
-        assistantMsg.tool_calls = msg.toolCalls.map((tc) => ({
-          id: tc.id,
-          type: "function" as const,
-          function: { name: tc.name, arguments: tc.arguments },
-        }));
-      }
-      result.push(assistantMsg);
-      continue;
-    }
-    if (msg.role === "tool") {
-      result.push({
-        role: "tool",
-        tool_call_id: msg.toolCallId,
-        content: msg.content,
-      });
-    }
-  }
-
-  return result;
-}
-
-// === OpenAI Responses API Implementation (optional) ===
-
-async function chatCompletionOpenAIResponses(
-  client: OpenAI,
-  model: string,
-  messages: ReadonlyArray<LLMMessage>,
-  options: { readonly temperature: number; readonly maxTokens: number },
-  webSearch?: boolean,
-  onStreamProgress?: OnStreamProgress,
-  onTextDelta?: (text: string) => void,
-): Promise<LLMResponse> {
-  const input: OpenAI.Responses.ResponseInputItem[] = messages.map((m) => ({
-    role: m.role as "system" | "user" | "assistant",
-    content: m.content,
-  }));
-
-  const tools: OpenAI.Responses.Tool[] | undefined = webSearch
-    ? [{ type: "web_search_preview" as const }]
-    : undefined;
-
-  const stream = await client.responses.create({
-    model,
-    input,
-    temperature: options.temperature,
-    max_output_tokens: options.maxTokens,
-    stream: true,
-    ...(tools ? { tools } : {}),
-  });
-
-  const chunks: string[] = [];
-  let inputTokens = 0;
-  let outputTokens = 0;
-  const monitor = createStreamMonitor(onStreamProgress);
-
-  try {
-    for await (const event of stream) {
-      if (event.type === "response.output_text.delta") {
-        chunks.push(event.delta);
-        monitor.onChunk(event.delta);
-        onTextDelta?.(event.delta);
-      }
-      if (event.type === "response.completed") {
-        inputTokens = event.response.usage?.input_tokens ?? 0;
-        outputTokens = event.response.usage?.output_tokens ?? 0;
-      }
-    }
-  } catch (streamError) {
-    monitor.stop();
-    const partial = chunks.join("");
-    if (partial.length >= MIN_SALVAGEABLE_CHARS) {
-      throw new PartialResponseError(partial, streamError);
-    }
-    throw streamError;
-  } finally {
-    monitor.stop();
-  }
-
-  const content = chunks.join("");
-  if (!content) throw new Error("LLM returned empty response from stream");
-
-  return {
-    content,
-    usage: {
-      promptTokens: inputTokens,
-      completionTokens: outputTokens,
-      totalTokens: inputTokens + outputTokens,
-    },
-  };
-}
-
-async function chatCompletionOpenAIResponsesSync(
-  client: OpenAI,
-  model: string,
-  messages: ReadonlyArray<LLMMessage>,
-  options: { readonly temperature: number; readonly maxTokens: number },
-  _webSearch?: boolean,
-  onTextDelta?: (text: string) => void,
-): Promise<LLMResponse> {
-  const input: OpenAI.Responses.ResponseInputItem[] = messages.map((m) => ({
-    role: m.role as "system" | "user" | "assistant",
-    content: m.content,
-  }));
-
-  const response = await client.responses.create({
-    model,
-    input,
-    temperature: options.temperature,
-    max_output_tokens: options.maxTokens,
-    stream: false,
-  });
-
-  const content = response.output
-    .filter((item): item is OpenAI.Responses.ResponseOutputMessage => item.type === "message")
-    .flatMap((item) => item.content)
-    .filter((block): block is OpenAI.Responses.ResponseOutputText => block.type === "output_text")
-    .map((block) => block.text)
-    .join("");
-
-  if (!content) throw new Error("LLM returned empty response");
-  onTextDelta?.(content);
-
-  return {
-    content,
-    usage: {
-      promptTokens: response.usage?.input_tokens ?? 0,
-      completionTokens: response.usage?.output_tokens ?? 0,
-      totalTokens: (response.usage?.input_tokens ?? 0) + (response.usage?.output_tokens ?? 0),
-    },
-  };
+    });
+  return { systemPrompt, messages: piMessages };
 }
 
-async function chatWithToolsOpenAIResponses(
-  client: OpenAI,
-  model: string,
-  messages: ReadonlyArray<AgentMessage>,
-  tools: ReadonlyArray<ToolDefinition>,
-  options: { readonly temperature: number; readonly maxTokens: number },
-): Promise<ChatWithToolsResult> {
-  const input = agentMessagesToResponsesInput(messages);
-  const responsesTools: OpenAI.Responses.Tool[] = tools.map((t) => ({
-    type: "function" as const,
-    name: t.name,
-    description: t.description,
-    parameters: t.parameters as OpenAI.Responses.FunctionTool["parameters"],
-    strict: false,
-  }));
-
-  const stream = await client.responses.create({
-    model,
-    input,
-    tools: responsesTools,
-    temperature: options.temperature,
-    max_output_tokens: options.maxTokens,
-    stream: true,
-  });
-
-  let content = "";
-  const toolCalls: ToolCall[] = [];
-
-  for await (const event of stream) {
-    if (event.type === "response.output_text.delta") {
-      content += event.delta;
-    }
-    if (event.type === "response.output_item.done" && event.item.type === "function_call") {
-      toolCalls.push({
-        id: event.item.call_id,
-        name: event.item.name,
-        arguments: event.item.arguments,
-      });
-    }
-  }
-
-  return { content, toolCalls };
-}
-
-function agentMessagesToResponsesInput(
-  messages: ReadonlyArray<AgentMessage>,
-): OpenAI.Responses.ResponseInputItem[] {
-  const result: OpenAI.Responses.ResponseInputItem[] = [];
-
+/** Convert inkos AgentMessage[] to pi-ai Context (with tool calls/results). */
+function agentMessagesToPiContext(messages: ReadonlyArray<AgentMessage>): PiContext {
+  const systemParts = messages.filter((m) => m.role === "system").map((m) => (m as { content: string }).content);
+  const systemPrompt = systemParts.length > 0 ? systemParts.join("\n\n") : undefined;
+  const piMessages: PiContext["messages"] = [];
   for (const msg of messages) {
-    if (msg.role === "system") {
-      result.push({ role: "system", content: msg.content });
-      continue;
-    }
+    if (msg.role === "system") continue;
     if (msg.role === "user") {
-      result.push({ role: "user", content: msg.content });
+      piMessages.push({ role: "user", content: msg.content, timestamp: Date.now() });
       continue;
     }
     if (msg.role === "assistant") {
-      if (msg.content) {
-        result.push({ role: "assistant", content: msg.content });
-      }
+      const content: (PiTextContent | PiToolCall)[] = [];
+      if (msg.content) content.push({ type: "text", text: msg.content });
       if (msg.toolCalls) {
         for (const tc of msg.toolCalls) {
-          result.push({
-            type: "function_call" as const,
-            call_id: tc.id,
+          content.push({
+            type: "toolCall",
+            id: tc.id,
             name: tc.name,
-            arguments: tc.arguments,
+            arguments: JSON.parse(tc.arguments),
           });
         }
       }
+      if (content.length === 0) content.push({ type: "text", text: "" });
+      piMessages.push({
+        role: "assistant",
+        content,
+        api: "openai-completions" as PiApi,
+        provider: "openai",
+        model: "",
+        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: Date.now(),
+      });
       continue;
     }
     if (msg.role === "tool") {
-      result.push({
-        type: "function_call_output" as const,
-        call_id: msg.toolCallId,
-        output: msg.content,
+      piMessages.push({
+        role: "toolResult",
+        toolCallId: msg.toolCallId,
+        toolName: "",
+        content: [{ type: "text", text: msg.content }],
+        isError: false,
+        timestamp: Date.now(),
       });
     }
   }
-
-  return result;
+  return { systemPrompt, messages: piMessages };
 }
 
-// === Anthropic Implementation ===
+/** Convert inkos ToolDefinition[] to pi-ai Tool[]. */
+function toPiTools(tools: ReadonlyArray<ToolDefinition>): PiTool[] {
+  return tools.map((t) => ({
+    name: t.name,
+    description: t.description,
+    parameters: t.parameters as PiTool["parameters"],
+  }));
+}
 
-async function chatCompletionAnthropic(
-  client: Anthropic,
+async function chatCompletionViaPiAi(
+  client: LLMClient,
   model: string,
   messages: ReadonlyArray<LLMMessage>,
-  options: { readonly temperature: number; readonly maxTokens: number },
-  thinkingBudget: number = 0,
+  resolved: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
   onStreamProgress?: OnStreamProgress,
   onTextDelta?: (text: string) => void,
 ): Promise<LLMResponse> {
-  const systemText = messages
-    .filter((m) => m.role === "system")
-    .map((m) => m.content)
-    .join("\n\n");
-  const nonSystem = messages.filter((m) => m.role !== "system");
-
-  const stream = await client.messages.create({
-    model,
-    ...(systemText ? { system: systemText } : {}),
-    messages: nonSystem.map((m) => ({
-      role: m.role as "user" | "assistant",
-      content: m.content,
-    })),
-    ...(thinkingBudget > 0
-      ? { thinking: { type: "enabled" as const, budget_tokens: thinkingBudget } }
-      : { temperature: options.temperature }),
-    max_tokens: options.maxTokens,
-    stream: true,
-  });
+  const piModel = resolvePiModel(client, model);
+  const context = toPiContext(messages);
+  const streamOpts = {
+    temperature: resolved.temperature,
+    maxTokens: resolved.maxTokens,
+    apiKey: client._apiKey,
+    headers: piModel.headers,
+  };
 
+  const eventStream = piStreamSimple(piModel, context, streamOpts);
   const chunks: string[] = [];
+  const monitor = createStreamMonitor(onStreamProgress);
   let inputTokens = 0;
   let outputTokens = 0;
-  const monitor = createStreamMonitor(onStreamProgress);
 
   try {
-    for await (const event of stream) {
-      if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
-        chunks.push(event.delta.text);
-        monitor.onChunk(event.delta.text);
-        onTextDelta?.(event.delta.text);
-      }
-      if (event.type === "message_start") {
-        inputTokens = event.message.usage?.input_tokens ?? 0;
+    for await (const event of eventStream) {
+      if (event.type === "text_delta") {
+        chunks.push(event.delta);
+        monitor.onChunk(event.delta);
+        onTextDelta?.(event.delta);
       }
-      if (event.type === "message_delta") {
-        outputTokens = ((event as unknown as { usage?: { output_tokens?: number } }).usage?.output_tokens) ?? 0;
+      if (event.type === "done" || event.type === "error") {
+        const msg = event.type === "done" ? event.message : event.error;
+        inputTokens = msg.usage.input;
+        outputTokens = msg.usage.output;
+        if (event.type === "error" && msg.errorMessage) {
+          // Check if we have partial content worth salvaging
+          const partial = chunks.join("");
+          if (partial.length >= MIN_SALVAGEABLE_CHARS) {
+            throw new PartialResponseError(partial, new Error(msg.errorMessage));
+          }
+          throw new Error(msg.errorMessage);
+        }
       }
     }
   } catch (streamError) {
     monitor.stop();
+    if (streamError instanceof PartialResponseError) throw streamError;
     const partial = chunks.join("");
     if (partial.length >= MIN_SALVAGEABLE_CHARS) {
       throw new PartialResponseError(partial, streamError);
@@ -877,7 +515,11 @@ async function chatCompletionAnthropic(
   }
 
   const content = chunks.join("");
-  if (!content) throw new Error("LLM returned empty response from stream");
+  if (!content) {
+    const diag = `usage=${inputTokens}+${outputTokens}`;
+    console.warn(`[inkos] LLM 流式响应无文本内容 (${diag})`);
+    throw new Error(`LLM returned empty response from stream (${diag})`);
+  }
 
   return {
     content,
@@ -889,167 +531,42 @@ async function chatCompletionAnthropic(
   };
 }
 
-async function chatCompletionAnthropicSync(
-  client: Anthropic,
-  model: string,
-  messages: ReadonlyArray<LLMMessage>,
-  options: { readonly temperature: number; readonly maxTokens: number },
-  thinkingBudget: number = 0,
-  onTextDelta?: (text: string) => void,
-): Promise<LLMResponse> {
-  const systemText = messages
-    .filter((m) => m.role === "system")
-    .map((m) => m.content)
-    .join("\n\n");
-  const nonSystem = messages.filter((m) => m.role !== "system");
-
-  const response = await client.messages.create({
-    model,
-    ...(systemText ? { system: systemText } : {}),
-    messages: nonSystem.map((m) => ({
-      role: m.role as "user" | "assistant",
-      content: m.content,
-    })),
-    ...(thinkingBudget > 0
-      ? { thinking: { type: "enabled" as const, budget_tokens: thinkingBudget } }
-      : { temperature: options.temperature }),
-    max_tokens: options.maxTokens,
-  });
-
-  const content = response.content
-    .filter((block): block is Anthropic.Messages.TextBlock => block.type === "text")
-    .map((block) => block.text)
-    .join("");
-
-  if (!content) throw new Error("LLM returned empty response");
-  onTextDelta?.(content);
-
-  return {
-    content,
-    usage: {
-      promptTokens: response.usage?.input_tokens ?? 0,
-      completionTokens: response.usage?.output_tokens ?? 0,
-      totalTokens: (response.usage?.input_tokens ?? 0) + (response.usage?.output_tokens ?? 0),
-    },
-  };
-}
-
-async function chatWithToolsAnthropic(
-  client: Anthropic,
+async function chatWithToolsViaPiAi(
+  client: LLMClient,
   model: string,
   messages: ReadonlyArray<AgentMessage>,
   tools: ReadonlyArray<ToolDefinition>,
-  options: { readonly temperature: number; readonly maxTokens: number },
-  thinkingBudget: number = 0,
+  resolved: { readonly temperature: number; readonly maxTokens: number },
 ): Promise<ChatWithToolsResult> {
-  const systemText = messages
-    .filter((m) => m.role === "system")
-    .map((m) => (m as { content: string }).content)
-    .join("\n\n");
-  const nonSystem = messages.filter((m) => m.role !== "system");
-
-  const anthropicMessages = agentMessagesToAnthropic(nonSystem);
-  const anthropicTools = tools.map((t) => ({
-    name: t.name,
-    description: t.description,
-    input_schema: t.parameters as Anthropic.Messages.Tool.InputSchema,
-  }));
-
-  const stream = await client.messages.create({
-    model,
-    ...(systemText ? { system: systemText } : {}),
-    messages: anthropicMessages,
-    tools: anthropicTools,
-    ...(thinkingBudget > 0
-      ? { thinking: { type: "enabled" as const, budget_tokens: thinkingBudget } }
-      : { temperature: options.temperature }),
-    max_tokens: options.maxTokens,
-    stream: true,
-  });
+  const piModel = resolvePiModel(client, model);
+  const context = agentMessagesToPiContext(messages);
+  context.tools = toPiTools(tools);
+  const streamOpts = {
+    temperature: resolved.temperature,
+    maxTokens: resolved.maxTokens,
+    apiKey: client._apiKey,
+    headers: piModel.headers,
+  };
 
+  const eventStream = piStream(piModel, context, streamOpts);
   let content = "";
   const toolCalls: ToolCall[] = [];
-  let currentBlock: { id: string; name: string; input: string } | null = null;
 
-  for await (const event of stream) {
-    if (event.type === "content_block_start" && event.content_block.type === "tool_use") {
-      currentBlock = {
-        id: event.content_block.id,
-        name: event.content_block.name,
-        input: "",
-      };
-    }
-    if (event.type === "content_block_delta") {
-      if (event.delta.type === "text_delta") {
-        content += event.delta.text;
-      }
-      if (event.delta.type === "input_json_delta" && currentBlock) {
-        currentBlock.input += event.delta.partial_json;
-      }
+  for await (const event of eventStream) {
+    if (event.type === "text_delta") {
+      content += event.delta;
     }
-    if (event.type === "content_block_stop" && currentBlock) {
+    if (event.type === "toolcall_end") {
       toolCalls.push({
-        id: currentBlock.id,
-        name: currentBlock.name,
-        arguments: currentBlock.input,
+        id: event.toolCall.id,
+        name: event.toolCall.name,
+        arguments: JSON.stringify(event.toolCall.arguments),
       });
-      currentBlock = null;
-    }
-  }
-
-  return { content, toolCalls };
-}
-
-function agentMessagesToAnthropic(
-  messages: ReadonlyArray<AgentMessage>,
-): Anthropic.Messages.MessageParam[] {
-  const result: Anthropic.Messages.MessageParam[] = [];
-
-  for (const msg of messages) {
-    if (msg.role === "system") continue;
-
-    if (msg.role === "user") {
-      result.push({ role: "user", content: msg.content });
-      continue;
-    }
-
-    if (msg.role === "assistant") {
-      const blocks: Anthropic.Messages.ContentBlockParam[] = [];
-      if (msg.content) {
-        blocks.push({ type: "text", text: msg.content });
-      }
-      if (msg.toolCalls) {
-        for (const tc of msg.toolCalls) {
-          blocks.push({
-            type: "tool_use",
-            id: tc.id,
-            name: tc.name,
-            input: JSON.parse(tc.arguments),
-          });
-        }
-      }
-      if (blocks.length === 0) {
-        blocks.push({ type: "text", text: "" });
-      }
-      result.push({ role: "assistant", content: blocks });
-      continue;
     }
-
-    if (msg.role === "tool") {
-      const toolResult: Anthropic.Messages.ToolResultBlockParam = {
-        type: "tool_result",
-        tool_use_id: msg.toolCallId,
-        content: msg.content,
-      };
-      // Merge consecutive tool results into one user message (Anthropic requires alternating roles)
-      const prev = result[result.length - 1];
-      if (prev && prev.role === "user" && Array.isArray(prev.content)) {
-        (prev.content as Anthropic.Messages.ToolResultBlockParam[]).push(toolResult);
-      } else {
-        result.push({ role: "user", content: [toolResult] });
-      }
+    if (event.type === "error" && event.error.errorMessage) {
+      throw new Error(event.error.errorMessage);
     }
   }
 
-  return result;
+  return { content, toolCalls };
 }

+ 50 - 0
packages/core/src/llm/secrets.ts

@@ -0,0 +1,50 @@
+import { readFile, writeFile, mkdir } from "node:fs/promises";
+import { join } from "node:path";
+
+export interface SecretsFile {
+  services: Record<string, { apiKey: string }>;
+}
+
+const SECRETS_DIR = ".inkos";
+const SECRETS_FILE = "secrets.json";
+
+export async function loadSecrets(projectRoot: string): Promise<SecretsFile> {
+  try {
+    const raw = await readFile(
+      join(projectRoot, SECRETS_DIR, SECRETS_FILE),
+      "utf-8",
+    );
+    return JSON.parse(raw) as SecretsFile;
+  } catch {
+    return { services: {} };
+  }
+}
+
+export async function saveSecrets(
+  projectRoot: string,
+  secrets: SecretsFile,
+): Promise<void> {
+  const dir = join(projectRoot, SECRETS_DIR);
+  await mkdir(dir, { recursive: true });
+  await writeFile(
+    join(dir, SECRETS_FILE),
+    JSON.stringify(secrets, null, 2),
+    "utf-8",
+  );
+}
+
+export async function getServiceApiKey(
+  projectRoot: string,
+  service: string,
+): Promise<string | null> {
+  // 1. secrets.json
+  const secrets = await loadSecrets(projectRoot);
+  const entry = secrets.services[service];
+  if (entry?.apiKey) return entry.apiKey;
+
+  // 2. Environment variable: MOONSHOT_API_KEY, DEEPSEEK_API_KEY, etc.
+  const envKey = `${service.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase()}_API_KEY`;
+  if (process.env[envKey]) return process.env[envKey]!;
+
+  return null;
+}

+ 143 - 0
packages/core/src/llm/service-presets.ts

@@ -0,0 +1,143 @@
+export interface ServicePreset {
+  readonly api: string;
+  readonly baseUrl: string;
+  readonly label: string;
+  readonly temperatureRange?: [number, number];
+  readonly defaultTemperature?: number;
+  readonly writingTemperature?: number;
+  readonly temperatureHint?: string;
+}
+
+export const SERVICE_PRESETS: Record<string, ServicePreset> = {
+  openai:      { api: "openai-responses",   baseUrl: "https://api.openai.com/v1",                         label: "OpenAI",          temperatureRange: [0, 2], defaultTemperature: 1.0, writingTemperature: 1.0 },
+  anthropic:   { api: "anthropic-messages",  baseUrl: "https://api.anthropic.com",                         label: "Anthropic",       temperatureRange: [0, 1], defaultTemperature: 1.0, writingTemperature: 1.0, temperatureHint: "不要同时改 temperature 和 top_p" },
+  deepseek:    { api: "openai-completions",  baseUrl: "https://api.deepseek.com",                          label: "DeepSeek",        temperatureRange: [0, 2], defaultTemperature: 1.0, writingTemperature: 1.5, temperatureHint: "创意写作推荐 1.5" },
+  moonshot:    { api: "openai-completions",  baseUrl: "https://api.moonshot.cn/v1",                        label: "Moonshot (Kimi)", temperatureRange: [0, 1], defaultTemperature: 0.3, writingTemperature: 1.0, temperatureHint: "kimi-k2.5 推荐 temperature=1.0" },
+  minimax:     { api: "openai-completions",  baseUrl: "https://api.minimax.chat/v1",                       label: "MiniMax",         temperatureRange: [0, 2], defaultTemperature: 0.9, writingTemperature: 0.9 },
+  bailian:     { api: "openai-completions",  baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", label: "百炼 (通义千问)", temperatureRange: [0, 2], defaultTemperature: 0.7, writingTemperature: 1.0 },
+  zhipu:       { api: "openai-completions",  baseUrl: "https://open.bigmodel.cn/api/paas/v4",              label: "智谱 GLM",        temperatureRange: [0, 1], defaultTemperature: 0.95, writingTemperature: 0.95 },
+  siliconflow: { api: "openai-completions",  baseUrl: "https://api.siliconflow.cn/v1",                     label: "硅基流动" },
+  ppio:        { api: "openai-completions",  baseUrl: "https://api.ppinfra.com/v3/openai",                 label: "PPIO" },
+  openrouter:  { api: "openai-responses",    baseUrl: "https://openrouter.ai/api/v1",                      label: "OpenRouter" },
+  ollama:      { api: "openai-completions",  baseUrl: "http://localhost:11434/v1",                         label: "Ollama (本地)" },
+  custom:      { api: "openai-completions",  baseUrl: "",                                                   label: "自定义端点" },
+};
+
+export function resolveServicePreset(service: string): ServicePreset | undefined {
+  return SERVICE_PRESETS[service];
+}
+
+const DEFAULT_TEMPERATURE_RANGE: [number, number] = [0, 2];
+
+export function clampTemperature(service: string, temperature: number): number {
+  const preset = resolveServicePreset(service);
+  const [min, max] = preset?.temperatureRange ?? DEFAULT_TEMPERATURE_RANGE;
+  return Math.max(min, Math.min(max, temperature));
+}
+
+export function getWritingTemperature(service: string): number {
+  const preset = resolveServicePreset(service);
+  return preset?.writingTemperature ?? preset?.defaultTemperature ?? 1.0;
+}
+
+export function guessServiceFromBaseUrl(baseUrl: string): string {
+  for (const [key, preset] of Object.entries(SERVICE_PRESETS)) {
+    if (key === "custom" || !preset.baseUrl) continue;
+    try {
+      if (baseUrl.includes(new URL(preset.baseUrl).hostname)) return key;
+    } catch {
+      continue;
+    }
+  }
+  return "custom";
+}
+
+// pi-ai service → pi-ai provider 映射
+export const SERVICE_TO_PI_PROVIDER: Record<string, string> = {
+  openai: "openai",
+  anthropic: "anthropic",
+  deepseek: "openai",         // OpenAI 兼容,pi-ai 无独立 provider
+  moonshot: "openai",         // Moonshot API (api.moonshot.cn) 是 OpenAI 兼容,不是 kimi-coding (api.kimi.com)
+  minimax: "minimax",
+  bailian: "openai",          // 百炼走 OpenAI 兼容
+  zhipu: "zai",               // pi-ai 有 zai provider
+  siliconflow: "openai",      // OpenAI 兼容
+  ppio: "openai",             // OpenAI 兼容
+  openrouter: "openrouter",
+  ollama: "openai",           // OpenAI 兼容
+};
+
+export interface ModelInfo {
+  readonly id: string;
+  readonly name: string;
+  readonly reasoning: boolean;
+  readonly contextWindow: number;
+}
+
+/**
+ * 动态获取某个 service 下可用的模型列表。
+ * 优先调用服务商的 GET /models API(OpenAI 兼容),回退到 pi-ai 内置模型列表。
+ *
+ * @param apiKey 用户配置的 API key,用于认证 /models 请求
+ */
+export async function listModelsForService(service: string, apiKey?: string): Promise<ReadonlyArray<ModelInfo>> {
+  const preset = SERVICE_PRESETS[service];
+  if (!preset || service === "custom") return [];
+
+  // 1) 尝试动态获取:调用 GET {baseUrl}/models
+  if (apiKey && preset.baseUrl) {
+    try {
+      const modelsUrl = preset.baseUrl.replace(/\/$/, "") + "/models";
+      const res = await fetch(modelsUrl, {
+        headers: { Authorization: `Bearer ${apiKey}` },
+        signal: AbortSignal.timeout(10_000),
+      });
+      if (res.ok) {
+        const json = await res.json() as { data?: Array<{ id: string; owned_by?: string }> };
+        if (json.data && json.data.length > 0) {
+          return json.data.map((m) => ({
+            id: m.id,
+            name: m.id,
+            reasoning: false,
+            contextWindow: 0,
+          }));
+        }
+      }
+    } catch {
+      // /models 不可用,回退
+    }
+  }
+
+  // 2) 回退到 pi-ai 内置模型列表
+  const piProvider = SERVICE_TO_PI_PROVIDER[service];
+  if (!piProvider) return [];
+
+  try {
+    const { getModels } = await import("@mariozechner/pi-ai");
+    const models = getModels(piProvider as any);
+    return models.map((m: any) => ({
+      id: m.id,
+      name: m.name,
+      reasoning: m.reasoning ?? false,
+      contextWindow: m.contextWindow ?? 0,
+    }));
+  } catch {
+    return [];
+  }
+}
+
+/**
+ * 获取所有 service 及其可用模型数。
+ */
+export async function listServicesWithModelCount(): Promise<ReadonlyArray<{ service: string; label: string; modelCount: number }>> {
+  const result: { service: string; label: string; modelCount: number }[] = [];
+  for (const [key, preset] of Object.entries(SERVICE_PRESETS)) {
+    if (key === "custom") {
+      result.push({ service: key, label: preset.label, modelCount: 0 });
+      continue;
+    }
+    const models = await listModelsForService(key);
+    result.push({ service: key, label: preset.label, modelCount: models.length });
+  }
+  return result;
+}

+ 66 - 0
packages/core/src/llm/service-resolver.ts

@@ -0,0 +1,66 @@
+import { getModel } from "@mariozechner/pi-ai";
+import type { Model, Api } from "@mariozechner/pi-ai";
+import { resolveServicePreset, SERVICE_TO_PI_PROVIDER } from "./service-presets.js";
+import { getServiceApiKey } from "./secrets.js";
+
+export interface ResolvedModel {
+  model: Model<Api>;
+  apiKey: string;
+  writingTemperature?: number;
+  temperatureRange?: [number, number];
+  temperatureHint?: string;
+}
+
+export async function resolveServiceModel(
+  service: string,
+  modelId: string,
+  projectRoot: string,
+  customBaseUrl?: string,
+): Promise<ResolvedModel> {
+  // Resolve API key
+  const apiKey = await getServiceApiKey(projectRoot, service);
+  if (!apiKey) {
+    throw new Error(
+      `API key not found for service "${service}". Add it in .inkos/secrets.json or set the environment variable.`,
+    );
+  }
+
+  // Determine pi-ai provider
+  const baseService = service.startsWith("custom:") ? "custom" : service;
+  const preset = resolveServicePreset(baseService);
+  const piProvider = SERVICE_TO_PI_PROVIDER[baseService] ?? "openai";
+
+  // Get pi-ai Model — may return undefined for model IDs not in the built-in registry
+  let model = getModel(piProvider as any, modelId as any) as Model<Api> | undefined;
+
+  if (!model) {
+    // Construct a Model object from service preset for models not in pi-ai's registry
+    const apiType = preset?.api ?? "openai-completions";
+    const baseUrl = customBaseUrl ?? preset?.baseUrl ?? "";
+    if (!baseUrl) {
+      throw new Error(
+        `Cannot resolve model "${modelId}" for service "${service}": no baseUrl available.`,
+      );
+    }
+    model = {
+      id: modelId,
+      name: modelId,
+      api: apiType as Api,
+      provider: piProvider,
+      baseUrl,
+      reasoning: false,
+      input: ["text"] as ("text" | "image")[],
+      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+      contextWindow: 0,
+      maxTokens: 16384,
+    };
+  }
+
+  return {
+    model,
+    apiKey,
+    writingTemperature: preset?.writingTemperature,
+    temperatureRange: preset?.temperatureRange,
+    temperatureHint: preset?.temperatureHint,
+  };
+}

+ 1 - 0
packages/core/src/models/project.ts

@@ -2,6 +2,7 @@ import { z } from "zod";
 
 export const LLMConfigSchema = z.object({
   provider: z.enum(["anthropic", "openai", "custom"]),
+  service: z.string().default("custom"),
   baseUrl: z.string().url(),
   apiKey: z.string().default(""),
   model: z.string().min(1),

+ 5 - 1
packages/core/src/pipeline/chapter-persistence.ts

@@ -57,7 +57,11 @@ export async function persistChapterArtifacts(params: {
     lengthTelemetry: params.lengthTelemetry,
     tokenUsage: params.tokenUsage,
   };
-  await params.saveChapterIndex([...existingIndex, entry]);
+  const existingIdx = existingIndex.findIndex((e) => e.number === params.chapterNumber);
+  const updatedIndex = existingIdx >= 0
+    ? existingIndex.map((e, i) => i === existingIdx ? { ...entry, createdAt: e.createdAt } : e)
+    : [...existingIndex, entry];
+  await params.saveChapterIndex(updatedIndex);
   await params.markBookActiveIfNeeded();
 
   const driftIssues = params.auditResult.issues.filter(

+ 27 - 1
packages/core/src/pipeline/chapter-truth-validation.ts

@@ -57,7 +57,33 @@ export async function validateChapterTruthPersistence(params: {
       params.language,
     );
   } catch (error) {
-    throw new Error(`State validation failed for chapter ${params.chapterNumber}: ${String(error)}`);
+    params.logger?.warn(`State validation error for chapter ${params.chapterNumber}: ${String(error)}`);
+    const errorDescription = params.language === "en"
+      ? `State validation unavailable: ${String(error)}`
+      : `状态校验不可用:${String(error)}`;
+    const errorIssue: AuditIssue = {
+      severity: "warning",
+      category: "state-validation",
+      description: errorDescription,
+      suggestion: params.language === "en"
+        ? "Repair chapter state from the persisted body before continuing."
+        : "请先基于已保存正文修复本章 state,再继续后续章节。",
+    };
+    return {
+      validation: { passed: true, warnings: [] },
+      chapterStatus: "state-degraded",
+      degradedIssues: [errorIssue],
+      persistenceOutput: buildStateDegradedPersistenceOutput({
+        output: persistenceOutput,
+        oldState: params.previousTruth.oldState,
+        oldHooks: params.previousTruth.oldHooks,
+        oldLedger: params.previousTruth.oldLedger,
+      }),
+      auditResult: {
+        ...params.auditResult,
+        issues: [...params.auditResult.issues, errorIssue],
+      },
+    };
   }
 
   if (validation.warnings.length > 0) {

+ 8 - 3
packages/core/src/pipeline/runner.ts

@@ -393,6 +393,7 @@ export class PipelineRunner {
         : base?.apiKey ?? "";
       client = createLLMClient({
         provider,
+        service: base?.service ?? "custom",
         baseUrl: override.baseUrl,
         apiKey,
         model: override.model,
@@ -693,7 +694,11 @@ export class PipelineRunner {
         lengthTelemetry,
         ...(draftOutput.tokenUsage ? { tokenUsage: draftOutput.tokenUsage } : {}),
       };
-      await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]);
+      const existingIdx = existingIndex.findIndex((e) => e.number === chapterNumber);
+      const updatedIndex = existingIdx >= 0
+        ? existingIndex.map((e, i) => i === existingIdx ? newEntry : e)
+        : [...existingIndex, newEntry];
+      await this.state.saveChapterIndex(bookId, updatedIndex);
       await this.markBookActiveIfNeeded(bookId);
 
       // Snapshot
@@ -1808,7 +1813,7 @@ export class PipelineRunner {
         role: "user",
         content: `分析以下参考文本的写作风格:\n\n${referenceText.slice(0, 20000)}`,
       },
-    ], { temperature: 0.3, maxTokens: 4096 });
+    ], { temperature: 0.3 });
 
     await writeFile(join(storyDir, "style_guide.md"), response.content, "utf-8");
     return response.content;
@@ -1930,7 +1935,7 @@ ${emotions}
 ## 正传角色矩阵
 ${matrix}`,
       },
-    ], { temperature: 0.3, maxTokens: 16384 });
+    ], { temperature: 0.3 });
 
     // Append deterministic meta block (LLM may hallucinate timestamps)
     const metaBlock = [

+ 9 - 1
packages/core/src/state/manager.ts

@@ -5,6 +5,9 @@ import type { ChapterMeta } from "../models/chapter.js";
 import { bootstrapStructuredStateFromMarkdown, resolveDurableStoryProgress } from "./state-bootstrap.js";
 
 export class StateManager {
+  /** Books actively being written by this process — used for same-process stale lock detection. */
+  private readonly activeWrites = new Set<string>();
+
   constructor(private readonly projectRoot: string) {}
 
   private static defaultAuthorIntent(language: "zh" | "en"): string {
@@ -93,7 +96,10 @@ export class StateManager {
       if (code === "EEXIST") {
         const lockData = await readFile(lockPath, "utf-8").catch(() => "pid:unknown ts:unknown");
         const lockPid = this.extractLockPid(lockData);
-        if (lockPid !== undefined && !this.isProcessAlive(lockPid)) {
+        const isStale =
+          (lockPid !== undefined && !this.isProcessAlive(lockPid)) ||
+          (lockPid === process.pid && !this.activeWrites.has(bookId));
+        if (isStale) {
           await unlink(lockPath).catch(() => undefined);
           return this.acquireBookLock(bookId);
         }
@@ -104,7 +110,9 @@ export class StateManager {
       }
       throw e;
     }
+    this.activeWrites.add(bookId);
     return async () => {
+      this.activeWrites.delete(bookId);
       try {
         await unlink(lockPath);
       } catch {

+ 25 - 0
packages/studio/components.json

@@ -0,0 +1,25 @@
+{
+  "$schema": "https://ui.shadcn.com/schema.json",
+  "style": "base-nova",
+  "rsc": false,
+  "tsx": true,
+  "tailwind": {
+    "config": "",
+    "css": "src/index.css",
+    "baseColor": "neutral",
+    "cssVariables": true,
+    "prefix": ""
+  },
+  "iconLibrary": "lucide",
+  "rtl": false,
+  "aliases": {
+    "components": "@/components",
+    "utils": "@/lib/utils",
+    "ui": "@/components/ui",
+    "lib": "@/lib",
+    "hooks": "@/hooks"
+  },
+  "menuColor": "default",
+  "menuAccent": "subtle",
+  "registries": {}
+}

+ 20 - 2
packages/studio/package.json

@@ -19,7 +19,9 @@
     "prepack": "npm run build && node ../../scripts/prepare-package-for-publish.mjs",
     "postpack": "node ../../scripts/restore-package-json.mjs",
     "prepublishOnly": "node ../../scripts/verify-no-workspace-protocol.mjs .",
-    "dev": "vite --host --port 4567",
+    "dev": "INKOS_STUDIO_PORT=4569 INKOS_PROJECT_ROOT=../.. tsx watch --clear-screen=false src/api/index.ts & vite --host --port 4567 ; kill %1 2>/dev/null",
+    "dev:client": "vite --host --port 4567",
+    "dev:server": "INKOS_STUDIO_PORT=4569 INKOS_PROJECT_ROOT=../.. tsx watch src/api/index.ts",
     "build": "npm run build:client && npm run build:server",
     "build:client": "vite build",
     "build:server": "tsc -p tsconfig.server.json",
@@ -30,13 +32,29 @@
   "dependencies": {
     "@actalk/inkos-core": "workspace:*",
     "@base-ui/react": "^1.3.0",
+    "@fontsource-variable/geist": "^5.2.8",
     "@hono/node-server": "^1.13.0",
+    "@radix-ui/react-use-controllable-state": "^1.2.2",
+    "@streamdown/cjk": "^1.0.3",
+    "@streamdown/code": "^1.1.1",
+    "@streamdown/math": "^1.0.2",
+    "@streamdown/mermaid": "^1.0.2",
+    "ai": "^6.0.159",
+    "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
+    "cmdk": "^1.1.1",
     "hono": "^4.7.0",
     "lucide-react": "^0.577.0",
+    "motion": "^12.38.0",
+    "nanoid": "^5.1.7",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
-    "tailwind-merge": "^3.5.0"
+    "shadcn": "^4.2.0",
+    "shiki": "^4.0.2",
+    "streamdown": "^2.5.0",
+    "tailwind-merge": "^3.5.0",
+    "tw-animate-css": "^1.4.0",
+    "zustand": "^5.0.12"
   },
   "devDependencies": {
     "@tailwindcss/vite": "^4.0.0",

+ 1 - 1
packages/studio/src/App.test.ts

@@ -11,7 +11,7 @@ describe("deriveActiveBookId", () => {
 
   it("returns undefined for non-book routes", () => {
     expect(deriveActiveBookId({ page: "dashboard" })).toBeUndefined();
-    expect(deriveActiveBookId({ page: "config" })).toBeUndefined();
+    expect(deriveActiveBookId({ page: "services" })).toBeUndefined();
     expect(deriveActiveBookId({ page: "style" })).toBeUndefined();
   });
 });

+ 126 - 75
packages/studio/src/App.tsx

@@ -1,12 +1,13 @@
 import { useState, useEffect } from "react";
+import { useHashRoute } from "./hooks/use-hash-route";
+import type { HashRoute } from "./hooks/use-hash-route";
 import { Sidebar } from "./components/Sidebar";
-import { ChatPanel } from "./components/ChatBar";
 import { Dashboard } from "./pages/Dashboard";
-import { BookDetail } from "./pages/BookDetail";
-import { BookCreate } from "./pages/BookCreate";
+import { ChatPage } from "./pages/ChatPage";
 import { ChapterReader } from "./pages/ChapterReader";
 import { Analytics } from "./pages/Analytics";
-import { ConfigView } from "./pages/ConfigView";
+import { ServiceListPage } from "./pages/ServiceListPage";
+import { ServiceDetailPage } from "./pages/ServiceDetailPage";
 import { TruthFiles } from "./pages/TruthFiles";
 import { DaemonControl } from "./pages/DaemonControl";
 import { LogViewer } from "./pages/LogViewer";
@@ -16,44 +17,28 @@ import { ImportManager } from "./pages/ImportManager";
 import { RadarView } from "./pages/RadarView";
 import { DoctorView } from "./pages/DoctorView";
 import { LanguageSelector } from "./pages/LanguageSelector";
+import { BookSidebar, BookSidebarToggle } from "./components/chat/BookSidebar";
 import { useSSE } from "./hooks/use-sse";
 import { useTheme } from "./hooks/use-theme";
 import { useI18n } from "./hooks/use-i18n";
-import { postApi, useApi } from "./hooks/use-api";
-import { Sun, Moon, MessageSquare } from "lucide-react";
-import { DEFAULT_CHAT_OPEN } from "./app-state";
+import { postApi, putApi, useApi } from "./hooks/use-api";
+import { Sun, Moon } from "lucide-react";
 
-export type Route =
-  | { page: "dashboard" }
-  | { page: "book"; bookId: string }
-  | { page: "book-create" }
-  | { page: "chapter"; bookId: string; chapterNumber: number }
-  | { page: "analytics"; bookId: string }
-  | { page: "config" }
-  | { page: "truth"; bookId: string }
-  | { page: "daemon" }
-  | { page: "logs" }
-  | { page: "genres" }
-  | { page: "style" }
-  | { page: "import" }
-  | { page: "radar" }
-  | { page: "doctor" };
+export type { HashRoute as Route } from "./hooks/use-hash-route";
 
-export function deriveActiveBookId(route: Route): string | undefined {
-  return route.page === "book" || route.page === "chapter" || route.page === "truth" || route.page === "analytics"
-    ? route.bookId
-    : undefined;
+export function deriveActiveBookId(route: HashRoute): string | undefined {
+  if ("bookId" in route) return route.bookId;
+  return undefined;
 }
 
 export function App() {
-  const [route, setRoute] = useState<Route>({ page: "dashboard" });
+  const { route, setRoute } = useHashRoute();
   const sse = useSSE();
   const { theme, setTheme } = useTheme();
-  const { t } = useI18n();
+  const { t, lang: currentLang } = useI18n();
   const { data: project, refetch: refetchProject } = useApi<{ language: string; languageExplicit: boolean }>("/project");
   const [showLanguageSelector, setShowLanguageSelector] = useState(false);
   const [ready, setReady] = useState(false);
-  const [chatOpen, setChatOpen] = useState(DEFAULT_CHAT_OPEN);
 
   const isDark = theme === "dark";
 
@@ -77,7 +62,8 @@ export function App() {
     toChapter: (bookId: string, chapterNumber: number) =>
       setRoute({ page: "chapter", bookId, chapterNumber }),
     toAnalytics: (bookId: string) => setRoute({ page: "analytics", bookId }),
-    toConfig: () => setRoute({ page: "config" }),
+    toServices: () => setRoute({ page: "services" }),
+    toServiceDetail: (id: string) => setRoute({ page: "service-detail", serviceId: id }),
     toTruth: (bookId: string) => setRoute({ page: "truth", bookId }),
     toDaemon: () => setRoute({ page: "daemon" }),
     toLogs: () => setRoute({ page: "logs" }),
@@ -92,7 +78,9 @@ export function App() {
   const activePage =
     activeBookId
       ? `book:${activeBookId}`
-      : route.page;
+      : route.page === "service-detail"
+        ? "services"
+        : route.page;
 
   if (!ready) {
     return (
@@ -124,65 +112,128 @@ export function App() {
         {/* Header Strip */}
         <header className="h-14 shrink-0 flex items-center justify-between px-8 border-b border-border/40">
           <div className="flex items-center gap-2">
-             <span className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-bold">
+             <button onClick={nav.toDashboard} className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-bold hover:text-foreground transition-colors">
                InkOS Studio
-             </span>
+             </button>
           </div>
 
           <div className="flex items-center gap-3">
+            <div className="flex gap-0.5 bg-muted/50 rounded-md p-0.5">
+              <button
+                onClick={async () => {
+                  await putApi("/project", { language: "zh" });
+                  refetchProject();
+                }}
+                className={`text-xs px-2 py-0.5 rounded ${currentLang === "zh" ? "bg-primary text-primary-foreground" : "text-muted-foreground"}`}
+              >
+                中
+              </button>
+              <button
+                onClick={async () => {
+                  await putApi("/project", { language: "en" });
+                  refetchProject();
+                }}
+                className={`text-xs px-2 py-0.5 rounded ${currentLang === "en" ? "bg-primary text-primary-foreground" : "text-muted-foreground"}`}
+              >
+                EN
+              </button>
+            </div>
 
             <button
               onClick={() => setTheme(isDark ? "light" : "dark")}
-              className="w-8 h-8 flex items-center justify-center rounded-lg bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all shadow-sm"
-              title={isDark ? "Switch to Light Mode" : "Switch to Dark Mode"}
+              className="text-muted-foreground hover:text-foreground transition-colors"
             >
-              {isDark ? <Sun size={16} /> : <Moon size={16} />}
-            </button>
-
-            {/* Chat Panel Toggle */}
-            <button
-              onClick={() => setChatOpen((prev) => !prev)}
-              className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all shadow-sm ${
-                chatOpen
-                  ? "bg-primary text-primary-foreground shadow-primary/20"
-                  : "bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10"
-              }`}
-              title="Toggle AI Assistant"
-            >
-              <MessageSquare size={16} />
+              {isDark ? <Sun size={14} /> : <Moon size={14} />}
             </button>
           </div>
         </header>
 
         {/* Main Content Area */}
-        <main className="flex-1 overflow-y-auto scroll-smooth">
-          <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
-            {route.page === "dashboard" && <Dashboard nav={nav} sse={sse} theme={theme} t={t} />}
-            {route.page === "book" && <BookDetail bookId={route.bookId} nav={nav} theme={theme} t={t} sse={sse} />}
-            {route.page === "book-create" && <BookCreate nav={nav} theme={theme} t={t} />}
-            {route.page === "chapter" && <ChapterReader bookId={route.bookId} chapterNumber={route.chapterNumber} nav={nav} theme={theme} t={t} />}
-            {route.page === "analytics" && <Analytics bookId={route.bookId} nav={nav} theme={theme} t={t} />}
-            {route.page === "config" && <ConfigView nav={nav} theme={theme} t={t} />}
-            {route.page === "truth" && <TruthFiles bookId={route.bookId} nav={nav} theme={theme} t={t} />}
-            {route.page === "daemon" && <DaemonControl nav={nav} theme={theme} t={t} sse={sse} />}
-            {route.page === "logs" && <LogViewer nav={nav} theme={theme} t={t} />}
-            {route.page === "genres" && <GenreManager nav={nav} theme={theme} t={t} />}
-            {route.page === "style" && <StyleManager nav={nav} theme={theme} t={t} />}
-            {route.page === "import" && <ImportManager nav={nav} theme={theme} t={t} />}
-            {route.page === "radar" && <RadarView nav={nav} theme={theme} t={t} />}
-            {route.page === "doctor" && <DoctorView nav={nav} theme={theme} t={t} />}
-          </div>
+        <main className="flex-1 relative overflow-y-auto scroll-smooth">
+          {route.page === "dashboard" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <Dashboard nav={nav} sse={sse} theme={theme} t={t} />
+            </div>
+          )}
+          {(route.page === "book" || route.page === "book-create") && (
+            <div className="absolute inset-0 flex min-w-0">
+              <ChatPage
+                activeBookId={route.page === "book" ? route.bookId : undefined}
+                nav={nav}
+                theme={theme}
+                t={t}
+                sse={sse}
+              />
+              {route.page === "book" && (
+                <>
+                  <BookSidebar bookId={route.bookId} theme={theme} t={t} sse={sse} />
+                  <BookSidebarToggle bookId={route.bookId} theme={theme} t={t} sse={sse} />
+                </>
+              )}
+            </div>
+          )}
+          {route.page === "chapter" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <ChapterReader bookId={route.bookId} chapterNumber={route.chapterNumber} nav={nav} theme={theme} t={t} />
+            </div>
+          )}
+          {route.page === "analytics" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <Analytics bookId={route.bookId} nav={nav} theme={theme} t={t} />
+            </div>
+          )}
+          {route.page === "services" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <ServiceListPage nav={nav} />
+            </div>
+          )}
+          {route.page === "service-detail" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <ServiceDetailPage serviceId={route.serviceId} nav={nav} />
+            </div>
+          )}
+          {route.page === "truth" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <TruthFiles bookId={route.bookId} nav={nav} theme={theme} t={t} />
+            </div>
+          )}
+          {route.page === "daemon" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <DaemonControl nav={nav} theme={theme} t={t} sse={sse} />
+            </div>
+          )}
+          {route.page === "logs" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <LogViewer nav={nav} theme={theme} t={t} />
+            </div>
+          )}
+          {route.page === "genres" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <GenreManager nav={nav} theme={theme} t={t} />
+            </div>
+          )}
+          {route.page === "style" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <StyleManager nav={nav} theme={theme} t={t} />
+            </div>
+          )}
+          {route.page === "import" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <ImportManager nav={nav} theme={theme} t={t} />
+            </div>
+          )}
+          {route.page === "radar" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <RadarView nav={nav} theme={theme} t={t} />
+            </div>
+          )}
+          {route.page === "doctor" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <DoctorView nav={nav} theme={theme} t={t} />
+            </div>
+          )}
         </main>
       </div>
-
-      {/* Right Chat Panel */}
-      <ChatPanel
-        open={chatOpen}
-        onClose={() => setChatOpen(false)}
-        t={t}
-        sse={sse}
-        activeBookId={activeBookId}
-      />
     </div>
   );
 }

+ 1 - 1
packages/studio/src/api/book-create.ts

@@ -83,7 +83,7 @@ export async function waitForStudioBookReady(
   const retryDelayMs = options.retryDelayMs ?? 150;
 
   for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
-    const response = await fetchImpl(`/api/books/${encodeURIComponent(bookId)}`);
+    const response = await fetchImpl(`/api/v1/books/${encodeURIComponent(bookId)}`);
     if (response.ok) {
       return await response.json() as StudioBookDetail;
     }

+ 1 - 1
packages/studio/src/api/index.ts

@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
 
 const __dirname = dirname(fileURLToPath(import.meta.url));
 
-const root = process.argv[2] ?? process.env.INKOS_PROJECT_ROOT ?? process.cwd();
+const root = resolve(process.argv[2] ?? process.env.INKOS_PROJECT_ROOT ?? process.cwd());
 const port = parseInt(process.env.INKOS_STUDIO_PORT ?? "4567", 10);
 
 // Find studio package root (2 levels up from src/api/)

+ 89 - 47
packages/studio/src/api/server.test.ts

@@ -22,6 +22,15 @@ const processProjectInteractionRequestMock = vi.fn();
 const createInteractionToolsFromDepsMock = vi.fn(() => ({}));
 const loadProjectSessionMock = vi.fn();
 const resolveSessionActiveBookMock = vi.fn();
+const runAgentSessionMock = vi.fn();
+const findOrCreateBookSessionMock = vi.fn();
+const loadBookSessionMock = vi.fn();
+const persistBookSessionMock = vi.fn();
+const appendBookSessionMessageMock = vi.fn();
+const resolveServiceModelMock = vi.fn();
+const loadSecretsMock = vi.fn();
+const saveSecretsMock = vi.fn();
+const getServiceApiKeyMock = vi.fn();
 
 const logger = {
   child: () => logger,
@@ -112,6 +121,15 @@ vi.mock("@actalk/inkos-core", () => {
     createInteractionToolsFromDeps: createInteractionToolsFromDepsMock,
     loadProjectSession: loadProjectSessionMock,
     resolveSessionActiveBook: resolveSessionActiveBookMock,
+    runAgentSession: runAgentSessionMock,
+    findOrCreateBookSession: findOrCreateBookSessionMock,
+    loadBookSession: loadBookSessionMock,
+    persistBookSession: persistBookSessionMock,
+    appendBookSessionMessage: appendBookSessionMessageMock,
+    resolveServiceModel: resolveServiceModelMock,
+    loadSecrets: loadSecretsMock,
+    saveSecrets: saveSecretsMock,
+    getServiceApiKey: getServiceApiKeyMock,
     GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),
   };
 });
@@ -262,6 +280,36 @@ describe("createStudioServer daemon lifecycle", () => {
     saveChapterIndexMock.mockResolvedValue(undefined);
     rollbackToChapterMock.mockResolvedValue([]);
     pipelineConfigs.length = 0;
+    runAgentSessionMock.mockReset();
+    findOrCreateBookSessionMock.mockReset();
+    loadBookSessionMock.mockReset();
+    persistBookSessionMock.mockReset();
+    appendBookSessionMessageMock.mockReset();
+    resolveServiceModelMock.mockReset();
+    loadSecretsMock.mockReset();
+    saveSecretsMock.mockReset();
+    getServiceApiKeyMock.mockReset();
+    // Default BookSession for agent tests
+    const defaultBookSession = {
+      sessionId: "agent-session-1",
+      projectRoot: root,
+      activeBookId: "demo-book",
+      messages: [],
+      events: [],
+    };
+    findOrCreateBookSessionMock.mockResolvedValue(defaultBookSession);
+    loadBookSessionMock.mockResolvedValue(null);
+    persistBookSessionMock.mockResolvedValue(undefined);
+    appendBookSessionMessageMock.mockImplementation(
+      (session: unknown, _msg: unknown) => session,
+    );
+    runAgentSessionMock.mockResolvedValue({
+      responseText: "Agent response.",
+      messages: [],
+    });
+    loadSecretsMock.mockResolvedValue({ services: {} });
+    saveSecretsMock.mockResolvedValue(undefined);
+    getServiceApiKeyMock.mockResolvedValue(undefined);
   });
 
   afterEach(async () => {
@@ -281,7 +329,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
     const responseOrTimeout = await Promise.race([
-      app.request("http://localhost/api/daemon/start", { method: "POST" }),
+      app.request("http://localhost/api/v1/daemon/start", { method: "POST" }),
       new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 30)),
     ]);
 
@@ -291,7 +339,7 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(response.status).toBe(200);
     await expect(response.json()).resolves.toMatchObject({ ok: true, running: true });
 
-    const status = await app.request("http://localhost/api/daemon");
+    const status = await app.request("http://localhost/api/v1/daemon");
     await expect(status.json()).resolves.toEqual({ running: true });
 
     resolveStart?.();
@@ -301,7 +349,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/books/..%2Fetc%2Fpasswd", {
+    const response = await app.request("http://localhost/api/v1/books/..%2Fetc%2Fpasswd", {
       method: "GET",
     });
 
@@ -326,14 +374,14 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const readAuthorIntent = await app.request("http://localhost/api/books/demo-book/truth/author_intent.md");
+    const readAuthorIntent = await app.request("http://localhost/api/v1/books/demo-book/truth/author_intent.md");
     expect(readAuthorIntent.status).toBe(200);
     await expect(readAuthorIntent.json()).resolves.toMatchObject({
       file: "author_intent.md",
       content: "# Author Intent\n\nStay cold.\n",
     });
 
-    const updateCurrentFocus = await app.request("http://localhost/api/books/demo-book/truth/current_focus.md", {
+    const updateCurrentFocus = await app.request("http://localhost/api/v1/books/demo-book/truth/current_focus.md", {
       method: "PUT",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ content: "# Current Focus\n\nPull focus back to the harbor trail.\n" }),
@@ -349,7 +397,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const save = await app.request("http://localhost/api/project", {
+    const save = await app.request("http://localhost/api/v1/project", {
       method: "PUT",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({
@@ -362,7 +410,7 @@ describe("createStudioServer daemon lifecycle", () => {
 
     expect(save.status).toBe(200);
 
-    const project = await app.request("http://localhost/api/project");
+    const project = await app.request("http://localhost/api/v1/project");
     await expect(project.json()).resolves.toMatchObject({
       language: "en",
       temperature: 0.2,
@@ -394,7 +442,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(startupConfig as never, root);
 
-    const response = await app.request("http://localhost/api/doctor");
+    const response = await app.request("http://localhost/api/v1/doctor");
 
     expect(response.status).toBe(200);
     expect(createLLMClientMock).toHaveBeenCalledWith(expect.objectContaining({
@@ -432,7 +480,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(startupConfig as never, root);
 
-    const response = await app.request("http://localhost/api/radar/scan", {
+    const response = await app.request("http://localhost/api/v1/radar/scan", {
       method: "POST",
     });
 
@@ -451,7 +499,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const save = await app.request("http://localhost/api/project/language", {
+    const save = await app.request("http://localhost/api/v1/project/language", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ language: "en" }),
@@ -459,7 +507,7 @@ describe("createStudioServer daemon lifecycle", () => {
 
     expect(save.status).toBe(200);
 
-    const project = await app.request("http://localhost/api/project");
+    const project = await app.request("http://localhost/api/v1/project");
     await expect(project.json()).resolves.toMatchObject({
       language: "en",
       languageExplicit: true,
@@ -474,7 +522,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/books/create", {
+    const response = await app.request("http://localhost/api/v1/books/create", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({
@@ -499,7 +547,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/books/create", {
+    const response = await app.request("http://localhost/api/v1/books/create", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({
@@ -513,7 +561,7 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(response.status).toBe(200);
     await Promise.resolve();
 
-    const status = await app.request("http://localhost/api/books/broken-book/create-status");
+    const status = await app.request("http://localhost/api/v1/books/broken-book/create-status");
     expect(status.status).toBe(200);
     await expect(status.json()).resolves.toMatchObject({
       status: "error",
@@ -549,7 +597,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/books/demo-book/chapters/3/reject", {
+    const response = await app.request("http://localhost/api/v1/books/demo-book/chapters/3/reject", {
       method: "POST",
     });
 
@@ -569,7 +617,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/books/create", {
+    const response = await app.request("http://localhost/api/v1/books/create", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({
@@ -602,7 +650,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/books/demo-book/revise/3", {
+    const response = await app.request("http://localhost/api/v1/books/demo-book/revise/3", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ mode: "rewrite", brief: "把注意力拉回师债主线。" }),
@@ -617,7 +665,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/books/demo-book/resync/3", {
+    const response = await app.request("http://localhost/api/v1/books/demo-book/resync/3", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ brief: "以师债线为准同步状态。" }),
@@ -632,7 +680,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/books/demo-book/export-save", {
+    const response = await app.request("http://localhost/api/v1/books/demo-book/export-save", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ format: "md", approvedOnly: true }),
@@ -655,26 +703,19 @@ describe("createStudioServer daemon lifecycle", () => {
     });
   });
 
-  it("routes /api/agent through the shared interaction control layer", async () => {
-    processProjectInteractionInputMock.mockResolvedValue({
-      request: { intent: "write_next", bookId: "demo-book" },
+  it("routes /api/agent through runAgentSession and returns response + sessionId", async () => {
+    runAgentSessionMock.mockResolvedValueOnce({
       responseText: "Completed write_next for demo-book.",
-      session: {
-        sessionId: "session-1",
-        projectRoot: root,
-        activeBookId: "demo-book",
-        automationMode: "semi",
-        messages: [
-          { role: "user", content: "continue", timestamp: 1 },
-          { role: "assistant", content: "Completed write_next for demo-book.", timestamp: 2 },
-        ],
-      },
+      messages: [
+        { role: "user", content: "continue" },
+        { role: "assistant", content: "Completed write_next for demo-book." },
+      ],
     });
 
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/agent", {
+    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" }),
@@ -683,26 +724,27 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(response.status).toBe(200);
     await expect(response.json()).resolves.toMatchObject({
       response: "Completed write_next for demo-book.",
-      request: { intent: "write_next", bookId: "demo-book" },
       session: expect.objectContaining({
-        activeBookId: "demo-book",
+        sessionId: "agent-session-1",
       }),
     });
-    expect(createInteractionToolsFromDepsMock).toHaveBeenCalledTimes(1);
-    expect(processProjectInteractionInputMock).toHaveBeenCalledWith(expect.objectContaining({
-      projectRoot: root,
-      input: "continue",
-      activeBookId: "demo-book",
-    }));
+    expect(runAgentSessionMock).toHaveBeenCalledWith(
+      expect.objectContaining({
+        bookId: "demo-book",
+        projectRoot: root,
+      }),
+      "continue",
+      expect.any(Array),
+    );
   });
 
-  it("returns 500 with an error payload when the shared agent execution fails", async () => {
-    processProjectInteractionInputMock.mockRejectedValueOnce(new Error("boom"));
+  it("returns 500 with an error payload when the agent session fails", async () => {
+    runAgentSessionMock.mockRejectedValueOnce(new Error("boom"));
 
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/agent", {
+    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" }),
@@ -711,7 +753,7 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(response.status).toBe(500);
     await expect(response.json()).resolves.toEqual({
       error: {
-        code: "INTERACTION_ERROR",
+        code: "AGENT_ERROR",
         message: "boom",
       },
     });
@@ -732,7 +774,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/interaction/session");
+    const response = await app.request("http://localhost/api/v1/interaction/session");
 
     expect(response.status).toBe(200);
     await expect(response.json()).resolves.toMatchObject({
@@ -763,7 +805,7 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/interaction/session");
+    const response = await app.request("http://localhost/api/v1/interaction/session");
 
     expect(response.status).toBe(200);
     await expect(response.json()).resolves.toMatchObject({

Разница между файлами не показана из-за своего большого размера
+ 546 - 78
packages/studio/src/api/server.ts


+ 0 - 614
packages/studio/src/components/ChatBar.tsx

@@ -1,614 +0,0 @@
-import { useState, useRef, useEffect, useMemo } from "react";
-import type { TFunction } from "../hooks/use-i18n";
-import type { SSEMessage } from "../hooks/use-sse";
-import { cn } from "../lib/utils";
-import { fetchJson, postApi } from "../hooks/use-api";
-import {
-  // Panel controls
-  Sparkles,
-  Trash2,
-  PanelRightClose,
-  ArrowUp,
-  Loader2,
-  MessageSquare,
-  Lightbulb,
-  // Message avatars
-  User,
-  CheckCircle2,
-  XCircle,
-  BotMessageSquare,
-  // Message content badges
-  BadgeCheck,
-  CircleAlert,
-  // Status phase icons
-  Brain,
-  PenTool,
-  Shield,
-  Wrench,
-  AlertTriangle,
-  // Quick command chips
-  Zap,
-  Search,
-  FileOutput,
-  TrendingUp,
-} from "lucide-react";
-
-// ── Types ──
-
-interface ChatMessage {
-  readonly role: "user" | "assistant";
-  readonly content: string;
-  readonly timestamp: number;
-}
-
-interface SharedSessionMeta {
-  readonly activeBookId?: string;
-  readonly automationMode?: string;
-  readonly currentStage?: string;
-  readonly pendingSummary?: string;
-  readonly draftTitle?: string;
-}
-
-interface BookRef {
-  readonly id: string;
-}
-
-export function coerceSharedSessionMessages(
-  messages: ReadonlyArray<{ role: "user" | "assistant" | "system"; content: string; timestamp: number }>,
-): ReadonlyArray<ChatMessage> {
-  return messages
-    .filter((message) => message.role === "user" || message.role === "assistant")
-    .map((message) => ({
-      role: message.role as "user" | "assistant",
-      content: message.content,
-      timestamp: message.timestamp,
-    }));
-}
-
-export function resolveDirectWriteTarget(
-  activeBookId: string | undefined,
-  books: ReadonlyArray<BookRef>,
-): { bookId: string | null; reason: "active" | "single" | "missing" | "ambiguous" } {
-  if (activeBookId && books.some((book) => book.id === activeBookId)) {
-    return { bookId: activeBookId, reason: "active" };
-  }
-  if (books.length === 1) {
-    return { bookId: books[0]!.id, reason: "single" };
-  }
-  if (books.length === 0) {
-    return { bookId: null, reason: "missing" };
-  }
-  return { bookId: null, reason: "ambiguous" };
-}
-
-export function formatSharedSessionContext(meta: SharedSessionMeta): string {
-  return [
-    meta.activeBookId ?? "no-book",
-    meta.draftTitle ? `draft:${meta.draftTitle}` : undefined,
-    meta.automationMode ?? "semi",
-    meta.currentStage,
-  ].filter(Boolean).join(" · ");
-}
-
-// ── Sub-components ──
-
-function StatusIcon({ phase }: { readonly phase: string }) {
-  const lower = phase.toLowerCase();
-
-  if (lower.includes("think") || lower.includes("plan"))
-    return <Brain size={14} className="text-purple-500 animate-pulse" />;
-  if (lower.includes("writ") || lower.includes("draft") || lower.includes("stream"))
-    return <PenTool size={14} className="text-blue-500 chat-icon-write" />;
-  if (lower.includes("audit") || lower.includes("review"))
-    return <Shield size={14} className="text-amber-500 animate-pulse" />;
-  if (lower.includes("revis") || lower.includes("fix") || lower.includes("spot"))
-    return <Wrench size={14} className="text-orange-500 chat-icon-spin-slow" />;
-  if (lower.includes("complet") || lower.includes("done") || lower.includes("success"))
-    return <CheckCircle2 size={14} className="text-emerald-500 chat-icon-pop" />;
-  if (lower.includes("error") || lower.includes("fail"))
-    return <AlertTriangle size={14} className="text-destructive animate-pulse" />;
-  return <Loader2 size={14} className="text-primary animate-spin" />;
-}
-
-function EmptyState() {
-  return (
-    <div className="h-full flex flex-col items-center justify-center text-center opacity-40 select-none fade-in">
-      <div className="w-14 h-14 rounded-2xl border border-dashed border-border flex items-center justify-center mb-4 bg-secondary/30">
-        <BotMessageSquare size={24} className="text-muted-foreground" />
-      </div>
-      <p className="text-sm italic font-serif mb-1">How shall we proceed today?</p>
-      <p className="text-[10px] text-muted-foreground/60 uppercase tracking-widest">Type a command below</p>
-    </div>
-  );
-}
-
-function ThinkingBubble() {
-  return (
-    <div className="flex gap-2.5 chat-msg-assistant">
-      <div className="w-7 h-7 rounded-lg bg-secondary flex items-center justify-center shrink-0 chat-thinking-glow">
-        <Brain size={14} className="text-primary animate-pulse" />
-      </div>
-      <div className="bg-card border border-border/50 px-3.5 py-2.5 rounded-2xl rounded-tl-sm flex gap-1.5 items-center">
-        <span className="w-1.5 h-1.5 bg-primary/50 rounded-full chat-typing-dot" />
-        <span className="w-1.5 h-1.5 bg-primary/50 rounded-full chat-typing-dot" />
-        <span className="w-1.5 h-1.5 bg-primary/50 rounded-full chat-typing-dot" />
-      </div>
-    </div>
-  );
-}
-
-function MessageBubble({ msg }: { readonly msg: ChatMessage }) {
-  const isUser = msg.role === "user";
-  const isStatus = msg.content.startsWith("⋯");
-  const isSuccess = msg.content.startsWith("✓");
-  const isError = msg.content.startsWith("✗");
-
-  return (
-    <div className={cn(
-      "flex gap-2.5",
-      isUser ? "flex-row-reverse chat-msg-user" : "chat-msg-assistant",
-    )}>
-      {/* Avatar */}
-      <div className={cn(
-        "w-7 h-7 rounded-lg flex items-center justify-center shrink-0 mt-0.5 transition-colors",
-        isUser ? "bg-primary/10" : "bg-secondary",
-      )}>
-        {isUser ? (
-          <User size={14} className="text-primary" />
-        ) : isSuccess ? (
-          <CheckCircle2 size={14} className="text-emerald-500 chat-icon-pop" />
-        ) : isError ? (
-          <XCircle size={14} className="text-destructive" />
-        ) : isStatus ? (
-          <Loader2 size={14} className="text-primary animate-spin" />
-        ) : (
-          <Sparkles size={14} className="text-primary" />
-        )}
-      </div>
-
-      {/* Bubble */}
-      <div className={cn(
-        "max-w-[80%] rounded-2xl px-3.5 py-2 text-sm leading-relaxed shadow-sm",
-        isUser
-          ? "bg-primary text-primary-foreground font-medium rounded-tr-sm"
-          : isStatus
-            ? "bg-secondary/50 border border-border/30 text-muted-foreground font-mono text-xs rounded-tl-sm"
-            : "bg-card border border-border/50 text-foreground font-serif rounded-tl-sm",
-      )}>
-        {/* Status badge */}
-        {isSuccess && (
-          <span className="flex items-center gap-1.5 text-emerald-600 dark:text-emerald-400 font-sans font-medium text-[10px] mb-1 uppercase tracking-wider">
-            <BadgeCheck size={11} />
-            Complete
-          </span>
-        )}
-        {isError && (
-          <span className="flex items-center gap-1.5 text-destructive font-sans font-medium text-[10px] mb-1 uppercase tracking-wider">
-            <CircleAlert size={11} />
-            Error
-          </span>
-        )}
-
-        <div>{msg.content}</div>
-
-        {/* Timestamp */}
-        <div className={cn(
-          "text-[9px] mt-1.5 font-mono",
-          isUser ? "text-primary-foreground/40" : "text-muted-foreground/40",
-        )}>
-          {new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
-        </div>
-      </div>
-    </div>
-  );
-}
-
-function QuickChip({ icon, label, onClick }: {
-  readonly icon: React.ReactNode;
-  readonly label: string;
-  readonly onClick: () => void;
-}) {
-  return (
-    <button
-      onClick={onClick}
-      className="shrink-0 flex items-center gap-1 px-2.5 py-1 rounded-lg bg-secondary/50 border border-border/30 text-[10px] font-medium text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/5 transition-all group"
-    >
-      <span className="group-hover:scale-110 transition-transform">{icon}</span>
-      {label}
-    </button>
-  );
-}
-
-// ── Main Component ──
-
-export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
-  readonly open: boolean;
-  readonly onClose: () => void;
-  readonly t: TFunction;
-  readonly sse: { messages: ReadonlyArray<SSEMessage>; connected: boolean };
-  readonly activeBookId?: string;
-}) {
-  const [input, setInput] = useState("");
-  const [messages, setMessages] = useState<ReadonlyArray<ChatMessage>>([]);
-  const [loading, setLoading] = useState(false);
-  const [sessionMeta, setSessionMeta] = useState<SharedSessionMeta>({});
-  const inputRef = useRef<HTMLInputElement>(null);
-  const scrollRef = useRef<HTMLDivElement>(null);
-
-  // Auto-scroll on new messages
-  useEffect(() => {
-    if (scrollRef.current) {
-      scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
-    }
-  }, [messages, sse.messages.length]);
-
-  // Focus input when panel opens
-  useEffect(() => {
-    if (open) {
-      setTimeout(() => inputRef.current?.focus(), 300);
-    }
-  }, [open]);
-
-  useEffect(() => {
-    if (!open) return;
-
-    let cancelled = false;
-    void fetchJson<{
-      session?: {
-        activeBookId?: string;
-        automationMode?: string;
-        creationDraft?: {
-          title?: string;
-        };
-        currentExecution?: {
-          status?: string;
-          stageLabel?: string;
-        };
-        pendingDecision?: {
-          summary?: string;
-        };
-        messages?: ReadonlyArray<{ role: "user" | "assistant" | "system"; content: string; timestamp: number }>;
-      };
-      activeBookId?: string;
-    }>("/interaction/session").then((data) => {
-      if (cancelled) return;
-      setSessionMeta({
-        activeBookId: data.activeBookId ?? data.session?.activeBookId,
-        draftTitle: data.session?.creationDraft?.title,
-        automationMode: data.session?.automationMode,
-        currentStage: data.session?.currentExecution?.stageLabel ?? data.session?.currentExecution?.status,
-        pendingSummary: data.session?.pendingDecision?.summary,
-      });
-      setMessages((current) => {
-        if (current.length > 0) return current;
-        return coerceSharedSessionMessages(data.session?.messages ?? []);
-      });
-    }).catch(() => {
-      // keep local empty state on session fetch failures
-    });
-
-    return () => {
-      cancelled = true;
-    };
-  }, [open]);
-
-  // SSE events → assistant messages
-  const loadingRef = useRef(false);
-  loadingRef.current = loading;
-
-  useEffect(() => {
-    const recent = sse.messages.slice(-1)[0];
-    if (!recent || recent.event === "ping") return;
-
-    const d = recent.data as Record<string, unknown>;
-
-    if (recent.event === "write:complete" || recent.event === "draft:complete") {
-      setLoading(false);
-      const title = d.title ?? `Chapter ${d.chapterNumber}`;
-      setMessages((prev) => [...prev, {
-        role: "assistant",
-        content: `✓ ${title} (${(d.wordCount as number)?.toLocaleString() ?? "?"} chars)`,
-        timestamp: Date.now(),
-      }]);
-    }
-    if (recent.event === "write:error" || recent.event === "draft:error") {
-      setLoading(false);
-      setMessages((prev) => [...prev, {
-        role: "assistant",
-        content: `✗ ${d.error ?? "Unknown error"}`,
-        timestamp: Date.now(),
-      }]);
-    }
-    if (recent.event === "log" && loadingRef.current) {
-      const msg = d.message as string;
-      if (msg && (msg.includes("Phase") || msg.includes("streaming") || msg.includes("Writing") || msg.includes("Audit") || msg.includes("Revis"))) {
-        setMessages((prev) => {
-          const last = prev[prev.length - 1];
-          if (last?.role === "assistant" && last.content.startsWith("⋯")) {
-            return [...prev.slice(0, -1), { role: "assistant", content: `⋯ ${msg}`, timestamp: Date.now() }];
-          }
-          return [...prev, { role: "assistant", content: `⋯ ${msg}`, timestamp: Date.now() }];
-        });
-      }
-    }
-  }, [sse.messages.length]);
-
-  // Current phase for status bar
-  const currentPhase = useMemo(() => {
-    const lastStatus = [...messages].reverse().find((m) => m.role === "assistant" && m.content.startsWith("⋯"));
-    return lastStatus?.content.replace("⋯ ", "") ?? "Initializing...";
-  }, [messages]);
-
-  const executeCommand = async (text: string) => {
-    const lower = text.toLowerCase();
-
-    try {
-      if (lower.match(/^(写下一章|write next)/)) {
-        const { books } = await fetchJson<{ books: ReadonlyArray<BookRef> }>("/books");
-        const target = resolveDirectWriteTarget(activeBookId, books);
-
-        if (target.bookId) {
-          setMessages((prev) => [...prev, {
-            role: "assistant",
-            content: isZh ? `⋯ 开始处理《${target.bookId}》...` : `⋯ Starting ${target.bookId}...`,
-            timestamp: Date.now(),
-          }]);
-          await postApi(`/books/${target.bookId}/write-next`, {});
-          return;
-        }
-
-        setLoading(false);
-        setMessages((prev) => [...prev, {
-          role: "assistant",
-          content:
-            target.reason === "missing"
-              ? (isZh ? "\u2717 \u8fd8\u6ca1\u6709\u4e66\uff0c\u5148\u521b\u5efa\u4e00\u672c\u518d\u5199\u3002" : "\u2717 No books yet. Create one first.")
-              : (isZh ? "\u2717 \u5f53\u524d\u6709\u591a\u672c\u4e66\uff0c\u8bf7\u5148\u6253\u5f00\u76ee\u6807\u4e66\u7c4d\u540e\u518d\u6267\u884c\u201c\u5199\u4e0b\u4e00\u7ae0\u201d\u3002" : '\u2717 Multiple books found. Open the target book first, then run "write next".'),
-          timestamp: Date.now(),
-        }]);
-        return;
-      }
-
-      const data = await fetchJson<{
-        response?: string;
-        error?: string;
-        session?: {
-          activeBookId?: string;
-          automationMode?: string;
-          creationDraft?: { title?: string };
-          currentExecution?: { status?: string; stageLabel?: string };
-          pendingDecision?: { summary?: string };
-          messages?: ReadonlyArray<{ role: "user" | "assistant" | "system"; content: string; timestamp: number }>;
-        };
-      }>("/agent", {
-        method: "POST",
-        headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({ instruction: text, activeBookId }),
-      });
-      setLoading(false);
-      if (data.session) {
-        setSessionMeta({
-          activeBookId: data.session.activeBookId ?? activeBookId,
-          draftTitle: data.session.creationDraft?.title,
-          automationMode: data.session.automationMode,
-          currentStage: data.session.currentExecution?.stageLabel ?? data.session.currentExecution?.status,
-          pendingSummary: data.session.pendingDecision?.summary,
-        });
-      }
-      setMessages((prev) => [...prev, {
-        role: "assistant",
-        content: data.response ?? "Acknowledged.",
-        timestamp: Date.now(),
-      }]);
-    } catch (e) {
-      setLoading(false);
-      setMessages((prev) => [...prev, {
-        role: "assistant",
-        content: `✗ ${e instanceof Error ? e.message : String(e)}`,
-        timestamp: Date.now(),
-      }]);
-    }
-  };
-
-  const handleSubmit = async () => {
-    const text = input.trim();
-    if (!text || loading) return;
-    setInput("");
-    setMessages((prev) => [...prev, { role: "user", content: text, timestamp: Date.now() }]);
-    setLoading(true);
-    await executeCommand(text);
-  };
-
-  const handleQuickCommand = async (command: string) => {
-    if (loading) return;
-    setInput("");
-    setMessages((prev) => [...prev, { role: "user", content: command, timestamp: Date.now() }]);
-    setLoading(true);
-    await executeCommand(command);
-  };
-
-  const isZh = t("nav.connected") === "已连接";
-
-  // Rotating tips
-  const TIPS_ZH = [
-    "写下一章", "审计第5章", "帮我创建一本都市修仙小说",
-    "扫描市场趋势", "导出全书为 epub", "分析文风 → 导入到我的书",
-    "导入已有章节续写", "创建一个玄幻题材的同人", "修订第5章,spot-fix",
-  ];
-  const TIPS_EN = [
-    "write next chapter", "audit chapter 5", "create a LitRPG novel",
-    "scan market trends", "export book as epub", "analyze style → import",
-    "import chapters to continue", "create a progression fantasy fanfic", "revise chapter 5, spot-fix",
-  ];
-  const tips = isZh ? TIPS_ZH : TIPS_EN;
-  const [tipIndex, setTipIndex] = useState(() => Math.floor(Math.random() * tips.length));
-
-  useEffect(() => {
-    if (input) return;
-    const timer = setInterval(() => setTipIndex((i) => (i + 1) % tips.length), 8000);
-    return () => clearInterval(timer);
-  }, [input, tips.length]);
-
-  return (
-    <aside
-      className={cn(
-        "h-full flex flex-col border-l border-border/40 bg-background/80 backdrop-blur-md chat-panel-enter shrink-0 overflow-hidden",
-        open ? "w-[380px] opacity-100" : "w-0 opacity-0",
-      )}
-    >
-      {open && (
-        <>
-          {/* ── Section 1: Header ── */}
-          <div className="h-12 shrink-0 px-4 flex items-center justify-between border-b border-border/40">
-            <div className="flex items-center gap-2.5 min-w-0">
-              <div className="relative shrink-0">
-                <Sparkles size={15} className="text-primary chat-icon-glow" />
-                {loading && (
-                  <span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-primary rounded-full animate-ping" />
-                )}
-              </div>
-              <div className="min-w-0">
-                <div className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-foreground">
-                  InkOS Assistant
-                </div>
-                <div className="text-[10px] text-muted-foreground/60 truncate">
-                  {formatSharedSessionContext({
-                    activeBookId: sessionMeta.activeBookId ?? activeBookId,
-                    automationMode: sessionMeta.automationMode,
-                    currentStage: sessionMeta.currentStage,
-                  })}
-                </div>
-              </div>
-            </div>
-            <div className="flex items-center gap-1">
-              <button
-                onClick={() => setMessages([])}
-                className="p-1.5 rounded-md text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors group"
-                title="Clear conversation"
-              >
-                <Trash2 size={14} className="group-hover:animate-[shake_0.3s_ease-in-out]" />
-              </button>
-              <button
-                onClick={onClose}
-                className="p-1.5 rounded-md text-muted-foreground hover:bg-secondary transition-colors group"
-                title="Close panel"
-              >
-                <PanelRightClose size={14} className="group-hover:translate-x-0.5 transition-transform" />
-              </button>
-            </div>
-          </div>
-
-          {/* ── Section 2: Status Bar (when loading) ── */}
-          {loading && (
-            <div className="shrink-0 px-4 py-2 border-b border-border/30 bg-primary/[0.03] fade-in">
-              <div className="flex items-center gap-2.5">
-                <StatusIcon phase={currentPhase} />
-                <span className="text-xs font-medium text-primary truncate flex-1">
-                  {currentPhase}
-                </span>
-                <div className="flex gap-1">
-                  <span className="w-1 h-1 bg-primary/40 rounded-full chat-typing-dot" />
-                  <span className="w-1 h-1 bg-primary/40 rounded-full chat-typing-dot" />
-                  <span className="w-1 h-1 bg-primary/40 rounded-full chat-typing-dot" />
-                </div>
-              </div>
-            </div>
-          )}
-
-          {!loading && sessionMeta.pendingSummary && (
-            <div className="shrink-0 px-4 py-2 border-b border-border/30 bg-amber-500/[0.06] fade-in">
-              <div className="flex items-center gap-2 text-[11px] text-amber-700 dark:text-amber-300">
-                <AlertTriangle size={12} />
-                <span className="truncate">{sessionMeta.pendingSummary}</span>
-              </div>
-            </div>
-          )}
-
-          {/* ── Section 3: Messages ── */}
-          <div
-            ref={scrollRef}
-            className="flex-1 overflow-y-auto px-4 py-4 space-y-3"
-          >
-            {messages.length === 0 && !loading && <EmptyState />}
-
-            {messages.map((msg, i) => (
-              <MessageBubble key={`${msg.timestamp}-${i}`} msg={msg} />
-            ))}
-
-            {loading && !messages.some((m) => m.content.startsWith("⋯")) && (
-              <ThinkingBubble />
-            )}
-          </div>
-
-          {/* ── Section 4: Quick Commands ── */}
-          <div className="shrink-0 px-3 py-2 border-t border-border/30 flex gap-1.5 overflow-x-auto">
-            <QuickChip
-              icon={<Zap size={11} />}
-              label={t("dash.writeNext")}
-              onClick={() => void handleQuickCommand(isZh ? "写下一章" : "write next")}
-            />
-            <QuickChip
-              icon={<Search size={11} />}
-              label={t("book.audit")}
-              onClick={() => void handleQuickCommand(isZh ? "审计第1章" : "audit chapter 1")}
-            />
-            <QuickChip
-              icon={<FileOutput size={11} />}
-              label={t("book.export")}
-              onClick={() => void handleQuickCommand(isZh ? "导出全书" : "export book as epub")}
-            />
-            <QuickChip
-              icon={<TrendingUp size={11} />}
-              label={t("nav.radar")}
-              onClick={() => void handleQuickCommand(isZh ? "扫描市场趋势" : "scan market trends")}
-            />
-          </div>
-
-          {/* ── Section 5: Input ── */}
-          <div className="shrink-0 p-3 border-t border-border/40">
-            <div className="flex items-center gap-2 rounded-xl bg-secondary/30 border border-border/40 px-3 py-1.5 focus-within:border-primary/40 focus-within:ring-2 focus-within:ring-primary/10 transition-all">
-              <MessageSquare size={14} className="text-muted-foreground/50 shrink-0" />
-              <input
-                ref={inputRef}
-                type="text"
-                value={input}
-                onChange={(e) => setInput(e.target.value)}
-                onKeyDown={(e) => {
-                  if (e.key === "Enter" && !e.shiftKey) {
-                    e.preventDefault();
-                    handleSubmit();
-                  }
-                }}
-                placeholder={t("common.enterCommand")}
-                disabled={loading}
-                className="flex-1 bg-transparent text-sm placeholder:text-muted-foreground/50 outline-none ring-0 shadow-none disabled:opacity-50"
-                style={{ outline: "none", boxShadow: "none" }}
-              />
-              <button
-                onClick={handleSubmit}
-                disabled={!input.trim() || loading}
-                className="w-7 h-7 rounded-lg bg-primary text-primary-foreground flex items-center justify-center 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} />
-                )}
-              </button>
-            </div>
-
-            {/* Rotating tip */}
-            {!input && (
-              <div className="mt-1.5 px-1 flex items-center gap-1.5">
-                <Lightbulb size={10} className="text-muted-foreground/30 shrink-0" />
-                <span className="text-[9px] text-muted-foreground/40 truncate fade-in" key={tipIndex}>
-                  {tips[tipIndex]}
-                </span>
-              </div>
-            )}
-          </div>
-        </>
-      )}
-    </aside>
-  );
-}

+ 20 - 18
packages/studio/src/components/Sidebar.tsx

@@ -29,7 +29,7 @@ interface Nav {
   toDashboard: () => void;
   toBook: (id: string) => void;
   toBookCreate: () => void;
-  toConfig: () => void;
+  toServices: () => void;
   toDaemon: () => void;
   toLogs: () => void;
   toGenres: () => void;
@@ -87,10 +87,10 @@ export function Sidebar({ nav, activePage, sse, t }: {
             </span>
             <button
               onClick={nav.toBookCreate}
-              className="p-1 rounded-md text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all group"
-              title={t("nav.newBook")}
+              className="flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all"
             >
-              <Plus size={14} className="group-hover:rotate-90 transition-transform duration-300" />
+              <Plus size={12} />
+              <span>{t("nav.newBook")}</span>
             </button>
           </div>
 
@@ -101,7 +101,7 @@ export function Sidebar({ nav, activePage, sse, t }: {
                 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-semibold"
+                    ? "bg-primary/10 text-primary font-medium"
                     : "text-foreground font-medium hover:text-foreground hover:bg-secondary/50"
                 }`}
               >
@@ -140,17 +140,17 @@ export function Sidebar({ nav, activePage, sse, t }: {
             <SidebarItem
               label={t("nav.config")}
               icon={<Settings size={16} />}
-              active={activePage === "config"}
-              onClick={nav.toConfig}
+              active={activePage === "services"}
+              onClick={nav.toServices}
             />
-            <SidebarItem
+{/*            <SidebarItem
               label={t("nav.daemon")}
               icon={<Zap size={16} />}
               active={activePage === "daemon"}
               onClick={nav.toDaemon}
               badge={daemon?.running ? t("nav.running") : undefined}
               badgeColor={daemon?.running ? "bg-emerald-500/10 text-emerald-500" : "bg-muted text-muted-foreground"}
-            />
+            />*/}
             <SidebarItem
               label={t("nav.logs")}
               icon={<Terminal size={16} />}
@@ -196,15 +196,17 @@ export function Sidebar({ nav, activePage, sse, t }: {
         </div>
       </div>
 
-      {/* Footer / Status Area */}
-      <div className="p-4 border-t border-border bg-secondary/40">
-        <div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-card border border-border shadow-sm">
-          <div className={`w-2 h-2 rounded-full ${daemon?.running ? "bg-emerald-500 animate-pulse" : "bg-muted-foreground/40"}`} />
-          <span className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider">
-            {daemon?.running ? t("nav.agentOnline") : t("nav.agentOffline")}
-          </span>
+      {/* Footer / Status Area — only show when agent is online */}
+      {daemon?.running && (
+        <div className="p-4 border-t border-border bg-secondary/40">
+          <div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-card border border-border shadow-sm">
+            <div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
+            <span className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider">
+              {t("nav.agentOnline")}
+            </span>
+          </div>
         </div>
-      </div>
+      )}
     </aside>
   );
 }
@@ -222,7 +224,7 @@ function SidebarItem({ label, icon, active, onClick, badge, badgeColor }: {
       onClick={onClick}
       className={`w-full group flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all duration-200 ${
         active
-          ? "bg-secondary text-foreground font-semibold shadow-sm border border-border"
+          ? "bg-secondary text-foreground font-medium shadow-sm border border-border"
           : "text-foreground font-medium hover:text-foreground hover:bg-secondary/50"
       }`}
     >

+ 562 - 0
packages/studio/src/components/ai-elements/code-block.tsx

@@ -0,0 +1,562 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { cn } from "@/lib/utils";
+import { CheckIcon, CopyIcon } from "lucide-react";
+import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
+import {
+  createContext,
+  memo,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
+import type {
+  BundledLanguage,
+  BundledTheme,
+  HighlighterGeneric,
+  ThemedToken,
+} from "shiki";
+import { createHighlighter } from "shiki";
+
+// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
+// oxlint-disable-next-line eslint(no-bitwise)
+const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
+// oxlint-disable-next-line eslint(no-bitwise)
+const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
+const isUnderline = (fontStyle: number | undefined) =>
+  // oxlint-disable-next-line eslint(no-bitwise)
+  fontStyle && fontStyle & 4;
+
+// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
+interface KeyedToken {
+  token: ThemedToken;
+  key: string;
+}
+interface KeyedLine {
+  tokens: KeyedToken[];
+  key: string;
+}
+
+const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
+  lines.map((line, lineIdx) => ({
+    key: `line-${lineIdx}`,
+    tokens: line.map((token, tokenIdx) => ({
+      key: `line-${lineIdx}-${tokenIdx}`,
+      token,
+    })),
+  }));
+
+// Token rendering component
+const TokenSpan = ({ token }: { token: ThemedToken }) => (
+  <span
+    className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
+    style={
+      {
+        backgroundColor: token.bgColor,
+        color: token.color,
+        fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
+        fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
+        textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
+        ...token.htmlStyle,
+      } as CSSProperties
+    }
+  >
+    {token.content}
+  </span>
+);
+
+// Line number styles using CSS counters
+const LINE_NUMBER_CLASSES = cn(
+  "block",
+  "before:content-[counter(line)]",
+  "before:inline-block",
+  "before:[counter-increment:line]",
+  "before:w-8",
+  "before:mr-4",
+  "before:text-right",
+  "before:text-muted-foreground/50",
+  "before:font-mono",
+  "before:select-none"
+);
+
+// Line rendering component
+const LineSpan = ({
+  keyedLine,
+  showLineNumbers,
+}: {
+  keyedLine: KeyedLine;
+  showLineNumbers: boolean;
+}) => (
+  <span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
+    {keyedLine.tokens.length === 0
+      ? "\n"
+      : keyedLine.tokens.map(({ token, key }) => (
+          <TokenSpan key={key} token={token} />
+        ))}
+  </span>
+);
+
+// Types
+type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
+  code: string;
+  language: BundledLanguage;
+  showLineNumbers?: boolean;
+};
+
+interface TokenizedCode {
+  tokens: ThemedToken[][];
+  fg: string;
+  bg: string;
+}
+
+interface CodeBlockContextType {
+  code: string;
+}
+
+// Context
+const CodeBlockContext = createContext<CodeBlockContextType>({
+  code: "",
+});
+
+// Highlighter cache (singleton per language)
+const highlighterCache = new Map<
+  string,
+  Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
+>();
+
+// Token cache
+const tokensCache = new Map<string, TokenizedCode>();
+
+// Subscribers for async token updates
+const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
+
+const getTokensCacheKey = (code: string, language: BundledLanguage) => {
+  const start = code.slice(0, 100);
+  const end = code.length > 100 ? code.slice(-100) : "";
+  return `${language}:${code.length}:${start}:${end}`;
+};
+
+const getHighlighter = (
+  language: BundledLanguage
+): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
+  const cached = highlighterCache.get(language);
+  if (cached) {
+    return cached;
+  }
+
+  const highlighterPromise = createHighlighter({
+    langs: [language],
+    themes: ["github-light", "github-dark"],
+  });
+
+  highlighterCache.set(language, highlighterPromise);
+  return highlighterPromise;
+};
+
+// Create raw tokens for immediate display while highlighting loads
+const createRawTokens = (code: string): TokenizedCode => ({
+  bg: "transparent",
+  fg: "inherit",
+  tokens: code.split("\n").map((line) =>
+    line === ""
+      ? []
+      : [
+          {
+            color: "inherit",
+            content: line,
+          } as ThemedToken,
+        ]
+  ),
+});
+
+// Synchronous highlight with callback for async results
+export const highlightCode = (
+  code: string,
+  language: BundledLanguage,
+  // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
+  callback?: (result: TokenizedCode) => void
+): TokenizedCode | null => {
+  const tokensCacheKey = getTokensCacheKey(code, language);
+
+  // Return cached result if available
+  const cached = tokensCache.get(tokensCacheKey);
+  if (cached) {
+    return cached;
+  }
+
+  // Subscribe callback if provided
+  if (callback) {
+    if (!subscribers.has(tokensCacheKey)) {
+      subscribers.set(tokensCacheKey, new Set());
+    }
+    subscribers.get(tokensCacheKey)?.add(callback);
+  }
+
+  // Start highlighting in background - fire-and-forget async pattern
+  getHighlighter(language)
+    // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
+    .then((highlighter) => {
+      const availableLangs = highlighter.getLoadedLanguages();
+      const langToUse = availableLangs.includes(language) ? language : "text";
+
+      const result = highlighter.codeToTokens(code, {
+        lang: langToUse,
+        themes: {
+          dark: "github-dark",
+          light: "github-light",
+        },
+      });
+
+      const tokenized: TokenizedCode = {
+        bg: result.bg ?? "transparent",
+        fg: result.fg ?? "inherit",
+        tokens: result.tokens,
+      };
+
+      // Cache the result
+      tokensCache.set(tokensCacheKey, tokenized);
+
+      // Notify all subscribers
+      const subs = subscribers.get(tokensCacheKey);
+      if (subs) {
+        for (const sub of subs) {
+          sub(tokenized);
+        }
+        subscribers.delete(tokensCacheKey);
+      }
+    })
+    // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
+    .catch((error) => {
+      console.error("Failed to highlight code:", error);
+      subscribers.delete(tokensCacheKey);
+    });
+
+  return null;
+};
+
+const CodeBlockBody = memo(
+  ({
+    tokenized,
+    showLineNumbers,
+    className,
+  }: {
+    tokenized: TokenizedCode;
+    showLineNumbers: boolean;
+    className?: string;
+  }) => {
+    const preStyle = useMemo(
+      () => ({
+        backgroundColor: tokenized.bg,
+        color: tokenized.fg,
+      }),
+      [tokenized.bg, tokenized.fg]
+    );
+
+    const keyedLines = useMemo(
+      () => addKeysToTokens(tokenized.tokens),
+      [tokenized.tokens]
+    );
+
+    return (
+      <pre
+        className={cn(
+          "dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
+          className
+        )}
+        style={preStyle}
+      >
+        <code
+          className={cn(
+            "font-mono text-sm",
+            showLineNumbers && "[counter-increment:line_0] [counter-reset:line]"
+          )}
+        >
+          {keyedLines.map((keyedLine) => (
+            <LineSpan
+              key={keyedLine.key}
+              keyedLine={keyedLine}
+              showLineNumbers={showLineNumbers}
+            />
+          ))}
+        </code>
+      </pre>
+    );
+  },
+  (prevProps, nextProps) =>
+    prevProps.tokenized === nextProps.tokenized &&
+    prevProps.showLineNumbers === nextProps.showLineNumbers &&
+    prevProps.className === nextProps.className
+);
+
+CodeBlockBody.displayName = "CodeBlockBody";
+
+export const CodeBlockContainer = ({
+  className,
+  language,
+  style,
+  ...props
+}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
+  <div
+    className={cn(
+      "group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
+      className
+    )}
+    data-language={language}
+    style={{
+      containIntrinsicSize: "auto 200px",
+      contentVisibility: "auto",
+      ...style,
+    }}
+    {...props}
+  />
+);
+
+export const CodeBlockHeader = ({
+  children,
+  className,
+  ...props
+}: HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn(
+      "flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
+      className
+    )}
+    {...props}
+  >
+    {children}
+  </div>
+);
+
+export const CodeBlockTitle = ({
+  children,
+  className,
+  ...props
+}: HTMLAttributes<HTMLDivElement>) => (
+  <div className={cn("flex items-center gap-2", className)} {...props}>
+    {children}
+  </div>
+);
+
+export const CodeBlockFilename = ({
+  children,
+  className,
+  ...props
+}: HTMLAttributes<HTMLSpanElement>) => (
+  <span className={cn("font-mono", className)} {...props}>
+    {children}
+  </span>
+);
+
+export const CodeBlockActions = ({
+  children,
+  className,
+  ...props
+}: HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
+    {...props}
+  >
+    {children}
+  </div>
+);
+
+export const CodeBlockContent = ({
+  code,
+  language,
+  showLineNumbers = false,
+}: {
+  code: string;
+  language: BundledLanguage;
+  showLineNumbers?: boolean;
+}) => {
+  // Memoized raw tokens for immediate display
+  const rawTokens = useMemo(() => createRawTokens(code), [code]);
+
+  // Synchronous cache lookup — avoids setState in effect for cached results
+  const syncTokens = useMemo(
+    () => highlightCode(code, language) ?? rawTokens,
+    [code, language, rawTokens]
+  );
+
+  // Async highlighting result (populated after shiki loads)
+  const [asyncTokens, setAsyncTokens] = useState<TokenizedCode | null>(null);
+  const asyncKeyRef = useRef({ code, language });
+
+  // Invalidate stale async tokens synchronously during render
+  if (
+    asyncKeyRef.current.code !== code ||
+    asyncKeyRef.current.language !== language
+  ) {
+    asyncKeyRef.current = { code, language };
+    setAsyncTokens(null);
+  }
+
+  useEffect(() => {
+    let cancelled = false;
+
+    highlightCode(code, language, (result) => {
+      if (!cancelled) {
+        setAsyncTokens(result);
+      }
+    });
+
+    return () => {
+      cancelled = true;
+    };
+  }, [code, language]);
+
+  const tokenized = asyncTokens ?? syncTokens;
+
+  return (
+    <div className="relative overflow-auto">
+      <CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
+    </div>
+  );
+};
+
+export const CodeBlock = ({
+  code,
+  language,
+  showLineNumbers = false,
+  className,
+  children,
+  ...props
+}: CodeBlockProps) => {
+  const contextValue = useMemo(() => ({ code }), [code]);
+
+  return (
+    <CodeBlockContext.Provider value={contextValue}>
+      <CodeBlockContainer className={className} language={language} {...props}>
+        {children}
+        <CodeBlockContent
+          code={code}
+          language={language}
+          showLineNumbers={showLineNumbers}
+        />
+      </CodeBlockContainer>
+    </CodeBlockContext.Provider>
+  );
+};
+
+export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
+  onCopy?: () => void;
+  onError?: (error: Error) => void;
+  timeout?: number;
+};
+
+export const CodeBlockCopyButton = ({
+  onCopy,
+  onError,
+  timeout = 2000,
+  children,
+  className,
+  ...props
+}: CodeBlockCopyButtonProps) => {
+  const [isCopied, setIsCopied] = useState(false);
+  const timeoutRef = useRef<number>(0);
+  const { code } = useContext(CodeBlockContext);
+
+  const copyToClipboard = useCallback(async () => {
+    if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
+      onError?.(new Error("Clipboard API not available"));
+      return;
+    }
+
+    try {
+      if (!isCopied) {
+        await navigator.clipboard.writeText(code);
+        setIsCopied(true);
+        onCopy?.();
+        timeoutRef.current = window.setTimeout(
+          () => setIsCopied(false),
+          timeout
+        );
+      }
+    } catch (error) {
+      onError?.(error as Error);
+    }
+  }, [code, onCopy, onError, timeout, isCopied]);
+
+  useEffect(
+    () => () => {
+      window.clearTimeout(timeoutRef.current);
+    },
+    []
+  );
+
+  const Icon = isCopied ? CheckIcon : CopyIcon;
+
+  return (
+    <Button
+      className={cn("shrink-0", className)}
+      onClick={copyToClipboard}
+      size="icon"
+      variant="ghost"
+      {...props}
+    >
+      {children ?? <Icon size={14} />}
+    </Button>
+  );
+};
+
+export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
+
+export const CodeBlockLanguageSelector = (
+  props: CodeBlockLanguageSelectorProps
+) => <Select {...props} />;
+
+export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
+  typeof SelectTrigger
+>;
+
+export const CodeBlockLanguageSelectorTrigger = ({
+  className,
+  ...props
+}: CodeBlockLanguageSelectorTriggerProps) => (
+  <SelectTrigger
+    className={cn(
+      "h-7 border-none bg-transparent px-2 text-xs shadow-none",
+      className
+    )}
+    size="sm"
+    {...props}
+  />
+);
+
+export type CodeBlockLanguageSelectorValueProps = ComponentProps<
+  typeof SelectValue
+>;
+
+export const CodeBlockLanguageSelectorValue = (
+  props: CodeBlockLanguageSelectorValueProps
+) => <SelectValue {...props} />;
+
+export type CodeBlockLanguageSelectorContentProps = ComponentProps<
+  typeof SelectContent
+>;
+
+export const CodeBlockLanguageSelectorContent = ({
+  align = "end",
+  ...props
+}: CodeBlockLanguageSelectorContentProps) => (
+  <SelectContent align={align} {...props} />
+);
+
+export type CodeBlockLanguageSelectorItemProps = ComponentProps<
+  typeof SelectItem
+>;
+
+export const CodeBlockLanguageSelectorItem = (
+  props: CodeBlockLanguageSelectorItemProps
+) => <SelectItem {...props} />;

+ 174 - 0
packages/studio/src/components/ai-elements/confirmation.tsx

@@ -0,0 +1,174 @@
+"use client";
+
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import type { ToolUIPart } from "ai";
+import type { ComponentProps, ReactNode } from "react";
+import { createContext, useContext, useMemo } from "react";
+
+type ToolUIPartApproval =
+  | {
+      id: string;
+      approved?: never;
+      reason?: never;
+    }
+  | {
+      id: string;
+      approved: boolean;
+      reason?: string;
+    }
+  | {
+      id: string;
+      approved: true;
+      reason?: string;
+    }
+  | {
+      id: string;
+      approved: true;
+      reason?: string;
+    }
+  | {
+      id: string;
+      approved: false;
+      reason?: string;
+    }
+  | undefined;
+
+interface ConfirmationContextValue {
+  approval: ToolUIPartApproval;
+  state: ToolUIPart["state"];
+}
+
+const ConfirmationContext = createContext<ConfirmationContextValue | null>(
+  null
+);
+
+const useConfirmation = () => {
+  const context = useContext(ConfirmationContext);
+
+  if (!context) {
+    throw new Error("Confirmation components must be used within Confirmation");
+  }
+
+  return context;
+};
+
+export type ConfirmationProps = ComponentProps<typeof Alert> & {
+  approval?: ToolUIPartApproval;
+  state: ToolUIPart["state"];
+};
+
+export const Confirmation = ({
+  className,
+  approval,
+  state,
+  ...props
+}: ConfirmationProps) => {
+  const contextValue = useMemo(() => ({ approval, state }), [approval, state]);
+
+  if (!approval || state === "input-streaming" || state === "input-available") {
+    return null;
+  }
+
+  return (
+    <ConfirmationContext.Provider value={contextValue}>
+      <Alert className={cn("flex flex-col gap-2", className)} {...props} />
+    </ConfirmationContext.Provider>
+  );
+};
+
+export type ConfirmationTitleProps = ComponentProps<typeof AlertDescription>;
+
+export const ConfirmationTitle = ({
+  className,
+  ...props
+}: ConfirmationTitleProps) => (
+  <AlertDescription className={cn("inline", className)} {...props} />
+);
+
+export interface ConfirmationRequestProps {
+  children?: ReactNode;
+}
+
+export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
+  const { state } = useConfirmation();
+
+  // Only show when approval is requested
+  if (state !== "approval-requested") {
+    return null;
+  }
+
+  return children;
+};
+
+export interface ConfirmationAcceptedProps {
+  children?: ReactNode;
+}
+
+export const ConfirmationAccepted = ({
+  children,
+}: ConfirmationAcceptedProps) => {
+  const { approval, state } = useConfirmation();
+
+  // Only show when approved and in response states
+  if (
+    !approval?.approved ||
+    (state !== "approval-responded" &&
+      state !== "output-denied" &&
+      state !== "output-available")
+  ) {
+    return null;
+  }
+
+  return children;
+};
+
+export interface ConfirmationRejectedProps {
+  children?: ReactNode;
+}
+
+export const ConfirmationRejected = ({
+  children,
+}: ConfirmationRejectedProps) => {
+  const { approval, state } = useConfirmation();
+
+  // Only show when rejected and in response states
+  if (
+    approval?.approved !== false ||
+    (state !== "approval-responded" &&
+      state !== "output-denied" &&
+      state !== "output-available")
+  ) {
+    return null;
+  }
+
+  return children;
+};
+
+export type ConfirmationActionsProps = ComponentProps<"div">;
+
+export const ConfirmationActions = ({
+  className,
+  ...props
+}: ConfirmationActionsProps) => {
+  const { state } = useConfirmation();
+
+  // Only show when approval is requested
+  if (state !== "approval-requested") {
+    return null;
+  }
+
+  return (
+    <div
+      className={cn("flex items-center justify-end gap-2 self-end", className)}
+      {...props}
+    />
+  );
+};
+
+export type ConfirmationActionProps = ComponentProps<typeof Button>;
+
+export const ConfirmationAction = (props: ConfirmationActionProps) => (
+  <Button className="h-8 px-3 text-sm" type="button" {...props} />
+);

+ 360 - 0
packages/studio/src/components/ai-elements/message.tsx

@@ -0,0 +1,360 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+  ButtonGroup,
+  ButtonGroupText,
+} from "@/components/ui/button-group";
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipProvider,
+  TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { cjk } from "@streamdown/cjk";
+import { code } from "@streamdown/code";
+import { math } from "@streamdown/math";
+import { mermaid } from "@streamdown/mermaid";
+import type { UIMessage } from "ai";
+import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
+import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
+import {
+  createContext,
+  memo,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useState,
+} from "react";
+import { Streamdown } from "streamdown";
+
+export type MessageProps = HTMLAttributes<HTMLDivElement> & {
+  from: UIMessage["role"];
+};
+
+export const Message = ({ className, from, ...props }: MessageProps) => (
+  <div
+    className={cn(
+      "group flex w-full max-w-[95%] flex-col gap-2",
+      from === "user" ? "is-user ml-auto items-end" : "is-assistant items-start",
+      className
+    )}
+    {...props}
+  />
+);
+
+export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
+
+export const MessageContent = ({
+  children,
+  className,
+  ...props
+}: MessageContentProps) => (
+  <div
+    className={cn(
+      "is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
+      "group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
+      "group-[.is-assistant]:text-foreground",
+      className
+    )}
+    {...props}
+  >
+    {children}
+  </div>
+);
+
+export type MessageActionsProps = ComponentProps<"div">;
+
+export const MessageActions = ({
+  className,
+  children,
+  ...props
+}: MessageActionsProps) => (
+  <div className={cn("flex items-center gap-1", className)} {...props}>
+    {children}
+  </div>
+);
+
+export type MessageActionProps = ComponentProps<typeof Button> & {
+  tooltip?: string;
+  label?: string;
+};
+
+export const MessageAction = ({
+  tooltip,
+  children,
+  label,
+  variant = "ghost",
+  size = "icon-sm",
+  ...props
+}: MessageActionProps) => {
+  const button = (
+    <Button size={size} type="button" variant={variant} {...props}>
+      {children}
+      <span className="sr-only">{label || tooltip}</span>
+    </Button>
+  );
+
+  if (tooltip) {
+    return (
+      <TooltipProvider>
+        <Tooltip>
+          <TooltipTrigger>{button}</TooltipTrigger>
+          <TooltipContent>
+            <p>{tooltip}</p>
+          </TooltipContent>
+        </Tooltip>
+      </TooltipProvider>
+    );
+  }
+
+  return button;
+};
+
+interface MessageBranchContextType {
+  currentBranch: number;
+  totalBranches: number;
+  goToPrevious: () => void;
+  goToNext: () => void;
+  branches: ReactElement[];
+  setBranches: (branches: ReactElement[]) => void;
+}
+
+const MessageBranchContext = createContext<MessageBranchContextType | null>(
+  null
+);
+
+const useMessageBranch = () => {
+  const context = useContext(MessageBranchContext);
+
+  if (!context) {
+    throw new Error(
+      "MessageBranch components must be used within MessageBranch"
+    );
+  }
+
+  return context;
+};
+
+export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
+  defaultBranch?: number;
+  onBranchChange?: (branchIndex: number) => void;
+};
+
+export const MessageBranch = ({
+  defaultBranch = 0,
+  onBranchChange,
+  className,
+  ...props
+}: MessageBranchProps) => {
+  const [currentBranch, setCurrentBranch] = useState(defaultBranch);
+  const [branches, setBranches] = useState<ReactElement[]>([]);
+
+  const handleBranchChange = useCallback(
+    (newBranch: number) => {
+      setCurrentBranch(newBranch);
+      onBranchChange?.(newBranch);
+    },
+    [onBranchChange]
+  );
+
+  const goToPrevious = useCallback(() => {
+    const newBranch =
+      currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
+    handleBranchChange(newBranch);
+  }, [currentBranch, branches.length, handleBranchChange]);
+
+  const goToNext = useCallback(() => {
+    const newBranch =
+      currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
+    handleBranchChange(newBranch);
+  }, [currentBranch, branches.length, handleBranchChange]);
+
+  const contextValue = useMemo<MessageBranchContextType>(
+    () => ({
+      branches,
+      currentBranch,
+      goToNext,
+      goToPrevious,
+      setBranches,
+      totalBranches: branches.length,
+    }),
+    [branches, currentBranch, goToNext, goToPrevious]
+  );
+
+  return (
+    <MessageBranchContext.Provider value={contextValue}>
+      <div
+        className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
+        {...props}
+      />
+    </MessageBranchContext.Provider>
+  );
+};
+
+export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
+
+export const MessageBranchContent = ({
+  children,
+  ...props
+}: MessageBranchContentProps) => {
+  const { currentBranch, setBranches, branches } = useMessageBranch();
+  const childrenArray = useMemo(
+    () => (Array.isArray(children) ? children : [children]),
+    [children]
+  );
+
+  // Use useEffect to update branches when they change
+  useEffect(() => {
+    if (branches.length !== childrenArray.length) {
+      setBranches(childrenArray);
+    }
+  }, [childrenArray, branches, setBranches]);
+
+  return childrenArray.map((branch, index) => (
+    <div
+      className={cn(
+        "grid gap-2 overflow-hidden [&>div]:pb-0",
+        index === currentBranch ? "block" : "hidden"
+      )}
+      key={branch.key}
+      {...props}
+    >
+      {branch}
+    </div>
+  ));
+};
+
+export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
+
+export const MessageBranchSelector = ({
+  className,
+  ...props
+}: MessageBranchSelectorProps) => {
+  const { totalBranches } = useMessageBranch();
+
+  // Don't render if there's only one branch
+  if (totalBranches <= 1) {
+    return null;
+  }
+
+  return (
+    <ButtonGroup
+      className={cn(
+        "[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
+        className
+      )}
+      orientation="horizontal"
+      {...props}
+    />
+  );
+};
+
+export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
+
+export const MessageBranchPrevious = ({
+  children,
+  ...props
+}: MessageBranchPreviousProps) => {
+  const { goToPrevious, totalBranches } = useMessageBranch();
+
+  return (
+    <Button
+      aria-label="Previous branch"
+      disabled={totalBranches <= 1}
+      onClick={goToPrevious}
+      size="icon-sm"
+      type="button"
+      variant="ghost"
+      {...props}
+    >
+      {children ?? <ChevronLeftIcon size={14} />}
+    </Button>
+  );
+};
+
+export type MessageBranchNextProps = ComponentProps<typeof Button>;
+
+export const MessageBranchNext = ({
+  children,
+  ...props
+}: MessageBranchNextProps) => {
+  const { goToNext, totalBranches } = useMessageBranch();
+
+  return (
+    <Button
+      aria-label="Next branch"
+      disabled={totalBranches <= 1}
+      onClick={goToNext}
+      size="icon-sm"
+      type="button"
+      variant="ghost"
+      {...props}
+    >
+      {children ?? <ChevronRightIcon size={14} />}
+    </Button>
+  );
+};
+
+export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
+
+export const MessageBranchPage = ({
+  className,
+  ...props
+}: MessageBranchPageProps) => {
+  const { currentBranch, totalBranches } = useMessageBranch();
+
+  return (
+    <ButtonGroupText
+      className={cn(
+        "border-none bg-transparent text-muted-foreground shadow-none",
+        className
+      )}
+      {...props}
+    >
+      {currentBranch + 1} of {totalBranches}
+    </ButtonGroupText>
+  );
+};
+
+export type MessageResponseProps = ComponentProps<typeof Streamdown>;
+
+const streamdownPlugins = { cjk, code, math, mermaid };
+
+export const MessageResponse = memo(
+  ({ className, ...props }: MessageResponseProps) => (
+    <Streamdown
+      className={cn(
+        "size-full text-[16px] leading-[1.6] font-['SimSun','Songti_SC','STSong',serif] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>p+p]:mt-4",
+        className
+      )}
+      plugins={streamdownPlugins}
+      {...props}
+    />
+  ),
+  (prevProps, nextProps) =>
+    prevProps.children === nextProps.children &&
+    nextProps.isAnimating === prevProps.isAnimating
+);
+
+MessageResponse.displayName = "MessageResponse";
+
+export type MessageToolbarProps = ComponentProps<"div">;
+
+export const MessageToolbar = ({
+  className,
+  children,
+  ...props
+}: MessageToolbarProps) => (
+  <div
+    className={cn(
+      "mt-4 flex w-full items-center justify-between gap-4",
+      className
+    )}
+    {...props}
+  >
+    {children}
+  </div>
+);

+ 1457 - 0
packages/studio/src/components/ai-elements/prompt-input.tsx

@@ -0,0 +1,1457 @@
+"use client";
+
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+  CommandSeparator,
+} from "@/components/ui/command";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+  HoverCard,
+  HoverCardContent,
+  HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import {
+  InputGroup,
+  InputGroupAddon,
+  InputGroupButton,
+  InputGroupTextarea,
+} from "@/components/ui/input-group";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { Spinner } from "@/components/ui/spinner";
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai";
+import {
+  CornerDownLeftIcon,
+  ImageIcon,
+  Monitor,
+  PlusIcon,
+  SquareIcon,
+  XIcon,
+} from "lucide-react";
+import { nanoid } from "nanoid";
+import type {
+  ChangeEvent,
+  ChangeEventHandler,
+  ClipboardEventHandler,
+  ComponentProps,
+  FormEvent,
+  FormEventHandler,
+  HTMLAttributes,
+  KeyboardEventHandler,
+  PropsWithChildren,
+  ReactNode,
+  RefObject,
+} from "react";
+import {
+  Children,
+  createContext,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => {
+  try {
+    const response = await fetch(url);
+    const blob = await response.blob();
+    // FileReader uses callback-based API, wrapping in Promise is necessary
+    // oxlint-disable-next-line eslint-plugin-promise(avoid-new)
+    return new Promise((resolve) => {
+      const reader = new FileReader();
+      // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
+      reader.onloadend = () => resolve(reader.result as string);
+      // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
+      reader.onerror = () => resolve(null);
+      reader.readAsDataURL(blob);
+    });
+  } catch {
+    return null;
+  }
+};
+
+const captureScreenshot = async (): Promise<File | null> => {
+  if (
+    typeof navigator === "undefined" ||
+    !navigator.mediaDevices?.getDisplayMedia
+  ) {
+    return null;
+  }
+
+  let stream: MediaStream | null = null;
+  const video = document.createElement("video");
+  video.muted = true;
+  video.playsInline = true;
+
+  try {
+    stream = await navigator.mediaDevices.getDisplayMedia({
+      audio: false,
+      video: true,
+    });
+
+    video.srcObject = stream;
+
+    // Video element uses callback-based API, wrapping in Promise is necessary
+    // oxlint-disable-next-line eslint-plugin-promise(avoid-new)
+    await new Promise<void>((resolve, reject) => {
+      // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
+      video.onloadedmetadata = () => resolve();
+      // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
+      video.onerror = () => reject(new Error("Failed to load screen stream"));
+    });
+
+    await video.play();
+
+    const width = video.videoWidth;
+    const height = video.videoHeight;
+    if (!width || !height) {
+      return null;
+    }
+
+    const canvas = document.createElement("canvas");
+    canvas.width = width;
+    canvas.height = height;
+    const context = canvas.getContext("2d");
+    if (!context) {
+      return null;
+    }
+
+    context.drawImage(video, 0, 0, width, height);
+    // canvas.toBlob uses callback-based API, wrapping in Promise is necessary
+    // oxlint-disable-next-line eslint-plugin-promise(avoid-new)
+    const blob = await new Promise<Blob | null>((resolve) => {
+      canvas.toBlob(resolve, "image/png");
+    });
+    if (!blob) {
+      return null;
+    }
+
+    const timestamp = new Date()
+      .toISOString()
+      .replaceAll(/[:.]/g, "-")
+      .replace("T", "_")
+      .replace("Z", "");
+
+    return new File([blob], `screenshot-${timestamp}.png`, {
+      lastModified: Date.now(),
+      type: "image/png",
+    });
+  } finally {
+    if (stream) {
+      for (const track of stream.getTracks()) {
+        track.stop();
+      }
+    }
+    video.pause();
+    video.srcObject = null;
+  }
+};
+
+// ============================================================================
+// Provider Context & Types
+// ============================================================================
+
+export interface AttachmentsContext {
+  files: (FileUIPart & { id: string })[];
+  add: (files: File[] | FileList) => void;
+  remove: (id: string) => void;
+  clear: () => void;
+  openFileDialog: () => void;
+  fileInputRef: RefObject<HTMLInputElement | null>;
+}
+
+export interface TextInputContext {
+  value: string;
+  setInput: (v: string) => void;
+  clear: () => void;
+}
+
+export interface PromptInputControllerProps {
+  textInput: TextInputContext;
+  attachments: AttachmentsContext;
+  /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
+  __registerFileInput: (
+    ref: RefObject<HTMLInputElement | null>,
+    open: () => void
+  ) => void;
+}
+
+const PromptInputController = createContext<PromptInputControllerProps | null>(
+  null
+);
+const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
+  null
+);
+
+export const usePromptInputController = () => {
+  const ctx = useContext(PromptInputController);
+  if (!ctx) {
+    throw new Error(
+      "Wrap your component inside <PromptInputProvider> to use usePromptInputController()."
+    );
+  }
+  return ctx;
+};
+
+// Optional variants (do NOT throw). Useful for dual-mode components.
+const useOptionalPromptInputController = () =>
+  useContext(PromptInputController);
+
+export const useProviderAttachments = () => {
+  const ctx = useContext(ProviderAttachmentsContext);
+  if (!ctx) {
+    throw new Error(
+      "Wrap your component inside <PromptInputProvider> to use useProviderAttachments()."
+    );
+  }
+  return ctx;
+};
+
+const useOptionalProviderAttachments = () =>
+  useContext(ProviderAttachmentsContext);
+
+export type PromptInputProviderProps = PropsWithChildren<{
+  initialInput?: string;
+}>;
+
+/**
+ * Optional global provider that lifts PromptInput state outside of PromptInput.
+ * If you don't use it, PromptInput stays fully self-managed.
+ */
+export const PromptInputProvider = ({
+  initialInput: initialTextInput = "",
+  children,
+}: PromptInputProviderProps) => {
+  // ----- textInput state
+  const [textInput, setTextInput] = useState(initialTextInput);
+  const clearInput = useCallback(() => setTextInput(""), []);
+
+  // ----- attachments state (global when wrapped)
+  const [attachmentFiles, setAttachmentFiles] = useState<
+    (FileUIPart & { id: string })[]
+  >([]);
+  const fileInputRef = useRef<HTMLInputElement | null>(null);
+  // oxlint-disable-next-line eslint(no-empty-function)
+  const openRef = useRef<() => void>(() => {});
+
+  const add = useCallback((files: File[] | FileList) => {
+    const incoming = [...files];
+    if (incoming.length === 0) {
+      return;
+    }
+
+    setAttachmentFiles((prev) => [
+      ...prev,
+      ...incoming.map((file) => ({
+        filename: file.name,
+        id: nanoid(),
+        mediaType: file.type,
+        type: "file" as const,
+        url: URL.createObjectURL(file),
+      })),
+    ]);
+  }, []);
+
+  const remove = useCallback((id: string) => {
+    setAttachmentFiles((prev) => {
+      const found = prev.find((f) => f.id === id);
+      if (found?.url) {
+        URL.revokeObjectURL(found.url);
+      }
+      return prev.filter((f) => f.id !== id);
+    });
+  }, []);
+
+  const clear = useCallback(() => {
+    setAttachmentFiles((prev) => {
+      for (const f of prev) {
+        if (f.url) {
+          URL.revokeObjectURL(f.url);
+        }
+      }
+      return [];
+    });
+  }, []);
+
+  // Keep a ref to attachments for cleanup on unmount (avoids stale closure)
+  const attachmentsRef = useRef(attachmentFiles);
+
+  useEffect(() => {
+    attachmentsRef.current = attachmentFiles;
+  }, [attachmentFiles]);
+
+  // Cleanup blob URLs on unmount to prevent memory leaks
+  useEffect(
+    () => () => {
+      for (const f of attachmentsRef.current) {
+        if (f.url) {
+          URL.revokeObjectURL(f.url);
+        }
+      }
+    },
+    []
+  );
+
+  const openFileDialog = useCallback(() => {
+    openRef.current?.();
+  }, []);
+
+  const attachments = useMemo<AttachmentsContext>(
+    () => ({
+      add,
+      clear,
+      fileInputRef,
+      files: attachmentFiles,
+      openFileDialog,
+      remove,
+    }),
+    [attachmentFiles, add, remove, clear, openFileDialog]
+  );
+
+  const __registerFileInput = useCallback(
+    (ref: RefObject<HTMLInputElement | null>, open: () => void) => {
+      fileInputRef.current = ref.current;
+      openRef.current = open;
+    },
+    []
+  );
+
+  const controller = useMemo<PromptInputControllerProps>(
+    () => ({
+      __registerFileInput,
+      attachments,
+      textInput: {
+        clear: clearInput,
+        setInput: setTextInput,
+        value: textInput,
+      },
+    }),
+    [textInput, clearInput, attachments, __registerFileInput]
+  );
+
+  return (
+    <PromptInputController.Provider value={controller}>
+      <ProviderAttachmentsContext.Provider value={attachments}>
+        {children}
+      </ProviderAttachmentsContext.Provider>
+    </PromptInputController.Provider>
+  );
+};
+
+// ============================================================================
+// Component Context & Hooks
+// ============================================================================
+
+const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
+
+export const usePromptInputAttachments = () => {
+  // Prefer local context (inside PromptInput) as it has validation, fall back to provider
+  const provider = useOptionalProviderAttachments();
+  const local = useContext(LocalAttachmentsContext);
+  const context = local ?? provider;
+  if (!context) {
+    throw new Error(
+      "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider"
+    );
+  }
+  return context;
+};
+
+// ============================================================================
+// Referenced Sources (Local to PromptInput)
+// ============================================================================
+
+export interface ReferencedSourcesContext {
+  sources: (SourceDocumentUIPart & { id: string })[];
+  add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void;
+  remove: (id: string) => void;
+  clear: () => void;
+}
+
+export const LocalReferencedSourcesContext =
+  createContext<ReferencedSourcesContext | null>(null);
+
+export const usePromptInputReferencedSources = () => {
+  const ctx = useContext(LocalReferencedSourcesContext);
+  if (!ctx) {
+    throw new Error(
+      "usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider"
+    );
+  }
+  return ctx;
+};
+
+export type PromptInputActionAddAttachmentsProps = ComponentProps<
+  typeof DropdownMenuItem
+> & {
+  label?: string;
+};
+
+export const PromptInputActionAddAttachments = ({
+  label = "Add photos or files",
+  ...props
+}: PromptInputActionAddAttachmentsProps) => {
+  const attachments = usePromptInputAttachments();
+
+  const handleSelect = useCallback(
+    (e: { preventDefault: () => void }) => {
+      e.preventDefault();
+      attachments.openFileDialog();
+    },
+    [attachments]
+  );
+
+  return (
+    <DropdownMenuItem {...props} onSelect={handleSelect as any}>
+      <ImageIcon className="mr-2 size-4" /> {label}
+    </DropdownMenuItem>
+  );
+};
+
+export type PromptInputActionAddScreenshotProps = ComponentProps<
+  typeof DropdownMenuItem
+> & {
+  label?: string;
+};
+
+export const PromptInputActionAddScreenshot = ({
+  label = "Take screenshot",
+  onSelect,
+  ...props
+}: PromptInputActionAddScreenshotProps) => {
+  const attachments = usePromptInputAttachments();
+
+  const handleSelect = useCallback(
+    async (event: { preventDefault: () => void; defaultPrevented: boolean }) => {
+      onSelect?.(event as any);
+      if (event.defaultPrevented) {
+        return;
+      }
+
+      try {
+        const screenshot = await captureScreenshot();
+        if (screenshot) {
+          attachments.add([screenshot]);
+        }
+      } catch (error) {
+        if (
+          error instanceof DOMException &&
+          (error.name === "NotAllowedError" || error.name === "AbortError")
+        ) {
+          return;
+        }
+        throw error;
+      }
+    },
+    [onSelect, attachments]
+  );
+
+  return (
+    <DropdownMenuItem {...props} onSelect={handleSelect as any}>
+      <Monitor className="mr-2 size-4" />
+      {label}
+    </DropdownMenuItem>
+  );
+};
+
+export interface PromptInputMessage {
+  text: string;
+  files: FileUIPart[];
+}
+
+export type PromptInputProps = Omit<
+  HTMLAttributes<HTMLFormElement>,
+  "onSubmit" | "onError"
+> & {
+  // e.g., "image/*" or leave undefined for any
+  accept?: string;
+  multiple?: boolean;
+  // When true, accepts drops anywhere on document. Default false (opt-in).
+  globalDrop?: boolean;
+  // Render a hidden input with given name and keep it in sync for native form posts. Default false.
+  syncHiddenInput?: boolean;
+  // Minimal constraints
+  maxFiles?: number;
+  // bytes
+  maxFileSize?: number;
+  onError?: (err: {
+    code: "max_files" | "max_file_size" | "accept";
+    message: string;
+  }) => void;
+  onSubmit: (
+    message: PromptInputMessage,
+    event: FormEvent<HTMLFormElement>
+  ) => void | Promise<void>;
+};
+
+export const PromptInput = ({
+  className,
+  accept,
+  multiple,
+  globalDrop,
+  syncHiddenInput,
+  maxFiles,
+  maxFileSize,
+  onError,
+  onSubmit,
+  children,
+  ...props
+}: PromptInputProps) => {
+  // Try to use a provider controller if present
+  const controller = useOptionalPromptInputController();
+  const usingProvider = !!controller;
+
+  // Refs
+  const inputRef = useRef<HTMLInputElement | null>(null);
+  const formRef = useRef<HTMLFormElement | null>(null);
+
+  // ----- Local attachments (only used when no provider)
+  const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
+  const files = usingProvider ? controller.attachments.files : items;
+
+  // ----- Local referenced sources (always local to PromptInput)
+  const [referencedSources, setReferencedSources] = useState<
+    (SourceDocumentUIPart & { id: string })[]
+  >([]);
+
+  // Keep a ref to files for cleanup on unmount (avoids stale closure)
+  const filesRef = useRef(files);
+
+  useEffect(() => {
+    filesRef.current = files;
+  }, [files]);
+
+  const openFileDialogLocal = useCallback(() => {
+    inputRef.current?.click();
+  }, []);
+
+  const matchesAccept = useCallback(
+    (f: File) => {
+      if (!accept || accept.trim() === "") {
+        return true;
+      }
+
+      const patterns = accept
+        .split(",")
+        .map((s) => s.trim())
+        .filter(Boolean);
+
+      return patterns.some((pattern) => {
+        if (pattern.endsWith("/*")) {
+          // e.g: image/* -> image/
+          const prefix = pattern.slice(0, -1);
+          return f.type.startsWith(prefix);
+        }
+        return f.type === pattern;
+      });
+    },
+    [accept]
+  );
+
+  const addLocal = useCallback(
+    (fileList: File[] | FileList) => {
+      const incoming = [...fileList];
+      const accepted = incoming.filter((f) => matchesAccept(f));
+      if (incoming.length && accepted.length === 0) {
+        onError?.({
+          code: "accept",
+          message: "No files match the accepted types.",
+        });
+        return;
+      }
+      const withinSize = (f: File) =>
+        maxFileSize ? f.size <= maxFileSize : true;
+      const sized = accepted.filter(withinSize);
+      if (accepted.length > 0 && sized.length === 0) {
+        onError?.({
+          code: "max_file_size",
+          message: "All files exceed the maximum size.",
+        });
+        return;
+      }
+
+      setItems((prev) => {
+        const capacity =
+          typeof maxFiles === "number"
+            ? Math.max(0, maxFiles - prev.length)
+            : undefined;
+        const capped =
+          typeof capacity === "number" ? sized.slice(0, capacity) : sized;
+        if (typeof capacity === "number" && sized.length > capacity) {
+          onError?.({
+            code: "max_files",
+            message: "Too many files. Some were not added.",
+          });
+        }
+        const next: (FileUIPart & { id: string })[] = [];
+        for (const file of capped) {
+          next.push({
+            filename: file.name,
+            id: nanoid(),
+            mediaType: file.type,
+            type: "file",
+            url: URL.createObjectURL(file),
+          });
+        }
+        return [...prev, ...next];
+      });
+    },
+    [matchesAccept, maxFiles, maxFileSize, onError]
+  );
+
+  const removeLocal = useCallback(
+    (id: string) =>
+      setItems((prev) => {
+        const found = prev.find((file) => file.id === id);
+        if (found?.url) {
+          URL.revokeObjectURL(found.url);
+        }
+        return prev.filter((file) => file.id !== id);
+      }),
+    []
+  );
+
+  // Wrapper that validates files before calling provider's add
+  const addWithProviderValidation = useCallback(
+    (fileList: File[] | FileList) => {
+      const incoming = [...fileList];
+      const accepted = incoming.filter((f) => matchesAccept(f));
+      if (incoming.length && accepted.length === 0) {
+        onError?.({
+          code: "accept",
+          message: "No files match the accepted types.",
+        });
+        return;
+      }
+      const withinSize = (f: File) =>
+        maxFileSize ? f.size <= maxFileSize : true;
+      const sized = accepted.filter(withinSize);
+      if (accepted.length > 0 && sized.length === 0) {
+        onError?.({
+          code: "max_file_size",
+          message: "All files exceed the maximum size.",
+        });
+        return;
+      }
+
+      const currentCount = files.length;
+      const capacity =
+        typeof maxFiles === "number"
+          ? Math.max(0, maxFiles - currentCount)
+          : undefined;
+      const capped =
+        typeof capacity === "number" ? sized.slice(0, capacity) : sized;
+      if (typeof capacity === "number" && sized.length > capacity) {
+        onError?.({
+          code: "max_files",
+          message: "Too many files. Some were not added.",
+        });
+      }
+
+      if (capped.length > 0) {
+        controller?.attachments.add(capped);
+      }
+    },
+    [matchesAccept, maxFileSize, maxFiles, onError, files.length, controller]
+  );
+
+  const clearAttachments = useCallback(
+    () =>
+      usingProvider
+        ? controller?.attachments.clear()
+        : setItems((prev) => {
+            for (const file of prev) {
+              if (file.url) {
+                URL.revokeObjectURL(file.url);
+              }
+            }
+            return [];
+          }),
+    [usingProvider, controller]
+  );
+
+  const clearReferencedSources = useCallback(
+    () => setReferencedSources([]),
+    []
+  );
+
+  const add = usingProvider ? addWithProviderValidation : addLocal;
+  const remove = usingProvider ? controller.attachments.remove : removeLocal;
+  const openFileDialog = usingProvider
+    ? controller.attachments.openFileDialog
+    : openFileDialogLocal;
+
+  const clear = useCallback(() => {
+    clearAttachments();
+    clearReferencedSources();
+  }, [clearAttachments, clearReferencedSources]);
+
+  // Let provider know about our hidden file input so external menus can call openFileDialog()
+  useEffect(() => {
+    if (!usingProvider) {
+      return;
+    }
+    controller.__registerFileInput(inputRef, () => inputRef.current?.click());
+  }, [usingProvider, controller]);
+
+  // Note: File input cannot be programmatically set for security reasons
+  // The syncHiddenInput prop is no longer functional
+  useEffect(() => {
+    if (syncHiddenInput && inputRef.current && files.length === 0) {
+      inputRef.current.value = "";
+    }
+  }, [files, syncHiddenInput]);
+
+  // Attach drop handlers on nearest form and document (opt-in)
+  useEffect(() => {
+    const form = formRef.current;
+    if (!form) {
+      return;
+    }
+    if (globalDrop) {
+      // when global drop is on, let the document-level handler own drops
+      return;
+    }
+
+    const onDragOver = (e: DragEvent) => {
+      if (e.dataTransfer?.types?.includes("Files")) {
+        e.preventDefault();
+      }
+    };
+    const onDrop = (e: DragEvent) => {
+      if (e.dataTransfer?.types?.includes("Files")) {
+        e.preventDefault();
+      }
+      if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
+        add(e.dataTransfer.files);
+      }
+    };
+    form.addEventListener("dragover", onDragOver);
+    form.addEventListener("drop", onDrop);
+    return () => {
+      form.removeEventListener("dragover", onDragOver);
+      form.removeEventListener("drop", onDrop);
+    };
+  }, [add, globalDrop]);
+
+  useEffect(() => {
+    if (!globalDrop) {
+      return;
+    }
+
+    const onDragOver = (e: DragEvent) => {
+      if (e.dataTransfer?.types?.includes("Files")) {
+        e.preventDefault();
+      }
+    };
+    const onDrop = (e: DragEvent) => {
+      if (e.dataTransfer?.types?.includes("Files")) {
+        e.preventDefault();
+      }
+      if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
+        add(e.dataTransfer.files);
+      }
+    };
+    document.addEventListener("dragover", onDragOver);
+    document.addEventListener("drop", onDrop);
+    return () => {
+      document.removeEventListener("dragover", onDragOver);
+      document.removeEventListener("drop", onDrop);
+    };
+  }, [add, globalDrop]);
+
+  useEffect(
+    () => () => {
+      if (!usingProvider) {
+        for (const f of filesRef.current) {
+          if (f.url) {
+            URL.revokeObjectURL(f.url);
+          }
+        }
+      }
+    },
+    [usingProvider]
+  );
+
+  const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
+    (event) => {
+      if (event.currentTarget.files) {
+        add(event.currentTarget.files);
+      }
+      // Reset input value to allow selecting files that were previously removed
+      event.currentTarget.value = "";
+    },
+    [add]
+  );
+
+  const attachmentsCtx = useMemo<AttachmentsContext>(
+    () => ({
+      add,
+      clear: clearAttachments,
+      fileInputRef: inputRef,
+      files: files.map((item) => ({ ...item, id: item.id })),
+      openFileDialog,
+      remove,
+    }),
+    [files, add, remove, clearAttachments, openFileDialog]
+  );
+
+  const refsCtx = useMemo<ReferencedSourcesContext>(
+    () => ({
+      add: (incoming: SourceDocumentUIPart[] | SourceDocumentUIPart) => {
+        const array = Array.isArray(incoming) ? incoming : [incoming];
+        setReferencedSources((prev) => [
+          ...prev,
+          ...array.map((s) => ({ ...s, id: nanoid() })),
+        ]);
+      },
+      clear: clearReferencedSources,
+      remove: (id: string) => {
+        setReferencedSources((prev) => prev.filter((s) => s.id !== id));
+      },
+      sources: referencedSources,
+    }),
+    [referencedSources, clearReferencedSources]
+  );
+
+  const handleSubmit: FormEventHandler<HTMLFormElement> = useCallback(
+    async (event) => {
+      event.preventDefault();
+
+      const form = event.currentTarget;
+      const text = usingProvider
+        ? controller.textInput.value
+        : (() => {
+            const formData = new FormData(form);
+            return (formData.get("message") as string) || "";
+          })();
+
+      // Reset form immediately after capturing text to avoid race condition
+      // where user input during async blob conversion would be lost
+      if (!usingProvider) {
+        form.reset();
+      }
+
+      try {
+        // Convert blob URLs to data URLs asynchronously
+        const convertedFiles: FileUIPart[] = await Promise.all(
+          files.map(async ({ id: _id, ...item }) => {
+            if (item.url?.startsWith("blob:")) {
+              const dataUrl = await convertBlobUrlToDataUrl(item.url);
+              // If conversion failed, keep the original blob URL
+              return {
+                ...item,
+                url: dataUrl ?? item.url,
+              };
+            }
+            return item;
+          })
+        );
+
+        const result = onSubmit({ files: convertedFiles, text }, event);
+
+        // Handle both sync and async onSubmit
+        if (result instanceof Promise) {
+          try {
+            await result;
+            clear();
+            if (usingProvider) {
+              controller.textInput.clear();
+            }
+          } catch {
+            // Don't clear on error - user may want to retry
+          }
+        } else {
+          // Sync function completed without throwing, clear inputs
+          clear();
+          if (usingProvider) {
+            controller.textInput.clear();
+          }
+        }
+      } catch {
+        // Don't clear on error - user may want to retry
+      }
+    },
+    [usingProvider, controller, files, onSubmit, clear]
+  );
+
+  // Render with or without local provider
+  const inner = (
+    <>
+      <input
+        accept={accept}
+        aria-label="Upload files"
+        className="hidden"
+        multiple={multiple}
+        onChange={handleChange}
+        ref={inputRef}
+        title="Upload files"
+        type="file"
+      />
+      <form
+        className={cn("w-full", className)}
+        onSubmit={handleSubmit}
+        ref={formRef}
+        {...props}
+      >
+        <InputGroup className="overflow-hidden">{children}</InputGroup>
+      </form>
+    </>
+  );
+
+  const withReferencedSources = (
+    <LocalReferencedSourcesContext.Provider value={refsCtx}>
+      {inner}
+    </LocalReferencedSourcesContext.Provider>
+  );
+
+  // Always provide LocalAttachmentsContext so children get validated add function
+  return (
+    <LocalAttachmentsContext.Provider value={attachmentsCtx}>
+      {withReferencedSources}
+    </LocalAttachmentsContext.Provider>
+  );
+};
+
+export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;
+
+export const PromptInputBody = ({
+  className,
+  ...props
+}: PromptInputBodyProps) => (
+  <div className={cn("contents", className)} {...props} />
+);
+
+export type PromptInputTextareaProps = ComponentProps<
+  typeof InputGroupTextarea
+>;
+
+export const PromptInputTextarea = ({
+  onChange,
+  onKeyDown,
+  className,
+  placeholder = "What would you like to know?",
+  ...props
+}: PromptInputTextareaProps) => {
+  const controller = useOptionalPromptInputController();
+  const attachments = usePromptInputAttachments();
+  const [isComposing, setIsComposing] = useState(false);
+
+  const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = useCallback(
+    (e) => {
+      // Call the external onKeyDown handler first
+      onKeyDown?.(e);
+
+      // If the external handler prevented default, don't run internal logic
+      if (e.defaultPrevented) {
+        return;
+      }
+
+      if (e.key === "Enter") {
+        if (isComposing || e.nativeEvent.isComposing) {
+          return;
+        }
+        if (e.shiftKey) {
+          return;
+        }
+        e.preventDefault();
+
+        // Check if the submit button is disabled before submitting
+        const { form } = e.currentTarget;
+        const submitButton = form?.querySelector(
+          'button[type="submit"]'
+        ) as HTMLButtonElement | null;
+        if (submitButton?.disabled) {
+          return;
+        }
+
+        form?.requestSubmit();
+      }
+
+      // Remove last attachment when Backspace is pressed and textarea is empty
+      if (
+        e.key === "Backspace" &&
+        e.currentTarget.value === "" &&
+        attachments.files.length > 0
+      ) {
+        e.preventDefault();
+        const lastAttachment = attachments.files.at(-1);
+        if (lastAttachment) {
+          attachments.remove(lastAttachment.id);
+        }
+      }
+    },
+    [onKeyDown, isComposing, attachments]
+  );
+
+  const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = useCallback(
+    (event) => {
+      const items = event.clipboardData?.items;
+
+      if (!items) {
+        return;
+      }
+
+      const files: File[] = [];
+
+      for (const item of items) {
+        if (item.kind === "file") {
+          const file = item.getAsFile();
+          if (file) {
+            files.push(file);
+          }
+        }
+      }
+
+      if (files.length > 0) {
+        event.preventDefault();
+        attachments.add(files);
+      }
+    },
+    [attachments]
+  );
+
+  const handleCompositionEnd = useCallback(() => setIsComposing(false), []);
+  const handleCompositionStart = useCallback(() => setIsComposing(true), []);
+
+  const controlledProps = controller
+    ? {
+        onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
+          controller.textInput.setInput(e.currentTarget.value);
+          onChange?.(e);
+        },
+        value: controller.textInput.value,
+      }
+    : {
+        onChange,
+      };
+
+  return (
+    <InputGroupTextarea
+      className={cn("field-sizing-content max-h-48 min-h-16", className)}
+      name="message"
+      onCompositionEnd={handleCompositionEnd}
+      onCompositionStart={handleCompositionStart}
+      onKeyDown={handleKeyDown}
+      onPaste={handlePaste}
+      placeholder={placeholder}
+      {...props}
+      {...controlledProps}
+    />
+  );
+};
+
+export type PromptInputHeaderProps = Omit<
+  ComponentProps<typeof InputGroupAddon>,
+  "align"
+>;
+
+export const PromptInputHeader = ({
+  className,
+  ...props
+}: PromptInputHeaderProps) => (
+  <InputGroupAddon
+    align="block-end"
+    className={cn("order-first flex-wrap gap-1", className)}
+    {...props}
+  />
+);
+
+export type PromptInputFooterProps = Omit<
+  ComponentProps<typeof InputGroupAddon>,
+  "align"
+>;
+
+export const PromptInputFooter = ({
+  className,
+  ...props
+}: PromptInputFooterProps) => (
+  <InputGroupAddon
+    align="block-end"
+    className={cn("justify-between gap-1", className)}
+    {...props}
+  />
+);
+
+export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
+
+export const PromptInputTools = ({
+  className,
+  ...props
+}: PromptInputToolsProps) => (
+  <div
+    className={cn("flex min-w-0 items-center gap-1", className)}
+    {...props}
+  />
+);
+
+export type PromptInputButtonTooltip =
+  | string
+  | {
+      content: ReactNode;
+      shortcut?: string;
+      side?: ComponentProps<typeof TooltipContent>["side"];
+    };
+
+export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton> & {
+  tooltip?: PromptInputButtonTooltip;
+};
+
+export const PromptInputButton = ({
+  variant = "ghost",
+  className,
+  size,
+  tooltip,
+  ...props
+}: PromptInputButtonProps) => {
+  const newSize =
+    size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm");
+
+  const button = (
+    <InputGroupButton
+      className={cn(className)}
+      size={newSize}
+      type="button"
+      variant={variant}
+      {...props}
+    />
+  );
+
+  if (!tooltip) {
+    return button;
+  }
+
+  const tooltipContent =
+    typeof tooltip === "string" ? tooltip : tooltip.content;
+  const shortcut = typeof tooltip === "string" ? undefined : tooltip.shortcut;
+  const side = typeof tooltip === "string" ? "top" : (tooltip.side ?? "top");
+
+  return (
+    <Tooltip>
+      <TooltipTrigger>{button}</TooltipTrigger>
+      <TooltipContent side={side}>
+        {tooltipContent}
+        {shortcut && (
+          <span className="ml-2 text-muted-foreground">{shortcut}</span>
+        )}
+      </TooltipContent>
+    </Tooltip>
+  );
+};
+
+export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
+export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
+  <DropdownMenu {...props} />
+);
+
+export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
+
+export const PromptInputActionMenuTrigger = ({
+  className,
+  children,
+  ...props
+}: PromptInputActionMenuTriggerProps) => (
+  <DropdownMenuTrigger render={<PromptInputButton className={className} {...props} />}>{children ?? <PlusIcon className="size-4" />}</DropdownMenuTrigger>
+);
+
+export type PromptInputActionMenuContentProps = ComponentProps<
+  typeof DropdownMenuContent
+>;
+export const PromptInputActionMenuContent = ({
+  className,
+  ...props
+}: PromptInputActionMenuContentProps) => (
+  <DropdownMenuContent align="start" className={cn(className)} {...props} />
+);
+
+export type PromptInputActionMenuItemProps = ComponentProps<
+  typeof DropdownMenuItem
+>;
+export const PromptInputActionMenuItem = ({
+  className,
+  ...props
+}: PromptInputActionMenuItemProps) => (
+  <DropdownMenuItem className={cn(className)} {...props} />
+);
+
+// Note: Actions that perform side-effects (like opening a file dialog)
+// are provided in opt-in modules (e.g., prompt-input-attachments).
+
+export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
+  status?: ChatStatus;
+  onStop?: () => void;
+};
+
+export const PromptInputSubmit = ({
+  className,
+  variant = "default",
+  size = "icon-sm",
+  status,
+  onStop,
+  onClick,
+  children,
+  ...props
+}: PromptInputSubmitProps) => {
+  const isGenerating = status === "submitted" || status === "streaming";
+
+  let Icon = <CornerDownLeftIcon className="size-4" />;
+
+  if (status === "submitted") {
+    Icon = <Spinner />;
+  } else if (status === "streaming") {
+    Icon = <SquareIcon className="size-4" />;
+  } else if (status === "error") {
+    Icon = <XIcon className="size-4" />;
+  }
+
+  const handleClick = useCallback(
+    (e: React.MouseEvent<HTMLButtonElement>) => {
+      if (isGenerating && onStop) {
+        e.preventDefault();
+        onStop();
+        return;
+      }
+      onClick?.(e as any);
+    },
+    [isGenerating, onStop, onClick]
+  );
+
+  return (
+    <InputGroupButton
+      aria-label={isGenerating ? "Stop" : "Submit"}
+      className={cn(className)}
+      onClick={handleClick}
+      size={size}
+      type={isGenerating && onStop ? "button" : "submit"}
+      variant={variant}
+      {...props}
+    >
+      {children ?? Icon}
+    </InputGroupButton>
+  );
+};
+
+export type PromptInputSelectProps = ComponentProps<typeof Select>;
+
+export const PromptInputSelect = (props: PromptInputSelectProps) => (
+  <Select {...props} />
+);
+
+export type PromptInputSelectTriggerProps = ComponentProps<
+  typeof SelectTrigger
+>;
+
+export const PromptInputSelectTrigger = ({
+  className,
+  ...props
+}: PromptInputSelectTriggerProps) => (
+  <SelectTrigger
+    className={cn(
+      "border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors",
+      "hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
+      className
+    )}
+    {...props}
+  />
+);
+
+export type PromptInputSelectContentProps = ComponentProps<
+  typeof SelectContent
+>;
+
+export const PromptInputSelectContent = ({
+  className,
+  ...props
+}: PromptInputSelectContentProps) => (
+  <SelectContent className={cn(className)} {...props} />
+);
+
+export type PromptInputSelectItemProps = ComponentProps<typeof SelectItem>;
+
+export const PromptInputSelectItem = ({
+  className,
+  ...props
+}: PromptInputSelectItemProps) => (
+  <SelectItem className={cn(className)} {...props} />
+);
+
+export type PromptInputSelectValueProps = ComponentProps<typeof SelectValue>;
+
+export const PromptInputSelectValue = ({
+  className,
+  ...props
+}: PromptInputSelectValueProps) => (
+  <SelectValue className={cn(className)} {...props} />
+);
+
+export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>;
+
+export const PromptInputHoverCard = ({
+  ...props
+}: PromptInputHoverCardProps) => (
+  <HoverCard {...props} />
+);
+
+export type PromptInputHoverCardTriggerProps = ComponentProps<
+  typeof HoverCardTrigger
+>;
+
+export const PromptInputHoverCardTrigger = (
+  props: PromptInputHoverCardTriggerProps
+) => <HoverCardTrigger {...props} />;
+
+export type PromptInputHoverCardContentProps = ComponentProps<
+  typeof HoverCardContent
+>;
+
+export const PromptInputHoverCardContent = ({
+  align = "start",
+  ...props
+}: PromptInputHoverCardContentProps) => (
+  <HoverCardContent align={align} {...props} />
+);
+
+export type PromptInputTabsListProps = HTMLAttributes<HTMLDivElement>;
+
+export const PromptInputTabsList = ({
+  className,
+  ...props
+}: PromptInputTabsListProps) => <div className={cn(className)} {...props} />;
+
+export type PromptInputTabProps = HTMLAttributes<HTMLDivElement>;
+
+export const PromptInputTab = ({
+  className,
+  ...props
+}: PromptInputTabProps) => <div className={cn(className)} {...props} />;
+
+export type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>;
+
+export const PromptInputTabLabel = ({
+  className,
+  ...props
+}: PromptInputTabLabelProps) => (
+  // Content provided via children in props
+  // oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)
+  <h3
+    className={cn(
+      "mb-2 px-3 font-medium text-muted-foreground text-xs",
+      className
+    )}
+    {...props}
+  />
+);
+
+export type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>;
+
+export const PromptInputTabBody = ({
+  className,
+  ...props
+}: PromptInputTabBodyProps) => (
+  <div className={cn("space-y-1", className)} {...props} />
+);
+
+export type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>;
+
+export const PromptInputTabItem = ({
+  className,
+  ...props
+}: PromptInputTabItemProps) => (
+  <div
+    className={cn(
+      "flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent",
+      className
+    )}
+    {...props}
+  />
+);
+
+export type PromptInputCommandProps = ComponentProps<typeof Command>;
+
+export const PromptInputCommand = ({
+  className,
+  ...props
+}: PromptInputCommandProps) => <Command className={cn(className)} {...props} />;
+
+export type PromptInputCommandInputProps = ComponentProps<typeof CommandInput>;
+
+export const PromptInputCommandInput = ({
+  className,
+  ...props
+}: PromptInputCommandInputProps) => (
+  <CommandInput className={cn(className)} {...props} />
+);
+
+export type PromptInputCommandListProps = ComponentProps<typeof CommandList>;
+
+export const PromptInputCommandList = ({
+  className,
+  ...props
+}: PromptInputCommandListProps) => (
+  <CommandList className={cn(className)} {...props} />
+);
+
+export type PromptInputCommandEmptyProps = ComponentProps<typeof CommandEmpty>;
+
+export const PromptInputCommandEmpty = ({
+  className,
+  ...props
+}: PromptInputCommandEmptyProps) => (
+  <CommandEmpty className={cn(className)} {...props} />
+);
+
+export type PromptInputCommandGroupProps = ComponentProps<typeof CommandGroup>;
+
+export const PromptInputCommandGroup = ({
+  className,
+  ...props
+}: PromptInputCommandGroupProps) => (
+  <CommandGroup className={cn(className)} {...props} />
+);
+
+export type PromptInputCommandItemProps = ComponentProps<typeof CommandItem>;
+
+export const PromptInputCommandItem = ({
+  className,
+  ...props
+}: PromptInputCommandItemProps) => (
+  <CommandItem className={cn(className)} {...props} />
+);
+
+export type PromptInputCommandSeparatorProps = ComponentProps<
+  typeof CommandSeparator
+>;
+
+export const PromptInputCommandSeparator = ({
+  className,
+  ...props
+}: PromptInputCommandSeparatorProps) => (
+  <CommandSeparator className={cn(className)} {...props} />
+);

+ 226 - 0
packages/studio/src/components/ai-elements/reasoning.tsx

@@ -0,0 +1,226 @@
+"use client";
+
+import { useControllableState } from "@radix-ui/react-use-controllable-state";
+import {
+  Collapsible,
+  CollapsibleContent,
+  CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import { cjk } from "@streamdown/cjk";
+import { code } from "@streamdown/code";
+import { math } from "@streamdown/math";
+import { mermaid } from "@streamdown/mermaid";
+import { BrainIcon, ChevronDownIcon } from "lucide-react";
+import type { ComponentProps, ReactNode } from "react";
+import {
+  createContext,
+  memo,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
+import { Streamdown } from "streamdown";
+
+import { Shimmer } from "./shimmer";
+
+interface ReasoningContextValue {
+  isStreaming: boolean;
+  isOpen: boolean;
+  setIsOpen: (open: boolean) => void;
+  duration: number | undefined;
+}
+
+const ReasoningContext = createContext<ReasoningContextValue | null>(null);
+
+export const useReasoning = () => {
+  const context = useContext(ReasoningContext);
+  if (!context) {
+    throw new Error("Reasoning components must be used within Reasoning");
+  }
+  return context;
+};
+
+export type ReasoningProps = ComponentProps<typeof Collapsible> & {
+  isStreaming?: boolean;
+  open?: boolean;
+  defaultOpen?: boolean;
+  onOpenChange?: (open: boolean) => void;
+  duration?: number;
+};
+
+const AUTO_CLOSE_DELAY = 1000;
+const MS_IN_S = 1000;
+
+export const Reasoning = memo(
+  ({
+    className,
+    isStreaming = false,
+    open,
+    defaultOpen,
+    onOpenChange,
+    duration: durationProp,
+    children,
+    ...props
+  }: ReasoningProps) => {
+    const resolvedDefaultOpen = defaultOpen ?? isStreaming;
+    // Track if defaultOpen was explicitly set to false (to prevent auto-open)
+    const isExplicitlyClosed = defaultOpen === false;
+
+    const [isOpen, setIsOpen] = useControllableState<boolean>({
+      defaultProp: resolvedDefaultOpen,
+      onChange: onOpenChange,
+      prop: open,
+    });
+    const [duration, setDuration] = useControllableState<number | undefined>({
+      defaultProp: undefined,
+      prop: durationProp,
+    });
+
+    const hasEverStreamedRef = useRef(isStreaming);
+    const [hasAutoClosed, setHasAutoClosed] = useState(false);
+    const startTimeRef = useRef<number | null>(null);
+
+    // Track when streaming starts and compute duration
+    useEffect(() => {
+      if (isStreaming) {
+        hasEverStreamedRef.current = true;
+        if (startTimeRef.current === null) {
+          startTimeRef.current = Date.now();
+        }
+      } else if (startTimeRef.current !== null) {
+        setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
+        startTimeRef.current = null;
+      }
+    }, [isStreaming, setDuration]);
+
+    // Auto-open when streaming starts (unless explicitly closed)
+    useEffect(() => {
+      if (isStreaming && !isOpen && !isExplicitlyClosed) {
+        setIsOpen(true);
+      }
+    }, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
+
+    // Auto-close when streaming ends (once only, and only if it ever streamed)
+    useEffect(() => {
+      if (
+        hasEverStreamedRef.current &&
+        !isStreaming &&
+        isOpen &&
+        !hasAutoClosed
+      ) {
+        const timer = setTimeout(() => {
+          setIsOpen(false);
+          setHasAutoClosed(true);
+        }, AUTO_CLOSE_DELAY);
+
+        return () => clearTimeout(timer);
+      }
+    }, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
+
+    const handleOpenChange = useCallback(
+      (newOpen: boolean) => {
+        setIsOpen(newOpen);
+      },
+      [setIsOpen]
+    );
+
+    const contextValue = useMemo(
+      () => ({ duration, isOpen, isStreaming, setIsOpen }),
+      [duration, isOpen, isStreaming, setIsOpen]
+    );
+
+    return (
+      <ReasoningContext.Provider value={contextValue}>
+        <Collapsible
+          className={cn("not-prose mb-4", className)}
+          onOpenChange={handleOpenChange}
+          open={isOpen}
+          {...props}
+        >
+          {children}
+        </Collapsible>
+      </ReasoningContext.Provider>
+    );
+  }
+);
+
+export type ReasoningTriggerProps = ComponentProps<
+  typeof CollapsibleTrigger
+> & {
+  getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
+};
+
+const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
+  if (isStreaming || duration === 0) {
+    return <Shimmer duration={1}>Thinking...</Shimmer>;
+  }
+  if (duration === undefined) {
+    return <p>Thought for a few seconds</p>;
+  }
+  return <p>Thought for {duration} seconds</p>;
+};
+
+export const ReasoningTrigger = memo(
+  ({
+    className,
+    children,
+    getThinkingMessage = defaultGetThinkingMessage,
+    ...props
+  }: ReasoningTriggerProps) => {
+    const { isStreaming, isOpen, duration } = useReasoning();
+
+    return (
+      <CollapsibleTrigger
+        className={cn(
+          "flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
+          className
+        )}
+        {...props}
+      >
+        {children ?? (
+          <>
+            <BrainIcon className="size-4" />
+            {getThinkingMessage(isStreaming, duration)}
+            <ChevronDownIcon
+              className={cn(
+                "size-4 transition-transform",
+                isOpen ? "rotate-180" : "rotate-0"
+              )}
+            />
+          </>
+        )}
+      </CollapsibleTrigger>
+    );
+  }
+);
+
+export type ReasoningContentProps = ComponentProps<
+  typeof CollapsibleContent
+> & {
+  children: string;
+};
+
+const streamdownPlugins = { cjk, code, math, mermaid };
+
+export const ReasoningContent = memo(
+  ({ className, children, ...props }: ReasoningContentProps) => (
+    <CollapsibleContent
+      className={cn(
+        "mt-4 text-sm",
+        "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
+        className
+      )}
+      {...props}
+    >
+      <Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
+    </CollapsibleContent>
+  )
+);
+
+Reasoning.displayName = "Reasoning";
+ReasoningTrigger.displayName = "ReasoningTrigger";
+ReasoningContent.displayName = "ReasoningContent";

+ 77 - 0
packages/studio/src/components/ai-elements/shimmer.tsx

@@ -0,0 +1,77 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import type { MotionProps } from "motion/react";
+import { motion } from "motion/react";
+import type { CSSProperties, ElementType, JSX } from "react";
+import { memo, useMemo } from "react";
+
+type MotionHTMLProps = MotionProps & Record<string, unknown>;
+
+// Cache motion components at module level to avoid creating during render
+const motionComponentCache = new Map<
+  keyof JSX.IntrinsicElements,
+  React.ComponentType<MotionHTMLProps>
+>();
+
+const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
+  let component = motionComponentCache.get(element);
+  if (!component) {
+    component = motion.create(element);
+    motionComponentCache.set(element, component);
+  }
+  return component;
+};
+
+export interface TextShimmerProps {
+  children: string;
+  as?: ElementType;
+  className?: string;
+  duration?: number;
+  spread?: number;
+}
+
+const ShimmerComponent = ({
+  children,
+  as: Component = "p",
+  className,
+  duration = 2,
+  spread = 2,
+}: TextShimmerProps) => {
+  const MotionComponent = getMotionComponent(
+    Component as keyof JSX.IntrinsicElements
+  );
+
+  const dynamicSpread = useMemo(
+    () => (children?.length ?? 0) * spread,
+    [children, spread]
+  );
+
+  return (
+    <MotionComponent
+      animate={{ backgroundPosition: "0% center" }}
+      className={cn(
+        "relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
+        "[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
+        className
+      )}
+      initial={{ backgroundPosition: "100% center" }}
+      style={
+        {
+          "--spread": `${dynamicSpread}px`,
+          backgroundImage:
+            "var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
+        } as CSSProperties
+      }
+      transition={{
+        duration,
+        ease: "linear",
+        repeat: Number.POSITIVE_INFINITY,
+      }}
+    >
+      {children}
+    </MotionComponent>
+  );
+};
+
+export const Shimmer = memo(ShimmerComponent);

+ 173 - 0
packages/studio/src/components/ai-elements/tool.tsx

@@ -0,0 +1,173 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import {
+  Collapsible,
+  CollapsibleContent,
+  CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import type { DynamicToolUIPart, ToolUIPart } from "ai";
+import {
+  CheckCircleIcon,
+  ChevronDownIcon,
+  CircleIcon,
+  ClockIcon,
+  WrenchIcon,
+  XCircleIcon,
+} from "lucide-react";
+import type { ComponentProps, ReactNode } from "react";
+import { isValidElement } from "react";
+
+import { CodeBlock } from "./code-block";
+
+export type ToolProps = ComponentProps<typeof Collapsible>;
+
+export const Tool = ({ className, ...props }: ToolProps) => (
+  <Collapsible
+    className={cn("group not-prose mb-4 w-full rounded-md border", className)}
+    {...props}
+  />
+);
+
+export type ToolPart = ToolUIPart | DynamicToolUIPart;
+
+export type ToolHeaderProps = {
+  title?: string;
+  className?: string;
+} & (
+  | { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
+  | {
+      type: DynamicToolUIPart["type"];
+      state: DynamicToolUIPart["state"];
+      toolName: string;
+    }
+);
+
+const statusLabels: Record<ToolPart["state"], string> = {
+  "approval-requested": "等待确认",
+  "approval-responded": "已响应",
+  "input-available": "执行中",
+  "input-streaming": "处理中",
+  "output-available": "已完成",
+  "output-denied": "已拒绝",
+  "output-error": "出错",
+};
+
+const statusIcons: Record<ToolPart["state"], ReactNode> = {
+  "approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
+  "approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
+  "input-available": <ClockIcon className="size-4 animate-pulse" />,
+  "input-streaming": <CircleIcon className="size-4" />,
+  "output-available": <CheckCircleIcon className="size-4 text-green-600" />,
+  "output-denied": <XCircleIcon className="size-4 text-orange-600" />,
+  "output-error": <XCircleIcon className="size-4 text-red-600" />,
+};
+
+export const getStatusBadge = (status: ToolPart["state"]) => (
+  <Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
+    {statusIcons[status]}
+    {statusLabels[status]}
+  </Badge>
+);
+
+export const ToolHeader = ({
+  className,
+  title,
+  type,
+  state,
+  toolName,
+  ...props
+}: ToolHeaderProps) => {
+  const derivedName =
+    type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
+
+  return (
+    <CollapsibleTrigger
+      className={cn(
+        "flex w-full items-center justify-between gap-4 p-3",
+        className
+      )}
+      {...props}
+    >
+      <div className="flex items-center gap-2">
+        <WrenchIcon className="size-4 text-muted-foreground" />
+        <span className="font-medium text-sm">{title ?? derivedName}</span>
+        {getStatusBadge(state)}
+      </div>
+      <ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
+    </CollapsibleTrigger>
+  );
+};
+
+export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
+
+export const ToolContent = ({ className, ...props }: ToolContentProps) => (
+  <CollapsibleContent
+    className={cn(
+      "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 space-y-4 p-4 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
+      className
+    )}
+    {...props}
+  />
+);
+
+export type ToolInputProps = ComponentProps<"div"> & {
+  input: ToolPart["input"];
+};
+
+export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
+  <div className={cn("space-y-2 overflow-hidden", className)} {...props}>
+    <h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
+      Parameters
+    </h4>
+    <div className="rounded-md bg-muted/50">
+      <CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
+    </div>
+  </div>
+);
+
+export type ToolOutputProps = ComponentProps<"div"> & {
+  output: ToolPart["output"];
+  errorText: ToolPart["errorText"];
+};
+
+export const ToolOutput = ({
+  className,
+  output,
+  errorText,
+  ...props
+}: ToolOutputProps) => {
+  if (!(output || errorText)) {
+    return null;
+  }
+
+  let Output = <div>{output as ReactNode}</div>;
+
+  if (typeof output === "object" && !isValidElement(output)) {
+    Output = (
+      <CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
+    );
+  } else if (typeof output === "string") {
+    Output = <CodeBlock code={output} language="json" />;
+  }
+
+  return (
+    <div className={cn("space-y-2", className)} {...props}>
+      <h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
+        {errorText ? "Error" : "Result"}
+      </h4>
+      <div
+        className={cn(
+          "overflow-x-auto rounded-md text-xs [&_table]:w-full",
+          errorText
+            ? "bg-destructive/10 text-destructive"
+            : "bg-muted/50 text-foreground"
+        )}
+      >
+        {errorText && <div>{errorText}</div>}
+        {Output}
+      </div>
+    </div>
+  );
+};

+ 56 - 0
packages/studio/src/components/chat-utils.ts

@@ -0,0 +1,56 @@
+// Pure helper functions extracted from ChatBar for reuse in ChatPage and tests.
+
+interface ChatMessage {
+  readonly role: "user" | "assistant";
+  readonly content: string;
+  readonly timestamp: number;
+}
+
+interface SharedSessionMeta {
+  readonly activeBookId?: string;
+  readonly automationMode?: string;
+  readonly currentStage?: string;
+  readonly pendingSummary?: string;
+  readonly draftTitle?: string;
+}
+
+interface BookRef {
+  readonly id: string;
+}
+
+export function coerceSharedSessionMessages(
+  messages: ReadonlyArray<{ role: "user" | "assistant" | "system"; content: string; timestamp: number }>,
+): ReadonlyArray<ChatMessage> {
+  return messages
+    .filter((message) => message.role === "user" || message.role === "assistant")
+    .map((message) => ({
+      role: message.role as "user" | "assistant",
+      content: message.content,
+      timestamp: message.timestamp,
+    }));
+}
+
+export function resolveDirectWriteTarget(
+  activeBookId: string | undefined,
+  books: ReadonlyArray<BookRef>,
+): { bookId: string | null; reason: "active" | "single" | "missing" | "ambiguous" } {
+  if (activeBookId && books.some((book) => book.id === activeBookId)) {
+    return { bookId: activeBookId, reason: "active" };
+  }
+  if (books.length === 1) {
+    return { bookId: books[0]!.id, reason: "single" };
+  }
+  if (books.length === 0) {
+    return { bookId: null, reason: "missing" };
+  }
+  return { bookId: null, reason: "ambiguous" };
+}
+
+export function formatSharedSessionContext(meta: SharedSessionMeta): string {
+  return [
+    meta.activeBookId ?? "no-book",
+    meta.draftTitle ? `draft:${meta.draftTitle}` : undefined,
+    meta.automationMode ?? "semi",
+    meta.currentStage,
+  ].filter(Boolean).join(" · ");
+}

+ 199 - 0
packages/studio/src/components/chat/BookFormCard.tsx

@@ -0,0 +1,199 @@
+import type { Theme } from "../../hooks/use-theme";
+import { cn } from "../../lib/utils";
+import { Tool, ToolHeader, ToolContent } from "../ai-elements/tool";
+import { Loader2 } from "lucide-react";
+
+export interface BookFormArgs {
+  title?: string;
+  genre?: string;
+  platform?: string;
+  targetChapters?: number;
+  chapterWordCount?: number;
+  language?: string;
+  brief?: string;
+}
+
+export interface BookFormCardProps {
+  readonly args: BookFormArgs;
+  readonly onArgsChange: (args: BookFormArgs) => void;
+  readonly onConfirm: () => void;
+  readonly confirming: boolean;
+  readonly theme: Theme;
+}
+
+const PLATFORM_OPTIONS = [
+  { label: "番茄小说", value: "tomato" },
+  { label: "起点中文网", value: "qidian" },
+  { label: "飞卢", value: "feilu" },
+  { label: "其他", value: "other" },
+] as const;
+
+const LANGUAGE_OPTIONS = [
+  { label: "中文", value: "zh" },
+  { label: "English", value: "en" },
+] as const;
+
+const labelClass = "text-[10px] uppercase tracking-[0.18em] text-muted-foreground font-semibold";
+
+const inputClass = cn(
+  "w-full rounded-lg border border-border/60 bg-background/80 px-3 py-1.5 text-sm",
+  "outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/10 transition-all",
+  "placeholder:text-muted-foreground/40",
+);
+
+function RadioGroup({
+  options,
+  value,
+  onChange,
+  disabled,
+}: {
+  readonly options: ReadonlyArray<{ readonly label: string; readonly value: string }>;
+  readonly value: string | undefined;
+  readonly onChange: (v: string) => void;
+  readonly disabled?: boolean;
+}) {
+  return (
+    <div className="flex flex-wrap gap-2">
+      {options.map((opt) => (
+        <button
+          key={opt.value}
+          type="button"
+          onClick={() => onChange(opt.value)}
+          disabled={disabled}
+          className={cn(
+            "px-3 py-1 rounded-lg text-xs font-medium border transition-all",
+            value === opt.value
+              ? "border-primary bg-primary/10 text-primary"
+              : "border-border/60 bg-background/60 text-muted-foreground hover:border-primary/30",
+            disabled && "opacity-50 cursor-not-allowed",
+          )}
+        >
+          {opt.label}
+        </button>
+      ))}
+    </div>
+  );
+}
+
+export function BookFormCard({ args, onArgsChange, onConfirm, confirming }: BookFormCardProps) {
+  const disabled = confirming;
+  const update = <K extends keyof BookFormArgs>(key: K, value: BookFormArgs[K]) => {
+    if (disabled) return;
+    onArgsChange({ ...args, [key]: value });
+  };
+
+  const disabledInput = disabled ? "opacity-60 cursor-not-allowed" : "";
+  const toolState = confirming ? "input-available" : "approval-requested";
+
+  return (
+    <Tool defaultOpen>
+      <ToolHeader
+        title="创建新书"
+        type="tool-invocation"
+        state={toolState}
+      />
+      <ToolContent>
+        <div className={cn("space-y-4", disabled && "pointer-events-none opacity-80")}>
+          {/* 书名 */}
+          <div className="space-y-1.5">
+            <label className={labelClass}>书名</label>
+            <input
+              type="text"
+              value={args.title ?? ""}
+              onChange={(e) => update("title", e.target.value)}
+              placeholder="输入书名"
+              disabled={disabled}
+              className={cn(inputClass, disabledInput)}
+            />
+          </div>
+
+          {/* 题材 */}
+          <div className="space-y-1.5">
+            <label className={labelClass}>题材</label>
+            <input
+              type="text"
+              value={args.genre ?? ""}
+              onChange={(e) => update("genre", e.target.value)}
+              placeholder="如 xuanhuan、urban、romance"
+              disabled={disabled}
+              className={cn(inputClass, disabledInput)}
+            />
+          </div>
+
+          {/* 目标平台 */}
+          <div className="space-y-1.5">
+            <label className={labelClass}>目标平台</label>
+            <RadioGroup
+              options={PLATFORM_OPTIONS}
+              value={args.platform}
+              onChange={(v) => update("platform", v)}
+              disabled={disabled}
+            />
+          </div>
+
+          {/* 目标章数 + 每章字数 */}
+          <div className="grid grid-cols-2 gap-3">
+            <div className="space-y-1.5">
+              <label className={labelClass}>目标章数</label>
+              <input
+                type="number"
+                value={args.targetChapters ?? ""}
+                onChange={(e) => update("targetChapters", e.target.value ? Number(e.target.value) : undefined)}
+                placeholder="如 200"
+                disabled={disabled}
+                className={cn(inputClass, disabledInput)}
+              />
+            </div>
+            <div className="space-y-1.5">
+              <label className={labelClass}>每章字数</label>
+              <input
+                type="number"
+                value={args.chapterWordCount ?? ""}
+                onChange={(e) => update("chapterWordCount", e.target.value ? Number(e.target.value) : undefined)}
+                placeholder="如 2000"
+                disabled={disabled}
+                className={cn(inputClass, disabledInput)}
+              />
+            </div>
+          </div>
+
+          {/* 写作语言 */}
+          <div className="space-y-1.5">
+            <label className={labelClass}>写作语言</label>
+            <RadioGroup
+              options={LANGUAGE_OPTIONS}
+              value={args.language}
+              onChange={(v) => update("language", v)}
+              disabled={disabled}
+            />
+          </div>
+
+          {/* 创意简述 */}
+          <div className="space-y-1.5">
+            <label className={labelClass}>创意简述</label>
+            <div className={cn(
+              "rounded-lg border border-border/60 bg-background/80 px-3 py-2 text-sm leading-7 whitespace-pre-wrap",
+              disabled ? "opacity-60" : "",
+            )}>
+              {args.brief || <span className="text-muted-foreground/40">AI 会根据你的描述自动生成</span>}
+            </div>
+          </div>
+        </div>
+
+        {/* 确认按钮 */}
+        <div className="flex items-center justify-between pt-4 border-t border-border/40 mt-4">
+          <p className="text-xs text-muted-foreground">确认后将由 Architect 生成完整 foundation</p>
+          <button
+            type="button"
+            onClick={onConfirm}
+            disabled={confirming}
+            className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
+          >
+            {confirming && <Loader2 size={14} className="animate-spin" />}
+            {confirming ? "创建中…" : "开始写这本书"}
+          </button>
+        </div>
+      </ToolContent>
+    </Tool>
+  );
+}

+ 291 - 0
packages/studio/src/components/chat/BookSidebar.tsx

@@ -0,0 +1,291 @@
+import { useState, useEffect, useCallback, useRef } from "react";
+import type { Theme } from "../../hooks/use-theme";
+import type { TFunction } from "../../hooks/use-i18n";
+import type { SSEMessage } from "../../hooks/use-sse";
+import { useChatStore } from "../../store/chat";
+import { fetchJson } from "../../hooks/use-api";
+import { PanelRightClose, PanelRightOpen, ArrowLeft, Loader2, Pencil, Save, X } from "lucide-react";
+import { Streamdown } from "streamdown";
+import { cjk } from "@streamdown/cjk";
+import { ProgressSection } from "../sidebar/ProgressSection";
+import { FoundationSection } from "../sidebar/FoundationSection";
+import { SummarySection } from "../sidebar/SummarySection";
+import { ChaptersSection } from "../sidebar/ChaptersSection";
+import { CharacterSection } from "../sidebar/CharacterSection";
+
+export interface BookSidebarProps {
+  readonly bookId: string;
+  readonly theme: Theme;
+  readonly t: TFunction;
+  readonly sse: { messages: ReadonlyArray<SSEMessage>; connected: boolean };
+}
+
+const FOUNDATION_LABELS: Record<string, string> = {
+  "story_bible.md": "世界观设定",
+  "volume_outline.md": "卷纲规划",
+  "book_rules.md": "叙事规则",
+  "current_state.md": "状态卡",
+  "pending_hooks.md": "伏笔池",
+  "subplot_board.md": "支线进度",
+  "emotional_arcs.md": "感情线",
+  "character_matrix.md": "角色矩阵",
+};
+
+const streamdownPlugins = { cjk };
+
+function ArtifactView({ bookId }: { readonly bookId: string }) {
+  const artifactFile = useChatStore((s) => s.artifactFile);
+  const artifactChapter = useChatStore((s) => s.artifactChapter);
+  const closeArtifact = useChatStore((s) => s.closeArtifact);
+  const [content, setContent] = useState<string | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [editing, setEditing] = useState(false);
+  const [editContent, setEditContent] = useState("");
+  const [saving, setSaving] = useState(false);
+
+  const isChapter = artifactChapter !== null;
+  const label = isChapter
+    ? `第 ${artifactChapter} 章`
+    : artifactFile ? FOUNDATION_LABELS[artifactFile] ?? artifactFile : "";
+
+  useEffect(() => {
+    setEditing(false);
+    setLoading(true);
+    if (isChapter) {
+      fetchJson<{ content: string }>(`/books/${bookId}/chapters/${artifactChapter}`)
+        .then((data) => setContent(data.content ?? ""))
+        .catch(() => setContent(null))
+        .finally(() => setLoading(false));
+    } else if (artifactFile) {
+      fetchJson<{ content: string | null }>(`/books/${bookId}/truth/${artifactFile}`)
+        .then((data) => setContent(data.content ?? ""))
+        .catch(() => setContent(null))
+        .finally(() => setLoading(false));
+    }
+  }, [bookId, artifactFile, artifactChapter, isChapter]);
+
+  const handleEdit = useCallback(() => {
+    setEditContent(content ?? "");
+    setEditing(true);
+  }, [content]);
+
+  const handleSave = useCallback(async () => {
+    setSaving(true);
+    try {
+      if (isChapter) {
+        await fetchJson(`/books/${bookId}/chapters/${artifactChapter}`, {
+          method: "PUT",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ content: editContent }),
+        });
+      } else if (artifactFile) {
+        await fetchJson(`/books/${bookId}/truth/${artifactFile}`, {
+          method: "PUT",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ content: editContent }),
+        });
+      }
+      setContent(editContent);
+      setEditing(false);
+    } catch {
+      // keep editing state on error
+    } finally {
+      setSaving(false);
+    }
+  }, [bookId, artifactFile, artifactChapter, isChapter, editContent]);
+
+  return (
+    <div className="flex flex-col h-full">
+      <div className="flex items-center gap-2 px-3 py-2.5 border-b border-border/20 shrink-0">
+        <button
+          onClick={closeArtifact}
+          className="w-6 h-6 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors"
+        >
+          <ArrowLeft size={14} />
+        </button>
+        <span className="text-sm font-medium truncate flex-1">{label}</span>
+        {!loading && content !== null && !editing && (
+          <button
+            onClick={handleEdit}
+            className="w-6 h-6 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors"
+          >
+            <Pencil size={12} />
+          </button>
+        )}
+        {editing && (
+          <div className="flex items-center gap-1">
+            <button
+              onClick={handleSave}
+              disabled={saving}
+              className="w-6 h-6 rounded-md flex items-center justify-center text-emerald-500 hover:bg-emerald-500/10 transition-colors"
+            >
+              {saving ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
+            </button>
+            <button
+              onClick={() => setEditing(false)}
+              className="w-6 h-6 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors"
+            >
+              <X size={12} />
+            </button>
+          </div>
+        )}
+      </div>
+      <div className="flex-1 overflow-y-auto">
+        {loading ? (
+          <div className="flex items-center justify-center py-8">
+            <Loader2 size={16} className="text-muted-foreground animate-spin" />
+          </div>
+        ) : content === null ? (
+          <p className="text-xs text-muted-foreground/50 italic px-4 py-3">文件不存在</p>
+        ) : editing ? (
+          <textarea
+            value={editContent}
+            onChange={(e) => setEditContent(e.target.value)}
+            className="w-full h-full min-h-[300px] bg-transparent text-sm leading-7 px-4 py-3 resize-none outline-none border-0 font-mono"
+          />
+        ) : (
+          <div className="px-4 py-3 text-sm leading-7">
+            <Streamdown plugins={streamdownPlugins} mode="static">{content}</Streamdown>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}
+
+function PanelView({ bookId, theme: _theme, t, sse }: BookSidebarProps) {
+  const isZh = t("nav.connected") === "\u5DF2\u8FDE\u63A5";
+
+  // Show writing indicator only during pipeline operations (write/audit/revise)
+  const [activeOp, setActiveOp] = useState<string | null>(null);
+  useEffect(() => {
+    const latest = sse.messages;
+    if (latest.length === 0) return;
+    const last = latest[latest.length - 1];
+    if (last.event === "write:start") setActiveOp("write");
+    else if (last.event === "tool:start") {
+      const data = last.data as { tool?: string; args?: { agent?: string } } | null;
+      if (data?.tool === "sub_agent") {
+        const agent = data.args?.agent;
+        if (agent === "writer") setActiveOp("write");
+        else if (agent === "auditor") setActiveOp("audit");
+        else if (agent === "reviser") setActiveOp("revise");
+      }
+    } else if (last.event === "write:complete" || last.event === "tool:end") {
+      setActiveOp(null);
+    }
+  }, [sse.messages]);
+
+  const OP_LABELS: Record<string, string> = {
+    write: isZh ? "正在写作中..." : "Writing...",
+    audit: isZh ? "正在审计中..." : "Auditing...",
+    revise: isZh ? "正在修订中..." : "Revising...",
+  };
+
+  return (
+    <div className="flex flex-col gap-2 p-3">
+      {activeOp && (
+        <div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-primary/5 border border-primary/10">
+          <Loader2 size={12} className="text-primary animate-spin shrink-0" />
+          <span className="text-xs text-primary font-medium">
+            {OP_LABELS[activeOp] ?? activeOp}
+          </span>
+        </div>
+      )}
+      <ProgressSection sse={sse} />
+      <ChaptersSection bookId={bookId} isZh={isZh} />
+      <CharacterSection bookId={bookId} />
+      <FoundationSection bookId={bookId} />
+      <SummarySection bookId={bookId} />
+    </div>
+  );
+}
+
+const SIDEBAR_RATIO = 0.4;
+const SIDEBAR_MIN = 280;
+const SIDEBAR_MAX = 700;
+
+function defaultSidebarWidth(): number {
+  return Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, Math.round(window.innerWidth * SIDEBAR_RATIO)));
+}
+
+export function BookSidebar({ bookId, theme, t, sse }: BookSidebarProps) {
+  const sidebarView = useChatStore((s) => s.sidebarView);
+  const [width, setWidth] = useState(defaultSidebarWidth);
+  const dragging = useRef(false);
+
+  const onMouseDown = useCallback((e: React.MouseEvent) => {
+    e.preventDefault();
+    dragging.current = true;
+    const startX = e.clientX;
+    const startW = width;
+    const onMove = (ev: MouseEvent) => {
+      if (!dragging.current) return;
+      const delta = startX - ev.clientX;
+      setWidth(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, startW + delta)));
+    };
+    const onUp = () => {
+      dragging.current = false;
+      document.removeEventListener("mousemove", onMove);
+      document.removeEventListener("mouseup", onUp);
+    };
+    document.addEventListener("mousemove", onMove);
+    document.addEventListener("mouseup", onUp);
+  }, [width]);
+
+  return (
+    <aside
+      className="hidden lg:flex shrink-0 flex-col bg-background/30 backdrop-blur-sm overflow-y-auto relative"
+      style={{ width }}
+    >
+      {/* Resize handle */}
+      <div
+        onMouseDown={onMouseDown}
+        className="absolute left-0 top-0 h-full w-1 cursor-col-resize hover:bg-primary/20 active:bg-primary/30 transition-colors z-10"
+      />
+      {sidebarView === "artifact" ? (
+        <ArtifactView bookId={bookId} />
+      ) : (
+        <PanelView bookId={bookId} theme={theme} t={t} sse={sse} />
+      )}
+    </aside>
+  );
+}
+
+export function BookSidebarToggle({ bookId, theme, t, sse }: BookSidebarProps) {
+  const [open, setOpen] = useState(false);
+  const sidebarView = useChatStore((s) => s.sidebarView);
+
+  return (
+    <>
+      <button
+        onClick={() => setOpen(true)}
+        className="fixed right-3 top-[72px] z-20 lg:hidden w-8 h-8 rounded-lg bg-card border border-border/40 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
+      >
+        <PanelRightOpen size={14} />
+      </button>
+
+      {open && (
+        <div className="fixed inset-0 z-40 lg:hidden" onClick={() => setOpen(false)}>
+          <div className="absolute inset-0 bg-black/20 backdrop-blur-sm" />
+          <aside
+            className="absolute right-0 top-0 h-full w-[420px] max-w-[85vw] bg-background border-l border-border/20 overflow-y-auto"
+            onClick={(e) => e.stopPropagation()}
+          >
+            <div className="flex items-center justify-between px-3 py-2 border-b border-border/20">
+              <span className="text-xs font-medium text-muted-foreground">书籍信息</span>
+              <button onClick={() => setOpen(false)} className="text-muted-foreground hover:text-foreground">
+                <PanelRightClose size={14} />
+              </button>
+            </div>
+            {sidebarView === "artifact" ? (
+              <ArtifactView bookId={bookId} />
+            ) : (
+              <PanelView bookId={bookId} theme={theme} t={t} sse={sse} />
+            )}
+          </aside>
+        </div>
+      )}
+    </>
+  );
+}

+ 75 - 0
packages/studio/src/components/chat/ChatMessage.tsx

@@ -0,0 +1,75 @@
+import type { Theme } from "../../hooks/use-theme";
+import { cn } from "../../lib/utils";
+import {
+  Message,
+  MessageContent,
+  MessageResponse,
+} from "../ai-elements/message";
+import { BookFormCard } from "./BookFormCard";
+import type { BookFormArgs } from "./BookFormCard";
+import {
+  CheckCircle2,
+  XCircle,
+  Loader2,
+} from "lucide-react";
+
+export interface ToolCall {
+  readonly name: string;
+  readonly arguments: Record<string, unknown>;
+}
+
+export interface ChatMessageProps {
+  readonly role: "user" | "assistant";
+  readonly content: string;
+  readonly timestamp: number;
+  readonly theme: Theme;
+  readonly toolCall?: ToolCall;
+  readonly onArgsChange?: (args: Record<string, unknown>) => void;
+  readonly onConfirm?: () => void;
+  readonly confirming?: boolean;
+}
+
+export function ChatMessage({
+  role,
+  content,
+  timestamp,
+  theme,
+  toolCall,
+  onArgsChange,
+  onConfirm,
+  confirming,
+}: ChatMessageProps) {
+  const isUser = role === "user";
+  const isStatus = content.startsWith("\u22EF");
+  const isSuccess = content.startsWith("\u2713");
+  const isError = content.startsWith("\u2717");
+
+  const hasBookForm = toolCall?.name === "create_book" && onArgsChange && onConfirm;
+
+  return (
+    <Message from={role}>
+      <MessageContent>
+        {isUser ? (
+          <div className="text-sm leading-relaxed">{content}</div>
+        ) : isError ? (
+          <div className="flex items-center gap-2 text-sm text-destructive">
+            <XCircle size={14} className="shrink-0" />
+            <span>{content.replace(/^\u2717\s*/, "")}</span>
+          </div>
+        ) : (
+          <MessageResponse>{content}</MessageResponse>
+        )}
+      </MessageContent>
+
+      {hasBookForm && (
+        <BookFormCard
+          args={toolCall.arguments as BookFormArgs}
+          onArgsChange={(a) => onArgsChange(a as Record<string, unknown>)}
+          onConfirm={onConfirm}
+          confirming={confirming ?? false}
+          theme={theme}
+        />
+      )}
+    </Message>
+  );
+}

+ 73 - 0
packages/studio/src/components/chat/QuickActions.tsx

@@ -0,0 +1,73 @@
+import {
+  Zap,
+  Search,
+  FileOutput,
+  TrendingUp,
+} from "lucide-react";
+
+export interface QuickActionsProps {
+  readonly onAction: (command: string) => void;
+  readonly disabled: boolean;
+  readonly isZh: boolean;
+}
+
+interface ChipDef {
+  readonly icon: React.ReactNode;
+  readonly labelZh: string;
+  readonly labelEn: string;
+  readonly commandZh: string;
+  readonly commandEn: string;
+}
+
+const CHIPS: ReadonlyArray<ChipDef> = [
+  {
+    icon: <Zap size={12} />,
+    labelZh: "写下一章",
+    labelEn: "Write next",
+    commandZh: "写下一章",
+    commandEn: "write next",
+  },
+  {
+    icon: <Search size={12} />,
+    labelZh: "审计",
+    labelEn: "Audit",
+    commandZh: "审计",
+    commandEn: "audit",
+  },
+  {
+    icon: <FileOutput size={12} />,
+    labelZh: "导出",
+    labelEn: "Export",
+    commandZh: "导出全书",
+    commandEn: "export book",
+  },
+  {
+    icon: <TrendingUp size={12} />,
+    labelZh: "市场雷达",
+    labelEn: "Market radar",
+    commandZh: "扫描市场趋势",
+    commandEn: "scan market trends",
+  },
+];
+
+export function QuickActions({ onAction, disabled, isZh }: QuickActionsProps) {
+  return (
+    <div className="flex gap-2 overflow-x-auto px-1 py-1">
+      {CHIPS.map((chip) => {
+        const label = isZh ? chip.labelZh : chip.labelEn;
+        const command = isZh ? chip.commandZh : chip.commandEn;
+        return (
+          <button
+            key={label}
+            onClick={() => onAction(command)}
+            disabled={disabled}
+            className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-secondary/50 border border-border/30 text-xs font-medium text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/5 transition-all disabled:opacity-40 disabled:pointer-events-none group"
+          >
+            <span className="group-hover:scale-110 transition-transform">{chip.icon}</span>
+            {label}
+          </button>
+        );
+      })}
+    </div>
+  );
+}

+ 236 - 0
packages/studio/src/components/chat/ToolExecutionSteps.tsx

@@ -0,0 +1,236 @@
+import { useMemo, useState, useEffect } from "react";
+import type { ToolExecution, PipelineStage } from "../../store/chat/types";
+import {
+  Collapsible,
+  CollapsibleTrigger,
+  CollapsibleContent,
+} from "../ui/collapsible";
+import {
+  Loader2,
+  CheckCircle2,
+  XCircle,
+  ChevronDown,
+  Wrench,
+} from "lucide-react";
+
+// -- Status rendering helpers --
+
+function ExecStatusBadge({ status }: { status: ToolExecution["status"] }) {
+  switch (status) {
+    case "running":
+      return (
+        <span className="inline-flex items-center gap-1 text-xs text-primary">
+          <Loader2 size={12} className="animate-spin" />
+          <span>执行中</span>
+        </span>
+      );
+    case "processing":
+      return (
+        <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
+          <Loader2 size={12} className="animate-spin" style={{ animationDuration: "2s" }} />
+          <span>处理结果</span>
+        </span>
+      );
+    case "completed":
+      return (
+        <span className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
+          <CheckCircle2 size={12} />
+          <span>已完成</span>
+        </span>
+      );
+    case "error":
+      return (
+        <span className="inline-flex items-center gap-1 text-xs text-destructive">
+          <XCircle size={12} />
+          <span>失败</span>
+        </span>
+      );
+  }
+}
+
+function StageIcon({ status }: { status: PipelineStage["status"] }) {
+  switch (status) {
+    case "pending":
+      return <span className="w-4 h-4 rounded-full border border-border/60 flex items-center justify-center shrink-0 text-[8px] text-muted-foreground/40">○</span>;
+    case "active":
+      return <Loader2 size={14} className="text-primary animate-spin shrink-0" />;
+    case "completed":
+      return <CheckCircle2 size={14} className="text-green-600 dark:text-green-400 shrink-0" />;
+  }
+}
+
+function formatProgress(progress: NonNullable<PipelineStage["progress"]>): string {
+  const secs = Math.round(progress.elapsedMs / 1000);
+  const statusLabel = progress.status === "thinking" ? "思考中" : "";
+  const chars = progress.totalChars > 0
+    ? progress.chineseChars > 0 ? `${progress.totalChars}字` : `${progress.totalChars} chars`
+    : "";
+  const parts = [statusLabel, `${secs}s`, chars].filter(Boolean);
+  return parts.join(" · ");
+}
+
+function formatDuration(startedAt: number, completedAt?: number): string {
+  const ms = (completedAt ?? Date.now()) - startedAt;
+  const secs = Math.round(ms / 1000);
+  return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`;
+}
+
+// -- Live elapsed timer hook --
+
+function useElapsedTimer(startedAt: number, active: boolean): number {
+  const [elapsed, setElapsed] = useState(() => active ? Date.now() - startedAt : 0);
+  useEffect(() => {
+    if (!active) return;
+    setElapsed(Date.now() - startedAt);
+    const id = setInterval(() => setElapsed(Date.now() - startedAt), 1000);
+    return () => clearInterval(id);
+  }, [startedAt, active]);
+  return elapsed;
+}
+
+// -- Pipeline operation (sub_agent) --
+
+function PipelineExecution({ exec }: { exec: ToolExecution }) {
+  const isActive = exec.status === "running" || exec.status === "processing";
+  const [open, setOpen] = useState(isActive);
+  const elapsedMs = useElapsedTimer(exec.startedAt, isActive);
+
+  useEffect(() => {
+    if (exec.status === "running") setOpen(true);
+    if (exec.status === "completed") {
+      const timer = setTimeout(() => setOpen(false), 500);
+      return () => clearTimeout(timer);
+    }
+  }, [exec.status]);
+
+  const bookId = exec.args?.bookId as string | undefined;
+
+  return (
+    <Collapsible open={open} onOpenChange={setOpen} className="rounded-xl border border-border/40 bg-card/60">
+      <CollapsibleTrigger className="w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-xl hover:bg-card/80 transition-colors cursor-pointer">
+        <div className="flex items-center gap-2 min-w-0">
+          <span className="text-sm font-medium text-foreground truncate">
+            {exec.label}
+            {bookId && <span className="text-muted-foreground font-normal"> · {bookId}</span>}
+          </span>
+        </div>
+        <div className="flex items-center gap-2 shrink-0">
+          <span className="text-[10px] text-muted-foreground/60">
+            {isActive
+              ? formatDuration(exec.startedAt, exec.startedAt + elapsedMs)
+              : exec.completedAt ? formatDuration(exec.startedAt, exec.completedAt) : ""}
+          </span>
+          <ExecStatusBadge status={exec.status} />
+          <ChevronDown size={14} className={`text-muted-foreground transition-transform ${open ? "rotate-180" : ""}`} />
+        </div>
+      </CollapsibleTrigger>
+      <CollapsibleContent>
+        <div className="px-3 pb-3 pt-1">
+          {/* Real-time execution logs */}
+          {exec.logs && exec.logs.length > 0 && (
+            <ul className="space-y-0.5">
+              {exec.logs.map((log, i) => {
+                const isError = log.startsWith("[error]") || /error/i.test(log);
+                const isWarn = log.startsWith("[warning]") || /warning|警告/i.test(log);
+                return (
+                  <li key={i} className={`text-xs font-mono break-words ${isError ? "text-destructive" : isWarn ? "text-yellow-600 dark:text-yellow-400" : "text-muted-foreground"}`}>
+                    {log}
+                  </li>
+                );
+              })}
+            </ul>
+          )}
+          {exec.status === "error" && exec.error && (
+            <div className="mt-2 text-xs text-destructive bg-destructive/5 rounded-lg px-2.5 py-2">
+              {exec.error}
+            </div>
+          )}
+        </div>
+      </CollapsibleContent>
+    </Collapsible>
+  );
+}
+
+// -- Utility tools (read/edit/grep/ls) grouped --
+
+function UtilityToolsGroup({ execs }: { execs: ToolExecution[] }) {
+  const [open, setOpen] = useState(false);
+  const allDone = execs.every(e => e.status === "completed" || e.status === "error");
+  const hasError = execs.some(e => e.status === "error");
+
+  return (
+    <Collapsible open={open} onOpenChange={setOpen}>
+      <CollapsibleTrigger className="flex items-center gap-2 px-2 py-1 rounded-lg hover:bg-muted/50 transition-colors cursor-pointer text-xs text-muted-foreground">
+        <Wrench size={12} />
+        <span>{execs.length} 个文件操作</span>
+        {allDone && !hasError && <CheckCircle2 size={10} className="text-green-600 dark:text-green-400" />}
+        {hasError && <XCircle size={10} className="text-destructive" />}
+        {!allDone && <Loader2 size={10} className="animate-spin text-primary" />}
+        <ChevronDown size={10} className={`transition-transform ${open ? "rotate-180" : ""}`} />
+      </CollapsibleTrigger>
+      <CollapsibleContent>
+        <ul className="pl-6 space-y-0.5 py-1">
+          {execs.map((exec) => (
+            <li key={exec.id} className="flex items-center gap-2 text-xs text-muted-foreground">
+              <span className="font-mono truncate">{exec.tool} {String(exec.args?.path ?? exec.args?.pattern ?? "")}</span>
+              {exec.status === "completed" && <CheckCircle2 size={10} className="text-green-600 dark:text-green-400 shrink-0" />}
+              {exec.status === "error" && <XCircle size={10} className="text-destructive shrink-0" />}
+              {(exec.status === "running" || exec.status === "processing") && <Loader2 size={10} className="animate-spin text-primary shrink-0" />}
+            </li>
+          ))}
+        </ul>
+      </CollapsibleContent>
+    </Collapsible>
+  );
+}
+
+// -- Main component --
+
+export interface ToolExecutionStepsProps {
+  executions: ToolExecution[];
+}
+
+/**
+ * Group executions chronologically: pipeline ops render individually,
+ * consecutive utility tools are merged into a single collapsed group.
+ */
+type RenderGroup =
+  | { type: "pipeline"; exec: ToolExecution }
+  | { type: "utilities"; execs: ToolExecution[] };
+
+function groupChronologically(executions: ToolExecution[]): RenderGroup[] {
+  const groups: RenderGroup[] = [];
+  let utilBuf: ToolExecution[] = [];
+
+  const flushUtils = () => {
+    if (utilBuf.length > 0) {
+      groups.push({ type: "utilities", execs: utilBuf });
+      utilBuf = [];
+    }
+  };
+
+  for (const exec of executions) {
+    if (exec.tool === "sub_agent") {
+      flushUtils();
+      groups.push({ type: "pipeline", exec });
+    } else {
+      utilBuf.push(exec);
+    }
+  }
+  flushUtils();
+  return groups;
+}
+
+export function ToolExecutionSteps({ executions }: ToolExecutionStepsProps) {
+  const groups = useMemo(() => groupChronologically(executions), [executions]);
+
+  return (
+    <div className="space-y-2 mt-2">
+      {groups.map((g, i) =>
+        g.type === "pipeline"
+          ? <PipelineExecution key={g.exec.id} exec={g.exec} />
+          : <UtilityToolsGroup key={`utils-${i}`} execs={g.execs} />
+      )}
+    </div>
+  );
+}

+ 103 - 0
packages/studio/src/components/chat/__tests__/ToolExecutionSteps.test.ts

@@ -0,0 +1,103 @@
+import { describe, it, expect } from "vitest";
+import type { ToolExecution } from "../../../store/chat/types";
+
+// Import the grouping logic — we'll extract it as a named export
+// For now, inline the function to test the expected behavior
+function groupChronologically(executions: ToolExecution[]) {
+  type RenderGroup =
+    | { type: "pipeline"; exec: ToolExecution }
+    | { type: "utilities"; execs: ToolExecution[] };
+
+  const groups: RenderGroup[] = [];
+  let utilBuf: ToolExecution[] = [];
+
+  const flushUtils = () => {
+    if (utilBuf.length > 0) {
+      groups.push({ type: "utilities", execs: utilBuf });
+      utilBuf = [];
+    }
+  };
+
+  for (const exec of executions) {
+    if (exec.tool === "sub_agent") {
+      flushUtils();
+      groups.push({ type: "pipeline", exec });
+    } else {
+      utilBuf.push(exec);
+    }
+  }
+  flushUtils();
+  return groups;
+}
+
+const makeExec = (overrides: Partial<ToolExecution> & { id: string; tool: string }): ToolExecution => ({
+  label: "test",
+  status: "completed",
+  startedAt: Date.now(),
+  ...overrides,
+});
+
+describe("groupChronologically", () => {
+  it("keeps read before pipeline when read happened first", () => {
+    const execs: ToolExecution[] = [
+      makeExec({ id: "1", tool: "read", label: "读取文件" }),
+      makeExec({ id: "2", tool: "sub_agent", agent: "writer", label: "写作" }),
+    ];
+
+    const groups = groupChronologically(execs);
+
+    expect(groups).toHaveLength(2);
+    expect(groups[0].type).toBe("utilities");
+    expect(groups[1].type).toBe("pipeline");
+  });
+
+  it("groups consecutive utility tools together", () => {
+    const execs: ToolExecution[] = [
+      makeExec({ id: "1", tool: "read", label: "读取文件" }),
+      makeExec({ id: "2", tool: "grep", label: "搜索" }),
+      makeExec({ id: "3", tool: "read", label: "读取文件" }),
+    ];
+
+    const groups = groupChronologically(execs);
+
+    expect(groups).toHaveLength(1);
+    expect(groups[0].type).toBe("utilities");
+    if (groups[0].type === "utilities") {
+      expect(groups[0].execs).toHaveLength(3);
+    }
+  });
+
+  it("interleaves utility groups around pipeline ops", () => {
+    const execs: ToolExecution[] = [
+      makeExec({ id: "1", tool: "read", label: "读取文件" }),
+      makeExec({ id: "2", tool: "sub_agent", agent: "writer", label: "写作" }),
+      makeExec({ id: "3", tool: "read", label: "读取文件" }),
+      makeExec({ id: "4", tool: "grep", label: "搜索" }),
+    ];
+
+    const groups = groupChronologically(execs);
+
+    expect(groups).toHaveLength(3);
+    expect(groups[0].type).toBe("utilities");
+    expect(groups[1].type).toBe("pipeline");
+    expect(groups[2].type).toBe("utilities");
+    if (groups[2].type === "utilities") {
+      expect(groups[2].execs).toHaveLength(2);
+    }
+  });
+
+  it("handles pipeline-only executions", () => {
+    const execs: ToolExecution[] = [
+      makeExec({ id: "1", tool: "sub_agent", agent: "writer", label: "写作" }),
+    ];
+
+    const groups = groupChronologically(execs);
+
+    expect(groups).toHaveLength(1);
+    expect(groups[0].type).toBe("pipeline");
+  });
+
+  it("handles empty array", () => {
+    expect(groupChronologically([])).toHaveLength(0);
+  });
+});

+ 1 - 1
packages/studio/src/components/chatbar-state.test.ts

@@ -3,7 +3,7 @@ import {
   coerceSharedSessionMessages,
   formatSharedSessionContext,
   resolveDirectWriteTarget,
-} from "./ChatBar";
+} from "./chat-utils";
 
 describe("resolveDirectWriteTarget", () => {
   it("prefers the active book when the user is already inside a book flow", () => {

+ 66 - 0
packages/studio/src/components/sidebar/ChaptersSection.tsx

@@ -0,0 +1,66 @@
+import { useEffect, useState } from "react";
+import { fetchJson } from "../../hooks/use-api";
+import { useChatStore } from "../../store/chat";
+import { SidebarCard } from "./SidebarCard";
+import { cn } from "../../lib/utils";
+
+interface ChapterMeta {
+  number: number;
+  title: string;
+  status: string;
+  wordCount: number;
+}
+
+const STATUS_INDICATOR: Record<string, { symbol: string; color: string }> = {
+  approved: { symbol: "✓", color: "text-emerald-500" },
+  "ready-for-review": { symbol: "◆", color: "text-amber-500" },
+  drafted: { symbol: "○", color: "text-muted-foreground" },
+  "needs-revision": { symbol: "✕", color: "text-destructive" },
+  imported: { symbol: "◇", color: "text-blue-500" },
+};
+
+interface ChaptersSectionProps {
+  readonly bookId: string;
+  readonly isZh: boolean;
+}
+
+export function ChaptersSection({ bookId, isZh }: ChaptersSectionProps) {
+  const [chapters, setChapters] = useState<ReadonlyArray<ChapterMeta>>([]);
+  const bookDataVersion = useChatStore((s) => s.bookDataVersion);
+
+  useEffect(() => {
+    fetchJson<{ chapters: ChapterMeta[] }>(`/books/${bookId}`)
+      .then((data) => setChapters(data.chapters))
+      .catch(() => setChapters([]));
+  }, [bookId, bookDataVersion]);
+
+  return (
+    <SidebarCard title={isZh ? "章节" : "Chapters"}>
+      {chapters.length === 0 ? (
+        <p className="text-xs text-muted-foreground/50 italic">
+          {isZh ? "暂无章节" : "No chapters"}
+        </p>
+      ) : (
+        <ul className="space-y-1 max-h-52 overflow-y-auto overflow-x-hidden">
+          {chapters.map((ch) => {
+            const ind = STATUS_INDICATOR[ch.status] ?? { symbol: "○", color: "text-muted-foreground" };
+            return (
+              <li
+                key={`${ch.number}-${ch.title ?? ""}`}
+                onClick={() => useChatStore.getState().openChapterArtifact(ch.number)}
+                className="flex items-center gap-2 py-0.5 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors rounded px-1 -mx-1 hover:bg-secondary/50">
+                <span className={cn("text-[10px] shrink-0", ind.color)}>{ind.symbol}</span>
+                <span className="truncate flex-1">
+                  {String(ch.number).padStart(2, "0")} {ch.title || (isZh ? `第${ch.number}章` : `Chapter ${ch.number}`)}
+                </span>
+                <span className="tabular-nums text-[10px] text-muted-foreground/50 shrink-0">
+                  {(ch.wordCount ?? 0).toLocaleString()}
+                </span>
+              </li>
+            );
+          })}
+        </ul>
+      )}
+    </SidebarCard>
+  );
+}

+ 129 - 0
packages/studio/src/components/sidebar/CharacterSection.tsx

@@ -0,0 +1,129 @@
+import { useEffect, useState } from "react";
+import { Users, ChevronDown } from "lucide-react";
+import { useChatStore } from "../../store/chat";
+import { fetchJson } from "../../hooks/use-api";
+import { SidebarCard } from "./SidebarCard";
+import { cn } from "../../lib/utils";
+
+interface CharacterInfo {
+  name: string;
+  fields: Record<string, string>;
+}
+
+function parseCharacterMatrix(md: string): CharacterInfo[] {
+  const characters: CharacterInfo[] = [];
+  // Split by ## headings (level 2 only)
+  const sections = md.split(/^## /m).slice(1);
+  for (const section of sections) {
+    const lines = section.split("\n");
+    const name = lines[0].trim();
+    if (!name) continue;
+    const fields: Record<string, string> = {};
+    for (let i = 1; i < lines.length; i++) {
+      const match = lines[i].match(/^-\s+\*\*(.+?)\*\*:\s*(.+)/);
+      if (match) {
+        fields[match[1]] = match[2].trim();
+      }
+    }
+    characters.push({ name, fields });
+  }
+  return characters;
+}
+
+const ROLE_COLORS: Record<string, string> = {
+  "主角": "bg-amber-500/15 text-amber-600 dark:text-amber-400",
+  "反派": "bg-red-500/15 text-red-600 dark:text-red-400",
+  "盟友": "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
+  "配角": "bg-blue-500/15 text-blue-600 dark:text-blue-400",
+  "提及": "bg-zinc-500/15 text-zinc-600 dark:text-zinc-400",
+  "protagonist": "bg-amber-500/15 text-amber-600 dark:text-amber-400",
+  "antagonist": "bg-red-500/15 text-red-600 dark:text-red-400",
+  "ally": "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
+  "minor": "bg-blue-500/15 text-blue-600 dark:text-blue-400",
+  "mentioned": "bg-zinc-500/15 text-zinc-600 dark:text-zinc-400",
+};
+
+function getRoleColor(role: string): string {
+  const lower = role.toLowerCase().trim();
+  for (const [key, color] of Object.entries(ROLE_COLORS)) {
+    if (lower.includes(key)) return color;
+  }
+  return "bg-zinc-500/15 text-zinc-600 dark:text-zinc-400";
+}
+
+function CharacterCard({ char }: { readonly char: CharacterInfo }) {
+  const [expanded, setExpanded] = useState(false);
+  const role = char.fields["定位"] ?? char.fields["Role"] ?? "";
+  const tags = char.fields["标签"] ?? char.fields["Tags"] ?? "";
+  const current = char.fields["当前"] ?? char.fields["Current"] ?? "";
+
+  return (
+    <div className="rounded-lg bg-secondary/30 overflow-hidden">
+      <button
+        onClick={() => setExpanded(!expanded)}
+        className="w-full flex items-center gap-2 px-2.5 py-2 text-left"
+      >
+        <Users size={14} className="shrink-0 text-muted-foreground/60" />
+        <span className="text-sm font-medium text-foreground font-['SimSun','Songti_SC','STSong',serif] flex-1 truncate">
+          {char.name}
+        </span>
+        {role && (
+          <span className={cn("text-[10px] px-1.5 py-0.5 rounded-full shrink-0", getRoleColor(role))}>
+            {role.split("/")[0].trim()}
+          </span>
+        )}
+        <ChevronDown size={12} className={cn("text-muted-foreground/50 transition-transform shrink-0", expanded && "rotate-180")} />
+      </button>
+      {expanded && (
+        <div className="px-2.5 pb-2.5 space-y-1">
+          {tags && (
+            <p className="text-xs text-muted-foreground"><span className="text-muted-foreground/60">标签</span> {tags}</p>
+          )}
+          {current && (
+            <p className="text-xs text-muted-foreground"><span className="text-muted-foreground/60">当前</span> {current}</p>
+          )}
+          {Object.entries(char.fields)
+            .filter(([k]) => !["定位", "Role", "标签", "Tags", "当前", "Current"].includes(k))
+            .map(([key, val]) => (
+              <p key={key} className="text-xs text-muted-foreground">
+                <span className="text-muted-foreground/60">{key}</span> {val}
+              </p>
+            ))}
+        </div>
+      )}
+    </div>
+  );
+}
+
+interface CharacterSectionProps {
+  readonly bookId: string;
+}
+
+export function CharacterSection({ bookId }: CharacterSectionProps) {
+  const [characters, setCharacters] = useState<CharacterInfo[]>([]);
+  const bookDataVersion = useChatStore((s) => s.bookDataVersion);
+
+  useEffect(() => {
+    fetchJson<{ content: string | null }>(`/books/${bookId}/truth/character_matrix.md`)
+      .then((data) => {
+        if (data.content) {
+          setCharacters(parseCharacterMatrix(data.content));
+        } else {
+          setCharacters([]);
+        }
+      })
+      .catch(() => setCharacters([]));
+  }, [bookId, bookDataVersion]);
+
+  if (characters.length === 0) return null;
+
+  return (
+    <SidebarCard title="角色">
+      <div className="space-y-1.5">
+        {characters.map((char) => (
+          <CharacterCard key={char.name} char={char} />
+        ))}
+      </div>
+    </SidebarCard>
+  );
+}

+ 61 - 0
packages/studio/src/components/sidebar/FoundationSection.tsx

@@ -0,0 +1,61 @@
+import { useEffect, useState } from "react";
+import { FileText } from "lucide-react";
+import { useChatStore } from "../../store/chat";
+import { fetchJson } from "../../hooks/use-api";
+import { SidebarCard } from "./SidebarCard";
+
+const FOUNDATION_FILES: ReadonlyArray<{ file: string; label: string }> = [
+  { file: "story_bible.md", label: "世界观设定" },
+  { file: "volume_outline.md", label: "卷纲规划" },
+  { file: "book_rules.md", label: "叙事规则" },
+  { file: "current_state.md", label: "状态卡" },
+  { file: "pending_hooks.md", label: "伏笔池" },
+  { file: "subplot_board.md", label: "支线进度" },
+  { file: "emotional_arcs.md", label: "感情线" },
+  { file: "character_matrix.md", label: "角色矩阵" },
+];
+
+interface TruthFileInfo {
+  name: string;
+  size: number;
+}
+
+interface FoundationSectionProps {
+  readonly bookId: string;
+}
+
+export function FoundationSection({ bookId }: FoundationSectionProps) {
+  const [files, setFiles] = useState<ReadonlyArray<TruthFileInfo>>([]);
+  const openArtifact = useChatStore((s) => s.openArtifact);
+  const bookDataVersion = useChatStore((s) => s.bookDataVersion);
+
+  useEffect(() => {
+    fetchJson<{ files: TruthFileInfo[] }>(`/books/${bookId}/truth`)
+      .then((data) => setFiles(data.files))
+      .catch(() => setFiles([]));
+  }, [bookId, bookDataVersion]);
+
+  const available = FOUNDATION_FILES.filter((f) =>
+    files.some((tf) => tf.name === f.file),
+  );
+
+  if (available.length === 0) return null;
+
+  return (
+    <SidebarCard title="核心文件">
+      <ul className="space-y-1">
+        {available.map((item) => (
+          <li key={item.file}>
+            <button
+              onClick={() => openArtifact(item.file)}
+              className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors font-['SimSun','Songti_SC','STSong',serif]"
+            >
+              <FileText size={14} className="shrink-0 text-muted-foreground/60" />
+              <span className="truncate">{item.label}</span>
+            </button>
+          </li>
+        ))}
+      </ul>
+    </SidebarCard>
+  );
+}

+ 124 - 0
packages/studio/src/components/sidebar/ProgressSection.tsx

@@ -0,0 +1,124 @@
+import { useEffect, useState } from "react";
+import type { SSEMessage } from "../../hooks/use-sse";
+import { Loader2, Check } from "lucide-react";
+import { cn } from "../../lib/utils";
+import { SidebarCard } from "./SidebarCard";
+
+const INIT_BOOK_STEPS = [
+  "生成基础设定",
+  "保存书籍配置",
+  "写入基础设定文件",
+  "初始化控制文档",
+  "创建初始快照",
+] as const;
+
+const WRITE_CHAPTER_STEPS = [
+  "准备章节输入",
+  "撰写章节草稿",
+  "落盘最终章节",
+  "生成最终真相文件",
+  "校验真相文件变更",
+  "同步记忆索引",
+  "更新章节索引与快照",
+] as const;
+
+type StepStatus = "pending" | "active" | "done";
+
+interface ProgressSectionProps {
+  readonly sse: { messages: ReadonlyArray<SSEMessage>; connected: boolean };
+}
+
+export function ProgressSection({ sse }: ProgressSectionProps) {
+  const [operation, setOperation] = useState<"idle" | "init" | "write">("idle");
+  const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set());
+  const [activeStep, setActiveStep] = useState<string | null>(null);
+
+  useEffect(() => {
+    const latest = sse.messages;
+    if (latest.length === 0) return;
+    const last = latest[latest.length - 1];
+
+    if (last.event === "book:creating") {
+      setOperation("init");
+      setCompletedSteps(new Set());
+      setActiveStep(null);
+    } else if (last.event === "write:start") {
+      setOperation("write");
+      setCompletedSteps(new Set());
+      setActiveStep(null);
+    } else if (last.event === "book:created" || last.event === "write:complete") {
+      // Mark all steps done
+      const steps = operation === "init" ? INIT_BOOK_STEPS : WRITE_CHAPTER_STEPS;
+      setCompletedSteps(new Set(steps));
+      setActiveStep(null);
+    } else if (last.event === "log") {
+      const data = last.data as { message?: string } | null;
+      const message = data?.message;
+      if (message && operation !== "idle") {
+        // Mark previous active step as done, set new active
+        setCompletedSteps((prev) => {
+          if (activeStep) {
+            const next = new Set(prev);
+            next.add(activeStep);
+            return next;
+          }
+          return prev;
+        });
+        setActiveStep(message);
+      }
+    }
+  }, [sse.messages]);
+
+  const steps = operation === "init" ? INIT_BOOK_STEPS
+    : operation === "write" ? WRITE_CHAPTER_STEPS
+    : null;
+
+  if (!steps) return null;
+
+  return (
+    <SidebarCard title="执行">
+      <ul className="space-y-2">
+        {steps.map((step, i) => {
+          const status: StepStatus = completedSteps.has(step) ? "done"
+            : activeStep === step ? "active"
+            : "pending";
+          return (
+            <li key={step} className="flex items-center gap-2.5">
+              <StepIndicator index={i + 1} status={status} />
+              <span className={cn(
+                "text-xs",
+                status === "done" && "text-muted-foreground",
+                status === "active" && "text-foreground font-medium",
+                status === "pending" && "text-muted-foreground/50",
+              )}>
+                {step}
+              </span>
+            </li>
+          );
+        })}
+      </ul>
+    </SidebarCard>
+  );
+}
+
+function StepIndicator({ index, status }: { readonly index: number; readonly status: StepStatus }) {
+  if (status === "done") {
+    return (
+      <div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center shrink-0">
+        <Check size={12} className="text-primary-foreground" strokeWidth={2.5} />
+      </div>
+    );
+  }
+  if (status === "active") {
+    return (
+      <div className="w-5 h-5 rounded-full border-2 border-primary flex items-center justify-center shrink-0">
+        <Loader2 size={10} className="text-primary animate-spin" />
+      </div>
+    );
+  }
+  return (
+    <div className="w-5 h-5 rounded-full border border-border/60 flex items-center justify-center shrink-0">
+      <span className="text-[10px] text-muted-foreground/50">{index}</span>
+    </div>
+  );
+}

+ 30 - 0
packages/studio/src/components/sidebar/SidebarCard.tsx

@@ -0,0 +1,30 @@
+import { useState } from "react";
+import { ChevronDown } from "lucide-react";
+import { cn } from "../../lib/utils";
+
+interface SidebarCardProps {
+  readonly title: string;
+  readonly defaultOpen?: boolean;
+  readonly children: React.ReactNode;
+  readonly actions?: React.ReactNode;
+}
+
+export function SidebarCard({ title, defaultOpen = true, children, actions }: SidebarCardProps) {
+  const [open, setOpen] = useState(defaultOpen);
+
+  return (
+    <div className="rounded-xl bg-card/60">
+      <button
+        onClick={() => setOpen(!open)}
+        className="w-full flex items-center justify-between px-3 py-2.5"
+      >
+        <span className="text-base font-medium text-foreground font-['SimSun','Songti_SC','STSong',serif]">{title}</span>
+        <div className="flex items-center gap-1.5">
+          {actions}
+          <ChevronDown className={cn("w-4 h-4 text-muted-foreground transition-transform", open && "rotate-180")} />
+        </div>
+      </button>
+      {open && <div className="px-3 pb-3">{children}</div>}
+    </div>
+  );
+}

+ 71 - 0
packages/studio/src/components/sidebar/SummarySection.tsx

@@ -0,0 +1,71 @@
+import { useEffect } from "react";
+import { useChatStore } from "../../store/chat";
+import type { BookSummary } from "../../store/chat";
+import { fetchJson } from "../../hooks/use-api";
+import { SidebarCard } from "./SidebarCard";
+
+function parseStoryBible(content: string): BookSummary {
+  const sections = content.split(/^##\s+/m);
+  let world = "";
+  let protagonist = "";
+  let cast = "";
+
+  for (const section of sections) {
+    if (/^0?1[_\s]|世界观|world/i.test(section)) {
+      world = section.replace(/^[^\n]+\n/, "").trim().split("\n\n")[0] ?? "";
+    } else if (/^0?2[_\s]|主角|protagonist/i.test(section)) {
+      protagonist = section.replace(/^[^\n]+\n/, "").trim().split("\n\n")[0] ?? "";
+    } else if (/^0?3[_\s]|配角|supporting|cast/i.test(section)) {
+      cast = section.replace(/^[^\n]+\n/, "").trim().split("\n\n")[0] ?? "";
+    }
+  }
+
+  return { world, protagonist, cast };
+}
+
+interface SummarySectionProps {
+  readonly bookId: string;
+}
+
+export function SummarySection({ bookId }: SummarySectionProps) {
+  const summary = useChatStore((s) => s.bookSummary);
+  const setBookSummary = useChatStore((s) => s.setBookSummary);
+  const bookDataVersion = useChatStore((s) => s.bookDataVersion);
+
+  useEffect(() => {
+    setBookSummary(null);
+    fetchJson<{ content: string | null }>(`/books/${bookId}/truth/story_bible.md`)
+      .then((data) => {
+        if (data.content) setBookSummary(parseStoryBible(data.content));
+      })
+      .catch(() => {});
+  }, [bookId, bookDataVersion, setBookSummary]);
+
+  if (!summary) return null;
+
+  return (
+    <>
+      {summary.world && (
+        <SidebarCard title="世界观">
+          <p className="text-xs text-muted-foreground leading-relaxed line-clamp-4">
+            {summary.world}
+          </p>
+        </SidebarCard>
+      )}
+      {(summary.protagonist || summary.cast) && (
+        <SidebarCard title="角色">
+          {summary.protagonist && (
+            <p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
+              {summary.protagonist}
+            </p>
+          )}
+          {summary.cast && (
+            <p className="text-xs text-muted-foreground leading-relaxed line-clamp-3 mt-2">
+              {summary.cast}
+            </p>
+          )}
+        </SidebarCard>
+      )}
+    </>
+  );
+}

+ 76 - 0
packages/studio/src/components/ui/alert.tsx

@@ -0,0 +1,76 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+  "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
+  {
+    variants: {
+      variant: {
+        default: "bg-card text-card-foreground",
+        destructive:
+          "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  }
+)
+
+function Alert({
+  className,
+  variant,
+  ...props
+}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
+  return (
+    <div
+      data-slot="alert"
+      role="alert"
+      className={cn(alertVariants({ variant }), className)}
+      {...props}
+    />
+  )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="alert-title"
+      className={cn(
+        "font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function AlertDescription({
+  className,
+  ...props
+}: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="alert-description"
+      className={cn(
+        "text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="alert-action"
+      className={cn("absolute top-2 right-2", className)}
+      {...props}
+    />
+  )
+}
+
+export { Alert, AlertTitle, AlertDescription, AlertAction }

+ 52 - 0
packages/studio/src/components/ui/badge.tsx

@@ -0,0 +1,52 @@
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+  "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
+  {
+    variants: {
+      variant: {
+        default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+        secondary:
+          "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+        destructive:
+          "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
+        outline:
+          "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+        ghost:
+          "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+        link: "text-primary underline-offset-4 hover:underline",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  }
+)
+
+function Badge({
+  className,
+  variant = "default",
+  render,
+  ...props
+}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
+  return useRender({
+    defaultTagName: "span",
+    props: mergeProps<"span">(
+      {
+        className: cn(badgeVariants({ variant }), className),
+      },
+      props
+    ),
+    render,
+    state: {
+      slot: "badge",
+      variant,
+    },
+  })
+}
+
+export { Badge, badgeVariants }

+ 87 - 0
packages/studio/src/components/ui/button-group.tsx

@@ -0,0 +1,87 @@
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { Separator } from "@/components/ui/separator"
+
+const buttonGroupVariants = cva(
+  "flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
+  {
+    variants: {
+      orientation: {
+        horizontal:
+          "*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
+        vertical:
+          "flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
+      },
+    },
+    defaultVariants: {
+      orientation: "horizontal",
+    },
+  }
+)
+
+function ButtonGroup({
+  className,
+  orientation,
+  ...props
+}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
+  return (
+    <div
+      role="group"
+      data-slot="button-group"
+      data-orientation={orientation}
+      className={cn(buttonGroupVariants({ orientation }), className)}
+      {...props}
+    />
+  )
+}
+
+function ButtonGroupText({
+  className,
+  render,
+  ...props
+}: useRender.ComponentProps<"div">) {
+  return useRender({
+    defaultTagName: "div",
+    props: mergeProps<"div">(
+      {
+        className: cn(
+          "flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
+          className
+        ),
+      },
+      props
+    ),
+    render,
+    state: {
+      slot: "button-group-text",
+    },
+  })
+}
+
+function ButtonGroupSeparator({
+  className,
+  orientation = "vertical",
+  ...props
+}: React.ComponentProps<typeof Separator>) {
+  return (
+    <Separator
+      data-slot="button-group-separator"
+      orientation={orientation}
+      className={cn(
+        "relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export {
+  ButtonGroup,
+  ButtonGroupSeparator,
+  ButtonGroupText,
+  buttonGroupVariants,
+}

+ 58 - 0
packages/studio/src/components/ui/button.tsx

@@ -0,0 +1,58 @@
+import { Button as ButtonPrimitive } from "@base-ui/react/button"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+  "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+  {
+    variants: {
+      variant: {
+        default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+        outline:
+          "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+        secondary:
+          "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+        ghost:
+          "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+        destructive:
+          "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
+        link: "text-primary underline-offset-4 hover:underline",
+      },
+      size: {
+        default:
+          "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+        xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+        sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+        lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+        icon: "size-8",
+        "icon-xs":
+          "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+        "icon-sm":
+          "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
+        "icon-lg": "size-9",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+      size: "default",
+    },
+  }
+)
+
+function Button({
+  className,
+  variant = "default",
+  size = "default",
+  ...props
+}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
+  return (
+    <ButtonPrimitive
+      data-slot="button"
+      className={cn(buttonVariants({ variant, size, className }))}
+      {...props}
+    />
+  )
+}
+
+export { Button, buttonVariants }

+ 19 - 0
packages/studio/src/components/ui/collapsible.tsx

@@ -0,0 +1,19 @@
+import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
+
+function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
+  return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
+}
+
+function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
+  return (
+    <CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
+  )
+}
+
+function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
+  return (
+    <CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
+  )
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }

+ 194 - 0
packages/studio/src/components/ui/command.tsx

@@ -0,0 +1,194 @@
+import * as React from "react"
+import { Command as CommandPrimitive } from "cmdk"
+
+import { cn } from "@/lib/utils"
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog"
+import {
+  InputGroup,
+  InputGroupAddon,
+} from "@/components/ui/input-group"
+import { SearchIcon, CheckIcon } from "lucide-react"
+
+function Command({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive>) {
+  return (
+    <CommandPrimitive
+      data-slot="command"
+      className={cn(
+        "flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CommandDialog({
+  title = "Command Palette",
+  description = "Search for a command to run...",
+  children,
+  className,
+  showCloseButton = false,
+  ...props
+}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
+  title?: string
+  description?: string
+  className?: string
+  showCloseButton?: boolean
+  children: React.ReactNode
+}) {
+  return (
+    <Dialog {...props}>
+      <DialogHeader className="sr-only">
+        <DialogTitle>{title}</DialogTitle>
+        <DialogDescription>{description}</DialogDescription>
+      </DialogHeader>
+      <DialogContent
+        className={cn(
+          "top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
+          className
+        )}
+        showCloseButton={showCloseButton}
+      >
+        {children}
+      </DialogContent>
+    </Dialog>
+  )
+}
+
+function CommandInput({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Input>) {
+  return (
+    <div data-slot="command-input-wrapper" className="p-1 pb-0">
+      <InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
+        <CommandPrimitive.Input
+          data-slot="command-input"
+          className={cn(
+            "w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
+            className
+          )}
+          {...props}
+        />
+        <InputGroupAddon>
+          <SearchIcon className="size-4 shrink-0 opacity-50" />
+        </InputGroupAddon>
+      </InputGroup>
+    </div>
+  )
+}
+
+function CommandList({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.List>) {
+  return (
+    <CommandPrimitive.List
+      data-slot="command-list"
+      className={cn(
+        "no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CommandEmpty({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
+  return (
+    <CommandPrimitive.Empty
+      data-slot="command-empty"
+      className={cn("py-6 text-center text-sm", className)}
+      {...props}
+    />
+  )
+}
+
+function CommandGroup({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Group>) {
+  return (
+    <CommandPrimitive.Group
+      data-slot="command-group"
+      className={cn(
+        "overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CommandSeparator({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
+  return (
+    <CommandPrimitive.Separator
+      data-slot="command-separator"
+      className={cn("-mx-1 h-px bg-border", className)}
+      {...props}
+    />
+  )
+}
+
+function CommandItem({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Item>) {
+  return (
+    <CommandPrimitive.Item
+      data-slot="command-item"
+      className={cn(
+        "group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
+        className
+      )}
+      {...props}
+    >
+      {children}
+      <CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
+    </CommandPrimitive.Item>
+  )
+}
+
+function CommandShortcut({
+  className,
+  ...props
+}: React.ComponentProps<"span">) {
+  return (
+    <span
+      data-slot="command-shortcut"
+      className={cn(
+        "ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export {
+  Command,
+  CommandDialog,
+  CommandInput,
+  CommandList,
+  CommandEmpty,
+  CommandGroup,
+  CommandItem,
+  CommandShortcut,
+  CommandSeparator,
+}

+ 158 - 0
packages/studio/src/components/ui/dialog.tsx

@@ -0,0 +1,158 @@
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+  return <DialogPrimitive.Root data-slot="dialog" {...props} />
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+  return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+  return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+  return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
+}
+
+function DialogOverlay({
+  className,
+  ...props
+}: DialogPrimitive.Backdrop.Props) {
+  return (
+    <DialogPrimitive.Backdrop
+      data-slot="dialog-overlay"
+      className={cn(
+        "fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function DialogContent({
+  className,
+  children,
+  showCloseButton = true,
+  ...props
+}: DialogPrimitive.Popup.Props & {
+  showCloseButton?: boolean
+}) {
+  return (
+    <DialogPortal>
+      <DialogOverlay />
+      <DialogPrimitive.Popup
+        data-slot="dialog-content"
+        className={cn(
+          "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
+          className
+        )}
+        {...props}
+      >
+        {children}
+        {showCloseButton && (
+          <DialogPrimitive.Close
+            data-slot="dialog-close"
+            render={
+              <Button
+                variant="ghost"
+                className="absolute top-2 right-2"
+                size="icon-sm"
+              />
+            }
+          >
+            <XIcon
+            />
+            <span className="sr-only">Close</span>
+          </DialogPrimitive.Close>
+        )}
+      </DialogPrimitive.Popup>
+    </DialogPortal>
+  )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="dialog-header"
+      className={cn("flex flex-col gap-2", className)}
+      {...props}
+    />
+  )
+}
+
+function DialogFooter({
+  className,
+  showCloseButton = false,
+  children,
+  ...props
+}: React.ComponentProps<"div"> & {
+  showCloseButton?: boolean
+}) {
+  return (
+    <div
+      data-slot="dialog-footer"
+      className={cn(
+        "-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
+        className
+      )}
+      {...props}
+    >
+      {children}
+      {showCloseButton && (
+        <DialogPrimitive.Close render={<Button variant="outline" />}>
+          Close
+        </DialogPrimitive.Close>
+      )}
+    </div>
+  )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+  return (
+    <DialogPrimitive.Title
+      data-slot="dialog-title"
+      className={cn(
+        "text-base leading-none font-medium",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function DialogDescription({
+  className,
+  ...props
+}: DialogPrimitive.Description.Props) {
+  return (
+    <DialogPrimitive.Description
+      data-slot="dialog-description"
+      className={cn(
+        "text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export {
+  Dialog,
+  DialogClose,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogOverlay,
+  DialogPortal,
+  DialogTitle,
+  DialogTrigger,
+}

+ 266 - 0
packages/studio/src/components/ui/dropdown-menu.tsx

@@ -0,0 +1,266 @@
+import * as React from "react"
+import { Menu as MenuPrimitive } from "@base-ui/react/menu"
+
+import { cn } from "@/lib/utils"
+import { ChevronRightIcon, CheckIcon } from "lucide-react"
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+  return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+  return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+  return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
+}
+
+function DropdownMenuContent({
+  align = "start",
+  alignOffset = 0,
+  side = "bottom",
+  sideOffset = 4,
+  className,
+  ...props
+}: MenuPrimitive.Popup.Props &
+  Pick<
+    MenuPrimitive.Positioner.Props,
+    "align" | "alignOffset" | "side" | "sideOffset"
+  >) {
+  return (
+    <MenuPrimitive.Portal>
+      <MenuPrimitive.Positioner
+        className="isolate z-50 outline-none"
+        align={align}
+        alignOffset={alignOffset}
+        side={side}
+        sideOffset={sideOffset}
+      >
+        <MenuPrimitive.Popup
+          data-slot="dropdown-menu-content"
+          className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
+          {...props}
+        />
+      </MenuPrimitive.Positioner>
+    </MenuPrimitive.Portal>
+  )
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+  return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
+}
+
+function DropdownMenuLabel({
+  className,
+  inset,
+  ...props
+}: MenuPrimitive.GroupLabel.Props & {
+  inset?: boolean
+}) {
+  return (
+    <MenuPrimitive.GroupLabel
+      data-slot="dropdown-menu-label"
+      data-inset={inset}
+      className={cn(
+        "px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function DropdownMenuItem({
+  className,
+  inset,
+  variant = "default",
+  ...props
+}: MenuPrimitive.Item.Props & {
+  inset?: boolean
+  variant?: "default" | "destructive"
+}) {
+  return (
+    <MenuPrimitive.Item
+      data-slot="dropdown-menu-item"
+      data-inset={inset}
+      data-variant={variant}
+      className={cn(
+        "group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+  return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
+}
+
+function DropdownMenuSubTrigger({
+  className,
+  inset,
+  children,
+  ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+  inset?: boolean
+}) {
+  return (
+    <MenuPrimitive.SubmenuTrigger
+      data-slot="dropdown-menu-sub-trigger"
+      data-inset={inset}
+      className={cn(
+        "flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      {...props}
+    >
+      {children}
+      <ChevronRightIcon className="ml-auto" />
+    </MenuPrimitive.SubmenuTrigger>
+  )
+}
+
+function DropdownMenuSubContent({
+  align = "start",
+  alignOffset = -3,
+  side = "right",
+  sideOffset = 0,
+  className,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuContent>) {
+  return (
+    <DropdownMenuContent
+      data-slot="dropdown-menu-sub-content"
+      className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
+      align={align}
+      alignOffset={alignOffset}
+      side={side}
+      sideOffset={sideOffset}
+      {...props}
+    />
+  )
+}
+
+function DropdownMenuCheckboxItem({
+  className,
+  children,
+  checked,
+  inset,
+  ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+  inset?: boolean
+}) {
+  return (
+    <MenuPrimitive.CheckboxItem
+      data-slot="dropdown-menu-checkbox-item"
+      data-inset={inset}
+      className={cn(
+        "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      checked={checked}
+      {...props}
+    >
+      <span
+        className="pointer-events-none absolute right-2 flex items-center justify-center"
+        data-slot="dropdown-menu-checkbox-item-indicator"
+      >
+        <MenuPrimitive.CheckboxItemIndicator>
+          <CheckIcon
+          />
+        </MenuPrimitive.CheckboxItemIndicator>
+      </span>
+      {children}
+    </MenuPrimitive.CheckboxItem>
+  )
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+  return (
+    <MenuPrimitive.RadioGroup
+      data-slot="dropdown-menu-radio-group"
+      {...props}
+    />
+  )
+}
+
+function DropdownMenuRadioItem({
+  className,
+  children,
+  inset,
+  ...props
+}: MenuPrimitive.RadioItem.Props & {
+  inset?: boolean
+}) {
+  return (
+    <MenuPrimitive.RadioItem
+      data-slot="dropdown-menu-radio-item"
+      data-inset={inset}
+      className={cn(
+        "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      {...props}
+    >
+      <span
+        className="pointer-events-none absolute right-2 flex items-center justify-center"
+        data-slot="dropdown-menu-radio-item-indicator"
+      >
+        <MenuPrimitive.RadioItemIndicator>
+          <CheckIcon
+          />
+        </MenuPrimitive.RadioItemIndicator>
+      </span>
+      {children}
+    </MenuPrimitive.RadioItem>
+  )
+}
+
+function DropdownMenuSeparator({
+  className,
+  ...props
+}: MenuPrimitive.Separator.Props) {
+  return (
+    <MenuPrimitive.Separator
+      data-slot="dropdown-menu-separator"
+      className={cn("-mx-1 my-1 h-px bg-border", className)}
+      {...props}
+    />
+  )
+}
+
+function DropdownMenuShortcut({
+  className,
+  ...props
+}: React.ComponentProps<"span">) {
+  return (
+    <span
+      data-slot="dropdown-menu-shortcut"
+      className={cn(
+        "ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export {
+  DropdownMenu,
+  DropdownMenuPortal,
+  DropdownMenuTrigger,
+  DropdownMenuContent,
+  DropdownMenuGroup,
+  DropdownMenuLabel,
+  DropdownMenuItem,
+  DropdownMenuCheckboxItem,
+  DropdownMenuRadioGroup,
+  DropdownMenuRadioItem,
+  DropdownMenuSeparator,
+  DropdownMenuShortcut,
+  DropdownMenuSub,
+  DropdownMenuSubTrigger,
+  DropdownMenuSubContent,
+}

+ 51 - 0
packages/studio/src/components/ui/hover-card.tsx

@@ -0,0 +1,51 @@
+"use client"
+
+import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"
+
+import { cn } from "@/lib/utils"
+
+function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
+  return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />
+}
+
+function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
+  return (
+    <PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
+  )
+}
+
+function HoverCardContent({
+  className,
+  side = "bottom",
+  sideOffset = 4,
+  align = "center",
+  alignOffset = 4,
+  ...props
+}: PreviewCardPrimitive.Popup.Props &
+  Pick<
+    PreviewCardPrimitive.Positioner.Props,
+    "align" | "alignOffset" | "side" | "sideOffset"
+  >) {
+  return (
+    <PreviewCardPrimitive.Portal data-slot="hover-card-portal">
+      <PreviewCardPrimitive.Positioner
+        align={align}
+        alignOffset={alignOffset}
+        side={side}
+        sideOffset={sideOffset}
+        className="isolate z-50"
+      >
+        <PreviewCardPrimitive.Popup
+          data-slot="hover-card-content"
+          className={cn(
+            "z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
+            className
+          )}
+          {...props}
+        />
+      </PreviewCardPrimitive.Positioner>
+    </PreviewCardPrimitive.Portal>
+  )
+}
+
+export { HoverCard, HoverCardTrigger, HoverCardContent }

+ 158 - 0
packages/studio/src/components/ui/input-group.tsx

@@ -0,0 +1,158 @@
+"use client"
+
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+
+function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="input-group"
+      role="group"
+      className={cn(
+        "group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-border/40 transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-primary/40 has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot=input-group-control]:focus-visible]:ring-primary/10 has-[[data-slot][aria-invalid=true]]:border-border/40 has-[[data-slot][aria-invalid=true]]:ring-0 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-0 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+const inputGroupAddonVariants = cva(
+  "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
+  {
+    variants: {
+      align: {
+        "inline-start":
+          "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
+        "inline-end":
+          "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
+        "block-start":
+          "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
+        "block-end":
+          "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
+      },
+    },
+    defaultVariants: {
+      align: "inline-start",
+    },
+  }
+)
+
+function InputGroupAddon({
+  className,
+  align = "inline-start",
+  ...props
+}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
+  return (
+    <div
+      role="group"
+      data-slot="input-group-addon"
+      data-align={align}
+      className={cn(inputGroupAddonVariants({ align }), className)}
+      onClick={(e) => {
+        if ((e.target as HTMLElement).closest("button")) {
+          return
+        }
+        e.currentTarget.parentElement?.querySelector("input")?.focus()
+      }}
+      {...props}
+    />
+  )
+}
+
+const inputGroupButtonVariants = cva(
+  "flex items-center gap-2 text-sm shadow-none",
+  {
+    variants: {
+      size: {
+        xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
+        sm: "",
+        "icon-xs":
+          "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
+        "icon-sm": "size-8 p-0 has-[>svg]:p-0",
+      },
+    },
+    defaultVariants: {
+      size: "xs",
+    },
+  }
+)
+
+function InputGroupButton({
+  className,
+  type = "button",
+  variant = "ghost",
+  size = "xs",
+  ...props
+}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
+  VariantProps<typeof inputGroupButtonVariants> & {
+    type?: "button" | "submit" | "reset"
+  }) {
+  return (
+    <Button
+      type={type}
+      data-size={size}
+      variant={variant}
+      className={cn(inputGroupButtonVariants({ size }), className)}
+      {...props}
+    />
+  )
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
+  return (
+    <span
+      className={cn(
+        "flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function InputGroupInput({
+  className,
+  ...props
+}: React.ComponentProps<"input">) {
+  return (
+    <Input
+      data-slot="input-group-control"
+      className={cn(
+        "flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function InputGroupTextarea({
+  className,
+  ...props
+}: React.ComponentProps<"textarea">) {
+  return (
+    <Textarea
+      data-slot="input-group-control"
+      className={cn(
+        "flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export {
+  InputGroup,
+  InputGroupAddon,
+  InputGroupButton,
+  InputGroupText,
+  InputGroupInput,
+  InputGroupTextarea,
+}

+ 20 - 0
packages/studio/src/components/ui/input.tsx

@@ -0,0 +1,20 @@
+import * as React from "react"
+import { Input as InputPrimitive } from "@base-ui/react/input"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+  return (
+    <InputPrimitive
+      type={type}
+      data-slot="input"
+      className={cn(
+        "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export { Input }

+ 199 - 0
packages/studio/src/components/ui/select.tsx

@@ -0,0 +1,199 @@
+import * as React from "react"
+import { Select as SelectPrimitive } from "@base-ui/react/select"
+
+import { cn } from "@/lib/utils"
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+
+const Select = SelectPrimitive.Root
+
+function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
+  return (
+    <SelectPrimitive.Group
+      data-slot="select-group"
+      className={cn("scroll-my-1 p-1", className)}
+      {...props}
+    />
+  )
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+  return (
+    <SelectPrimitive.Value
+      data-slot="select-value"
+      className={cn("flex flex-1 text-left", className)}
+      {...props}
+    />
+  )
+}
+
+function SelectTrigger({
+  className,
+  size = "default",
+  children,
+  ...props
+}: SelectPrimitive.Trigger.Props & {
+  size?: "sm" | "default"
+}) {
+  return (
+    <SelectPrimitive.Trigger
+      data-slot="select-trigger"
+      data-size={size}
+      className={cn(
+        "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      {...props}
+    >
+      {children}
+      <SelectPrimitive.Icon
+        render={
+          <ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
+        }
+      />
+    </SelectPrimitive.Trigger>
+  )
+}
+
+function SelectContent({
+  className,
+  children,
+  side = "bottom",
+  sideOffset = 4,
+  align = "center",
+  alignOffset = 0,
+  alignItemWithTrigger = true,
+  ...props
+}: SelectPrimitive.Popup.Props &
+  Pick<
+    SelectPrimitive.Positioner.Props,
+    "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
+  >) {
+  return (
+    <SelectPrimitive.Portal>
+      <SelectPrimitive.Positioner
+        side={side}
+        sideOffset={sideOffset}
+        align={align}
+        alignOffset={alignOffset}
+        alignItemWithTrigger={alignItemWithTrigger}
+        className="isolate z-50"
+      >
+        <SelectPrimitive.Popup
+          data-slot="select-content"
+          data-align-trigger={alignItemWithTrigger}
+          className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
+          {...props}
+        >
+          <SelectScrollUpButton />
+          <SelectPrimitive.List>{children}</SelectPrimitive.List>
+          <SelectScrollDownButton />
+        </SelectPrimitive.Popup>
+      </SelectPrimitive.Positioner>
+    </SelectPrimitive.Portal>
+  )
+}
+
+function SelectLabel({
+  className,
+  ...props
+}: SelectPrimitive.GroupLabel.Props) {
+  return (
+    <SelectPrimitive.GroupLabel
+      data-slot="select-label"
+      className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
+      {...props}
+    />
+  )
+}
+
+function SelectItem({
+  className,
+  children,
+  ...props
+}: SelectPrimitive.Item.Props) {
+  return (
+    <SelectPrimitive.Item
+      data-slot="select-item"
+      className={cn(
+        "relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
+        className
+      )}
+      {...props}
+    >
+      <SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
+        {children}
+      </SelectPrimitive.ItemText>
+      <SelectPrimitive.ItemIndicator
+        render={
+          <span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
+        }
+      >
+        <CheckIcon className="pointer-events-none" />
+      </SelectPrimitive.ItemIndicator>
+    </SelectPrimitive.Item>
+  )
+}
+
+function SelectSeparator({
+  className,
+  ...props
+}: SelectPrimitive.Separator.Props) {
+  return (
+    <SelectPrimitive.Separator
+      data-slot="select-separator"
+      className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
+      {...props}
+    />
+  )
+}
+
+function SelectScrollUpButton({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
+  return (
+    <SelectPrimitive.ScrollUpArrow
+      data-slot="select-scroll-up-button"
+      className={cn(
+        "top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      {...props}
+    >
+      <ChevronUpIcon
+      />
+    </SelectPrimitive.ScrollUpArrow>
+  )
+}
+
+function SelectScrollDownButton({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
+  return (
+    <SelectPrimitive.ScrollDownArrow
+      data-slot="select-scroll-down-button"
+      className={cn(
+        "bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      {...props}
+    >
+      <ChevronDownIcon
+      />
+    </SelectPrimitive.ScrollDownArrow>
+  )
+}
+
+export {
+  Select,
+  SelectContent,
+  SelectGroup,
+  SelectItem,
+  SelectLabel,
+  SelectScrollDownButton,
+  SelectScrollUpButton,
+  SelectSeparator,
+  SelectTrigger,
+  SelectValue,
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов