// Genesis Chat UI 业务逻辑(TypeScript 版;由 esbuild 打包为 /static/chat.js) import { api } from './api.ts'; import { must, maybe, esc, setText } from './dom.ts'; import * as GenesisState from './state.ts'; import * as GenesisWS from './ws.ts'; import type { SessionListItem, SessionRecord, SessionCreated, ProjectConfig, ProjectBody, ChatMessage, ChatReply, RagStats, UploadResp, RenameResp, ProgressItem, } from './types.ts'; let sid: string | null = null; let progressWs: WebSocket | null = null; let draftProject: string | null = null; // 下一空白会话将绑定/已绑的项目 let activeProject: string | null = null; // 当前已落库会话绑定的项目 let projects: ProjectConfig[] = []; let drawerOpen = false; let drawerSelected: string | null = null; // 当前抽屉中编辑的项目名(null = 新建模式) let lastTriggerEl: HTMLElement | null = null; let drawerDirty = false; let drawerSnapshot: string | null = null; const chatEl = must('chat'); const inputEl = must('input'); const sendBtn = must('send'); const sessionTitle = must('session-title'); const psMenu = must('ps-menu'); const psName = must('ps-name'); const drawer = must('proj-drawer'); const drawerMask = must('drawer-mask'); const sidebarEl = must('sidebar'); const sidebarToggleBtn = must('sidebar-toggle'); const projSwitcherEl = maybe('proj-switcher'); const sessMenuEl = must('sess-menu'); const fileTypeSel = must('file-type'); const fileInputEl = must('file-input'); const uploadStatusEl = must('upload-status'); const projHintEl = must('proj-hint'); const uploadPanelEl = maybe('upload-panel'); const ragEnabledEl = must('rag-enabled'); const ragStatsEl = must('rag-stats'); const ragBarEl = must('rag-bar'); const impactBtnEl = must('impact-btn'); const pfName = must('pf-name'); const pfDisplay = must('pf-display'); const pfTemplate = must('pf-template'); const pfWrite = must('pf-write'); const pfRules = must('pf-rules'); const pfCode = must('pf-code'); const pfDesign = must('pf-design'); const pfDelete = must('pf-delete'); const PF_FIELDS: HTMLInputElement[] = [pfName, pfDisplay, pfTemplate, pfWrite, pfRules, pfCode, pfDesign]; function addMsg(role: string, html: string): HTMLElement { const m = document.createElement('div'); m.className = 'msg ' + role; m.innerHTML = '
' + html + '
'; chatEl.appendChild(m); chatEl.scrollTop = chatEl.scrollHeight; return m; } function autoGrowInput(): void { inputEl.style.height = 'auto'; inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px'; } function showTyping(): HTMLElement { return addMsg('assistant', '思考中…'); } // ---------- 会话列表 ---------- async function refreshSessions(): Promise { let all: SessionListItem[] = []; 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: SessionListItem): HTMLElement { 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 = '
' + esc(avatar) + '
' + '
' + '
' + esc(s.name || '新会话') + '
' + '
' + esc(s.status) + '
' + '
' + '' + ''; const info = el.querySelector('.info'); if (info) info.onclick = () => { void loadSession(s.session_id); }; const delBtn = el.querySelector('.del'); if (delBtn) delBtn.onclick = (ev: MouseEvent) => { ev.stopPropagation(); void deleteSession(s.session_id); }; const kebab = el.querySelector('.kebab'); if (kebab) kebab.onclick = (ev: MouseEvent) => openSessMenu(ev, s.session_id, s.name || '新会话', el); return el; } // 项目展开/收起状态 let collapsedProjects: Set | null = null; function loadCollapsedProjects(): Set { if (collapsedProjects) return collapsedProjects; collapsedProjects = new Set(); try { const raw = localStorage.getItem('genesis_collapsed_projects'); if (raw) collapsedProjects = new Set(JSON.parse(raw) as string[]); } catch (e) { collapsedProjects = new Set(); } return collapsedProjects; } function saveCollapsedProjects(): void { try { localStorage.setItem('genesis_collapsed_projects', JSON.stringify(Array.from(loadCollapsedProjects()))); } catch (e) { /* 忽略 */ } } // 显式「不选择项目」标记 const EXPLICIT_NONE_KEY = 'genesis_project_explicit_none'; function readExplicitNone(): boolean { try { return localStorage.getItem(EXPLICIT_NONE_KEY) === '1'; } catch (e) { return false; } } function setExplicitNone(v: boolean): void { try { if (v) localStorage.setItem(EXPLICIT_NONE_KEY, '1'); else localStorage.removeItem(EXPLICIT_NONE_KEY); } catch (e) { /* 忽略 */ } } // 项目树渲染 function renderProjectTree(byProj: Record): void { const box = maybe('project-tree'); if (!box) return; const collapsed = loadCollapsedProjects(); const names: string[] = []; 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 }).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 = '' + (isCollapsed ? '▸' : '▾') + '' + '' + esc(disp) + '' + ''; 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: MouseEvent) => { const t = ev.target as HTMLElement | null; if (t && t.closest && t.closest('.proj-config')) return; onProjectRowClick(name); }; const cfgBtn = row.querySelector('.proj-config'); if (cfgBtn) cfgBtn.onclick = (ev: MouseEvent) => { ev.stopPropagation(); if (!guardDrawerDirty()) return; openProjectDrawer(name); }; box.appendChild(grp); } } function onProjectRowClick(name: string): void { 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(); void refreshSessions(); } function renderOtherSessions(list: SessionListItem[]): void { const box = maybe('other-session-list'); if (!box) return; box.innerHTML = ''; for (const s of (list || [])) box.appendChild(renderSessItem(s)); } async function deleteSession(targetSid: string): Promise { 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(): Promise { try { projects = await api('GET', '/api/projects'); } catch (e) { projects = []; } draftProject = GenesisState.autoBindProject(draftProject, projects, readExplicitNone()); applyProjectContext(); renderProjectSwitcher(); if (drawerOpen) loadDrawerForm(drawerSelected); } function applyProjectContext(): void { setText(psName, draftProject || '未选择项目'); } function renderProjectSwitcher(): void { let html = '
' + '不选择项目
'; for (const p of projects) { html += '
' + '' + esc(p.display_name || p.name) + '
'; } psMenu.innerHTML = html; psMenu.querySelectorAll('.ps-item[data-name]').forEach((it) => { it.onclick = () => { if (!guardDrawerDirty()) return; draftProject = it.dataset.name === '' ? null : (it.dataset.name || null); setExplicitNone(draftProject === null); applyProjectContext(); renderProjectSwitcher(); updateUploadBar(); toggleProjectSwitcher(false); }; }); } function toggleProjectSwitcher(force?: boolean): void { const willOpen = force === undefined ? !psMenu.classList.contains('open') : !!force; psMenu.classList.toggle('open', willOpen); } // ---------- 抽屉 ---------- function openProjectDrawer(projectName?: string | null): void { drawerOpen = true; toggleProjectSwitcher(false); drawer.classList.add('open'); drawerMask.classList.add('show'); const sel = (projectName === undefined || projectName === null) ? drawerSelected : projectName; try { loadDrawerForm(sel); } catch (e) { console.error(e); } } function closeProjectDrawer(): void { if (!guardDrawerDirty()) return; drawerOpen = false; drawer.classList.remove('open'); drawerMask.classList.remove('show'); } function loadDrawerForm(projectName?: string | null): void { drawerSelected = (projectName === undefined) ? drawerSelected : projectName; if (drawerSelected) { const p = projects.find((x) => x.name === drawerSelected); if (p) { pfName.value = p.name; pfName.readOnly = true; pfDisplay.value = p.display_name || ''; pfTemplate.value = p.template || ''; pfWrite.value = p.write_instruction || ''; pfRules.value = (p.rules || []).join(', '); pfCode.value = p.existing_system_code_dir || ''; pfDesign.value = p.design_docs_dir || ''; pfDelete.hidden = false; } } else { pfName.value = ''; pfName.readOnly = false; pfDisplay.value = ''; pfTemplate.value = ''; pfWrite.value = ''; pfRules.value = ''; pfCode.value = ''; pfDesign.value = ''; pfDelete.hidden = true; } drawerSnapshot = drawerSnapshotNow(); } function drawerSnapshotNow(): string { return GenesisState.computeDrawerSnapshot(PF_FIELDS.map((el) => el.value)); } function isDrawerDirty(): boolean { if (!drawerOpen || drawerSnapshot === null) return false; return GenesisState.isDrawerDirty(drawerSnapshotNow(), drawerSnapshot); } function guardDrawerDirty(): boolean { if (isDrawerDirty()) return confirm('有未保存的修改,确定放弃?'); return true; } // 抽屉字段级红框(关键字与 store.py 的 ProjectConfigError 中文 label 对齐) const PF_LABEL_TO_FIELD: Record = { '项目名': pfName, '模板': pfTemplate, '做成说明书': pfWrite, '规则': pfRules, '既有系统代码库': pfCode, '既有设计文档目录': pfDesign, }; function clearPfInvalid(): void { PF_FIELDS.forEach((el) => el.classList.remove('invalid')); } function flagPfInvalidByMessage(message: string): void { if (!message) return; for (const [label, el] of Object.entries(PF_LABEL_TO_FIELD)) { if (message.indexOf(label) >= 0) { el.classList.add('invalid'); el.focus(); setTimeout(() => el.classList.remove('invalid'), 3000); } } } PF_FIELDS.forEach((el) => el.addEventListener('input', () => el.classList.remove('invalid'))); async function saveDrawerProject(): Promise { clearPfInvalid(); const body: ProjectBody = { name: pfName.value.trim(), display_name: pfDisplay.value.trim(), template: pfTemplate.value.trim(), write_instruction: pfWrite.value.trim(), rules: pfRules.value.split(',').map((s) => s.trim()).filter(Boolean), existing_system_code_dir: pfCode.value.trim(), design_docs_dir: pfDesign.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) { flagPfInvalidByMessage(String(e)); addMsg('error', '保存项目失败:' + esc(String(e))); } } async function deleteDrawerProject(): Promise { 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) + '」已删除。'); drawerSelected = null; if (draftProject === deleted) { draftProject = null; } await loadProjects(); loadDrawerForm(null); } // ---------- 侧边栏折叠 ---------- function toggleSidebar(force?: boolean): void { const willCollapse = force === undefined ? !sidebarEl.classList.contains('collapsed') : !!force; sidebarEl.classList.toggle('collapsed', willCollapse); if (willCollapse) { sidebarToggleBtn.textContent = '»'; sidebarToggleBtn.title = '展开侧边栏'; sidebarToggleBtn.dataset.state = 'collapsed'; } else { sidebarToggleBtn.textContent = '«'; sidebarToggleBtn.title = '折叠侧边栏'; sidebarToggleBtn.dataset.state = 'expanded'; } try { localStorage.setItem('genesis_sidebar_collapsed', willCollapse ? '1' : '0'); } catch (e) { /* 忽略 */ } } // ---------- 上传区 ---------- function updateUploadBar(): void { fileTypeSel.style.display = ''; if (fileTypeSel.value !== 'requirements') fileTypeSel.value = 'requirements'; if (draftProject) { projHintEl.textContent = '默认上传要件定义 xlsx;如需补传模板/规则/既有系统 zip,请在下拉中选择对应类型。'; } else { projHintEl.textContent = '请选择 file_type 后上传(未选项目时,模板/规则仍可手动上传)。'; } } // ---------- 空态/欢迎渲染 ---------- function insertPrompt(text: string): void { inputEl.value = text; inputEl.focus(); try { inputEl.setSelectionRange(text.length, text.length); } catch (e) { /* 忽略 */ } autoGrowInput(); } function renderEmptyOrWelcome(): void { chatEl.innerHTML = ''; const wrap = document.createElement('div'); wrap.className = 'empty-state'; const cards = GenesisState.quickPrompts().map((t) => '', ).join(''); wrap.innerHTML = '' + '

