diff --git a/app/server/app.py b/app/server/app.py index 8a12d44..48ad542 100644 --- a/app/server/app.py +++ b/app/server/app.py @@ -5,9 +5,11 @@ POST /datasets multipart:files(一个或多个 CSV,可含 *.dict.csv 字典)、name GET /datasets 列表 GET /datasets/{id} 详情(含文件与每张表的列) + GET /datasets/{id}/preview?rows=20 每张表前 N 行(1–100),单元格一律字符串或 null(阶段 5 F1) DELETE /datasets/{id} POST /sessions {"dataset_id", "agent": "workflow"|"react", "title"?} GET /sessions 列表(按更新时间倒序) + PATCH /sessions/{id} {"title"} 改名(阶段 5 F1) GET /sessions/{id} 会话 + 全部消息(每条助手消息带事件与结果)——"历史可查" DELETE /sessions/{id} POST /sessions/{id}/messages {"content"} → text/event-stream,逐条转发 analyze_stream 事件 @@ -15,7 +17,7 @@ PUT /messages/{id}/feedback {"rating": "up"|"down"|null, "note"?}(只限已结束的助手消息) GET / 、/assets/* 前端(ui/web 构建产物)。不要 token——页面本身不含机密,token 由 URL fragment 交给前端;index.html 带 CSP(阶段 5 F0) - GET /settings PUT /settings 非机密设置:model、base_url + GET /settings PUT /settings 非机密设置:model、base_url、timeout(单次请求超时,秒) GET /settings/api-key {"configured", "source"}——从不返回 Key 本身 PUT /settings/api-key {"api_key"} → 存进系统钥匙串 DELETE /settings/api-key @@ -45,7 +47,10 @@ MAX_FILE_BYTES = 1024 * 1024 * 1024 # 单个文件 1 GB MAX_FILES = 50 -SETTING_KEYS = ("model", "base_url") +SETTING_KEYS = ("model", "base_url", "timeout") +TIMEOUT_RANGE = (5.0, 600.0) +PREVIEW_MAX_ROWS = 100 +PREVIEW_CELL_MAX = 200 # 单元格超长截断(按文本渲染,T9) CHART_NAME = re.compile(r"^chart_[0-9_]+_[0-9a-f]{6}\.png$") FEEDBACK_NOTE_MAX = 1000 # 去掉控制字符(保留 \t \n)和双向文本控制符:note 会原样回显到前端(THREAT_MODEL T9) @@ -70,13 +75,18 @@ class SessionIn(BaseModel): title: str = "" +class SessionPatch(BaseModel): + title: str = Field(max_length=200) + + class MessageIn(BaseModel): content: str = Field(min_length=1, max_length=4000) class SettingsIn(BaseModel): - model: Optional[str] = None - base_url: Optional[str] = None + model: Optional[str] = Field(None, max_length=200) + base_url: Optional[str] = Field(None, max_length=500) + timeout: Optional[float] = Field(None, ge=TIMEOUT_RANGE[0], le=TIMEOUT_RANGE[1]) class FeedbackIn(BaseModel): @@ -217,7 +227,24 @@ def turn_history(messages): def llm_config(): with Session() as db: st = {r.key: r.value for r in db.scalars(select(Setting))} - return LLMConfig.from_env(**{k: v for k, v in st.items() if k in SETTING_KEYS and v}) + over = {k: v for k, v in st.items() if k in SETTING_KEYS and v} + if "timeout" in over: + try: + over["timeout"] = float(over["timeout"]) + except ValueError: + over.pop("timeout") # 库里的坏值不让分析整个起不来,退回环境变量 / 默认 + return LLMConfig.from_env(**over) + + def preview_cell(v): + """预览单元格:缺失 → None;其余转字符串并截断。前端按文本渲染(THREAT_MODEL T9)。""" + import pandas as pd + try: + if pd.isna(v): + return None + except (TypeError, ValueError): # 列表之类的非标量 + pass + s = str(v) + return s if len(s) <= PREVIEW_CELL_MAX else s[:PREVIEW_CELL_MAX] + "…" # ------------------------------------------------------------------ 路由 @app.get("/health") @@ -269,6 +296,33 @@ def list_datasets(db=Depends(db_dep)): def get_dataset(dataset_id: str, db=Depends(db_dep)): return dataset_out(get_or_404(db, Dataset, dataset_id), detail=True) + @app.get("/datasets/{dataset_id}/preview", dependencies=[Depends(auth)]) + def preview_dataset(dataset_id: str, rows: int = 20, db=Depends(db_dep)): + """每张表前 rows 行。读法与分析时一致(同一个编码探测和 read_csv),用户看到的就是模型拿到的。""" + from app.tools.csv_io import read_csv_with_encoding + from app.tools.dataset import is_dict_file + if not 1 <= rows <= PREVIEW_MAX_ROWS: + raise HTTPException(400, f"rows 需在 1–{PREVIEW_MAX_ROWS}") + ds = get_or_404(db, Dataset, dataset_id) + tables, dicts = [], [] + for f in sorted(ds.files, key=lambda f: f.filename.lower()): + if is_dict_file(f.filename): + dicts.append(f.filename) + continue + name = os.path.splitext(f.filename)[0] + try: + df, enc = read_csv_with_encoding(f.path) + except Exception as e: # noqa: BLE001 —— 一张表读坏不影响其他表的预览 + tables.append({"name": name, "filename": f.filename, "error": f"{type(e).__name__}: {e}"}) + continue + head = df.head(rows) + tables.append({"name": name, "filename": f.filename, "encoding": enc, + "total_rows": int(len(df)), "total_columns": int(len(df.columns)), + "columns": [preview_cell(c) or "" for c in df.columns], + "dtypes": [str(t) for t in df.dtypes], + "rows": [[preview_cell(v) for v in r] for r in head.itertuples(index=False, name=None)]}) + return {"dataset_id": ds.id, "rows": rows, "tables": tables, "dicts": dicts} + @app.delete("/datasets/{dataset_id}", dependencies=[Depends(auth)]) def delete_dataset(dataset_id: str, db=Depends(db_dep)): ds = get_or_404(db, Dataset, dataset_id) @@ -296,6 +350,13 @@ def get_session(session_id: str, db=Depends(db_dep)): s = get_or_404(db, ChatSession, session_id) return {**session_out(s), "messages": [message_out(m) for m in s.messages]} + @app.patch("/sessions/{session_id}", dependencies=[Depends(auth)]) + def rename_session(session_id: str, body: SessionPatch, db=Depends(db_dep)): + s = get_or_404(db, ChatSession, session_id) + s.title = _CONTROL_CHARS.sub("", body.title).strip()[:120] + db.commit() + return session_out(s) + @app.delete("/sessions/{session_id}", dependencies=[Depends(auth)]) def delete_session(session_id: str, db=Depends(db_dep)): db.delete(get_or_404(db, ChatSession, session_id)) @@ -380,16 +441,18 @@ def get_chart(name: str): def get_settings(db=Depends(db_dep)): st = {r.key: r.value for r in db.scalars(select(Setting))} cfg = llm_config() - return {"model": st.get("model") or cfg.model, "base_url": st.get("base_url") or cfg.base_url} + return {"model": st.get("model") or cfg.model, "base_url": st.get("base_url") or cfg.base_url, + "timeout": cfg.timeout} @app.put("/settings", dependencies=[Depends(auth)]) def put_settings(body: SettingsIn, db=Depends(db_dep)): for k, v in body.model_dump(exclude_none=True).items(): + v = str(v).strip() # 空串 = 清除,回到环境变量 / 默认 row = db.get(Setting, k) if row is None: - db.add(Setting(key=k, value=v.strip())) + db.add(Setting(key=k, value=v)) else: - row.value = v.strip() + row.value = v db.commit() return get_settings(db) diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 73264f9..a445266 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -1155,7 +1155,7 @@ S 决定(10-14):跳过"当前 master 先重跑一次基线",直接改提 - 留痕:workflow 多表门槛由 [24] 预登记的 ≥9/10 收严到 10/10(`thresholds.json` 的 `m13.agents.workflow.multi.allowed_fail=[]`,[25] 实现、S 10-14 确认),依据 [28] 实测 10/10。[24] 正文不改,以本条为准。 - 未做(等 S 定案):A1 的"名单外错题自动复跑 2 次,2 次都对记波动、有 1 次错记回归"规则。现行 `m13` 的 `allowed_fail` 是逐题登记的,`m09`/`m10` 这类会翻的题会让参照基线自己报"有回归"。S 不同意放宽名单(会把 B70 的效果盖掉),改走自动复跑 —— 规则定下来后随下一个闸门 PR 落地。S 已定(10-15):该规则**取代** [28] 的 ≥4/5;复跑报告存原始 answer / result_text 并人工标注,不只存布尔(B68 前车之鉴);超时、网关错误归为无效复跑,最多补跑 1 次,不计答错;verdict 注明假阴性率 ≤25%(真错误率 p=0.5 时)。 -## [31] 2026-10-15 · 阶段 5 F0:前端骨架、同源托管、token 流转、CSP、feedback 表、CI · commit <待提交> +## [31] 2026-10-15 · 阶段 5 F0:前端骨架、同源托管、token 流转、CSP、feedback 表、CI · commit 4beb924(PR #32) 当前阶段:**阶段 5 前端,F0 完成,下一步 F1**(数据集含预览、会话、设置)。方案:`docs/roadmap/phase5-frontend.md`(v2.1,随本条入库)。[30] 的 SHA 已回填 4497d2b。 @@ -1179,3 +1179,31 @@ S 决定(10-14):跳过"当前 master 先重跑一次基线",直接改提 验证:pytest 587 passed(3.12;[30] 后 578 + test_web 9);`tests/test_web.py` 9 条;vitest 12;Playwright 5(沙箱本地跑通)。 未做 / 下一步:F1 的 `GET /datasets/{id}/preview`;F2 的 cancel(按 v2.1:CAS + finally CAS + `done_seen` + 分流 cancelled)和 `gen_ui_types.py`。A1 自动复跑规则仍按计划在下一次闸门 PR 落地。 + +## [32] 2026-10-15 · 阶段 5 F1:数据集预览、会话、设置 · commit <待提交> + +当前阶段:**阶段 5 前端,F1 完成,下一步 F2**(SSE、事件渲染、重放合成 done、cancel、👍👎、gen_ui_types)。[31] 的 SHA 已回填 4beb924(PR #32,CI tests 38694679388 含 web job、sidecar 38694679138 均绿)。 + +后端(`app/server/app.py`): +1. **`GET /datasets/{id}/preview?rows=20`**:rows ∈ 1–100(越界 400)。每个非字典文件一张表,按文件名排序;用与分析时**同一个** `read_csv_with_encoding`(同一编码探测、同一 read_csv),即"用户看到的就是模型拿到的"。返回 `{name, filename, encoding, total_rows, total_columns, columns, dtypes, rows}`;单元格缺失 → `null`,其余 `str()` 后超 200 字截断加"…",不做 HTML 处理(前端按文本渲染,T9)。一张表读失败只在该表返回 `error`,不影响其他表;字典文件列在 `dicts`。 + - 已知代价:预览读整表再取 head(与详情接口一样),大文件会慢;量到再说(可加按 sha256 的缓存或 nrows 只读头部——后者会让编码探测和 dtype 与分析时不一致,不默认采用)。 +2. **`PATCH /sessions/{id}` `{title}`**:≤200 字(422),清控制字符与双向文本控制符后截到 120 字。 +3. **设置加 `timeout`**(单次请求超时,秒,5–600,越界 422):存 Setting 表,`llm_config()` 转 float;库里的坏值退回环境变量 / 默认 60,不让分析整个起不来。`GET /settings` 返回生效值。`SettingsIn` 的 model / base_url 加长度上限。PUT 里空串 = 清除,回到环境变量 / 默认(原行为)。 + +前端(`ui/web/src`): +- `types.ts`(接口形状;F2 起事件类型改由 gen_ui_types 生成)、`client.ts`(各接口薄封装,全部经 `Api`)。 +- `views/Shell.tsx`:左栏 上传(多选 CSV,可带 *.dict.csv)、数据集列表、会话列表、设置;没设 Key 时首屏直接进设置页,其他页顶部提示。 +- `views/DatasetView.tsx` + `PreviewTable.tsx`:摘要、文件与大小、字典文件、多表切换、前 20 行预览(表头带 dtype;缺失值显示灰色"空",与字符串 "null" 区分);选工作流 / ReAct 新建会话;删除。 +- `views/SessionView.tsx`:标题就地改名(回车 / 失焦提交)、删除、历史消息纯文本 + 状态徽标(失败 / 已取消 / 分析中)。事件流、图表、提问、停止、👍👎 是 F2 / F3。 +- `views/SettingsView.tsx`:Key 只写不读(password 输入,保存后立即清空;显示来源 环境变量 / 钥匙串;env 优先时给说明;只有钥匙串来源才给删除);model、base_url、超时(前端先拦 5–600,后端再拦)。 +- 删除一律"点两次确认"(不用 window.confirm:WebView 与 Playwright 对原生对话框处理不一致)。 +- 样式 `styles.css`(基础样式;4 套主题在 F3)。 + +测试: +- `tests/test_web_f1.py` 7 条:预览与 `read_csv` 逐格一致;rows 边界 / 401 / 404;GBK + 缺失 + 注入串原样 + 超长截断;多表 + 字典 + 一张空文件只坏一张;改名清控制字符 / 422 / 404 / 401;超时设置与越界、只改 model 不动 timeout;库里坏值回退 60。 +- vitest 17(+5):`views/views.test.tsx` 预览与消息里的注入串只作文本(无 img、`window.__pwned` 未定义)、"空"与 "null" 区分、改名走 PATCH 并刷新左栏、无 Key 进设置页且保存后输入清空、页面文本不含 Key、超时越界不发请求、删除要点两次;`fakeBackend.ts` 测试用路由。 +- Playwright 6(+1)`e2e/f1.spec.ts`:在后端托管的 dist(带 CSP)上走 设置超时 → 上传含注入串的 CSV + 字典 → 预览按文本渲染、tbody 无 img → 建 ReAct 会话 → 改名 → 刷新后会话名与超时都在;全程无 CSP 违规。 + +验证:pytest 594 passed(3.12;587 + test_web_f1 7);vitest 17;Playwright 6(沙箱本地);lint、build(check-dist 0 内联)通过。 + +未做 / 下一步:F2。A1 自动复跑规则仍按计划在下一次闸门 PR 落地。 diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 21777c8..6a77958 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -64,7 +64,7 @@ keyring 对逃逸不是完全没用:逃逸后偷 Key 的路径从"读任意文 | 自然语言结论 | 同上;若结论支持 Markdown,Markdown 渲染器关闭原始 HTML | 生效(第 5 步实现) | 生效 | | 日志、导出 | 日志是纯文本文件;导出 HTML 报告(如有)时同样在写入处转义 | 生效(第 5 步实现) | 生效 | | 👍👎 的 note(用户输入,阶段 5 F0) | 后端清控制字符和双向文本控制符、限 1000 字;前端按纯文本渲染 | 生效 | 生效 | -| 数据集预览单元格(F1) | 后端一律转字符串原样返回;前端按纯文本渲染 | F1 实现 | 生效 | +| 数据集预览单元格与表头(F1) | 后端一律转字符串原样返回(超 200 字截断),缺失为 null;前端按纯文本渲染 | tests/test_web_f1.py、views.test.tsx(注入串不成元素)、e2e/f1.spec.ts(托管 dist 上 tbody 无 img、无 CSP 违规) | 生效 | | Markdown 里的 `img` / 链接 | 链接 `rel="noreferrer noopener"`;`javascript:` 由渲染器过滤(测试钉版本);外链图片由 CSP `img-src 'self' data: blob:` 拦下 | F2 实现 | 生效 | **白名单只校验 builder 的输出(以及 v2 的 patch),最终渲染产物永不回验。** 前端在校验之后才加 `tooltip.renderMode = 'richText'`,而 `renderMode` 同时在 `BANNED_KEYS` 里,所以最终交给 ECharts 的 option 必然不过白名单。这是有意的:`BANNED_KEYS` 禁的是"由后端 / 模型决定这个键",不是"渲染时不许出现"。将来任何把前端 option 回灌后端的流程("保存我的风格"、服务端重绘导出 PNG 等)必须: diff --git a/tests/test_web_f1.py b/tests/test_web_f1.py new file mode 100644 index 0000000..e8ad5c9 --- /dev/null +++ b/tests/test_web_f1.py @@ -0,0 +1,103 @@ +"""阶段 5 F1:数据集预览、会话改名、设置里的超时。全部用假模型 / 不调模型。""" +import pytest +from fastapi.testclient import TestClient + +from app.server.app import PREVIEW_CELL_MAX, PREVIEW_MAX_ROWS, create_app +from tests.test_server import H, TOKEN, upload + + +@pytest.fixture +def c(tmp_path): + return TestClient(create_app(str(tmp_path / "home"), TOKEN)) + + +def write(path, text, encoding="utf-8"): + path.write_bytes(text.encode(encoding)) + return str(path) + + +# ---------------------------------------------------------------- 预览 +def test_preview_single_table_matches_analysis_read(c, sample_path): + import pandas as pd + from app.tools.csv_io import read_csv + ds = upload(c, sample_path) + r = c.get(f"/datasets/{ds['id']}/preview", headers=H) + assert r.status_code == 200 + body = r.json() + assert body["rows"] == 20 and body["dicts"] == [] + [t] = body["tables"] + df = read_csv(sample_path) + assert t["columns"] == [str(x) for x in df.columns] + assert t["total_rows"] == len(df) and t["total_columns"] == len(df.columns) + assert len(t["rows"]) == min(20, len(df)) + # 与分析时读到的逐格一致(缺失 → None,其余字符串) + exp = [[None if pd.isna(v) else str(v) for v in row] for row in df.head(20).itertuples(index=False, name=None)] + assert t["rows"] == exp + + +def test_preview_rows_bounds_and_auth(c, sample_path): + ds = upload(c, sample_path) + url = f"/datasets/{ds['id']}/preview" + assert len(c.get(url + "?rows=3", headers=H).json()["tables"][0]["rows"]) == 3 + for bad in (0, -1, PREVIEW_MAX_ROWS + 1): + assert c.get(f"{url}?rows={bad}", headers=H).status_code == 400 + assert c.get(url).status_code == 401 + assert c.get("/datasets/nope/preview", headers=H).status_code == 404 + + +def test_preview_cells_missing_long_and_gbk(c, tmp_path): + long = "x" * (PREVIEW_CELL_MAX + 50) + p = write(tmp_path / "t.csv", f"名称,数量,备注\n苹果,,\n梨,3,{long}\n", "gbk") + t = c.get(f"/datasets/{upload(c, p)['id']}/preview", headers=H).json()["tables"][0] + assert t["encoding"].lower() in ("gbk", "gb18030", "gb2312") + assert t["columns"] == ["名称", "数量", "备注"] + assert t["rows"][0] == ["苹果", None, ""] # 原样字符串,前端按文本渲染 + assert t["rows"][1][2] == "x" * PREVIEW_CELL_MAX + "…" + assert t["dtypes"][1] == "float64" + + +def test_preview_multi_table_dict_and_one_broken(c, tmp_path): + a = write(tmp_path / "orders.csv", "id,amt\n1,2\n") + b = write(tmp_path / "users.csv", "") # 空文件:读失败,只影响这一张 + d = write(tmp_path / "orders.dict.csv", "列,含义\nid,编号\n") + body = c.get(f"/datasets/{upload(c, a, b, d)['id']}/preview", headers=H).json() + assert body["dicts"] == ["orders.dict.csv"] + orders, users = body["tables"] + assert orders["name"] == "orders" and orders["rows"] == [["1", "2"]] + assert users["name"] == "users" and "error" in users and "rows" not in users + + +# ---------------------------------------------------------------- 会话改名 +def test_session_rename(c, sample_path): + ds = upload(c, sample_path) + s = c.post("/sessions", json={"dataset_id": ds["id"]}, headers=H).json() + r = c.patch(f"/sessions/{s['id']}", json={"title": " 月度\u202e销量\x07 "}, headers=H) + assert r.status_code == 200 and r.json()["title"] == "月度销量" + assert c.get(f"/sessions/{s['id']}", headers=H).json()["title"] == "月度销量" + assert c.patch(f"/sessions/{s['id']}", json={"title": "x" * 201}, headers=H).status_code == 422 + assert c.patch("/sessions/nope", json={"title": "a"}, headers=H).status_code == 404 + assert c.patch(f"/sessions/{s['id']}", json={"title": "a"}).status_code == 401 + + +# ---------------------------------------------------------------- 设置:超时 +def test_settings_timeout(c, monkeypatch): + monkeypatch.delenv("DATACHAT_LLM_TIMEOUT", raising=False) + assert c.get("/settings", headers=H).json()["timeout"] == 60.0 + assert c.put("/settings", json={"timeout": 120}, headers=H).json()["timeout"] == 120.0 + for bad in (1, 601, "abc"): + assert c.put("/settings", json={"timeout": bad}, headers=H).status_code == 422 + assert c.get("/settings", headers=H).json()["timeout"] == 120.0 + # 只改 model 不动 timeout + r = c.put("/settings", json={"model": "m"}, headers=H).json() + assert r["timeout"] == 120.0 and r["model"] == "m" + + +def test_settings_bad_stored_timeout_falls_back(tmp_path, monkeypatch): + from app.server.db import Setting, make_sessionmaker + monkeypatch.delenv("DATACHAT_LLM_TIMEOUT", raising=False) + home = str(tmp_path / "home") + c = TestClient(create_app(home, TOKEN)) + with make_sessionmaker(f"{home}/datachat.db")() as db: + db.add(Setting(key="timeout", value="oops")) + db.commit() + assert c.get("/settings", headers=H).json()["timeout"] == 60.0 diff --git a/ui/web/e2e/f1.spec.ts b/ui/web/e2e/f1.spec.ts new file mode 100644 index 0000000..7290df0 --- /dev/null +++ b/ui/web/e2e/f1.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from "@playwright/test"; +import { E2E_TOKEN } from "../playwright.config"; + +// F1 验收路径的前半段(不调模型):设置 → 上传 → 看预览 → 建会话 → 改名 → 刷新后还在。跑在后端托管的 dist(带 CSP)上。 +const INJECT = ''; +const CSV = `名称,备注,数量\n苹果,"${INJECT.replace(/"/g, '""')}",\n梨,普通,3\n`; + +test("设置超时 → 上传 → 预览按文本渲染 → 建会话改名 → 刷新仍在", async ({ page }) => { + const violations: string[] = []; + await page.addInitScript(() => { + const w = window as unknown as { __csp: string[] }; + w.__csp = []; + document.addEventListener("securitypolicyviolation", (e) => w.__csp.push(e.violatedDirective)); + }); + await page.goto(`/#token=${E2E_TOKEN}`); + + // 没有 Key → 先进设置页 + await expect(page.getByTestId("settings-view")).toBeVisible(); + await expect(page.getByTestId("key-status")).toHaveText("未设置"); + await page.getByLabel("单次请求超时(秒)").fill("90"); + await page.getByRole("button", { name: "保存设置" }).click(); + await expect(page.getByText("设置已保存。")).toBeVisible(); + + // 上传(含注入串的 CSV + 字典) + await page.getByLabel("选择 CSV 文件").setInputFiles([ + { name: "fruit.csv", mimeType: "text/csv", buffer: Buffer.from(CSV, "utf-8") }, + { name: "fruit.dict.csv", mimeType: "text/csv", buffer: Buffer.from("列,含义\n数量,箱数\n", "utf-8") }, + ]); + await page.getByPlaceholder("数据集名称(可选)").fill("水果"); + await page.getByRole("button", { name: "上传" }).click(); + + const view = page.getByTestId("dataset-view"); + await expect(view.getByRole("heading", { name: "水果" })).toBeVisible(); + await expect(view.getByText("共 2 行 × 3 列", { exact: false })).toBeVisible(); + await expect(view.getByText(INJECT)).toBeVisible(); + await expect(view.locator("tbody img")).toHaveCount(0); + await expect(view.locator("td .na")).toHaveText("空"); + await expect(view.getByText("字典文件:fruit.dict.csv")).toBeVisible(); + + // 建会话 → 改名 + await view.getByLabel("分析方式").selectOption("react"); + await view.getByRole("button", { name: "新建会话" }).click(); + const sv = page.getByTestId("session-view"); + await expect(sv.getByText("数据集:水果 · ReAct")).toBeVisible(); + await sv.getByLabel("会话标题").fill("看看水果"); + await sv.getByLabel("会话标题").press("Enter"); + await expect(page.getByRole("navigation", { name: "会话" }).getByRole("button", { name: /看看水果/ })).toBeVisible(); + + // 刷新:token 走 sessionStorage;数据集、会话名、超时都在 + await page.reload(); + await page.getByRole("navigation", { name: "会话" }).getByRole("button", { name: /看看水果/ }).click(); + await expect(page.getByTestId("session-view").getByLabel("会话标题")).toHaveValue("看看水果"); + await page.getByRole("button", { name: /^设置/ }).click(); + await expect(page.getByLabel("单次请求超时(秒)")).toHaveValue("90"); + + violations.push(...(await page.evaluate(() => (window as unknown as { __csp: string[] }).__csp))); + expect(violations).toEqual([]); + expect(await page.evaluate(() => (window as unknown as { __pwned?: number }).__pwned)).toBeUndefined(); +}); diff --git a/ui/web/src/App.test.tsx b/ui/web/src/App.test.tsx index 21cf414..bf43e32 100644 --- a/ui/web/src/App.test.tsx +++ b/ui/web/src/App.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from "@testing-library/react"; import App from "./App"; import { Api } from "./api"; +import { fakeBackend } from "./fakeBackend"; const res = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); @@ -16,6 +17,11 @@ test("没有 token → 会话已失效页", async () => { }); test("token 有效 → 已连接", async () => { - render( res({ configured: true, source: "keyring" }))} />); + const { fetchImpl } = fakeBackend({ + "GET /settings/api-key": { configured: true, source: "keyring" }, + "GET /datasets": [], + "GET /sessions": [], + }); + render(); expect(await screen.findByTestId("ready")).toBeTruthy(); }); diff --git a/ui/web/src/App.tsx b/ui/web/src/App.tsx index d0fb2c8..5259dc2 100644 --- a/ui/web/src/App.tsx +++ b/ui/web/src/App.tsx @@ -1,7 +1,10 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Api } from "./api"; +import { client } from "./client"; +import type { KeyStatus } from "./types"; +import { Shell } from "./views/Shell"; -type State = { kind: "checking" } | { kind: "ready"; keyConfigured: boolean } | { kind: "expired" } | { kind: "error"; message: string }; +type State = { kind: "checking" } | { kind: "ready"; key: KeyStatus } | { kind: "expired" } | { kind: "error"; message: string }; export function ExpiredPage() { return ( @@ -17,19 +20,19 @@ export function ExpiredPage() { export default function App({ api }: { api: Api }) { const [state, setState] = useState({ kind: "checking" }); + const c = useMemo(() => client(api), [api]); useEffect(() => { const off = api.onUnauthorized(() => setState({ kind: "expired" })); - api - .json<{ configured: boolean }>("/settings/api-key") - .then((r) => setState({ kind: "ready", keyConfigured: r.configured })) + c.keyStatus() + .then((key) => setState({ kind: "ready", key })) .catch((e: unknown) => { if (e instanceof Error && e.name !== "Unauthorized") setState({ kind: "error", message: e.message }); }); return () => { off(); }; - }, [api]); + }, [api, c]); switch (state.kind) { case "expired": @@ -39,11 +42,6 @@ export default function App({ api }: { api: Api }) { case "error": return
连接后端失败:{state.message}
; case "ready": - return ( -
-

datachat

-

已连接后端。{state.keyConfigured ? "" : "还没有设置 API Key。"}

-
- ); + return ; } } diff --git a/ui/web/src/client.ts b/ui/web/src/client.ts new file mode 100644 index 0000000..dcd14b4 --- /dev/null +++ b/ui/web/src/client.ts @@ -0,0 +1,53 @@ +// 各接口的薄封装。全部经过 Api(统一 Bearer 与 401 处理)。 +import type { Api } from "./api"; +import type { Agent, Dataset, DatasetDetail, KeyStatus, Preview, Session, SessionDetail, Settings } from "./types"; + +const jsonBody = (method: string, body: unknown): RequestInit => ({ + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), +}); + +export const PREVIEW_ROWS = 20; + +export function client(api: Api) { + return { + datasets: () => api.json("/datasets"), + dataset: (id: string) => api.json(`/datasets/${encodeURIComponent(id)}`), + preview: (id: string, rows = PREVIEW_ROWS) => + api.json(`/datasets/${encodeURIComponent(id)}/preview?rows=${rows}`), + upload: (files: File[], name: string) => { + const fd = new FormData(); + files.forEach((f) => fd.append("files", f, f.name)); + fd.append("name", name); + return api.json("/datasets", { method: "POST", body: fd }); + }, + deleteDataset: (id: string) => api.json(`/datasets/${encodeURIComponent(id)}`, { method: "DELETE" }), + + sessions: () => api.json("/sessions"), + session: (id: string) => api.json(`/sessions/${encodeURIComponent(id)}`), + createSession: (dataset_id: string, agent: Agent) => + api.json("/sessions", jsonBody("POST", { dataset_id, agent })), + renameSession: (id: string, title: string) => + api.json(`/sessions/${encodeURIComponent(id)}`, jsonBody("PATCH", { title })), + deleteSession: (id: string) => api.json(`/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }), + + settings: () => api.json("/settings"), + saveSettings: (s: Partial) => api.json("/settings", jsonBody("PUT", s)), + keyStatus: () => api.json("/settings/api-key"), + setKey: (api_key: string) => api.json("/settings/api-key", jsonBody("PUT", { api_key })), + deleteKey: () => api.json("/settings/api-key", { method: "DELETE" }), + }; +} +export type Client = ReturnType; + +export function errText(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +export function formatSize(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`; + return `${(n / 1024 ** 3).toFixed(2)} GB`; +} diff --git a/ui/web/src/fakeBackend.ts b/ui/web/src/fakeBackend.ts new file mode 100644 index 0000000..1b4834f --- /dev/null +++ b/ui/web/src/fakeBackend.ts @@ -0,0 +1,23 @@ +// 测试用的假后端:按 "METHOD path" 路由,记录每次请求 +export type Call = { method: string; path: string; body: unknown }; +type Handler = (body: unknown, path: string) => unknown; + +export function fakeBackend(routes: Record) { + const calls: Call[] = []; + const fetchImpl = async (input: RequestInfo | URL, init: RequestInit = {}) => { + const url = new URL(String(input), "http://x"); + const method = (init.method ?? "GET").toUpperCase(); + const raw = init.body; + const body = typeof raw === "string" ? JSON.parse(raw) : raw; + calls.push({ method, path: url.pathname + url.search, body }); + const key = Object.keys(routes).find((k) => { + const [m, p] = k.split(" "); + return m === method && (p === url.pathname || p === url.pathname + url.search); + }); + if (!key) return new Response(JSON.stringify({ detail: `no route ${method} ${url.pathname}` }), { status: 404 }); + const h = routes[key]; + const out = typeof h === "function" ? (h as Handler)(body, url.pathname) : h; + return new Response(JSON.stringify(out), { status: 200, headers: { "Content-Type": "application/json" } }); + }; + return { fetchImpl: fetchImpl as typeof fetch, calls }; +} diff --git a/ui/web/src/main.tsx b/ui/web/src/main.tsx index 9cc7468..144eba2 100644 --- a/ui/web/src/main.tsx +++ b/ui/web/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import App from "./App"; import { Api } from "./api"; import { takeToken } from "./token"; +import "./styles.css"; // 必须在任何 await / 渲染之前:读出 fragment 里的 token 并同步抹掉地址栏 const api = new Api(takeToken()); diff --git a/ui/web/src/styles.css b/ui/web/src/styles.css new file mode 100644 index 0000000..ac7a45f --- /dev/null +++ b/ui/web/src/styles.css @@ -0,0 +1,79 @@ +/* F1 的基础样式;主题(4 套)在 F3 做 */ +:root { + --bg: #fafafa; + --panel: #fff; + --fg: #1f2328; + --muted: #6a737d; + --line: #e1e4e8; + --accent: #2f6feb; + --warn-bg: #fff8e1; + --warn: #8a5a00; + --danger: #c62828; + font-family: system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; + font-size: 14px; + color: var(--fg); + background: var(--bg); +} +* { box-sizing: border-box; } +body { margin: 0; } +button, input, select { font: inherit; } +button { + border: 1px solid var(--line); background: var(--panel); border-radius: 6px; padding: 4px 10px; cursor: pointer; +} +button:disabled { opacity: 0.5; cursor: default; } +button.danger { color: var(--danger); } +button.danger.armed { background: var(--danger); color: #fff; border-color: var(--danger); } +button.link { border: none; background: none; color: var(--accent); padding: 0; } +input, select { border: 1px solid var(--line); border-radius: 6px; padding: 4px 8px; background: var(--panel); } + +.shell { display: grid; grid-template-columns: 260px 1fr; height: 100vh; } +.sidebar { border-right: 1px solid var(--line); background: var(--panel); padding: 12px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; } +.sidebar h1 { font-size: 18px; margin: 0; } +.sidebar h2 { font-size: 12px; color: var(--muted); margin: 0 0 4px; font-weight: 600; } +.sidebar ul { list-style: none; margin: 0; padding: 0; } +.nav { display: block; width: 100%; text-align: left; border: none; background: none; padding: 5px 8px; border-radius: 6px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.nav:hover { background: #f0f2f5; } +.nav.active { background: #e7efff; } +.nav .small { display: block; font-size: 11px; } +.settings-link { margin-top: auto; } +.upload { display: flex; flex-direction: column; gap: 6px; } +.upload input[type="file"] { border: none; padding: 0; max-width: 100%; } + +.main { overflow: auto; padding: 16px 24px; } +.view { max-width: 1200px; } +.view-head { display: flex; align-items: center; gap: 12px; justify-content: space-between; } +.view-head h2 { margin: 8px 0; overflow-wrap: anywhere; } +.actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.title-input { font-size: 18px; font-weight: 600; border-color: transparent; flex: 1; min-width: 0; } +.title-input:hover, .title-input:focus { border-color: var(--line); } + +.muted { color: var(--muted); } +.warn { background: var(--warn-bg); color: var(--warn); padding: 6px 10px; border-radius: 6px; } +.ok { color: #1a7f37; } +.files { margin: 4px 0; padding-left: 18px; } +.tabs { display: flex; gap: 4px; margin: 8px 0; flex-wrap: wrap; } +.tabs [aria-selected="true"] { background: #e7efff; border-color: var(--accent); } + +.table-scroll { overflow: auto; max-height: 60vh; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); } +.preview table { border-collapse: collapse; font-size: 13px; } +.preview th, .preview td { border-bottom: 1px solid var(--line); padding: 4px 8px; text-align: left; white-space: nowrap; + max-width: 320px; overflow: hidden; text-overflow: ellipsis; } +.preview th { position: sticky; top: 0; background: #f6f8fa; } +.preview .dtype { display: block; font-weight: normal; font-size: 11px; color: var(--muted); } +.preview .rownum { color: var(--muted); text-align: right; } +.na { color: #b0b7c0; font-style: italic; } + +.messages { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 10px; } +.msg { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px; } +.msg.user { background: #f0f6ff; } +.msg .who { font-size: 12px; color: var(--muted); margin-bottom: 4px; } +.msg .text { white-space: pre-wrap; overflow-wrap: anywhere; } +.badge { margin-left: 6px; padding: 0 6px; border-radius: 10px; background: #eee; font-size: 11px; } +.badge.error { background: #fde7e9; color: var(--danger); } + +.settings form { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px 16px; margin-bottom: 16px; max-width: 560px; } +.settings h3 { margin: 0 0 8px; font-size: 15px; } +.settings label { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; } + +.expired { max-width: 560px; margin: 15vh auto; } diff --git a/ui/web/src/types.ts b/ui/web/src/types.ts new file mode 100644 index 0000000..cb7ab0a --- /dev/null +++ b/ui/web/src/types.ts @@ -0,0 +1,35 @@ +// 后端接口的形状(app/server/app.py)。F2 起事件类型改由 gen_ui_types 生成。 +export type FileInfo = { filename: string; size: number; sha256: string }; +export type Dataset = { id: string; name: string; created_at: string; files: FileInfo[] }; +export type DatasetDetail = Dataset & { tables?: Record; summary?: string; error?: string }; + +export type PreviewCell = string | null; +export type PreviewTable = + | { + name: string; + filename: string; + encoding: string; + total_rows: number; + total_columns: number; + columns: string[]; + dtypes: string[]; + rows: PreviewCell[][]; + error?: undefined; + } + | { name: string; filename: string; error: string }; +export type Preview = { dataset_id: string; rows: number; tables: PreviewTable[]; dicts: string[] }; + +export type Agent = "workflow" | "react"; +export type Session = { id: string; dataset_id: string; agent: Agent; title: string; created_at: string; updated_at: string }; +export type Message = { + id: string; + role: "user" | "assistant"; + content: string; + status: string; + created_at: string; + feedback?: { rating: "up" | "down" | null; note: string } | null; +}; +export type SessionDetail = Session & { messages: Message[] }; + +export type Settings = { model: string; base_url: string; timeout: number }; +export type KeyStatus = { configured: boolean; source: string | null }; diff --git a/ui/web/src/views/ConfirmButton.tsx b/ui/web/src/views/ConfirmButton.tsx new file mode 100644 index 0000000..199af43 --- /dev/null +++ b/ui/web/src/views/ConfirmButton.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; + +// 两次点击确认(不用 window.confirm:桌面 WebView 和 Playwright 对原生对话框的处理都不一致) +export function ConfirmButton({ label, confirmLabel, onConfirm }: { label: string; confirmLabel: string; onConfirm: () => void }) { + const [armed, setArmed] = useState(false); + useEffect(() => { + if (!armed) return; + const t = setTimeout(() => setArmed(false), 4000); + return () => clearTimeout(t); + }, [armed]); + return ( + + ); +} diff --git a/ui/web/src/views/DatasetView.tsx b/ui/web/src/views/DatasetView.tsx new file mode 100644 index 0000000..8c2865d --- /dev/null +++ b/ui/web/src/views/DatasetView.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from "react"; +import { type Client, errText, formatSize } from "../client"; +import type { Agent, DatasetDetail, Preview } from "../types"; +import { ConfirmButton } from "./ConfirmButton"; +import { PreviewTable } from "./PreviewTable"; + +type Props = { + c: Client; + id: string; + onDeleted: () => void; + onNewSession: (datasetId: string, agent: Agent) => void; +}; + +export function DatasetView({ c, id, onDeleted, onNewSession }: Props) { + const [detail, setDetail] = useState(null); + const [preview, setPreview] = useState(null); + const [tab, setTab] = useState(0); + const [agent, setAgent] = useState("workflow"); + const [error, setError] = useState(""); + + useEffect(() => { + // Shell 按 id 设了 key,换数据集会重新挂载,这里不用手动复位状态 + let live = true; + Promise.all([c.dataset(id), c.preview(id)]) + .then(([d, p]) => live && (setDetail(d), setPreview(p))) + .catch((e) => live && setError(errText(e))); + return () => { + live = false; + }; + }, [c, id]); + + if (error) return
加载数据集失败:{error}
; + if (!detail || !preview) return
加载中…
; + + const current = preview.tables[Math.min(tab, preview.tables.length - 1)]; + return ( +
+
+

{detail.name}

+
+ + + c.deleteDataset(id).then(onDeleted, (e) => setError(errText(e)))} + /> +
+
+

+ {detail.summary ?? ""} + {detail.error ? (读取出错:{detail.error}) : null} +

+
    + {detail.files.map((f) => ( +
  • + {f.filename} {formatSize(f.size)} +
  • + ))} +
+ {preview.dicts.length > 0 &&

字典文件:{preview.dicts.join("、")}

} + {preview.tables.length > 1 && ( +
+ {preview.tables.map((t, i) => ( + + ))} +
+ )} + {current && } +
+ ); +} diff --git a/ui/web/src/views/PreviewTable.tsx b/ui/web/src/views/PreviewTable.tsx new file mode 100644 index 0000000..96b5d20 --- /dev/null +++ b/ui/web/src/views/PreviewTable.tsx @@ -0,0 +1,45 @@ +import type { PreviewTable as T } from "../types"; + +// 单元格一律按文本渲染(THREAT_MODEL T9);缺失值显示成灰色的"空",和字符串 "null" 区分开 +export function PreviewTable({ table }: { table: T }) { + if (table.error !== undefined) { + return ( +

