From 7d9e6cb40a2c2cbea9048bba2cb0dc3216579be7 Mon Sep 17 00:00:00 2001 From: Smilewithoutfalling <91455578+Smilewithoutfalling@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:12:17 +0000 Subject: [PATCH] =?UTF-8?q?chore:=20=E9=98=B6=E6=AE=B5=206=20=E6=94=B6?= =?UTF-8?q?=E5=B0=BE=EF=BC=9A=E5=B5=8C=E5=A5=97=20Job=20selftest=E3=80=81?= =?UTF-8?q?=E6=A1=8C=E9=9D=A2=E7=AB=AF=E5=AF=BC=E5=87=BA=20PNG=20=E8=90=BD?= =?UTF-8?q?=E7=9B=98=E3=80=81=E8=AE=BE=E7=BD=AE=E6=9D=A5=E6=BA=90=E6=98=BE?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/desktop.yml | 8 +++ README.md | 3 +- app/server/__main__.py | 4 ++ app/server/app.py | 45 ++++++++++++- app/server/exports.py | 104 +++++++++++++++++++++++++++++ desktop/src-tauri/src/main.rs | 4 ++ desktop/src-tauri/src/sidecar.rs | 39 ++++++++++- docs/DEVLOG.md | 26 +++++++- docs/roadmap/phase6-desktop.md | 7 +- docs/roadmap/plan.md | 6 ++ docs/sidecar-contract.md | 1 + tests/test_exports.py | 101 ++++++++++++++++++++++++++++ ui/web/src/charts/EChart.test.tsx | 20 ++++++ ui/web/src/charts/EChart.tsx | 12 +++- ui/web/src/charts/pngSaver.test.ts | 28 ++++++++ ui/web/src/charts/pngSaver.ts | 25 +++++++ ui/web/src/client.ts | 8 +++ ui/web/src/types.ts | 9 ++- ui/web/src/views/SettingsView.tsx | 16 ++++- ui/web/src/views/Shell.tsx | 10 ++- ui/web/src/views/views.test.tsx | 38 +++++++++++ 21 files changed, 502 insertions(+), 12 deletions(-) create mode 100644 app/server/exports.py create mode 100644 tests/test_exports.py create mode 100644 ui/web/src/charts/pngSaver.test.ts create mode 100644 ui/web/src/charts/pngSaver.ts diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 4bdcf11..8842fd5 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -81,6 +81,13 @@ jobs: Get-Content smoke-shell.json | Tee-Object -FilePath $rep -Append if ($p.ExitCode -ne 0) { throw "--smoke 失败,退出码 $($p.ExitCode)" } + # ①b 嵌套 Job:sidecar --selftest 放进壳的 Job Object 里跑,worker 的内存上限等八项必须仍然生效 + $p = Start-Process $exe.FullName -ArgumentList "--smoke-selftest", "$PWD\selftest-in-job.json" -Wait -PassThru + $st = Get-Content selftest-in-job.json -Raw -Encoding utf8 | ConvertFrom-Json + $mem = $st.checks | Where-Object { $_.name -eq "memory" } + "selftest_in_job_ok=$($st.ok) memory_ok=$($mem.ok) memory_seconds=$($mem.seconds)" | Tee-Object -FilePath $rep -Append + if ($p.ExitCode -ne 0 -or -not $st.ok) { throw "嵌套 Job 下 selftest 失败:$(($st.checks | Where-Object { -not $_.ok } | ForEach-Object { $_.name + ': ' + $_.detail }) -join '; ')" } + # ② 强杀壳,sidecar 必须被 Job Object 一起带走 $hold = Start-Process $exe.FullName -ArgumentList "--smoke-hold", "$PWD\sidecar.pid" -PassThru for ($i = 0; $i -lt 60 -and -not (Test-Path sidecar.pid); $i++) { Start-Sleep -Milliseconds 500 } @@ -111,6 +118,7 @@ jobs: path: | desktop-report.txt smoke-shell.json + selftest-in-job.json retention-days: 30 - uses: actions/upload-artifact@v7 with: diff --git a/README.md b/README.md index ff1decf..366ac72 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,8 @@ DataChat 用来**分析你自己的数据**:表里的内容会被模型读到 1. 从 GitHub Actions 的 `desktop` 工作流下载工件 `datachat-setup-win64`,解压得到 `DataChat_<版本>_x64-setup.exe`。 2. 双击安装(装在当前用户下,不要管理员权限)。安装包没有签名,第一次运行 SmartScreen 会拦:点「更多信息 → 仍要运行」。没有 WebView2 时安装程序会在线下载。 -3. 从开始菜单打开 DataChat。第一次会直接进设置页:先填 API Key 并保存,再上传 CSV。 +3. 从开始菜单打开 DataChat。第一次会直接进设置页:先填 API Key 并保存,再上传 CSV。**桌面版不读取仓库里的 `.env`**:用自己的网关或模型时,在设置页填「模型名」和「Base URL」并保存,再点「测试连接」。设置页会标出每一项当前生效的值来自本页设置、环境变量还是默认值(默认是 `https://api.deepseek.com/v1` + `deepseek-chat`)。 + 导出 PNG 时,桌面版会把图片直接存进你的「下载」文件夹,并在按钮旁显示完整路径;重名时自动加 (1)、(2)。 4. 升级:直接装新版本的安装包,数据不会丢。 5. 卸载:「设置 → 应用」里卸载。数据目录 `%LOCALAPPDATA%\datachat` 默认保留(卸载页勾选「删除应用数据」才删);保存在 Windows 凭据管理器里的 Key 不会被删,要删就运行 `python -m app.core.keystore delete`,或在凭据管理器里删掉 datachat 那一项。 diff --git a/app/server/__main__.py b/app/server/__main__.py index a9f5ab5..46e2084 100644 --- a/app/server/__main__.py +++ b/app/server/__main__.py @@ -36,6 +36,7 @@ def main(argv=None): ap.add_argument("--home", default=None, help="数据目录(默认 DATACHAT_HOME 或 %%LOCALAPPDATA%%\\datachat)") ap.add_argument("--token", default=os.getenv("DATACHAT_TOKEN"), help="默认每次启动随机生成") ap.add_argument("--open", action="store_true", help="启动后在浏览器打开前端") + ap.add_argument("--desktop", action="store_true", help="由桌面壳启动:开启 POST /exports/png(写入「下载」文件夹)") a = ap.parse_args(argv) from dotenv import load_dotenv @@ -44,6 +45,9 @@ def main(argv=None): from app.server.app import create_app app = create_app(a.home, a.token) + if a.desktop: + from app.server.exports import default_export_dir + app.state.export_dir = default_export_dir() logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s", handlers=[logging.FileHandler(os.path.join(app.state.home, "server.log"), encoding="utf-8"), logging.StreamHandler(sys.stderr)]) diff --git a/app/server/app.py b/app/server/app.py index 7dbea3c..7b78382 100644 --- a/app/server/app.py +++ b/app/server/app.py @@ -19,7 +19,10 @@ 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、timeout(单次请求超时,秒) + GET /settings PUT /settings 非机密设置:model、base_url、timeout(单次请求超时,秒); + sources 给出每项当前生效值的来源 settings | env | default(阶段 6 收尾) + POST /exports/png?filename= 桌面模式(--desktop):请求体是 PNG 字节,写进「下载」文件夹,返回 {"path"}; + 浏览器模式 404(前端走 )。见 app/server/exports.py GET /settings/api-key {"configured", "source"}——从不返回 Key 本身 PUT /settings/api-key {"api_key"} → 存进系统钥匙串 DELETE /settings/api-key @@ -334,6 +337,32 @@ def shutdown(): fn() return {"ok": True} + # 阶段 6 收尾:桌面端导出 PNG。export_dir 由 app.server.__main__ 在 --desktop 时挂上;None(浏览器模式、TestClient + # 默认)时 404,前端在浏览器里本来也不会调它。先看 Content-Length 再读体,超限不读进内存。 + app.state.export_dir = None + + @app.post("/exports/png", dependencies=[Depends(auth)]) + async def export_png(request: Request, filename: str = Query("", max_length=500)): + from app.server.exports import MAX_PNG_BYTES, ExportError, save_png + if app.state.export_dir is None: + raise HTTPException(404, "仅桌面版可用") + try: + declared = int(request.headers.get("content-length", "0")) + except ValueError: + declared = 0 + if declared > MAX_PNG_BYTES: + raise HTTPException(413, "图片太大") + data = await request.body() + try: + path = save_png(app.state.export_dir, filename, data) + except ExportError as e: + raise HTTPException(400, str(e)) from None + except OSError as e: + logger.warning("导出 PNG 写盘失败:%s", type(e).__name__) + raise HTTPException(500, "写入下载文件夹失败") from None + logger.info("导出 PNG:%s(%d 字节)", os.path.basename(path), len(data)) + return {"path": path} + @app.post("/datasets", dependencies=[Depends(auth)]) def create_dataset(files: list[UploadFile] = File(...), name: str = Form(""), db=Depends(db_dep)): if not files or len(files) > MAX_FILES: @@ -538,12 +567,24 @@ def get_chart(name: str): raise HTTPException(404, "图表不存在") return FileResponse(path, media_type="image/png") + # sources(阶段 6 收尾,S 10-27):桌面端不读仓库里的 .env,没在设置页填时会回退到默认的 api.deepseek.com, + # 用户看不出"现在用的是哪一份"。每项标出当前生效值来自 设置页 / 环境变量 / 默认值。 + SETTING_ENV = {"model": "DEEPSEEK_MODEL", "base_url": "DEEPSEEK_BASE_URL", "timeout": "DATACHAT_LLM_TIMEOUT"} + @app.get("/settings", dependencies=[Depends(auth)]) def get_settings(db=Depends(db_dep)): st = {r.key: r.value for r in db.scalars(select(Setting))} cfg = llm_config() + over = {k for k, v in st.items() if k in SETTING_KEYS and v} + if "timeout" in over: + try: + float(st["timeout"]) + except ValueError: + over.discard("timeout") # 同 llm_config:坏值不生效 + sources = {k: "settings" if k in over else "env" if os.getenv(SETTING_ENV[k]) else "default" + for k in SETTING_KEYS} return {"model": st.get("model") or cfg.model, "base_url": st.get("base_url") or cfg.base_url, - "timeout": cfg.timeout} + "timeout": cfg.timeout, "sources": sources} @app.put("/settings", dependencies=[Depends(auth)]) def put_settings(body: SettingsIn, db=Depends(db_dep)): diff --git a/app/server/exports.py b/app/server/exports.py new file mode 100644 index 0000000..5075dfc --- /dev/null +++ b/app/server/exports.py @@ -0,0 +1,104 @@ +"""桌面端「导出 PNG」落盘(阶段 6 收尾,S 10-27 本机试装:WebView2 里 data URL + 没反应)。 + +壳里没有下载处理器,前端在桌面模式下把 PNG 字节 POST 给后端,由后端写进用户的「下载」文件夹。 +只在 app.state.export_dir 不为 None 时启用(app.server.__main__ 带 --desktop 才挂);浏览器模式照旧走 。 + +防线(THREAT_MODEL:文件名是用户字符串离开画布的出口): +- 只收 PNG:校验 8 字节文件头,大小上限 MAX_PNG_BYTES; +- 文件名后端再清一遍(不信任前端 safeFilename):删控制符和 bidi、换掉路径分隔符和 Windows 非法字符、限长、保留名前加 _、 + 一律以 .png 结尾;只取 basename,写入路径必须仍在 export_dir 里; +- 重名自动编号「名字 (1).png」,用 open(..., "xb") 独占创建,不覆盖已有文件。 +""" +from __future__ import annotations + +import os +import re +import unicodedata + +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" +MAX_PNG_BYTES = 64 * 1024 * 1024 +NAME_MAX = 60 +FALLBACK = "图表" +MAX_SUFFIX = 999 + +_DROP = re.compile("[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069]") +_ILLEGAL = re.compile(r'[\\/:*?"<>|]') +_RESERVED = {"CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(1, 10)), *(f"LPT{i}" for i in range(1, 10))} + + +class ExportError(ValueError): + pass + + +def clean_filename(name: str | None) -> str: + """清洗后的文件名主干(不含 .png)。""" + s = name or "" + if s.lower().endswith(".png"): + s = s[:-4] + s = unicodedata.normalize("NFC", s) + s = _DROP.sub("", s) + s = _ILLEGAL.sub("_", s) + s = re.sub(r"\s+", " ", s) + s = s[:NAME_MAX] # 限长在去首尾空格 / 点之前,免得截断重新造出尾随的点 + s = s.strip(" .") + if not s: + s = FALLBACK + if s.split(".")[0].rstrip().upper() in _RESERVED: + s = "_" + s + return s + + +def default_export_dir() -> str: + """用户的「下载」文件夹:DATACHAT_EXPORT_DIR;Windows 用已知文件夹 FOLDERID_Downloads(用户可能把它挪到 D 盘); + 其他系统或取不到时用 ~/Downloads。""" + if os.getenv("DATACHAT_EXPORT_DIR"): + return os.getenv("DATACHAT_EXPORT_DIR") + if os.name == "nt": + try: + return _known_downloads() + except Exception: # noqa: BLE001 + pass + return os.path.join(os.path.expanduser("~"), "Downloads") + + +def _known_downloads() -> str: # pragma: no cover - 只在 Windows 上跑(desktop.yml 安装冒烟) + import ctypes + from ctypes import wintypes + import uuid + + class GUID(ctypes.Structure): + _fields_ = [("Data1", wintypes.DWORD), ("Data2", wintypes.WORD), ("Data3", wintypes.WORD), + ("Data4", ctypes.c_ubyte * 8)] + + u = uuid.UUID("{374DE290-123F-4565-9164-39C4925E467B}") + g = GUID(u.fields[0], u.fields[1], u.fields[2], (ctypes.c_ubyte * 8)(*u.bytes[8:])) + p = ctypes.c_wchar_p() + if ctypes.windll.shell32.SHGetKnownFolderPath(ctypes.byref(g), 0, None, ctypes.byref(p)) != 0: + raise OSError("SHGetKnownFolderPath failed") + try: + return p.value + finally: + ctypes.windll.ole32.CoTaskMemFree(p) + + +def save_png(export_dir: str, filename: str | None, data: bytes) -> str: + """写入 export_dir,返回完整路径。不是 PNG / 太大时抛 ExportError。""" + if len(data) > MAX_PNG_BYTES: + raise ExportError("图片太大") + if not data.startswith(PNG_MAGIC): + raise ExportError("只接受 PNG 图片") + root = os.path.realpath(export_dir) + os.makedirs(root, exist_ok=True) + stem = clean_filename(os.path.basename((filename or "").replace("\\", "/"))) + for i in range(MAX_SUFFIX + 1): + name = f"{stem}.png" if i == 0 else f"{stem} ({i}).png" + path = os.path.join(root, name) + if os.path.dirname(os.path.realpath(path)) != root: # 纵深防御:清洗后不应出现,出现就拒绝 + raise ExportError("文件名无效") + try: + with open(path, "xb") as f: + f.write(data) + return path + except FileExistsError: + continue + raise ExportError("同名文件太多") diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index e198e6a..4c36151 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -115,6 +115,10 @@ fn main() { let out = args.get(i + 1).map(PathBuf::from).unwrap_or_else(|| PathBuf::from("smoke-shell.json")); std::process::exit(sidecar::smoke(&out)); } + if let Some(i) = args.iter().position(|a| a == "--smoke-selftest") { + let out = args.get(i + 1).map(PathBuf::from).unwrap_or_else(|| PathBuf::from("selftest-in-job.json")); + std::process::exit(sidecar::smoke_selftest(&out)); + } if let Some(i) = args.iter().position(|a| a == "--smoke-hold") { let out = args.get(i + 1).map(PathBuf::from).unwrap_or_else(|| PathBuf::from("sidecar.pid")); sidecar::smoke_hold(&out); diff --git a/desktop/src-tauri/src/sidecar.rs b/desktop/src-tauri/src/sidecar.rs index 67449b3..63e2d2a 100644 --- a/desktop/src-tauri/src/sidecar.rs +++ b/desktop/src-tauri/src/sidecar.rs @@ -53,7 +53,7 @@ pub fn start(exe: &Path, home: &Path) -> Result { return Err(format!("找不到后台服务程序:{}", exe.display())); } let mut cmd = Command::new(exe); - cmd.arg("--port").arg("0").arg("--home").arg(home); + cmd.arg("--port").arg("0").arg("--home").arg(home).arg("--desktop"); // --desktop:开启导出 PNG 落盘(app/server/exports.py) cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()); // 日志在 home/server.log cmd.env_remove("DATACHAT_TOKEN"); // token 每次随机生成(sidecar-contract.md) #[cfg(windows)] @@ -204,6 +204,43 @@ pub fn smoke(out: &Path) -> i32 { if ok { 0 } else { 1 } } +/// `--smoke-selftest `:把 `sidecar --selftest` 放进和正式启动一样的 Job Object(KILL_ON_JOB_CLOSE)里跑, +/// stdout 的 JSON 原样写进文件,退出码透传。用来验证嵌套 Job 下沙箱 worker 的内存上限(selftest 的 memory 项) +/// 和其余各项限制是否仍然生效(phase6-desktop.md「D1/D2 实现与偏离」的待验证项)。 +pub fn smoke_selftest(out: &Path) -> i32 { + let exe = exe_path(); + let mut cmd = Command::new(&exe); + cmd.arg("--selftest").stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + } + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + let _ = std::fs::write(out, format!("{{\"ok\": false, \"error\": {:?}}}", e.to_string())); + return 1; + } + }; + #[cfg(windows)] + let _job = match job_for(&child) { + Ok(j) => j, + Err(e) => { + let _ = child.kill(); + let _ = std::fs::write(out, format!("{{\"ok\": false, \"error\": {:?}}}", format!("Job Object:{e}"))); + return 1; + } + }; + let mut text = String::new(); + if let Some(mut so) = child.stdout.take() { + let _ = so.read_to_string(&mut text); + } + let code = child.wait().ok().and_then(|s| s.code()).unwrap_or(1); + let _ = std::fs::write(out, text); + code +} + /// `--smoke-hold `:起 sidecar,把它的 pid 写进文件后一直挂着。CI 用 taskkill /F 结束壳, /// 再检查 sidecar 是否被 Job Object 一起带走。 pub fn smoke_hold(pid_file: &Path) -> ! { diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 73bf9ef..094ef3f 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -2256,7 +2256,7 @@ pyarrow:它只是 streamlit 的传递依赖([18],DEVLOG 第 771 行), S 10-26 定:只在本机自用,D0 的建议全部采纳;D3~D5 推迟到对外分发之前再做,本阶段只做 D1 + D2(方案「范围」一节)。网页端(`--open` 浏览器模式)保留,壳的功能不能让它坏掉。 -## [69] 2026-10-26 · 阶段 6 D1 壳 + D2 首次启动 · commit <待提交> +## [69] 2026-10-26 · 阶段 6 D1 壳 + D2 首次启动 · commit cdabfcf(PR #81) [68] 回填为 bf6413f(PR #80;master tests run 40290000283 绿)。按 phase6-desktop.md §2、§3 实现,偏离写在该文档「D1/D2 实现与偏离」。 @@ -2265,3 +2265,27 @@ S 10-26 定:只在本机自用,D0 的建议全部采纳;D3~D5 推迟到对 - 前端:`desktop.ts` 能力探测(`#shell=desktop`,不看 UA);失效页在壳里换成「重新启动 DataChat」(P1-16);上传对话框常驻「来路不明」提示(THREAT_MODEL §4 (a));设置页引导文案改成「先填 API Key 并保存,再上传 CSV」。浏览器模式不变(App.test.tsx 两条对照)。 - CI:新工作流 desktop.yml(windows-latest):打包 → 静默安装 → `--smoke` → 强杀壳查孤儿 → 静默卸载查数据目录。PR #81 head 393e1e0:desktop run 40295387628 绿(安装包 65.5 MB、打包 347 s、--smoke 启动 2027 ms / 退出 505 ms 退出码 0、强杀后无孤儿、卸载后数据保留),tests 40295387542、sidecar 40295387588 绿。 - 本地:Rust 只能 `cargo check --target x86_64-pc-windows-msvc`(沙箱 aarch64、没有 MSVC),真编译和安装冒烟以 CI 为准。 + +## [70] 2026-10-27 · 阶段 6 收尾:嵌套 Job 实测 + 重估 · commit <待提交> + +[69] 回填为 cdabfcf(PR #81,S 10-27 同意合并;合并前 head cb9e840 的 tests 40426911618、sidecar 40426911472、desktop 40426911624 都绿)。 + +- 壳新增 `--smoke-selftest `(sidecar.rs::smoke_selftest):用和正式启动同一个 `job_for`(KILL_ON_JOB_CLOSE)把 `sidecar --selftest` 放进 Job 里跑,stdout 的 JSON 原样落盘,退出码透传。selftest 的 memory 项会让 worker 在 512 MB 上限下申请约 1.6 GB,worker 自己的 Job 这时嵌套在壳的 Job 里面,所以这一项就是 phase6-desktop.md §2 第 3 条「嵌套后内存上限仍然生效」的实测。其余七项(读文件拦截、import 拦截、超时等)一起验证。 +- desktop.yml 安装冒烟加 ①b:对安装好的 exe 跑 `--smoke-selftest`,八项全过才绿,`memory_ok` / `memory_seconds` 写进 desktop-report.txt,selftest-in-job.json 一起上传。 +- 没覆盖:「壳被强杀时正在运行的 worker 也被带走」。worker 只在分析时存在,CI 上要先起一次真实分析才能测,留给 S 本机验收(分析进行中关掉窗口,任务管理器里不应留下 datachat-sidecar.exe)。 +- plan.md:进度表补阶段 6 一行,并按 M18 重估一次(见该节)。 +- 本地:`cargo check --target x86_64-pc-windows-msvc` 通过,无 warning。 + +### S 10-27 本机试装发现的两个问题 + +1. **点「导出 PNG」没有反应**。exportPng.ts 用的是 data URL 加 ``,而壳里没有下载处理器,WebView2 遇到这个链接什么也不做。修法是桌面模式改由后端写盘: + - 新增 `POST /exports/png?filename=`(app/server/exports.py)。要带 Bearer,并且只在 sidecar 以 `--desktop` 启动时开启;浏览器模式下返回 404。壳的 sidecar.rs::start 会传 `--desktop`,sidecar-contract.md 已补上这一条。 + - 只接受 PNG:检查 8 字节文件头,大小上限 64 MB,超限时先按 Content-Length 返回 413。 + - 文件名在后端再清洗一遍,不信任前端:去掉控制字符和 bidi,替换路径分隔符和非法字符,限 60 字,保留名前面加 `_`,只取 basename。重名时自动编号成「名 (1).png」,用 `open(..., "xb")` 独占创建,所以不会覆盖已有文件。 + - 写到 Windows 已知文件夹 FOLDERID_Downloads(用户可能把它挪到别的盘),可以用 `DATACHAT_EXPORT_DIR` 覆盖。 + - 前端改动:Shell 只在 `inDesktopShell()` 时通过 charts/pngSaver.ts 注册 saver,EChart 有 saver 时交给它处理,并显示「已保存到 路径」;浏览器模式保持原来的 ``。 +2. **连不上模型**(WinError 10054)。为了定位做了几组对照:A 浏览器模式能连;B 在 dc 窗口里直接运行安装好的 sidecar(用同一个 --home)能连;C 在普通 cmd 里运行也能连。这说明 exe、设置和证书都没问题。之后 S 再打开桌面端,就正常了。**根因没有实证**,推断是第一次连接时设置还没保存好、或者没有重启。不过桌面端不读 `.env` 这一点确实会让人误判,所以: + - `GET /settings` 新增 `sources`,标出每一项当前生效的值来自 settings、env 还是 default。设置页显示一行「当前生效:…」,并写明桌面版不读取 .env。后端没有返回 sources 的旧版本不显示这一行。 + - README「安装」第 3 步说明了这一点。 + +**没做:** 安装目录 `%LOCALAPPDATA%\DataChat`(Tauri currentUser 默认的 `$LOCALAPPDATA\${PRODUCTNAME}`)和数据目录 `%LOCALAPPDATA%\datachat` 在 NTFS 上不区分大小写,其实是同一个目录。目前不出错的原因是:卸载器只删它自己装的文件,最后执行的是非递归的 RMDir;勾选「删除应用数据」时整个目录都会被删,而这本来就是卸载。要把两者分开,得改安装目录(需要改 NSIS 模板或 productName),或者改数据目录(会和浏览器模式分家,需要迁移已有数据),两种都超出收尾的范围,所以推迟到 D3 更新之前,记在 phase6-desktop.md 里。desktop.yml 卸载检查 ③ 的 data_kept 结论仍然成立,因为它验证的正是「卸载不递归删除」。 diff --git a/docs/roadmap/phase6-desktop.md b/docs/roadmap/phase6-desktop.md index 612b11d..d691059 100644 --- a/docs/roadmap/phase6-desktop.md +++ b/docs/roadmap/phase6-desktop.md @@ -1,4 +1,4 @@ -# 阶段 6:桌面打包(方案,待 S 评审) +# 阶段 6:桌面打包(D1、D2 已完成;D3~D5 推迟到对外分发前) 来源:plan.md 阶段 6 的交付物与验收;M19(签名 / SmartScreen / 干净机器验证)、M22(外壳选型);sidecar 契约 docs/sidecar-contract.md;阶段 4 spike DEVLOG [18];phase5 F5 末尾的「阶段 6 前置事项」。 @@ -37,6 +37,7 @@ S 定:**目前只在自己的电脑上安装使用,不对外分发**,§1 2. 启动:`--port 0 --home %LOCALAPPDATA%\datachat`,`CREATE_NO_WINDOW`(不弹黑框),stdout 用管道接,stderr 丢弃(日志已经写进 server.log)。读第一行 JSON(超时 20 s)→ 轮询 `/health`(每 100 ms 一次,总超时 20 s)→ 主窗口 `navigate` 到 `http://127.0.0.1:/#token=`。等待期间窗口显示本地的「正在启动…」页(打包进壳的静态 html,不经 sidecar)。 3. **sidecar 不能变成孤儿进程**:壳一启动就建一个 Job Object,设 `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`,把 sidecar 放进去。壳不管是正常退出、崩溃还是被任务管理器结束,sidecar 都会被系统一起杀掉。正常退出时先 `POST /shutdown`(新接口,要 Bearer),等 3 s 再强杀。理由:Windows 没有「父进程死了子进程跟着死」的默认行为,没有 Job Object 的话,残留的 sidecar 会一直占着 SQLite 和端口。 - 注意:沙箱 worker 已经在**另一个** Job Object 里(内存上限)。Win8 起 Job 可以嵌套,嵌套是否生效要在 spike 里实测(worker 的内存上限在嵌套后仍然生效、壳被杀后 worker 也会消失)。 + - 实测(DEVLOG [70]):内存上限由 desktop.yml ①b 的 `--smoke-selftest` 在 CI 验证;「壳被杀后正在运行的 worker 也消失」由 S 本机验收。 4. sidecar 中途退出:窗口切到本地错误页,显示退出码和 server.log 最后 20 行(先打码,复用 T11 的打码函数),并提供「重新启动」按钮。顺带解决 P1-16(「重新打开应用」在浏览器里做不到,到了壳里就能做)。 5. 单实例:`tauri-plugin-single-instance`。第二次打开时聚焦已有窗口,不再起第二个 sidecar(两个 sidecar 写同一个 SQLite 会冲突)。 6. 导航边界:主窗口只允许 `http://127.0.0.1:<本次端口>/` 和本地启动页、错误页。其他地址(结论里的 Markdown 链接、`target=_blank`)一律用系统默认浏览器打开,不在壳里打开(`on_navigation` + `on_new_window`)。capabilities 给远程 URL 的权限为空:127.0.0.1 页面拿不到任何 Tauri API,T9 的防线不因为套了壳而变弱。 @@ -68,6 +69,10 @@ S 定:**目前只在自己的电脑上安装使用,不对外分发**,§1 - §3 验收「e2e 首次启动用例」:放在 f0.spec.ts(nokey 项目),不放 f[34]*。fake 项目已经配了 Key,测不了「没有 Key」。 - 卸载页「删除应用数据」:Tauri 自带的勾选框只删 `%LOCALAPPDATA%\`,而数据目录是 `%LOCALAPPDATA%\datachat`(和浏览器模式共用),所以用 `hooks.nsh` 的 POSTUNINSTALL 补删;默认不勾,静默卸载不删。钥匙串里的 Key 不删(README「安装」写了怎么手动删)。 - 图标是临时的(蓝底白柱,PIL 生成),对外分发前再换。 +- S 10-27 本机试装后的修改(DEVLOG [70]): + - 导出 PNG 在桌面模式下改由后端写进「下载」文件夹:`POST /exports/png`,只在 `--desktop` 时开放。这样 §2 第 6 条放行 `blob:` / `data:` 的规则就不再承担下载。 + - `GET /settings` 新增 `sources`,设置页显示每一项值的来源,并注明桌面版不读取 `.env`。 +- **遗留(D3 之前处理)**:安装目录 `%LOCALAPPDATA%\DataChat` 和数据目录 `%LOCALAPPDATA%\datachat` 在 NTFS 上其实是同一个目录。目前不会出错,因为卸载时只删自己装的文件,删目录用的是不递归的 RMDir。但做自动更新之前必须把两者分开,有两种办法:一是改安装目录(NSIS 模板或 productName),二是改数据目录(要迁移数据,还会影响浏览器模式共用同一份数据)。 ## 4. D3 自动更新 diff --git a/docs/roadmap/plan.md b/docs/roadmap/plan.md index 602f897..c7cd182 100644 --- a/docs/roadmap/plan.md +++ b/docs/roadmap/plan.md @@ -31,6 +31,7 @@ | 3.5 / 3.6 | 完成,闸门中间区,P0 ⑤⑥ 已修 | 2026-10-12 | DEVLOG [10]–[13] | | 4 | **完成**,三条验收都达到 | 2026-10-12 → 10-13 | DEVLOG [14]–[18];curl 全流程与重启见 [16],跨重启多轮见 [17],干净 runner 上执行沙箱代码见 [18] | | 5 | **完成**:F0–F4、V1 / V2a~V2d、F5 真 Key 走查通过(2026-10-26),Streamlit 已删 | 2026-10-13 → 10-26 | DEVLOG [19]–[67] | +| 6 | **自用范围完成**:D1 壳 + NSIS 安装包、D2 首次启动 + (a) 提示;D3 更新 / D4 许可证 / D5 签名推迟到对外分发前(S 10-26 定) | 2026-10-26 → 10-27 | DEVLOG [68]–[70];CI 安装冒烟 desktop run 40295387628 | **重估(M18)**:阶段 0–4 原估 5–7.5 周,实际 5 个日历日。原因是代码主要由 Hark 写、S 审,**不能**照这个速度外推后面的阶段: - 阶段 0–4 几乎都能在 Linux 沙箱 + CI 里验证;阶段 5–6 的验收是"不碰命令行能用""干净 Windows 一键安装",必须 S 在本机动手看,日历时间取决于 S 的可用时间,不取决于写代码的速度。 @@ -45,6 +46,11 @@ 阶段 6 结束时再按同样方法重估一次。 +**重估(2026-10-27,阶段 6 自用范围结束)**:阶段 5 原估 2–3 周、实际 14 个日历日;阶段 6 自用部分原估 1–2 周、实际 2 天(D3~D5 没做)。剩下的工期不再取决于写代码的速度: +- 阶段 7:仍估 2–4 周。瓶颈是真实模型评测的轮数(每轮要 S 的 Key 和时间)以及留出集的建设,不是写代码。方案见 docs/roadmap/phase7-accuracy.md。 +- 对外分发(D3~D5 + THREAT_MODEL 重新确认):代码约 1 周,签名证书另计几天到几周的审核。 +- 阶段 8:另定,不变。 + ## 开发日志(docs/DEVLOG.md) - 每完成一个阶段或一次重要提交,追加一条记录,内容包括: - commit SHA 和当前技术栈; diff --git a/docs/sidecar-contract.md b/docs/sidecar-contract.md index 9684b4c..67162da 100644 --- a/docs/sidecar-contract.md +++ b/docs/sidecar-contract.md @@ -6,6 +6,7 @@ - 壳**必须**用 `--port 0` 启动 sidecar,由系统分配空闲端口。不要传固定端口:上一次运行的端口可能还处在 TIME_WAIT,会报 Address already in use。 - `--home <目录>`:数据目录。不传时用 `DATACHAT_HOME`,再不行用 `%LOCALAPPDATA%\datachat`。 +- `--desktop`:桌面壳**必须**传。开启 `POST /exports/png`,把前端导出的 PNG 写进用户的「下载」文件夹(取 Windows 已知文件夹 FOLDERID_Downloads;`DATACHAT_EXPORT_DIR` 可覆盖)。不传时这个接口返回 404,浏览器模式照旧用 ``。 - `--token`:一般不传,sidecar 每次启动随机生成。 ## 握手 diff --git a/tests/test_exports.py b/tests/test_exports.py new file mode 100644 index 0000000..286d81c --- /dev/null +++ b/tests/test_exports.py @@ -0,0 +1,101 @@ +"""阶段 6 收尾:桌面端导出 PNG 落盘(app/server/exports.py、POST /exports/png)和 GET /settings 的 sources。""" +import os + +import pytest +from fastapi.testclient import TestClient + +from app.server.app import create_app +from app.server.exports import MAX_PNG_BYTES, PNG_MAGIC, ExportError, clean_filename, save_png + +TOKEN = "t0ken" +H = {"Authorization": f"Bearer {TOKEN}"} +PNG = PNG_MAGIC + b"\x00" * 32 + + +@pytest.mark.parametrize("raw, want", [ + ("各地区销量.png", "各地区销量"), + ("各地区销量", "各地区销量"), + ("a/b\\c:d*e?f\"gi|j", "a_b_c_d_e_f_g_h_i_j"), + ("gnp\u202eexe", "gnpexe"), # RLO 删掉 + ("x\x00\x1fy\x7f\x9f", "xy"), + (" ..名字.. ", "名字"), + ("", "图表"), + (None, "图表"), + ("...", "图表"), + ("CON", "_CON"), + ("lpt1.backup", "_lpt1.backup"), + ("长" * 80, "长" * 60), +]) +def test_clean_filename(raw, want): + assert clean_filename(raw) == want + + +def test_save_png_numbers_duplicates_and_never_overwrites(tmp_path): + d = str(tmp_path / "dl") + p1 = save_png(d, "图.png", PNG) + p2 = save_png(d, "图.png", PNG + b"2") + p3 = save_png(d, "图", PNG + b"3") + assert [os.path.basename(p) for p in (p1, p2, p3)] == ["图.png", "图 (1).png", "图 (2).png"] + assert open(p1, "rb").read() == PNG # 第一份没被覆盖 + assert all(os.path.dirname(p) == os.path.realpath(d) for p in (p1, p2, p3)) + + +def test_save_png_stays_in_dir(tmp_path): + d = str(tmp_path / "dl") + p = save_png(d, "../../evil.png", PNG) + assert os.path.dirname(p) == os.path.realpath(d) and os.path.basename(p) == "evil.png" + p = save_png(d, "..\\..\\evil2", PNG) + assert os.path.dirname(p) == os.path.realpath(d) and os.path.basename(p) == "evil2.png" + + +def test_save_png_rejects_non_png_and_oversize(tmp_path): + d = str(tmp_path / "dl") # 子目录:conftest 的 autouse 夹具会在 tmp_path 下建 charts/ + with pytest.raises(ExportError): + save_png(d, "x", b"GIF89a....") + with pytest.raises(ExportError): + save_png(d, "x", b"") + with pytest.raises(ExportError): + save_png(d, "x", PNG_MAGIC + b"\x00" * MAX_PNG_BYTES) + assert not os.path.exists(d) # 校验在建目录之前 + + +def test_export_endpoint_desktop_only_and_auth(tmp_path): + app = create_app(str(tmp_path / "home"), TOKEN) + c = TestClient(app) + hdr = {**H, "Content-Type": "image/png"} + assert c.post("/exports/png?filename=a", content=PNG).status_code == 401 + assert c.post("/exports/png?filename=a", content=PNG, headers=hdr).status_code == 404 # 浏览器模式不开 + d = tmp_path / "dl" + app.state.export_dir = str(d) + r = c.post("/exports/png?filename=" + "各地区销量.png", content=PNG, headers=hdr) + assert r.status_code == 200 and r.json()["path"] == os.path.join(os.path.realpath(d), "各地区销量.png") + assert open(r.json()["path"], "rb").read() == PNG + assert c.post("/exports/png?filename=a", content=b"not a png", headers=hdr).status_code == 400 + assert c.post("/exports/png?filename=a", content=PNG, + headers={**hdr, "Content-Length": str(MAX_PNG_BYTES + 1)}).status_code == 413 + assert sorted(os.listdir(d)) == ["各地区销量.png"] + + +def test_desktop_flag_sets_export_dir(monkeypatch, tmp_path): + from app.server.exports import default_export_dir + monkeypatch.setenv("DATACHAT_EXPORT_DIR", str(tmp_path / "x")) + assert default_export_dir() == str(tmp_path / "x") + monkeypatch.delenv("DATACHAT_EXPORT_DIR") + if os.name != "nt": + assert default_export_dir() == os.path.join(os.path.expanduser("~"), "Downloads") + + +def test_settings_sources(tmp_path, monkeypatch): + for k in ("DEEPSEEK_MODEL", "DEEPSEEK_BASE_URL", "DATACHAT_LLM_TIMEOUT"): + monkeypatch.delenv(k, raising=False) + c = TestClient(create_app(str(tmp_path / "home"), TOKEN)) + s = c.get("/settings", headers=H).json() + assert s["sources"] == {"model": "default", "base_url": "default", "timeout": "default"} + assert s["base_url"] == "https://api.deepseek.com/v1" + monkeypatch.setenv("DEEPSEEK_BASE_URL", "https://gw.example/v1") + s = c.get("/settings", headers=H).json() + assert s["sources"]["base_url"] == "env" and s["base_url"] == "https://gw.example/v1" + s = c.put("/settings", json={"base_url": "https://mine.example/v1", "model": "qwen"}, headers=H).json() + assert s["sources"] == {"model": "settings", "base_url": "settings", "timeout": "default"} + s = c.put("/settings", json={"base_url": ""}, headers=H).json() # 清空 → 回到环境变量 + assert s["sources"]["base_url"] == "env" diff --git a/ui/web/src/charts/EChart.test.tsx b/ui/web/src/charts/EChart.test.tsx index 349d81b..4282055 100644 --- a/ui/web/src/charts/EChart.test.tsx +++ b/ui/web/src/charts/EChart.test.tsx @@ -8,6 +8,7 @@ import type { Turn } from "../turn"; import { TurnView } from "../views/TurnView"; import { EChart, type ChartSpec, placeCard, resetMarksForTest } from "./EChart"; import * as exp from "./exportPng"; +import { setPngSaver } from "./pngSaver"; import { DEFAULT_SWITCHES } from "./switches"; import { setChartTheme, THEMES } from "./themes"; import { getPrefs, resetPrefsForTest, setPrefs } from "./uiPrefs"; @@ -319,6 +320,25 @@ describe("F3 第六步:导出与交互态", () => { expect(downloadSpy.mock.calls.at(-1)![1]).toBe("柱状图.png"); }); + // 阶段 6 收尾:桌面壳注册了 saver 时不走 (WebView2 里没反应,S 10-27),改交给 saver,并显示保存路径 + test("桌面模式:有 saver 时交给 saver、不调 download,成功显示「已保存到 路径」,失败显示导出失败", async () => { + const saver = vi.fn(async () => "C:\\Users\\S\\Downloads\\各城市销量.png"); + setPngSaver(saver); + try { + render(); + clickExport(); + expect(saver).toHaveBeenCalledWith(expect.stringMatching(/^data:image\//), "各城市销量.png"); + expect(downloadSpy).not.toHaveBeenCalled(); + expect((await screen.findByTestId("export-saved")).textContent).toBe("已保存到 C:\\Users\\S\\Downloads\\各城市销量.png"); + saver.mockRejectedValueOnce(new Error("500")); + clickExport(); + expect((await screen.findByRole("alert")).textContent).toBe("导出失败"); + expect(screen.queryByTestId("export-saved")).toBeNull(); + } finally { + setPngSaver(null); + } + }); + test("导出为浅色(经典配色):只在暗色画布下出现;勾上用 classic,不勾用 darkCanvas;不持久化", () => { render(); expect(screen.queryByRole("checkbox", { name: "导出为浅色(经典配色)" })).toBeNull(); diff --git a/ui/web/src/charts/EChart.tsx b/ui/web/src/charts/EChart.tsx index f7d79b7..5d47862 100644 --- a/ui/web/src/charts/EChart.tsx +++ b/ui/web/src/charts/EChart.tsx @@ -15,6 +15,7 @@ import { IconChevronDown, IconDownload } from "../views/icons"; import { init } from "echarts/core"; import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { download, exportPng } from "./exportPng"; +import { getPngSaver } from "./pngSaver"; import { safeFilename } from "./filename"; import { effectiveKind, normalize, type Ctx, type Option, prepare, type UiState } from "./prepare"; import { registerCharts } from "./register"; @@ -67,6 +68,7 @@ export function EChart({ spec, editKey }: { spec: ChartSpec; editKey?: string }) const viewFor = useRef<{ spec: ChartSpec; target: Kind | null } | null>(null); const [light, setLight] = useState(false); // 导出为浅色(经典配色):不持久化 const [exportFailed, setExportFailed] = useState(false); + const [savedTo, setSavedTo] = useState(""); // 桌面模式:后端写到的完整路径(pngSaver.ts) const src = asKind(spec.kind); const kind = effectiveKind(spec, { kind: target }); // 只看能不能换图型;签名只收 kind,开关传不进去 const prefKind = kind ?? "bar"; @@ -235,8 +237,15 @@ export function EChart({ spec, editKey }: { spec: ChartSpec; editKey?: string }) const t = theme === "darkCanvas" && light ? "classic" : theme; const url = exportPng(spec, { ...ui, view: view.current }, t, size); // V2b B3:用户设了标题就用它(已过 sanitizeTitle),文件名仍经 safeFilename - download(url, safeFilename(edit.title ?? backendTitle, kind ? KIND_LABEL[kind] : undefined)); + const name = safeFilename(edit.title ?? backendTitle, kind ? KIND_LABEL[kind] : undefined); + const saver = getPngSaver(); + setSavedTo(""); setExportFailed(false); + if (saver) { + saver(url, name).then(setSavedTo, () => setExportFailed(true)); + } else { + download(url, name); + } } catch { setExportFailed(true); } @@ -279,6 +288,7 @@ export function EChart({ spec, editKey }: { spec: ChartSpec; editKey?: string }) )} {exportFailed && 导出失败} + {savedTo && 已保存到 {savedTo}} {marksOpen && ( { + const bytes = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff]; + const url = `data:image/png;base64,${btoa(String.fromCharCode(...bytes))}`; + expect(Array.from(dataUrlBytes(url))).toEqual(bytes); + expect(() => dataUrlBytes("data:image/svg+xml;base64,AAAA")).toThrow(); + expect(() => dataUrlBytes("https://x/a.png")).toThrow(); +}); + +test("savePng:POST /exports/png?filename=(编码),Bearer、image/png、请求体是字节;返回后端给的路径", async () => { + const calls: { url: string; init: RequestInit }[] = []; + const fetchImpl = (async (url: string, init: RequestInit) => { + calls.push({ url, init }); + return new Response(JSON.stringify({ path: "D:\\Downloads\\图 (1).png" }), { status: 200 }); + }) as unknown as typeof fetch; + const c = client(new Api("tok", fetchImpl)); + const url = `data:image/png;base64,${btoa("\x89PNG\r\n\x1a\n")}`; + expect(await c.savePng(url, "图 #1?.png")).toBe("D:\\Downloads\\图 (1).png"); + expect(calls[0].url).toBe(`/exports/png?filename=${encodeURIComponent("图 #1?.png")}`); + const h = new Headers(calls[0].init.headers); + expect(h.get("Authorization")).toBe("Bearer tok"); + expect(h.get("Content-Type")).toBe("image/png"); + expect(calls[0].init.method).toBe("POST"); + expect(Array.from(calls[0].init.body as Uint8Array).slice(0, 4)).toEqual([0x89, 0x50, 0x4e, 0x47]); +}); diff --git a/ui/web/src/charts/pngSaver.ts b/ui/web/src/charts/pngSaver.ts new file mode 100644 index 0000000..605104e --- /dev/null +++ b/ui/web/src/charts/pngSaver.ts @@ -0,0 +1,25 @@ +// 阶段 6 收尾:桌面端导出 PNG(S 10-27 本机试装:WebView2 里 data URL + 点了没反应,壳里没有下载处理器)。 +// 桌面壳里由 Shell 注册一个 saver:把 PNG 字节 POST /exports/png,后端写进「下载」文件夹并返回完整路径(app/server/exports.py)。 +// 浏览器模式不注册,EChart 照旧走 exportPng.download()。模块级单例:EChart 不持有 client,免得为一个按钮 +// 把 client 穿过 TurnView → EChart 三层 props。 +export type PngSaver = (dataUrl: string, filename: string) => Promise; + +let saver: PngSaver | null = null; + +export function setPngSaver(s: PngSaver | null): void { + saver = s; +} + +export function getPngSaver(): PngSaver | null { + return saver; +} + +/** data:image/png;base64,… → 字节。不用 fetch(dataUrl):connect-src 'self' 会拦 data:(exportPng.ts 头注释,实测)。 */ +export function dataUrlBytes(dataUrl: string): Uint8Array { + const m = /^data:image\/png;base64,(.*)$/s.exec(dataUrl); + if (!m) throw new Error("not a png data url"); + const bin = atob(m[1]); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} diff --git a/ui/web/src/client.ts b/ui/web/src/client.ts index 6002a79..2225d6e 100644 --- a/ui/web/src/client.ts +++ b/ui/web/src/client.ts @@ -1,5 +1,6 @@ // 各接口的薄封装。全部经过 Api(统一 Bearer 与 401 处理)。 import type { Api } from "./api"; +import { dataUrlBytes } from "./charts/pngSaver"; import { parseSse } from "./sse"; import type { Ev } from "./turn"; import type { Agent, Dataset, DatasetDetail, KeyStatus, Rating, Session, SessionDetail, Settings, SettingsTest } from "./types"; @@ -73,6 +74,13 @@ export function client(api: Api) { deleteKey: () => api.json("/settings/api-key", { method: "DELETE" }), /** 测试连接:测的是已保存的配置;同时只许一个,第二个 409。 */ testSettings: () => api.json("/settings/test", { method: "POST" }), + /** 桌面模式导出 PNG(阶段 6 收尾):后端写进「下载」文件夹,返回完整路径。浏览器模式后端 404,前端也不会调用。 */ + savePng: async (dataUrl: string, filename: string) => + (await api.json<{ path: string }>(`/exports/png?filename=${encodeURIComponent(filename)}`, { + method: "POST", + headers: { "Content-Type": "image/png" }, + body: dataUrlBytes(dataUrl) as BodyInit, + })).path, }; } export type Client = ReturnType; diff --git a/ui/web/src/types.ts b/ui/web/src/types.ts index 4bae59b..111c2ea 100644 --- a/ui/web/src/types.ts +++ b/ui/web/src/types.ts @@ -43,7 +43,14 @@ export type Rating = "up" | "down" | null; export type Feedback = { rating: Rating; note: string }; export type SessionDetail = Session & { messages: Message[] }; -export type Settings = { model: string; base_url: string; timeout: number }; +export type SettingSource = "settings" | "env" | "default"; +/** GET /settings:sources 是每项当前生效值的来源(阶段 6 收尾;旧后端没有这个字段)。 */ +export type Settings = { + model: string; + base_url: string; + timeout: number; + sources?: { model: SettingSource; base_url: SettingSource; timeout: SettingSource }; +}; export type KeyStatus = { configured: boolean; source: string | null }; /** POST /settings/test(阶段 5 收尾 C):用已保存的设置发一次最小请求。message 是 T11 user_error 的文案(带编号)。 */ export type SettingsTest = diff --git a/ui/web/src/views/SettingsView.tsx b/ui/web/src/views/SettingsView.tsx index 3e96bc6..8fcc3f1 100644 --- a/ui/web/src/views/SettingsView.tsx +++ b/ui/web/src/views/SettingsView.tsx @@ -1,6 +1,6 @@ import { type FormEvent, useEffect, useRef, useState } from "react"; import { type Client, errText } from "../client"; -import type { KeyStatus, Settings, SettingsTest } from "../types"; +import type { KeyStatus, Settings, SettingSource, SettingsTest } from "../types"; import { IconCheck, IconChevronDown, IconChevronUp, IconWarn } from "./icons"; import { llmErrorText } from "./llmErrorText"; import { Topbar } from "./ui"; @@ -11,10 +11,14 @@ import { Topbar } from "./ui"; // 阶段 5 收尾 C:凭证卡和模型三项下面一个「测试连接」。测的是**已保存**的配置(POST /settings/test),所以表单有未保存的 // 改动(模型三项和保存时不同、或填了新 Key 没保存)时按钮不可点,旁边写「先保存再测试」,避免"测的和用的不是一份"。 // 结果三态:测试中 / 连接正常 · 0.8s · 模型名(绿)/ 失败显示 message;auth、no_key 时焦点移到 Key 输入框。 +// 阶段 6 收尾(S 10-27):每项标出当前生效值的来源,桌面端没读到 .env 时一眼能看出用的是默认值 +const SOURCE_TEXT: Record = { settings: "来自本页设置", env: "来自环境变量", default: "是默认值" }; + export function SettingsView({ c, onKeyChange, onBack }: { c: Client; onKeyChange: (k: KeyStatus) => void; onBack?: () => void }) { const [key, setKey] = useState(null); const [form, setForm] = useState<{ model: string; base_url: string; timeout: string } | null>(null); const [savedForm, setSavedForm] = useState<{ model: string; base_url: string; timeout: string } | null>(null); + const [sources, setSources] = useState(undefined); const [testRaw, setTest] = useState< (({ state: "running" } | { state: "done"; r: SettingsTest } | { state: "error"; text: string }) & { at: TestAt }) | null >(null); @@ -37,7 +41,7 @@ export function SettingsView({ c, onKeyChange, onBack }: { c: Client; onKeyChang useEffect(() => { Promise.all([c.keyStatus(), c.settings()]) - .then(([k, s]) => (setKey(k), setForm(toForm(s)), setSavedForm(toForm(s)))) + .then(([k, s]) => (setKey(k), setForm(toForm(s)), setSavedForm(toForm(s)), setSources(s.sources))) .catch((e) => setError(errText(e))); }, [c]); @@ -66,7 +70,7 @@ export function SettingsView({ c, onKeyChange, onBack }: { c: Client; onKeyChang return; } c.saveSettings({ model: form.model, base_url: form.base_url, timeout: t }).then( - (s) => (setForm(toForm(s)), setSavedForm(toForm(s)), setMsg("设置已保存。"), setError("")), + (s) => (setForm(toForm(s)), setSavedForm(toForm(s)), setSources(s.sources), setMsg("设置已保存。"), setError("")), (er) => setError(errText(er)), ); }; @@ -175,6 +179,12 @@ export function SettingsView({ c, onKeyChange, onBack }: { c: Client; onKeyChang 5–600 秒 + {sources ? ( +

