Files
2026Technology-Competition/src/genesis/server/static/chat.js
T

887 lines
36 KiB
JavaScript
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.
// src/genesis/server/static/chat.js
// Genesis Chat UI 业务逻辑(v1 拆分自原 chat.html 内联 <script> 段,行 1-704
// 本次完整恢复,零行为变更
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 + '」提供)。',
emptyHeadline: () => '有什么可以帮到你的?',
quickPrompts: () => ['生成概要设计书', '影响调查', '用中文生成', '现在什么状态?'],
};
const chatEl = document.getElementById('chat');
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('send');
const sessionTitle = document.getElementById('session-title');
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;','"':'&quot;',"'":'&#39;'}[c])); }
function setText(el, v) { if (el) el.textContent = v; }
function autoGrowInput() {
if (!inputEl) return;
inputEl.style.height = 'auto';
inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px';
}
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() {
let all = [];
try { all = await api('GET', '/api/sessions?user_id=default'); } catch (e) { all = []; }
all.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''));
const grouped = GenesisState.groupSessionsByProject(all);
renderProjectTree(grouped.byProject);
renderOtherSessions(grouped.other);
}
// 单个会话行(项目组 / 其他会话共用)
function renderSessItem(s) {
const el = document.createElement('div');
el.className = 'sess' + (s.session_id === sid ? ' active' : '');
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) + '</div>' +
'</div>' +
'<button class="kebab" title="更多" aria-label="更多操作">⋯</button>' +
'<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); };
const kebab = el.querySelector('.kebab');
kebab.onclick = (ev) => openSessMenu(ev, s.session_id, s.name || '新会话', el);
return el;
}
// 项目展开/收起状态
let collapsedProjects = null;
function loadCollapsedProjects() {
if (collapsedProjects) return collapsedProjects;
collapsedProjects = new Set();
try {
const raw = localStorage.getItem('genesis_collapsed_projects');
if (raw) collapsedProjects = new Set(JSON.parse(raw));
} catch (e) { collapsedProjects = new Set(); }
return collapsedProjects;
}
function saveCollapsedProjects() {
try { localStorage.setItem('genesis_collapsed_projects', JSON.stringify(Array.from(loadCollapsedProjects()))); } catch (e) { /* 忽略 */ }
}
// 显式「不选择项目」标记(F1
const EXPLICIT_NONE_KEY = 'genesis_project_explicit_none';
function readExplicitNone() {
try { return localStorage.getItem(EXPLICIT_NONE_KEY) === '1'; } catch (e) { return false; }
}
function setExplicitNone(v) {
try { if (v) localStorage.setItem(EXPLICIT_NONE_KEY, '1'); else localStorage.removeItem(EXPLICIT_NONE_KEY); } catch (e) { /* 忽略 */ }
}
// 项目树渲染
function renderProjectTree(byProj) {
const box = document.getElementById('project-tree');
if (!box) return;
const collapsed = loadCollapsedProjects();
const names = [];
for (const p of projects) names.push(p.name);
for (const k of Object.keys(byProj || {})) { if (k && names.indexOf(k) < 0) names.push(k); }
box.innerHTML = '';
for (const name of names) {
const sessList = (byProj && byProj[name]) || [];
const isCurrent = (draftProject === name);
const hasActive = sessList.some(s => s.session_id === sid);
const isCollapsed = collapsed.has(name);
const disp = (((projects || []).find(p => p.name === name)) || {}).display_name || name;
const grp = document.createElement('div');
grp.className = 'proj-group' + (isCurrent ? ' current' : '') + (hasActive ? ' has-active' : '');
const row = document.createElement('div');
row.className = 'proj-row';
row.innerHTML = '<span class="proj-caret">' + (isCollapsed ? '▸' : '▾') + '</span>'
+ '<span class="proj-name">' + esc(disp) + '</span>'
+ '<button class="proj-config" type="button" title="配置">⚙</button>';
grp.appendChild(row);
if (!isCollapsed) {
const sessBox = document.createElement('div');
sessBox.className = 'proj-sessions';
for (const s of sessList) sessBox.appendChild(renderSessItem(s));
grp.appendChild(sessBox);
}
row.onclick = (ev) => {
if (ev.target && ev.target.closest && ev.target.closest('.proj-config')) return;
onProjectRowClick(name);
};
row.querySelector('.proj-config').onclick = (ev) => {
ev.stopPropagation();
if (!guardDrawerDirty()) return;
openProjectDrawer(name);
};
box.appendChild(grp);
}
}
function onProjectRowClick(name) {
if (!guardDrawerDirty()) return;
if (draftProject !== name) {
draftProject = name;
setExplicitNone(false);
applyProjectContext();
renderProjectSwitcher();
updateUploadBar();
}
const collapsed = loadCollapsedProjects();
if (collapsed.has(name)) collapsed.delete(name); else collapsed.add(name);
saveCollapsedProjects();
refreshSessions();
}
function renderOtherSessions(list) {
const box = document.getElementById('other-session-list');
if (!box) return;
box.innerHTML = '';
for (const s of (list || [])) box.appendChild(renderSessItem(s));
}
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, readExplicitNone());
applyProjectContext();
renderProjectSwitcher();
if (drawerOpen) loadDrawerForm(drawerSelected);
}
function applyProjectContext() {
setText(psName, 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>';
}
psMenu.innerHTML = html;
psMenu.querySelectorAll('.ps-item[data-name]').forEach(it => {
it.onclick = () => {
if (!guardDrawerDirty()) return;
draftProject = it.dataset.name === '' ? null : it.dataset.name;
setExplicitNone(draftProject === null);
applyProjectContext();
renderProjectSwitcher();
updateUploadBar();
toggleProjectSwitcher(false);
};
});
}
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 mask = document.getElementById('drawer-mask');
if (mask) mask.classList.add('show');
const sel = (projectName === undefined || projectName === null) ? drawerSelected : projectName;
try { loadDrawerForm(sel); } catch (e) { console.error(e); }
}
function closeProjectDrawer() {
if (!guardDrawerDirty()) return;
drawerOpen = false;
drawer.classList.remove('open');
const mask = document.getElementById('drawer-mask');
if (mask) mask.classList.remove('show');
}
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();
}
// 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')));
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) + '」已删除。');
// P1F:先记住被删的,再清 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);
// 切换折叠/展开图标 + title + data-state
const btn = document.getElementById('sidebar-toggle');
if (btn) {
if (willCollapse) {
btn.textContent = '»';
btn.title = '展开侧边栏';
btn.dataset.state = 'collapsed';
} else {
btn.textContent = '«';
btn.title = '折叠侧边栏';
btn.dataset.state = 'expanded';
}
}
try { localStorage.setItem('genesis_sidebar_collapsed', willCollapse ? '1' : '0'); } catch (e) { /* localStorage 不可用时忽略 */ }
}
// ---------- 上传区:始终显示完整类型下拉 ----------
// P0B:选项目时也允许补传 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 insertPrompt(text) {
inputEl.value = text;
inputEl.focus();
try { inputEl.setSelectionRange(text.length, text.length); } catch (e) { /* 忽略 */ }
autoGrowInput();
}
function renderEmptyOrWelcome() {
chatEl.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'empty-state';
const cards = GenesisState.quickPrompts().map(t =>
'<button class="suggest-card" type="button" data-prompt="' + esc(t) + '">' + esc(t) + '</button>'
).join('');
wrap.innerHTML =
'<div class="es-logo">G</div>' +
'<h2 class="es-title">' + esc(GenesisState.emptyHeadline()) + '</h2>' +
'<p class="es-sub">' + GenesisState.buildWelcome(draftProject) + '</p>' +
'<div class="suggests">' + cards + '</div>';
chatEl.appendChild(wrap);
wrap.querySelectorAll('.suggest-card').forEach(b => {
b.onclick = () => insertPrompt(b.dataset.prompt);
});
}
// ---------- 会话「⋯」菜单 + 重命名 ----------
let sessMenuTarget = null;
function openSessMenu(ev, sidVal, name, el) {
ev.stopPropagation();
sessMenuTarget = { sid: sidVal, name: name, el: el };
const m = document.getElementById('sess-menu');
if (!m) return;
m.hidden = false;
const r = ev.currentTarget.getBoundingClientRect();
m.style.top = (r.bottom + 4) + 'px';
m.style.left = Math.max(8, r.right - 150) + 'px';
}
function closeSessMenu() {
const m = document.getElementById('sess-menu');
if (m) m.hidden = true;
}
function startRenameSession(el, s) {
const nameEl = el.querySelector('.name');
if (!nameEl) return;
const input = document.createElement('input');
input.className = 'rename-input';
input.value = s.name || '';
input.maxLength = 100;
nameEl.replaceWith(input);
input.focus(); input.select();
let done = false;
const finish = async (save) => {
if (done) return; done = true;
const newName = input.value.trim();
if (save && newName && newName !== (s.name || '')) {
try {
const r = await api('PATCH', '/api/sessions/' + encodeURIComponent(s.session_id), { name: newName });
if (s.session_id === sid) setText(sessionTitle, r.name || newName);
} catch (e) {
addMsg('error', '重命名失败:' + esc(String(e)));
}
}
await refreshSessions();
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); finish(true); }
else if (e.key === 'Escape') { e.preventDefault(); finish(false); }
});
input.addEventListener('blur', () => finish(true));
}
// ---------- 会话创建/加载 ----------
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 = '';
setText(sessionTitle, '未创建会话');
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;
// P1D:不再隐式覆盖 draftProject。会话事实(activeProject)与用户偏好(draftProject)
// 分层;若不一致,由提示条让用户主动选择。
// draftProject = rec.project || null; // 删除:原行为会悄悄改用户偏好
applyProjectContext();
setText(sessionTitle, (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)));
}
}
// P1D:项目不匹配提示条。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();
};
}
// P2Hsend() 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;
setText(sessionTitle, (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 = '';
inputEl.style.height = '';
const _es = chatEl.querySelector('.empty-state');
if (_es) _es.remove();
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' && !e.shiftKey) { e.preventDefault(); send(); }
});
inputEl.addEventListener('input', autoGrowInput);
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('drawer-mask').addEventListener('click', closeProjectDrawer);
document.getElementById('pf-save').addEventListener('click', saveDrawerProject);
document.getElementById('pf-delete').addEventListener('click', deleteDrawerProject);
document.getElementById('side-add-project').addEventListener('click', () => {
if (!guardDrawerDirty()) return;
openProjectDrawer(null);
});
document.getElementById('tb-upload').addEventListener('click', () => {
const panel = document.getElementById('upload-panel');
if (panel) panel.hidden = !panel.hidden;
});
document.getElementById('sm-rename').addEventListener('click', (ev) => {
ev.stopPropagation();
const t = sessMenuTarget;
closeSessMenu();
if (t) startRenameSession(t.el, { session_id: t.sid, name: t.name });
});
// ---------- P0ARAG 开关 + 状态徽标 + 开始影响调查 ----------
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;
setText(ragStatsEl, n > 0
? 'RAG 已索引 ' + n + ' 片段'
: 'RAG 0 片段(请在项目配置中设置代码库目录)');
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 = '请在项目配置中设置既有系统代码库目录';
} 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;
setText(impactBtnEl, 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);
// 点击「⋯」菜单外部关闭
const sm = document.getElementById('sess-menu');
if (sm && !sm.hidden && !sm.contains(e.target)) closeSessMenu();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeSessMenu();
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 对应的项目 configprojects[])判定是否预置。
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');
setText(statusEl, '上传中…');
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);
setText(statusEl, '');
// B5 / P1F:上传成功后同步 activeProject、刷新上传条与侧边栏状态
const rec = await api('GET', '/api/sessions/' + encodeURIComponent(sid));
activeProject = rec.project || null;
setText(sessionTitle, (rec.name || '新会话'));
await refreshSessions();
updateUploadBar(); // P1‑F:上传成功后同步上传条(防状态滞后)
await refreshRagStats(); // P0A:上传 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) {
setText(statusEl, '');
// 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) {
// P2H:启动恢复前显示 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))));
})();