feat(client): add Tauri 2.x desktop client with sidecar process management

- Tauri 2.x project scaffold with dual-window (splash + main)
- Rust sidecar management: spawn/kill Python backend, port discovery via stdout
- CancellationToken for graceful task cancellation on exit
- System tray with show/quit, close-to-tray behavior
- Frontend: dynamic baseURL, SplashScreen, TitleBar, Tauri IPC adapter
- PyInstaller build scripts for cross-platform sidecar packaging
- GitHub Actions CI for Win/Mac/Linux release builds
- CSP security policy, proper capabilities configuration
This commit is contained in:
chiguyong 2026-06-14 10:06:12 +08:00
parent 321c2333d6
commit bc43b962c7
30 changed files with 7357 additions and 69 deletions

110
.github/workflows/release-desktop.yml vendored Normal file
View File

@ -0,0 +1,110 @@
name: Release Desktop Client
on:
push:
tags: ['v*']
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: macos-latest
target: aarch64-apple-darwin
- platform: macos-latest
target: x86_64-apple-darwin
- platform: windows-latest
target: x86_64-pc-windows-msvc
- platform: ubuntu-22.04
target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install Linux dependencies
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Python dependencies
run: |
pip install -e ".[server]" pyinstaller
- name: Build Python sidecar
shell: bash
run: |
mkdir -p src-tauri/binaries
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
python -m PyInstaller --onefile \
--name "agentkit-server-${{ matrix.target }}.exe" \
--hidden-import agentkit.server \
--hidden-import agentkit.server.app \
--hidden-import agentkit.server.routes \
--hidden-import agentkit.server.config \
--hidden-import agentkit.cli.main \
--hidden-import uvicorn.logging \
--hidden-import uvicorn.lifespan.on \
--hidden-import uvicorn.lifespan.off \
--hidden-import uvicorn.protocols.websockets.auto \
--hidden-import uvicorn.protocols.http.auto \
--hidden-import sse_starlette \
--distpath src-tauri/binaries \
--workpath build/pyinstaller-work \
--specpath build \
src/agentkit/__main__.py
else
python -m PyInstaller --onefile \
--name "agentkit-server-${{ matrix.target }}" \
--hidden-import agentkit.server \
--hidden-import agentkit.server.app \
--hidden-import agentkit.server.routes \
--hidden-import agentkit.server.config \
--hidden-import agentkit.cli.main \
--hidden-import uvicorn.logging \
--hidden-import uvicorn.lifespan.on \
--hidden-import uvicorn.lifespan.off \
--hidden-import uvicorn.protocols.websockets.auto \
--hidden-import uvicorn.protocols.http.auto \
--hidden-import sse_starlette \
--distpath src-tauri/binaries \
--workpath build/pyinstaller-work \
--specpath build \
src/agentkit/__main__.py
fi
- name: Install frontend dependencies
working-directory: src/agentkit/server/frontend
run: npm ci
- name: Install Rust dependencies
working-directory: src-tauri
run: cargo fetch
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: ${{ github.ref_name }}
releaseName: 'Fischer AgentKit ${{ github.ref_name }}'
releaseBody: 'See the assets below to download and install.'
releaseDraft: true
prerelease: false

15
.gitignore vendored
View File

@ -23,3 +23,18 @@ htmlcov/
# OS # OS
.DS_Store .DS_Store
# Tauri
src-tauri/target/
src-tauri/gen/
src-tauri/binaries/agentkit-server-*
# PyInstaller
build/pyinstaller-work/
*.spec
# Frontend build artifacts
src/agentkit/server/static/
# Env
.env

View File