' + esc(GenesisState.emptyHeadline()) + '

' + '

' + GenesisState.buildWelcome(draftProject) + '

' + '
' + cards + '
'; chatEl.appendChild(wrap); wrap.querySelectorAll('.suggest-card').forEach((b) => { b.onclick = () => insertPrompt(b.dataset.prompt || ''); }); } // ---------- 会话「⋯」菜单 + 重命名 ---------- interface SessMenuTarget { sid: string; name: string; el: HTMLElement; } let sessMenuTarget: SessMenuTarget | null = null; function openSessMenu(ev: MouseEvent, sidVal: string, name: string, el: HTMLElement): void { ev.stopPropagation(); sessMenuTarget = { sid: sidVal, name, el }; sessMenuEl.hidden = false; const r = (ev.currentTarget as HTMLElement).getBoundingClientRect(); sessMenuEl.style.top = (r.bottom + 4) + 'px'; sessMenuEl.style.left = Math.max(8, r.right - 150) + 'px'; } function closeSessMenu(): void { sessMenuEl.hidden = true; } function startRenameSession(el: HTMLElement, s: { session_id: string; name: string }): void { 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: boolean): Promise => { 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: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); void finish(true); } else if (e.key === 'Escape') { e.preventDefault(); void finish(false); } }); input.addEventListener('blur', () => { void finish(true); }); } // ---------- 会话创建/加载 ---------- async function newSession(): Promise { if (progressWs) { try { progressWs.close(); } catch (e) { /* 忽略 */ } } progressWs = null; sid = null; activeProject = null; try { localStorage.removeItem('genesis_session'); } catch (e) { /* 忽略 */ } chatEl.innerHTML = ''; setText(sessionTitle, '未创建会话'); updateUploadBar(); await refreshRagStats(); await refreshSessions(); renderEmptyOrWelcome(); } async function loadSession(id: string): Promise { try { const rec = await api('GET', '/api/sessions/' + encodeURIComponent(id)); sid = id; activeProject = 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: () => { /* noop */ }, }); const msgs = await api('GET', '/api/chat/' + encodeURIComponent(id) + '/messages'); chatEl.innerHTML = ''; for (const m of msgs) { const roleMap: Record = { 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))); } } function renderProjectMismatchHint(): void { const old = document.getElementById('proj-mismatch-hint'); if (old) old.remove(); if (!sid) return; if (!activeProject) return; if (activeProject === draftProject) return; let skipped: Record = {}; try { skipped = JSON.parse(sessionStorage.getItem('genesis_skipped_project_hint') || '{}') as Record; } catch (e) { skipped = {}; } if (skipped[sid] === activeProject) return; const wrap = document.createElement('div'); wrap.id = 'proj-mismatch-hint'; wrap.className = 'msg assistant'; wrap.innerHTML = '
' + '该会话属于项目 ' + esc(activeProject) + ',' + '当前项目为 ' + esc(draftProject || '(未选择)') + '。' + '
'; chatEl.prepend(wrap); const switchEl = wrap.querySelector('[data-act="switch"]'); if (switchEl) switchEl.onclick = (e: MouseEvent) => { e.preventDefault(); draftProject = activeProject; applyProjectContext(); renderProjectSwitcher(); updateUploadBar(); void refreshSessions(); wrap.remove(); }; const keepEl = wrap.querySelector('[data-act="keep"]'); if (keepEl) keepEl.onclick = (e: MouseEvent) => { e.preventDefault(); try { skipped[sid as string] = activeProject as string; sessionStorage.setItem('genesis_skipped_project_hint', JSON.stringify(skipped)); } catch (er) { /* 忽略 */ } wrap.remove(); }; } // ---------- 发送 ---------- let sendInFlight = false; async function send(): Promise { if (sendInFlight) return; const text = inputEl.value.trim(); if (!text) return; sendInFlight = true; if (!sid) { 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))); sendInFlight = false; return; } } const sessionId = sid as string; if (progressWs) { try { progressWs.close(); } catch (e) { /* 忽略 */ } } progressWs = GenesisWS.connectProgressWs(sessionId, { onProgress: (e) => GenesisWS.applyProgressEvent(e, (role, text2) => addMsg(role, text2)), onClose: () => { /* noop */ }, }); 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(sessionId) + '/messages', { content: text }); typing.remove(); if (res.progress && res.progress.length) { const items = res.progress.map((p: ProgressItem) => '' + esc(p.step) + ': ' + esc(p.detail || '') + '').join(''); addMsg('progress', items); } let replyHtml = esc(res.reply || ''); const st = res.status; if (st === 'done' || st === 'writing') { replyHtml += ''; } 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', () => { void send(); }); inputEl.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void send(); } }); inputEl.addEventListener('input', autoGrowInput); document.getElementById('new-chat')!.addEventListener('click', () => { void newSession().catch((e) => addMsg('error', esc(String(e)))); }); sidebarToggleBtn.addEventListener('click', () => toggleSidebar()); document.getElementById('ps-current')!.addEventListener('click', (e) => { e.stopPropagation(); lastTriggerEl = e.currentTarget as HTMLElement; toggleProjectSwitcher(); }); document.getElementById('drawer-close')!.addEventListener('click', closeProjectDrawer); drawerMask.addEventListener('click', closeProjectDrawer); document.getElementById('pf-save')!.addEventListener('click', () => { void saveDrawerProject(); }); document.getElementById('pf-delete')!.addEventListener('click', () => { void deleteDrawerProject(); }); document.getElementById('side-add-project')!.addEventListener('click', () => { if (!guardDrawerDirty()) return; openProjectDrawer(null); }); document.getElementById('tb-upload')!.addEventListener('click', () => { if (uploadPanelEl) uploadPanelEl.hidden = !uploadPanelEl.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 }); }); // ---------- RAG 开关 + 状态徽标 + 开始影响调查 ---------- const RAG_LS_KEY = 'genesis_rag_enabled'; function readRagFlag(): boolean { try { return localStorage.getItem(RAG_LS_KEY) === '1'; } catch (e) { return false; } } function writeRagFlag(v: boolean): void { try { localStorage.setItem(RAG_LS_KEY, v ? '1' : '0'); } catch (e) { /* 忽略 */ } } ragEnabledEl.addEventListener('change', () => { writeRagFlag(ragEnabledEl.checked); updateImpactButton(); }); async function refreshRagStats(): Promise { if (!sid) { ragBarEl.hidden = true; return; } ragBarEl.hidden = false; let stats: RagStats = { 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); ragEnabledEl.disabled = n === 0; if (n === 0) { ragEnabledEl.checked = false; writeRagFlag(false); if (ragEnabledEl.parentElement) ragEnabledEl.parentElement.title = '请在项目配置中设置既有系统代码库目录'; } else { ragEnabledEl.checked = readRagFlag(); if (ragEnabledEl.parentElement) { ragEnabledEl.parentElement.title = ragEnabledEl.checked ? 'RAG 检索结果会注入到 LLM prompt' : '勾选后启用 RAG 检索'; } } updateImpactButton(); } function updateImpactButton(): void { 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', () => { void (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().catch(() => ({}))) as { detail?: { message?: string } }; if (!r.ok) throw new Error((d && d.detail && d.detail.message) || String(r.status)); typing.remove(); addMsg('progress', '影响调查已提交(use_rag=' + (useRag ? 'true' : 'false') + ')'); addMsg('assistant', '影响调查已启动。完成后可在此会话点击"预览"或访问 /result/impact-report 下载报告。'); } catch (e) { typing.remove(); addMsg('error', '影响调查失败:' + esc(String(e))); } finally { updateImpactButton(); } })(); }); document.addEventListener('click', (e: MouseEvent) => { const target = e.target as Node; if (projSwitcherEl && !projSwitcherEl.contains(target)) toggleProjectSwitcher(false); if (!sessMenuEl.hidden && !sessMenuEl.contains(target)) closeSessMenu(); }); document.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Escape') { closeSessMenu(); if (psMenu.classList.contains('open')) toggleProjectSwitcher(false); else if (drawerOpen) closeProjectDrawer(); if (lastTriggerEl) { lastTriggerEl.focus(); lastTriggerEl = null; } } }); // 客户端类型 → 扩展名白名单校验 const UPLOAD_EXT_RULES: Record = { existing_system: ['.zip'], requirements: ['.xlsx'], template: ['.docx'], write_instruction: ['.docx'], rules: ['.docx', '.xlsx'], }; function pickExt(name: string): string { const i = String(name).lastIndexOf('.'); return i >= 0 ? String(name).slice(i).toLowerCase() : ''; } function projectHasPrefixedType(ft: string): boolean { 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', () => { void (async () => { if (!sid) { addMsg('error', '请先发送一条消息以创建会话'); inputEl.focus(); return; } const ft = GenesisState.resolveUploadType(activeProject, draftProject, fileTypeSel.value); const file = fileInputEl.files && fileInputEl.files[0]; if (!file) { addMsg('error', '请选择文件'); return; } 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; } } if (projectHasPrefixedType(ft)) { const ok = confirm('项目已预置 ' + ft + '。继续上传将覆盖该会话中的此类型文件,是否继续?'); if (!ok) return; } const fd = new FormData(); fd.append('file_type', ft); fd.append('file', file); setText(uploadStatusEl, '上传中…'); try { const r = await fetch('/api/sessions/' + encodeURIComponent(sid as string) + '/files', { method: 'POST', body: fd }); const d = (await r.json().catch(() => ({}))) as UploadResp & { detail?: { message?: string } }; if (!r.ok) throw new Error((d && d.detail && d.detail.message) || String(r.status)); setText(uploadStatusEl, ''); const rec = await api('GET', '/api/sessions/' + encodeURIComponent(sid as string)); activeProject = rec.project || null; setText(sessionTitle, (rec.name || '新会话')); await refreshSessions(); updateUploadBar(); await refreshRagStats(); fileInputEl.value = ''; addMsg('user', '[上传] ' + ft + ':' + d.file_name); addMsg('progress', '上传完成:' + esc(d.file_name) + '(' + d.size + 'B)'); } catch (e) { setText(uploadStatusEl, ''); addMsg('error', '上传失败:' + esc(String(e))); } })(); }); // ---------- 启动:恢复侧边栏折叠态、上次会话 ---------- void (async () => { try { const collapsed = localStorage.getItem('genesis_sidebar_collapsed') === '1'; if (collapsed) sidebarEl.classList.add('collapsed'); } catch (e) { /* 忽略 */ } await loadProjects(); const saved = (() => { try { return localStorage.getItem('genesis_session'); } catch (e) { return null; } })(); if (saved) { addMsg('assistant', '正在恢复上次会话…'); try { await loadSession(saved); 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) { /* 忽略,新建 */ } } void newSession().catch((e) => addMsg('error', esc(String(e)))); })();