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

feat(studio): Phase 1 — sidebar layout, chat bar, language selector, agent API

Layout redesign:
- Sidebar (200px): book list + system nav (config/daemon/logs)
- Main area: scrollable page content (max-w-4xl)
- Bottom Chat Bar: always-visible input, expands on interaction
- Thin header strip: theme toggle + connection status

Chat Bar:
- Connects to POST /api/agent (wraps inkos agent loop)
- Simple command routing (write-next shortcut)
- SSE-driven progress display (Phase 1/2, streaming stats)
- Expand/collapse with message history

New components:
- Sidebar.tsx: book list + system navigation
- ChatBar.tsx: collapsible chat with agent integration
- LanguageSelector.tsx: first-time language choice (zh/en)

New API endpoints:
- POST /api/agent: natural language agent instruction
- POST /api/project/language: set project language
Ma 7 месяцев назад
Родитель
Сommit
fd4889979c

+ 1 - 0
.superpowers/brainstorm/33312-1773817943/.server-info

@@ -0,0 +1 @@
+{"type":"server-started","port":60485,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:60485","screen_dir":"/Users/majunxian/Desktop/PyProject/inkos-studio/.superpowers/brainstorm/33312-1773817943"}

+ 3 - 0
.superpowers/brainstorm/33312-1773817943/.server.log

@@ -0,0 +1,3 @@
+{"type":"server-started","port":60485,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:60485","screen_dir":"/Users/majunxian/Desktop/PyProject/inkos-studio/.superpowers/brainstorm/33312-1773817943"}
+{"type":"screen-added","file":"/Users/majunxian/Desktop/PyProject/inkos-studio/.superpowers/brainstorm/33312-1773817943/layout-concept.html"}
+{"type":"screen-added","file":"/Users/majunxian/Desktop/PyProject/inkos-studio/.superpowers/brainstorm/33312-1773817943/layout-refined.html"}

+ 1 - 0
.superpowers/brainstorm/33312-1773817943/.server.pid

@@ -0,0 +1 @@
+33321

+ 120 - 0
.superpowers/brainstorm/33312-1773817943/layout-concept.html

@@ -0,0 +1,120 @@
+<h2>Studio 整体布局方案</h2>
+<p class="subtitle">工作台 + 嵌入式 Chat Panel,两套交互并存</p>
+
+<div class="cards">
+  <div class="card" data-choice="a" onclick="toggleSelect(this)">
+    <div class="card-image" style="padding: 16px;">
+      <div style="display: flex; height: 320px; gap: 1px; background: rgba(255,255,255,0.05); border-radius: 8px; overflow: hidden; font-size: 12px;">
+        <!-- Sidebar -->
+        <div style="width: 200px; background: rgba(255,255,255,0.03); padding: 12px; display: flex; flex-direction: column; gap: 8px;">
+          <div style="font-weight: 600; margin-bottom: 8px; color: #c4956a;">InkOS</div>
+          <div style="padding: 6px 8px; background: rgba(255,255,255,0.06); border-radius: 4px;">📚 书籍列表</div>
+          <div style="padding: 6px 8px; opacity: 0.5;">⚙ 配置</div>
+          <div style="padding: 6px 8px; opacity: 0.5;">🔄 Daemon</div>
+          <div style="padding: 6px 8px; opacity: 0.5;">📋 日志</div>
+          <div style="flex: 1;"></div>
+          <div style="padding: 6px 8px; font-size: 10px; opacity: 0.3; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 12px;">ZH · 玄幻</div>
+        </div>
+        <!-- Main content -->
+        <div style="flex: 1; display: flex; flex-direction: column;">
+          <div style="padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,0.06); font-weight: 500;">吞天魔帝 · 31 章</div>
+          <div style="flex: 1; padding: 16px; overflow: hidden;">
+            <div style="display: grid; gap: 6px;">
+              <div style="padding: 8px; background: rgba(255,255,255,0.03); border-radius: 4px; display: flex; justify-content: space-between;"><span>第31章 天劫降临</span><span style="color: #4ade80; font-size: 10px;">approved</span></div>
+              <div style="padding: 8px; background: rgba(255,255,255,0.03); border-radius: 4px; display: flex; justify-content: space-between;"><span>第30章 暗涌</span><span style="color: #fbbf24; font-size: 10px;">review</span></div>
+              <div style="padding: 8px; background: rgba(255,255,255,0.03); border-radius: 4px; display: flex; justify-content: space-between;"><span>第29章 破阵</span><span style="color: #4ade80; font-size: 10px;">approved</span></div>
+            </div>
+          </div>
+        </div>
+        <!-- Chat panel -->
+        <div style="width: 280px; background: rgba(255,255,255,0.02); border-left: 1px solid rgba(255,255,255,0.06); display: flex; flex-direction: column;">
+          <div style="padding: 10px 12px; border-bottom: 1px solid rgba(255,255,255,0.06); font-size: 11px; font-weight: 500;">Chat</div>
+          <div style="flex: 1; padding: 12px; font-size: 11px; display: flex; flex-direction: column; gap: 8px;">
+            <div style="background: rgba(196,149,106,0.1); padding: 8px; border-radius: 6px; max-width: 85%;">写下一章,重点写师徒矛盾</div>
+            <div style="background: rgba(255,255,255,0.04); padding: 8px; border-radius: 6px; max-width: 85%; align-self: flex-end; font-size: 10px;">正在写第32章…<br>Phase 1: creative writing</div>
+          </div>
+          <div style="padding: 8px; border-top: 1px solid rgba(255,255,255,0.06);">
+            <div style="background: rgba(255,255,255,0.04); padding: 8px; border-radius: 6px; font-size: 11px; opacity: 0.4;">输入指令...</div>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="card-body">
+      <h3>A) 三栏:侧边栏 + 主区域 + Chat</h3>
+      <p>左侧固定导航,中间工作区,右侧可收起的 Chat Panel。最像 Cursor/VS Code 的布局。Chat 和 UI 各自独立,互不干扰。</p>
+    </div>
+  </div>
+
+  <div class="card" data-choice="b" onclick="toggleSelect(this)">
+    <div class="card-image" style="padding: 16px;">
+      <div style="display: flex; flex-direction: column; height: 320px; gap: 1px; background: rgba(255,255,255,0.05); border-radius: 8px; overflow: hidden; font-size: 12px;">
+        <!-- Top nav -->
+        <div style="padding: 10px 16px; background: rgba(255,255,255,0.03); display: flex; justify-content: space-between; align-items: center;">
+          <div style="display: flex; gap: 16px; align-items: center;">
+            <span style="font-weight: 600; color: #c4956a;">InkOS</span>
+            <span style="opacity: 0.6;">书籍</span>
+            <span style="opacity: 0.6;">配置</span>
+            <span style="opacity: 0.6;">Daemon</span>
+          </div>
+          <div style="font-size: 10px; opacity: 0.4;">ZH · 玄幻</div>
+        </div>
+        <!-- Main + Bottom chat -->
+        <div style="flex: 1; display: flex; flex-direction: column;">
+          <div style="flex: 1; padding: 16px;">
+            <div style="font-weight: 500; margin-bottom: 12px;">吞天魔帝 · 31 章</div>
+            <div style="display: grid; gap: 6px;">
+              <div style="padding: 8px; background: rgba(255,255,255,0.03); border-radius: 4px; display: flex; justify-content: space-between;"><span>第31章 天劫降临</span><span style="color: #4ade80; font-size: 10px;">approved</span></div>
+              <div style="padding: 8px; background: rgba(255,255,255,0.03); border-radius: 4px; display: flex; justify-content: space-between;"><span>第30章 暗涌</span><span style="color: #fbbf24; font-size: 10px;">review</span></div>
+            </div>
+          </div>
+          <!-- Bottom chat bar -->
+          <div style="border-top: 1px solid rgba(255,255,255,0.08); padding: 12px 16px; background: rgba(255,255,255,0.02);">
+            <div style="display: flex; gap: 8px; align-items: center;">
+              <div style="flex: 1; background: rgba(255,255,255,0.04); padding: 10px 12px; border-radius: 8px; font-size: 11px; opacity: 0.4;">告诉 InkOS 你想做什么...</div>
+              <div style="width: 32px; height: 32px; background: rgba(196,149,106,0.3); border-radius: 6px; display: flex; align-items: center; justify-content: center;">↑</div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="card-body">
+      <h3>B) 顶栏 + 底部 Chat Bar</h3>
+      <p>顶部导航,主区域占满,底部一个始终在的输入框(像 ChatGPT 的输入栏)。输入后展开成对话面板。更轻量,不吃侧边空间。适合宽屏不够的笔记本。</p>
+    </div>
+  </div>
+
+  <div class="card" data-choice="c" onclick="toggleSelect(this)">
+    <div class="card-image" style="padding: 16px;">
+      <div style="display: flex; height: 320px; gap: 1px; background: rgba(255,255,255,0.05); border-radius: 8px; overflow: hidden; font-size: 12px;">
+        <!-- Sidebar -->
+        <div style="width: 52px; background: rgba(255,255,255,0.03); padding: 12px 8px; display: flex; flex-direction: column; align-items: center; gap: 12px;">
+          <div style="font-weight: 600; color: #c4956a; font-size: 14px;">I</div>
+          <div style="width: 32px; height: 32px; background: rgba(255,255,255,0.06); border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 14px;">📚</div>
+          <div style="width: 32px; height: 32px; background: rgba(255,255,255,0.03); border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 14px; opacity: 0.4;">💬</div>
+          <div style="width: 32px; height: 32px; background: rgba(255,255,255,0.03); border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 14px; opacity: 0.4;">⚙</div>
+          <div style="flex: 1;"></div>
+          <div style="width: 32px; height: 32px; background: rgba(255,255,255,0.03); border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 10px; opacity: 0.4;">ZH</div>
+        </div>
+        <!-- Book list panel -->
+        <div style="width: 200px; background: rgba(255,255,255,0.02); padding: 12px; border-right: 1px solid rgba(255,255,255,0.04);">
+          <div style="font-size: 10px; text-transform: uppercase; opacity: 0.4; margin-bottom: 8px; letter-spacing: 1px;">书籍</div>
+          <div style="padding: 8px; background: rgba(196,149,106,0.08); border-radius: 4px; margin-bottom: 4px; border-left: 2px solid #c4956a;">吞天魔帝</div>
+          <div style="padding: 8px; opacity: 0.5;">烈焰前传</div>
+        </div>
+        <!-- Main area -->
+        <div style="flex: 1; padding: 16px;">
+          <div style="font-weight: 500; margin-bottom: 4px;">吞天魔帝</div>
+          <div style="font-size: 10px; opacity: 0.4; margin-bottom: 16px;">玄幻 · 31章 · 452,191字</div>
+          <div style="display: grid; gap: 6px;">
+            <div style="padding: 8px; background: rgba(255,255,255,0.03); border-radius: 4px; display: flex; justify-content: space-between;"><span>第31章 天劫降临</span><span style="color: #4ade80; font-size: 10px;">✓</span></div>
+            <div style="padding: 8px; background: rgba(255,255,255,0.03); border-radius: 4px; display: flex; justify-content: space-between;"><span>第30章 暗涌</span><span style="color: #fbbf24; font-size: 10px;">◎</span></div>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="card-body">
+      <h3>C) 图标侧栏 + 面板 + 主区域</h3>
+      <p>极窄图标侧栏(像 Discord/Slack),点击展开二级面板。Chat 是侧栏的一个 tab,跟书籍列表平级。紧凑、现代,给主区域最大空间。</p>
+    </div>
+  </div>
+</div>