@ -0,0 +1,405 @@
---
date: "2026-06-13"
status: active
depth: standard
origin: null
---
# feat: Tauri 2.x Desktop Client
## Summary
创建 Tauri 2.x 桌面客户端外壳,将现有 Vue 3 GUI 包装为跨平台桌面应用Windows/macOS/Linux。Tauri 管理窗口和 Python 后端进程生命周期,前端代码仅做最小适配(动态 API baseURL + Splash 启动流程),不修改任何 GUI 组件、布局、样式或业务逻辑。
## Problem Frame
当前 AgentKit GUI 只能通过浏览器访问(`agentkit gui` 启动后自动打开浏览器),用户需要:
1. 手动启动后端进程
2. 在浏览器中操作,缺乏原生体验(无系统托盘、无桌面集成、无自动更新)
3. 无法像桌面应用一样安装、启动、关闭
竞品Cursor、Devin均提供原生桌面客户端。AgentKit 需要对齐这一体验标准。
## Key Technical Decisions
**KTD-1: Tauri 2.x 作为桌面壳。** Tauri 使用系统 WebView不打包 Chromium安装包 ~5-10MB + PyInstaller 产物 ~30-80MB总计远小于 Electron 方案(~200MB+)。启动快(窗口 <500ms内存低~110-200MB vs Electron ~300-500MBTauri 2.x 插件化架构更灵活capabilities 权限模型更安全且是长期维护版本
**KTD-2: PyInstaller 打包 Python 后端为 sidecar。** PyInstaller 将 Python 后端打包为独立单文件二进制Tauri 通过 `tauri-plugin-shell` sidecar API 管理其生命周期。比 Nuitka 兼容性更好,社区更成熟。
**KTD-3: 端口 0 + stdout 解析实现端口发现。** Python 后端以 `--port 0` 启动OS 分配随机可用端口uvicorn 启动后通过 stdout 输出 `AGENTKIT_PORT=XXXXX`Rust 侧解析后通过 Tauri IPC 传递给前端。避免端口冲突,无需用户配置。
**KTD-4: 前端最小适配策略。** 仅修改 `api/base.ts`(动态 baseURL`App.vue`Splash 启动流程),新增 `api/tauri.ts`Tauri invoke 封装)和 `TitleBar.vue`(自定义标题栏)。所有现有 GUI 组件、布局、样式、业务逻辑不做任何修改。
**KTD-5: 双窗口 Splash 方案。** Tauri 配置两个窗口splash小窗口加载动画和 main主窗口初始隐藏。后端就绪后 Rust 侧关闭 splash、显示 main。比前端内 Splash 更干净,不侵入现有 App.vue 的布局逻辑。
## High-Level Technical Design
```
┌──────────────────────────────────────────────────────┐
│ Tauri Application (Rust) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ Splash │ │ Main Window │ │ System Tray │ │
│ │ (loading) │ │ (Vue 3 SPA) │ │ (minimize) │ │
│ └─────────────┘ └──────┬───────┘ └─────────────┘ │
│ │ HTTP/WS │
│ ┌───────────────────────▼──────────────────────────┐│
│ │ Python Backend (sidecar process) ││
│ │ FastAPI + uvicorn on random port ││
│ └──────────────────────────────────────────────────┘│
│ │
│ Rust Modules: │
│ - sidecar.rs: start/stop/health Python process │
│ - tray.rs: system tray menu │
│ - lib.rs: Tauri Builder + plugin registration │
└──────────────────────────────────────────────────────┘
```
**启动序列:**
```
1. 用户双击桌面图标 → Tauri 启动
2. Rust setup() → 创建系统托盘
3. Splash 窗口显示(加载动画)
4. 前端 invoke('start_backend')
5. Rust spawn sidecar: agentkit-server --port 0
6. Python 绑定随机端口 → stdout: AGENTKIT_PORT=XXXXX
7. Rust 解析端口 → 存入 BackendState → emit('backend-ready')
8. 前端设置 apiBaseURL → 健康检查通过
9. Rust 关闭 splash → 显示 main 窗口
```
**关闭序列:**
```
1. 用户点击关闭按钮 → 窗口隐藏到托盘(不退出)
2. 托盘菜单"退出" → Rust 发送 SIGTERM 给 Python 进程
3. Python 优雅关闭 → Tauri app.exit(0)
```
## Scope Boundaries
**在范围内:**
- Tauri 2.x 项目脚手架和配置
- Rust 侧 sidecar 进程管理(启动/停止/健康检查/端口发现)
- 前端最小适配api/base.ts 动态 baseURL、api/tauri.ts 封装、App.vue Splash 流程、TitleBar.vue
- 系统托盘(显示/隐藏窗口、退出)
- 单实例锁
- 自定义标题栏(无边框窗口)
- PyInstaller 打包脚本
- 跨平台 CI 构建GitHub Actions
**延迟到后续迭代:**
- 自动更新器tauri-plugin-updater 集成 + 签名密钥 + 发布服务器)
- 应用签名和公证macOS code signing / Windows Authenticode
- 多语言支持
- 离线模式
- 深度链接deep link / URL scheme 注册)
- 文件关联
**不在范围内:**
- 修改现有 GUI 组件、布局、样式
- 修改后端 API 或业务逻辑
- 移动端适配Tauri 2.x 支持 iOS/Android但不在本次范围
- 多用户协作
## Implementation Units
### U1. Tauri 2.x 项目脚手架
**Goal:** 创建 Tauri 2.x 项目结构,配置构建管线,验证 dev 模式可运行。
**Requirements:** KTD-1, KTD-4
**Dependencies:** 无
**Files:**
- `src-tauri/Cargo.toml` — Rust 依赖tauri 2, tauri-plugin-shell, tauri-plugin-single-instance, serde, tokio, reqwest
- `src-tauri/tauri.conf.json` — Tauri 核心配置窗口、sidecar 声明、bundle 设置)
- `src-tauri/capabilities/default.json` — 权限声明
- `src-tauri/src/main.rs` — Rust 入口
- `src-tauri/src/lib.rs` — Tauri Builder占位后续单元填充
- `src-tauri/build.rs` — 构建脚本
- `src-tauri/icons/` — 应用图标(占位)
- `package.json` — 添加 Tauri CLI 和 API 依赖
**Approach:**
1. 在项目根目录初始化 Tauri 2.x`npm create tauri-app@latest` 或手动创建 `src-tauri/` 目录
2. `tauri.conf.json` 配置双窗口splash + mainmain 初始 `visible: false`
3. `build.beforeDevCommand` 指向前端 dev 脚本,`build.beforeBuildCommand` 指向前端 build 脚本
4. `build.frontendDist` 指向前端构建输出目录
5. `bundle.externalBin` 声明 sidecar 二进制名
6. 安装前端依赖:`@tauri-apps/api@^2`, `@tauri-apps/plugin-shell@^2`
7. 验证 `npm run tauri dev` 能启动窗口
**Patterns to follow:** Tauri 2.x 官方项目结构
**Test scenarios:**
- `npm run tauri dev` 启动后显示 Tauri 窗口(前端内容可能为空,因为后端未启动)
- `npm run tauri build` 能生成安装包(可能因缺少 sidecar 二进制而失败,此为预期)
**Verification:** `npm run tauri dev` 能启动 Tauri 窗口并加载前端页面
---
### U2. Rust Sidecar 进程管理
**Goal:** 实现 Python 后端进程的启动、停止、健康检查和端口发现。
**Requirements:** KTD-2, KTD-3
**Dependencies:** U1
**Files:**
- `src-tauri/src/sidecar.rs` — sidecar 管理模块(新建)
- `src-tauri/src/lib.rs` — 注册 invoke handler 和 BackendState
- `src-tauri/Cargo.toml` — 添加 `regex` 依赖
**Approach:**
1. 定义 `BackendState` 结构体:`port: Mutex<Option<u16>>`, `pid: Mutex<Option<u32>>`
2. 实现 `start_backend` 命令:
- 通过 `app.shell().sidecar("agentkit-server")` 创建 sidecar 命令
- 传入 `--port 0``AGENTKIT_GUI_MODE=1` 环境变量
- spawn 后监听 stdout/stderr用正则解析 `AGENTKIT_PORT=(\d+)`
- 端口就绪后 emit `backend-ready` 事件
- 带超时等待30s
3. 实现 `get_backend_port` 命令:返回当前端口
4. 实现 `stop_backend` 命令:发送 SIGTERMUnix或 taskkillWindows
5. 实现 `check_backend_health` 命令HTTP GET `http://127.0.0.1:{port}/api/v1/health`
6. 在 `lib.rs` 中注册所有命令和 `BackendState` managed state
**Technical design:**
```
start_backend() flow:
sidecar("agentkit-server").args(["--port", "0"]).env("AGENTKIT_GUI_MODE", "1")
→ spawn → tokio::spawn listen stdout/stderr
→ parse AGENTKIT_PORT=XXXXX → store in BackendState.port
→ emit("backend-ready", { port }) → return port
stop_backend() flow:
BackendState.pid → SIGTERM/taskkill → clear port & pid
```
**Patterns to follow:** `tauri-plugin-shell` sidecar API 文档
**Test scenarios:**
- 启动 sidecar 后 `get_backend_port` 返回有效端口号
- `check_backend_health` 在后端就绪后返回 true
- `stop_backend` 能成功终止 Python 进程
- 后端启动超时30s时返回错误
- 后端意外崩溃时 Rust 侧能检测到并清理状态
**Verification:** 手动测试 `invoke('start_backend')``invoke('get_backend_port')``invoke('check_backend_health')``invoke('stop_backend')` 全流程
---
### U3. 前端适配 — 动态 API baseURL + Splash 启动流程
**Goal:** 前端支持 Tauri 环境,动态设置 API baseURL添加 Splash 启动流程。
**Requirements:** KTD-3, KTD-4, KTD-5
**Dependencies:** U2
**Files:**
- `src/agentkit/server/frontend/src/api/base.ts` — 修改:支持 Tauri 环境动态 baseURL
- `src/agentkit/server/frontend/src/api/tauri.ts` — 新增Tauri invoke 封装
- `src/agentkit/server/frontend/src/App.vue` — 修改:添加 Splash 启动流程
- `src/agentkit/server/frontend/src/components/layout/SplashScreen.vue` — 新增Splash 加载组件
- `src/agentkit/server/frontend/src/components/layout/TitleBar.vue` — 新增:自定义标题栏
- `src/agentkit/server/frontend/src/views/AgentLayout.vue` — 修改:集成 TitleBar
- `src/agentkit/server/frontend/vite.config.ts` — 修改Tauri 环境适配
**Approach:**
1. **`api/tauri.ts`**:封装 Tauri invoke 调用
- `isTauri()` 检测是否在 Tauri 环境中(`'__TAURI_INTERNALS__' in window`
- `startBackend()``invoke('start_backend')`
- `getBackendPort()``invoke('get_backend_port')`
- `stopBackend()``invoke('stop_backend')`
- `checkBackendHealth()``invoke('check_backend_health')`
2. **`api/base.ts`**:动态 baseURL
- 新增 `initApiBaseURL()` 函数
- Tauri 环境:从 `getBackendPort()` 获取端口,设置 `apiBaseURL = http://127.0.0.1:{port}`
- 浏览器环境:`apiBaseURL = ''`(保持现有相对路径行为)
- `BaseApiClient``request()``createWebSocket()` 方法使用动态 baseURL
3. **`App.vue`**Splash 启动流程
- 新增 `loading``loadingProgress` 响应式状态
- `onMounted` 时:如果 `isTauri()`,调用 `startBackend()``initApiBaseURL()``checkBackendHealth()`
- 加载完成前显示 `SplashScreen`,完成后显示 `RouterView`
- 浏览器环境保持现有行为不变
4. **`SplashScreen.vue`**:加载动画组件
- 品牌 Logo + 加载进度条 + 状态文字("正在启动后端..."、"正在连接..."
- 使用现有 Design Token 变量
5. **`TitleBar.vue`**:自定义标题栏
- `data-tauri-drag-region` 使标题栏可拖拽
- 最小化/最大化/关闭按钮(关闭时隐藏到托盘)
- 仅在 Tauri 环境中显示
6. **`vite.config.ts`**Tauri 适配
- 读取 `TAURI_DEV_HOST` 环境变量
- `envPrefix` 添加 `TAURI_`
- `server.watch.ignored` 排除 `src-tauri/`
**Patterns to follow:** 现有 `api/base.ts``BaseApiClient` 模式;现有 Design Token 体系
**Test scenarios:**
- 浏览器模式:所有 API 调用正常工作(相对路径不变)
- Tauri 模式:`initApiBaseURL()` 设置正确的 `http://127.0.0.1:{port}`
- Tauri 模式WebSocket 连接到正确的 `ws://127.0.0.1:{port}/api/v1/...`
- Splash 在后端就绪前显示,就绪后消失
- TitleBar 拖拽移动窗口,按钮功能正确
- 后端启动失败时显示错误信息
**Verification:** `npm run tauri dev` 启动后Splash 显示 → 后端启动 → 主界面加载 → API 调用正常
---
### U4. 系统托盘 + 单实例 + 窗口管理
**Goal:** 实现系统托盘、单实例锁和窗口关闭行为(最小化到托盘)。
**Requirements:** KTD-1
**Dependencies:** U2
**Files:**
- `src-tauri/src/tray.rs` — 新增:系统托盘模块
- `src-tauri/src/lib.rs` — 修改:注册托盘、单实例插件、窗口事件
- `src-tauri/Cargo.toml` — 添加 `tauri-plugin-single-instance`
**Approach:**
1. **系统托盘**`tray.rs`
- 菜单项:显示主窗口 / 分隔线 / 退出
- 左键点击托盘图标:显示并聚焦主窗口
- 退出菜单项:先 `stop_backend()`,再 `app.exit(0)`
2. **单实例锁**
- 注册 `tauri-plugin-single-instance`
- 第二个实例启动时:聚焦已有主窗口
3. **窗口关闭行为**
- `CloseRequested` 事件中 `api.prevent_close()` + `window.hide()`
- 窗口隐藏到托盘而非退出
4. **Splash → Main 切换**
- `start_backend` 成功后Rust 侧关闭 splash 窗口、显示 main 窗口
**Patterns to follow:** Tauri 2.x tray API 文档
**Test scenarios:**
- 点击关闭按钮后窗口隐藏,托盘图标仍在
- 托盘"显示"菜单恢复窗口
- 托盘"退出"菜单停止后端并退出应用
- 左键点击托盘图标恢复窗口
- 启动第二个实例时聚焦已有窗口
**Verification:** 完整的窗口生命周期测试:启动 → 关闭到托盘 → 从托盘恢复 → 托盘退出
---
### U5. PyInstaller 打包脚本 + Python 侧适配
**Goal:** 创建 PyInstaller 打包脚本Python 后端支持 `--port 0` 并输出端口信息。
**Requirements:** KTD-2, KTD-3
**Dependencies:** U2
**Files:**
- `scripts/build-backend.sh` — 新增PyInstaller 打包脚本
- `scripts/build-backend.ps1` — 新增Windows 版打包脚本
- `src/agentkit/cli/main.py` — 修改:`gui` 命令支持 `--port 0`
- `src/agentkit/server/runner.py` — 修改uvicorn 启动后输出 `AGENTKIT_PORT=XXXXX`
**Approach:**
1. **Python 侧适配**
- `gui` 命令的 `--port` 参数支持 `0`(让 OS 分配随机端口)
- uvicorn 启动后,在 lifespan startup 中检测实际绑定端口
- 通过 stdout 输出 `AGENTKIT_PORT={port}`flush=True供 Rust 解析
- 当 `--port 0`host 默认改为 `127.0.0.1`(仅本地访问,安全)
2. **PyInstaller 打包脚本**
- `--onefile` 模式打包
- `--name agentkit-server` 命名
- 包含必要的 `--hidden-import`uvicorn.logging, agentkit.server 等)
- `--add-data` 包含 agentkit.yaml 和 configs 目录
- 输出到 `src-tauri/binaries/` 并添加平台后缀
3. **平台后缀命名**
- macOS ARM: `agentkit-server-aarch64-apple-darwin`
- macOS Intel: `agentkit-server-x86_64-apple-darwin`
- Windows: `agentkit-server-x86_64-pc-windows-msvc.exe`
- Linux: `agentkit-server-x86_64-unknown-linux-gnu`
**Patterns to follow:** 现有 `agentkit gui` 命令的启动流程
**Test scenarios:**
- `python -m agentkit gui --port 0` 启动后 stdout 输出 `AGENTKIT_PORT=XXXXX`
- PyInstaller 打包的二进制能独立运行
- 打包的二进制支持 `--port 0``AGENTKIT_GUI_MODE=1`
- 打包的二进制在无 Python 环境的机器上能运行
**Verification:** 在当前平台执行 `scripts/build-backend.sh`,产物放入 `src-tauri/binaries/``npm run tauri dev` 能通过 sidecar 启动后端
---
### U6. 跨平台 CI 构建
**Goal:** 配置 GitHub Actions 自动构建 Windows/macOS/Linux 安装包。
**Requirements:** KTD-1
**Dependencies:** U1, U5
**Files:**
- `.github/workflows/release-desktop.yml` — 新增:桌面客户端发布工作流
**Approach:**
1. 触发条件:推送 `v*` tag
2. 构建矩阵macOS (aarch64 + x86_64)、Windows (x86_64)、Linux (x86_64)
3. 每个平台步骤:
- Setup Node.js 20 + Rust stable + Python 3.11
- 安装 Linux 系统依赖libwebkit2gtk-4.1-dev 等)
- `pip install pyinstaller` + 执行 `scripts/build-backend.sh`
- `npm ci` + `npm run tauri build`
4. 使用 `tauri-apps/tauri-action@v0` 构建和发布
5. 产物上传到 GitHub Releasedraft
**Patterns to follow:** Tauri 官方 GitHub Actions 模板
**Test scenarios:**
- 推送 tag 后工作流在所有 4 个目标上成功运行
- 产物正确上传到 GitHub Release
**Verification:** 手动触发工作流或推送 tag检查 GitHub Release 页面
---
## Risks & Dependencies
| 风险 | 影响 | 缓解 |
|------|------|------|
| PyInstaller 打包后部分依赖缺失 | 后端无法启动 | 逐步测试,添加 `--hidden-import` |
| Linux WebView 兼容性差异 | GUI 渲染异常 | 测试主流发行版Ubuntu 22.04+),文档说明依赖 |
| Python sidecar 启动慢3-5s | 用户等待时间长 | Splash 加载画面 + 进度反馈 |
| macOS 签名/公证缺失 | 用户安装时被 Gatekeeper 阻止 | 延迟到后续迭代,文档说明绕过方法 |
| Tauri 2.x API 变更 | 代码需调整 | 锁定 Tauri 2.x 稳定版 |
## Assumptions
- 用户机器上有 WebView2Windows 10+ 自带、WebKitmacOS 自带、WebKitGTKLinux 需安装)
- Python 后端不需要 GPU 加速或其他特殊硬件
- 单用户本地使用场景,不需要多用户认证
- 首个版本不需要应用签名和公证
## Open Questions
- 自动更新服务器的部署方案S3 / GitHub Releases / 自建)— 延迟到后续迭代
- 是否需要支持 Linux ARM64 — 视用户需求决定
- PyInstaller 产物体积优化(是否排除不必要的依赖)— 实现时评估

