feat(ui): 要件定义书作为附件随消息发送(先上传后发消息/消息绑定attachment持久) + SheetJS 在线查看(多sheet)

This commit is contained in:
lhl
2026-09-13 00:47:35 +08:00
parent e2eeda8aa2
commit b462105039
15 changed files with 27817 additions and 80 deletions
+225
View File
@@ -0,0 +1,225 @@
python : C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object
at 0x000001E71B0563E0>
所在位置 行:59 字符: 1
+ python -m pytest tests/test_server_store.py::test_add_message_with_at ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (C:\Python314\Li...0001E71B0563E0>:String) [], RemoteException
+ FullyQualifiedErrorId : NativeCommandError
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B0575B0>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B28B100>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B28B4C0>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B28B5B0>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B28B790>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B28B880>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B28B970>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B28BB50>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B28BC40>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B28BD30>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B28BE20>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B28B1F0>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B28B3D0>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B361C60>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B3616C0>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B3618A0>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B361E40>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B361F30>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B3607C0>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B3627A0>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B362A70>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B362B60>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B362C50>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B363010>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B363100>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E77FF04F40>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B3604F0>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B3632E0>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B362F20>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B363D30>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B363F10>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B458040>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B363880>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
C:\Python314\Lib\collections\__init__.py:453: ResourceWarning: unclosed database in <sqlite3.Connection object at 0x000
001E71B3626B0>
@classmethod
ResourceWarning: Enable tracemalloc to get the object allocation traceback
....
ERROR: Coverage failure: total of 27 is less than fail-under=99
[100%]
============================== warnings summary ===============================
C:\Users\NB-060\AppData\Roaming\Python\Python314\site-packages\fastapi\testclient.py:1
C:\Users\NB-060\AppData\Roaming\Python\Python314\site-packages\fastapi\testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=============================== tests coverage ================================
_______________ coverage: platform win32, python 3.14.3-final-0 _______________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------------------------------------
src\genesis\__init__.py 1 0 0 0 100%
src\genesis\data_models.py 220 0 0 0 100%
src\genesis\inference\__init__.py 5 0 0 0 100%
src\genesis\inference\exceptions.py 11 0 0 0 100%
src\genesis\inference\types.py 38 0 0 0 100%
src\genesis\impact\existing_system_explorer.py 5 1 0 0 80% 14
src\genesis\parsers\provenance.py 2 1 0 0 50% 3
src\genesis\writer\exceptions.py 2 2 0 0 0% 2-5
src\genesis\eval\__init__.py 3 3 0 0 0% 8-11
src\genesis\orchestrator\__init__.py 3 3 0 0 0% 6-15
src\genesis\writer\__init__.py 3 3 0 0 0% 3-7
src\genesis\parsers\_word_common.py 6 4 0 0 33% 8-11
src\genesis\parsers\excel_reader.py 12 5 2 0 50% 12-15, 20
src\genesis\qa\report.py 10 10 0 0 0% 2-15
src\genesis\parsers\merge_fill.py 15 12 10 0 12% 11-23
src\genesis\parsers\sheet_detector.py 20 12 14 0 24% 34-37, 41-47, 51
src\genesis\rag\embeddings.py 24 12 4 0 43% 18-19, 24-34
src\genesis\parsers\paragraph_splitter.py 17 13 10 0 15% 7, 12-24
src\genesis\server\hub.py 26 13 8 0 38% 22, 25-27, 30-34, 37-41
src\genesis\writer\context_builder.py 15 15 2 0 0% 2-37
src\genesis\chat\intent.py 48 16 20 9 60% 66, 68, 71, 73, 75, 77, 79, 81, 83, 89-102
src\genesis\writer\renderer.py 16 16 10 0 0% 6-25
src\genesis\inference\prompt_registry.py 27 17 8 0 29% 14, 18, 27-38, 42, 46-48
src\genesis\parsers\free_text_extractor.py 22 17 10 0 16% 10-22, 31-45
src\genesis\eval\golden_set.py 18 18 0 0 0% 7-31
src\genesis\inference\token.py 26 18 2 0 29% 13-14, 27-30, 35-43, 49-51
src\genesis\parsers\sheet_nature.py 29 18 12 0 27% 14, 18-21, 25-33, 37-44
src\genesis\parsers\table_extractor.py 24 19 10 0 15% 12-16, 31-57
src\genesis\writer\writer_state.py 21 21 6 0 0% 2-28
src\genesis\parsers\word_template_parser.py 32 22 12 0 23% 24-56
src\genesis\inference\factory.py 35 23 12 0 26% 25-37, 41-68
src\genesis\qa\guardrails.py 27 27 6 0 0% 1-56
src\genesis\qa\validator.py 27 27 2 0 0% 2-53
src\genesis\rag\store.py 45 27 6 0 35% 11-14, 29-31, 35-43, 46-58, 62-66, 69
src\genesis\writer\template_mapper.py 27 27 12 0 0% 11-45
src\genesis\parsers\formatting_detector.py 40 32 20 0 13% 11-23, 27-37, 41-43, 51-57
src\genesis\parsers\rule_doc_parser.py 45 33 18 0 19% 18-20, 25-31, 38-64
src\genesis\parsers\source_aggregator.py 48 34 14 0 23% 23-28, 39, 59-93
src\genesis\rag\impact_rag.py 53 38 26 0 19% 17-27, 36-43, 47-66, 69-72
src\genesis\state_machine.py 40 40 10 0 0% 1-110
src\genesis\config.py 132 41 24 0 58% 147-153, 158-164, 169-177, 181-185, 189-197, 212-220, 223
src\genesis\orchestrator\datagate.py 43 43 10 0 0% 15-121
src\genesis\inference\client.py 58 45 14 0 18% 47-54, 58, 61, 71-125
src\genesis\parsers\resolver.py 51 51 20 0 0% 12-96
src\genesis\writer\language.py 51 51 32 0 0% 13-112
src\genesis\parsers\excel_parser.py 73 52 28 0 21% 30-114
src\genesis\qa\qa_loop.py 55 55 10 0 0% 2-77
src\genesis\writer\orchestrator.py 65 65 20 0 0% 7-113
src\genesis\server\store.py 183 67 34 2 56% 157, 161-178, 181-183, 192->191, 197-200, 251, 272-305, 340-361, 364-368, 371-373, 376-378, 382
src\genesis\writer\writer_agent.py 75 75 22 0 0% 6-179
src\genesis\inference\engine.py 103 81 30 0 17% 49-55, 61, 64-66, 70-74, 78-88, 99-103, 122-146, 162-217
src\genesis\impact\code_parser.py 128 89 32 0 24% 29, 33, 48-51, 55, 82-111, 115-116, 122-145, 159-160, 170-175, 202-253, 268-273
src\genesis\orchestrator\task_queue.py 98 98 16 0 0% 12-239
src\genesis\eval\scorer.py 108 108 18 0 0% 12-220
src\genesis\writer\models.py 117 117 34 0 0% 2-253
src\genesis\server\app.py 279 128 26 2 51% 37-39, 103->107, 107->115, 130-134, 137-139, 147, 151, 156, 166-175, 179-181, 185-190, 194-196, 207, 215-219, 223-230, 234-235, 244-247, 254-255, 264-270, 274-276, 280-284, 290-294, 298-300, 304, 308-312, 318-325, 329-333, 337-339, 345-349, 353-356, 360-363, 367-370, 378-388, 399-400, 407-408
src\genesis\server\service.py 202 130 62 4 30% 92-105, 109-120, 137, 141-147, 155, 160-173, 182->186, 184->186, 192-209, 222-239, 242-248, 253-294, 297-300, 305-327, 330-359, 364-378, 381-383
src\genesis\writer\docx_injector.py 132 132 66 0 0% 14-204
src\genesis\chat\agent.py 256 199 70 9 20% 35, 49, 57, 60-65, 68-72, 77-78, 83-114, 122, 124, 126, 128, 130, 132-134, 141-163, 167-179, 182-200, 203-234, 237-259, 262-282, 285-303, 315-320
--------------------------------------------------------------------------------------------
TOTAL 3297 2226 834 26 27%
5 empty files skipped.
FAIL Required test coverage of 99.0% not reached. Total coverage: 26.85%
4 passed, 1 warning in 6.67s
@@ -0,0 +1,97 @@
# 要件定义书作为附件随消息发送 + 在线查看 — 实施计划
目标文件:
- 后端:`src/genesis/server/{store.py, service.py, app.py}``src/genesis/chat/agent.py`
- 前端:`frontend/{package.json, src/main.ts, src/chat.css}`
- 测试:`tests/{test_server_store.py, test_server_api.py, test_server_service.py}`
## 背景 / 目标
现状:选择 `.xlsx` 后**立即上传**。改为:
1. 选择 `.xlsx`**不清空为上传,而是作为“待发附件”chip 停在输入框上方**(可 `×` 移除/替换)。
2. 输入文字后点发送 → **先建会话(如需)→ 上传附件 → 再发送文字消息**;用户气泡内显示附件。
3. **附件与消息绑定**,刷新会话后仍显示在对应消息上。
4. 点击附件可**在线查看**(前端 SheetJS 解析原 xlsx 渲染,多 sheet)。
## 已确认决策
- D1 **必须有文字才能发送**(有附件但无文字也不可发送)。
- D2 查看 = **前端 SheetJS 在线解析原 xlsx 渲染**(多 sheet 切换;样式有限)。
- D3 附件与消息绑定、**刷新后仍显示**(后端消息加 `attachment` 字段 + 旧库迁移)。
- D4 **单个**要件定义书(再选替换待发附件)。
## 后端改动
### `store.py`
- `_init_db``chat_messages` 建表增列 `attachment TEXT`;对旧库执行迁移:
```python
cols = {r["name"] for r in c.execute("PRAGMA table_info(chat_messages)")}
if "attachment" not in cols:
c.execute("ALTER TABLE chat_messages ADD COLUMN attachment TEXT")
```
- `add_message(session_id, role, content, action=None, attachment: str | None = None)`INSERT 增 `attachment`。
- `list_messages`SELECT 增 `attachment`,返回 dict 增该字段(原样字符串)。
### `chat/agent.py`
- `handle_message(session_id, content, attachment: str | None = None)``self.store.add_message(session_id, "user", content, attachment=attachment)`L29)。
### `app.py`
- 新增 `class AttachmentRef(BaseModel): file_type: str = "requirements"; name: str = ""``ChatMessageReq` 增 `attachment: AttachmentRef | None = None`。
- `POST /api/chat/{sid}/messages`:把 `body.attachment` 序列化为 JSON 传给 `handle_message`。
- 新增 `GET /api/sessions/{sid}/files/{file_type}` → `FileResponse`(供前端取字节)。
- `service.file_path(sid, file_type) -> str | None`(校验 `ALLOWED_FILE_TYPES` 与存在性)。
### `service.py`
- 新增 `file_path(sid, file_type)`:从 `session.files[file_type]["path"]` 取,校验存在;非法类型/缺失返回 None。
## 前端改动
### 依赖
- `frontend/package.json` 增 `xlsx`SheetJSApache-2.0)到 `devDependencies`esbuild 打包进 `chat.js`。
### `main.ts`
- 状态 `pendingAttachment: { file: File; name: string } | null`。
- `file-input.change`:校验 `.xlsx` → 存为待发 → 渲染待发 chip(**不立即上传**)→ 清空 `input.value`。
- 待发 chip 渲染在输入框上方(`#composer-inner` 之前):`📄 文件名 ×``×` 清空。
- `send()`(仍要求有文字):
1. 有附件 → 确保会话(`!sid` → `POST /api/sessions`)→ `POST /files``file_type=requirements`);
2. 成功 → 记 `attachmentRef={file_type:'requirements',name}`、清待发;
3. `POST /api/chat/{sid}/messages` body `{ content, attachment?: attachmentRef }`
4. 上传失败 → 报错并**保留**待发附件、中止发送。
- 用户气泡:本消息带附件 → 气泡内渲染附件 chip(点击查看)。
- 历史(`loadSession`):消息含 `attachment` → 用户气泡内渲染 chip`try/catch` 解析 JSON、文件名 `esc`)。
- 查看:点击 chip → `fetch('/api/sessions/{sid}/files/{file_type}')` → `arrayBuffer` → `XLSX.read(new Uint8Array(buf), {type:'array'})` → 各 sheet `XLSX.utils.sheet_to_html` → 渲染进**页面内弹层**(sheet 标签切换 + 关闭;DOM 由 JS 动态创建,无需改 chat.html)。
- `updateSendState()` 不变(仍以文字为准)。
### `chat.css`
- 待发 chip、气泡内附件 chip、查看弹层(overlay + sheet tabs + 表格滚动)样式。
## 验证
- `cd frontend && npm run verify`typecheck + node --test + build)。
- `python -m pytest`(覆盖率 ≥99%,含新增测试)。
- 手动:选文件→待发 chip;无文字时发送键禁用;有文字发送→先上传后发消息;气泡显示附件;点查看→SheetJS 弹层多 sheet;刷新会话后附件仍在对应消息。
## 评审结论(plan-eng-review
### Step 0 假设(已核对)
- 用户消息在 `chat/agent.py:29` 入库 → attachment 从此透传。✅
- `chat_messages` 无 `attachment` 列 → 需迁移。✅
- 无 xlsx 查看能力 → 前端 SheetJS。✅
- 无上传文件读取端点 → 新增 GET。✅
### 发现
- **F1(体积)**SheetJS 打包进 `chat.js` 会显著增大(约数百 KB)。为保持“单文件 + mtime 版本”简单性,本轮直接打包;后续可改动态 `import()` 懒加载。**接受**。
- **F2(依赖/许可)**npm `xlsx@0.18.5`SheetJS 新版走自有 CDN);Apache-2.0,合规;仅用读取功能。**接受**。
- **F3(迁移)**PRAGMA 检测后再 ALTER,兼容新老库;补迁移测试。**已纳入**。
- **F4(顺序)**:先上传后发消息;上传成功而发消息失败时,附件已在会话、消息未绑定,重试覆盖重发。**接受**。
- **F5(安全)**`GET /files/{file_type}` 限 `ALLOWED_FILE_TYPES`,路径取自会话存储,防穿越。**已纳入**。
- **F6(首条消息带附件)**:先建会话→上传(会话名取文件名)→发消息,agent 可读 requirements。✅
- **F7(兼容)**`attachment` 为 JSON 字符串,前端 try/catch 解析;文件名一律 `esc`。**已纳入**。
### 剩余风险(非阻塞,实施中若触发则询问)
- **R1**`xlsx` 为 CommonJSesbuild 打包时可能尝试解析 `fs`/`crypto` 等 Node 内建。若报错,改用其浏览器发行版或配置 `external` 处理。
## GSTACK REVIEW REPORT
- STATUS: DONE
- REVIEWED: docs/superpowers/plans/2026-09-12-attachment-with-message.md
- FINDINGS: F1 体积(接受) / F2 依赖许可(接受) / F3 迁移(纳入) / F4 顺序(接受) / F5 安全(纳入) / F6/F7 已覆盖
- KEY DECISION: 附件待发 → 发送时先上传后发消息;消息绑定 attachment 并持久;SheetJS 在线查看
- RISK: R1xlsx/esbuild 打包兼容)——实施中若触发即询问
- NEXT: 无阻塞问题点,开始实施
+114 -1
View File
@@ -11,7 +11,8 @@
"@types/node": "^22.20.2",
"esbuild": "^0.24.0",
"typescript": "^5.6.0",
"vite": "^5.4.0"
"vite": "^5.4.0",
"xlsx": "^0.18.5"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -865,6 +866,53 @@
"undici-types": "~6.21.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.24.2.tgz",
@@ -906,6 +954,16 @@
"@esbuild/win32-x64": "0.24.2"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
@@ -1032,6 +1090,19 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
@@ -1542,6 +1613,48 @@
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
}
}
}
+2 -1
View File
@@ -17,6 +17,7 @@
"@types/node": "^22.20.2",
"esbuild": "^0.24.0",
"typescript": "^5.6.0",
"vite": "^5.4.0"
"vite": "^5.4.0",
"xlsx": "^0.18.5"
}
}
+54
View File
@@ -419,3 +419,57 @@ header h1 { font-size: 16px; font-weight: 700; letter-spacing: -.02em; margin: 0
.sess-menu .sm-item:hover { background: var(--bg-elevated); color: var(--accent); }
.sess-menu .sm-ic { color: var(--accent); }
@media (max-width: 640px) { .empty-state .suggests { grid-template-columns: 1fr; } }
/* === 待发附件(输入框上方) === */
.pending-attach { display: flex; align-items: center; gap: 8px; margin: 0 0 8px; }
.pending-attach .pending-name {
display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px;
background: var(--bg-elevated); border: 1px solid var(--border-strong); border-radius: var(--r-md);
color: var(--text-primary); font-size: 13px; max-width: 100%;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.pending-attach .attach-remove {
background: transparent; border: 1px solid var(--border-strong); color: var(--text-muted);
width: 24px; height: 24px; border-radius: 50%; cursor: pointer; font-size: 14px; line-height: 1;
display: flex; align-items: center; justify-content: center; transition: all var(--dur) var(--ease);
}
.pending-attach .attach-remove:hover { border-color: var(--danger); color: var(--danger); }
/* === 消息内附件 chip === */
.attach-line { margin-top: 6px; }
.attach-chip {
display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; max-width: 100%;
background: var(--bg-elevated); border: 1px solid var(--border-strong); border-radius: 999px;
color: var(--accent); font-size: 13px; font-family: var(--font-ui); cursor: pointer;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: all var(--dur) var(--ease);
}
.attach-chip:hover { border-color: var(--accent); box-shadow: 0 0 12px var(--accent-soft); }
/* === 附件查看弹层(SheetJS 渲染) === */
.viewer-overlay {
position: fixed; inset: 0; z-index: 200; background: rgba(0,0,0,.6);
display: flex; align-items: center; justify-content: center; padding: 24px;
}
.viewer-panel {
background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--r-lg);
width: min(1100px, 100%); height: 100%; display: flex; flex-direction: column; overflow: hidden;
box-shadow: 0 12px 48px rgba(0,0,0,.5);
}
.viewer-head { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--border-subtle); }
.viewer-title { flex: 1; font-size: 14px; font-weight: 700; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.viewer-close {
background: transparent; border: 1px solid var(--border-subtle); color: var(--text-secondary);
width: 30px; height: 30px; border-radius: 50%; cursor: pointer; font-size: 16px; line-height: 1;
display: flex; align-items: center; justify-content: center; transition: all var(--dur) var(--ease);
}
.viewer-close:hover { background: var(--danger); border-color: var(--danger); color: var(--bg-base); }
.viewer-tabs { display: flex; gap: 4px; padding: 8px 12px; border-bottom: 1px solid var(--border-subtle); overflow-x: auto; }
.vt-tab {
background: transparent; border: 1px solid transparent; border-radius: var(--r-sm);
color: var(--text-secondary); font-size: 13px; padding: 5px 12px; cursor: pointer; white-space: nowrap;
}
.vt-tab:hover { background: var(--bg-elevated); color: var(--text-primary); }
.vt-tab.active { background: var(--bg-elevated); color: var(--accent); border-color: var(--border-strong); font-weight: 700; }
.viewer-body { flex: 1; overflow: auto; padding: 12px; background: #fff; color: #111; }
.viewer-body table { border-collapse: collapse; font-size: 12px; }
.viewer-body td, .viewer-body th { border: 1px solid #ccc; padding: 3px 8px; white-space: nowrap; }
+129 -33
View File
@@ -1,5 +1,6 @@
// Genesis Chat UI 业务逻辑(TypeScript 版;由 esbuild 打包为 /static/chat.js
import './chat.css';
import * as XLSX from 'xlsx';
import { api } from './api.ts';
import { must, maybe, esc, setText } from './dom.ts';
import * as GenesisState from './state.ts';
@@ -35,6 +36,39 @@ const sessMenuEl = must<HTMLDivElement>('sess-menu');
const fileInputEl = must<HTMLInputElement>('file-input');
const uploadStatusEl = must<HTMLElement>('upload-status');
const composerEl = must<HTMLDivElement>('composer');
interface AttachmentRef { file_type: string; name: string; }
let pendingAttachment: { file: File; name: string } | null = null;
// 输入框上方的「待发附件」区
const pendingBox = document.createElement('div');
pendingBox.className = 'pending-attach';
pendingBox.hidden = true;
composerEl.insertBefore(pendingBox, composerEl.firstChild);
function renderPendingAttachment(): void {
if (!pendingAttachment) { pendingBox.hidden = true; pendingBox.innerHTML = ''; return; }
pendingBox.hidden = false;
pendingBox.innerHTML = '<span class="pending-name">📄 ' + esc(pendingAttachment.name) + '</span>'
+ '<button class="attach-remove" type="button" title="移除附件" aria-label="移除附件">×</button>';
const rm = pendingBox.querySelector<HTMLElement>('.attach-remove');
if (rm) rm.onclick = () => { pendingAttachment = null; renderPendingAttachment(); };
}
function attachmentChipHtml(att: AttachmentRef): string {
return '<div class="attach-line"><button class="attach-chip" type="button"'
+ ' data-ft="' + esc(att.file_type) + '" data-name="' + esc(att.name) + '">📄 ' + esc(att.name) + '</button></div>';
}
function bindAttachChips(root: HTMLElement): void {
root.querySelectorAll<HTMLElement>('.attach-chip').forEach((btn) => {
btn.onclick = () => { void openAttachmentViewer(btn.dataset.ft || 'requirements', btn.dataset.name || ''); };
});
}
function addUserMsg(text: string, att: AttachmentRef | null): void {
const m = addMsg('user', esc(text) + (att ? attachmentChipHtml(att) : ''));
bindAttachChips(m);
}
const pfName = must<HTMLInputElement>('pf-name');
const pfDisplay = must<HTMLInputElement>('pf-display');
@@ -479,7 +513,13 @@ async function loadSession(id: string): Promise<void> {
for (const m of msgs) {
const roleMap: Record<string, string> = { user: 'user', progress: 'progress', error: 'error' };
const role = roleMap[m.role] || 'assistant';
addMsg(role, esc(m.content));
if (role === 'user') {
let att: AttachmentRef | null = null;
if (m.attachment) { try { att = JSON.parse(m.attachment) as AttachmentRef; } catch (e) { att = null; } }
addUserMsg(m.content, att);
} else {
addMsg(role, esc(m.content));
}
}
await refreshSessions();
try { localStorage.setItem('genesis_session', sid); } catch (e) { /* 忽略 */ }
@@ -555,6 +595,34 @@ async function send(): Promise<void> {
}
}
const sessionId = sid as string;
// 待发附件:先上传(失败则中止并保留待发附件)
let attachmentRef: AttachmentRef | null = null;
if (pendingAttachment) {
const f = pendingAttachment;
const fd = new FormData();
fd.append('file_type', 'requirements');
fd.append('file', f.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<SessionRecord>('GET', '/api/sessions/' + encodeURIComponent(sessionId));
activeProject = rec.project || null;
setText(sessionTitle, (rec.name || '新会话'));
await refreshSessions();
attachmentRef = { file_type: 'requirements', name: d.file_name };
pendingAttachment = null;
renderPendingAttachment();
} catch (e) {
setText(uploadStatusEl, '');
addMsg('error', '附件上传失败:' + esc(String(e)));
sendInFlight = false;
updateSendState();
return;
}
}
if (progressWs) { try { progressWs.close(); } catch (e) { /* 忽略 */ } }
progressWs = GenesisWS.connectProgressWs(sessionId, {
onProgress: (e) => GenesisWS.applyProgressEvent(e, (role, text2) => addMsg(role, text2)),
@@ -564,10 +632,12 @@ async function send(): Promise<void> {
inputEl.style.height = '';
const es = chatEl.querySelector('.empty-state');
if (es) es.remove();
addMsg('user', esc(text));
addUserMsg(text, attachmentRef);
const typing = showTyping();
const msgBody: { content: string; attachment?: AttachmentRef } = { content: text };
if (attachmentRef) msgBody.attachment = attachmentRef;
try {
const res = await api<ChatReply>('POST', '/api/chat/' + encodeURIComponent(sessionId) + '/messages', { content: text });
const res = await api<ChatReply>('POST', '/api/chat/' + encodeURIComponent(sessionId) + '/messages', msgBody);
typing.remove();
if (res.progress && res.progress.length) {
const items = res.progress.map((p: ProgressItem) => '<span class="progress-item ' + (p.status || 'ok') + '">' + esc(p.step) + ': ' + esc(p.detail || '') + '</span>').join('');
@@ -615,7 +685,17 @@ document.getElementById('side-add-project')!.addEventListener('click', () => {
openProjectDrawer(null);
});
document.getElementById('tb-upload')!.addEventListener('click', () => { fileInputEl.click(); });
fileInputEl.addEventListener('change', () => { void uploadRequirements(); });
fileInputEl.addEventListener('change', () => {
const file = fileInputEl.files && fileInputEl.files[0];
fileInputEl.value = '';
if (!file) return;
if (pickExt(file.name) !== '.xlsx') {
addMsg('error', '要件定义书须为 .xlsx 文件(当前:' + esc(file.name) + '');
return;
}
pendingAttachment = { file, name: file.name };
renderPendingAttachment();
});
document.getElementById('sm-rename')!.addEventListener('click', (ev) => {
ev.stopPropagation();
const t = sessMenuTarget;
@@ -643,40 +723,56 @@ function pickExt(name: string): string {
return i >= 0 ? String(name).slice(i).toLowerCase() : '';
}
// 要件定义书上传:仅 .xlsx;选定文件即自动上传
async function uploadRequirements(): Promise<void> {
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, '上传中…');
// 在线查看附件:前端用 SheetJS 解析原 xlsx 渲染(多 sheet
async function openAttachmentViewer(fileType: string, name: string): Promise<void> {
if (!sid) { addMsg('error', '请先创建会话'); return; }
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<SessionRecord>('GET', '/api/sessions/' + encodeURIComponent(sessionId));
activeProject = rec.project || null;
setText(sessionTitle, (rec.name || '新会话'));
await refreshSessions();
addMsg('user', '[上传] 要件定义书:' + d.file_name);
addMsg('progress', '<span class="progress-item ok">上传完成:' + esc(d.file_name) + '' + d.size + 'B</span>');
const r = await fetch('/api/sessions/' + encodeURIComponent(sid) + '/files/' + encodeURIComponent(fileType));
if (!r.ok) throw new Error(String(r.status));
const buf = await r.arrayBuffer();
const wb = XLSX.read(new Uint8Array(buf), { type: 'array' });
showWorkbookViewer(name, wb);
} catch (e) {
setText(uploadStatusEl, '');
addMsg('error', '上传失败:' + esc(String(e)));
} finally {
fileInputEl.value = '';
addMsg('error', '附件查看失败:' + esc(String(e)));
}
}
function showWorkbookViewer(name: string, wb: XLSX.WorkBook): void {
const old = document.getElementById('attach-viewer');
if (old) old.remove();
const overlay = document.createElement('div');
overlay.id = 'attach-viewer';
overlay.className = 'viewer-overlay';
const names = wb.SheetNames;
const tabs = names.map((n, i) => '<button class="vt-tab' + (i === 0 ? ' active' : '') + '" type="button" data-i="' + i + '">' + esc(n) + '</button>').join('');
overlay.innerHTML =
'<div class="viewer-panel">' +
'<div class="viewer-head"><span class="viewer-title">📄 ' + esc(name) + '</span>' +
'<button class="viewer-close" type="button" title="关闭" aria-label="关闭">×</button></div>' +
'<div class="viewer-tabs">' + tabs + '</div>' +
'<div class="viewer-body"></div>' +
'</div>';
document.body.appendChild(overlay);
const vbody = overlay.querySelector<HTMLElement>('.viewer-body');
const renderSheet = (i: number): void => {
if (!vbody) return;
const ws = wb.Sheets[names[i]];
vbody.innerHTML = ws ? XLSX.utils.sheet_to_html(ws) : '';
};
renderSheet(0);
overlay.querySelectorAll<HTMLElement>('.vt-tab').forEach((el) => {
el.onclick = () => {
overlay.querySelectorAll('.vt-tab').forEach((x) => x.classList.remove('active'));
el.classList.add('active');
renderSheet(Number(el.dataset.i || 0));
};
});
const close = (): void => overlay.remove();
const closeBtn = overlay.querySelector<HTMLElement>('.viewer-close');
if (closeBtn) closeBtn.onclick = close;
overlay.addEventListener('click', (e: MouseEvent) => { if (e.target === overlay) close(); });
}
// ---------- 启动:恢复侧边栏折叠态、上次会话 ----------
void (async () => {
try {
+2 -2
View File
@@ -1,4 +1,4 @@
// 前端共享类型(与后端 app.py / store.py 的 DTO 对齐)
// 前端共享类型(与后端 app.py / store.py 的 DTO 对齐)
export interface SessionRecord {
session_id: string;
@@ -41,7 +41,7 @@ export interface ProjectBody {
design_docs_dir: string;
}
export interface ChatMessage { role: string; content: string; action?: string | null; created_at?: string; }
export interface ChatMessage { role: string; content: string; action?: string | null; attachment?: string | null; created_at?: string; }
export interface ProgressItem { step: string; status?: string; detail?: string; }
export interface ChatReply { reply?: string; status?: string; progress?: ProgressItem[]; }
export interface RagStats { chunks: number; rag_enabled: boolean; }
+2 -2
View File
@@ -25,8 +25,8 @@ class ChatAgent:
# ---------- 主入口 ----------
def handle_message(self, session_id: str, content: str) -> dict:
self.store.add_message(session_id, "user", content)
def handle_message(self, session_id: str, content: str, attachment: str | None = None) -> dict:
self.store.add_message(session_id, "user", content, attachment=attachment)
rec = self.store.get_session(session_id)
progress: list[dict] = []
+20 -1
View File
@@ -46,8 +46,14 @@ class _FakeEngine:
)
class AttachmentRef(BaseModel):
file_type: str = "requirements"
name: str = ""
class ChatMessageReq(BaseModel):
content: str
attachment: AttachmentRef | None = None
class SessionCreate(BaseModel):
@@ -241,6 +247,16 @@ def create_app(
raise _error(404, "SESSION_NOT_FOUND", f"会话不存在: {sid}")
return FileUploadResp(file_id=entry["file_id"], file_name=entry["name"], size=entry["size"])
@app.get("/api/sessions/{sid}/files/{file_type}")
def get_session_file(sid: str, file_type: str):
try:
path = service.file_path(sid, file_type)
except SessionNotFoundError:
raise _error(404, "SESSION_NOT_FOUND", f"会话不存在: {sid}")
if not path:
raise _error(404, "FILE_NOT_FOUND", f"文件不存在: {file_type}")
return FileResponse(path, media_type="application/octet-stream", filename=Path(path).name)
# ---------- 解析 ----------
@app.post("/api/sessions/{sid}/start-parse")
@@ -375,8 +391,11 @@ def create_app(
@app.post("/api/chat/{sid}/messages")
def chat_message(sid: str, body: ChatMessageReq):
import json
att = body.attachment.model_dump() if body.attachment else None
att_json = json.dumps(att, ensure_ascii=False) if att else None
try:
result = chat_agent.handle_message(sid, body.content)
result = chat_agent.handle_message(sid, body.content, attachment=att_json)
except SessionNotFoundError:
raise _error(404, "SESSION_NOT_FOUND", f"会话不存在: {sid}")
return result
+10
View File
@@ -122,6 +122,16 @@ class GenesisService:
def get_session(self, session_id: str) -> SessionRecord:
return self.store.get_session(session_id)
def file_path(self, session_id: str, file_type: str) -> str | None:
"""已上传文件的有效路径(类型非法/未上传/文件不存在 → None)。"""
if file_type not in ALLOWED_FILE_TYPES:
return None
entry = (self.get_session(session_id).files or {}).get(file_type)
if not entry:
return None
p = Path(entry.get("path", ""))
return str(p) if p.exists() else None
def rename_session(self, session_id: str, name: str) -> SessionRecord:
"""重命名会话(历史项「重命名」)。name 由调用方保证非空。"""
return self.store.update_session(session_id, name=name)
+166
View File
@@ -1129,3 +1129,169 @@ header h1 {
grid-template-columns: 1fr;
}
}
.pending-attach {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px;
}
.pending-attach .pending-name {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
background: var(--bg-elevated);
border: 1px solid var(--border-strong);
border-radius: var(--r-md);
color: var(--text-primary);
font-size: 13px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pending-attach .attach-remove {
background: transparent;
border: 1px solid var(--border-strong);
color: var(--text-muted);
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
font-size: 14px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--dur) var(--ease);
}
.pending-attach .attach-remove:hover {
border-color: var(--danger);
color: var(--danger);
}
.attach-line {
margin-top: 6px;
}
.attach-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
max-width: 100%;
background: var(--bg-elevated);
border: 1px solid var(--border-strong);
border-radius: 999px;
color: var(--accent);
font-size: 13px;
font-family: var(--font-ui);
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: all var(--dur) var(--ease);
}
.attach-chip:hover {
border-color: var(--accent);
box-shadow: 0 0 12px var(--accent-soft);
}
.viewer-overlay {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, .6);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.viewer-panel {
background: var(--bg-surface);
border: 1px solid var(--border-strong);
border-radius: var(--r-lg);
width: min(1100px, 100%);
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 12px 48px rgba(0, 0, 0, .5);
}
.viewer-head {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.viewer-title {
flex: 1;
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.viewer-close {
background: transparent;
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
width: 30px;
height: 30px;
border-radius: 50%;
cursor: pointer;
font-size: 16px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--dur) var(--ease);
}
.viewer-close:hover {
background: var(--danger);
border-color: var(--danger);
color: var(--bg-base);
}
.viewer-tabs {
display: flex;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
overflow-x: auto;
}
.vt-tab {
background: transparent;
border: 1px solid transparent;
border-radius: var(--r-sm);
color: var(--text-secondary);
font-size: 13px;
padding: 5px 12px;
cursor: pointer;
white-space: nowrap;
}
.vt-tab:hover {
background: var(--bg-elevated);
color: var(--text-primary);
}
.vt-tab.active {
background: var(--bg-elevated);
color: var(--accent);
border-color: var(--border-strong);
font-weight: 700;
}
.viewer-body {
flex: 1;
overflow: auto;
padding: 12px;
background: #fff;
color: #111;
}
.viewer-body table {
border-collapse: collapse;
font-size: 12px;
}
.viewer-body td,
.viewer-body th {
border: 1px solid #ccc;
padding: 3px 8px;
white-space: nowrap;
}
File diff suppressed because it is too large Load Diff
+13 -6
View File
@@ -98,28 +98,35 @@ class SessionStore:
" role TEXT NOT NULL,"
" content TEXT NOT NULL,"
" action TEXT,"
" attachment TEXT,"
" created_at TEXT NOT NULL)"
)
# 旧库迁移:补充 attachment 列(消息携带的附件引用,JSON 字符串)
_cols = {r["name"] for r in c.execute("PRAGMA table_info(chat_messages)")}
if "attachment" not in _cols:
c.execute("ALTER TABLE chat_messages ADD COLUMN attachment TEXT")
c.execute(
"CREATE INDEX IF NOT EXISTS idx_messages_session ON chat_messages(session_id)"
)
# ---------- 聊天消息 ----------
def add_message(self, session_id: str, role: str, content: str, action: str | None = None) -> dict:
msg = {"role": role, "content": content, "action": action, "created_at": _now()}
def add_message(self, session_id: str, role: str, content: str, action: str | None = None,
attachment: str | None = None) -> dict:
msg = {"role": role, "content": content, "action": action, "attachment": attachment,
"created_at": _now()}
with self._conn() as c:
c.execute(
"INSERT INTO chat_messages (session_id, role, content, action, created_at)"
" VALUES (?, ?, ?, ?, ?)",
(session_id, role, content, action, msg["created_at"]),
"INSERT INTO chat_messages (session_id, role, content, action, attachment, created_at)"
" VALUES (?, ?, ?, ?, ?, ?)",
(session_id, role, content, action, attachment, msg["created_at"]),
)
return msg
def list_messages(self, session_id: str) -> list[dict]:
with self._conn() as c:
rows = c.execute(
"SELECT role, content, action, created_at FROM chat_messages"
"SELECT role, content, action, attachment, created_at FROM chat_messages"
" WHERE session_id = ? ORDER BY id",
(session_id,),
).fetchall()
+26
View File
@@ -408,3 +408,29 @@ def test_root_version_matches_disk(client):
names = ("chat.html", "chat.css", "chat.js", "favicon.svg")
expect = str(max(int((static_dir / n).stat().st_mtime_ns) for n in names if (static_dir / n).exists()))
assert m.group(1) == expect
def test_chat_message_with_attachment_persisted(client):
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
sample = _SAMPLE / "requirements_newdev.xlsx"
up = client.post(f"/api/sessions/{sid}/files", data={"file_type": "requirements"},
files={"file": ("req.xlsx", sample.read_bytes())})
assert up.status_code == 200
att = {"file_type": "requirements", "name": "req.xlsx"}
m = client.post(f"/api/chat/{sid}/messages", json={"content": "你好", "attachment": att})
assert m.status_code == 200
hist = client.get(f"/api/chat/{sid}/messages").json()
assert any((x.get("attachment") or "").find("requirements") >= 0 for x in hist)
def test_get_session_file(client):
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
sample = _SAMPLE / "requirements_newdev.xlsx"
client.post(f"/api/sessions/{sid}/files", data={"file_type": "requirements"},
files={"file": ("req.xlsx", sample.read_bytes())})
r = client.get(f"/api/sessions/{sid}/files/requirements")
assert r.status_code == 200
assert len(r.content) > 0
# 未上传的类型 → 404
assert client.get(f"/api/sessions/{sid}/files/template").status_code == 404
# 非法类型 → 404
assert client.get(f"/api/sessions/{sid}/files/nope").status_code == 404
+26
View File
@@ -243,3 +243,29 @@ def test_delete_session_cascades_messages(store):
ok = store.delete_session(rec.session_id)
assert ok is True
assert store.list_messages(rec.session_id) == []
def test_add_message_with_attachment(store):
s = store.create_session("u1")
att = '{"file_type": "requirements", "name": "req.xlsx"}'
store.add_message(s.session_id, "user", "你好", attachment=att)
msgs = store.list_messages(s.session_id)
assert msgs[-1]["attachment"] == att
# 普通消息 attachment 为 None
store.add_message(s.session_id, "assistant", "收到")
assert store.list_messages(s.session_id)[-1]["attachment"] is None
def test_chat_messages_attachment_migration(tmp_path):
import sqlite3
db = str(tmp_path / "old.db")
conn = sqlite3.connect(db)
conn.execute(
"CREATE TABLE chat_messages ("
" id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL,"
" content TEXT NOT NULL, action TEXT, created_at TEXT NOT NULL)"
)
conn.commit(); conn.close()
st = SessionStore(db_path=db) # 初始化应迁移补列
s = st.create_session("u1")
st.add_message(s.session_id, "user", "x", attachment="{}")
assert st.list_messages(s.session_id)[0]["attachment"] == "{}"