Files
2026Technology-Competition/docs/superpowers/plans/2026-08-30-frontend-beautify.md
T
lhl b14c2213bc 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/ 目录。
2026-08-31 22:33:46 +08:00

1780 lines
83 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 前端美化与交互改造 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
**选哪个?**