44
scripts/build-backend.ps1 Normal file
View File

@ -0,0 +1,44 @@
# Build Python backend as standalone binary using PyInstaller (Windows)
# Usage: .\scripts\build-backend.ps1 [target-triple]
param(
[string]$TargetTriple = "x86_64-pc-windows-msvc"
)
$ErrorActionPreference = "Stop"
$ProjectRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
Write-Host "Building agentkit-server for $TargetTriple..."
Set-Location $ProjectRoot
# Install PyInstaller if not present
pip install pyinstaller 2>$null
# Build the binary
pyinstaller --onefile `
--name "agentkit-server.exe" `
--hidden-import agentkit.server `
--hidden-import agentkit.server.app `
--hidden-import agentkit.server.routes `
--hidden-import agentkit.server.config `
--hidden-import agentkit.cli.main `
--hidden-import uvicorn.logging `
--hidden-import uvicorn.lifespan.on `
--hidden-import uvicorn.lifespan.off `
--hidden-import uvicorn.protocols.websockets.auto `
--hidden-import uvicorn.protocols.http.auto `
--hidden-import uvicorn.protocols.websockets.wsproto_impl `
--hidden-import uvicorn.protocols.websockets.websockets_impl `
--hidden-import uvicorn.protocols.websockets.impl_11 `
--hidden-import uvicorn.lifespan `
--hidden-import sse_starlette `
--distpath "$ProjectRoot\src-tauri\binaries" `
--workpath "$ProjectRoot\build\pyinstaller-work" `
--specpath "$ProjectRoot\build" `
src\agentkit\__main__.py
# Rename with target triple suffix
Rename-Item "$ProjectRoot\src-tauri\binaries\agentkit-server.exe" "agentkit-server-$TargetTriple.exe"
Write-Host "Built: src-tauri\binaries\agentkit-server-$TargetTriple.exe"