+ {table.filename} 读取失败:{table.error} +

+ ); + } + return ( +
+

+ {table.filename}:共 {table.total_rows} 行 × {table.total_columns} 列,编码 {table.encoding} + {table.rows.length < table.total_rows ? `,显示前 ${table.rows.length} 行` : ""} +

+
+ + + + + {table.columns.map((c, i) => ( + + ))} + + + + {table.rows.map((r, i) => ( + + + {r.map((v, j) => ( + + ))} + + ))} + +
# + {c} + {table.dtypes[i]} +
{i + 1}{v === null ? 空 : v}
+
+
+ ); +} diff --git a/ui/web/src/views/SessionView.tsx b/ui/web/src/views/SessionView.tsx new file mode 100644 index 0000000..98efe27 --- /dev/null +++ b/ui/web/src/views/SessionView.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import { type Client, errText } from "../client"; +import type { Session, SessionDetail } from "../types"; +import { ConfirmButton } from "./ConfirmButton"; + +const STATUS: Record = { ok: "", error: "失败", cancelled: "已取消", running: "分析中" }; +const AGENT: Record = { workflow: "工作流", react: "ReAct" }; + +// F1:会话头(改名、删除)和历史消息的纯文本。事件流、图表、提问、停止、👍👎 在 F2 / F3。 +export function SessionView({ c, id, datasetName, onChanged, onDeleted }: { + c: Client; + id: string; + datasetName: string; + onChanged: (s: Session) => void; + onDeleted: () => void; +}) { + const [s, setS] = useState(null); + const [title, setTitle] = useState(""); + const [error, setError] = useState(""); + + useEffect(() => { + let live = true; // Shell 按 id 设了 key,换会话会重新挂载 + c.session(id) + .then((d) => live && (setS(d), setTitle(d.title))) + .catch((e) => live && setError(errText(e))); + return () => { + live = false; + }; + }, [c, id]); + + if (error) return
{error}
; + if (!s) return
加载中…
; + + const rename = () => { + const t = title.trim(); + if (t === s.title) return; + c.renameSession(id, t).then( + (r) => (setS({ ...s, title: r.title }), setTitle(r.title), onChanged(r)), + (e) => setError(errText(e)), + ); + }; + + return ( +
+
+ setTitle(e.target.value)} + onBlur={rename} + onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()} + /> +
+ c.deleteSession(id).then(onDeleted, (e) => setError(errText(e)))} + /> +
+
+