+ 138 - 0
.superpowers/brainstorm/33312-1773817943/layout-refined.html

@@ -0,0 +1,138 @@
+<h2>布局细化:Chat Panel 的存在感</h2>
+<p class="subtitle">基于方案 A,调整 Chat 的大小和行为</p>
+
+<div class="cards">
+  <div class="card" data-choice="slim" onclick="toggleSelect(this)">
+    <div class="card-image" style="padding: 16px;">
+      <div style="display: flex; height: 340px; gap: 0; background: rgba(255,255,255,0.05); border-radius: 8px; overflow: hidden; font-size: 12px;">
+        <!-- Sidebar -->
+        <div style="width: 180px; background: rgba(255,255,255,0.03); padding: 12px; display: flex; flex-direction: column; gap: 6px; border-right: 1px solid rgba(255,255,255,0.05);">
+          <div style="font-weight: 600; margin-bottom: 8px; display: flex; align-items: baseline; gap: 2px;"><span style="color: #c4956a; font-style: italic; font-size: 15px;">Ink</span><span style="font-size: 13px;">OS</span></div>
+          <div style="padding: 6px 8px; background: rgba(196,149,106,0.1); border-radius: 4px; color: #c4956a; font-size: 11px;">📚 吞天魔帝</div>
+          <div style="padding: 6px 8px; opacity: 0.4; font-size: 11px;">📖 烈焰前传</div>
+          <div style="flex: 1;"></div>
+          <div style="padding: 6px 8px; opacity: 0.4; font-size: 10px;">⚙ 配置</div>
+          <div style="padding: 6px 8px; opacity: 0.4; font-size: 10px;">🔄 Daemon <span style="color: #4ade80;">●</span></div>
+        </div>
+        <!-- Main content - WIDE -->
+        <div style="flex: 1; display: flex; flex-direction: column; min-width: 0;">
+          <div style="padding: 12px 20px; border-bottom: 1px solid rgba(255,255,255,0.05); display: flex; justify-content: space-between; align-items: center;">
+            <div>
+              <div style="font-weight: 500;">吞天魔帝</div>
+              <div style="font-size: 10px; opacity: 0.4;">玄幻 · 31章 · 452,191字 · 审计通过率 100%</div>
+            </div>
+            <div style="display: flex; gap: 6px;">
+              <div style="padding: 5px 10px; background: rgba(196,149,106,0.15); color: #c4956a; border-radius: 4px; font-size: 11px;">写下一章</div>
+              <div style="padding: 5px 10px; background: rgba(255,255,255,0.05); border-radius: 4px; font-size: 11px; opacity: 0.6;">草稿</div>
+            </div>
+          </div>
+          <div style="flex: 1; padding: 16px 20px; overflow: hidden;">
+            <div style="display: grid; gap: 4px; font-size: 11px;">
+              <div style="padding: 10px 12px; background: rgba(255,255,255,0.025); border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
+                <span>31 · 天劫降临</span>
+                <div style="display: flex; gap: 8px; align-items: center;"><span style="opacity: 0.4;">14,200字</span><span style="color: #4ade80;">approved</span></div>
+              </div>
+              <div style="padding: 10px 12px; background: rgba(255,255,255,0.025); border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
+                <span>30 · 暗涌</span>
+                <div style="display: flex; gap: 8px; align-items: center;"><span style="opacity: 0.4;">13,800字</span><span style="color: #fbbf24;">review</span><span style="display: flex; gap: 3px;"><span style="padding: 2px 6px; background: rgba(74,222,128,0.1); color: #4ade80; border-radius: 3px; font-size: 9px;">通过</span><span style="padding: 2px 6px; background: rgba(248,113,113,0.1); color: #f87171; border-radius: 3px; font-size: 9px;">驳回</span></span></div>
+              </div>
+              <div style="padding: 10px 12px; background: rgba(255,255,255,0.025); border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
+                <span>29 · 破阵</span>
+                <div style="display: flex; gap: 8px; align-items: center;"><span style="opacity: 0.4;">15,100字</span><span style="color: #4ade80;">approved</span></div>
+              </div>
+              <div style="padding: 10px 12px; background: rgba(255,255,255,0.025); border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
+                <span>28 · 灵脉觉醒</span>
+                <div style="display: flex; gap: 8px; align-items: center;"><span style="opacity: 0.4;">14,500字</span><span style="color: #4ade80;">approved</span></div>
+              </div>
+            </div>
+          </div>
+        </div>
+        <!-- Chat - SLIM -->
+        <div style="width: 220px; background: rgba(255,255,255,0.015); border-left: 1px solid rgba(255,255,255,0.05); display: flex; flex-direction: column;">
+          <div style="padding: 10px 12px; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 10px; display: flex; justify-content: space-between; align-items: center;">
+            <span style="text-transform: uppercase; letter-spacing: 1px; opacity: 0.5;">Chat</span>
+            <span style="opacity: 0.3; cursor: pointer;">✕</span>
+          </div>
+          <div style="flex: 1; padding: 10px; font-size: 11px; display: flex; flex-direction: column; gap: 6px; justify-content: flex-end;">
+            <div style="background: rgba(196,149,106,0.08); padding: 6px 8px; border-radius: 6px; line-height: 1.4; max-width: 90%;">写下一章</div>
+            <div style="background: rgba(255,255,255,0.03); padding: 6px 8px; border-radius: 6px; font-size: 10px; max-width: 90%; align-self: flex-end; line-height: 1.4;">
+              <div style="color: #c4956a;">正在写第32章...</div>
+              <div style="opacity: 0.5; margin-top: 2px;">Phase 1: creative · 45s</div>
+            </div>
+          </div>
+          <div style="padding: 8px; border-top: 1px solid rgba(255,255,255,0.05);">
+            <div style="background: rgba(255,255,255,0.03); padding: 7px 10px; border-radius: 6px; font-size: 10px; opacity: 0.3;">输入指令...</div>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="card-body">
+      <h3>Slim Chat — 窄面板,常驻但不抢戏</h3>
+      <p>220px 窄条,只显示最近几条消息 + 输入框。主区域拿到绝大部分空间。Chat 是辅助工具,不是主角。✕ 可完全关闭。</p>
+    </div>
+  </div>
+
+  <div class="card" data-choice="drawer" onclick="toggleSelect(this)">
+    <div class="card-image" style="padding: 16px;">
+      <div style="display: flex; height: 340px; gap: 0; background: rgba(255,255,255,0.05); border-radius: 8px; overflow: hidden; font-size: 12px; position: relative;">
+        <!-- Sidebar -->
+        <div style="width: 180px; background: rgba(255,255,255,0.03); padding: 12px; display: flex; flex-direction: column; gap: 6px; border-right: 1px solid rgba(255,255,255,0.05);">
+          <div style="font-weight: 600; margin-bottom: 8px; display: flex; align-items: baseline; gap: 2px;"><span style="color: #c4956a; font-style: italic; font-size: 15px;">Ink</span><span style="font-size: 13px;">OS</span></div>
+          <div style="padding: 6px 8px; background: rgba(196,149,106,0.1); border-radius: 4px; color: #c4956a; font-size: 11px;">📚 吞天魔帝</div>
+          <div style="padding: 6px 8px; opacity: 0.4; font-size: 11px;">📖 烈焰前传</div>
+          <div style="flex: 1;"></div>
+          <div style="padding: 6px 8px; opacity: 0.4; font-size: 10px;">⚙ 配置</div>
+          <div style="padding: 6px 8px; background: rgba(196,149,106,0.08); border-radius: 4px; font-size: 10px; color: #c4956a;">💬 Chat</div>
+        </div>
+        <!-- Main content - FULL WIDTH -->
+        <div style="flex: 1; display: flex; flex-direction: column; min-width: 0;">
+          <div style="padding: 12px 20px; border-bottom: 1px solid rgba(255,255,255,0.05); display: flex; justify-content: space-between; align-items: center;">
+            <div>
+              <div style="font-weight: 500;">吞天魔帝</div>
+              <div style="font-size: 10px; opacity: 0.4;">玄幻 · 31章 · 452,191字</div>
+            </div>
+            <div style="display: flex; gap: 6px;">
+              <div style="padding: 5px 10px; background: rgba(196,149,106,0.15); color: #c4956a; border-radius: 4px; font-size: 11px;">写下一章</div>
+            </div>
+          </div>
+          <div style="flex: 1; padding: 16px 20px; overflow: hidden;">
+            <div style="display: grid; gap: 4px; font-size: 11px;">
+              <div style="padding: 10px 12px; background: rgba(255,255,255,0.025); border-radius: 4px; display: flex; justify-content: space-between;"><span>31 · 天劫降临</span><span style="color: #4ade80;">approved</span></div>
+              <div style="padding: 10px 12px; background: rgba(255,255,255,0.025); border-radius: 4px; display: flex; justify-content: space-between;"><span>30 · 暗涌</span><span style="color: #fbbf24;">review</span></div>
+              <div style="padding: 10px 12px; background: rgba(255,255,255,0.025); border-radius: 4px; display: flex; justify-content: space-between;"><span>29 · 破阵</span><span style="color: #4ade80;">approved</span></div>
+              <div style="padding: 10px 12px; background: rgba(255,255,255,0.025); border-radius: 4px; display: flex; justify-content: space-between;"><span>28 · 灵脉觉醒</span><span style="color: #4ade80;">approved</span></div>
+              <div style="padding: 10px 12px; background: rgba(255,255,255,0.025); border-radius: 4px; display: flex; justify-content: space-between;"><span>27 · 宗门大比</span><span style="color: #4ade80;">approved</span></div>
+            </div>
+          </div>
+        </div>
+        <!-- Overlay hint -->
+        <div style="position: absolute; bottom: 12px; left: 192px; right: 12px; text-align: center; font-size: 10px; opacity: 0.3;">
+          点击侧栏 💬 Chat 展开对话面板 (overlay)
+        </div>
+      </div>
+    </div>
+    <div class="card-body">
+      <h3>Drawer Chat — 默认隐藏,点击弹出</h3>
+      <p>Chat 在侧栏是一个图标/入口,点击后从右侧 slide-in 覆盖主区域。不用时主区域 100% 宽度。用时覆盖 ~300px。更极端的"不抢空间"方案。</p>
+    </div>
+  </div>
+</div>
+
+<div class="pros-cons" style="margin-top: 24px;">
+  <div class="pros">
+    <h4>Slim (常驻窄条)</h4>
+    <ul>
+      <li>随时可见,输入即用</li>
+      <li>进度实时显示不需要切换</li>
+      <li>轻量但不消失</li>
+    </ul>
+  </div>
+  <div class="cons">
+    <h4>Drawer (弹出抽屉)</h4>
+    <ul>
+      <li>主区域 100% 宽度</li>
+      <li>对话空间更大(弹出时)</li>
+      <li>但看不到实时进度(除非打开)</li>
+    </ul>
+  </div>
+</div>

