Skip to content
Open
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
66 changes: 66 additions & 0 deletions desktop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import sys
import time
import threading
from pathlib import Path

import httpx
import uvicorn
import webview

import web_server as server_module

# PyInstaller --onefile 会解压到临时目录,退出后清除
# 将可写数据路径重定向到 exe 所在目录,避免每次运行数据丢失
if getattr(sys, "frozen", False):
d = Path(sys.executable).parent
_DATA_PATHS = {
"HISTORY_DIR": "history",
"CONFIG_FILE": ".web_config.json",
"CLIENT_CONFIG_FILE": ".client_config.json",
"ENV_FILE": ".env",
"DESCRIPTION_FILE": "profiles/description.txt",
"RESEARCHER_PROFILE_FILE": "profiles/researcher_profile.md",
"TWITTER_ACCOUNTS_FILE": "profiles/x_accounts.txt",
"SWIPE_FEEDBACK_FILE": "profiles/swipe_feedback.json",
"USERS_DIR": "users",
}
for attr, rel in _DATA_PATHS.items():
setattr(server_module, attr, d / rel)

# 打包后 web_server 用 sys.executable 调用 main.py,实际会重启 exe
# 检测到 "main.py" 参数时,直接调用 main() 函数
if "main.py" in sys.argv:
sys.argv.remove("main.py")
from main import main as run_main
run_main()
sys.exit(0)


def start_server():
uvicorn.run(server_module.app, host="127.0.0.1", port=8090)


def wait_for_server(url: str = "http://127.0.0.1:8090/health", timeout: int = 15):
deadline = time.time() + timeout
while time.time() < deadline:
try:
if httpx.get(url, timeout=1).status_code == 200:
return
except Exception:
pass
time.sleep(0.3)
raise TimeoutError("Server did not start in time")


threading.Thread(target=start_server, daemon=True).start()
wait_for_server()

webview.create_window(
"iDeer",
"http://127.0.0.1:8090",
width=1280,
height=800,
min_size=(960, 600),
)
# webview.start(debug=True)
webview.start()
48 changes: 48 additions & 0 deletions web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@

from fetchers.profile_fetcher import build_profile_text_from_urls

# Fix missing MIME types on Windows
mimetypes.add_type("application/javascript", ".js")
mimetypes.add_type("text/javascript", ".mjs")
mimetypes.add_type("text/css", ".css")
mimetypes.add_type("image/svg+xml", ".svg")
mimetypes.add_type("image/png", ".png")
mimetypes.add_type("image/jpeg", ".jpg")
mimetypes.add_type("image/webp", ".webp")
mimetypes.add_type("font/woff", ".woff")
mimetypes.add_type("font/woff2", ".woff2")


# 项目根目录
PROJECT_ROOT = Path(__file__).parent.absolute()
Expand Down Expand Up @@ -986,23 +997,52 @@ def get_file(source: str, date: str, filename: str):

# ============== Static Files ==============

WEBUI_DIST = PROJECT_ROOT / "webui" / "dist"
WEBUI_INDEX = WEBUI_DIST / "index.html"

@app.get("/health")
def health_check():
return {"status": "ok", "service": "Daily Recommender"}


@app.get("/assets/{path:path}")
def webui_assets(path: str):
if WEBUI_DIST.exists():
file = WEBUI_DIST / "assets" / path
if file.is_file():
media_type, _ = mimetypes.guess_type(str(file))
return FileResponse(file, media_type=media_type or "application/octet-stream")
return JSONResponse({"error": "Not found"}, status_code=404)


@app.get("/icons/{path:path}")
def webui_icons(path: str):
if WEBUI_DIST.exists():
file = WEBUI_DIST / "icons" / path
if file.is_file():
media_type, _ = mimetypes.guess_type(str(file))
return FileResponse(file, media_type=media_type or "application/octet-stream")
return JSONResponse({"error": "Not found"}, status_code=404)


@app.get("/")
def root():
if WEBUI_INDEX.exists():
return FileResponse(WEBUI_INDEX)
return FileResponse(PUBLIC_UI_FILE)


@app.get("/public")
def public_web_ui():
if WEBUI_INDEX.exists():
return FileResponse(WEBUI_INDEX)
return FileResponse(PUBLIC_UI_FILE)


@app.get("/admin")
def admin_web_ui():
if WEBUI_INDEX.exists():
return FileResponse(WEBUI_INDEX)
return FileResponse(ADMIN_UI_FILE)


Expand Down Expand Up @@ -1034,6 +1074,14 @@ def legacy_admin_web_ui():
return FileResponse(ADMIN_UI_FILE)


# SPA fallback: serve React index.html for unrecognized routes when webui dist exists
@app.get("/{path:path}")
def spa_fallback(path: str):
if WEBUI_INDEX.exists():
return FileResponse(WEBUI_INDEX)
return JSONResponse({"error": "Not found"}, status_code=404)


# ============== Scheduler ==============

_scheduler_task: asyncio.Task | None = None
Expand Down
149 changes: 149 additions & 0 deletions webui/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# iDeer Web UI

iDeer 的浏览器端前端,基于 React 构建,替代原有的 HTML 内嵌模板。

## 技术栈

- React 19 + TypeScript
- Vite 6
- Tailwind CSS 3.4
- React Router 7

## 开发

```bash
# 安装依赖
npm install

# 启动开发服务器(默认 http://localhost:5173)
npm run dev

# 构建
npm run build

# 预览构建结果
npm run preview
```

