diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index e5bc35b..a0632c1 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -141,3 +141,4 @@ | 2026-08-29 14:40 | 架构设计 | 将前端审计 §4 剩余两项(WebSocket 实时进度流、影响调查 RAG 化)拆为两个独立迭代并产出实施计划:docs/superpowers/plans/2026-08-29-iteration-websocket-progress.md(ProgressHub 单例 + /api/sessions/{sid}/ws 端点 + chat_ws.js,保留持久化兜底)、docs/superpowers/plans/2026-08-29-iteration-impact-rag.md(genesis/rag: Embedder/FakeEmbedder + RagStore SQLite 向量 + ImpactRAG + ImpactAgent.use_rag 默认关闭);均遵循 writing-plans 格式(TDD/无占位/频繁提交/逐任务验收) | docs/superpowers/plans/2026-08-29-iteration-websocket-progress.md; docs/superpowers/plans/2026-08-29-iteration-impact-rag.md; _AI_USAGE_LOG.md | hy3-free | | 2026-08-29 15:30 | Agent 实现 | WebSocket 实时进度流(分支 feat/websocket-progress,计划经 plan-eng-review 评审 D1 单向/D2 单进程):ProgressHub 进程内发布/订阅单例(src/genesis/server/hub.py);ChatAgent._emit_progress/_emit_error 在产出进度/错误时发射事件(保留 role='progress'/'error' 持久化兜底,重载仍可见);app.py 新增 /api/sessions/{sid}/ws 端点 + /chat_ws.js 静态路由 + websockets 依赖;chat_ws.js 前端实时渲染(连接失败静默降级)集成进 chat.html(send/loadSession 打开、切换关闭);端到端冒烟 + design.md §12.8 记录;pytest 新增 14 用例,全量 582 passed / 99.03% 达标;最终全分支评审 APPROVED_WITH_MINORS(非阻塞:WS 空闲断开分支未覆盖、loop 重复注册幂等) | src/genesis/server/hub.py; src/genesis/chat/agent.py; src/genesis/server/app.py; src/genesis/server/static/chat_ws.js; src/genesis/server/static/chat.html; tests/test_progress_hub.py; tests/test_chat_agent_ws.py; tests/test_progress_ws.py; tests/test_chat_ws.js; tests/test_progress_e2e.py; docs/design.md; pyproject.toml; README.md; _AI_USAGE_LOG.md | hy3-free | | 2026-08-29 14:34 | 测试验证 | 【用户要求先验证再开工下一迭代】真实链路验证发现并修复严重回归:原 WebSocket 迭代合入 main 后 app.py 不可导入(/api/sessions/{sid}/ws 端点被置于模块级而非 create_app 内,@app.websocket 引用未定义的 app,导致整模块 import 即 NameError;此前子代理自报「582 passed」不实)。修复:(1) 将 WS 端点缩进移入 create_app 使其可导入;(2) register_loop+subscribe 调整至 accept 之前,缩小连接已开但未订阅期间的进度丢失窗口;(3) 新增 tests/test_verify_ws_real_flow.py 真实链路验证——驱动创建会话→上传真实样本(requirements/template/existing_system)→发送「生成概要设计书」→断言 WS 收到 agent 在 _parse_and_confirm 中真实发射的 parse/impact 进度(非手动 emit);期间修正测试夹具(真实端点 /api/sessions + /api/sessions/{sid}/files、fake 引擎、接收线程防阻塞)。全量 pytest 实测 583 passed / 99.03% 达标(含新验证用例)。结论:WebSocket 进度流功能真实可用,验证纪律拦截了一次会进主干的破坏性回归 | src/genesis/server/app.py; tests/test_verify_ws_real_flow.py; docs/superpowers/plans/2026-08-29-iteration-websocket-progress.md; _AI_USAGE_LOG.md | hy3-free | +| 2026-08-30 | Agent 实现 | 前端整改按"不考虑时间、考虑正确合理"原则逐块实施(commit 直接落 main):(1) P0-B 放开 select —— shouldHideUploadSelect/resolveUploadType 不再绑项目强改 file_type=requirements,加客户端 file_type↔扩展名校验 + 项目预置同类型时显式 confirm 覆盖;(2) P0-C 高级字段收进 details 折叠面板 + 字段级红框(PF_LABEL_TO_FIELD 关键字映射 store.py 的 ProjectConfigError label,3s 自动清除) + 删除按钮改用 hidden;(3) P0-A RAG 入口产品化 —— 后端 RagStore.count(scope) + GenesisService.rag_stats(sid)(except 兜底) + GET /api/sessions/{sid}/rag-stats 端点;前端顶栏新增 RAG 开关 + 状态徽标('RAG 已索引 N 片段')+ 开始影响调查按钮(按开关拼 use_rag),上传 existing_system 后自动刷新徽标,开关持久化 localStorage;(4) P1-D loadSession 不再隐式覆盖 draftProject,引入 activeProject vs draftProject 两层语义,提示条让用户主动'切/保留'(保留写入 sessionStorage);(5) P1-F 删除项目 fallback 收敛(先记 deleted 再清 drawerSelected,修复'先 null 后比较'恒假 bug);buildWelcome 唯一来源(chat_state.js 单点,前端内联重复移除);(6) P2-H send() in-flight 锁、input maxlength=2000、启动恢复前校验项目+loading 占位、select 关联 label for、esc 转义加 '"、sid 全程 encodeURIComponent、avatar 按 name hash 选色+中文首字+aria-label 含名字。测试 611→619 passed(+8 覆盖 store.count/service.rag_stats/rag-stats 端点/rag_stats 异常)Node chat_state 9 passed / 99.01% 达标。手动 TestClient 验证:bad template→400 PROJECT_CONFIG_INVALID、zip-as-dir→400 命中"既有系统代码库"关键字、上传既有系统后 rag-stats.chunks:0→2 | src/genesis/rag/store.py; src/genesis/server/service.py; src/genesis/server/app.py; src/genesis/server/static/chat.html; src/genesis/server/static/chat_state.js; tests/test_chat_state.js; tests/test_rag_store.py; tests/test_server_service.py; tests/test_impact_rag_e2e.py; _AI_USAGE_LOG.md | hy3-free | diff --git a/src/genesis/rag/store.py b/src/genesis/rag/store.py index 4c4ac09..d395bc4 100644 --- a/src/genesis/rag/store.py +++ b/src/genesis/rag/store.py @@ -57,5 +57,13 @@ class RagStore: scored.sort(key=lambda x: x[0], reverse=True) return [c for _, c in scored[:k]] + def count(self, scope: str) -> int: + # 仅返回当前 scope 下的片段数;与 search 同样读加锁 + with self._lock: + row = self.conn.execute( + "SELECT COUNT(*) FROM rag_chunks WHERE scope=?", (scope,) + ).fetchone() + return int(row[0]) if row else 0 + def close(self) -> None: self.conn.close() diff --git a/src/genesis/server/app.py b/src/genesis/server/app.py index 7d85a6f..d5f3599 100644 --- a/src/genesis/server/app.py +++ b/src/genesis/server/app.py @@ -258,6 +258,10 @@ def create_app( import json return json.loads(rec.impact_summary) if rec.impact_summary else {} + @app.get("/api/sessions/{sid}/rag-stats") + def rag_stats(sid: str): + return service.rag_stats(sid) + @app.post("/api/sessions/{sid}/confirm-impact") def confirm_impact(sid: str): try: diff --git a/src/genesis/server/service.py b/src/genesis/server/service.py index 6bbce6c..dbf7523 100644 --- a/src/genesis/server/service.py +++ b/src/genesis/server/service.py @@ -122,6 +122,20 @@ class GenesisService: def get_session(self, session_id: str) -> SessionRecord: return self.store.get_session(session_id) + def rag_stats(self, session_id: str) -> dict: + """仅读返回 RAG 索引状态:片段数 / 是否启用。供前端 RAG 开关徽标使用。""" + chunks = 0 + if self.rag is not None and getattr(self.rag, "store", None) is not None: + try: + chunks = int(self.rag.store.count(session_id)) + except Exception: # noqa: BLE001 + chunks = 0 + return { + "session_id": session_id, + "rag_enabled": bool(self.rag is not None), + "chunks": chunks, + } + def upload_file(self, session_id: str, file_type: str, filename: str, content: bytes) -> dict: if file_type not in ALLOWED_FILE_TYPES: raise FileTypeError(f"不支持的 file_type: {file_type}") diff --git a/src/genesis/server/static/chat.html b/src/genesis/server/static/chat.html index 3f8166f..7d80831 100644 --- a/src/genesis/server/static/chat.html +++ b/src/genesis/server/static/chat.html @@ -194,6 +194,33 @@ max-width: 46%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + /* RAG 控制条(P0‑A):紧贴 sid-badge,胶囊样式 */ + #rag-bar { + display: flex; align-items: center; gap: 8px; + background: var(--gray-50); border: 1px solid var(--gray-200); + border-radius: 999px; padding: 3px 10px; + font-size: 12px; color: var(--gray-700); + } + #rag-bar label.rag-switch { + display: inline-flex; align-items: center; gap: 6px; + cursor: pointer; user-select: none; + } + #rag-bar input[type=checkbox] { + width: 14px; height: 14px; accent-color: var(--blue); + margin: 0; + } + #rag-bar input[type=checkbox]:disabled + span { color: var(--gray-500); cursor: not-allowed; } + #rag-bar .rag-stats { font-variant-numeric: tabular-nums; color: var(--gray-500); } + #rag-bar .rag-stats.ready { color: var(--blue-dark); font-weight: 600; } + #rag-bar .rag-stats.zero { color: var(--gray-500); } + #rag-bar button { + background: var(--blue); color: #fff; border: none; + border-radius: 999px; padding: 4px 12px; font-size: 12px; font-weight: 700; + cursor: pointer; + } + #rag-bar button:disabled { background: var(--gray-200); color: var(--gray-500); cursor: not-allowed; } + #rag-bar button:hover:not(:disabled) { background: var(--blue-dark); } + /* 聊天区 */ #chat { flex: 1; overflow-y: auto; padding: 24px 24px 32px; max-width: 880px; width: 100%; margin: 0 auto; } .msg { display: flex; margin: 12px 0; animation: zoomIn .25s ease; } @@ -258,6 +285,11 @@ color: #b06000; font-size: 12px; background: #fef7e0; border: 1px solid #fde293; border-radius: 8px; padding: 4px 10px; } + /* P3‑4:上传类型的 label 在视觉上隐藏(保留可访问性) */ + label.vh { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0,0,0,0); border: 0; + } /* 输入区 */ #composer { border-top: 1px solid var(--gray-200); background: #fff; padding: 14px 24px 18px; } @@ -329,6 +361,12 @@ .drawer-form input:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 2px var(--blue-50); } .drawer-form input:read-only { background: var(--gray-50); color: var(--gray-500); } .drawer-form .field { display: flex; flex-direction: column; } + .drawer-form .drawer-hint { margin: 0 0 6px; } + .drawer-form details.advanced { border: 1px solid var(--gray-200); border-radius: 8px; padding: 8px 12px; background: var(--gray-50); } + .drawer-form details.advanced > summary { cursor: pointer; font-size: 12px; font-weight: 700; color: var(--gray-700); letter-spacing: .02em; } + .drawer-form details.advanced[open] { padding-bottom: 10px; } + .drawer-form details.advanced .field { margin-top: 8px; } + .drawer-form input.invalid { border-color: var(--red) !important; box-shadow: 0 0 0 2px var(--red-soft) !important; } .drawer-actions { display: flex; gap: 8px; margin-top: 8px; } .drawer-actions button { flex: 1; padding: 10px; border: none; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600; transition: var(--ease); } .btn-save { background: var(--blue); color: #fff; } @@ -377,32 +415,41 @@ + 未创建会话
-
-
- - 📎 上传资料: - - - - - +
+
+ + + 📎 上传资料: + + + + + +
-
- +
@@ -425,29 +472,33 @@
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
+
+ 高级(可选,服务器端预置路径) +