+ 59 - 58
packages/studio/src/App.tsx

@@ -1,4 +1,6 @@
 import { useState, useEffect } from "react";
+import { Sidebar } from "./components/Sidebar";
+import { ChatBar } from "./components/ChatBar";
 import { Dashboard } from "./pages/Dashboard";
 import { BookDetail } from "./pages/BookDetail";
 import { BookCreate } from "./pages/BookCreate";
@@ -8,9 +10,11 @@ import { ConfigView } from "./pages/ConfigView";
 import { TruthFiles } from "./pages/TruthFiles";
 import { DaemonControl } from "./pages/DaemonControl";
 import { LogViewer } from "./pages/LogViewer";
+import { LanguageSelector } from "./pages/LanguageSelector";
 import { useSSE } from "./hooks/use-sse";
 import { useTheme } from "./hooks/use-theme";
 import { useI18n } from "./hooks/use-i18n";
+import { useApi } from "./hooks/use-api";
 
 type Route =
   | { page: "dashboard" }
@@ -28,6 +32,8 @@ export function App() {
   const sse = useSSE();
   const { theme, setTheme } = useTheme();
   const { t } = useI18n();
+  const { data: project } = useApi<{ language: string }>("/project");
+  const [languageSet, setLanguageSet] = useState<boolean | null>(null);
 
   const isDark = theme === "dark";
 
@@ -35,6 +41,15 @@ export function App() {
     document.documentElement.classList.toggle("dark", isDark);
   }, [isDark]);
 
