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
35 changes: 35 additions & 0 deletions .github/workflows/windows-package.yml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 5 additions & 3 deletions backend/app/api/routes/conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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")

Expand Down
9 changes: 7 additions & 2 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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()
57 changes: 57 additions & 0 deletions backend/app/core/paths.py
Original file line number Diff line number Diff line change
@@ -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"
121 changes: 121 additions & 0 deletions backend/app/desktop.py
Original file line number Diff line number Diff line change
@@ -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())
30 changes: 29 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
2 changes: 1 addition & 1 deletion backend/app/services/conversation_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
11 changes: 6 additions & 5 deletions backend/app/services/file_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@
from fastapi import HTTPException
from fastapi.responses import FileResponse

from app.core.config import settings


ReportType = Literal["report", "article", "references"]


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.
Expand All @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions backend/app/services/four_agents/writing_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
"""执行写作阶段任务。
Expand Down
5 changes: 3 additions & 2 deletions backend/app/services/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading