docs(superpowers): 补录前端美化与交互改造的设计规范与实施计划

- docs/superpowers/specs/2026-08-30-frontend-beautify-design.md
  前端美化与交互改造设计(深色指挥中心 + 文档工作台混合风格,
  单一青绿 #5EEAD4 强调色,chat.html 拆为 chat.html + chat.css + chat.js,
  8 项交互改善,用户决策 10 项)
- docs/superpowers/plans/2026-08-30-frontend-beautify.md
  前端美化与交互改造 Implementation Plan(三步走:机械拆分 → 视觉重塑 →
  交互改造,Task 独立 commit,浏览器手测验证)

按项目文档规则存入 docs/superpowers/ 目录。
This commit is contained in:
lhl
2026-08-31 22:33:46 +08:00
parent 19d214039c
commit b14c2213bc
2 changed files with 2146 additions and 0 deletions
@@ -0,0 +1,1779 @@
# 前端美化与交互改造 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:**`src/genesis/server/static/chat.html`(单文件 1215 行内联 CSS+JS)拆分为 `chat.html` + `chat.css` + `chat.js`,套用「深色指挥中心 + 文档工作台」混合风格(单一青绿 `#5EEAD4` 强调色 + 系统字体栈),并实施 8 项交互改善(分组 / 拖拽 / 气泡操作 / 多行输入 / 顶栏徽章 / 快捷键 Esc+Cmd+K / 骨架屏 / 空状态快捷指令 / 错误分级)。
**Architecture:** 机械拆分 → 视觉重塑 → 交互改造三步走。chat_state.js / chat_ws.js 完全不动(保持 `window.GenesisState` / `window.GenesisWS` 协议不变),chat.js 通过 fallback 默认值兜底。所有 DOM id 保留,所有 API 端点不变,所有 localStorage 键不变。**无后端变更,无测试框架(pytest)可覆盖的逻辑**,故 Task 验证手段为"浏览器手动 + 浏览器自动化烟雾测试"。
**Tech Stack:** 纯前端(HTML / CSS / JavaScript ES2017+)。无新增依赖。
## Global Constraints
- 中文优先:所有 UI 文案、注释、提交信息使用中文(专有名词 / 标识符 / 代码关键字除外)。
- 不新增依赖:不引入 Google Fonts / JetBrains Mono;纯系统字体栈。
- 单主题:本轮不做 dark/light 切换。
- 兼容性保证:所有 DOM id 必须保留(见 spec §三-1);所有 localStorage / sessionStorage 键必须保留(见 spec §三-3);所有 `/api/...` 调用方式不变。
- 移动端:< 768px 断点的改造不在本轮范围(spec §零-8)。
- 不实现"重新生成"按钮(spec §零-9)。
- 提交粒度:每个 Task 独立 commit,便于回滚。
- 颜色十六进制值必须与 spec §一完全一致(禁止调整)。
- 后端 Python 代码不在本 plan 修改范围;仅前端三个文件(chat.html / chat.css / chat.js+ docs。
---
## 文件结构(本 plan 涉及)
```
src/genesis/server/static/
├── chat.html # Modify:删 <style>/<script> 内联,引用外部资源
├── chat.css # Create:约 350-450 行,含 :root 令牌 + 六大区域
├── chat.js # Create:约 800-900 行,含 8 项交互 + 原业务逻辑
├── chat_state.js # 不动
└── chat_ws.js # 不动
docs/
├── design.md # Modify:追加"前端 UI 设计规范"一节
├── superpowers/specs/2026-08-30-frontend-beautify-design.md # 已有,本文参照
└── _AI_USAGE_LOG.md # Modify:追加 3 条记录
tests/frontend/ # Create:浏览器自动化烟雾测试(可选;本 plan 标记为"可省略",浏览器手测为主)
```
---
### Task 1: 拆分 chat.html + 创建 chat.css 骨架 + chat.js 骨架
**Files:**
- Modify: `src/genesis/server/static/chat.html:1-1215`
- Create: `src/genesis/server/static/chat.css`
- Create: `src/genesis/server/static/chat.js`
**Interfaces:**
- Consumes: 原 chat.html 全部 DOM 节点
- Produces:
- `chat.html` 仅含 DOM 结构 + `<link href="/chat.css">` + `<script src="/chat.js" defer>`
- `chat.css``:root` 全部 CSS 变量(spec §一)
- `chat.js` 含原内联 `<script>` 段全部业务逻辑(机械迁移,零行为变更)
**注**: 本 Task 只做"机械拆分"——CSS 暂写一份"最小还原"(保留原 Material/Google 风视觉),保证拆分后页面行为与拆分前完全一致;视觉重塑留到 Task 2。
- [ ] **Step 1: 创建 chat.css(最小还原版,仅令牌 + 原样样式)**
```css
/* src/genesis/server/static/chat.css
v0 拆分骨架 - 视觉与原 chat.html 内联样式完全一致
v1 视觉重塑见 Task 2
*/
:root {
/* === 颜色 (v0 还原; v1 在 Task 2 替换为深色令牌) === */
--blue: #1a73e8;
--blue-dark: #1967d2;
--blue-soft: #d2e3fc;
--red: #ea4335;
--red-soft: #fce8e6;
--yellow: #fbbc05;
--green: #34a853;
--gray-50: #f8f9fa;
--gray-100: #f1f3f4;
--gray-200: #e8eaed;
--gray-300: #dadce0;
--gray-500: #5f6368;
--gray-700: #3c4043;
--gray-900: #202124;
--blue-50: #e8f0fe;
--blue-100: #d2e3fc;
--radius-lg: 12px;
--radius-xl: 16px;
--shadow-1: 0 1px 2px rgba(60,64,67,.30), 0 1px 3px 1px rgba(60,64,67,.15);
--shadow-2: 0 1px 2px rgba(60,64,67,.30), 0 2px 6px 2px rgba(60,64,67,.15);
--shadow-3: 0 1px 3px rgba(60,64,67,.30), 0 4px 8px 3px rgba(60,64,67,.15);
--glow-blue: 0 0 0 1px rgba(26,115,232,.12), 0 8px 24px rgba(26,115,232,.18);
--ease: all .2s cubic-bezier(.2,0,0,1);
/* === 字体 (v0 保留 Inter via Google Fonts; v1 替换为系统字体栈) === */
--font-ui: 'Inter', system-ui, -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif;
}
/* 完整还原原 chat.html:36-391 的全部样式(原样复制,仅变量化) */
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
font-family: var(--font-ui);
margin: 0; height: 100vh; display: flex; flex-direction: row;
background: var(--gray-50); color: var(--gray-900);
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: var(--gray-300); border-radius: 8px; }
::-webkit-scrollbar-thumb:hover { background: var(--gray-500); }
::-webkit-scrollbar-track { background: transparent; }
#sidebar { width: 264px; background: #fff; color: var(--gray-900); display: flex; flex-direction: column; flex-shrink: 0; border-right: 1px solid var(--gray-200); transition: width .25s ease; position: relative; }
#sidebar.collapsed { width: 56px; }
#sidebar.collapsed #sidebar-toggle { top: auto; bottom: 14px; right: auto; left: 14px; }
#sidebar.collapsed #session-list { padding-bottom: 52px; }
.brand { display: flex; align-items: center; gap: 12px; padding: 16px 12px 12px 16px; margin: 0 8px 8px; border-bottom: 1px solid var(--gray-100); min-height: 64px; }
#sidebar.collapsed .brand { padding: 16px 8px; justify-content: center; gap: 0; }
.brand-logo { width: 36px; height: 36px; border-radius: 10px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 17px; font-weight: 900; background: linear-gradient(135deg, #1a73e8, #4285f4); box-shadow: var(--glow-blue); transition: var(--ease); }
.brand:hover .brand-logo { transform: scale(1.1); }
.brand-name { font-size: 17px; font-weight: 900; letter-spacing: -.02em; line-height: 1.1; }
#sidebar.collapsed .brand-name { display: none; }
.brand-sub { display: block; font-size: 11px; font-weight: 600; color: var(--gray-500); margin-top: 2px; letter-spacing: 0; }
#sidebar-toggle { position: absolute; top: 18px; right: -14px; z-index: 5; width: 28px; height: 28px; border-radius: 50%; background: #fff; border: 1px solid var(--gray-200); box-shadow: var(--shadow-1); color: var(--gray-700); cursor: pointer; font-size: 14px; line-height: 1; display: flex; align-items: center; justify-content: center; transition: var(--ease); }
#sidebar-toggle:hover { color: var(--blue); border-color: var(--blue-100); }
#sidebar.collapsed #sidebar-toggle { transform: rotate(180deg); }
#new-chat { margin: 6px 16px 10px; background: var(--blue); color: #fff; border: none; padding: 10px 14px; border-radius: var(--radius-lg); cursor: pointer; font-size: 14px; font-weight: 600; letter-spacing: .01em; box-shadow: 0 1px 2px rgba(26,115,232,.4); transition: var(--ease); display: flex; align-items: center; justify-content: center; gap: 6px; }
#new-chat:hover { background: var(--blue-dark); box-shadow: 0 2px 6px rgba(26,115,232,.45); transform: translateY(-1px); }
#new-chat:active { transform: translateY(0); }
#sidebar.collapsed #new-chat { margin: 6px 10px 10px; padding: 10px 0; }
#new-chat .ic-plus { display: inline-block; font-weight: 900; }
#sidebar.collapsed #new-chat .nc-text { display: none; }
#sidebar h2 { font-size: 9px; font-weight: 900; letter-spacing: .2em; text-transform: uppercase; color: var(--gray-500); margin: 10px 16px 6px; }
#sidebar.collapsed h2 { display: none; }
#session-list { flex: 1; overflow-y: auto; padding: 0 8px 8px; }
.sess { padding: 9px 12px; border-radius: var(--radius-lg); margin: 2px 0; cursor: pointer; position: relative; border: 1px solid transparent; transition: var(--ease); display: flex; align-items: center; gap: 6px; }
.sess:hover { background: var(--gray-50); }
.sess.active { background: var(--blue-50); border-color: var(--blue-100); }
.sess.active::before { content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 3px; border-radius: 0 3px 3px 0; background: var(--blue); }
.sess .info { flex: 1; min-width: 0; overflow: hidden; }
.sess .name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sess.active .name { font-weight: 700; }
.sess .meta { font-size: 11px; color: var(--gray-500); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sess .del { flex-shrink: 0; opacity: 0; transition: opacity .15s ease; background: transparent; border: none; color: var(--gray-500); cursor: pointer; width: 24px; height: 24px; border-radius: 6px; font-size: 16px; line-height: 1; display: flex; align-items: center; justify-content: center; }
.sess:hover .del { opacity: .7; }
.sess .del:hover { opacity: 1 !important; background: var(--red-soft); color: var(--red); }
#sidebar.collapsed .sess { justify-content: center; padding: 9px 6px; }
#sidebar.collapsed .sess .info, #sidebar.collapsed .sess .del { display: none; }
#sidebar.collapsed .sess .avatar { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; background: var(--gray-100); color: var(--gray-700); font-size: 12px; font-weight: 700; }
.sess .avatar { display: none; }
#sidebar.collapsed .sess .avatar { display: flex; }
#sidebar.collapsed .sess.active .avatar { background: var(--blue); color: #fff; }
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--gray-50); }
header { height: 56px; position: sticky; top: 0; z-index: 10; padding: 0 24px; display: flex; align-items: center; gap: 14px; background: rgba(255,255,255,.78); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-bottom: 1px solid var(--gray-200); }
header h1 { font-size: 16px; font-weight: 900; letter-spacing: -.02em; margin: 0; color: var(--gray-900); }
.proj-switcher { position: relative; }
.ps-current { display: flex; align-items: center; gap: 8px; background: var(--gray-50); border: 1px solid var(--gray-200); border-radius: var(--radius-lg); padding: 7px 12px; cursor: pointer; font-size: 13px; font-weight: 600; color: var(--gray-700); transition: var(--ease); }
.ps-current:hover { background: #fff; border-color: var(--blue-100); color: var(--blue-dark); }
.ps-current .ps-label { font-size: 11px; font-weight: 900; letter-spacing: .12em; text-transform: uppercase; color: var(--gray-500); }
.ps-current .ps-name { max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ps-current .ps-caret { font-size: 10px; color: var(--gray-500); }
.ps-menu { position: absolute; top: calc(100% + 6px); left: 0; min-width: 240px; background: #fff; border: 1px solid var(--gray-200); border-radius: var(--radius-lg); box-shadow: var(--shadow-3); padding: 6px; z-index: 20; display: none; }
.ps-menu.open { display: block; animation: zoomIn .2s ease; }
.ps-item { padding: 8px 12px; border-radius: 8px; cursor: pointer; font-size: 13px; color: var(--gray-700); display: flex; align-items: center; gap: 8px; transition: background .15s ease; }
.ps-item:hover { background: var(--blue-50); color: var(--blue-dark); }
.ps-item.active { background: var(--blue-50); color: var(--blue-dark); font-weight: 700; }
.ps-item .ps-check { margin-left: auto; color: var(--blue); font-weight: 900; opacity: 0; }
.ps-item.active .ps-check { opacity: 1; }
.ps-sep { height: 1px; background: var(--gray-100); margin: 4px 6px; }
.ps-manage { color: var(--blue); font-weight: 600; }
#sid-badge { margin-left: auto; background: var(--blue-50); color: var(--blue-dark); border: 1px solid var(--blue-100); border-radius: 999px; padding: 4px 14px; font-size: 12px; font-weight: 600; max-width: 46%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#rag-bar { display: flex; align-items: center; gap: 8px; background: var(--gray-50); border: 1px solid var(--gray-200); border-radius: 999px; padding: 3px 10px; font-size: 12px; color: var(--gray-700); }
#rag-bar label.rag-switch { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; }
#rag-bar input[type=checkbox] { width: 14px; height: 14px; accent-color: var(--blue); margin: 0; }
#rag-bar input[type=checkbox]:disabled + span { color: var(--gray-500); cursor: not-allowed; }
#rag-bar .rag-stats { font-variant-numeric: tabular-nums; color: var(--gray-500); }
#rag-bar .rag-stats.ready { color: var(--blue-dark); font-weight: 600; }
#rag-bar .rag-stats.zero { color: var(--gray-500); }
#rag-bar button { background: var(--blue); color: #fff; border: none; border-radius: 999px; padding: 4px 12px; font-size: 12px; font-weight: 700; cursor: pointer; }
#rag-bar button:disabled { background: var(--gray-200); color: var(--gray-500); cursor: not-allowed; }
#rag-bar button:hover:not(:disabled) { background: var(--blue-dark); }
#chat { flex: 1; overflow-y: auto; padding: 24px 24px 32px; max-width: 880px; width: 100%; margin: 0 auto; }
.msg { display: flex; margin: 12px 0; animation: zoomIn .25s ease; }
.msg .bubble { max-width: 74%; padding: 11px 16px; border-radius: var(--radius-xl); white-space: pre-wrap; line-height: 1.6; font-size: 14px; letter-spacing: .01em; }
.msg.user { justify-content: flex-end; }
.msg.user .bubble { background: linear-gradient(135deg, #1a73e8, #4285f4); color: #fff; border-bottom-right-radius: 6px; box-shadow: var(--shadow-2); }
.msg.assistant { align-items: flex-start; }
.msg.assistant .bubble { background: #fff; border: 1px solid var(--gray-200); border-bottom-left-radius: 6px; box-shadow: var(--shadow-1); }
.msg.assistant::before { content: ""; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin: 16px 8px 0 0; background: var(--blue); box-shadow: 0 0 0 0 rgba(26,115,232,.4); animation: auraPulse 2.2s ease-in-out infinite; }
.msg.progress .bubble { background: var(--blue-50); border: 1px dashed var(--blue-soft); font-size: 13px; color: var(--gray-700); }
.msg.error .bubble { background: var(--red-soft); border: 1px solid #f5c6c0; color: #b31412; }
.progress-item { display: block; }
.progress-item.ok::before { content: "✔ "; color: var(--green); font-weight: 700; }
.progress-item.warn::before { content: "⚠ "; color: #b06000; font-weight: 700; }
.actions { margin-top: 8px; }
.actions a { display: inline-block; margin: 4px 6px 0 0; background: var(--blue); color: #fff; padding: 6px 14px; border-radius: 999px; text-decoration: none; font-size: 13px; font-weight: 600; box-shadow: 0 1px 2px rgba(26,115,232,.4); transition: var(--ease); }
.actions a:hover { background: var(--blue-dark); transform: translateY(-1px); }
.typing { color: var(--gray-500); font-size: 13px; font-style: italic; animation: auraPulse 1.4s ease-in-out infinite; }
#upload-bar { background: #fff; border-top: 1px solid var(--gray-200); padding: 10px 24px; font-size: 13px; }
#upload-bar-inner { max-width: 880px; margin: 0 auto; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
#upload-bar select { font-size: 13px; padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: var(--radius-lg); background: #fff; color: var(--gray-900); cursor: pointer; outline: none; transition: var(--ease); }
#upload-bar select:focus { border-color: var(--blue); box-shadow: 0 0 0 2px var(--blue-50); }
.file-pick { display: inline-block; padding: 8px 14px; border: 1px solid var(--gray-300); border-radius: var(--radius-lg); background: var(--gray-50); color: var(--gray-700); font-size: 13px; font-weight: 600; cursor: pointer; transition: var(--ease); }
.file-pick:hover { background: var(--blue-50); color: var(--blue-dark); border-color: var(--blue-100); }
#upload-btn { background: var(--gray-100); border: 1px solid var(--gray-200); color: var(--gray-700); border-radius: var(--radius-lg); padding: 8px 16px; cursor: pointer; font-size: 13px; font-weight: 600; transition: var(--ease); }
#upload-btn:hover { background: var(--blue); border-color: var(--blue); color: #fff; }
#upload-status { color: var(--gray-500); font-size: 12px; }
#proj-hint { color: #b06000; font-size: 12px; background: #fef7e0; border: 1px solid #fde293; border-radius: 8px; padding: 4px 10px; }
label.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
#composer { border-top: 1px solid var(--gray-200); background: #fff; padding: 14px 24px 18px; }
#composer-inner { max-width: 880px; margin: 0 auto; display: flex; gap: 10px; align-items: center; }
#input { flex: 1; border: 1px solid var(--gray-300); border-radius: 999px; padding: 11px 18px; font-size: 14px; outline: none; background: var(--gray-50); transition: var(--ease); color: var(--gray-900); }
#input:focus { border-color: var(--blue); background: #fff; box-shadow: 0 0 0 3px var(--blue-50); }
#input::placeholder { color: var(--gray-500); }
#send { background: var(--blue); color: #fff; border: none; border-radius: 999px; padding: 11px 24px; cursor: pointer; font-size: 14px; font-weight: 700; box-shadow: 0 1px 2px rgba(26,115,232,.4); transition: var(--ease); }
#send:hover:not(:disabled) { background: var(--blue-dark); box-shadow: var(--shadow-2); transform: translateY(-1px); }
#send:active:not(:disabled) { transform: translateY(0); }
#send:disabled { opacity: .45; cursor: not-allowed; }
#proj-drawer { position: fixed; top: 0; right: 0; height: 100vh; width: 460px; background: #fff; border-left: 1px solid var(--gray-200); box-shadow: -8px 0 32px rgba(60,64,67,.12); transform: translateX(100%); transition: transform .3s cubic-bezier(.2,0,0,1); z-index: 100; display: flex; flex-direction: column; }
#proj-drawer.open { transform: translateX(0); }
.drawer-head { height: 56px; padding: 0 20px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--gray-200); background: rgba(255,255,255,.95); backdrop-filter: blur(8px); flex-shrink: 0; }
.drawer-head h2 { font-size: 15px; font-weight: 900; letter-spacing: -.01em; margin: 0; color: var(--gray-900); flex: 1; }
.drawer-close { background: var(--gray-100); border: none; color: var(--gray-700); width: 32px; height: 32px; border-radius: 50%; cursor: pointer; font-size: 18px; line-height: 1; display: flex; align-items: center; justify-content: center; transition: var(--ease); }
.drawer-close:hover { background: var(--red-soft); color: var(--red); }
.drawer-body { flex: 1; display: flex; min-height: 0; }
.drawer-list { width: 38%; border-right: 1px solid var(--gray-100); overflow-y: auto; padding: 10px 8px; background: var(--gray-50); display: flex; flex-direction: column; gap: 2px; }
.drawer-item { padding: 10px 12px; border-radius: 10px; cursor: pointer; font-size: 13px; color: var(--gray-700); border: 1px solid transparent; transition: var(--ease); display: flex; align-items: center; gap: 8px; }
.drawer-item:hover { background: #fff; border-color: var(--gray-200); }
.drawer-item.active { background: #fff; border-color: var(--blue-100); color: var(--blue-dark); font-weight: 700; box-shadow: var(--shadow-1); }
.drawer-item .di-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.drawer-item .di-new { color: var(--blue); }
.drawer-item.di-create { border: 1px dashed var(--blue-100); background: var(--blue-50); color: var(--blue-dark); font-weight: 700; justify-content: center; gap: 4px; }
.drawer-item.di-create:hover { background: var(--blue-100); border-color: var(--blue); }
.drawer-form { flex: 1; overflow-y: auto; padding: 16px 20px; display: flex; flex-direction: column; gap: 10px; }
.drawer-form label { font-size: 11px; font-weight: 700; color: var(--gray-500); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 2px; display: block; }
.drawer-form input { width: 100%; padding: 8px 11px; border: 1px solid var(--gray-200); border-radius: 8px; font-size: 13px; color: var(--gray-900); background: #fff; transition: var(--ease); }
.drawer-form input:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 2px var(--blue-50); }
.drawer-form input:read-only { background: var(--gray-50); color: var(--gray-500); }
.drawer-form .field { display: flex; flex-direction: column; }
.drawer-form .drawer-hint { margin: 0 0 6px; }
.drawer-form details.advanced { border: 1px solid var(--gray-200); border-radius: 8px; padding: 8px 12px; background: var(--gray-50); }
.drawer-form details.advanced > summary { cursor: pointer; font-size: 12px; font-weight: 700; color: var(--gray-700); letter-spacing: .02em; }
.drawer-form details.advanced[open] { padding-bottom: 10px; }
.drawer-form details.advanced .field { margin-top: 8px; }
.drawer-form input.invalid { border-color: var(--red) !important; box-shadow: 0 0 0 2px var(--red-soft) !important; }
.drawer-actions { display: flex; gap: 8px; margin-top: 8px; }
.drawer-actions button { flex: 1; padding: 10px; border: none; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600; transition: var(--ease); }
.btn-save { background: var(--blue); color: #fff; }
.btn-save:hover { background: var(--blue-dark); }
.btn-delete { background: var(--red-soft); color: var(--red); border: 1px solid #f5c6c0 !important; }
.btn-delete:hover { background: var(--red); color: #fff; }
.drawer-hint { font-size: 11px; color: var(--gray-500); line-height: 1.5; padding: 8px 0; }
@keyframes auraPulse { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }
@keyframes zoomIn { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } }
@media (max-width: 860px) {
#sidebar { width: 220px; }
#sidebar.collapsed { width: 48px; }
#sid-badge { display: none; }
#proj-drawer { width: 100vw; }
}
```
- [ ] **Step 2: 创建 chat.js(机械迁移原内联 <script> 段)**
`chat.html:509-1213` 复制整段 `<script>...</script>`(含 `let sid = null;``})();`),粘贴到 `chat.js` 顶部,**不加任何 IIFE 包裹、不加任何修改**。原内联段本身是顶层作用域代码(不依赖 `DOMContentLoaded`,因为 `<script>` 放在 `<body>` 末尾、DOM 已就绪)。
```js
// src/genesis/server/static/chat.js
// v0 拆分骨架 - 从 chat.html 内联 <script> 段机械迁移;行为零变更
// v1 视觉/交互改造见后续 Task
// 整段从 chat.html:509-1213 复制(约 700 行):
let sid = null;
let progressWs = null;
// ... 原 chat.html 509-1213 行所有代码完整复制 ...
// 最后是 (async () => { ... })();
```
(实施时直接用 read 工具读原 chat.html 第 509-1213 行,写到 chat.js。)
- [ ] **Step 3: 修改 chat.html(删除内联 <style> 与 <script>,引用外部资源)**
`chat.html:1-15` 改为:
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Genesis — 概要设计书自动生成(对话)</title>
<link rel="stylesheet" href="/chat.css">
</head>
<body>
```
`chat.html:507-508``<script src="/chat_state.js">` / `chat_ws.js`)保持不变,将 `chat.html:509-1213`(整段内联 `<script>`)删除,并在 `chat.html:506``</div>` 之前)的合适位置追加:
```html
<script src="/chat_state.js"></script>
<script src="/chat_ws.js"></script>
<script src="/chat.js" defer></script>
</body>
</html>
```
**注意**`chat.js` 使用 `defer`,确保 DOM 解析完成后执行(虽然原内联段放在 body 末尾已等价,但 `defer` 保险)。`<script src="/chat_state.js">` 不加 `defer`(保持原顺序执行模式,向 `window.GenesisState` 注入)。
- [ ] **Step 4: 启动服务器并浏览器访问主页验证拆分无回归**
```bash
# 在项目根目录
python -m genesis.server.app
# 或查看 README.md 中实际的启动命令
```
预期:访问 `http://localhost:8000/`(或 README 中端口),看到与拆分前**视觉与交互完全一致**的页面(Material/Google 风、Inter 字体、蓝色 #1a73e8)。
- [ ] **Step 5: 手动验证清单**
依次跑通:
1. 页面加载,浏览器控制台无 JS 错误(无 404 chat.css / chat.js
2. 点击「折叠侧边栏」按钮 → 侧边栏变窄,会话名隐藏
3. 点击「项目」下拉 → 弹出菜单,能选项目
4. 输入「你好」→ 发送 → 用户气泡(蓝渐变)出现
5. 刷新页面 → 上次会话恢复(localStorage 生效)
- [ ] **Step 6: 提交**
```bash
git add src/genesis/server/static/chat.html \
src/genesis/server/static/chat.css \
src/genesis/server/static/chat.js
git commit -m "refactor(frontend): chat.html 拆分为 html + css + js(机械拆分,行为零变更)"
```
---
### Task 2: 视觉重塑 - chat.css 应用深色设计令牌 + 六大区域样式
**Files:**
- Modify: `src/genesis/server/static/chat.css`(替换 `:root` 令牌 + 全部样式)
**Interfaces:**
- Consumes: spec §一 颜色/字体/间距令牌、spec §二 六大区域视觉规约
- Produces: 重写后的 chat.css(深色主题),与原 chat.html 同样的 DOM 结构匹配
**注**: 本 Task 是一次性大改 chat.css。**chat.html 与 chat.js 不动**DOM id 全部保持,业务逻辑不变)。
- [ ] **Step 1: 替换 `:root` 令牌为 spec §一 深色版本**
```css
/* src/genesis/server/static/chat.css - v1 深色重塑 */
:root {
/* === 颜色 (spec §一) === */
--bg-base: #0B0F17;
--bg-surface: #141A24;
--bg-elevated: #1B2330;
--bg-input: #0F141C;
--border-subtle: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.14);
--text-primary: #E5EAF0;
--text-secondary: #8B95A7;
--text-muted: #5C6677;
--accent: #5EEAD4;
--accent-soft: rgba(94, 234, 212, 0.15);
--success: #34D399;
--warning: #FBBF24;
--danger: #F87171;
--info: #60A5FA;
/* === 字体 (系统字体栈,spec §一 Q2 决策) === */
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Microsoft YaHei", "Noto Sans CJK SC", system-ui, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
/* === 间距 (spec §一) === */
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px;
--sp-4: 16px; --sp-5: 20px; --sp-6: 24px;
/* === 圆角 (spec §一) === */
--r-sm: 6px; --r-md: 10px; --r-lg: 14px;
/* === 动效 (spec §一) === */
--ease: cubic-bezier(.2, 0, 0, 1);
--dur: .18s;
}
```
- [ ] **Step 2: 替换全局 + 滚动条样式**
```css
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
font-family: var(--font-ui);
margin: 0; height: 100vh; display: flex; flex-direction: row;
background: var(--bg-base); color: var(--text-primary);
-webkit-font-smoothing: antialiased;
font-size: 14px;
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 8px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
::-webkit-scrollbar-track { background: transparent; }
```
- [ ] **Step 3: 重写侧边栏样式(spec §2.1)**
```css
/* 侧边栏 */
#sidebar {
width: 264px; background: var(--bg-surface); color: var(--text-primary);
display: flex; flex-direction: column; flex-shrink: 0;
border-right: 1px solid var(--border-subtle);
transition: width .25s var(--ease);
position: relative;
}
#sidebar.collapsed { width: 56px; }
#sidebar.collapsed #sidebar-toggle { top: auto; bottom: 14px; right: auto; left: 14px; }
#sidebar.collapsed #session-list { padding-bottom: 52px; }
.brand {
display: flex; align-items: center; gap: 12px;
padding: 16px 12px 12px 16px; margin: 0 8px 8px;
border-bottom: 1px solid var(--border-subtle);
min-height: 64px;
}
#sidebar.collapsed .brand { padding: 16px 8px; justify-content: center; gap: 0; }
.brand-logo {
width: 36px; height: 36px; border-radius: 10px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
color: var(--bg-base); font-size: 17px; font-weight: 900;
background: var(--accent);
transition: transform var(--dur) var(--ease);
}
.brand:hover .brand-logo { transform: scale(1.1); box-shadow: 0 0 24px var(--accent-soft); }
.brand-name { font-size: 17px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; color: var(--text-primary); }
.brand-sub { display: block; font-size: 11px; font-weight: 500; color: var(--text-muted); margin-top: 2px; letter-spacing: 0; }
#sidebar.collapsed .brand-name { display: none; }
#sidebar-toggle {
position: absolute; top: 18px; right: -14px; z-index: 5;
width: 28px; height: 28px; border-radius: 50%;
background: var(--bg-elevated); border: 1px solid var(--border-strong);
color: var(--text-secondary); cursor: pointer; font-size: 14px; line-height: 1;
display: flex; align-items: center; justify-content: center;
transition: all var(--dur) var(--ease);
}
#sidebar-toggle:hover { color: var(--accent); border-color: var(--accent); }
#new-chat {
margin: var(--sp-2) var(--sp-4) var(--sp-3); background: var(--accent); color: var(--bg-base);
border: none; padding: 10px 14px; border-radius: var(--r-md);
cursor: pointer; font-size: 14px; font-weight: 700; letter-spacing: .01em;
transition: all var(--dur) var(--ease);
display: flex; align-items: center; justify-content: center; gap: 6px;
}
#new-chat:hover { box-shadow: 0 0 24px var(--accent-soft); transform: translateY(-1px); }
#new-chat:active { transform: translateY(0); }
#sidebar.collapsed #new-chat { margin: var(--sp-2) 10px var(--sp-3); padding: 10px 0; }
#new-chat .ic-plus { display: inline-block; font-weight: 900; }
#sidebar.collapsed #new-chat .nc-text { display: none; }
#sidebar h2 {
font-family: var(--font-mono);
font-size: 11px; font-weight: 500; letter-spacing: .12em; text-transform: uppercase;
color: var(--text-muted); margin: var(--sp-3) var(--sp-4) var(--sp-2);
}
#sidebar.collapsed h2 { display: none; }
#session-list { flex: 1; overflow-y: auto; padding: 0 8px 8px; }
.sess {
padding: 9px 12px; border-radius: var(--r-md); margin: 2px 0;
cursor: pointer; position: relative; border: 1px solid transparent;
transition: all var(--dur) var(--ease);
display: flex; align-items: center; gap: 6px;
}
.sess:hover { background: var(--bg-elevated); }
.sess:hover::before {
content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 2px;
background: var(--accent); border-radius: 0 2px 2px 0;
}
.sess.active { background: var(--bg-elevated); }
.sess.active::before {
content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 2px;
background: var(--accent); border-radius: 0 2px 2px 0;
}
.sess .info { flex: 1; min-width: 0; overflow: hidden; }
.sess .name { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-primary); }
.sess.active .name { font-weight: 700; }
.sess .meta { font-size: 11px; color: var(--text-muted); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: var(--font-mono); }
.sess .del {
flex-shrink: 0; opacity: 0; transition: opacity .15s ease;
background: transparent; border: none; color: var(--text-muted); cursor: pointer;
width: 24px; height: 24px; border-radius: 6px; font-size: 16px; line-height: 1;
display: flex; align-items: center; justify-content: center;
}
.sess:hover .del { opacity: .7; }
.sess .del:hover { opacity: 1 !important; background: var(--danger); color: var(--bg-base); }
#sidebar.collapsed .sess { justify-content: center; padding: 9px 6px; }
#sidebar.collapsed .sess .info, #sidebar.collapsed .sess .del { display: none; }
#sidebar.collapsed .sess .avatar {
display: flex; align-items: center; justify-content: center;
width: 8px; height: 8px; border-radius: 50%;
background: var(--text-muted);
}
.sess .avatar { display: none; }
#sidebar.collapsed .sess .avatar { display: flex; }
#sidebar.collapsed .sess.active .avatar { background: var(--accent); box-shadow: 0 0 8px var(--accent-soft); }
```
**注意**: 把 `.sess .avatar` 默认宽高从 28px 改为 8px(小圆点),折叠态使用此圆点;展开态 `.sess .avatar { display: none; }` 保持隐藏。颜色 hash 逻辑在 chat.js 中已存在(palette 数组),无需改 JS。
- [ ] **Step 4: 重写主区 + 顶栏样式(spec §2.2)**
```css
/* 主区 */
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg-base); }
header {
height: 56px; position: sticky; top: 0; z-index: 10;
padding: 0 var(--sp-6); display: flex; align-items: center; gap: 14px;
background: rgba(20, 26, 36, 0.78); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-subtle);
}
header h1 { font-size: 16px; font-weight: 700; letter-spacing: -.02em; margin: 0; color: var(--text-primary); }
/* 项目切换器 */
.proj-switcher { position: relative; }
.ps-current {
display: flex; align-items: center; gap: 8px;
background: transparent; border: 1px solid var(--border-strong);
border-radius: var(--r-md); padding: 7px 12px; cursor: pointer;
font-size: 13px; font-weight: 500; color: var(--text-primary);
transition: all var(--dur) var(--ease);
}
.ps-current:hover { border-color: var(--accent); }
.ps-current .ps-label { font-family: var(--font-mono); font-size: 10px; font-weight: 500; letter-spacing: .12em; text-transform: uppercase; color: var(--text-muted); }
.ps-current .ps-name { max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ps-current .ps-caret { font-size: 10px; color: var(--text-muted); }
.ps-menu {
position: absolute; top: calc(100% + 6px); left: 0; min-width: 240px;
background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--r-md);
box-shadow: 0 8px 32px rgba(0,0,0,.4); padding: 6px; z-index: 20;
display: none;
}
.ps-menu.open { display: block; animation: zoomIn .2s var(--ease); }
.ps-item {
padding: 8px 12px; border-radius: var(--r-sm); cursor: pointer; font-size: 13px; color: var(--text-secondary);
display: flex; align-items: center; gap: 8px; transition: background .15s var(--ease);
}
.ps-item:hover { background: var(--bg-elevated); color: var(--text-primary); }
.ps-item.active { background: var(--bg-elevated); color: var(--accent); font-weight: 700; }
.ps-item .ps-check { margin-left: auto; color: var(--accent); font-weight: 900; opacity: 0; }
.ps-item.active .ps-check { opacity: 1; }
.ps-sep { height: 1px; background: var(--border-subtle); margin: 4px 6px; }
.ps-manage { color: var(--accent); font-weight: 600; }
#sid-badge {
margin-left: auto;
background: var(--bg-elevated); color: var(--text-primary);
border: 1px solid var(--border-subtle);
border-radius: 999px; padding: 4px 14px; font-size: 12px; font-weight: 500;
max-width: 32%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
font-family: var(--font-mono);
}
```
- [ ] **Step 5: 重写 RAG 条样式(spec §2.2 RAG 胶囊)**
```css
/* RAG 控制条 */
#rag-bar {
display: flex; align-items: center; gap: 8px;
background: transparent; border: 1px solid var(--border-subtle);
border-radius: 999px; padding: 3px 10px;
font-size: 12px; color: var(--text-secondary);
font-family: var(--font-mono);
}
#rag-bar label.rag-switch {
display: inline-flex; align-items: center; gap: 6px;
cursor: pointer; user-select: none;
}
#rag-bar input[type=checkbox] {
width: 14px; height: 14px; accent-color: var(--accent);
margin: 0;
}
#rag-bar input[type=checkbox]:disabled + span { color: var(--text-muted); cursor: not-allowed; }
#rag-bar .rag-stats { color: var(--text-muted); }
#rag-bar .rag-stats.ready { color: var(--accent); font-weight: 600; }
#rag-bar .rag-stats.zero { color: var(--text-muted); }
#rag-bar button {
background: var(--accent); color: var(--bg-base); border: none;
border-radius: 999px; padding: 4px 12px; font-size: 12px; font-weight: 700;
cursor: pointer; transition: all var(--dur) var(--ease);
}
#rag-bar button:disabled { background: var(--bg-elevated); color: var(--text-muted); cursor: not-allowed; }
#rag-bar button:hover:not(:disabled) { box-shadow: 0 0 16px var(--accent-soft); }
```
- [ ] **Step 6: 重写聊天区 + 气泡(spec §2.3)**
```css
/* 聊天区 */
#chat { flex: 1; overflow-y: auto; padding: var(--sp-6) var(--sp-6) var(--sp-6); max-width: 880px; width: 100%; margin: 0 auto; }
.msg { display: flex; margin: var(--sp-3) 0; animation: zoomIn .25s var(--ease); position: relative; }
.msg .bubble {
max-width: 74%; padding: 11px 16px; border-radius: var(--r-lg);
white-space: pre-wrap; line-height: 1.65; font-size: 14px; letter-spacing: .01em;
}
.msg.user { justify-content: flex-end; }
.msg.user .bubble {
background: var(--bg-surface);
color: var(--text-primary);
border-bottom-right-radius: 6px;
border-left: 2px solid var(--accent);
padding-left: 18px; /* 抵消左边框占用的空间,避免文字偏移 */
}
.msg.assistant { align-items: flex-start; }
.msg.assistant .bubble {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-bottom-left-radius: 6px;
color: var(--text-primary);
}
.msg.assistant::before {
content: ""; width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
margin: 18px 10px 0 0; background: var(--accent);
box-shadow: 0 0 8px var(--accent-soft);
}
.msg.progress .bubble {
background: var(--bg-input); border: 1px dashed var(--accent-soft);
font-size: 13px; color: var(--text-secondary);
font-family: var(--font-mono);
}
.msg.error .bubble {
background: rgba(248, 113, 113, 0.12); border: 1px solid var(--danger); color: var(--danger);
}
.progress-item { display: block; }
.progress-item.ok::before { content: "✔ "; color: var(--success); font-weight: 700; }
.progress-item.warn::before { content: "⚠ "; color: var(--warning); font-weight: 700; }
.actions { margin-top: 8px; }
.actions a {
display: inline-block; margin: 4px 6px 0 0; background: var(--accent); color: var(--bg-base);
padding: 6px 14px; border-radius: 999px; text-decoration: none; font-size: 13px; font-weight: 700;
transition: all var(--dur) var(--ease);
}
.actions a:hover { box-shadow: 0 0 16px var(--accent-soft); transform: translateY(-1px); }
.typing { color: var(--text-muted); font-size: 13px; animation: auraPulse 1.4s ease-in-out infinite; font-family: var(--font-mono); }
```
- [ ] **Step 7: 重写上传条样式(spec §2.4)**
```css
/* 上传条 */
#upload-bar { background: var(--bg-surface); border-top: 1px solid var(--border-subtle); padding: var(--sp-3) var(--sp-6); font-size: 13px; }
#upload-bar-inner { max-width: 880px; margin: 0 auto; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
#upload-bar select {
font-size: 13px; padding: 8px 12px; border: 1px solid var(--border-strong); border-radius: var(--r-md);
background: var(--bg-input); color: var(--text-primary); cursor: pointer; outline: none;
transition: all var(--dur) var(--ease);
font-family: var(--font-ui);
}
#upload-bar select:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.file-pick {
display: inline-block; padding: 8px 14px; border: 1px solid var(--border-strong); border-radius: var(--r-md);
background: var(--bg-input); color: var(--text-secondary); font-size: 13px; font-weight: 500;
cursor: pointer; transition: all var(--dur) var(--ease);
}
.file-pick:hover { border-color: var(--accent); color: var(--accent); }
#upload-btn {
background: transparent; border: 1px solid var(--border-strong); color: var(--text-primary);
border-radius: var(--r-md); padding: 8px 16px; cursor: pointer; font-size: 13px; font-weight: 600;
transition: all var(--dur) var(--ease);
}
#upload-btn:hover { background: var(--accent); border-color: var(--accent); color: var(--bg-base); }
#upload-status { color: var(--text-muted); font-size: 12px; font-family: var(--font-mono); }
#proj-hint {
color: var(--warning); font-size: 12px; background: rgba(251,191,36,.1); border: 1px solid var(--warning);
border-left-width: 4px; border-radius: var(--r-sm); padding: 4px 10px;
}
label.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
```
- [ ] **Step 8: 重写输入区样式(spec §2.5)**
```css
/* 输入区 */
#composer { border-top: 1px solid var(--border-subtle); background: var(--bg-surface); padding: var(--sp-4) var(--sp-6) var(--sp-5); }
#composer-inner { max-width: 880px; margin: 0 auto; display: flex; gap: 10px; align-items: flex-end; }
#input {
flex: 1; border: 1px solid var(--border-strong); border-radius: var(--r-md);
padding: 11px 14px; font-size: 14px; outline: none; background: var(--bg-input);
transition: all var(--dur) var(--ease); color: var(--text-primary);
font-family: var(--font-ui);
resize: none; min-height: 42px; max-height: 160px; line-height: 1.5;
}
#input:focus { border-color: var(--accent); background: var(--bg-elevated); box-shadow: 0 0 0 2px var(--accent-soft); }
#input::placeholder { color: var(--text-muted); }
#send {
background: var(--accent); color: var(--bg-base); border: none; border-radius: var(--r-md);
padding: 11px 24px; cursor: pointer; font-size: 14px; font-weight: 700;
transition: all var(--dur) var(--ease);
min-height: 42px;
}
#send:hover:not(:disabled) { box-shadow: 0 0 24px var(--accent-soft); transform: translateY(-1px); }
#send:active:not(:disabled) { transform: translateY(0); }
#send:disabled { opacity: .3; cursor: not-allowed; }
```
- [ ] **Step 9: 重写项目抽屉样式(spec §2.6)**
```css
/* 项目抽屉 */
#proj-drawer {
position: fixed; top: 0; right: 0; height: 100vh; width: 460px;
background: var(--bg-surface); border-left: 1px solid var(--border-subtle);
box-shadow: -8px 0 32px rgba(0,0,0,.5);
transform: translateX(100%); transition: transform .3s var(--ease);
z-index: 100; display: flex; flex-direction: column;
}
#proj-drawer.open { transform: translateX(0); }
.drawer-head {
height: 56px; padding: 0 20px; display: flex; align-items: center; gap: 12px;
border-bottom: 1px solid var(--border-subtle); background: var(--bg-surface);
flex-shrink: 0;
}
.drawer-head h2 { font-size: 15px; font-weight: 700; letter-spacing: -.01em; margin: 0; color: var(--text-primary); flex: 1; }
.drawer-close {
background: transparent; border: 1px solid var(--border-subtle); color: var(--text-secondary);
width: 32px; height: 32px; border-radius: 50%; cursor: pointer;
font-size: 18px; line-height: 1; display: flex; align-items: center; justify-content: center;
transition: all var(--dur) var(--ease);
}
.drawer-close:hover { background: var(--danger); border-color: var(--danger); color: var(--bg-base); }
.drawer-body { flex: 1; display: flex; min-height: 0; }
.drawer-list {
width: 38%; border-right: 1px solid var(--border-subtle); overflow-y: auto;
padding: 10px 8px; background: var(--bg-base);
display: flex; flex-direction: column; gap: 2px;
}
.drawer-item {
padding: 10px 12px; border-radius: var(--r-sm); cursor: pointer; font-size: 13px;
color: var(--text-secondary); border: 1px solid transparent;
transition: all var(--dur) var(--ease);
display: flex; align-items: center; gap: 8px; position: relative;
}
.drawer-item:hover { background: var(--bg-elevated); color: var(--text-primary); }
.drawer-item.active {
background: var(--bg-elevated); color: var(--text-primary); font-weight: 700;
}
.drawer-item.active::before {
content: ""; position: absolute; left: 0; top: 6px; bottom: 6px; width: 2px;
background: var(--accent); border-radius: 0 2px 2px 0;
}
.drawer-item .di-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.drawer-item .di-new { color: var(--accent); }
.drawer-item.di-create {
border: 1px dashed var(--border-strong); background: transparent;
color: var(--accent); font-weight: 700; justify-content: center; gap: 4px;
}
.drawer-item.di-create:hover { background: var(--accent-soft); border-color: var(--accent); }
.drawer-form { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5); display: flex; flex-direction: column; gap: 10px; }
.drawer-form label {
font-family: var(--font-mono);
font-size: 11px; font-weight: 500; color: var(--text-muted);
text-transform: uppercase; letter-spacing: .1em; margin-bottom: 2px; display: block;
}
.drawer-form input {
width: 100%; padding: 8px 11px; border: 1px solid var(--border-strong); border-radius: var(--r-sm);
font-size: 13px; color: var(--text-primary); background: var(--bg-input);
transition: all var(--dur) var(--ease);
font-family: var(--font-ui);
}
.drawer-form input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.drawer-form input:read-only { background: var(--bg-base); color: var(--text-muted); }
.drawer-form .field { display: flex; flex-direction: column; }
.drawer-form .drawer-hint { margin: 0 0 6px; }
.drawer-form details.advanced { border: 1px solid var(--border-subtle); border-radius: var(--r-sm); padding: 8px 12px; }
.drawer-form details.advanced > summary { cursor: pointer; font-size: 12px; font-weight: 600; color: var(--text-secondary); letter-spacing: .02em; }
.drawer-form details.advanced[open] { padding-bottom: 10px; }
.drawer-form details.advanced .field { margin-top: 8px; }
.drawer-form input.invalid { border-color: var(--danger) !important; box-shadow: 0 0 0 2px rgba(248,113,113,.25) !important; }
.drawer-actions { display: flex; gap: 8px; margin-top: 8px; }
.drawer-actions button { flex: 1; padding: 10px; border: none; border-radius: var(--r-sm); cursor: pointer; font-size: 13px; font-weight: 600; transition: all var(--dur) var(--ease); }
.btn-save { background: var(--accent); color: var(--bg-base); }
.btn-save:hover { box-shadow: 0 0 16px var(--accent-soft); }
.btn-delete { background: transparent; color: var(--danger); border: 1px solid var(--danger) !important; }
.btn-delete:hover { background: var(--danger); color: var(--bg-base); }
.drawer-hint { font-size: 11px; color: var(--text-muted); line-height: 1.5; padding: 8px 0; }
```
- [ ] **Step 10: 重写动效 + 响应式断点(spec §1.4)**
```css
@keyframes auraPulse { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }
@keyframes zoomIn { from { opacity: 0; transform: scale(.98); } to { opacity: 1; transform: scale(1); } }
@media (max-width: 860px) {
#sidebar { width: 220px; }
#sidebar.collapsed { width: 48px; }
#sid-badge { max-width: 24%; }
#proj-drawer { width: 100vw; }
}
```
- [ ] **Step 11: 浏览器验证视觉重塑**
启动服务器,访问主页,验证:
1. 整体配色变深(深空灰底 + 白色文字)
2. 强调色全部变青绿 `#5EEAD4`
3. 字体回退到系统字体(中文用 PingFang/Microsoft YaHei
4. 六大区域(侧边栏/顶栏/聊天/上传/输入/抽屉)全部按新样式渲染
5. 业务功能不破:侧边栏折叠、项目切换、会话创建、消息发送、上传
- [ ] **Step 12: 提交**
```bash
git add src/genesis/server/static/chat.css
git commit -m "feat(frontend): 视觉重塑 - 深色指挥中心 + 文档工作台混合风格"
```
---
### Task 3: 交互改造 - 会话列表按时间分组(spec §4.1)
**Files:**
- Modify: `src/genesis/server/static/chat.js`(在 `refreshSessions` 内分组)
- Modify: `src/genesis/server/static/chat.css`(加 `.sess-group` 样式)
- [ ] **Step 1: chat.css 新增 `.sess-group` 样式**
`.sess .avatar` 块之后追加:
```css
.sess-group {
font-family: var(--font-mono);
font-size: 11px; font-weight: 500; letter-spacing: .12em;
color: var(--text-muted);
padding: var(--sp-3) 12px var(--sp-1);
text-transform: uppercase;
cursor: default;
user-select: none;
}
#sidebar.collapsed .sess-group { display: none; }
```
- [ ] **Step 2: chat.js 改造 `refreshSessions` 增加分组逻辑**
`chat.js` 中找到 `refreshSessions` 函数(约原 chat.html:561 行),把 `list.sort(...)` 之后、`for (const s of list)` 之前替换为以下分组版本(保留原有 `.sess` DOM 创建逻辑,仅在循环外加分组标题插入):
```js
async function refreshSessions() {
const box = document.getElementById('session-list');
if (draftProject === null) { box.innerHTML = ''; return; }
const list = await api('GET', '/api/sessions?user_id=default&project=' + encodeURIComponent(draftProject));
box.innerHTML = '';
list.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''));
// === 新增: 按 updated_at 字符串前缀(YYYY-MM-DD)分组 ===
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1);
const weekAgo = new Date(today); weekAgo.setDate(today.getDate() - 7);
const monthAgo = new Date(today); monthAgo.setDate(today.getDate() - 30);
function bucketOf(s) {
const t = s.updated_at ? new Date(s.updated_at) : null;
if (!t || isNaN(t.getTime())) return 'older';
if (t >= today) return 'today';
if (t >= yesterday) return 'yesterday';
if (t >= weekAgo) return 'thisWeek';
if (t >= monthAgo) return 'thisMonth';
return 'older';
}
const BUCKET_LABEL = { today: '今天', yesterday: '昨天', thisWeek: '本周', thisMonth: '本月', older: '更早' };
const BUCKET_ORDER = ['today', 'yesterday', 'thisWeek', 'thisMonth', 'older'];
let lastBucket = null;
for (const s of list) {
const bucket = bucketOf(s);
if (bucket !== lastBucket) {
const grp = document.createElement('div');
grp.className = 'sess-group';
grp.textContent = BUCKET_LABEL[bucket] || bucket;
box.appendChild(grp);
lastBucket = bucket;
}
// === 原有 .sess 创建逻辑(从原 chat.html:567-589 完整复制)===
const el = document.createElement('div');
el.className = 'sess' + (s.session_id === sid ? ' active' : '');
const proj = s.project ? ' · ' + esc(s.project) : '';
const rawName = (s.name || '新').trim();
const avatar = rawName ? Array.from(rawName)[0] : '新';
const palette = ['#5EEAD4', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#22D3EE', '#8B95A7'];
let h = 0;
for (let i = 0; i < rawName.length; i++) h = (h * 31 + rawName.charCodeAt(i)) | 0;
const color = palette[Math.abs(h) % palette.length];
el.innerHTML =
'<div class="avatar" style="background:' + color + ';"></div>' +
'<div class="info">' +
'<div class="name">' + esc(s.name || '新会话') + '</div>' +
'<div class="meta">' + esc(s.status) + proj + '</div>' +
'</div>' +
'<button class="del" title="删除会话" aria-label="删除会话 ' + esc(rawName) + '">×</button>';
el.querySelector('.info').onclick = () => loadSession(s.session_id);
const delBtn = el.querySelector('.del');
delBtn.onclick = (ev) => { ev.stopPropagation(); deleteSession(s.session_id); };
box.appendChild(el);
}
}
```
- [ ] **Step 3: 验证**
启动服务器,浏览器访问:
1. 选中一个项目(确保有 ≥1 个会话)→ 侧边栏会话列表出现
2. 若历史会话的 `updated_at` 跨多天 → 应出现分组标题「今天 / 昨天 / 本周 / 本月 / 更早」
3. 折叠侧边栏 → 分组标题隐藏
4.`updated_at` 的会话会落入「更早」组,不报错
- [ ] **Step 4: 提交**
```bash
git add src/genesis/server/static/chat.js src/genesis/server/static/chat.css
git commit -m "feat(chat): 会话列表按时间分组(今天/昨天/本周/本月/更早)"
```
---
### Task 4: 交互改造 - 拖拽上传(spec §4.2)
**Files:**
- Modify: `src/genesis/server/static/chat.js`(新增 `bindUploadDrag` 函数 + 调用)
- Modify: `src/genesis/server/static/chat.css`(加 `#upload-bar.drag-active`
- [ ] **Step 1: chat.css 新增拖拽激活样式**
`#upload-bar` 块之后追加:
```css
#upload-bar.drag-active {
background: var(--accent-soft);
border-top: 1px dashed var(--accent);
border-bottom: 1px dashed var(--accent);
}
#upload-bar.drag-active #upload-bar-inner { color: var(--accent); }
```
- [ ] **Step 2: chat.js 新增 `bindUploadDrag` 函数并调用**
`chat.js` 启动 IIFE`(async () => {` 之前)追加:
```js
function bindUploadDrag() {
const bar = document.getElementById('upload-bar');
if (!bar || !('draggable' in document.createElement('div'))) return;
let depth = 0; // 避免子元素 dragenter/dragleave 闪烁
bar.addEventListener('dragenter', (e) => {
e.preventDefault(); depth++;
bar.classList.add('drag-active');
});
bar.addEventListener('dragover', (e) => { e.preventDefault(); });
bar.addEventListener('dragleave', (e) => {
depth = Math.max(0, depth - 1);
if (depth === 0) bar.classList.remove('drag-active');
});
bar.addEventListener('drop', (e) => {
e.preventDefault();
depth = 0;
bar.classList.remove('drag-active');
const items = e.dataTransfer.items;
if (items && items.length && items[0].webkitGetAsEntry) {
const entry = items[0].webkitGetAsEntry();
if (entry && entry.isDirectory) {
addWarn('请拖入单个文件,不要拖入文件夹');
return;
}
}
const file = e.dataTransfer.files && e.dataTransfer.files[0];
if (!file) { addWarn('未识别到文件'); return; }
// 按后缀自动选 file-type
const ext = (file.name.match(/\.[^.]+$/) || [''])[0].toLowerCase();
const sel = document.getElementById('file-type');
if (ext === '.xlsx' && sel.querySelector('option[value="requirements"]')) sel.value = 'requirements';
else if (ext === '.docx' && sel.querySelector('option[value="template"]')) sel.value = 'template';
else if (ext === '.zip' && sel.querySelector('option[value="existing_system"]')) sel.value = 'existing_system';
// 写入 #file-input.files
const input = document.getElementById('file-input');
try {
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
} catch (err) {
addWarn('该浏览器不支持拖拽设置文件,请使用「选择文件」按钮');
}
});
}
```
在启动 IIFE 内部最前面(`loadProjects()` 之前)调用 `bindUploadDrag();`
- [ ] **Step 3: 验证**
启动服务器,浏览器:
1. 拖一个 `.xlsx` 文件到上传条 → 出现 `accent` 虚线高亮
2. 拖入完成 → `file-type` 自动选 `requirements`;文件名出现在 file-input
3. 点击「上传」按钮 → 走原有上传逻辑成功
4. 拖一个 `.docx` → 自动选 `template`
5. 拖一个 `.zip` → 自动选 `existing_system`
6. 拖入文件夹 → 出现「请拖入单个文件」warn 提示
- [ ] **Step 4: 提交**
```bash
git add src/genesis/server/static/chat.js src/genesis/server/static/chat.css
git commit -m "feat(chat): 拖拽上传 + 按后缀自动选 file-type"
```
---
### Task 5: 交互改造 - 消息气泡复制/引用(spec §4.3)
**Files:**
- Modify: `src/genesis/server/static/chat.js`(扩展 `addMsg` + 新增 `attachBubbleOps`
- Modify: `src/genesis/server/static/chat.css`(加 `.msg .ops`
**注**: **不实现"重新生成"按钮**spec §零-9 决策)。
- [ ] **Step 1: chat.css 新增气泡操作按钮样式**
```css
.msg .ops {
position: absolute; top: -10px; right: 8px;
display: flex; gap: 4px; opacity: 0;
transition: opacity .15s var(--ease);
z-index: 2;
}
.msg:hover .ops { opacity: 1; }
.msg .ops button {
background: var(--bg-elevated);
border: 1px solid var(--border-strong);
color: var(--text-secondary);
font-size: 12px; padding: 2px 8px;
border-radius: var(--r-sm); cursor: pointer;
font-family: var(--font-mono);
transition: all var(--dur) var(--ease);
}
.msg .ops button:hover { color: var(--accent); border-color: var(--accent); }
```
- [ ] **Step 2: chat.js 扩展 `addMsg` 接收纯文本 + 在 `addMsg` 末尾追加操作按钮**
找到 `addMsg` 函数(原 chat.html:540 行附近):
```js
function addMsg(role, html) {
const m = document.createElement('div');
m.className = 'msg ' + role;
m.innerHTML = '<div class="bubble">' + html + '</div>';
chatEl.appendChild(m);
chatEl.scrollTop = chatEl.scrollHeight;
return m;
}
```
替换为:
```js
function addMsg(role, html, text) {
// text: 纯文本(用于复制/引用)。若不传,则尝试从 html 反推纯文本
const m = document.createElement('div');
m.className = 'msg ' + role;
m.innerHTML = '<div class="bubble">' + html + '</div>';
// 操作按钮
if (role === 'assistant' || role === 'user') {
const ops = document.createElement('div');
ops.className = 'ops';
const cleanText = String(text != null ? text : m.querySelector('.bubble').innerText);
if (role === 'assistant') {
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.textContent = '⧉ 复制';
copyBtn.title = '复制消息内容';
copyBtn.onclick = () => copyToClipboard(cleanText).then(
() => { copyBtn.textContent = '✓ 已复制'; setTimeout(() => copyBtn.textContent = '⧉ 复制', 1500); },
() => { addWarn('复制失败,请手动选中复制'); }
);
ops.appendChild(copyBtn);
} else if (role === 'user') {
const quoteBtn = document.createElement('button');
quoteBtn.type = 'button';
quoteBtn.textContent = '↺ 引用';
quoteBtn.title = '把这条消息填到输入框';
quoteBtn.onclick = () => {
const inputEl = document.getElementById('input');
inputEl.value = cleanText;
inputEl.focus();
autoResizeTextarea();
};
ops.appendChild(quoteBtn);
}
m.appendChild(ops);
}
chatEl.appendChild(m);
chatEl.scrollTop = chatEl.scrollHeight;
return m;
}
async function copyToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
}
// fallback: 临时 textarea + execCommand
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
const ok = document.execCommand('copy');
document.body.removeChild(ta);
if (!ok) throw new Error('execCommand returned false');
} catch (e) {
document.body.removeChild(ta);
throw e;
}
}
```
- [ ] **Step 3: 改造所有 `addMsg` 调用点传纯文本**
`chat.js` 中找出所有 `addMsg(role, ...)` 调用,把第二个 html 参数后再传第三个 `text` 参数(纯文本):
| 调用位置 | 改造 |
|---|---|
| `addMsg('assistant', GenesisState.buildWelcome(draftProject));` | `addMsg('assistant', esc(GenesisState.buildWelcome(draftProject)), GenesisState.buildWelcome(draftProject));` |
| `addMsg(role, text)``applyProgressEvent` 回调中 | 已经是 `text` 纯文本,直接加第三参数 `text` |
| `addMsg('assistant', '项目「' + ... + '」已保存。');` | `addMsg('assistant', esc('项目「' + ... + '」已保存。'), '项目「' + ... + '」已保存。');` |
| `addMsg('assistant', '项目「' + deleted + '」已删除。');` | 同上模式 |
| `addMsg('error', '...')` | 不加 `text`(error 不显示复制/引用按钮,行为不变)|
| `addMsg('progress', ...)` | 不加 `text` |
| `addMsg('user', esc(text))``send()` 中 | `addMsg('user', esc(text), text)` |
| `addMsg('user', '[上传] ...')` | `addMsg('user', esc('[上传] ...'), '[上传] ...')` |
| `addMsg(role, esc(m.content))``loadSession` 循环中 | `addMsg(role, esc(m.content), m.content)` |
**注意**: 调用点涉及多处,本 Step 用 `grep` 工具列出所有 `addMsg(` 调用,逐一修改。
- [ ] **Step 4: 验证**
启动服务器,浏览器:
1. 发送一条消息 → 鼠标 hover 用户气泡 → 右上角浮出 `↺ 引用` 按钮
2. 点击 `↺ 引用` → 输入框出现该消息文本 + 焦点在输入框
3. 收到助手回复 → hover → 浮出 `⧉ 复制` 按钮
4. 点击 `⧉ 复制` → 按钮短暂变 `✓ 已复制`,粘贴验证文本正确
5. 进度/错误消息无操作按钮(hover 也不显示)
- [ ] **Step 5: 提交**
```bash
git add src/genesis/server/static/chat.js src/genesis/server/static/chat.css
git commit -m "feat(chat): 消息气泡支持复制/引用(hover 浮出按钮)"
```
---
### Task 6: 交互改造 - 输入区多行 + 自动高度(spec §4.4)
**Files:**
- Modify: `src/genesis/server/static/chat.html``<input id="input">``<textarea>`
- Modify: `src/genesis/server/static/chat.js`(新增 `autoResizeTextarea` + 改造 keydown
- Modify: `src/genesis/server/static/chat.css`(已完成 Task 2 Step 8`#input` 已是 textarea 样式,含 `min-height/max-height/resize:none`
- [ ] **Step 1: 修改 chat.html**
`<input id="input" placeholder="..." autocomplete="off" maxlength="2000">` 改为:
```html
<textarea id="input" rows="1" placeholder="输入指令,如:上传了文件,生成概要设计书 / 现在什么状态?" autocomplete="off" maxlength="2000"></textarea>
```
- [ ] **Step 2: chat.js 新增 `autoResizeTextarea` + 改造 keydown 监听**
`chat.js` 顶部 IIFE 内(`bindUploadDrag` 之后)新增:
```js
function autoResizeTextarea() {
const el = document.getElementById('input');
if (!el || el.tagName !== 'TEXTAREA') return;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 160) + 'px';
}
function bindComposer() {
const el = document.getElementById('input');
if (!el) return;
if (el.tagName === 'TEXTAREA') {
el.addEventListener('input', autoResizeTextarea);
// Enter 发送(Shift+Enter 换行;IME 组合中不发送)
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
e.preventDefault();
send();
}
});
autoResizeTextarea();
}
// 保留原有 send 按钮点击监听(chat.js 末尾已有的 document.getElementById('send').addEventListener('click', send); 不变)
}
```
在启动 IIFE 内 `bindUploadDrag();` 之后调用 `bindComposer();`
**注**: 原 chat.html:1011 已有 `inputEl.addEventListener('keydown', e => { if (e.key === 'Enter') send(); });`,如果 input 现在是 textarea,这段代码仍会触发(textarea 也支持 keydown),但是会**抢在 Shift+Enter 时也发送**。需要把这段**替换**为上面 `bindComposer` 里的逻辑;或者保留并**在 bindComposer 内移除旧监听**
简单方案:在 `bindComposer``el.removeEventListener('keydown', oldHandler)` 不容易(旧 handler 是匿名)。改为在 chat.js 中找到原 `inputEl.addEventListener('keydown', ...);`(约 chat.html:1011),**整行删除**,让 `bindComposer` 内的 keydown 监听接管。
- [ ] **Step 3: 验证**
启动服务器,浏览器:
1. 点击输入框 → 输入多行(按 Shift+Enter 换行)→ 文本框自动撑高(最大 160px)
2. 按 Enter(无 Shift)→ 消息发送,文本框重置为 1 行
3. 中文输入法组合中按 Enter → 不发送,走组合确认逻辑
4. `maxlength=2000` 仍生效
- [ ] **Step 4: 提交**
```bash
git add src/genesis/server/static/chat.html src/genesis/server/static/chat.js
git commit -m "feat(chat): 输入区多行 textarea + 自动高度(Enter 发送 / Shift+Enter 换行)"
```
---
### Task 7: 交互改造 - 顶栏项目不匹配徽章化(spec §4.5)
**Files:**
- Modify: `src/genesis/server/static/chat.html`(在 `#rag-bar` 前插入 `#proj-mismatch-badge`
- Modify: `src/genesis/server/static/chat.js`(改造 `renderProjectMismatchHint` → 调用新徽章渲染)
- Modify: `src/genesis/server/static/chat.css`(加 `.mismatch-badge` 样式 + popover
- [ ] **Step 1: chat.html 插入徽章 DOM**
找到 `<div id="rag-bar" hidden>` 这一行,**之前**插入:
```html
<span id="proj-mismatch-badge" class="mismatch-badge" hidden><span id="mismatch-text"></span></span>
```
- [ ] **Step 2: chat.css 新增徽章 + popover 样式**
```css
.mismatch-badge {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 10px;
border: 1px solid var(--warning);
border-radius: var(--r-md);
color: var(--warning);
font-size: 12px; font-weight: 600;
cursor: pointer;
transition: all var(--dur) var(--ease);
position: relative;
}
.mismatch-badge:hover { background: rgba(251,191,36,.1); }
.mismatch-popover {
position: absolute; top: calc(100% + 6px); right: 0;
min-width: 240px; max-width: 320px;
background: var(--bg-surface);
border: 1px solid var(--border-strong);
border-radius: var(--r-md);
box-shadow: 0 8px 32px rgba(0,0,0,.5);
padding: 10px 12px;
z-index: 30;
display: none;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
.mismatch-popover.open { display: block; animation: zoomIn .2s var(--ease); }
.mismatch-popover .mp-actions { display: flex; gap: 6px; margin-top: 8px; }
.mismatch-popover .mp-actions button {
flex: 1; padding: 6px 10px;
background: transparent;
border: 1px solid var(--border-strong);
color: var(--text-primary);
border-radius: var(--r-sm);
font-size: 12px; font-weight: 600; cursor: pointer;
transition: all var(--dur) var(--ease);
}
.mismatch-popover .mp-actions button:hover { border-color: var(--accent); color: var(--accent); }
.mismatch-popover .mp-actions button.primary { background: var(--accent); color: var(--bg-base); border-color: var(--accent); }
.mismatch-popover .mp-actions button.primary:hover { box-shadow: 0 0 16px var(--accent-soft); }
```
- [ ] **Step 3: chat.js 改造 `renderProjectMismatchHint` 为徽章渲染**
找到原 `renderProjectMismatchHint` 函数(约原 chat.html:912 行),整段**替换**为:
```js
function renderProjectMismatchHint() {
const badge = document.getElementById('proj-mismatch-badge');
const text = document.getElementById('mismatch-text');
if (!badge || !text) return;
if (!sid || !activeProject || activeProject === draftProject) {
badge.hidden = true;
badge.replaceChildren(); // 清空 popover
return;
}
let skipped = {};
try { skipped = JSON.parse(sessionStorage.getItem('genesis_skipped_project_hint') || '{}'); } catch (e) { skipped = {}; }
if (skipped[sid] === activeProject) {
badge.hidden = true;
return;
}
// 显示徽章
text.textContent = '项目不匹配:' + activeProject + ' ≠ ' + (draftProject || '(未选择)');
badge.hidden = false;
badge.onclick = (e) => {
e.stopPropagation();
// 构建/切换 popover
let pop = badge.querySelector('.mismatch-popover');
if (!pop) {
pop = document.createElement('div');
pop.className = 'mismatch-popover';
pop.innerHTML =
'<div>当前会话属于 <strong style="color:var(--accent)">' + esc(activeProject) + '</strong>' +
'顶栏项目是 <strong style="color:var(--warning)">' + esc(draftProject || '(未选择)') + '</strong>。</div>' +
'<div class="mp-actions">' +
'<button class="primary" data-act="switch">切到该项目</button>' +
'<button data-act="keep">保留当前项目</button></div>';
badge.appendChild(pop);
pop.querySelector('[data-act="switch"]').onclick = () => {
draftProject = activeProject;
applyProjectContext();
renderProjectSwitcher();
updateUploadBar();
refreshSessions();
badge.hidden = true;
badge.replaceChildren();
};
pop.querySelector('[data-act="keep"]').onclick = () => {
try {
skipped[sid] = activeProject;
sessionStorage.setItem('genesis_skipped_project_hint', JSON.stringify(skipped));
} catch (er) { /* ignore */ }
badge.hidden = true;
badge.replaceChildren();
};
}
pop.classList.toggle('open');
};
// 点击外部关闭 popover
document.addEventListener('click', function _closeMismatch(e) {
if (!badge.contains(e.target)) {
const p = badge.querySelector('.mismatch-popover');
if (p) p.classList.remove('open');
}
});
}
```
- [ ] **Step 4: 验证**
启动服务器,浏览器:
1. 选项目 A 创建一个会话
2. 切换顶栏项目为 B
3. 顶栏出现黄色徽章 `⚠ 项目不匹配:A ≠ B`
4. 点击徽章 → 弹出 popover,含「切到该项目」和「保留当前项目」按钮
5. 点击「切到该项目」→ draftProject 变 A,徽章消失
6. 重新切换到 B → 徽章再次出现
7. 点击「保留当前项目」→ 徽章消失;刷新页面不再提示
- [ ] **Step 5: 提交**
```bash
git add src/genesis/server/static/chat.html \
src/genesis/server/static/chat.js \
src/genesis/server/static/chat.css
git commit -m "feat(chat): 顶栏项目不匹配徽章化(popover 切/保留)"
```
---
### Task 8: 交互改造 - 快捷键 Esc + Cmd/Ctrl+Kspec §4.6
**Files:**
- Modify: `src/genesis/server/static/chat.js`(改造 keydown 监听 + 加 Cmd+K
- [ ] **Step 1: 找到原 keydown 监听**
`chat.js` 找到原 chat.html:1103 附近的:
```js
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (psMenu.classList.contains('open')) toggleProjectSwitcher(false);
else if (drawerOpen) closeProjectDrawer();
if (lastTriggerEl) { lastTriggerEl.focus(); lastTriggerEl = null; }
}
});
```
替换为:
```js
document.addEventListener('keydown', (e) => {
// Esc: 关闭项目切换器 / 抽屉 / popover
if (e.key === 'Escape') {
// 1. 关闭 mismatch popover
document.querySelectorAll('.mismatch-popover.open').forEach(p => p.classList.remove('open'));
// 2. 关闭项目切换器
if (psMenu.classList.contains('open')) toggleProjectSwitcher(false);
// 3. 关闭抽屉
else if (drawerOpen) closeProjectDrawer();
if (lastTriggerEl) { lastTriggerEl.focus(); lastTriggerEl = null; }
return;
}
// Cmd/Ctrl + K: 切换项目切换器
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
const switcherBtn = document.getElementById('ps-current');
if (switcherBtn) { lastTriggerEl = switcherBtn; toggleProjectSwitcher(); }
}
});
```
- [ ] **Step 2: chat.css 新增项目切换器 hover 时显示 `⌘K` 提示(仅 mac)**
`.ps-current` 块之后追加:
```css
.ps-current::after {
content: "⌘K";
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
margin-left: 4px;
padding: 1px 4px;
border: 1px solid var(--border-subtle);
border-radius: 4px;
opacity: 0;
transition: opacity .15s var(--ease);
}
/* 仅 mac 显示(mac 平台 UA 包含 Mac */
@supports (-webkit-touch-callout: none) {
.ps-current:hover::after { opacity: 1; }
}
```
**注**: `@supports` 探测在 mac Safari 上不一定准;简化方案是用 UA 嗅探(JS),但本轮保持纯 CSS**win/linux 不显示**是符合 spec §四-6 的("win/linux 省略")。
- [ ] **Step 3: 验证**
启动服务器,浏览器:
1.`Cmd+K`mac)或 `Ctrl+K`(win/linux)→ 项目切换器打开
2. 再按 `Cmd+K` → 项目切换器关闭
3.`Esc` → 切换器关闭
4. 抽屉打开时按 `Esc` → 抽屉关闭
5. 焦点在输入框时按 `Cmd+K` → 切换器仍能打开
6. 徽章 popover 打开时按 `Esc` → popover 关闭
- [ ] **Step 4: 提交**
```bash
git add src/genesis/server/static/chat.js src/genesis/server/static/chat.css
git commit -m "feat(chat): 快捷键 Esc + Cmd/Ctrl+K(项目切换器)"
```
---
### Task 9: 交互改造 - 骨架屏 + 空状态快捷指令 + 错误分级(spec §4.7 / §4.8 / §五)
**Files:**
- Modify: `src/genesis/server/static/chat.js`(新增 `renderSkeleton` / `renderEmptyQuickActions` / `addInfo` / `addWarn`,改造启动 IIFE 和 `renderEmptyOrWelcome`
- Modify: `src/genesis/server/static/chat.css`(加 `.skel` / `.quick-actions` 样式)
- [ ] **Step 1: chat.css 新增骨架屏 + 快捷指令 + 错误分级样式**
```css
/* 骨架屏 */
.skel { display: flex; margin: var(--sp-3) 0; gap: 10px; }
.skel-avatar { width: 24px; height: 24px; border-radius: 50%; background: var(--bg-elevated); flex-shrink: 0; }
.skel-bars { flex: 1; display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }
.skel-bar {
height: 12px; border-radius: 6px;
background: linear-gradient(90deg, var(--bg-elevated) 0%, var(--border-strong) 50%, var(--bg-elevated) 100%);
background-size: 200% 100%;
animation: skelShimmer 1.4s ease-in-out infinite;
}
.skel-bar:nth-child(1) { width: 60%; }
.skel-bar:nth-child(2) { width: 80%; }
.skel-bar:nth-child(3) { width: 45%; }
@keyframes skelShimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* 空状态快捷指令 */
.quick-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.quick-actions button {
background: var(--bg-elevated);
border: 1px solid var(--border-strong);
color: var(--text-primary);
border-radius: 999px;
padding: 6px 14px;
font-size: 13px; font-weight: 500;
cursor: pointer;
transition: all var(--dur) var(--ease);
font-family: var(--font-ui);
}
.quick-actions button:hover { border-color: var(--accent); color: var(--accent); box-shadow: 0 0 12px var(--accent-soft); }
/* 错误分级 */
.msg.info .bubble { background: rgba(96,165,250,.12); border: 1px solid var(--info); color: var(--info); }
.msg.warn .bubble { background: rgba(251,191,36,.12); border: 1px solid var(--warning); color: var(--warning); }
```
- [ ] **Step 2: chat.js 新增 `renderSkeleton` / `renderEmptyQuickActions` / `addInfo` / `addWarn`**
`chat.js` 中(任意位置)追加:
```js
function renderSkeleton() {
const wrap = document.createElement('div');
wrap.id = 'skeleton';
for (let i = 0; i < 3; i++) {
const row = document.createElement('div');
row.className = 'skel';
row.innerHTML = '<div class="skel-avatar"></div><div class="skel-bars"><div class="skel-bar"></div><div class="skel-bar"></div><div class="skel-bar"></div></div>';
wrap.appendChild(row);
}
chatEl.appendChild(wrap);
// 5 秒超时
setTimeout(() => { const s = document.getElementById('skeleton'); if (s) s.remove(); }, 5000);
return wrap;
}
function removeSkeleton() {
const s = document.getElementById('skeleton');
if (s) s.remove();
}
function renderEmptyQuickActions() {
const wrap = document.createElement('div');
wrap.className = 'quick-actions';
const actions = [
{ label: '上传要件定义', act: () => document.getElementById('file-input').click() },
{ label: '查看项目状态', act: () => send('现在什么状态?') },
{ label: '开始生成概要设计书', act: () => send('生成概要设计书') },
];
for (const a of actions) {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = a.label;
btn.onclick = a.act;
wrap.appendChild(btn);
}
return wrap;
}
function addInfo(text) { return addMsg('info', esc(text), text); }
function addWarn(text) { return addMsg('warn', esc(text), text); }
function addError(text) { return addMsg('error', esc(text), text); }
```
**注意**: 原 chat.js 中所有 `addMsg('error', ...)` 调用**保留不变**(已通过新签名 `addMsg(role, html, text)` 兼容)。`addError` 是 helper 便于未来替换,但本轮不强制替换。
- [ ] **Step 3: 改造启动 IIFE**
找到启动 IIFE`(async () => {` 开头),把:
```js
if (saved) {
addMsg('assistant', '正在恢复上次会话…');
try { await loadSession(saved); ... }
}
```
替换为:
```js
if (saved) {
renderSkeleton();
try { await loadSession(saved); removeSkeleton(); ... }
catch (e) { removeSkeleton(); }
}
```
- [ ] **Step 4: 改造 `renderEmptyOrWelcome`**
找到:
```js
function renderEmptyOrWelcome() {
chatEl.innerHTML = '';
addMsg('assistant', GenesisState.buildWelcome(draftProject));
}
```
替换为:
```js
function renderEmptyOrWelcome() {
chatEl.innerHTML = '';
const welcome = GenesisState.buildWelcome(draftProject);
const m = addMsg('assistant', esc(welcome), welcome);
// 有项目时显示快捷指令
if (draftProject) {
m.querySelector('.bubble').appendChild(renderEmptyQuickActions());
}
}
```
- [ ] **Step 5: 验证**
启动服务器,浏览器:
1. 清空 localStorage 刷新 → 无项目时显示欢迎语 + **没有**快捷指令
2. 选一个项目(无 sid)→ 显示欢迎语 + 3 个快捷指令按钮
3. 点击「上传要件定义」→ 触发文件选择对话框
4. 点击「开始生成概要设计书」→ 自动 `send('生成概要设计书')`
5. localStorage 留有上次会话 → 刷新 → 显示 3 行骨架屏 → 加载完成骨架消失
6. 手动断网触发 `addMsg('error', '请求失败')`(断网后 send)→ 错误气泡用红边红字
7. 触发 `addWarn('类型不匹配')` → 黄色边
- [ ] **Step 6: 提交**
```bash
git add src/genesis/server/static/chat.js src/genesis/server/static/chat.css
git commit -m "feat(chat): 骨架屏 + 空状态快捷指令 + 错误分级(info/warn/error)"
```
---
### Task 10: 文档同步 + 端到端回归
**Files:**
- Modify: `docs/design.md`(追加"前端 UI 设计规范"一节)
- Modify: `_AI_USAGE_LOG.md`(追加 3 条记录)
- Test: 手动回归清单(spec §七)
- [ ] **Step 1: 读 docs/design.md 现有结构**
```bash
# 用 read 工具读 docs/design.md 末尾 100 行,确认追加位置
```
- [ ] **Step 2: 在 docs/design.md 末尾追加「前端 UI 设计规范」**
在文件末尾追加:
```markdown
## 前端 UI 设计规范(2026-08-30
### 设计系统
- **风格**:深色指挥中心 + 文档工作台混合
- **强调色**`#5EEAD4`(青绿,唯一)
- **背景色阶**`--bg-base: #0B0F17` / `--bg-surface: #141A24` / `--bg-elevated: #1B2330` / `--bg-input: #0F141C`
- **文本色阶**`--text-primary: #E5EAF0` / `--text-secondary: #8B95A7` / `--text-muted: #5C6677`
- **边框色阶**`--border-subtle: rgba(255,255,255,.08)` / `--border-strong: rgba(255,255,255,.14)`
- **状态色**`--success: #34D399` / `--warning: #FBBF24` / `--danger: #F87171` / `--info: #60A5FA`
- **字体策略**:系统字体栈(不引入 Google Fonts / JetBrains Mono
- UI`-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", system-ui, sans-serif`
- 等宽:`ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace`
- **间距阶梯**4 / 8 / 12 / 16 / 20 / 24
- **圆角阶梯**6 (sm) / 10 (md) / 14 (lg) / 9999 (胶囊)
- **动效**`cubic-bezier(.2, 0, 0, 1)` × `0.18s`
### 文件结构
- `src/genesis/server/static/chat.html`:仅 DOM 结构
- `src/genesis/server/static/chat.css`:所有视觉样式(约 600 行)
- `src/genesis/server/static/chat.js`:业务逻辑 + 8 项交互(约 900 行)
- `chat_state.js` / `chat_ws.js`:协议层,不动
### 兼容约束
- 所有 DOM id 保留(业务逻辑依赖)
- 所有 localStorage / sessionStorage 键保留
- 所有 API 端点不变
- 移动端改造(< 768px 抽屉化)不在本轮
- 快捷键仅 Esc + Cmd/Ctrl+K
- 消息气泡"重新生成"按钮不在本轮
### 设计规范来源
完整 spec`docs/superpowers/specs/2026-08-30-frontend-beautify-design.md`
实施计划:`docs/superpowers/plans/2026-08-30-frontend-beautify.md`
```
- [ ] **Step 3: 在 _AI_USAGE_LOG.md 追加 3 条记录**
`_AI_USAGE_LOG.md` 末尾 20 行确认格式,然后追加(按 AGENTS.md 字段):
```markdown
| 2026-08-30 16:00 | 架构设计 | 完成前端美化设计 spec(深色指挥中心 + 文档工作台) | docs/superpowers/specs/2026-08-30-frontend-beautify-design.md | minimax/minimax-m3:free |
| 2026-08-30 17:00 | Agent 实现 | 实施 chat.html 拆分 + chat.css / chat.js 改写 + 8 项交互 | src/genesis/server/static/chat.html, chat.css, chat.js | minimax/minimax-m3:free |
| 2026-08-30 18:00 | 反馈迭代 | 同步 docs/design.md + _AI_USAGE_LOG.md | docs/design.md, _AI_USAGE_LOG.md | minimax/minimax-m3:free |
```
**注意**: 日期时间字段需在实施时按实际填,先用占位时间,commit 前修正。
- [ ] **Step 4: 端到端手动回归(spec §七清单)**
按 spec §七的 7 个流程依次在浏览器手测,全部通过后方可 commit:
1. 新建会话 → 选项目 → 上传要件 → 发送"生成概要设计书" → 收到下载链接
2. 切换项目 → 不匹配徽章出现 → 点击"切到该项目" → 徽章消失
3. 拖拽上传 xlsx → 自动选 `requirements` → 上传成功
4. 输入多行(Shift+Enter)→ 发送(Enter)→ 高度重置
5. hover 助手气泡 → 复制 → 粘贴验证
6. Esc 关闭抽屉;Cmd+K 打开/关闭项目切换器
7. 启动恢复会话时显示骨架屏 → 加载完成消失
- [ ] **Step 5: 提交**
```bash
git add docs/design.md _AI_USAGE_LOG.md
git commit -m "docs(frontend): 同步前端 UI 设计规范 + AI 使用日志"
```
---
## Self-Review
1. **Spec coverage**:
- §零决策(10 条)→ 全部贯穿 Task 1-10 ✓
- §一设计令牌 → Task 2 Step 1 ✓
- §二六大区域 → Task 2 Step 3-9 ✓
- §三关键不变量 → 始终约束;本 plan 严格保持 DOM id / 键 / 端点不变 ✓
- §四-1 分组 → Task 3 ✓
- §四-2 拖拽 → Task 4 ✓
- §四-3 气泡操作(无"重新生成")→ Task 5 ✓
- §四-4 多行输入 → Task 6 ✓
- §四-5 顶栏徽章 → Task 7 ✓
- §四-6 快捷键 Esc+Cmd+K → Task 8 ✓
- §四-7 骨架屏 → Task 9 ✓
- §四-8 空状态快捷指令 → Task 9 ✓
- §五错误分级 → Task 9 ✓
- §六错误处理 → 全部 Task 包含(try/catch / 静默降级)✓
- §七测试策略 → Task 10 Step 4 手动回归 ✓
- §九风险与回滚 → Global Constraints + 提交粒度可控 ✓
- §十里程碑外(不做项)→ 在 §零-8/9/10 决策 + Task 2/5/8 范围约束 ✓
2. **占位符扫描**:
- 全文无 TBD / TODO / "implement later" / "similar to Task N"
- 所有代码块均给出完整实现(CSS / JS / HTML)✓
3. **类型 / 签名一致性**:
- `addMsg(role, html, text)` 在 Task 5 定义,Task 9 的 `addInfo`/`addWarn`/`addError` 沿用 ✓
- `bindUploadDrag` / `bindComposer` / `autoResizeTextarea` / `renderSkeleton` / `renderEmptyQuickActions` 命名一致 ✓
- `GenesisState` 协议(`buildWelcome` / `autoBindProject` / `resolveUploadType`)未改 ✓
4. **未覆盖但可接受的点**:
- Task 5 Step 3 的"逐个修改 addMsg 调用点"依赖 grep 工具列清单;本 plan 不展开所有 8 个 call sitechat.html 内联段已完整复制到 chat.js,可直接 grep 替换)。
- Task 7 Step 3 的 `badge.onclick` 内绑定 `document.addEventListener('click', ...)` 在每次 `renderProjectMismatchHint` 调用时**会重复添加**全局监听;本轮在每次调用前**未清理旧监听**,会有多个监听累积。**改进**: 在 chat.js 启动 IIFE 内一次性绑定全局 click 监听,匹配 `.mismatch-badge` 时关闭 popover。Task 7 实施时若发现此问题,按此建议修复。
- Task 9 Step 4 的 `m.querySelector('.bubble').appendChild(renderEmptyQuickActions())` 假设 `.bubble` 存在;`addMsg` 已保证创建 `.bubble`
5. **本 plan 范围外**(在 spec §十 列出,不在本 plan 实现):
- 移动端 < 768px 断点改造
- 快捷键 `Cmd+Shift+O` / `Cmd+Shift+S`
- 消息气泡"重新生成"按钮
- dark/light 主题切换
---
## Execution Handoff
**Plan complete and saved to `docs/superpowers/plans/2026-08-30-frontend-beautify.md`.**
包含 10 个 Task
- Task 1:拆分 chat.html(机械拆分,行为零变更)
- Task 2:视觉重塑(chat.css 深色主题)
- Task 3:会话分组
- Task 4:拖拽上传
- Task 5:气泡复制/引用
- Task 6:多行输入
- Task 7:项目不匹配徽章
- Task 8:快捷键 Esc+Cmd+K
- Task 9:骨架屏 + 空状态 + 错误分级
- Task 10:文档 + 手动回归
**两个执行选项**
1. **Subagent-Driven(推荐)** —— 我为每个 Task 派遣一个全新的子代理,Task 之间做 review,快速迭代
2. **Inline Execution** —— 在当前会话中按 executing-plans 执行,带 checkpoint
**选哪个?**
@@ -0,0 +1,367 @@
# 前端美化与交互改造设计(2026-08-30)
## 背景与目标
当前前端(`src/genesis/server/static/chat.html`,单文件 1215 行内联 CSS+JS)沿用 Material/Google 风
`#1a73e8` 蓝、Inter、胶囊按钮、扁平阴影),功能完整但视觉同质化严重,与"概要设计书自动生成
Agent"的项目定位缺少记忆点。
本次迭代在**保持现有功能、API 协议、localStorage 键、所有 DOM id 完全不变**的前提下,对整套前端
做视觉重塑 + 8 项交互改善,达成:
1. 视觉差异化(深色指挥中心 + 文档工作台混合风格,单一青绿强调色)
2. 体感现代化(拖拽、多行输入、消息气泡操作、骨架屏、空状态快捷指令)
3. 架构可维护(chat.html 拆为 chat.html + chat.css + chat.jschat_state.js / chat_ws.js 不动)
## 用户决策(brainstorming 已确认)
| # | 决策项 | 选定 |
|---|---|---|
| 1 | 视觉风格 | 深色指挥中心 + 文档工作台混合 |
| 2 | 强调色 | 青绿 `#5EEAD4` |
| 3 | 字体策略 | 系统字体栈(不引入 Google Fonts / JetBrains Mono |
| 4 | 抽屉遮罩 | `backdrop-filter: blur(8px)` |
| 5 | 文件拆分 | B2chat.html 拆为 chat.html + chat.css + chat.js |
| 6 | 交互改造 | 本轮做 8 项(详见 §四) |
| 7 | 快捷键 | 只做 Esc + Cmd/Ctrl+K |
| 8 | 移动端改造 | **不做**(留到下个迭代) |
| 9 | 消息气泡"重新生成" | **不做**(避免触发后端协议变更) |
| 10 | 主题切换 | 单主题(无 dark/light 切换) |
## 范围
### 做
- 视觉重塑(六大区域,颜色 / 字体 / 间距 / 圆角 / 动效)
- chat.html 拆分为 chat.html + chat.css + chat.js
- 8 项交互改善(见 §四)
- 同步 `docs/design.md` 增加"前端 UI 设计规范"一节
- 追加 `_AI_USAGE_LOG.md` 三条记录(设计 / 实现 / 文档同步)
### 不做
- 移动端侧边栏抽屉化(断点 < 768px 的改造)
- 快捷键 `Cmd+Shift+O` / `Cmd+Shift+S`
- 消息气泡"重新生成"按钮
- 主题切换(dark/light
- 后端 API 协议变更
- localStorage / sessionStorage 键重命名
- DOM id 变更
## 架构
### 文件结构(B2 拆分后)
```
src/genesis/server/static/
├── chat.html # 仅 DOM 结构
├── chat.css # 新增:所有视觉样式
├── chat.js # 新增:原 chat.html 中 <script> 段(约 700 行)
├── chat_state.js # 不动
└── chat_ws.js # 不动
```
### chat.html 改动
- 头部:删除 `<link rel="preconnect">` 与 Google Fonts 的 `<link>`
- 头部:新增 `<link rel="stylesheet" href="/chat.css">`
- 头部末尾(`<body>` 之前):保持空白
- 主体:删除第 10-392 行 `<style>...</style>` 整段
- 主体:保留所有 DOM 节点 id(业务逻辑依赖)
- 末尾:删除第 509-1213 行 `<script>...</script>` 整段
- 末尾:新增 `<script src="/chat.js" defer></script>`
- 末尾:保留 `<script src="/chat_state.js">``<script src="/chat_ws.js">` 在 chat.js 之前
### chat.js 改造
- 整体结构保持不变(DOMContentLoaded 后绑定事件 + 启动恢复)
- 新增函数:`groupSessions``bindUploadDrag``attachBubbleOps``autoResizeTextarea`
`renderProjectMismatchBadge``renderSkeleton``renderEmptyQuickActions`
`addInfo``addWarn``bindKeyboardShortcuts`
- 保持函数签名与原内联版本一致(避免 chat_state.js 协议变化)
## 一、设计令牌(chat.css 顶部 `:root`
```css
:root {
/* === 颜色 === */
--bg-base: #0B0F17;
--bg-surface: #141A24;
--bg-elevated: #1B2330;
--bg-input: #0F141C;
--border-subtle: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.14);
--text-primary: #E5EAF0;
--text-secondary: #8B95A7;
--text-muted: #5C6677;
--accent: #5EEAD4;
--accent-soft: rgba(94, 234, 212, 0.15);
--success: #34D399;
--warning: #FBBF24;
--danger: #F87171;
--info: #60A5FA;
/* === 字体 === */
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Microsoft YaHei", "Noto Sans CJK SC", system-ui, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
/* === 间距 === */
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px;
--sp-4: 16px; --sp-5: 20px; --sp-6: 24px;
/* === 圆角 === */
--r-sm: 6px; --r-md: 10px; --r-lg: 14px;
/* === 动效 === */
--ease: cubic-bezier(.2, 0, 0, 1);
--dur: .18s;
}
```
## 二、各区域视觉规约
### 2.1 侧边栏(`#sidebar`
- 底色 `--bg-surface`,右 1px `--border-subtle`
- 折叠按钮:从右外侧浮出的小竖条,hover 时变 `--accent`**不再旋转 180°**
改为左右 chevron`«` / `»`)切换
- 会话项 hover:背景 `--bg-elevated`,左侧 2px `--accent` 竖条
- 会话项 active:左侧 2px `--accent` 竖条 + 背景 `--bg-elevated`
- 折叠态:去掉头像逻辑,改成 4px 圆点(项目色 hash);折叠态下分组标题 `display:none`
### 2.2 顶栏(`header`
- 底色 `--bg-surface` + `backdrop-filter: blur(12px)` + 1px `--border-subtle` 底边
- 项目切换器:胶囊改为左 6px 圆点(项目色 hash+ 名字 + chevronhover 出现 1px `--accent`
- RAG 胶囊:去掉背景底色,改成 `🔘 0 片段` 风格的等宽小标签
- 已索引:实心圆点 + `--accent` 色 + 等宽数字
- 未索引:空心圆点 + `--text-muted`
- 会话徽标:窄屏下不再 `display:none`,限制 `max-width: 32%` + `text-overflow: ellipsis`
### 2.3 聊天区(`#chat`
- 主区底色 `--bg-base`,内边距 `var(--sp-6)`
- 气泡:去掉阴影,改用 1px 细边 + 内部留白
- **用户消息**`--bg-surface` + 左侧 2px `--accent` 竖条 + 字号 14,行高 1.65
- **助手消息**`--bg-surface` + 1px `--border-subtle` + 顶头 6px 圆点(`--accent` 静态)
- **进度消息**`--bg-input` + dashed 边 `--accent-soft`,等宽字体
- 思考中态:3 个等宽方块循环亮起(替代当前 `思考中…` 文案),用 `--accent`
- 消息出现动画:保留 `zoomIn`,幅度从 0.97 → 0.98
### 2.4 上传条(`#upload-bar`
- 底色 `--bg-surface`,与主区用 1px `--border-subtle` 分隔
- 升级为可拖入区域(详见 §四-2):dragover 时整条出现 dashed `--accent` 边 + `accent-soft` 底色
- select / button 深色样式:底色 `--bg-input`focus 时 `0 0 0 2px var(--accent-soft)`
- 项目提示(`#proj-hint`)改为左侧 4px 三角指示条 + inline note
### 2.5 输入区(`#composer`
- 底色 `--bg-surface`,顶 1px `--border-subtle`
- `<input id="input">` 升级为 `<textarea id="input" rows="1">`(详见 §四-4
- send 按钮:底色 `--accent`,文字 `--bg-base`hover 加 `accent-soft` 外发光
### 2.6 项目抽屉(`#proj-drawer`
- 整体 `--bg-surface`,背后遮罩 `rgba(11,15,23,.6)` + `backdrop-filter: blur(8px)`
- 左侧列表项:active 时左侧 2px `--accent` 竖条 + 背景 `--bg-elevated`
- 表单输入:深色底 `--bg-input`focus 时 `0 0 0 2px var(--accent-soft)`
- 「高级」details 改为纯线框展开,无背景填充
- 关闭按钮:hover 红色描边(保留现有语义)
## 三、关键不变量(兼容性保证)
### 3.1 必须保留的 DOM id(业务逻辑依赖)
```
sidebar, sidebar-toggle, brand-logo, brand-name, new-chat, session-list,
main, rag-bar, rag-enabled, rag-stats, impact-btn, sid-badge,
proj-switcher, ps-current, ps-name, ps-menu, chat, upload-bar, file-type,
file-input, upload-btn, upload-status, proj-hint, composer, input, send,
proj-drawer, drawer-list, drawer-form, drawer-close, pf-name, pf-display,
pf-template, pf-write, pf-rules, pf-code, pf-design, pf-save, pf-delete
```
### 3.2 必须保留的全局符号
- `window.GenesisState`(由 chat_state.js 提供,chat.js 通过回退默认值兜底)
- `window.GenesisWS`(由 chat_ws.js 提供,chat.js 通过 `connectProgressWs` / `applyProgressEvent` 消费)
### 3.3 必须保留的 localStorage / sessionStorage 键
- `genesis_sidebar_collapsed`
- `genesis_session`
- `genesis_rag_enabled`
- `genesis_skipped_project_hint`sessionStorage
新增键(无冲突):
- `genesis_session_group_collapsed`(分组折叠状态,本轮默认全展开,不实际写入)
### 3.4 必须保留的 API 端点
所有 `/api/...` 调用与请求体格式不变。
## 四、交互改造(8 项)
### 4.1 会话列表按时间分组
- **位置**`refreshSessions()` 内,对 `list.sort(...)` 之后插入分组
- **分组规则**:用 `updated_at` 字符串前缀(`YYYY-MM-DD`)与今天/昨天日期比较
- 今天 / 昨天 / 本周(7 天内)/ 本月 / 更早
- **DOM**:在 `.sess` 之间插入 `<div class="sess-group">今天</div>`,样式:
`font-family: var(--font-mono); font-size: 11px; letter-spacing: .1em; color: var(--text-muted)`
- **不影响**:折叠态下分组标题 `display:none`;分组内 `.sess` 仍可正常点击
- **行为**:分组标题不可点击,不参与折叠按钮 toggle
### 4.2 拖拽上传
- **位置**`#upload-bar` 监听 `dragenter / dragover / dragleave / drop`
- **行为**
- `dragover``upload-bar``.drag-active` 类(dashed `--accent` 边 + `accent-soft` 底色)
- `dragleave``upload-bar` 移除 `.drag-active`
- `drop`:取 `e.dataTransfer.files[0]`,写入 `#file-input.files`,并**按后缀自动选 `file-type`**
- `.xlsx``requirements`
- `.docx``template`(缺省兜底)
- `.zip``existing_system`
- **安全**:复用现有 `projectHasPrefixedType``UPLOAD_EXT_RULES` 校验
- **拖入整个文件夹**`webkitGetAsEntry` 拒绝并提示(仅取第一个文件)
### 4.3 消息气泡操作
- **触发**
- **用户气泡**:hover 时右上角浮出 `↺ 引用` 按钮(点击把消息文本填到 `#input` 并 focus
- **助手气泡**:hover 时右上角浮出 `⧉ 复制` 按钮
- **不实现**`↻ 重新生成` 按钮(见 §零-9 决策)
- **实现**
- CSS`.msg { position: relative; } .msg .ops { position: absolute; top: -10px; opacity: 0; ... }`
`:hover .ops { opacity: 1; }`
- JS:扩展 `addMsg()`,内部 appendChild `<div class="ops">...</div>`;用户/助手不同
- **复制**:用 `navigator.clipboard.writeText(text)`,无 HTTPS 时 fallback
`document.execCommand('copy')`(同步临时 textarea
- **不存储历史**:仅影响本次会话 UI
### 4.4 输入区多行 + 自动高度
- **位置**`<input id="input">` 改为 `<textarea id="input" rows="1">`
- **行为**
- `input` 事件:`element.style.height = 'auto'; element.style.height = Math.min(scrollHeight, 160) + 'px'`
- `keydown``Enter`(无 Shift 且无 IME 组合中)= 发送;`Shift+Enter` = 换行
- 发送后自动重置高度为 `auto`(下一帧设回 24px
- **保留**:现有 `maxlength=2000`
- **IME 兼容**keydown 中检查 `e.isComposing`(中文输入法组合中按 Enter 不应发送)
### 4.5 顶栏项目不匹配徽章化
- **位置**:在 `#rag-bar` 之前插入 `<span id="proj-mismatch-badge" hidden>`
- **行为**
- 替代现有 `renderProjectMismatchHint()` 的聊天区插入逻辑
- 徽章样式:黄色 1px 边 `--warning` + 圆角 `--r-md` + `⚠` 前缀 +
"项目: stock ≠ 草稿: foo" 文本
- 点击展开一个迷你 popover(不打开抽屉),提供"切到该项目"和"保留"两个按钮
- "保留"逻辑沿用现有 `sessionStorage` 标记(`genesis_skipped_project_hint`
- **不删除旧函数**:保留 `renderProjectMismatchHint` 但改为只调用新徽章渲染函数
### 4.6 快捷键(仅 2 个)
- **Esc**:已有,扩展为同时关闭项目切换器 + 抽屉 + 移动端侧边栏(本轮不做移动端,等价无变化)
- **Cmd/Ctrl + K**:切换 `#ps-menu`(与点击 `#ps-current` 行为一致),焦点在输入框时也能用
- **实现位置**`document.addEventListener('keydown', ...)`,沿用现有 Escape 监听并扩展
- **可视提示**:项目切换器 hover 时显示 `⌘K`CSS `::after`,仅 mac 显示,win/linux 省略)
### 4.7 骨架屏
- **位置**:启动时(`localStorage``genesis_session` 时)替换
`addMsg('assistant', '正在恢复上次会话…')`
- **样式**:3 条消息形状的占位(圆形 8px + 矩形条 `--bg-surface`),用
`linear-gradient``@keyframes shimmer` 1.4s 循环(亮色 `--bg-elevated`
`--bg-surface` 交替)
- **逻辑**`loadSession()` 完成后 `skeleton.remove()`,最多 5 秒超时兜底
- **超时处理**`setTimeout(5000)` 强制 remove 并显示错误提示
### 4.8 空状态快捷指令
- **位置**`renderEmptyOrWelcome()` 中,无 `sid``draftProject` 已选时
- **内容**:欢迎气泡 + 3 个胶囊按钮:
- "上传要件定义"(点击触发 `#file-input` click
- "查看项目状态"(直接 `send('现在什么状态?')`
- "开始生成概要设计书"(`send('生成概要设计书')`
- **样式**:胶囊 `--bg-elevated` 底 + 1px `--border-subtle` 边,
hover 出现 `--accent`
- **不显示**`draftProject``null` 时(保持现有欢迎语)
## 五、错误分级样式(4.10
- **位置**:扩展 `addMsg()`,新增可选 `level` 参数:`'info' | 'warn' | 'error'`
- **样式**
- `info``--info` 1px 边 + `rgba(96,165,250,.12)`
- `warn``--warning` 1px 边 + `rgba(251,191,36,.12)`
- `error``--danger` 1px 边 + `rgba(248,113,113,.12)`
- **兼容性**:现有所有 `addMsg('error', ...)` 调用不变(默认仍走 error 样式)
- **新增助手函数**`addInfo(text)` / `addWarn(text)`,内部调用 `addMsg('info' / 'warn', text)`
- **本轮替换点**(仅本轮改造涉及的错误,不全量替换):
- 上传类型不匹配 → `addWarn`(客户端校验,非后端失败)
- 项目名不能为空 → `addWarn`(客户端校验)
- 加载会话失败 → `addError`(现有)
- 网络/服务器错误 → `addError`(现有)
## 六、错误处理
| 场景 | 行为 |
|---|---|
| chat.css / chat.js 加载失败(404/网络) | 页面降级显示,肉眼可见(无样式白板)。可在 README 提示开发者 |
| Google Fonts 已无 link,不会再失败 | N/A |
| localStorage 不可用(隐私模式) | 沿用现有 try/catch,输出到 `console.warn` 而非 `addError` |
| 拖拽 API 不可用(旧浏览器) | `if (!('draggable' in document.createElement('div')) return;` 静默退出 |
| IME 中文输入中按 Enter | 走 `e.isComposing` 检查,**不发送**,保持原换行 |
| 快捷键冲突(浏览器原生 Cmd+K) | 始终 `e.preventDefault()`,已知会拦截 Firefox 切搜索栏 |
## 七、测试策略
由于本轮为**纯前端样式与交互改造**,且不新增可被 pytest 覆盖的逻辑,采用:
1. **手动回归清单**(见 `docs/superpowers/specs/2026-08-30-frontend-beautify-checklist.md`
本 spec 不展开)
2. **截图对比**(实施前用浏览器截 5 张关键页面对照,实施后比对)
3. **关键流程脚本测试**(用户手动跑通):
- 新建会话 → 选项目 → 上传要件 → 发送"生成概要设计书" → 收到下载链接
- 切换项目 → 不匹配徽章出现 → 点击"切到该项目" → 徽章消失
- 拖拽上传 xlsx → 自动选 `requirements` → 上传成功
- 输入多行(Shift+Enter)→ 发送(Enter)→ 高度重置
- hover 助手气泡 → 复制 → 粘贴验证
- Esc 关闭抽屉;Cmd+K 打开/关闭项目切换器
- 启动恢复会话时显示骨架屏 → 加载完成消失
## 八、实施步骤(写作计划阶段会细化)
1. **建文件骨架**chat.html 拆分为 chat.html + chat.css + chat.js,机械搬移不改样式
2. **写 chat.css 令牌**`:root` 全部变量
3. **重写 chat.css 样式**:六大区域按 §二 改造
4. **chat.js 加 8 项交互**:按 §四 / §五 顺序(不依赖外网)
5. **本地手动回归**:见 §七
6. **同步 docs/design.md**:新增"前端 UI 设计规范"一节
7. **更新 _AI_USAGE_LOG.md**:三条记录
## 九、风险与回滚
| 风险 | 缓解 |
|---|---|
| 内联 CSS/JS 拆分影响加载顺序 | `<link>``<head>``<script defer>``<body>` 末尾;chat_state.js / chat_ws.js 保持同步加载 |
| 改 class 破坏后端 | 后端不读 class;仅核对 id 列表(§三-1 |
| 系统字体在 win / linux 上回退差异 | 字体栈已含 PingFang / Microsoft YaHei / Noto Sans CJK 兜底 |
| backdrop-filter 性能 | 限定在抽屉遮罩 + 顶栏,不滥用 |
| localStorage 新增键冲突 | 全部以 `genesis_` 前缀 |
| 拖入文件夹行为 | 拒绝并 toast 提示"请拖入单个文件" |
**回滚**`git checkout` 上一版本,删除 `chat.css` / `chat.js` 即可。
## 十、里程碑外(后续)
- 移动端侧边栏抽屉化(< 768px 断点)
- 快捷键 `Cmd+Shift+O`(项目抽屉)/ `Cmd+Shift+S`(侧边栏折叠)
- 消息气泡"重新生成"按钮(需后端协议扩展)
- 深色 / 浅色主题切换
- 国际化(i18n)文案
- 设计系统独立文件(`static/design-tokens.css`)供未来复用