+  // Check if language has been set
+  useEffect(() => {
+    if (project) {
+      setLanguageSet(!!project.language && project.language !== "zh" ? true : true);
+      // For now, always consider language "set" — the selector is for first-time experience
+      // In a real implementation, we'd check a "hasCompletedSetup" flag
+    }
+  }, [project]);
+
   const nav = {
     toDashboard: () => setRoute({ page: "dashboard" }),
     toBook: (bookId: string) => setRoute({ page: "book", bookId }),
@@ -48,71 +63,57 @@ export function App() {
     toLogs: () => setRoute({ page: "logs" }),
   };
 
-  const navItems = [
-    { label: t("nav.books"), action: nav.toDashboard, active: route.page === "dashboard" || route.page === "book" || route.page === "chapter" },
-    { label: t("nav.newBook"), action: nav.toBookCreate, active: route.page === "book-create" },
-    { label: "Daemon", action: nav.toDaemon, active: route.page === "daemon" },
-    { label: "Logs", action: nav.toLogs, active: route.page === "logs" },
-    { label: t("nav.config"), action: nav.toConfig, active: route.page === "config" },
-  ];
+  // Derive active page for sidebar highlighting
+  const activePage =
+    route.page === "book" || route.page === "chapter" || route.page === "truth" || route.page === "analytics"
+      ? `book:${(route as { bookId: string }).bookId}`
+      : route.page;
+
+  // Language selector for first-time setup
+  if (languageSet === null) {
+    return <div className="min-h-screen bg-background" />; // Loading
+  }
 
   return (
-    <div className="min-h-screen bg-background text-foreground">
-      <header className="sticky top-0 z-10 border-b border-border/60 bg-background/90 backdrop-blur-md">
-        <div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
-          <div className="flex items-center gap-8">
-            <button
-              onClick={nav.toDashboard}
-              className="group flex items-baseline gap-0.5 hover:opacity-80 transition-opacity"
-            >
-              <span className="font-serif text-xl italic text-primary">Ink</span>
-              <span className="text-lg font-medium tracking-tight">OS</span>
-            </button>
+    <div className="h-screen bg-background text-foreground flex overflow-hidden">
+      {/* Sidebar */}
+      <Sidebar nav={nav} activePage={activePage} t={t} />
 
-            <nav className="flex gap-1 text-[13px] text-muted-foreground">
-              {navItems.map((item) => (
-                <button
-                  key={item.label}
-                  onClick={item.action}
-                  className={`px-3 py-2 rounded-md transition-all duration-200 ${
-                    item.active
-                      ? "text-foreground bg-secondary"
-                      : "hover:text-foreground hover:bg-secondary/50"
-                  }`}
-                >
-                  {item.label}
-                </button>
-              ))}
-            </nav>
-          </div>
+      {/* Main area + Chat bar */}
+      <div className="flex-1 flex flex-col min-w-0">
+        {/* Header strip */}
+        <div className="h-11 shrink-0 border-b border-border/30 flex items-center justify-end px-4 gap-3">
+          <button
+            onClick={() => setTheme(isDark ? "light" : "dark")}
+            className="w-7 h-7 flex items-center justify-center rounded-full bg-secondary/50 text-muted-foreground hover:text-foreground transition-colors text-xs"
+          >
+            {isDark ? "☀" : "☽"}
+          </button>
+          {sse.connected && (
+            <span className="text-[10px] text-muted-foreground/40 uppercase tracking-wider">
+              {t("nav.connected")}
+            </span>
+          )}
+        </div>
 
-          <div className="flex items-center gap-4">
-            <button
-              onClick={() => setTheme(isDark ? "light" : "dark")}
-              className="w-8 h-8 flex items-center justify-center rounded-full bg-secondary text-muted-foreground hover:text-foreground transition-colors text-xs"
-            >
-              {isDark ? "☀" : "☽"}
-            </button>
-            {sse.connected && (
-              <span className="text-[11px] text-muted-foreground/60 tracking-wide uppercase">
-                {t("nav.connected")}
-              </span>
-            )}
+        {/* Scrollable main content */}
+        <div className="flex-1 overflow-y-auto">
+          <div className="max-w-4xl mx-auto px-6 py-8">
+            {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} />}
+            {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} />}
           </div>
         </div>
