From 10d72ec7e8a16530f4581bd11b1447b265e13140 Mon Sep 17 00:00:00 2001 From: HalfAnElephant <12142917@qq.com> Date: Thu, 16 Apr 2026 09:58:32 +0800 Subject: [PATCH] feat(release): add windows portable package workflow --- .github/workflows/windows-package.yml | 35 +++++ backend/app/api/routes/conversations.py | 8 +- backend/app/core/config.py | 9 +- backend/app/core/paths.py | 57 +++++++++ backend/app/desktop.py | 121 ++++++++++++++++++ backend/app/main.py | 30 ++++- backend/app/services/conversation_agent.py | 2 +- backend/app/services/file_service.py | 11 +- .../app/services/four_agents/writing_agent.py | 5 +- backend/app/services/writer.py | 5 +- docs/WINDOWS_PORTABLE_PACKAGE.md | 84 ++++++++++++ frontend/src/api.ts | 16 ++- packaging/windows/ResearchFlow.spec | 64 +++++++++ packaging/windows/desktop.env.example | 9 ++ scripts/build_windows_bundle.ps1 | 44 +++++++ 15 files changed, 483 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/windows-package.yml create mode 100644 backend/app/core/paths.py create mode 100644 backend/app/desktop.py create mode 100644 docs/WINDOWS_PORTABLE_PACKAGE.md create mode 100644 packaging/windows/ResearchFlow.spec create mode 100644 packaging/windows/desktop.env.example create mode 100644 scripts/build_windows_bundle.ps1 diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml new file mode 100644 index 0000000..d2fe23f --- /dev/null +++ b/.github/workflows/windows-package.yml @@ -0,0 +1,35 @@ +name: Build Windows Package + +on: + workflow_dispatch: + push: + branches: + - "**" + +jobs: + build-windows-package: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build portable bundle + shell: pwsh + run: .\scripts\build_windows_bundle.ps1 -Clean + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ResearchFlow-windows-portable + path: dist/windows/ResearchFlow-windows-portable.zip + if-no-files-found: error diff --git a/backend/app/api/routes/conversations.py b/backend/app/api/routes/conversations.py index b22fc8f..19238ce 100644 --- a/backend/app/api/routes/conversations.py +++ b/backend/app/api/routes/conversations.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, HTTPException, Query from fastapi.responses import FileResponse, PlainTextResponse +from app.core.config import settings from app.deps import conversation_agent, conversation_repository, evidence_repository, task_repository from app.models.schemas import ( ConversationBulkDeleteResponse, @@ -138,10 +139,11 @@ def export_article(conversation_id: str) -> FileResponse: raise HTTPException(status_code=404, detail="Conversation has no task yet") task_id = summary.taskId - article_path = Path(f"backend/.data/reports/{task_id}_article.md") + reports_dir = Path(settings.reports_dir) + article_path = reports_dir / f"{task_id}_article.md" if not article_path.exists(): # 回退到旧的报告文件 - legacy_path = Path(f"backend/.data/reports/{task_id}.md") + legacy_path = reports_dir / f"{task_id}.md" if not legacy_path.exists(): raise HTTPException(status_code=404, detail="Article file not generated yet") article_path = legacy_path @@ -164,7 +166,7 @@ def export_references(conversation_id: str) -> FileResponse: raise HTTPException(status_code=404, detail="Conversation has no task yet") task_id = summary.taskId - references_path = Path(f"backend/.data/reports/{task_id}_references.md") + references_path = Path(settings.reports_dir) / f"{task_id}_references.md" if not references_path.exists(): raise HTTPException(status_code=404, detail="References file not generated yet") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index c7c2276..fe27bf6 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,10 +1,15 @@ from pydantic_settings import BaseSettings, SettingsConfigDict +from app.core.paths import default_data_dir, default_frontend_dist_dir, default_reports_dir + class Settings(BaseSettings): app_name: str = "Research Flow" api_prefix: str = "/api/v1" - db_path: str = "backend/.data/research_flow.db" + data_dir: str = str(default_data_dir()) + db_path: str = str(default_data_dir() / "research_flow.db") + reports_dir: str = str(default_reports_dir()) + frontend_dist_dir: str = str(default_frontend_dist_dir()) log_level: str = "INFO" use_mock_sources: bool = False default_llm_provider: str = "openrouter" @@ -46,7 +51,7 @@ class Settings(BaseSettings): llm_timeout_medium: int = 60 # 计划生成 llm_timeout_long: int = 120 # 文章生成 - model_config = SettingsConfigDict(env_file=".env", env_prefix="DR_") + model_config = SettingsConfigDict(env_file=".env", env_prefix="DR_", extra="ignore") settings = Settings() diff --git a/backend/app/core/paths.py b/backend/app/core/paths.py new file mode 100644 index 0000000..1c0f3dc --- /dev/null +++ b/backend/app/core/paths.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path + + +APP_DIR_NAME = "ResearchFlow" + + +def is_frozen() -> bool: + return bool(getattr(sys, "frozen", False)) + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def bundle_root() -> Path: + if is_frozen(): + meipass = getattr(sys, "_MEIPASS", "") + if meipass: + return Path(meipass) + return Path(sys.executable).resolve().parent + return repo_root() + + +def default_data_dir() -> Path: + configured = os.getenv("DR_DATA_DIR", "").strip() + if configured: + return Path(configured).expanduser() + + if is_frozen(): + local_appdata = os.getenv("LOCALAPPDATA", "").strip() + if local_appdata: + return Path(local_appdata) / APP_DIR_NAME + return Path.home() / f".{APP_DIR_NAME.lower()}" + + return repo_root() / "backend" / ".data" + + +def default_reports_dir() -> Path: + configured = os.getenv("DR_REPORTS_DIR", "").strip() + if configured: + return Path(configured).expanduser() + return default_data_dir() / "reports" + + +def default_frontend_dist_dir() -> Path: + configured = os.getenv("DR_FRONTEND_DIST_DIR", "").strip() + if configured: + return Path(configured).expanduser() + + if is_frozen(): + return bundle_root() / "frontend_dist" + + return repo_root() / "frontend" / "dist" diff --git a/backend/app/desktop.py b/backend/app/desktop.py new file mode 100644 index 0000000..fc12ef3 --- /dev/null +++ b/backend/app/desktop.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import argparse +import os +import socket +import sys +import threading +import time +import urllib.error +import urllib.request +import webbrowser +from pathlib import Path + +import uvicorn + + +APP_DIR_NAME = "ResearchFlow" + + +def _load_env_file(path: Path) -> None: + if not path.exists(): + return + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip()) + + +def _default_data_dir() -> Path: + local_appdata = os.getenv("LOCALAPPDATA", "").strip() + if local_appdata: + return Path(local_appdata) / APP_DIR_NAME + return Path.home() / f".{APP_DIR_NAME.lower()}" + + +def _bundle_dir() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return Path(__file__).resolve().parents[2] + + +def _pick_port(host: str) -> int: + preferred = os.getenv("DR_PORT", "").strip() + if preferred.isdigit(): + return int(preferred) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind((host, 0)) + sock.listen(1) + return int(sock.getsockname()[1]) + + +def _wait_for_server(url: str, timeout_seconds: float = 20.0) -> None: + deadline = time.time() + timeout_seconds + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=1.5) as response: + if response.status == 200: + return + except (OSError, urllib.error.URLError): + time.sleep(0.25) + raise RuntimeError(f"服务启动超时:{url}") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Launch the packaged Research Flow desktop app.") + parser.add_argument("--host", default="127.0.0.1", help="Bind host for the local HTTP server") + parser.add_argument("--port", type=int, default=None, help="Bind port for the local HTTP server") + parser.add_argument("--no-browser", action="store_true", help="Do not open the browser automatically") + parser.add_argument("--real-mode", action="store_true", help="Disable mock mode and use real providers") + args = parser.parse_args() + + bundle_dir = _bundle_dir() + _load_env_file(bundle_dir / "desktop.env") + + os.environ.setdefault("DR_DATA_DIR", str(_default_data_dir())) + os.environ.setdefault("DR_USE_MOCK_SOURCES", "true") + if args.real_mode: + os.environ["DR_USE_MOCK_SOURCES"] = "false" + + host = args.host + port = args.port or _pick_port(host) + os.environ["DR_PORT"] = str(port) + + from app.main import app + + config = uvicorn.Config( + app, + host=host, + port=port, + log_level="info", + access_log=False, + ) + server = uvicorn.Server(config) + server_thread = threading.Thread(target=server.run, daemon=False) + server_thread.start() + + url = f"http://{host}:{port}" + try: + _wait_for_server(f"{url}/healthz") + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + return 1 + + if not args.no_browser: + webbrowser.open(url) + + print(f"Research Flow is running at {url}") + print("Close this window to stop the local server.") + + try: + server_thread.join() + except KeyboardInterrupt: + server.should_exit = True + server_thread.join(timeout=5) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/app/main.py b/backend/app/main.py index 51f0eea..dc5339a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,10 @@ from contextlib import asynccontextmanager +from pathlib import Path -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles from app.api.router import api_router from app.core.config import settings @@ -27,3 +30,28 @@ async def lifespan(_: FastAPI): @app.get("/healthz") def healthz() -> dict[str, str]: return {"status": "ok"} + + +frontend_dist_dir = Path(settings.frontend_dist_dir) +if frontend_dist_dir.exists(): + resolved_frontend_dist_dir = frontend_dist_dir.resolve() + assets_dir = frontend_dist_dir / "assets" + if assets_dir.exists(): + app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") + + @app.get("/", include_in_schema=False) + def serve_frontend_index() -> FileResponse: + return FileResponse(frontend_dist_dir / "index.html") + + @app.get("/{full_path:path}", include_in_schema=False) + def serve_frontend_app(full_path: str) -> FileResponse: + if full_path.startswith(("api/", "docs", "redoc", "openapi.json", "healthz")): + raise HTTPException(status_code=404, detail="Not found") + candidate = (frontend_dist_dir / full_path).resolve() + try: + candidate.relative_to(resolved_frontend_dist_dir) + except ValueError: + return FileResponse(frontend_dist_dir / "index.html") + if candidate.is_file(): + return FileResponse(candidate) + return FileResponse(frontend_dist_dir / "index.html") diff --git a/backend/app/services/conversation_agent.py b/backend/app/services/conversation_agent.py index 6cd768e..eda1367 100644 --- a/backend/app/services/conversation_agent.py +++ b/backend/app/services/conversation_agent.py @@ -926,7 +926,7 @@ def _persist_report(self, *, task_id: str, content: str) -> None: if task.reportPath: report_path = Path(task.reportPath) else: - report_path = Path("backend/.data/reports") / f"{task_id}.md" + report_path = Path(settings.reports_dir) / f"{task_id}.md" report_path.parent.mkdir(parents=True, exist_ok=True) report_path.write_text(content, encoding="utf-8") self.task_repository.set_report_path(task_id, str(report_path)) diff --git a/backend/app/services/file_service.py b/backend/app/services/file_service.py index d22c427..d9477be 100644 --- a/backend/app/services/file_service.py +++ b/backend/app/services/file_service.py @@ -8,6 +8,8 @@ from fastapi import HTTPException from fastapi.responses import FileResponse +from app.core.config import settings + ReportType = Literal["report", "article", "references"] @@ -15,8 +17,6 @@ class FileService: """Service for handling file operations.""" - REPORTS_DIR = Path("backend/.data/reports") - @classmethod def get_report_path(cls, task_id: str, report_type: ReportType = "report") -> Path: """Get the path to a report file. @@ -28,11 +28,12 @@ def get_report_path(cls, task_id: str, report_type: ReportType = "report") -> Pa Returns: Path to the report file """ + reports_dir = Path(settings.reports_dir) if report_type == "article": - return cls.REPORTS_DIR / f"{task_id}_article.md" + return reports_dir / f"{task_id}_article.md" elif report_type == "references": - return cls.REPORTS_DIR / f"{task_id}_references.md" - return cls.REPORTS_DIR / f"{task_id}.md" + return reports_dir / f"{task_id}_references.md" + return reports_dir / f"{task_id}.md" @classmethod def validate_file_exists(cls, path: Path, detail: str = "File not found") -> None: diff --git a/backend/app/services/four_agents/writing_agent.py b/backend/app/services/four_agents/writing_agent.py index dd3040d..f3e9de6 100644 --- a/backend/app/services/four_agents/writing_agent.py +++ b/backend/app/services/four_agents/writing_agent.py @@ -2,6 +2,7 @@ from __future__ import annotations +from app.core.config import settings from app.models.schemas import AgentType, Evidence from app.services.four_agents.base import AgentContext, AgentResult, BaseAgent from app.services.writer import WriterService @@ -20,11 +21,11 @@ class WritingAgent(BaseAgent): def __init__( self, - output_dir: str = "backend/.data/reports", + output_dir: str | None = None, on_progress=None ) -> None: super().__init__(on_progress) - self.writer = WriterService(output_dir) + self.writer = WriterService(output_dir or settings.reports_dir) async def run(self, context: AgentContext) -> AgentResult: """执行写作阶段任务。 diff --git a/backend/app/services/writer.py b/backend/app/services/writer.py index 4644403..5bf3a06 100644 --- a/backend/app/services/writer.py +++ b/backend/app/services/writer.py @@ -79,8 +79,9 @@ class WriterService: re.compile(r"^\s*研究问题[::].*```(?:yaml|yml)?\s*$", re.IGNORECASE), ) - def __init__(self, output_dir: str = "backend/.data/reports") -> None: - self.output_dir = Path(output_dir) + def __init__(self, output_dir: str | None = None) -> None: + resolved_output_dir = output_dir or settings.reports_dir + self.output_dir = Path(resolved_output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) def write_report( diff --git a/docs/WINDOWS_PORTABLE_PACKAGE.md b/docs/WINDOWS_PORTABLE_PACKAGE.md new file mode 100644 index 0000000..72dd653 --- /dev/null +++ b/docs/WINDOWS_PORTABLE_PACKAGE.md @@ -0,0 +1,84 @@ +# Windows 零依赖打包说明 + +目标:让评委在 Windows 电脑上无需安装 Python、Node.js、uv 等依赖,直接双击即可运行。 + +## 方案 + +- 前端先执行 `vite build`,生成静态页面。 +- FastAPI 在生产模式下直接托管 `frontend/dist`。 +- 使用 PyInstaller 把 `backend/app/desktop.py` 打成 Windows 可执行程序。 +- 桌面入口会自动: + - 选择本机空闲端口 + - 启动本地 HTTP 服务 + - 默认启用 `mock` 演示模式 + - 自动打开浏览器进入应用 + +评委拿到的是一个压缩包,解压后双击 `ResearchFlow.exe` 即可。 + +## 打包产物 + +- 可执行文件目录:`dist/windows/ResearchFlow/` +- 可分发压缩包:`dist/windows/ResearchFlow-windows-portable.zip` + +## 本地 Windows 构建 + +前提: + +- Windows 10/11 +- Python 3.12 +- Node.js 20+ + +执行: + +```powershell +.\scripts\build_windows_bundle.ps1 -Clean +``` + +## GitHub Actions 构建 + +仓库已提供工作流: + +- [`.github/workflows/windows-package.yml`](/Users/xcy/Program/SH-Program/Deep-Research/.github/workflows/windows-package.yml) + +触发后会在 `Actions` 产出一个 artifact: + +- `ResearchFlow-windows-portable` + +下载并解压,把整个目录交给评委即可。 + +## 运行方式 + +评委机器上: + +1. 解压 `ResearchFlow-windows-portable.zip` +2. 双击 `ResearchFlow.exe` +3. 浏览器会自动打开本地地址 + +关闭 `ResearchFlow.exe` 对应的控制台窗口即可停止程序。 + +## 演示模式与真实模式 + +默认行为: + +- 桌面版默认 `DR_USE_MOCK_SOURCES=true` +- 不需要 API Key,适合答辩现场演示 + +如果你想在自己的机器上切换到真实检索/真实模型: + +1. 把 `desktop.env.example` 复制为 `desktop.env` +2. 在同目录下填写 API Key +3. 设置 `DR_USE_MOCK_SOURCES=false` + +桌面入口启动时会自动读取 `desktop.env`。 + +## 数据落盘位置 + +桌面版默认把数据库和导出文件放到: + +- `%LOCALAPPDATA%\ResearchFlow\` + +其中报告输出目录为: + +- `%LOCALAPPDATA%\ResearchFlow\reports\` + +这样不会写回程序目录,避免权限问题。 diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f1ee000..e0c36f6 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -15,7 +15,21 @@ import type { TaskResponse } from "./types"; -const API_BASE = import.meta.env.VITE_API_BASE ?? "http://127.0.0.1:8000"; +function resolveApiBase(): string { + const configured = import.meta.env.VITE_API_BASE?.trim(); + if (configured) { + return configured.replace(/\/$/, ""); + } + if (import.meta.env.DEV) { + return "http://127.0.0.1:8000"; + } + if (typeof window !== "undefined" && window.location.origin) { + return window.location.origin.replace(/\/$/, ""); + } + return "http://127.0.0.1:8000"; +} + +const API_BASE = resolveApiBase(); const API_TIMEOUT_MS = Number(import.meta.env.VITE_API_TIMEOUT_MS ?? "30000"); const PLAN_API_TIMEOUT_MS = Number(import.meta.env.VITE_PLAN_API_TIMEOUT_MS ?? "120000"); diff --git a/packaging/windows/ResearchFlow.spec b/packaging/windows/ResearchFlow.spec new file mode 100644 index 0000000..3f47a81 --- /dev/null +++ b/packaging/windows/ResearchFlow.spec @@ -0,0 +1,64 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from PyInstaller.utils.hooks import collect_submodules + + +project_root = Path(SPECPATH).resolve().parents[1] +backend_dir = project_root / "backend" +frontend_dist_dir = project_root / "frontend" / "dist" +launcher_script = backend_dir / "app" / "desktop.py" + +sys.path.insert(0, str(project_root)) +sys.path.insert(0, str(backend_dir)) + +datas = [] +if frontend_dist_dir.exists(): + datas.append((str(frontend_dist_dir), "frontend_dist")) + +hiddenimports = ( + collect_submodules("app") + + collect_submodules("uvicorn") + + collect_submodules("websockets") +) + + +a = Analysis( + [str(launcher_script)], + pathex=[str(project_root), str(backend_dir)], + binaries=[], + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="ResearchFlow", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, +) + +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + name="ResearchFlow", +) diff --git a/packaging/windows/desktop.env.example b/packaging/windows/desktop.env.example new file mode 100644 index 0000000..77bafb1 --- /dev/null +++ b/packaging/windows/desktop.env.example @@ -0,0 +1,9 @@ +# 桌面版默认启用 mock 演示模式,评委机器无需 API Key 也能运行。 +DR_USE_MOCK_SOURCES=true + +# 如需切换真实模型或检索,可复制本文件为同目录下的 desktop.env 并补充下面配置。 +# DR_USE_MOCK_SOURCES=false +# DR_DEFAULT_LLM_PROVIDER=openai +# DR_OPENAI_API_KEY= +# DR_OPENAI_MODEL=gpt-4.1-mini +# DR_TAVILY_API_KEY= diff --git a/scripts/build_windows_bundle.ps1 b/scripts/build_windows_bundle.ps1 new file mode 100644 index 0000000..c5a1568 --- /dev/null +++ b/scripts/build_windows_bundle.ps1 @@ -0,0 +1,44 @@ +param( + [switch]$Clean +) + +$ErrorActionPreference = "Stop" +$root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + +if ($Clean) { + Remove-Item -Recurse -Force (Join-Path $root "build\pyinstaller") -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force (Join-Path $root "dist\windows") -ErrorAction SilentlyContinue +} + +Push-Location $root +try { + Push-Location (Join-Path $root "frontend") + npm ci + npm run build + Pop-Location + + python -m pip install --upgrade pip + python -m pip install -e ".\backend[dev]" pyinstaller + + $distRoot = Join-Path $root "dist\windows" + New-Item -ItemType Directory -Force -Path $distRoot | Out-Null + + pyinstaller --noconfirm --clean ` + --distpath $distRoot ` + --workpath (Join-Path $root "build\pyinstaller") ` + (Join-Path $root "packaging\windows\ResearchFlow.spec") + + Copy-Item ` + (Join-Path $root "packaging\windows\desktop.env.example") ` + (Join-Path $distRoot "ResearchFlow\desktop.env.example") ` + -Force + + $zipPath = Join-Path $distRoot "ResearchFlow-windows-portable.zip" + if (Test-Path $zipPath) { + Remove-Item -Force $zipPath + } + Compress-Archive -Path (Join-Path $distRoot "ResearchFlow\*") -DestinationPath $zipPath +} +finally { + Pop-Location +}