mirror of
https://github.com/Narcooo/inkos.git
synced 2026-09-01 15:08:51 +08:00
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
This commit is contained in:
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -0,0 +1 @@
|
||||
33321
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user