Files
2026Technology-Competition/src/genesis/server/static/chat.html
T
lhl 916c5beed7 feat(web): 项目级配置 + 会话命名/历史 + 设计文档纳入影响调查
- 会话支持 name/project 字段,上传要件定义后自动命名;前端侧边栏会话历史 + localStorage 恢复,顶部只显示会话名
- 新增 ProjectsStore(SQLite)与 /api/projects CRUD;绑定项目后 _rebuild_source 合并模板/规则/代码库/设计文档,上传区仅要件定义
- StructuredSource.design_docs 与 ImpactReport.design_references;影响调查新增既有设计文档确定性交叉引用(无 LLM)
- 同步更新 docs/design.md §12.7、README、_AI_USAGE_LOG.md;全量测试 558 通过,覆盖率 99.10%
2026-08-27 12:13:28 +08:00

339 lines
16 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">
<title>Genesis — 概要设计书自动生成(对话)</title>
<style>
* { box-sizing: border-box; }
body { font-family: "Microsoft YaHei", sans-serif; margin: 0; height: 100vh; display: flex; flex-direction: row; background: #f5f6f8; }
/* 左侧边栏 */
#sidebar { width: 250px; background: #16384d; color: #e6eef4; display: flex; flex-direction: column; flex-shrink: 0; }
#sidebar h2 { font-size: 13px; margin: 12px 14px 6px; color: #9fc2d6; letter-spacing: 1px; }
#new-chat { margin: 12px 14px 6px; background: #2e86c1; color: #fff; border: none; padding: 8px; border-radius: 8px; cursor: pointer; font-size: 14px; }
#new-chat:hover { background: #3498db; }
#session-list { flex: 1; overflow-y: auto; padding: 0 8px; }
.sess { padding: 8px 10px; border-radius: 8px; margin: 4px 0; cursor: pointer; border: 1px solid transparent; }
.sess:hover { background: #1f4d68; }
.sess.active { background: #2e86c1; }
.sess .name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sess .meta { font-size: 11px; color: #9fc2d6; }
#proj-panel { border-top: 1px solid #2c5872; padding: 8px 12px; max-height: 42%; overflow-y: auto; }
#proj-panel summary { cursor: pointer; font-size: 13px; color: #9fc2d6; outline: none; }
#proj-list { list-style: none; padding: 0; margin: 8px 0; }
#proj-list li { font-size: 12px; padding: 4px 6px; border-radius: 6px; cursor: pointer; }
#proj-list li:hover, #proj-list li.active { background: #1f4d68; }
#proj-form { display: none; margin-top: 8px; }
#proj-form.show { display: block; }
#proj-form input { width: 100%; margin: 3px 0; padding: 5px; border-radius: 5px; border: 1px solid #2c5872; background: #0f2a3b; color: #e6eef4; font-size: 12px; }
#proj-form button { margin-top: 6px; width: 100%; background: #2e86c1; color: #fff; border: none; padding: 6px; border-radius: 6px; cursor: pointer; }
/* 主区域 */
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
header { background: #1a5276; color: #fff; padding: 10px 20px; display: flex; align-items: center; gap: 12px; }
header h1 { font-size: 17px; margin: 0; }
#sid-badge { background: rgba(255,255,255,.18); border-radius: 12px; padding: 2px 10px; font-size: 12px; }
#chat { flex: 1; overflow-y: auto; padding: 20px; max-width: 860px; width: 100%; margin: 0 auto; }
.msg { display: flex; margin: 10px 0; }
.msg .bubble { max-width: 72%; padding: 10px 14px; border-radius: 14px; white-space: pre-wrap; line-height: 1.55; font-size: 14px; }
.msg.user { justify-content: flex-end; }
.msg.user .bubble { background: #1a5276; color: #fff; border-bottom-right-radius: 4px; }
.msg.assistant .bubble { background: #fff; border: 1px solid #e0e0e0; border-bottom-left-radius: 4px; }
.msg.progress .bubble { background: #eaf2f8; border: 1px dashed #7fb3d5; font-size: 13px; color: #2c3e50; }
.msg.error .bubble { background: #fdecea; border: 1px solid #e6b4b0; color: #922b21; }
.progress-item { display: block; }
.progress-item.ok::before { content: "✔ "; color: #1e8449; }
.progress-item.warn::before { content: "⚠ "; color: #b7950b; }
.actions a { display: inline-block; margin: 4px 6px 0 0; background: #1a5276; color: #fff; padding: 5px 12px; border-radius: 12px; text-decoration: none; font-size: 13px; }
#composer { border-top: 1px solid #ddd; background: #fff; padding: 12px 20px; }
#composer-inner { max-width: 860px; margin: 0 auto; display: flex; gap: 8px; align-items: center; }
#input { flex: 1; border: 1px solid #ccc; border-radius: 18px; padding: 10px 16px; font-size: 14px; outline: none; }
#send { background: #1a5276; color: #fff; border: none; border-radius: 18px; padding: 10px 20px; cursor: pointer; }
#send:disabled { opacity: .5; cursor: not-allowed; }
#upload-bar { background: #fff; border-top: 1px solid #eee; padding: 8px 20px; font-size: 13px; }
#upload-bar-inner { max-width: 860px; margin: 0 auto; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
#upload-bar select, #upload-bar input[type=file] { font-size: 13px; }
#upload-btn { background: #f0f3f5; border: 1px solid #ccc; border-radius: 12px; padding: 5px 12px; cursor: pointer; }
#proj-hint { color: #b7950b; font-size: 12px; }
.typing { color: #888; font-size: 13px; font-style: italic; }
</style>
</head>
<body>
<div id="sidebar">
<button id="new-chat">+ 新会话</button>
<h2>会话历史</h2>
<div id="session-list"></div>
<div id="proj-panel">
<details>
<summary>项目配置(模板/规则/代码库/设计文档)</summary>
<ul id="proj-list"></ul>
<button id="proj-new-btn" style="width:100%;background:#2e86c1;color:#fff;border:none;padding:6px;border-radius:6px;cursor:pointer;font-size:12px;">新建项目</button>
<div id="proj-form">
<input id="pf-name" placeholder="项目名(英文/唯一)">
<input id="pf-display" placeholder="显示名(可选)">
<input id="pf-template" placeholder="模板 docx 路径">
<input id="pf-write" placeholder="做成说明书 docx 路径">
<input id="pf-rules" placeholder="规则 docx 目录(逗号分隔)">
<input id="pf-code" placeholder="既有系统代码库目录">
<input id="pf-design" placeholder="既有设计文档目录">
<button id="pf-save">保存项目</button>
</div>
</details>
</div>
</div>
<div id="main">
<header>
<h1>Genesis 概要设计书 Agent</h1>
<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>
<input type="file" id="file-input">
<button id="upload-btn">上传</button>
<span id="upload-status" style="color:#888"></span>
</div>
</div>
<div id="composer">
<div id="composer-inner">
<input id="input" placeholder="输入指令,如:上传了文件,生成概要设计书 / 现在什么状态?" autocomplete="off">
<button id="send">发送</button>
</div>
</div>
</div>
<script>
let sid = null;
let currentProject = null; // 新建会话时绑定的项目名
let activeProject = null; // 当前会话已绑定的项目名
const chatEl = document.getElementById('chat');
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('send');
const badge = document.getElementById('sid-badge');
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) : '';
el.innerHTML = '<div class="name">' + esc(s.name || '新会话') + '</div>'
+ '<div class="meta">' + esc(s.status) + proj + '</div>';
el.onclick = () => loadSession(s.session_id);
box.appendChild(el);
}
}
// ---------- 项目配置 ----------
async function loadProjects() {
const projects = await api('GET', '/api/projects');
const ul = document.getElementById('proj-list');
ul.innerHTML = '';
for (const p of projects) {
const li = document.createElement('li');
li.textContent = p.display_name || p.name;
li.title = p.name;
if (p.name === currentProject) li.classList.add('active');
li.onclick = () => {
currentProject = (currentProject === p.name) ? null : p.name;
loadProjects();
updateUploadBar();
addMsg('assistant', currentProject
? '已选择项目「' + esc(currentProject) + '」,新建会话将复用其模板/规则/代码库配置,你只需上传要件定义。'
: '已取消项目选择,新建会话需自行上传全部资料。');
};
ul.appendChild(li);
}
}
document.getElementById('proj-new-btn').onclick = () => {
document.getElementById('proj-form').classList.toggle('show');
};
document.getElementById('pf-save').onclick = async () => {
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;
document.getElementById('proj-form').classList.remove('show');
await loadProjects();
addMsg('assistant', '项目「' + esc(cfg.display_name || cfg.name) + '」已保存。');
} catch (e) {
addMsg('error', '保存项目失败:' + esc(String(e)));
}
};
// ---------- 上传区:项目绑定时仅要件定义 ----------
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();
localStorage.setItem('genesis_session', sid);
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();
localStorage.setItem('genesis_session', sid);
} 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('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 () => {
await loadProjects();
const saved = localStorage.getItem('genesis_session');
if (saved) {
try { await loadSession(saved); return; } catch (e) { /* 忽略,新建 */ }
}
newSession().catch(e => addMsg('error', esc(String(e))));
})();
</script>
</body>
</html>