-      </header>
 
-      <main className="max-w-5xl mx-auto px-6 py-10">
-        {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} />}
-        {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} />}
-      </main>
+        {/* Chat bar - always at bottom */}
+        <ChatBar t={t} sse={sse} />
+      </div>
     </div>
   );
 }

+ 51 - 0
packages/studio/src/api/server.ts

@@ -418,6 +418,57 @@ export function createStudioServer(config: ProjectConfig, root: string) {
     }
   });
 
+  // --- Agent chat ---
+
+  app.post("/api/agent", async (c) => {
+    const { instruction } = await c.req.json<{ instruction: string }>();
+    if (!instruction?.trim()) {
+      return c.json({ error: "No instruction provided" }, 400);
+    }
+
+    broadcast("agent:start", { instruction });
+
+    try {
+      const { runAgentLoop, AGENT_TOOLS } = await import("@actalk/inkos-core");
+      const agentCtx = {
+        client: createLLMClient(config.llm),
+        model: config.llm.model,
+        projectRoot: root,
+      };
+
+      const result = await runAgentLoop({
+        ...agentCtx,
+        tools: AGENT_TOOLS,
+        instruction,
+        pipelineConfig: buildPipelineConfig(),
+      });
+
+      broadcast("agent:complete", { instruction, response: result.summary });
+      return c.json({ response: result.summary });
+    } catch (e) {
+      const msg = e instanceof Error ? e.message : String(e);
+      broadcast("agent:error", { instruction, error: msg });
+      return c.json({ response: msg });
+    }
+  });
+
+  // --- Language setup ---
+
+  app.post("/api/project/language", async (c) => {
+    const { language } = await c.req.json<{ language: "zh" | "en" }>();
+    const configPath = join(root, "inkos.json");
+    try {
+      const raw = await readFile(configPath, "utf-8");
+      const existing = JSON.parse(raw);
+      existing.language = language;
+      const { writeFile: writeFileFs } = await import("node:fs/promises");
+      await writeFileFs(configPath, JSON.stringify(existing, null, 2), "utf-8");
+      return c.json({ ok: true, language });
+    } catch (e) {
+      return c.json({ error: String(e) }, 500);
+    }
+  });
+
   return app;
 }
 

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

