chore: bump version to 0.61.0

This commit is contained in:
coso
2026-02-09 02:08:53 +08:00
parent 98c124b0bb
commit bf0c1a05fb
138 changed files with 5629 additions and 5450 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.60.0",
"version": "0.61.0",
"type": "module",
"repository": {
"type": "git",
+25 -15
View File
@@ -6635,7 +6635,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.60.0"
version = "0.61.0"
dependencies = [
"anyhow",
"arboard",
@@ -6730,24 +6730,29 @@ dependencies = [
[[package]]
name = "proxycast-agent"
version = "0.60.0"
version = "0.61.0"
dependencies = [
"aster",
"async-trait",
"chrono",
"dirs 5.0.1",
"proxycast-core",
"proxycast-mcp",
"proxycast-providers",
"proxycast-services",
"rmcp 0.6.4",
"serde",
"serde_json",
"tempfile",
"tokio",
"tokio-util",
"tracing",
"uuid",
]
[[package]]
name = "proxycast-config"
version = "0.60.0"
version = "0.61.0"
dependencies = [
"async-trait",
"parking_lot",
@@ -6763,7 +6768,7 @@ dependencies = [
[[package]]
name = "proxycast-core"
version = "0.60.0"
version = "0.61.0"
dependencies = [
"async-trait",
"axum 0.7.9",
@@ -6802,7 +6807,7 @@ dependencies = [
[[package]]
name = "proxycast-credential"
version = "0.60.0"
version = "0.61.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -6820,7 +6825,7 @@ dependencies = [
[[package]]
name = "proxycast-infra"
version = "0.60.0"
version = "0.61.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -6840,7 +6845,7 @@ dependencies = [
[[package]]
name = "proxycast-mcp"
version = "0.60.0"
version = "0.61.0"
dependencies = [
"async-trait",
"glob",
@@ -6855,7 +6860,7 @@ dependencies = [
[[package]]
name = "proxycast-processor"
version = "0.60.0"
version = "0.61.0"
dependencies = [
"async-trait",
"parking_lot",
@@ -6874,7 +6879,7 @@ dependencies = [
[[package]]
name = "proxycast-providers"
version = "0.60.0"
version = "0.61.0"
dependencies = [
"anyhow",
"async-stream",
@@ -6908,7 +6913,7 @@ dependencies = [
[[package]]
name = "proxycast-server"
version = "0.60.0"
version = "0.61.0"
dependencies = [
"async-stream",
"axum 0.7.9",
@@ -6945,7 +6950,7 @@ dependencies = [
[[package]]
name = "proxycast-server-utils"
version = "0.60.0"
version = "0.61.0"
dependencies = [
"axum 0.7.9",
"futures",
@@ -6960,7 +6965,7 @@ dependencies = [
[[package]]
name = "proxycast-services"
version = "0.60.0"
version = "0.61.0"
dependencies = [
"anyhow",
"aster",
@@ -6992,16 +6997,21 @@ dependencies = [
"url",
"urlencoding",
"uuid",
"voice-core",
"whoami",
"zip",
]
[[package]]
name = "proxycast-skills"
version = "0.60.0"
version = "0.61.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
"proxycast-core",
"proxycast-providers",
"proxycast-server-utils",
"proxycast-services",
"regex",
"serde",
"serde_json",
@@ -7010,7 +7020,7 @@ dependencies = [
[[package]]
name = "proxycast-terminal"
version = "0.60.0"
version = "0.61.0"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7037,7 +7047,7 @@ dependencies = [
[[package]]
name = "proxycast-websocket"
version = "0.60.0"
version = "0.61.0"
dependencies = [
"axum 0.7.9",
"dashmap 5.5.3",
+2 -2
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.60.0"
version = "0.61.0"
edition = "2021"
authors = ["you"]
repository = "https://github.com/aiclientproxy/proxycast"
@@ -179,7 +179,7 @@ version = "2.4"
[package]
name = "proxycast"
version = "0.60.0"
version = "0.61.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
+7
View File
@@ -8,6 +8,8 @@ repository.workspace = true
[dependencies]
proxycast-core.workspace = true
proxycast-mcp.workspace = true
proxycast-services.workspace = true
proxycast-providers.workspace = true
aster.workspace = true
rmcp.workspace = true
serde.workspace = true
@@ -17,3 +19,8 @@ tokio-util.workspace = true
async-trait.workspace = true
tracing.workspace = true
chrono.workspace = true
dirs.workspace = true
uuid.workspace = true
[dev-dependencies]
tempfile.workspace = true
+602
View File
@@ -0,0 +1,602 @@
//! Aster Agent 状态管理
//!
//! 管理 Aster Agent 实例和相关状态
//! 提供 Tauri 应用与 Aster 框架的桥接
//! 支持从 ProxyCast 凭证池自动选择凭证
//!
//! ## 重要:SessionStore 注入
//!
//! 为了让 Aster Agent 的消息存储到 ProxyCast 数据库,必须在创建 Agent 时
//! 注入 `ProxyCastSessionStore`。使用 `init_agent_with_db()` 方法而不是 `init_agent()`。
//!
//! ## Agent 身份配置
//!
//! 通过 Aster 框架的 `AgentIdentity` API 设置 ProxyCast 专属的 Agent 身份,
//! 包括名称、语言偏好、产品描述等。这是架构层面的正确做法,
//! 而不是简单地追加提示词。
//!
//! ## Skills 集成
//!
//! Agent 初始化时会自动加载 `~/.proxycast/skills/` 目录下的 Skills 到
//! aster-rust 的 global_registry,使 AI 能够自动发现和调用这些 Skills。
//!
//! 参考文档:`docs/prd/chat-architecture-redesign.md`
use aster::agents::{Agent, SessionConfig};
use aster::model::ModelConfig;
#[cfg(test)]
use aster::skills::{global_registry, load_skills_from_directory, SkillSource};
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use crate::credential_bridge::{create_aster_provider, AsterProviderConfig, CredentialBridge};
use proxycast_core::database::DbConnection;
use proxycast_services::aster_session_store::ProxyCastSessionStore;
/// Provider 配置信息
#[derive(Debug, Clone)]
pub struct ProviderConfig {
/// Provider 名称 (openai, anthropic, google, ollama 等)
pub provider_name: String,
/// 模型名称
pub model_name: String,
/// API Key (可选,某些 provider 从环境变量读取)
pub api_key: Option<String>,
/// Base URL (可选,用于自定义端点)
pub base_url: Option<String>,
/// 凭证 UUID(来自凭证池,用于记录使用和健康状态)
pub credential_uuid: Option<String>,
}
/// Aster Agent 全局状态
///
/// 在 Tauri 应用中作为 managed state 使用
pub struct AsterAgentState {
/// Aster Agent 实例
agent: Arc<RwLock<Option<Agent>>>,
/// 当前活跃的取消令牌(用于中止正在进行的对话)
cancel_tokens: Arc<RwLock<std::collections::HashMap<String, CancellationToken>>>,
/// 当前 Provider 配置
current_provider_config: Arc<RwLock<Option<ProviderConfig>>>,
/// 凭证桥接器
credential_bridge: CredentialBridge,
}
impl Default for AsterAgentState {
fn default() -> Self {
Self::new()
}
}
impl AsterAgentState {
/// 创建新的 Aster Agent 状态
pub fn new() -> Self {
Self {
agent: Arc::new(RwLock::new(None)),
cancel_tokens: Arc::new(RwLock::new(std::collections::HashMap::new())),
current_provider_config: Arc::new(RwLock::new(None)),
credential_bridge: CredentialBridge::new(),
}
}
/// 初始化 Agent(带数据库连接)
///
/// 创建 Agent 并注入 ProxyCastSessionStore,确保消息存储到 ProxyCast 数据库。
/// 同时设置 ProxyCast 专属的 Agent 身份(名称、语言、描述)。
/// 自动加载 `~/.proxycast/skills/` 目录下的 Skills 到 aster-rust 的 global_registry。
///
/// **推荐使用此方法**而不是 `init_agent()`。
///
/// # 参数
/// - `db`: 数据库连接,用于创建 SessionStore
pub async fn init_agent_with_db(&self, db: &DbConnection) -> Result<(), String> {
let mut agent_guard = self.agent.write().await;
if agent_guard.is_none() {
// 创建 SessionStore
let session_store = Arc::new(ProxyCastSessionStore::new(db.clone()));
tracing::info!("[AsterAgent] 创建 ProxyCastSessionStore 成功");
// 创建 Agent 并注入 SessionStore
let agent = Agent::new().with_session_store(session_store);
// 验证 session_store 是否被正确设置
let has_store = agent.session_store().is_some();
tracing::info!(
"[AsterAgent] Agent 创建完成,session_store 已设置: {}",
has_store
);
// 使用异步方法设置 ProxyCast 专属身份
let identity = crate::create_proxycast_identity();
agent.set_identity(identity).await;
// 加载 ProxyCast Skills 到 aster-rust 的 global_registry
crate::reload_proxycast_skills();
*agent_guard = Some(agent);
tracing::info!(
"[AsterAgent] Agent 初始化成功,已注入 ProxyCastSessionStore、ProxyCast 身份和 Skills"
);
} else {
tracing::debug!("[AsterAgent] Agent 已初始化,跳过");
}
Ok(())
}
/// 重新加载 ProxyCast Skills
///
/// 当用户安装或卸载 Skills 后调用此方法刷新 registry。
pub fn reload_proxycast_skills() {
crate::reload_proxycast_skills();
}
/// 初始化 Agent(无数据库版本)
///
/// **警告**:此方法创建的 Agent 不会将消息存储到 ProxyCast 数据库,
/// 消息会存储到 Aster 默认的 `~/.aster/sessions.db`。
///
/// 建议使用 `init_agent_with_db()` 代替。
#[deprecated(
since = "0.1.0",
note = "请使用 init_agent_with_db() 以确保消息存储到 ProxyCast 数据库"
)]
pub async fn init_agent(&self) -> Result<(), String> {
let mut agent_guard = self.agent.write().await;
if agent_guard.is_none() {
let agent = Agent::new();
*agent_guard = Some(agent);
tracing::warn!(
"[AsterAgent] Agent 初始化(无 SessionStore),消息将存储到 Aster 默认数据库"
);
}
Ok(())
}
/// 配置 Provider
///
/// 根据配置创建并设置 Provider
///
/// # 参数
/// - `config`: Provider 配置
/// - `session_id`: 会话 ID
/// - `db`: 数据库连接(用于初始化 Agent)
pub async fn configure_provider(
&self,
config: ProviderConfig,
session_id: &str,
db: &DbConnection,
) -> Result<(), String> {
// 确保 Agent 已初始化(使用带数据库的版本)
self.init_agent_with_db(db).await?;
// 设置环境变量(Aster 的 provider 从环境变量读取配置)
self.set_provider_env_vars(&config);
// 创建 ModelConfig
let model_config = ModelConfig::new(&config.model_name)
.map_err(|e| format!("创建 ModelConfig 失败: {e}"))?;
// 创建 Provider
let provider = aster::providers::create(&config.provider_name, model_config)
.await
.map_err(|e| format!("创建 Provider 失败: {e}"))?;
// 更新 Agent 的 Provider
let agent_guard = self.agent.read().await;
if let Some(agent) = agent_guard.as_ref() {
agent
.update_provider(provider, session_id)
.await
.map_err(|e| format!("更新 Provider 失败: {e}"))?;
}
// 保存当前配置
let mut config_guard = self.current_provider_config.write().await;
*config_guard = Some(config.clone());
tracing::info!(
"[AsterAgent] Provider 配置成功: {} / {}",
config.provider_name,
config.model_name
);
Ok(())
}
/// 从凭证池配置 Provider
///
/// 自动从 ProxyCast 凭证池选择可用凭证并配置 Aster Provider
///
/// # 参数
/// - `db`: 数据库连接
/// - `provider_type`: Provider 类型 (openai, anthropic, kiro 等)
/// - `model`: 模型名称
/// - `session_id`: 会话 ID
pub async fn configure_provider_from_pool(
&self,
db: &DbConnection,
provider_type: &str,
model: &str,
session_id: &str,
) -> Result<AsterProviderConfig, String> {
// 确保 Agent 已初始化(使用带数据库的版本)
self.init_agent_with_db(db).await?;
// 从凭证池选择凭证并获取配置
let aster_config = self
.credential_bridge
.select_and_configure(db, provider_type, model)
.await
.map_err(|e| format!("从凭证池选择凭证失败: {e}"))?;
// 创建 Provider
let provider = create_aster_provider(&aster_config)
.await
.map_err(|e| format!("创建 Provider 失败: {e}"))?;
// 更新 Agent 的 Provider
let agent_guard = self.agent.read().await;
if let Some(agent) = agent_guard.as_ref() {
agent
.update_provider(provider, session_id)
.await
.map_err(|e| format!("更新 Provider 失败: {e}"))?;
}
// 保存当前配置
let config = ProviderConfig {
provider_name: aster_config.provider_name.clone(),
model_name: aster_config.model_name.clone(),
api_key: aster_config.api_key.clone(),
base_url: aster_config.base_url.clone(),
credential_uuid: Some(aster_config.credential_uuid.clone()),
};
let mut config_guard = self.current_provider_config.write().await;
*config_guard = Some(config);
// 记录凭证使用
if let Err(e) = self
.credential_bridge
.record_usage(db, &aster_config.credential_uuid)
{
tracing::warn!("[AsterAgent] 记录凭证使用失败: {}", e);
}
tracing::info!(
"[AsterAgent] 从凭证池配置 Provider 成功: {} / {} (凭证: {})",
aster_config.provider_name,
aster_config.model_name,
aster_config.credential_uuid
);
Ok(aster_config)
}
/// 标记当前凭证为健康
pub fn mark_current_healthy(&self, db: &DbConnection, model: Option<&str>) {
if let Ok(config_guard) = self.current_provider_config.try_read() {
if let Some(config) = config_guard.as_ref() {
if let Some(uuid) = &config.credential_uuid {
if let Err(e) = self.credential_bridge.mark_healthy(db, uuid, model) {
tracing::warn!("[AsterAgent] 标记凭证健康失败: {}", e);
}
}
}
}
}
/// 标记当前凭证为不健康
pub fn mark_current_unhealthy(&self, db: &DbConnection, error: Option<&str>) {
if let Ok(config_guard) = self.current_provider_config.try_read() {
if let Some(config) = config_guard.as_ref() {
if let Some(uuid) = &config.credential_uuid {
if let Err(e) = self.credential_bridge.mark_unhealthy(db, uuid, error) {
tracing::warn!("[AsterAgent] 标记凭证不健康失败: {}", e);
}
}
}
}
}
/// 设置 Provider 相关的环境变量
fn set_provider_env_vars(&self, config: &ProviderConfig) {
tracing::info!(
"[AsterAgent] set_provider_env_vars: provider_name={}, model_name={}, has_api_key={}, base_url={:?}",
config.provider_name,
config.model_name,
config.api_key.is_some(),
config.base_url
);
// 根据 provider 类型设置对应的环境变量
let env_key = match config.provider_name.as_str() {
"openai" => "OPENAI_API_KEY",
"anthropic" => "ANTHROPIC_API_KEY",
"google" => "GOOGLE_API_KEY",
"deepseek" | "custom_deepseek" => "OPENAI_API_KEY", // DeepSeek 使用 OpenAI 兼容 API
"groq" => "OPENAI_API_KEY", // Groq 使用 OpenAI 兼容 API
"mistral" => "OPENAI_API_KEY", // Mistral 使用 OpenAI 兼容 API
"openrouter" => "OPENROUTER_API_KEY",
"ollama" => return, // Ollama 不需要 API Key
_ => {
tracing::warn!(
"[AsterAgent] 未知的 provider_name: {}, 使用通用 OpenAI 格式",
config.provider_name
);
// 通用 OpenAI 兼容格式
if let Some(api_key) = &config.api_key {
std::env::set_var("OPENAI_API_KEY", api_key);
}
if let Some(base_url) = &config.base_url {
std::env::set_var("OPENAI_BASE_URL", base_url);
}
return;
}
};
tracing::info!("[AsterAgent] 设置环境变量: {}=***", env_key);
if let Some(api_key) = &config.api_key {
std::env::set_var(env_key, api_key);
}
if let Some(base_url) = &config.base_url {
let base_url_key = format!(
"{}_BASE_URL",
config.provider_name.to_uppercase().replace("_", "")
);
std::env::set_var(base_url_key, base_url);
}
}
/// 获取当前 Provider 配置
pub async fn get_provider_config(&self) -> Option<ProviderConfig> {
self.current_provider_config.read().await.clone()
}
/// 清除当前 Provider 配置
///
/// 用于切换凭证后重置状态,下次对话时会重新从凭证池选择凭证
pub async fn clear_provider_config(&self) {
let mut config_guard = self.current_provider_config.write().await;
*config_guard = None;
tracing::info!("[AsterAgent] Provider 配置已清除");
}
/// 检查 Provider 是否已配置
pub async fn is_provider_configured(&self) -> bool {
self.current_provider_config.read().await.is_some()
}
/// 获取 Agent 的只读引用并执行同步操作
pub async fn with_agent<F, R>(&self, f: F) -> Result<R, String>
where
F: FnOnce(&Agent) -> R,
{
let guard = self.agent.read().await;
match guard.as_ref() {
Some(agent) => Ok(f(agent)),
None => Err("Agent not initialized".to_string()),
}
}
/// 获取 Agent 的可变引用并执行同步操作
pub async fn with_agent_mut<F, R>(&self, f: F) -> Result<R, String>
where
F: FnOnce(&mut Agent) -> R,
{
let mut guard = self.agent.write().await;
match guard.as_mut() {
Some(agent) => Ok(f(agent)),
None => Err("Agent not initialized".to_string()),
}
}
/// 获取 Agent 的 Arc 引用
///
/// 用于需要长期持有 Agent 引用的场景
pub fn get_agent_arc(&self) -> Arc<RwLock<Option<Agent>>> {
self.agent.clone()
}
/// 创建新的取消令牌
pub async fn create_cancel_token(&self, session_id: &str) -> CancellationToken {
let token = CancellationToken::new();
let mut tokens = self.cancel_tokens.write().await;
tokens.insert(session_id.to_string(), token.clone());
token
}
/// 取消指定会话的操作
pub async fn cancel_session(&self, session_id: &str) -> bool {
let tokens = self.cancel_tokens.read().await;
if let Some(token) = tokens.get(session_id) {
token.cancel();
true
} else {
false
}
}
/// 移除取消令牌
pub async fn remove_cancel_token(&self, session_id: &str) {
let mut tokens = self.cancel_tokens.write().await;
tokens.remove(session_id);
}
// ------------------------------------------------------------------------
// 项目上下文支持
// ------------------------------------------------------------------------
/// 构建带项目上下文的 System Prompt
///
/// 加载项目的人设、素材、模板配置,构建完整的 AI 提示词。
///
/// # 参数
/// - `db`: 数据库连接
/// - `project_id`: 项目 ID
///
/// # 返回
/// - 成功返回构建好的 System Prompt
/// - 失败返回错误信息
pub fn build_project_system_prompt(
db: &DbConnection,
project_id: &str,
) -> Result<String, String> {
crate::build_project_system_prompt(db, project_id)
}
/// 创建带项目上下文的会话配置
///
/// 自动加载项目配置并构建 SessionConfig。
///
/// # 参数
/// - `db`: 数据库连接
/// - `session_id`: 会话 ID
/// - `project_id`: 项目 ID(可选,如果为 None 则不注入项目上下文)
///
/// # 返回
/// - 构建好的 SessionConfig
pub fn create_session_config_with_project(
db: &DbConnection,
session_id: &str,
project_id: Option<&str>,
) -> SessionConfig {
crate::create_session_config_with_project(db, session_id, project_id)
}
/// 检查 Agent 是否已初始化
pub async fn is_initialized(&self) -> bool {
self.agent.read().await.is_some()
}
}
pub use crate::aster_state_support::{message_helpers, SessionConfigBuilder};
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[tokio::test]
async fn test_aster_state_init() {
let state = AsterAgentState::new();
assert!(!state.is_initialized().await);
#[allow(deprecated)]
state.init_agent().await.unwrap();
assert!(state.is_initialized().await);
}
#[tokio::test]
async fn test_cancel_token() {
let state = AsterAgentState::new();
let session_id = "test-session";
let token = state.create_cancel_token(session_id).await;
assert!(!token.is_cancelled());
assert!(state.cancel_session(session_id).await);
assert!(token.is_cancelled());
state.remove_cancel_token(session_id).await;
assert!(!state.cancel_session(session_id).await);
}
// =========================================================================
// Skills 集成测试
// =========================================================================
/// 测试辅助函数:创建测试用的 Skill 目录
fn create_test_skill(skills_dir: &std::path::Path, skill_name: &str, description: &str) {
let skill_path = skills_dir.join(skill_name);
fs::create_dir_all(&skill_path).unwrap();
let skill_md = format!(
r#"---
name: {}
description: {}
---
# {}
这是一个测试 Skill。
"#,
skill_name, description, skill_name
);
fs::write(skill_path.join("SKILL.md"), skill_md).unwrap();
}
/// 测试:load_skills_from_directory 能正确加载 Skills
#[test]
fn test_load_skills_from_directory() {
let temp_dir = TempDir::new().unwrap();
let skills_dir = temp_dir.path();
// 创建测试 Skills
create_test_skill(skills_dir, "test-skill-1", "第一个测试技能");
create_test_skill(skills_dir, "test-skill-2", "第二个测试技能");
// 加载 Skills
let skills = load_skills_from_directory(skills_dir, SkillSource::User);
// 验证
assert_eq!(skills.len(), 2);
let names: Vec<_> = skills.iter().map(|s| s.display_name.as_str()).collect();
assert!(names.contains(&"test-skill-1"));
assert!(names.contains(&"test-skill-2"));
}
/// 测试:空目录返回空列表
#[test]
fn test_load_skills_empty_directory() {
let temp_dir = TempDir::new().unwrap();
let skills = load_skills_from_directory(temp_dir.path(), SkillSource::User);
assert!(skills.is_empty());
}
/// 测试:不存在的目录返回空列表
#[test]
fn test_load_skills_nonexistent_directory() {
let nonexistent = std::path::Path::new("/nonexistent/path/to/skills");
let skills = load_skills_from_directory(nonexistent, SkillSource::User);
assert!(skills.is_empty());
}
/// 测试:global_registry 能正确注册和查找 Skills
#[test]
fn test_global_registry_register_and_find() {
let temp_dir = TempDir::new().unwrap();
let skills_dir = temp_dir.path();
// 创建测试 Skill
create_test_skill(skills_dir, "registry-test-skill", "注册表测试技能");
// 加载并注册到 global_registry
let skills = load_skills_from_directory(skills_dir, SkillSource::User);
let registry = global_registry();
if let Ok(mut registry_guard) = registry.write() {
for skill in skills {
registry_guard.register(skill);
}
}
// 验证能找到注册的 Skill
if let Ok(registry_guard) = registry.read() {
let found = registry_guard.find("registry-test-skill");
assert!(found.is_some());
assert_eq!(found.unwrap().display_name, "registry-test-skill");
}
}
/// 测试:reload_proxycast_skills 不会 panic(即使目录不存在)
#[test]
fn test_reload_proxycast_skills_no_panic() {
// 这个测试确保 reload_proxycast_skills 在各种情况下都不会 panic
// 即使 ~/.proxycast/skills/ 目录不存在
AsterAgentState::reload_proxycast_skills();
// 如果没有 panic,测试通过
}
}
@@ -0,0 +1,163 @@
//! Aster 状态支持模块
//!
//! 提供可复用的会话配置构建、项目上下文 Prompt 构建、
//! ProxyCast Skills 加载与 Agent 身份配置。
use aster::agents::{AgentIdentity, SessionConfig};
use aster::skills::{global_registry, load_skills_from_directory, SkillSource};
use proxycast_core::database::DbConnection;
use proxycast_services::project_context_builder::ProjectContextBuilder;
/// 重新加载 ProxyCast Skills
pub fn reload_proxycast_skills() {
load_proxycast_skills();
}
/// 创建 ProxyCast 专属的 Agent 身份配置
pub fn create_proxycast_identity() -> AgentIdentity {
AgentIdentity::new("ProxyCast 助手")
.with_language("Chinese")
.with_description(
"ProxyCast 是一个 AI 代理服务应用,帮助用户管理和使用各种 AI 模型的凭证。",
)
.with_custom_prompt(PROXYCAST_IDENTITY_PROMPT.to_string())
}
/// 加载 ProxyCast Skills 到 aster-rust 的 global_registry
fn load_proxycast_skills() {
let home = match dirs::home_dir() {
Some(home_dir) => home_dir,
None => {
tracing::warn!("[AsterAgent] 无法获取 home 目录,跳过 Skills 加载");
return;
}
};
let skills_dir = home.join(".proxycast").join("skills");
if !skills_dir.exists() {
tracing::info!(
"[AsterAgent] ProxyCast Skills 目录不存在: {:?},跳过加载",
skills_dir
);
return;
}
let skills = load_skills_from_directory(&skills_dir, SkillSource::User);
let skill_count = skills.len();
if skill_count == 0 {
tracing::info!("[AsterAgent] ProxyCast Skills 目录为空,无 Skills 可加载");
return;
}
let registry = global_registry();
if let Ok(mut registry_guard) = registry.write() {
for skill in skills {
let skill_name = skill.skill_name.clone();
registry_guard.register(skill);
tracing::debug!("[AsterAgent] 已注册 Skill: {}", skill_name);
}
tracing::info!(
"[AsterAgent] 成功加载 {} 个 ProxyCast Skills 到 global_registry",
skill_count
);
} else {
tracing::error!("[AsterAgent] 无法获取 global_registry 写锁,Skills 加载失败");
}
}
/// 构建带项目上下文的 System Prompt
pub fn build_project_system_prompt(db: &DbConnection, project_id: &str) -> Result<String, String> {
let conn = db.lock().map_err(|e| format!("获取数据库连接失败: {e}"))?;
let context = ProjectContextBuilder::build_context(&conn, project_id)
.map_err(|e| format!("构建项目上下文失败: {e}"))?;
Ok(ProjectContextBuilder::build_system_prompt(&context))
}
/// 创建带项目上下文的会话配置
pub fn create_session_config_with_project(
db: &DbConnection,
session_id: &str,
project_id: Option<&str>,
) -> SessionConfig {
let system_prompt = project_id.and_then(|pid| build_project_system_prompt(db, pid).ok());
SessionConfigBuilder::new(session_id)
.system_prompt(system_prompt.unwrap_or_default())
.build()
}
/// 会话配置构建器
pub struct SessionConfigBuilder {
id: String,
max_turns: Option<u32>,
system_prompt: Option<String>,
}
impl SessionConfigBuilder {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
max_turns: None,
system_prompt: None,
}
}
pub fn max_turns(mut self, turns: u32) -> Self {
self.max_turns = Some(turns);
self
}
pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
self.system_prompt = Some(prompt.into());
self
}
pub fn build(self) -> SessionConfig {
SessionConfig {
id: self.id,
schedule_id: None,
max_turns: self.max_turns,
retry_config: None,
system_prompt: self.system_prompt,
}
}
}
/// 消息构建辅助函数
pub mod message_helpers {
use aster::conversation::message::Message;
/// 创建用户文本消息
pub fn user_text(text: impl Into<String>) -> Message {
Message::user().with_text(text)
}
/// 创建助手文本消息
pub fn assistant_text(text: impl Into<String>) -> Message {
Message::assistant().with_text(text)
}
}
/// ProxyCast 专属的 Agent 身份提示词
const PROXYCAST_IDENTITY_PROMPT: &str = r#"你是 ProxyCast 助手,一个专业、友好的 AI 技术伙伴。
## 关于 ProxyCast
ProxyCast 是一个 AI 代理服务应用,帮助用户:
- 管理多个 AI 模型提供商的凭证(OpenAI、Claude、Gemini、Kiro 等)
- 通过统一的 API 接口访问不同的 AI 模型
- 实现凭证池的负载均衡和健康检查
## 语言规范
1. **始终使用中文回复**:除非用户明确要求使用其他语言
2. **代码注释使用中文**:生成代码时,注释应使用中文
3. **技术术语保持原文**:API、JSON、HTTP、Token 等专业术语保持英文
## 交互风格
- 简洁专业,直接给出解决方案
- 友好但不啰嗦,像经验丰富的技术伙伴
- 遇到问题时,先分析原因再提供方案
"#;
@@ -0,0 +1,563 @@
//! 凭证池桥接模块
//!
//! 将 ProxyCast 凭证池与 Aster Provider 系统连接
//! 支持从凭证池自动选择凭证并配置 Aster Provider
//!
//! ## 功能
//! - 从凭证池选择可用凭证
//! - 将凭证转换为 Aster Provider 配置
//! - 支持 OAuth 和 API Key 两种凭证类型
//! - 自动刷新过期的 OAuth Token
//! - 智能拆分 base_url 为 host + path,避免路径重复(如智谱 /v4/v1 问题)
use aster::model::ModelConfig;
use aster::providers::base::Provider;
use proxycast_core::database::DbConnection;
use proxycast_core::models::provider_pool_model::{
CredentialData, PoolProviderType, ProviderCredential,
};
use proxycast_services::api_key_provider_service::ApiKeyProviderService;
use proxycast_services::provider_pool_service::ProviderPoolService;
use std::sync::Arc;
/// 凭证桥接错误
#[derive(Debug, Clone)]
pub enum CredentialBridgeError {
/// 没有可用凭证
NoCredentials(String),
/// 凭证类型不支持
UnsupportedCredentialType(String),
/// Provider 创建失败
ProviderCreationFailed(String),
/// Token 刷新失败
TokenRefreshFailed(String),
/// 数据库错误
DatabaseError(String),
}
impl std::fmt::Display for CredentialBridgeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoCredentials(msg) => write!(f, "没有可用凭证: {msg}"),
Self::UnsupportedCredentialType(msg) => write!(f, "不支持的凭证类型: {msg}"),
Self::ProviderCreationFailed(msg) => write!(f, "Provider 创建失败: {msg}"),
Self::TokenRefreshFailed(msg) => write!(f, "Token 刷新失败: {msg}"),
Self::DatabaseError(msg) => write!(f, "数据库错误: {msg}"),
}
}
}
impl std::error::Error for CredentialBridgeError {}
/// Aster Provider 配置
#[derive(Debug, Clone)]
pub struct AsterProviderConfig {
/// Provider 名称 (openai, anthropic, google 等)
pub provider_name: String,
/// 模型名称
pub model_name: String,
/// API Key
pub api_key: Option<String>,
/// Base URL
pub base_url: Option<String>,
/// 凭证 UUID(用于记录使用和健康状态)
pub credential_uuid: String,
}
/// 凭证池桥接器
///
/// 负责从 ProxyCast 凭证池选择凭证并转换为 Aster Provider 配置
pub struct CredentialBridge {
pool_service: ProviderPoolService,
api_key_service: ApiKeyProviderService,
}
impl Default for CredentialBridge {
fn default() -> Self {
Self::new()
}
}
impl CredentialBridge {
pub fn new() -> Self {
Self {
pool_service: ProviderPoolService::new(),
api_key_service: ApiKeyProviderService::new(),
}
}
/// 从凭证池选择凭证并创建 Aster Provider 配置
///
/// # 参数
/// - `db`: 数据库连接
/// - `provider_type`: Provider 类型 (openai, anthropic, kiro, deepseek 等)
/// - `model`: 模型名称
///
/// # 返回
/// 成功时返回 AsterProviderConfig,失败时返回错误
pub async fn select_and_configure(
&self,
db: &DbConnection,
provider_type: &str,
model: &str,
) -> Result<AsterProviderConfig, CredentialBridgeError> {
// 1. 从凭证池选择凭证
// 将 provider_type 同时作为 provider_id_hint 传递,支持 60+ API Key Provider
// 例如 "deepseek", "moonshot", "qwen" 等
let credential = self
.pool_service
.select_credential_with_fallback(
db,
&self.api_key_service,
provider_type,
Some(model),
Some(provider_type), // 传递 provider_id_hint 支持智能降级
None,
)
.await
.map_err(CredentialBridgeError::DatabaseError)?
.ok_or_else(|| {
CredentialBridgeError::NoCredentials(format!(
"没有找到 {provider_type} 类型的可用凭证"
))
})?;
// 2. 转换为 Aster Provider 配置,传递 provider_type 以便正确识别 Provider
self.credential_to_config(&credential, model, provider_type, db)
.await
}
/// 将 ProxyCast 凭证转换为 Aster Provider 配置
async fn credential_to_config(
&self,
credential: &ProviderCredential,
model: &str,
provider_type_hint: &str,
db: &DbConnection,
) -> Result<AsterProviderConfig, CredentialBridgeError> {
tracing::info!(
"[CredentialBridge] credential_to_config: provider_type_hint={}, credential_type={:?}",
provider_type_hint,
credential.provider_type
);
let (provider_name, api_key, base_url) = match &credential.credential {
// OpenAI API Key - 根据 provider_type_hint 确定实际的 Provider
CredentialData::OpenAIKey { api_key, base_url } => {
// 使用 provider_type_hint 来确定 aster provider 名称
let provider = map_provider_type_to_aster(provider_type_hint);
tracing::info!(
"[CredentialBridge] OpenAIKey: provider_type_hint={} -> aster_provider={}",
provider_type_hint,
provider
);
(
provider.to_string(),
Some(api_key.clone()),
base_url.clone(),
)
}
// Claude/Anthropic API Key
CredentialData::ClaudeKey { api_key, base_url }
| CredentialData::AnthropicKey { api_key, base_url } => (
"anthropic".to_string(),
Some(api_key.clone()),
base_url.clone(),
),
// Kiro OAuth - 需要获取 access_token
CredentialData::KiroOAuth { creds_file_path } => {
let token = self
.get_kiro_token(creds_file_path, db, &credential.uuid)
.await?;
// Kiro 使用 CodeWhisperer API,映射到 bedrock provider
("bedrock".to_string(), Some(token), None)
}
// Gemini OAuth
CredentialData::GeminiOAuth {
creds_file_path, ..
} => {
let token = self.get_oauth_token(creds_file_path).await?;
("google".to_string(), Some(token), None)
}
// Gemini API Key
CredentialData::GeminiApiKey {
api_key, base_url, ..
} => (
"google".to_string(),
Some(api_key.clone()),
base_url.clone(),
),
// Vertex AI
CredentialData::VertexKey {
api_key, base_url, ..
} => (
"gcpvertexai".to_string(),
Some(api_key.clone()),
base_url.clone(),
),
// Codex OAuth
CredentialData::CodexOAuth {
creds_file_path,
api_base_url,
} => {
let token = self.get_codex_token(creds_file_path).await?;
("codex".to_string(), Some(token), api_base_url.clone())
}
// Claude OAuth
CredentialData::ClaudeOAuth { creds_file_path } => {
let token = self.get_oauth_token(creds_file_path).await?;
("anthropic".to_string(), Some(token), None)
}
// Antigravity OAuth
CredentialData::AntigravityOAuth {
creds_file_path, ..
} => {
let token = self.get_oauth_token(creds_file_path).await?;
("google".to_string(), Some(token), None)
}
};
Ok(AsterProviderConfig {
provider_name,
model_name: model.to_string(),
api_key,
base_url,
credential_uuid: credential.uuid.clone(),
})
}
/// 获取 Kiro OAuth Token
async fn get_kiro_token(
&self,
creds_path: &str,
_db: &DbConnection,
_uuid: &str,
) -> Result<String, CredentialBridgeError> {
use proxycast_providers::providers::KiroProvider;
let mut provider = KiroProvider::new();
provider
.load_credentials_from_path(creds_path)
.await
.map_err(|e| {
CredentialBridgeError::TokenRefreshFailed(format!("加载 Kiro 凭证失败: {e}"))
})?;
// 检查 token 是否过期,如果过期则刷新
if provider.is_token_expired() {
tracing::info!("[CredentialBridge] Kiro token 已过期,尝试刷新");
self.pool_service
.refresh_kiro_token(creds_path)
.await
.map_err(CredentialBridgeError::TokenRefreshFailed)?;
// 重新加载凭证
provider
.load_credentials_from_path(creds_path)
.await
.map_err(|e| {
CredentialBridgeError::TokenRefreshFailed(format!("重新加载凭证失败: {e}"))
})?;
}
provider.credentials.access_token.ok_or_else(|| {
CredentialBridgeError::TokenRefreshFailed("缺少 access_token".to_string())
})
}
/// 获取通用 OAuth Token
async fn get_oauth_token(&self, creds_path: &str) -> Result<String, CredentialBridgeError> {
let content = std::fs::read_to_string(creds_path).map_err(|e| {
CredentialBridgeError::TokenRefreshFailed(format!("读取凭证文件失败: {e}"))
})?;
let creds: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| CredentialBridgeError::TokenRefreshFailed(format!("解析凭证失败: {e}")))?;
creds["access_token"]
.as_str()
.map(String::from)
.ok_or_else(|| {
CredentialBridgeError::TokenRefreshFailed("凭证中缺少 access_token".to_string())
})
}
/// 获取 Codex OAuth Token
async fn get_codex_token(&self, creds_path: &str) -> Result<String, CredentialBridgeError> {
use proxycast_providers::providers::CodexProvider;
let mut provider = CodexProvider::new();
provider
.load_credentials_from_path(creds_path)
.await
.map_err(|e| {
CredentialBridgeError::TokenRefreshFailed(format!("加载 Codex 凭证失败: {e}"))
})?;
provider.ensure_valid_token().await.map_err(|e| {
CredentialBridgeError::TokenRefreshFailed(format!("获取 Codex token 失败: {e}"))
})
}
/// 记录凭证使用
pub fn record_usage(&self, db: &DbConnection, uuid: &str) -> Result<(), CredentialBridgeError> {
self.pool_service
.record_usage(db, uuid)
.map_err(CredentialBridgeError::DatabaseError)
}
/// 标记凭证为健康
pub fn mark_healthy(
&self,
db: &DbConnection,
uuid: &str,
model: Option<&str>,
) -> Result<(), CredentialBridgeError> {
self.pool_service
.mark_healthy(db, uuid, model)
.map_err(CredentialBridgeError::DatabaseError)
}
/// 标记凭证为不健康
pub fn mark_unhealthy(
&self,
db: &DbConnection,
uuid: &str,
error: Option<&str>,
) -> Result<(), CredentialBridgeError> {
self.pool_service
.mark_unhealthy(db, uuid, error)
.map_err(CredentialBridgeError::DatabaseError)
}
}
/// 从 AsterProviderConfig 创建 Aster Provider
///
/// 设置环境变量并调用 aster::providers::create
pub async fn create_aster_provider(
config: &AsterProviderConfig,
) -> Result<Arc<dyn Provider>, CredentialBridgeError> {
// 设置环境变量
set_provider_env_vars(config);
// 创建 ModelConfig
let model_config = ModelConfig::new(&config.model_name).map_err(|e| {
CredentialBridgeError::ProviderCreationFailed(format!("创建 ModelConfig 失败: {e}"))
})?;
// 创建 Provider
aster::providers::create(&config.provider_name, model_config)
.await
.map_err(|e| {
CredentialBridgeError::ProviderCreationFailed(format!("创建 Provider 失败: {e}"))
})
}
/// 设置 Provider 环境变量
/// 从 URL 中拆分 host(scheme+authority)和 path 部分
///
/// 例如:
/// - `https://api.openai.com` -> (`https://api.openai.com`, ``)
/// - `https://open.bigmodel.cn/api/paas/v4` -> (`https://open.bigmodel.cn`, `api/paas/v4`)
/// - `https://localhost:8080/v1` -> (`https://localhost:8080`, `v1`)
fn split_url_host_and_path(url: &str) -> (String, String) {
// 找到 scheme 之后的 authority 部分
let after_scheme = if let Some(pos) = url.find("://") {
pos + 3
} else {
return (url.to_string(), String::new());
};
// 找到 authority 之后的第一个 /(即路径开始)
let path_start = url[after_scheme..].find('/').map(|p| p + after_scheme);
match path_start {
Some(pos) => {
let host = url[..pos].to_string();
let path = url[pos..].trim_matches('/').to_string();
(host, path)
}
None => (url.to_string(), String::new()),
}
}
fn set_provider_env_vars(config: &AsterProviderConfig) {
tracing::info!(
"[CredentialBridge] set_provider_env_vars: provider_name={}, has_api_key={}, base_url={:?}",
config.provider_name,
config.api_key.is_some(),
config.base_url
);
let env_key = match config.provider_name.as_str() {
"openai" => "OPENAI_API_KEY",
"anthropic" => "ANTHROPIC_API_KEY",
"google" => "GOOGLE_API_KEY",
"bedrock" => "AWS_ACCESS_KEY_ID", // Bedrock 使用 AWS 凭证
"gcpvertexai" => "GOOGLE_API_KEY",
"codex" => "OPENAI_API_KEY", // Codex 兼容 OpenAI
"deepseek" | "custom_deepseek" => "OPENAI_API_KEY", // DeepSeek 使用 OpenAI 兼容 API
"groq" => "OPENAI_API_KEY", // Groq 使用 OpenAI 兼容 API
"mistral" => "OPENAI_API_KEY", // Mistral 使用 OpenAI 兼容 API
"openrouter" => "OPENROUTER_API_KEY",
_ => "OPENAI_API_KEY", // 默认使用 OpenAI 格式
};
tracing::info!("[CredentialBridge] 设置环境变量: {}=***", env_key);
if let Some(api_key) = &config.api_key {
std::env::set_var(env_key, api_key);
}
// 设置 base_url
// Aster 的 OpenAI Provider 使用 OPENAI_HOST(仅 scheme+host+port)和
// OPENAI_BASE_PATH(路径部分 + /chat/completions)环境变量
if let Some(base_url) = &config.base_url {
match config.provider_name.as_str() {
"openai" => {
// 解析 base_url,将路径部分拆分到 OPENAI_BASE_PATH
// 例如 https://open.bigmodel.cn/api/paas/v4
// -> OPENAI_HOST = https://open.bigmodel.cn
// -> OPENAI_BASE_PATH = api/paas/v4/chat/completions
let (host_part, path_part) = split_url_host_and_path(base_url);
if path_part.is_empty() {
// 无路径部分(如 https://api.openai.com),直接设置
std::env::set_var("OPENAI_HOST", base_url);
// 清除可能残留的 OPENAI_BASE_PATH,使用 Aster 默认值
std::env::remove_var("OPENAI_BASE_PATH");
tracing::info!("[CredentialBridge] 设置 OPENAI_HOST={}", base_url);
} else {
// base_url 包含路径,需要拆分
let base_path = format!("{}/chat/completions", path_part);
std::env::set_var("OPENAI_HOST", &host_part);
std::env::set_var("OPENAI_BASE_PATH", &base_path);
tracing::info!(
"[CredentialBridge] 设置 OPENAI_HOST={}, OPENAI_BASE_PATH={}",
host_part,
base_path
);
}
}
"anthropic" => {
std::env::set_var("ANTHROPIC_BASE_URL", base_url);
}
_ => {
// 其他 Provider 使用通用格式
let base_url_key = format!(
"{}_BASE_URL",
config.provider_name.to_uppercase().replace('-', "_")
);
std::env::set_var(&base_url_key, base_url);
}
}
}
}
/// Provider 类型映射
///
/// 将 ProxyCast PoolProviderType 映射到 Aster Provider 名称
pub fn map_pool_type_to_aster(pool_type: &PoolProviderType) -> &'static str {
match pool_type {
PoolProviderType::Kiro => "bedrock",
PoolProviderType::Gemini => "google",
PoolProviderType::Antigravity => "google",
PoolProviderType::OpenAI => "openai",
PoolProviderType::Claude => "anthropic",
PoolProviderType::Anthropic => "anthropic",
PoolProviderType::AnthropicCompatible => "anthropic",
PoolProviderType::Vertex => "gcpvertexai",
PoolProviderType::GeminiApiKey => "google",
PoolProviderType::Codex => "codex",
PoolProviderType::ClaudeOAuth => "anthropic",
PoolProviderType::AzureOpenai => "azure",
PoolProviderType::AwsBedrock => "bedrock",
PoolProviderType::Ollama => "ollama",
}
}
/// 将 provider_type 字符串映射到 Aster Provider 名称
///
/// 支持 60+ API Key Provider,包括 deepseek, moonshot, qwen 等
fn map_provider_type_to_aster(provider_type: &str) -> &'static str {
match provider_type {
// 标准 Provider
"openai" => "openai",
"anthropic" | "claude" => "anthropic",
"google" | "gemini" => "google",
"bedrock" | "kiro" => "bedrock",
"gcpvertexai" | "vertex" => "gcpvertexai",
"codex" => "codex",
"azure" | "azure-openai" => "azure",
"ollama" => "ollama",
// DeepSeek - 使用 openai 兼容 provider(Aster 会通过 alias 映射)
"deepseek" | "custom_deepseek" => "openai",
// 其他 OpenAI 兼容 Provider - 使用 openai provider
// 这些 Provider 都使用 OpenAI 兼容 API,通过 base_url 区分
"groq" => "openai",
"mistral" => "openai",
"openrouter" => "openrouter",
// 默认使用 openai(OpenAI 兼容格式)
_ => "openai",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_map_pool_type_to_aster() {
assert_eq!(map_pool_type_to_aster(&PoolProviderType::OpenAI), "openai");
assert_eq!(
map_pool_type_to_aster(&PoolProviderType::Claude),
"anthropic"
);
assert_eq!(map_pool_type_to_aster(&PoolProviderType::Gemini), "google");
assert_eq!(map_pool_type_to_aster(&PoolProviderType::Kiro), "bedrock");
}
#[test]
fn test_credential_bridge_error_display() {
let err = CredentialBridgeError::NoCredentials("test".to_string());
assert!(err.to_string().contains("没有可用凭证"));
}
#[test]
fn test_split_url_host_and_path() {
// 无路径
let (host, path) = split_url_host_and_path("https://api.openai.com");
assert_eq!(host, "https://api.openai.com");
assert_eq!(path, "");
// 带路径(智谱)
let (host, path) = split_url_host_and_path("https://open.bigmodel.cn/api/paas/v4");
assert_eq!(host, "https://open.bigmodel.cn");
assert_eq!(path, "api/paas/v4");
// 带端口
let (host, path) = split_url_host_and_path("https://localhost:8080/v1");
assert_eq!(host, "https://localhost:8080");
assert_eq!(path, "v1");
// 尾部斜杠
let (host, path) = split_url_host_and_path("https://api.deepseek.com/v1/");
assert_eq!(host, "https://api.deepseek.com");
assert_eq!(path, "v1");
// 仅根路径
let (host, path) = split_url_host_and_path("https://api.openai.com/");
assert_eq!(host, "https://api.openai.com");
assert_eq!(path, "");
}
}
+20 -2
View File
@@ -1,12 +1,30 @@
//! ProxyCast Agent Crate
//!
//! 包含 Agent 模块中不依赖主 crate 内部模块的纯逻辑部分。
//! 深耦合部分(aster_state、aster_agent、credential_bridge、subagent_scheduler)
//! 留在主 crate。
//! 深耦合部分(aster_state、aster_agent 流式桥接)留在主 crate。
pub mod aster_state;
pub mod aster_state_support;
pub mod credential_bridge;
pub mod event_converter;
pub mod mcp_bridge;
pub mod prompt;
pub mod session_store;
pub mod subagent_scheduler;
pub use aster_state::{AsterAgentState, ProviderConfig};
pub use aster_state_support::{
build_project_system_prompt, create_proxycast_identity, create_session_config_with_project,
message_helpers, reload_proxycast_skills, SessionConfigBuilder,
};
pub use credential_bridge::{
create_aster_provider, AsterProviderConfig, CredentialBridge, CredentialBridgeError,
};
pub use event_converter::{convert_agent_event, convert_to_tauri_message, TauriAgentEvent};
pub use prompt::SystemPromptBuilder;
pub use session_store::{
create_session_sync, get_session_sync, list_sessions_sync, SessionDetail, SessionInfo,
};
pub use subagent_scheduler::{
ProxyCastScheduler, ProxyCastSubAgentExecutor, SchedulerEventEmitter, SubAgentProgressEvent,
};
+2 -8
View File
@@ -3,17 +3,10 @@
//! 实现 Aster 的 McpClientTrait,将工具调用转发到
//! ProxyCast 已有的 MCP RunningService,避免重复启动进程。
use aster::agents::mcp_client::{Error, McpClientTrait};
use rmcp::model::{
CallToolResult, GetPromptResult, InitializeResult, JsonObject, ListPromptsResult,
ListResourcesResult, ListToolsResult, ReadResourceResult, ServerNotification,
};
use rmcp::model::InitializeResult;
use rmcp::service::RunningService;
use rmcp::RoleClient;
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
use tokio_util::sync::CancellationToken;
use proxycast_mcp::client::ProxyCastMcpClient;
@@ -21,6 +14,7 @@ use proxycast_mcp::client::ProxyCastMcpClient;
///
/// 持有 ProxyCast 的 RunningService 引用,
/// 将 Aster 的工具调用转发到已有的 MCP 连接。
#[allow(dead_code)]
pub struct McpBridgeClient {
/// 服务器名称
name: String,
+134
View File
@@ -0,0 +1,134 @@
//! Agent 会话存储服务
//!
//! 提供会话创建、列表查询、详情查询能力。
//! 数据来源为 ProxyCast 数据库(AgentDao)。
use chrono::Utc;
use proxycast_core::agent::types::{AgentMessage, AgentSession, ContentPart, MessageContent};
use proxycast_core::database::dao::agent::AgentDao;
use proxycast_core::database::DbConnection;
use uuid::Uuid;
use crate::event_converter::{TauriMessage, TauriMessageContent};
/// 会话信息(简化版)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SessionInfo {
pub id: String,
pub name: String,
pub created_at: i64,
pub updated_at: i64,
pub messages_count: usize,
}
/// 会话详情(包含消息)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SessionDetail {
pub id: String,
pub name: String,
pub created_at: i64,
pub updated_at: i64,
pub messages: Vec<TauriMessage>,
}
/// 创建新会话
pub fn create_session_sync(db: &DbConnection, name: Option<String>) -> Result<String, String> {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
let session_name = name.unwrap_or_else(|| "新对话".to_string());
let session_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let session = AgentSession {
id: session_id.clone(),
model: "agent:default".to_string(),
messages: Vec::new(),
system_prompt: None,
title: Some(session_name),
created_at: now.clone(),
updated_at: now,
};
AgentDao::create_session(&conn, &session).map_err(|e| format!("创建会话失败: {e}"))?;
Ok(session_id)
}
/// 列出所有会话
pub fn list_sessions_sync(db: &DbConnection) -> Result<Vec<SessionInfo>, String> {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
let sessions = AgentDao::list_sessions(&conn).map_err(|e| format!("获取会话列表失败: {e}"))?;
Ok(sessions
.into_iter()
.map(|session| {
let messages_count = AgentDao::get_message_count(&conn, &session.id).unwrap_or(0);
SessionInfo {
id: session.id,
name: session.title.unwrap_or_else(|| "未命名".to_string()),
created_at: chrono::DateTime::parse_from_rfc3339(&session.created_at)
.map(|dt| dt.timestamp())
.unwrap_or(0),
updated_at: chrono::DateTime::parse_from_rfc3339(&session.updated_at)
.map(|dt| dt.timestamp())
.unwrap_or(0),
messages_count,
}
})
.collect())
}
/// 获取会话详情
pub fn get_session_sync(db: &DbConnection, session_id: &str) -> Result<SessionDetail, String> {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
let session = AgentDao::get_session(&conn, session_id)
.map_err(|e| format!("获取会话失败: {e}"))?
.ok_or_else(|| format!("会话不存在: {session_id}"))?;
let messages =
AgentDao::get_messages(&conn, session_id).map_err(|e| format!("获取消息失败: {e}"))?;
Ok(SessionDetail {
id: session.id,
name: session.title.unwrap_or_else(|| "未命名".to_string()),
created_at: chrono::DateTime::parse_from_rfc3339(&session.created_at)
.map(|dt| dt.timestamp())
.unwrap_or(0),
updated_at: chrono::DateTime::parse_from_rfc3339(&session.updated_at)
.map(|dt| dt.timestamp())
.unwrap_or(0),
messages: messages
.into_iter()
.map(|message| convert_agent_message(&message))
.collect(),
})
}
/// 将 AgentMessage 转换为 TauriMessage
fn convert_agent_message(message: &AgentMessage) -> TauriMessage {
let content = match &message.content {
MessageContent::Text(text) => vec![TauriMessageContent::Text { text: text.clone() }],
MessageContent::Parts(parts) => parts
.iter()
.filter_map(|part| {
if let ContentPart::Text { text } = part {
Some(TauriMessageContent::Text { text: text.clone() })
} else {
None
}
})
.collect(),
};
let timestamp = chrono::DateTime::parse_from_rfc3339(&message.timestamp)
.map(|dt| dt.timestamp())
.unwrap_or(0);
TauriMessage {
id: None,
role: message.role.clone(),
content,
timestamp,
}
}
@@ -0,0 +1,246 @@
//! SubAgent 调度器集成
//!
//! 将 aster-rust 的 SubAgent 调度器与 ProxyCast 凭证池集成。
//! 纯逻辑位于此 crate,事件发送通过注入回调实现。
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use aster::agents::context::AgentContext;
use aster::agents::subagent_scheduler::{
SchedulerConfig, SchedulerError, SchedulerExecutionResult, SchedulerProgress, SchedulerResult,
SubAgentExecutor, SubAgentResult, SubAgentScheduler, SubAgentTask,
TokenUsage as SchedulerTokenUsage,
};
use aster::conversation::message::Message;
use chrono::Utc;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use crate::credential_bridge::{create_aster_provider, AsterProviderConfig, CredentialBridge};
use proxycast_core::database::DbConnection;
/// 调度器事件发射器
pub type SchedulerEventEmitter = Arc<dyn Fn(&serde_json::Value) + Send + Sync>;
/// ProxyCast SubAgent 执行器
///
/// 实现 aster-rust 的 SubAgentExecutor trait,
/// 集成 ProxyCast 凭证池进行 LLM 调用。
pub struct ProxyCastSubAgentExecutor {
/// 凭证桥接器
credential_bridge: CredentialBridge,
/// 数据库连接
db: DbConnection,
/// 默认模型
default_model: String,
/// 默认 Provider 类型
default_provider: String,
}
impl ProxyCastSubAgentExecutor {
/// 创建新的执行器
pub fn new(db: DbConnection) -> Self {
Self {
credential_bridge: CredentialBridge::new(),
db,
default_model: "claude-sonnet-4-20250514".to_string(),
default_provider: "anthropic".to_string(),
}
}
/// 设置默认模型
pub fn with_default_model(mut self, model: impl Into<String>) -> Self {
self.default_model = model.into();
self
}
/// 设置默认 Provider
pub fn with_default_provider(mut self, provider: impl Into<String>) -> Self {
self.default_provider = provider.into();
self
}
/// 从凭证池选择凭证
async fn select_credential(&self, task: &SubAgentTask) -> SchedulerResult<AsterProviderConfig> {
let model = task.model.as_deref().unwrap_or(&self.default_model);
let provider_type = &self.default_provider;
let config = self
.credential_bridge
.select_and_configure(&self.db, provider_type, model)
.await
.map_err(|e| SchedulerError::ProviderError(e.to_string()))?;
Ok(config)
}
/// 生成摘要
fn generate_summary(&self, output: &str, task: &SubAgentTask) -> String {
let max_len = 500;
if output.chars().count() <= max_len {
format!("任务 {} 完成:\n{}", task.id, output)
} else {
let truncated: String = output.chars().take(max_len - 3).collect();
format!("任务 {} 完成:\n{}...", task.id, truncated)
}
}
}
#[async_trait::async_trait]
impl SubAgentExecutor for ProxyCastSubAgentExecutor {
async fn execute_task(
&self,
task: &SubAgentTask,
context: &AgentContext,
) -> SchedulerResult<SubAgentResult> {
let start_time = Utc::now();
info!("执行 SubAgent 任务: {}", task.id);
let provider_config = self.select_credential(task).await?;
debug!("使用凭证: {}", provider_config.credential_uuid);
let provider = create_aster_provider(&provider_config)
.await
.map_err(|e| SchedulerError::ProviderError(e.to_string()))?;
let system_prompt = context.system_prompt.clone().unwrap_or_default();
let user_message = Message::user().with_text(&task.prompt);
let (response_msg, usage) = provider
.complete(&system_prompt, &[user_message], &[])
.await
.map_err(|e| SchedulerError::ProviderError(e.to_string()))?;
let response = response_msg.as_concat_text();
let end_time = Utc::now();
let duration = (end_time - start_time).to_std().unwrap_or(Duration::ZERO);
let summary = if task.return_summary {
Some(self.generate_summary(&response, task))
} else {
None
};
let token_usage = Some(SchedulerTokenUsage {
input_tokens: usage.usage.input_tokens.unwrap_or(0) as usize,
output_tokens: usage.usage.output_tokens.unwrap_or(0) as usize,
total_tokens: usage.usage.total_tokens.unwrap_or(0) as usize,
});
Ok(SubAgentResult {
task_id: task.id.clone(),
success: true,
output: Some(response),
summary,
error: None,
duration,
retries: 0,
started_at: start_time,
completed_at: end_time,
token_usage,
metadata: HashMap::new(),
})
}
}
/// ProxyCast SubAgent 调度器
pub struct ProxyCastScheduler {
/// 内部调度器
scheduler: Arc<RwLock<Option<SubAgentScheduler<ProxyCastSubAgentExecutor>>>>,
/// 数据库连接
db: DbConnection,
}
impl ProxyCastScheduler {
/// 创建新的调度器
pub fn new(db: DbConnection) -> Self {
Self {
scheduler: Arc::new(RwLock::new(None)),
db,
}
}
/// 初始化调度器(不附带事件回调)
pub async fn init(&self, config: Option<SchedulerConfig>) {
self.init_with_event_emitter(config, None).await;
}
/// 初始化调度器(可附带事件回调)
pub async fn init_with_event_emitter(
&self,
config: Option<SchedulerConfig>,
event_emitter: Option<SchedulerEventEmitter>,
) {
let executor = ProxyCastSubAgentExecutor::new(self.db.clone());
let config = config.unwrap_or_default();
let scheduler = if let Some(emitter) = event_emitter {
SubAgentScheduler::new(config, executor).with_event_callback(move |event| {
match serde_json::to_value(&event) {
Ok(payload) => emitter(&payload),
Err(err) => warn!("序列化调度事件失败: {}", err),
}
})
} else {
SubAgentScheduler::new(config, executor)
};
*self.scheduler.write().await = Some(scheduler);
info!("ProxyCast SubAgent 调度器初始化完成");
}
/// 执行任务
pub async fn execute(
&self,
tasks: Vec<SubAgentTask>,
parent_context: Option<&AgentContext>,
) -> SchedulerResult<SchedulerExecutionResult> {
let scheduler = self.scheduler.read().await;
let scheduler = scheduler
.as_ref()
.ok_or_else(|| SchedulerError::ContextError("调度器未初始化".to_string()))?;
scheduler.execute(tasks, parent_context).await
}
/// 取消执行
pub async fn cancel(&self) {
if let Some(scheduler) = self.scheduler.read().await.as_ref() {
scheduler.cancel().await;
}
}
}
/// Tauri 事件:SubAgent 进度
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubAgentProgressEvent {
/// 总任务数
pub total: usize,
/// 已完成数
pub completed: usize,
/// 失败数
pub failed: usize,
/// 运行中数
pub running: usize,
/// 进度百分比
pub percentage: f64,
/// 当前任务
pub current_tasks: Vec<String>,
}
impl From<SchedulerProgress> for SubAgentProgressEvent {
fn from(progress: SchedulerProgress) -> Self {
Self {
total: progress.total,
completed: progress.completed,
failed: progress.failed,
running: progress.running,
percentage: progress.percentage,
current_tasks: progress.current_tasks,
}
}
}
@@ -0,0 +1,68 @@
//! 应用启动前的配置校验逻辑
//!
//! 该模块仅包含与 Tauri 无关的纯配置处理。
use crate::app_utils::{generate_api_key, is_valid_bind_host};
use crate::config::{self, Config};
/// 配置验证错误
#[derive(Debug)]
pub enum ConfigError {
LoadFailed(String),
SaveFailed(String),
InvalidHost,
DefaultApiKeyWithNonLocalBind,
TlsNotSupported,
RemoteManagementNotSupported,
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::LoadFailed(e) => write!(f, "配置加载失败: {e}"),
ConfigError::SaveFailed(e) => write!(f, "配置保存失败: {e}"),
ConfigError::InvalidHost => {
write!(
f,
"无效的监听地址。允许的地址:127.0.0.1、localhost、::1、0.0.0.0、::"
)
}
ConfigError::DefaultApiKeyWithNonLocalBind => write!(
f,
"监听所有网络接口 (0.0.0.0 或 ::) 时,必须设置非默认的 API Key"
),
ConfigError::TlsNotSupported => write!(f, "当前版本尚未支持 TLS"),
ConfigError::RemoteManagementNotSupported => {
write!(f, "远程管理需要 TLS 支持,当前版本未启用")
}
}
}
}
impl std::error::Error for ConfigError {}
/// 加载并验证配置
pub fn load_and_validate_config() -> Result<Config, ConfigError> {
let mut config = config::load_config().map_err(|e| ConfigError::LoadFailed(e.to_string()))?;
if !is_valid_bind_host(&config.server.host) {
return Err(ConfigError::InvalidHost);
}
if config.server.api_key == config::DEFAULT_API_KEY {
let new_key = generate_api_key();
config.server.api_key = new_key;
config::save_config(&config).map_err(|e| ConfigError::SaveFailed(e.to_string()))?;
tracing::info!("检测到默认 API key,已自动生成并保存新密钥");
}
if config.server.tls.enable {
return Err(ConfigError::TlsNotSupported);
}
if config.remote_management.allow_remote {
return Err(ConfigError::RemoteManagementNotSupported);
}
Ok(config)
}
+120
View File
@@ -0,0 +1,120 @@
//! 应用通用工具函数
//!
//! 包含 API Key 生成、绑定地址校验、Token 掩码等与 Tauri 无关的逻辑。
use crate::config;
/// 生成安全的 API Key
pub fn generate_api_key() -> String {
config::generate_secure_api_key()
}
/// 检查是否为回环地址
pub fn is_loopback_host(host: &str) -> bool {
if host == "localhost" {
return true;
}
match host.parse::<std::net::IpAddr>() {
Ok(address) => address.is_loopback(),
Err(_) => false,
}
}
/// 检查是否为有效的绑定地址
/// 允许回环地址、0.0.0.0 和私有网络地址。
pub fn is_valid_bind_host(host: &str) -> bool {
if is_loopback_host(host) {
return true;
}
if host == "0.0.0.0" || host == "::" {
return true;
}
if let Ok(address) = host.parse::<std::net::IpAddr>() {
if let std::net::IpAddr::V4(ipv4) = address {
let octets = ipv4.octets();
return octets[0] == 10
|| (octets[0] == 172 && (octets[1] >= 16 && octets[1] <= 31))
|| (octets[0] == 192 && octets[1] == 168);
}
}
false
}
/// 检查是否为非本地绑定地址(需要强 API Key)
pub fn is_non_local_bind(host: &str) -> bool {
if host == "0.0.0.0" || host == "::" {
return true;
}
if let Ok(address) = host.parse::<std::net::IpAddr>() {
if let std::net::IpAddr::V4(ipv4) = address {
let octets = ipv4.octets();
return octets[0] == 10
|| (octets[0] == 172 && (octets[1] >= 16 && octets[1] <= 31))
|| (octets[0] == 192 && octets[1] == 168);
}
}
false
}
/// 掩码敏感 Token
pub fn mask_token(token: &str) -> String {
let chars: Vec<char> = token.chars().collect();
if chars.len() <= 12 {
"****".to_string()
} else {
let prefix: String = chars[..6].iter().collect();
let suffix: String = chars[chars.len() - 4..].iter().collect();
format!("{prefix}****{suffix}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_loopback_host() {
assert!(is_loopback_host("localhost"));
assert!(is_loopback_host("127.0.0.1"));
assert!(is_loopback_host("::1"));
assert!(!is_loopback_host("0.0.0.0"));
assert!(!is_loopback_host("192.168.1.1"));
}
#[test]
fn test_is_valid_bind_host() {
assert!(is_valid_bind_host("localhost"));
assert!(is_valid_bind_host("127.0.0.1"));
assert!(is_valid_bind_host("::1"));
assert!(is_valid_bind_host("0.0.0.0"));
assert!(is_valid_bind_host("::"));
assert!(is_valid_bind_host("192.168.1.1"));
assert!(is_valid_bind_host("10.0.0.1"));
assert!(is_valid_bind_host("172.16.0.1"));
assert!(is_valid_bind_host("172.31.255.255"));
assert!(!is_valid_bind_host("8.8.8.8"));
assert!(!is_valid_bind_host("1.1.1.1"));
}
#[test]
fn test_is_non_local_bind() {
assert!(is_non_local_bind("0.0.0.0"));
assert!(is_non_local_bind("::"));
assert!(!is_non_local_bind("127.0.0.1"));
assert!(!is_non_local_bind("localhost"));
assert!(is_non_local_bind("192.168.1.1"));
assert!(is_non_local_bind("10.0.0.1"));
assert!(is_non_local_bind("172.16.0.1"));
}
#[test]
fn test_mask_token() {
assert_eq!(mask_token("short"), "****");
assert_eq!(mask_token("abcdefghijklmnop"), "abcdef****mnop");
}
}
+6
View File
@@ -3,6 +3,7 @@
//! 包含纯数据类型、基础模块和无外部业务依赖的独立模块。
//!
//! ## 模块结构
//! - `app_utils`: 应用通用工具函数
//! - `models`: 核心数据模型定义
//! - `data`: 静态数据
//! - `logger`: 日志配置
@@ -16,9 +17,14 @@
//! - `session`: 会话管理(限速、粘性路由)
//! - `session_files`: 会话文件存储
pub mod app_bootstrap;
pub mod app_utils;
pub mod data;
pub mod logger;
pub mod models;
pub mod tray_format;
pub mod tray_menu_meta;
pub mod tray_state;
// 独立业务模块(无主 crate 依赖)
pub mod backends;
+5
View File
@@ -1,4 +1,5 @@
//! 日志管理模块
use crate::config::LoggingConfig;
use chrono::{Duration, Local, Utc};
use regex::Regex;
use serde::{Deserialize, Serialize};
@@ -238,6 +239,10 @@ impl LogStore {
pub type SharedLogStore = Arc<parking_lot::RwLock<LogStore>>;
pub fn create_log_store_from_config(logging: &LoggingConfig) -> LogStore {
LogStore::with_custom_config(logging.retention_days, logging.enabled)
}
/// P2 安全修复:扩展日志脱敏规则,覆盖更多敏感字段
pub fn sanitize_log_message(message: &str) -> String {
let patterns = [
+116
View File
@@ -0,0 +1,116 @@
//! 菜单文本格式化模块
//!
//! 提供托盘菜单文本的格式化函数
/// 格式化服务器状态文本
///
/// # 示例输出
/// - "🟢 API 服务器: 运行中 (127.0.0.1:8080)"
/// - "⚪ API 服务器: 已停止"
pub fn format_server_status(running: bool, host: &str, port: u16) -> String {
if running {
format!("🟢 API 服务器: 运行中 ({host}:{port})")
} else {
"⚪ API 服务器: 已停止".to_string()
}
}
/// 格式化凭证状态文本
///
/// # 示例输出
/// - "🔑 可用凭证: 3/5"
pub fn format_credential_status(available: usize, total: usize) -> String {
format!("🔑 可用凭证: {available}/{total}")
}
/// 格式化请求统计文本
///
/// # 示例输出
/// - "📊 今日请求: 128 次"
pub fn format_request_count(count: u64) -> String {
format!("📊 今日请求: {count} 次")
}
/// 格式化 API 地址
///
/// # 示例输出
/// - "http://127.0.0.1:8080"
pub fn format_api_address(host: &str, port: u16) -> String {
format!("http://{host}:{port}")
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
proptest! {
/// **Feature: system-tray, Property 2: 菜单内容格式化正确性**
/// **Validates: Requirements 2.2, 2.3, 2.4**
#[test]
fn prop_menu_content_formatting(
host in "[a-z0-9.]{1,50}",
port in 1024u16..65535,
available in 0usize..100,
total in 0usize..100,
requests in 0u64..1000000
) {
let running_status = format_server_status(true, &host, port);
prop_assert!(running_status.contains(&host), "运行状态应包含 host");
prop_assert!(running_status.contains(&port.to_string()), "运行状态应包含 port");
prop_assert!(running_status.contains("运行中"), "运行状态应包含'运行中'");
let stopped_status = format_server_status(false, &host, port);
prop_assert!(stopped_status.contains("已停止"), "停止状态应包含'已停止'");
let cred_status = format_credential_status(available, total);
prop_assert!(cred_status.contains(&available.to_string()), "凭证状态应包含可用数");
prop_assert!(cred_status.contains(&total.to_string()), "凭证状态应包含总数");
let req_status = format_request_count(requests);
prop_assert!(req_status.contains(&requests.to_string()), "请求统计应包含请求次数");
}
/// **Feature: system-tray, Property 4: API 地址格式化正确性**
/// **Validates: Requirements 4.2**
#[test]
fn prop_api_address_formatting(
host in "[a-z0-9.]{1,50}",
port in 1024u16..65535
) {
let address = format_api_address(&host, port);
let expected = format!("http://{host}:{port}");
prop_assert_eq!(address, expected, "API 地址格式应为 http://{{host}}:{{port}}");
}
}
#[test]
fn test_format_server_status_running() {
let status = format_server_status(true, "127.0.0.1", 8080);
assert_eq!(status, "🟢 API 服务器: 运行中 (127.0.0.1:8080)");
}
#[test]
fn test_format_server_status_stopped() {
let status = format_server_status(false, "127.0.0.1", 8080);
assert_eq!(status, "⚪ API 服务器: 已停止");
}
#[test]
fn test_format_credential_status() {
let status = format_credential_status(3, 5);
assert_eq!(status, "🔑 可用凭证: 3/5");
}
#[test]
fn test_format_request_count() {
let status = format_request_count(128);
assert_eq!(status, "📊 今日请求: 128 次");
}
#[test]
fn test_format_api_address() {
let address = format_api_address("127.0.0.1", 8080);
assert_eq!(address, "http://127.0.0.1:8080");
}
}
+201
View File
@@ -0,0 +1,201 @@
//! 托盘菜单元数据模块
//!
//! 提供与 Tauri 无关的菜单 ID、地址解析和菜单元信息工具函数。
/// 菜单项 ID 常量
pub mod menu_ids {
/// 状态信息
pub const STATUS_INFO: &str = "status_info";
/// 凭证信息
pub const CREDENTIAL_INFO: &str = "credential_info";
/// 请求信息
pub const REQUEST_INFO: &str = "request_info";
/// 分隔符 1
pub const SEPARATOR_1: &str = "sep_1";
/// 启动服务器
pub const START_SERVER: &str = "start_server";
/// 停止服务器
pub const STOP_SERVER: &str = "stop_server";
/// 刷新所有 Token
pub const REFRESH_TOKENS: &str = "refresh_tokens";
/// 健康检查
pub const HEALTH_CHECK: &str = "health_check";
/// 分隔符 2
pub const SEPARATOR_2: &str = "sep_2";
/// 打开主窗口
pub const OPEN_WINDOW: &str = "open_window";
/// 复制 API 地址
pub const COPY_API_ADDRESS: &str = "copy_api_address";
/// 打开日志目录
pub const OPEN_LOG_DIR: &str = "open_log_dir";
/// 分隔符 3
pub const SEPARATOR_3: &str = "sep_3";
/// 开机自启
pub const AUTO_START: &str = "auto_start";
/// 分隔符 4
pub const SEPARATOR_4: &str = "sep_4";
/// 退出
pub const QUIT: &str = "quit";
/// 获取所有必需的菜单项 ID 列表
pub fn all_required_ids() -> Vec<&'static str> {
vec![
STATUS_INFO,
CREDENTIAL_INFO,
REQUEST_INFO,
START_SERVER,
STOP_SERVER,
REFRESH_TOKENS,
HEALTH_CHECK,
OPEN_WINDOW,
COPY_API_ADDRESS,
OPEN_LOG_DIR,
AUTO_START,
QUIT,
]
}
}
/// 解析服务器地址字符串为 host 和 port
///
/// 支持格式:
/// - "host:port" -> (host, port)
/// - "host" -> (host, 8080)
/// - "" -> ("127.0.0.1", 8080)
pub fn parse_server_address(address: &str) -> (String, u16) {
if address.is_empty() {
return ("127.0.0.1".to_string(), 8080);
}
if let Some((host, port_str)) = address.rsplit_once(':') {
if let Ok(port) = port_str.parse::<u16>() {
return (host.to_string(), port);
}
}
(address.to_string(), 8080)
}
/// 获取菜单中包含的所有菜单项 ID
pub fn get_menu_item_ids() -> Vec<&'static str> {
menu_ids::all_required_ids()
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn test_all_required_ids_not_empty() {
let ids = menu_ids::all_required_ids();
assert!(!ids.is_empty(), "必需的菜单项 ID 列表不应为空");
}
#[test]
fn test_all_required_ids_unique() {
let ids = menu_ids::all_required_ids();
let mut unique_ids = ids.clone();
unique_ids.sort();
unique_ids.dedup();
assert_eq!(ids.len(), unique_ids.len(), "菜单项 ID 应该唯一");
}
#[test]
fn test_menu_ids_completeness() {
let ids = menu_ids::all_required_ids();
assert!(ids.contains(&menu_ids::STATUS_INFO), "应包含 STATUS_INFO");
assert!(
ids.contains(&menu_ids::CREDENTIAL_INFO),
"应包含 CREDENTIAL_INFO"
);
assert!(ids.contains(&menu_ids::REQUEST_INFO), "应包含 REQUEST_INFO");
assert!(ids.contains(&menu_ids::START_SERVER), "应包含 START_SERVER");
assert!(ids.contains(&menu_ids::STOP_SERVER), "应包含 STOP_SERVER");
assert!(
ids.contains(&menu_ids::REFRESH_TOKENS),
"应包含 REFRESH_TOKENS"
);
assert!(ids.contains(&menu_ids::HEALTH_CHECK), "应包含 HEALTH_CHECK");
assert!(ids.contains(&menu_ids::OPEN_WINDOW), "应包含 OPEN_WINDOW");
assert!(
ids.contains(&menu_ids::COPY_API_ADDRESS),
"应包含 COPY_API_ADDRESS"
);
assert!(ids.contains(&menu_ids::OPEN_LOG_DIR), "应包含 OPEN_LOG_DIR");
assert!(ids.contains(&menu_ids::AUTO_START), "应包含 AUTO_START");
assert!(ids.contains(&menu_ids::QUIT), "应包含 QUIT");
}
#[test]
fn test_parse_server_address_with_port() {
let (host, port) = parse_server_address("127.0.0.1:8080");
assert_eq!(host, "127.0.0.1");
assert_eq!(port, 8080);
}
#[test]
fn test_parse_server_address_without_port() {
let (host, port) = parse_server_address("localhost");
assert_eq!(host, "localhost");
assert_eq!(port, 8080);
}
#[test]
fn test_parse_server_address_empty() {
let (host, port) = parse_server_address("");
assert_eq!(host, "127.0.0.1");
assert_eq!(port, 8080);
}
#[test]
fn test_parse_server_address_ipv6() {
let (host, port) = parse_server_address("[::1]:9000");
assert_eq!(host, "[::1]");
assert_eq!(port, 9000);
}
#[test]
fn test_get_menu_item_ids() {
let ids = get_menu_item_ids();
assert_eq!(ids.len(), 12, "应有 12 个必需的菜单项");
}
proptest! {
#[test]
fn prop_menu_ids_completeness(
_server_running in any::<bool>(),
_available in 0usize..100,
_total in 0usize..100,
_requests in 0u64..1000000,
_auto_start in any::<bool>()
) {
let ids = menu_ids::all_required_ids();
let required = vec![
menu_ids::STATUS_INFO,
menu_ids::CREDENTIAL_INFO,
menu_ids::REQUEST_INFO,
menu_ids::START_SERVER,
menu_ids::STOP_SERVER,
menu_ids::REFRESH_TOKENS,
menu_ids::HEALTH_CHECK,
menu_ids::OPEN_WINDOW,
menu_ids::COPY_API_ADDRESS,
menu_ids::OPEN_LOG_DIR,
menu_ids::AUTO_START,
menu_ids::QUIT,
];
for id in required {
prop_assert!(ids.contains(&id), "菜单项列表应包含 {}", id);
}
let mut sorted_ids = ids.clone();
sorted_ids.sort();
sorted_ids.dedup();
prop_assert_eq!(ids.len(), sorted_ids.len(), "菜单项 ID 应该唯一");
}
}
}
+288
View File
@@ -0,0 +1,288 @@
//! 托盘状态模块
//!
//! 定义托盘图标状态和状态快照结构
use serde::{Deserialize, Serialize};
/// 托盘图标状态枚举
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TrayIconStatus {
/// 正常运行(绿色)- 服务器运行且凭证健康
Running,
/// 警告状态(黄色)- 有凭证即将过期或余额不足
Warning,
/// 错误状态(红色)- 服务器停止或所有凭证无效
Error,
/// 停止状态(灰色)- 服务器未启动
Stopped,
}
impl Default for TrayIconStatus {
fn default() -> Self {
Self::Stopped
}
}
/// 凭证健康状态
#[derive(Debug, Clone, Default)]
pub struct CredentialHealth {
/// 凭证是否有效
pub is_valid: bool,
/// 是否即将过期
pub is_expiring_soon: bool,
/// 是否余额不足
pub is_low_balance: bool,
}
impl CredentialHealth {
/// 创建健康的凭证状态
pub fn healthy() -> Self {
Self {
is_valid: true,
is_expiring_soon: false,
is_low_balance: false,
}
}
/// 创建无效的凭证状态
pub fn invalid() -> Self {
Self {
is_valid: false,
is_expiring_soon: false,
is_low_balance: false,
}
}
/// 检查凭证是否有警告
pub fn has_warning(&self) -> bool {
self.is_valid && (self.is_expiring_soon || self.is_low_balance)
}
}
/// 托盘状态快照
#[derive(Debug, Clone, Serialize)]
pub struct TrayStateSnapshot {
/// 图标状态
pub icon_status: TrayIconStatus,
/// 服务器是否运行
pub server_running: bool,
/// 服务器地址
pub server_address: String,
/// 可用凭证数
pub available_credentials: usize,
/// 总凭证数
pub total_credentials: usize,
/// 今日请求数
pub today_requests: u64,
/// 是否开机自启
pub auto_start_enabled: bool,
}
impl Default for TrayStateSnapshot {
fn default() -> Self {
Self {
icon_status: TrayIconStatus::Stopped,
server_running: false,
server_address: String::new(),
available_credentials: 0,
total_credentials: 0,
today_requests: 0,
auto_start_enabled: false,
}
}
}
/// 根据服务器状态和凭证健康状态计算托盘图标状态
///
/// # 规则
/// - 服务器未运行 -> Stopped
/// - 服务器运行 + 所有凭证无效 -> Error
/// - 服务器运行 + 有凭证警告 -> Warning
/// - 服务器运行 + 所有凭证健康 -> Running
pub fn calculate_icon_status(
server_running: bool,
credentials: &[CredentialHealth],
) -> TrayIconStatus {
if !server_running {
return TrayIconStatus::Stopped;
}
if credentials.is_empty() {
return TrayIconStatus::Error;
}
let all_invalid = credentials.iter().all(|c| !c.is_valid);
if all_invalid {
return TrayIconStatus::Error;
}
let has_warning = credentials.iter().any(|c| c.has_warning());
if has_warning {
return TrayIconStatus::Warning;
}
TrayIconStatus::Running
}
/// 将凭证池健康数据转换为托盘所需的健康状态
pub fn get_credential_health_from_pool(
pool_credentials: &[(String, bool, bool, bool)], // (id, is_valid, is_expiring_soon, is_low_balance)
) -> Vec<CredentialHealth> {
pool_credentials
.iter()
.map(
|(_, is_valid, is_expiring_soon, is_low_balance)| CredentialHealth {
is_valid: *is_valid,
is_expiring_soon: *is_expiring_soon,
is_low_balance: *is_low_balance,
},
)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
fn arb_credential_health() -> impl Strategy<Value = CredentialHealth> {
(any::<bool>(), any::<bool>(), any::<bool>()).prop_map(
|(is_valid, is_expiring_soon, is_low_balance)| CredentialHealth {
is_valid,
is_expiring_soon,
is_low_balance,
},
)
}
proptest! {
/// **Feature: system-tray, Property 1: 状态到图标映射正确性**
/// **Validates: Requirements 1.1, 1.2, 1.3**
#[test]
fn prop_icon_status_mapping(
server_running in any::<bool>(),
credentials in prop::collection::vec(arb_credential_health(), 0..10)
) {
let status = calculate_icon_status(server_running, &credentials);
if !server_running {
prop_assert_eq!(status, TrayIconStatus::Stopped);
return Ok(());
}
if credentials.is_empty() {
prop_assert_eq!(status, TrayIconStatus::Error);
return Ok(());
}
let all_invalid = credentials.iter().all(|c| !c.is_valid);
if all_invalid {
prop_assert_eq!(status, TrayIconStatus::Error);
return Ok(());
}
let has_warning = credentials.iter().any(|c| c.has_warning());
if has_warning {
prop_assert_eq!(status, TrayIconStatus::Warning);
return Ok(());
}
prop_assert_eq!(status, TrayIconStatus::Running);
}
}
#[test]
fn test_credential_health_healthy() {
let health = CredentialHealth::healthy();
assert!(health.is_valid);
assert!(!health.is_expiring_soon);
assert!(!health.is_low_balance);
assert!(!health.has_warning());
}
#[test]
fn test_credential_health_invalid() {
let health = CredentialHealth::invalid();
assert!(!health.is_valid);
assert!(!health.has_warning());
}
#[test]
fn test_credential_health_warning() {
let mut health = CredentialHealth::healthy();
health.is_expiring_soon = true;
assert!(health.has_warning());
let mut health2 = CredentialHealth::healthy();
health2.is_low_balance = true;
assert!(health2.has_warning());
}
#[test]
fn test_calculate_icon_status_server_stopped() {
let credentials = vec![CredentialHealth::healthy()];
let status = calculate_icon_status(false, &credentials);
assert_eq!(status, TrayIconStatus::Stopped);
}
#[test]
fn test_calculate_icon_status_no_credentials() {
let credentials = vec![];
let status = calculate_icon_status(true, &credentials);
assert_eq!(status, TrayIconStatus::Error);
}
#[test]
fn test_calculate_icon_status_all_invalid() {
let credentials = vec![CredentialHealth::invalid(), CredentialHealth::invalid()];
let status = calculate_icon_status(true, &credentials);
assert_eq!(status, TrayIconStatus::Error);
}
#[test]
fn test_calculate_icon_status_with_warning() {
let credentials = vec![
CredentialHealth::healthy(),
CredentialHealth {
is_valid: true,
is_expiring_soon: true,
is_low_balance: false,
},
];
let status = calculate_icon_status(true, &credentials);
assert_eq!(status, TrayIconStatus::Warning);
}
#[test]
fn test_calculate_icon_status_running() {
let credentials = vec![CredentialHealth::healthy(), CredentialHealth::healthy()];
let status = calculate_icon_status(true, &credentials);
assert_eq!(status, TrayIconStatus::Running);
}
#[test]
fn test_get_credential_health_from_pool() {
let pool_data = vec![
("cred1".to_string(), true, false, false),
("cred2".to_string(), true, true, false),
("cred3".to_string(), false, false, false),
];
let health = get_credential_health_from_pool(&pool_data);
assert_eq!(health.len(), 3);
assert!(health[0].is_valid);
assert!(!health[0].is_expiring_soon);
assert!(health[1].is_valid);
assert!(health[1].is_expiring_soon);
assert!(!health[2].is_valid);
}
#[test]
fn test_get_credential_health_empty() {
let pool_data: Vec<(String, bool, bool, bool)> = vec![];
let health = get_credential_health_from_pool(&pool_data);
assert!(health.is_empty());
}
}
-8
View File
@@ -166,14 +166,6 @@ impl QuotaManager {
self.exceeded_credentials.len()
}
/// 手动设置凭证的冷却结束时间(仅用于测试)
#[cfg(test)]
pub fn set_cooldown_until(&self, credential_id: &str, until: DateTime<Utc>) {
if let Some(mut record) = self.exceeded_credentials.get_mut(credential_id) {
record.cooldown_until = until;
}
}
/// 检查是否为配额超限错误
pub fn is_quota_exceeded_error(status_code: Option<u16>, error_message: &str) -> bool {
if let Some(code) = status_code {
+51
View File
@@ -1005,3 +1005,54 @@ mod property_tests {
}
}
}
/// 解析 models index.json 的 provider_id 列表
pub fn load_model_registry_provider_ids_from_resources() -> Result<Vec<String>, String> {
let index_path =
resolve_models_index_path().ok_or_else(|| "未找到 models index.json".to_string())?;
let index_content = std::fs::read_to_string(&index_path)
.map_err(|e| format!("读取 models index.json 失败 ({index_path:?}): {e}"))?;
let index_json = serde_json::from_str::<serde_json::Value>(&index_content)
.map_err(|e| format!("解析 models index.json 失败: {e}"))?;
let providers = index_json
.get("providers")
.and_then(|v| v.as_array())
.ok_or_else(|| "models index.json 缺少 providers 数组".to_string())?;
let mut provider_ids: Vec<String> = providers
.iter()
.filter_map(|v| v.as_str())
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty())
.collect();
provider_ids.sort();
provider_ids.dedup();
Ok(provider_ids)
}
/// 定位 models index.json 路径
pub fn resolve_models_index_path() -> Option<std::path::PathBuf> {
let mut candidates: Vec<std::path::PathBuf> = Vec::new();
if let Ok(current_dir) = std::env::current_dir() {
candidates.push(current_dir.join("src-tauri/resources/models/index.json"));
candidates.push(current_dir.join("resources/models/index.json"));
}
if let Ok(exe_path) = std::env::current_exe() {
if let Some(parent) = exe_path.parent() {
candidates.push(parent.join("resources/models/index.json"));
candidates.push(parent.join("../../src-tauri/resources/models/index.json"));
candidates.push(parent.join("../../../src-tauri/resources/models/index.json"));
candidates.push(parent.join("../Resources/resources/models/index.json"));
candidates.push(parent.join("../../Resources/resources/models/index.json"));
candidates.push(parent.join("../../../Resources/resources/models/index.json"));
}
}
candidates.into_iter().find(|path| path.exists())
}
+6 -1
View File
@@ -13,7 +13,12 @@ pub mod websocket;
pub use api::*;
pub use credentials_api::*;
pub use image_handler::*;
pub use kiro_credential::*;
// 避免 SelectCredentialRequest 歧义 glob re-export(credentials_api 和 kiro_credential 都定义了同名类型)
pub use kiro_credential::{
get_available_credentials, get_credential_status, refresh_credential, select_credential,
AvailableCredential, AvailableCredentialsResponse, RefreshCredentialResponse,
SelectCredentialResponse,
};
pub use management::*;
pub use provider_calls::*;
pub use websocket::*;
+5
View File
@@ -5,10 +5,15 @@ edition.workspace = true
authors.workspace = true
repository.workspace = true
[features]
default = []
local-whisper = ["voice-core/local-whisper"]
[dependencies]
# 项目内 crate
proxycast-core.workspace = true
proxycast-providers.workspace = true
voice-core.workspace = true
# 序列化
serde.workspace = true
@@ -0,0 +1,730 @@
//! 文件浏览器服务
//!
//! 提供文件系统浏览功能
//! 支持目录列表、文件预览等操作
//!
//! # 功能
//! - 列出目录内容
//! - 读取文件预览
//! - 获取文件元信息
//! - 获取文件权限和 MIME 类型
use serde::{Deserialize, Serialize};
use std::fs::{self, Metadata};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use tracing::{debug, error};
/// 文件条目
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEntry {
/// 文件名
pub name: String,
/// 完整路径
pub path: String,
/// 是否为目录
#[serde(rename = "isDir")]
pub is_dir: bool,
/// 文件大小(字节)
pub size: u64,
/// 修改时间(Unix 时间戳毫秒)
#[serde(rename = "modifiedAt")]
pub modified_at: u64,
/// 文件类型/扩展名
#[serde(rename = "fileType")]
pub file_type: Option<String>,
/// 是否隐藏文件
#[serde(rename = "isHidden")]
pub is_hidden: bool,
/// 文件权限字符串(如 -rw-r--r--)
#[serde(rename = "modeStr")]
pub mode_str: Option<String>,
/// 文件权限数字(8进制)
pub mode: Option<u32>,
/// MIME 类型
#[serde(rename = "mimeType")]
pub mime_type: Option<String>,
/// 是否为符号链接
#[serde(rename = "isSymlink")]
pub is_symlink: bool,
}
/// 目录列表结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectoryListing {
/// 当前路径
pub path: String,
/// 父目录路径
#[serde(rename = "parentPath")]
pub parent_path: Option<String>,
/// 文件列表
pub entries: Vec<FileEntry>,
/// 错误信息
pub error: Option<String>,
}
/// 文件预览结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilePreview {
/// 文件路径
pub path: String,
/// 文件内容(文本)
pub content: Option<String>,
/// 是否为二进制文件
#[serde(rename = "isBinary")]
pub is_binary: bool,
/// 文件大小
pub size: u64,
/// 错误信息
pub error: Option<String>,
}
/// 获取文件扩展名
fn get_file_extension(path: &Path) -> Option<String> {
path.extension()
.and_then(|ext| ext.to_str())
.map(|s| s.to_lowercase())
}
/// 判断是否为隐藏文件
fn is_hidden_file(name: &str) -> bool {
name.starts_with('.')
}
/// 将 Unix 文件模式转换为权限字符串(如 -rw-r--r--)
#[cfg(unix)]
fn mode_to_string(mode: u32, is_dir: bool, is_symlink: bool) -> String {
let mut result = String::with_capacity(10);
// 文件类型
if is_symlink {
result.push('l');
} else if is_dir {
result.push('d');
} else {
result.push('-');
}
// 用户权限
result.push(if mode & 0o400 != 0 { 'r' } else { '-' });
result.push(if mode & 0o200 != 0 { 'w' } else { '-' });
result.push(if mode & 0o100 != 0 { 'x' } else { '-' });
// 组权限
result.push(if mode & 0o040 != 0 { 'r' } else { '-' });
result.push(if mode & 0o020 != 0 { 'w' } else { '-' });
result.push(if mode & 0o010 != 0 { 'x' } else { '-' });
// 其他用户权限
result.push(if mode & 0o004 != 0 { 'r' } else { '-' });
result.push(if mode & 0o002 != 0 { 'w' } else { '-' });
result.push(if mode & 0o001 != 0 { 'x' } else { '-' });
result
}
/// 根据文件扩展名和元数据获取 MIME 类型
fn get_mime_type(path: &Path, metadata: &Metadata) -> String {
// 特殊类型检测
if metadata.is_dir() {
return "directory".to_string();
}
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt;
let ft = metadata.file_type();
if ft.is_symlink() {
return "symlink".to_string();
}
if ft.is_block_device() {
return "block-device".to_string();
}
if ft.is_char_device() {
return "char-device".to_string();
}
if ft.is_fifo() {
return "pipe".to_string();
}
if ft.is_socket() {
return "socket".to_string();
}
}
// 基于扩展名的 MIME 类型映射
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase());
match ext.as_deref() {
// 文本文件
Some("txt") => "text/plain",
Some("md" | "markdown") => "text/markdown",
Some("html" | "htm") => "text/html",
Some("css") => "text/css",
Some("xml") => "text/xml",
Some("csv") => "text/csv",
// 代码文件
Some("js" | "mjs" | "cjs") => "text/javascript",
Some("ts" | "mts" | "cts") => "text/typescript",
Some("tsx") => "text/tsx",
Some("jsx") => "text/jsx",
Some("json") => "application/json",
Some("yaml" | "yml") => "text/yaml",
Some("toml") => "text/toml",
Some("rs") => "text/x-rust",
Some("py") => "text/x-python",
Some("go") => "text/x-go",
Some("java") => "text/x-java",
Some("c") => "text/x-c",
Some("cpp" | "cc" | "cxx") => "text/x-c++",
Some("h" | "hpp") => "text/x-c-header",
Some("sh" | "bash" | "zsh") => "text/x-shellscript",
Some("sql") => "text/x-sql",
Some("vue") => "text/x-vue",
Some("svelte") => "text/x-svelte",
Some("swift") => "text/x-swift",
Some("kt" | "kts") => "text/x-kotlin",
Some("rb") => "text/x-ruby",
Some("php") => "text/x-php",
Some("lua") => "text/x-lua",
// 图片
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("svg") => "image/svg+xml",
Some("ico") => "image/x-icon",
Some("bmp") => "image/bmp",
Some("tiff" | "tif") => "image/tiff",
// 音频
Some("mp3") => "audio/mpeg",
Some("wav") => "audio/wav",
Some("ogg") => "audio/ogg",
Some("flac") => "audio/flac",
Some("aac") => "audio/aac",
Some("m4a") => "audio/mp4",
// 视频
Some("mp4") => "video/mp4",
Some("webm") => "video/webm",
Some("avi") => "video/x-msvideo",
Some("mov") => "video/quicktime",
Some("mkv") => "video/x-matroska",
Some("wmv") => "video/x-ms-wmv",
// 压缩文件
Some("zip") => "application/zip",
Some("tar") => "application/x-tar",
Some("gz" | "gzip") => "application/gzip",
Some("bz2") => "application/x-bzip2",
Some("xz") => "application/x-xz",
Some("7z") => "application/x-7z-compressed",
Some("rar") => "application/vnd.rar",
// 文档
Some("pdf") => "application/pdf",
Some("doc") => "application/msword",
Some("docx") => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
Some("xls") => "application/vnd.ms-excel",
Some("xlsx") => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
Some("ppt") => "application/vnd.ms-powerpoint",
Some("pptx") => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
// 可执行文件
Some("exe") => "application/x-msdownload",
Some("dmg") => "application/x-apple-diskimage",
Some("app") => "application/x-apple-application",
Some("deb") => "application/x-debian-package",
Some("rpm") => "application/x-rpm",
// 字体
Some("ttf") => "font/ttf",
Some("otf") => "font/otf",
Some("woff") => "font/woff",
Some("woff2") => "font/woff2",
// 其他
Some("wasm") => "application/wasm",
_ => "application/octet-stream",
}
.to_string()
}
/// 判断是否为文本文件(基于扩展名)
fn is_text_file(extension: Option<&str>) -> bool {
match extension {
Some(ext) => matches!(
ext,
"txt"
| "md"
| "json"
| "yaml"
| "yml"
| "toml"
| "xml"
| "html"
| "htm"
| "css"
| "js"
| "ts"
| "tsx"
| "jsx"
| "rs"
| "py"
| "go"
| "java"
| "c"
| "cpp"
| "h"
| "hpp"
| "sh"
| "bash"
| "zsh"
| "fish"
| "sql"
| "graphql"
| "vue"
| "svelte"
| "astro"
| "log"
| "env"
| "gitignore"
| "dockerignore"
| "editorconfig"
| "prettierrc"
| "eslintrc"
| "babelrc"
| "conf"
| "cfg"
| "ini"
| "properties"
),
None => false,
}
}
/// 列出目录内容
pub fn list_directory(path: &str) -> DirectoryListing {
let path_buf = if path.is_empty() || path == "~" {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
} else if path.starts_with('~') {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
home.join(&path[2..])
} else {
PathBuf::from(path)
};
let canonical_path = match path_buf.canonicalize() {
Ok(p) => p,
Err(e) => {
error!("无法解析路径 {}: {}", path, e);
return DirectoryListing {
path: path.to_string(),
parent_path: None,
entries: vec![],
error: Some(format!("无法解析路径: {e}")),
};
}
};
let parent_path = canonical_path
.parent()
.map(|p| p.to_string_lossy().to_string());
let entries = match fs::read_dir(&canonical_path) {
Ok(read_dir) => {
let mut entries: Vec<FileEntry> = read_dir
.filter_map(|entry| {
let entry = entry.ok()?;
// 先获取符号链接信息
let symlink_metadata = entry.metadata().ok();
let is_symlink = entry
.file_type()
.ok()
.map(|ft| ft.is_symlink())
.unwrap_or(false);
// 获取真实文件的元数据(解析符号链接)
let metadata = if is_symlink {
fs::metadata(entry.path()).ok().or(symlink_metadata)?
} else {
symlink_metadata?
};
let name = entry.file_name().to_string_lossy().to_string();
let path = entry.path();
let modified_at = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let file_type = if metadata.is_dir() {
Some("folder".to_string())
} else {
get_file_extension(&path)
};
// 获取文件权限(仅 Unix)
#[cfg(unix)]
let (mode, mode_str) = {
let m = metadata.permissions().mode();
(
Some(m & 0o777),
Some(mode_to_string(m & 0o777, metadata.is_dir(), is_symlink)),
)
};
#[cfg(not(unix))]
let (mode, mode_str): (Option<u32>, Option<String>) = (None, None);
// 获取 MIME 类型
let mime_type = get_mime_type(&path, &metadata);
Some(FileEntry {
name: name.clone(),
path: path.to_string_lossy().to_string(),
is_dir: metadata.is_dir(),
size: metadata.len(),
modified_at,
file_type,
is_hidden: is_hidden_file(&name),
mode_str,
mode,
mime_type: Some(mime_type),
is_symlink,
})
})
.collect();
// 排序:目录在前,然后按名称排序
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
});
entries
}
Err(e) => {
error!("无法读取目录 {}: {}", canonical_path.display(), e);
return DirectoryListing {
path: canonical_path.to_string_lossy().to_string(),
parent_path,
entries: vec![],
error: Some(format!("无法读取目录: {e}")),
};
}
};
debug!(
"列出目录 {}: {} 个条目",
canonical_path.display(),
entries.len()
);
DirectoryListing {
path: canonical_path.to_string_lossy().to_string(),
parent_path,
entries,
error: None,
}
}
/// 读取文件预览
pub fn read_file_preview(path: &str, max_size: Option<usize>) -> FilePreview {
let max_size = max_size.unwrap_or(100 * 1024); // 默认 100KB
let path_buf = PathBuf::from(path);
let metadata = match fs::metadata(&path_buf) {
Ok(m) => m,
Err(e) => {
return FilePreview {
path: path.to_string(),
content: None,
is_binary: false,
size: 0,
error: Some(format!("无法读取文件元信息: {e}")),
};
}
};
if metadata.is_dir() {
return FilePreview {
path: path.to_string(),
content: None,
is_binary: false,
size: 0,
error: Some("不能预览目录".to_string()),
};
}
let size = metadata.len();
let extension = get_file_extension(&path_buf);
let is_text = is_text_file(extension.as_deref());
if !is_text {
return FilePreview {
path: path.to_string(),
content: None,
is_binary: true,
size,
error: None,
};
}
// 读取文件内容
let content = match fs::read(&path_buf) {
Ok(bytes) => {
let bytes_to_read = bytes.len().min(max_size);
match String::from_utf8(bytes[..bytes_to_read].to_vec()) {
Ok(s) => Some(s),
Err(_) => {
return FilePreview {
path: path.to_string(),
content: None,
is_binary: true,
size,
error: None,
};
}
}
}
Err(e) => {
return FilePreview {
path: path.to_string(),
content: None,
is_binary: false,
size,
error: Some(format!("无法读取文件: {e}")),
};
}
};
FilePreview {
path: path.to_string(),
content,
is_binary: false,
size,
error: None,
}
}
/// 服务接口:列出目录
pub async fn list_dir(path: String) -> Result<DirectoryListing, String> {
Ok(list_directory(&path))
}
/// 服务接口:读取文件预览
pub async fn read_file_preview_cmd(
path: String,
max_size: Option<usize>,
) -> Result<FilePreview, String> {
Ok(read_file_preview(&path, max_size))
}
/// 服务接口:获取用户主目录
pub async fn get_home_dir() -> Result<String, String> {
dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.ok_or_else(|| "无法获取主目录".to_string())
}
/// 服务接口:创建新文件
pub async fn create_file(path: String) -> Result<(), String> {
let path_buf = PathBuf::from(&path);
// 检查文件是否已存在
if path_buf.exists() {
return Err("文件已存在".to_string());
}
// 确保父目录存在
if let Some(parent) = path_buf.parent() {
if !parent.exists() {
fs::create_dir_all(parent).map_err(|e| format!("无法创建父目录: {e}"))?;
}
}
// 创建空文件
fs::File::create(&path_buf).map_err(|e| format!("无法创建文件: {e}"))?;
debug!("创建文件: {}", path);
Ok(())
}
/// 服务接口:创建新目录
pub async fn create_directory(path: String) -> Result<(), String> {
let path_buf = PathBuf::from(&path);
// 检查目录是否已存在
if path_buf.exists() {
return Err("目录已存在".to_string());
}
fs::create_dir_all(&path_buf).map_err(|e| format!("无法创建目录: {e}"))?;
debug!("创建目录: {}", path);
Ok(())
}
/// 服务接口:删除文件或目录
pub async fn delete_file(path: String, recursive: bool) -> Result<(), String> {
let path_buf = PathBuf::from(&path);
if !path_buf.exists() {
return Err("文件或目录不存在".to_string());
}
if path_buf.is_dir() {
if recursive {
fs::remove_dir_all(&path_buf).map_err(|e| format!("无法删除目录: {e}"))?;
} else {
fs::remove_dir(&path_buf)
.map_err(|e| format!("无法删除目录(目录非空,需要递归删除): {e}"))?;
}
debug!("删除目录: {}", path);
} else {
fs::remove_file(&path_buf).map_err(|e| format!("无法删除文件: {e}"))?;
debug!("删除文件: {}", path);
}
Ok(())
}
/// 服务接口:重命名文件或目录
pub async fn rename_file(old_path: String, new_path: String) -> Result<(), String> {
let old_path_buf = PathBuf::from(&old_path);
let new_path_buf = PathBuf::from(&new_path);
if !old_path_buf.exists() {
return Err("源文件或目录不存在".to_string());
}
if new_path_buf.exists() {
return Err("目标文件或目录已存在".to_string());
}
fs::rename(&old_path_buf, &new_path_buf).map_err(|e| format!("无法重命名: {e}"))?;
debug!("重命名: {} -> {}", old_path, new_path);
Ok(())
}
/// 服务接口:复制文件名到剪贴板(返回文件名供前端处理)
pub async fn get_file_name(path: String) -> Result<String, String> {
let path_buf = PathBuf::from(&path);
path_buf
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.ok_or_else(|| "无法获取文件名".to_string())
}
/// 服务接口:在 Finder 中显示文件
pub async fn reveal_in_finder(path: String) -> Result<(), String> {
let path_buf = PathBuf::from(&path);
if !path_buf.exists() {
return Err("文件或目录不存在".to_string());
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.args(["-R", &path])
.spawn()
.map_err(|e| format!("无法打开 Finder: {e}"))?;
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
.args(["/select,", &path])
.spawn()
.map_err(|e| format!("无法打开资源管理器: {}", e))?;
}
#[cfg(target_os = "linux")]
{
// 尝试使用 xdg-open 打开父目录
let parent = path_buf.parent().unwrap_or(&path_buf);
std::process::Command::new("xdg-open")
.arg(parent)
.spawn()
.map_err(|e| format!("无法打开文件管理器: {}", e))?;
}
Ok(())
}
/// 服务接口:使用默认应用打开文件
pub async fn open_with_default_app(path: String) -> Result<(), String> {
let path_buf = PathBuf::from(&path);
if !path_buf.exists() {
return Err("文件不存在".to_string());
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(&path)
.spawn()
.map_err(|e| format!("无法打开文件: {e}"))?;
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("cmd")
.args(["/C", "start", "", &path])
.spawn()
.map_err(|e| format!("无法打开文件: {}", e))?;
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(&path)
.spawn()
.map_err(|e| format!("无法打开文件: {}", e))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_list_home_directory() {
let result = list_directory("~");
assert!(result.error.is_none());
assert!(!result.entries.is_empty());
}
#[test]
fn test_is_hidden_file() {
assert!(is_hidden_file(".gitignore"));
assert!(is_hidden_file(".config"));
assert!(!is_hidden_file("readme.md"));
}
#[test]
fn test_is_text_file() {
assert!(is_text_file(Some("txt")));
assert!(is_text_file(Some("rs")));
assert!(is_text_file(Some("json")));
assert!(!is_text_file(Some("png")));
assert!(!is_text_file(Some("exe")));
assert!(!is_text_file(None));
}
}
+18 -1
View File
@@ -7,8 +7,15 @@
//! - `file_browser_service` - 文件浏览服务
//! - `sysinfo_service` - 系统信息服务
//! - `update_check_service` - 更新检查服务
//! - `update_window` - 更新窗口
//! - `usage_service` - 使用统计服务
//! - `voice_config_service` - 语音配置服务
//! - `voice_processor_service` - 语音润色服务
//! - `voice_output_service` - 语音输出服务
//! - `voice_asr_service` - ASR 识别服务
//! - `voice_command_service` - 语音命令业务服务
//! - `voice_recording_service` - 录音状态与设备服务
//! - `screenshot_capture_service` - 跨平台截图服务
//! - `screenshot_image_service` - 截图图片编码服务
//! - `machine_id_service` - 机器 ID 服务
//! - `live_sync` - 实时同步
//! - `mcp_sync` - MCP 同步
@@ -36,8 +43,18 @@
// 无外部依赖的服务
pub mod context_memory_service;
pub mod file_browser_service;
pub mod screenshot_capture_service;
pub mod screenshot_image_service;
pub mod sysinfo_service;
pub mod update_check_service;
pub mod usage_service;
pub mod voice_asr_service;
pub mod voice_command_service;
pub mod voice_config_service;
pub mod voice_output_service;
pub mod voice_processor_service;
pub mod voice_recording_service;
// 依赖 models 的服务
pub mod live_sync;
@@ -0,0 +1,244 @@
//! 屏幕截图服务
//!
//! 提供跨平台的屏幕截图功能,支持交互式区域选择
use std::path::PathBuf;
use tracing::{debug, error, info};
#[cfg(target_os = "macos")]
use tracing::warn;
/// 截图错误类型
#[derive(Debug, thiserror::Error)]
pub enum CaptureError {
#[error("用户取消截图")]
Cancelled,
#[error("权限不足: {0}")]
PermissionDenied(String),
#[error("系统错误: {0}")]
SystemError(String),
#[error("IO 错误: {0}")]
IoError(#[from] std::io::Error),
#[error("临时文件创建失败: {0}")]
TempFileError(String),
}
/// 截图结果
#[derive(Debug)]
pub enum CaptureResult {
/// 截图成功,包含图片路径
Success(PathBuf),
/// 用户取消截图
Cancelled,
}
/// 启动交互式截图
///
/// 调用系统截图工具进行交互式区域选择
///
/// # 返回
/// 成功返回图片路径,用户取消返回 Cancelled 错误,其他情况返回相应错误
///
/// # 平台支持
/// - macOS: 使用 `screencapture -i -x` 命令
/// - Windows: 使用 Windows API 或系统截图工具
/// - Linux: 使用 `gnome-screenshot` 或 `scrot`
pub async fn start_capture() -> Result<PathBuf, CaptureError> {
info!("启动交互式截图");
// 生成临时文件路径
let temp_dir = std::env::temp_dir();
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S_%3f");
let filename = format!("proxycast_screenshot_{timestamp}.png");
let temp_path = temp_dir.join(&filename);
debug!("截图临时文件路径: {:?}", temp_path);
// 根据平台调用不同的截图命令
#[cfg(target_os = "macos")]
{
capture_macos(&temp_path).await?;
}
#[cfg(target_os = "windows")]
{
capture_windows(&temp_path).await?;
}
#[cfg(target_os = "linux")]
{
capture_linux(&temp_path).await?;
}
// 检查文件是否存在(用户可能取消了截图)
if !temp_path.exists() {
info!("截图文件不存在,用户可能取消了截图");
return Err(CaptureError::Cancelled);
}
// 检查文件大小(空文件表示取消)
let metadata = std::fs::metadata(&temp_path)?;
if metadata.len() == 0 {
info!("截图文件为空,用户取消了截图");
std::fs::remove_file(&temp_path)?;
return Err(CaptureError::Cancelled);
}
info!("截图成功: {:?}", temp_path);
Ok(temp_path)
}
/// macOS 截图实现
#[cfg(target_os = "macos")]
async fn capture_macos(output_path: &PathBuf) -> Result<(), CaptureError> {
use std::process::Command;
debug!("使用 macOS screencapture 命令");
// 先检查屏幕录制权限
// 通过尝试执行一个快速的全屏截图到 /dev/null 来检测权限
let permission_check = Command::new("screencapture")
.args(["-x", "-c"]) // -c 截图到剪贴板,快速检测权限
.output();
if let Ok(output) = permission_check {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("cannot") || stderr.contains("permission") {
warn!("屏幕录制权限不足: {}", stderr);
return Err(CaptureError::PermissionDenied(
"请在系统设置 → 隐私与安全性 → 录屏与系统录音 中授权 ProxyCast".to_string(),
));
}
}
}
// 使用 screencapture 命令
// -i: 交互式选择区域
// -x: 不播放截图声音
let output = Command::new("screencapture")
.args(["-i", "-x", output_path.to_str().unwrap()])
.output()
.map_err(|e| CaptureError::SystemError(format!("执行 screencapture 失败: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// 用户按 ESC 取消时,screencapture 返回非零状态但不创建文件
if !output_path.exists() {
return Err(CaptureError::Cancelled);
}
error!("screencapture 命令失败: {}", stderr);
return Err(CaptureError::SystemError(format!(
"screencapture 失败: {stderr}"
)));
}
Ok(())
}
/// Windows 截图实现
#[cfg(target_os = "windows")]
async fn capture_windows(output_path: &PathBuf) -> Result<(), CaptureError> {
use std::process::Command;
debug!("使用 Windows 截图工具");
// 使用 PowerShell 调用截图功能
// 注意:这是一个简化实现,实际可能需要使用 Windows API
let script = format!(
r#"
Add-Type -AssemblyName System.Windows.Forms
$screen = [System.Windows.Forms.Screen]::PrimaryScreen
$bitmap = New-Object System.Drawing.Bitmap($screen.Bounds.Width, $screen.Bounds.Height)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen($screen.Bounds.Location, [System.Drawing.Point]::Empty, $screen.Bounds.Size)
$bitmap.Save('{}')
"#,
output_path.to_str().unwrap().replace("\\", "\\\\")
);
let output = Command::new("powershell")
.args(["-Command", &script])
.output()
.map_err(|e| CaptureError::SystemError(format!("执行 PowerShell 失败: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
error!("PowerShell 截图失败: {}", stderr);
return Err(CaptureError::SystemError(format!("截图失败: {}", stderr)));
}
Ok(())
}
/// Linux 截图实现
#[cfg(target_os = "linux")]
async fn capture_linux(output_path: &PathBuf) -> Result<(), CaptureError> {
use std::process::Command;
debug!("使用 Linux 截图工具");
// 尝试使用 gnome-screenshot
let gnome_result = Command::new("gnome-screenshot")
.args(["-a", "-f", output_path.to_str().unwrap()])
.output();
if let Ok(output) = gnome_result {
if output.status.success() {
return Ok(());
}
}
// 回退到 scrot
let scrot_result = Command::new("scrot")
.args(["-s", output_path.to_str().unwrap()])
.output()
.map_err(|e| {
CaptureError::SystemError(format!(
"无法找到截图工具 (gnome-screenshot 或 scrot): {}",
e
))
})?;
if !scrot_result.status.success() {
let stderr = String::from_utf8_lossy(&scrot_result.stderr);
if !output_path.exists() {
return Err(CaptureError::Cancelled);
}
return Err(CaptureError::SystemError(format!("scrot 失败: {}", stderr)));
}
Ok(())
}
/// 清理临时截图文件
///
/// # 参数
/// - `path`: 要删除的文件路径
pub fn cleanup_temp_file(path: &PathBuf) {
if path.exists() {
if let Err(e) = std::fs::remove_file(path) {
error!("删除临时截图文件失败: {}", e);
} else {
debug!("已删除临时截图文件: {:?}", path);
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_temp_path_generation() {
let temp_dir = std::env::temp_dir();
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S_%3f");
let filename = format!("proxycast_screenshot_{timestamp}.png");
let temp_path = temp_dir.join(&filename);
assert!(temp_path
.to_str()
.unwrap()
.contains("proxycast_screenshot_"));
assert!(temp_path.to_str().unwrap().ends_with(".png"));
}
}
@@ -0,0 +1,31 @@
//! 截图图片处理服务
//!
//! 提供截图文件读取与 Base64 编码能力。
use base64::{engine::general_purpose::STANDARD, Engine};
use std::path::Path;
use tokio::fs;
/// 读取图片文件并转换为 Base64
pub async fn read_image_as_base64(path: &str) -> Result<String, String> {
tracing::debug!("读取图片为 Base64: {}", path);
let file_path = Path::new(path);
if !file_path.exists() {
return Err(format!("文件不存在: {}", file_path.display()));
}
let bytes = fs::read(file_path)
.await
.map_err(|e| format!("读取文件失败: {e}"))?;
if bytes.is_empty() {
return Err("文件为空".to_string());
}
let base64 = STANDARD.encode(&bytes);
tracing::debug!("图片读取成功,大小: {} 字节", bytes.len());
Ok(base64)
}
@@ -0,0 +1,180 @@
//! 系统信息服务
//!
//! 提供 CPU、内存等系统信息的获取和订阅功能。
//! 不依赖 Tauri,事件发送通过回调注入。
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use sysinfo::{CpuRefreshKind, MemoryRefreshKind, RefreshKind, System};
use tokio::sync::Mutex;
use tokio::time::{interval, Duration};
use tracing::{debug, error, info};
/// 系统信息事件回调
pub type SysinfoEmitter = Arc<dyn Fn(&SysinfoData) -> Result<(), String> + Send + Sync>;
/// 系统信息数据点
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SysinfoData {
/// 时间戳(毫秒)
pub ts: u64,
/// 总 CPU 使用率(0-100)
pub cpu: f32,
/// 已用内存(GB)
#[serde(rename = "mem:used")]
pub mem_used: f64,
/// 总内存(GB)
#[serde(rename = "mem:total")]
pub mem_total: f64,
/// 各核心 CPU 使用率
#[serde(flatten)]
pub cpu_cores: std::collections::HashMap<String, f32>,
}
/// 系统信息服务状态
pub struct SysinfoService {
/// 是否正在运行订阅
running: AtomicBool,
/// 系统信息实例
system: Mutex<System>,
}
impl SysinfoService {
/// 创建新的系统信息服务
pub fn new() -> Self {
let system = System::new_with_specifics(
RefreshKind::new()
.with_cpu(CpuRefreshKind::everything())
.with_memory(MemoryRefreshKind::everything()),
);
Self {
running: AtomicBool::new(false),
system: Mutex::new(system),
}
}
/// 获取当前系统信息快照
pub async fn get_sysinfo(&self) -> SysinfoData {
let mut system = self.system.lock().await;
system.refresh_cpu_usage();
system.refresh_memory();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let cpu = system.global_cpu_usage();
let mem_total = system.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
let mem_used = system.used_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
let mut cpu_cores = std::collections::HashMap::new();
for (index, core) in system.cpus().iter().enumerate() {
cpu_cores.insert(format!("cpu:{index}"), core.cpu_usage());
}
SysinfoData {
ts,
cpu,
mem_used,
mem_total,
cpu_cores,
}
}
/// 检查是否正在运行
pub fn is_running(&self) -> bool {
self.running.load(Ordering::SeqCst)
}
/// 停止订阅
pub fn stop(&self) {
self.running.store(false, Ordering::SeqCst);
info!("系统信息订阅已停止");
}
}
impl Default for SysinfoService {
fn default() -> Self {
Self::new()
}
}
/// 全局系统信息服务实例
static SYSINFO_SERVICE: once_cell::sync::Lazy<Arc<SysinfoService>> =
once_cell::sync::Lazy::new(|| Arc::new(SysinfoService::new()));
/// 获取全局系统信息服务
pub fn get_sysinfo_service() -> Arc<SysinfoService> {
SYSINFO_SERVICE.clone()
}
/// 获取当前系统信息
pub async fn get_sysinfo() -> Result<SysinfoData, String> {
let service = get_sysinfo_service();
Ok(service.get_sysinfo().await)
}
/// 开始订阅系统信息(每秒回调一次)
pub async fn subscribe_sysinfo(emitter: SysinfoEmitter) -> Result<(), String> {
let service = get_sysinfo_service();
if service.is_running() {
debug!("系统信息订阅已在运行");
return Ok(());
}
service.running.store(true, Ordering::SeqCst);
info!("开始系统信息订阅");
let service_clone = service.clone();
tokio::spawn(async move {
let mut ticker = interval(Duration::from_secs(1));
ticker.tick().await;
while service_clone.is_running() {
ticker.tick().await;
if !service_clone.is_running() {
break;
}
let data = service_clone.get_sysinfo().await;
if let Err(err) = emitter(&data) {
error!("发送系统信息事件失败: {}", err);
}
}
info!("系统信息订阅任务结束");
});
Ok(())
}
/// 停止订阅系统信息
pub async fn unsubscribe_sysinfo() -> Result<(), String> {
let service = get_sysinfo_service();
service.stop();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_get_sysinfo() {
let service = SysinfoService::new();
tokio::time::sleep(Duration::from_millis(100)).await;
let data = service.get_sysinfo().await;
assert!(data.ts > 0);
assert!(data.cpu >= 0.0 && data.cpu <= 100.0);
assert!(data.mem_total > 0.0);
assert!(data.mem_used >= 0.0);
assert!(data.mem_used <= data.mem_total);
}
}
@@ -0,0 +1,310 @@
//! ASR 服务
//!
//! 统一管理语音识别服务,支持本地 Whisper 和云端 ASR。
//!
//! ## 功能
//! - 本地 Whisper 识别(离线、隐私)
//! - OpenAI Whisper API
//! - 百度语音识别
//! - 讯飞语音识别(WebSocket 流式)
//!
//! ## 模型文件路径
//! Whisper 模型文件存储在:`~/Library/Application Support/proxycast/models/whisper/`
//!
//! 支持的模型:
//! - `ggml-tiny.bin` (~75MB)
//! - `ggml-base.bin` (~142MB)
//! - `ggml-small.bin` (~466MB)
//! - `ggml-medium.bin` (~1.5GB)
//!
//! ## 使用示例
//! ```rust,ignore
//! let credential = AsrService::get_default_credential()?.unwrap();
//! let text = AsrService::transcribe(&credential, &audio_data, 16000).await?;
//! ```
#[cfg(feature = "local-whisper")]
use std::path::PathBuf;
#[cfg(feature = "local-whisper")]
use proxycast_core::config::WhisperModelSize;
use proxycast_core::config::{AsrCredentialEntry, AsrProviderType};
use super::voice_config_service;
use voice_core::asr_client::{AsrClient, BaiduClient, OpenAIWhisperClient, XunfeiClient};
use voice_core::types::AudioData;
/// ASR 服务
pub struct AsrService;
impl AsrService {
/// 获取默认 ASR 凭证
pub fn get_default_credential() -> Result<Option<AsrCredentialEntry>, String> {
voice_config_service::get_default_asr_credential()
}
/// 获取指定 ID 的 ASR 凭证
pub fn get_credential(id: &str) -> Result<Option<AsrCredentialEntry>, String> {
voice_config_service::get_asr_credential(id)
}
/// 使用指定凭证进行语音识别
///
/// 当云端服务失败时,自动回退到本地 Whisper(需求 3.4)
pub async fn transcribe(
credential: &AsrCredentialEntry,
audio_data: &[u8],
sample_rate: u32,
) -> Result<String, String> {
// 如果是本地 Whisper,直接调用
if matches!(credential.provider, AsrProviderType::WhisperLocal) {
return Self::transcribe_whisper_local(credential, audio_data, sample_rate).await;
}
// 云端服务:先尝试云端,失败则回退到本地 Whisper
let cloud_result = match credential.provider {
AsrProviderType::OpenAI => {
Self::transcribe_openai(credential, audio_data, sample_rate).await
}
AsrProviderType::Baidu => {
Self::transcribe_baidu(credential, audio_data, sample_rate).await
}
AsrProviderType::Xunfei => {
Self::transcribe_xunfei(credential, audio_data, sample_rate).await
}
AsrProviderType::WhisperLocal => unreachable!(), // 已在上面处理
};
// 云端成功,直接返回
if cloud_result.is_ok() {
return cloud_result;
}
// 云端失败,尝试回退到本地 Whisper
let cloud_error = cloud_result.unwrap_err();
tracing::warn!(
"云端 ASR 服务 ({:?}) 失败: {},尝试回退到本地 Whisper",
credential.provider,
cloud_error
);
// 尝试获取本地 Whisper 凭证
match Self::get_whisper_local_credential() {
Ok(Some(whisper_credential)) => {
tracing::info!("正在使用本地 Whisper 进行回退识别...");
match Self::transcribe_whisper_local(&whisper_credential, audio_data, sample_rate)
.await
{
Ok(text) => {
tracing::info!("本地 Whisper 回退识别成功");
Ok(text)
}
Err(whisper_error) => {
tracing::error!("本地 Whisper 回退也失败: {}", whisper_error);
// 返回原始云端错误,因为那是用户选择的服务
Err(format!(
"云端服务失败: {cloud_error};本地 Whisper 回退也失败: {whisper_error}"
))
}
}
}
Ok(None) => {
tracing::warn!("未找到本地 Whisper 凭证,无法回退");
Err(format!(
"云端服务失败: {cloud_error};未配置本地 Whisper,无法回退"
))
}
Err(e) => {
tracing::error!("获取本地 Whisper 凭证失败: {}", e);
Err(format!(
"云端服务失败: {cloud_error};获取本地 Whisper 凭证失败: {e}"
))
}
}
}
/// 获取本地 Whisper 凭证(用于回退)
fn get_whisper_local_credential() -> Result<Option<AsrCredentialEntry>, String> {
voice_config_service::get_enabled_asr_credential_by_provider(AsrProviderType::WhisperLocal)
}
/// 本地 Whisper 识别
#[cfg(feature = "local-whisper")]
async fn transcribe_whisper_local(
credential: &AsrCredentialEntry,
audio_data: &[u8],
sample_rate: u32,
) -> Result<String, String> {
// 获取 Whisper 配置
let whisper_config = credential
.whisper_config
.as_ref()
.ok_or("Whisper 本地配置缺失")?;
// 获取模型文件路径
let model_path = Self::get_whisper_model_path(&whisper_config.model)?;
// 将 PCM 字节转换为 i16 采样
let audio = Self::build_audio_data(audio_data, sample_rate)?;
// 检查录音时长
if !audio.is_valid() {
return Err("录音时间过短(需要至少 0.5 秒)".to_string());
}
// 转换模型大小枚举
let model = Self::convert_model_size(&whisper_config.model);
// 创建 Whisper 识别器
let transcriber =
voice_core::WhisperTranscriber::new(model_path, model, &credential.language)
.map_err(|e| format!("Whisper 模型加载失败: {e}"))?;
// 执行识别
let result = transcriber
.transcribe(&audio)
.map_err(|e| format!("Whisper 识别失败: {e}"))?;
Ok(result.text)
}
/// 本地 Whisper 识别(未启用 local-whisper feature 时的 stub)
#[cfg(not(feature = "local-whisper"))]
async fn transcribe_whisper_local(
_credential: &AsrCredentialEntry,
_audio_data: &[u8],
_sample_rate: u32,
) -> Result<String, String> {
Err("本地 Whisper 功能未启用。请使用云端 ASR 服务(OpenAI、百度、讯飞)".to_string())
}
/// 获取 Whisper 模型文件路径
#[cfg(feature = "local-whisper")]
fn get_whisper_model_path(model_size: &WhisperModelSize) -> Result<PathBuf, String> {
// 模型文件名
let filename = match model_size {
WhisperModelSize::Tiny => "ggml-tiny.bin",
WhisperModelSize::Base => "ggml-base.bin",
WhisperModelSize::Small => "ggml-small.bin",
WhisperModelSize::Medium => "ggml-medium.bin",
};
// 模型存储目录:~/Library/Application Support/proxycast/models/whisper/
let models_dir = dirs::data_dir()
.ok_or("无法获取数据目录")?
.join("proxycast")
.join("models")
.join("whisper");
let model_path = models_dir.join(filename);
// 检查模型文件是否存在
if !model_path.exists() {
return Err(format!(
"Whisper 模型文件不存在: {}\n请下载模型文件到: {}",
filename,
models_dir.display()
));
}
Ok(model_path)
}
/// 转换模型大小枚举
#[cfg(feature = "local-whisper")]
fn convert_model_size(size: &WhisperModelSize) -> voice_core::types::WhisperModel {
match size {
WhisperModelSize::Tiny => voice_core::types::WhisperModel::Tiny,
WhisperModelSize::Base => voice_core::types::WhisperModel::Base,
WhisperModelSize::Small => voice_core::types::WhisperModel::Small,
WhisperModelSize::Medium => voice_core::types::WhisperModel::Medium,
}
}
/// OpenAI Whisper API 识别
async fn transcribe_openai(
credential: &AsrCredentialEntry,
audio_data: &[u8],
sample_rate: u32,
) -> Result<String, String> {
let config = credential.openai_config.as_ref().ok_or("OpenAI 配置缺失")?;
let audio = Self::build_audio_data(audio_data, sample_rate)?;
let mut client = OpenAIWhisperClient::new(config.api_key.clone());
if let Some(base_url) = config.base_url.clone() {
client = client.with_host(base_url);
}
if !credential.language.is_empty() {
client = client.with_language(credential.language.clone());
}
let result = client
.transcribe(&audio)
.await
.map_err(|e| format!("OpenAI Whisper 识别失败: {e}"))?;
Ok(result.text)
}
/// 百度语音识别
async fn transcribe_baidu(
credential: &AsrCredentialEntry,
audio_data: &[u8],
sample_rate: u32,
) -> Result<String, String> {
let config = credential.baidu_config.as_ref().ok_or("百度配置缺失")?;
let audio = Self::build_audio_data(audio_data, sample_rate)?;
let client = BaiduClient::new(config.api_key.clone(), config.secret_key.clone());
let result = client
.transcribe(&audio)
.await
.map_err(|e| format!("百度识别失败: {e}"))?;
Ok(result.text)
}
/// 讯飞语音识别
///
/// 使用 WebSocket 流式识别,支持实时语音转文字
async fn transcribe_xunfei(
credential: &AsrCredentialEntry,
audio_data: &[u8],
sample_rate: u32,
) -> Result<String, String> {
let config = credential.xunfei_config.as_ref().ok_or("讯飞配置缺失")?;
let audio = Self::build_audio_data(audio_data, sample_rate)?;
// 创建讯飞客户端
// 讯飞语言代码转换:zh -> zh_cn, en -> en_us
let xunfei_language = match credential.language.as_str() {
"zh" => "zh_cn".to_string(),
"en" => "en_us".to_string(),
other => other.to_string(),
};
let client = XunfeiClient::new(
config.app_id.clone(),
config.api_key.clone(),
config.api_secret.clone(),
)
.with_language(xunfei_language);
let result = client
.transcribe(&audio)
.await
.map_err(|e| format!("讯飞识别失败: {e}"))?;
Ok(result.text)
}
/// 将 PCM 字节构造成 voice-core 的 AudioData
fn build_audio_data(audio_data: &[u8], sample_rate: u32) -> Result<AudioData, String> {
let audio = AudioData::from_pcm16le_bytes(audio_data, sample_rate, 1);
if audio.samples.is_empty() {
return Err("音频数据为空".to_string());
}
Ok(audio)
}
}
@@ -0,0 +1,155 @@
//! 语音命令业务服务
//!
//! 封装语音转写、润色、输出等可复用业务流程。
use serde::{Deserialize, Serialize};
use super::voice_asr_service::AsrService;
use super::voice_config_service;
use super::voice_output_service;
use super::voice_processor_service;
/// 语音识别结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscribeResult {
/// 识别文本
pub text: String,
/// 使用的 ASR 服务
pub provider: String,
}
/// 润色文本结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolishResult {
/// 润色后的文本
pub text: String,
/// 使用的指令
pub instruction_name: String,
}
/// 执行语音识别
pub async fn transcribe_audio(
audio_data: &[u8],
sample_rate: u32,
credential_id: Option<&str>,
) -> Result<TranscribeResult, String> {
tracing::info!(
"[语音识别] 开始识别,音频大小: {} 字节,采样率: {}",
audio_data.len(),
sample_rate
);
if audio_data.is_empty() {
tracing::error!("[语音识别] 音频数据为空!");
return Err("音频数据为空,请检查麦克风权限".to_string());
}
let non_zero_count = audio_data.iter().filter(|&&byte| byte != 0).count();
let non_zero_ratio = non_zero_count as f32 / audio_data.len() as f32;
tracing::info!(
"[语音识别] 非零字节比例: {:.2}% ({}/{})",
non_zero_ratio * 100.0,
non_zero_count,
audio_data.len()
);
if non_zero_ratio < 0.01 {
tracing::warn!("[语音识别] 音频数据几乎全为静音,可能是麦克风权限问题或未正确录音");
}
let credential = if let Some(id) = credential_id {
tracing::info!("[语音识别] 使用指定凭证: {}", id);
AsrService::get_credential(id)?.ok_or_else(|| format!("凭证不存在: {id}"))?
} else {
tracing::info!("[语音识别] 获取默认凭证...");
match AsrService::get_default_credential() {
Ok(Some(credential)) => {
tracing::info!(
"[语音识别] 找到默认凭证: id={}, provider={:?}",
credential.id,
credential.provider
);
credential
}
Ok(None) => {
if let Ok(credentials) = voice_config_service::list_asr_credentials() {
tracing::error!(
"[语音识别] 未找到默认凭证,当前 ASR 凭证数量: {}",
credentials.len()
);
for (index, credential) in credentials.iter().enumerate() {
tracing::error!(
"[语音识别] 凭证 {}: id={}, is_default={}, disabled={}",
index,
credential.id,
credential.is_default,
credential.disabled
);
}
}
return Err("未配置语音识别服务。请在设置 → 凭证池 → ASR 中添加讯飞、百度或 OpenAI Whisper 凭证。".to_string());
}
Err(error) => {
tracing::error!("[语音识别] 获取默认凭证失败: {}", error);
return Err(format!("获取凭证失败: {error}"));
}
}
};
let provider_name = voice_config_service::asr_provider_name(credential.provider);
tracing::info!("[语音识别] 使用服务: {}", provider_name);
let text = AsrService::transcribe(&credential, audio_data, sample_rate).await?;
tracing::info!("[语音识别] 识别完成,文本长度: {} 字符", text.len());
Ok(TranscribeResult {
text,
provider: provider_name.to_string(),
})
}
/// 润色文本
pub async fn polish_voice_text(
text: &str,
instruction_id: Option<&str>,
) -> Result<PolishResult, String> {
let voice_config = voice_config_service::load_voice_config()?;
let instruction_id = instruction_id
.map(|value| value.to_string())
.unwrap_or_else(|| voice_config.processor.default_instruction_id.clone());
let instruction = voice_config
.instructions
.iter()
.find(|item| item.id == instruction_id)
.ok_or_else(|| format!("指令不存在: {instruction_id}"))?;
if instruction_id == "raw" {
return Ok(PolishResult {
text: text.to_string(),
instruction_name: instruction.name.clone(),
});
}
let polished = voice_processor_service::polish_text(
text,
instruction,
voice_config.processor.polish_provider.as_deref(),
voice_config.processor.polish_model.as_deref(),
)
.await?;
Ok(PolishResult {
text: polished,
instruction_name: instruction.name.clone(),
})
}
/// 输出文本到系统
pub fn output_voice_text(text: &str, mode: Option<&str>) -> Result<(), String> {
let output_mode = voice_config_service::resolve_output_mode(mode)?;
voice_output_service::output_text(text, output_mode)?;
tracing::info!("[语音输出] 文本已输出: {} 字符", text.chars().count());
Ok(())
}
@@ -0,0 +1,132 @@
//! 语音输入配置服务
//!
//! 管理语音输入配置、ASR 凭证与润色指令。
//! 不依赖 Tauri,可被主 crate 以桥接方式复用。
use proxycast_core::config::{
load_config, save_config, AsrCredentialEntry, AsrProviderType, VoiceInputConfig,
VoiceInstruction, VoiceOutputMode,
};
/// 加载语音输入配置
pub fn load_voice_config() -> Result<VoiceInputConfig, String> {
let config = load_config().map_err(|e| e.to_string())?;
Ok(config.experimental.voice_input)
}
/// 保存语音输入配置
pub fn save_voice_config(voice_config: VoiceInputConfig) -> Result<(), String> {
let mut config = load_config().map_err(|e| e.to_string())?;
config.experimental.voice_input = voice_config;
save_config(&config).map_err(|e| e.to_string())?;
Ok(())
}
/// 获取默认 ASR 凭证
pub fn get_default_asr_credential() -> Result<Option<AsrCredentialEntry>, String> {
let config = load_config().map_err(|e| e.to_string())?;
Ok(config
.credential_pool
.asr
.into_iter()
.find(|credential| credential.is_default && !credential.disabled))
}
/// 获取指定 ID 的 ASR 凭证
pub fn get_asr_credential(id: &str) -> Result<Option<AsrCredentialEntry>, String> {
let config = load_config().map_err(|e| e.to_string())?;
Ok(config
.credential_pool
.asr
.into_iter()
.find(|credential| credential.id == id))
}
/// 列出所有 ASR 凭证
pub fn list_asr_credentials() -> Result<Vec<AsrCredentialEntry>, String> {
let config = load_config().map_err(|e| e.to_string())?;
Ok(config.credential_pool.asr)
}
/// 获取首个启用的指定 Provider 凭证
pub fn get_enabled_asr_credential_by_provider(
provider: AsrProviderType,
) -> Result<Option<AsrCredentialEntry>, String> {
let config = load_config().map_err(|e| e.to_string())?;
Ok(config
.credential_pool
.asr
.into_iter()
.find(|credential| credential.provider == provider && !credential.disabled))
}
/// 获取指令列表
pub fn get_instructions() -> Result<Vec<VoiceInstruction>, String> {
let config = load_config().map_err(|e| e.to_string())?;
Ok(config.experimental.voice_input.instructions)
}
/// 获取指定 ID 的指令
pub fn get_instruction(id: &str) -> Result<Option<VoiceInstruction>, String> {
let instructions = get_instructions()?;
Ok(instructions
.into_iter()
.find(|instruction| instruction.id == id))
}
/// 保存或更新语音指令
pub fn save_voice_instruction(instruction: VoiceInstruction) -> Result<(), String> {
let mut voice_config = load_voice_config()?;
if let Some(index) = voice_config
.instructions
.iter()
.position(|item| item.id == instruction.id)
{
voice_config.instructions[index] = instruction;
} else {
voice_config.instructions.push(instruction);
}
save_voice_config(voice_config)
}
/// 删除语音指令(预设指令不可删除)
pub fn delete_voice_instruction(id: &str) -> Result<(), String> {
let mut voice_config = load_voice_config()?;
if let Some(instruction) = voice_config.instructions.iter().find(|item| item.id == id) {
if instruction.is_preset {
return Err("无法删除预设指令".to_string());
}
}
voice_config.instructions.retain(|item| item.id != id);
save_voice_config(voice_config)
}
/// 解析输出模式
///
/// 当 `mode` 为 `None` 时,返回配置中的默认输出模式。
pub fn resolve_output_mode(mode: Option<&str>) -> Result<VoiceOutputMode, String> {
match mode {
Some("type") => Ok(VoiceOutputMode::Type),
Some("clipboard") => Ok(VoiceOutputMode::Clipboard),
Some("both") => Ok(VoiceOutputMode::Both),
None => {
let voice_config = load_voice_config()?;
Ok(voice_config.output.mode)
}
Some(other) => Err(format!("未知的输出模式: {other}")),
}
}
/// 获取 ASR Provider 展示名
pub fn asr_provider_name(provider: AsrProviderType) -> &'static str {
match provider {
AsrProviderType::WhisperLocal => "本地 Whisper",
AsrProviderType::OpenAI => "OpenAI Whisper",
AsrProviderType::Baidu => "百度语音",
AsrProviderType::Xunfei => "讯飞语音",
}
}
@@ -0,0 +1,22 @@
//! 语音文本输出服务
//!
//! 提供模拟键盘输入和剪贴板输出能力。
use proxycast_core::config::VoiceOutputMode;
use voice_core::{OutputHandler, OutputMode};
/// 输出文字到系统
///
/// 根据配置的输出模式,将文字输出到当前焦点应用。
pub fn output_text(text: &str, mode: VoiceOutputMode) -> Result<(), String> {
let output_mode = match mode {
VoiceOutputMode::Type => OutputMode::Type,
VoiceOutputMode::Clipboard => OutputMode::Clipboard,
VoiceOutputMode::Both => OutputMode::Both,
};
let mut handler = OutputHandler::new().map_err(|e| format!("初始化输出处理器失败: {e}"))?;
handler
.output(text, output_mode)
.map_err(|e| format!("输出文本失败: {e}"))
}
@@ -0,0 +1,49 @@
//! 语音文本处理服务
//!
//! 提供语音识别文本的 Prompt 套用与 LLM 润色能力。
use proxycast_core::config::VoiceInstruction;
/// 处理文本(应用指令模板)
pub fn process_text(text: &str, instruction: &VoiceInstruction) -> String {
voice_core::text_polish::apply_prompt_template(text, &instruction.prompt)
}
/// 使用 LLM 润色文本
///
/// 通过本地 API 服务器调用 LLM 进行文本润色。
pub async fn polish_text(
text: &str,
instruction: &VoiceInstruction,
_provider: Option<&str>,
model: Option<&str>,
) -> Result<String, String> {
if instruction.id == "raw" {
return Ok(text.to_string());
}
let prompt = process_text(text, instruction);
call_local_llm(&prompt, model, &instruction.id).await
}
/// 调用本地 API 服务器进行 LLM 推理
async fn call_local_llm(
prompt: &str,
model: Option<&str>,
instruction_id: &str,
) -> Result<String, String> {
use proxycast_core::config::load_config;
let config = load_config().map_err(|e| e.to_string())?;
let base_url = format!("http://{}:{}", config.server.host, config.server.port);
let api_key = &config.server.api_key;
voice_core::text_polish::polish_with_local_api(
&base_url,
api_key,
prompt,
model,
instruction_id,
)
.await
}
@@ -0,0 +1,35 @@
//! 录音服务桥接层
//!
//! 录音核心逻辑已迁移到 `voice-core` 的 `threaded_recorder` 模块。
//! 本模块保留 Tauri State 包装和向后兼容导出路径。
use parking_lot::Mutex;
use std::sync::Arc;
pub use voice_core::{AudioDeviceInfo, RecordingCommand, RecordingResponse, RecordingService};
/// 获取所有可用的麦克风设备
pub fn list_audio_devices() -> Result<Vec<AudioDeviceInfo>, String> {
voice_core::list_audio_devices().map_err(|e| e.to_string())
}
/// 全局录音服务状态(Tauri State 包装)
pub struct RecordingServiceState(pub Arc<Mutex<RecordingService>>);
impl RecordingServiceState {
/// 创建新的录音服务状态
pub fn new() -> Self {
Self(Arc::new(Mutex::new(RecordingService::new())))
}
}
impl Default for RecordingServiceState {
fn default() -> Self {
Self::new()
}
}
/// 创建录音服务状态
pub fn create_recording_service_state() -> RecordingServiceState {
RecordingServiceState::new()
}
+5
View File
@@ -6,6 +6,11 @@ authors.workspace = true
repository.workspace = true
[dependencies]
proxycast-core.workspace = true
proxycast-providers.workspace = true
proxycast-services.workspace = true
proxycast-server-utils.workspace = true
serde.workspace = true
serde_json.workspace = true
async-trait.workspace = true
+3 -1
View File
@@ -1,10 +1,11 @@
//! ProxyCast Skills Crate
//!
//! 包含 Skills 系统的 trait 定义和纯逻辑部分。
//! Tauri 相关的实现(TauriExecutionCallback、ProxyCastLlmProvider)留在主 crate。
//! Tauri 相关实现(TauriExecutionCallback)保留在主 crate。
mod execution_callback;
mod llm_provider;
mod proxycast_llm_provider;
mod skill_loader;
pub use execution_callback::{
@@ -12,6 +13,7 @@ pub use execution_callback::{
StepStartPayload,
};
pub use llm_provider::{LlmProvider, SkillError};
pub use proxycast_llm_provider::ProxyCastLlmProvider;
pub use skill_loader::{
find_skill_by_name, get_proxycast_skills_dir, load_skill_from_file, load_skills_from_directory,
parse_allowed_tools, parse_boolean, parse_skill_frontmatter, LoadedSkillDefinition,
@@ -0,0 +1,543 @@
//! ProxyCast LLM Provider 实现
//!
//! 使用 ProviderPoolService 选择凭证并调用 LLM API。
//! trait 定义(LlmProvider, SkillError)已迁移到 proxycast-skills crate。
use std::sync::Arc;
use async_trait::async_trait;
use proxycast_core::database::DbConnection;
use proxycast_core::models::anthropic::AnthropicMessagesRequest;
#[cfg(test)]
use proxycast_core::models::provider_pool_model::PoolProviderType;
use proxycast_core::models::provider_pool_model::{CredentialData, ProviderCredential};
use proxycast_providers::providers::claude_custom::ClaudeCustomProvider;
use proxycast_providers::providers::kiro::KiroProvider;
use proxycast_providers::providers::openai_custom::OpenAICustomProvider;
use proxycast_services::api_key_provider_service::ApiKeyProviderService;
use proxycast_services::provider_pool_service::ProviderPoolService;
use crate::{LlmProvider, SkillError};
/// ProxyCast LLM Provider
///
/// 使用 ProviderPoolService 选择凭证并调用 LLM API。
/// 实现 aster-rust 定义的 LlmProvider trait。
pub struct ProxyCastLlmProvider {
/// 凭证池服务
pool_service: Arc<ProviderPoolService>,
/// API Key Provider 服务(用于智能降级)
api_key_service: Arc<ApiKeyProviderService>,
/// 数据库连接
db: DbConnection,
/// 偏好的 Provider 类型(可选)
preferred_provider: Option<String>,
}
impl ProxyCastLlmProvider {
/// 创建新的 ProxyCastLlmProvider 实例
///
/// # Arguments
/// * `pool_service` - 凭证池服务
/// * `api_key_service` - API Key 服务
/// * `db` - 数据库连接
pub fn new(
pool_service: Arc<ProviderPoolService>,
api_key_service: Arc<ApiKeyProviderService>,
db: DbConnection,
) -> Self {
Self {
pool_service,
api_key_service,
db,
preferred_provider: None,
}
}
/// 创建带有偏好 Provider 的实例
///
/// # Arguments
/// * `pool_service` - 凭证池服务
/// * `api_key_service` - API Key 服务
/// * `db` - 数据库连接
/// * `preferred_provider` - 偏好的 Provider 类型
pub fn with_preferred_provider(
pool_service: Arc<ProviderPoolService>,
api_key_service: Arc<ApiKeyProviderService>,
db: DbConnection,
preferred_provider: String,
) -> Self {
Self {
pool_service,
api_key_service,
db,
preferred_provider: Some(preferred_provider),
}
}
/// 设置偏好的 Provider 类型
pub fn set_preferred_provider(&mut self, provider: Option<String>) {
self.preferred_provider = provider;
}
/// 获取偏好的 Provider 类型
pub fn preferred_provider(&self) -> Option<&str> {
self.preferred_provider.as_deref()
}
/// 将 Skill 的 provider 字段映射到 PoolProviderType
///
/// # Arguments
/// * `provider` - Provider 名称字符串
///
/// # Returns
/// 对应的 PoolProviderType,未知类型返回 None
#[cfg(test)]
fn map_skill_provider_to_pool_type(provider: &str) -> Option<PoolProviderType> {
match provider.to_lowercase().as_str() {
"openai" | "gpt" => Some(PoolProviderType::OpenAI),
"anthropic" | "claude" => Some(PoolProviderType::Claude),
"gemini" | "google" => Some(PoolProviderType::Gemini),
"kiro" | "codewhisperer" => Some(PoolProviderType::Kiro),
"vertex" => Some(PoolProviderType::Vertex),
"codex" => Some(PoolProviderType::Codex),
_ => None,
}
}
/// 根据凭证调用 LLM API
///
/// # Arguments
/// * `credential` - 选中的凭证
/// * `system_prompt` - 系统提示词
/// * `user_message` - 用户消息
/// * `model` - 模型名称
///
/// # Returns
/// LLM 响应文本或错误
async fn call_llm_with_credential(
&self,
credential: &ProviderCredential,
system_prompt: &str,
user_message: &str,
model: &str,
) -> Result<String, SkillError> {
match &credential.credential {
CredentialData::KiroOAuth { creds_file_path } => {
self.call_kiro_api(creds_file_path, system_prompt, user_message, model)
.await
}
CredentialData::ClaudeKey { api_key, base_url } => {
self.call_claude_api(
api_key,
base_url.as_deref(),
system_prompt,
user_message,
model,
)
.await
}
CredentialData::OpenAIKey { api_key, base_url } => {
self.call_openai_api(
api_key,
base_url.as_deref(),
system_prompt,
user_message,
model,
)
.await
}
CredentialData::AnthropicKey { api_key, base_url } => {
// Anthropic API Key 使用 Claude API
self.call_claude_api(
api_key,
base_url.as_deref(),
system_prompt,
user_message,
model,
)
.await
}
_ => Err(SkillError::ProviderError(format!(
"不支持的凭证类型: {:?}",
credential.provider_type
))),
}
}
/// 调用 Kiro API
async fn call_kiro_api(
&self,
creds_file_path: &str,
system_prompt: &str,
user_message: &str,
model: &str,
) -> Result<String, SkillError> {
use proxycast_core::models::anthropic::AnthropicMessage;
use proxycast_providers::converter::anthropic_to_openai::convert_anthropic_to_openai;
use proxycast_providers::providers::traits::CredentialProvider;
use proxycast_server_utils::parse_cw_response;
let mut kiro = KiroProvider::new();
kiro.load_credentials_from_path(creds_file_path)
.await
.map_err(|e| SkillError::ProviderError(format!("加载 Kiro 凭证失败: {}", e)))?;
// 确保 Token 有效
if !kiro.is_token_valid() || kiro.is_token_expiring_soon() {
kiro.refresh_token()
.await
.map_err(|e| SkillError::ProviderError(format!("刷新 Token 失败: {}", e)))?;
}
// 构建 Anthropic 请求
let request = AnthropicMessagesRequest {
model: model.to_string(),
max_tokens: Some(4096),
system: Some(serde_json::Value::String(system_prompt.to_string())),
messages: vec![AnthropicMessage {
role: "user".to_string(),
content: serde_json::Value::String(user_message.to_string()),
}],
stream: false,
temperature: None,
tools: None,
tool_choice: None,
};
// 转换为 OpenAI 格式并调用
let openai_request = convert_anthropic_to_openai(&request);
let resp = kiro
.call_api(&openai_request)
.await
.map_err(|e| SkillError::ProviderError(format!("Kiro API 调用失败: {}", e)))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(SkillError::ProviderError(format!(
"Kiro API 返回错误: status={}, body={}",
status, body
)));
}
let bytes = resp
.bytes()
.await
.map_err(|e| SkillError::ProviderError(format!("读取响应失败: {}", e)))?;
let body = String::from_utf8_lossy(&bytes).to_string();
let parsed = parse_cw_response(&body);
Ok(parsed.content)
}
/// 调用 Claude API
async fn call_claude_api(
&self,
api_key: &str,
base_url: Option<&str>,
system_prompt: &str,
user_message: &str,
model: &str,
) -> Result<String, SkillError> {
use proxycast_core::models::anthropic::AnthropicMessage;
let claude =
ClaudeCustomProvider::with_config(api_key.to_string(), base_url.map(|s| s.to_string()));
// 构建 Anthropic 请求
let request = AnthropicMessagesRequest {
model: model.to_string(),
max_tokens: Some(4096),
system: Some(serde_json::Value::String(system_prompt.to_string())),
messages: vec![AnthropicMessage {
role: "user".to_string(),
content: serde_json::Value::String(user_message.to_string()),
}],
stream: false,
temperature: None,
tools: None,
tool_choice: None,
};
let resp = claude
.call_api(&request)
.await
.map_err(|e| SkillError::ProviderError(format!("Claude API 调用失败: {}", e)))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(SkillError::ProviderError(format!(
"Claude API 返回错误: status={}, body={}",
status, body
)));
}
let body = resp
.text()
.await
.map_err(|e| SkillError::ProviderError(format!("读取响应失败: {}", e)))?;
// 解析 Anthropic 响应
let json: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| SkillError::ProviderError(format!("解析响应失败: {}", e)))?;
// 提取文本内容
let content = json["content"]
.as_array()
.and_then(|arr| arr.first())
.and_then(|block| block["text"].as_str())
.unwrap_or("");
Ok(content.to_string())
}
/// 调用 OpenAI API
async fn call_openai_api(
&self,
api_key: &str,
base_url: Option<&str>,
system_prompt: &str,
user_message: &str,
model: &str,
) -> Result<String, SkillError> {
use proxycast_core::models::openai::{ChatCompletionRequest, ChatMessage, MessageContent};
let openai =
OpenAICustomProvider::with_config(api_key.to_string(), base_url.map(|s| s.to_string()));
// 构建 OpenAI 请求
let request = ChatCompletionRequest {
model: model.to_string(),
messages: vec![
ChatMessage {
role: "system".to_string(),
content: Some(MessageContent::Text(system_prompt.to_string())),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
},
ChatMessage {
role: "user".to_string(),
content: Some(MessageContent::Text(user_message.to_string())),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
},
],
max_tokens: Some(4096),
stream: false,
temperature: None,
top_p: None,
tools: None,
tool_choice: None,
reasoning_effort: None,
};
let resp = openai
.call_api(&request)
.await
.map_err(|e| SkillError::ProviderError(format!("OpenAI API 调用失败: {}", e)))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(SkillError::ProviderError(format!(
"OpenAI API 返回错误: status={}, body={}",
status, body
)));
}
let body = resp
.text()
.await
.map_err(|e| SkillError::ProviderError(format!("读取响应失败: {}", e)))?;
// 解析 OpenAI 响应
let json: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| SkillError::ProviderError(format!("解析响应失败: {}", e)))?;
// 提取文本内容
let content = json["choices"]
.as_array()
.and_then(|arr| arr.first())
.and_then(|choice| choice["message"]["content"].as_str())
.unwrap_or("");
Ok(content.to_string())
}
}
#[async_trait]
impl LlmProvider for ProxyCastLlmProvider {
/// 调用 LLM 进行对话
///
/// # 实现说明
/// 1. 使用 ProviderPoolService.select_credential_with_fallback() 选择凭证
/// 2. 如果指定了 preferred_provider,优先选择该类型的凭证
/// 3. 如果指定了 model,传递给底层 provider
/// 4. 如果没有可用凭证,返回 ProviderError
///
/// # Requirements
/// - 1.2: 使用 ProviderPoolService 选择可用凭证
/// - 1.3: 优先选择指定 provider 类型的凭证
/// - 1.4: 将 model 参数传递给底层 provider
/// - 1.5: 没有可用凭证时返回 ProviderError
async fn chat(
&self,
system_prompt: &str,
user_message: &str,
model: Option<&str>,
) -> Result<String, SkillError> {
// 确定要使用的 provider 类型
let provider_type = self.preferred_provider.as_deref().unwrap_or("claude"); // 默认使用 Claude
// 确定要使用的模型
let model_name = model.unwrap_or("claude-sonnet-4-5-20250514");
tracing::info!(
"[ProxyCastLlmProvider] chat 调用: provider_type={}, model={}",
provider_type,
model_name
);
// 使用 ProviderPoolService 选择凭证(Requirements 1.2, 1.3)
let credential = self
.pool_service
.select_credential_with_fallback(
&self.db,
&self.api_key_service,
provider_type,
Some(model_name),
None, // provider_id_hint
None, // client_type
)
.await
.map_err(|e| SkillError::ProviderError(format!("选择凭证失败: {}", e)))?
.ok_or_else(|| {
// Requirements 1.5: 没有可用凭证时返回 ProviderError
SkillError::ProviderError(format!(
"没有可用的凭证: provider_type={}, model={}",
provider_type, model_name
))
})?;
tracing::info!(
"[ProxyCastLlmProvider] 选中凭证: uuid={}, type={:?}",
&credential.uuid[..8],
credential.provider_type
);
// 调用 LLM API(Requirements 1.4: 传递 model 参数)
let result = self
.call_llm_with_credential(&credential, system_prompt, user_message, model_name)
.await;
// 记录使用情况
match &result {
Ok(_) => {
let _ = self.pool_service.record_usage(&self.db, &credential.uuid);
let _ =
self.pool_service
.mark_healthy(&self.db, &credential.uuid, Some(model_name));
}
Err(e) => {
let _ = self.pool_service.mark_unhealthy(
&self.db,
&credential.uuid,
Some(&e.to_string()),
);
}
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_map_skill_provider_openai() {
assert_eq!(
ProxyCastLlmProvider::map_skill_provider_to_pool_type("openai"),
Some(PoolProviderType::OpenAI)
);
assert_eq!(
ProxyCastLlmProvider::map_skill_provider_to_pool_type("gpt"),
Some(PoolProviderType::OpenAI)
);
assert_eq!(
ProxyCastLlmProvider::map_skill_provider_to_pool_type("OPENAI"),
Some(PoolProviderType::OpenAI)
);
}
#[test]
fn test_map_skill_provider_claude() {
assert_eq!(
ProxyCastLlmProvider::map_skill_provider_to_pool_type("claude"),
Some(PoolProviderType::Claude)
);
assert_eq!(
ProxyCastLlmProvider::map_skill_provider_to_pool_type("anthropic"),
Some(PoolProviderType::Claude)
);
assert_eq!(
ProxyCastLlmProvider::map_skill_provider_to_pool_type("CLAUDE"),
Some(PoolProviderType::Claude)
);
}
#[test]
fn test_map_skill_provider_gemini() {
assert_eq!(
ProxyCastLlmProvider::map_skill_provider_to_pool_type("gemini"),
Some(PoolProviderType::Gemini)
);
assert_eq!(
ProxyCastLlmProvider::map_skill_provider_to_pool_type("google"),
Some(PoolProviderType::Gemini)
);
}
#[test]
fn test_map_skill_provider_kiro() {
assert_eq!(
ProxyCastLlmProvider::map_skill_provider_to_pool_type("kiro"),
Some(PoolProviderType::Kiro)
);
assert_eq!(
ProxyCastLlmProvider::map_skill_provider_to_pool_type("codewhisperer"),
Some(PoolProviderType::Kiro)
);
}
#[test]
fn test_map_skill_provider_unknown() {
assert_eq!(
ProxyCastLlmProvider::map_skill_provider_to_pool_type("unknown_provider"),
None
);
assert_eq!(
ProxyCastLlmProvider::map_skill_provider_to_pool_type(""),
None
);
}
#[test]
fn test_skill_error_display() {
let provider_err = SkillError::ProviderError("没有可用凭证".to_string());
assert!(provider_err.to_string().contains("Provider error"));
assert!(provider_err.to_string().contains("没有可用凭证"));
let exec_err = SkillError::ExecutionError("执行失败".to_string());
assert!(exec_err.to_string().contains("Execution error"));
let config_err = SkillError::ConfigError("配置错误".to_string());
assert!(config_err.to_string().contains("Config error"));
}
}
+1
View File
@@ -19,6 +19,7 @@ src/
├── device.rs # 音频设备枚举
├── recorder.rs # 音频录制
├── threaded_recorder.rs # 线程化录音服务(可跨线程控制)
├── text_polish.rs # 文本润色与本地 LLM 调用
├── transcriber.rs # Whisper 本地识别
├── output.rs # 文字输出
└── asr_client/ # 云端 ASR
+1
View File
@@ -8,6 +8,7 @@ pub mod device;
pub mod error;
pub mod output;
pub mod recorder;
pub mod text_polish;
pub mod threaded_recorder;
#[cfg(feature = "local-whisper")]
pub mod transcriber;
@@ -0,0 +1,133 @@
//! 语音文本润色
//!
//! 提供与 Tauri 无关的文本模板处理和本地 LLM 调用能力。
/// 语音润色的 System Prompt
pub const VOICE_POLISH_SYSTEM_PROMPT: &str = r#"你是一个语音转文字的后处理助手。
## 背景说明
用户通过麦克风录音,然后使用语音识别 API(如讯飞、百度等)将语音转换为文字。由于录音环境、口音、语速等因素,识别结果可能存在以下问题:
1. **重复字词**:语音识别的流式返回机制可能导致字词重复,如"你你好好"实际是"你好"
2. **语气词和填充词**:如"嗯"、"啊"、"那个"、"就是"、"然后"等口语习惯
3. **同音字错误**:识别引擎可能选错同音字,如"准备"识别成"准被"
4. **环境噪音干扰**:背景噪音可能被误识别为无意义的字词
5. **断句错误**:缺少标点或标点位置不当
6. **多余内容**:录音开始或结束时的杂音可能被识别为无关文字
## 你的任务
根据上下文语义,智能还原用户真正想表达的内容:
- 去除明显的重复字词
- 去除无意义的语气词和填充词
- 根据语义修正可能的同音字错误
- 过滤掉噪音产生的无意义内容
- 添加合适的标点符号
- 保持用户的原意,不要添加或臆测内容
## 输出要求
只输出处理后的文本,不要添加任何解释、说明或前缀。"#;
/// 应用 Prompt 模板(将 `{{text}}` 替换为实际文本)
pub fn apply_prompt_template(text: &str, template: &str) -> String {
template.replace("{{text}}", text)
}
/// 调用本地 API 服务器进行 LLM 润色
pub async fn polish_with_local_api(
base_url: &str,
api_key: &str,
prompt: &str,
model: Option<&str>,
instruction_id: &str,
) -> Result<String, String> {
// 使用配置的模型,如果没有配置则使用 deepseek-chat
let model_name = model.filter(|m| !m.is_empty()).unwrap_or("deepseek-chat");
tracing::info!(
"[语音润色] 使用模型: {}, 指令: {}",
model_name,
instruction_id
);
#[derive(serde::Serialize)]
struct Message {
role: String,
content: String,
}
#[derive(serde::Serialize)]
struct ChatRequest {
model: String,
messages: Vec<Message>,
max_tokens: u32,
temperature: f32,
}
let messages = if instruction_id == "default" {
vec![
Message {
role: "system".to_string(),
content: VOICE_POLISH_SYSTEM_PROMPT.to_string(),
},
Message {
role: "user".to_string(),
content: prompt.to_string(),
},
]
} else {
vec![Message {
role: "user".to_string(),
content: prompt.to_string(),
}]
};
let request = ChatRequest {
model: model_name.to_string(),
messages,
max_tokens: 2048,
temperature: 0.3,
};
let endpoint = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
let response = reqwest::Client::new()
.post(endpoint)
.header("Authorization", format!("Bearer {api_key}"))
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
.map_err(|e| format!("请求失败: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("LLM API 错误: {status} - {body}"));
}
#[derive(serde::Deserialize)]
struct Choice {
message: ResponseMessage,
}
#[derive(serde::Deserialize)]
struct ResponseMessage {
content: Option<String>,
}
#[derive(serde::Deserialize)]
struct ChatResponse {
choices: Vec<Choice>,
}
let result: ChatResponse = response
.json()
.await
.map_err(|e| format!("解析响应失败: {e}"))?;
result
.choices
.first()
.and_then(|c| c.message.content.clone())
.ok_or_else(|| "LLM 返回空内容".to_string())
}
@@ -19,13 +19,13 @@
//! - Tauri 命令通过 channel 发送控制指令
//! - 录音线程通过 channel 返回结果
use crate::types::AudioData;
use parking_lot::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::Instant;
use crate::types::AudioData;
/// 录音控制命令
#[derive(Debug)]
+6 -6
View File
@@ -19,12 +19,12 @@ AI Agent 集成模块,基于 aster-rust 框架实现。
|------|------|
| `mod.rs` | 模块入口,导出公共类型 |
| `types.rs` | Agent 相关类型定义 |
| `aster_state.rs` | Aster Agent 状态管理(Provider 配置、取消令牌、Skills 加载) |
| `aster_agent.rs` | Aster Agent 包装器(会话管理) |
| `aster_state.rs` | Aster Agent 状态管理(主状态桥接;会话配置/项目上下文/身份配置/Skills 加载辅助逻辑委托 `crates/agent/src/aster_state_support.rs`) |
| `aster_agent.rs` | Aster Agent 包装器(流式桥接,会话存储逻辑委托 `crates/agent/src/session_store.rs`) |
| `event_converter.rs` | Aster 事件到 Tauri 事件转换 |
| `credential_bridge.rs` | 凭证池桥接(连接 ProxyCast 凭证池与 Aster Provider,智能拆分 base_url) |
| `credential_bridge.rs` | 重导出层(纯逻辑已迁移到 `crates/agent/src/credential_bridge.rs`) |
| `mcp_bridge.rs` | MCP 服务桥接 |
| `subagent_scheduler.rs` | 子 Agent 调度器 |
| `subagent_scheduler.rs` | Tauri 事件桥接层(纯逻辑已迁移到 `crates/agent/src/subagent_scheduler.rs`) |
## Skills 集成
@@ -34,7 +34,7 @@ Agent 初始化时自动加载 `~/.proxycast/skills/` 目录下的 Skills:
```rust
// init_agent_with_db() 内部调用
Self::load_proxycast_skills();
proxycast_agent::reload_proxycast_skills();
```
### AI 自动调用
@@ -104,7 +104,7 @@ let stream = agent.reply(user_message, session_config, Some(cancel_token)).await
## 凭证池桥接
`credential_bridge.rs` 模块将 ProxyCast 凭证池与 Aster Provider 系统连接:
`credential_bridge.rs` 在主 crate 中仅作为兼容导出,核心逻辑位于 `crates/agent/src/credential_bridge.rs`:
- 自动从凭证池选择可用凭证
- 支持 OAuth 和 API Key 两种凭证类型
+23 -152
View File
@@ -1,16 +1,17 @@
//! Aster Agent 包装器
//!
//! 提供简化的接口来使用 Aster Agent
//! 处理消息发送、事件流转换和会话管理
//! 提供简化的接口来使用 Aster Agent。
//! 处理消息发送、事件流转换,并桥接会话存储服务。
use crate::agent::aster_state::{AsterAgentState, SessionConfigBuilder};
use crate::database::dao::agent::AgentDao;
use crate::database::DbConnection;
use aster::conversation::message::Message;
use chrono::Utc;
use futures::StreamExt;
use proxycast_agent::{convert_agent_event, TauriAgentEvent};
use tauri::{AppHandle, Emitter};
pub use proxycast_agent::session_store::{SessionDetail, SessionInfo};
/// Aster Agent 包装器
///
/// 提供与 Tauri 集成的简化接口
@@ -37,72 +38,56 @@ impl AsterAgentWrapper {
session_id: String,
event_name: String,
) -> Result<(), String> {
// 1. 初始化检查(使用带数据库的版本)
if !state.is_initialized().await {
state.init_agent_with_db(db).await?;
}
// 2. 创建取消令牌
let cancel_token = state.create_cancel_token(&session_id).await;
// 3. 构建消息和配置
let user_message = Message::user().with_text(&message);
let session_config = SessionConfigBuilder::new(&session_id).build();
// 4. 获取 Agent 引用(关键步骤)
let agent_arc = state.get_agent_arc();
let guard = agent_arc.read().await;
let agent = guard.as_ref().ok_or("Agent not initialized")?;
// 5. 调用 Agent::reply
let stream_result = agent
.reply(user_message, session_config, Some(cancel_token.clone()))
.await;
// 6. 处理流式响应
match stream_result {
Ok(mut stream) => {
while let Some(event_result) = stream.next().await {
match event_result {
Ok(agent_event) => {
// 转换并发送事件到前端
let tauri_events =
crate::agent::event_converter::convert_agent_event(agent_event);
let tauri_events = convert_agent_event(agent_event);
for tauri_event in tauri_events {
if let Err(e) = app.emit(&event_name, &tauri_event) {
tracing::error!("[AsterAgentWrapper] 发送事件失败: {}", e);
if let Err(error) = app.emit(&event_name, &tauri_event) {
tracing::error!("[AsterAgentWrapper] 发送事件失败: {}", error);
}
}
}
Err(e) => {
// 发送错误事件
let error_event =
crate::agent::event_converter::TauriAgentEvent::Error {
message: format!("Stream error: {e}"),
};
Err(error) => {
let error_event = TauriAgentEvent::Error {
message: format!("Stream error: {error}"),
};
let _ = app.emit(&event_name, &error_event);
}
}
}
// 发送完成事件
let done_event =
crate::agent::event_converter::TauriAgentEvent::FinalDone { usage: None };
let done_event = TauriAgentEvent::FinalDone { usage: None };
let _ = app.emit(&event_name, &done_event);
}
Err(e) => {
// 发送错误事件并返回错误
let error_event = crate::agent::event_converter::TauriAgentEvent::Error {
message: format!("Agent error: {e}"),
Err(error) => {
let error_event = TauriAgentEvent::Error {
message: format!("Agent error: {error}"),
};
let _ = app.emit(&event_name, &error_event);
return Err(format!("Agent error: {e}"));
return Err(format!("Agent error: {error}"));
}
}
// guard 在作用域结束时自动释放
// 7. 清理取消令牌
state.remove_cancel_token(&session_id).await;
Ok(())
@@ -113,133 +98,19 @@ impl AsterAgentWrapper {
state.cancel_session(session_id).await
}
/// 创建新会话 - 使用 ProxyCast 数据库
/// 创建新会话
pub fn create_session_sync(db: &DbConnection, name: Option<String>) -> Result<String, String> {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
let session_name = name.unwrap_or_else(|| "新对话".to_string());
let session_id = uuid::Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let session = crate::agent::types::AgentSession {
id: session_id.clone(),
model: "agent:default".to_string(),
messages: Vec::new(),
system_prompt: None,
title: Some(session_name),
created_at: now.clone(),
updated_at: now,
};
AgentDao::create_session(&conn, &session).map_err(|e| format!("创建会话失败: {e}"))?;
Ok(session_id)
proxycast_agent::session_store::create_session_sync(db, name)
}
/// 列出所有会话 - 使用 ProxyCast 数据库
/// 列出所有会话
pub fn list_sessions_sync(db: &DbConnection) -> Result<Vec<SessionInfo>, String> {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
let sessions =
AgentDao::list_sessions(&conn).map_err(|e| format!("获取会话列表失败: {e}"))?;
Ok(sessions
.into_iter()
.map(|s| {
let messages_count = AgentDao::get_message_count(&conn, &s.id).unwrap_or(0);
SessionInfo {
id: s.id,
name: s.title.unwrap_or_else(|| "未命名".to_string()),
created_at: chrono::DateTime::parse_from_rfc3339(&s.created_at)
.map(|dt| dt.timestamp())
.unwrap_or(0),
updated_at: chrono::DateTime::parse_from_rfc3339(&s.updated_at)
.map(|dt| dt.timestamp())
.unwrap_or(0),
messages_count,
}
})
.collect())
proxycast_agent::session_store::list_sessions_sync(db)
}
/// 获取会话详情 - 使用 ProxyCast 数据库
/// 获取会话详情
pub fn get_session_sync(db: &DbConnection, session_id: &str) -> Result<SessionDetail, String> {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
let session = AgentDao::get_session(&conn, session_id)
.map_err(|e| format!("获取会话失败: {e}"))?
.ok_or_else(|| format!("会话不存在: {session_id}"))?;
let messages =
AgentDao::get_messages(&conn, session_id).map_err(|e| format!("获取消息失败: {e}"))?;
Ok(SessionDetail {
id: session.id,
name: session.title.unwrap_or_else(|| "未命名".to_string()),
created_at: chrono::DateTime::parse_from_rfc3339(&session.created_at)
.map(|dt| dt.timestamp())
.unwrap_or(0),
updated_at: chrono::DateTime::parse_from_rfc3339(&session.updated_at)
.map(|dt| dt.timestamp())
.unwrap_or(0),
messages: messages
.into_iter()
.map(|m| convert_agent_message(&m))
.collect(),
})
}
}
/// 会话信息(简化版)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SessionInfo {
pub id: String,
pub name: String,
pub created_at: i64,
pub updated_at: i64,
pub messages_count: usize,
}
/// 会话详情(包含消息)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SessionDetail {
pub id: String,
pub name: String,
pub created_at: i64,
pub updated_at: i64,
pub messages: Vec<crate::agent::event_converter::TauriMessage>,
}
/// 将 AgentMessage 转换为 TauriMessage
fn convert_agent_message(
msg: &crate::agent::types::AgentMessage,
) -> crate::agent::event_converter::TauriMessage {
use crate::agent::event_converter::{TauriMessage, TauriMessageContent};
use crate::agent::types::MessageContent;
let content = match &msg.content {
MessageContent::Text(text) => vec![TauriMessageContent::Text { text: text.clone() }],
MessageContent::Parts(parts) => parts
.iter()
.filter_map(|p| {
if let crate::agent::types::ContentPart::Text { text } = p {
Some(TauriMessageContent::Text { text: text.clone() })
} else {
None
}
})
.collect(),
};
// 解析时间戳
let timestamp = chrono::DateTime::parse_from_rfc3339(&msg.timestamp)
.map(|dt| dt.timestamp())
.unwrap_or(0);
TauriMessage {
id: None,
role: msg.role.clone(),
content,
timestamp,
proxycast_agent::session_store::get_session_sync(db, session_id)
}
}
+5 -751
View File
@@ -1,753 +1,7 @@
//! Aster Agent 状态管理
//! Aster Agent 状态管理(桥接层)
//!
//! 管理 Aster Agent 实例和相关状态
//! 提供 Tauri 应用与 Aster 框架的桥接
//! 支持从 ProxyCast 凭证池自动选择凭证
//!
//! ## 重要:SessionStore 注入
//!
//! 为了让 Aster Agent 的消息存储到 ProxyCast 数据库,必须在创建 Agent 时
//! 注入 `ProxyCastSessionStore`。使用 `init_agent_with_db()` 方法而不是 `init_agent()`。
//!
//! ## Agent 身份配置
//!
//! 通过 Aster 框架的 `AgentIdentity` API 设置 ProxyCast 专属的 Agent 身份,
//! 包括名称、语言偏好、产品描述等。这是架构层面的正确做法,
//! 而不是简单地追加提示词。
//!
//! ## Skills 集成
//!
//! Agent 初始化时会自动加载 `~/.proxycast/skills/` 目录下的 Skills 到
//! aster-rust 的 global_registry,使 AI 能够自动发现和调用这些 Skills。
//!
//! 参考文档:`docs/prd/chat-architecture-redesign.md`
//! 纯逻辑已迁移到 `proxycast-agent` crate,
//! 本模块保留兼容导出。
use aster::agents::{Agent, AgentIdentity, SessionConfig};
use aster::model::ModelConfig;
use aster::skills::{global_registry, load_skills_from_directory, SkillSource};
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use crate::agent::credential_bridge::{
create_aster_provider, AsterProviderConfig, CredentialBridge,
};
use crate::database::DbConnection;
use crate::services::aster_session_store::ProxyCastSessionStore;
use crate::services::project_context_builder::ProjectContextBuilder;
/// Provider 配置信息
#[derive(Debug, Clone)]
pub struct ProviderConfig {
/// Provider 名称 (openai, anthropic, google, ollama 等)
pub provider_name: String,
/// 模型名称
pub model_name: String,
/// API Key (可选,某些 provider 从环境变量读取)
pub api_key: Option<String>,
/// Base URL (可选,用于自定义端点)
pub base_url: Option<String>,
/// 凭证 UUID(来自凭证池,用于记录使用和健康状态)
pub credential_uuid: Option<String>,
}
/// Aster Agent 全局状态
///
/// 在 Tauri 应用中作为 managed state 使用
pub struct AsterAgentState {
/// Aster Agent 实例
agent: Arc<RwLock<Option<Agent>>>,
/// 当前活跃的取消令牌(用于中止正在进行的对话)
cancel_tokens: Arc<RwLock<std::collections::HashMap<String, CancellationToken>>>,
/// 当前 Provider 配置
current_provider_config: Arc<RwLock<Option<ProviderConfig>>>,
/// 凭证桥接器
credential_bridge: CredentialBridge,
}
impl Default for AsterAgentState {
fn default() -> Self {
Self::new()
}
}
impl AsterAgentState {
/// 创建新的 Aster Agent 状态
pub fn new() -> Self {
Self {
agent: Arc::new(RwLock::new(None)),
cancel_tokens: Arc::new(RwLock::new(std::collections::HashMap::new())),
current_provider_config: Arc::new(RwLock::new(None)),
credential_bridge: CredentialBridge::new(),
}
}
/// 初始化 Agent(带数据库连接)
///
/// 创建 Agent 并注入 ProxyCastSessionStore,确保消息存储到 ProxyCast 数据库。
/// 同时设置 ProxyCast 专属的 Agent 身份(名称、语言、描述)。
/// 自动加载 `~/.proxycast/skills/` 目录下的 Skills 到 aster-rust 的 global_registry。
///
/// **推荐使用此方法**而不是 `init_agent()`。
///
/// # 参数
/// - `db`: 数据库连接,用于创建 SessionStore
pub async fn init_agent_with_db(&self, db: &DbConnection) -> Result<(), String> {
let mut agent_guard = self.agent.write().await;
if agent_guard.is_none() {
// 创建 SessionStore
let session_store = Arc::new(ProxyCastSessionStore::new(db.clone()));
tracing::info!("[AsterAgent] 创建 ProxyCastSessionStore 成功");
// 创建 Agent 并注入 SessionStore
let agent = Agent::new().with_session_store(session_store);
// 验证 session_store 是否被正确设置
let has_store = agent.session_store().is_some();
tracing::info!(
"[AsterAgent] Agent 创建完成,session_store 已设置: {}",
has_store
);
// 使用异步方法设置 ProxyCast 专属身份
let identity = Self::create_proxycast_identity();
agent.set_identity(identity).await;
// 加载 ProxyCast Skills 到 aster-rust 的 global_registry
Self::load_proxycast_skills();
*agent_guard = Some(agent);
tracing::info!(
"[AsterAgent] Agent 初始化成功,已注入 ProxyCastSessionStore、ProxyCast 身份和 Skills"
);
} else {
tracing::debug!("[AsterAgent] Agent 已初始化,跳过");
}
Ok(())
}
/// 加载 ProxyCast Skills 到 aster-rust 的 global_registry
///
/// 从 `~/.proxycast/skills/` 目录加载 Skills,使 AI 能够自动发现和调用。
fn load_proxycast_skills() {
let home = match dirs::home_dir() {
Some(h) => h,
None => {
tracing::warn!("[AsterAgent] 无法获取 home 目录,跳过 Skills 加载");
return;
}
};
let skills_dir = home.join(".proxycast").join("skills");
if !skills_dir.exists() {
tracing::info!(
"[AsterAgent] ProxyCast Skills 目录不存在: {:?},跳过加载",
skills_dir
);
return;
}
// 从 ProxyCast skills 目录加载 Skills
let skills = load_skills_from_directory(&skills_dir, SkillSource::User);
let skill_count = skills.len();
if skill_count == 0 {
tracing::info!("[AsterAgent] ProxyCast Skills 目录为空,无 Skills 可加载");
return;
}
// 注册到 global_registry
let registry = global_registry();
if let Ok(mut registry_guard) = registry.write() {
for skill in skills {
let skill_name = skill.skill_name.clone();
registry_guard.register(skill);
tracing::debug!("[AsterAgent] 已注册 Skill: {}", skill_name);
}
tracing::info!(
"[AsterAgent] 成功加载 {} 个 ProxyCast Skills 到 global_registry",
skill_count
);
} else {
tracing::error!("[AsterAgent] 无法获取 global_registry 写锁,Skills 加载失败");
}
}
/// 重新加载 ProxyCast Skills
///
/// 当用户安装或卸载 Skills 后调用此方法刷新 registry。
pub fn reload_proxycast_skills() {
Self::load_proxycast_skills();
}
/// 创建 ProxyCast 专属的 Agent 身份配置
fn create_proxycast_identity() -> AgentIdentity {
AgentIdentity::new("ProxyCast 助手")
.with_language("Chinese")
.with_description(
"ProxyCast 是一个 AI 代理服务应用,帮助用户管理和使用各种 AI 模型的凭证。",
)
.with_custom_prompt(PROXYCAST_IDENTITY_PROMPT.to_string())
}
/// 初始化 Agent(无数据库版本)
///
/// **警告**:此方法创建的 Agent 不会将消息存储到 ProxyCast 数据库,
/// 消息会存储到 Aster 默认的 `~/.aster/sessions.db`。
///
/// 建议使用 `init_agent_with_db()` 代替。
#[deprecated(
since = "0.1.0",
note = "请使用 init_agent_with_db() 以确保消息存储到 ProxyCast 数据库"
)]
pub async fn init_agent(&self) -> Result<(), String> {
let mut agent_guard = self.agent.write().await;
if agent_guard.is_none() {
let agent = Agent::new();
*agent_guard = Some(agent);
tracing::warn!(
"[AsterAgent] Agent 初始化(无 SessionStore),消息将存储到 Aster 默认数据库"
);
}
Ok(())
}
/// 配置 Provider
///
/// 根据配置创建并设置 Provider
///
/// # 参数
/// - `config`: Provider 配置
/// - `session_id`: 会话 ID
/// - `db`: 数据库连接(用于初始化 Agent)
pub async fn configure_provider(
&self,
config: ProviderConfig,
session_id: &str,
db: &DbConnection,
) -> Result<(), String> {
// 确保 Agent 已初始化(使用带数据库的版本)
self.init_agent_with_db(db).await?;
// 设置环境变量(Aster 的 provider 从环境变量读取配置)
self.set_provider_env_vars(&config);
// 创建 ModelConfig
let model_config = ModelConfig::new(&config.model_name)
.map_err(|e| format!("创建 ModelConfig 失败: {e}"))?;
// 创建 Provider
let provider = aster::providers::create(&config.provider_name, model_config)
.await
.map_err(|e| format!("创建 Provider 失败: {e}"))?;
// 更新 Agent 的 Provider
let agent_guard = self.agent.read().await;
if let Some(agent) = agent_guard.as_ref() {
agent
.update_provider(provider, session_id)
.await
.map_err(|e| format!("更新 Provider 失败: {e}"))?;
}
// 保存当前配置
let mut config_guard = self.current_provider_config.write().await;
*config_guard = Some(config.clone());
tracing::info!(
"[AsterAgent] Provider 配置成功: {} / {}",
config.provider_name,
config.model_name
);
Ok(())
}
/// 从凭证池配置 Provider
///
/// 自动从 ProxyCast 凭证池选择可用凭证并配置 Aster Provider
///
/// # 参数
/// - `db`: 数据库连接
/// - `provider_type`: Provider 类型 (openai, anthropic, kiro 等)
/// - `model`: 模型名称
/// - `session_id`: 会话 ID
pub async fn configure_provider_from_pool(
&self,
db: &DbConnection,
provider_type: &str,
model: &str,
session_id: &str,
) -> Result<AsterProviderConfig, String> {
// 确保 Agent 已初始化(使用带数据库的版本)
self.init_agent_with_db(db).await?;
// 从凭证池选择凭证并获取配置
let aster_config = self
.credential_bridge
.select_and_configure(db, provider_type, model)
.await
.map_err(|e| format!("从凭证池选择凭证失败: {e}"))?;
// 创建 Provider
let provider = create_aster_provider(&aster_config)
.await
.map_err(|e| format!("创建 Provider 失败: {e}"))?;
// 更新 Agent 的 Provider
let agent_guard = self.agent.read().await;
if let Some(agent) = agent_guard.as_ref() {
agent
.update_provider(provider, session_id)
.await
.map_err(|e| format!("更新 Provider 失败: {e}"))?;
}
// 保存当前配置
let config = ProviderConfig {
provider_name: aster_config.provider_name.clone(),
model_name: aster_config.model_name.clone(),
api_key: aster_config.api_key.clone(),
base_url: aster_config.base_url.clone(),
credential_uuid: Some(aster_config.credential_uuid.clone()),
};
let mut config_guard = self.current_provider_config.write().await;
*config_guard = Some(config);
// 记录凭证使用
if let Err(e) = self
.credential_bridge
.record_usage(db, &aster_config.credential_uuid)
{
tracing::warn!("[AsterAgent] 记录凭证使用失败: {}", e);
}
tracing::info!(
"[AsterAgent] 从凭证池配置 Provider 成功: {} / {} (凭证: {})",
aster_config.provider_name,
aster_config.model_name,
aster_config.credential_uuid
);
Ok(aster_config)
}
/// 标记当前凭证为健康
pub fn mark_current_healthy(&self, db: &DbConnection, model: Option<&str>) {
if let Ok(config_guard) = self.current_provider_config.try_read() {
if let Some(config) = config_guard.as_ref() {
if let Some(uuid) = &config.credential_uuid {
if let Err(e) = self.credential_bridge.mark_healthy(db, uuid, model) {
tracing::warn!("[AsterAgent] 标记凭证健康失败: {}", e);
}
}
}
}
}
/// 标记当前凭证为不健康
pub fn mark_current_unhealthy(&self, db: &DbConnection, error: Option<&str>) {
if let Ok(config_guard) = self.current_provider_config.try_read() {
if let Some(config) = config_guard.as_ref() {
if let Some(uuid) = &config.credential_uuid {
if let Err(e) = self.credential_bridge.mark_unhealthy(db, uuid, error) {
tracing::warn!("[AsterAgent] 标记凭证不健康失败: {}", e);
}
}
}
}
}
/// 设置 Provider 相关的环境变量
fn set_provider_env_vars(&self, config: &ProviderConfig) {
tracing::info!(
"[AsterAgent] set_provider_env_vars: provider_name={}, model_name={}, has_api_key={}, base_url={:?}",
config.provider_name,
config.model_name,
config.api_key.is_some(),
config.base_url
);
// 根据 provider 类型设置对应的环境变量
let env_key = match config.provider_name.as_str() {
"openai" => "OPENAI_API_KEY",
"anthropic" => "ANTHROPIC_API_KEY",
"google" => "GOOGLE_API_KEY",
"deepseek" | "custom_deepseek" => "OPENAI_API_KEY", // DeepSeek 使用 OpenAI 兼容 API
"groq" => "OPENAI_API_KEY", // Groq 使用 OpenAI 兼容 API
"mistral" => "OPENAI_API_KEY", // Mistral 使用 OpenAI 兼容 API
"openrouter" => "OPENROUTER_API_KEY",
"ollama" => return, // Ollama 不需要 API Key
_ => {
tracing::warn!(
"[AsterAgent] 未知的 provider_name: {}, 使用通用 OpenAI 格式",
config.provider_name
);
// 通用 OpenAI 兼容格式
if let Some(api_key) = &config.api_key {
std::env::set_var("OPENAI_API_KEY", api_key);
}
if let Some(base_url) = &config.base_url {
std::env::set_var("OPENAI_BASE_URL", base_url);
}
return;
}
};
tracing::info!("[AsterAgent] 设置环境变量: {}=***", env_key);
if let Some(api_key) = &config.api_key {
std::env::set_var(env_key, api_key);
}
if let Some(base_url) = &config.base_url {
let base_url_key = format!(
"{}_BASE_URL",
config.provider_name.to_uppercase().replace("_", "")
);
std::env::set_var(base_url_key, base_url);
}
}
/// 获取当前 Provider 配置
pub async fn get_provider_config(&self) -> Option<ProviderConfig> {
self.current_provider_config.read().await.clone()
}
/// 清除当前 Provider 配置
///
/// 用于切换凭证后重置状态,下次对话时会重新从凭证池选择凭证
pub async fn clear_provider_config(&self) {
let mut config_guard = self.current_provider_config.write().await;
*config_guard = None;
tracing::info!("[AsterAgent] Provider 配置已清除");
}
/// 检查 Provider 是否已配置
pub async fn is_provider_configured(&self) -> bool {
self.current_provider_config.read().await.is_some()
}
/// 获取 Agent 的只读引用并执行同步操作
pub async fn with_agent<F, R>(&self, f: F) -> Result<R, String>
where
F: FnOnce(&Agent) -> R,
{
let guard = self.agent.read().await;
match guard.as_ref() {
Some(agent) => Ok(f(agent)),
None => Err("Agent not initialized".to_string()),
}
}
/// 获取 Agent 的可变引用并执行同步操作
pub async fn with_agent_mut<F, R>(&self, f: F) -> Result<R, String>
where
F: FnOnce(&mut Agent) -> R,
{
let mut guard = self.agent.write().await;
match guard.as_mut() {
Some(agent) => Ok(f(agent)),
None => Err("Agent not initialized".to_string()),
}
}
/// 获取 Agent 的 Arc 引用
///
/// 用于需要长期持有 Agent 引用的场景
pub fn get_agent_arc(&self) -> Arc<RwLock<Option<Agent>>> {
self.agent.clone()
}
/// 创建新的取消令牌
pub async fn create_cancel_token(&self, session_id: &str) -> CancellationToken {
let token = CancellationToken::new();
let mut tokens = self.cancel_tokens.write().await;
tokens.insert(session_id.to_string(), token.clone());
token
}
/// 取消指定会话的操作
pub async fn cancel_session(&self, session_id: &str) -> bool {
let tokens = self.cancel_tokens.read().await;
if let Some(token) = tokens.get(session_id) {
token.cancel();
true
} else {
false
}
}
/// 移除取消令牌
pub async fn remove_cancel_token(&self, session_id: &str) {
let mut tokens = self.cancel_tokens.write().await;
tokens.remove(session_id);
}
// ------------------------------------------------------------------------
// 项目上下文支持
// ------------------------------------------------------------------------
/// 构建带项目上下文的 System Prompt
///
/// 加载项目的人设、素材、模板配置,构建完整的 AI 提示词。
///
/// # 参数
/// - `db`: 数据库连接
/// - `project_id`: 项目 ID
///
/// # 返回
/// - 成功返回构建好的 System Prompt
/// - 失败返回错误信息
pub fn build_project_system_prompt(
db: &DbConnection,
project_id: &str,
) -> Result<String, String> {
let conn = db
.lock()
.map_err(|e| format!("获取数据库连接失败: {}", e))?;
let context = ProjectContextBuilder::build_context(&conn, project_id)
.map_err(|e| format!("构建项目上下文失败: {}", e))?;
Ok(ProjectContextBuilder::build_system_prompt(&context))
}
/// 创建带项目上下文的会话配置
///
/// 自动加载项目配置并构建 SessionConfig。
///
/// # 参数
/// - `db`: 数据库连接
/// - `session_id`: 会话 ID
/// - `project_id`: 项目 ID(可选,如果为 None 则不注入项目上下文)
///
/// # 返回
/// - 构建好的 SessionConfig
pub fn create_session_config_with_project(
db: &DbConnection,
session_id: &str,
project_id: Option<&str>,
) -> SessionConfig {
let system_prompt =
project_id.and_then(|pid| Self::build_project_system_prompt(db, pid).ok());
SessionConfigBuilder::new(session_id)
.system_prompt(system_prompt.unwrap_or_default())
.build()
}
/// 检查 Agent 是否已初始化
pub async fn is_initialized(&self) -> bool {
self.agent.read().await.is_some()
}
}
/// 会话配置构建器
///
/// 用于构建 Aster SessionConfig
pub struct SessionConfigBuilder {
id: String,
max_turns: Option<u32>,
system_prompt: Option<String>,
}
impl SessionConfigBuilder {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
max_turns: None,
system_prompt: None,
}
}
pub fn max_turns(mut self, turns: u32) -> Self {
self.max_turns = Some(turns);
self
}
pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
self.system_prompt = Some(prompt.into());
self
}
pub fn build(self) -> SessionConfig {
SessionConfig {
id: self.id,
schedule_id: None,
max_turns: self.max_turns,
retry_config: None,
system_prompt: self.system_prompt,
}
}
}
/// 消息构建辅助函数
pub mod message_helpers {
use aster::conversation::message::Message;
/// 创建用户文本消息
pub fn user_text(text: impl Into<String>) -> Message {
Message::user().with_text(text)
}
/// 创建助手文本消息
pub fn assistant_text(text: impl Into<String>) -> Message {
Message::assistant().with_text(text)
}
}
// =============================================================================
// ProxyCast Agent 身份提示词
// =============================================================================
/// ProxyCast 专属的 Agent 身份提示词
///
/// 这是完整的身份定义,会替换 Aster 框架默认的 "aster by Block" 身份。
/// 框架的能力描述(Extensions、Response Guidelines)会自动追加。
const PROXYCAST_IDENTITY_PROMPT: &str = r#"你是 ProxyCast 助手,一个专业、友好的 AI 技术伙伴。
## 关于 ProxyCast
ProxyCast 是一个 AI 代理服务应用,帮助用户:
- 管理多个 AI 模型提供商的凭证(OpenAI、Claude、Gemini、Kiro 等)
- 通过统一的 API 接口访问不同的 AI 模型
- 实现凭证池的负载均衡和健康检查
## 语言规范
1. **始终使用中文回复**:除非用户明确要求使用其他语言
2. **代码注释使用中文**:生成代码时,注释应使用中文
3. **技术术语保持原文**:API、JSON、HTTP、Token 等专业术语保持英文
## 交互风格
- 简洁专业,直接给出解决方案
- 友好但不啰嗦,像经验丰富的技术伙伴
- 遇到问题时,先分析原因再提供方案
"#;
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[tokio::test]
async fn test_aster_state_init() {
let state = AsterAgentState::new();
assert!(!state.is_initialized().await);
#[allow(deprecated)]
state.init_agent().await.unwrap();
assert!(state.is_initialized().await);
}
#[tokio::test]
async fn test_cancel_token() {
let state = AsterAgentState::new();
let session_id = "test-session";
let token = state.create_cancel_token(session_id).await;
assert!(!token.is_cancelled());
assert!(state.cancel_session(session_id).await);
assert!(token.is_cancelled());
state.remove_cancel_token(session_id).await;
assert!(!state.cancel_session(session_id).await);
}
// =========================================================================
// Skills 集成测试
// =========================================================================
/// 测试辅助函数:创建测试用的 Skill 目录
fn create_test_skill(skills_dir: &std::path::Path, skill_name: &str, description: &str) {
let skill_path = skills_dir.join(skill_name);
fs::create_dir_all(&skill_path).unwrap();
let skill_md = format!(
r#"---
name: {}
description: {}
---
# {}
这是一个测试 Skill。
"#,
skill_name, description, skill_name
);
fs::write(skill_path.join("SKILL.md"), skill_md).unwrap();
}
/// 测试:load_skills_from_directory 能正确加载 Skills
#[test]
fn test_load_skills_from_directory() {
let temp_dir = TempDir::new().unwrap();
let skills_dir = temp_dir.path();
// 创建测试 Skills
create_test_skill(skills_dir, "test-skill-1", "第一个测试技能");
create_test_skill(skills_dir, "test-skill-2", "第二个测试技能");
// 加载 Skills
let skills = load_skills_from_directory(skills_dir, SkillSource::User);
// 验证
assert_eq!(skills.len(), 2);
let names: Vec<_> = skills.iter().map(|s| s.display_name.as_str()).collect();
assert!(names.contains(&"test-skill-1"));
assert!(names.contains(&"test-skill-2"));
}
/// 测试:空目录返回空列表
#[test]
fn test_load_skills_empty_directory() {
let temp_dir = TempDir::new().unwrap();
let skills = load_skills_from_directory(temp_dir.path(), SkillSource::User);
assert!(skills.is_empty());
}
/// 测试:不存在的目录返回空列表
#[test]
fn test_load_skills_nonexistent_directory() {
let nonexistent = std::path::Path::new("/nonexistent/path/to/skills");
let skills = load_skills_from_directory(nonexistent, SkillSource::User);
assert!(skills.is_empty());
}
/// 测试:global_registry 能正确注册和查找 Skills
#[test]
fn test_global_registry_register_and_find() {
let temp_dir = TempDir::new().unwrap();
let skills_dir = temp_dir.path();
// 创建测试 Skill
create_test_skill(skills_dir, "registry-test-skill", "注册表测试技能");
// 加载并注册到 global_registry
let skills = load_skills_from_directory(skills_dir, SkillSource::User);
let registry = global_registry();
if let Ok(mut registry_guard) = registry.write() {
for skill in skills {
registry_guard.register(skill);
}
}
// 验证能找到注册的 Skill
if let Ok(registry_guard) = registry.read() {
let found = registry_guard.find("registry-test-skill");
assert!(found.is_some());
assert_eq!(found.unwrap().display_name, "registry-test-skill");
}
}
/// 测试:reload_proxycast_skills 不会 panic(即使目录不存在)
#[test]
fn test_reload_proxycast_skills_no_panic() {
// 这个测试确保 reload_proxycast_skills 在各种情况下都不会 panic
// 即使 ~/.proxycast/skills/ 目录不存在
AsterAgentState::reload_proxycast_skills();
// 如果没有 panic,测试通过
}
}
pub use proxycast_agent::aster_state::{AsterAgentState, ProviderConfig};
pub use proxycast_agent::aster_state_support::{message_helpers, SessionConfigBuilder};
+4 -559
View File
@@ -1,561 +1,6 @@
//! 凭证池桥接模块
//! 凭证池桥接模块(重导出层)
//!
//! 将 ProxyCast 凭证池与 Aster Provider 系统连接
//! 支持从凭证池自动选择凭证并配置 Aster Provider
//!
//! ## 功能
//! - 从凭证池选择可用凭证
//! - 将凭证转换为 Aster Provider 配置
//! - 支持 OAuth 和 API Key 两种凭证类型
//! - 自动刷新过期的 OAuth Token
//! - 智能拆分 base_url 为 host + path,避免路径重复(如智谱 /v4/v1 问题)
//! 纯逻辑已迁移到 `proxycast-agent` crate,
//! 本模块仅保留兼容导出。
use crate::database::DbConnection;
use crate::models::provider_pool_model::{CredentialData, PoolProviderType, ProviderCredential};
use crate::services::api_key_provider_service::ApiKeyProviderService;
use crate::services::provider_pool_service::ProviderPoolService;
use aster::model::ModelConfig;
use aster::providers::base::Provider;
use std::sync::Arc;
/// 凭证桥接错误
#[derive(Debug, Clone)]
pub enum CredentialBridgeError {
/// 没有可用凭证
NoCredentials(String),
/// 凭证类型不支持
UnsupportedCredentialType(String),
/// Provider 创建失败
ProviderCreationFailed(String),
/// Token 刷新失败
TokenRefreshFailed(String),
/// 数据库错误
DatabaseError(String),
}
impl std::fmt::Display for CredentialBridgeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoCredentials(msg) => write!(f, "没有可用凭证: {msg}"),
Self::UnsupportedCredentialType(msg) => write!(f, "不支持的凭证类型: {msg}"),
Self::ProviderCreationFailed(msg) => write!(f, "Provider 创建失败: {msg}"),
Self::TokenRefreshFailed(msg) => write!(f, "Token 刷新失败: {msg}"),
Self::DatabaseError(msg) => write!(f, "数据库错误: {msg}"),
}
}
}
impl std::error::Error for CredentialBridgeError {}
/// Aster Provider 配置
#[derive(Debug, Clone)]
pub struct AsterProviderConfig {
/// Provider 名称 (openai, anthropic, google 等)
pub provider_name: String,
/// 模型名称
pub model_name: String,
/// API Key
pub api_key: Option<String>,
/// Base URL
pub base_url: Option<String>,
/// 凭证 UUID(用于记录使用和健康状态)
pub credential_uuid: String,
}
/// 凭证池桥接器
///
/// 负责从 ProxyCast 凭证池选择凭证并转换为 Aster Provider 配置
pub struct CredentialBridge {
pool_service: ProviderPoolService,
api_key_service: ApiKeyProviderService,
}
impl Default for CredentialBridge {
fn default() -> Self {
Self::new()
}
}
impl CredentialBridge {
pub fn new() -> Self {
Self {
pool_service: ProviderPoolService::new(),
api_key_service: ApiKeyProviderService::new(),
}
}
/// 从凭证池选择凭证并创建 Aster Provider 配置
///
/// # 参数
/// - `db`: 数据库连接
/// - `provider_type`: Provider 类型 (openai, anthropic, kiro, deepseek 等)
/// - `model`: 模型名称
///
/// # 返回
/// 成功时返回 AsterProviderConfig,失败时返回错误
pub async fn select_and_configure(
&self,
db: &DbConnection,
provider_type: &str,
model: &str,
) -> Result<AsterProviderConfig, CredentialBridgeError> {
// 1. 从凭证池选择凭证
// 将 provider_type 同时作为 provider_id_hint 传递,支持 60+ API Key Provider
// 例如 "deepseek", "moonshot", "qwen" 等
let credential = self
.pool_service
.select_credential_with_fallback(
db,
&self.api_key_service,
provider_type,
Some(model),
Some(provider_type), // 传递 provider_id_hint 支持智能降级
None,
)
.await
.map_err(CredentialBridgeError::DatabaseError)?
.ok_or_else(|| {
CredentialBridgeError::NoCredentials(format!(
"没有找到 {provider_type} 类型的可用凭证"
))
})?;
// 2. 转换为 Aster Provider 配置,传递 provider_type 以便正确识别 Provider
self.credential_to_config(&credential, model, provider_type, db)
.await
}
/// 将 ProxyCast 凭证转换为 Aster Provider 配置
async fn credential_to_config(
&self,
credential: &ProviderCredential,
model: &str,
provider_type_hint: &str,
db: &DbConnection,
) -> Result<AsterProviderConfig, CredentialBridgeError> {
tracing::info!(
"[CredentialBridge] credential_to_config: provider_type_hint={}, credential_type={:?}",
provider_type_hint,
credential.provider_type
);
let (provider_name, api_key, base_url) = match &credential.credential {
// OpenAI API Key - 根据 provider_type_hint 确定实际的 Provider
CredentialData::OpenAIKey { api_key, base_url } => {
// 使用 provider_type_hint 来确定 aster provider 名称
let provider = map_provider_type_to_aster(provider_type_hint);
tracing::info!(
"[CredentialBridge] OpenAIKey: provider_type_hint={} -> aster_provider={}",
provider_type_hint,
provider
);
(
provider.to_string(),
Some(api_key.clone()),
base_url.clone(),
)
}
// Claude/Anthropic API Key
CredentialData::ClaudeKey { api_key, base_url }
| CredentialData::AnthropicKey { api_key, base_url } => (
"anthropic".to_string(),
Some(api_key.clone()),
base_url.clone(),
),
// Kiro OAuth - 需要获取 access_token
CredentialData::KiroOAuth { creds_file_path } => {
let token = self
.get_kiro_token(creds_file_path, db, &credential.uuid)
.await?;
// Kiro 使用 CodeWhisperer API,映射到 bedrock provider
("bedrock".to_string(), Some(token), None)
}
// Gemini OAuth
CredentialData::GeminiOAuth {
creds_file_path, ..
} => {
let token = self.get_oauth_token(creds_file_path).await?;
("google".to_string(), Some(token), None)
}
// Gemini API Key
CredentialData::GeminiApiKey {
api_key, base_url, ..
} => (
"google".to_string(),
Some(api_key.clone()),
base_url.clone(),
),
// Vertex AI
CredentialData::VertexKey {
api_key, base_url, ..
} => (
"gcpvertexai".to_string(),
Some(api_key.clone()),
base_url.clone(),
),
// Codex OAuth
CredentialData::CodexOAuth {
creds_file_path,
api_base_url,
} => {
let token = self.get_codex_token(creds_file_path).await?;
("codex".to_string(), Some(token), api_base_url.clone())
}
// Claude OAuth
CredentialData::ClaudeOAuth { creds_file_path } => {
let token = self.get_oauth_token(creds_file_path).await?;
("anthropic".to_string(), Some(token), None)
}
// Antigravity OAuth
CredentialData::AntigravityOAuth {
creds_file_path, ..
} => {
let token = self.get_oauth_token(creds_file_path).await?;
("google".to_string(), Some(token), None)
}
};
Ok(AsterProviderConfig {
provider_name,
model_name: model.to_string(),
api_key,
base_url,
credential_uuid: credential.uuid.clone(),
})
}
/// 获取 Kiro OAuth Token
async fn get_kiro_token(
&self,
creds_path: &str,
_db: &DbConnection,
_uuid: &str,
) -> Result<String, CredentialBridgeError> {
use crate::providers::kiro::KiroProvider;
let mut provider = KiroProvider::new();
provider
.load_credentials_from_path(creds_path)
.await
.map_err(|e| {
CredentialBridgeError::TokenRefreshFailed(format!("加载 Kiro 凭证失败: {e}"))
})?;
// 检查 token 是否过期,如果过期则刷新
if provider.is_token_expired() {
tracing::info!("[CredentialBridge] Kiro token 已过期,尝试刷新");
self.pool_service
.refresh_kiro_token(creds_path)
.await
.map_err(CredentialBridgeError::TokenRefreshFailed)?;
// 重新加载凭证
provider
.load_credentials_from_path(creds_path)
.await
.map_err(|e| {
CredentialBridgeError::TokenRefreshFailed(format!("重新加载凭证失败: {e}"))
})?;
}
provider.credentials.access_token.ok_or_else(|| {
CredentialBridgeError::TokenRefreshFailed("缺少 access_token".to_string())
})
}
/// 获取通用 OAuth Token
async fn get_oauth_token(&self, creds_path: &str) -> Result<String, CredentialBridgeError> {
let content = std::fs::read_to_string(creds_path).map_err(|e| {
CredentialBridgeError::TokenRefreshFailed(format!("读取凭证文件失败: {e}"))
})?;
let creds: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| CredentialBridgeError::TokenRefreshFailed(format!("解析凭证失败: {e}")))?;
creds["access_token"]
.as_str()
.map(String::from)
.ok_or_else(|| {
CredentialBridgeError::TokenRefreshFailed("凭证中缺少 access_token".to_string())
})
}
/// 获取 Codex OAuth Token
async fn get_codex_token(&self, creds_path: &str) -> Result<String, CredentialBridgeError> {
use crate::providers::codex::CodexProvider;
let mut provider = CodexProvider::new();
provider
.load_credentials_from_path(creds_path)
.await
.map_err(|e| {
CredentialBridgeError::TokenRefreshFailed(format!("加载 Codex 凭证失败: {e}"))
})?;
provider.ensure_valid_token().await.map_err(|e| {
CredentialBridgeError::TokenRefreshFailed(format!("获取 Codex token 失败: {e}"))
})
}
/// 记录凭证使用
pub fn record_usage(&self, db: &DbConnection, uuid: &str) -> Result<(), CredentialBridgeError> {
self.pool_service
.record_usage(db, uuid)
.map_err(CredentialBridgeError::DatabaseError)
}
/// 标记凭证为健康
pub fn mark_healthy(
&self,
db: &DbConnection,
uuid: &str,
model: Option<&str>,
) -> Result<(), CredentialBridgeError> {
self.pool_service
.mark_healthy(db, uuid, model)
.map_err(CredentialBridgeError::DatabaseError)
}
/// 标记凭证为不健康
pub fn mark_unhealthy(
&self,
db: &DbConnection,
uuid: &str,
error: Option<&str>,
) -> Result<(), CredentialBridgeError> {
self.pool_service
.mark_unhealthy(db, uuid, error)
.map_err(CredentialBridgeError::DatabaseError)
}
}
/// 从 AsterProviderConfig 创建 Aster Provider
///
/// 设置环境变量并调用 aster::providers::create
pub async fn create_aster_provider(
config: &AsterProviderConfig,
) -> Result<Arc<dyn Provider>, CredentialBridgeError> {
// 设置环境变量
set_provider_env_vars(config);
// 创建 ModelConfig
let model_config = ModelConfig::new(&config.model_name).map_err(|e| {
CredentialBridgeError::ProviderCreationFailed(format!("创建 ModelConfig 失败: {e}"))
})?;
// 创建 Provider
aster::providers::create(&config.provider_name, model_config)
.await
.map_err(|e| {
CredentialBridgeError::ProviderCreationFailed(format!("创建 Provider 失败: {e}"))
})
}
/// 设置 Provider 环境变量
/// 从 URL 中拆分 host(scheme+authority)和 path 部分
///
/// 例如:
/// - `https://api.openai.com` -> (`https://api.openai.com`, ``)
/// - `https://open.bigmodel.cn/api/paas/v4` -> (`https://open.bigmodel.cn`, `api/paas/v4`)
/// - `https://localhost:8080/v1` -> (`https://localhost:8080`, `v1`)
fn split_url_host_and_path(url: &str) -> (String, String) {
// 找到 scheme 之后的 authority 部分
let after_scheme = if let Some(pos) = url.find("://") {
pos + 3
} else {
return (url.to_string(), String::new());
};
// 找到 authority 之后的第一个 /(即路径开始)
let path_start = url[after_scheme..].find('/').map(|p| p + after_scheme);
match path_start {
Some(pos) => {
let host = url[..pos].to_string();
let path = url[pos..].trim_matches('/').to_string();
(host, path)
}
None => (url.to_string(), String::new()),
}
}
fn set_provider_env_vars(config: &AsterProviderConfig) {
tracing::info!(
"[CredentialBridge] set_provider_env_vars: provider_name={}, has_api_key={}, base_url={:?}",
config.provider_name,
config.api_key.is_some(),
config.base_url
);
let env_key = match config.provider_name.as_str() {
"openai" => "OPENAI_API_KEY",
"anthropic" => "ANTHROPIC_API_KEY",
"google" => "GOOGLE_API_KEY",
"bedrock" => "AWS_ACCESS_KEY_ID", // Bedrock 使用 AWS 凭证
"gcpvertexai" => "GOOGLE_API_KEY",
"codex" => "OPENAI_API_KEY", // Codex 兼容 OpenAI
"deepseek" | "custom_deepseek" => "OPENAI_API_KEY", // DeepSeek 使用 OpenAI 兼容 API
"groq" => "OPENAI_API_KEY", // Groq 使用 OpenAI 兼容 API
"mistral" => "OPENAI_API_KEY", // Mistral 使用 OpenAI 兼容 API
"openrouter" => "OPENROUTER_API_KEY",
_ => "OPENAI_API_KEY", // 默认使用 OpenAI 格式
};
tracing::info!("[CredentialBridge] 设置环境变量: {}=***", env_key);
if let Some(api_key) = &config.api_key {
std::env::set_var(env_key, api_key);
}
// 设置 base_url
// Aster 的 OpenAI Provider 使用 OPENAI_HOST(仅 scheme+host+port)和
// OPENAI_BASE_PATH(路径部分 + /chat/completions)环境变量
if let Some(base_url) = &config.base_url {
match config.provider_name.as_str() {
"openai" => {
// 解析 base_url,将路径部分拆分到 OPENAI_BASE_PATH
// 例如 https://open.bigmodel.cn/api/paas/v4
// -> OPENAI_HOST = https://open.bigmodel.cn
// -> OPENAI_BASE_PATH = api/paas/v4/chat/completions
let (host_part, path_part) = split_url_host_and_path(base_url);
if path_part.is_empty() {
// 无路径部分(如 https://api.openai.com),直接设置
std::env::set_var("OPENAI_HOST", base_url);
// 清除可能残留的 OPENAI_BASE_PATH,使用 Aster 默认值
std::env::remove_var("OPENAI_BASE_PATH");
tracing::info!("[CredentialBridge] 设置 OPENAI_HOST={}", base_url);
} else {
// base_url 包含路径,需要拆分
let base_path = format!("{}/chat/completions", path_part);
std::env::set_var("OPENAI_HOST", &host_part);
std::env::set_var("OPENAI_BASE_PATH", &base_path);
tracing::info!(
"[CredentialBridge] 设置 OPENAI_HOST={}, OPENAI_BASE_PATH={}",
host_part,
base_path
);
}
}
"anthropic" => {
std::env::set_var("ANTHROPIC_BASE_URL", base_url);
}
_ => {
// 其他 Provider 使用通用格式
let base_url_key = format!(
"{}_BASE_URL",
config.provider_name.to_uppercase().replace('-', "_")
);
std::env::set_var(&base_url_key, base_url);
}
}
}
}
/// Provider 类型映射
///
/// 将 ProxyCast PoolProviderType 映射到 Aster Provider 名称
pub fn map_pool_type_to_aster(pool_type: &PoolProviderType) -> &'static str {
match pool_type {
PoolProviderType::Kiro => "bedrock",
PoolProviderType::Gemini => "google",
PoolProviderType::Antigravity => "google",
PoolProviderType::OpenAI => "openai",
PoolProviderType::Claude => "anthropic",
PoolProviderType::Anthropic => "anthropic",
PoolProviderType::AnthropicCompatible => "anthropic",
PoolProviderType::Vertex => "gcpvertexai",
PoolProviderType::GeminiApiKey => "google",
PoolProviderType::Codex => "codex",
PoolProviderType::ClaudeOAuth => "anthropic",
PoolProviderType::AzureOpenai => "azure",
PoolProviderType::AwsBedrock => "bedrock",
PoolProviderType::Ollama => "ollama",
}
}
/// 将 provider_type 字符串映射到 Aster Provider 名称
///
/// 支持 60+ API Key Provider,包括 deepseek, moonshot, qwen 等
fn map_provider_type_to_aster(provider_type: &str) -> &'static str {
match provider_type {
// 标准 Provider
"openai" => "openai",
"anthropic" | "claude" => "anthropic",
"google" | "gemini" => "google",
"bedrock" | "kiro" => "bedrock",
"gcpvertexai" | "vertex" => "gcpvertexai",
"codex" => "codex",
"azure" | "azure-openai" => "azure",
"ollama" => "ollama",
// DeepSeek - 使用 openai 兼容 provider(Aster 会通过 alias 映射)
"deepseek" | "custom_deepseek" => "openai",
// 其他 OpenAI 兼容 Provider - 使用 openai provider
// 这些 Provider 都使用 OpenAI 兼容 API,通过 base_url 区分
"groq" => "openai",
"mistral" => "openai",
"openrouter" => "openrouter",
// 默认使用 openai(OpenAI 兼容格式)
_ => "openai",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_map_pool_type_to_aster() {
assert_eq!(map_pool_type_to_aster(&PoolProviderType::OpenAI), "openai");
assert_eq!(
map_pool_type_to_aster(&PoolProviderType::Claude),
"anthropic"
);
assert_eq!(map_pool_type_to_aster(&PoolProviderType::Gemini), "google");
assert_eq!(map_pool_type_to_aster(&PoolProviderType::Kiro), "bedrock");
}
#[test]
fn test_credential_bridge_error_display() {
let err = CredentialBridgeError::NoCredentials("test".to_string());
assert!(err.to_string().contains("没有可用凭证"));
}
#[test]
fn test_split_url_host_and_path() {
// 无路径
let (host, path) = split_url_host_and_path("https://api.openai.com");
assert_eq!(host, "https://api.openai.com");
assert_eq!(path, "");
// 带路径(智谱)
let (host, path) = split_url_host_and_path("https://open.bigmodel.cn/api/paas/v4");
assert_eq!(host, "https://open.bigmodel.cn");
assert_eq!(path, "api/paas/v4");
// 带端口
let (host, path) = split_url_host_and_path("https://localhost:8080/v1");
assert_eq!(host, "https://localhost:8080");
assert_eq!(path, "v1");
// 尾部斜杠
let (host, path) = split_url_host_and_path("https://api.deepseek.com/v1/");
assert_eq!(host, "https://api.deepseek.com");
assert_eq!(path, "v1");
// 仅根路径
let (host, path) = split_url_host_and_path("https://api.openai.com/");
assert_eq!(host, "https://api.openai.com");
assert_eq!(path, "");
}
}
pub use proxycast_agent::credential_bridge::*;
+2 -2
View File
@@ -1,7 +1,7 @@
//! AI Agent 集成模块
//!
//! 纯逻辑部分已迁移到 proxycast-agent crate,
//! 本模块保留深耦合部分(依赖 database, services, AppHandle)。
//! 本模块保留深耦合部分(Aster 状态与 Tauri 桥接)。
pub mod aster_agent;
pub mod aster_state;
@@ -15,6 +15,7 @@ pub use proxycast_agent::prompt;
// types 已迁移到 proxycast-core
pub use proxycast_core::agent::types;
pub use proxycast_core::agent::types::*;
pub use aster_agent::{AsterAgentWrapper, SessionDetail, SessionInfo};
pub use aster_state::AsterAgentState;
@@ -25,4 +26,3 @@ pub use proxycast_agent::{convert_agent_event, convert_to_tauri_message, TauriAg
pub use subagent_scheduler::{
ProxyCastScheduler, ProxyCastSubAgentExecutor, SubAgentProgressEvent,
};
pub use types::*;
+22 -234
View File
@@ -1,190 +1,26 @@
//! SubAgent 调度器集成
//! SubAgent 调度器集成(Tauri 桥接层)
//!
//! 将 aster-rust 的 SubAgent 调度器与 ProxyCast 凭证池集成
//!
//! ## 功能
//! - 自动从凭证池选择健康凭证
//! - 支持凭证 fallback 策略
//! - 集成 Tauri 事件系统进行进度通知
//! 纯逻辑已迁移到 `proxycast-agent` crate,
//! 本模块负责 Tauri 事件桥接。
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use aster::agents::context::AgentContext;
use aster::agents::subagent_scheduler::{
SchedulerConfig, SchedulerError, SchedulerExecutionResult, SchedulerResult, SubAgentExecutor,
SubAgentResult, SubAgentScheduler, SubAgentTask, TokenUsage as SchedulerTokenUsage,
SchedulerConfig, SchedulerExecutionResult, SchedulerResult, SubAgentTask,
};
use aster::conversation::message::Message;
use chrono::Utc;
use tauri::{AppHandle, Emitter};
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use crate::agent::credential_bridge::{
create_aster_provider, AsterProviderConfig, CredentialBridge,
};
use crate::database::DbConnection;
/// ProxyCast SubAgent 执行器
///
/// 实现 aster-rust 的 SubAgentExecutor trait,
/// 集成 ProxyCast 凭证池进行 LLM 调用
pub struct ProxyCastSubAgentExecutor {
/// 凭证桥接器
credential_bridge: CredentialBridge,
/// 数据库连接
db: DbConnection,
/// 默认模型
default_model: String,
/// 默认 Provider 类型
default_provider: String,
/// Tauri AppHandle(用于事件通知)
app_handle: Option<AppHandle>,
}
pub use proxycast_agent::subagent_scheduler::{
ProxyCastSubAgentExecutor, SchedulerEventEmitter, SubAgentProgressEvent,
};
impl ProxyCastSubAgentExecutor {
/// 创建新的执行器
pub fn new(db: DbConnection) -> Self {
Self {
credential_bridge: CredentialBridge::new(),
db,
default_model: "claude-sonnet-4-20250514".to_string(),
default_provider: "anthropic".to_string(),
app_handle: None,
}
}
/// 设置 Tauri AppHandle
pub fn with_app_handle(mut self, handle: AppHandle) -> Self {
self.app_handle = Some(handle);
self
}
/// 设置默认模型
pub fn with_default_model(mut self, model: impl Into<String>) -> Self {
self.default_model = model.into();
self
}
/// 设置默认 Provider
pub fn with_default_provider(mut self, provider: impl Into<String>) -> Self {
self.default_provider = provider.into();
self
}
/// 从凭证池选择凭证
async fn select_credential(&self, task: &SubAgentTask) -> SchedulerResult<AsterProviderConfig> {
// 根据任务类型和模型选择 provider
let model = task.model.as_deref().unwrap_or(&self.default_model);
let provider_type = &self.default_provider;
// 使用 CredentialBridge 选择凭证
let config = self
.credential_bridge
.select_and_configure(&self.db, provider_type, model)
.await
.map_err(|e| SchedulerError::ProviderError(e.to_string()))?;
Ok(config)
}
/// 发送 Tauri 事件
#[allow(dead_code)]
fn emit_event(&self, event_name: &str, payload: impl serde::Serialize + Clone) {
if let Some(handle) = &self.app_handle {
if let Err(e) = handle.emit(event_name, payload) {
warn!("发送 Tauri 事件失败: {}", e);
}
}
}
}
#[async_trait::async_trait]
impl SubAgentExecutor for ProxyCastSubAgentExecutor {
async fn execute_task(
&self,
task: &SubAgentTask,
context: &AgentContext,
) -> SchedulerResult<SubAgentResult> {
let start_time = Utc::now();
info!("执行 SubAgent 任务: {}", task.id);
// 选择凭证
let provider_config = self.select_credential(task).await?;
debug!("使用凭证: {}", provider_config.credential_uuid);
// 创建 provider
let provider = create_aster_provider(&provider_config)
.await
.map_err(|e| SchedulerError::ProviderError(e.to_string()))?;
// 构建提示
let system_prompt = context.system_prompt.clone().unwrap_or_default();
let user_message = Message::user().with_text(&task.prompt);
// 调用 LLM(使用 complete 方法)
let (response_msg, usage) = provider
.complete(&system_prompt, &[user_message], &[])
.await
.map_err(|e| SchedulerError::ProviderError(e.to_string()))?;
let response = response_msg.as_concat_text();
let end_time = Utc::now();
let duration = (end_time - start_time).to_std().unwrap_or(Duration::ZERO);
// 生成摘要
let summary = if task.return_summary {
Some(self.generate_summary(&response, task))
} else {
None
};
// 转换 token 使用
let token_usage = Some(SchedulerTokenUsage {
input_tokens: usage.usage.input_tokens.unwrap_or(0) as usize,
output_tokens: usage.usage.output_tokens.unwrap_or(0) as usize,
total_tokens: usage.usage.total_tokens.unwrap_or(0) as usize,
});
Ok(SubAgentResult {
task_id: task.id.clone(),
success: true,
output: Some(response),
summary,
error: None,
duration,
retries: 0,
started_at: start_time,
completed_at: end_time,
token_usage,
metadata: HashMap::new(),
})
}
}
impl ProxyCastSubAgentExecutor {
/// 生成摘要
fn generate_summary(&self, output: &str, task: &SubAgentTask) -> String {
// 简单摘要:取前 500 字符
let max_len = 500;
if output.chars().count() <= max_len {
format!("任务 {} 完成:\n{}", task.id, output)
} else {
let truncated: String = output.chars().take(max_len - 3).collect();
format!("任务 {} 完成:\n{}...", task.id, truncated)
}
}
}
/// ProxyCast SubAgent 调度器包装器
/// ProxyCast SubAgent 调度器(Tauri 桥接)
pub struct ProxyCastScheduler {
/// 内部调度器
scheduler: Arc<RwLock<Option<SubAgentScheduler<ProxyCastSubAgentExecutor>>>>,
/// 数据库连接
db: DbConnection,
/// 内部纯逻辑调度器
inner: proxycast_agent::subagent_scheduler::ProxyCastScheduler,
/// Tauri AppHandle
app_handle: Option<AppHandle>,
}
@@ -193,8 +29,7 @@ impl ProxyCastScheduler {
/// 创建新的调度器
pub fn new(db: DbConnection) -> Self {
Self {
scheduler: Arc::new(RwLock::new(None)),
db,
inner: proxycast_agent::subagent_scheduler::ProxyCastScheduler::new(db),
app_handle: None,
}
}
@@ -207,26 +42,17 @@ impl ProxyCastScheduler {
/// 初始化调度器
pub async fn init(&self, config: Option<SchedulerConfig>) {
let executor = ProxyCastSubAgentExecutor::new(self.db.clone());
let executor = if let Some(handle) = &self.app_handle {
executor.with_app_handle(handle.clone())
} else {
executor
};
let config = config.unwrap_or_default();
// 创建调度器并设置事件回调
let app_handle = self.app_handle.clone();
let scheduler =
SubAgentScheduler::new(config, executor).with_event_callback(move |event| {
if let Some(handle) = &app_handle {
let _ = handle.emit("subagent-scheduler-event", &event);
let event_emitter = self.app_handle.clone().map(|handle| {
Arc::new(move |event: &serde_json::Value| {
if let Err(err) = handle.emit("subagent-scheduler-event", event) {
tracing::warn!("发送 Tauri 事件失败: {}", err);
}
});
}) as SchedulerEventEmitter
});
*self.scheduler.write().await = Some(scheduler);
info!("ProxyCast SubAgent 调度器初始化完成");
self.inner
.init_with_event_emitter(config, event_emitter)
.await;
}
/// 执行任务
@@ -235,49 +61,11 @@ impl ProxyCastScheduler {
tasks: Vec<SubAgentTask>,
parent_context: Option<&AgentContext>,
) -> SchedulerResult<SchedulerExecutionResult> {
let scheduler = self.scheduler.read().await;
let scheduler = scheduler
.as_ref()
.ok_or_else(|| SchedulerError::ContextError("调度器未初始化".to_string()))?;
scheduler.execute(tasks, parent_context).await
self.inner.execute(tasks, parent_context).await
}
/// 取消执行
pub async fn cancel(&self) {
if let Some(scheduler) = self.scheduler.read().await.as_ref() {
scheduler.cancel().await;
}
}
}
/// Tauri 事件:SubAgent 进度
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubAgentProgressEvent {
/// 总任务数
pub total: usize,
/// 已完成数
pub completed: usize,
/// 失败数
pub failed: usize,
/// 运行中数
pub running: usize,
/// 进度百分比
pub percentage: f64,
/// 当前任务
pub current_tasks: Vec<String>,
}
impl From<aster::agents::subagent_scheduler::SchedulerProgress> for SubAgentProgressEvent {
fn from(p: aster::agents::subagent_scheduler::SchedulerProgress) -> Self {
Self {
total: p.total,
completed: p.completed,
failed: p.failed,
running: p.running,
percentage: p.percentage,
current_tasks: p.current_tasks,
}
self.inner.cancel().await;
}
}
+13 -75
View File
@@ -21,89 +21,27 @@ use crate::commands::skill_cmd::SkillServiceState;
use crate::commands::terminal_cmd::TerminalManagerState;
use crate::commands::tool_hooks::ToolHooksServiceState;
use crate::commands::webview_cmd::{WebviewManagerState, WebviewManagerWrapper};
use crate::config::{self, Config, ConfigManager, GlobalConfigManager, GlobalConfigManagerState};
use crate::config::{GlobalConfigManager, GlobalConfigManagerState};
use crate::database::{self, DbConnection};
use crate::logger;
use crate::mcp::McpManagerState;
use crate::plugin;
use crate::server;
use crate::services::api_key_provider_service::ApiKeyProviderService;
use crate::services::aster_session_store::ProxyCastSessionStore;
use crate::services::context_memory_service::{ContextMemoryConfig, ContextMemoryService};
use crate::services::provider_pool_service::ProviderPoolService;
use crate::services::skill_service::SkillService;
use crate::services::token_cache_service::TokenCacheService;
use crate::services::tool_hooks_service::ToolHooksService;
use crate::services::update_check_service::UpdateCheckServiceState;
use crate::telemetry;
use crate::voice::recording_service::{create_recording_service_state, RecordingServiceState};
use proxycast_core::config::{Config, ConfigManager};
use proxycast_server as server;
use proxycast_services::api_key_provider_service::ApiKeyProviderService;
use proxycast_services::aster_session_store::ProxyCastSessionStore;
use proxycast_services::context_memory_service::{ContextMemoryConfig, ContextMemoryService};
use proxycast_services::provider_pool_service::ProviderPoolService;
use proxycast_services::skill_service::SkillService;
use proxycast_services::token_cache_service::TokenCacheService;
use proxycast_services::tool_hooks_service::ToolHooksService;
use proxycast_services::update_check_service::UpdateCheckServiceState;
use super::types::{AppState, LogState, TokenCacheServiceState};
use super::utils::{generate_api_key, is_valid_bind_host};
/// 配置验证错误
#[derive(Debug)]
pub enum ConfigError {
LoadFailed(String),
SaveFailed(String),
InvalidHost,
DefaultApiKeyWithNonLocalBind,
TlsNotSupported,
RemoteManagementNotSupported,
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::LoadFailed(e) => write!(f, "配置加载失败: {e}"),
ConfigError::SaveFailed(e) => write!(f, "配置保存失败: {e}"),
ConfigError::InvalidHost => {
write!(
f,
"无效的监听地址。允许的地址:127.0.0.1、localhost、::1、0.0.0.0、::"
)
}
ConfigError::DefaultApiKeyWithNonLocalBind => write!(
f,
"监听所有网络接口 (0.0.0.0 或 ::) 时,必须设置非默认的 API Key"
),
ConfigError::TlsNotSupported => write!(f, "当前版本尚未支持 TLS"),
ConfigError::RemoteManagementNotSupported => {
write!(f, "远程管理需要 TLS 支持,当前版本未启用")
}
}
}
}
/// 加载并验证配置
pub fn load_and_validate_config() -> Result<Config, ConfigError> {
let mut config = config::load_config().map_err(|e| ConfigError::LoadFailed(e.to_string()))?;
// 验证主机地址
if !is_valid_bind_host(&config.server.host) {
return Err(ConfigError::InvalidHost);
}
// 如果使用默认 API key,自动生成新密钥
if config.server.api_key == config::DEFAULT_API_KEY {
let new_key = generate_api_key();
config.server.api_key = new_key;
config::save_config(&config).map_err(|e| ConfigError::SaveFailed(e.to_string()))?;
tracing::info!("检测到默认 API key,已自动生成并保存新密钥");
}
// 检查 TLS 配置
if config.server.tls.enable {
return Err(ConfigError::TlsNotSupported);
}
// 检查远程管理配置
if config.remote_management.allow_remote {
return Err(ConfigError::RemoteManagementNotSupported);
}
Ok(config)
}
pub use proxycast_core::app_bootstrap::{load_and_validate_config, ConfigError};
/// 应用状态集合
pub struct AppStates {
@@ -167,7 +105,7 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
let token_cache_service = TokenCacheService::new();
let token_cache_service_state = TokenCacheServiceState(Arc::new(token_cache_service));
let machine_id_service = crate::services::machine_id_service::MachineIdService::new()
let machine_id_service = proxycast_services::machine_id_service::MachineIdService::new()
.map_err(|e| format!("MachineIdService 初始化失败: {e}"))?;
let machine_id_service_state: MachineIdState = Arc::new(RwLock::new(machine_id_service));
+1 -1
View File
@@ -203,7 +203,7 @@ pub async fn update_provider_env_vars(
api_host: String,
api_key: Option<String>,
) -> Result<(), String> {
use crate::services::live_sync::write_env_to_shell_config;
use proxycast_services::live_sync::write_env_to_shell_config;
use serde_json::{json, Value};
use std::fs;
+1 -1
View File
@@ -7,7 +7,7 @@ use crate::app::TokenCacheServiceState;
use crate::commands::provider_pool_cmd::ProviderPoolServiceState;
use crate::commands::telemetry_cmd::TelemetryState;
use crate::database;
use crate::server;
use proxycast_server as server;
/// 启动服务器
#[tauri::command]
+2 -2
View File
@@ -318,7 +318,7 @@ pub fn run() {
tauri::async_runtime::spawn(async move {
// 创建 ModelRegistryService
let mut service = crate::services::model_registry_service::ModelRegistryService::new(db_clone);
let mut service = proxycast_services::model_registry_service::ModelRegistryService::new(db_clone);
// 设置资源目录路径
service.set_resource_dir(resource_dir);
@@ -344,7 +344,7 @@ pub fn run() {
// 初始化终端会话管理器
{
let app_handle = app.handle().clone();
let terminal_manager = crate::terminal::TerminalSessionManager::new(crate::terminal::TauriEmitter(app_handle.clone()));
let terminal_manager = proxycast_terminal::TerminalSessionManager::new(crate::terminal::TauriEmitter(app_handle.clone()));
if let Some(state) = app_handle.try_state::<crate::commands::terminal_cmd::TerminalManagerState>() {
let mut guard = state.inner().0.blocking_write();
*guard = Some(terminal_manager);
+3 -3
View File
@@ -8,11 +8,11 @@ use tauri::{App, Manager};
// use crate::agent::tools::{set_term_scrollback_tool_app_handle, set_terminal_tool_app_handle};
use crate::agent::AsterAgentState;
use crate::database;
use crate::services::aster_session_store::ProxyCastSessionStore;
use crate::services::provider_pool_service::ProviderPoolService;
use crate::services::token_cache_service::TokenCacheService;
use crate::telemetry;
use crate::tray::{TrayIconStatus, TrayManager, TrayStateSnapshot};
use proxycast_services::aster_session_store::ProxyCastSessionStore;
use proxycast_services::provider_pool_service::ProviderPoolService;
use proxycast_services::token_cache_service::TokenCacheService;
use super::types::{AppState, LogState, TrayManagerState};
+10 -9
View File
@@ -15,20 +15,21 @@ use crate::commands::provider_pool_cmd::{CredentialSyncServiceState, ProviderPoo
use crate::commands::resilience_cmd::ResilienceConfigState;
use crate::commands::skill_cmd::SkillServiceState;
use crate::commands::tool_hooks::ToolHooksServiceState;
use crate::config::{Config, ConfigManager, GlobalConfigManager, GlobalConfigManagerState};
use crate::config::{GlobalConfigManager, GlobalConfigManagerState};
use crate::database;
use crate::plugin;
use crate::services::api_key_provider_service::ApiKeyProviderService;
use crate::services::context_memory_service::{ContextMemoryConfig, ContextMemoryService};
use crate::services::provider_pool_service::ProviderPoolService;
use crate::services::skill_service::SkillService;
use crate::services::token_cache_service::TokenCacheService;
use crate::services::tool_hooks_service::ToolHooksService;
use crate::telemetry;
use proxycast_core::config::{Config, ConfigManager};
use proxycast_services::api_key_provider_service::ApiKeyProviderService;
use proxycast_services::context_memory_service::{ContextMemoryConfig, ContextMemoryService};
use proxycast_services::provider_pool_service::ProviderPoolService;
use proxycast_services::skill_service::SkillService;
use proxycast_services::token_cache_service::TokenCacheService;
use proxycast_services::tool_hooks_service::ToolHooksService;
use super::types::{AppState, LogState, TokenCacheServiceState};
use crate::logger;
use crate::server;
use proxycast_server as server;
/// 初始化核心应用状态
pub fn init_core_state(config: Config) -> (AppState, LogState) {
@@ -85,7 +86,7 @@ pub fn init_service_states() -> ServiceStates {
let token_cache_service_state = TokenCacheServiceState(Arc::new(token_cache_service));
// Initialize MachineIdService
let machine_id_service = crate::services::machine_id_service::MachineIdService::new()
let machine_id_service = proxycast_services::machine_id_service::MachineIdService::new()
.expect("Failed to initialize MachineIdService");
let machine_id_service_state: MachineIdState = Arc::new(RwLock::new(machine_id_service));
+2 -2
View File
@@ -7,9 +7,9 @@ use tauri::{Emitter, Runtime};
use tokio::sync::RwLock;
use crate::logger;
use crate::server;
use crate::services::token_cache_service::TokenCacheService;
use crate::tray::TrayManager;
use proxycast_server as server;
use proxycast_services::token_cache_service::TokenCacheService;
use proxycast_core::event_emit::EventEmit;
+6 -128
View File
@@ -1,130 +1,8 @@
//! 辅助函数
//! 辅助函数(桥接层)
//!
//! 包含通用工具函数。
//! 纯逻辑已迁移到 `proxycast-core` crate,
//! 本模块保留兼容导出。
use crate::config;
/// 生成安全的 API Key
pub fn generate_api_key() -> String {
config::generate_secure_api_key()
}
/// 检查是否为回环地址
pub fn is_loopback_host(host: &str) -> bool {
if host == "localhost" {
return true;
}
match host.parse::<std::net::IpAddr>() {
Ok(addr) => addr.is_loopback(),
Err(_) => false,
}
}
/// 检查是否为有效的绑定地址
/// 允许回环地址、0.0.0.0 和私有网络地址
pub fn is_valid_bind_host(host: &str) -> bool {
if is_loopback_host(host) {
return true;
}
// 允许 0.0.0.0 和 :: (监听所有接口)
if host == "0.0.0.0" || host == "::" {
return true;
}
// 允许私有网络地址
if let Ok(addr) = host.parse::<std::net::IpAddr>() {
if let std::net::IpAddr::V4(ipv4) = addr {
// 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
let octets = ipv4.octets();
return octets[0] == 10
|| (octets[0] == 172 && (octets[1] >= 16 && octets[1] <= 31))
|| (octets[0] == 192 && octets[1] == 168);
}
}
false
}
/// 检查是否为非本地绑定地址(需要强 API Key)
pub fn is_non_local_bind(host: &str) -> bool {
if host == "0.0.0.0" || host == "::" {
return true;
}
// 私有网络地址也算非本地绑定
if let Ok(addr) = host.parse::<std::net::IpAddr>() {
if let std::net::IpAddr::V4(ipv4) = addr {
let octets = ipv4.octets();
return octets[0] == 10
|| (octets[0] == 172 && (octets[1] >= 16 && octets[1] <= 31))
|| (octets[0] == 192 && octets[1] == 168);
}
}
false
}
/// 掩码敏感 Token
pub fn mask_token(token: &str) -> String {
let chars: Vec<char> = token.chars().collect();
if chars.len() <= 12 {
"****".to_string()
} else {
let prefix: String = chars[..6].iter().collect();
let suffix: String = chars[chars.len() - 4..].iter().collect();
format!("{prefix}****{suffix}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_loopback_host() {
assert!(is_loopback_host("localhost"));
assert!(is_loopback_host("127.0.0.1"));
assert!(is_loopback_host("::1"));
assert!(!is_loopback_host("0.0.0.0"));
assert!(!is_loopback_host("192.168.1.1"));
}
#[test]
fn test_is_valid_bind_host() {
// 回环地址
assert!(is_valid_bind_host("localhost"));
assert!(is_valid_bind_host("127.0.0.1"));
assert!(is_valid_bind_host("::1"));
// 监听所有接口
assert!(is_valid_bind_host("0.0.0.0"));
assert!(is_valid_bind_host("::"));
// 私有网络地址(局域网)- 应该允许
assert!(is_valid_bind_host("192.168.1.1"));
assert!(is_valid_bind_host("10.0.0.1"));
assert!(is_valid_bind_host("172.16.0.1"));
assert!(is_valid_bind_host("172.31.255.255"));
// 公网地址不允许
assert!(!is_valid_bind_host("8.8.8.8"));
assert!(!is_valid_bind_host("1.1.1.1"));
}
#[test]
fn test_is_non_local_bind() {
// 监听所有接口
assert!(is_non_local_bind("0.0.0.0"));
assert!(is_non_local_bind("::"));
// 回环地址不是非本地绑定
assert!(!is_non_local_bind("127.0.0.1"));
assert!(!is_non_local_bind("localhost"));
// 私有网络地址是非本地绑定(需要强 API Key)
assert!(is_non_local_bind("192.168.1.1"));
assert!(is_non_local_bind("10.0.0.1"));
assert!(is_non_local_bind("172.16.0.1"));
}
#[test]
fn test_mask_token() {
assert_eq!(mask_token("short"), "****");
assert_eq!(mask_token("abcdefghijklmnop"), "abcdef****mnop");
}
}
pub use proxycast_core::app_utils::{
generate_api_key, is_loopback_host, is_non_local_bind, is_valid_bind_host, mask_token,
};
@@ -10,7 +10,7 @@ use crate::database::dao::api_key_provider::{
};
use crate::database::system_providers::get_system_providers;
use crate::database::DbConnection;
use crate::services::api_key_provider_service::{
use proxycast_services::api_key_provider_service::{
ApiKeyProviderService, ChatTestResult, ConnectionTestResult, ImportResult,
};
use serde::{Deserialize, Serialize};
+3 -3
View File
@@ -5,17 +5,17 @@
//! 支持从 ProxyCast 凭证池自动选择凭证
use crate::agent::aster_state::{ProviderConfig, SessionConfigBuilder};
use crate::agent::event_converter::convert_agent_event;
use crate::agent::{
AsterAgentState, AsterAgentWrapper, SessionDetail, SessionInfo, TauriAgentEvent,
};
use crate::database::dao::agent::AgentDao;
use crate::database::DbConnection;
use crate::mcp::{McpManagerState, McpServerConfig};
use crate::services::mcp_service::McpService;
use aster::agents::extension::{Envs, ExtensionConfig};
use aster::conversation::message::Message;
use futures::StreamExt;
use proxycast_agent::event_converter::convert_agent_event;
use proxycast_services::mcp_service::McpService;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
@@ -651,7 +651,7 @@ async fn ensure_proxycast_mcp_servers_running(
return (0, 0);
}
let candidates: Vec<&crate::models::McpServer> =
let candidates: Vec<&crate::models::mcp_model::McpServer> =
servers.iter().filter(|s| s.enabled_proxycast).collect();
if candidates.is_empty() {
+1 -1
View File
@@ -2,7 +2,7 @@ use crate::config::{
Config, ConfigManager, ExportBundle, ExportOptions as ExportServiceOptions, ExportService,
ImportOptions as ImportServiceOptions, ImportService, ValidationResult,
};
use crate::models::AppType;
use crate::models::app_type::AppType;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
+1 -1
View File
@@ -11,7 +11,7 @@
//! - `connection_get_raw_config` - 获取原始配置内容
//! - `connection_save_raw_config` - 保存原始配置内容
use crate::terminal::connections::{
use proxycast_terminal::connections::{
ConnectionConfig, ConnectionConfigManager, ConnectionConfigType, ConnectionListEntry,
};
use serde::{Deserialize, Serialize};
+1 -1
View File
@@ -1,6 +1,6 @@
//! 上下文记忆管理相关的 Tauri 命令
use crate::services::context_memory_service::{
use proxycast_services::context_memory_service::{
ContextMemoryService, MemoryEntry, MemoryFileType, MemoryStats,
};
use serde::{Deserialize, Serialize};
+1 -1
View File
@@ -14,7 +14,7 @@
use crate::database::dao::general_chat::GeneralChatDao;
use crate::database::DbConnection;
use crate::services::general_chat::{
use proxycast_services::general_chat::{
ChatMessage, ChatSession, ContentBlock, MessageRole, SessionDetail,
};
use serde::{Deserialize, Serialize};
+1 -1
View File
@@ -6,7 +6,7 @@ use crate::commands::provider_pool_cmd::ProviderPoolServiceState;
use crate::database::DbConnection;
use crate::models::kiro_fingerprint::{KiroFingerprintStore, SwitchToLocalResult};
use crate::models::provider_pool_model::CredentialData;
use crate::services::machine_id_service::MachineIdService;
use proxycast_services::machine_id_service::MachineIdService;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
+1 -1
View File
@@ -1,5 +1,5 @@
use crate::models::machine_id::*;
use crate::services::machine_id_service::MachineIdService;
use proxycast_services::machine_id_service::MachineIdService;
use std::sync::Arc;
use tauri::State;
use tokio::sync::RwLock;
+1 -1
View File
@@ -18,7 +18,7 @@ use crate::database::DbConnection;
use crate::models::project_model::{
Material, MaterialFilter, MaterialUpdate, UploadMaterialRequest,
};
use crate::services::material_service::MaterialService;
use proxycast_services::material_service::MaterialService;
// ============================================================================
// Tauri 命令
+2 -2
View File
@@ -39,8 +39,8 @@ use crate::mcp::{
McpManagerState, McpPromptDefinition, McpPromptResult, McpResourceContent,
McpResourceDefinition, McpServerConfig, McpServerInfo, McpToolDefinition, McpToolResult,
};
use crate::models::McpServer;
use crate::services::mcp_service::McpService;
use crate::models::mcp_model::McpServer;
use proxycast_services::mcp_service::McpService;
use tauri::State;
use tracing::{debug, error, info};
+1 -1
View File
@@ -2,7 +2,7 @@
use crate::database::dao::provider_pool::ProviderPoolDao;
use crate::database::DbConnection;
use crate::services::model_service::ModelService;
use proxycast_services::model_service::ModelService;
use std::collections::HashMap;
use tauri::State;
+1 -1
View File
@@ -5,7 +5,7 @@
use crate::models::model_registry::{
EnhancedModelMetadata, ModelSyncState, ModelTier, ProviderAliasConfig, UserModelPreference,
};
use crate::services::model_registry_service::{FetchModelsResult, ModelRegistryService};
use proxycast_services::model_registry_service::{FetchModelsResult, ModelRegistryService};
use std::collections::BTreeSet;
use std::sync::Arc;
use tauri::State;
+1 -3
View File
@@ -3,9 +3,7 @@
//! 核心逻辑已迁移到 proxycast-core::network,本文件保留 Tauri 命令包装。
// 重新导出核心类型
pub use proxycast_core::network::{
get_accessible_host, get_accessible_url, get_local_url, NetworkInfo,
};
pub use proxycast_core::network::{get_accessible_url, NetworkInfo};
/// 获取本地网络信息(Tauri 命令包装)
#[tauri::command]
+1 -1
View File
@@ -4,7 +4,7 @@
use crate::database::dao::provider_pool::ProviderPoolDao;
use crate::database::DbConnection;
use crate::orchestrator::{
use proxycast_core::orchestrator::{
get_global_orchestrator, init_global_orchestrator, AvailableModel, CredentialInfo,
OrchestratorConfig, PoolStats, ProviderType, SelectionContext, SelectionResult, ServiceTier,
StrategyInfo, TaskHint,
+1 -1
View File
@@ -24,7 +24,7 @@ use crate::models::project_model::{
BrandPersona, BrandPersonaExtension, BrandPersonaTemplate, CreateBrandExtensionRequest,
CreatePersonaRequest, Persona, PersonaTemplate, PersonaUpdate, UpdateBrandExtensionRequest,
};
use crate::services::persona_service::PersonaService;
use proxycast_services::persona_service::PersonaService;
// ============================================================================
// Tauri 命令
+2 -2
View File
@@ -11,7 +11,7 @@
#![allow(dead_code)]
use crate::plugin::{PluginConfig, PluginInfo, PluginManager, PluginManifest, PluginType};
use proxycast_core::plugin::{PluginConfig, PluginInfo, PluginManager, PluginManifest, PluginType};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::Arc;
@@ -360,7 +360,7 @@ pub async fn get_plugins_with_ui(
// 插件 UI 相关命令
// ============================================================================
use crate::plugin::{UIMessage, UserAction};
use proxycast_core::plugin::{UIMessage, UserAction};
/// 获取插件 UI 定义
/// 返回插件的初始 UI 消息列表
+2 -2
View File
@@ -8,7 +8,7 @@
//!
//! _需求: 1.1, 2.1, 2.2, 2.4, 3.1, 3.2, 3.3, 4.2, 6.1_
use crate::plugin::installer::{
use proxycast_core::plugin::installer::{
InstallProgress, InstalledPlugin, PluginInstaller, ProgressCallback,
};
use serde::{Deserialize, Serialize};
@@ -44,7 +44,7 @@ impl<R: Runtime> TauriProgressCallback<R> {
}
}
impl<R: Runtime> crate::plugin::installer::ProgressCallback for TauriProgressCallback<R> {
impl<R: Runtime> proxycast_core::plugin::installer::ProgressCallback for TauriProgressCallback<R> {
fn on_progress(&self, progress: InstallProgress) {
// 发送进度事件到前端
let _ = self.app_handle.emit(INSTALL_PROGRESS_EVENT, &progress);
+2 -2
View File
@@ -1,6 +1,6 @@
use crate::database::DbConnection;
use crate::models::Prompt;
use crate::services::prompt_service::PromptService;
use crate::models::prompt_model::Prompt;
use proxycast_services::prompt_service::PromptService;
use std::collections::HashMap;
use tauri::State;
+4 -4
View File
@@ -2,15 +2,15 @@
#![allow(dead_code)]
use crate::credential::CredentialSyncService;
use crate::database::dao::provider_pool::ProviderPoolDao;
use crate::database::DbConnection;
use crate::models::provider_pool_model::{
AddCredentialRequest, CredentialData, CredentialDisplay, HealthCheckResult, OAuthStatus,
PoolProviderType, ProviderCredential, ProviderPoolOverview, UpdateCredentialRequest,
};
use crate::services::provider_pool_service::ProviderPoolService;
use chrono::Utc;
use proxycast_credential::CredentialSyncService;
use proxycast_services::provider_pool_service::ProviderPoolService;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -3852,7 +3852,7 @@ pub async fn get_credential_health(
db: State<'_, DbConnection>,
pool_service: State<'_, ProviderPoolServiceState>,
uuid: String,
) -> Result<Option<crate::services::provider_pool_service::CredentialHealthInfo>, String> {
) -> Result<Option<proxycast_services::provider_pool_service::CredentialHealthInfo>, String> {
pool_service.0.get_credential_health(&db, &uuid)
}
@@ -3862,6 +3862,6 @@ pub async fn get_credential_health(
pub async fn get_all_credential_health(
db: State<'_, DbConnection>,
pool_service: State<'_, ProviderPoolServiceState>,
) -> Result<Vec<crate::services::provider_pool_service::CredentialHealthInfo>, String> {
) -> Result<Vec<proxycast_services::provider_pool_service::CredentialHealthInfo>, String> {
pool_service.0.get_all_credential_health(&db)
}
+3 -35
View File
@@ -10,10 +10,7 @@
use crate::config::{ExperimentalFeatures, GlobalConfigManagerState};
use crate::screenshot::{capture, shortcut};
use base64::{engine::general_purpose::STANDARD, Engine};
use std::path::Path;
use tauri::{AppHandle, Emitter, Manager, State};
use tokio::fs;
use tracing::{debug, error, info};
/// 获取实验室功能配置
@@ -112,18 +109,15 @@ pub async fn save_experimental_config(
///
/// 启动交互式截图,返回截图文件路径
///
/// # 参数
/// - `app`: Tauri 应用句柄
///
/// # 返回
/// 成功返回截图文件路径,用户取消返回空字符串,失败返回错误信息
///
/// _需求: 3.1_
#[tauri::command]
pub async fn start_screenshot(app: AppHandle) -> Result<String, String> {
pub async fn start_screenshot() -> Result<String, String> {
info!("启动截图命令");
match capture::start_capture(&app).await {
match capture::start_capture().await {
Ok(path) => {
info!("截图成功: {:?}", path);
Ok(path.to_string_lossy().to_string())
@@ -211,9 +205,6 @@ pub async fn update_screenshot_shortcut(
///
/// 关闭当前打开的截图对话悬浮窗口
///
/// # 参数
/// - `app`: Tauri 应用句柄
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误信息
#[tauri::command]
@@ -254,30 +245,7 @@ pub fn open_input_with_text(app: AppHandle, text: String) -> Result<(), String>
/// _需求: 5.1_
#[tauri::command]
pub async fn read_image_as_base64(path: String) -> Result<String, String> {
debug!("读取图片为 Base64: {}", path);
let path = Path::new(&path);
// 检查文件是否存在
if !path.exists() {
return Err(format!("文件不存在: {}", path.display()));
}
// 读取文件内容
let bytes = fs::read(path)
.await
.map_err(|e| format!("读取文件失败: {e}"))?;
// 检查文件是否为空
if bytes.is_empty() {
return Err("文件为空".to_string());
}
// 编码为 Base64
let base64 = STANDARD.encode(&bytes);
debug!("图片读取成功,大小: {} 字节", bytes.len());
Ok(base64)
proxycast_services::screenshot_image_service::read_image_as_base64(&path).await
}
/// 截图对话消息结构
+3 -2
View File
@@ -1,9 +1,10 @@
use crate::agent::aster_state::AsterAgentState;
use crate::database::dao::skills::SkillDao;
use crate::database::DbConnection;
use crate::models::{AppType, Skill, SkillRepo, SkillState};
use crate::services::skill_service::SkillService;
use crate::models::app_type::AppType;
use crate::models::skill_model::{Skill, SkillRepo, SkillState};
use chrono::Utc;
use proxycast_services::skill_service::SkillService;
use std::path::Path;
use std::sync::Arc;
use tauri::State;
+4 -4
View File
@@ -23,15 +23,15 @@ use uuid::Uuid;
use aster::conversation::message::Message;
use crate::agent::aster_state::SessionConfigBuilder;
use crate::agent::event_converter::convert_agent_event;
use crate::agent::{AsterAgentState, TauriAgentEvent};
use crate::database::DbConnection;
use crate::skills::{
use crate::skills::TauriExecutionCallback;
use proxycast_agent::event_converter::convert_agent_event;
use proxycast_skills::{
find_skill_by_name, get_proxycast_skills_dir, load_skills_from_directory, ExecutionCallback,
TauriExecutionCallback,
};
#[cfg(test)]
use crate::skills::{
use proxycast_skills::{
load_skill_from_file, parse_allowed_tools, parse_boolean, parse_skill_frontmatter,
};
+4 -3
View File
@@ -1,7 +1,8 @@
use crate::database::DbConnection;
use crate::models::{AppType, Provider};
use crate::services::live_sync::{check_config_sync, sync_from_external, SyncCheckResult};
use crate::services::switch::SwitchService;
use crate::models::app_type::AppType;
use crate::models::provider_model::Provider;
use proxycast_services::live_sync::{check_config_sync, sync_from_external, SyncCheckResult};
use proxycast_services::switch::SwitchService;
use serde_json::Value;
use tauri::State;
+1 -1
View File
@@ -15,7 +15,7 @@ use tauri::State;
use crate::database::DbConnection;
use crate::models::project_model::{CreateTemplateRequest, Template, TemplateUpdate};
use crate::services::template_service::TemplateService;
use proxycast_services::template_service::TemplateService;
// ============================================================================
// Tauri 命令
+1 -1
View File
@@ -18,7 +18,7 @@ use serde::Serialize;
use tauri::State;
use tokio::sync::RwLock;
use crate::terminal::{SessionMetadata, TerminalSessionManager};
use proxycast_terminal::{SessionMetadata, TerminalSessionManager};
/// 终端会话管理器状态包装
pub struct TerminalManagerState(pub Arc<RwLock<Option<TerminalSessionManager>>>);
+1 -1
View File
@@ -1,6 +1,6 @@
//! 工具钩子管理相关的 Tauri 命令
use crate::services::tool_hooks_service::{
use proxycast_services::tool_hooks_service::{
HookContext, HookExecutionStats, HookRule, HookTrigger, ToolHooksService,
};
use serde::{Deserialize, Serialize};
+1 -1
View File
@@ -14,12 +14,12 @@
//! - `docs/prd/chat-architecture-redesign.md`
use crate::agent::aster_state::SessionConfigBuilder;
use crate::agent::event_converter::convert_agent_event;
use crate::agent::{AsterAgentState, TauriAgentEvent};
use crate::database::dao::chat::{ChatDao, ChatMessage, ChatMode, ChatSession};
use crate::database::DbConnection;
use aster::conversation::message::Message;
use futures::StreamExt;
use proxycast_agent::event_converter::convert_agent_event;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
+2 -2
View File
@@ -8,10 +8,10 @@
use crate::app::AppState;
use crate::config;
use crate::services::update_check_service::{
use crate::services::update_window;
use proxycast_services::update_check_service::{
UpdateCheckService, UpdateCheckServiceState, UpdateInfo,
};
use crate::services::update_window;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::{AppHandle, Manager, State};
+1 -1
View File
@@ -5,8 +5,8 @@
use crate::database::dao::provider_pool::ProviderPoolDao;
use crate::database::DbConnection;
use crate::models::provider_pool_model::{CredentialData, PoolProviderType};
use crate::services::usage_service::{self, UsageInfo};
use crate::TokenCacheServiceState;
use proxycast_services::usage_service::{self, UsageInfo};
use tauri::State;
/// 默认 Kiro 版本号
+1 -1
View File
@@ -1,6 +1,6 @@
//! WebSocket 相关的 Tauri 命令
use crate::websocket::{WsConnection, WsStatsSnapshot};
use proxycast_websocket::{WsConnection, WsStatsSnapshot};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
+1 -1
View File
@@ -13,10 +13,10 @@
use crate::database::DbConnection;
use crate::models::project_model::ProjectContext;
use crate::services::project_context_builder::ProjectContextBuilder;
use crate::workspace::{
Workspace, WorkspaceManager, WorkspaceSettings, WorkspaceType, WorkspaceUpdate,
};
use proxycast_services::project_context_builder::ProjectContextBuilder;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
+6 -9
View File
@@ -3,19 +3,16 @@
//! 核心配置类型、YAML 支持、热重载和导入导出功能已迁移到 proxycast-core crate。
//! 本模块保留 observer(依赖 Tauri)和集成测试。
#![allow(unused_imports)]
// 从 core crate 重新导出所有配置类型
pub use proxycast_core::config::*;
// observer 模块保留在主 crate(依赖 Tauri)
pub mod observer;
// 兼容导出:配置核心能力已迁移到 proxycast-core crate
pub use proxycast_core::config::*;
// 重新导出观察者模块的核心类型
pub use observer::{
ConfigChangeEvent, ConfigChangeSource, ConfigObserver, ConfigSubject, GlobalConfigManager,
GlobalConfigManagerState,
};
pub use observer::ConfigChangeSource;
pub use proxycast_config::observer::manager::GlobalConfigManager;
pub use proxycast_config::GlobalConfigManagerState;
#[cfg(test)]
mod tests;
+3 -18
View File
@@ -1,30 +1,15 @@
//! 配置观察者模块
//!
//! 核心逻辑已迁移到 proxycast-config crate。
//! 本模块保留 Tauri 相关实现和重新导出。
//! 本模块保留 Tauri 相关实现和必要的重新导出。
mod tauri_emitter;
mod tauri_observer;
// 从 proxycast-config crate 重新导出所有类型
pub use proxycast_config::observer::emitter::{ConfigEventEmit, NoOpEmitter};
// 从 proxycast-config crate 重新导出被使用的类型
pub use proxycast_config::observer::events::{
AmpConfigChangeEvent, ConfigChangeEvent, ConfigChangeSource, CredentialPoolChangeEvent,
EndpointProvidersChangeEvent, FullReloadEvent, InjectionChangeEvent, LoggingChangeEvent,
NativeAgentChangeEvent, RetryChangeEvent, RoutingChangeEvent, ServerChangeEvent,
ConfigChangeEvent, ConfigChangeSource, EndpointProvidersChangeEvent, RoutingChangeEvent,
};
pub use proxycast_config::observer::manager::GlobalConfigManager;
pub use proxycast_config::observer::observers::{
DefaultProviderRefObserver, EndpointObserver, InjectorObserver, LoggingObserver, RouterObserver,
};
pub use proxycast_config::observer::subject::{
ConfigSubject, CONFIG_CHANGED_EVENT, CONFIG_RELOAD_EVENT,
};
pub use proxycast_config::observer::traits::{
ConfigObserver, FnObserver, SyncConfigObserver, SyncObserverWrapper,
};
pub use proxycast_config::GlobalConfigManagerState;
// Tauri 相关实现
pub use tauri_emitter::TauriConfigEmitter;
pub use tauri_observer::TauriObserver;
@@ -13,6 +13,7 @@ pub struct TauriObserver {
app_handle: AppHandle,
}
#[allow(dead_code)]
impl TauriObserver {
pub fn new(app_handle: AppHandle) -> Self {
Self { app_handle }
+39 -39
View File
@@ -2,13 +2,13 @@
//!
//! 使用 proptest 进行属性测试
use crate::config::{
use proptest::prelude::*;
use proxycast_core::config::{
collapse_tilde, contains_tilde, expand_tilde, Config, ConfigManager, CustomProviderConfig,
HotReloadManager, InjectionSettings, LoggingConfig, ProviderConfig, ProvidersConfig,
ReloadResult, RetrySettings, RoutingConfig, ServerConfig, YamlService,
};
use crate::config::{ContentCreatorConfig, NavigationConfig};
use proptest::prelude::*;
use proxycast_core::config::{ContentCreatorConfig, NavigationConfig};
use std::io::Write;
use tempfile::NamedTempFile;
@@ -38,7 +38,7 @@ fn arb_server_config() -> impl Strategy<Value = ServerConfig> {
host,
port,
api_key,
tls: crate::config::TlsConfig::default(),
tls: proxycast_core::config::TlsConfig::default(),
})
}
@@ -179,17 +179,17 @@ fn arb_config() -> impl Strategy<Value = Config> {
logging,
injection: InjectionSettings::default(),
auth_dir: "~/.proxycast/auth".to_string(),
credential_pool: crate::config::CredentialPoolConfig::default(),
remote_management: crate::config::RemoteManagementConfig::default(),
quota_exceeded: crate::config::QuotaExceededConfig::default(),
credential_pool: proxycast_core::config::CredentialPoolConfig::default(),
remote_management: proxycast_core::config::RemoteManagementConfig::default(),
quota_exceeded: proxycast_core::config::QuotaExceededConfig::default(),
proxy_url: None,
ampcode: crate::config::AmpConfig::default(),
endpoint_providers: crate::config::EndpointProvidersConfig::default(),
ampcode: proxycast_core::config::AmpConfig::default(),
endpoint_providers: proxycast_core::config::EndpointProvidersConfig::default(),
minimize_to_tray: true,
models: crate::config::ModelsConfig::default(),
agent: crate::config::NativeAgentConfig::default(),
models: proxycast_core::config::ModelsConfig::default(),
agent: proxycast_core::config::NativeAgentConfig::default(),
language: "zh".to_string(),
experimental: crate::config::ExperimentalFeatures::default(),
experimental: proxycast_core::config::ExperimentalFeatures::default(),
content_creator: ContentCreatorConfig::default(),
navigation: NavigationConfig::default(),
})
@@ -356,7 +356,7 @@ fn arb_valid_server_config() -> impl Strategy<Value = ServerConfig> {
host,
port,
api_key,
tls: crate::config::TlsConfig::default(),
tls: proxycast_core::config::TlsConfig::default(),
})
}
@@ -419,17 +419,17 @@ fn arb_valid_config() -> impl Strategy<Value = Config> {
logging,
injection: InjectionSettings::default(),
auth_dir: "~/.proxycast/auth".to_string(),
credential_pool: crate::config::CredentialPoolConfig::default(),
remote_management: crate::config::RemoteManagementConfig::default(),
quota_exceeded: crate::config::QuotaExceededConfig::default(),
credential_pool: proxycast_core::config::CredentialPoolConfig::default(),
remote_management: proxycast_core::config::RemoteManagementConfig::default(),
quota_exceeded: proxycast_core::config::QuotaExceededConfig::default(),
proxy_url: None,
ampcode: crate::config::AmpConfig::default(),
endpoint_providers: crate::config::EndpointProvidersConfig::default(),
ampcode: proxycast_core::config::AmpConfig::default(),
endpoint_providers: proxycast_core::config::EndpointProvidersConfig::default(),
minimize_to_tray: true,
models: crate::config::ModelsConfig::default(),
agent: crate::config::NativeAgentConfig::default(),
models: proxycast_core::config::ModelsConfig::default(),
agent: proxycast_core::config::NativeAgentConfig::default(),
language: "zh".to_string(),
experimental: crate::config::ExperimentalFeatures::default(),
experimental: proxycast_core::config::ExperimentalFeatures::default(),
content_creator: ContentCreatorConfig::default(),
navigation: NavigationConfig::default(),
})
@@ -470,17 +470,17 @@ fn arb_invalid_config() -> impl Strategy<Value = Config> {
logging,
injection: InjectionSettings::default(),
auth_dir: "~/.proxycast/auth".to_string(),
credential_pool: crate::config::CredentialPoolConfig::default(),
remote_management: crate::config::RemoteManagementConfig::default(),
quota_exceeded: crate::config::QuotaExceededConfig::default(),
credential_pool: proxycast_core::config::CredentialPoolConfig::default(),
remote_management: proxycast_core::config::RemoteManagementConfig::default(),
quota_exceeded: proxycast_core::config::QuotaExceededConfig::default(),
proxy_url: None,
ampcode: crate::config::AmpConfig::default(),
endpoint_providers: crate::config::EndpointProvidersConfig::default(),
ampcode: proxycast_core::config::AmpConfig::default(),
endpoint_providers: proxycast_core::config::EndpointProvidersConfig::default(),
minimize_to_tray: true,
models: crate::config::ModelsConfig::default(),
agent: crate::config::NativeAgentConfig::default(),
models: proxycast_core::config::ModelsConfig::default(),
agent: proxycast_core::config::NativeAgentConfig::default(),
language: "zh".to_string(),
experimental: crate::config::ExperimentalFeatures::default(),
experimental: proxycast_core::config::ExperimentalFeatures::default(),
content_creator: ContentCreatorConfig::default(),
navigation: NavigationConfig::default(),
};
@@ -1178,7 +1178,7 @@ providers:
// Property 4: Export Scope Filtering
// ============================================================================
use crate::config::{
use proxycast_core::config::{
ApiKeyEntry, CredentialEntry, CredentialPoolConfig, ExportOptions, ExportService,
};
@@ -1357,7 +1357,7 @@ proptest! {
// Property 5: Redaction Completeness
// ============================================================================
use crate::config::REDACTED_PLACEHOLDER;
use proxycast_core::config::REDACTED_PLACEHOLDER;
/// 生成包含敏感信息的配置
fn arb_config_with_secrets() -> impl Strategy<Value = Config> {
@@ -1589,7 +1589,7 @@ proptest! {
// Property 6: Import Validation
// ============================================================================
use crate::config::{ExportBundle, ImportService};
use proxycast_core::config::{ExportBundle, ImportService};
/// 生成有效的导出包
fn arb_valid_export_bundle() -> impl Strategy<Value = ExportBundle> {
@@ -1761,7 +1761,7 @@ proptest! {
// Property 7: Import Merge vs Replace
// ============================================================================
use crate::config::ImportOptions;
use proxycast_core::config::ImportOptions;
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
@@ -2176,7 +2176,7 @@ fn arb_oauth_credential_entry() -> impl Strategy<Value = CredentialEntry> {
}
/// 生成随机的 Gemini API Key 条目
fn arb_gemini_api_key_entry() -> impl Strategy<Value = crate::config::GeminiApiKeyEntry> {
fn arb_gemini_api_key_entry() -> impl Strategy<Value = proxycast_core::config::GeminiApiKeyEntry> {
(
"[a-z]{3,10}-[0-9]{1,5}".prop_map(|s| s),
"AIzaSy[a-zA-Z0-9_-]{33}".prop_map(|s| s),
@@ -2187,7 +2187,7 @@ fn arb_gemini_api_key_entry() -> impl Strategy<Value = crate::config::GeminiApiK
)
.prop_map(
|(id, api_key, base_url, proxy_url, excluded_models, disabled)| {
crate::config::GeminiApiKeyEntry {
proxycast_core::config::GeminiApiKeyEntry {
id,
api_key,
base_url,
@@ -2200,7 +2200,7 @@ fn arb_gemini_api_key_entry() -> impl Strategy<Value = crate::config::GeminiApiK
}
/// 生成随机的 Vertex AI 条目
fn arb_vertex_api_key_entry() -> impl Strategy<Value = crate::config::VertexApiKeyEntry> {
fn arb_vertex_api_key_entry() -> impl Strategy<Value = proxycast_core::config::VertexApiKeyEntry> {
(
"[a-z]{3,10}-[0-9]{1,5}".prop_map(|s| s),
"vk-[a-zA-Z0-9]{20,40}".prop_map(|s| s),
@@ -2216,13 +2216,13 @@ fn arb_vertex_api_key_entry() -> impl Strategy<Value = crate::config::VertexApiK
any::<bool>(),
)
.prop_map(|(id, api_key, base_url, models, proxy_url, disabled)| {
crate::config::VertexApiKeyEntry {
proxycast_core::config::VertexApiKeyEntry {
id,
api_key,
base_url,
models: models
.into_iter()
.map(|(name, alias)| crate::config::VertexModelAlias { name, alias })
.map(|(name, alias)| proxycast_core::config::VertexModelAlias { name, alias })
.collect(),
proxy_url,
disabled,
@@ -2346,7 +2346,7 @@ proptest! {
// Property 3: EndpointProvidersConfig 序列化往返一致性
// ============================================================================
use crate::config::EndpointProvidersConfig;
use proxycast_core::config::EndpointProvidersConfig;
/// 生成随机的 Provider 名称
fn arb_provider_name() -> impl Strategy<Value = String> {
-3
View File
@@ -1,3 +0,0 @@
//! Content 模块 - 从 proxycast-core 重新导出
pub use proxycast_core::content::*;
-29
View File
@@ -1,29 +0,0 @@
//! 凭证池管理模块
//!
//! 提供多凭证管理、负载均衡和健康检查功能
//!
//! ## 模块结构
//!
//! - `types` - 凭证相关类型定义(来自 proxycast-core)
//! - `pool` - 凭证池管理(来自 proxycast-core)
//! - `health` - 健康检查(来自 proxycast-core)
//! - `risk` - 风控模块(来自 proxycast-core)
//! - `balancer` - 负载均衡策略(来自 proxycast-credential)
//! - `quota` - 配额管理(来自 proxycast-credential)
//! - `sync` - 数据库同步(来自 proxycast-credential)
// 从 proxycast-core 重新导出核心类型模块
pub use proxycast_core::credential::{health, pool, risk, types};
// 重新导出 core 类型
pub use proxycast_core::credential::{
CooldownConfig, Credential, CredentialData, CredentialPool, CredentialStats, CredentialStatus,
HealthCheckConfig, HealthCheckResult, HealthChecker, HealthStatus, PoolError, PoolStatus,
RateLimitEvent, RateLimitStats, RiskController, RiskLevel,
};
// 从 proxycast-credential crate 重新导出
pub use proxycast_credential::*;
#[cfg(test)]
mod tests;
-5
View File
@@ -1,5 +0,0 @@
//! 数据库模块 - 从 proxycast-core 重新导出
//!
//! 核心数据库类型和 DAO 已迁移到 proxycast-core crate
pub use proxycast_core::database::*;
+1 -1
View File
@@ -24,7 +24,7 @@ use tokio::sync::RwLock;
#[cfg(debug_assertions)]
use tower_http::cors::CorsLayer;
use crate::server::AppState;
use proxycast_server::AppState;
#[cfg(debug_assertions)]
#[derive(Debug, Deserialize)]
+7 -56
View File
@@ -2,59 +2,10 @@
//!
//! 将 HTTP 请求路由到现有的 Tauri 命令函数。
use crate::server::AppState;
use serde::Deserialize;
use proxycast_server::AppState;
use proxycast_server_utils::load_model_registry_provider_ids_from_resources;
use serde_json::Value as JsonValue;
#[derive(Debug, Deserialize)]
struct ModelRegistryIndex {
providers: Vec<String>,
}
fn resolve_models_index_path() -> Option<std::path::PathBuf> {
let mut candidates: Vec<std::path::PathBuf> = Vec::new();
if let Ok(current_dir) = std::env::current_dir() {
candidates.push(current_dir.join("src-tauri/resources/models/index.json"));
candidates.push(current_dir.join("resources/models/index.json"));
}
if let Ok(exe_path) = std::env::current_exe() {
if let Some(parent) = exe_path.parent() {
candidates.push(parent.join("resources/models/index.json"));
candidates.push(parent.join("../../src-tauri/resources/models/index.json"));
candidates.push(parent.join("../../../src-tauri/resources/models/index.json"));
candidates.push(parent.join("../Resources/resources/models/index.json"));
candidates.push(parent.join("../../Resources/resources/models/index.json"));
candidates.push(parent.join("../../../Resources/resources/models/index.json"));
}
}
candidates.into_iter().find(|path| path.exists())
}
fn load_model_registry_provider_ids_from_resources() -> Result<Vec<String>, String> {
let index_path =
resolve_models_index_path().ok_or_else(|| "未找到 models index.json".to_string())?;
let index_content = std::fs::read_to_string(&index_path)
.map_err(|e| format!("读取 models index.json 失败 ({index_path:?}): {e}"))?;
let index: ModelRegistryIndex = serde_json::from_str(&index_content)
.map_err(|e| format!("解析 models index.json 失败: {e}"))?;
let mut provider_ids: Vec<String> = index
.providers
.into_iter()
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty())
.collect();
provider_ids.sort();
provider_ids.dedup();
Ok(provider_ids)
}
fn load_model_registry_provider_ids_from_db(
state: &AppState,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
@@ -88,16 +39,16 @@ pub async fn handle_command(
// ========== P0 - 核心配置 ==========
"get_config" => {
// 从配置文件读取
let config_path = crate::config::ConfigManager::default_config_path();
let manager = crate::config::ConfigManager::load(&config_path)?;
let config_path = proxycast_core::config::ConfigManager::default_config_path();
let manager = proxycast_core::config::ConfigManager::load(&config_path)?;
let config = manager.config();
Ok(serde_json::to_value(config)?)
}
"save_config" => {
// 保存配置到文件
let config: crate::config::Config = serde_json::from_value(args.unwrap_or_default())?;
crate::config::save_config(&config)?;
let config: proxycast_core::config::Config = serde_json::from_value(args.unwrap_or_default())?;
proxycast_core::config::save_config(&config)?;
Ok(serde_json::json!({ "success": true }))
}
@@ -292,7 +243,7 @@ pub async fn handle_command(
if let Some(db) = &state.db {
// 简化版本:直接创建会话,不需要 agent_state
use crate::database::dao::agent::AgentDao;
use crate::agent::types::AgentSession;
use proxycast_core::agent::types::AgentSession;
let session_id = uuid::Uuid::new_v4().to_string();
let model_name = model.clone().unwrap_or_else(|| "claude-sonnet-4-20250514".to_string());
+20 -45
View File
@@ -14,77 +14,52 @@
// 该警告来自 cocoa/objc 依赖的 msg_send! 宏,是已知的 issue
#![allow(unexpected_cfgs)]
// 重新导出子 crate 的类型
// 注意:主 crate 保留了自己的 data, logger, models 模块,所以只导出 core 的具体类型
pub use proxycast_core::{LogEntry, LogStore, LogStoreConfig, SharedLogStore};
// infra crate 的类型通过 proxycast_infra 前缀访问,避免与 core 的 InjectionMode/InjectionRule 冲突
pub use proxycast_infra::{
injection, proxy, resilience, telemetry, Failover, FailoverConfig, InjectionConfig,
InjectionMode, InjectionResult, InjectionRule, Injector, LogRotationConfig, LoggerError,
ModelStats, ModelTokenStats, PeriodTokenStats, ProviderStats, ProviderTokenStats,
ProxyClientFactory, ProxyError, ProxyProtocol, RequestLog, RequestLogger, RequestStatus,
Retrier, RetryConfig, StatsAggregator, StatsSummary, TimeRange, TimeoutConfig,
TimeoutController, TokenSource, TokenStatsSummary, TokenTracker, TokenUsageRecord,
};
// 从 providers crate 重新导出(保持 crate::xxx 路径兼容)
pub use proxycast_providers::converter;
pub use proxycast_providers::providers;
pub use proxycast_providers::stream;
pub use proxycast_providers::streaming;
pub use proxycast_providers::translator;
// 从 core crate 重新导出(保持 crate::xxx 路径兼容)
pub use proxycast_core::backends;
pub use proxycast_core::connect;
pub use proxycast_core::orchestrator;
pub use proxycast_core::content;
pub use proxycast_core::credential;
pub use proxycast_core::database;
pub use proxycast_core::memory;
pub use proxycast_core::session_files;
pub use proxycast_core::workspace;
// 核心模块
// 从 infra crate 重新导出(保持 crate::xxx 路径兼容)
pub use proxycast_infra::{injection, resilience, telemetry};
// MCP 模块(从 proxycast-mcp crate 重新导出)
pub use proxycast_mcp as mcp;
// 核心模块(Tauri 相关业务逻辑)
pub mod agent;
pub mod app;
pub mod content;
pub mod credential;
pub mod database;
pub mod memory;
pub mod plugin;
pub mod screenshot;
pub mod services;
pub mod session;
pub mod skills;
pub mod terminal;
pub mod tray;
pub mod voice;
pub mod workspace;
// Skills 集成模块
pub mod skills;
// MCP 集成模块
pub mod mcp;
// 内部模块
mod commands;
mod config;
mod data;
#[cfg(debug_assertions)]
#[allow(dead_code)]
mod dev_bridge;
mod logger;
mod models;
mod server_utils;
use proxycast_core::models;
// 从 core crate 重新导出 errors
pub use proxycast_core::errors;
// 服务器相关模块
mod middleware;
mod processor;
mod router;
mod server;
mod websocket;
// 测试模块
#[cfg(test)]
mod tests;
// 重新导出核心类型以保持向后兼容
pub use app::{AppState, LogState, ProviderType, TokenCacheServiceState, TrayManagerState};
pub use services::provider_pool_service::ProviderPoolService;
pub use proxycast_services::provider_pool_service::ProviderPoolService;
// 重新导出 run 函数
// 重新导出 run 函数(main.rs 入口)
pub use app::run;
+3 -9
View File
@@ -1,12 +1,6 @@
//! 日志管理模块
//! 日志管理模块(桥接层)
//!
//! 核心逻辑已迁移到 proxycast-core crate,本文件保留扩展函数。
//! 纯逻辑已迁移到 `proxycast-core` crate,
//! 本模块保留兼容导出。
pub use proxycast_core::logger::*;
use crate::config::LoggingConfig;
/// 使用 LoggingConfig 创建 LogStore
pub fn create_log_store_from_config(logging: &LoggingConfig) -> LogStore {
LogStore::with_custom_config(logging.retention_days, logging.enabled)
}
-42
View File
@@ -1,42 +0,0 @@
# MCP 模块
MCP(Model Context Protocol)集成模块,提供 MCP 协议的客户端实现。
## 模块结构
| 文件 | 说明 |
|------|------|
| `mod.rs` | 模块导出和文档 |
| `types.rs` | MCP 数据类型定义(配置、工具、提示词、资源、错误) |
| `client.rs` | MCP 客户端实现(rmcp ClientHandler) |
| `manager.rs` | MCP 客户端管理器(连接池、缓存、生命周期) |
| `tool_converter.rs` | 工具格式转换器(OpenAI/Anthropic/Gemini) |
## 功能概览
### 服务器生命周期管理
- 启动/停止 MCP 服务器进程
- stdio 传输连接
- 状态监控和事件通知
### 工具管理
- 工具发现和缓存
- 工具调用路由
- 名称冲突解决(服务器前缀)
### 格式转换
- MCP → OpenAI function calling
- MCP → Anthropic tool use
- MCP → Gemini function declaration
## 依赖
- `rmcp`: Rust MCP SDK
- `tokio`: 异步运行时
- `serde`: 序列化/反序列化
- `thiserror`: 错误类型定义
## 相关文档
- 设计文档: `.kiro/specs/mcp-integration/design.md`
- 需求文档: `.kiro/specs/mcp-integration/requirements.md`
-19
View File
@@ -1,19 +0,0 @@
//! MCP(Model Context Protocol)模块
//!
//! 业务逻辑已迁移到 proxycast-mcp crate,
//! 本模块仅作为桥接层 re-export。
// 从 proxycast-mcp crate re-export 所有公开类型
pub use proxycast_mcp::client;
pub use proxycast_mcp::manager;
pub use proxycast_mcp::tool_converter;
pub use proxycast_mcp::types;
pub use proxycast_mcp::{McpClientManager, ProxyCastMcpClient};
pub use proxycast_mcp::{
McpClientWrapper, McpContent, McpError, McpManagerState, McpPromptArgument,
McpPromptDefinition, McpPromptMessage, McpPromptResult, McpResourceContent,
McpResourceDefinition, McpServerCapabilities, McpServerConfig, McpServerErrorPayload,
McpServerInfo, McpServerStartedPayload, McpServerStoppedPayload, McpToolCall,
McpToolDefinition, McpToolResult, McpToolsUpdatedPayload, ToolConverter,
};
-3
View File
@@ -1,3 +0,0 @@
//! Memory 模块 - 从 proxycast-core 重新导出
pub use proxycast_core::memory::*;
-5
View File
@@ -1,5 +0,0 @@
//! Middleware 模块
//!
//! 从 proxycast-core 重新导出
pub use proxycast_core::middleware::*;

Some files were not shown because too many files have changed in this diff Show More