feat(ui): 前端整改 —— RAG 入口产品化、放开 select、高级字段折叠、字段级红框

按"不考虑时间、考虑正确合理"原则逐块实施。

P0-B 放开 select
  - chat_state.js: shouldHideUploadSelect 始终 false; resolveUploadType 尊重用户选择
  - chat.html: 选项目时 select 不再隐藏 + 不再强改 file_type=requirements
  - 客户端 file_type↔扩展名校验(existing_system=.zip, requirements=.xlsx 等)
  - 项目已预置同类型时 confirm() 显式覆盖确认(不藏起入口)

P0-C 高级字段折叠 + 字段级红框
  - 项目抽屉主面板仅留 项目名/显示名;4 个服务器路径字段收进 details.advanced
  - saveDrawerProject 错误时按 ProjectConfigError label 关键字(模板/做成说明书/
    既有系统代码库/既有设计文档目录/项目名)给对应输入加 .invalid 3s 清除
  - 删除按钮 pf-delete 改用 hidden 而非 style.display

P0-A RAG 入口产品化
  - 后端 RagStore.count(scope) 线程安全读加锁
  - GenesisService.rag_stats(sid) 含 except 兜底(count 抛错返回 0)
  - GET /api/sessions/{sid}/rag-stats 端点
  - 前端顶栏:RAG 开关 + RAG 已索引 N 片段 状态徽标 + 开始影响调查按钮
  - 上传 existing_system 后自动 refreshRagStats 刷新徽标
  - 开关持久化到 localStorage(genesis_rag_enabled)

P1-D loadSession 不再隐式覆盖 draftProject
  - activeProject vs draftProject 分层;不一致时由 renderProjectMismatchHint
    提示用户主动"切到该项目"或"保留当前项目"
  - 保留 写入 sessionStorage 标记,避免每次 load 都提示

P1-F 删除抽屉项目 fallback 收敛
  - 先记 deleted 再清 drawerSelected,修复"先 null 后比较"恒假 bug
  - buildWelcome 唯一来源(chat_state.js 单点,前端内联重复移除)

P2-H 杂项
  - send() in-flight 锁防双击
  - input maxlength=2000
  - 启动恢复前校验项目存在 + loading 占位
  - select 关联 label for
  - esc 转义加 引号
  - sid 全程 encodeURIComponent
  - avatar 按 name hash 选色 + 中文首字(Array.from)+ aria-label 含名字

