Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions .github/workflows/desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 那一项。

Expand Down
4 changes: 4 additions & 0 deletions app/server/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)])
Expand Down
45 changes: 43 additions & 2 deletions app/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(前端走 <a download>)。见 app/server/exports.py
GET /settings/api-key {"configured", "source"}——从不返回 Key 本身
PUT /settings/api-key {"api_key"} → 存进系统钥匙串
DELETE /settings/api-key
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)):
Expand Down
104 changes: 104 additions & 0 deletions app/server/exports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""桌面端「导出 PNG」落盘(阶段 6 收尾,S 10-27 本机试装:WebView2 里 data URL + <a download> 没反应)。

壳里没有下载处理器,前端在桌面模式下把 PNG 字节 POST 给后端,由后端写进用户的「下载」文件夹。
只在 app.state.export_dir 不为 None 时启用(app.server.__main__ 带 --desktop 才挂);浏览器模式照旧走 <a download>。

防线(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("同名文件太多")
4 changes: 4 additions & 0 deletions desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
39 changes: 38 additions & 1 deletion desktop/src-tauri/src/sidecar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ pub fn start(exe: &Path, home: &Path) -> Result<Running, String> {
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)]
Expand Down Expand Up @@ -204,6 +204,43 @@ pub fn smoke(out: &Path) -> i32 {
if ok { 0 } else { 1 }
}

/// `--smoke-selftest <out.json>`:把 `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 <pid.txt>`:起 sidecar,把它的 pid 写进文件后一直挂着。CI 用 taskkill /F 结束壳,
/// 再检查 sidecar 是否被 Job Object 一起带走。
pub fn smoke_hold(pid_file: &Path) -> ! {
Expand Down
Loading
Loading