81
scripts/build-backend.sh Executable file
View File

@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Build Python backend as standalone binary using PyInstaller
# Usage: ./scripts/build-backend.sh [target-triple]
# Example: ./scripts/build-backend.sh aarch64-apple-darwin
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TARGET_TRIPLE="${1:-}"
# Detect platform triple if not provided
if [ -z "$TARGET_TRIPLE" ]; then
OS="$(uname -s)"
ARCH="$(uname -m)"
case "$OS" in
Darwin)
if [ "$ARCH" = "arm64" ]; then
TARGET_TRIPLE="aarch64-apple-darwin"
else
TARGET_TRIPLE="x86_64-apple-darwin"
fi
;;
Linux)
TARGET_TRIPLE="x86_64-unknown-linux-gnu"
;;
MINGW*|MSYS*|CYGWIN*)
TARGET_TRIPLE="x86_64-pc-windows-msvc"
;;
*)
echo "Unsupported OS: $OS"
exit 1
;;
esac
fi
echo "Building agentkit-server for $TARGET_TRIPLE..."
cd "$PROJECT_ROOT"
# Install PyInstaller if not present
pip install pyinstaller 2>/dev/null || pip3 install pyinstaller 2>/dev/null
# Build the binary
BINARY_NAME="agentkit-server"
if [[ "$TARGET_TRIPLE" == *"-windows-"* ]]; then
BINARY_NAME="agentkit-server.exe"
fi
pyinstaller --onefile \
--name "$BINARY_NAME" \
--hidden-import agentkit.server \
--hidden-import agentkit.server.app \
--hidden-import agentkit.server.routes \
--hidden-import agentkit.server.config \
--hidden-import agentkit.cli.main \
--hidden-import uvicorn.logging \
--hidden-import uvicorn.lifespan.on \
--hidden-import uvicorn.lifespan.off \
--hidden-import uvicorn.protocols.websockets.auto \
--hidden-import uvicorn.protocols.http.auto \
--hidden-import uvicorn.protocols.websockets.wsproto_impl \
--hidden-import uvicorn.protocols.websockets.websockets_impl \
--hidden-import uvicorn.protocols.websockets.impl_11 \
--hidden-import uvicorn.lifespan \
--hidden-import sse_starlette \
--distpath "$PROJECT_ROOT/src-tauri/binaries" \
--workpath "$PROJECT_ROOT/build/pyinstaller-work" \
--specpath "$PROJECT_ROOT/build" \
src/agentkit/__main__.py
# Rename with target triple suffix (Tauri sidecar convention)
if [[ "$TARGET_TRIPLE" != *"-windows-"* ]]; then
mv "$PROJECT_ROOT/src-tauri/binaries/$BINARY_NAME" \
"$PROJECT_ROOT/src-tauri/binaries/agentkit-server-$TARGET_TRIPLE"
else
mv "$PROJECT_ROOT/src-tauri/binaries/$BINARY_NAME" \
"$PROJECT_ROOT/src-tauri/binaries/agentkit-server-$TARGET_TRIPLE.exe"
fi
echo "Built: src-tauri/binaries/agentkit-server-$TARGET_TRIPLE"

5612
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

26
src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,26 @@
[package]
name = "fischer-agentkit"
version = "0.1.0"
description = "Fischer AgentKit Desktop Client"
authors = ["Fischer Team"]
edition = "2021"
[lib]
name = "fischer_agentkit_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-shell = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
reqwest = { version = "0.12", features = ["json"] }
regex = "1"
log = "0.4"

View File

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,17 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json",
"identifier": "default",
"description": "Default capabilities for Fischer AgentKit",
"windows": ["main", "splash"],
"permissions": [
"core:default",
"shell:default",
"shell:allow-spawn",
"shell:allow-execute",
"shell:allow-kill",
"shell:allow-stdin-write",
"process:default",
"process:allow-restart",
"process:allow-exit"
]
}

0
src-tauri/icons/.gitkeep Normal file
View File

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 857 B

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