+ 数据集:{datasetName} · {AGENT[s.agent] ?? s.agent} +

+ {s.messages.length === 0 ? ( +

还没有消息。

+ ) : ( +
    + {s.messages.map((m) => ( +
  1. +
    + {m.role === "user" ? "你" : "datachat"} + {STATUS[m.status] ? {STATUS[m.status]} : null} +
    +
    {m.content}
    +
  2. + ))} +
+ )} +
+ ); +} diff --git a/ui/web/src/views/SettingsView.tsx b/ui/web/src/views/SettingsView.tsx new file mode 100644 index 0000000..3ae5b64 --- /dev/null +++ b/ui/web/src/views/SettingsView.tsx @@ -0,0 +1,125 @@ +import { type FormEvent, useEffect, useState } from "react"; +import { type Client, errText } from "../client"; +import type { KeyStatus, Settings } from "../types"; + +const SOURCE: Record = { env: "环境变量 DEEPSEEK_API_KEY", keyring: "系统钥匙串" }; + +// API Key 只写不读:后端从不返回 Key 本身,这里也不保留输入值(保存后立即清空) +export function SettingsView({ c, onKeyChange }: { c: Client; onKeyChange: (k: KeyStatus) => void }) { + const [key, setKey] = useState(null); + const [form, setForm] = useState<{ model: string; base_url: string; timeout: string } | null>(null); + const [newKey, setNewKey] = useState(""); + const [msg, setMsg] = useState(""); + const [error, setError] = useState(""); + + useEffect(() => { + Promise.all([c.keyStatus(), c.settings()]) + .then(([k, s]) => (setKey(k), setForm(toForm(s)))) + .catch((e) => setError(errText(e))); + }, [c]); + + const keyDone = (k: KeyStatus, text: string) => { + setKey(k); + onKeyChange(k); + setMsg(text); + setError(""); + }; + + const saveKey = (e: FormEvent) => { + e.preventDefault(); + const v = newKey.trim(); + setNewKey(""); + if (!v) return; + c.setKey(v).then((k) => keyDone(k, "API Key 已保存。"), (er) => setError(errText(er))); + }; + + const saveSettings = (e: FormEvent) => { + e.preventDefault(); + if (!form) return; + const t = Number(form.timeout); + if (!Number.isFinite(t) || t < 5 || t > 600) { + setError("超时需在 5–600 秒之间"); + return; + } + c.saveSettings({ model: form.model, base_url: form.base_url, timeout: t }).then( + (s) => (setForm(toForm(s)), setMsg("设置已保存。"), setError("")), + (er) => setError(errText(er)), + ); + }; + + if (!key || !form) return
{error || "加载中…"}
; + + return ( +
+

设置

+ {msg &&

{msg}

} + {error &&

{error}

} + +
+

API Key

+

+ {key.configured ? `已设置(来源:${SOURCE[key.source ?? ""] ?? key.source})` : "未设置"} +

+ {key.source === "env" &&

环境变量优先于钥匙串;要改用钥匙串里的 Key,先去掉环境变量。

} + +
+ + {key.source === "keyring" && ( + + )} +
+
+ +
+

模型

+ + + +
+ +
+
+
+ ); +} + +function toForm(s: Settings) { + return { model: s.model, base_url: s.base_url, timeout: String(s.timeout) }; +} diff --git a/ui/web/src/views/Shell.tsx b/ui/web/src/views/Shell.tsx new file mode 100644 index 0000000..5a5341b --- /dev/null +++ b/ui/web/src/views/Shell.tsx @@ -0,0 +1,160 @@ +import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type Client, errText } from "../client"; +import type { Agent, Dataset, KeyStatus, Session } from "../types"; +import { DatasetView } from "./DatasetView"; +import { SessionView } from "./SessionView"; +import { SettingsView } from "./SettingsView"; + +type View = { kind: "home" } | { kind: "dataset"; id: string } | { kind: "session"; id: string } | { kind: "settings" }; + +export function Shell({ c, initialKey }: { c: Client; initialKey: KeyStatus }) { + const [datasets, setDatasets] = useState([]); + const [sessions, setSessions] = useState([]); + const [view, setView] = useState(initialKey.configured ? { kind: "home" } : { kind: "settings" }); + const [key, setKey] = useState(initialKey); + const [error, setError] = useState(""); + + const reload = useCallback( + () => + Promise.all([c.datasets(), c.sessions()]) + .then(([d, s]) => (setDatasets(d), setSessions(s))) + .catch((e) => setError(errText(e))), + [c], + ); + useEffect(() => { + reload(); + }, [reload]); + + const names = useMemo(() => Object.fromEntries(datasets.map((d) => [d.id, d.name])), [datasets]); + + const newSession = (datasetId: string, agent: Agent) => + c.createSession(datasetId, agent).then( + (s) => (setSessions((xs) => [s, ...xs]), setView({ kind: "session", id: s.id })), + (e) => setError(errText(e)), + ); + + return ( +
+ + +
+ {error && ( +

+ {error}{" "} + +

+ )} + {!key.configured && view.kind !== "settings" && ( +

还没有设置 API Key,分析会失败。去"设置"里填写。

+ )} + {view.kind === "home" && ( +
+

开始

+

在左侧上传 CSV(可多选:多张表,或数据表加 *.dict.csv 字典),看预览确认数据读对了,再新建会话提问。

+
+ )} + {view.kind === "dataset" && ( + (setView({ kind: "home" }), reload())} + /> + )} + {view.kind === "session" && ( + s.id === view.id)?.dataset_id ?? ""] ?? ""} + onChanged={(s) => setSessions((xs) => xs.map((x) => (x.id === s.id ? s : x)))} + onDeleted={() => (setView({ kind: "home" }), setSessions((xs) => xs.filter((x) => x.id !== view.id)))} + /> + )} + {view.kind === "settings" && } +
+
+ ); +} + +function Upload({ c, onDone, onError }: { c: Client; onDone: (d: Dataset) => void; onError: (m: string) => void }) { + const input = useRef(null); + const [name, setName] = useState(""); + const [busy, setBusy] = useState(false); + + const submit = (e: FormEvent) => { + e.preventDefault(); + const files = Array.from(input.current?.files ?? []); + if (files.length === 0) return; + setBusy(true); + c.upload(files, name.trim()) + .then((d) => { + onDone(d); + setName(""); + if (input.current) input.current.value = ""; + }, (er) => onError(`上传失败:${errText(er)}`)) + .finally(() => setBusy(false)); + }; + + return ( +
+ + setName(e.target.value)} /> + +
+ ); +} diff --git a/ui/web/src/views/views.test.tsx b/ui/web/src/views/views.test.tsx new file mode 100644 index 0000000..6da8943 --- /dev/null +++ b/ui/web/src/views/views.test.tsx @@ -0,0 +1,126 @@ +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { Api } from "../api"; +import { client } from "../client"; +import { fakeBackend } from "../fakeBackend"; +import type { KeyStatus } from "../types"; +import { Shell } from "./Shell"; + +const INJECT = ''; +const DS = { id: "d1", name: "销量", created_at: "t", files: [{ filename: "sales.csv", size: 2048, sha256: "h" }] }; +const PREVIEW = { + dataset_id: "d1", + rows: 20, + dicts: ["sales.dict.csv"], + tables: [ + { + name: "sales", + filename: "sales.csv", + encoding: "gbk", + total_rows: 100, + total_columns: 3, + columns: ["名称", INJECT, "数量"], + dtypes: ["object", "object", "float64"], + rows: [ + ["苹果", INJECT, null], + ["null", "b", "3.0"], + ], + }, + ], +}; +const SESSION = { id: "s1", dataset_id: "d1", agent: "react", title: "旧标题", created_at: "t", updated_at: "t" }; + +function setup(extra: Record = {}, key: KeyStatus = { configured: true, source: "keyring" }) { + const be = fakeBackend({ + "GET /datasets": [DS], + "GET /sessions": [SESSION], + "GET /datasets/d1": { ...DS, summary: "100 行 × 3 列,编码 gbk" }, + "GET /datasets/d1/preview?rows=20": PREVIEW, + "GET /sessions/s1": { + ...SESSION, + messages: [ + { id: "m1", role: "user", content: "问题", status: "ok", created_at: "t" }, + { id: "m2", role: "assistant", content: INJECT, status: "error", created_at: "t" }, + ], + }, + "PATCH /sessions/s1": (b: unknown) => ({ ...SESSION, title: (b as { title: string }).title }), + "GET /settings/api-key": key, + "GET /settings": { model: "qwen", base_url: "https://x/v1", timeout: 60 }, + "PUT /settings": (b: unknown) => ({ model: "qwen", base_url: "https://x/v1", ...(b as object) }), + "PUT /settings/api-key": { configured: true, source: "keyring" }, + ...extra, + }); + render(); + return be; +} + +afterEach(() => { + delete (window as unknown as { __pwned?: number }).__pwned; +}); + +test("预览:表头、单元格按文本渲染,注入串不变成元素;缺失值显示为'空',与字符串 null 区分", async () => { + setup(); + fireEvent.click(await screen.findByRole("button", { name: "销量" })); + const view = await screen.findByTestId("dataset-view"); + expect(within(view).getByText("共 100 行 × 3 列,编码 gbk,显示前 2 行", { exact: false })).toBeTruthy(); + expect(within(view).getAllByText(INJECT)).toHaveLength(2); // 表头一次、单元格一次,都是文本 + expect(view.querySelector("img")).toBeNull(); + expect((window as unknown as { __pwned?: number }).__pwned).toBeUndefined(); + expect(within(view).getByText("空").className).toBe("na"); + expect(within(view).getByText("null").className).toBe(""); + expect(within(view).getByText("字典文件:sales.dict.csv")).toBeTruthy(); +}); + +test("会话:消息按文本渲染;改名走 PATCH 并更新左栏", async () => { + const be = setup(); + fireEvent.click(await screen.findByRole("button", { name: /旧标题/ })); + const view = await screen.findByTestId("session-view"); + expect(within(view).getByText(INJECT)).toBeTruthy(); + expect(view.querySelector("img")).toBeNull(); + expect(within(view).getByText("失败")).toBeTruthy(); + const input = within(view).getByLabelText("会话标题"); + fireEvent.change(input, { target: { value: "新标题" } }); + await act(async () => fireEvent.blur(input)); + expect(be.calls.find((x) => x.method === "PATCH")?.body).toEqual({ title: "新标题" }); + expect(await screen.findByRole("button", { name: /新标题/ })).toBeTruthy(); +}); + +test("没设 Key → 直接进设置页;保存后输入框清空,Key 不出现在任何请求以外的地方", async () => { + const be = setup({}, { configured: false, source: null }); + const view = await screen.findByTestId("settings-view"); + expect(within(view).getByTestId("key-status").textContent).toBe("未设置"); + const input = view.querySelector('input[type="password"]') as HTMLInputElement; + fireEvent.change(input, { target: { value: "sk-secret" } }); + await act(async () => fireEvent.click(within(view).getByRole("button", { name: "保存 Key" }))); + expect(be.calls.find((x) => x.path === "/settings/api-key" && x.method === "PUT")?.body).toEqual({ api_key: "sk-secret" }); + expect(input.value).toBe(""); + expect(document.body.textContent).not.toContain("sk-secret"); + expect(within(view).getByTestId("key-status").textContent).toContain("系统钥匙串"); +}); + +test("设置:超时越界前端拦下,不发请求;合法值以数字发出", async () => { + const be = setup(); + fireEvent.click(await screen.findByRole("button", { name: "设置" })); + const view = await screen.findByTestId("settings-view"); + const t = within(view).getByLabelText("单次请求超时(秒)"); + fireEvent.change(t, { target: { value: "1" } }); + await act(async () => fireEvent.click(within(view).getByRole("button", { name: "保存设置" }))); + expect(within(view).getByRole("alert").textContent).toContain("5–600"); + expect(be.calls.some((x) => x.method === "PUT" && x.path === "/settings")).toBe(false); + fireEvent.change(t, { target: { value: "120" } }); + await act(async () => fireEvent.click(within(view).getByRole("button", { name: "保存设置" }))); + expect(be.calls.find((x) => x.method === "PUT" && x.path === "/settings")?.body).toEqual({ + model: "qwen", + base_url: "https://x/v1", + timeout: 120, + }); +}); + +test("删除要点两次", async () => { + const be = setup({ "DELETE /datasets/d1": { ok: true } }); + fireEvent.click(await screen.findByRole("button", { name: "销量" })); + const view = await screen.findByTestId("dataset-view"); + fireEvent.click(within(view).getByRole("button", { name: "删除数据集" })); + expect(be.calls.some((x) => x.method === "DELETE")).toBe(false); + await act(async () => fireEvent.click(within(view).getByRole("button", { name: "再点一次确认删除" }))); + expect(be.calls.some((x) => x.method === "DELETE" && x.path === "/datasets/d1")).toBe(true); +}); diff --git a/ui/web/src/vite-env.d.ts b/ui/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/ui/web/src/vite-env.d.ts @@ -0,0 +1 @@ +///