832 lines
33 KiB
TypeScript
832 lines
33 KiB
TypeScript
// Genesis Chat UI 业务逻辑(TypeScript 版;由 esbuild 打包为 /static/chat.js)
|
||
import './chat.css';
|
||
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))));
|
||
})();
|