refactor(frontend): 拆分 chat.html 内联 CSS 到 chat.css(机械拆分,行为零变更)
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
/* src/genesis/server/static/chat.css
|
||||
v0 拆分骨架 - 视觉与原 chat.html 内联样式完全一致
|
||||
v1 视觉重塑见 docs/superpowers/plans/2026-08-30-frontend-beautify.md Task 2
|
||||
*/
|
||||
: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);
|
||||
--font-ui: 'Inter', system-ui, -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
* { 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 */
|
||||
#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 */
|
||||
#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 */
|
||||
#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 */
|
||||
#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 */
|
||||
#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 */
|
||||
#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; }
|
||||
|
||||
/* Drawer */
|
||||
#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; }
|
||||
}
|
||||
@@ -4,392 +4,7 @@
|
||||
<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; }
|
||||
#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 控制条(P0‑A):紧贴 sid-badge,胶囊样式 */
|
||||
#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;
|
||||
}
|
||||
/* P3‑4:上传类型的 label 在视觉上隐藏(保留可访问性) */
|
||||
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; }
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/chat.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="sidebar">
|
||||
@@ -506,710 +121,5 @@
|
||||
|
||||
<script src="/chat_state.js"></script>
|
||||
<script src="/chat_ws.js"></script>
|
||||
<script>
|
||||
let sid = null;
|
||||
let progressWs = null;
|
||||
let draftProject = null; // 下一空白会话将绑定/已绑的项目(原 currentProject)
|
||||
let activeProject = null; // 当前已落库会话绑定的项目(来自后端)
|
||||
let projects = [];
|
||||
let drawerOpen = false;
|
||||
let drawerSelected = null; // 当前抽屉中编辑的项目名(null = 新建模式)
|
||||
let lastTriggerEl = null; // 打开抽屉/下拉前的焦点元素(C1)
|
||||
let drawerDirty = false; // 抽屉表单是否有未保存改动(B6)
|
||||
let drawerSnapshot = null; // loadDrawerForm 时的字段快照(B6)
|
||||
|
||||
// 状态逻辑统一走 chat_state.js;若静态路由未就绪则回退内联实现,避免整页脚本崩溃
|
||||
const GenesisState = window.GenesisState || {
|
||||
autoBindProject: (d, p) => (d === null && p.length ? p[0].name : (d && !p.find(x => x.name === d) ? (p.length ? p[0].name : null) : d)),
|
||||
shouldHideUploadSelect: () => false,
|
||||
resolveUploadType: (_a, _d, v) => v,
|
||||
computeDrawerSnapshot: (f) => f.map(x => (x == null ? '' : String(x)).trim()).join(String.fromCharCode(1)),
|
||||
isDrawerDirty: (n, s) => n !== s,
|
||||
buildWelcome: (d) => d === null ? '请新建项目或者选择项目'
|
||||
: '你好!我是 Genesis 概要设计书生成 Agent。请先上传要件定义 xlsx(模板/规则/代码库已由项目「' + d + '」提供)。',
|
||||
};
|
||||
|
||||
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 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 || ''));
|
||||
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) : '';
|
||||
// P3‑1:取首字符(含中文/日文/英文)作 avatar,并用名字 hash 选色,避免重名混淆
|
||||
const rawName = (s.name || '新').trim();
|
||||
const avatar = rawName ? Array.from(rawName)[0] : '新';
|
||||
const palette = ['#1a73e8', '#34a853', '#fbbc05', '#ea4335', '#8e24aa', '#0097a7', '#5f6368'];
|
||||
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 + ';">' + 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="删除会话 ' + 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);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(targetSid) {
|
||||
if (!confirm('确认删除该会话?此操作不可撤销。')) return;
|
||||
try {
|
||||
await api('DELETE', '/api/sessions/' + encodeURIComponent(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 = [];
|
||||
}
|
||||
// 启动自动绑首项 / 已删项目回退首项(统一由 GenesisState.autoBindProject 处理)
|
||||
draftProject = GenesisState.autoBindProject(draftProject, projects);
|
||||
applyProjectContext();
|
||||
renderProjectSwitcher();
|
||||
renderDrawerList();
|
||||
if (drawerOpen) loadDrawerForm(drawerSelected);
|
||||
}
|
||||
|
||||
function applyProjectContext() {
|
||||
psName.textContent = draftProject || '未选择';
|
||||
}
|
||||
|
||||
function renderProjectSwitcher() {
|
||||
let html = '<div class="ps-item' + (draftProject === null ? ' active' : '') + '" data-name="">'
|
||||
+ '<span>不选择项目</span><span class="ps-check">✓</span></div>';
|
||||
for (const p of projects) {
|
||||
html += '<div class="ps-item' + (p.name === draftProject ? ' 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 = () => {
|
||||
if (!guardDrawerDirty()) return;
|
||||
draftProject = it.dataset.name === '' ? null : it.dataset.name;
|
||||
applyProjectContext();
|
||||
renderProjectSwitcher();
|
||||
updateUploadBar();
|
||||
toggleProjectSwitcher(false);
|
||||
};
|
||||
});
|
||||
psMenu.querySelectorAll('.ps-item[data-manage]').forEach(it => {
|
||||
it.onclick = () => {
|
||||
if (!guardDrawerDirty()) return;
|
||||
lastTriggerEl = it;
|
||||
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');
|
||||
const sel = (projectName === undefined || projectName === null) ? drawerSelected : projectName;
|
||||
try { loadDrawerForm(sel); } catch (e) { console.error(e); }
|
||||
renderDrawerList(); // 始终渲染左侧列表(含 +新建项目)
|
||||
}
|
||||
|
||||
function closeProjectDrawer() {
|
||||
if (!guardDrawerDirty()) return;
|
||||
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.hidden = false;
|
||||
}
|
||||
} else {
|
||||
nameEl.value = ''; nameEl.readOnly = false;
|
||||
dispEl.value = '';
|
||||
tplEl.value = ''; wrEl.value = ''; rulesEl.value = ''; codeEl.value = ''; designEl.value = '';
|
||||
delBtn.hidden = true;
|
||||
}
|
||||
// B6:记录字段快照用于脏检测
|
||||
drawerSnapshot = drawerSnapshotNow();
|
||||
renderDrawerList();
|
||||
}
|
||||
|
||||
// B6:抽屉未保存变更守卫
|
||||
function drawerSnapshotNow() {
|
||||
return GenesisState.computeDrawerSnapshot([
|
||||
document.getElementById('pf-name').value,
|
||||
document.getElementById('pf-display').value,
|
||||
document.getElementById('pf-template').value,
|
||||
document.getElementById('pf-write').value,
|
||||
document.getElementById('pf-rules').value,
|
||||
document.getElementById('pf-code').value,
|
||||
document.getElementById('pf-design').value,
|
||||
]);
|
||||
}
|
||||
function isDrawerDirty() {
|
||||
if (!drawerOpen || drawerSnapshot === null) return false;
|
||||
return GenesisState.isDrawerDirty(drawerSnapshotNow(), drawerSnapshot);
|
||||
}
|
||||
function guardDrawerDirty() {
|
||||
if (isDrawerDirty()) return confirm('有未保存的修改,确定放弃?');
|
||||
return true;
|
||||
}
|
||||
|
||||
// P0‑C:抽屉字段级红框。命中关键字 → 给对应输入加 .invalid 3 秒后清除。
|
||||
// 关键字与 store.py 的 ProjectConfigError 中文 label 对齐。
|
||||
const PF_LABEL_TO_FIELD = {
|
||||
'项目名': 'pf-name',
|
||||
'模板': 'pf-template',
|
||||
'做成说明书': 'pf-write',
|
||||
'规则': 'pf-rules',
|
||||
'既有系统代码库': 'pf-code',
|
||||
'既有设计文档目录': 'pf-design',
|
||||
};
|
||||
function clearPfInvalid() {
|
||||
['pf-name','pf-display','pf-template','pf-write','pf-rules','pf-code','pf-design']
|
||||
.forEach(id => document.getElementById(id).classList.remove('invalid'));
|
||||
}
|
||||
function flagPfInvalidByMessage(message) {
|
||||
if (!message) return;
|
||||
for (const [label, fid] of Object.entries(PF_LABEL_TO_FIELD)) {
|
||||
if (message.indexOf(label) >= 0) {
|
||||
const el = document.getElementById(fid);
|
||||
el.classList.add('invalid');
|
||||
el.focus();
|
||||
setTimeout(() => el.classList.remove('invalid'), 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 任意输入清掉自身红框(避免"红着输入"误导)
|
||||
['pf-name','pf-display','pf-template','pf-write','pf-rules','pf-code','pf-design']
|
||||
.forEach(id => document.getElementById(id).addEventListener('input', (e) => e.target.classList.remove('invalid')));
|
||||
|
||||
function renderDrawerList() {
|
||||
let html = '<div class="drawer-item di-create' + (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 = () => {
|
||||
if (!guardDrawerDirty()) return;
|
||||
const n = it.dataset.name === '' ? null : it.dataset.name;
|
||||
loadDrawerForm(n);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function saveDrawerProject() {
|
||||
clearPfInvalid();
|
||||
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) {
|
||||
flagPfInvalidByMessage('项目名');
|
||||
addMsg('error', '项目名不能为空');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cfg = await api('POST', '/api/projects', body);
|
||||
draftProject = cfg.name;
|
||||
drawerSelected = cfg.name;
|
||||
await loadProjects();
|
||||
addMsg('assistant', '项目「' + esc(cfg.display_name || cfg.name) + '」已保存。');
|
||||
closeProjectDrawer();
|
||||
} catch (e) {
|
||||
// 400 / PROJECT_CONFIG_INVALID:字段级红框定位
|
||||
flagPfInvalidByMessage(String(e));
|
||||
addMsg('error', '保存项目失败:' + esc(String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDrawerProject() {
|
||||
if (!drawerSelected) return;
|
||||
if (!confirm('确认删除项目「' + drawerSelected + '」?此操作不可撤销。')) return;
|
||||
const deleted = drawerSelected;
|
||||
try {
|
||||
await api('DELETE', '/api/projects/' + encodeURIComponent(deleted));
|
||||
} catch (e) {
|
||||
addMsg('error', '删除项目失败:' + esc(String(e)));
|
||||
return;
|
||||
}
|
||||
addMsg('assistant', '项目「' + esc(deleted) + '」已删除。');
|
||||
// P1‑F:先记住被删的,再清 drawerSelected;避免"先 null 后比较"恒假 bug
|
||||
drawerSelected = null;
|
||||
if (draftProject === deleted) {
|
||||
// 当前选的就是被删的项目 → 清空偏好(autoBindProject 会从剩余项目重选)
|
||||
draftProject = 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 不可用时忽略 */ }
|
||||
}
|
||||
|
||||
// ---------- 上传区:始终显示完整类型下拉 ----------
|
||||
// P0‑B:选项目时也允许补传 template / write_instruction / rules / existing_system,
|
||||
// 不再藏起入口。"项目已预置 template 时再上传 template"在上传处用 confirm() 显式确认。
|
||||
function updateUploadBar() {
|
||||
const sel = document.getElementById('file-type');
|
||||
const hint = document.getElementById('proj-hint');
|
||||
if (sel) sel.style.display = '';
|
||||
if (!hint) return;
|
||||
// 默认 file_type 仍为 requirements(最常用),但下拉始终可改。
|
||||
if (sel && sel.value !== 'requirements') sel.value = 'requirements';
|
||||
if (draftProject) {
|
||||
hint.textContent = '默认上传要件定义 xlsx;如需补传模板/规则/既有系统 zip,请在下拉中选择对应类型。';
|
||||
} else {
|
||||
hint.textContent = '请选择 file_type 后上传(未选项目时,模板/规则仍可手动上传)。';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 空态/欢迎渲染 ----------
|
||||
function renderEmptyOrWelcome() {
|
||||
chatEl.innerHTML = '';
|
||||
// P1‑F:欢迎文由 GenesisState.buildWelcome 统一来源
|
||||
addMsg('assistant', GenesisState.buildWelcome(draftProject));
|
||||
}
|
||||
|
||||
// ---------- 会话创建/加载 ----------
|
||||
async function newSession() {
|
||||
if (progressWs) { try { progressWs.close(); } catch (e) {} } progressWs = null;
|
||||
sid = null;
|
||||
activeProject = null;
|
||||
// 保留 draftProject(当前顶栏已选项目),使新会话继承项目绑定(§4.2)
|
||||
try { localStorage.removeItem('genesis_session'); } catch (e) { /* 忽略 */ }
|
||||
chatEl.innerHTML = '';
|
||||
badge.textContent = '未创建会话';
|
||||
updateUploadBar();
|
||||
await refreshRagStats();
|
||||
await refreshSessions();
|
||||
renderEmptyOrWelcome();
|
||||
}
|
||||
|
||||
async function loadSession(id) {
|
||||
try {
|
||||
const rec = await api('GET', '/api/sessions/' + encodeURIComponent(id));
|
||||
sid = id;
|
||||
activeProject = rec.project || null;
|
||||
// P1‑D:不再隐式覆盖 draftProject。会话事实(activeProject)与用户偏好(draftProject)
|
||||
// 分层;若不一致,由提示条让用户主动选择。
|
||||
// draftProject = rec.project || null; // 删除:原行为会悄悄改用户偏好
|
||||
applyProjectContext();
|
||||
badge.textContent = '会话: ' + (rec.name || '新会话');
|
||||
if (progressWs) { try { progressWs.close(); } catch (e) {} }
|
||||
progressWs = GenesisWS.connectProgressWs(id, {
|
||||
onProgress: (e) => GenesisWS.applyProgressEvent(e, (role, text) => addMsg(role, text)),
|
||||
onClose: () => {},
|
||||
});
|
||||
const msgs = await api('GET', '/api/chat/' + encodeURIComponent(id) + '/messages');
|
||||
chatEl.innerHTML = '';
|
||||
for (const m of msgs) {
|
||||
const roleMap = { user: 'user', progress: 'progress', error: 'error' };
|
||||
const role = roleMap[m.role] || 'assistant';
|
||||
addMsg(role, esc(m.content));
|
||||
}
|
||||
updateUploadBar();
|
||||
await refreshRagStats();
|
||||
await refreshSessions();
|
||||
try { localStorage.setItem('genesis_session', sid); } catch (e) { /* 忽略 */ }
|
||||
renderProjectMismatchHint();
|
||||
} catch (e) {
|
||||
addMsg('error', '加载会话失败:' + esc(String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
// P1‑D:项目不匹配提示条。activeProject 与 draftProject 不一致时显示,
|
||||
// "切到该项目" → 写 draftProject = activeProject,刷新;
|
||||
// "保留" → 写入 sessionStorage 标记,刷新;后续 loadSession 不再提示。
|
||||
function renderProjectMismatchHint() {
|
||||
const old = document.getElementById('proj-mismatch-hint');
|
||||
if (old) old.remove();
|
||||
if (!sid) return;
|
||||
if (!activeProject) return;
|
||||
if (activeProject === draftProject) return;
|
||||
// 已"保留"过本会话的不再提示
|
||||
let skipped = {};
|
||||
try { skipped = JSON.parse(sessionStorage.getItem('genesis_skipped_project_hint') || '{}'); } catch (e) { skipped = {}; }
|
||||
if (skipped[sid] === activeProject) return;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.id = 'proj-mismatch-hint';
|
||||
wrap.className = 'msg assistant';
|
||||
wrap.innerHTML =
|
||||
'<div class="bubble">' +
|
||||
'该会话属于项目 <strong>' + esc(activeProject) + '</strong>,' +
|
||||
'当前顶栏项目为 <strong>' + esc(draftProject || '(未选择)') + '</strong>。' +
|
||||
'<div class="actions">' +
|
||||
'<a href="#" data-act="switch">切到该项目</a>' +
|
||||
'<a href="#" data-act="keep">保留当前项目</a>' +
|
||||
'</div></div>';
|
||||
chatEl.prepend(wrap);
|
||||
wrap.querySelector('[data-act="switch"]').onclick = (e) => {
|
||||
e.preventDefault();
|
||||
draftProject = activeProject;
|
||||
applyProjectContext();
|
||||
renderProjectSwitcher();
|
||||
updateUploadBar();
|
||||
refreshSessions();
|
||||
wrap.remove();
|
||||
};
|
||||
wrap.querySelector('[data-act="keep"]').onclick = (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
skipped[sid] = activeProject;
|
||||
sessionStorage.setItem('genesis_skipped_project_hint', JSON.stringify(skipped));
|
||||
} catch (er) { /* 忽略 */ }
|
||||
wrap.remove();
|
||||
};
|
||||
}
|
||||
|
||||
// P2‑H:send() in-flight 锁,防止快速双击产生重复请求
|
||||
let sendInFlight = false;
|
||||
async function send() {
|
||||
if (sendInFlight) return; // in-flight 期间再次进入直接忽略
|
||||
const text = inputEl.value.trim();
|
||||
if (!text) return;
|
||||
sendInFlight = true;
|
||||
if (!sid) {
|
||||
// 首条消息:先落库会话(D-v4)
|
||||
try {
|
||||
const d = await api('POST', '/api/sessions', { user_id: 'default', project: draftProject || null });
|
||||
sid = d.session_id;
|
||||
activeProject = draftProject || null;
|
||||
badge.textContent = '会话: ' + (d.name || '新会话');
|
||||
try { localStorage.setItem('genesis_session', sid); } catch (e) { /* 忽略 */ }
|
||||
await refreshSessions();
|
||||
} catch (e) {
|
||||
addMsg('error', '创建会话失败:' + esc(String(e)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (progressWs) { try { progressWs.close(); } catch (e) {} }
|
||||
progressWs = GenesisWS.connectProgressWs(sid, {
|
||||
onProgress: (e) => GenesisWS.applyProgressEvent(e, (role, text) => addMsg(role, text)),
|
||||
onClose: () => {},
|
||||
});
|
||||
inputEl.value = '';
|
||||
addMsg('user', esc(text));
|
||||
const typing = showTyping();
|
||||
sendBtn.disabled = true;
|
||||
try {
|
||||
const res = await api('POST', '/api/chat/' + encodeURIComponent(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 {
|
||||
sendInFlight = false;
|
||||
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(); lastTriggerEl = e.currentTarget; toggleProjectSwitcher(); });
|
||||
document.getElementById('drawer-close').addEventListener('click', closeProjectDrawer);
|
||||
document.getElementById('pf-save').addEventListener('click', saveDrawerProject);
|
||||
document.getElementById('pf-delete').addEventListener('click', deleteDrawerProject);
|
||||
|
||||
// ---------- P0‑A:RAG 开关 + 状态徽标 + 开始影响调查 ----------
|
||||
const ragEnabledEl = document.getElementById('rag-enabled');
|
||||
const ragStatsEl = document.getElementById('rag-stats');
|
||||
const ragBarEl = document.getElementById('rag-bar');
|
||||
const impactBtnEl = document.getElementById('impact-btn');
|
||||
|
||||
const RAG_LS_KEY = 'genesis_rag_enabled';
|
||||
function readRagFlag() {
|
||||
try { return localStorage.getItem(RAG_LS_KEY) === '1'; } catch (e) { return false; }
|
||||
}
|
||||
function writeRagFlag(v) {
|
||||
try { localStorage.setItem(RAG_LS_KEY, v ? '1' : '0'); } catch (e) { /* 忽略 */ }
|
||||
}
|
||||
ragEnabledEl.addEventListener('change', () => { writeRagFlag(ragEnabledEl.checked); updateImpactButton(); });
|
||||
|
||||
async function refreshRagStats() {
|
||||
// 无 sid → 隐藏 RAG 条
|
||||
if (!sid) {
|
||||
ragBarEl.hidden = true;
|
||||
return;
|
||||
}
|
||||
ragBarEl.hidden = false;
|
||||
let stats = { chunks: 0, rag_enabled: false };
|
||||
try {
|
||||
stats = await api('GET', '/api/sessions/' + encodeURIComponent(sid) + '/rag-stats');
|
||||
} catch (e) {
|
||||
stats = { chunks: 0, rag_enabled: false };
|
||||
}
|
||||
const n = Number(stats.chunks) || 0;
|
||||
ragStatsEl.textContent = n > 0
|
||||
? 'RAG 已索引 ' + n + ' 片段'
|
||||
: 'RAG 0 片段(请上传既有系统 zip)';
|
||||
ragStatsEl.classList.toggle('ready', n > 0);
|
||||
ragStatsEl.classList.toggle('zero', n === 0);
|
||||
// 开关可用性:必须先有 RAG 索引才能用
|
||||
ragEnabledEl.disabled = n === 0;
|
||||
if (n === 0) {
|
||||
ragEnabledEl.checked = false;
|
||||
writeRagFlag(false);
|
||||
ragEnabledEl.parentElement.title = '请先在上传区选择"既有系统 zip"并上传';
|
||||
} else {
|
||||
ragEnabledEl.checked = readRagFlag();
|
||||
ragEnabledEl.parentElement.title = ragEnabledEl.checked
|
||||
? 'RAG 检索结果会注入到 LLM prompt'
|
||||
: '勾选后启用 RAG 检索';
|
||||
}
|
||||
updateImpactButton();
|
||||
}
|
||||
|
||||
function updateImpactButton() {
|
||||
// 已有 RAG 索引(rag-stats 决定) 且有 sid 才能影响
|
||||
if (!sid) { impactBtnEl.disabled = true; return; }
|
||||
const hasRag = (Number(ragStatsEl.dataset.chunks || 0) > 0) || !ragEnabledEl.disabled;
|
||||
impactBtnEl.disabled = !hasRag;
|
||||
impactBtnEl.textContent = ragEnabledEl.checked
|
||||
? '开始影响调查(启用 RAG)'
|
||||
: '开始影响调查';
|
||||
}
|
||||
|
||||
impactBtnEl.addEventListener('click', async () => {
|
||||
if (!sid) { addMsg('error', '请先发送一条消息以创建会话'); inputEl.focus(); return; }
|
||||
const useRag = !!ragEnabledEl.checked;
|
||||
impactBtnEl.disabled = true;
|
||||
const typing = showTyping();
|
||||
try {
|
||||
const url = '/api/sessions/' + encodeURIComponent(sid) + '/start-impact?use_rag=' + (useRag ? 'true' : 'false');
|
||||
const r = await fetch(url, { method: 'POST' });
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.detail?.message || r.status);
|
||||
typing.remove();
|
||||
addMsg('progress', '<span class="progress-item ok">影响调查已提交(use_rag=' + (useRag ? 'true' : 'false') + ')</span>');
|
||||
// 让 WebSocket 把后续进度推上来;稍后用户可查 impact-result
|
||||
addMsg('assistant', '影响调查已启动。完成后可在此会话点击"预览"或访问 /result/impact-report 下载报告。');
|
||||
} catch (e) {
|
||||
typing.remove();
|
||||
addMsg('error', '影响调查失败:' + esc(String(e)));
|
||||
} finally {
|
||||
updateImpactButton();
|
||||
}
|
||||
});
|
||||
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();
|
||||
if (lastTriggerEl) { lastTriggerEl.focus(); lastTriggerEl = null; }
|
||||
}
|
||||
});
|
||||
|
||||
// 客户端类型 → 扩展名白名单校验(P0‑B / P1‑E)。
|
||||
const UPLOAD_EXT_RULES = {
|
||||
existing_system: ['.zip'],
|
||||
requirements: ['.xlsx'],
|
||||
template: ['.docx'],
|
||||
write_instruction: ['.docx'],
|
||||
rules: ['.docx', '.xlsx'],
|
||||
};
|
||||
function pickExt(name) {
|
||||
const i = String(name).lastIndexOf('.');
|
||||
return i >= 0 ? String(name).slice(i).toLowerCase() : '';
|
||||
}
|
||||
// 项目级预置 → 提醒:再次上传将覆盖该会话的该类型文件。
|
||||
// 这里用 activeProject/draftProject 对应的项目 config(projects[])判定是否预置。
|
||||
function projectHasPrefixedType(ft) {
|
||||
const name = activeProject || draftProject;
|
||||
if (!name) return false;
|
||||
const p = (projects || []).find(x => x.name === name);
|
||||
if (!p) return false;
|
||||
if (ft === 'template') return !!(p.template && p.template.trim());
|
||||
if (ft === 'write_instruction') return !!(p.write_instruction && p.write_instruction.trim());
|
||||
if (ft === 'existing_system') return !!(p.existing_system_code_dir && p.existing_system_code_dir.trim());
|
||||
if (ft === 'rules') return Array.isArray(p.rules) && p.rules.length > 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
document.getElementById('upload-btn').addEventListener('click', async () => {
|
||||
if (!sid) { addMsg('error', '请先发送一条消息以创建会话'); inputEl.focus(); return; }
|
||||
const sel = document.getElementById('file-type');
|
||||
const ft = GenesisState.resolveUploadType(activeProject, draftProject, sel.value);
|
||||
const file = document.getElementById('file-input').files[0];
|
||||
if (!file) { addMsg('error', '请选择文件'); return; }
|
||||
// P0‑B / P1‑E:客户端类型与扩展名校验
|
||||
const allowed = UPLOAD_EXT_RULES[ft];
|
||||
if (allowed) {
|
||||
const ext = pickExt(file.name);
|
||||
if (!ext || !allowed.includes(ext)) {
|
||||
addMsg('error', '类型不匹配:' + esc(ft) + ' 应为 ' + allowed.join('/') + ' 文件(当前:' + esc(file.name) + ')');
|
||||
return;
|
||||
}
|
||||
}
|
||||
// P0‑B:项目已预置同类型时显式确认覆盖(不再以"藏起入口"做隐式约束)
|
||||
if (projectHasPrefixedType(ft)) {
|
||||
const ok = confirm('项目已预置 ' + ft + '。继续上传将覆盖该会话中的此类型文件,是否继续?');
|
||||
if (!ok) 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/' + encodeURIComponent(sid) + '/files', { method: 'POST', body: fd });
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.detail?.message || r.status);
|
||||
statusEl.textContent = '';
|
||||
// B5 / P1‑F:上传成功后同步 activeProject、刷新上传条与侧边栏状态
|
||||
const rec = await api('GET', '/api/sessions/' + encodeURIComponent(sid));
|
||||
activeProject = rec.project || null;
|
||||
badge.textContent = '会话: ' + (rec.name || '新会话');
|
||||
await refreshSessions();
|
||||
updateUploadBar(); // P1‑F:上传成功后同步上传条(防状态滞后)
|
||||
await refreshRagStats(); // P0‑A:上传 existing_system 后 RAG 索引已建
|
||||
document.getElementById('file-input').value = ''; // 仅成功时清空
|
||||
addMsg('user', '[上传] ' + ft + ':' + d.file_name);
|
||||
addMsg('progress', '<span class="progress-item ok">上传完成:' + esc(d.file_name) + '(' + d.size + 'B)</span>');
|
||||
} catch (e) {
|
||||
statusEl.textContent = '';
|
||||
// F1:失败保留 file-input 值,展示后端明细
|
||||
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) {
|
||||
// P2‑H:启动恢复前显示 loading 占位,避免空 chat 一闪
|
||||
addMsg('assistant', '正在恢复上次会话…');
|
||||
try {
|
||||
await loadSession(saved);
|
||||
// P2‑7:恢复后若会话的项目已被删,清掉 localStorage 以免下次再试
|
||||
const rec = await api('GET', '/api/sessions/' + encodeURIComponent(saved));
|
||||
const recProj = rec && rec.project;
|
||||
if (recProj && !projects.find(p => p.name === recProj)) {
|
||||
try { localStorage.removeItem('genesis_session'); } catch (e) { /* 忽略 */ }
|
||||
// 顶栏若显示的是已删项目,刷新回退
|
||||
if (draftProject === recProj) {
|
||||
draftProject = (projects.length > 0) ? projects[0].name : null;
|
||||
applyProjectContext();
|
||||
}
|
||||
}
|
||||
return;
|
||||
} catch (e) { /* 忽略,新建 */ }
|
||||
}
|
||||
newSession().catch(e => addMsg('error', esc(String(e))));
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user