Files
2026Technology-Competition/frontend/src/main.ts
T

831 lines
33 KiB
TypeScript
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.
// 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<HTMLDivElement>('chat');
const inputEl = must<HTMLTextAreaElement>('input');
const sendBtn = must<HTMLButtonElement>('send');
const sessionTitle = must<HTMLElement>('session-title');
const psMenu = must<HTMLDivElement>('ps-menu');
const psName = must<HTMLElement>('ps-name');
const drawer = must<HTMLDivElement>('proj-drawer');
const drawerMask = must<HTMLDivElement>('drawer-mask');
const sidebarEl = must<HTMLDivElement>('sidebar');
const sidebarToggleBtn = must<HTMLButtonElement>('sidebar-toggle');
const projSwitcherEl = maybe<HTMLDivElement>('proj-switcher');
const sessMenuEl = must<HTMLDivElement>('sess-menu');
const fileTypeSel = must<HTMLSelectElement>('file-type');
const fileInputEl = must<HTMLInputElement>('file-input');
const uploadStatusEl = must<HTMLElement>('upload-status');
const projHintEl = must<HTMLElement>('proj-hint');
const uploadPanelEl = maybe<HTMLDivElement>('upload-panel');
const ragEnabledEl = must<HTMLInputElement>('rag-enabled');
const ragStatsEl = must<HTMLElement>('rag-stats');
const ragBarEl = must<HTMLElement>('rag-bar');
const impactBtnEl = must<HTMLButtonElement>('impact-btn');
const pfName = must<HTMLInputElement>('pf-name');
const pfDisplay = must<HTMLInputElement>('pf-display');
const pfTemplate = must<HTMLInputElement>('pf-template');
const pfWrite = must<HTMLInputElement>('pf-write');
const pfRules = must<HTMLInputElement>('pf-rules');
const pfCode = must<HTMLInputElement>('pf-code');
const pfDesign = must<HTMLInputElement>('pf-design');
const pfDelete = must<HTMLButtonElement>('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 = '<div class="bubble">' + html + '</div>';
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', '<span class="typing">思考中…</span>'); }
// ---------- 会话列表 ----------
async function refreshSessions(): Promise<void> {
let all: SessionListItem[] = [];
try { all = await api<SessionListItem[]>('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 =
'<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>';
const info = el.querySelector<HTMLElement>('.info');
if (info) info.onclick = () => { void loadSession(s.session_id); };
const delBtn = el.querySelector<HTMLElement>('.del');
if (delBtn) delBtn.onclick = (ev: MouseEvent) => { ev.stopPropagation(); void deleteSession(s.session_id); };
const kebab = el.querySelector<HTMLElement>('.kebab');
if (kebab) kebab.onclick = (ev: MouseEvent) => openSessMenu(ev, s.session_id, s.name || '新会话', el);
return el;
}
// 项目展开/收起状态
let collapsedProjects: Set<string> | null = null;
function loadCollapsedProjects(): Set<string> {
if (collapsedProjects) return collapsedProjects;
collapsedProjects = new Set<string>();
try {
const raw = localStorage.getItem('genesis_collapsed_projects');
if (raw) collapsedProjects = new Set<string>(JSON.parse(raw) as string[]);
} catch (e) { collapsedProjects = new Set<string>(); }
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<string, SessionListItem[]>): void {
const box = maybe<HTMLDivElement>('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 = '<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: MouseEvent) => {
const t = ev.target as HTMLElement | null;
if (t && t.closest && t.closest('.proj-config')) return;
onProjectRowClick(name);
};
const cfgBtn = row.querySelector<HTMLElement>('.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<HTMLDivElement>('other-session-list');
if (!box) return;
box.innerHTML = '';
for (const s of (list || [])) box.appendChild(renderSessItem(s));
}
async function deleteSession(targetSid: string): Promise<void> {
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<void> {
try {
projects = await api<ProjectConfig[]>('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 = '<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<HTMLElement>('.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<string, HTMLInputElement> = {
'项目名': 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<void> {
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<ProjectConfig>('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<void> {
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) =>
'<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<HTMLElement>('.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<HTMLElement>('.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<void> => {
if (done) return; done = true;
const newName = input.value.trim();
if (save && newName && newName !== (s.name || '')) {
try {
const r = await api<RenameResp>('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<void> {
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<void> {
try {
const rec = await api<SessionRecord>('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<ChatMessage[]>('GET', '/api/chat/' + encodeURIComponent(id) + '/messages');
chatEl.innerHTML = '';
for (const m of msgs) {
const roleMap: Record<string, string> = { 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<string, string> = {};
try { skipped = JSON.parse(sessionStorage.getItem('genesis_skipped_project_hint') || '{}') as Record<string, string>; } 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);
const switchEl = wrap.querySelector<HTMLElement>('[data-act="switch"]');
if (switchEl) switchEl.onclick = (e: MouseEvent) => {
e.preventDefault();
draftProject = activeProject;
applyProjectContext();
renderProjectSwitcher();
updateUploadBar();
void refreshSessions();
wrap.remove();
};
const keepEl = wrap.querySelector<HTMLElement>('[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<void> {
if (sendInFlight) return;
const text = inputEl.value.trim();
if (!text) return;
sendInFlight = true;
if (!sid) {
try {
const d = await api<SessionCreated>('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<ChatReply>('POST', '/api/chat/' + encodeURIComponent(sessionId) + '/messages', { content: text });
typing.remove();
if (res.progress && res.progress.length) {
const items = res.progress.map((p: ProgressItem) => '<span class="progress-item ' + (p.status || 'ok') + '">' + esc(p.step) + ': ' + esc(p.detail || '') + '</span>').join('');
addMsg('progress', items);
}
let replyHtml = esc(res.reply || '');
const st = res.status;
if (st === 'done' || st === 'writing') {
replyHtml += '<div class="actions">'
+ '<a href="/api/sessions/' + sessionId + '/result/download">下载 docx</a>'
+ '<a href="/api/sessions/' + sessionId + '/result/preview">预览</a>'
+ '<a href="/api/sessions/' + sessionId + '/result/impact-report">影响调查书</a>'
+ '<a href="/api/sessions/' + sessionId + '/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', () => { 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<void> {
if (!sid) {
ragBarEl.hidden = true;
return;
}
ragBarEl.hidden = false;
let stats: RagStats = { chunks: 0, rag_enabled: false };
try {
stats = await api<RagStats>('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', '<span class="progress-item ok">影响调查已提交(use_rag=' + (useRag ? 'true' : 'false') + '</span>');
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<string, string[]> = {
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<SessionRecord>('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', '<span class="progress-item ok">上传完成:' + esc(d.file_name) + '' + d.size + 'B</span>');
} 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<SessionRecord>('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))));
})();