Files
2026Technology-Competition/src/genesis/server/static/chat.html
T
lhl e6702a2009 feat(web): 前端 v3 — 移除 Aura Pipeline 痕迹 / 项目切换器 / 删除会话 / 抽屉化项目配置 / 侧边栏折叠
按 docs/design-web-chat-v3.md 实施,仅前端改动,JS 与后端 0 改动:
- 移除品牌区 "Aura Pipeline" 装饰小标与 header 面包屑装饰
- 顶栏项目切换器(#proj-switcher):按钮 + 下拉菜单,含「不选择项目」+ 项目列表 + 「+ 项目管理」;
  存在项目时自动绑首项(用户显式选过则不覆盖),项目被外部删除自动回退
- 会话项 hover 显示 × 删除图标 → confirm → DELETE /api/sessions/{sid};删的是当前会话自动 newSession
- 侧边栏 #sidebar-toggle 按钮整体折叠 56↔264px,状态 localStorage 持久化,刷新保持
- 项目配置(7 字段表单 + 列表 + 保存/删除)整体迁入主区右侧 #proj-drawer(460px 滑入,Esc 关闭);
  侧边栏 #proj-panel / #proj-form 已移除
- 全量 558 passed, 覆盖率 99.10%
2026-08-28 00:01:13 +08:00

837 lines
36 KiB
HTML
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.
<!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="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;900&display=swap" rel="stylesheet">
<style>
:root {
--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);
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif;
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; }
/* ========== 左侧边栏(默认 264px,可折叠至 56px ========== */
#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; }
.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; }
/* 折叠按钮 */
#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;
}
/* 聊天区 */
#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;
}
/* 输入区 */
#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-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-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; }
}
</style>
</head>
<body>
<div id="sidebar">
<button id="sidebar-toggle" title="折叠/展开侧边栏">«</button>
<div class="brand">
<div class="brand-logo">G</div>
<div class="brand-name">Genesis</div>
</div>
<button id="new-chat" title="新建会话"><span class="ic-plus">+</span><span class="nc-text">新会话</span></button>
<h2>会话历史</h2>
<div id="session-list"></div>
</div>
<div id="main">
<header>
<h1>Genesis 概要设计书 Agent</h1>
<div class="proj-switcher" id="proj-switcher">
<button class="ps-current" id="ps-current" type="button" title="切换项目">
<span class="ps-label">项目</span>
<span class="ps-name" id="ps-name">未选择</span>
<span class="ps-caret"></span>
</button>
<div class="ps-menu" id="ps-menu" role="menu"></div>
</div>
<span id="sid-badge">未创建会话</span>
</header>
<div id="chat"></div>
<div id="upload-bar">
<div id="upload-bar-inner">
<span id="proj-hint"></span>
<span>📎 上传资料:</span>
<select id="file-type">
<option value="requirements">要件定义 xlsx(必需)</option>
<option value="template">概要设计模板 docx(必需)</option>
<option value="write_instruction">做成说明书 docx</option>
<option value="rules">记入/图表规则 docx/xlsx</option>
<option value="existing_system">既有系统 zip(追加改修)</option>
</select>
<label class="file-pick" for="file-input">选择文件</label>
<input type="file" id="file-input" style="display:none">
<button id="upload-btn">上传</button>
<span id="upload-status"></span>
</div>
</div>
<div id="composer">
<div id="composer-inner">
<input id="input" placeholder="输入指令,如:上传了文件,生成概要设计书 / 现在什么状态?" autocomplete="off">
<button id="send">发送</button>
</div>
</div>
</div>
<div id="proj-drawer" role="dialog" aria-label="项目配置">
<div class="drawer-head">
<h2>项目配置</h2>
<button class="drawer-close" id="drawer-close" title="关闭">×</button>
</div>
<div class="drawer-body">
<div class="drawer-list" id="drawer-list"></div>
<div class="drawer-form" id="drawer-form">
<div class="drawer-hint">选择左侧项目以查看与编辑,或点 <strong>+ 新建项目</strong> 创建一个。</div>
<div class="field">
<label>项目名(英文/唯一)</label>
<input id="pf-name" placeholder="stock">
</div>
<div class="field">
<label>显示名(可选)</label>
<input id="pf-display" placeholder="股票系统">
</div>
<div class="field">
<label>模板 docx 路径</label>
<input id="pf-template" placeholder="/abs/path/template.docx">
</div>
<div class="field">
<label>做成说明书 docx 路径</label>
<input id="pf-write" placeholder="/abs/path/write_instruction.docx">
</div>
<div class="field">
<label>规则 docx 目录(逗号分隔)</label>
<input id="pf-rules" placeholder="/abs/path/rules, /abs/path/diagrams">
</div>
<div class="field">
<label>既有系统代码库目录</label>
<input id="pf-code" placeholder="/abs/path/existing-system">
</div>
<div class="field">
<label>既有设计文档目录</label>
<input id="pf-design" placeholder="/abs/path/design-docs">
</div>
<div class="drawer-actions">
<button class="btn-save" id="pf-save">保存项目</button>
<button class="btn-delete" id="pf-delete">删除</button>
</div>
</div>
</div>
</div>
<script>
let sid = null;
let currentProject = null;
let activeProject = null;
let projects = [];
let drawerOpen = false;
let drawerSelected = null; // 当前抽屉中编辑的项目名(null = 新建模式)
const chatEl = document.getElementById('chat');
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('send');
const badge = document.getElementById('sid-badge');
const psMenu = document.getElementById('ps-menu');
const psName = document.getElementById('ps-name');
const drawer = document.getElementById('proj-drawer');
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 esc(s) { return String(s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
function showTyping() { return addMsg('assistant', '<span class="typing">思考中…</span>'); }
async function api(method, url, body) {
const opt = { method, headers: {} };
if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); }
const r = await fetch(url, opt);
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.detail?.message || r.status);
return data;
}
// ---------- 会话列表 ----------
async function refreshSessions() {
const list = await api('GET', '/api/sessions?user_id=default');
const box = document.getElementById('session-list');
box.innerHTML = '';
list.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''));
for (const s of list) {
const el = document.createElement('div');
el.className = 'sess' + (s.session_id === sid ? ' active' : '');
const proj = s.project ? ' · ' + esc(s.project) : '';
const avatar = (s.name || '新').trim().charAt(0).toUpperCase();
el.innerHTML =
'<div class="avatar">' + esc(avatar) + '</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="删除">×</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);
}
}
async function deleteSession(targetSid) {
if (!confirm('确认删除该会话?此操作不可撤销。')) return;
try {
await api('DELETE', '/api/sessions/' + targetSid);
} catch (e) {
addMsg('error', '删除失败:' + esc(String(e)));
return;
}
if (targetSid === sid) {
// 删除的是当前会话 → 自动新建并清空
await newSession();
} else {
await refreshSessions();
}
}
// ---------- 项目配置 ----------
async function loadProjects() {
try {
projects = await api('GET', '/api/projects');
} catch (e) {
projects = [];
}
// 自动绑首项:仅在 currentProject 仍为 null(未初始化)时设置
// 若用户已显式选过(即便选"不选择"),不覆盖其选择
if (currentProject === null && projects.length > 0) {
currentProject = projects[0].name;
} else if (currentProject && !projects.find(p => p.name === currentProject)) {
// 之前绑定的项目被外部删除 → 回退到首项(若存在)
currentProject = projects.length > 0 ? projects[0].name : null;
}
applyProjectContext();
renderProjectSwitcher();
renderDrawerList();
if (drawerOpen) loadDrawerForm(drawerSelected);
}
function applyProjectContext() {
psName.textContent = currentProject || '未选择';
}
function renderProjectSwitcher() {
let html = '<div class="ps-item' + (currentProject === null ? ' active' : '') + '" data-name="">'
+ '<span>不选择项目</span><span class="ps-check">✓</span></div>';
for (const p of projects) {
html += '<div class="ps-item' + (p.name === currentProject ? ' active' : '') + '" data-name="' + esc(p.name) + '">'
+ '<span>' + esc(p.display_name || p.name) + '</span><span class="ps-check">✓</span></div>';
}
html += '<div class="ps-sep"></div>'
+ '<div class="ps-item ps-manage" data-manage="1">+ 项目管理(打开抽屉)</div>';
psMenu.innerHTML = html;
psMenu.querySelectorAll('.ps-item[data-name]').forEach(it => {
it.onclick = () => {
currentProject = it.dataset.name === '' ? null : it.dataset.name;
applyProjectContext();
renderProjectSwitcher();
updateUploadBar();
toggleProjectSwitcher(false);
};
});
psMenu.querySelectorAll('.ps-item[data-manage]').forEach(it => {
it.onclick = () => {
toggleProjectSwitcher(false);
openProjectDrawer(drawerSelected);
};
});
}
function toggleProjectSwitcher(force) {
const willOpen = force === undefined ? !psMenu.classList.contains('open') : !!force;
psMenu.classList.toggle('open', willOpen);
}
// ---------- 抽屉 ----------
function openProjectDrawer(projectName) {
drawerOpen = true;
toggleProjectSwitcher(false);
drawer.classList.add('open');
loadDrawerForm(projectName === undefined ? drawerSelected : projectName);
}
function closeProjectDrawer() {
drawerOpen = false;
drawer.classList.remove('open');
}
function loadDrawerForm(projectName) {
drawerSelected = (projectName === undefined) ? drawerSelected : projectName;
const nameEl = document.getElementById('pf-name');
const dispEl = document.getElementById('pf-display');
const tplEl = document.getElementById('pf-template');
const wrEl = document.getElementById('pf-write');
const rulesEl = document.getElementById('pf-rules');
const codeEl = document.getElementById('pf-code');
const designEl = document.getElementById('pf-design');
const delBtn = document.getElementById('pf-delete');
if (drawerSelected) {
const p = projects.find(x => x.name === drawerSelected);
if (p) {
nameEl.value = p.name; nameEl.readOnly = true;
dispEl.value = p.display_name || '';
tplEl.value = p.template || '';
wrEl.value = p.write_instruction || '';
rulesEl.value = (p.rules || []).join(', ');
codeEl.value = p.existing_system_code_dir || '';
designEl.value = p.design_docs_dir || '';
delBtn.style.display = '';
}
} else {
nameEl.value = ''; nameEl.readOnly = false;
dispEl.value = '';
tplEl.value = ''; wrEl.value = ''; rulesEl.value = ''; codeEl.value = ''; designEl.value = '';
delBtn.style.display = 'none';
}
renderDrawerList();
}
function renderDrawerList() {
let html = '<div class="drawer-item' + (drawerSelected === null ? ' active' : '') + '" data-name="">'
+ '<span class="di-new">+</span><span class="di-name">新建项目</span></div>';
for (const p of projects) {
html += '<div class="drawer-item' + (p.name === drawerSelected ? ' active' : '') + '" data-name="' + esc(p.name) + '">'
+ '<span class="di-name">' + esc(p.display_name || p.name) + '</span></div>';
}
const box = document.getElementById('drawer-list');
box.innerHTML = html;
box.querySelectorAll('.drawer-item').forEach(it => {
it.onclick = () => {
const n = it.dataset.name === '' ? null : it.dataset.name;
loadDrawerForm(n);
};
});
}
async function saveDrawerProject() {
const body = {
name: document.getElementById('pf-name').value.trim(),
display_name: document.getElementById('pf-display').value.trim(),
template: document.getElementById('pf-template').value.trim(),
write_instruction: document.getElementById('pf-write').value.trim(),
rules: document.getElementById('pf-rules').value.split(',').map(s => s.trim()).filter(Boolean),
existing_system_code_dir: document.getElementById('pf-code').value.trim(),
design_docs_dir: document.getElementById('pf-design').value.trim(),
};
if (!body.name) { addMsg('error', '项目名不能为空'); return; }
try {
const cfg = await api('POST', '/api/projects', body);
currentProject = cfg.name;
drawerSelected = cfg.name;
await loadProjects();
addMsg('assistant', '项目「' + esc(cfg.display_name || cfg.name) + '」已保存。');
closeProjectDrawer();
} catch (e) {
addMsg('error', '保存项目失败:' + esc(String(e)));
}
}
async function deleteDrawerProject() {
if (!drawerSelected) return;
if (!confirm('确认删除项目「' + drawerSelected + '」?此操作不可撤销。')) return;
try {
await api('DELETE', '/api/projects/' + drawerSelected);
} catch (e) {
addMsg('error', '删除项目失败:' + esc(String(e)));
return;
}
addMsg('assistant', '项目「' + esc(drawerSelected) + '」已删除。');
drawerSelected = null;
if (currentProject === drawerSelected) {
// 触发 loadProjects 中的回退逻辑
currentProject = null;
}
await loadProjects();
loadDrawerForm(null);
}
// ---------- 侧边栏折叠 ----------
function toggleSidebar(force) {
const el = document.getElementById('sidebar');
const willCollapse = force === undefined ? !el.classList.contains('collapsed') : !!force;
el.classList.toggle('collapsed', willCollapse);
try { localStorage.setItem('genesis_sidebar_collapsed', willCollapse ? '1' : '0'); } catch (e) { /* localStorage 不可用时忽略 */ }
}
// ---------- 上传区:项目绑定时仅要件定义 ----------
function updateUploadBar() {
const sel = document.getElementById('file-type');
const hint = document.getElementById('proj-hint');
if (activeProject || currentProject) {
sel.style.display = 'none';
hint.textContent = '(项目「' + esc(activeProject || currentProject) + '」已提供模板/规则/代码库,仅需上传要件定义)';
} else {
sel.style.display = '';
hint.textContent = '';
}
}
// ---------- 会话创建/加载 ----------
async function newSession() {
const d = await api('POST', '/api/sessions', { user_id: 'default', project: currentProject || null });
sid = d.session_id;
activeProject = d.project || null;
badge.textContent = '会话: ' + (d.name || '新会话');
chatEl.innerHTML = '';
updateUploadBar();
await refreshSessions();
try { localStorage.setItem('genesis_session', sid); } catch (e) { /* 忽略 */ }
addMsg('assistant', '你好!我是 Genesis 概要设计书生成 Agent。<br>请先在上方📎上传<strong>要件定义 xlsx</strong>'
+ (activeProject ? '(模板/规则/代码库已由项目「' + esc(activeProject) + '」提供)' : '与<strong>模板 docx</strong>')
+ ',然后告诉我:<br>· 「生成概要设计书」(自动解析→影响→生成→QA)<br>· 「用中文生成」 / 「现在什么状态?」');
}
async function loadSession(id) {
try {
const rec = await api('GET', '/api/sessions/' + id);
sid = id;
activeProject = rec.project || null;
badge.textContent = '会话: ' + (rec.name || '新会话');
const msgs = await api('GET', '/api/chat/' + id + '/messages');
chatEl.innerHTML = '';
for (const m of msgs) {
if (m.role === 'user') addMsg('user', esc(m.content));
else if (m.action === 'confirm' || m.action === 'reject') addMsg('assistant', esc(m.content));
else addMsg('assistant', esc(m.content));
}
updateUploadBar();
await refreshSessions();
try { localStorage.setItem('genesis_session', sid); } catch (e) { /* 忽略 */ }
} catch (e) {
addMsg('error', '加载会话失败:' + esc(String(e)));
}
}
async function send() {
const text = inputEl.value.trim();
if (!text || !sid) return;
inputEl.value = '';
addMsg('user', esc(text));
const typing = showTyping();
sendBtn.disabled = true;
try {
const res = await api('POST', '/api/chat/' + sid + '/messages', { content: text });
typing.remove();
if (res.progress && res.progress.length) {
const items = res.progress.map(p => '<span class="progress-item ' + (p.status || 'ok') + '">' + esc(p.step) + ': ' + esc(p.detail || '') + '</span>').join('');
addMsg('progress', items);
}
let replyHtml = esc(res.reply || '');
const s = res.status;
if (s === 'done' || s === 'writing') {
replyHtml += '<div class="actions">'
+ '<a href="/api/sessions/' + sid + '/result/download">下载 docx</a>'
+ '<a href="/api/sessions/' + sid + '/result/preview">预览</a>'
+ '<a href="/api/sessions/' + sid + '/result/impact-report">影响调查书</a>'
+ '<a href="/api/sessions/' + sid + '/result/qa-report">QA 报告</a></div>';
}
addMsg('assistant', replyHtml);
} catch (e) {
typing.remove();
addMsg('error', '请求失败:' + esc(String(e)));
} finally {
sendBtn.disabled = false;
inputEl.focus();
}
}
document.getElementById('send').addEventListener('click', send);
inputEl.addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
document.getElementById('new-chat').addEventListener('click', () => newSession().catch(e => addMsg('error', esc(String(e)))));
document.getElementById('sidebar-toggle').addEventListener('click', () => toggleSidebar());
document.getElementById('ps-current').addEventListener('click', (e) => { e.stopPropagation(); toggleProjectSwitcher(); });
document.getElementById('drawer-close').addEventListener('click', closeProjectDrawer);
document.getElementById('pf-save').addEventListener('click', saveDrawerProject);
document.getElementById('pf-delete').addEventListener('click', deleteDrawerProject);
document.addEventListener('click', (e) => {
// 点击切换器外部关闭下拉
if (!document.getElementById('proj-switcher').contains(e.target)) toggleProjectSwitcher(false);
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (psMenu.classList.contains('open')) toggleProjectSwitcher(false);
else if (drawerOpen) closeProjectDrawer();
}
});
document.getElementById('upload-btn').addEventListener('click', async () => {
let ft = document.getElementById('file-type').value;
if (activeProject || currentProject) ft = 'requirements';
const file = document.getElementById('file-input').files[0];
if (!sid) { addMsg('error', '请先创建会话'); return; }
if (!file) { addMsg('error', '请选择文件'); return; }
const fd = new FormData();
fd.append('file_type', ft);
fd.append('file', file);
const statusEl = document.getElementById('upload-status');
statusEl.textContent = '上传中…';
try {
const r = await fetch('/api/sessions/' + sid + '/files', { method: 'POST', body: fd });
const d = await r.json();
if (!r.ok) throw new Error(d.detail?.message || r.status);
statusEl.textContent = '';
addMsg('user', '[上传] ' + ft + '' + d.file_name);
addMsg('progress', '<span class="progress-item ok">上传完成:' + esc(d.file_name) + '' + d.size + 'B</span>');
document.getElementById('file-input').value = '';
const rec = await api('GET', '/api/sessions/' + sid);
badge.textContent = '会话: ' + (rec.name || '新会话');
await refreshSessions();
} catch (e) {
statusEl.textContent = '';
addMsg('error', '上传失败:' + esc(String(e)));
}
});
// ---------- 启动:恢复侧边栏折叠态、上次会话 ----------
(async () => {
try {
const collapsed = localStorage.getItem('genesis_sidebar_collapsed') === '1';
if (collapsed) document.getElementById('sidebar').classList.add('collapsed');
} catch (e) { /* 忽略 */ }
await loadProjects();
const saved = (() => { try { return localStorage.getItem('genesis_session'); } catch (e) { return null; } })();
if (saved) {
try { await loadSession(saved); return; } catch (e) { /* 忽略,新建 */ }
}
newSession().catch(e => addMsg('error', esc(String(e))));
})();
</script>
</body>
</html>