开发服务器通过 Vite proxy 将 `/api` 和 `/ws` 请求转发到后端 `localhost:8090`,需要先启动后端服务(`python web_server.py`)。

## 打包为桌面客户端

使用 PyWebView + PyInstaller 将前后端打包为单个可执行文件。

### 依赖

```bash
pip install pywebview pyinstaller
```

### 打包步骤

```bash
# 1. 构建前端
cd webui && npm run build && cd ..

# 2. 打包为 exe(在项目根目录执行)
pyinstaller --onefile --windowed --name iDeer --add-data "webui/dist;webui/dist" desktop.py
```

打包产物在 `dist/iDeer.exe`。

### 原理

- `desktop.py` 在后台线程启动 FastAPI 后端,等待 `/health` 就绪后用 PyWebView 创建原生窗口加载 `http://127.0.0.1:8090`
- `web_server.py` 检测到 `webui/dist` 存在时,自动优先 serve React 构建产物(否则回退到旧模板)
- 静态资源(JS/CSS/SVG)放在 `public/` 目录下,构建后路径不变,避免 hash 问题

### 与 Tauri 的对比

| 维度 | PyWebView + PyInstaller | Tauri |
| ---------- | ------------------------------ | ----------------------------|
| 后端语言 | Python | Rust |
| 打包体积 | ~40-80 MB(含 Python 运行时) | ~5-10 MB |
| 窗口渲染 | 系统 WebView(Edge / WKWebView)| 系统 WebView |
| 启动速度 | 较慢(Python 解释器初始化) | 相对快 |


### 当前状态

PyWebView 方案已测试通过,可以正常打包和运行。

### 可能可以尝试的其他方案

- PyInstaller + Pake:用 PyInstaller 打包 Python 后端,再用 Pake 将前端包裹为轻量桌面客户端,两个进程独立运行,体积更小
- Rust 重写 web_server + CLI:用 Rust 重写后端 API 服务和 CLI 调用逻辑,核心算法保留为 Python 包供 Rust 侧调用(通过 PyO3 或子进程),兼顾体积和启动速度

## 页面结构

| 路由 | 说明 | 状态 |
| ---------- | -------------------- | -------- |
| `/` | 公开页 -- 触发运行和查看结果 | 已完成 |
| `/admin` | 管理后台 -- 配置/控制台/历史 | 已完成 |

## 项目结构

```
src/
App.tsx # 路由入口
main.tsx # 应用入口
index.css # Tailwind 基础样式 + 自定义 CSS
hooks/ # 跨页面共享 Hook
useToast.tsx # 通知状态(Context)
useWebSocket.ts # WebSocket 连接管理
components/ # 跨页面共享组件
BackendStatus.tsx # 后端连接状态横幅
MarkdownRender.tsx # Markdown 渲染
Toast.tsx # 通知组件
lib/
api.ts # HTTP / WebSocket 通信层
types.ts # 类型定义(含 AdminConfig / HistoryEntry 等)
constants.ts # 常量(含 TYPE_COLORS / TYPE_LABELS 等)
utils.ts # 工具函数
pages/
public/
PublicPage.tsx # 公开页主组件
hooks/
useMeta.ts # 公开页元信息 + 后端健康检查
components/
Header.tsx # 页头 + 模式切换
HeroSection.tsx # 标题区域
MailWarning.tsx # SMTP 未配置警告
SendForm.tsx # Quick / Custom 发送表单
SourceSelection.tsx # 数据源选择
SourceCard.tsx # 数据源卡片
DeliveryToggle.tsx # 投递方式切换
RunProgress.tsx # 运行进度条
ResultPanel.tsx # 结果面板
FileCard.tsx # 文件卡片
admin/ # 管理后台
AdminPage.tsx # 顶层编排(Tab 切换 + Hook 调用)
hooks/
useConfig.ts # 配置加载/编辑/保存
useHistory.ts # 历史记录加载
useRunState.ts # WebSocket 运行生命周期
components/
Header.tsx # 导航栏 + Tab 按钮
config/ # 配置 Tab
ConfigView.tsx # 配置容器 + 保存按钮
LLMConfig.tsx # LLM 配置(Provider / Model / Key 等)
EmailConfig.tsx # 邮件配置(SMTP)
SourceConfig.tsx # 信息源配置(GitHub / HF / Twitter / arXiv / SS)
InterestConfig.tsx # 兴趣描述
ProfileConfig.tsx # 研究者画像
ScheduleConfig.tsx # 定时推送
dashboard/ # 控制台 Tab
DashboardView.tsx # 控制台容器
QuickActions.tsx # 快速运行 / 生成报告 / 研究想法
SourceSelection.tsx # 数据源选择网格
SourceCard.tsx # 数据源卡片(含配置摘要)
RunPanel.tsx # 运行进度 + 日志 + 取消
ResultsPanel.tsx # 运行结果展示
records/ # 历史 Tab
HistoryView.tsx # 历史记录容器 + 筛选
HistoryList.tsx # 历史卡片列表
ResultModal.tsx # 结果详情弹窗
public/ # 静态资源(不经过 Vite 处理,原样复制到 dist)
icons/
icon_ideer.svg # 应用图标
```
13 changes: 13 additions & 0 deletions webui/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>iDeer</title>
<link rel="icon" type="image/svg+xml" href="/icons/icon_ideer.svg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Loading