测试:619 passed / 99.01% 99.0% 达标(+8 覆盖 store.count/service.rag_stats/
rag-stats 端点/rag_stats 异常);Node chat_state 9 passed
This commit is contained in:
lhl
2026-08-30 21:16:07 +08:00
parent cc8850cffe
commit 520c9e25b0
10 changed files with 529 additions and 96 deletions
+1
View File
@@ -141,3 +141,4 @@
| 2026-08-29 14:40 | 架构设计 | 将前端审计 §4 剩余两项(WebSocket 实时进度流、影响调查 RAG 化)拆为两个独立迭代并产出实施计划:docs/superpowers/plans/2026-08-29-iteration-websocket-progress.mdProgressHub 单例 + /api/sessions/{sid}/ws 端点 + chat_ws.js,保留持久化兜底)、docs/superpowers/plans/2026-08-29-iteration-impact-rag.mdgenesis/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 14:40 | 架构设计 | 将前端审计 §4 剩余两项(WebSocket 实时进度流、影响调查 RAG 化)拆为两个独立迭代并产出实施计划:docs/superpowers/plans/2026-08-29-iteration-websocket-progress.mdProgressHub 单例 + /api/sessions/{sid}/ws 端点 + chat_ws.js,保留持久化兜底)、docs/superpowers/plans/2026-08-29-iteration-impact-rag.mdgenesis/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.htmlsend/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 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.htmlsend/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-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 label3s 自动清除) + 删除按钮改用 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 |
+8
View File
@@ -57,5 +57,13 @@ class RagStore:
scored.sort(key=lambda x: x[0], reverse=True) scored.sort(key=lambda x: x[0], reverse=True)
return [c for _, c in scored[:k]] 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: def close(self) -> None:
self.conn.close() self.conn.close()
+4
View File
@@ -258,6 +258,10 @@ def create_app(
import json import json
return json.loads(rec.impact_summary) if rec.impact_summary else {} 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") @app.post("/api/sessions/{sid}/confirm-impact")
def confirm_impact(sid: str): def confirm_impact(sid: str):
try: try:
+14
View File
@@ -122,6 +122,20 @@ class GenesisService:
def get_session(self, session_id: str) -> SessionRecord: def get_session(self, session_id: str) -> SessionRecord:
return self.store.get_session(session_id) 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: def upload_file(self, session_id: str, file_type: str, filename: str, content: bytes) -> dict:
if file_type not in ALLOWED_FILE_TYPES: if file_type not in ALLOWED_FILE_TYPES:
raise FileTypeError(f"不支持的 file_type: {file_type}") raise FileTypeError(f"不支持的 file_type: {file_type}")
+361 -74
View File
@@ -194,6 +194,33 @@
max-width: 46%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 46%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
/* RAG 控制条(P0A):紧贴 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; } #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; } .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; color: #b06000; font-size: 12px; background: #fef7e0; border: 1px solid #fde293;
border-radius: 8px; padding: 4px 10px; 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; } #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: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 input:read-only { background: var(--gray-50); color: var(--gray-500); }
.drawer-form .field { display: flex; flex-direction: column; } .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 { 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); } .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; } .btn-save { background: var(--blue); color: #fff; }
@@ -377,32 +415,41 @@
</button> </button>
<div class="ps-menu" id="ps-menu" role="menu"></div> <div class="ps-menu" id="ps-menu" role="menu"></div>
</div> </div>
<div id="rag-bar" hidden>
<label class="rag-switch">
<input type="checkbox" id="rag-enabled">
<span>启用 RAG 影响调查</span>
</label>
<span class="rag-stats" id="rag-stats">RAG 0 片段</span>
<button id="impact-btn" type="button" disabled>开始影响调查</button>
</div>
<span id="sid-badge">未创建会话</span> <span id="sid-badge">未创建会话</span>
</header> </header>
<div id="chat"></div> <div id="chat"></div>
<div id="upload-bar"> <div id="upload-bar">
<div id="upload-bar-inner"> <div id="upload-bar-inner">
<span id="proj-hint"></span> <span id="proj-hint"></span>
<span>📎 上传资料:</span> <label for="file-type" class="vh">上传类型</label>
<select id="file-type"> <span>📎 上传资料:</span>
<option value="requirements">要件定义 xlsx(必需)</option> <select id="file-type" aria-label="上传类型">
<option value="template">概要设计模板 docx(必需)</option> <option value="requirements">要件定义 xlsx(必需)</option>
<option value="write_instruction">做成说明书 docx</option> <option value="template">概要设计模板 docx(必需)</option>
<option value="rules">记入/图表规则 docx/xlsx</option> <option value="write_instruction">做成说明书 docx</option>
<option value="existing_system">既有系统 zip(追加改修)</option> <option value="rules">记入/图表规则 docx/xlsx</option>
</select> <option value="existing_system">既有系统 zip(追加改修)</option>
<label class="file-pick" for="file-input">选择文件</label> </select>
<input type="file" id="file-input" style="display:none"> <label class="file-pick" for="file-input">选择文件</label>
<button id="upload-btn">上传</button> <input type="file" id="file-input" style="display:none">
<span id="upload-status"></span> <button id="upload-btn">上传</button>
<span id="upload-status"></span>
</div>
</div> </div>
</div>
<div id="composer"> <div id="composer">
<div id="composer-inner"> <div id="composer-inner">
<input id="input" placeholder="输入指令,如:上传了文件,生成概要设计书 / 现在什么状态?" autocomplete="off"> <input id="input" placeholder="输入指令,如:上传了文件,生成概要设计书 / 现在什么状态?" autocomplete="off" maxlength="2000">
<button id="send">发送</button> <button id="send">发送</button>
</div> </div>
</div> </div>
@@ -425,29 +472,33 @@
<label>显示名(可选)</label> <label>显示名(可选)</label>
<input id="pf-display" placeholder="股票系统"> <input id="pf-display" placeholder="股票系统">
</div> </div>
<div class="field"> <details class="advanced">
<label>模板 docx 路径</label> <summary>高级(可选,服务器端预置路径</summary>
<input id="pf-template" placeholder="/abs/path/template.docx"> <p class="drawer-hint">这些是<strong>服务器端</strong>预置路径,普通用户无需填写,留空则在每个会话中上传对应文件。仅在管理员/CLI 集成时使用。</p>
</div> <div class="field">
<div class="field"> <label>模板 docx 路径</label>
<label>做成说明书 docx 路径</label> <input id="pf-template" placeholder="/abs/path/template.docx">
<input id="pf-write" placeholder="/abs/path/write_instruction.docx"> </div>
</div> <div class="field">
<div class="field"> <label>做成说明书 docx 路径</label>
<label>规则 docx 目录(逗号分隔)</label> <input id="pf-write" placeholder="/abs/path/write_instruction.docx">
<input id="pf-rules" placeholder="/abs/path/rules, /abs/path/diagrams"> </div>
</div> <div class="field">
<div class="field"> <label>规则 docx 目录(逗号分隔)</label>
<label>既有系统代码库目录</label> <input id="pf-rules" placeholder="/abs/path/rules, /abs/path/diagrams">
<input id="pf-code" placeholder="/abs/path/existing-system"> </div>
</div> <div class="field">
<div class="field"> <label>既有系统代码库目录</label>
<label>既有设计文档目录</label> <input id="pf-code" placeholder="/abs/path/existing-system">
<input id="pf-design" placeholder="/abs/path/design-docs"> </div>
</div> <div class="field">
<label>既有设计文档目录</label>
<input id="pf-design" placeholder="/abs/path/design-docs">
</div>
</details>
<div class="drawer-actions"> <div class="drawer-actions">
<button class="btn-save" id="pf-save">保存项目</button> <button class="btn-save" id="pf-save">保存项目</button>
<button class="btn-delete" id="pf-delete">删除</button> <button class="btn-delete" id="pf-delete" hidden>删除</button>
</div> </div>
</div> </div>
</div> </div>
@@ -470,8 +521,8 @@ let drawerDirty = false; // 抽屉表单是否有未保存改动(B6)
// 状态逻辑统一走 chat_state.js;若静态路由未就绪则回退内联实现,避免整页脚本崩溃 // 状态逻辑统一走 chat_state.js;若静态路由未就绪则回退内联实现,避免整页脚本崩溃
const GenesisState = window.GenesisState || { 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)), 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), shouldHideUploadSelect: () => false,
resolveUploadType: (a, d, v) => (a || d) ? 'requirements' : v, resolveUploadType: (_a, _d, v) => v,
computeDrawerSnapshot: (f) => f.map(x => (x == null ? '' : String(x)).trim()).join(String.fromCharCode(1)), computeDrawerSnapshot: (f) => f.map(x => (x == null ? '' : String(x)).trim()).join(String.fromCharCode(1)),
isDrawerDirty: (n, s) => n !== s, isDrawerDirty: (n, s) => n !== s,
buildWelcome: (d) => d === null ? '请新建项目或者选择项目' buildWelcome: (d) => d === null ? '请新建项目或者选择项目'
@@ -494,7 +545,7 @@ function addMsg(role, html) {
chatEl.scrollTop = chatEl.scrollHeight; chatEl.scrollTop = chatEl.scrollHeight;
return m; return m;
} }
function esc(s) { return String(s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); } function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function showTyping() { return addMsg('assistant', '<span class="typing">思考中…</span>'); } function showTyping() { return addMsg('assistant', '<span class="typing">思考中…</span>'); }
async function api(method, url, body) { async function api(method, url, body) {
@@ -517,14 +568,20 @@ async function refreshSessions() {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'sess' + (s.session_id === sid ? ' active' : ''); el.className = 'sess' + (s.session_id === sid ? ' active' : '');
const proj = s.project ? ' · ' + esc(s.project) : ''; 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 = el.innerHTML =
'<div class="avatar">' + esc(avatar) + '</div>' + '<div class="avatar" style="background:' + color + ';">' + esc(avatar) + '</div>' +
'<div class="info">' + '<div class="info">' +
'<div class="name">' + esc(s.name || '新会话') + '</div>' + '<div class="name">' + esc(s.name || '新会话') + '</div>' +
'<div class="meta">' + esc(s.status) + proj + '</div>' + '<div class="meta">' + esc(s.status) + proj + '</div>' +
'</div>' + '</div>' +
'<button class="del" title="删除会话" aria-label="删除">×</button>'; '<button class="del" title="删除会话" aria-label="删除会话 ' + esc(rawName) + '">×</button>';
el.querySelector('.info').onclick = () => loadSession(s.session_id); el.querySelector('.info').onclick = () => loadSession(s.session_id);
const delBtn = el.querySelector('.del'); const delBtn = el.querySelector('.del');
delBtn.onclick = (ev) => { ev.stopPropagation(); deleteSession(s.session_id); }; delBtn.onclick = (ev) => { ev.stopPropagation(); deleteSession(s.session_id); };
@@ -535,7 +592,7 @@ async function refreshSessions() {
async function deleteSession(targetSid) { async function deleteSession(targetSid) {
if (!confirm('确认删除该会话?此操作不可撤销。')) return; if (!confirm('确认删除该会话?此操作不可撤销。')) return;
try { try {
await api('DELETE', '/api/sessions/' + targetSid); await api('DELETE', '/api/sessions/' + encodeURIComponent(targetSid));
} catch (e) { } catch (e) {
addMsg('error', '删除失败:' + esc(String(e))); addMsg('error', '删除失败:' + esc(String(e)));
return; return;
@@ -638,13 +695,13 @@ function loadDrawerForm(projectName) {
rulesEl.value = (p.rules || []).join(', '); rulesEl.value = (p.rules || []).join(', ');
codeEl.value = p.existing_system_code_dir || ''; codeEl.value = p.existing_system_code_dir || '';
designEl.value = p.design_docs_dir || ''; designEl.value = p.design_docs_dir || '';
delBtn.style.display = ''; delBtn.hidden = false;
} }
} else { } else {
nameEl.value = ''; nameEl.readOnly = false; nameEl.value = ''; nameEl.readOnly = false;
dispEl.value = ''; dispEl.value = '';
tplEl.value = ''; wrEl.value = ''; rulesEl.value = ''; codeEl.value = ''; designEl.value = ''; tplEl.value = ''; wrEl.value = ''; rulesEl.value = ''; codeEl.value = ''; designEl.value = '';
delBtn.style.display = 'none'; delBtn.hidden = true;
} }
// B6:记录字段快照用于脏检测 // B6:记录字段快照用于脏检测
drawerSnapshot = drawerSnapshotNow(); drawerSnapshot = drawerSnapshotNow();
@@ -672,6 +729,35 @@ function guardDrawerDirty() {
return true; 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() { function renderDrawerList() {
let html = '<div class="drawer-item di-create' + (drawerSelected === null ? ' active' : '') + '" data-name="">' let html = '<div class="drawer-item di-create' + (drawerSelected === null ? ' active' : '') + '" data-name="">'
+ '<span class="di-new"></span><span class="di-name">新建项目</span></div>'; + '<span class="di-new"></span><span class="di-name">新建项目</span></div>';
@@ -691,6 +777,7 @@ function renderDrawerList() {
} }
async function saveDrawerProject() { async function saveDrawerProject() {
clearPfInvalid();
const body = { const body = {
name: document.getElementById('pf-name').value.trim(), name: document.getElementById('pf-name').value.trim(),
display_name: document.getElementById('pf-display').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(), existing_system_code_dir: document.getElementById('pf-code').value.trim(),
design_docs_dir: document.getElementById('pf-design').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 { try {
const cfg = await api('POST', '/api/projects', body); const cfg = await api('POST', '/api/projects', body);
draftProject = cfg.name; draftProject = cfg.name;
@@ -709,6 +800,8 @@ async function saveDrawerProject() {
addMsg('assistant', '项目「' + esc(cfg.display_name || cfg.name) + '」已保存。'); addMsg('assistant', '项目「' + esc(cfg.display_name || cfg.name) + '」已保存。');
closeProjectDrawer(); closeProjectDrawer();
} catch (e) { } catch (e) {
// 400 / PROJECT_CONFIG_INVALID:字段级红框定位
flagPfInvalidByMessage(String(e));
addMsg('error', '保存项目失败:' + esc(String(e))); addMsg('error', '保存项目失败:' + esc(String(e)));
} }
} }
@@ -716,16 +809,18 @@ async function saveDrawerProject() {
async function deleteDrawerProject() { async function deleteDrawerProject() {
if (!drawerSelected) return; if (!drawerSelected) return;
if (!confirm('确认删除项目「' + drawerSelected + '」?此操作不可撤销。')) return; if (!confirm('确认删除项目「' + drawerSelected + '」?此操作不可撤销。')) return;
const deleted = drawerSelected;
try { try {
await api('DELETE', '/api/projects/' + drawerSelected); await api('DELETE', '/api/projects/' + encodeURIComponent(deleted));
} catch (e) { } catch (e) {
addMsg('error', '删除项目失败:' + esc(String(e))); addMsg('error', '删除项目失败:' + esc(String(e)));
return; return;
} }
addMsg('assistant', '项目「' + esc(drawerSelected) + '」已删除。'); addMsg('assistant', '项目「' + esc(deleted) + '」已删除。');
// P1F:先记住被删的,再清 drawerSelected;避免"先 null 后比较"恒假 bug
drawerSelected = null; drawerSelected = null;
if (draftProject === drawerSelected) { if (draftProject === deleted) {
// 触发 loadProjects 中的回退逻辑 // 当前选的就是被删的项目 → 清空偏好(autoBindProject 会从剩余项目重选)
draftProject = null; draftProject = null;
} }
await loadProjects(); await loadProjects();
@@ -740,29 +835,28 @@ function toggleSidebar(force) {
try { localStorage.setItem('genesis_sidebar_collapsed', willCollapse ? '1' : '0'); } catch (e) { /* localStorage 不可用时忽略 */ } try { localStorage.setItem('genesis_sidebar_collapsed', willCollapse ? '1' : '0'); } catch (e) { /* localStorage 不可用时忽略 */ }
} }
// ---------- 上传区:项目绑定时仅要件定义 ---------- // ---------- 上传区:始终显示完整类型下拉 ----------
// P0B:选项目时也允许补传 template / write_instruction / rules / existing_system
// 不再藏起入口。"项目已预置 template 时再上传 template"在上传处用 confirm() 显式确认。
function updateUploadBar() { function updateUploadBar() {
const sel = document.getElementById('file-type'); const sel = document.getElementById('file-type');
const hint = document.getElementById('proj-hint'); const hint = document.getElementById('proj-hint');
if (GenesisState.shouldHideUploadSelect(activeProject, draftProject)) { if (sel) sel.style.display = '';
sel.style.display = 'none'; if (!hint) return;
hint.textContent = '(项目「' + esc(activeProject || draftProject) + '」已提供模板/规则/代码库,仅需上传要件定义)'; // 默认 file_type 仍为 requirements(最常用),但下拉始终可改。
if (sel && sel.value !== 'requirements') sel.value = 'requirements';
if (draftProject) {
hint.textContent = '默认上传要件定义 xlsx;如需补传模板/规则/既有系统 zip,请在下拉中选择对应类型。';
} else { } else {
sel.style.display = ''; hint.textContent = '请选择 file_type 后上传(未选项目时,模板/规则仍可手动上传)。';
hint.textContent = '';
} }
} }
// ---------- 空态/欢迎渲染 ---------- // ---------- 空态/欢迎渲染 ----------
function renderEmptyOrWelcome() { function renderEmptyOrWelcome() {
chatEl.innerHTML = ''; chatEl.innerHTML = '';
if (draftProject === null) { // P1F:欢迎文由 GenesisState.buildWelcome 统一来源
addMsg('assistant', '请新建项目或者选择项目'); addMsg('assistant', GenesisState.buildWelcome(draftProject));
} else {
addMsg('assistant', '你好!我是 Genesis 概要设计书生成 Agent。<br>请先在上方📎上传<strong>要件定义 xlsx</strong>'
+ (draftProject ? '(模板/规则/代码库已由项目「' + esc(draftProject) + '」提供)' : '与<strong>模板 docx</strong>')
+ ',然后告诉我:<br>· 「生成概要设计书」(自动解析→影响→生成→QA)<br>· 「用中文生成」 / 「现在什么状态?」');
}
} }
// ---------- 会话创建/加载 ---------- // ---------- 会话创建/加载 ----------
@@ -775,16 +869,19 @@ async function newSession() {
chatEl.innerHTML = ''; chatEl.innerHTML = '';
badge.textContent = '未创建会话'; badge.textContent = '未创建会话';
updateUploadBar(); updateUploadBar();
await refreshRagStats();
await refreshSessions(); await refreshSessions();
renderEmptyOrWelcome(); renderEmptyOrWelcome();
} }
async function loadSession(id) { async function loadSession(id) {
try { try {
const rec = await api('GET', '/api/sessions/' + id); const rec = await api('GET', '/api/sessions/' + encodeURIComponent(id));
sid = id; sid = id;
activeProject = rec.project || null; activeProject = rec.project || null;
draftProject = rec.project || null; // 载入既有会话时,顶栏与侧边栏过滤对齐该会话项目 // P1D:不再隐式覆盖 draftProject。会话事实(activeProject)与用户偏好(draftProject)
// 分层;若不一致,由提示条让用户主动选择。
// draftProject = rec.project || null; // 删除:原行为会悄悄改用户偏好
applyProjectContext(); applyProjectContext();
badge.textContent = '会话: ' + (rec.name || '新会话'); badge.textContent = '会话: ' + (rec.name || '新会话');
if (progressWs) { try { progressWs.close(); } catch (e) {} } 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)), onProgress: (e) => GenesisWS.applyProgressEvent(e, (role, text) => addMsg(role, text)),
onClose: () => {}, onClose: () => {},
}); });
const msgs = await api('GET', '/api/chat/' + id + '/messages'); const msgs = await api('GET', '/api/chat/' + encodeURIComponent(id) + '/messages');
chatEl.innerHTML = ''; chatEl.innerHTML = '';
for (const m of msgs) { for (const m of msgs) {
const roleMap = { user: 'user', progress: 'progress', error: 'error' }; const roleMap = { user: 'user', progress: 'progress', error: 'error' };
@@ -800,16 +897,66 @@ async function loadSession(id) {
addMsg(role, esc(m.content)); addMsg(role, esc(m.content));
} }
updateUploadBar(); updateUploadBar();
await refreshRagStats();
await refreshSessions(); await refreshSessions();
try { localStorage.setItem('genesis_session', sid); } catch (e) { /* 忽略 */ } try { localStorage.setItem('genesis_session', sid); } catch (e) { /* 忽略 */ }
renderProjectMismatchHint();
} catch (e) { } catch (e) {
addMsg('error', '加载会话失败:' + esc(String(e))); addMsg('error', '加载会话失败:' + esc(String(e)));
} }
} }
// P1D:项目不匹配提示条。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();
};
}
// P2Hsend() in-flight 锁,防止快速双击产生重复请求
let sendInFlight = false;
async function send() { async function send() {
if (sendInFlight) return; // in-flight 期间再次进入直接忽略
const text = inputEl.value.trim(); const text = inputEl.value.trim();
if (!text) return; if (!text) return;
sendInFlight = true;
if (!sid) { if (!sid) {
// 首条消息:先落库会话(D-v4) // 首条消息:先落库会话(D-v4)
try { try {
@@ -834,7 +981,7 @@ async function send() {
const typing = showTyping(); const typing = showTyping();
sendBtn.disabled = true; sendBtn.disabled = true;
try { 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(); typing.remove();
if (res.progress && res.progress.length) { 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(''); const items = res.progress.map(p => '<span class="progress-item ' + (p.status || 'ok') + '">' + esc(p.step) + ': ' + esc(p.detail || '') + '</span>').join('');
@@ -854,6 +1001,7 @@ async function send() {
typing.remove(); typing.remove();
addMsg('error', '请求失败:' + esc(String(e))); addMsg('error', '请求失败:' + esc(String(e)));
} finally { } finally {
sendInFlight = false;
sendBtn.disabled = false; sendBtn.disabled = false;
inputEl.focus(); inputEl.focus();
} }
@@ -867,6 +1015,87 @@ document.getElementById('ps-current').addEventListener('click', (e) => { e.stopP
document.getElementById('drawer-close').addEventListener('click', closeProjectDrawer); document.getElementById('drawer-close').addEventListener('click', closeProjectDrawer);
document.getElementById('pf-save').addEventListener('click', saveDrawerProject); document.getElementById('pf-save').addEventListener('click', saveDrawerProject);
document.getElementById('pf-delete').addEventListener('click', deleteDrawerProject); document.getElementById('pf-delete').addEventListener('click', deleteDrawerProject);
// ---------- P0ARAG 开关 + 状态徽标 + 开始影响调查 ----------
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) => { document.addEventListener('click', (e) => {
// 点击切换器外部关闭下拉 // 点击切换器外部关闭下拉
if (!document.getElementById('proj-switcher').contains(e.target)) toggleProjectSwitcher(false); 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 对应的项目 configprojects[])判定是否预置。
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 () => { document.getElementById('upload-btn').addEventListener('click', async () => {
if (!sid) { addMsg('error', '请先发送一条消息以创建会话'); inputEl.focus(); return; } if (!sid) { addMsg('error', '请先发送一条消息以创建会话'); inputEl.focus(); return; }
const sel = document.getElementById('file-type'); 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]; const file = document.getElementById('file-input').files[0];
if (!file) { addMsg('error', '请选择文件'); return; } 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(); const fd = new FormData();
fd.append('file_type', ft); fd.append('file_type', ft);
fd.append('file', file); fd.append('file', file);
const statusEl = document.getElementById('upload-status'); const statusEl = document.getElementById('upload-status');
statusEl.textContent = '上传中…'; statusEl.textContent = '上传中…';
try { 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(); const d = await r.json();
if (!r.ok) throw new Error(d.detail?.message || r.status); if (!r.ok) throw new Error(d.detail?.message || r.status);
statusEl.textContent = ''; statusEl.textContent = '';
// B5:上传成功后同步 activeProject 并刷新侧边栏状态 // B5 / P1F:上传成功后同步 activeProject、刷新上传条与侧边栏状态
const rec = await api('GET', '/api/sessions/' + sid); const rec = await api('GET', '/api/sessions/' + encodeURIComponent(sid));
activeProject = rec.project || null; activeProject = rec.project || null;
badge.textContent = '会话: ' + (rec.name || '新会话'); badge.textContent = '会话: ' + (rec.name || '新会话');
await refreshSessions(); await refreshSessions();
updateUploadBar(); // P1‑F:上传成功后同步上传条(防状态滞后)
await refreshRagStats(); // P0A:上传 existing_system 后 RAG 索引已建
document.getElementById('file-input').value = ''; // 仅成功时清空 document.getElementById('file-input').value = ''; // 仅成功时清空
addMsg('user', '[上传] ' + ft + '' + d.file_name); addMsg('user', '[上传] ' + ft + '' + d.file_name);
addMsg('progress', '<span class="progress-item ok">上传完成:' + esc(d.file_name) + '' + d.size + 'B</span>'); addMsg('progress', '<span class="progress-item ok">上传完成:' + esc(d.file_name) + '' + d.size + 'B</span>');
@@ -919,7 +1190,23 @@ document.getElementById('upload-btn').addEventListener('click', async () => {
await loadProjects(); await loadProjects();
const saved = (() => { try { return localStorage.getItem('genesis_session'); } catch (e) { return null; } })(); const saved = (() => { try { return localStorage.getItem('genesis_session'); } catch (e) { return null; } })();
if (saved) { if (saved) {
try { await loadSession(saved); return; } catch (e) { /* 忽略,新建 */ } // P2H:启动恢复前显示 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)))); newSession().catch(e => addMsg('error', esc(String(e))));
})(); })();
+15 -9
View File
@@ -23,14 +23,18 @@
return draftProject; return draftProject;
} }
// 绑定项目(draft 或 active)时隐藏上传类型下拉,仅允许要件定义 // 上传类型下拉始终可见,让用户能补传任何类型。
function shouldHideUploadSelect(activeProject, draftProject) { // (P0B:取消"绑项目即隐藏 select / 强改 file_type=requirements"的产品错误。
return Boolean(activeProject || draftProject); // "项目已预置 template 时再上传 template"的安全约束改在 chat.html 上传处用
// 显式 confirm() 实现,而非藏起入口。)
// 仅保留签名以兼容旧调用点,但语义固定为 false。
function shouldHideUploadSelect(_activeProject, _draftProject) {
return false;
} }
// 解析实际上传类型:绑定项目时恒为 requirements // 解析实际上传类型:尊重用户下拉选择。
function resolveUploadType(activeProject, draftProject, selectValue) { function resolveUploadType(_activeProject, _draftProject, selectValue) {
return (activeProject || draftProject) ? 'requirements' : selectValue; return selectValue;
} }
// 抽屉表单字段快照(去空白、单元分隔符连接),用于脏检测 // 抽屉表单字段快照(去空白、单元分隔符连接),用于脏检测
@@ -43,14 +47,16 @@
return snapshotNow !== saved; return snapshotNow !== saved;
} }
// 空态/欢迎语文案 // 空态/欢迎语文案P1F:单点来源;chat.html 不再内联重复)
function buildWelcome(draftProject) { function buildWelcome(draftProject) {
if (draftProject === null) { if (draftProject === null) {
return '请新建项目或者选择项目'; return '请新建项目或者选择项目';
} }
return '你好!我是 Genesis 概要设计书生成 Agent。请先上传要件定义 xlsx' return '你好!我是 Genesis 概要设计书生成 Agent。'
+ '请先在上方📎上传<strong>要件定义 xlsx</strong>'
+ '(模板/规则/代码库已由项目「' + draftProject + '」提供),然后告诉我:' + '(模板/规则/代码库已由项目「' + draftProject + '」提供),然后告诉我:'
+ '「生成概要设计书」/「用中文生成」/「现在什么状态?」'; + '<br>· 「生成概要设计书」(自动解析→影响→生成→QA)'
+ '<br>· 「用中文生成」 / 「现在什么状态?」';
} }
return { return {
+11 -13
View File
@@ -16,21 +16,17 @@ test('autoBindProject: 已选有效项目保持不变', () => {
assert.equal(S.autoBindProject('A', [{ name: 'A' }]), 'A'); 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, 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)时隐藏下拉', () => { test('resolveUploadType: 始终尊重用户下拉选择(不再被项目绑定强制为 requirements', () => {
assert.equal(S.shouldHideUploadSelect(null, 'A'), true); assert.equal(S.resolveUploadType(null, 'A', 'template'), 'template');
assert.equal(S.shouldHideUploadSelect('A', null), true);
});
test('resolveUploadType: 绑定项目时强制 requirements', () => {
assert.equal(S.resolveUploadType(null, 'A', 'template'), 'requirements');
});
test('resolveUploadType: 未绑定项目时保留用户选择', () => {
assert.equal(S.resolveUploadType(null, null, 'template'), 'template'); assert.equal(S.resolveUploadType(null, null, 'template'), 'template');
assert.equal(S.resolveUploadType('A', null, 'existing_system'), 'existing_system');
}); });
test('computeDrawerSnapshot: 去空白并以单元分隔符连接', () => { test('computeDrawerSnapshot: 去空白并以单元分隔符连接', () => {
@@ -46,6 +42,8 @@ test('buildWelcome: 无项目提示新建/选择项目', () => {
assert.match(S.buildWelcome(null), /请新建项目|选择项目/); assert.match(S.buildWelcome(null), /请新建项目|选择项目/);
}); });
test('buildWelcome: 有项目提示由项目提供模板/规则/代码库', () => { test('buildWelcome: 有项目提示由项目提供模板/规则/代码库', () => {
assert.match(S.buildWelcome('A'), /项目「A」/); const txt = S.buildWelcome('A');
assert.match(txt, /项目「A」/);
assert.match(txt, /要件定义/);
}); });
+54
View File
@@ -222,3 +222,57 @@ def test_http_start_impact_use_rag_e2e(tmp_path):
ir = client.get(f"/api/sessions/{sid}/impact-result").json() ir = client.get(f"/api/sessions/{sid}/impact-result").json()
# 经 HTTP 真实走到 RAG 路径,摘要标记 rag_enabled # 经 HTTP 真实走到 RAG 路径,摘要标记 rag_enabled
assert ir.get("rag_enabled") is True assert ir.get("rag_enabled") is True
def test_http_rag_stats_endpoint(tmp_path):
"""P0Aapp.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):
"""P0ARagStore.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
+27
View File
@@ -59,3 +59,30 @@ def test_concurrent_add_and_search_no_crash():
assert all(isinstance(r, str) for r in res) assert all(isinstance(r, str) for r in res)
finally: finally:
s.close() 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
+34
View File
@@ -12,6 +12,9 @@ import pytest
from genesis.server.store import SessionStore, ProjectsStore from genesis.server.store import SessionStore, ProjectsStore
from genesis.server.service import GenesisService, ServiceStepError, FileTypeError 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" _SAMPLE = Path(__file__).resolve().parents[1] / "sample"
@@ -334,3 +337,34 @@ def test_confirm_impact_wrong_state_raises(tmp_path):
# writing 态直接确认影响 → 报错(service 167 # writing 态直接确认影响 → 报错(service 167
with pytest.raises(ServiceStepError): with pytest.raises(ServiceStepError):
svc.confirm_impact(s.session_id) 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