diff --git a/docs/superpowers/plans/2026-09-12-requirements-upload.md b/docs/superpowers/plans/2026-09-12-requirements-upload.md new file mode 100644 index 0000000..6bc0961 --- /dev/null +++ b/docs/superpowers/plans/2026-09-12-requirements-upload.md @@ -0,0 +1,78 @@ +# 要件定义书直传入口 + 移除 rag-bar(含输入区上移根治)— 实施计划 + +目标文件:`src/genesis/server/static/chat.html`、`frontend/src/main.ts`、`frontend/src/chat.css` +后端零改动(沿用 `POST /api/sessions/{sid}/files`,`file_type=requirements`)。 + +## 背景 / 问题 +- 点「上传资料」展开 `#upload-panel` → `#composer`(底部锚定)变高 → 顶部输入框/按钮被顶上去。 +- 需求:按钮改名「要件定义书」;**只允许上传要件定义书**;点击后**直接打开系统文件选择框**;选定即上传;其他资料改由项目配置提供。 +- 另有同类“上移”来源:`#rag-bar` 初始 `hidden`,**建会话后被显示** → 工具栏高度变化 → 输入区再跳一次。 + +## 已确认决策 +- D1 选定文件后**自动上传**(选定即 POST,无二次确认)。 +- D2 未创建会话时点按钮 → 提示「请先发送一条消息以创建会话」。 +- D3 会话内**只传要件定义书**(`.xlsx`);模板/规则/做成说明书由项目配置提供;`.xls` 不支持(后端 `EXPECTED_SUFFIX` 与 `excel_reader` 仅 `.xlsx`)。 +- D4 **移除 `#rag-bar`**(DOM + 相关前端 JS)。理由:影响调查已由聊天驱动(`chat/agent.py` 意图 `impact`,`tests/test_chat_intent.py` 覆盖),按钮冗余;RAG 现不可用(0 片段/禁用)。“启用 RAG/影响调查”待下一轮 RAG 落地再以合适方式加回。 + +## 变更清单 + +### chat.html +- `#tb-upload`:`📎 上传资料` → `📄 要件定义书`;`title="上传要件定义书"`;补 `aria-label`。 +- 删除 `#upload-panel` 整块(`#file-type`、`.file-pick`、`#upload-btn`、`#proj-hint`)。 +- `#composer-toolbar` 内保留 `` 与 ``。 +- **删除 `#rag-bar`**(`#rag-enabled` / `#rag-stats` / `#impact-btn`)。 + +### frontend/src/main.ts +- 删除常量 `fileTypeSel` / `projHintEl` / `uploadPanelEl`;保留 `fileInputEl` / `uploadStatusEl`。 +- 删除 `updateUploadBar()` 及**全部调用点**。 +- 删除 `UPLOAD_EXT_RULES` / `projectHasPrefixedType`;保留 `pickExt`。 +- 新增 `uploadRequirements()`:无 `sid` 提示 → 取文件 → 校验 `.xlsx` → `FormData(file_type=requirements)` POST → 刷新标题/侧边栏 → 聊天区提示;失败提示;**各路径均清空 `file-input.value`**。 +- 绑定:`#tb-upload` click → `fileInputEl.click()`;`fileInputEl` change → `uploadRequirements()`;删除 `#upload-btn` 绑定与面板切换。 +- **删除 RAG 相关前端代码**:常量 `ragEnabledEl` / `ragStatsEl` / `ragBarEl` / `impactBtnEl`;`RAG_LS_KEY` / `readRagFlag` / `writeRagFlag` / `refreshRagStats()` / `updateImpactButton()`;`ragEnabledEl` change 监听、`impactBtnEl` click 监听;以及 `refreshRagStats()` 的全部调用点。 +- `state.ts` 的 `resolveUploadType` / `shouldHideUploadSelect` 保留(node 测试覆盖),仅不再调用。 + +### frontend/src/chat.css +- 删除死样式:`#upload-bar`、`#upload-bar-inner`、`#upload-bar select(:focus)`、`.file-pick(:hover)`、`#upload-btn(:hover)`、`#proj-hint`、`#upload-panel`、`label.vh`。 +- **删除 RAG 样式**:`#rag-bar`、`#rag-bar label.rag-switch`、`#rag-bar input[type=checkbox]`(及 `:disabled`)、`#rag-bar .rag-stats(.ready/.zero)`、`#rag-bar button`(`:disabled`/`:hover:not(:disabled)`)。 +- 保留 `#upload-status`。 + +## 验证 +- `cd frontend && npm run verify`(typecheck + node --test + build)。 +- `python -m pytest`(覆盖率 ≥99%)。 +- 手动: + - 点「要件定义书」→ 弹系统文件框(过滤 .xlsx)→ 选文件**自动上传** → 聊天区显示「上传完成」; + - **输入区/按钮不再上移**(上传面板已删、rag-bar 已移除、工具栏高度恒定)且仍钉底; + - 无会话时提示先发消息;聊天框输入「做影响调查」仍可触发后端影响调查意图。 + +## 风险 +- 会话内不再能补传模板/规则/做成说明书 → 完全依赖项目配置;未配置则生成阶段报错(符合 D3)。 +- 移除 rag-bar 后,UI 不再有“启用 RAG / 影响调查”按钮;RAG 开关语义暂不可用(当前本就无效),下一轮 RAG 落地时重新引入。 +- 自动上传无二次确认(符合 D1)。 + +--- + +## 评审结论与计划调整(plan-eng-review) + +### 事实核对(Step 0) +- 后端 `/files`:`file_type: str = Form(...)` + `file: UploadFile = File(...)`;`requirements` 允许,白名单 `(".xlsx",)`。✅ +- `updateUploadBar()` 定义 1 处 + 调用 5 处 + 上传分支内 1 处,删函数须清干净(漏调用 tsc 会报错)。✅ +- `.vh` 仅用于将被删的「上传类型」label。✅ +- 无测试引用将删的 id。✅ + +### 采纳 +- **F1(同类“上移”来源)**:`#rag-bar` 建会话后出现会使工具栏变高 → 一并处理:**移除 rag-bar**(DOM+JS)。影响调查改由聊天驱动(已有意图解析)。 +- **F2**:仅 `.xlsx`(与后端/解析一致);`accept` 仅过滤,`change` 再校验后缀。 +- **F3**:所有路径清空 `fileInputEl.value`(同文件再次可选)。 +- **F4**:清理上传相关死样式与 `.vh`。 +- **F7**:按钮补 `aria-label`。 + +### 接受 +- **F5**:会话内不再补传模板/规则 → 依赖项目配置(D3 结果)。 +- **F6**:新上传流为 DOM 行为,无自动化测试;靠 tsc + 手动。 + +## GSTACK REVIEW REPORT +- STATUS: DONE +- REVIEWED: docs/superpowers/plans/2026-09-12-requirements-upload.md +- FINDINGS: F1 同类上移(已采纳:移除 rag-bar) / F2 扩展名(仅 .xlsx) / F3 重复选择(已纳入) / F4 死样式清理 / F7 aria;F5/F6 接受 +- KEY DECISION: 移除 rag-bar(影响调查由聊天驱动);会话内仅传 .xlsx 要件定义书,选定即上传 +- NEXT: 待执行 diff --git a/frontend/src/chat.css b/frontend/src/chat.css index 351e960..6dc93e4 100644 --- a/frontend/src/chat.css +++ b/frontend/src/chat.css @@ -229,34 +229,6 @@ header h1 { font-size: 16px; font-weight: 700; letter-spacing: -.02em; margin: 0 font-family: var(--font-mono); } -/* === RAG 控制条 (spec §2.2) === */ -#rag-bar { - display: flex; align-items: center; gap: 8px; - background: transparent; border: 1px solid var(--border-subtle); - border-radius: 999px; padding: 3px 10px; - font-size: 12px; color: var(--text-secondary); - font-family: var(--font-mono); -} -#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(--accent); - margin: 0; -} -#rag-bar input[type=checkbox]:disabled + span { color: var(--text-muted); cursor: not-allowed; } -#rag-bar .rag-stats { color: var(--text-muted); } -#rag-bar .rag-stats.ready { color: var(--accent); font-weight: 600; } -#rag-bar .rag-stats.zero { color: var(--text-muted); } -#rag-bar button { - background: var(--accent); color: var(--bg-base); border: none; - border-radius: 999px; padding: 4px 12px; font-size: 12px; font-weight: 700; - cursor: pointer; transition: all var(--dur) var(--ease); -} -#rag-bar button:disabled { background: var(--bg-elevated); color: var(--text-muted); cursor: not-allowed; } -#rag-bar button:hover:not(:disabled) { box-shadow: 0 0 16px var(--accent-soft); } - /* === 聊天区 (spec §2.3) === */ #chat { flex: 1; min-height: 0; overflow-y: auto; padding: var(--sp-6) var(--sp-4); max-width: none; width: 100%; margin: 0; } .msg { display: flex; margin: var(--sp-3) 0; padding-right: var(--composer-gutter); animation: zoomIn .25s var(--ease); position: relative; } @@ -300,34 +272,7 @@ header h1 { font-size: 16px; font-weight: 700; letter-spacing: -.02em; margin: 0 .actions a:hover { box-shadow: 0 0 16px var(--accent-soft); transform: translateY(-1px); } .typing { color: var(--text-muted); font-size: 13px; animation: auraPulse 1.4s ease-in-out infinite; font-family: var(--font-mono); } -/* === 上传条 (spec §2.4) === */ -#upload-bar { background: var(--bg-surface); border-top: 1px solid var(--border-subtle); padding: var(--sp-3) var(--sp-6); font-size: 13px; } -#upload-bar-inner { max-width: 880px; margin: 0 auto; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } -#upload-bar select { - font-size: 13px; padding: 8px 12px; border: 1px solid var(--border-strong); border-radius: var(--r-md); - background: var(--bg-input); color: var(--text-primary); cursor: pointer; outline: none; - transition: all var(--dur) var(--ease); - font-family: var(--font-ui); -} -#upload-bar select:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); } -.file-pick { - display: inline-block; padding: 8px 14px; border: 1px solid var(--border-strong); border-radius: var(--r-md); - background: var(--bg-input); color: var(--text-secondary); font-size: 13px; font-weight: 500; - cursor: pointer; transition: all var(--dur) var(--ease); -} -.file-pick:hover { border-color: var(--accent); color: var(--accent); } -#upload-btn { - background: transparent; border: 1px solid var(--border-strong); color: var(--text-primary); - border-radius: var(--r-md); padding: 8px 16px; cursor: pointer; font-size: 13px; font-weight: 600; - transition: all var(--dur) var(--ease); -} -#upload-btn:hover { background: var(--accent); border-color: var(--accent); color: var(--bg-base); } #upload-status { color: var(--text-muted); font-size: 12px; font-family: var(--font-mono); } -#proj-hint { - color: var(--warning); font-size: 12px; background: rgba(251,191,36,.1); border: 1px solid var(--warning); - border-left-width: 4px; border-radius: var(--r-sm); padding: 4px 10px; -} -label.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; } /* === 输入区 (spec §2.5) === */ #composer { flex-shrink: 0; border-top: none; background: transparent; padding: var(--sp-3) var(--sp-4) var(--sp-4); } @@ -456,7 +401,6 @@ label.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px .tb-btn .tb-ic { font-size: 14px; line-height: 1; } .tb-btn .tb-caret { font-size: 10px; color: var(--text-muted); } .proj-switcher { position: relative; } -#upload-panel { max-width: none; margin: 10px 0 0; } /* === 会话项「⋯」与重命名菜单 === */ .sess .kebab { flex-shrink: 0; opacity: 0; transition: opacity .15s ease; background: transparent; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index ab940f7..a00c9a3 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -6,7 +6,7 @@ import * as GenesisState from './state.ts'; import * as GenesisWS from './ws.ts'; import type { SessionListItem, SessionRecord, SessionCreated, ProjectConfig, ProjectBody, - ChatMessage, ChatReply, RagStats, UploadResp, RenameResp, ProgressItem, + ChatMessage, ChatReply, UploadResp, RenameResp, ProgressItem, } from './types.ts'; let sid: string | null = null; @@ -33,16 +33,8 @@ const sidebarToggleBtn = must('sidebar-toggle'); const projSwitcherEl = maybe('proj-switcher'); const sessMenuEl = must('sess-menu'); -const fileTypeSel = must('file-type'); const fileInputEl = must('file-input'); const uploadStatusEl = must('upload-status'); -const projHintEl = must('proj-hint'); -const uploadPanelEl = maybe('upload-panel'); - -const ragEnabledEl = must('rag-enabled'); -const ragStatsEl = must('rag-stats'); -const ragBarEl = must('rag-bar'); -const impactBtnEl = must('impact-btn'); const pfName = must('pf-name'); const pfDisplay = must('pf-display'); @@ -180,7 +172,6 @@ function onProjectRowClick(name: string): void { setExplicitNone(false); applyProjectContext(); renderProjectSwitcher(); - updateUploadBar(); } const collapsed = loadCollapsedProjects(); if (collapsed.has(name)) collapsed.delete(name); else collapsed.add(name); @@ -242,7 +233,6 @@ function renderProjectSwitcher(): void { setExplicitNone(draftProject === null); applyProjectContext(); renderProjectSwitcher(); - updateUploadBar(); toggleProjectSwitcher(false); }; }); @@ -389,23 +379,13 @@ function toggleSidebar(force?: boolean): void { try { localStorage.setItem('genesis_sidebar_collapsed', willCollapse ? '1' : '0'); } catch (e) { /* 忽略 */ } } -// ---------- 上传区 ---------- -function updateUploadBar(): void { - fileTypeSel.style.display = ''; - if (fileTypeSel.value !== 'requirements') fileTypeSel.value = 'requirements'; - if (draftProject) { - projHintEl.textContent = '默认上传要件定义 xlsx;如需补传模板/规则/既有系统 zip,请在下拉中选择对应类型。'; - } else { - projHintEl.textContent = '请选择 file_type 后上传(未选项目时,模板/规则仍可手动上传)。'; - } -} - // ---------- 空态/欢迎渲染 ---------- function insertPrompt(text: string): void { inputEl.value = text; inputEl.focus(); try { inputEl.setSelectionRange(text.length, text.length); } catch (e) { /* 忽略 */ } autoGrowInput(); + updateSendState(); } function renderEmptyOrWelcome(): void { @@ -478,8 +458,6 @@ async function newSession(): Promise { try { localStorage.removeItem('genesis_session'); } catch (e) { /* 忽略 */ } chatEl.innerHTML = ''; setText(sessionTitle, '未创建会话'); - updateUploadBar(); - await refreshRagStats(); await refreshSessions(); renderEmptyOrWelcome(); } @@ -503,8 +481,6 @@ async function loadSession(id: string): Promise { const role = roleMap[m.role] || 'assistant'; addMsg(role, esc(m.content)); } - updateUploadBar(); - await refreshRagStats(); await refreshSessions(); try { localStorage.setItem('genesis_session', sid); } catch (e) { /* 忽略 */ } renderProjectMismatchHint(); @@ -540,7 +516,6 @@ function renderProjectMismatchHint(): void { draftProject = activeProject; applyProjectContext(); renderProjectSwitcher(); - updateUploadBar(); void refreshSessions(); wrap.remove(); }; @@ -557,11 +532,13 @@ function renderProjectMismatchHint(): void { // ---------- 发送 ---------- let sendInFlight = false; +function updateSendState(): void { sendBtn.disabled = sendInFlight || inputEl.value.trim() === ''; } async function send(): Promise { if (sendInFlight) return; const text = inputEl.value.trim(); if (!text) return; sendInFlight = true; + updateSendState(); if (!sid) { try { const d = await api('POST', '/api/sessions', { user_id: 'default', project: draftProject || null }); @@ -573,6 +550,7 @@ async function send(): Promise { } catch (e) { addMsg('error', '创建会话失败:' + esc(String(e))); sendInFlight = false; + updateSendState(); return; } } @@ -588,7 +566,6 @@ async function send(): Promise { if (es) es.remove(); addMsg('user', esc(text)); const typing = showTyping(); - sendBtn.disabled = true; try { const res = await api('POST', '/api/chat/' + encodeURIComponent(sessionId) + '/messages', { content: text }); typing.remove(); @@ -611,7 +588,7 @@ async function send(): Promise { addMsg('error', '请求失败:' + esc(String(e))); } finally { sendInFlight = false; - sendBtn.disabled = false; + updateSendState(); inputEl.focus(); } } @@ -620,7 +597,8 @@ document.getElementById('send')!.addEventListener('click', () => { void send(); inputEl.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void send(); } }); -inputEl.addEventListener('input', autoGrowInput); +inputEl.addEventListener('input', () => { autoGrowInput(); updateSendState(); }); +updateSendState(); document.getElementById('new-chat')!.addEventListener('click', () => { void newSession().catch((e) => addMsg('error', esc(String(e)))); }); sidebarToggleBtn.addEventListener('click', () => toggleSidebar()); document.getElementById('ps-current')!.addEventListener('click', (e) => { @@ -636,9 +614,8 @@ document.getElementById('side-add-project')!.addEventListener('click', () => { if (!guardDrawerDirty()) return; openProjectDrawer(null); }); -document.getElementById('tb-upload')!.addEventListener('click', () => { - if (uploadPanelEl) uploadPanelEl.hidden = !uploadPanelEl.hidden; -}); +document.getElementById('tb-upload')!.addEventListener('click', () => { fileInputEl.click(); }); +fileInputEl.addEventListener('change', () => { void uploadRequirements(); }); document.getElementById('sm-rename')!.addEventListener('click', (ev) => { ev.stopPropagation(); const t = sessMenuTarget; @@ -646,82 +623,6 @@ document.getElementById('sm-rename')!.addEventListener('click', (ev) => { if (t) startRenameSession(t.el, { session_id: t.sid, name: t.name }); }); -// ---------- RAG 开关 + 状态徽标 + 开始影响调查 ---------- -const RAG_LS_KEY = 'genesis_rag_enabled'; -function readRagFlag(): boolean { - try { return localStorage.getItem(RAG_LS_KEY) === '1'; } catch (e) { return false; } -} -function writeRagFlag(v: boolean): void { - try { localStorage.setItem(RAG_LS_KEY, v ? '1' : '0'); } catch (e) { /* 忽略 */ } -} -ragEnabledEl.addEventListener('change', () => { writeRagFlag(ragEnabledEl.checked); updateImpactButton(); }); - -async function refreshRagStats(): Promise { - if (!sid) { - ragBarEl.hidden = true; - return; - } - ragBarEl.hidden = false; - let stats: RagStats = { 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; - setText(ragStatsEl, n > 0 - ? 'RAG 已索引 ' + n + ' 片段' - : 'RAG 0 片段(请在项目配置中设置代码库目录)'); - ragStatsEl.classList.toggle('ready', n > 0); - ragStatsEl.classList.toggle('zero', n === 0); - ragEnabledEl.disabled = n === 0; - if (n === 0) { - ragEnabledEl.checked = false; - writeRagFlag(false); - if (ragEnabledEl.parentElement) ragEnabledEl.parentElement.title = '请在项目配置中设置既有系统代码库目录'; - } else { - ragEnabledEl.checked = readRagFlag(); - if (ragEnabledEl.parentElement) { - ragEnabledEl.parentElement.title = ragEnabledEl.checked - ? 'RAG 检索结果会注入到 LLM prompt' - : '勾选后启用 RAG 检索'; - } - } - updateImpactButton(); -} - -function updateImpactButton(): void { - if (!sid) { impactBtnEl.disabled = true; return; } - const hasRag = (Number(ragStatsEl.dataset.chunks || 0) > 0) || !ragEnabledEl.disabled; - impactBtnEl.disabled = !hasRag; - setText(impactBtnEl, ragEnabledEl.checked - ? '开始影响调查(启用 RAG)' - : '开始影响调查'); -} - -impactBtnEl.addEventListener('click', () => { - void (async () => { - if (!sid) { addMsg('error', '请先发送一条消息以创建会话'); inputEl.focus(); return; } - const useRag = !!ragEnabledEl.checked; - impactBtnEl.disabled = true; - const typing = showTyping(); - try { - const url = '/api/sessions/' + encodeURIComponent(sid) + '/start-impact?use_rag=' + (useRag ? 'true' : 'false'); - const r = await fetch(url, { method: 'POST' }); - const d = (await r.json().catch(() => ({}))) as { detail?: { message?: string } }; - if (!r.ok) throw new Error((d && d.detail && d.detail.message) || String(r.status)); - typing.remove(); - addMsg('progress', '影响调查已提交(use_rag=' + (useRag ? 'true' : 'false') + ')'); - addMsg('assistant', '影响调查已启动。完成后可在此会话点击"预览"或访问 /result/impact-report 下载报告。'); - } catch (e) { - typing.remove(); - addMsg('error', '影响调查失败:' + esc(String(e))); - } finally { - updateImpactButton(); - } - })(); -}); - document.addEventListener('click', (e: MouseEvent) => { const target = e.target as Node; if (projSwitcherEl && !projSwitcherEl.contains(target)) toggleProjectSwitcher(false); @@ -736,72 +637,45 @@ document.addEventListener('keydown', (e: KeyboardEvent) => { } }); -// 客户端类型 → 扩展名白名单校验 -const UPLOAD_EXT_RULES: Record = { - existing_system: ['.zip'], - requirements: ['.xlsx'], - template: ['.docx'], - write_instruction: ['.docx'], - rules: ['.docx', '.xlsx'], -}; +// 取扩展名(小写,含点) function pickExt(name: string): string { const i = String(name).lastIndexOf('.'); return i >= 0 ? String(name).slice(i).toLowerCase() : ''; } -function projectHasPrefixedType(ft: string): boolean { - const name = activeProject || draftProject; - if (!name) return false; - const p = (projects || []).find((x) => x.name === name); - if (!p) return false; - if (ft === 'template') return !!(p.template && p.template.trim()); - if (ft === 'write_instruction') return !!(p.write_instruction && p.write_instruction.trim()); - if (ft === 'existing_system') return !!(p.existing_system_code_dir && p.existing_system_code_dir.trim()); - if (ft === 'rules') return Array.isArray(p.rules) && p.rules.length > 0; - return false; -} -document.getElementById('upload-btn')!.addEventListener('click', () => { - void (async () => { - if (!sid) { addMsg('error', '请先发送一条消息以创建会话'); inputEl.focus(); return; } - const ft = GenesisState.resolveUploadType(activeProject, draftProject, fileTypeSel.value); - const file = fileInputEl.files && fileInputEl.files[0]; - if (!file) { addMsg('error', '请选择文件'); return; } - const allowed = UPLOAD_EXT_RULES[ft]; - if (allowed) { - const ext = pickExt(file.name); - if (!ext || !allowed.includes(ext)) { - addMsg('error', '类型不匹配:' + esc(ft) + ' 应为 ' + allowed.join('/') + ' 文件(当前:' + esc(file.name) + ')'); - return; - } - } - if (projectHasPrefixedType(ft)) { - const ok = confirm('项目已预置 ' + ft + '。继续上传将覆盖该会话中的此类型文件,是否继续?'); - if (!ok) return; - } - const fd = new FormData(); - fd.append('file_type', ft); - fd.append('file', file); - setText(uploadStatusEl, '上传中…'); - try { - const r = await fetch('/api/sessions/' + encodeURIComponent(sid as string) + '/files', { method: 'POST', body: fd }); - const d = (await r.json().catch(() => ({}))) as UploadResp & { detail?: { message?: string } }; - if (!r.ok) throw new Error((d && d.detail && d.detail.message) || String(r.status)); - setText(uploadStatusEl, ''); - const rec = await api('GET', '/api/sessions/' + encodeURIComponent(sid as string)); - activeProject = rec.project || null; - setText(sessionTitle, (rec.name || '新会话')); - await refreshSessions(); - updateUploadBar(); - await refreshRagStats(); - fileInputEl.value = ''; - addMsg('user', '[上传] ' + ft + ':' + d.file_name); - addMsg('progress', '上传完成:' + esc(d.file_name) + '(' + d.size + 'B)'); - } catch (e) { - setText(uploadStatusEl, ''); - addMsg('error', '上传失败:' + esc(String(e))); - } - })(); -}); +// 要件定义书上传:仅 .xlsx;选定文件即自动上传 +async function uploadRequirements(): Promise { + const file = fileInputEl.files && fileInputEl.files[0]; + if (!sid) { addMsg('error', '请先发送一条消息以创建会话'); inputEl.focus(); fileInputEl.value = ''; return; } + if (!file) return; + if (pickExt(file.name) !== '.xlsx') { + addMsg('error', '要件定义书须为 .xlsx 文件(当前:' + esc(file.name) + ')'); + fileInputEl.value = ''; + return; + } + const sessionId = sid; + const fd = new FormData(); + fd.append('file_type', 'requirements'); + fd.append('file', file); + setText(uploadStatusEl, '上传中…'); + try { + const r = await fetch('/api/sessions/' + encodeURIComponent(sessionId) + '/files', { method: 'POST', body: fd }); + const d = (await r.json().catch(() => ({}))) as UploadResp & { detail?: { message?: string } }; + if (!r.ok) throw new Error((d && d.detail && d.detail.message) || String(r.status)); + setText(uploadStatusEl, ''); + const rec = await api('GET', '/api/sessions/' + encodeURIComponent(sessionId)); + activeProject = rec.project || null; + setText(sessionTitle, (rec.name || '新会话')); + await refreshSessions(); + addMsg('user', '[上传] 要件定义书:' + d.file_name); + addMsg('progress', '上传完成:' + esc(d.file_name) + '(' + d.size + 'B)'); + } catch (e) { + setText(uploadStatusEl, ''); + addMsg('error', '上传失败:' + esc(String(e))); + } finally { + fileInputEl.value = ''; + } +} // ---------- 启动:恢复侧边栏折叠态、上次会话 ---------- void (async () => { diff --git a/src/genesis/server/static/chat.css b/src/genesis/server/static/chat.css index f10b9c4..4fe7abf 100644 --- a/src/genesis/server/static/chat.css +++ b/src/genesis/server/static/chat.css @@ -557,64 +557,6 @@ header h1 { text-overflow: ellipsis; font-family: var(--font-mono); } -#rag-bar { - display: flex; - align-items: center; - gap: 8px; - background: transparent; - border: 1px solid var(--border-subtle); - border-radius: 999px; - padding: 3px 10px; - font-size: 12px; - color: var(--text-secondary); - font-family: var(--font-mono); -} -#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(--accent); - margin: 0; -} -#rag-bar input[type=checkbox]:disabled + span { - color: var(--text-muted); - cursor: not-allowed; -} -#rag-bar .rag-stats { - color: var(--text-muted); -} -#rag-bar .rag-stats.ready { - color: var(--accent); - font-weight: 600; -} -#rag-bar .rag-stats.zero { - color: var(--text-muted); -} -#rag-bar button { - background: var(--accent); - color: var(--bg-base); - border: none; - border-radius: 999px; - padding: 4px 12px; - font-size: 12px; - font-weight: 700; - cursor: pointer; - transition: all var(--dur) var(--ease); -} -#rag-bar button:disabled { - background: var(--bg-elevated); - color: var(--text-muted); - cursor: not-allowed; -} -#rag-bar button:hover:not(:disabled) { - box-shadow: 0 0 16px var(--accent-soft); -} #chat { flex: 1; min-height: 0; @@ -709,92 +651,11 @@ header h1 { animation: auraPulse 1.4s ease-in-out infinite; font-family: var(--font-mono); } -#upload-bar { - background: var(--bg-surface); - border-top: 1px solid var(--border-subtle); - padding: var(--sp-3) var(--sp-6); - font-size: 13px; -} -#upload-bar-inner { - max-width: 880px; - margin: 0 auto; - display: flex; - gap: 10px; - align-items: center; - flex-wrap: wrap; -} -#upload-bar select { - font-size: 13px; - padding: 8px 12px; - border: 1px solid var(--border-strong); - border-radius: var(--r-md); - background: var(--bg-input); - color: var(--text-primary); - cursor: pointer; - outline: none; - transition: all var(--dur) var(--ease); - font-family: var(--font-ui); -} -#upload-bar select:focus { - border-color: var(--accent); - box-shadow: 0 0 0 2px var(--accent-soft); -} -.file-pick { - display: inline-block; - padding: 8px 14px; - border: 1px solid var(--border-strong); - border-radius: var(--r-md); - background: var(--bg-input); - color: var(--text-secondary); - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: all var(--dur) var(--ease); -} -.file-pick:hover { - border-color: var(--accent); - color: var(--accent); -} -#upload-btn { - background: transparent; - border: 1px solid var(--border-strong); - color: var(--text-primary); - border-radius: var(--r-md); - padding: 8px 16px; - cursor: pointer; - font-size: 13px; - font-weight: 600; - transition: all var(--dur) var(--ease); -} -#upload-btn:hover { - background: var(--accent); - border-color: var(--accent); - color: var(--bg-base); -} #upload-status { color: var(--text-muted); font-size: 12px; font-family: var(--font-mono); } -#proj-hint { - color: var(--warning); - font-size: 12px; - background: rgba(251, 191, 36, .1); - border: 1px solid var(--warning); - border-left-width: 4px; - border-radius: var(--r-sm); - padding: 4px 10px; -} -label.vh { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - border: 0; -} #composer { flex-shrink: 0; border-top: none; @@ -1194,10 +1055,6 @@ label.vh { .proj-switcher { position: relative; } -#upload-panel { - max-width: none; - margin: 10px 0 0; -} .sess .kebab { flex-shrink: 0; opacity: 0; diff --git a/src/genesis/server/static/chat.html b/src/genesis/server/static/chat.html index 417de0a..07f4b9f 100644 --- a/src/genesis/server/static/chat.html +++ b/src/genesis/server/static/chat.html @@ -40,7 +40,7 @@
- +
@@ -52,33 +52,9 @@
- - -
- - diff --git a/src/genesis/server/static/chat.js b/src/genesis/server/static/chat.js index 3548408..678a90f 100644 --- a/src/genesis/server/static/chat.js +++ b/src/genesis/server/static/chat.js @@ -42,9 +42,6 @@ function autoBindProject(draftProject2, projects2, explicitNone) { } return draftProject2; } -function resolveUploadType(_activeProject, _draftProject, selectValue) { - return selectValue; -} function computeDrawerSnapshot(fields) { return fields.map((f) => (f == null ? "" : String(f)).trim()).join(String.fromCharCode(1)); } @@ -131,15 +128,8 @@ var sidebarEl = must("sidebar"); var sidebarToggleBtn = must("sidebar-toggle"); var projSwitcherEl = maybe("proj-switcher"); var sessMenuEl = must("sess-menu"); -var fileTypeSel = must("file-type"); var fileInputEl = must("file-input"); var uploadStatusEl = must("upload-status"); -var projHintEl = must("proj-hint"); -var uploadPanelEl = maybe("upload-panel"); -var ragEnabledEl = must("rag-enabled"); -var ragStatsEl = must("rag-stats"); -var ragBarEl = must("rag-bar"); -var impactBtnEl = must("impact-btn"); var pfName = must("pf-name"); var pfDisplay = must("pf-display"); var pfTemplate = must("pf-template"); @@ -281,7 +271,6 @@ function onProjectRowClick(name) { setExplicitNone(false); applyProjectContext(); renderProjectSwitcher(); - updateUploadBar(); } const collapsed = loadCollapsedProjects(); if (collapsed.has(name)) collapsed.delete(name); @@ -336,7 +325,6 @@ function renderProjectSwitcher() { setExplicitNone(draftProject === null); applyProjectContext(); renderProjectSwitcher(); - updateUploadBar(); toggleProjectSwitcher(false); }; }); @@ -487,15 +475,6 @@ function toggleSidebar(force) { } catch (e) { } } -function updateUploadBar() { - fileTypeSel.style.display = ""; - if (fileTypeSel.value !== "requirements") fileTypeSel.value = "requirements"; - if (draftProject) { - projHintEl.textContent = "\u9ED8\u8BA4\u4E0A\u4F20\u8981\u4EF6\u5B9A\u4E49 xlsx\uFF1B\u5982\u9700\u8865\u4F20\u6A21\u677F/\u89C4\u5219/\u65E2\u6709\u7CFB\u7EDF zip\uFF0C\u8BF7\u5728\u4E0B\u62C9\u4E2D\u9009\u62E9\u5BF9\u5E94\u7C7B\u578B\u3002"; - } else { - projHintEl.textContent = "\u8BF7\u9009\u62E9 file_type \u540E\u4E0A\u4F20\uFF08\u672A\u9009\u9879\u76EE\u65F6\uFF0C\u6A21\u677F/\u89C4\u5219\u4ECD\u53EF\u624B\u52A8\u4E0A\u4F20\uFF09\u3002"; - } -} function insertPrompt(text) { inputEl.value = text; inputEl.focus(); @@ -504,6 +483,7 @@ function insertPrompt(text) { } catch (e) { } autoGrowInput(); + updateSendState(); } function renderEmptyOrWelcome() { chatEl.innerHTML = ""; @@ -584,8 +564,6 @@ async function newSession() { } chatEl.innerHTML = ""; setText(sessionTitle, "\u672A\u521B\u5EFA\u4F1A\u8BDD"); - updateUploadBar(); - await refreshRagStats(); await refreshSessions(); renderEmptyOrWelcome(); } @@ -614,8 +592,6 @@ async function loadSession(id) { const role = roleMap[m.role] || "assistant"; addMsg(role, esc(m.content)); } - updateUploadBar(); - await refreshRagStats(); await refreshSessions(); try { localStorage.setItem("genesis_session", sid); @@ -650,7 +626,6 @@ function renderProjectMismatchHint() { draftProject = activeProject; applyProjectContext(); renderProjectSwitcher(); - updateUploadBar(); void refreshSessions(); wrap.remove(); }; @@ -666,11 +641,15 @@ function renderProjectMismatchHint() { }; } var sendInFlight = false; +function updateSendState() { + sendBtn.disabled = sendInFlight || inputEl.value.trim() === ""; +} async function send() { if (sendInFlight) return; const text = inputEl.value.trim(); if (!text) return; sendInFlight = true; + updateSendState(); if (!sid) { try { const d = await api("POST", "/api/sessions", { user_id: "default", project: draftProject || null }); @@ -685,6 +664,7 @@ async function send() { } catch (e) { addMsg("error", "\u521B\u5EFA\u4F1A\u8BDD\u5931\u8D25\uFF1A" + esc(String(e))); sendInFlight = false; + updateSendState(); return; } } @@ -706,7 +686,6 @@ async function send() { if (es) es.remove(); addMsg("user", esc(text)); const typing = showTyping(); - sendBtn.disabled = true; try { const res = await api("POST", "/api/chat/" + encodeURIComponent(sessionId) + "/messages", { content: text }); typing.remove(); @@ -725,7 +704,7 @@ async function send() { addMsg("error", "\u8BF7\u6C42\u5931\u8D25\uFF1A" + esc(String(e))); } finally { sendInFlight = false; - sendBtn.disabled = false; + updateSendState(); inputEl.focus(); } } @@ -738,7 +717,11 @@ inputEl.addEventListener("keydown", (e) => { void send(); } }); -inputEl.addEventListener("input", autoGrowInput); +inputEl.addEventListener("input", () => { + autoGrowInput(); + updateSendState(); +}); +updateSendState(); document.getElementById("new-chat").addEventListener("click", () => { void newSession().catch((e) => addMsg("error", esc(String(e)))); }); @@ -761,7 +744,10 @@ document.getElementById("side-add-project").addEventListener("click", () => { openProjectDrawer(null); }); document.getElementById("tb-upload").addEventListener("click", () => { - if (uploadPanelEl) uploadPanelEl.hidden = !uploadPanelEl.hidden; + fileInputEl.click(); +}); +fileInputEl.addEventListener("change", () => { + void uploadRequirements(); }); document.getElementById("sm-rename").addEventListener("click", (ev) => { ev.stopPropagation(); @@ -769,88 +755,6 @@ document.getElementById("sm-rename").addEventListener("click", (ev) => { closeSessMenu(); if (t) startRenameSession(t.el, { session_id: t.sid, name: t.name }); }); -var 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() { - 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; - setText(ragStatsEl, n > 0 ? "RAG \u5DF2\u7D22\u5F15 " + n + " \u7247\u6BB5" : "RAG 0 \u7247\u6BB5\uFF08\u8BF7\u5728\u9879\u76EE\u914D\u7F6E\u4E2D\u8BBE\u7F6E\u4EE3\u7801\u5E93\u76EE\u5F55\uFF09"); - ragStatsEl.classList.toggle("ready", n > 0); - ragStatsEl.classList.toggle("zero", n === 0); - ragEnabledEl.disabled = n === 0; - if (n === 0) { - ragEnabledEl.checked = false; - writeRagFlag(false); - if (ragEnabledEl.parentElement) ragEnabledEl.parentElement.title = "\u8BF7\u5728\u9879\u76EE\u914D\u7F6E\u4E2D\u8BBE\u7F6E\u65E2\u6709\u7CFB\u7EDF\u4EE3\u7801\u5E93\u76EE\u5F55"; - } else { - ragEnabledEl.checked = readRagFlag(); - if (ragEnabledEl.parentElement) { - ragEnabledEl.parentElement.title = ragEnabledEl.checked ? "RAG \u68C0\u7D22\u7ED3\u679C\u4F1A\u6CE8\u5165\u5230 LLM prompt" : "\u52FE\u9009\u540E\u542F\u7528 RAG \u68C0\u7D22"; - } - } - updateImpactButton(); -} -function updateImpactButton() { - if (!sid) { - impactBtnEl.disabled = true; - return; - } - const hasRag = Number(ragStatsEl.dataset.chunks || 0) > 0 || !ragEnabledEl.disabled; - impactBtnEl.disabled = !hasRag; - setText(impactBtnEl, ragEnabledEl.checked ? "\u5F00\u59CB\u5F71\u54CD\u8C03\u67E5\uFF08\u542F\u7528 RAG\uFF09" : "\u5F00\u59CB\u5F71\u54CD\u8C03\u67E5"); -} -impactBtnEl.addEventListener("click", () => { - void (async () => { - if (!sid) { - addMsg("error", "\u8BF7\u5148\u53D1\u9001\u4E00\u6761\u6D88\u606F\u4EE5\u521B\u5EFA\u4F1A\u8BDD"); - inputEl.focus(); - return; - } - const useRag = !!ragEnabledEl.checked; - impactBtnEl.disabled = true; - const typing = showTyping(); - try { - const url = "/api/sessions/" + encodeURIComponent(sid) + "/start-impact?use_rag=" + (useRag ? "true" : "false"); - const r = await fetch(url, { method: "POST" }); - const d = await r.json().catch(() => ({})); - if (!r.ok) throw new Error(d && d.detail && d.detail.message || String(r.status)); - typing.remove(); - addMsg("progress", '\u5F71\u54CD\u8C03\u67E5\u5DF2\u63D0\u4EA4\uFF08use_rag=' + (useRag ? "true" : "false") + "\uFF09"); - addMsg("assistant", '\u5F71\u54CD\u8C03\u67E5\u5DF2\u542F\u52A8\u3002\u5B8C\u6210\u540E\u53EF\u5728\u6B64\u4F1A\u8BDD\u70B9\u51FB"\u9884\u89C8"\u6216\u8BBF\u95EE /result/impact-report \u4E0B\u8F7D\u62A5\u544A\u3002'); - } catch (e) { - typing.remove(); - addMsg("error", "\u5F71\u54CD\u8C03\u67E5\u5931\u8D25\uFF1A" + esc(String(e))); - } finally { - updateImpactButton(); - } - })(); -}); document.addEventListener("click", (e) => { const target = e.target; if (projSwitcherEl && !projSwitcherEl.contains(target)) toggleProjectSwitcher(false); @@ -867,77 +771,47 @@ document.addEventListener("keydown", (e) => { } } }); -var 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() : ""; } -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; +async function uploadRequirements() { + const file = fileInputEl.files && fileInputEl.files[0]; + if (!sid) { + addMsg("error", "\u8BF7\u5148\u53D1\u9001\u4E00\u6761\u6D88\u606F\u4EE5\u521B\u5EFA\u4F1A\u8BDD"); + inputEl.focus(); + fileInputEl.value = ""; + return; + } + if (!file) return; + if (pickExt(file.name) !== ".xlsx") { + addMsg("error", "\u8981\u4EF6\u5B9A\u4E49\u4E66\u987B\u4E3A .xlsx \u6587\u4EF6\uFF08\u5F53\u524D\uFF1A" + esc(file.name) + "\uFF09"); + fileInputEl.value = ""; + return; + } + const sessionId = sid; + const fd = new FormData(); + fd.append("file_type", "requirements"); + fd.append("file", file); + setText(uploadStatusEl, "\u4E0A\u4F20\u4E2D\u2026"); + try { + const r = await fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/files", { method: "POST", body: fd }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d && d.detail && d.detail.message || String(r.status)); + setText(uploadStatusEl, ""); + const rec = await api("GET", "/api/sessions/" + encodeURIComponent(sessionId)); + activeProject = rec.project || null; + setText(sessionTitle, rec.name || "\u65B0\u4F1A\u8BDD"); + await refreshSessions(); + addMsg("user", "[\u4E0A\u4F20] \u8981\u4EF6\u5B9A\u4E49\u4E66\uFF1A" + d.file_name); + addMsg("progress", '\u4E0A\u4F20\u5B8C\u6210\uFF1A' + esc(d.file_name) + "\uFF08" + d.size + "B\uFF09"); + } catch (e) { + setText(uploadStatusEl, ""); + addMsg("error", "\u4E0A\u4F20\u5931\u8D25\uFF1A" + esc(String(e))); + } finally { + fileInputEl.value = ""; + } } -document.getElementById("upload-btn").addEventListener("click", () => { - void (async () => { - if (!sid) { - addMsg("error", "\u8BF7\u5148\u53D1\u9001\u4E00\u6761\u6D88\u606F\u4EE5\u521B\u5EFA\u4F1A\u8BDD"); - inputEl.focus(); - return; - } - const ft = resolveUploadType(activeProject, draftProject, fileTypeSel.value); - const file = fileInputEl.files && fileInputEl.files[0]; - if (!file) { - addMsg("error", "\u8BF7\u9009\u62E9\u6587\u4EF6"); - return; - } - const allowed = UPLOAD_EXT_RULES[ft]; - if (allowed) { - const ext = pickExt(file.name); - if (!ext || !allowed.includes(ext)) { - addMsg("error", "\u7C7B\u578B\u4E0D\u5339\u914D\uFF1A" + esc(ft) + " \u5E94\u4E3A " + allowed.join("/") + " \u6587\u4EF6\uFF08\u5F53\u524D\uFF1A" + esc(file.name) + "\uFF09"); - return; - } - } - if (projectHasPrefixedType(ft)) { - const ok = confirm("\u9879\u76EE\u5DF2\u9884\u7F6E " + ft + "\u3002\u7EE7\u7EED\u4E0A\u4F20\u5C06\u8986\u76D6\u8BE5\u4F1A\u8BDD\u4E2D\u7684\u6B64\u7C7B\u578B\u6587\u4EF6\uFF0C\u662F\u5426\u7EE7\u7EED\uFF1F"); - if (!ok) return; - } - const fd = new FormData(); - fd.append("file_type", ft); - fd.append("file", file); - setText(uploadStatusEl, "\u4E0A\u4F20\u4E2D\u2026"); - try { - const r = await fetch("/api/sessions/" + encodeURIComponent(sid) + "/files", { method: "POST", body: fd }); - const d = await r.json().catch(() => ({})); - if (!r.ok) throw new Error(d && d.detail && d.detail.message || String(r.status)); - setText(uploadStatusEl, ""); - const rec = await api("GET", "/api/sessions/" + encodeURIComponent(sid)); - activeProject = rec.project || null; - setText(sessionTitle, rec.name || "\u65B0\u4F1A\u8BDD"); - await refreshSessions(); - updateUploadBar(); - await refreshRagStats(); - fileInputEl.value = ""; - addMsg("user", "[\u4E0A\u4F20] " + ft + "\uFF1A" + d.file_name); - addMsg("progress", '\u4E0A\u4F20\u5B8C\u6210\uFF1A' + esc(d.file_name) + "\uFF08" + d.size + "B\uFF09"); - } catch (e) { - setText(uploadStatusEl, ""); - addMsg("error", "\u4E0A\u4F20\u5931\u8D25\uFF1A" + esc(String(e))); - } - })(); -}); void (async () => { try { const collapsed = localStorage.getItem("genesis_sidebar_collapsed") === "1";