52
src-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,52 @@
use std::sync::{Arc, Mutex};
use tauri::Manager;
use tauri_plugin_shell::process::CommandChild;
use tokio_util::sync::CancellationToken;
mod sidecar;
mod tray;
pub struct BackendState {
pub port: Arc<Mutex<Option<u16>>>,
pub child: Arc<Mutex<Option<CommandChild>>>,
pub cancel_token: Arc<Mutex<Option<CancellationToken>>>,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.set_focus();
}
}))
.plugin(tauri_plugin_process::init())
.manage(BackendState {
port: Arc::new(Mutex::new(None)),
child: Arc::new(Mutex::new(None)),
cancel_token: Arc::new(Mutex::new(None)),
})
.invoke_handler(tauri::generate_handler![
sidecar::start_backend,
sidecar::get_backend_port,
sidecar::stop_backend,
sidecar::check_backend_health,
])
.setup(|app| {
tray::create_tray(app)?;
Ok(())
})
.on_window_event(|window, event| {
// Close-to-tray: hide main window instead of closing
if window.label() == "main" {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

5
src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
fischer_agentkit_lib::run()
}

165
src-tauri/src/sidecar.rs Normal file
View File

@ -0,0 +1,165 @@
use std::sync::OnceLock;
use tauri::{AppHandle, Emitter, Manager, State};
use tauri_plugin_shell::process::CommandEvent;
use tauri_plugin_shell::ShellExt;
use tokio_util::sync::CancellationToken;
static AGENTKIT_PORT_RE: OnceLock<regex::Regex> = OnceLock::new();
static UVICORN_PORT_RE: OnceLock<regex::Regex> = OnceLock::new();
#[tauri::command]
pub async fn start_backend(
app: AppHandle,
state: State<'_, crate::BackendState>,
) -> Result<u16, String> {
// If already started, return existing port
if let Some(port) = *state.port.lock().unwrap() {
return Ok(port);
}
// Spawn sidecar with --port 0 for random port assignment
let sidecar_command = app
.shell()
.sidecar("agentkit-server")
.map_err(|e| format!("Failed to create sidecar command: {}", e))?
.args(["--port", "0", "--host", "127.0.0.1"])
.env("AGENTKIT_GUI_MODE", "1");
let (mut rx, child) = sidecar_command
.spawn()
.map_err(|e| format!("Failed to spawn sidecar: {}", e))?;
// Store the CommandChild for proper cleanup later
*state.child.lock().unwrap() = Some(child);
let port_clone = state.port.clone();
let cancel_token = CancellationToken::new();
*state.cancel_token.lock().unwrap() = Some(cancel_token.clone());
let app_clone = app.clone();
// Listen for stdout/stderr to detect port
let cancel = cancel_token.clone();
tokio::spawn(async move {
tokio::select! {
_ = cancel.cancelled() => {
log::info!("Backend listener task cancelled");
}
result = async {
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(line) => {
let output = String::from_utf8_lossy(&line);
log::info!("[backend:stdout] {}", output.trim());
if let Some(port) = extract_port(&output) {
*port_clone.lock().unwrap() = Some(port);
let _ = app_clone.emit("backend-ready", serde_json::json!({ "port": port }));
break;
}
}
CommandEvent::Stderr(line) => {
let output = String::from_utf8_lossy(&line);
log::info!("[backend:stderr] {}", output.trim());
if let Some(port) = extract_port(&output) {
*port_clone.lock().unwrap() = Some(port);
let _ = app_clone.emit("backend-ready", serde_json::json!({ "port": port }));
break;
}
}
CommandEvent::Terminated(payload) => {
log::error!("Backend process terminated: {:?}", payload);
break;
}
_ => {}
}
}
} => { result }
}
});
// Wait for port to be ready (with timeout)
let max_wait = std::time::Duration::from_secs(30);
let start = std::time::Instant::now();
while start.elapsed() < max_wait {
{
let guard = state.port.lock().unwrap();
if let Some(port) = *guard {
log::info!("Backend started on port {}", port);
// Close splash, show main window
if let Some(splash) = app.get_webview_window("splash") {
let _ = splash.close();
}
if let Some(main) = app.get_webview_window("main") {
let _ = main.show();
let _ = main.set_focus();
}
return Ok(port);
}
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
// Timeout: clean up the started backend process
log::warn!("Backend startup timed out, cleaning up...");
cleanup_backend(&state);
Err("Backend startup timed out after 30 seconds".into())
}
fn extract_port(output: &str) -> Option<u16> {
let re = AGENTKIT_PORT_RE.get_or_init(|| regex::Regex::new(r"AGENTKIT_PORT=(\d{1,5})").unwrap());
if let Some(caps) = re.captures(output) {
return caps[1].parse().ok();
}
let re2 = UVICORN_PORT_RE.get_or_init(|| regex::Regex::new(r"Uvicorn running on https?://[\d.]+:(\d{1,5})").unwrap());
if let Some(caps) = re2.captures(output) {
return caps[1].parse().ok();
}
None
}
/// Clean up backend process: cancel listener task, kill child process, reset state.
fn cleanup_backend(state: &State<'_, crate::BackendState>) {
// Cancel the listener task
if let Some(token) = state.cancel_token.lock().unwrap().take() {
token.cancel();
}
// Kill the child process
if let Some(child) = state.child.lock().unwrap().take() {
if let Err(e) = child.kill() {
log::warn!("Failed to kill backend child process: {}", e);
}
}
// Reset state
*state.port.lock().unwrap() = None;
}
#[tauri::command]
pub async fn get_backend_port(state: State<'_, crate::BackendState>) -> Result<u16, String> {
state
.port
.lock()
.unwrap()
.ok_or_else(|| "Backend not started".into())
}
#[tauri::command]
pub async fn stop_backend(state: State<'_, crate::BackendState>) -> Result<(), String> {
cleanup_backend(&state);
Ok(())
}
#[tauri::command]
pub async fn check_backend_health(
state: State<'_, crate::BackendState>,
) -> Result<bool, String> {
let port = state.port.lock().unwrap().ok_or("Backend not started")?;
let url = format!("http://127.0.0.1:{}/api/v1/health", port);
let client = reqwest::Client::new();
let resp = client
.get(&url)
.timeout(std::time::Duration::from_secs(3))
.send()
.await;
Ok(resp.map(|r| r.status().is_success()).unwrap_or(false))
}

66
src-tauri/src/tray.rs Normal file
View File

@ -0,0 +1,66 @@
use tauri::{
Manager,
menu::{MenuBuilder, MenuItemBuilder},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
};
pub fn create_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let show = MenuItemBuilder::with_id("show", "显示主窗口").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "退出").build(app)?;
let menu = MenuBuilder::new(app)
.item(&show)
.separator()
.item(&quit)
.build()?;
let mut tray_builder = TrayIconBuilder::new()
.menu(&menu)
.tooltip("Fischer AgentKit");
if let Some(icon) = app.default_window_icon() {
tray_builder = tray_builder.icon(icon.clone());
}
let _tray = tray_builder
.on_menu_event(|app, event| match event.id().as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
"quit" => {
// Stop backend before exiting
let state = app.state::<crate::BackendState>();
// Cancel listener task
if let Some(token) = state.cancel_token.lock().unwrap().take() {
token.cancel();
}
// Kill the child process
if let Some(child) = state.child.lock().unwrap().take() {
let _ = child.kill();
}
*state.port.lock().unwrap() = None;
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(app)?;
Ok(())
}

54
src-tauri/tauri.conf.json Normal file
View File

@ -0,0 +1,54 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
"productName": "Fischer AgentKit",
"version": "0.1.0",
"identifier": "com.fischer.agentkit",
"build": {
"beforeDevCommand": "cd src/agentkit/server/frontend && npm run dev:frontend",
"devUrl": "http://localhost:5173",
"beforeBuildCommand": "cd src/agentkit/server/frontend && npm run build:frontend",
"frontendDist": "../src/agentkit/server/static"
},
"app": {
"windows": [
{
"label": "splash",
"title": "Fischer AgentKit",
"width": 400,
"height": 280,
"resizable": false,
"decorations": false,
"center": true,
"visible": true,
"url": "/splash.html"
},
{
"label": "main",
"title": "Fischer AgentKit",
"width": 1280,
"height": 800,
"resizable": true,
"fullscreen": false,
"decorations": false,
"center": true,
"visible": false
}
],
"security": {
"csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; style-src 'self' 'unsafe-inline'; img-src 'self' data: asset: https://asset.localhost"
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png"
],
"externalBin": [
"binaries/agentkit-server"
],
"resources": []
}
}

View File

@ -33,18 +33,23 @@ app.command(name="chat")(chat)
@app.command() @app.command()
def gui( def gui(
host: str = typer.Option("0.0.0.0", "--host", help="Server bind host"), host: str = typer.Option("0.0.0.0", "--host", help="Server bind host"),
port: int = typer.Option(8002, "--port", help="Server port"), port: int = typer.Option(8002, "--port", help="Server port (0 for random)"),
config: Optional[str] = typer.Option(None, "--config", help="Path to agentkit.yaml"), config: Optional[str] = typer.Option(None, "--config", help="Path to agentkit.yaml"),
no_open: bool = typer.Option(False, "--no-open", help="Do not open browser automatically"), no_open: bool = typer.Option(False, "--no-open", help="Do not open browser automatically"),
): ):
"""Start AgentKit with a web UI for chatting with your Agent""" """Start AgentKit with a web UI for chatting with your Agent"""
import os import os
import sys
import webbrowser import webbrowser
import uvicorn import uvicorn
from agentkit.server.config import ServerConfig, find_config_path from agentkit.server.config import ServerConfig, find_config_path
from agentkit.cli.onboarding import run_onboarding from agentkit.cli.onboarding import run_onboarding
# When port=0, default to localhost for security (desktop client mode)
if port == 0 and host == "0.0.0.0":
host = "127.0.0.1"
# Load config # Load config
config_path = find_config_path(config) config_path = find_config_path(config)
@ -58,6 +63,7 @@ def gui(
else: else:
rprint("[dim]Using default configuration (no LLM providers).[/dim]") rprint("[dim]Using default configuration (no LLM providers).[/dim]")
server_config = None
if config_path: if config_path:
rprint(f"[green]Loading config from {config_path}[/green]") rprint(f"[green]Loading config from {config_path}[/green]")
server_config = ServerConfig.from_yaml(config_path) server_config = ServerConfig.from_yaml(config_path)
@ -89,10 +95,11 @@ def gui(
os.environ["AGENTKIT_GUI_MODE"] = "1" os.environ["AGENTKIT_GUI_MODE"] = "1"
# Browser always opens localhost, server binds to configured host # Browser always opens localhost, server binds to configured host
browser_url = f"http://localhost:{port}" browser_url = f"http://localhost:{port}" if port != 0 else None
rprint(f"[green]Starting AgentKit GUI — open {browser_url} in your browser[/green]") if browser_url:
rprint(f"[green]Starting AgentKit GUI — open {browser_url} in your browser[/green]")
if not no_open: if not no_open and browser_url:
import threading import threading
def _open_browser(): def _open_browser():
import time import time
@ -105,11 +112,37 @@ def gui(
from agentkit.server.app import create_app from agentkit.server.app import create_app
app = create_app(server_config=server_config) app = create_app(server_config=server_config)
uvicorn.run( if port == 0:
app, # Direct app instance, not factory string # Desktop client mode: use Server + Config for port discovery
host=host, config = uvicorn.Config(
port=port, app,
) host=host,
port=0,
log_level="info",
)
server = uvicorn.Server(config)
# Hook into startup to detect the actual port
original_on_startup = config.on_startup
async def on_startup_with_port():
if original_on_startup:
await original_on_startup()
# Output the actual port for sidecar process to parse
for server_state in server.servers:
for sock in server_state.sockets:
actual_port = sock.getsockname()[1]
print(f"AGENTKIT_PORT={actual_port}", flush=True)
rprint(f"[green]Backend started on port {actual_port}[/green]")
config.on_startup = on_startup_with_port
server.run()
else:
uvicorn.run(
app,
host=host,
port=port,
)
@app.command() @app.command()

View File

@ -9,17 +9,21 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@ant-design/icons-vue": "^7.0.0", "@ant-design/icons-vue": "^7.0.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-shell": "^2",
"@vue-flow/background": "^1.3.0", "@vue-flow/background": "^1.3.0",
"@vue-flow/controls": "^1.1.0", "@vue-flow/controls": "^1.1.0",
"@vue-flow/core": "^1.41.0", "@vue-flow/core": "^1.41.0",
"ant-design-vue": "^4.2.0", "ant-design-vue": "^4.2.0",
"dompurify": "^3.4.10", "dompurify": "^3.4.10",
"highlight.js": "^11.11.1",
"markdown-it": "^14.2.0", "markdown-it": "^14.2.0",
"pinia": "^2.2.0", "pinia": "^2.2.0",
"vue": "^3.5.0", "vue": "^3.5.0",
"vue-router": "^4.4.0" "vue-router": "^4.4.0"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.11.2",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@vitejs/plugin-vue": "^5.1.0", "@vitejs/plugin-vue": "^5.1.0",
@ -935,6 +939,242 @@
"nanopop": "^2.1.0" "nanopop": "^2.1.0"
} }
}, },
"node_modules/@tauri-apps/api": {
"version": "2.11.0",
"resolved": "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.11.0.tgz",
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.2",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli/-/cli-2.11.2.tgz",
"integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.11.2",
"@tauri-apps/cli-darwin-x64": "2.11.2",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.2",
"@tauri-apps/cli-linux-arm64-musl": "2.11.2",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-musl": "2.11.2",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.2",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.2",
"@tauri-apps/cli-win32-x64-msvc": "2.11.2"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.11.2",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz",
"integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.11.2",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz",
"integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.11.2",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz",
"integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz",
"integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.11.2",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz",
"integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz",
"integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz",
"integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.11.2",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz",
"integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz",
"integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz",
"integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz",
"integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-shell": {
"version": "2.3.5",
"resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
"integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@types/dompurify": { "node_modules/@types/dompurify": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.0.5.tgz", "resolved": "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.0.5.tgz",
@ -1674,6 +1914,15 @@
"he": "bin/he" "he": "bin/he"
} }
}, },
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/is-plain-object": { "node_modules/is-plain-object": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-3.0.1.tgz", "resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-3.0.1.tgz",

