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

819 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 './chat.css';
import * as XLSX from 'xlsx';
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, 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 fileInputEl = must<HTMLInputElement>('file-input');
const uploadStatusEl = must<HTMLElement>('upload-status');
const composerEl = must<HTMLDivElement>('composer');
interface AttachmentRef { file_type: string; name: string; }
let pendingAttachment: { file: File; name: string } | null = null;
// 输入框上方的「待发附件」区
const pendingBox = document.createElement('div');
pendingBox.className = 'pending-attach';
pendingBox.hidden = true;
composerEl.insertBefore(pendingBox, composerEl.firstChild);
function renderPendingAttachment(): void {
if (!pendingAttachment) { pendingBox.hidden = true; pendingBox.innerHTML = ''; return; }
pendingBox.hidden = false;
pendingBox.innerHTML = '<span class="pending-name">📄 ' + esc(pendingAttachment.name) + '</span>'
+ '<button class="attach-remove" type="button" title="移除附件" aria-label="移除附件">×</button>';
const rm = pendingBox.querySelector<HTMLElement>('.attach-remove');
if (rm) rm.onclick = () => { pendingAttachment = null; renderPendingAttachment(); };
}
function attachmentChipHtml(att: AttachmentRef): string {
return '<button class="attach-chip" type="button"'
+ ' data-ft="' + esc(att.file_type) + '" data-name="' + esc(att.name) + '">📄 ' + esc(att.name) + '</button>';
}
function bindAttachChips(root: HTMLElement): void {
root.querySelectorAll<HTMLElement>('.attach-chip').forEach((btn) => {
btn.onclick = () => { void openAttachmentViewer(btn.dataset.ft || 'requirements', btn.dataset.name || ''); };
});
}
// 用户消息:文字在气泡内,附件在气泡**下方**(同一列、右对齐)
function addUserMsg(text: string, att: AttachmentRef | null): void {
const m = document.createElement('div');
m.className = 'msg user';
const stack = document.createElement('div');
stack.className = 'msg-stack';
const bubble = document.createElement('div');
bubble.className = 'bubble';
bubble.innerHTML = esc(text);
stack.appendChild(bubble);
if (att) {
const line = document.createElement('div');
line.className = 'attach-line';
line.innerHTML = attachmentChipHtml(att);
stack.appendChild(line);
}
m.appendChild(stack);
chatEl.appendChild(m);
chatEl.scrollTop = chatEl.scrollHeight;
bindAttachChips(m);
}
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();
}
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();
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 insertPrompt(text: string): void {
inputEl.value = text;
inputEl.focus();
try { inputEl.setSelectionRange(text.length, text.length); } catch (e) { /* 忽略 */ }
autoGrowInput();
updateSendState();
}
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, '未创建会话');
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';
if (role === 'user') {
let att: AttachmentRef | null = null;
if (m.attachment) { try { att = JSON.parse(m.attachment) as AttachmentRef; } catch (e) { att = null; } }
addUserMsg(m.content, att);
} else {
addMsg(role, esc(m.content));
}
}
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();
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;
function updateSendState(): void { sendBtn.disabled = sendInFlight || inputEl.value.trim() === ''; }
async function send(): Promise<void> {
if (sendInFlight) return;
const text = inputEl.value.trim();
if (!text) return;
sendInFlight = true;
updateSendState();
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;
updateSendState();
return;
}
}
const sessionId = sid as string;
// 待发附件:先上传(失败则中止并保留待发附件)
let attachmentRef: AttachmentRef | null = null;
if (pendingAttachment) {
const f = pendingAttachment;
const fd = new FormData();
fd.append('file_type', 'requirements');
fd.append('file', f.file);
setText(uploadStatusEl, '上传中…');
try {
const r = await fetch('/api/sessions/' + encodeURIComponent(sessionId) + '/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(sessionId));
activeProject = rec.project || null;
setText(sessionTitle, (rec.name || '新会话'));
await refreshSessions();
attachmentRef = { file_type: 'requirements', name: d.file_name };
pendingAttachment = null;
renderPendingAttachment();
} catch (e) {
setText(uploadStatusEl, '');
addMsg('error', '附件上传失败:' + esc(String(e)));
sendInFlight = false;
updateSendState();
return;
}
}
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();
addUserMsg(text, attachmentRef);
const typing = showTyping();
const msgBody: { content: string; attachment?: AttachmentRef } = { content: text };
if (attachmentRef) msgBody.attachment = attachmentRef;
try {
const res = await api<ChatReply>('POST', '/api/chat/' + encodeURIComponent(sessionId) + '/messages', msgBody);
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;
updateSendState();
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(); updateSendState(); });
updateSendState();
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', () => { fileInputEl.click(); });
fileInputEl.addEventListener('change', () => {
const file = fileInputEl.files && fileInputEl.files[0];
fileInputEl.value = '';
if (!file) return;
if (pickExt(file.name) !== '.xlsx') {
addMsg('error', '要件定义书须为 .xlsx 文件(当前:' + esc(file.name) + '');
return;
}
pendingAttachment = { file, name: file.name };
renderPendingAttachment();
});
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 });
});
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; }
}
});
// 取扩展名(小写,含点)
function pickExt(name: string): string {
const i = String(name).lastIndexOf('.');
return i >= 0 ? String(name).slice(i).toLowerCase() : '';
}
// 在线查看附件:前端用 SheetJS 解析原 xlsx 渲染(多 sheet
async function openAttachmentViewer(fileType: string, name: string): Promise<void> {
if (!sid) { addMsg('error', '请先创建会话'); return; }
try {
const r = await fetch('/api/sessions/' + encodeURIComponent(sid) + '/files/' + encodeURIComponent(fileType));
if (!r.ok) throw new Error(String(r.status));
const buf = await r.arrayBuffer();
const wb = XLSX.read(new Uint8Array(buf), { type: 'array' });
showWorkbookViewer(name, wb);
} catch (e) {
addMsg('error', '附件查看失败:' + esc(String(e)));
}
}
function showWorkbookViewer(name: string, wb: XLSX.WorkBook): void {
const old = document.getElementById('attach-viewer');
if (old) old.remove();
const overlay = document.createElement('div');
overlay.id = 'attach-viewer';
overlay.className = 'viewer-overlay';
const names = wb.SheetNames;
const tabs = names.map((n, i) => '<button class="vt-tab' + (i === 0 ? ' active' : '') + '" type="button" data-i="' + i + '">' + esc(n) + '</button>').join('');
overlay.innerHTML =
'<div class="viewer-panel">' +
'<div class="viewer-head"><span class="viewer-title">📄 ' + esc(name) + '</span>' +
'<button class="viewer-close" type="button" title="关闭" aria-label="关闭">×</button></div>' +
'<div class="viewer-tabs">' + tabs + '</div>' +
'<div class="viewer-body"></div>' +
'</div>';
document.body.appendChild(overlay);
const vbody = overlay.querySelector<HTMLElement>('.viewer-body');
const renderSheet = (i: number): void => {
if (!vbody) return;
const ws = wb.Sheets[names[i]];
vbody.innerHTML = ws ? XLSX.utils.sheet_to_html(ws) : '';
};
renderSheet(0);
overlay.querySelectorAll<HTMLElement>('.vt-tab').forEach((el) => {
el.onclick = () => {
overlay.querySelectorAll('.vt-tab').forEach((x) => x.classList.remove('active'));
el.classList.add('active');
renderSheet(Number(el.dataset.i || 0));
};
});
const close = (): void => overlay.remove();
const closeBtn = overlay.querySelector<HTMLElement>('.viewer-close');
if (closeBtn) closeBtn.onclick = close;
overlay.addEventListener('click', (e: MouseEvent) => { if (e.target === overlay) close(); });
}
// ---------- 启动:恢复侧边栏折叠态、上次会话 ----------
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))));
})();