fix(frontend): 完整恢复 chat.js UI 业务逻辑 + 折叠按钮图标/标题切换
背景:之前 commita16996b从 git520c9e2恢复 chat.html 内联 JS 段到 chat.js 时,提取过程因 PowerShell 5.1 `>` 重定向默认按 UTF-16 LE 写入文件、 后续 UTF-8 解码时字节被错误解读,导致 chat.js 包含大量乱码字符和未关闭 的字符串字面量。Node.js 解析时报 `Unexpected token ':'` 错误。 本次重新提取: - 用 `git cat-file -p` 直接获取原 chat.html 的 UTF-8 原始字节 - 找到内联 `<script>...</script>` 段(从 byte 24765 到 54899,共 30134 字节) - 按 UTF-8 解码后写回 chat.js(30279 字节) - 验证 Node.js 解析通过 变更: - chat.js 整体重写:27094 字节 → 30306 字节 - 恢复正确的简体中文 UI 文案("请新建项目或者选择项目"等) - 恢复正确的字符串字面量(之前是乱码字符) 同时恢复 commit264054b的折叠按钮增强: - toggleSidebar 切换图标 '«' / '»' - 切换 title "折叠侧边栏" / "展开侧边栏" - 切换 data-state 'expanded' / 'collapsed' 零行为变更:JS 内容完全从 git520c9e2恢复, 只是264054b的 toggleSidebar 增强被应用。 验证(node --check): - chat.js 0 errors - /static/chat.js 服务端响应 200, 30306 bytes 后续浏览器手测清单: - [ ] 侧边栏折叠按钮:点击正常切换 + 图标/标题变化 - [ ] 项目下拉:弹出 + 切换项目 - [ ] 新建项目抽屉:滑入 - [ ] 发送消息:用户气泡 + 自动滚动 - [ ] 上传:选文件 + 上传进度
This commit is contained in:
+718
-684
@@ -1,686 +1,720 @@
|
|||||||
// src/genesis/server/static/chat.js
|
// src/genesis/server/static/chat.js
|
||||||
// Genesis Chat UI 涓氬姟閫昏緫锛坴1 鎷嗗垎鑷師 chat.html 鍐呰仈 <script> 娈碉紝琛?510-1179锛塦n// 涓婁竴杞?chat.html 鎷嗗垎 CSS 鏃惰鍒犳暣娈靛唴鑱?JS锛屽鑷存墍鏈?UI 鎸夐挳澶辨晥
|
// Genesis Chat UI 业务逻辑(v1 拆分自原 chat.html 内联 <script> 段,行 1-704)
|
||||||
// 鏈瀹屾暣鎭㈠锛岄浂琛屼负鍙樻洿
|
// 本次完整恢复,零行为变更
|
||||||
let sid = null;
|
|
||||||
let progressWs = null;
|
let sid = null;
|
||||||
let draftProject = null; // 涓嬩竴绌虹櫧浼氳瘽灏嗙粦瀹?宸茬粦鐨勯」鐩紙鍘?currentProject锛?let activeProject = null; // 褰撳墠宸茶惤搴撲細璇濈粦瀹氱殑椤圭洰锛堟潵鑷悗绔級
|
let progressWs = null;
|
||||||
let projects = [];
|
let draftProject = null; // 下一空白会话将绑定/已绑的项目(原 currentProject)
|
||||||
let drawerOpen = false;
|
let activeProject = null; // 当前已落库会话绑定的项目(来自后端)
|
||||||
let drawerSelected = null; // 褰撳墠鎶藉眽涓紪杈戠殑椤圭洰鍚嶏紙null = 鏂板缓妯″紡锛?let lastTriggerEl = null; // 鎵撳紑鎶藉眽/涓嬫媺鍓嶇殑鐒︾偣鍏冪礌锛圕1锛?let drawerDirty = false; // 鎶藉眽琛ㄥ崟鏄惁鏈夋湭淇濆瓨鏀瑰姩锛圔6锛? let drawerSnapshot = null; // loadDrawerForm 鏃剁殑瀛楁蹇収锛圔6锛?
|
let projects = [];
|
||||||
// 鐘舵€侀€昏緫缁熶竴璧?chat_state.js锛涜嫢闈欐€佽矾鐢辨湭灏辩华鍒欏洖閫€鍐呰仈瀹炵幇锛岄伩鍏嶆暣椤佃剼鏈穿婧?const GenesisState = window.GenesisState || {
|
let drawerOpen = false;
|
||||||
autoBindProject: (d, p) => (d === null && p.length ? p[0].name : (d && !p.find(x => x.name === d) ? (p.length ? p[0].name : null) : d)),
|
let drawerSelected = null; // 当前抽屉中编辑的项目名(null = 新建模式)
|
||||||
shouldHideUploadSelect: () => false,
|
let lastTriggerEl = null; // 打开抽屉/下拉前的焦点元素(C1)
|
||||||
resolveUploadType: (_a, _d, v) => v,
|
let drawerDirty = false; // 抽屉表单是否有未保存改动(B6)
|
||||||
computeDrawerSnapshot: (f) => f.map(x => (x == null ? '' : String(x)).trim()).join(String.fromCharCode(1)),
|
let drawerSnapshot = null; // loadDrawerForm 时的字段快照(B6)
|
||||||
isDrawerDirty: (n, s) => n !== s,
|
|
||||||
buildWelcome: (d) => d === null ? '璇锋柊寤洪」鐩垨鑰呴€夋嫨椤圭洰'
|
// 状态逻辑统一走 chat_state.js;若静态路由未就绪则回退内联实现,避免整页脚本崩溃
|
||||||
: '浣犲ソ锛佹垜鏄?Genesis 姒傝璁捐涔︾敓鎴?Agent銆傝鍏堜笂浼犺浠跺畾涔?xlsx锛堟ā鏉?瑙勫垯/浠g爜搴撳凡鐢遍」鐩€? + d + '銆嶆彁渚涳級銆?,
|
const GenesisState = window.GenesisState || {
|
||||||
};
|
autoBindProject: (d, p) => (d === null && p.length ? p[0].name : (d && !p.find(x => x.name === d) ? (p.length ? p[0].name : null) : d)),
|
||||||
|
shouldHideUploadSelect: () => false,
|
||||||
const chatEl = document.getElementById('chat');
|
resolveUploadType: (_a, _d, v) => v,
|
||||||
const inputEl = document.getElementById('input');
|
computeDrawerSnapshot: (f) => f.map(x => (x == null ? '' : String(x)).trim()).join(String.fromCharCode(1)),
|
||||||
const sendBtn = document.getElementById('send');
|
isDrawerDirty: (n, s) => n !== s,
|
||||||
const badge = document.getElementById('sid-badge');
|
buildWelcome: (d) => d === null ? '请新建项目或者选择项目'
|
||||||
const psMenu = document.getElementById('ps-menu');
|
: '你好!我是 Genesis 概要设计书生成 Agent。请先上传要件定义 xlsx(模板/规则/代码库已由项目「' + d + '」提供)。',
|
||||||
const psName = document.getElementById('ps-name');
|
};
|
||||||
const drawer = document.getElementById('proj-drawer');
|
|
||||||
|
const chatEl = document.getElementById('chat');
|
||||||
function addMsg(role, html) {
|
const inputEl = document.getElementById('input');
|
||||||
const m = document.createElement('div');
|
const sendBtn = document.getElementById('send');
|
||||||
m.className = 'msg ' + role;
|
const badge = document.getElementById('sid-badge');
|
||||||
m.innerHTML = '<div class="bubble">' + html + '</div>';
|
const psMenu = document.getElementById('ps-menu');
|
||||||
chatEl.appendChild(m);
|
const psName = document.getElementById('ps-name');
|
||||||
chatEl.scrollTop = chatEl.scrollHeight;
|
const drawer = document.getElementById('proj-drawer');
|
||||||
return m;
|
|
||||||
}
|
function addMsg(role, html) {
|
||||||
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
const m = document.createElement('div');
|
||||||
function showTyping() { return addMsg('assistant', '<span class="typing">鎬濊€冧腑鈥?/span>'); }
|
m.className = 'msg ' + role;
|
||||||
|
m.innerHTML = '<div class="bubble">' + html + '</div>';
|
||||||
async function api(method, url, body) {
|
chatEl.appendChild(m);
|
||||||
const opt = { method, headers: {} };
|
chatEl.scrollTop = chatEl.scrollHeight;
|
||||||
if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); }
|
return m;
|
||||||
const r = await fetch(url, opt);
|
|
||||||
const data = await r.json().catch(() => ({}));
|
|
||||||
if (!r.ok) throw new Error(data.detail?.message || r.status);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 浼氳瘽鍒楄〃 ----------
|
|
||||||
async function refreshSessions() {
|
|
||||||
const box = document.getElementById('session-list');
|
|
||||||
if (draftProject === null) { box.innerHTML = ''; return; } // 鏈€夐」鐩?鈫?鏃犲巻鍙? const list = await api('GET', '/api/sessions?user_id=default&project=' + encodeURIComponent(draftProject));
|
|
||||||
box.innerHTML = '';
|
|
||||||
list.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''));
|
|
||||||
for (const s of list) {
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.className = 'sess' + (s.session_id === sid ? ' active' : '');
|
|
||||||
const proj = s.project ? ' 路 ' + esc(s.project) : '';
|
|
||||||
// P3鈥?锛氬彇棣栧瓧绗︼紙鍚腑鏂?鏃ユ枃/鑻辨枃锛変綔 avatar锛屽苟鐢ㄥ悕瀛?hash 閫夎壊锛岄伩鍏嶉噸鍚嶆贩娣? 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) + proj + '</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<button class="del" title="鍒犻櫎浼氳瘽" aria-label="鍒犻櫎浼氳瘽 ' + esc(rawName) + '">脳</button>';
|
|
||||||
el.querySelector('.info').onclick = () => loadSession(s.session_id);
|
|
||||||
const delBtn = el.querySelector('.del');
|
|
||||||
delBtn.onclick = (ev) => { ev.stopPropagation(); deleteSession(s.session_id); };
|
|
||||||
box.appendChild(el);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteSession(targetSid) {
|
|
||||||
if (!confirm('纭鍒犻櫎璇ヤ細璇濓紵姝ゆ搷浣滀笉鍙挙閿€銆?)) return;
|
|
||||||
try {
|
|
||||||
await api('DELETE', '/api/sessions/' + encodeURIComponent(targetSid));
|
|
||||||
} catch (e) {
|
|
||||||
addMsg('error', '鍒犻櫎澶辫触锛? + esc(String(e)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (targetSid === sid) {
|
|
||||||
// 鍒犻櫎鐨勬槸褰撳墠浼氳瘽 鈫?鑷姩鏂板缓骞舵竻绌? await newSession();
|
|
||||||
} else {
|
|
||||||
await refreshSessions();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 椤圭洰閰嶇疆 ----------
|
|
||||||
async function loadProjects() {
|
|
||||||
try {
|
|
||||||
projects = await api('GET', '/api/projects');
|
|
||||||
} catch (e) {
|
|
||||||
projects = [];
|
|
||||||
}
|
|
||||||
// 鍚姩鑷姩缁戦椤?/ 宸插垹椤圭洰鍥為€€棣栭」锛堢粺涓€鐢?GenesisState.autoBindProject 澶勭悊锛? draftProject = GenesisState.autoBindProject(draftProject, projects);
|
|
||||||
applyProjectContext();
|
|
||||||
renderProjectSwitcher();
|
|
||||||
renderDrawerList();
|
|
||||||
if (drawerOpen) loadDrawerForm(drawerSelected);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyProjectContext() {
|
|
||||||
psName.textContent = draftProject || '鏈€夋嫨';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderProjectSwitcher() {
|
|
||||||
let html = '<div class="ps-item' + (draftProject === null ? ' active' : '') + '" data-name="">'
|
|
||||||
+ '<span>涓嶉€夋嫨椤圭洰</span><span class="ps-check">鉁?/span></div>';
|
|
||||||
for (const p of projects) {
|
|
||||||
html += '<div class="ps-item' + (p.name === draftProject ? ' active' : '') + '" data-name="' + esc(p.name) + '">'
|
|
||||||
+ '<span>' + esc(p.display_name || p.name) + '</span><span class="ps-check">鉁?/span></div>';
|
|
||||||
}
|
|
||||||
html += '<div class="ps-sep"></div>'
|
|
||||||
+ '<div class="ps-item ps-manage" data-manage="1">+ 椤圭洰绠$悊锛堟墦寮€鎶藉眽锛?/div>';
|
|
||||||
psMenu.innerHTML = html;
|
|
||||||
psMenu.querySelectorAll('.ps-item[data-name]').forEach(it => {
|
|
||||||
it.onclick = () => {
|
|
||||||
if (!guardDrawerDirty()) return;
|
|
||||||
draftProject = it.dataset.name === '' ? null : it.dataset.name;
|
|
||||||
applyProjectContext();
|
|
||||||
renderProjectSwitcher();
|
|
||||||
updateUploadBar();
|
|
||||||
toggleProjectSwitcher(false);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
psMenu.querySelectorAll('.ps-item[data-manage]').forEach(it => {
|
|
||||||
it.onclick = () => {
|
|
||||||
if (!guardDrawerDirty()) return;
|
|
||||||
lastTriggerEl = it;
|
|
||||||
toggleProjectSwitcher(false);
|
|
||||||
openProjectDrawer(drawerSelected);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleProjectSwitcher(force) {
|
|
||||||
const willOpen = force === undefined ? !psMenu.classList.contains('open') : !!force;
|
|
||||||
psMenu.classList.toggle('open', willOpen);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 鎶藉眽 ----------
|
|
||||||
function openProjectDrawer(projectName) {
|
|
||||||
drawerOpen = true;
|
|
||||||
toggleProjectSwitcher(false);
|
|
||||||
drawer.classList.add('open');
|
|
||||||
const sel = (projectName === undefined || projectName === null) ? drawerSelected : projectName;
|
|
||||||
try { loadDrawerForm(sel); } catch (e) { console.error(e); }
|
|
||||||
renderDrawerList(); // 濮嬬粓娓叉煋宸︿晶鍒楄〃锛堝惈 +鏂板缓椤圭洰锛?}
|
|
||||||
|
|
||||||
function closeProjectDrawer() {
|
|
||||||
if (!guardDrawerDirty()) return;
|
|
||||||
drawerOpen = false;
|
|
||||||
drawer.classList.remove('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadDrawerForm(projectName) {
|
|
||||||
drawerSelected = (projectName === undefined) ? drawerSelected : projectName;
|
|
||||||
const nameEl = document.getElementById('pf-name');
|
|
||||||
const dispEl = document.getElementById('pf-display');
|
|
||||||
const tplEl = document.getElementById('pf-template');
|
|
||||||
const wrEl = document.getElementById('pf-write');
|
|
||||||
const rulesEl = document.getElementById('pf-rules');
|
|
||||||
const codeEl = document.getElementById('pf-code');
|
|
||||||
const designEl = document.getElementById('pf-design');
|
|
||||||
const delBtn = document.getElementById('pf-delete');
|
|
||||||
if (drawerSelected) {
|
|
||||||
const p = projects.find(x => x.name === drawerSelected);
|
|
||||||
if (p) {
|
|
||||||
nameEl.value = p.name; nameEl.readOnly = true;
|
|
||||||
dispEl.value = p.display_name || '';
|
|
||||||
tplEl.value = p.template || '';
|
|
||||||
wrEl.value = p.write_instruction || '';
|
|
||||||
rulesEl.value = (p.rules || []).join(', ');
|
|
||||||
codeEl.value = p.existing_system_code_dir || '';
|
|
||||||
designEl.value = p.design_docs_dir || '';
|
|
||||||
delBtn.hidden = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
nameEl.value = ''; nameEl.readOnly = false;
|
|
||||||
dispEl.value = '';
|
|
||||||
tplEl.value = ''; wrEl.value = ''; rulesEl.value = ''; codeEl.value = ''; designEl.value = '';
|
|
||||||
delBtn.hidden = true;
|
|
||||||
}
|
|
||||||
// B6锛氳褰曞瓧娈靛揩鐓х敤浜庤剰妫€娴? drawerSnapshot = drawerSnapshotNow();
|
|
||||||
renderDrawerList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// B6锛氭娊灞夋湭淇濆瓨鍙樻洿瀹堝崼
|
|
||||||
function drawerSnapshotNow() {
|
|
||||||
return GenesisState.computeDrawerSnapshot([
|
|
||||||
document.getElementById('pf-name').value,
|
|
||||||
document.getElementById('pf-display').value,
|
|
||||||
document.getElementById('pf-template').value,
|
|
||||||
document.getElementById('pf-write').value,
|
|
||||||
document.getElementById('pf-rules').value,
|
|
||||||
document.getElementById('pf-code').value,
|
|
||||||
document.getElementById('pf-design').value,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
function isDrawerDirty() {
|
|
||||||
if (!drawerOpen || drawerSnapshot === null) return false;
|
|
||||||
return GenesisState.isDrawerDirty(drawerSnapshotNow(), drawerSnapshot);
|
|
||||||
}
|
|
||||||
function guardDrawerDirty() {
|
|
||||||
if (isDrawerDirty()) return confirm('鏈夋湭淇濆瓨鐨勪慨鏀癸紝纭畾鏀惧純锛?);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// P0鈥慍锛氭娊灞夊瓧娈电骇绾㈡銆傚懡涓叧閿瓧 鈫?缁欏搴旇緭鍏ュ姞 .invalid 3 绉掑悗娓呴櫎銆?// 鍏抽敭瀛椾笌 store.py 鐨?ProjectConfigError 涓枃 label 瀵归綈銆?const PF_LABEL_TO_FIELD = {
|
|
||||||
'椤圭洰鍚?: 'pf-name',
|
|
||||||
'妯℃澘': 'pf-template',
|
|
||||||
'鍋氭垚璇存槑涔?: 'pf-write',
|
|
||||||
'瑙勫垯': 'pf-rules',
|
|
||||||
'鏃㈡湁绯荤粺浠g爜搴?: 'pf-code',
|
|
||||||
'鏃㈡湁璁捐鏂囨。鐩綍': 'pf-design',
|
|
||||||
};
|
|
||||||
function clearPfInvalid() {
|
|
||||||
['pf-name','pf-display','pf-template','pf-write','pf-rules','pf-code','pf-design']
|
|
||||||
.forEach(id => document.getElementById(id).classList.remove('invalid'));
|
|
||||||
}
|
|
||||||
function flagPfInvalidByMessage(message) {
|
|
||||||
if (!message) return;
|
|
||||||
for (const [label, fid] of Object.entries(PF_LABEL_TO_FIELD)) {
|
|
||||||
if (message.indexOf(label) >= 0) {
|
|
||||||
const el = document.getElementById(fid);
|
|
||||||
el.classList.add('invalid');
|
|
||||||
el.focus();
|
|
||||||
setTimeout(() => el.classList.remove('invalid'), 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 浠绘剰杈撳叆娓呮帀鑷韩绾㈡锛堥伩鍏?绾㈢潃杈撳叆"璇锛?['pf-name','pf-display','pf-template','pf-write','pf-rules','pf-code','pf-design']
|
|
||||||
.forEach(id => document.getElementById(id).addEventListener('input', (e) => e.target.classList.remove('invalid')));
|
|
||||||
|
|
||||||
function renderDrawerList() {
|
|
||||||
let html = '<div class="drawer-item di-create' + (drawerSelected === null ? ' active' : '') + '" data-name="">'
|
|
||||||
+ '<span class="di-new">锛?/span><span class="di-name">鏂板缓椤圭洰</span></div>';
|
|
||||||
for (const p of projects) {
|
|
||||||
html += '<div class="drawer-item' + (p.name === drawerSelected ? ' active' : '') + '" data-name="' + esc(p.name) + '">'
|
|
||||||
+ '<span class="di-name">' + esc(p.display_name || p.name) + '</span></div>';
|
|
||||||
}
|
|
||||||
const box = document.getElementById('drawer-list');
|
|
||||||
box.innerHTML = html;
|
|
||||||
box.querySelectorAll('.drawer-item').forEach(it => {
|
|
||||||
it.onclick = () => {
|
|
||||||
if (!guardDrawerDirty()) return;
|
|
||||||
const n = it.dataset.name === '' ? null : it.dataset.name;
|
|
||||||
loadDrawerForm(n);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveDrawerProject() {
|
|
||||||
clearPfInvalid();
|
|
||||||
const body = {
|
|
||||||
name: document.getElementById('pf-name').value.trim(),
|
|
||||||
display_name: document.getElementById('pf-display').value.trim(),
|
|
||||||
template: document.getElementById('pf-template').value.trim(),
|
|
||||||
write_instruction: document.getElementById('pf-write').value.trim(),
|
|
||||||
rules: document.getElementById('pf-rules').value.split(',').map(s => s.trim()).filter(Boolean),
|
|
||||||
existing_system_code_dir: document.getElementById('pf-code').value.trim(),
|
|
||||||
design_docs_dir: document.getElementById('pf-design').value.trim(),
|
|
||||||
};
|
|
||||||
if (!body.name) {
|
|
||||||
flagPfInvalidByMessage('椤圭洰鍚?);
|
|
||||||
addMsg('error', '椤圭洰鍚嶄笉鑳戒负绌?);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const cfg = await api('POST', '/api/projects', body);
|
|
||||||
draftProject = cfg.name;
|
|
||||||
drawerSelected = cfg.name;
|
|
||||||
await loadProjects();
|
|
||||||
addMsg('assistant', '椤圭洰銆? + esc(cfg.display_name || cfg.name) + '銆嶅凡淇濆瓨銆?);
|
|
||||||
closeProjectDrawer();
|
|
||||||
} catch (e) {
|
|
||||||
// 400 / PROJECT_CONFIG_INVALID锛氬瓧娈电骇绾㈡瀹氫綅
|
|
||||||
flagPfInvalidByMessage(String(e));
|
|
||||||
addMsg('error', '淇濆瓨椤圭洰澶辫触锛? + esc(String(e)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteDrawerProject() {
|
|
||||||
if (!drawerSelected) return;
|
|
||||||
if (!confirm('纭鍒犻櫎椤圭洰銆? + drawerSelected + '銆嶏紵姝ゆ搷浣滀笉鍙挙閿€銆?)) return;
|
|
||||||
const deleted = drawerSelected;
|
|
||||||
try {
|
|
||||||
await api('DELETE', '/api/projects/' + encodeURIComponent(deleted));
|
|
||||||
} catch (e) {
|
|
||||||
addMsg('error', '鍒犻櫎椤圭洰澶辫触锛? + esc(String(e)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
addMsg('assistant', '椤圭洰銆? + esc(deleted) + '銆嶅凡鍒犻櫎銆?);
|
|
||||||
// P1鈥慒锛氬厛璁颁綇琚垹鐨勶紝鍐嶆竻 drawerSelected锛涢伩鍏?鍏?null 鍚庢瘮杈?鎭掑亣 bug
|
|
||||||
drawerSelected = null;
|
|
||||||
if (draftProject === deleted) {
|
|
||||||
// 褰撳墠閫夌殑灏辨槸琚垹鐨勯」鐩?鈫?娓呯┖鍋忓ソ锛坅utoBindProject 浼氫粠鍓╀綑椤圭洰閲嶉€夛級
|
|
||||||
draftProject = null;
|
|
||||||
}
|
|
||||||
await loadProjects();
|
|
||||||
loadDrawerForm(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 渚ц竟鏍忔姌鍙?----------
|
|
||||||
function toggleSidebar(force) {
|
|
||||||
const el = document.getElementById('sidebar');
|
|
||||||
const willCollapse = force === undefined ? !el.classList.contains('collapsed') : !!force;
|
|
||||||
el.classList.toggle('collapsed', willCollapse);
|
|
||||||
// 切换折叠/展开图标 + title + data-state
|
|
||||||
const btn = document.getElementById('sidebar-toggle');
|
|
||||||
if (btn) {
|
|
||||||
if (willCollapse) {
|
|
||||||
btn.textContent = '»';
|
|
||||||
btn.title = '展开侧边栏';
|
|
||||||
btn.dataset.state = 'collapsed';
|
|
||||||
} else {
|
|
||||||
btn.textContent = '«';
|
|
||||||
btn.title = '折叠侧边栏';
|
|
||||||
btn.dataset.state = 'expanded';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try { localStorage.setItem('genesis_sidebar_collapsed', willCollapse ? '1' : '0'); } catch (e) { /* localStorage 不可用时忽略 */ }
|
|
||||||
}
|
}
|
||||||
|
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||||
// ---------- 涓婁紶鍖猴細濮嬬粓鏄剧ず瀹屾暣绫诲瀷涓嬫媺 ----------
|
function showTyping() { return addMsg('assistant', '<span class="typing">思考中…</span>'); }
|
||||||
// P0鈥態锛氶€夐」鐩椂涔熷厑璁歌ˉ浼?template / write_instruction / rules / existing_system锛?// 涓嶅啀钘忚捣鍏ュ彛銆?椤圭洰宸查缃?template 鏃跺啀涓婁紶 template"鍦ㄤ笂浼犲鐢?confirm() 鏄惧紡纭銆?function updateUploadBar() {
|
|
||||||
const sel = document.getElementById('file-type');
|
async function api(method, url, body) {
|
||||||
const hint = document.getElementById('proj-hint');
|
const opt = { method, headers: {} };
|
||||||
if (sel) sel.style.display = '';
|
if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); }
|
||||||
if (!hint) return;
|
const r = await fetch(url, opt);
|
||||||
// 榛樿 file_type 浠嶄负 requirements锛堟渶甯哥敤锛夛紝浣嗕笅鎷夊缁堝彲鏀广€? if (sel && sel.value !== 'requirements') sel.value = 'requirements';
|
const data = await r.json().catch(() => ({}));
|
||||||
if (draftProject) {
|
if (!r.ok) throw new Error(data.detail?.message || r.status);
|
||||||
hint.textContent = '榛樿涓婁紶瑕佷欢瀹氫箟 xlsx锛涘闇€琛ヤ紶妯℃澘/瑙勫垯/鏃㈡湁绯荤粺 zip锛岃鍦ㄤ笅鎷変腑閫夋嫨瀵瑰簲绫诲瀷銆?;
|
return data;
|
||||||
} else {
|
}
|
||||||
hint.textContent = '璇烽€夋嫨 file_type 鍚庝笂浼狅紙鏈€夐」鐩椂锛屾ā鏉?瑙勫垯浠嶅彲鎵嬪姩涓婁紶锛夈€?;
|
|
||||||
}
|
// ---------- 会话列表 ----------
|
||||||
}
|
async function refreshSessions() {
|
||||||
|
const box = document.getElementById('session-list');
|
||||||
// ---------- 绌烘€?娆㈣繋娓叉煋 ----------
|
if (draftProject === null) { box.innerHTML = ''; return; } // 未选项目 → 无历史
|
||||||
function renderEmptyOrWelcome() {
|
const list = await api('GET', '/api/sessions?user_id=default&project=' + encodeURIComponent(draftProject));
|
||||||
chatEl.innerHTML = '';
|
box.innerHTML = '';
|
||||||
// P1鈥慒锛氭杩庢枃鐢?GenesisState.buildWelcome 缁熶竴鏉ユ簮
|
list.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''));
|
||||||
addMsg('assistant', GenesisState.buildWelcome(draftProject));
|
for (const s of list) {
|
||||||
}
|
const el = document.createElement('div');
|
||||||
|
el.className = 'sess' + (s.session_id === sid ? ' active' : '');
|
||||||
// ---------- 浼氳瘽鍒涘缓/鍔犺浇 ----------
|
const proj = s.project ? ' · ' + esc(s.project) : '';
|
||||||
async function newSession() {
|
// P3‑1:取首字符(含中文/日文/英文)作 avatar,并用名字 hash 选色,避免重名混淆
|
||||||
if (progressWs) { try { progressWs.close(); } catch (e) {} } progressWs = null;
|
const rawName = (s.name || '新').trim();
|
||||||
sid = null;
|
const avatar = rawName ? Array.from(rawName)[0] : '新';
|
||||||
activeProject = null;
|
const palette = ['#1a73e8', '#34a853', '#fbbc05', '#ea4335', '#8e24aa', '#0097a7', '#5f6368'];
|
||||||
// 淇濈暀 draftProject锛堝綋鍓嶉《鏍忓凡閫夐」鐩級锛屼娇鏂颁細璇濈户鎵块」鐩粦瀹氾紙搂4.2锛? try { localStorage.removeItem('genesis_session'); } catch (e) { /* 蹇界暐 */ }
|
let h = 0;
|
||||||
chatEl.innerHTML = '';
|
for (let i = 0; i < rawName.length; i++) h = (h * 31 + rawName.charCodeAt(i)) | 0;
|
||||||
badge.textContent = '鏈垱寤轰細璇?;
|
const color = palette[Math.abs(h) % palette.length];
|
||||||
updateUploadBar();
|
el.innerHTML =
|
||||||
await refreshRagStats();
|
'<div class="avatar" style="background:' + color + ';">' + esc(avatar) + '</div>' +
|
||||||
await refreshSessions();
|
'<div class="info">' +
|
||||||
renderEmptyOrWelcome();
|
'<div class="name">' + esc(s.name || '新会话') + '</div>' +
|
||||||
}
|
'<div class="meta">' + esc(s.status) + proj + '</div>' +
|
||||||
|
'</div>' +
|
||||||
async function loadSession(id) {
|
'<button class="del" title="删除会话" aria-label="删除会话 ' + esc(rawName) + '">×</button>';
|
||||||
try {
|
el.querySelector('.info').onclick = () => loadSession(s.session_id);
|
||||||
const rec = await api('GET', '/api/sessions/' + encodeURIComponent(id));
|
const delBtn = el.querySelector('.del');
|
||||||
sid = id;
|
delBtn.onclick = (ev) => { ev.stopPropagation(); deleteSession(s.session_id); };
|
||||||
activeProject = rec.project || null;
|
box.appendChild(el);
|
||||||
// P1鈥慏锛氫笉鍐嶉殣寮忚鐩?draftProject銆備細璇濅簨瀹?activeProject)涓庣敤鎴峰亸濂?draftProject)
|
}
|
||||||
// 鍒嗗眰锛涜嫢涓嶄竴鑷达紝鐢辨彁绀烘潯璁╃敤鎴蜂富鍔ㄩ€夋嫨銆? // draftProject = rec.project || null; // 鍒犻櫎锛氬師琛屼负浼氭倓鎮勬敼鐢ㄦ埛鍋忓ソ
|
}
|
||||||
applyProjectContext();
|
|
||||||
badge.textContent = '浼氳瘽: ' + (rec.name || '鏂颁細璇?);
|
async function deleteSession(targetSid) {
|
||||||
if (progressWs) { try { progressWs.close(); } catch (e) {} }
|
if (!confirm('确认删除该会话?此操作不可撤销。')) return;
|
||||||
progressWs = GenesisWS.connectProgressWs(id, {
|
try {
|
||||||
onProgress: (e) => GenesisWS.applyProgressEvent(e, (role, text) => addMsg(role, text)),
|
await api('DELETE', '/api/sessions/' + encodeURIComponent(targetSid));
|
||||||
onClose: () => {},
|
} catch (e) {
|
||||||
});
|
addMsg('error', '删除失败:' + esc(String(e)));
|
||||||
const msgs = await api('GET', '/api/chat/' + encodeURIComponent(id) + '/messages');
|
return;
|
||||||
chatEl.innerHTML = '';
|
}
|
||||||
for (const m of msgs) {
|
if (targetSid === sid) {
|
||||||
const roleMap = { user: 'user', progress: 'progress', error: 'error' };
|
// 删除的是当前会话 → 自动新建并清空
|
||||||
const role = roleMap[m.role] || 'assistant';
|
await newSession();
|
||||||
addMsg(role, esc(m.content));
|
} else {
|
||||||
}
|
await refreshSessions();
|
||||||
updateUploadBar();
|
}
|
||||||
await refreshRagStats();
|
}
|
||||||
await refreshSessions();
|
|
||||||
try { localStorage.setItem('genesis_session', sid); } catch (e) { /* 蹇界暐 */ }
|
// ---------- 项目配置 ----------
|
||||||
renderProjectMismatchHint();
|
async function loadProjects() {
|
||||||
} catch (e) {
|
try {
|
||||||
addMsg('error', '鍔犺浇浼氳瘽澶辫触锛? + esc(String(e)));
|
projects = await api('GET', '/api/projects');
|
||||||
}
|
} catch (e) {
|
||||||
}
|
projects = [];
|
||||||
|
}
|
||||||
// P1鈥慏锛氶」鐩笉鍖归厤鎻愮ず鏉°€俛ctiveProject 涓?draftProject 涓嶄竴鑷存椂鏄剧ず锛?// "鍒囧埌璇ラ」鐩? 鈫?鍐?draftProject = activeProject锛屽埛鏂帮紱
|
// 启动自动绑首项 / 已删项目回退首项(统一由 GenesisState.autoBindProject 处理)
|
||||||
// "淇濈暀" 鈫?鍐欏叆 sessionStorage 鏍囪锛屽埛鏂帮紱鍚庣画 loadSession 涓嶅啀鎻愮ず銆?function renderProjectMismatchHint() {
|
draftProject = GenesisState.autoBindProject(draftProject, projects);
|
||||||
const old = document.getElementById('proj-mismatch-hint');
|
applyProjectContext();
|
||||||
if (old) old.remove();
|
renderProjectSwitcher();
|
||||||
if (!sid) return;
|
renderDrawerList();
|
||||||
if (!activeProject) return;
|
if (drawerOpen) loadDrawerForm(drawerSelected);
|
||||||
if (activeProject === draftProject) return;
|
}
|
||||||
// 宸?淇濈暀"杩囨湰浼氳瘽鐨勪笉鍐嶆彁绀? let skipped = {};
|
|
||||||
try { skipped = JSON.parse(sessionStorage.getItem('genesis_skipped_project_hint') || '{}'); } catch (e) { skipped = {}; }
|
function applyProjectContext() {
|
||||||
if (skipped[sid] === activeProject) return;
|
psName.textContent = draftProject || '未选择';
|
||||||
const wrap = document.createElement('div');
|
}
|
||||||
wrap.id = 'proj-mismatch-hint';
|
|
||||||
wrap.className = 'msg assistant';
|
function renderProjectSwitcher() {
|
||||||
wrap.innerHTML =
|
let html = '<div class="ps-item' + (draftProject === null ? ' active' : '') + '" data-name="">'
|
||||||
'<div class="bubble">' +
|
+ '<span>不选择项目</span><span class="ps-check">✓</span></div>';
|
||||||
'璇ヤ細璇濆睘浜庨」鐩?<strong>' + esc(activeProject) + '</strong>锛? +
|
for (const p of projects) {
|
||||||
'褰撳墠椤舵爮椤圭洰涓?<strong>' + esc(draftProject || '锛堟湭閫夋嫨锛?) + '</strong>銆? +
|
html += '<div class="ps-item' + (p.name === draftProject ? ' active' : '') + '" data-name="' + esc(p.name) + '">'
|
||||||
'<div class="actions">' +
|
+ '<span>' + esc(p.display_name || p.name) + '</span><span class="ps-check">✓</span></div>';
|
||||||
'<a href="#" data-act="switch">鍒囧埌璇ラ」鐩?/a>' +
|
}
|
||||||
'<a href="#" data-act="keep">淇濈暀褰撳墠椤圭洰</a>' +
|
html += '<div class="ps-sep"></div>'
|
||||||
'</div></div>';
|
+ '<div class="ps-item ps-manage" data-manage="1">+ 项目管理(打开抽屉)</div>';
|
||||||
chatEl.prepend(wrap);
|
psMenu.innerHTML = html;
|
||||||
wrap.querySelector('[data-act="switch"]').onclick = (e) => {
|
psMenu.querySelectorAll('.ps-item[data-name]').forEach(it => {
|
||||||
e.preventDefault();
|
it.onclick = () => {
|
||||||
draftProject = activeProject;
|
if (!guardDrawerDirty()) return;
|
||||||
applyProjectContext();
|
draftProject = it.dataset.name === '' ? null : it.dataset.name;
|
||||||
renderProjectSwitcher();
|
applyProjectContext();
|
||||||
updateUploadBar();
|
renderProjectSwitcher();
|
||||||
refreshSessions();
|
updateUploadBar();
|
||||||
wrap.remove();
|
toggleProjectSwitcher(false);
|
||||||
};
|
};
|
||||||
wrap.querySelector('[data-act="keep"]').onclick = (e) => {
|
});
|
||||||
e.preventDefault();
|
psMenu.querySelectorAll('.ps-item[data-manage]').forEach(it => {
|
||||||
try {
|
it.onclick = () => {
|
||||||
skipped[sid] = activeProject;
|
if (!guardDrawerDirty()) return;
|
||||||
sessionStorage.setItem('genesis_skipped_project_hint', JSON.stringify(skipped));
|
lastTriggerEl = it;
|
||||||
} catch (er) { /* 蹇界暐 */ }
|
toggleProjectSwitcher(false);
|
||||||
wrap.remove();
|
openProjectDrawer(drawerSelected);
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
|
}
|
||||||
// P2鈥慔锛歴end() in-flight 閿侊紝闃叉蹇€熷弻鍑讳骇鐢熼噸澶嶈姹?let sendInFlight = false;
|
|
||||||
async function send() {
|
function toggleProjectSwitcher(force) {
|
||||||
if (sendInFlight) return; // in-flight 鏈熼棿鍐嶆杩涘叆鐩存帴蹇界暐
|
const willOpen = force === undefined ? !psMenu.classList.contains('open') : !!force;
|
||||||
const text = inputEl.value.trim();
|
psMenu.classList.toggle('open', willOpen);
|
||||||
if (!text) return;
|
}
|
||||||
sendInFlight = true;
|
|
||||||
if (!sid) {
|
// ---------- 抽屉 ----------
|
||||||
// 棣栨潯娑堟伅锛氬厛钀藉簱浼氳瘽锛圖-v4锛? try {
|
function openProjectDrawer(projectName) {
|
||||||
const d = await api('POST', '/api/sessions', { user_id: 'default', project: draftProject || null });
|
drawerOpen = true;
|
||||||
sid = d.session_id;
|
toggleProjectSwitcher(false);
|
||||||
activeProject = draftProject || null;
|
drawer.classList.add('open');
|
||||||
badge.textContent = '浼氳瘽: ' + (d.name || '鏂颁細璇?);
|
const sel = (projectName === undefined || projectName === null) ? drawerSelected : projectName;
|
||||||
try { localStorage.setItem('genesis_session', sid); } catch (e) { /* 蹇界暐 */ }
|
try { loadDrawerForm(sel); } catch (e) { console.error(e); }
|
||||||
await refreshSessions();
|
renderDrawerList(); // 始终渲染左侧列表(含 +新建项目)
|
||||||
} catch (e) {
|
}
|
||||||
addMsg('error', '鍒涘缓浼氳瘽澶辫触锛? + esc(String(e)));
|
|
||||||
return;
|
function closeProjectDrawer() {
|
||||||
}
|
if (!guardDrawerDirty()) return;
|
||||||
}
|
drawerOpen = false;
|
||||||
if (progressWs) { try { progressWs.close(); } catch (e) {} }
|
drawer.classList.remove('open');
|
||||||
progressWs = GenesisWS.connectProgressWs(sid, {
|
}
|
||||||
onProgress: (e) => GenesisWS.applyProgressEvent(e, (role, text) => addMsg(role, text)),
|
|
||||||
onClose: () => {},
|
function loadDrawerForm(projectName) {
|
||||||
});
|
drawerSelected = (projectName === undefined) ? drawerSelected : projectName;
|
||||||
inputEl.value = '';
|
const nameEl = document.getElementById('pf-name');
|
||||||
addMsg('user', esc(text));
|
const dispEl = document.getElementById('pf-display');
|
||||||
const typing = showTyping();
|
const tplEl = document.getElementById('pf-template');
|
||||||
sendBtn.disabled = true;
|
const wrEl = document.getElementById('pf-write');
|
||||||
try {
|
const rulesEl = document.getElementById('pf-rules');
|
||||||
const res = await api('POST', '/api/chat/' + encodeURIComponent(sid) + '/messages', { content: text });
|
const codeEl = document.getElementById('pf-code');
|
||||||
typing.remove();
|
const designEl = document.getElementById('pf-design');
|
||||||
if (res.progress && res.progress.length) {
|
const delBtn = document.getElementById('pf-delete');
|
||||||
const items = res.progress.map(p => '<span class="progress-item ' + (p.status || 'ok') + '">' + esc(p.step) + ': ' + esc(p.detail || '') + '</span>').join('');
|
if (drawerSelected) {
|
||||||
addMsg('progress', items);
|
const p = projects.find(x => x.name === drawerSelected);
|
||||||
}
|
if (p) {
|
||||||
let replyHtml = esc(res.reply || '');
|
nameEl.value = p.name; nameEl.readOnly = true;
|
||||||
const s = res.status;
|
dispEl.value = p.display_name || '';
|
||||||
if (s === 'done' || s === 'writing') {
|
tplEl.value = p.template || '';
|
||||||
replyHtml += '<div class="actions">'
|
wrEl.value = p.write_instruction || '';
|
||||||
+ '<a href="/api/sessions/' + sid + '/result/download">涓嬭浇 docx</a>'
|
rulesEl.value = (p.rules || []).join(', ');
|
||||||
+ '<a href="/api/sessions/' + sid + '/result/preview">棰勮</a>'
|
codeEl.value = p.existing_system_code_dir || '';
|
||||||
+ '<a href="/api/sessions/' + sid + '/result/impact-report">褰卞搷璋冩煡涔?/a>'
|
designEl.value = p.design_docs_dir || '';
|
||||||
+ '<a href="/api/sessions/' + sid + '/result/qa-report">QA 鎶ュ憡</a></div>';
|
delBtn.hidden = false;
|
||||||
}
|
}
|
||||||
addMsg('assistant', replyHtml);
|
} else {
|
||||||
} catch (e) {
|
nameEl.value = ''; nameEl.readOnly = false;
|
||||||
typing.remove();
|
dispEl.value = '';
|
||||||
addMsg('error', '璇锋眰澶辫触锛? + esc(String(e)));
|
tplEl.value = ''; wrEl.value = ''; rulesEl.value = ''; codeEl.value = ''; designEl.value = '';
|
||||||
} finally {
|
delBtn.hidden = true;
|
||||||
sendInFlight = false;
|
}
|
||||||
sendBtn.disabled = false;
|
// B6:记录字段快照用于脏检测
|
||||||
inputEl.focus();
|
drawerSnapshot = drawerSnapshotNow();
|
||||||
}
|
renderDrawerList();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('send').addEventListener('click', send);
|
// B6:抽屉未保存变更守卫
|
||||||
inputEl.addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
|
function drawerSnapshotNow() {
|
||||||
document.getElementById('new-chat').addEventListener('click', () => newSession().catch(e => addMsg('error', esc(String(e)))));
|
return GenesisState.computeDrawerSnapshot([
|
||||||
document.getElementById('sidebar-toggle').addEventListener('click', () => toggleSidebar());
|
document.getElementById('pf-name').value,
|
||||||
document.getElementById('ps-current').addEventListener('click', (e) => { e.stopPropagation(); lastTriggerEl = e.currentTarget; toggleProjectSwitcher(); });
|
document.getElementById('pf-display').value,
|
||||||
document.getElementById('drawer-close').addEventListener('click', closeProjectDrawer);
|
document.getElementById('pf-template').value,
|
||||||
document.getElementById('pf-save').addEventListener('click', saveDrawerProject);
|
document.getElementById('pf-write').value,
|
||||||
document.getElementById('pf-delete').addEventListener('click', deleteDrawerProject);
|
document.getElementById('pf-rules').value,
|
||||||
|
document.getElementById('pf-code').value,
|
||||||
// ---------- P0鈥慉锛歊AG 寮€鍏?+ 鐘舵€佸窘鏍?+ 寮€濮嬪奖鍝嶈皟鏌?----------
|
document.getElementById('pf-design').value,
|
||||||
const ragEnabledEl = document.getElementById('rag-enabled');
|
]);
|
||||||
const ragStatsEl = document.getElementById('rag-stats');
|
}
|
||||||
const ragBarEl = document.getElementById('rag-bar');
|
function isDrawerDirty() {
|
||||||
const impactBtnEl = document.getElementById('impact-btn');
|
if (!drawerOpen || drawerSnapshot === null) return false;
|
||||||
|
return GenesisState.isDrawerDirty(drawerSnapshotNow(), drawerSnapshot);
|
||||||
const RAG_LS_KEY = 'genesis_rag_enabled';
|
}
|
||||||
function readRagFlag() {
|
function guardDrawerDirty() {
|
||||||
try { return localStorage.getItem(RAG_LS_KEY) === '1'; } catch (e) { return false; }
|
if (isDrawerDirty()) return confirm('有未保存的修改,确定放弃?');
|
||||||
}
|
return true;
|
||||||
function writeRagFlag(v) {
|
}
|
||||||
try { localStorage.setItem(RAG_LS_KEY, v ? '1' : '0'); } catch (e) { /* 蹇界暐 */ }
|
|
||||||
}
|
// P0‑C:抽屉字段级红框。命中关键字 → 给对应输入加 .invalid 3 秒后清除。
|
||||||
ragEnabledEl.addEventListener('change', () => { writeRagFlag(ragEnabledEl.checked); updateImpactButton(); });
|
// 关键字与 store.py 的 ProjectConfigError 中文 label 对齐。
|
||||||
|
const PF_LABEL_TO_FIELD = {
|
||||||
async function refreshRagStats() {
|
'项目名': 'pf-name',
|
||||||
// 鏃?sid 鈫?闅愯棌 RAG 鏉? if (!sid) {
|
'模板': 'pf-template',
|
||||||
ragBarEl.hidden = true;
|
'做成说明书': 'pf-write',
|
||||||
return;
|
'规则': 'pf-rules',
|
||||||
}
|
'既有系统代码库': 'pf-code',
|
||||||
ragBarEl.hidden = false;
|
'既有设计文档目录': 'pf-design',
|
||||||
let stats = { chunks: 0, rag_enabled: false };
|
};
|
||||||
try {
|
function clearPfInvalid() {
|
||||||
stats = await api('GET', '/api/sessions/' + encodeURIComponent(sid) + '/rag-stats');
|
['pf-name','pf-display','pf-template','pf-write','pf-rules','pf-code','pf-design']
|
||||||
} catch (e) {
|
.forEach(id => document.getElementById(id).classList.remove('invalid'));
|
||||||
stats = { chunks: 0, rag_enabled: false };
|
}
|
||||||
}
|
function flagPfInvalidByMessage(message) {
|
||||||
const n = Number(stats.chunks) || 0;
|
if (!message) return;
|
||||||
ragStatsEl.textContent = n > 0
|
for (const [label, fid] of Object.entries(PF_LABEL_TO_FIELD)) {
|
||||||
? 'RAG 宸茬储寮?' + n + ' 鐗囨'
|
if (message.indexOf(label) >= 0) {
|
||||||
: 'RAG 0 鐗囨锛堣涓婁紶鏃㈡湁绯荤粺 zip锛?;
|
const el = document.getElementById(fid);
|
||||||
ragStatsEl.classList.toggle('ready', n > 0);
|
el.classList.add('invalid');
|
||||||
ragStatsEl.classList.toggle('zero', n === 0);
|
el.focus();
|
||||||
// 寮€鍏冲彲鐢ㄦ€э細蹇呴』鍏堟湁 RAG 绱㈠紩鎵嶈兘鐢? ragEnabledEl.disabled = n === 0;
|
setTimeout(() => el.classList.remove('invalid'), 3000);
|
||||||
if (n === 0) {
|
}
|
||||||
ragEnabledEl.checked = false;
|
}
|
||||||
writeRagFlag(false);
|
}
|
||||||
ragEnabledEl.parentElement.title = '璇峰厛鍦ㄤ笂浼犲尯閫夋嫨"鏃㈡湁绯荤粺 zip"骞朵笂浼?;
|
// 任意输入清掉自身红框(避免"红着输入"误导)
|
||||||
} else {
|
['pf-name','pf-display','pf-template','pf-write','pf-rules','pf-code','pf-design']
|
||||||
ragEnabledEl.checked = readRagFlag();
|
.forEach(id => document.getElementById(id).addEventListener('input', (e) => e.target.classList.remove('invalid')));
|
||||||
ragEnabledEl.parentElement.title = ragEnabledEl.checked
|
|
||||||
? 'RAG 妫€绱㈢粨鏋滀細娉ㄥ叆鍒?LLM prompt'
|
function renderDrawerList() {
|
||||||
: '鍕鹃€夊悗鍚敤 RAG 妫€绱?;
|
let html = '<div class="drawer-item di-create' + (drawerSelected === null ? ' active' : '') + '" data-name="">'
|
||||||
}
|
+ '<span class="di-new">+</span><span class="di-name">新建项目</span></div>';
|
||||||
updateImpactButton();
|
for (const p of projects) {
|
||||||
}
|
html += '<div class="drawer-item' + (p.name === drawerSelected ? ' active' : '') + '" data-name="' + esc(p.name) + '">'
|
||||||
|
+ '<span class="di-name">' + esc(p.display_name || p.name) + '</span></div>';
|
||||||
function updateImpactButton() {
|
}
|
||||||
// 宸叉湁 RAG 绱㈠紩锛坮ag-stats 鍐冲畾锛?涓旀湁 sid 鎵嶈兘褰卞搷
|
const box = document.getElementById('drawer-list');
|
||||||
if (!sid) { impactBtnEl.disabled = true; return; }
|
box.innerHTML = html;
|
||||||
const hasRag = (Number(ragStatsEl.dataset.chunks || 0) > 0) || !ragEnabledEl.disabled;
|
box.querySelectorAll('.drawer-item').forEach(it => {
|
||||||
impactBtnEl.disabled = !hasRag;
|
it.onclick = () => {
|
||||||
impactBtnEl.textContent = ragEnabledEl.checked
|
if (!guardDrawerDirty()) return;
|
||||||
? '寮€濮嬪奖鍝嶈皟鏌ワ紙鍚敤 RAG锛?
|
const n = it.dataset.name === '' ? null : it.dataset.name;
|
||||||
: '寮€濮嬪奖鍝嶈皟鏌?;
|
loadDrawerForm(n);
|
||||||
}
|
};
|
||||||
|
});
|
||||||
impactBtnEl.addEventListener('click', async () => {
|
}
|
||||||
if (!sid) { addMsg('error', '璇峰厛鍙戦€佷竴鏉℃秷鎭互鍒涘缓浼氳瘽'); inputEl.focus(); return; }
|
|
||||||
const useRag = !!ragEnabledEl.checked;
|
async function saveDrawerProject() {
|
||||||
impactBtnEl.disabled = true;
|
clearPfInvalid();
|
||||||
const typing = showTyping();
|
const body = {
|
||||||
try {
|
name: document.getElementById('pf-name').value.trim(),
|
||||||
const url = '/api/sessions/' + encodeURIComponent(sid) + '/start-impact?use_rag=' + (useRag ? 'true' : 'false');
|
display_name: document.getElementById('pf-display').value.trim(),
|
||||||
const r = await fetch(url, { method: 'POST' });
|
template: document.getElementById('pf-template').value.trim(),
|
||||||
const d = await r.json();
|
write_instruction: document.getElementById('pf-write').value.trim(),
|
||||||
if (!r.ok) throw new Error(d.detail?.message || r.status);
|
rules: document.getElementById('pf-rules').value.split(',').map(s => s.trim()).filter(Boolean),
|
||||||
typing.remove();
|
existing_system_code_dir: document.getElementById('pf-code').value.trim(),
|
||||||
addMsg('progress', '<span class="progress-item ok">褰卞搷璋冩煡宸叉彁浜わ紙use_rag=' + (useRag ? 'true' : 'false') + '锛?/span>');
|
design_docs_dir: document.getElementById('pf-design').value.trim(),
|
||||||
// 璁?WebSocket 鎶婂悗缁繘搴︽帹涓婃潵锛涚◢鍚庣敤鎴峰彲鏌?impact-result
|
};
|
||||||
addMsg('assistant', '褰卞搷璋冩煡宸插惎鍔ㄣ€傚畬鎴愬悗鍙湪姝や細璇濈偣鍑?棰勮"鎴栬闂?/result/impact-report 涓嬭浇鎶ュ憡銆?);
|
if (!body.name) {
|
||||||
} catch (e) {
|
flagPfInvalidByMessage('项目名');
|
||||||
typing.remove();
|
addMsg('error', '项目名不能为空');
|
||||||
addMsg('error', '褰卞搷璋冩煡澶辫触锛? + esc(String(e)));
|
return;
|
||||||
} finally {
|
}
|
||||||
updateImpactButton();
|
try {
|
||||||
}
|
const cfg = await api('POST', '/api/projects', body);
|
||||||
});
|
draftProject = cfg.name;
|
||||||
document.addEventListener('click', (e) => {
|
drawerSelected = cfg.name;
|
||||||
// 鐐瑰嚮鍒囨崲鍣ㄥ閮ㄥ叧闂笅鎷? if (!document.getElementById('proj-switcher').contains(e.target)) toggleProjectSwitcher(false);
|
await loadProjects();
|
||||||
});
|
addMsg('assistant', '项目「' + esc(cfg.display_name || cfg.name) + '」已保存。');
|
||||||
document.addEventListener('keydown', (e) => {
|
closeProjectDrawer();
|
||||||
if (e.key === 'Escape') {
|
} catch (e) {
|
||||||
if (psMenu.classList.contains('open')) toggleProjectSwitcher(false);
|
// 400 / PROJECT_CONFIG_INVALID:字段级红框定位
|
||||||
else if (drawerOpen) closeProjectDrawer();
|
flagPfInvalidByMessage(String(e));
|
||||||
if (lastTriggerEl) { lastTriggerEl.focus(); lastTriggerEl = null; }
|
addMsg('error', '保存项目失败:' + esc(String(e)));
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// 瀹㈡埛绔被鍨?鈫?鎵╁睍鍚嶇櫧鍚嶅崟鏍¢獙锛圥0鈥態 / P1鈥慐锛夈€?const UPLOAD_EXT_RULES = {
|
async function deleteDrawerProject() {
|
||||||
existing_system: ['.zip'],
|
if (!drawerSelected) return;
|
||||||
requirements: ['.xlsx'],
|
if (!confirm('确认删除项目「' + drawerSelected + '」?此操作不可撤销。')) return;
|
||||||
template: ['.docx'],
|
const deleted = drawerSelected;
|
||||||
write_instruction: ['.docx'],
|
try {
|
||||||
rules: ['.docx', '.xlsx'],
|
await api('DELETE', '/api/projects/' + encodeURIComponent(deleted));
|
||||||
};
|
} catch (e) {
|
||||||
function pickExt(name) {
|
addMsg('error', '删除项目失败:' + esc(String(e)));
|
||||||
const i = String(name).lastIndexOf('.');
|
return;
|
||||||
return i >= 0 ? String(name).slice(i).toLowerCase() : '';
|
}
|
||||||
}
|
addMsg('assistant', '项目「' + esc(deleted) + '」已删除。');
|
||||||
// 椤圭洰绾ч缃?鈫?鎻愰啋锛氬啀娆′笂浼犲皢瑕嗙洊璇ヤ細璇濈殑璇ョ被鍨嬫枃浠躲€?// 杩欓噷鐢?activeProject/draftProject 瀵瑰簲鐨勯」鐩?config锛坧rojects[]锛夊垽瀹氭槸鍚﹂缃€?function projectHasPrefixedType(ft) {
|
// P1‑F:先记住被删的,再清 drawerSelected;避免"先 null 后比较"恒假 bug
|
||||||
const name = activeProject || draftProject;
|
drawerSelected = null;
|
||||||
if (!name) return false;
|
if (draftProject === deleted) {
|
||||||
const p = (projects || []).find(x => x.name === name);
|
// 当前选的就是被删的项目 → 清空偏好(autoBindProject 会从剩余项目重选)
|
||||||
if (!p) return false;
|
draftProject = null;
|
||||||
if (ft === 'template') return !!(p.template && p.template.trim());
|
}
|
||||||
if (ft === 'write_instruction') return !!(p.write_instruction && p.write_instruction.trim());
|
await loadProjects();
|
||||||
if (ft === 'existing_system') return !!(p.existing_system_code_dir && p.existing_system_code_dir.trim());
|
loadDrawerForm(null);
|
||||||
if (ft === 'rules') return Array.isArray(p.rules) && p.rules.length > 0;
|
}
|
||||||
return false;
|
|
||||||
}
|
// ---------- 侧边栏折叠 ----------
|
||||||
|
function toggleSidebar(force) {
|
||||||
document.getElementById('upload-btn').addEventListener('click', async () => {
|
const el = document.getElementById('sidebar');
|
||||||
if (!sid) { addMsg('error', '璇峰厛鍙戦€佷竴鏉℃秷鎭互鍒涘缓浼氳瘽'); inputEl.focus(); return; }
|
const willCollapse = force === undefined ? !el.classList.contains('collapsed') : !!force;
|
||||||
const sel = document.getElementById('file-type');
|
el.classList.toggle('collapsed', willCollapse);
|
||||||
const ft = GenesisState.resolveUploadType(activeProject, draftProject, sel.value);
|
// 切换折叠/展开图标 + title + data-state
|
||||||
const file = document.getElementById('file-input').files[0];
|
const btn = document.getElementById('sidebar-toggle');
|
||||||
if (!file) { addMsg('error', '璇烽€夋嫨鏂囦欢'); return; }
|
if (btn) {
|
||||||
// P0鈥態 / P1鈥慐锛氬鎴风绫诲瀷涓庢墿灞曞悕鏍¢獙
|
if (willCollapse) {
|
||||||
const allowed = UPLOAD_EXT_RULES[ft];
|
btn.textContent = '»';
|
||||||
if (allowed) {
|
btn.title = '展开侧边栏';
|
||||||
const ext = pickExt(file.name);
|
btn.dataset.state = 'collapsed';
|
||||||
if (!ext || !allowed.includes(ext)) {
|
} else {
|
||||||
addMsg('error', '绫诲瀷涓嶅尮閰嶏細' + esc(ft) + ' 搴斾负 ' + allowed.join('/') + ' 鏂囦欢锛堝綋鍓嶏細' + esc(file.name) + '锛?);
|
btn.textContent = '«';
|
||||||
return;
|
btn.title = '折叠侧边栏';
|
||||||
}
|
btn.dataset.state = 'expanded';
|
||||||
}
|
}
|
||||||
// P0鈥態锛氶」鐩凡棰勭疆鍚岀被鍨嬫椂鏄惧紡纭瑕嗙洊锛堜笉鍐嶄互"钘忚捣鍏ュ彛"鍋氶殣寮忕害鏉燂級
|
}
|
||||||
if (projectHasPrefixedType(ft)) {
|
try { localStorage.setItem('genesis_sidebar_collapsed', willCollapse ? '1' : '0'); } catch (e) { /* localStorage 不可用时忽略 */ }
|
||||||
const ok = confirm('椤圭洰宸查缃?' + ft + '銆傜户缁笂浼犲皢瑕嗙洊璇ヤ細璇濅腑鐨勬绫诲瀷鏂囦欢锛屾槸鍚︾户缁紵');
|
}
|
||||||
if (!ok) return;
|
|
||||||
}
|
// ---------- 上传区:始终显示完整类型下拉 ----------
|
||||||
const fd = new FormData();
|
// P0‑B:选项目时也允许补传 template / write_instruction / rules / existing_system,
|
||||||
fd.append('file_type', ft);
|
// 不再藏起入口。"项目已预置 template 时再上传 template"在上传处用 confirm() 显式确认。
|
||||||
fd.append('file', file);
|
function updateUploadBar() {
|
||||||
const statusEl = document.getElementById('upload-status');
|
const sel = document.getElementById('file-type');
|
||||||
statusEl.textContent = '涓婁紶涓€?;
|
const hint = document.getElementById('proj-hint');
|
||||||
try {
|
if (sel) sel.style.display = '';
|
||||||
const r = await fetch('/api/sessions/' + encodeURIComponent(sid) + '/files', { method: 'POST', body: fd });
|
if (!hint) return;
|
||||||
const d = await r.json();
|
// 默认 file_type 仍为 requirements(最常用),但下拉始终可改。
|
||||||
if (!r.ok) throw new Error(d.detail?.message || r.status);
|
if (sel && sel.value !== 'requirements') sel.value = 'requirements';
|
||||||
statusEl.textContent = '';
|
if (draftProject) {
|
||||||
// B5 / P1鈥慒锛氫笂浼犳垚鍔熷悗鍚屾 activeProject銆佸埛鏂颁笂浼犳潯涓庝晶杈规爮鐘舵€? const rec = await api('GET', '/api/sessions/' + encodeURIComponent(sid));
|
hint.textContent = '默认上传要件定义 xlsx;如需补传模板/规则/既有系统 zip,请在下拉中选择对应类型。';
|
||||||
activeProject = rec.project || null;
|
} else {
|
||||||
badge.textContent = '浼氳瘽: ' + (rec.name || '鏂颁細璇?);
|
hint.textContent = '请选择 file_type 后上传(未选项目时,模板/规则仍可手动上传)。';
|
||||||
await refreshSessions();
|
}
|
||||||
updateUploadBar(); // P1鈥慒锛氫笂浼犳垚鍔熷悗鍚屾涓婁紶鏉★紙闃茬姸鎬佹粸鍚庯級
|
}
|
||||||
await refreshRagStats(); // P0鈥慉锛氫笂浼?existing_system 鍚?RAG 绱㈠紩宸插缓
|
|
||||||
document.getElementById('file-input').value = ''; // 浠呮垚鍔熸椂娓呯┖
|
// ---------- 空态/欢迎渲染 ----------
|
||||||
addMsg('user', '[涓婁紶] ' + ft + '锛? + d.file_name);
|
function renderEmptyOrWelcome() {
|
||||||
addMsg('progress', '<span class="progress-item ok">涓婁紶瀹屾垚锛? + esc(d.file_name) + '锛? + d.size + 'B锛?/span>');
|
chatEl.innerHTML = '';
|
||||||
} catch (e) {
|
// P1‑F:欢迎文由 GenesisState.buildWelcome 统一来源
|
||||||
statusEl.textContent = '';
|
addMsg('assistant', GenesisState.buildWelcome(draftProject));
|
||||||
// F1锛氬け璐ヤ繚鐣?file-input 鍊硷紝灞曠ず鍚庣鏄庣粏
|
}
|
||||||
addMsg('error', '涓婁紶澶辫触锛? + esc(String(e)));
|
|
||||||
}
|
// ---------- 会话创建/加载 ----------
|
||||||
});
|
async function newSession() {
|
||||||
|
if (progressWs) { try { progressWs.close(); } catch (e) {} } progressWs = null;
|
||||||
// ---------- 鍚姩锛氭仮澶嶄晶杈规爮鎶樺彔鎬併€佷笂娆′細璇?----------
|
sid = null;
|
||||||
(async () => {
|
activeProject = null;
|
||||||
try {
|
// 保留 draftProject(当前顶栏已选项目),使新会话继承项目绑定(§4.2)
|
||||||
const collapsed = localStorage.getItem('genesis_sidebar_collapsed') === '1';
|
try { localStorage.removeItem('genesis_session'); } catch (e) { /* 忽略 */ }
|
||||||
if (collapsed) document.getElementById('sidebar').classList.add('collapsed');
|
chatEl.innerHTML = '';
|
||||||
} catch (e) { /* 蹇界暐 */ }
|
badge.textContent = '未创建会话';
|
||||||
await loadProjects();
|
updateUploadBar();
|
||||||
const saved = (() => { try { return localStorage.getItem('genesis_session'); } catch (e) { return null; } })();
|
await refreshRagStats();
|
||||||
if (saved) {
|
await refreshSessions();
|
||||||
// P2鈥慔锛氬惎鍔ㄦ仮澶嶅墠鏄剧ず loading 鍗犱綅锛岄伩鍏嶇┖ chat 涓€闂? addMsg('assistant', '姝e湪鎭㈠涓婃浼氳瘽鈥?);
|
renderEmptyOrWelcome();
|
||||||
try {
|
}
|
||||||
await loadSession(saved);
|
|
||||||
// P2鈥?锛氭仮澶嶅悗鑻ヤ細璇濈殑椤圭洰宸茶鍒狅紝娓呮帀 localStorage 浠ュ厤涓嬫鍐嶈瘯
|
async function loadSession(id) {
|
||||||
const rec = await api('GET', '/api/sessions/' + encodeURIComponent(saved));
|
try {
|
||||||
const recProj = rec && rec.project;
|
const rec = await api('GET', '/api/sessions/' + encodeURIComponent(id));
|
||||||
if (recProj && !projects.find(p => p.name === recProj)) {
|
sid = id;
|
||||||
try { localStorage.removeItem('genesis_session'); } catch (e) { /* 蹇界暐 */ }
|
activeProject = rec.project || null;
|
||||||
// 椤舵爮鑻ユ樉绀虹殑鏄凡鍒犻」鐩紝鍒锋柊鍥為€€
|
// P1‑D:不再隐式覆盖 draftProject。会话事实(activeProject)与用户偏好(draftProject)
|
||||||
if (draftProject === recProj) {
|
// 分层;若不一致,由提示条让用户主动选择。
|
||||||
draftProject = (projects.length > 0) ? projects[0].name : null;
|
// draftProject = rec.project || null; // 删除:原行为会悄悄改用户偏好
|
||||||
applyProjectContext();
|
applyProjectContext();
|
||||||
}
|
badge.textContent = '会话: ' + (rec.name || '新会话');
|
||||||
}
|
if (progressWs) { try { progressWs.close(); } catch (e) {} }
|
||||||
return;
|
progressWs = GenesisWS.connectProgressWs(id, {
|
||||||
} catch (e) { /* 蹇界暐锛屾柊寤?*/ }
|
onProgress: (e) => GenesisWS.applyProgressEvent(e, (role, text) => addMsg(role, text)),
|
||||||
}
|
onClose: () => {},
|
||||||
newSession().catch(e => addMsg('error', esc(String(e))));
|
});
|
||||||
})();␍
|
const msgs = await api('GET', '/api/chat/' + encodeURIComponent(id) + '/messages');
|
||||||
|
chatEl.innerHTML = '';
|
||||||
|
for (const m of msgs) {
|
||||||
|
const roleMap = { user: 'user', progress: 'progress', error: 'error' };
|
||||||
|
const role = roleMap[m.role] || 'assistant';
|
||||||
|
addMsg(role, esc(m.content));
|
||||||
|
}
|
||||||
|
updateUploadBar();
|
||||||
|
await refreshRagStats();
|
||||||
|
await refreshSessions();
|
||||||
|
try { localStorage.setItem('genesis_session', sid); } catch (e) { /* 忽略 */ }
|
||||||
|
renderProjectMismatchHint();
|
||||||
|
} catch (e) {
|
||||||
|
addMsg('error', '加载会话失败:' + esc(String(e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P1‑D:项目不匹配提示条。activeProject 与 draftProject 不一致时显示,
|
||||||
|
// "切到该项目" → 写 draftProject = activeProject,刷新;
|
||||||
|
// "保留" → 写入 sessionStorage 标记,刷新;后续 loadSession 不再提示。
|
||||||
|
function renderProjectMismatchHint() {
|
||||||
|
const old = document.getElementById('proj-mismatch-hint');
|
||||||
|
if (old) old.remove();
|
||||||
|
if (!sid) return;
|
||||||
|
if (!activeProject) return;
|
||||||
|
if (activeProject === draftProject) return;
|
||||||
|
// 已"保留"过本会话的不再提示
|
||||||
|
let skipped = {};
|
||||||
|
try { skipped = JSON.parse(sessionStorage.getItem('genesis_skipped_project_hint') || '{}'); } catch (e) { skipped = {}; }
|
||||||
|
if (skipped[sid] === activeProject) return;
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.id = 'proj-mismatch-hint';
|
||||||
|
wrap.className = 'msg assistant';
|
||||||
|
wrap.innerHTML =
|
||||||
|
'<div class="bubble">' +
|
||||||
|
'该会话属于项目 <strong>' + esc(activeProject) + '</strong>,' +
|
||||||
|
'当前顶栏项目为 <strong>' + esc(draftProject || '(未选择)') + '</strong>。' +
|
||||||
|
'<div class="actions">' +
|
||||||
|
'<a href="#" data-act="switch">切到该项目</a>' +
|
||||||
|
'<a href="#" data-act="keep">保留当前项目</a>' +
|
||||||
|
'</div></div>';
|
||||||
|
chatEl.prepend(wrap);
|
||||||
|
wrap.querySelector('[data-act="switch"]').onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
draftProject = activeProject;
|
||||||
|
applyProjectContext();
|
||||||
|
renderProjectSwitcher();
|
||||||
|
updateUploadBar();
|
||||||
|
refreshSessions();
|
||||||
|
wrap.remove();
|
||||||
|
};
|
||||||
|
wrap.querySelector('[data-act="keep"]').onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
skipped[sid] = activeProject;
|
||||||
|
sessionStorage.setItem('genesis_skipped_project_hint', JSON.stringify(skipped));
|
||||||
|
} catch (er) { /* 忽略 */ }
|
||||||
|
wrap.remove();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// P2‑H:send() in-flight 锁,防止快速双击产生重复请求
|
||||||
|
let sendInFlight = false;
|
||||||
|
async function send() {
|
||||||
|
if (sendInFlight) return; // in-flight 期间再次进入直接忽略
|
||||||
|
const text = inputEl.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
sendInFlight = true;
|
||||||
|
if (!sid) {
|
||||||
|
// 首条消息:先落库会话(D-v4)
|
||||||
|
try {
|
||||||
|
const d = await api('POST', '/api/sessions', { user_id: 'default', project: draftProject || null });
|
||||||
|
sid = d.session_id;
|
||||||
|
activeProject = draftProject || null;
|
||||||
|
badge.textContent = '会话: ' + (d.name || '新会话');
|
||||||
|
try { localStorage.setItem('genesis_session', sid); } catch (e) { /* 忽略 */ }
|
||||||
|
await refreshSessions();
|
||||||
|
} catch (e) {
|
||||||
|
addMsg('error', '创建会话失败:' + esc(String(e)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (progressWs) { try { progressWs.close(); } catch (e) {} }
|
||||||
|
progressWs = GenesisWS.connectProgressWs(sid, {
|
||||||
|
onProgress: (e) => GenesisWS.applyProgressEvent(e, (role, text) => addMsg(role, text)),
|
||||||
|
onClose: () => {},
|
||||||
|
});
|
||||||
|
inputEl.value = '';
|
||||||
|
addMsg('user', esc(text));
|
||||||
|
const typing = showTyping();
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await api('POST', '/api/chat/' + encodeURIComponent(sid) + '/messages', { content: text });
|
||||||
|
typing.remove();
|
||||||
|
if (res.progress && res.progress.length) {
|
||||||
|
const items = res.progress.map(p => '<span class="progress-item ' + (p.status || 'ok') + '">' + esc(p.step) + ': ' + esc(p.detail || '') + '</span>').join('');
|
||||||
|
addMsg('progress', items);
|
||||||
|
}
|
||||||
|
let replyHtml = esc(res.reply || '');
|
||||||
|
const s = res.status;
|
||||||
|
if (s === 'done' || s === 'writing') {
|
||||||
|
replyHtml += '<div class="actions">'
|
||||||
|
+ '<a href="/api/sessions/' + sid + '/result/download">下载 docx</a>'
|
||||||
|
+ '<a href="/api/sessions/' + sid + '/result/preview">预览</a>'
|
||||||
|
+ '<a href="/api/sessions/' + sid + '/result/impact-report">影响调查书</a>'
|
||||||
|
+ '<a href="/api/sessions/' + sid + '/result/qa-report">QA 报告</a></div>';
|
||||||
|
}
|
||||||
|
addMsg('assistant', replyHtml);
|
||||||
|
} catch (e) {
|
||||||
|
typing.remove();
|
||||||
|
addMsg('error', '请求失败:' + esc(String(e)));
|
||||||
|
} finally {
|
||||||
|
sendInFlight = false;
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
inputEl.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('send').addEventListener('click', send);
|
||||||
|
inputEl.addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
|
||||||
|
document.getElementById('new-chat').addEventListener('click', () => newSession().catch(e => addMsg('error', esc(String(e)))));
|
||||||
|
document.getElementById('sidebar-toggle').addEventListener('click', () => toggleSidebar());
|
||||||
|
document.getElementById('ps-current').addEventListener('click', (e) => { e.stopPropagation(); lastTriggerEl = e.currentTarget; toggleProjectSwitcher(); });
|
||||||
|
document.getElementById('drawer-close').addEventListener('click', closeProjectDrawer);
|
||||||
|
document.getElementById('pf-save').addEventListener('click', saveDrawerProject);
|
||||||
|
document.getElementById('pf-delete').addEventListener('click', deleteDrawerProject);
|
||||||
|
|
||||||
|
// ---------- P0‑A:RAG 开关 + 状态徽标 + 开始影响调查 ----------
|
||||||
|
const ragEnabledEl = document.getElementById('rag-enabled');
|
||||||
|
const ragStatsEl = document.getElementById('rag-stats');
|
||||||
|
const ragBarEl = document.getElementById('rag-bar');
|
||||||
|
const impactBtnEl = document.getElementById('impact-btn');
|
||||||
|
|
||||||
|
const RAG_LS_KEY = 'genesis_rag_enabled';
|
||||||
|
function readRagFlag() {
|
||||||
|
try { return localStorage.getItem(RAG_LS_KEY) === '1'; } catch (e) { return false; }
|
||||||
|
}
|
||||||
|
function writeRagFlag(v) {
|
||||||
|
try { localStorage.setItem(RAG_LS_KEY, v ? '1' : '0'); } catch (e) { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
ragEnabledEl.addEventListener('change', () => { writeRagFlag(ragEnabledEl.checked); updateImpactButton(); });
|
||||||
|
|
||||||
|
async function refreshRagStats() {
|
||||||
|
// 无 sid → 隐藏 RAG 条
|
||||||
|
if (!sid) {
|
||||||
|
ragBarEl.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ragBarEl.hidden = false;
|
||||||
|
let stats = { chunks: 0, rag_enabled: false };
|
||||||
|
try {
|
||||||
|
stats = await api('GET', '/api/sessions/' + encodeURIComponent(sid) + '/rag-stats');
|
||||||
|
} catch (e) {
|
||||||
|
stats = { chunks: 0, rag_enabled: false };
|
||||||
|
}
|
||||||
|
const n = Number(stats.chunks) || 0;
|
||||||
|
ragStatsEl.textContent = n > 0
|
||||||
|
? 'RAG 已索引 ' + n + ' 片段'
|
||||||
|
: 'RAG 0 片段(请上传既有系统 zip)';
|
||||||
|
ragStatsEl.classList.toggle('ready', n > 0);
|
||||||
|
ragStatsEl.classList.toggle('zero', n === 0);
|
||||||
|
// 开关可用性:必须先有 RAG 索引才能用
|
||||||
|
ragEnabledEl.disabled = n === 0;
|
||||||
|
if (n === 0) {
|
||||||
|
ragEnabledEl.checked = false;
|
||||||
|
writeRagFlag(false);
|
||||||
|
ragEnabledEl.parentElement.title = '请先在上传区选择"既有系统 zip"并上传';
|
||||||
|
} else {
|
||||||
|
ragEnabledEl.checked = readRagFlag();
|
||||||
|
ragEnabledEl.parentElement.title = ragEnabledEl.checked
|
||||||
|
? 'RAG 检索结果会注入到 LLM prompt'
|
||||||
|
: '勾选后启用 RAG 检索';
|
||||||
|
}
|
||||||
|
updateImpactButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateImpactButton() {
|
||||||
|
// 已有 RAG 索引(rag-stats 决定) 且有 sid 才能影响
|
||||||
|
if (!sid) { impactBtnEl.disabled = true; return; }
|
||||||
|
const hasRag = (Number(ragStatsEl.dataset.chunks || 0) > 0) || !ragEnabledEl.disabled;
|
||||||
|
impactBtnEl.disabled = !hasRag;
|
||||||
|
impactBtnEl.textContent = ragEnabledEl.checked
|
||||||
|
? '开始影响调查(启用 RAG)'
|
||||||
|
: '开始影响调查';
|
||||||
|
}
|
||||||
|
|
||||||
|
impactBtnEl.addEventListener('click', async () => {
|
||||||
|
if (!sid) { addMsg('error', '请先发送一条消息以创建会话'); inputEl.focus(); return; }
|
||||||
|
const useRag = !!ragEnabledEl.checked;
|
||||||
|
impactBtnEl.disabled = true;
|
||||||
|
const typing = showTyping();
|
||||||
|
try {
|
||||||
|
const url = '/api/sessions/' + encodeURIComponent(sid) + '/start-impact?use_rag=' + (useRag ? 'true' : 'false');
|
||||||
|
const r = await fetch(url, { method: 'POST' });
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.detail?.message || r.status);
|
||||||
|
typing.remove();
|
||||||
|
addMsg('progress', '<span class="progress-item ok">影响调查已提交(use_rag=' + (useRag ? 'true' : 'false') + ')</span>');
|
||||||
|
// 让 WebSocket 把后续进度推上来;稍后用户可查 impact-result
|
||||||
|
addMsg('assistant', '影响调查已启动。完成后可在此会话点击"预览"或访问 /result/impact-report 下载报告。');
|
||||||
|
} catch (e) {
|
||||||
|
typing.remove();
|
||||||
|
addMsg('error', '影响调查失败:' + esc(String(e)));
|
||||||
|
} finally {
|
||||||
|
updateImpactButton();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
// 点击切换器外部关闭下拉
|
||||||
|
if (!document.getElementById('proj-switcher').contains(e.target)) toggleProjectSwitcher(false);
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (psMenu.classList.contains('open')) toggleProjectSwitcher(false);
|
||||||
|
else if (drawerOpen) closeProjectDrawer();
|
||||||
|
if (lastTriggerEl) { lastTriggerEl.focus(); lastTriggerEl = null; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 客户端类型 → 扩展名白名单校验(P0‑B / P1‑E)。
|
||||||
|
const UPLOAD_EXT_RULES = {
|
||||||
|
existing_system: ['.zip'],
|
||||||
|
requirements: ['.xlsx'],
|
||||||
|
template: ['.docx'],
|
||||||
|
write_instruction: ['.docx'],
|
||||||
|
rules: ['.docx', '.xlsx'],
|
||||||
|
};
|
||||||
|
function pickExt(name) {
|
||||||
|
const i = String(name).lastIndexOf('.');
|
||||||
|
return i >= 0 ? String(name).slice(i).toLowerCase() : '';
|
||||||
|
}
|
||||||
|
// 项目级预置 → 提醒:再次上传将覆盖该会话的该类型文件。
|
||||||
|
// 这里用 activeProject/draftProject 对应的项目 config(projects[])判定是否预置。
|
||||||
|
function projectHasPrefixedType(ft) {
|
||||||
|
const name = activeProject || draftProject;
|
||||||
|
if (!name) return false;
|
||||||
|
const p = (projects || []).find(x => x.name === name);
|
||||||
|
if (!p) return false;
|
||||||
|
if (ft === 'template') return !!(p.template && p.template.trim());
|
||||||
|
if (ft === 'write_instruction') return !!(p.write_instruction && p.write_instruction.trim());
|
||||||
|
if (ft === 'existing_system') return !!(p.existing_system_code_dir && p.existing_system_code_dir.trim());
|
||||||
|
if (ft === 'rules') return Array.isArray(p.rules) && p.rules.length > 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('upload-btn').addEventListener('click', async () => {
|
||||||
|
if (!sid) { addMsg('error', '请先发送一条消息以创建会话'); inputEl.focus(); return; }
|
||||||
|
const sel = document.getElementById('file-type');
|
||||||
|
const ft = GenesisState.resolveUploadType(activeProject, draftProject, sel.value);
|
||||||
|
const file = document.getElementById('file-input').files[0];
|
||||||
|
if (!file) { addMsg('error', '请选择文件'); return; }
|
||||||
|
// P0‑B / P1‑E:客户端类型与扩展名校验
|
||||||
|
const allowed = UPLOAD_EXT_RULES[ft];
|
||||||
|
if (allowed) {
|
||||||
|
const ext = pickExt(file.name);
|
||||||
|
if (!ext || !allowed.includes(ext)) {
|
||||||
|
addMsg('error', '类型不匹配:' + esc(ft) + ' 应为 ' + allowed.join('/') + ' 文件(当前:' + esc(file.name) + ')');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// P0‑B:项目已预置同类型时显式确认覆盖(不再以"藏起入口"做隐式约束)
|
||||||
|
if (projectHasPrefixedType(ft)) {
|
||||||
|
const ok = confirm('项目已预置 ' + ft + '。继续上传将覆盖该会话中的此类型文件,是否继续?');
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file_type', ft);
|
||||||
|
fd.append('file', file);
|
||||||
|
const statusEl = document.getElementById('upload-status');
|
||||||
|
statusEl.textContent = '上传中…';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sessions/' + encodeURIComponent(sid) + '/files', { method: 'POST', body: fd });
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.detail?.message || r.status);
|
||||||
|
statusEl.textContent = '';
|
||||||
|
// B5 / P1‑F:上传成功后同步 activeProject、刷新上传条与侧边栏状态
|
||||||
|
const rec = await api('GET', '/api/sessions/' + encodeURIComponent(sid));
|
||||||
|
activeProject = rec.project || null;
|
||||||
|
badge.textContent = '会话: ' + (rec.name || '新会话');
|
||||||
|
await refreshSessions();
|
||||||
|
updateUploadBar(); // P1‑F:上传成功后同步上传条(防状态滞后)
|
||||||
|
await refreshRagStats(); // P0‑A:上传 existing_system 后 RAG 索引已建
|
||||||
|
document.getElementById('file-input').value = ''; // 仅成功时清空
|
||||||
|
addMsg('user', '[上传] ' + ft + ':' + d.file_name);
|
||||||
|
addMsg('progress', '<span class="progress-item ok">上传完成:' + esc(d.file_name) + '(' + d.size + 'B)</span>');
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.textContent = '';
|
||||||
|
// F1:失败保留 file-input 值,展示后端明细
|
||||||
|
addMsg('error', '上传失败:' + esc(String(e)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 启动:恢复侧边栏折叠态、上次会话 ----------
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const collapsed = localStorage.getItem('genesis_sidebar_collapsed') === '1';
|
||||||
|
if (collapsed) document.getElementById('sidebar').classList.add('collapsed');
|
||||||
|
} catch (e) { /* 忽略 */ }
|
||||||
|
await loadProjects();
|
||||||
|
const saved = (() => { try { return localStorage.getItem('genesis_session'); } catch (e) { return null; } })();
|
||||||
|
if (saved) {
|
||||||
|
// P2‑H:启动恢复前显示 loading 占位,避免空 chat 一闪
|
||||||
|
addMsg('assistant', '正在恢复上次会话…');
|
||||||
|
try {
|
||||||
|
await loadSession(saved);
|
||||||
|
// P2‑7:恢复后若会话的项目已被删,清掉 localStorage 以免下次再试
|
||||||
|
const rec = await api('GET', '/api/sessions/' + encodeURIComponent(saved));
|
||||||
|
const recProj = rec && rec.project;
|
||||||
|
if (recProj && !projects.find(p => p.name === recProj)) {
|
||||||
|
try { localStorage.removeItem('genesis_session'); } catch (e) { /* 忽略 */ }
|
||||||
|
// 顶栏若显示的是已删项目,刷新回退
|
||||||
|
if (draftProject === recProj) {
|
||||||
|
draftProject = (projects.length > 0) ? projects[0].name : null;
|
||||||
|
applyProjectContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch (e) { /* 忽略,新建 */ }
|
||||||
|
}
|
||||||
|
newSession().catch(e => addMsg('error', esc(String(e))));
|
||||||
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user