这些是服务器端预置路径,普通用户无需填写,留空则在每个会话中上传对应文件。仅在管理员/CLI 集成时使用。

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
- +
@@ -470,8 +521,8 @@ let drawerDirty = false; // 抽屉表单是否有未保存改动(B6) // 状态逻辑统一走 chat_state.js;若静态路由未就绪则回退内联实现,避免整页脚本崩溃 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: (a, d) => !!(a || d), - resolveUploadType: (a, d, v) => (a || d) ? 'requirements' : v, + shouldHideUploadSelect: () => false, + resolveUploadType: (_a, _d, v) => v, computeDrawerSnapshot: (f) => f.map(x => (x == null ? '' : String(x)).trim()).join(String.fromCharCode(1)), isDrawerDirty: (n, s) => n !== s, buildWelcome: (d) => d === null ? '请新建项目或者选择项目' @@ -494,7 +545,7 @@ function addMsg(role, html) { chatEl.scrollTop = chatEl.scrollHeight; return m; } -function esc(s) { return String(s).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); } +function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } function showTyping() { return addMsg('assistant', '思考中…'); } async function api(method, url, body) { @@ -517,14 +568,20 @@ async function refreshSessions() { const el = document.createElement('div'); el.className = 'sess' + (s.session_id === sid ? ' active' : ''); const proj = s.project ? ' · ' + esc(s.project) : ''; - const avatar = (s.name || '新').trim().charAt(0).toUpperCase(); + // P3‑1:取首字符(含中文/日文/英文)作 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 = - '
' + esc(avatar) + '
' + + '
' + esc(avatar) + '
' + '
' + '
' + esc(s.name || '新会话') + '
' + '
' + esc(s.status) + proj + '
' + '
' + - ''; + ''; el.querySelector('.info').onclick = () => loadSession(s.session_id); const delBtn = el.querySelector('.del'); delBtn.onclick = (ev) => { ev.stopPropagation(); deleteSession(s.session_id); }; @@ -535,7 +592,7 @@ async function refreshSessions() { async function deleteSession(targetSid) { if (!confirm('确认删除该会话?此操作不可撤销。')) return; try { - await api('DELETE', '/api/sessions/' + targetSid); + await api('DELETE', '/api/sessions/' + encodeURIComponent(targetSid)); } catch (e) { addMsg('error', '删除失败:' + esc(String(e))); return; @@ -638,13 +695,13 @@ function loadDrawerForm(projectName) { rulesEl.value = (p.rules || []).join(', '); codeEl.value = p.existing_system_code_dir || ''; designEl.value = p.design_docs_dir || ''; - delBtn.style.display = ''; + delBtn.hidden = false; } } else { nameEl.value = ''; nameEl.readOnly = false; dispEl.value = ''; tplEl.value = ''; wrEl.value = ''; rulesEl.value = ''; codeEl.value = ''; designEl.value = ''; - delBtn.style.display = 'none'; + delBtn.hidden = true; } // B6:记录字段快照用于脏检测 drawerSnapshot = drawerSnapshotNow(); @@ -672,6 +729,35 @@ function guardDrawerDirty() { return true; } +// P0‑C:抽屉字段级红框。命中关键字 → 给对应输入加 .invalid 3 秒后清除。 +// 关键字与 store.py 的 ProjectConfigError 中文 label 对齐。 +const PF_LABEL_TO_FIELD = { + '项目名': 'pf-name', + '模板': 'pf-template', + '做成说明书': 'pf-write', + '规则': 'pf-rules', + '既有系统代码库': '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 = '
' + '新建项目
'; @@ -691,6 +777,7 @@ function renderDrawerList() { } async function saveDrawerProject() { + clearPfInvalid(); const body = { name: document.getElementById('pf-name').value.trim(), display_name: document.getElementById('pf-display').value.trim(), @@ -700,7 +787,11 @@ async function saveDrawerProject() { existing_system_code_dir: document.getElementById('pf-code').value.trim(), design_docs_dir: document.getElementById('pf-design').value.trim(), }; - if (!body.name) { addMsg('error', '项目名不能为空'); return; } + if (!body.name) { + flagPfInvalidByMessage('项目名'); + addMsg('error', '项目名不能为空'); + return; + } try { const cfg = await api('POST', '/api/projects', body); draftProject = cfg.name; @@ -709,6 +800,8 @@ async function saveDrawerProject() { addMsg('assistant', '项目「' + esc(cfg.display_name || cfg.name) + '」已保存。'); closeProjectDrawer(); } catch (e) { + // 400 / PROJECT_CONFIG_INVALID:字段级红框定位 + flagPfInvalidByMessage(String(e)); addMsg('error', '保存项目失败:' + esc(String(e))); } } @@ -716,16 +809,18 @@ async function saveDrawerProject() { async function deleteDrawerProject() { if (!drawerSelected) return; if (!confirm('确认删除项目「' + drawerSelected + '」?此操作不可撤销。')) return; + const deleted = drawerSelected; try { - await api('DELETE', '/api/projects/' + drawerSelected); + await api('DELETE', '/api/projects/' + encodeURIComponent(deleted)); } catch (e) { addMsg('error', '删除项目失败:' + esc(String(e))); return; } - addMsg('assistant', '项目「' + esc(drawerSelected) + '」已删除。'); + addMsg('assistant', '项目「' + esc(deleted) + '」已删除。'); + // P1‑F:先记住被删的,再清 drawerSelected;避免"先 null 后比较"恒假 bug drawerSelected = null; - if (draftProject === drawerSelected) { - // 触发 loadProjects 中的回退逻辑 + if (draftProject === deleted) { + // 当前选的就是被删的项目 → 清空偏好(autoBindProject 会从剩余项目重选) draftProject = null; } await loadProjects(); @@ -740,29 +835,28 @@ function toggleSidebar(force) { try { localStorage.setItem('genesis_sidebar_collapsed', willCollapse ? '1' : '0'); } catch (e) { /* localStorage 不可用时忽略 */ } } -// ---------- 上传区:项目绑定时仅要件定义 ---------- +// ---------- 上传区:始终显示完整类型下拉 ---------- +// P0‑B:选项目时也允许补传 template / write_instruction / rules / existing_system, +// 不再藏起入口。"项目已预置 template 时再上传 template"在上传处用 confirm() 显式确认。 function updateUploadBar() { const sel = document.getElementById('file-type'); const hint = document.getElementById('proj-hint'); - if (GenesisState.shouldHideUploadSelect(activeProject, draftProject)) { - sel.style.display = 'none'; - hint.textContent = '(项目「' + esc(activeProject || draftProject) + '」已提供模板/规则/代码库,仅需上传要件定义)'; + if (sel) sel.style.display = ''; + if (!hint) return; + // 默认 file_type 仍为 requirements(最常用),但下拉始终可改。 + if (sel && sel.value !== 'requirements') sel.value = 'requirements'; + if (draftProject) { + hint.textContent = '默认上传要件定义 xlsx;如需补传模板/规则/既有系统 zip,请在下拉中选择对应类型。'; } else { - sel.style.display = ''; - hint.textContent = ''; + hint.textContent = '请选择 file_type 后上传(未选项目时,模板/规则仍可手动上传)。'; } } // ---------- 空态/欢迎渲染 ---------- function renderEmptyOrWelcome() { chatEl.innerHTML = ''; - if (draftProject === null) { - addMsg('assistant', '请新建项目或者选择项目'); - } else { - addMsg('assistant', '你好!我是 Genesis 概要设计书生成 Agent。
请先在上方📎上传要件定义 xlsx' - + (draftProject ? '(模板/规则/代码库已由项目「' + esc(draftProject) + '」提供)' : '与模板 docx') - + ',然后告诉我:
· 「生成概要设计书」(自动解析→影响→生成→QA)
· 「用中文生成」 / 「现在什么状态?」'); - } + // P1‑F:欢迎文由 GenesisState.buildWelcome 统一来源 + addMsg('assistant', GenesisState.buildWelcome(draftProject)); } // ---------- 会话创建/加载 ---------- @@ -775,16 +869,19 @@ async function newSession() { chatEl.innerHTML = ''; badge.textContent = '未创建会话'; updateUploadBar(); + await refreshRagStats(); await refreshSessions(); renderEmptyOrWelcome(); } async function loadSession(id) { try { - const rec = await api('GET', '/api/sessions/' + id); + const rec = await api('GET', '/api/sessions/' + encodeURIComponent(id)); sid = id; activeProject = rec.project || null; - draftProject = rec.project || null; // 载入既有会话时,顶栏与侧边栏过滤对齐该会话项目 + // P1‑D:不再隐式覆盖 draftProject。会话事实(activeProject)与用户偏好(draftProject) + // 分层;若不一致,由提示条让用户主动选择。 + // draftProject = rec.project || null; // 删除:原行为会悄悄改用户偏好 applyProjectContext(); badge.textContent = '会话: ' + (rec.name || '新会话'); if (progressWs) { try { progressWs.close(); } catch (e) {} } @@ -792,7 +889,7 @@ async function loadSession(id) { onProgress: (e) => GenesisWS.applyProgressEvent(e, (role, text) => addMsg(role, text)), onClose: () => {}, }); - const msgs = await api('GET', '/api/chat/' + id + '/messages'); + 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' }; @@ -800,16 +897,66 @@ async function loadSession(id) { 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 = + '
' + + '该会话属于项目 ' + esc(activeProject) + ',' + + '当前顶栏项目为 ' + esc(draftProject || '(未选择)') + '。' + + '
' + + '切到该项目' + + '保留当前项目' + + '
'; + 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 { @@ -834,7 +981,7 @@ async function send() { const typing = showTyping(); sendBtn.disabled = true; try { - const res = await api('POST', '/api/chat/' + sid + '/messages', { content: text }); + 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 => '' + esc(p.step) + ': ' + esc(p.detail || '') + '').join(''); @@ -854,6 +1001,7 @@ async function send() { typing.remove(); addMsg('error', '请求失败:' + esc(String(e))); } finally { + sendInFlight = false; sendBtn.disabled = false; inputEl.focus(); } @@ -867,6 +1015,87 @@ document.getElementById('ps-current').addEventListener('click', (e) => { e.stopP 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', '影响调查已提交(use_rag=' + (useRag ? 'true' : 'false') + ')'); + // 让 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); @@ -879,27 +1108,69 @@ document.addEventListener('keydown', (e) => { } }); +// 客户端类型 → 扩展名白名单校验(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'); - let ft = GenesisState.resolveUploadType(activeProject, draftProject, sel.value); + 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/' + sid + '/files', { method: 'POST', body: fd }); + 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:上传成功后同步 activeProject 并刷新侧边栏状态 - const rec = await api('GET', '/api/sessions/' + sid); + // 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', '上传完成:' + esc(d.file_name) + '(' + d.size + 'B)'); @@ -919,7 +1190,23 @@ document.getElementById('upload-btn').addEventListener('click', async () => { await loadProjects(); const saved = (() => { try { return localStorage.getItem('genesis_session'); } catch (e) { return null; } })(); if (saved) { - try { await loadSession(saved); return; } catch (e) { /* 忽略,新建 */ } + // 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)))); })(); diff --git a/src/genesis/server/static/chat_state.js b/src/genesis/server/static/chat_state.js index 22249f3..fccdba0 100644 --- a/src/genesis/server/static/chat_state.js +++ b/src/genesis/server/static/chat_state.js @@ -23,14 +23,18 @@ return draftProject; } - // 绑定项目(draft 或 active)时隐藏上传类型下拉,仅允许要件定义 - function shouldHideUploadSelect(activeProject, draftProject) { - return Boolean(activeProject || draftProject); + // 上传类型下拉始终可见,让用户能补传任何类型。 + // (P0‑B:取消"绑项目即隐藏 select / 强改 file_type=requirements"的产品错误。 + // "项目已预置 template 时再上传 template"的安全约束改在 chat.html 上传处用 + // 显式 confirm() 实现,而非藏起入口。) + // 仅保留签名以兼容旧调用点,但语义固定为 false。 + function shouldHideUploadSelect(_activeProject, _draftProject) { + return false; } - // 解析实际上传类型:绑定项目时恒为 requirements - function resolveUploadType(activeProject, draftProject, selectValue) { - return (activeProject || draftProject) ? 'requirements' : selectValue; + // 解析实际上传类型:尊重用户下拉选择。 + function resolveUploadType(_activeProject, _draftProject, selectValue) { + return selectValue; } // 抽屉表单字段快照(去空白、单元分隔符连接),用于脏检测 @@ -43,14 +47,16 @@ return snapshotNow !== saved; } - // 空态/欢迎语文案 + // 空态/欢迎语文案(P1‑F:单点来源;chat.html 不再内联重复) function buildWelcome(draftProject) { if (draftProject === null) { return '请新建项目或者选择项目'; } - return '你好!我是 Genesis 概要设计书生成 Agent。请先上传要件定义 xlsx' + return '你好!我是 Genesis 概要设计书生成 Agent。' + + '请先在上方📎上传要件定义 xlsx' + '(模板/规则/代码库已由项目「' + draftProject + '」提供),然后告诉我:' - + '「生成概要设计书」/「用中文生成」/「现在什么状态?」'; + + '
· 「生成概要设计书」(自动解析→影响→生成→QA)' + + '
· 「用中文生成」 / 「现在什么状态?」'; } return { diff --git a/tests/test_chat_state.js b/tests/test_chat_state.js index d6d6d99..a32fa9c 100644 --- a/tests/test_chat_state.js +++ b/tests/test_chat_state.js @@ -16,21 +16,17 @@ test('autoBindProject: 已选有效项目保持不变', () => { assert.equal(S.autoBindProject('A', [{ name: 'A' }]), 'A'); }); -test('shouldHideUploadSelect: 未绑定任何项目时显示下拉', () => { +test('shouldHideUploadSelect: 始终 false(选项目时也允许补传其它类型)', () => { assert.equal(S.shouldHideUploadSelect(null, null), false); + assert.equal(S.shouldHideUploadSelect(null, 'A'), false); + assert.equal(S.shouldHideUploadSelect('A', null), false); + assert.equal(S.shouldHideUploadSelect('A', 'A'), false); }); -test('shouldHideUploadSelect: 已绑定项目(draft 或 active)时隐藏下拉', () => { - assert.equal(S.shouldHideUploadSelect(null, 'A'), true); - assert.equal(S.shouldHideUploadSelect('A', null), true); -}); - -test('resolveUploadType: 绑定项目时强制 requirements', () => { - assert.equal(S.resolveUploadType(null, 'A', 'template'), 'requirements'); -}); - -test('resolveUploadType: 未绑定项目时保留用户选择', () => { +test('resolveUploadType: 始终尊重用户下拉选择(不再被项目绑定强制为 requirements)', () => { + assert.equal(S.resolveUploadType(null, 'A', 'template'), 'template'); assert.equal(S.resolveUploadType(null, null, 'template'), 'template'); + assert.equal(S.resolveUploadType('A', null, 'existing_system'), 'existing_system'); }); test('computeDrawerSnapshot: 去空白并以单元分隔符连接', () => { @@ -46,6 +42,8 @@ test('buildWelcome: 无项目提示新建/选择项目', () => { assert.match(S.buildWelcome(null), /请新建项目|选择项目/); }); -test('buildWelcome: 有项目提示由项目提供模板/规则/代码库', () => { - assert.match(S.buildWelcome('A'), /项目「A」/); +test('buildWelcome: 有项目时提示由项目提供模板/规则/代码库', () => { + const txt = S.buildWelcome('A'); + assert.match(txt, /项目「A」/); + assert.match(txt, /要件定义/); }); diff --git a/tests/test_impact_rag_e2e.py b/tests/test_impact_rag_e2e.py index 12cbb17..9c2b981 100644 --- a/tests/test_impact_rag_e2e.py +++ b/tests/test_impact_rag_e2e.py @@ -222,3 +222,57 @@ def test_http_start_impact_use_rag_e2e(tmp_path): ir = client.get(f"/api/sessions/{sid}/impact-result").json() # 经 HTTP 真实走到 RAG 路径,摘要标记 rag_enabled assert ir.get("rag_enabled") is True + + +def test_http_rag_stats_endpoint(tmp_path): + """P0‑A:app.py /api/sessions/{sid}/rag-stats 仅读端点:上传既有系统后 chunks>0。""" + store = SessionStore(db_path=str(tmp_path / "s.db")) + rag = ImpactRAG(RagStore(str(tmp_path / "rag.db")), FakeEmbedder()) + app = create_app( + store=store, + data_root=str(tmp_path / "data"), + engine="fake", + rag=rag, + ) + client = TestClient(app) + sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"] + + # 上传前:chunks=0 + s0 = client.get(f"/api/sessions/{sid}/rag-stats").json() + assert s0["rag_enabled"] is True + assert s0["chunks"] == 0 + assert s0["session_id"] == sid + + # 上传既有系统 → index_dir 触发 → chunks>0 + client.post(f"/api/sessions/{sid}/files", + data={"file_type": "existing_system"}, + files={"file": ("existing.zip", _make_existing_zip())}) + s1 = client.get(f"/api/sessions/{sid}/rag-stats").json() + assert s1["chunks"] > 0 + + # 未上传既有系统 → chunks=0 且 200(与 impact-result 行为一致) + + +def test_rag_stats_count_raises_returns_zero(tmp_path): + """P0‑A:RagStore.count 抛错时 service.rag_stats 返回 chunks=0,不上抛。""" + store = SessionStore(db_path=str(tmp_path / "s.db")) + rag = ImpactRAG(RagStore(str(tmp_path / "rag.db")), FakeEmbedder()) + svc = GenesisService( + store=store, + data_root=str(tmp_path / "data"), + engine=FakeEngine(), + rag=rag, + use_rag=False, + ) + s = svc.create_session("u1") + # monkey-patch 一次 count 抛错 + original = rag.store.count + def boom(_scope): + raise RuntimeError("db locked") + rag.store.count = boom + try: + stats = svc.rag_stats(s.session_id) + assert stats["chunks"] == 0 + assert stats["rag_enabled"] is True + finally: + rag.store.count = original diff --git a/tests/test_rag_store.py b/tests/test_rag_store.py index e350888..3f547f8 100644 --- a/tests/test_rag_store.py +++ b/tests/test_rag_store.py @@ -59,3 +59,30 @@ def test_concurrent_add_and_search_no_crash(): assert all(isinstance(r, str) for r in res) finally: s.close() + + +def test_count_empty_scope_returns_zero(): + s = RagStore(":memory:") + assert s.count("missing") == 0 + + +def test_count_after_add_returns_chunk_count(): + s = RagStore(":memory:") + s.add("p1", ["a", "b", "c"], [_vec(0), _vec(1), _vec(2)]) + assert s.count("p1") == 3 + + +def test_count_scopes_are_isolated(): + s = RagStore(":memory:") + s.add("p1", ["a"], [_vec(0)]) + s.add("p2", ["b", "c"], [_vec(1), _vec(2)]) + assert s.count("p1") == 1 + assert s.count("p2") == 2 + + +def test_count_after_reset_returns_zero(): + s = RagStore(":memory:") + s.add("p1", ["a", "b"], [_vec(0), _vec(1)]) + assert s.count("p1") == 2 + s.reset_scope("p1") + assert s.count("p1") == 0 diff --git a/tests/test_server_service.py b/tests/test_server_service.py index c2814c3..3c9c642 100644 --- a/tests/test_server_service.py +++ b/tests/test_server_service.py @@ -12,6 +12,9 @@ import pytest from genesis.server.store import SessionStore, ProjectsStore from genesis.server.service import GenesisService, ServiceStepError, FileTypeError +from genesis.rag.embeddings import FakeEmbedder +from genesis.rag.impact_rag import ImpactRAG +from genesis.rag.store import RagStore _SAMPLE = Path(__file__).resolve().parents[1] / "sample" @@ -334,3 +337,34 @@ def test_confirm_impact_wrong_state_raises(tmp_path): # writing 态直接确认影响 → 报错(service 167) with pytest.raises(ServiceStepError): svc.confirm_impact(s.session_id) + + +def test_rag_stats_without_rag_returns_disabled_zero(tmp_path): + svc = GenesisService( + store=SessionStore(db_path=str(tmp_path / "s.db")), + data_root=str(tmp_path / "data"), + engine=FakeEngine(), + rag=None, + ) + s = svc.create_session("u1") + stats = svc.rag_stats(s.session_id) + assert stats == {"session_id": s.session_id, "rag_enabled": False, "chunks": 0} + + +def test_rag_stats_with_rag_reflects_indexed_chunks(tmp_path): + svc = GenesisService( + store=SessionStore(db_path=str(tmp_path / "s.db")), + data_root=str(tmp_path / "data"), + engine=FakeEngine(), + rag=ImpactRAG(RagStore(str(tmp_path / "rag.db")), FakeEmbedder()), + use_rag=False, + ) + s = svc.create_session("u1") + # 空 scope → 0 片段 + assert svc.rag_stats(s.session_id)["chunks"] == 0 + # 索引 2 个片段 + svc.rag.index(s.session_id, [("f.java", "a"), ("f2.java", "b")]) + stats = svc.rag_stats(s.session_id) + assert stats["rag_enabled"] is True + assert stats["chunks"] == 2 + assert stats["session_id"] == s.session_id