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:
@@ -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 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 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-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 |
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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 控制条(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; }
|
#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 => ({'&':'&','<':'<','>':'>'}[c])); }
|
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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) + '」已删除。');
|
||||||
|
// P1‑F:先记住被删的,再清 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 不可用时忽略 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 上传区:项目绑定时仅要件定义 ----------
|
// ---------- 上传区:始终显示完整类型下拉 ----------
|
||||||
|
// P0‑B:选项目时也允许补传 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) {
|
// P1‑F:欢迎文由 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; // 载入既有会话时,顶栏与侧边栏过滤对齐该会话项目
|
// P1‑D:不再隐式覆盖 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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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() {
|
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);
|
||||||
|
|
||||||
|
// ---------- 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) => {
|
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 对应的项目 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 () => {
|
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 / P1‑F:上传成功后同步 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(); // P0‑A:上传 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) { /* 忽略,新建 */ }
|
// 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))));
|
newSession().catch(e => addMsg('error', esc(String(e))));
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -23,14 +23,18 @@
|
|||||||
return draftProject;
|
return draftProject;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 绑定项目(draft 或 active)时隐藏上传类型下拉,仅允许要件定义
|
// 上传类型下拉始终可见,让用户能补传任何类型。
|
||||||
function shouldHideUploadSelect(activeProject, draftProject) {
|
// (P0‑B:取消"绑项目即隐藏 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 空态/欢迎语文案
|
// 空态/欢迎语文案(P1‑F:单点来源;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
@@ -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, /要件定义/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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):
|
||||||
|
"""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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user