Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2217b91
phase5-f1: app/server/app.py
Smilewithoutfalling Oct 15, 2026
b110737
phase5-f1: docs/DEVLOG.md
Smilewithoutfalling Oct 15, 2026
e57304e
phase5-f1: docs/THREAT_MODEL.md
Smilewithoutfalling Oct 15, 2026
6f5bd49
phase5-f1: ui/web/src/App.test.tsx
Smilewithoutfalling Oct 15, 2026
6719a80
phase5-f1: ui/web/src/App.tsx
Smilewithoutfalling Oct 15, 2026
b84a65b
phase5-f1: ui/web/src/main.tsx
Smilewithoutfalling Oct 15, 2026
21c5155
phase5-f1: tests/test_web_f1.py
Smilewithoutfalling Oct 15, 2026
0ef0a4b
phase5-f1: ui/web/e2e/f1.spec.ts
Smilewithoutfalling Oct 15, 2026
d3229d2
phase5-f1: ui/web/src/client.ts
Smilewithoutfalling Oct 15, 2026
76825ed
phase5-f1: ui/web/src/fakeBackend.ts
Smilewithoutfalling Oct 15, 2026
916524a
phase5-f1: ui/web/src/styles.css
Smilewithoutfalling Oct 15, 2026
8a13cbb
phase5-f1: ui/web/src/types.ts
Smilewithoutfalling Oct 15, 2026
ce4c8b3
phase5-f1: ui/web/src/views/ConfirmButton.tsx
Smilewithoutfalling Oct 15, 2026
2109f07
phase5-f1: ui/web/src/views/DatasetView.tsx
Smilewithoutfalling Oct 15, 2026
2522265
phase5-f1: ui/web/src/views/PreviewTable.tsx
Smilewithoutfalling Oct 15, 2026
5f14dd4
phase5-f1: ui/web/src/views/SessionView.tsx
Smilewithoutfalling Oct 15, 2026
b91fd30
phase5-f1: ui/web/src/views/SettingsView.tsx
Smilewithoutfalling Oct 15, 2026
ff1ad13
phase5-f1: ui/web/src/views/Shell.tsx
Smilewithoutfalling Oct 15, 2026
8fb70ca
phase5-f1: ui/web/src/views/views.test.tsx
Smilewithoutfalling Oct 15, 2026
4b92251
phase5-f1: ui/web/src/vite-env.d.ts
Smilewithoutfalling Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 71 additions & 8 deletions app/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@
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 事件
GET /charts/{name} 图表 png
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
Expand Down Expand Up @@ -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)
Expand All @@ -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):
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)

Expand Down
30 changes: 29 additions & 1 deletion docs/DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。

Expand All @@ -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 落地。
2 changes: 1 addition & 1 deletion docs/THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 等)必须:
Expand Down
Loading
Loading