@@ -0,0 +1,186 @@
+import { useState, useRef, useEffect } from "react";
+import type { TFunction } from "../hooks/use-i18n";
+import type { SSEMessage } from "../hooks/use-sse";
+
+interface ChatMessage {
+  readonly role: "user" | "assistant";
+  readonly content: string;
+  readonly timestamp: number;
+}
+
+export function ChatBar({ t, sse }: {
+  t: TFunction;
+  sse: { messages: ReadonlyArray<SSEMessage>; connected: boolean };
+}) {
+  const [input, setInput] = useState("");
+  const [expanded, setExpanded] = useState(false);
+  const [messages, setMessages] = useState<ReadonlyArray<ChatMessage>>([]);
+  const [loading, setLoading] = useState(false);
+  const inputRef = useRef<HTMLInputElement>(null);
+  const scrollRef = useRef<HTMLDivElement>(null);
+
+  // Auto-scroll on new messages
+  useEffect(() => {
+    if (scrollRef.current) {
+      scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+    }
+  }, [messages, sse.messages.length]);
+
+  // SSE events → assistant messages
+  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" && loading) {
+      const msg = d.message as string;
+      if (msg && (msg.includes("Phase") || msg.includes("streaming"))) {
+        setMessages((prev) => {
+          // Replace the last "thinking" message instead of appending
+          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]);
+
+  const handleSubmit = async () => {
+    const text = input.trim();
+    if (!text || loading) return;
+
+    setInput("");
+    setExpanded(true);
+    setMessages((prev) => [...prev, { role: "user", content: text, timestamp: Date.now() }]);
+    setLoading(true);
+
+    // Simple command routing — check if it's a direct action
+    const lower = text.toLowerCase();
+
+    try {
+      if (lower.match(/^(写下一章|write next)/)) {
+        // Extract book id from context or use first book
+        const res = await fetch("/api/books");
+        const { books } = await res.json() as { books: ReadonlyArray<{ id: string }> };
+        if (books.length > 0) {
+          setMessages((prev) => [...prev, { role: "assistant", content: "⋯ Starting...", timestamp: Date.now() }]);
+          await fetch(`/api/books/${books[0]!.id}/write-next`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
+          // SSE will handle the rest
+          return;
+        }
+      }
+
+      // Fallback: send to agent API
+      const res = await fetch("/api/agent", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ instruction: text }),
+      });
+      const data = await res.json() as { response?: string; error?: string };
+      setLoading(false);
+      setMessages((prev) => [...prev, {
+        role: "assistant",
+        content: data.response ?? data.error ?? "Done",
+        timestamp: Date.now(),
+      }]);
+    } catch (e) {
+      setLoading(false);
+      setMessages((prev) => [...prev, {
+        role: "assistant",
+        content: `Error: ${e instanceof Error ? e.message : String(e)}`,
+        timestamp: Date.now(),
+      }]);
+    }
+  };
+
+  const chatPlaceholder = t("nav.connected") === "已连接"
+    ? "告诉 InkOS 你想做什么..."
+    : "Tell InkOS what to do...";
+
+  return (
+    <div className="border-t border-border/40 bg-card/80 backdrop-blur-sm">
+      {/* Expanded message area */}
+      {expanded && messages.length > 0 && (
+        <div
+          ref={scrollRef}
+          className="max-h-[200px] overflow-y-auto px-4 py-3 space-y-2 border-b border-border/20"
+        >
+          {messages.map((msg, i) => (
+            <div
+              key={i}
+              className={`text-[13px] leading-relaxed ${
+                msg.role === "user"
+                  ? "text-foreground"
+                  : msg.content.startsWith("✗")
+                    ? "text-destructive"
+                    : msg.content.startsWith("⋯")
+                      ? "text-muted-foreground"
+                      : "text-primary"
+              }`}
+            >
+              {msg.role === "user" && <span className="text-muted-foreground/50 mr-1.5">›</span>}
+              {msg.content}
+            </div>
+          ))}
+          {loading && !messages.some((m) => m.content.startsWith("⋯")) && (
+            <div className="text-[13px] text-muted-foreground animate-pulse">⋯</div>
+          )}
+        </div>
+      )}
+
+      {/* Input bar */}
+      <div className="px-4 py-2.5 flex items-center gap-3">
+        <div className="flex-1 relative">
+          <input
+            ref={inputRef}
+            type="text"
+            value={input}
+            onChange={(e) => setInput(e.target.value)}
+            onFocus={() => messages.length > 0 && setExpanded(true)}
+            onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
+            placeholder={chatPlaceholder}
+            disabled={loading}
+            className="w-full bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground/30 focus:outline-none disabled:opacity-50"
+          />
+        </div>
+        <div className="flex items-center gap-2 shrink-0">
+          {expanded && messages.length > 0 && (
+            <button
+              onClick={() => { setExpanded(false); setMessages([]); }}
+              className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground transition-colors"
+            >
+              Clear
+            </button>
+          )}
+          <button
+            onClick={handleSubmit}
+            disabled={!input.trim() || loading}
+            className="w-6 h-6 rounded bg-primary/80 text-primary-foreground flex items-center justify-center text-xs hover:bg-primary transition-colors disabled:opacity-20"
+          >
+            ↑
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 123 - 0
packages/studio/src/components/Sidebar.tsx

@@ -0,0 +1,123 @@
+import { useApi } from "../hooks/use-api";
+import type { TFunction } from "../hooks/use-i18n";
+
+interface BookSummary {
+  readonly id: string;
+  readonly title: string;
+  readonly genre: string;
+  readonly status: string;
+  readonly chaptersWritten: number;
+}
+
+interface Nav {
+  toDashboard: () => void;
+  toBook: (id: string) => void;
+  toBookCreate: () => void;
+  toConfig: () => void;
+  toDaemon: () => void;
+  toLogs: () => void;
+}
+
+export function Sidebar({ nav, activePage, t }: {
+  nav: Nav;
+  activePage: string;
+  t: TFunction;
+}) {
+  const { data } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
+  const { data: daemon } = useApi<{ running: boolean }>("/daemon");
+
+  return (
+    <aside className="w-[200px] shrink-0 border-r border-border/40 bg-card/50 flex flex-col h-full overflow-y-auto">
+      {/* Logo */}
+      <div className="px-4 py-5 border-b border-border/30">
+        <button onClick={nav.toDashboard} className="flex items-baseline gap-0.5 hover:opacity-80 transition-opacity">
+          <span className="font-serif text-xl italic text-primary">Ink</span>
+          <span className="text-base font-semibold tracking-tight">OS</span>
+        </button>
+      </div>
+
+      {/* Books section */}
+      <div className="flex-1 py-3">
+        <div className="px-4 mb-2 flex items-center justify-between">
+          <span className="text-[10px] uppercase tracking-widest text-muted-foreground/50">{t("nav.books")}</span>
+          <button
+            onClick={nav.toBookCreate}
+            className="text-[10px] text-primary/60 hover:text-primary transition-colors"
+          >
+            +
+          </button>
+        </div>
+
+        <div className="space-y-0.5 px-2">
+          {data?.books.map((book) => (
+            <button
+              key={book.id}
+              onClick={() => nav.toBook(book.id)}
+              className={`w-full text-left px-2 py-1.5 rounded text-[13px] truncate transition-colors ${
+                activePage === `book:${book.id}`
+                  ? "bg-primary/10 text-primary"
+                  : "text-muted-foreground hover:text-foreground hover:bg-muted/30"
+              }`}
+            >
+              {book.title}
+            </button>
+          ))}
+
+          {(!data?.books || data.books.length === 0) && (
+            <div className="px-2 py-3 text-[11px] text-muted-foreground/40 italic">
+              {t("dash.noBooks")}
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* System nav */}
+      <div className="border-t border-border/30 py-3 px-2 space-y-0.5">
+        <SidebarItem
+          label={t("nav.config")}
+          icon="⚙"
+          active={activePage === "config"}
+          onClick={nav.toConfig}
+        />
+        <SidebarItem
+          label="Daemon"
+          icon="⟳"
+          active={activePage === "daemon"}
+          onClick={nav.toDaemon}
+          badge={daemon?.running ? "●" : undefined}
+          badgeColor={daemon?.running ? "text-emerald-500" : undefined}
+        />
+        <SidebarItem
+          label="Logs"
+          icon="☰"
+          active={activePage === "logs"}
+          onClick={nav.toLogs}
+        />
+      </div>
+    </aside>
+  );
+}
+
+function SidebarItem({ label, icon, active, onClick, badge, badgeColor }: {
+  label: string;
+  icon: string;
+  active: boolean;
+  onClick: () => void;
+  badge?: string;
+  badgeColor?: string;
+}) {
+  return (
+    <button
+      onClick={onClick}
+      className={`w-full text-left px-2 py-1.5 rounded text-[13px] flex items-center gap-2 transition-colors ${
+        active
+          ? "bg-secondary text-foreground"
+          : "text-muted-foreground hover:text-foreground hover:bg-muted/30"
+      }`}
+    >
+      <span className="text-xs w-4 text-center opacity-50">{icon}</span>
+      <span className="flex-1">{label}</span>
+      {badge && <span className={`text-[10px] ${badgeColor ?? ""}`}>{badge}</span>}
+    </button>
+  );
+}

+ 49 - 0
packages/studio/src/pages/LanguageSelector.tsx

@@ -0,0 +1,49 @@
+export function LanguageSelector({ onSelect }: { onSelect: (lang: "zh" | "en") => void }) {
+  return (
+    <div className="min-h-screen bg-background flex items-center justify-center">
+      <div className="text-center space-y-12">
+        {/* Logo */}
+        <div>
+          <div className="flex items-baseline justify-center gap-1 mb-3">
+            <span className="font-serif text-5xl italic text-primary">Ink</span>
+            <span className="text-4xl font-semibold tracking-tight">OS</span>
+          </div>
+          <div className="text-sm text-muted-foreground/50">Studio</div>
+        </div>
+
+        {/* Language cards */}
+        <div className="flex gap-6">
+          <button
+            onClick={() => onSelect("zh")}
+            className="group w-64 border border-border/60 rounded-lg p-8 hover:border-primary/40 transition-all duration-300 text-left"
+          >
+            <div className="font-serif text-2xl mb-2">中文创作</div>
+            <div className="text-sm text-muted-foreground leading-relaxed">
+              玄幻 · 仙侠 · 都市 · 恐怖
+            </div>
+            <div className="text-xs text-muted-foreground/40 mt-4">
+              番茄小说 · 起点中文网
+            </div>
+          </button>
+
+          <button
+            onClick={() => onSelect("en")}
+            className="group w-64 border border-border/60 rounded-lg p-8 hover:border-primary/40 transition-all duration-300 text-left"
+          >
+            <div className="font-serif text-2xl mb-2 italic">English Writing</div>
+            <div className="text-sm text-muted-foreground leading-relaxed">
+              LitRPG · Progression · Romantasy · Sci-Fi
+            </div>
+            <div className="text-xs text-muted-foreground/40 mt-4">
+              Royal Road · Kindle Unlimited
+            </div>
+          </button>
+        </div>
+
+        <div className="text-[11px] text-muted-foreground/30">
+          This can be changed later in Settings
+        </div>
+      </div>
+    </div>
+  );
+}