- 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/ 目录。
83 KiB
前端美化与交互改造 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(最小还原版,仅令牌 + 原样样式)
/* 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 已就绪)。
// 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 改为:
<!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> 之前)的合适位置追加:
<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: 启动服务器并浏览器访问主页验证拆分无回归
# 在项目根目录
python -m genesis.server.app
# 或查看 README.md 中实际的启动命令
预期:访问 http://localhost:8000/(或 README 中端口),看到与拆分前视觉与交互完全一致的页面(Material/Google 风、Inter 字体、蓝色 #1a73e8)。
- Step 5: 手动验证清单
依次跑通:
- 页面加载,浏览器控制台无 JS 错误(无 404 chat.css / chat.js)
- 点击「折叠侧边栏」按钮 → 侧边栏变窄,会话名隐藏
- 点击「项目」下拉 → 弹出菜单,能选项目
- 输入「你好」→ 发送 → 用户气泡(蓝渐变)出现
- 刷新页面 → 上次会话恢复(localStorage 生效)
- Step 6: 提交
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 §一 深色版本
/* 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: 替换全局 + 滚动条样式
* { 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)
/* 侧边栏 */
#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)
/* 主区 */
#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 胶囊)
/* 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)
/* 聊天区 */
#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)
/* 上传条 */
#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)
/* 输入区 */
#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)
/* 项目抽屉 */
#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)
@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: 浏览器验证视觉重塑
启动服务器,访问主页,验证:
- 整体配色变深(深空灰底 + 白色文字)
- 强调色全部变青绿
#5EEAD4 - 字体回退到系统字体(中文用 PingFang/Microsoft YaHei)
- 六大区域(侧边栏/顶栏/聊天/上传/输入/抽屉)全部按新样式渲染
- 业务功能不破:侧边栏折叠、项目切换、会话创建、消息发送、上传
- Step 12: 提交
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 块之后追加:
.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 创建逻辑,仅在循环外加分组标题插入):
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 个会话)→ 侧边栏会话列表出现
- 若历史会话的
updated_at跨多天 → 应出现分组标题「今天 / 昨天 / 本周 / 本月 / 更早」 - 折叠侧边栏 → 分组标题隐藏
- 无
updated_at的会话会落入「更早」组,不报错
- Step 4: 提交
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 块之后追加:
#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 () => { 之前)追加:
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: 验证
启动服务器,浏览器:
- 拖一个
.xlsx文件到上传条 → 出现accent虚线高亮 - 拖入完成 →
file-type自动选requirements;文件名出现在 file-input - 点击「上传」按钮 → 走原有上传逻辑成功
- 拖一个
.docx→ 自动选template - 拖一个
.zip→ 自动选existing_system - 拖入文件夹 → 出现「请拖入单个文件」warn 提示
- Step 4: 提交
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 新增气泡操作按钮样式
.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 行附近):
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;
}
替换为:
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: 验证
启动服务器,浏览器:
- 发送一条消息 → 鼠标 hover 用户气泡 → 右上角浮出
↺ 引用按钮 - 点击
↺ 引用→ 输入框出现该消息文本 + 焦点在输入框 - 收到助手回复 → hover → 浮出
⧉ 复制按钮 - 点击
⧉ 复制→ 按钮短暂变✓ 已复制,粘贴验证文本正确 - 进度/错误消息无操作按钮(hover 也不显示)
- Step 5: 提交
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"> 改为:
<textarea id="input" rows="1" placeholder="输入指令,如:上传了文件,生成概要设计书 / 现在什么状态?" autocomplete="off" maxlength="2000"></textarea>
- Step 2: chat.js 新增
autoResizeTextarea+ 改造 keydown 监听
在 chat.js 顶部 IIFE 内(bindUploadDrag 之后)新增:
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: 验证
启动服务器,浏览器:
- 点击输入框 → 输入多行(按 Shift+Enter 换行)→ 文本框自动撑高(最大 160px)
- 按 Enter(无 Shift)→ 消息发送,文本框重置为 1 行
- 中文输入法组合中按 Enter → 不发送,走组合确认逻辑
maxlength=2000仍生效
- Step 4: 提交
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> 这一行,之前插入:
<span id="proj-mismatch-badge" class="mismatch-badge" hidden>⚠ <span id="mismatch-text"></span></span>
- Step 2: chat.css 新增徽章 + popover 样式
.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 行),整段替换为:
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: 验证
启动服务器,浏览器:
- 选项目 A 创建一个会话
- 切换顶栏项目为 B
- 顶栏出现黄色徽章
⚠ 项目不匹配:A ≠ B - 点击徽章 → 弹出 popover,含「切到该项目」和「保留当前项目」按钮
- 点击「切到该项目」→ draftProject 变 A,徽章消失
- 重新切换到 B → 徽章再次出现
- 点击「保留当前项目」→ 徽章消失;刷新页面不再提示
- Step 5: 提交
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+K(spec §4.6)
Files:
-
Modify:
src/genesis/server/static/chat.js(改造 keydown 监听 + 加 Cmd+K) -
Step 1: 找到原 keydown 监听
在 chat.js 找到原 chat.html:1103 附近的:
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; }
}
});
替换为:
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 块之后追加:
.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: 验证
启动服务器,浏览器:
- 按
Cmd+K(mac)或Ctrl+K(win/linux)→ 项目切换器打开 - 再按
Cmd+K→ 项目切换器关闭 - 按
Esc→ 切换器关闭 - 抽屉打开时按
Esc→ 抽屉关闭 - 焦点在输入框时按
Cmd+K→ 切换器仍能打开 - 徽章 popover 打开时按
Esc→ popover 关闭
- Step 4: 提交
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 新增骨架屏 + 快捷指令 + 错误分级样式
/* 骨架屏 */
.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 中(任意位置)追加:
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 () => { 开头),把:
if (saved) {
addMsg('assistant', '正在恢复上次会话…');
try { await loadSession(saved); ... }
}
替换为:
if (saved) {
renderSkeleton();
try { await loadSession(saved); removeSkeleton(); ... }
catch (e) { removeSkeleton(); }
}
- Step 4: 改造
renderEmptyOrWelcome
找到:
function renderEmptyOrWelcome() {
chatEl.innerHTML = '';
addMsg('assistant', GenesisState.buildWelcome(draftProject));
}
替换为:
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: 验证
启动服务器,浏览器:
- 清空 localStorage 刷新 → 无项目时显示欢迎语 + 没有快捷指令
- 选一个项目(无 sid)→ 显示欢迎语 + 3 个快捷指令按钮
- 点击「上传要件定义」→ 触发文件选择对话框
- 点击「开始生成概要设计书」→ 自动
send('生成概要设计书') - localStorage 留有上次会话 → 刷新 → 显示 3 行骨架屏 → 加载完成骨架消失
- 手动断网触发
addMsg('error', '请求失败')(断网后 send)→ 错误气泡用红边红字 - 触发
addWarn('类型不匹配')→ 黄色边
- Step 6: 提交
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 现有结构
# 用 read 工具读 docs/design.md 末尾 100 行,确认追加位置
- Step 2: 在 docs/design.md 末尾追加「前端 UI 设计规范」
在文件末尾追加:
## 前端 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 字段):
| 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:
- 新建会话 → 选项目 → 上传要件 → 发送"生成概要设计书" → 收到下载链接
- 切换项目 → 不匹配徽章出现 → 点击"切到该项目" → 徽章消失
- 拖拽上传 xlsx → 自动选
requirements→ 上传成功 - 输入多行(Shift+Enter)→ 发送(Enter)→ 高度重置
- hover 助手气泡 → 复制 → 粘贴验证
- Esc 关闭抽屉;Cmd+K 打开/关闭项目切换器
- 启动恢复会话时显示骨架屏 → 加载完成消失
- Step 5: 提交
git add docs/design.md _AI_USAGE_LOG.md
git commit -m "docs(frontend): 同步前端 UI 设计规范 + AI 使用日志"
Self-Review
-
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 范围约束 ✓
-
占位符扫描:
- 全文无 TBD / TODO / "implement later" / "similar to Task N";
- 所有代码块均给出完整实现(CSS / JS / HTML)✓
-
类型 / 签名一致性:
addMsg(role, html, text)在 Task 5 定义,Task 9 的addInfo/addWarn/addError沿用 ✓bindUploadDrag/bindComposer/autoResizeTextarea/renderSkeleton/renderEmptyQuickActions命名一致 ✓GenesisState协议(buildWelcome/autoBindProject/resolveUploadType)未改 ✓
-
未覆盖但可接受的点:
- Task 5 Step 3 的"逐个修改 addMsg 调用点"依赖 grep 工具列清单;本 plan 不展开所有 8 个 call site(chat.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✓
-
本 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:文档 + 手动回归
两个执行选项:
- Subagent-Driven(推荐) —— 我为每个 Task 派遣一个全新的子代理,Task 之间做 review,快速迭代
- Inline Execution —— 在当前会话中按 executing-plans 执行,带 checkpoint
选哪个?