chore: initialize qiming workspace repository
This commit is contained in:
430
qimingclaw/specs/agent-client/vendorsdoc/auto-launch.md
Normal file
430
qimingclaw/specs/agent-client/vendorsdoc/auto-launch.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# auto-launch
|
||||
|
||||
## 项目概述
|
||||
|
||||
跨平台应用开机自启动库,支持 Windows、macOS 和 Linux。
|
||||
|
||||
**GitHub**: https://github.com/zzzgydi/auto-launch.git
|
||||
**本地路径**: `vendors/auto-launch`
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
auto-launch/
|
||||
├── src/
|
||||
│ ├── lib.rs # 公共 API
|
||||
│ ├── windows.rs # Windows 实现
|
||||
│ ├── macos.rs # macOS 实现
|
||||
│ ├── linux.rs # Linux 实现
|
||||
│ └── error.rs # 错误处理
|
||||
├── Cargo.toml
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 核心依赖
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
thiserror = "1.0"
|
||||
os_info = "7.0"
|
||||
|
||||
# Windows
|
||||
windows-registry = "0.2"
|
||||
windows-result = "0.2"
|
||||
|
||||
# macOS
|
||||
smappservice-rs = "0.3"
|
||||
|
||||
# Linux
|
||||
dirs = "5.0"
|
||||
```
|
||||
|
||||
## 核心 API
|
||||
|
||||
### AutoLaunchBuilder
|
||||
|
||||
```rust
|
||||
// lib.rs
|
||||
|
||||
pub struct AutoLaunchBuilder {
|
||||
app_name: Option<String>,
|
||||
app_path: Option<String>,
|
||||
macos_launch_mode: MacOSLaunchMode,
|
||||
bundle_identifiers: Option<Vec<String>>,
|
||||
agent_extra_config: Option<String>,
|
||||
windows_enable_mode: WindowsEnableMode,
|
||||
linux_launch_mode: LinuxLaunchMode,
|
||||
args: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl AutoLaunchBuilder {
|
||||
pub fn new() -> Self;
|
||||
|
||||
/// 设置应用名称
|
||||
pub fn set_app_name(&mut self, name: &str) -> &mut Self;
|
||||
|
||||
/// 设置应用路径
|
||||
pub fn set_app_path(&mut self, path: &str) -> &mut Self;
|
||||
|
||||
/// 设置 macOS 启动模式
|
||||
pub fn set_macos_launch_mode(&mut self, mode: MacOSLaunchMode) -> &mut Self;
|
||||
|
||||
/// 设置 Windows 启用模式
|
||||
pub fn set_windows_enable_mode(&mut self, mode: WindowsEnableMode) -> &mut Self;
|
||||
|
||||
/// 设置 Linux 启动模式
|
||||
pub fn set_linux_launch_mode(&mut self, mode: LinuxLaunchMode) -> &mut Self;
|
||||
|
||||
/// 设置启动参数
|
||||
pub fn set_args(&mut self, args: &[impl AsRef<str>]) -> &mut Self;
|
||||
|
||||
/// 构建 AutoLaunch
|
||||
pub fn build(&self) -> Result<AutoLaunch>;
|
||||
}
|
||||
```
|
||||
|
||||
### AutoLaunch
|
||||
|
||||
```rust
|
||||
pub struct AutoLaunch {
|
||||
app_name: String,
|
||||
app_path: String,
|
||||
args: Vec<String>,
|
||||
// 平台特定配置...
|
||||
}
|
||||
|
||||
impl AutoLaunch {
|
||||
/// 启用自启动
|
||||
pub async fn enable(&self) -> Result<()>;
|
||||
|
||||
/// 禁用自启动
|
||||
pub async fn disable(&self) -> Result<()>;
|
||||
|
||||
/// 检查是否已启用
|
||||
pub async fn is_enabled(&self) -> Result<bool>;
|
||||
}
|
||||
```
|
||||
|
||||
### 平台特定枚举
|
||||
|
||||
```rust
|
||||
pub enum MacOSLaunchMode {
|
||||
/// 使用 SMAppService (macOS 13+)
|
||||
SMAppService,
|
||||
/// 使用 LaunchAgent
|
||||
LaunchAgent,
|
||||
/// 使用 AppleScript
|
||||
AppleScript,
|
||||
}
|
||||
|
||||
pub enum WindowsEnableMode {
|
||||
/// 动态尝试(系统级 -> 用户级)
|
||||
Dynamic,
|
||||
/// 仅当前用户
|
||||
CurrentUser,
|
||||
/// 所有用户(需管理员权限)
|
||||
System,
|
||||
}
|
||||
|
||||
pub enum LinuxLaunchMode {
|
||||
/// 使用 XDG Autostart
|
||||
XdgAutostart,
|
||||
/// 使用 systemd
|
||||
Systemd,
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
```rust
|
||||
use auto_launch::AutoLaunch;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let auto = AutoLaunchBuilder::new()
|
||||
.set_app_name("qiming-agent")
|
||||
.set_app_path("/Applications/qiming-agent.app")
|
||||
.set_args(&["--hidden", "--minimize"])
|
||||
.set_macos_launch_mode(MacOSLaunchMode::SMAppService)
|
||||
.build()?;
|
||||
|
||||
// 检查是否已启用
|
||||
let is_enabled = auto.is_enabled().await?;
|
||||
println!("Auto launch enabled: {}", is_enabled);
|
||||
|
||||
// 启用自启动
|
||||
if !is_enabled {
|
||||
auto.enable().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## 平台实现
|
||||
|
||||
| 平台 | 实现方式 | 配置位置 |
|
||||
|------|----------|----------|
|
||||
| Windows | 注册表 | `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` |
|
||||
| macOS | SMAppService | `~/Library/LaunchAgents/` |
|
||||
| Linux | XDG | `~/.config/autostart/` |
|
||||
|
||||
## 与 agent-client 集成场景
|
||||
|
||||
### 场景1:设置界面的自启动开关
|
||||
|
||||
```rust
|
||||
// 在设置界面中集成自启动管理
|
||||
|
||||
use auto_launch::{AutoLaunch, AutoLaunchBuilder, MacOSLaunchMode};
|
||||
|
||||
pub struct AutoLaunchManager {
|
||||
auto_launch: AutoLaunch,
|
||||
// 当前状态(缓存,避免频繁读取)
|
||||
cached_enabled: bool,
|
||||
}
|
||||
|
||||
impl AutoLaunchManager {
|
||||
pub fn new(app_name: &str, app_path: &str) -> Result<Self> {
|
||||
let auto = AutoLaunchBuilder::new()
|
||||
.set_app_name(app_name)
|
||||
.set_app_path(app_path)
|
||||
.set_macos_launch_mode(MacOSLaunchMode::SMAppService) // macOS 13+
|
||||
.build()?;
|
||||
|
||||
Ok(Self {
|
||||
auto_launch: auto,
|
||||
cached_enabled: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取当前自启动状态
|
||||
pub async fn is_enabled(&self) -> Result<bool> {
|
||||
self.auto_launch.is_enabled().await
|
||||
}
|
||||
|
||||
/// 启用自启动
|
||||
pub async fn enable(&self) -> Result<()> {
|
||||
self.auto_launch.enable().await
|
||||
}
|
||||
|
||||
/// 禁用自启动
|
||||
pub async fn disable(&self) -> Result<()> {
|
||||
self.auto_launch.disable().await
|
||||
}
|
||||
|
||||
/// 切换自启动状态
|
||||
pub async fn toggle(&self) -> Result<bool> {
|
||||
let enabled = self.is_enabled().await?;
|
||||
if enabled {
|
||||
self.disable().await?;
|
||||
} else {
|
||||
self.enable().await?;
|
||||
}
|
||||
Ok(!enabled)
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置界面中的自启动开关组件
|
||||
pub struct AutoLaunchSwitch {
|
||||
manager: AutoLaunchManager,
|
||||
enabled: bool,
|
||||
loading: bool,
|
||||
}
|
||||
|
||||
impl AutoLaunchSwitch {
|
||||
pub fn new(manager: AutoLaunchManager) -> Self {
|
||||
Self {
|
||||
manager,
|
||||
enabled: false,
|
||||
loading: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化时检查状态
|
||||
pub fn refresh_status(&mut self) {
|
||||
let manager = self.manager.clone();
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let enabled = manager.is_enabled().await.unwrap_or(false);
|
||||
this.update(&mut cx, |this, _| {
|
||||
this.enabled = enabled;
|
||||
this.loading = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AutoLaunchSwitch {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.p_4()
|
||||
.rounded_md()
|
||||
.bg(cx.theme().colors.surface)
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors.border)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.font_medium()
|
||||
.child("Auto Launch")
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().colors.muted)
|
||||
.child("Start application when system boots")
|
||||
)
|
||||
)
|
||||
.child(
|
||||
match self.loading {
|
||||
true => Loading::new().into_any_element(),
|
||||
false => Switch::new(self.enabled, |enabled, _| {
|
||||
// 切换自启动
|
||||
let manager = self.manager.clone();
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let result = if enabled {
|
||||
manager.disable().await
|
||||
} else {
|
||||
manager.enable().await
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
this.update(&mut cx, |this, _| {
|
||||
this.enabled = !enabled;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
// 显示错误提示
|
||||
tracing::error!("Failed to toggle auto-launch: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}).into_any_element(),
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 场景2:应用启动时检查自启动状态
|
||||
|
||||
```rust
|
||||
// 应用启动时的初始化逻辑
|
||||
|
||||
pub struct AppInitializer {
|
||||
auto_launch_manager: Option<AutoLaunchManager>,
|
||||
}
|
||||
|
||||
impl AppInitializer {
|
||||
pub async fn initialize(&mut self, app_path: &str) -> Result<()> {
|
||||
// 1. 初始化自启动管理器
|
||||
self.auto_launch_manager = Some(
|
||||
AutoLaunchManager::new("qiming-agent", app_path).ok()
|
||||
);
|
||||
|
||||
// 2. 检查自启动是否已启用
|
||||
if let Some(manager) = &self.auto_launch_manager {
|
||||
let is_enabled = manager.is_enabled().await.unwrap_or(false);
|
||||
tracing::info!("Auto-launch enabled: {}", is_enabled);
|
||||
}
|
||||
|
||||
// 3. 根据启动参数决定行为
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// 如果是通过自启动触发的,可能需要最小化到托盘
|
||||
if args.iter().any(|arg| arg == "--minimize") {
|
||||
// 最小化到托盘,不显示主窗口
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 场景3:跨平台注意事项
|
||||
|
||||
```rust
|
||||
// 不同平台的自启动行为差异处理
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_impl {
|
||||
use auto_launch::WindowsEnableMode;
|
||||
|
||||
pub fn get_app_path() -> String {
|
||||
// Windows: 使用完整路径
|
||||
std::env::current_exe()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
pub fn build_auto_launch(name: &str, path: &str) -> auto_launch::AutoLaunchBuilder {
|
||||
AutoLaunchBuilder::new()
|
||||
.set_app_name(name)
|
||||
.set_app_path(path)
|
||||
.set_windows_enable_mode(WindowsEnableMode::CurrentUser) // 当前用户
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_impl {
|
||||
use auto_launch::MacOSLaunchMode;
|
||||
|
||||
pub fn get_app_path() -> String {
|
||||
// macOS: 使用 .app 路径
|
||||
std::env::current_exe()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
pub fn build_auto_launch(name: &str, path: &str) -> auto_launch::AutoLaunchBuilder {
|
||||
AutoLaunchBuilder::new()
|
||||
.set_app_name(name)
|
||||
.set_app_path(path)
|
||||
.set_macos_launch_mode(MacOSLaunchMode::SMAppService) // macOS 13+
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_impl {
|
||||
use auto_launch::LinuxLaunchMode;
|
||||
|
||||
pub fn get_app_path() -> String {
|
||||
std::env::current_exe()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
pub fn build_auto_launch(name: &str, path: &str) -> auto_launch::AutoLaunchBuilder {
|
||||
AutoLaunchBuilder::new()
|
||||
.set_app_name(name)
|
||||
.set_app_path(path)
|
||||
.set_linux_launch_mode(LinuxLaunchMode::XdgAutostart)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 在本项目中的使用
|
||||
|
||||
用于实现 agent-client 的开机自启动功能:
|
||||
|
||||
```
|
||||
agent-client
|
||||
│
|
||||
└── auto-launch
|
||||
│
|
||||
├── 检测自启动状态 (设置界面开关)
|
||||
├── 启用自启动 (用户勾选开关)
|
||||
├── 禁用自启动 (用户取消勾选)
|
||||
└── 应用启动时最小化到托盘
|
||||
```
|
||||
516
qimingclaw/specs/agent-client/vendorsdoc/cargo-packager.md
Normal file
516
qimingclaw/specs/agent-client/vendorsdoc/cargo-packager.md
Normal file
@@ -0,0 +1,516 @@
|
||||
# cargo-packager
|
||||
|
||||
## 项目概述
|
||||
|
||||
应用打包工具,支持生成多种平台安装包。是从 Tauri 框架剥离出来的独立打包工具。
|
||||
|
||||
**本地路径**: `vendors/cargo-packager`
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
cargo-packager/
|
||||
├── Cargo.toml # workspace 配置
|
||||
├── packager/ # 打包核心
|
||||
│ └── src/
|
||||
│ ├── lib.rs
|
||||
│ ├── config.rs # 配置
|
||||
│ ├── package.rs # 打包逻辑
|
||||
│ └── utils.rs # 工具函数
|
||||
├── config/ # 配置定义
|
||||
│ └── src/
|
||||
│ └── lib.rs
|
||||
├── resource-resolver/ # 资源解析
|
||||
│ └── src/
|
||||
│ └── lib.rs
|
||||
├── updater/ # 自动更新
|
||||
│ └── src/
|
||||
│ └── lib.rs
|
||||
├── codesign/ # 代码签名
|
||||
│ └── src/
|
||||
│ └── lib.rs
|
||||
├── package/
|
||||
│ ├── deb/ # Debian 包
|
||||
│ ├── appimage/ # AppImage
|
||||
│ ├── dmg/ # macOS DMG
|
||||
│ ├── msi/ # Windows MSI
|
||||
│ └── nsis/ # Windows NSIS
|
||||
└── Cargo.toml
|
||||
```
|
||||
|
||||
## 核心依赖
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
toml = "0.7"
|
||||
clap = { version = "4.0", features = ["derive"] }
|
||||
schemars = "0.8"
|
||||
tracing = "0.1"
|
||||
tar = "0.4"
|
||||
zip = "0.6"
|
||||
napi = { version = "2.0", optional = true }
|
||||
dirs = "5.0"
|
||||
semver = "1.0"
|
||||
url = "2.4"
|
||||
thiserror = "1.0"
|
||||
```
|
||||
|
||||
## 核心 API
|
||||
|
||||
### Config
|
||||
|
||||
```rust
|
||||
// config/src/lib.rs
|
||||
|
||||
pub struct PackageConfig {
|
||||
pub product_name: String,
|
||||
pub version: String,
|
||||
pub description: Option<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub default_author: Option<String>,
|
||||
pub icons: Vec<IconConfig>,
|
||||
pub resources: Vec<ResourceConfig>,
|
||||
pub bin: Vec<BinConfig>,
|
||||
pub i18n: Vec<I18nConfig>,
|
||||
}
|
||||
|
||||
pub struct Config {
|
||||
pub package: PackageConfig,
|
||||
pub formats: Vec<PackageFormat>,
|
||||
pub signing: Option<SigningConfig>,
|
||||
pub windows: Option<WindowsConfig>,
|
||||
pub macos: Option<MacosConfig>,
|
||||
pub linux: Option<LinuxConfig>,
|
||||
}
|
||||
```
|
||||
|
||||
### PackageFormat
|
||||
|
||||
```rust
|
||||
pub enum PackageFormat {
|
||||
/// Windows MSI
|
||||
Msi(WindowsMsiConfig),
|
||||
/// Windows NSIS
|
||||
Nsis(WindowsNsisConfig),
|
||||
/// macOS DMG
|
||||
Dmg(MacosDmgConfig),
|
||||
/// macOS Bundle
|
||||
Bundle(MacosBundleConfig),
|
||||
/// Linux Deb
|
||||
Deb(DebConfig),
|
||||
/// Linux AppImage
|
||||
AppImage(AppImageConfig),
|
||||
}
|
||||
```
|
||||
|
||||
### 使用示例
|
||||
|
||||
```rust
|
||||
use cargo_packager::{Config, PackageFormat};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let config = Config {
|
||||
package: PackageConfig {
|
||||
product_name: "qiming-agent".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
description: Some("Agent Client".to_string()),
|
||||
homepage: Some("https://example.com".to_string()),
|
||||
default_author: Some("Author <author@example.com>".to_string()),
|
||||
icons: vec![IconConfig::Path {
|
||||
path: "icons/icon.png".into(),
|
||||
}],
|
||||
resources: vec![ResourceConfig {
|
||||
patterns: vec!["assets/**/*".into()],
|
||||
target: None,
|
||||
}],
|
||||
bin: vec![],
|
||||
i18n: vec![],
|
||||
},
|
||||
formats: vec![
|
||||
PackageFormat::Msi(WindowsMsiConfig::default()),
|
||||
PackageFormat::Dmg(MacosDmgConfig::default()),
|
||||
PackageFormat::Deb(DebConfig::default()),
|
||||
PackageFormat::AppImage(AppImageConfig::default()),
|
||||
],
|
||||
signing: Some(SigningConfig {
|
||||
identity: None,
|
||||
digest: Some(DigestAlgorithm::Sha256),
|
||||
timestamp: Some(TimestampConfig::default()),
|
||||
..Default::default()
|
||||
}),
|
||||
windows: Some(WindowsConfig {
|
||||
signing_config: Some(SigningConfig::default()),
|
||||
..Default::default()
|
||||
}),
|
||||
macos: Some(MacosConfig {
|
||||
signing_config: Some(SigningConfig::default()),
|
||||
provider: None,
|
||||
}),
|
||||
linux: Some(LinuxConfig {
|
||||
package: LinuxPackageFormatConfig::default(),
|
||||
..Default::default()
|
||||
}),
|
||||
};
|
||||
|
||||
// 执行打包
|
||||
cargo_packager::package(&config).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## 自动更新配置
|
||||
|
||||
```rust
|
||||
pub struct UpdaterConfig {
|
||||
/// 启用自动更新
|
||||
pub active: bool,
|
||||
/// 更新端点 URL 模板
|
||||
pub endpoints: Option<Vec<String>>,
|
||||
/// 公钥
|
||||
pub pubkey: String,
|
||||
/// 苹果开发者 ID
|
||||
pub apple_id: Option<String>,
|
||||
/// Windows 证书
|
||||
pub windows: Option<WindowsUpdaterConfig>,
|
||||
}
|
||||
|
||||
impl UpdaterConfig {
|
||||
/// 默认端点模板
|
||||
pub fn default_endpoints(&self, target: &str) -> Vec<String> {
|
||||
vec![
|
||||
format!("https://updates.example.com/update/{target}/{version}"),
|
||||
format!("https://updates.example.com/update/{target}/{version}-{target}.{extension}"),
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 与 agent-client 集成场景
|
||||
|
||||
### 场景1:完整的打包配置
|
||||
|
||||
```rust
|
||||
// build.rs 或打包脚本
|
||||
|
||||
use cargo_packager::{Config, PackageFormat, PackageConfig};
|
||||
use cargo_packager::config::{MacosDmgConfig, WindowsMsiConfig, DebConfig, AppImageConfig};
|
||||
|
||||
pub async fn build_packages() -> Result<()> {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let app_name = "qiming-agent";
|
||||
|
||||
let config = Config {
|
||||
package: PackageConfig {
|
||||
product_name: app_name.to_string(),
|
||||
version: version.to_string(),
|
||||
description: Some("Cross-platform AI Agent Client".to_string()),
|
||||
homepage: Some("https://example.com".to_string()),
|
||||
default_author: Some("qiming <support@example.com>".to_string()),
|
||||
icons: vec![
|
||||
IconConfig::Path {
|
||||
path: "assets/icons/icon.png".into(),
|
||||
},
|
||||
IconConfig::Path {
|
||||
path: "assets/icons/icon.ico".into(),
|
||||
},
|
||||
IconConfig::Path {
|
||||
path: "assets/icons/icon.icns".into(),
|
||||
},
|
||||
],
|
||||
resources: vec![
|
||||
ResourceConfig {
|
||||
patterns: vec![
|
||||
"assets/**/*".into(),
|
||||
"locales/**/*".into(),
|
||||
],
|
||||
target: None,
|
||||
},
|
||||
],
|
||||
bin: vec![],
|
||||
i18n: vec![],
|
||||
},
|
||||
formats: vec![
|
||||
PackageFormat::Msi(WindowsMsiConfig {
|
||||
// MSI 配置
|
||||
database_path: Some("assets/wix/database.wxs".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
PackageFormat::Dmg(MacosDmgConfig {
|
||||
// DMG 配置
|
||||
background: Some("assets/dmg/background.png".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
PackageFormat::Deb(DebConfig {
|
||||
// Debian 包配置
|
||||
priority: Some("optional".to_string()),
|
||||
section: Some("utils".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
PackageFormat::AppImage(AppImageConfig {
|
||||
// AppImage 配置
|
||||
..Default::default()
|
||||
}),
|
||||
],
|
||||
signing: Some(SigningConfig {
|
||||
identity: std::env::var("SIGNING_IDENTITY").ok(),
|
||||
digest: Some(DigestAlgorithm::Sha256),
|
||||
timestamp: Some(TimestampConfig {
|
||||
server: Some("http://timestamp.digicert.com".to_string()),
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
windows: Some(WindowsConfig {
|
||||
signing_config: Some(SigningConfig {
|
||||
identity: std::env::var("WINDOWS_CERT").ok(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
macos: Some(MacosConfig {
|
||||
signing_config: Some(SigningConfig {
|
||||
identity: std::env::var("APPLE_SIGNING_ID").ok(),
|
||||
provider: std::env::var("APPLE_PROVIDER").ok(),
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
linux: Some(LinuxConfig {
|
||||
package: LinuxPackageFormatConfig::default(),
|
||||
..Default::default()
|
||||
}),
|
||||
};
|
||||
|
||||
// 执行打包
|
||||
cargo_packager::package(&config).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### 场景2:CI/CD 集成
|
||||
|
||||
```yaml
|
||||
# .github/workflows/release.yml
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
targets: deb,appimage
|
||||
- os: macos-latest
|
||||
targets: dmg
|
||||
- os: windows-latest
|
||||
targets: msi
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
target: ${{ matrix.target == 'deb' && 'x86_64-unknown-linux-gnu' || 'x86_64-apple-darwin' }}
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
cargo run --package cargo-packager -- --package-format ${{ matrix.targets }}
|
||||
|
||||
- name: Sign macOS
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
echo "${{ secrets.APPLE_CERT }}" | base64 -d > certificate.p12
|
||||
security create-keychain -p password build.keychain
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p password build.keychain
|
||||
security import certificate.p12 -P "" -A -t cert -f pkcs12 -k build.keychain
|
||||
codesign --deep --force --sign "${{ secrets.APPLE_SIGNING_ID }}" target/release/qiming-agent
|
||||
|
||||
- name: Sign Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: |
|
||||
& "C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64\signtool.exe" sign /f certificate.pfx /p ${{ secrets.WINDOWS_CERT_PASSWORD }} /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /v target/release/qiming-agent-*.msi
|
||||
|
||||
- name: Upload Assets
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
target/release/qiming-agent-*.msi
|
||||
target/release/qiming-agent-*.dmg
|
||||
target/release/qiming-agent-*.deb
|
||||
target/release/qiming-agent-*.AppImage
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
### 场景3:自动更新配置
|
||||
|
||||
```rust
|
||||
// 打包时生成更新配置
|
||||
|
||||
pub struct UpdaterConfig {
|
||||
/// 更新服务器地址
|
||||
pub update_url: String,
|
||||
|
||||
/// 公钥(用于签名验证)
|
||||
pub public_key: String,
|
||||
|
||||
/// 是否启用自动更新
|
||||
pub enable_auto_update: bool,
|
||||
}
|
||||
|
||||
impl UpdaterConfig {
|
||||
/// 生成更新配置模板
|
||||
pub fn generate_app_config(&self) -> String {
|
||||
format!(r#"
|
||||
[updater]
|
||||
enabled = {}
|
||||
url = "{}"
|
||||
"#, self.enable_auto_update, self.update_url)
|
||||
}
|
||||
|
||||
/// 获取更新端点
|
||||
pub fn get_update_endpoints(&self, version: &str, target: &str) -> Vec<String> {
|
||||
vec![
|
||||
format!("{}/{}/{}/qiming-agent_{}_{}.{}",
|
||||
self.update_url, version, target, version, target, self.extension(target)),
|
||||
]
|
||||
}
|
||||
|
||||
fn extension(target: &str) -> &str {
|
||||
match target {
|
||||
_ if target.contains("windows") => "msi",
|
||||
_ if target.contains("darwin") => "dmg",
|
||||
_ if target.contains("linux") => if target.contains("deb") { "deb" } else { "AppImage" },
|
||||
_ => "tar.gz",
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 场景4:资源文件处理
|
||||
|
||||
```rust
|
||||
// 打包时的资源处理
|
||||
|
||||
pub struct ResourceProcessor {
|
||||
// 资源目录
|
||||
resource_dir: PathBuf,
|
||||
// 输出目录
|
||||
output_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ResourceProcessor {
|
||||
pub fn new(resource_dir: &str, output_dir: &str) -> Self {
|
||||
Self {
|
||||
resource_dir: PathBuf::from(resource_dir),
|
||||
output_dir: PathBuf::from(output_dir),
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理所有资源
|
||||
pub async fn process(&self) -> Result<Vec<ResourceFile>> {
|
||||
let mut resources = Vec::new();
|
||||
|
||||
// 1. 图标资源
|
||||
resources.extend(self.process_icons().await?);
|
||||
|
||||
// 2. 翻译文件
|
||||
resources.extend(self.process_locales().await?);
|
||||
|
||||
// 3. 配置文件
|
||||
resources.extend(self.process_configs().await?);
|
||||
|
||||
Ok(resources)
|
||||
}
|
||||
|
||||
async fn process_icons(&self) -> Result<Vec<ResourceFile>> {
|
||||
let icons_dir = self.resource_dir.join("icons");
|
||||
let mut icons = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(&icons_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().map(|e| e == "png" || e == "ico" || e == "icns").unwrap_or(false) {
|
||||
icons.push(ResourceFile {
|
||||
source: path.clone(),
|
||||
target: format!("icons/{}", path.file_name().unwrap()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(icons)
|
||||
}
|
||||
|
||||
async fn process_locales(&self) -> Result<Vec<ResourceFile>> {
|
||||
let locales_dir = self.resource_dir.join("locales");
|
||||
let mut locales = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(&locales_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
locales.push(ResourceFile {
|
||||
source: path.clone(),
|
||||
target: format!("locales/{}", path.file_name().unwrap()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(locales)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 在本项目中的使用
|
||||
|
||||
用于打包 agent-client 为各平台安装包:
|
||||
|
||||
```
|
||||
agent-client
|
||||
│
|
||||
├── cargo-packager
|
||||
│ │
|
||||
│ ├── Windows MSI/NSIS 安装包
|
||||
│ ├── macOS DMG 磁盘镜像
|
||||
│ ├── Linux Deb 包
|
||||
│ └── Linux AppImage
|
||||
│
|
||||
└── 资源文件
|
||||
├── icons/ (应用图标)
|
||||
├── locales/ (多语言)
|
||||
└── assets/ (其他资源)
|
||||
```
|
||||
|
||||
## 打包命令
|
||||
|
||||
```bash
|
||||
# CLI 打包
|
||||
cargo-packager --package-format msi,dmg,deb,appimage
|
||||
|
||||
# 代码调用
|
||||
cargo_packager::package(&config).await?;
|
||||
|
||||
# 单独平台打包
|
||||
cargo-packager --package-format msi # Windows
|
||||
cargo-packager --package-format dmg # macOS
|
||||
cargo-packager --package-format deb # Linux Deb
|
||||
cargo-packager --package-format appimage # Linux AppImage
|
||||
```
|
||||
1193
qimingclaw/specs/agent-client/vendorsdoc/enigo.md
Normal file
1193
qimingclaw/specs/agent-client/vendorsdoc/enigo.md
Normal file
File diff suppressed because it is too large
Load Diff
1713
qimingclaw/specs/agent-client/vendorsdoc/gpui-component.md
Normal file
1713
qimingclaw/specs/agent-client/vendorsdoc/gpui-component.md
Normal file
File diff suppressed because it is too large
Load Diff
240
qimingclaw/specs/agent-client/vendorsdoc/mcp-proxy.md
Normal file
240
qimingclaw/specs/agent-client/vendorsdoc/mcp-proxy.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# mcp-proxy
|
||||
|
||||
## 项目概述
|
||||
|
||||
MCP (Model Context Protocol) 代理服务集合,包含 MCP 代理、文档解析、语音处理、OSS 客户端等功能。
|
||||
|
||||
**本地路径**: `vendors/mcp-proxy`
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
mcp-proxy/
|
||||
├── Cargo.toml # workspace 配置
|
||||
├── mcp-proxy/ # MCP 代理服务
|
||||
│ └── src/
|
||||
│ ├── main.rs
|
||||
│ ├── server.rs # HTTP 服务
|
||||
│ ├── handler.rs # 请求处理
|
||||
│ └── state.rs # 状态管理
|
||||
├── document-parser/ # 文档解析
|
||||
│ └── src/
|
||||
│ ├── lib.rs
|
||||
│ ├── pdf.rs
|
||||
│ ├── office.rs
|
||||
│ └── parser.rs
|
||||
├── voice-cli/ # 语音服务
|
||||
│ └── src/
|
||||
│ ├── lib.rs
|
||||
│ ├── recognize.rs
|
||||
│ └── synthesize.rs
|
||||
├── oss-client/ # OSS 客户端
|
||||
│ └── src/
|
||||
│ ├── lib.rs
|
||||
│ ├── client.rs
|
||||
│ └── error.rs
|
||||
├── mcp-common/ # 共享模块
|
||||
│ └── src/
|
||||
│ ├── lib.rs
|
||||
│ ├── types.rs
|
||||
│ └── util.rs
|
||||
└── Cargo.toml
|
||||
```
|
||||
|
||||
## 核心依赖
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
# Web 框架
|
||||
axum = "0.7"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "auth"] }
|
||||
|
||||
# MCP 协议
|
||||
rmcp = "1.0"
|
||||
|
||||
# 任务队列
|
||||
apalis = "1.0"
|
||||
|
||||
# 数据库
|
||||
sqlx = "0.7"
|
||||
sled = "1.0"
|
||||
|
||||
# 网络
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
tokio-tungstenite = "0.23"
|
||||
|
||||
# 并发
|
||||
dashmap = "6.0"
|
||||
|
||||
# OpenTelemetry
|
||||
opentelemetry = "0.30"
|
||||
opentelemetry-otlp = "0.27"
|
||||
|
||||
# 文档处理
|
||||
pdf = "0.8"
|
||||
calamine = "0.5" # Excel
|
||||
```
|
||||
|
||||
## 核心模块
|
||||
|
||||
### MCP 代理服务
|
||||
|
||||
```rust
|
||||
// mcp-proxy/src/server.rs
|
||||
|
||||
pub struct McpProxyServer {
|
||||
// MCP 工具注册表
|
||||
tools: DashMap<String, Box<dyn McpTool>>,
|
||||
|
||||
// 连接管理
|
||||
connections: ConnectionManager,
|
||||
|
||||
// 状态
|
||||
state: Arc<McpState>,
|
||||
}
|
||||
|
||||
impl McpProxyServer {
|
||||
/// 注册 MCP 工具
|
||||
pub fn register_tool<T: McpTool>(&self, tool: T);
|
||||
|
||||
/// 列出所有工具
|
||||
pub fn list_tools(&self) -> Vec<ToolInfo>;
|
||||
|
||||
/// 调用工具
|
||||
pub async fn call_tool(&self, name: &str, params: Value) -> Result<Value>;
|
||||
}
|
||||
```
|
||||
|
||||
### 文档解析
|
||||
|
||||
```rust
|
||||
// document-parser/src/parser.rs
|
||||
|
||||
pub enum Document {
|
||||
Pdf(PdfDocument),
|
||||
Word(WordDocument),
|
||||
Excel(ExcelDocument),
|
||||
Text(TextDocument),
|
||||
}
|
||||
|
||||
pub struct ParseOptions {
|
||||
pub max_pages: Option<usize>,
|
||||
pub extract_images: bool,
|
||||
pub extract_tables: bool,
|
||||
}
|
||||
|
||||
impl DocumentParser {
|
||||
/// 解析文档
|
||||
pub async fn parse(
|
||||
&self,
|
||||
path: &Path: ParseOptions,
|
||||
) -> Result,
|
||||
options<Document>;
|
||||
|
||||
/// 提取文本
|
||||
pub async fn extract_text(&self, doc: &Document) -> Result<String>;
|
||||
}
|
||||
```
|
||||
|
||||
### 任务队列 (Apolis)
|
||||
|
||||
```rust
|
||||
use apalis::prelude::*;
|
||||
|
||||
pub struct JobQueue {
|
||||
storage: SqliteStorage<JobData>,
|
||||
worker: Worker<SqliteStorage<JobData>>,
|
||||
}
|
||||
|
||||
impl JobQueue {
|
||||
pub async fn new(db_path: &str) -> Result<Self> {
|
||||
let storage = SqliteStorage::new(db_path);
|
||||
storage.setup().await?;
|
||||
|
||||
Ok(Self {
|
||||
storage,
|
||||
worker: Worker::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 添加任务
|
||||
pub async fn enqueue(&self, job: JobData) -> Result<()>;
|
||||
|
||||
/// 处理任务
|
||||
pub async fn process_jobs(&self);
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct JobData {
|
||||
pub id: String,
|
||||
pub job_type: JobType,
|
||||
pub payload: Value,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub enum JobType {
|
||||
DocumentParse,
|
||||
VoiceRecognize,
|
||||
VoiceSynthesize,
|
||||
McpRequest,
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
```rust
|
||||
use mcp_proxy::McpProxyServer;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let server = McpProxyServer::new();
|
||||
|
||||
// 注册自定义工具
|
||||
server.register_tool(SearchTool);
|
||||
server.register_tool(FileTool);
|
||||
|
||||
// 启动 HTTP 服务
|
||||
let app = axum::Router::new()
|
||||
.route("/tools", axum::routing::get(list_tools))
|
||||
.route("/tools/:name/call", axum::routing::post(call_tool))
|
||||
.with_state(server);
|
||||
|
||||
axum::Server::bind(&"0.0.0.0:3000".parse()?)
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## 在本项目中的使用
|
||||
|
||||
用于提供 MCP 服务能力,支持管理端的 MCP 工具调用:
|
||||
|
||||
```
|
||||
agent-server-admin (管理端)
|
||||
│
|
||||
├── MCP 工具调用 --> mcp-proxy
|
||||
│ │
|
||||
│ ├── 文档解析
|
||||
│ ├── 语音处理
|
||||
│ └── OSS 存储
|
||||
│
|
||||
└── SSE 响应 <---------
|
||||
```
|
||||
|
||||
## gRPC 服务定义
|
||||
|
||||
```protobuf
|
||||
service McpProxyService {
|
||||
// 列出可用工具
|
||||
rpc ListTools(ListToolsRequest) returns (ListToolsResponse);
|
||||
|
||||
// 调用工具
|
||||
rpc CallTool(CallToolRequest) returns (CallToolResponse);
|
||||
|
||||
// 订阅工具执行进度
|
||||
rpc Subscribe(SubscribeRequest) returns (stream ProgressEvent);
|
||||
}
|
||||
```
|
||||
700
qimingclaw/specs/agent-client/vendorsdoc/qiming-rustdesk.md
Normal file
700
qimingclaw/specs/agent-client/vendorsdoc/qiming-rustdesk.md
Normal file
@@ -0,0 +1,700 @@
|
||||
# qiming-rustdesk
|
||||
|
||||
## 项目概述
|
||||
|
||||
RustDesk 远程桌面客户端的定制版本,支持远程控制、文件传输、音视频服务、剪贴板同步等功能。作为本项目的核心通信库,提供双向通信、P2P 直连和中继传输能力。
|
||||
|
||||
**本地路径**: `vendors/qiming-rustdesk`
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
qiming-rustdesk/
|
||||
├── src/
|
||||
│ ├── lib.rs # 库入口,导出公共API
|
||||
│ ├── client.rs # 客户端连接处理核心模块
|
||||
│ ├── server.rs # 服务端音视频/剪贴板/输入服务
|
||||
│ ├── common.rs # 公共工具函数和类型定义
|
||||
│ ├── rendezvous_mediator.rs # 远程连接中介(核心)
|
||||
│ ├── ipc.rs # 进程间通信
|
||||
│ └── platform/ # 平台特定代码
|
||||
│ ├── linux/
|
||||
│ ├── macos/
|
||||
│ └── windows/
|
||||
├── libs/
|
||||
│ ├── hbb_common/ # 通用工具库(最重要)
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── lib.rs
|
||||
│ │ │ ├── message_proto/ # protobuf 消息定义
|
||||
│ │ │ ├── net/ # 网络相关(TCP/KCP/WebSocket)
|
||||
│ │ │ ├── crypto/ # 加密模块
|
||||
│ │ │ ├── config/ # 配置管理
|
||||
│ │ │ └── util/ # 工具函数
|
||||
│ │ └── Cargo.toml
|
||||
│ ├── scrap/ # 屏幕捕获库
|
||||
│ ├── enigo/ # 键盘鼠标模拟库
|
||||
│ ├── clipboard/ # 剪贴板库
|
||||
│ └── virtual_display/ # 虚拟显示驱动
|
||||
├── proto/ # protobuf 协议定义
|
||||
└── Cargo.toml # workspace 配置
|
||||
```
|
||||
|
||||
## 核心通信架构
|
||||
|
||||
### 整体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ agent-client │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌─────────────────────────────────┐ │
|
||||
│ │ UI 界面 │ │ 核心通信模块 │ │
|
||||
│ │ - 聊天窗口 │ │ - rendezvous_mediator (连接管理)│ │
|
||||
│ │ - 设置界面 │ │ - client (数据收发) │ │
|
||||
│ │ - 依赖管理 │ │ - server (音视频服务) │ │
|
||||
│ └─────────────────┘ └─────────────────────────────────┘ │
|
||||
│ │ │
|
||||
└────────────────────────────────┼────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ data-server (rustdesk-server) │
|
||||
│ │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ hbbs │ │ hbbr │ │
|
||||
│ │ (信令服务器) │ │ (中继服务器) │ │
|
||||
│ │ - 端口: 21116 │ │ - 端口: 21117 │ │
|
||||
│ │ - ID 注册 │ │ - 数据中继 │ │
|
||||
│ │ - 连接协商 │ │ - 带宽限制 │ │
|
||||
│ │ - NAT 穿透 │ │ │ │
|
||||
│ └─────────────────────┘ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ agent-server-admin (管理端) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 连接管理 (rendezvous_mediator.rs)
|
||||
|
||||
### 核心结构体
|
||||
|
||||
```rust
|
||||
// src/rendezvous_mediator.rs
|
||||
|
||||
pub struct RendezvousMediator {
|
||||
addr: TargetAddr<'static>,
|
||||
host: String,
|
||||
host_prefix: String,
|
||||
keep_alive: i32,
|
||||
}
|
||||
|
||||
impl RendezvousMediator {
|
||||
/// 启动连接管理器(根据配置选择 UDP 或 TCP 模式)
|
||||
pub async fn start(server: ServerPtr, host: String) -> ResultType<()> {
|
||||
log::info!("start rendezvous mediator of {}", host);
|
||||
|
||||
// 根据配置选择通信模式
|
||||
if cfg!(debug_assertions) && option_env!("TEST_TCP").is_some()
|
||||
|| Config::is_proxy()
|
||||
|| use_ws()
|
||||
|| crate::is_udp_disabled()
|
||||
{
|
||||
// WebSocket/代理模式
|
||||
Self::start_tcp(server, host).await
|
||||
} else {
|
||||
// UDP 直连模式
|
||||
Self::start_udp(server, host).await
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 双协议支持
|
||||
|
||||
```rust
|
||||
// UDP 直连模式(优先使用,性能更好)
|
||||
async fn start_udp(server: ServerPtr, host: String) -> ResultType<()> {
|
||||
// 注册超时管理
|
||||
const MIN_REG_TIMEOUT: i64 = 3_000;
|
||||
const MAX_REG_TIMEOUT: i64 = 30_000;
|
||||
let mut reg_timeout = MIN_REG_TIMEOUT;
|
||||
|
||||
loop {
|
||||
// 心跳延迟计算(EMA 指数移动平均)
|
||||
if ema_latency == 0 {
|
||||
ema_latency = latency;
|
||||
} else {
|
||||
ema_latency = latency / 30 + (ema_latency * 29 / 30);
|
||||
}
|
||||
|
||||
// 动态调整注册间隔
|
||||
let mut n = latency / 5;
|
||||
if n < 3000 { n = 3000; }
|
||||
|
||||
// 心跳保活
|
||||
if !send_heartbeat(...).await {
|
||||
reg_timeout = (reg_timeout * 2).min(MAX_REG_TIMEOUT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TCP/WebSocket 模式(代理环境或 UDP 被禁用时)
|
||||
async fn start_tcp(server: ServerPtr用于, host: String) -> ResultType<()> {
|
||||
// 建立 TCP 连接
|
||||
let mut socket = connect_tcp(&*self.host, CONNECT_TIMEOUT).await?;
|
||||
|
||||
// 发送注册消息
|
||||
let mut msg_out = Message::new();
|
||||
let mut rr = RegisterRelay {
|
||||
socket_addr: socket_addr.into(),
|
||||
version: crate::VERSION.to_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
msg_out.set_register_relay(rr);
|
||||
socket.send(&msg_out).await?;
|
||||
|
||||
// 处理中继响应
|
||||
if let Some(msg) = socket.next().await {
|
||||
let response = msg?.payload;
|
||||
// 建立中继连接...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## P2P 直连流程 (打洞)
|
||||
|
||||
### 打洞请求处理
|
||||
|
||||
```rust
|
||||
// src/rendezvous_mediator.rs
|
||||
|
||||
async fn handle_punch_hole(&self, ph: PunchHole, server: ServerPtr) -> ResultType<()> {
|
||||
// 1. 解析对端地址
|
||||
let mut peer_addr = AddrMangle::decode(&ph.socket_addr);
|
||||
|
||||
// 2. 去重处理(100ms 内重复消息忽略)
|
||||
let last = *LAST_MSG.lock().await;
|
||||
if last.0 == peer_addr && last.1.elapsed().as_millis() < 100 {
|
||||
return Ok(()); // 忽略重复消息
|
||||
}
|
||||
|
||||
// 3. 判断是否需要中继
|
||||
let relay = use_ws() || Config::is_proxy() || ph.force_relay;
|
||||
let nat_type = NatType::from_i32(Config::get_nat_type())?;
|
||||
|
||||
// 对称型 NAT 或强制中继时降级到中继模式
|
||||
if nat_type == NatType::SYMMETRIC || relay || (config::is_disable_tcp_listen() && ph.udp_port <= 0) {
|
||||
return self.create_relay(...).await;
|
||||
}
|
||||
|
||||
// 4. UDP 打洞
|
||||
if ph.udp_port > 0 {
|
||||
peer_addr.set_port(ph.udp_port as u16);
|
||||
return self.punch_udp_hole(peer_addr, server, msg_punch, control_permissions).await;
|
||||
}
|
||||
|
||||
// 5. TCP 打洞
|
||||
let mut socket = connect_tcp(&*self.host, CONNECT_TIMEOUT).await?;
|
||||
allow_err!(socket_client::connect_tcp_local(peer_addr, Some(local_addr), 30).await);
|
||||
}
|
||||
```
|
||||
|
||||
### NAT 类型与连接策略
|
||||
|
||||
```rust
|
||||
// NAT 类型决定连接策略
|
||||
enum NatType {
|
||||
NO_NAT, // 无 NAT,优先 P2P 直连
|
||||
FULL_CONE, // 完全锥形 NAT,优先 P2P 直连
|
||||
RESTRICTED, // 限制锥形 NAT,可能需要中继
|
||||
SYMMETRIC, // 对称型 NAT,必须中继
|
||||
}
|
||||
|
||||
// 连接策略选择
|
||||
fn select_connection_strategy(nat_type: NatType, force_relay: bool) -> ConnectionMode {
|
||||
if force_relay || nat_type == NatType::SYMMETRIC {
|
||||
ConnectionMode::Relay // 必须中继
|
||||
} else if nat_type == NatType::RESTRICTED {
|
||||
ConnectionMode::TryP2PThenRelay // 尝试 P2P,失败则中继
|
||||
} else {
|
||||
ConnectionMode::P2P // 优先 P2P
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 中继模式 (Relay)
|
||||
|
||||
### 创建中继连接
|
||||
|
||||
```rust
|
||||
// src/rendezvous_mediator.rs
|
||||
|
||||
async fn create_relay(
|
||||
&self,
|
||||
socket_addr: Vec<u8>,
|
||||
relay_server: String,
|
||||
uuid: String,
|
||||
server: ServerPtr,
|
||||
secure: bool,
|
||||
initiate: bool,
|
||||
socket_addr_v6: bytes::Bytes,
|
||||
control_permissions: Option<ControlPermissions>,
|
||||
) -> ResultType<()> {
|
||||
// 1. 建立到 rendezvous server 的 TCP 连接
|
||||
let mut socket = connect_tcp(&*self.host, CONNECT_TIMEOUT).await?;
|
||||
|
||||
// 2. 发送中继请求
|
||||
let mut msg_out = Message::new();
|
||||
let mut rr = RelayResponse {
|
||||
socket_addr: socket_addr.into(),
|
||||
version: crate::VERSION.to_owned(),
|
||||
socket_addr_v6,
|
||||
..Default::default()
|
||||
};
|
||||
msg_out.set_relay_response(rr);
|
||||
socket.send(&msg_out).await?;
|
||||
|
||||
// 3. 建立 relay 连接
|
||||
crate::create_relay_connection(
|
||||
server,
|
||||
relay_server,
|
||||
uuid,
|
||||
peer_addr,
|
||||
secure,
|
||||
is_ipv4(&self.addr),
|
||||
control_permissions,
|
||||
).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### 心跳保活机制
|
||||
|
||||
```rust
|
||||
// 心跳机制确保连接活跃
|
||||
async fn send_heartbeat(&self, socket: &mut impl FramedWrite) -> bool {
|
||||
let mut msg = Message::new();
|
||||
msg.set_ping(Ping {
|
||||
timestamp: get_time().await,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
if let Err(e) = socket.send(&msg).await {
|
||||
log::error!("Heartbeat failed: {}", e);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// 心跳超时检测
|
||||
fn check_heartbeat_timeout(last_heartbeat: Option<DateTime<Utc>>, timeout_secs: i64) -> bool {
|
||||
if let Some(timestamp) = last_heartbeat {
|
||||
let elapsed = Utc::now() - timestamp;
|
||||
elapsed.num_seconds() > timeout_secs
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Protocol Buffers 消息定义
|
||||
|
||||
### 主消息类型
|
||||
|
||||
```protobuf
|
||||
// libs/hbb_common/src/message_proto/mod.rs
|
||||
|
||||
// 主消息类型(oneof 模式支持多种消息)
|
||||
message Message {
|
||||
int32 id = 1; // 消息 ID
|
||||
MessageType type = 2; // 消息类型
|
||||
oneof payload {
|
||||
LoginRequest login_request = 3;
|
||||
LoginResponse login_response = 4;
|
||||
AudioFrame audio_frame = 5;
|
||||
VideoFrame video_frame = 6;
|
||||
InputEvent input_event = 7;
|
||||
ClipboardData clipboard_data = 8;
|
||||
FileTransfer file_transfer = 9;
|
||||
CloseReason close_reason = 10;
|
||||
Ping ping = 11;
|
||||
Pong pong = 12;
|
||||
PunchHole punch_hole = 13; // 打洞消息
|
||||
RelayResponse relay_response = 14; // 中继响应
|
||||
}
|
||||
}
|
||||
|
||||
enum MessageType {
|
||||
NONE = 0;
|
||||
LOGIN_REQUEST = 1;
|
||||
LOGIN_RESPONSE = 2;
|
||||
AUDIO_FRAME = 3;
|
||||
VIDEO_FRAME = 4;
|
||||
INPUT_EVENT = 5;
|
||||
CLIPBOARD = 6;
|
||||
FILE_TRANSFER = 7;
|
||||
CLOSE = 8;
|
||||
PING = 9;
|
||||
PONG = 10;
|
||||
PUNCH_HOLE = 11;
|
||||
RELAY_RESPONSE = 12;
|
||||
}
|
||||
```
|
||||
|
||||
### 打洞消息
|
||||
|
||||
```protobuf
|
||||
message PunchHole {
|
||||
string peer_id = 1; // 对端 ID
|
||||
bytes socket_addr = 2; // 序列化地址
|
||||
int32 socket_addr_version = 3; // 地址版本
|
||||
int32 peer_port = 4; // 对端端口
|
||||
bool force_relay = 5; // 强制中继
|
||||
int32 nat_type = 6; // NAT 类型
|
||||
bytes local_addr = 7; // 本地地址
|
||||
int32 local_port = 8; // 本地端口
|
||||
int32 udp_port = 9; // UDP 端口
|
||||
int32 conn_type = 10; // 连接类型
|
||||
bytes socket_addr_v6 = 11; // IPv6 地址
|
||||
bytes local_addr_v6 = 12; // IPv6 本地地址
|
||||
}
|
||||
```
|
||||
|
||||
### 文件传输(支持分片和断点续传)
|
||||
|
||||
```protobuf
|
||||
message FileTransfer {
|
||||
string id = 1; // 传输 ID
|
||||
FileTransferAction action = 2;
|
||||
bytes data = 3; // 文件数据
|
||||
int64 offset = 4; // 文件偏移(支持断点续传)
|
||||
bool compressed = 5; // 是否压缩
|
||||
}
|
||||
|
||||
enum FileTransferAction {
|
||||
FILE_TRANSFER_NONE = 0;
|
||||
FILE_TRANSFER_REQUEST = 1; // 请求传输
|
||||
FILE_TRANSFER_RESPONSE = 2; // 响应
|
||||
FILE_TRANSFER_DATA = 3; // 数据块
|
||||
FILE_TRANSFER_CANCEL = 4; // 取消
|
||||
FILE_TRANSFER_DONE = 5; // 完成
|
||||
}
|
||||
|
||||
// 文件元数据
|
||||
message FileMeta {
|
||||
string name = 1;
|
||||
int64 size = 2;
|
||||
int64 modified = 3;
|
||||
bool is_directory = 4;
|
||||
}
|
||||
|
||||
// 文件块
|
||||
message FileChunk {
|
||||
int32 id = 1;
|
||||
int32 file_num = 2;
|
||||
bytes data = 3;
|
||||
bool compressed = 4;
|
||||
int64 offset = 5;
|
||||
}
|
||||
```
|
||||
|
||||
### 输入事件
|
||||
|
||||
```protobuf
|
||||
message InputEvent {
|
||||
oneof event {
|
||||
KeyEvent key = 1;
|
||||
MouseEvent mouse = 2;
|
||||
WheelEvent wheel = 3;
|
||||
ClipboardEvent clipboard = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message KeyEvent {
|
||||
Direction direction = 1;
|
||||
int32 code = 2; // 扫描码
|
||||
int32 key = 3; // 虚拟键码
|
||||
bool scan = 4;
|
||||
bool raw = 5;
|
||||
}
|
||||
|
||||
message MouseEvent {
|
||||
Direction direction = 1;
|
||||
int32 button = 2;
|
||||
int32 x = 3;
|
||||
int32 y = 4;
|
||||
}
|
||||
|
||||
message WheelEvent {
|
||||
Axis axis = 1;
|
||||
int32 delta = 2;
|
||||
}
|
||||
|
||||
enum Direction {
|
||||
DOWN = 0;
|
||||
UP = 1;
|
||||
}
|
||||
|
||||
enum Axis {
|
||||
HORIZONTAL = 0;
|
||||
VERTICAL = 1;
|
||||
}
|
||||
```
|
||||
|
||||
## 在本项目中的使用
|
||||
|
||||
### 1. 客户端连接管理
|
||||
|
||||
```rust
|
||||
// agent-client/src/connection/mod.rs
|
||||
|
||||
use hbb_common::{rendezvous_proto::*, ResultType};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct ConnectionManager {
|
||||
mediator: Arc<RendezvousMediator>,
|
||||
state: ConnectionState,
|
||||
}
|
||||
|
||||
pub enum ConnectionState {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected(PeerConnection),
|
||||
Relay(RelayConnection),
|
||||
}
|
||||
|
||||
impl ConnectionManager {
|
||||
/// 连接到远程客户端
|
||||
pub async fn connect(
|
||||
&self,
|
||||
peer_id: &str,
|
||||
key: &str,
|
||||
) -> ResultType<PeerConnection> {
|
||||
// 1. 从 hbbs 获取对端信息
|
||||
let peer_info = self.mediator.get_peer_info(peer_id).await?;
|
||||
|
||||
// 2. 尝试 P2P 直连
|
||||
if let Some(conn) = self.try_p2p_connect(&peer_info, key).await {
|
||||
return Ok(conn);
|
||||
}
|
||||
|
||||
// 3. P2P 失败,降级到中继
|
||||
self.connect_via_relay(&peer_info, key).await
|
||||
}
|
||||
|
||||
/// 监听远程连接
|
||||
pub async fn listen(&self, id: &str, key: &str) -> ResultType<()> {
|
||||
self.mediator.start(id, key).await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 消息收发
|
||||
|
||||
```rust
|
||||
// agent-client/src/connection/message.rs
|
||||
|
||||
use hbb_common::message_proto::{Message, MessageType};
|
||||
|
||||
pub struct MessageChannel {
|
||||
stream: Box<dyn FramedStream>,
|
||||
}
|
||||
|
||||
impl MessageChannel {
|
||||
/// 发送消息
|
||||
pub async fn send(&mut self, msg: &Message) -> ResultType<()> {
|
||||
self.stream.send(msg).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 接收消息
|
||||
pub async fn recv(&mut self) -> ResultType<Option<Message>> {
|
||||
match self.stream.next().await {
|
||||
Some(Ok(msg)) => Ok(Some(msg)),
|
||||
Some(Err(e)) => Err(e.into()),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送聊天消息
|
||||
pub async fn send_chat(&mut self, content: &str) -> ResultType<()> {
|
||||
let msg = Message {
|
||||
id: generate_id(),
|
||||
type_: MessageType::MESSAGE_CHAT as i32,
|
||||
payload: Some(ChatMessage {
|
||||
content: content.to_string(),
|
||||
timestamp: get_timestamp(),
|
||||
}.into()),
|
||||
..Default::default()
|
||||
};
|
||||
self.send(&msg).await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 连接状态指示
|
||||
|
||||
```rust
|
||||
// agent-client/src/ui/status_bar.rs
|
||||
|
||||
use hbb_common::rendezvous_proto::PunchHole;
|
||||
|
||||
pub struct ConnectionIndicator {
|
||||
state: ConnectionState,
|
||||
last_heartbeat: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Render for ConnectionIndicator {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let status = match self.state {
|
||||
ConnectionState::Connected => Status::Online,
|
||||
ConnectionState::Relay => Status::Relay, // 中继模式
|
||||
ConnectionState::Connecting => Status::Connecting,
|
||||
ConnectionState::Disconnected => Status::Offline,
|
||||
};
|
||||
|
||||
let color = match status {
|
||||
Status::Online => cx.theme().colors.success,
|
||||
Status::Relay => cx.theme().colors.warning,
|
||||
Status::Connecting => cx.theme().colors.info,
|
||||
Status::Offline => cx.theme().colors.error,
|
||||
};
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.w_3()
|
||||
.h_3()
|
||||
.rounded_full()
|
||||
.bg(color)
|
||||
)
|
||||
.child(match status {
|
||||
Status::Online => "已连接 (P2P)",
|
||||
Status::Relay => "已连接 (中继)",
|
||||
Status::Connecting => "连接中...",
|
||||
Status::Offline => "未连接",
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 文件传输功能
|
||||
|
||||
### 文件分片传输
|
||||
|
||||
```rust
|
||||
// agent-client/src/connection/file_transfer.rs
|
||||
|
||||
use hbb_common::message_proto::{FileTransfer, FileTransferAction};
|
||||
|
||||
const CHUNK_SIZE: usize = 64 * 1024; // 64KB 分片
|
||||
|
||||
pub struct FileTransferManager {
|
||||
upload_jobs: HashMap<String, UploadJob>,
|
||||
download_jobs: HashMap<String, DownloadJob>,
|
||||
}
|
||||
|
||||
pub struct UploadJob {
|
||||
file: File,
|
||||
path: PathBuf,
|
||||
offset: i64,
|
||||
peer_id: String,
|
||||
}
|
||||
|
||||
impl FileTransferManager {
|
||||
/// 上传文件(支持断点续传)
|
||||
pub async fn upload(&mut self, path: &Path, peer_id: &str) -> ResultType<()> {
|
||||
let file = File::open(path).await?;
|
||||
let metadata = file.metadata().await?;
|
||||
let file_name = path.file_name().unwrap().to_string_lossy();
|
||||
|
||||
// 检查断点续传
|
||||
let offset = self.get_checkpoint(&peer_id, &file_name).unwrap_or(0);
|
||||
|
||||
let job = UploadJob {
|
||||
file,
|
||||
path: path.to_path_buf(),
|
||||
offset,
|
||||
peer_id: peer_id.to_string(),
|
||||
};
|
||||
|
||||
// 开始分片传输
|
||||
self.upload_with_offset(job).await
|
||||
}
|
||||
|
||||
async fn upload_with_offset(&mut self, mut job: UploadJob) -> ResultType<()> {
|
||||
job.file.seek(SeekFrom::Start(job.offset as u64)).await?;
|
||||
|
||||
let mut buffer = vec![0u8; CHUNK_SIZE];
|
||||
let mut offset = job.offset;
|
||||
|
||||
while let Ok(n) = job.file.read(&mut buffer).await {
|
||||
if n == 0 { break; }
|
||||
|
||||
// 发送分片
|
||||
let chunk = FileTransfer {
|
||||
id: job.peer_id.clone(),
|
||||
action: FileTransferAction::FILE_TRANSFER_DATA as i32,
|
||||
data: buffer[..n].to_vec(),
|
||||
offset,
|
||||
compressed: false,
|
||||
};
|
||||
|
||||
self.send_chunk(&chunk).await?;
|
||||
|
||||
// 保存断点
|
||||
self.save_checkpoint(&job.peer_id, &job.path, offset);
|
||||
|
||||
offset += n as i64;
|
||||
}
|
||||
|
||||
// 传输完成
|
||||
self.send_done(&job.peer_id).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 可复用代码
|
||||
|
||||
### 核心复用模块
|
||||
|
||||
| 模块 | 路径 | 用途 |
|
||||
|------|------|------|
|
||||
| **hbb_common** | `libs/hbb_common/` | 消息编解码、网络工具、配置管理 |
|
||||
| **scrap** | `libs/scrap/` | 屏幕捕获、视频编码 |
|
||||
| **enigo** | `libs/enigo/` | 键盘鼠标输入模拟 |
|
||||
| **clipboard** | `libs/clipboard/` | 跨平台剪贴板同步 |
|
||||
|
||||
### 推荐集成方式
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
hbb_common = { path = "vendors/qiming-rustdesk/libs/hbb_common" }
|
||||
```
|
||||
|
||||
## 关键设计模式
|
||||
|
||||
1. **双协议支持**:UDP 直连优先,失败降级 TCP/WebSocket 中继
|
||||
2. **NAT 打洞**:根据 NAT 类型自动选择连接策略
|
||||
3. **心跳保活**:EMA 动态调整心跳间隔
|
||||
4. **断点续传**:支持文件传输中断后继续
|
||||
5. **分片传输**:大文件分块传输,提高可靠性
|
||||
|
||||
## 与本项目的集成点
|
||||
|
||||
| 功能 | 使用模块 | 说明 |
|
||||
|------|----------|------|
|
||||
| 双向通信 | `hbb_common::message_proto` | 消息编解码 |
|
||||
| P2P 直连 | `rendezvous_mediator` | NAT 穿透 |
|
||||
| 中继传输 | `relay_connection` | 数据中转 |
|
||||
| 文件传输 | `file_transfer` | 分片传输 |
|
||||
| 输入模拟 | `enigo` | 远程控制 |
|
||||
| 屏幕捕获 | `scrap` | 远程桌面 |
|
||||
879
qimingclaw/specs/agent-client/vendorsdoc/rcoder.md
Normal file
879
qimingclaw/specs/agent-client/vendorsdoc/rcoder.md
Normal file
@@ -0,0 +1,879 @@
|
||||
# rcoder
|
||||
|
||||
## 项目概述
|
||||
|
||||
基于 ACP (Agent Protocol) 协议的 AI 代理开发平台,提供 Docker 容器化运行时和反向代理。本项目的核心模块 `agent_runner` 提供了完整的 Agent 生命周期管理能力,包括 Docker 容器管理、消息收发、超时自动销毁等功能。
|
||||
|
||||
**本地路径**: `vendors/rcoder`
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
rcoder/
|
||||
├── Cargo.toml # workspace 配置
|
||||
├── crates/
|
||||
│ ├── rcoder/ # 主应用服务
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── lib.rs
|
||||
│ │ │ ├── main.rs
|
||||
│ │ │ ├── app.rs # 应用主逻辑
|
||||
│ │ │ ├── router.rs # HTTP 路由
|
||||
│ │ │ ├── state.rs # 应用状态
|
||||
│ │ │ ├── error.rs # 错误处理
|
||||
│ │ │ └── middlewares/ # 中间件
|
||||
│ │ └── Cargo.toml
|
||||
│ │
|
||||
│ ├── agent_runner/ # Agent 运行时核心(最重要)
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── lib.rs
|
||||
│ │ │ ├── manager.rs # AgentWorkerManager
|
||||
│ │ │ ├── worker.rs # Agent Worker
|
||||
│ │ │ ├── adapter.rs # ACP 协议适配器
|
||||
│ │ │ ├── types.rs # 类型定义
|
||||
│ │ │ ├── message.rs # 消息处理
|
||||
│ │ │ └── docker.rs # Docker 集成
|
||||
│ │ └── Cargo.toml
|
||||
│ │
|
||||
│ ├── docker_manager/ # Docker 容器管理
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── lib.rs
|
||||
│ │ │ ├── manager.rs # DockerManager
|
||||
│ │ │ ├── actor.rs # Actor 模式状态
|
||||
│ │ │ ├── container.rs # 容器操作
|
||||
│ │ │ └── network.rs # 网络管理
|
||||
│ │ └── Cargo.toml
|
||||
│ │
|
||||
│ ├── acp_adapter/ # ACP 协议适配器
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── lib.rs
|
||||
│ │ │ ├── types.rs # ACP 类型定义
|
||||
│ │ │ ├── client.rs # ACP 客户端
|
||||
│ │ │ └── handler.rs # 消息处理
|
||||
│ │ └── Cargo.toml
|
||||
│ │
|
||||
│ ├── shared_types/ # 共享类型定义
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── lib.rs
|
||||
│ │ │ ├── proto/ # gRPC proto 文件
|
||||
│ │ │ │ └── agent.proto
|
||||
│ │ │ └── types.rs
|
||||
│ │ └── Cargo.toml
|
||||
│ │
|
||||
│ └── rcoder-proxy/ # 反向代理
|
||||
│ ├── src/
|
||||
│ │ ├── lib.rs
|
||||
│ │ ├── proxy.rs
|
||||
│ │ └── upstream.rs
|
||||
│ └── Cargo.toml
|
||||
└── Cargo.lock
|
||||
```
|
||||
|
||||
## Agent Worker Manager (agent_runner)
|
||||
|
||||
这是 rcoder 的核心模块,负责管理 agent 的生命周期。
|
||||
|
||||
### 核心结构体
|
||||
|
||||
```rust
|
||||
// crates/agent_runner/src/manager.rs
|
||||
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use chrono::DateTime;
|
||||
use dashmap::DashMap;
|
||||
|
||||
pub struct AgentWorkerManager {
|
||||
// 任务发送器(使用 ArcSwap 支持热更新)
|
||||
sender: ArcSwap<Option<mpsc::UnboundedSender<LocalSetAgentRequest>>>,
|
||||
|
||||
// 状态广播
|
||||
state_tx: watch::Sender<WorkerState>,
|
||||
|
||||
// 最后心跳时间
|
||||
last_heartbeat: Arc<Mutex<Option<DateTime<Utc>>>>,
|
||||
|
||||
// 状态变化时间
|
||||
last_state_change: Arc<Mutex<DateTime<Utc>>>,
|
||||
|
||||
// 活跃请求追踪
|
||||
active_requests: Arc<DashMap<String, DateTime<Utc>>>,
|
||||
|
||||
// 配置
|
||||
config: AgentRunnerConfig,
|
||||
}
|
||||
|
||||
// Worker 状态
|
||||
pub enum WorkerState {
|
||||
Starting,
|
||||
Running,
|
||||
Stopping,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
impl Default for WorkerState {
|
||||
fn default() -> Self {
|
||||
WorkerState::Stopped
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 心跳超时检测
|
||||
|
||||
```rust
|
||||
// crates/agent_runner/src/manager.rs
|
||||
|
||||
impl AgentWorkerManager {
|
||||
/// 检查心跳是否超时
|
||||
pub fn check_heartbeat_timeout(&self) -> bool {
|
||||
let last_heartbeat_opt = {
|
||||
let last = self.last_heartbeat.lock().expect("mutex poisoned");
|
||||
*last // Copy 数据,立即释放锁
|
||||
};
|
||||
|
||||
if let Some(timestamp) = last_heartbeat_opt {
|
||||
// 正常运行时:15秒无心跳视为超时
|
||||
let elapsed = Utc::now() - timestamp;
|
||||
elapsed.num_seconds() > 15
|
||||
} else {
|
||||
// 首次启动:30秒内未收到心跳视为超时
|
||||
let state_change = self.last_state_change.lock().expect("mutex poisoned");
|
||||
let elapsed = Utc::now() - *state_change;
|
||||
elapsed.num_seconds() > 30
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新心跳时间
|
||||
pub fn update_heartbeat(&self) {
|
||||
let mut last = self.last_heartbeat.lock().expect("mutex poisoned");
|
||||
*last = Some(Utc::now());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Agent Worker 核心逻辑
|
||||
|
||||
```rust
|
||||
// crates/agent_runner/src/worker.rs
|
||||
|
||||
pub struct AgentWorker {
|
||||
config: AgentRunnerConfig,
|
||||
receiver: mpsc::UnboundedReceiver<LocalSetAgentRequest>,
|
||||
state_tx: watch::Sender<WorkerState>,
|
||||
docker: DockerManager,
|
||||
adapter: ACPAdapter,
|
||||
}
|
||||
|
||||
impl AgentWorker {
|
||||
pub async fn run(&mut self) {
|
||||
tracing::info!("AgentWorker started");
|
||||
self.state_tx.send(WorkerState::Running).unwrap();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(request) = self.receiver.recv() => {
|
||||
self.handle_request(request).await;
|
||||
}
|
||||
_ = self.docker.health_check() => {
|
||||
// Docker 健康检查
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_request(&mut self, request: LocalSetAgentRequest) {
|
||||
match request {
|
||||
LocalSetAgentRequest::Message { session_id, content } => {
|
||||
self.handle_message(&session_id, &content).await;
|
||||
}
|
||||
LocalSetAgentRequest::Stop => {
|
||||
self.shutdown().await;
|
||||
return;
|
||||
}
|
||||
LocalSetAgentRequest::Spawn { config } => {
|
||||
self.spawn_container(&config).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动 Docker 容器
|
||||
async fn spawn_container(&mut self, config: &AgentConfig) -> Result<(), AgentError> {
|
||||
// 1. 创建容器
|
||||
let container_id = self.docker.create_container(CreateContainerOptions {
|
||||
image: config.image.clone(),
|
||||
env: config.env_vars.clone(),
|
||||
Cmd: config.command.clone().unwrap_or_else(|| vec!["/bin/sh"]),
|
||||
..Default::default()
|
||||
}).await?;
|
||||
|
||||
// 2. 启动容器
|
||||
self.docker.start_container(&container_id).await?;
|
||||
|
||||
// 3. 等待容器就绪
|
||||
self.docker.wait_until_ready(&container_id).await?;
|
||||
|
||||
// 4. 建立 ACP 连接
|
||||
self.adapter.connect(&container_id, config.acp_url).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ACP 协议消息类型
|
||||
|
||||
```rust
|
||||
// crates/agent_runner/src/types.rs
|
||||
|
||||
use agent_client_protocol::{Message, MessageChunk};
|
||||
|
||||
// ACP 流更新事件
|
||||
pub enum StreamUpdate {
|
||||
UserMessageChunk {
|
||||
session_id: String,
|
||||
content: String,
|
||||
},
|
||||
AgentMessageChunk {
|
||||
session_id: String,
|
||||
content: String,
|
||||
},
|
||||
ToolCall {
|
||||
session_id: String,
|
||||
tool_call: ToolCall,
|
||||
},
|
||||
ToolCallUpdate {
|
||||
session_id: String,
|
||||
tool_call_update: ToolCallUpdate,
|
||||
},
|
||||
SessionStateChanged {
|
||||
session_id: String,
|
||||
new_state: SessionState,
|
||||
message: String,
|
||||
},
|
||||
Plan {
|
||||
session_id: String,
|
||||
plan: Plan,
|
||||
},
|
||||
StepFinished {
|
||||
session_id: String,
|
||||
step_id: String,
|
||||
},
|
||||
SessionFinished {
|
||||
session_id: String,
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub enum SessionState {
|
||||
Initializing,
|
||||
Connected,
|
||||
Prompting,
|
||||
Paused,
|
||||
Closed,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
pub struct Plan {
|
||||
pub entries: Vec<PlanEntry>,
|
||||
pub created_at: SystemTime,
|
||||
pub status: PlanStatus,
|
||||
}
|
||||
|
||||
pub enum PlanStatus {
|
||||
Pending,
|
||||
Approved,
|
||||
Executing,
|
||||
Finished,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub arguments: serde_json::Value,
|
||||
}
|
||||
|
||||
pub struct ToolCallUpdate {
|
||||
pub id: String,
|
||||
pub status: ToolCallStatus,
|
||||
pub result: Option<serde_json::Value>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub enum ToolCallStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
```
|
||||
|
||||
## Docker Manager (docker_manager)
|
||||
|
||||
Docker 容器管理模块,使用 Actor 模式管理容器状态,避免并发问题。
|
||||
|
||||
### 核心结构体
|
||||
|
||||
```rust
|
||||
// crates/docker_manager/src/manager.rs
|
||||
|
||||
use bollard::{Docker, APIError};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub struct DockerManager {
|
||||
docker: Docker, // Docker 客户端
|
||||
config: DockerManagerConfig, // 配置
|
||||
containers: ContainerStateHandle, // Actor 模式状态
|
||||
main_network_name: Arc<RwLock<String>>, // 网络名称
|
||||
}
|
||||
|
||||
pub struct DockerManagerConfig {
|
||||
pub socket_path: Option<String>, // Docker socket 路径
|
||||
pub network_name: String, // 网络名称
|
||||
pub max_containers: usize, // 最大容器数
|
||||
pub container_ttl: Duration, // 容器存活时间
|
||||
pub auto_remove: bool, // 是否自动删除
|
||||
}
|
||||
```
|
||||
|
||||
### Actor 模式状态管理
|
||||
|
||||
```rust
|
||||
// crates/docker_manager/src/container_state_actor.rs
|
||||
|
||||
// 使用 Actor 模式避免 DashMap 跨 await 持有锁导致的死锁
|
||||
pub struct ContainerStateActor {
|
||||
containers: HashMap<String, DockerContainerInfo>,
|
||||
receiver: mpsc::Receiver<ContainerStateCommand>,
|
||||
}
|
||||
|
||||
pub enum ContainerStateCommand {
|
||||
Get { key: String, reply: oneshot::Sender<Option<DockerContainerInfo>> },
|
||||
Insert { key: String, info: DockerContainerInfo },
|
||||
Remove { key: String, reply: oneshot::Sender<Option<DockerContainerInfo>> },
|
||||
List { reply: oneshot::Sender<Vec<DockerContainerInfo>> },
|
||||
Keys { reply: oneshot::Sender<Vec<String>> },
|
||||
RemoveIfContainerId { key: String, container_id: String, reply: oneshot::Sender<Option<DockerContainerInfo>> },
|
||||
}
|
||||
|
||||
impl ContainerStateActor {
|
||||
pub async fn run(&mut self) {
|
||||
while let Some(cmd) = self.receiver.recv().await {
|
||||
match cmd {
|
||||
ContainerStateCommand::Get { key, reply } => {
|
||||
let info = self.containers.get(&key).cloned();
|
||||
reply.send(info).ok();
|
||||
}
|
||||
ContainerStateCommand::Insert { key, info } => {
|
||||
self.containers.insert(key, info);
|
||||
}
|
||||
// ... 其他命令处理
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 容器创建流程
|
||||
|
||||
```rust
|
||||
// crates/docker_manager/src/manager.rs
|
||||
|
||||
impl DockerManager {
|
||||
pub async fn create_container(
|
||||
&self,
|
||||
config: DockerContainerConfig,
|
||||
) -> DockerResult<DockerContainerInfo> {
|
||||
let container_name = format!("{}-{}", self.config.network_name, config.name);
|
||||
|
||||
// 1. 清理同名旧容器
|
||||
if let Ok(Some((existing_id, _, status, is_running))) =
|
||||
self.find_container_realtime(&container_name).await
|
||||
{
|
||||
if is_running {
|
||||
self.stop_container_by_id(&existing_id).await?;
|
||||
}
|
||||
self.remove_container_by_id(&existing_id).await?;
|
||||
}
|
||||
|
||||
// 2. 拉取镜像
|
||||
self.ensure_image_exists(&config.image).await?;
|
||||
|
||||
// 3. 构建挂载点
|
||||
let mut mounts = Vec::new();
|
||||
if !config.host_path.is_empty() {
|
||||
mounts.push(Mount {
|
||||
target: Some(config.container_path.clone()),
|
||||
source: Some(config.host_path.clone()),
|
||||
typ: Some(MountTypeEnum::BIND),
|
||||
read_only: Some(false),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 构建环境变量
|
||||
let mut env_vars: Vec<String> = config.env_vars
|
||||
.into_iter()
|
||||
.map(|(k, v)| format!("{}={}", k, v))
|
||||
.collect();
|
||||
|
||||
// 5. 构建网络配置
|
||||
let (networking_config, container_network_name) = if config.network_mode != "host" {
|
||||
let main_network = self.get_main_network_name().await;
|
||||
let mut endpoints = HashMap::new();
|
||||
endpoints.insert(main_network.clone(), EndpointSettings {
|
||||
aliases: Some(vec![container_name.clone()]),
|
||||
..Default::default()
|
||||
});
|
||||
(Some(NetworkingConfig { endpoints_config: Some(endpoints) }), main_network)
|
||||
} else {
|
||||
(None, "host".to_string())
|
||||
};
|
||||
|
||||
// 6. 创建并启动容器
|
||||
let container_config = ContainerConfig {
|
||||
image: config.image.clone(),
|
||||
env: Some(env_vars),
|
||||
cmd: config.command.map(|c| vec![c]),
|
||||
host_config: Some(HostConfig {
|
||||
mounts: Some(mounts),
|
||||
network_mode: Some(container_network_name),
|
||||
auto_remove: Some(self.config.auto_remove),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let response = self.docker.create_container(
|
||||
Some(CreateContainerOptions {
|
||||
name: container_name.clone(),
|
||||
..Default::default()
|
||||
}),
|
||||
container_config,
|
||||
).await?;
|
||||
|
||||
self.docker.start_container(&response.id, None::<StartContainerOptions>).await?;
|
||||
|
||||
// 7. 健康检查
|
||||
self.check_container_health(&response.id).await?;
|
||||
|
||||
// 8. 获取容器信息
|
||||
let inspect = self.docker.inspect_container(&response.id, None).await?;
|
||||
|
||||
Ok(DockerContainerInfo {
|
||||
id: response.id,
|
||||
name: container_name,
|
||||
image: config.image,
|
||||
status: "running".to_string(),
|
||||
ip_address: inspect.network_settings?.ip_address,
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 等待容器就绪
|
||||
pub async fn wait_until_ready(&self, container_id: &str) -> Result<(), DockerError> {
|
||||
let timeout = Duration::from_secs(60);
|
||||
let start_time = Utc::now();
|
||||
|
||||
loop {
|
||||
// 检查容器状态
|
||||
let info = self.inspect_container(container_id).await?;
|
||||
if info.state.as_ref().map(|s| &s.status) == Some(&ContainerStateStatusEnum::RUNNING) {
|
||||
// 检查健康检查
|
||||
if let Some(health) = &info.state.as_ref().unwrap().healthcheck {
|
||||
if health.status == ContainerHealthStatusEnum::HEALTHY {
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// 超时检查
|
||||
if (Utc::now() - start_time).num_seconds() > timeout.as_secs() as i64 {
|
||||
return Err(DockerError::Timeout);
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 超时自动销毁机制
|
||||
|
||||
```rust
|
||||
// crates/agent_runner/src/proxy_agent/cleanup_task.rs
|
||||
|
||||
pub struct CleanupConfig {
|
||||
pub idle_timeout: Duration, // 闲置超时(默认3分钟)
|
||||
pub cleanup_interval: Duration, // 清理间隔(默认30秒)
|
||||
}
|
||||
|
||||
impl Default for CleanupConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
idle_timeout: Duration::from_secs(3 * 60), // 3分钟
|
||||
cleanup_interval: Duration::from_secs(30), // 30秒
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理逻辑:基于 RAII 模式
|
||||
async fn cleanup_idle_agents(&mut self) -> Result<CleanupStats> {
|
||||
for entry in AGENT_REGISTRY.iter_agents() {
|
||||
let agent_info = entry.value();
|
||||
|
||||
// 只清理 Idle 状态的 agent
|
||||
if agent_info.status == AgentStatus::Idle
|
||||
&& self.is_agent_idle_timeout(agent_info.last_activity, current_time)
|
||||
{
|
||||
// 从 Registry 中移除,AgentLifecycleGuard 自动清理资源
|
||||
AGENT_REGISTRY.remove_by_project(project_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 环境变量和 PATH 管理
|
||||
|
||||
```rust
|
||||
// crates/agent_runner/src/docker.rs
|
||||
|
||||
pub fn build_environment(
|
||||
node_path: &Path,
|
||||
npm_global_path: &Path,
|
||||
extra_env: &HashMap<String, String>,
|
||||
) -> Vec<String> {
|
||||
let mut env = vec![
|
||||
// Node.js 环境
|
||||
format!("PATH={}:{}:{}",
|
||||
node_path.join("bin").to_string_lossy(),
|
||||
npm_global_path.join("bin").to_string_lossy(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
),
|
||||
format!("NODE_PATH={}", node_path.join("lib").to_string_lossy()),
|
||||
format!("NPM_CONFIG_PREFIX={}", npm_global_path.to_string_lossy()),
|
||||
];
|
||||
|
||||
// 添加额外的环境变量
|
||||
for (k, v) in extra_env {
|
||||
env.push(format!("{}={}", k, v));
|
||||
}
|
||||
|
||||
env
|
||||
}
|
||||
|
||||
// 在容器启动时设置环境变量
|
||||
pub async fn start_container_with_env(
|
||||
&self,
|
||||
config: &AgentConfig,
|
||||
) -> Result<String, AgentError> {
|
||||
let env = build_environment(
|
||||
&config.node_path,
|
||||
&config.npm_global_path,
|
||||
&config.extra_env,
|
||||
);
|
||||
|
||||
let container_id = self.docker.create_container(CreateContainerOptions {
|
||||
image: config.image.clone(),
|
||||
env: Some(env),
|
||||
Cmd: config.command.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
}).await?;
|
||||
|
||||
self.docker.start_container(&container_id, None).await?;
|
||||
Ok(container_id)
|
||||
}
|
||||
```
|
||||
|
||||
## Node.js 自动安装
|
||||
|
||||
```rust
|
||||
// crates/agent_runner/src/installer/node_installer.rs
|
||||
|
||||
pub struct NodeInstaller {
|
||||
install_dir: PathBuf,
|
||||
platform: Platform,
|
||||
}
|
||||
|
||||
pub enum Platform {
|
||||
Windows,
|
||||
MacOSIntel,
|
||||
MacOSAppleSilicon,
|
||||
Linux,
|
||||
}
|
||||
|
||||
impl NodeInstaller {
|
||||
/// 检测系统是否已有 Node.js
|
||||
pub async fn detect_system_node(&self) -> Option<Version> {
|
||||
// 检查系统 PATH
|
||||
if let Ok(path) = which("node") {
|
||||
if let Ok(output) = Command::new(&path)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
if output.status.success() {
|
||||
let version_str = String::from_utf8_lossy(&output.stdout);
|
||||
return Version::parse(&version_str.trim_start_matches('v')).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 下载并安装 Node.js
|
||||
pub async fn download_and_install(&self, version: &Version) -> Result<(), InstallationError> {
|
||||
let url = self.get_download_url(version);
|
||||
let archive_path = self.install_dir.join("cache").join(format!("node-{}.tar.xz", version));
|
||||
|
||||
// 1. 下载
|
||||
if !archive_path.exists() {
|
||||
self.download(&url, &archive_path).await?;
|
||||
}
|
||||
|
||||
// 2. 解压
|
||||
let extract_dir = self.install_dir.join("node").join(version.to_string());
|
||||
self.extract(&archive_path, &extract_dir).await?;
|
||||
|
||||
// 3. 创建 symlink
|
||||
let bin_path = extract_dir.join("bin");
|
||||
let symlink_path = self.install_dir.join("bin").join("node");
|
||||
if symlink_path.exists() {
|
||||
std::fs::remove_file(&symlink_path)?;
|
||||
}
|
||||
std::os::unix::fs::symlink(&bin_path.join("node"), &symlink_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_download_url(&self, version: &Version) -> String {
|
||||
match self.platform {
|
||||
Platform::Windows => format!(
|
||||
"https://nodejs.org/dist/v{}/node-v{}-win-x64.zip",
|
||||
version, version
|
||||
),
|
||||
Platform::MacOSIntel => format!(
|
||||
"https://nodejs.org/dist/v{}/node-v{}-darwin-x64.tar.gz",
|
||||
version, version
|
||||
),
|
||||
Platform::MacOSAppleSilicon => format!(
|
||||
"https://nodejs.org/dist/v{}/node-v{}-darwin-arm64.tar.gz",
|
||||
version, version
|
||||
),
|
||||
Platform::Linux => format!(
|
||||
"https://nodejs.org/dist/v{}/node-v{}-linux-x64.tar.xz",
|
||||
version, version
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## ACP 协议适配器
|
||||
|
||||
```rust
|
||||
// crates/acp_adapter/src/client.rs
|
||||
|
||||
use agent_client_protocol::{Client, Message as AcpMessage};
|
||||
|
||||
pub struct ACPAdapter {
|
||||
client: Option<Client<WebSocketStream<Upgraded>>>,
|
||||
session_id: String,
|
||||
pending_requests: Arc<DashMap<String, oneshot::Sender<Result<String>>>>,
|
||||
}
|
||||
|
||||
impl ACPAdapter {
|
||||
/// 连接到 Agent 容器
|
||||
pub async fn connect(
|
||||
&mut self,
|
||||
container_id: &str,
|
||||
url: &str,
|
||||
) -> Result<(), AdapterError> {
|
||||
// 获取容器 IP
|
||||
let container_ip = self.get_container_ip(container_id).await?;
|
||||
|
||||
// 建立 WebSocket 连接
|
||||
let stream = WebSocket::connect(url).await?;
|
||||
let (ws_stream, _) = stream.split();
|
||||
let (write, read) = ws_stream.split();
|
||||
|
||||
// 创建 ACP 客户端
|
||||
self.client = Some(Client::new(read, write));
|
||||
|
||||
// 生成会话 ID
|
||||
self.session_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 发送消息
|
||||
pub async fn send_message(&self, session_id: &str, content: &str) {
|
||||
if let Some(client) = &self.client {
|
||||
let message = AcpMessage::user_message_chunk(session_id, content);
|
||||
client.send(message).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// 接收消息
|
||||
pub async fn receive(&self) -> Option<StreamUpdate> {
|
||||
if let Some(client) = &self.client {
|
||||
if let Some(msg) = client.recv().await {
|
||||
Some(self.convert_message(msg))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 在本项目中的使用
|
||||
|
||||
### Agent 依赖管理界面
|
||||
|
||||
```rust
|
||||
// agent-client/src/ui/dependency_management.rs
|
||||
|
||||
use crate::agent::AgentManager;
|
||||
|
||||
pub struct DependencyManager {
|
||||
agent_manager: Arc<AgentManager>,
|
||||
node_installer: Arc<NodeInstaller>,
|
||||
}
|
||||
|
||||
impl DependencyManager {
|
||||
/// 检查所有依赖状态
|
||||
pub async fn check_all_dependencies(&self) -> Vec<DependencyStatus> {
|
||||
let mut statuses = vec![];
|
||||
|
||||
// 检查 Node.js
|
||||
statuses.push(self.check_node().await);
|
||||
|
||||
// 检查 npm
|
||||
statuses.push(self.check_npm().await);
|
||||
|
||||
// 检查 opencode
|
||||
statuses.push(self.check_agent("opencode").await);
|
||||
|
||||
// 检查 claude-code
|
||||
statuses.push(self.check_agent("@anthropic-ai/claude-code").await);
|
||||
|
||||
statuses
|
||||
}
|
||||
|
||||
async fn check_node(&self) -> DependencyStatus {
|
||||
// 1. 先检查系统全局
|
||||
if let Some(version) = self.node_installer.detect_system_node().await {
|
||||
return DependencyStatus {
|
||||
name: "Node.js",
|
||||
version: Some(version.to_string()),
|
||||
source: DependencySource::System,
|
||||
status: DependencyStatus::Installed,
|
||||
action: None,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 检查客户端目录
|
||||
if let Some(version) = self.detect_local_node().await {
|
||||
return DependencyStatus {
|
||||
name: "Node.js",
|
||||
version: Some(version.to_string()),
|
||||
source: DependencySource::Local,
|
||||
status: DependencyStatus::Installed,
|
||||
action: Some(DependencyAction::Update),
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 未安装
|
||||
DependencyStatus {
|
||||
name: "Node.js",
|
||||
version: None,
|
||||
source: DependencySource::None,
|
||||
status: DependencyStatus::NotInstalled,
|
||||
action: Some(DependencyAction::Install),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum DependencySource {
|
||||
System,
|
||||
Local,
|
||||
None,
|
||||
}
|
||||
|
||||
pub enum DependencyStatus {
|
||||
Installed,
|
||||
Installing,
|
||||
Failed,
|
||||
NotInstalled,
|
||||
NeedsUpdate,
|
||||
}
|
||||
```
|
||||
|
||||
### 安装目录结构
|
||||
|
||||
```
|
||||
<APP_DATA_DIR>/ # 客户端应用数据目录
|
||||
├── tools/
|
||||
│ ├── node/ # Node.js 安装目录
|
||||
│ │ ├── v20.10.0/
|
||||
│ │ │ ├── bin/
|
||||
│ │ │ │ ├── node
|
||||
│ │ │ │ └── npm
|
||||
│ │ │ └── lib/
|
||||
│ │ └── current -> v20.10.0 # 当前版本 symlink
|
||||
│ ├── npm-global/ # npm 全局安装目录(隔离)
|
||||
│ │ ├── bin/
|
||||
│ │ │ ├── opencode
|
||||
│ │ │ └── claude
|
||||
│ │ └── lib/
|
||||
│ └── versions.json # 已安装工具的版本记录
|
||||
├── config/ # 配置文件
|
||||
├── logs/ # 日志文件
|
||||
└── cache/ # 缓存文件(下载的安装包等)
|
||||
```
|
||||
|
||||
## 关键设计模式
|
||||
|
||||
1. **Actor 模式**: DockerManager 使用 Actor 模式管理容器状态,避免死锁
|
||||
2. **ArcSwap**: 热更新配置而不需要重启
|
||||
3. **DashMap**: 高并发场景下的线程安全 HashMap
|
||||
4. **Watch 通道**: 状态变更广播
|
||||
5. **RAII 清理**: Agent 闲置超时自动销毁
|
||||
6. **隔离安装**: Node.js 和 Agent 工具安装到独立目录,不污染全局环境
|
||||
|
||||
## 可复用代码
|
||||
|
||||
| 模块 | 路径 | 用途 |
|
||||
|------|------|------|
|
||||
| **agent_runner** | `crates/agent_runner/` | Agent 生命周期管理 |
|
||||
| **docker_manager** | `crates/docker_manager/` | Docker 容器操作 |
|
||||
| **acp_adapter** | `crates/acp_adapter/` | ACP 协议客户端 |
|
||||
|
||||
## 与本项目的集成
|
||||
|
||||
```
|
||||
agent-client
|
||||
│
|
||||
├── UI 层
|
||||
│ ├── 依赖管理界面 (DataTable, Badge, Button)
|
||||
│ ├── 安装进度弹窗 (Dialog, Progress)
|
||||
│ └── 设置界面 (Form, Switch, Input)
|
||||
│
|
||||
└── Agent 运行时
|
||||
├── rcoder/agent_runner # Agent 生命周期管理
|
||||
│ │
|
||||
│ ├── Docker Manager # 容器管理
|
||||
│ │ └── opencode 容器
|
||||
│ │
|
||||
│ └── ACP Adapter # ACP 协议通信
|
||||
│ │
|
||||
│ └── opencode ACP 端口
|
||||
│
|
||||
└── Node.js 安装器 # 环境隔离安装
|
||||
```
|
||||
1086
qimingclaw/specs/agent-client/vendorsdoc/rustdesk-server.md
Normal file
1086
qimingclaw/specs/agent-client/vendorsdoc/rustdesk-server.md
Normal file
File diff suppressed because it is too large
Load Diff
523
qimingclaw/specs/agent-client/vendorsdoc/tray-icon.md
Normal file
523
qimingclaw/specs/agent-client/vendorsdoc/tray-icon.md
Normal file
@@ -0,0 +1,523 @@
|
||||
# tray-icon
|
||||
|
||||
## 项目概述
|
||||
|
||||
跨平台系统托盘图标库,基于 muda 库实现,支持 Windows、macOS 和 Linux。
|
||||
|
||||
**GitHub**: https://github.com/tauri-apps/tray-icon.git
|
||||
**本地路径**: `vendors/tray-icon`
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
tray-icon/
|
||||
├── src/
|
||||
│ ├── lib.rs # 公共 API
|
||||
│ ├── icon.rs # 图标处理
|
||||
│ ├── menu.rs # 菜单构建
|
||||
│ └── platform_impl/ # 平台特定实现
|
||||
│ ├── mod.rs
|
||||
│ ├── windows.rs
|
||||
│ ├── macos.rs
|
||||
│ └── linux.rs
|
||||
├── Cargo.toml
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 核心依赖
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
muda = "1.0" # 跨平台菜单库
|
||||
crossbeam-channel = "0.5"
|
||||
once_cell = "1.18"
|
||||
thiserror = "1.0"
|
||||
|
||||
# 平台依赖
|
||||
objc2 = { version = "0.5", optional = true }
|
||||
gtk = { version = "0.18", optional = true, features = ["v3_22"] }
|
||||
libappindicator = { version = "0.8", optional = true }
|
||||
windows = { version = "0.48", optional = true }
|
||||
```
|
||||
|
||||
## 核心 API
|
||||
|
||||
### TrayIconBuilder
|
||||
|
||||
```rust
|
||||
// lib.rs
|
||||
|
||||
pub struct TrayIconBuilder {
|
||||
id: TrayIconId,
|
||||
attrs: TrayIconAttributes,
|
||||
}
|
||||
|
||||
impl TrayIconBuilder {
|
||||
pub fn new() -> Self;
|
||||
|
||||
/// 设置托盘图标
|
||||
pub fn with_icon(mut self, icon: Icon) -> Self;
|
||||
|
||||
/// 设置提示文本
|
||||
pub fn with_tooltip<S: AsRef<str>>(self, s: S) -> Self;
|
||||
|
||||
/// 设置标题(macOS)
|
||||
pub fn with_title<S: AsRef<str>>(self, title: S) -> Self;
|
||||
|
||||
/// 设置上下文菜单
|
||||
pub fn with_menu(mut self, menu: Box<dyn menu::ContextMenu>) -> Self;
|
||||
|
||||
/// 左键点击显示菜单(默认右键)
|
||||
pub fn with_menu_on_left_click(self, enable: bool) -> Self;
|
||||
|
||||
/// 设置临时目录路径
|
||||
pub fn with_temp_dir_path<P: AsRef<Path>>(self, s: P) -> Self;
|
||||
|
||||
/// 构建托盘图标
|
||||
pub fn build(self) -> Result<TrayIcon>;
|
||||
}
|
||||
```
|
||||
|
||||
### TrayIcon
|
||||
|
||||
```rust
|
||||
pub struct TrayIcon {
|
||||
id: TrayIconId,
|
||||
tray: Rc<RefCell<platform_impl::TrayIcon>>,
|
||||
}
|
||||
|
||||
impl TrayIcon {
|
||||
/// 设置图标
|
||||
pub fn set_icon(&self, icon: Option<Icon>) -> Result<()>;
|
||||
|
||||
/// 设置菜单
|
||||
pub fn set_menu(&self, menu: Option<Box<dyn menu::ContextMenu>>);
|
||||
|
||||
/// 设置提示文本
|
||||
pub fn set_tooltip<S: AsRef<str>>(&self, tooltip: Option<S>) -> Result<()>;
|
||||
|
||||
/// 设置可见性
|
||||
pub fn set_visible(&self, visible: bool) -> Result<()>;
|
||||
|
||||
/// 获取托盘区域矩形
|
||||
pub fn rect(&self) -> Option<Rect>;
|
||||
}
|
||||
```
|
||||
|
||||
### TrayIconEvent
|
||||
|
||||
```rust
|
||||
pub enum TrayIconEvent {
|
||||
Click {
|
||||
id: TrayIconId,
|
||||
position: Position,
|
||||
rect: Rect,
|
||||
button: MouseButton,
|
||||
button_state: ButtonState,
|
||||
},
|
||||
DoubleClick {
|
||||
id: TrayIconId,
|
||||
position: Position,
|
||||
rect: Rect,
|
||||
button: MouseButton,
|
||||
},
|
||||
Enter {
|
||||
id: TrayIconId,
|
||||
position: Position,
|
||||
rect: Rect,
|
||||
},
|
||||
Move {
|
||||
id: TrayIconId,
|
||||
position: Position,
|
||||
rect: Rect,
|
||||
},
|
||||
Leave {
|
||||
id: TrayIconId,
|
||||
position: Position,
|
||||
rect: Rect,
|
||||
},
|
||||
}
|
||||
|
||||
impl TrayIconEvent {
|
||||
/// 获取事件接收器
|
||||
pub fn receiver() -> &'a TrayIconEventReceiver;
|
||||
|
||||
/// 设置事件处理函数
|
||||
pub fn set_event_handler<F>(f: Option<F>)
|
||||
where
|
||||
F: Fn(TrayIconEvent, &dyn TrayIconHandle) + Send + 'static;
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
```rust
|
||||
use tray_icon::{TrayIconBuilder, icon::Icon, menu::MenuBuilder};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// 加载图标
|
||||
let icon = Icon::from_resource(1, None);
|
||||
|
||||
// 创建菜单
|
||||
let menu = MenuBuilder::new()
|
||||
.item("Show Window", "show_window", true, None)?
|
||||
.separator()
|
||||
.item("Settings", "settings", true, None)?
|
||||
.separator()
|
||||
.quit("Quit")
|
||||
.build()?;
|
||||
|
||||
// 创建托盘图标
|
||||
let _tray_icon = TrayIconBuilder::new()
|
||||
.with_icon(icon)
|
||||
.with_tooltip("My App")
|
||||
.with_menu(Box::new(menu))
|
||||
.with_on_tray_event(|event, tray| {
|
||||
match event {
|
||||
TrayIconEvent::Click { button, .. } => {
|
||||
if button == MouseButton::Left {
|
||||
show_window();
|
||||
}
|
||||
}
|
||||
TrayIconEvent::MenuItemClick { id, .. } => {
|
||||
match id.as_ref() {
|
||||
"quit" => std::process::exit(0),
|
||||
"settings" => open_settings(),
|
||||
"show_window" => show_window(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.build()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## 与 agent-client 集成场景
|
||||
|
||||
### 场景1:完整的托盘管理器
|
||||
|
||||
```rust
|
||||
// agent-client 托盘管理器
|
||||
|
||||
use tray_icon::{TrayIconBuilder, icon::Icon, menu::{Menu, MenuItem, MenuBuilder}};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
pub struct TrayManager {
|
||||
// 托盘图标
|
||||
tray_icon: Option<TrayIcon>,
|
||||
// 是否显示主窗口
|
||||
window_visible: AtomicBool,
|
||||
// 连接状态
|
||||
connection_state: Arc<dyn ConnectionStatusProvider>,
|
||||
// 应用退出标志
|
||||
exit_flag: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl TrayManager {
|
||||
pub fn new(
|
||||
connection_state: Arc<dyn ConnectionStatusProvider>,
|
||||
exit_flag: Arc<AtomicBool>,
|
||||
) -> Result<Self> {
|
||||
let icon = Self::load_icon()?;
|
||||
|
||||
let menu = MenuBuilder::new()
|
||||
.item("Show Window", "show_window", true, None)?
|
||||
.separator()
|
||||
.item("Agent Status", "status", true, None)?
|
||||
.item("Open Settings", "settings", true, None)?
|
||||
.separator()
|
||||
.item("Quit", "quit", true, None)?
|
||||
.build()?;
|
||||
|
||||
let tray_icon = TrayIconBuilder::new()
|
||||
.with_icon(icon)
|
||||
.with_tooltip("qiming-agent")
|
||||
.with_menu(Box::new(menu))
|
||||
.with_menu_on_left_click(true) // 左键也显示菜单
|
||||
.build()?;
|
||||
|
||||
Ok(Self {
|
||||
tray_icon: Some(tray_icon),
|
||||
window_visible: AtomicBool::new(true),
|
||||
connection_state,
|
||||
exit_flag,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_icon() -> Result<Icon> {
|
||||
// 从资源文件加载图标
|
||||
let icon_data = include_bytes!("../../assets/tray_icon.png");
|
||||
let image = image::load_from_memory(icon_data)?
|
||||
.to_rgba8()
|
||||
.into_raw();
|
||||
|
||||
Icon::from_rgba(icon_data.to_vec(), 32, 32)
|
||||
}
|
||||
|
||||
/// 设置托盘事件处理
|
||||
pub fn setup_event_handler(&self) {
|
||||
TrayIconEvent::set_event_handler(Some(move |event, _tray| {
|
||||
match event {
|
||||
TrayIconEvent::Click { button, .. } => {
|
||||
if button == MouseButton::Left {
|
||||
// 左键点击切换窗口可见性
|
||||
// 发送事件到主窗口
|
||||
}
|
||||
}
|
||||
TrayIconEvent::MenuItemClick { id, .. } => {
|
||||
match id.as_ref() {
|
||||
"show_window" => {
|
||||
// 显示主窗口
|
||||
}
|
||||
"settings" => {
|
||||
// 打开设置窗口
|
||||
}
|
||||
"quit" => {
|
||||
// 退出应用
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// 更新托盘图标(根据连接状态)
|
||||
pub fn update_icon(&self, status: &ConnectionState) {
|
||||
let icon = match status {
|
||||
ConnectionState::Connected { .. } => self.load_connected_icon(),
|
||||
ConnectionState::Connecting => self.load_loading_icon(),
|
||||
ConnectionState::Disconnected { .. } => self.load_disconnected_icon(),
|
||||
ConnectionState::Error { .. } => self.load_error_icon(),
|
||||
};
|
||||
|
||||
if let Some(tray) = &self.tray_icon {
|
||||
tray.set_icon(Some(icon)).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新托盘提示文本
|
||||
pub fn update_tooltip(&self, text: &str) {
|
||||
if let Some(tray) = &self.tray_icon {
|
||||
tray.set_tooltip(Some(text)).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// 销毁托盘
|
||||
pub fn destroy(&mut self) {
|
||||
if let Some(tray) = self.tray_icon.take() {
|
||||
tray.set_visible(false).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 场景2:托盘菜单与主窗口通信
|
||||
|
||||
```rust
|
||||
// 使用 channel 进行托盘事件通信
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub enum TrayEvent {
|
||||
ShowWindow,
|
||||
OpenSettings,
|
||||
Quit,
|
||||
}
|
||||
|
||||
pub struct TrayEventChannel {
|
||||
sender: mpsc::UnboundedSender<TrayEvent>,
|
||||
receiver: Arc<Mutex<mpsc::UnboundedReceiver<TrayEvent>>>,
|
||||
}
|
||||
|
||||
impl TrayEventChannel {
|
||||
pub fn new() -> Self {
|
||||
let (sender, receiver) = mpsc::unbounded_channel();
|
||||
Self {
|
||||
sender,
|
||||
receiver: Arc::new(Mutex::new(receiver)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sender(&self) -> mpsc::UnboundedSender<TrayEvent> {
|
||||
self.sender.clone()
|
||||
}
|
||||
|
||||
pub fn receiver(&self) -> Arc<Mutex<mpsc::UnboundedReceiver<TrayEvent>>> {
|
||||
self.receiver.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// 在托盘事件中发送消息
|
||||
fn setup_tray_events(channel: &TrayEventChannel) {
|
||||
TrayIconEvent::set_event_handler(Some(move |event, _tray| {
|
||||
if let TrayIconEvent::MenuItemClick { id, .. } = event {
|
||||
let event = match id.as_ref() {
|
||||
"show_window" => TrayEvent::ShowWindow,
|
||||
"settings" => TrayEvent::OpenSettings,
|
||||
"quit" => TrayEvent::Quit,
|
||||
_ => return,
|
||||
};
|
||||
channel.sender().send(event).ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// 在主窗口中监听托盘事件
|
||||
async fn handle_tray_events(channel: &TrayEventChannel, window: &WindowHandle) {
|
||||
let mut receiver = channel.receiver().lock().await;
|
||||
while let Some(event) = receiver.recv().await {
|
||||
match event {
|
||||
TrayEvent::ShowWindow => {
|
||||
window.activate().ok();
|
||||
}
|
||||
TrayEvent::OpenSettings => {
|
||||
// 打开设置窗口
|
||||
}
|
||||
TrayEvent::Quit => {
|
||||
// 退出应用
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 场景3:动态托盘菜单
|
||||
|
||||
```rust
|
||||
// 根据状态动态更新菜单
|
||||
|
||||
pub struct DynamicTrayMenu {
|
||||
menu: Menu,
|
||||
status_item: MenuItem,
|
||||
settings_item: MenuItem,
|
||||
}
|
||||
|
||||
impl DynamicTrayMenu {
|
||||
pub fn new() -> Result<Self> {
|
||||
let menu = MenuBuilder::new()
|
||||
.item("Show Window", "show_window", true, None)?
|
||||
.separator()
|
||||
.item("Status: Disconnected", "status", true, None)?
|
||||
.item("Open Settings", "settings", true, None)?
|
||||
.separator()
|
||||
.item("Quit", "quit", true, None)?
|
||||
.build()?;
|
||||
|
||||
// 获取子菜单项的引用
|
||||
let status_item = menu.items().find(|item| item.id() == "status").unwrap();
|
||||
let settings_item = menu.items().find(|item| item.id() == "settings").unwrap();
|
||||
|
||||
Ok(Self {
|
||||
menu,
|
||||
status_item,
|
||||
settings_item,
|
||||
})
|
||||
}
|
||||
|
||||
/// 更新状态文本
|
||||
pub fn update_status(&mut self, status: &ConnectionState) {
|
||||
let text = match status {
|
||||
ConnectionState::Connected { mode, latency } => {
|
||||
match mode {
|
||||
ConnectionMode::P2P => format!("Status: Connected (P2P) - {}ms", latency),
|
||||
ConnectionMode::Relay => format!("Status: Connected (Relay) - {}ms", latency),
|
||||
}
|
||||
}
|
||||
ConnectionState::Connecting => "Status: Connecting...".into(),
|
||||
ConnectionState::Disconnected { .. } => "Status: Disconnected".into(),
|
||||
ConnectionState::Error { .. } => "Status: Error".into(),
|
||||
};
|
||||
|
||||
// 更新菜单项文本(如果支持)
|
||||
// self.status_item.set_text(Some(&text));
|
||||
}
|
||||
|
||||
/// 根据连接状态启用/禁用菜单项
|
||||
pub fn update_items(&mut self, connected: bool) {
|
||||
// 如果已连接,可能禁用某些选项
|
||||
// 如果未连接,可能启用重连选项
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 场景4:托盘图标动画
|
||||
|
||||
```rust
|
||||
// 连接状态动画图标
|
||||
|
||||
pub struct AnimatedTrayIcon {
|
||||
frames: Vec<Icon>,
|
||||
current_frame: usize,
|
||||
timer: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl AnimatedTrayIcon {
|
||||
pub fn new_loading_animation() -> Result<Self> {
|
||||
// 创建加载动画帧
|
||||
let frames = (0..8).map(|i| {
|
||||
let degrees = i * 45;
|
||||
Self::create_rotated_icon(degrees)
|
||||
}).collect::<Result<Vec<_>>>()?;
|
||||
|
||||
Ok(Self {
|
||||
frames,
|
||||
current_frame: 0,
|
||||
timer: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 开始动画
|
||||
pub fn start_animation(&mut self, tray: &TrayIcon) {
|
||||
let tray = tray.clone();
|
||||
self.timer = Some(tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(100));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
// 下一帧
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// 停止动画
|
||||
pub fn stop_animation(&mut self) {
|
||||
if let Some(timer) = self.timer.take() {
|
||||
timer.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn create_rotated_icon(degrees: u32) -> Result<Icon> {
|
||||
// 创建旋转后的图标
|
||||
// 使用图像处理库生成旋转帧
|
||||
Ok(Icon::from_rgba(vec![], 32, 32).unwrap())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 在本项目中的使用
|
||||
|
||||
用于实现 agent-client 的任务栏常驻和托盘菜单功能:
|
||||
|
||||
```
|
||||
agent-client
|
||||
│
|
||||
├── tray-icon (托盘图标)
|
||||
│ │
|
||||
│ ├── 托盘图标显示 (根据连接状态变化)
|
||||
│ ├── 右键菜单 (显示窗口、设置、退出)
|
||||
│ ├── 左键点击 (切换窗口可见性)
|
||||
│ └── 动态提示文本 (显示连接状态)
|
||||
│
|
||||
└── muda (菜单系统)
|
||||
│
|
||||
├── Show Window
|
||||
├── Agent Status (只读)
|
||||
├── Open Settings
|
||||
└── Quit
|
||||
```
|
||||
Reference in New Issue
Block a user