View File

@ -5,22 +5,29 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:frontend": "vite",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview" "build:frontend": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons-vue": "^7.0.0", "@ant-design/icons-vue": "^7.0.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-shell": "^2",
"@vue-flow/background": "^1.3.0", "@vue-flow/background": "^1.3.0",
"@vue-flow/controls": "^1.1.0", "@vue-flow/controls": "^1.1.0",
"@vue-flow/core": "^1.41.0", "@vue-flow/core": "^1.41.0",
"ant-design-vue": "^4.2.0", "ant-design-vue": "^4.2.0",
"dompurify": "^3.4.10", "dompurify": "^3.4.10",
"highlight.js": "^11.11.1",
"markdown-it": "^14.2.0", "markdown-it": "^14.2.0",
"pinia": "^2.2.0", "pinia": "^2.2.0",
"vue": "^3.5.0", "vue": "^3.5.0",
"vue-router": "^4.4.0" "vue-router": "^4.4.0"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.11.2",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@vitejs/plugin-vue": "^5.1.0", "@vitejs/plugin-vue": "^5.1.0",

View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fischer AgentKit</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 100vw; height: 100vh; overflow: hidden;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
background: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.logo { font-size: 28px; font-weight: 700; color: #1a1a1a; margin-bottom: 8px; }
.subtitle { font-size: 13px; color: #888; margin-bottom: 32px; }
.progress-bar {
width: 200px; height: 3px; background: #eee; border-radius: 2px; overflow: hidden;
}
.progress-bar-inner {
width: 40%; height: 100%; background: #6366f1; border-radius: 2px;
animation: loading 1.5s ease-in-out infinite;
}
@keyframes loading {
0% { transform: translateX(-100%); }
100% { transform: translateX(350%); }
}
.status { margin-top: 16px; font-size: 12px; color: #aaa; }
</style>
</head>
<body>
<div class="logo">Fischer AgentKit</div>
<div class="subtitle">AI Agent Framework</div>
<div class="progress-bar"><div class="progress-bar-inner"></div></div>
<div class="status">正在启动后端服务...</div>
</body>
</html>

View File

@ -1,13 +1,44 @@
<template> <template>
<a-config-provider :locale="zhCN" :theme="themeConfig"> <a-config-provider :locale="zhCN" :theme="themeConfig">
<router-view /> <SplashScreen v-if="loading" :status="loadingStatus" :error="loadError" />
<router-view v-else />
</a-config-provider> </a-config-provider>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ConfigProvider as AConfigProvider } from 'ant-design-vue' import { ConfigProvider as AConfigProvider } from 'ant-design-vue'
import zhCN from 'ant-design-vue/es/locale/zh_CN' import zhCN from 'ant-design-vue/es/locale/zh_CN'
import { themeConfig } from './styles' import { themeConfig } from './styles'
import { isTauri, startBackend, checkBackendHealth } from './api/tauri'
import { initApiBaseURL } from './api/base'
import SplashScreen from './components/layout/SplashScreen.vue'
const loading = ref(isTauri())
const loadingStatus = ref('正在初始化...')
const loadError = ref('')
onMounted(async () => {
if (isTauri()) {
try {
loadingStatus.value = '正在启动后端...'
await startBackend()
loadingStatus.value = '正在配置连接...'
await initApiBaseURL()
loadingStatus.value = '正在检查服务...'
const healthy = await checkBackendHealth()
if (healthy) {
loading.value = false
} else {
loadError.value = '后端服务健康检查失败'
}
} catch (err: unknown) {
loadError.value = err instanceof Error ? err.message : String(err)
}
} else {
loading.value = false
}
})
</script> </script>
<style> <style>
@ -29,5 +60,56 @@ body {
'Noto Color Emoji'; 'Noto Color Emoji';
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
color: var(--text-primary, #1a1a1a);
background: var(--bg-secondary, #fbfbfa);
}
/* Notion-style scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-gray-300, #dfdfde);
border-radius: var(--radius-full, 9999px);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-gray-400, #cececd);
}
/* Ant Design overrides for Notion-style feel */
.ant-btn {
font-weight: var(--font-weight-medium, 500);
}
.ant-card {
border-color: var(--border-color, #ededec) !important;
}
.ant-card-hoverable:hover {
border-color: var(--color-primary-light, #eef2ff) !important;
box-shadow: var(--shadow-md, 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)) !important;
}
.ant-tabs-tab {
transition: color var(--transition-fast, 150ms ease) !important;
}
.ant-tag {
border-radius: var(--radius-sm, 4px) !important;
}
.ant-modal-content {
border-radius: var(--radius-xl, 14px) !important;
}
.ant-select-selector {
border-radius: var(--radius-md, 6px) !important;
} }
</style> </style>

View File

@ -1,11 +1,28 @@
/** Shared API base client */ /** Shared API base client */
import { isTauri, getBackendPort } from './tauri'
export interface IApiError { export interface IApiError {
status: number status: number
message: string message: string
detail?: string detail?: string
} }
let _dynamicBaseURL = ''
/** Initialize the dynamic base URL for Tauri (sidecar backend). */
export async function initApiBaseURL(): Promise<void> {
if (isTauri()) {
const port = await getBackendPort()
_dynamicBaseURL = `http://127.0.0.1:${port}`
}
}
/** Get the current dynamic base URL (empty string when not in Tauri). */
export function getDynamicBaseURL(): string {
return _dynamicBaseURL
}
export class BaseApiClient { export class BaseApiClient {
protected baseUrl: string protected baseUrl: string
@ -14,8 +31,9 @@ export class BaseApiClient {
} }
protected async request<T>(path: string, options: RequestInit = {}): Promise<T> { protected async request<T>(path: string, options: RequestInit = {}): Promise<T> {
// If path starts with /api/, it's an absolute path — don't prepend baseUrl const effectiveUrl = _dynamicBaseURL
const url = path.startsWith('/api/') ? path : `${this.baseUrl}${path}` ? (path.startsWith('/api/') ? `${_dynamicBaseURL}${path}` : `${_dynamicBaseURL}${this.baseUrl}${path}`)
: (path.startsWith('/api/') ? path : `${this.baseUrl}${path}`)
const headers: Record<string, string> = { const headers: Record<string, string> = {
...options.headers as Record<string, string>, ...options.headers as Record<string, string>,
} }
@ -24,7 +42,7 @@ export class BaseApiClient {
headers['Content-Type'] = 'application/json' headers['Content-Type'] = 'application/json'
} }
const response = await fetch(url, { ...options, headers }) const response = await fetch(effectiveUrl, { ...options, headers })
if (!response.ok) { if (!response.ok) {
const error: IApiError = { const error: IApiError = {
@ -45,6 +63,11 @@ export class BaseApiClient {
} }
protected createWebSocket(path: string): WebSocket { protected createWebSocket(path: string): WebSocket {
if (_dynamicBaseURL) {
const url = new URL(_dynamicBaseURL)
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
return new WebSocket(`${protocol}//${url.host}${this.baseUrl}${path}`)
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host const host = window.location.host
return new WebSocket(`${protocol}//${host}${this.baseUrl}${path}`) return new WebSocket(`${protocol}//${host}${this.baseUrl}${path}`)

View File

@ -0,0 +1,48 @@
/**
* Tauri IPC wrapper provides typed access to Rust backend commands.
* Falls back gracefully when not running inside Tauri (browser mode).
*/
// Detect Tauri environment
export function isTauri(): boolean {
return '__TAURI_INTERNALS__' in window
}
// Lazy-loaded invoke — only import when in Tauri environment
async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
if (!isTauri()) {
throw new Error(`invoke('${cmd}') called outside Tauri environment`)
}
const { invoke: tauriInvoke } = await import('@tauri-apps/api/core')
return tauriInvoke<T>(cmd, args)
}
/** Start the Python backend sidecar. Returns the port it bound to. */
export async function startBackend(): Promise<number> {
return invoke<number>('start_backend')
}
/** Get the currently bound backend port. Throws if backend not started. */
export async function getBackendPort(): Promise<number> {
return invoke<number>('get_backend_port')
}
/** Stop the Python backend sidecar. */
export async function stopBackend(): Promise<void> {
return invoke<void>('stop_backend')
}
/** Check if the backend is healthy (responds to /api/v1/health). */
export async function checkBackendHealth(): Promise<boolean> {
return invoke<boolean>('check_backend_health')
}
/** Listen for a Tauri event. Returns an unlisten function. */
export async function listen<T>(event: string, handler: (payload: T) => void): Promise<() => void> {
if (!isTauri()) {
return () => {}
}
const { listen: tauriListen } = await import('@tauri-apps/api/event')
const unlisten = await tauriListen<T>(event, (e) => handler(e.payload))
return unlisten
}

View File

@ -1,43 +1,19 @@
<template> <template>
<div class="agent-layout"> <div class="agent-layout">
<TopNav /> <TitleBar />
<TopNav :icon-nav-collapsed="iconNavCollapsed" @toggle-icon-nav="iconNavCollapsed = !iconNavCollapsed" />
<div class="agent-layout__body"> <div class="agent-layout__body">
<IconNav
:collapsed="iconNavCollapsed"
@navigate="handleIconNav"
/>
<SplitPane <SplitPane
direction="horizontal" direction="horizontal"
:default-ratio="0.5" :default-ratio="0.55"
storage-key="agent-h-split" storage-key="agent-h-split"
> >
<template #first> <template #first>
<SplitPane <ChatView />
direction="vertical"
:default-ratio="0.6"
storage-key="agent-left-v-split"
>
<template #first>
<QuadrantPanel
ref="tlPanel"
:tabs="topLeftTabs"
default-tab="chat"
storage-key="quadrant-tl-tab"
>
<template #chat>
<ChatView />
</template>
</QuadrantPanel>
</template>
<template #second>
<QuadrantPanel
ref="blPanel"
:tabs="bottomLeftTabs"
default-tab="terminal"
storage-key="quadrant-bl-tab"
>
<template #terminal>
<TerminalView />
</template>
</QuadrantPanel>
</template>
</SplitPane>
</template> </template>
<template #second> <template #second>
<SplitPane <SplitPane
@ -91,7 +67,7 @@
<div class="agent-layout__small-screen"> <div class="agent-layout__small-screen">
<DesktopOutlined style="font-size: 48px; color: var(--text-placeholder)" /> <DesktopOutlined style="font-size: 48px; color: var(--text-placeholder)" />
<h2>请使用更大的屏幕</h2> <h2>请使用更大的屏幕</h2>
<p>Fischer AgentKit 四象限布局需要至少 1280px 宽度的屏幕才能正常使用</p> <p>Fischer AgentKit 布局需要至少 1024px 宽度的屏幕才能正常使用</p>
</div> </div>
</div> </div>
</template> </template>
@ -100,8 +76,6 @@
import { ref, watch, defineAsyncComponent, type Component } from 'vue' import { ref, watch, defineAsyncComponent, type Component } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { import {
MessageOutlined,
CodeOutlined,
FileTextOutlined, FileTextOutlined,
ApartmentOutlined, ApartmentOutlined,
BookOutlined, BookOutlined,
@ -111,13 +85,14 @@ import {
DesktopOutlined, DesktopOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import TopNav from './TopNav.vue' import TopNav from './TopNav.vue'
import TitleBar from './TitleBar.vue'
import SplitPane from './SplitPane.vue' import SplitPane from './SplitPane.vue'
import QuadrantPanel from './QuadrantPanel.vue' import QuadrantPanel from './QuadrantPanel.vue'
import IconNav from './IconNav.vue'
import type { QuadrantTab } from './QuadrantPanel.vue' import type { QuadrantTab } from './QuadrantPanel.vue'
// Lazy-load views to avoid bundling all into initial chunk // Lazy-load views to avoid bundling all into initial chunk
const ChatView = defineAsyncComponent(() => import('@/views/ChatView.vue')) const ChatView = defineAsyncComponent(() => import('@/views/ChatView.vue'))
const TerminalView = defineAsyncComponent(() => import('@/views/TerminalView.vue'))
const WorkflowView = defineAsyncComponent(() => import('@/views/WorkflowView.vue')) const WorkflowView = defineAsyncComponent(() => import('@/views/WorkflowView.vue'))
const KnowledgeBaseView = defineAsyncComponent(() => import('@/views/KnowledgeBaseView.vue')) const KnowledgeBaseView = defineAsyncComponent(() => import('@/views/KnowledgeBaseView.vue'))
const EvolutionView = defineAsyncComponent(() => import('@/views/EvolutionView.vue')) const EvolutionView = defineAsyncComponent(() => import('@/views/EvolutionView.vue'))
@ -126,13 +101,19 @@ const SettingsView = defineAsyncComponent(() => import('@/views/SettingsView.vue
const route = useRoute() const route = useRoute()
const topLeftTabs: QuadrantTab[] = [ // IconNav state
{ key: 'chat', label: '对话', icon: MessageOutlined as Component }, const iconNavCollapsed = ref(localStorage.getItem('icon-nav-collapsed') === 'true')
] watch(iconNavCollapsed, (val) => {
localStorage.setItem('icon-nav-collapsed', String(val))
})
const bottomLeftTabs: QuadrantTab[] = [ function handleIconNav(panel: string, tab?: string) {
{ key: 'terminal', label: '终端', icon: CodeOutlined as Component }, if (panel === 'chat') return // ChatView is always visible
] const panelRef = panel === 'tr' ? trPanel : brPanel
if (tab && panelRef.value) {
panelRef.value.setActiveTab(tab)
}
}
const topRightTabs: QuadrantTab[] = [ const topRightTabs: QuadrantTab[] = [
{ key: 'code', label: '代码', icon: FileTextOutlined as Component }, { key: 'code', label: '代码', icon: FileTextOutlined as Component },
@ -147,25 +128,21 @@ const bottomRightTabs: QuadrantTab[] = [
] ]
// Quadrant refs for route-driven tab switching // Quadrant refs for route-driven tab switching
const tlPanel = ref<InstanceType<typeof QuadrantPanel> | null>(null)
const blPanel = ref<InstanceType<typeof QuadrantPanel> | null>(null)
const trPanel = ref<InstanceType<typeof QuadrantPanel> | null>(null) const trPanel = ref<InstanceType<typeof QuadrantPanel> | null>(null)
const brPanel = ref<InstanceType<typeof QuadrantPanel> | null>(null) const brPanel = ref<InstanceType<typeof QuadrantPanel> | null>(null)
// Watch route changes to sync quadrant tabs with URL // Watch route changes to sync quadrant tabs with URL
watch(() => route.meta, (meta) => { watch(() => route.meta, (meta) => {
const quadrant = meta.quadrant as string | undefined const panel = meta.panel as string | undefined
const tab = meta.tab as string | undefined const tab = meta.tab as string | undefined
if (quadrant && tab) { if (panel && tab) {
const panelMap: Record<string, typeof tlPanel> = { const panelMap: Record<string, typeof trPanel> = {
tl: tlPanel,
bl: blPanel,
tr: trPanel, tr: trPanel,
br: brPanel, br: brPanel,
} }
const panel = panelMap[quadrant] const panelRef = panelMap[panel]
if (panel?.value) { if (panelRef?.value) {
panel.value.setActiveTab(tab) panelRef.value.setActiveTab(tab)
} }
} }
}, { immediate: true }) }, { immediate: true })
@ -178,14 +155,15 @@ watch(() => route.meta, (meta) => {
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
overflow: hidden; overflow: hidden;
background: var(--bg-secondary); background: var(--bg-tertiary);
} }
.agent-layout__body { .agent-layout__body {
flex: 1; flex: 1;
padding: var(--space-2); display: flex;
gap: 0;
overflow: hidden; overflow: hidden;
padding: 0;
gap: 0;
} }
.agent-layout__placeholder { .agent-layout__placeholder {

View File

@ -0,0 +1,82 @@
<template>
<div class="splash-screen">
<div class="splash-content">
<div class="splash-logo">Fischer AgentKit</div>
<div class="splash-subtitle">AI Agent Framework</div>
<div class="splash-progress">
<div class="splash-progress-bar">
<div class="splash-progress-inner"></div>
</div>
</div>
<div class="splash-status">{{ status }}</div>
<div v-if="error" class="splash-error">{{ error }}</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
status: string
error?: string
}>()
</script>
<style scoped>
.splash-screen {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary, #fbfbfa);
}
.splash-content {
display: flex;
flex-direction: column;
align-items: center;
}
.splash-logo {
font-size: 28px;
font-weight: 700;
color: var(--text-primary, #1a1a1a);
margin-bottom: 8px;
}
.splash-subtitle {
font-size: 13px;
color: var(--text-tertiary, #888);
margin-bottom: 32px;
}
.splash-progress {
width: 200px;
}
.splash-progress-bar {
width: 100%;
height: 3px;
background: var(--border-color, #ededec);
border-radius: 2px;
overflow: hidden;
}
.splash-progress-inner {
width: 40%;
height: 100%;
background: var(--color-primary, #6366f1);
border-radius: 2px;
animation: splash-loading 1.5s ease-in-out infinite;
}
@keyframes splash-loading {
0% { transform: translateX(-100%); }
100% { transform: translateX(350%); }
}
.splash-status {
margin-top: 16px;
font-size: 12px;
color: var(--text-quaternary, #aaa);
}
.splash-error {
margin-top: 12px;
font-size: 12px;
color: var(--color-error, #ff4d4f);
max-width: 300px;
text-align: center;
}
</style>

View File

@ -0,0 +1,82 @@
<template>
<div v-if="isTauri()" class="titlebar" data-tauri-drag-region>
<div class="titlebar-title" data-tauri-drag-region>Fischer AgentKit</div>
<div class="titlebar-controls">
<button class="titlebar-btn" @click="minimize" title="最小化">
<MinusOutlined />
</button>
<button class="titlebar-btn" @click="toggleMaximize" title="最大化">
<BorderOutlined />
</button>
<button class="titlebar-btn close-btn" @click="close" title="关闭">
<CloseOutlined />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { MinusOutlined, BorderOutlined, CloseOutlined } from '@ant-design/icons-vue'
import { isTauri } from '../../api/tauri'
async function minimize() {
if (!isTauri()) return
const { getCurrentWindow } = await import('@tauri-apps/api/window')
await getCurrentWindow().minimize()
}
async function toggleMaximize() {
if (!isTauri()) return
const { getCurrentWindow } = await import('@tauri-apps/api/window')
await getCurrentWindow().toggleMaximize()
}
async function close() {
if (!isTauri()) return
// Hide to tray instead of closing
const { getCurrentWindow } = await import('@tauri-apps/api/window')
await getCurrentWindow().hide()
}
</script>
<style scoped>
.titlebar {
height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-container, #ffffff);
border-bottom: 1px solid var(--border-color, #ededec);
user-select: none;
flex-shrink: 0;
}
.titlebar-title {
padding-left: 12px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary, #555);
}
.titlebar-controls {
display: flex;
height: 100%;
}
.titlebar-btn {
width: 46px;
height: 100%;
border: none;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary, #555);
font-size: 12px;
}
.titlebar-btn:hover {
background: var(--fill-secondary, #f5f5f5);
}
.close-btn:hover {
background: #e81123;
color: white;
}
</style>

View File

@ -4,6 +4,8 @@ import { resolve } from 'path'
import Components from 'unplugin-vue-components/vite' import Components from 'unplugin-vue-components/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers' import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
const host = process.env.TAURI_DEV_HOST
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
vue(), vue(),
@ -24,7 +26,16 @@ export default defineConfig({
outDir: '../static', outDir: '../static',
emptyOutDir: true, emptyOutDir: true,
}, },
// Tauri dev server configuration
clearScreen: false,
server: { server: {
port: 5173,
strictPort: true,
host: host || false,
hmr: host ? { protocol: 'ws', host, port: 5174 } : undefined,
watch: {
ignored: ['**/src-tauri/**'],
},
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8000', target: 'http://localhost:8000',
@ -33,4 +44,5 @@ export default defineConfig({
}, },
}, },
}, },
envPrefix: ['VITE_', 'TAURI_'],
}) })