+ 当前生效:模型名{SOURCE_TEXT[sources.model]},Base URL {SOURCE_TEXT[sources.base_url]},超时{SOURCE_TEXT[sources.timeout]}。 + 桌面版不读取仓库里的 .env 文件,要用自己的网关请在这里填写并保存。 +

+ ) : null}
diff --git a/ui/web/src/views/Shell.tsx b/ui/web/src/views/Shell.tsx index fc97dcc..fc47e6f 100644 --- a/ui/web/src/views/Shell.tsx +++ b/ui/web/src/views/Shell.tsx @@ -1,5 +1,7 @@ import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ApiError } from "../api"; +import { setPngSaver } from "../charts/pngSaver"; +import { inDesktopShell } from "../desktop"; import { type Client, errText, formatSize } from "../client"; import type { Agent, Dataset, DatasetDetail, KeyStatus, Session } from "../types"; import { DatasetView } from "./DatasetView"; @@ -14,7 +16,13 @@ type View = { kind: "home" } | { kind: "dataset"; id: string } | { kind: "sessio const isDict = (f: string) => /\.dict\.csv$/i.test(f); const stem = (f: string) => f.replace(/\.csv$/i, ""); -export function Shell({ c, initialKey }: { c: Client; initialKey: KeyStatus }) { +export function Shell({ c, initialKey, desktop = inDesktopShell() }: { c: Client; initialKey: KeyStatus; desktop?: boolean }) { + // 阶段 6 收尾:桌面壳里导出 PNG 改由后端写进「下载」文件夹(charts/pngSaver.ts);浏览器模式不注册,照旧
+ useEffect(() => { + if (!desktop) return; + setPngSaver(c.savePng); + return () => setPngSaver(null); + }, [c, desktop]); const [datasets, setDatasets] = useState([]); const [sessions, setSessions] = useState([]); const [view, setView] = useState(initialKey.configured ? { kind: "home" } : { kind: "settings" }); diff --git a/ui/web/src/views/views.test.tsx b/ui/web/src/views/views.test.tsx index 7020fe1..87be045 100644 --- a/ui/web/src/views/views.test.tsx +++ b/ui/web/src/views/views.test.tsx @@ -166,6 +166,44 @@ test("设置:超时越界前端拦下,不发请求;合法值以数字发 }); }); +// 阶段 6 收尾(S 10-27):桌面端不读 .env,用户看不出生效的是默认 api.deepseek.com;每项标来源。旧后端没 sources 时不显示 +test("设置:显示每项当前生效值的来源;保存后按返回的 sources 刷新;没有 sources 不显示", async () => { + setup({ + "GET /settings": { model: "deepseek-chat", base_url: "https://api.deepseek.com/v1", timeout: 60, + sources: { model: "default", base_url: "default", timeout: "env" } }, + "PUT /settings": (b: unknown) => ({ model: "qwen", base_url: "https://gw/v1", timeout: 60, ...(b as object), + sources: { model: "settings", base_url: "settings", timeout: "env" } }), + }); + fireEvent.click(await screen.findByRole("button", { name: "设置" })); + const view = await screen.findByTestId("settings-view"); + const src = within(view).getByTestId("settings-sources"); + expect(src.textContent).toContain("模型名是默认值,Base URL 是默认值,超时来自环境变量"); + expect(src.textContent).toContain("桌面版不读取仓库里的 .env 文件"); + await act(async () => fireEvent.click(within(view).getByRole("button", { name: "保存设置" }))); + expect(within(view).getByTestId("settings-sources").textContent).toContain("模型名来自本页设置,Base URL 来自本页设置"); +}); + +test("设置:后端没给 sources(旧版本)时不显示来源行", async () => { + setup(); + fireEvent.click(await screen.findByRole("button", { name: "设置" })); + const view = await screen.findByTestId("settings-view"); + expect(within(view).queryByTestId("settings-sources")).toBeNull(); +}); + +// 阶段 6 收尾:只有桌面壳里注册导出 saver(浏览器模式照旧 ) +test("Shell:desktop 时注册 PNG saver,卸载时撤销;浏览器模式不注册", async () => { + const { getPngSaver } = await import("../charts/pngSaver"); + const be = fakeBackend({ "GET /datasets": [], "GET /sessions": [] }); + const key: KeyStatus = { configured: true, source: "keyring" }; + const a = render(); + expect(getPngSaver()).toBeNull(); + a.unmount(); + const b = render(); + expect(getPngSaver()).toBeTypeOf("function"); + b.unmount(); + expect(getPngSaver()).toBeNull(); +}); + test("删除数据集:输入名称才能确认,写明连带删除的会话数", async () => { // V2a §5.2:数据集是硬删并级联删会话(app.py:356),两次点击改成对话框 + 输入名称 const be = setup({ "DELETE /datasets/d1": { ok: true } });