20 Commits

Author SHA1 Message Date
4cdb22788f 修改面板大小
Some checks failed
Build Linux / Build Linux x86_64 (push) Has been cancelled
Build Test / Build Test (Linux) (push) Has been cancelled
Build Test / Build Test (Windows) (push) Has been cancelled
Build Test / Build Test (macOS) (push) Has been cancelled
Build for Open Source / build (Linux, ubuntu-22.04, ) (push) Has been cancelled
Build for Open Source / build (Windows, windows-latest, ) (push) Has been cancelled
Build for Open Source / build (macOS, macos-latest, ) (push) Has been cancelled
Release / build-linux (push) Has been cancelled
Release / build-macos (push) Has been cancelled
Build Test / Build Test Summary (push) Has been cancelled
Release / Create Release (push) Has been cancelled
2025-09-11 00:18:11 +08:00
3456f9e06d 修复packycode无法测速以及更新滴滴车地址 2025-09-10 23:16:59 +08:00
564d7a29fb 修复打包后CCR扫描不到 2025-09-08 09:39:45 +08:00
77837d3656 修复windows编译错误 2025-09-08 08:50:09 +08:00
6ef45f328c 修改版本
Some checks failed
Build for Open Source / build (Linux, ubuntu-22.04, ) (push) Has been cancelled
Build for Open Source / build (Windows, windows-latest, ) (push) Has been cancelled
Build for Open Source / build (macOS, macos-latest, ) (push) Has been cancelled
Release / build-linux (push) Has been cancelled
Release / build-macos (push) Has been cancelled
Release / Create Release (push) Has been cancelled
2025-09-08 00:34:50 +08:00
0e78b08549 完善CCR检测 2025-09-08 00:11:04 +08:00
50cce7a22c 删除冗余导入,优化代码结构 2025-09-07 23:23:18 +08:00
027999a9e5 适配windows 2025-09-07 23:11:49 +08:00
74e85fb8a2 适配windows 2025-09-06 20:53:39 +08:00
79d66a69a3 适配windows 2025-09-06 20:53:05 +08:00
e4245a6fed 配置导入导出
Some checks failed
Build Linux / Build Linux x86_64 (push) Has been cancelled
Build Test / Build Test (Linux) (push) Has been cancelled
Build Test / Build Test (Windows) (push) Has been cancelled
Build Test / Build Test (macOS) (push) Has been cancelled
Build Test / Build Test Summary (push) Has been cancelled
2025-09-06 18:43:52 +08:00
d90169ac6d 更新打包设置 2025-09-06 17:48:02 +08:00
7d15edd0c1 更新文档 2025-09-06 17:47:31 +08:00
b2e6f40269 删除重复 2025-09-06 17:46:48 +08:00
21c0a9e583 修改中转站 2025-09-06 17:35:03 +08:00
a91b3ebbb2 修改中转站 2025-09-06 15:07:09 +08:00
e8fe999d16 增加不同类型中转站 2025-09-05 23:51:05 +08:00
71adf8416a CCR 2025-09-05 22:16:06 +08:00
6e834a1a7c 增加主题快速切换按钮 2025-08-21 11:25:04 +08:00
c015fcf5f0 增加opus开启标识 2025-08-18 16:40:01 +08:00
36 changed files with 4887 additions and 1427 deletions

View File

@@ -16,7 +16,7 @@ jobs:
include:
# macOS 通用版本
- platform: 'macos-latest'
target: 'universal-apple-darwin'
target: ''
name: 'macOS'
# Linux x64

View File

@@ -17,7 +17,7 @@
## 📖 项目概述
基于 Tauri 2 的 Claude Code 图形界面,提供项目管理、AI代理、使用分析功能。
基于 Tauri 2 的 Claude Code 图形界面,集成项目管理、AI代理、使用分析、MCP服务器、API中转站等强大功能。
> 基于 [Asterisk Claudia](https://github.com/getAsterisk/claudia) 改进,参考 [Claude Suite](https://github.com/xinhai-ai/claude-suite) 和 [PackyCode Cost](https://github.com/94mashiro/packycode-cost)。
@@ -37,31 +37,55 @@
- 自动检测 Claude 项目,快速恢复会话
- 版本控制检查点,支持分支回滚
- 实时同步项目状态和历史
- Git Panel 集成,查看文件变更 diff
### 🤖 AI 代理系统
- 创建自定义代理,后台独立执行
- 详细运行日志,精细权限控制
- 非阻塞操作,高效任务管理
- 支持多代理并发运行
### 🔗 API 中转站
- 支持 PackyCode、Custom平台
- 实时额度查询,一键切换配置
- 自动同步到 Claude 设置
- 支持 PackyCode(公交车/滴滴车、DeepSeek、GLM、Qwen、Kimi 等平台
- 实时额度查询,公交车/滴滴车服务切换
- Token 脱敏显示,安全配置管理
- 自动同步到 Claude 设置,一键刷新 DNS
### 📊 使用分析
- 实时成本跟踪Token 详细统计
- 可视化图表,数据导出分析
- 按模型、项目、时间段分类
- SQLite 缓存优化,毫秒级查询响应
### 🎨 编辑增强
- Monaco 编辑器40+ 语言高亮
- 智能补全,实时诊断错误
- 多光标编辑,代码自动格式化
- 主题快速切换,深色/浅色模式
### 📁 文件监听
- 跨平台实时监听,外部修改同步
- 防抖机制,轮询降级方案
- 基于 Rust notify 高性能
- 支持大型项目文件变更追踪
### 🔌 MCP 服务器
- Model Context Protocol 支持
- 服务器配置管理,一键启用/禁用
- 支持多服务器并发管理
- 与 Claude 设置深度集成
### 🖥️ 终端集成
- 内置终端支持,执行命令行操作
- 会话数据持久化,历史记录保留
- 支持多终端标签页
- 自动识别系统 Shell
### 🎯 Claude Code Review (CCR)
- 集成 Claude Code Review 功能
- 一键启动/停止 CCR 服务
- 自动检测安装状态
- 支持打开 CCR UI 界面
## 🚀 安装使用
@@ -70,12 +94,23 @@
- `claude` 命令在 PATH 中可用
### 快速开始
1. 启动 Claudia
2. 选择 CC 代理 或 CC 项目模式
1. 下载并安装 Claudia
2. 启动应用,选择 CC 代理或 CC 项目模式
3. 创建代理或管理会话
4. 配置中转站享受更好的服务体验
### 中转站配置
菜单 → 中转站管理 → 创建 → 输入 Token → 启用
1. 菜单 → 中转站管理
2. 点击"创建中转站"
3. 选择服务类型PackyCode 公交车/滴滴车等)
4. 输入 API Token
5. 点击启用按钮
### 性能优化说明
- **用量分析**: 首次扫描后自动缓存,后续查询毫秒级响应
- **文件监听**: 采用防抖机制,避免频繁触发
- **大项目支持**: 优化了内存使用,支持大型代码库
- **实时响应**: 所有操作异步执行,界面始终流畅
## 🔨 从源码构建
@@ -110,15 +145,18 @@ bun run tauri build # 生产构建
## 🛠️ 开发
### 技术栈
- **前端**: React 18 + TypeScript + Vite 6 + Tailwind CSS v4
- **后端**: Rust + Tauri 2 + SQLite
- **编辑器**: Monaco Editor
- **前端**: React 18 + TypeScript + Vite 6 + Tailwind CSS v4 + shadcn/ui
- **后端**: Rust + Tauri 2 + SQLite (rusqlite)
- **编辑器**: Monaco Editor (VS Code 内核)
- **国际化**: i18next + fluent (中英双语)
- **文件监听**: notify crate (跨平台)
- **包管理**: Bun (替代 npm/yarn)
### 开发命令
```bash
bun run tauri dev # 启动开发服务器
bunx tsc --noEmit # 类型检查
cd src-tauri && cargo test # Rust 测试
cd src-tauri && cargo test.md # Rust 测试
bun run check # 完整检查
```
@@ -126,8 +164,21 @@ bun run check # 完整检查
```
claudia/
├── src/ # React 前端
│ ├── components/ # UI 组件
│ ├── hooks/ # 自定义 Hooks
│ ├── lib/ # 工具库和 API
│ ├── locales/ # 国际化资源
│ └── stores/ # Zustand 状态管理
├── src-tauri/ # Rust 后端
└── docs/ # 文档
│ ├── commands/ # Tauri 命令
│ ├── claude/ # Claude CLI 集成
│ └── utils/ # 工具函数
├── docs/ # 项目文档
│ ├── RELAY_STATION_*.md # 中转站文档
│ ├── PERFORMANCE_*.md # 性能优化文档
│ └── ... # 其他技术文档
└── .github/ # GitHub 配置
└── workflows/ # CI/CD 工作流
```
## 🔒 安全特性
@@ -135,6 +186,8 @@ claudia/
- 进程隔离,精细权限控制
- 本地存储,无数据收集
- 开源透明,代码可审计
- API Token 脱敏显示
- 配置文件加密存储
## 🤝 贡献
@@ -144,13 +197,15 @@ claudia/
## 📄 许可证
AGPL 许可证 - 详见 [LICENSE](LICENSE)
本项目采用 AGPL-3.0 许可证 - 详见 [LICENSE](LICENSE)
## 🙏 致谢
- [Tauri](https://tauri.app/) - 安全桌面应用框架
- [Asterisk Claudia](https://github.com/getAsterisk/claudia) - 原始项目
- [Claude](https://claude.ai) by Anthropic
- [Tauri](https://tauri.app/) - 安全高效的桌面应用框架
- [Asterisk Claudia](https://github.com/getAsterisk/claudia) - 原始项目灵感
- [Claude](https://claude.ai) by Anthropic - AI 核心能力
- [Monaco Editor](https://microsoft.github.io/monaco-editor/) - 强大的代码编辑器
- [shadcn/ui](https://ui.shadcn.com/) - 现代化 UI 组件库
---

View File

@@ -11,6 +11,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-select": "^2.1.3",
"@radix-ui/react-switch": "^1.1.3",
@@ -23,6 +24,7 @@
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.0.2",
"@tauri-apps/plugin-fs": "^2.4.2",
"@tauri-apps/plugin-global-shortcut": "^2.0.0",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-shell": "^2.0.1",
@@ -345,6 +347,8 @@
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="],
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="],
@@ -489,6 +493,8 @@
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.2.2", "", { "dependencies": { "@tauri-apps/api": "^2.0.0" } }, "sha512-Pm9qnXQq8ZVhAMFSEPwxvh+nWb2mk7LASVlNEHYaksHvcz8P6+ElR5U5dNL9Ofrm+uwhh1/gYKWswK8JJJAh6A=="],
"@tauri-apps/plugin-fs": ["@tauri-apps/plugin-fs@2.4.2", "https://registry.npmmirror.com/@tauri-apps/plugin-fs/-/plugin-fs-2.4.2.tgz", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-YGhmYuTgXGsi6AjoV+5mh2NvicgWBfVJHHheuck6oHD+HC9bVWPaHvCP0/Aw4pHDejwrvT8hE3+zZAaWf+hrig=="],
"@tauri-apps/plugin-global-shortcut": ["@tauri-apps/plugin-global-shortcut@2.2.1", "", { "dependencies": { "@tauri-apps/api": "^2.0.0" } }, "sha512-b64/TI1t5LIi2JY4OWlYjZpPRq60T5GVVL/no27sUuxaNUZY8dVtwsMtDUgxUpln2yR+P2PJsYlqY5V8sLSxEw=="],
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.0.0" } }, "sha512-yAbauwp8BCHIhhA48NN8rEf6OtfZBPCgTOCa10gmtoVCpmic5Bq+1Ba7C+NZOjogedkSiV7hAotjYnnbUVmYrw=="],
@@ -1413,6 +1419,8 @@
"@tauri-apps/plugin-clipboard-manager/@tauri-apps/api": ["@tauri-apps/api@2.7.0", "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.7.0.tgz", {}, "sha512-v7fVE8jqBl8xJFOcBafDzXFc8FnicoH3j8o8DNNs0tHuEBmXUDqrCOAzMRX0UkfpwqZLqvrvK0GNQ45DfnoVDg=="],
"@tauri-apps/plugin-fs/@tauri-apps/api": ["@tauri-apps/api@2.8.0", "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.8.0.tgz", {}, "sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw=="],
"@types/estree-jsx/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],

View File

@@ -1,7 +1,7 @@
{
"name": "claudia",
"private": true,
"version": "1.1.0",
"version": "1.2.0",
"license": "AGPL-3.0",
"type": "module",
"scripts": {
@@ -21,6 +21,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-select": "^2.1.3",
"@radix-ui/react-switch": "^1.1.3",
@@ -33,6 +34,7 @@
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.0.2",
"@tauri-apps/plugin-fs": "^2.4.2",
"@tauri-apps/plugin-global-shortcut": "^2.0.0",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-shell": "^2.0.1",

2
src-tauri/Cargo.lock generated
View File

@@ -718,7 +718,7 @@ dependencies = [
[[package]]
name = "claudia"
version = "1.1.0"
version = "1.2.0"
dependencies = [
"anyhow",
"async-trait",

View File

@@ -1,6 +1,6 @@
[package]
name = "claudia"
version = "1.1.0"
version = "1.2.0"
description = "GUI app and Toolkit for Claude Code"
authors = ["mufeedvh", "123vviekr"]
license = "AGPL-3.0"

View File

@@ -7,7 +7,7 @@
<key>LSMinimumSystemVersion</key>
<string>10.15</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<string>1.2.0</string>
<key>CFBundleName</key>
<string>Claudia</string>
<key>CFBundleDisplayName</key>

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -48,10 +48,37 @@ pub fn find_claude_binary(app_handle: &tauri::AppHandle) -> Result<String, Strin
) {
info!("Found stored claude path in database: {}", stored_path);
// Check if the path still exists
// Check if the path still exists and works
#[cfg(not(target_os = "windows"))]
let final_path = stored_path.clone();
#[cfg(not(target_os = "windows"))]
let path_buf = PathBuf::from(&stored_path);
#[cfg(target_os = "windows")]
let mut final_path = stored_path.clone();
#[cfg(target_os = "windows")]
let mut path_buf = PathBuf::from(&stored_path);
// On Windows, if stored path exists but is not executable (shell script), try .cmd version
#[cfg(target_os = "windows")]
if path_buf.exists() && !stored_path.ends_with(".cmd") && !stored_path.ends_with(".exe") {
// Test if the current path works by trying to get version
if let Err(_) = get_claude_version(&stored_path) {
// If it fails, try the .cmd version
let cmd_path = format!("{}.cmd", stored_path);
let cmd_path_buf = PathBuf::from(&cmd_path);
if cmd_path_buf.exists() {
if let Ok(_) = get_claude_version(&cmd_path) {
final_path = cmd_path;
path_buf = cmd_path_buf;
info!("Using .cmd version instead of shell script: {}", final_path);
}
}
}
}
if path_buf.exists() && path_buf.is_file() {
return Ok(stored_path);
return Ok(final_path);
} else {
warn!("Stored claude path no longer exists: {}", stored_path);
}
@@ -146,10 +173,8 @@ fn source_preference(installation: &ClaudeInstallation) -> u8 {
fn discover_system_installations() -> Vec<ClaudeInstallation> {
let mut installations = Vec::new();
// 1. Try 'which' command first (now works in production)
if let Some(installation) = try_which_command() {
installations.push(installation);
}
// 1. Try system command first (now works in production and can return multiple installations)
installations.extend(find_which_installations());
// 2. Check NVM paths
installations.extend(find_nvm_installations());
@@ -164,48 +189,111 @@ fn discover_system_installations() -> Vec<ClaudeInstallation> {
installations
}
/// Try using the 'which' command to find Claude
fn try_which_command() -> Option<ClaudeInstallation> {
debug!("Trying 'which claude' to find binary...");
/// Try using the command to find Claude installations
/// Returns multiple installations if found (Windows 'where' can return multiple paths)
fn find_which_installations() -> Vec<ClaudeInstallation> {
debug!("Trying to find claude binary...");
match Command::new("which").arg("claude").output() {
// Use 'where' on Windows, 'which' on Unix
#[cfg(target_os = "windows")]
let command_name = "where";
#[cfg(not(target_os = "windows"))]
let command_name = "which";
let mut installations = Vec::new();
match Command::new(command_name).arg("claude").output() {
Ok(output) if output.status.success() => {
let output_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
if output_str.is_empty() {
return None;
return installations;
}
// Process each line (Windows 'where' can return multiple paths)
for line in output_str.lines() {
let mut path = line.trim().to_string();
if path.is_empty() {
continue;
}
// Parse aliased output: "claude: aliased to /path/to/claude"
let path = if output_str.starts_with("claude:") && output_str.contains("aliased to") {
output_str
.split("aliased to")
.nth(1)
.map(|s| s.trim().to_string())
if path.starts_with("claude:") && path.contains("aliased to") {
if let Some(aliased_path) = path.split("aliased to").nth(1) {
path = aliased_path.trim().to_string();
} else {
Some(output_str)
}?;
continue;
}
}
debug!("'which' found claude at: {}", path);
// Convert Unix-style path to Windows path if needed
#[cfg(target_os = "windows")]
let path = {
if path.starts_with("/c/") {
// Convert /c/path to C:\path
let windows_path = path.replace("/c/", "C:\\").replace("/", "\\");
windows_path
} else if path.starts_with("/") && path.len() > 3 && path.chars().nth(2) == Some('/') {
// Convert /X/path to X:\path where X is drive letter
let drive = path.chars().nth(1).unwrap();
let rest = &path[3..];
format!("{}:\\{}", drive.to_uppercase(), rest.replace("/", "\\"))
} else {
path
}
};
#[cfg(not(target_os = "windows"))]
let path = path;
debug!("'{}' found claude at: {}", command_name, path);
// On Windows, prefer .cmd files over shell scripts
#[cfg(target_os = "windows")]
let final_path = {
if !path.ends_with(".cmd") && !path.ends_with(".exe") {
// Check if there's a .cmd file alongside
let cmd_path = format!("{}.cmd", path);
if PathBuf::from(&cmd_path).exists() {
// Only use .cmd if the original doesn't work
if let Err(_) = get_claude_version(&path) {
cmd_path
} else {
path
}
} else {
path
}
} else {
path
}
};
#[cfg(not(target_os = "windows"))]
let final_path = path;
// Verify the path exists
if !PathBuf::from(&path).exists() {
warn!("Path from 'which' does not exist: {}", path);
return None;
if !PathBuf::from(&final_path).exists() {
warn!("Path from '{}' does not exist: {}", command_name, final_path);
continue;
}
// Get version
let version = get_claude_version(&path).ok().flatten();
let version = get_claude_version(&final_path).ok().flatten();
Some(ClaudeInstallation {
path,
installations.push(ClaudeInstallation {
path: final_path,
version,
source: "which".to_string(),
source: command_name.to_string(),
installation_type: InstallationType::System,
})
});
}
_ => None,
}
_ => {}
}
installations
}
/// Find Claude installations in NVM directories

View File

@@ -1,5 +1,6 @@
use std::fs;
use std::path::PathBuf;
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use dirs::home_dir;
@@ -16,9 +17,27 @@ pub struct ClaudeConfig {
pub model: Option<String>,
#[serde(rename = "apiKeyHelper", skip_serializing_if = "Option::is_none")]
pub api_key_helper: Option<String>,
#[serde(rename = "statusLine", skip_serializing_if = "Option::is_none")]
pub status_line: Option<StatusLineConfig>,
// 使用 flatten 来支持任何其他未知字段
#[serde(flatten)]
pub extra_fields: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatusLineConfig {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub config_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub padding: Option<i32>,
// 支持其他可能的 statusLine 字段
#[serde(flatten)]
pub extra_fields: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeEnv {
#[serde(rename = "ANTHROPIC_AUTH_TOKEN", skip_serializing_if = "Option::is_none")]
pub anthropic_auth_token: Option<String>,
@@ -26,6 +45,20 @@ pub struct ClaudeEnv {
pub anthropic_base_url: Option<String>,
#[serde(rename = "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", skip_serializing_if = "Option::is_none")]
pub disable_nonessential_traffic: Option<String>,
// 使用 flatten 来支持任何其他环境变量
#[serde(flatten)]
pub extra_fields: std::collections::HashMap<String, serde_json::Value>,
}
impl Default for ClaudeEnv {
fn default() -> Self {
Self {
anthropic_auth_token: None,
anthropic_base_url: None,
disable_nonessential_traffic: None,
extra_fields: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -59,6 +92,8 @@ pub fn read_claude_config() -> Result<ClaudeConfig, String> {
permissions: Some(ClaudePermissions::default()),
model: None,
api_key_helper: None,
status_line: None,
extra_fields: HashMap::new(),
});
}
@@ -146,7 +181,7 @@ pub fn restore_claude_config() -> Result<(), String> {
Ok(())
}
/// 根据中转站配置更新 Claude 配置
/// 根据中转站配置更新 Claude 配置(仅更新 API 相关字段)
pub fn apply_relay_station_to_config(station: &RelayStation) -> Result<(), String> {
// 先备份当前配置
backup_claude_config()?;
@@ -154,30 +189,21 @@ pub fn apply_relay_station_to_config(station: &RelayStation) -> Result<(), Strin
// 读取当前配置
let mut config = read_claude_config()?;
// 更新 API URL
// 更新这三个关键字段,保留其他所有配置不变:
// 1. ANTHROPIC_BASE_URL
config.env.anthropic_base_url = Some(station.api_url.clone());
// 更新 API Token
// 2. ANTHROPIC_AUTH_TOKEN
config.env.anthropic_auth_token = Some(station.system_token.clone());
// 将中转站的 token 也设置到 apiKeyHelper
// 格式echo 'token'
// 3. apiKeyHelper - 设置为 echo 格式
config.api_key_helper = Some(format!("echo '{}'", station.system_token));
// 如果是特定适配器,可能需要特殊处理
// 如果是特定适配器,可能需要特殊处理 URL 格式
match station.adapter.as_str() {
"packycode" => {
// PackyCode 使用原始配置,不做特殊处理
}
"newapi" | "oneapi" => {
// NewAPI 和 OneAPI 兼容 OpenAI 格式,不需要特殊处理
}
"yourapi" => {
// YourAPI 可能需要特殊的路径格式
if !station.api_url.ends_with("/v1") {
config.env.anthropic_base_url = Some(format!("{}/v1", station.api_url));
}
}
"custom" => {
// 自定义适配器,使用原始配置
}
@@ -187,7 +213,7 @@ pub fn apply_relay_station_to_config(station: &RelayStation) -> Result<(), Strin
// 写入更新后的配置
write_claude_config(&config)?;
log::info!("已将中转站 {} 的配置应用到 Claude 配置文件", station.name);
log::info!("已将中转站 {} 的 API 配置apiKeyHelper, ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN应用到 Claude 配置文件", station.name);
Ok(())
}

File diff suppressed because it is too large Load Diff

View File

@@ -724,3 +724,75 @@ pub async fn mcp_save_project_config(
Ok("Project MCP configuration saved".to_string())
}
/// Export configuration for MCP server
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MCPExportConfig {
pub name: String,
pub transport: String,
pub command: Option<String>,
pub args: Vec<String>,
pub env: HashMap<String, String>,
pub url: Option<String>,
pub scope: String,
}
/// Export result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MCPExportResult {
pub servers: Vec<MCPExportConfig>,
pub format: String, // "single" or "multiple"
}
/// Exports all MCP servers configuration
#[tauri::command]
pub async fn mcp_export_servers(app: AppHandle) -> Result<MCPExportResult, String> {
info!("Exporting MCP servers configuration");
// Get all servers
let servers = mcp_list(app.clone()).await?;
if servers.is_empty() {
return Ok(MCPExportResult {
servers: vec![],
format: "multiple".to_string(),
});
}
// Get detailed information for each server
let mut export_configs = Vec::new();
for server in &servers {
match mcp_get(app.clone(), server.name.clone()).await {
Ok(detailed_server) => {
export_configs.push(MCPExportConfig {
name: detailed_server.name,
transport: detailed_server.transport,
command: detailed_server.command,
args: detailed_server.args,
env: detailed_server.env,
url: detailed_server.url,
scope: detailed_server.scope,
});
}
Err(e) => {
error!("Failed to get details for server {}: {}", server.name, e);
// Still include basic information
export_configs.push(MCPExportConfig {
name: server.name.clone(),
transport: server.transport.clone(),
command: server.command.clone(),
args: server.args.clone(),
env: server.env.clone(),
url: server.url.clone(),
scope: server.scope.clone(),
});
}
}
}
Ok(MCPExportResult {
format: if export_configs.len() == 1 { "single" } else { "multiple" }.to_string(),
servers: export_configs,
})
}

View File

@@ -14,3 +14,5 @@ pub mod packycode_nodes;
pub mod filesystem;
pub mod git;
pub mod terminal;
pub mod ccr;
pub mod system;

File diff suppressed because it is too large Load Diff

View File

@@ -15,9 +15,10 @@ use crate::claude_config;
#[serde(rename_all = "snake_case")]
pub enum RelayStationAdapter {
Packycode, // PackyCode 平台(放在第一位)
Newapi, // NewAPI 兼容平台
Oneapi, // OneAPI 兼容平台
Yourapi, // YourAPI 特定平台
Deepseek, // DeepSeek v3.1
Glm, // 智谱GLM
Qwen, // 千问Qwen
Kimi, // Kimi k2
Custom, // 自定义简单配置
}
@@ -25,9 +26,10 @@ impl RelayStationAdapter {
pub fn as_str(&self) -> &str {
match self {
RelayStationAdapter::Packycode => "packycode",
RelayStationAdapter::Newapi => "newapi",
RelayStationAdapter::Oneapi => "oneapi",
RelayStationAdapter::Yourapi => "yourapi",
RelayStationAdapter::Deepseek => "deepseek",
RelayStationAdapter::Glm => "glm",
RelayStationAdapter::Qwen => "qwen",
RelayStationAdapter::Kimi => "kimi",
RelayStationAdapter::Custom => "custom",
}
}
@@ -52,7 +54,7 @@ pub struct RelayStation {
pub adapter: RelayStationAdapter, // 适配器类型
pub auth_method: AuthMethod, // 认证方式
pub system_token: String, // 系统令牌
pub user_id: Option<String>, // 用户 IDNewAPI 必需
pub user_id: Option<String>, // 用户 ID可选
pub adapter_config: Option<HashMap<String, serde_json::Value>>, // 适配器特定配置
pub enabled: bool, // 启用状态
pub created_at: i64, // 创建时间
@@ -698,3 +700,211 @@ pub async fn relay_station_get_current_config() -> Result<HashMap<String, Option
Ok(config)
}
/// 导出所有中转站配置
#[command]
pub async fn relay_stations_export(db: State<'_, AgentDb>) -> Result<Vec<RelayStation>, String> {
let conn = db.0.lock().map_err(|e| {
log::error!("Failed to acquire database lock: {}", e);
i18n::t("database.lock_failed")
})?;
// 确保表存在
init_relay_stations_tables(&conn).map_err(|e| {
log::error!("Failed to initialize relay stations tables: {}", e);
i18n::t("database.init_failed")
})?;
let mut stmt = conn.prepare("SELECT * FROM relay_stations ORDER BY created_at DESC")
.map_err(|e| {
log::error!("Failed to prepare statement: {}", e);
i18n::t("database.query_failed")
})?;
let stations = stmt.query_map([], |row| RelayStation::from_row(row))
.map_err(|e| {
log::error!("Failed to query relay stations: {}", e);
i18n::t("database.query_failed")
})?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| {
log::error!("Failed to collect relay stations: {}", e);
i18n::t("database.query_failed")
})?;
log::info!("Exported {} relay stations", stations.len());
Ok(stations)
}
/// 导入结果统计
#[derive(Debug, Serialize, Deserialize)]
pub struct ImportResult {
pub total: usize, // 总数
pub imported: usize, // 成功导入数
pub skipped: usize, // 跳过数(重复)
pub failed: usize, // 失败数
pub message: String, // 结果消息
}
/// 导入中转站配置
#[derive(Debug, Serialize, Deserialize)]
pub struct ImportRelayStationsRequest {
pub stations: Vec<CreateRelayStationRequest>,
pub clear_existing: bool, // 是否清除现有配置
}
#[command]
pub async fn relay_stations_import(
request: ImportRelayStationsRequest,
db: State<'_, AgentDb>
) -> Result<ImportResult, String> {
let mut conn = db.0.lock().map_err(|e| {
log::error!("Failed to acquire database lock: {}", e);
i18n::t("database.lock_failed")
})?;
// 确保表存在
init_relay_stations_tables(&conn).map_err(|e| {
log::error!("Failed to initialize relay stations tables: {}", e);
i18n::t("database.init_failed")
})?;
// 开始事务
let tx = conn.transaction().map_err(|e| {
log::error!("Failed to start transaction: {}", e);
i18n::t("database.transaction_failed")
})?;
// 如果需要清除现有配置
if request.clear_existing {
tx.execute("DELETE FROM relay_stations", [])
.map_err(|e| {
log::error!("Failed to clear existing relay stations: {}", e);
i18n::t("relay_station.clear_failed")
})?;
log::info!("Cleared existing relay stations");
}
// 获取现有的中转站列表(用于重复检查)
let existing_stations: Vec<(String, String)> = if !request.clear_existing {
let mut stmt = tx.prepare("SELECT api_url, system_token FROM relay_stations")
.map_err(|e| {
log::error!("Failed to prepare statement: {}", e);
i18n::t("database.query_failed")
})?;
let stations_iter = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(|e| {
log::error!("Failed to query existing stations: {}", e);
i18n::t("database.query_failed")
})?;
// 立即收集结果,避免生命周期问题
let mut existing = Vec::new();
for station_result in stations_iter {
match station_result {
Ok(station) => existing.push(station),
Err(e) => {
log::error!("Failed to read existing station: {}", e);
return Err(i18n::t("database.query_failed"));
}
}
}
existing
} else {
Vec::new()
};
// 导入新的中转站
let total = request.stations.len();
let mut imported_count = 0;
let mut skipped_count = 0;
let mut failed_count = 0;
let now = Utc::now().timestamp();
for station_request in request.stations {
// 验证输入
if let Err(e) = validate_relay_station_request(&station_request.name, &station_request.api_url, &station_request.system_token) {
log::warn!("Skipping invalid station {}: {}", station_request.name, e);
failed_count += 1;
continue;
}
// 检查是否重复(同时匹配 api_url 和 system_token
let is_duplicate = existing_stations.iter().any(|(url, token)| {
url == &station_request.api_url && token == &station_request.system_token
});
if is_duplicate {
log::info!("Skipping duplicate station: {} ({})", station_request.name, station_request.api_url);
skipped_count += 1;
continue;
}
let id = Uuid::new_v4().to_string();
let adapter_str = serde_json::to_string(&station_request.adapter)
.map_err(|_| i18n::t("relay_station.invalid_adapter"))?
.trim_matches('"').to_string();
let auth_method_str = serde_json::to_string(&station_request.auth_method)
.map_err(|_| i18n::t("relay_station.invalid_auth_method"))?
.trim_matches('"').to_string();
let adapter_config_str = station_request.adapter_config.as_ref()
.map(|config| serde_json::to_string(config))
.transpose()
.map_err(|_| i18n::t("relay_station.invalid_config"))?;
match tx.execute(
r#"
INSERT INTO relay_stations
(id, name, description, api_url, adapter, auth_method, system_token, user_id, adapter_config, enabled, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
"#,
params![
id,
station_request.name,
station_request.description,
station_request.api_url,
adapter_str,
auth_method_str,
station_request.system_token,
station_request.user_id,
adapter_config_str,
if station_request.enabled { 1 } else { 0 },
now,
now
],
) {
Ok(_) => imported_count += 1,
Err(e) => {
log::error!("Failed to import relay station: {}", e);
failed_count += 1;
}
}
}
// 提交事务
tx.commit().map_err(|e| {
log::error!("Failed to commit transaction: {}", e);
i18n::t("database.transaction_failed")
})?;
let message = format!(
"导入完成:总计 {} 个,成功 {} 个,跳过 {} 个(重复),失败 {}",
total, imported_count, skipped_count, failed_count
);
log::info!("{}", message);
Ok(ImportResult {
total,
imported: imported_count,
skipped: skipped_count,
failed: failed_count,
message,
})
}

View File

@@ -0,0 +1,62 @@
use std::process::{Command, Stdio};
/// Flush system DNS cache across platforms
#[tauri::command]
pub async fn flush_dns() -> Result<String, String> {
#[cfg(target_os = "windows")]
{
let output = Command::new("ipconfig")
.arg("/flushdns")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| format!("Failed to execute ipconfig: {}", e))?;
if output.status.success() {
return Ok("DNS cache flushed".into());
} else {
let err = String::from_utf8_lossy(&output.stderr).to_string();
return Err(if err.is_empty() { "ipconfig /flushdns failed".into() } else { err });
}
}
#[cfg(target_os = "macos")]
{
let output = Command::new("dscacheutil")
.arg("-flushcache")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| format!("Failed to execute dscacheutil: {}", e))?;
if output.status.success() {
return Ok("DNS cache flushed".into());
} else {
let err = String::from_utf8_lossy(&output.stderr).to_string();
return Err(if err.is_empty() { "dscacheutil -flushcache failed".into() } else { err });
}
}
#[cfg(target_os = "linux")]
{
// Try common Linux methods in order
let attempts: Vec<(&str, Vec<&str>)> = vec![
("resolvectl", vec!["flush-caches"]),
("systemd-resolve", vec!["--flush-caches"]),
("sh", vec!["-c", "service nscd restart || service dnsmasq restart || rc-service nscd restart"]),
];
for (cmd, args) in attempts {
if let Ok(output) = Command::new(cmd)
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
{
if output.status.success() {
return Ok("DNS cache flushed".into());
}
}
}
Err("No supported DNS flush method succeeded on this Linux system".into())
}
}

View File

@@ -5,7 +5,7 @@ use tauri::{AppHandle, Emitter, State};
use tokio::sync::Mutex;
use uuid::Uuid;
use anyhow::Result;
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use portable_pty::{native_pty_system, CommandBuilder, PtySize, Child, MasterPty};
use std::io::{Read, Write};
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -19,6 +19,8 @@ pub struct TerminalSession {
/// Terminal child process wrapper
pub struct TerminalChild {
writer: Arc<Mutex<Box<dyn Write + Send>>>,
_master: Box<dyn MasterPty + Send>, // Keep master PTY alive
_child: Box<dyn Child + Send + Sync>, // Keep child process alive
}
/// State for managing terminal sessions
@@ -60,36 +62,69 @@ pub async fn create_terminal_session(
// Get shell command
let shell = get_default_shell();
log::info!("Using shell: {}", shell);
let mut cmd = CommandBuilder::new(&shell);
// Set as login interactive shell
// Set shell-specific arguments
if cfg!(target_os = "windows") {
if shell.contains("pwsh") {
// PowerShell Core - stay interactive
cmd.arg("-NoLogo");
cmd.arg("-NoExit");
} else if shell.contains("powershell") {
// Windows PowerShell - stay interactive
cmd.arg("-NoLogo");
cmd.arg("-NoExit");
} else {
// cmd.exe - use /K to keep session open
cmd.arg("/K");
}
} else {
// Unix shells: Set as login interactive shell
if shell.contains("bash") || shell.contains("zsh") {
cmd.arg("-il"); // Interactive login shell
} else if shell.contains("fish") {
cmd.arg("-il");
}
}
// Set working directory
cmd.cwd(working_directory.clone());
// Set environment variables
// Set environment variables based on platform
if cfg!(target_os = "windows") {
// Windows-specific environment
cmd.env("TERM", "xterm-256color");
// Keep PATH and other essential Windows environment variables
for (key, value) in std::env::vars() {
if !key.starts_with("TAURI_") && !key.starts_with("VITE_") {
cmd.env(&key, &value);
}
}
} else {
// Unix-specific environment
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
cmd.env("LANG", std::env::var("LANG").unwrap_or_else(|_| "en_US.UTF-8".to_string()));
cmd.env("LC_ALL", std::env::var("LC_ALL").unwrap_or_else(|_| "en_US.UTF-8".to_string()));
cmd.env("LC_CTYPE", std::env::var("LC_CTYPE").unwrap_or_else(|_| "en_US.UTF-8".to_string()));
// 继承其他环境变量
// Inherit other Unix environment variables
for (key, value) in std::env::vars() {
if !key.starts_with("TERM") && !key.starts_with("COLORTERM") && !key.starts_with("LC_") && !key.starts_with("LANG") {
if !key.starts_with("TERM") && !key.starts_with("COLORTERM") &&
!key.starts_with("LC_") && !key.starts_with("LANG") &&
!key.starts_with("TAURI_") && !key.starts_with("VITE_") {
cmd.env(&key, &value);
}
}
}
// Spawn the shell process
let _child = pty_pair.slave.spawn_command(cmd)
let child = pty_pair.slave.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn shell: {}", e))?;
log::info!("Shell process spawned successfully for session: {}", session_id);
// Get writer for stdin
let writer = pty_pair.master.take_writer()
.map_err(|e| format!("Failed to get PTY writer: {}", e))?;
@@ -103,15 +138,20 @@ pub async fn create_terminal_session(
// Spawn reader thread
std::thread::spawn(move || {
let mut buffer = [0u8; 4096];
log::info!("PTY reader thread started for session: {}", session_id_clone);
loop {
match reader.read(&mut buffer) {
Ok(0) => break, // EOF
Ok(0) => {
log::warn!("PTY reader got EOF for session: {}", session_id_clone);
break; // EOF
}
Ok(n) => {
let data = String::from_utf8_lossy(&buffer[..n]).to_string();
log::debug!("PTY reader got {} bytes for session {}: {:?}", n, session_id_clone, data);
let _ = app_handle_clone.emit(&format!("terminal-output:{}", session_id_clone), &data);
}
Err(e) => {
log::error!("Error reading PTY output: {}", e);
log::error!("Error reading PTY output for session {}: {}", session_id_clone, e);
break;
}
}
@@ -119,9 +159,11 @@ pub async fn create_terminal_session(
log::debug!("PTY reader thread finished for session: {}", session_id_clone);
});
// Store the session with PTY writer
// Store the session with PTY writer, master PTY and child process
let terminal_child = TerminalChild {
writer: Arc::new(Mutex::new(writer)),
_master: pty_pair.master,
_child: child,
};
{
@@ -245,13 +287,13 @@ pub async fn cleanup_terminal_sessions(
/// Get the default shell for the current platform
fn get_default_shell() -> String {
if cfg!(target_os = "windows") {
// Try PowerShell first, fallback to cmd
// Try PowerShell Core (pwsh) first, then Windows PowerShell, fallback to cmd
if std::process::Command::new("pwsh").arg("--version").output().is_ok() {
"pwsh".to_string()
} else if std::process::Command::new("powershell").arg("-Version").output().is_ok() {
"powershell".to_string()
} else {
"cmd".to_string()
"cmd.exe".to_string()
}
} else {
// Unix-like systems: try zsh, bash, then sh

View File

@@ -36,7 +36,7 @@ use commands::claude::{
use commands::mcp::{
mcp_add, mcp_add_from_claude_desktop, mcp_add_json, mcp_get, mcp_get_server_status, mcp_list,
mcp_read_project_config, mcp_remove, mcp_reset_project_choices, mcp_save_project_config,
mcp_serve, mcp_test_connection,
mcp_serve, mcp_test_connection, mcp_export_servers,
};
use commands::usage::{
@@ -58,6 +58,7 @@ use commands::relay_stations::{
relay_stations_list, relay_station_get, relay_station_create, relay_station_update,
relay_station_delete, relay_station_toggle_enable, relay_station_sync_config,
relay_station_restore_config, relay_station_get_current_config,
relay_stations_export, relay_stations_import,
};
use commands::relay_adapters::{
relay_station_get_info, relay_station_get_user_info,
@@ -79,6 +80,11 @@ use commands::terminal::{
create_terminal_session, send_terminal_input, close_terminal_session,
list_terminal_sessions, resize_terminal, cleanup_terminal_sessions, TerminalState,
};
use commands::ccr::{
check_ccr_installation, get_ccr_version, get_ccr_service_status, start_ccr_service,
stop_ccr_service, restart_ccr_service, open_ccr_ui, get_ccr_config_path,
};
use commands::system::flush_dns;
use process::ProcessRegistryState;
use file_watcher::FileWatcherState;
use std::sync::Mutex;
@@ -92,6 +98,7 @@ fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_log::Builder::new()
.level(log::LevelFilter::Debug)
@@ -342,6 +349,7 @@ fn main() {
mcp_get_server_status,
mcp_read_project_config,
mcp_save_project_config,
mcp_export_servers,
// Storage Management
storage_list_tables,
@@ -377,6 +385,8 @@ fn main() {
relay_station_sync_config,
relay_station_restore_config,
relay_station_get_current_config,
relay_stations_export,
relay_stations_import,
relay_station_get_info,
relay_station_get_user_info,
relay_station_test_connection,
@@ -417,6 +427,19 @@ fn main() {
list_terminal_sessions,
resize_terminal,
cleanup_terminal_sessions,
// CCR (Claude Code Router)
check_ccr_installation,
get_ccr_version,
get_ccr_service_status,
start_ccr_service,
stop_ccr_service,
restart_ccr_service,
open_ccr_ui,
get_ccr_config_path,
// System utilities
flush_dns,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Claudia",
"version": "1.1.0",
"version": "1.2.0",
"identifier": "claudia.asterisk.so",
"build": {
"beforeDevCommand": "bun run dev",
@@ -18,7 +18,7 @@
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost blob: data:; style-src 'self' 'unsafe-inline' blob: data: asset: https://asset.localhost; style-src-elem 'self' 'unsafe-inline' blob: data: asset: https://asset.localhost; style-src-attr 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval' https://app.posthog.com https://*.posthog.com https://*.i.posthog.com https://*.assets.i.posthog.com; worker-src 'self' blob: asset: https://asset.localhost; font-src 'self' data: blob: asset: https://asset.localhost; connect-src 'self' ipc: https://ipc.localhost https://app.posthog.com https://*.posthog.com https://*.i.posthog.com",
"csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost blob: data:; style-src 'self' 'unsafe-inline' blob: data: asset: https://asset.localhost; style-src-elem 'self' 'unsafe-inline' blob: data: asset: https://asset.localhost; style-src-attr 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval' https://app.posthog.com https://*.posthog.com https://*.i.posthog.com https://*.assets.i.posthog.com; worker-src 'self' blob: asset: https://asset.localhost; font-src 'self' data: blob: asset: https://asset.localhost; connect-src 'self' ipc: http://ipc.localhost https://ipc.localhost https://app.posthog.com https://*.posthog.com https://*.i.posthog.com https://api.packycode.com https://api-hk-cn2.packycode.com https://api-us-cmin2.packycode.com https://api-us-4837.packycode.com https://api-us-cn2.packycode.com https://api-cf-pro.packycode.com https://share-api.packycode.com https://share-api-cf-pro.packycode.com https://share-api-hk-cn2.packycode.com",
"assetProtocol": {
"enable": true,
"scope": [
@@ -28,40 +28,20 @@
}
},
"plugins": {
"fs": {
"scope": [
"$HOME/**"
],
"allow": [
"readFile",
"writeFile",
"readDir",
"copyFile",
"createDir",
"removeDir",
"removeFile",
"renameFile",
"exists"
]
},
"shell": {
"open": true
}
},
"bundle": {
"active": true,
"targets": [
"deb",
"rpm",
"appimage",
"app",
"dmg"
],
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns"
"icons/icon.icns",
"icons/icon.ico",
"icons/icon.png"
],
"resources": [],
"externalBin": [],

View File

@@ -29,6 +29,7 @@ import { useAppLifecycle, useTrackEvent } from "@/hooks";
import { useTranslation } from "@/hooks/useTranslation";
import { WelcomePage } from "@/components/WelcomePage";
import RelayStationManager from "@/components/RelayStationManager";
import { CcrRouterManager } from "@/components/CcrRouterManager";
import i18n from "@/lib/i18n";
type View =
@@ -44,6 +45,7 @@ type View =
| "agent-run-view"
| "mcp"
| "relay-stations"
| "ccr-router"
| "usage-dashboard"
| "project-settings"
| "tabs"; // New view for tab-based interface
@@ -282,6 +284,11 @@ function AppContent() {
<RelayStationManager onBack={() => handleViewChange("welcome")} />
);
case "ccr-router":
return (
<CcrRouterManager onBack={() => handleViewChange("welcome")} />
);
case "cc-agents":
return (
<CCAgents

View File

@@ -0,0 +1,488 @@
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { ArrowLeft, Play, Square, RotateCcw, ExternalLink, Download, AlertCircle, CheckCircle, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Toast, ToastContainer } from "@/components/ui/toast";
import { ccrApi, type CcrServiceStatus } from "@/lib/api";
import { open } from '@tauri-apps/plugin-shell';
interface CcrRouterManagerProps {
onBack: () => void;
}
export function CcrRouterManager({ onBack }: CcrRouterManagerProps) {
const [serviceStatus, setServiceStatus] = useState<CcrServiceStatus | null>(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null);
const [configPath, setConfigPath] = useState<string>("");
useEffect(() => {
loadServiceStatus();
loadConfigPath();
}, []);
const loadServiceStatus = async () => {
try {
setLoading(true);
const status = await ccrApi.getServiceStatus();
console.log("CCR service status:", status);
console.log("CCR raw output:", status.raw_output);
setServiceStatus(status);
} catch (error) {
console.error("Failed to load CCR service status:", error);
setToast({
message: `加载CCR服务状态失败: ${error}`,
type: "error"
});
} finally {
setLoading(false);
}
};
const loadConfigPath = async () => {
try {
const path = await ccrApi.getConfigPath();
setConfigPath(path);
} catch (error) {
console.error("Failed to get config path:", error);
}
};
const handleStartService = async () => {
try {
setActionLoading(true);
const result = await ccrApi.startService();
setServiceStatus(result.status);
setToast({
message: result.message,
type: "success"
});
} catch (error) {
console.error("Failed to start CCR service:", error);
setToast({
message: `启动CCR服务失败: ${error}`,
type: "error"
});
} finally {
setActionLoading(false);
}
};
const handleStopService = async () => {
try {
setActionLoading(true);
const result = await ccrApi.stopService();
setServiceStatus(result.status);
setToast({
message: result.message,
type: "success"
});
} catch (error) {
console.error("Failed to stop CCR service:", error);
setToast({
message: `停止CCR服务失败: ${error}`,
type: "error"
});
} finally {
setActionLoading(false);
}
};
const handleRestartService = async () => {
try {
setActionLoading(true);
const result = await ccrApi.restartService();
setServiceStatus(result.status);
setToast({
message: result.message,
type: "success"
});
} catch (error) {
console.error("Failed to restart CCR service:", error);
setToast({
message: `重启CCR服务失败: ${error}`,
type: "error"
});
} finally {
setActionLoading(false);
}
};
const handleOpenUI = async () => {
try {
setActionLoading(true);
// 如果服务未运行,先尝试启动
if (!serviceStatus?.is_running) {
setToast({
message: "检测到服务未运行,正在启动...",
type: "info"
});
const startResult = await ccrApi.startService();
setServiceStatus(startResult.status);
if (!startResult.status.is_running) {
throw new Error("服务启动失败");
}
// 等待服务完全启动
await new Promise(resolve => setTimeout(resolve, 3000));
}
await ccrApi.openUI();
setToast({
message: "正在打开CCR UI...",
type: "info"
});
// 刷新服务状态
setTimeout(() => {
loadServiceStatus();
}, 2000);
} catch (error) {
console.error("Failed to open CCR UI:", error);
setToast({
message: `打开CCR UI失败: ${error}`,
type: "error"
});
} finally {
setActionLoading(false);
}
};
const handleOpenInBrowser = async () => {
try {
// 如果服务未运行,先尝试启动
if (!serviceStatus?.is_running) {
setActionLoading(true);
setToast({
message: "检测到服务未运行,正在启动...",
type: "info"
});
const startResult = await ccrApi.startService();
setServiceStatus(startResult.status);
if (!startResult.status.is_running) {
throw new Error("服务启动失败");
}
// 等待服务完全启动
await new Promise(resolve => setTimeout(resolve, 2000));
setActionLoading(false);
}
if (serviceStatus?.endpoint) {
open(`${serviceStatus.endpoint}/ui/`);
setToast({
message: "正在打开CCR管理界面...",
type: "info"
});
}
} catch (error) {
console.error("Failed to open CCR UI in browser:", error);
setToast({
message: `打开管理界面失败: ${error}`,
type: "error"
});
setActionLoading(false);
}
};
const renderServiceStatus = () => {
if (!serviceStatus) return null;
const statusColor = serviceStatus.is_running ? "bg-green-500" : "bg-red-500";
const statusText = serviceStatus.is_running ? "运行中" : "已停止";
return (
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${statusColor}`}></div>
<span className="font-medium">{statusText}</span>
{serviceStatus.is_running && serviceStatus.port && (
<Badge variant="secondary"> {serviceStatus.port}</Badge>
)}
</div>
);
};
const renderInstallationStatus = () => {
if (!serviceStatus) return null;
return (
<div className="flex items-center gap-2">
{serviceStatus.has_ccr_binary ? (
<>
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-green-600"></span>
{serviceStatus.ccr_version && (
<Badge variant="outline">{serviceStatus.ccr_version}</Badge>
)}
</>
) : (
<>
<AlertCircle className="w-4 h-4 text-red-500" />
<span className="text-red-600"></span>
</>
)}
</div>
);
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
);
}
return (
<div className="flex-1 overflow-y-auto">
<div className="container mx-auto p-6 max-w-4xl">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="mb-6"
>
<div className="flex items-center gap-3 mb-4">
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight">CCR </h1>
<p className="mt-1 text-sm text-muted-foreground">
Claude Code Router
</p>
</div>
</div>
</motion.div>
{/* Service Status Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="mb-6"
>
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span></span>
<Button
variant="outline"
size="sm"
onClick={loadServiceStatus}
disabled={loading}
>
</Button>
</CardTitle>
<CardDescription>
CCR
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">:</span>
{renderInstallationStatus()}
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">:</span>
{renderServiceStatus()}
</div>
{serviceStatus?.endpoint && (
<div className="flex items-center justify-between">
<span className="text-sm font-medium">:</span>
<Button
variant="link"
size="sm"
onClick={handleOpenInBrowser}
className="p-0 h-auto"
>
{serviceStatus.endpoint}/ui/
<ExternalLink className="w-3 h-3 ml-1" />
</Button>
</div>
)}
{serviceStatus?.process_id && (
<div className="flex items-center justify-between">
<span className="text-sm font-medium"> ID:</span>
<Badge variant="outline">{serviceStatus.process_id}</Badge>
</div>
)}
{configPath && (
<div className="flex items-center justify-between">
<span className="text-sm font-medium">:</span>
<span className="text-xs text-muted-foreground font-mono">
{configPath}
</span>
</div>
)}
</CardContent>
</Card>
</motion.div>
{/* Control Panel */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="mb-6"
>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
CCR
</CardDescription>
</CardHeader>
<CardContent>
{serviceStatus?.has_ccr_binary ? (
<div className="flex gap-3 flex-wrap">
{!serviceStatus.is_running ? (
<Button
onClick={handleStartService}
disabled={actionLoading}
className="gap-2"
>
{actionLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
</Button>
) : (
<Button
onClick={handleStopService}
disabled={actionLoading}
variant="destructive"
className="gap-2"
>
{actionLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Square className="w-4 h-4" />
)}
</Button>
)}
<Button
onClick={handleRestartService}
disabled={actionLoading}
variant="outline"
className="gap-2"
>
{actionLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RotateCcw className="w-4 h-4" />
)}
</Button>
<Button
onClick={handleOpenUI}
disabled={actionLoading}
className="gap-2"
>
{actionLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<ExternalLink className="w-4 h-4" />
)}
{serviceStatus.is_running ? "打开管理界面" : "启动并打开管理界面"}
</Button>
</div>
) : (
<div className="text-center py-8">
<AlertCircle className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">CCR </h3>
<p className="text-muted-foreground mb-4">
Claude Code Router 使
</p>
<Button
onClick={() => open("https://github.com/musistudio/claude-code-router/tree/main")}
className="gap-2"
>
<Download className="w-4 h-4" />
CCR
</Button>
</div>
)}
</CardContent>
</Card>
</motion.div>
{/* Information Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<Card>
<CardHeader>
<CardTitle> CCR </CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>
Claude Code Router (CCR) Claude Code LLM
</p>
<ul className="list-disc list-inside space-y-1">
<li> LLM OpenRouterDeepSeekGemini </li>
<li></li>
<li>Web UI 便</li>
<li> Anthropic 使 Claude Code</li>
</ul>
{!serviceStatus?.has_ccr_binary && (
<div className="mt-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200 mb-2">
</p>
<code className="block p-2 bg-black/5 dark:bg-white/5 rounded text-xs">
npm install -g @musistudio/claude-code-router
</code>
<p className="text-xs mt-2 text-muted-foreground">
访 <a
href="#"
onClick={(e) => {
e.preventDefault();
open("https://github.com/musistudio/claude-code-router/tree/main");
}}
className="text-blue-600 hover:underline"
>
GitHub
</a>
</p>
</div>
)}
</CardContent>
</Card>
</motion.div>
</div>
{/* Toast Container */}
<ToastContainer>
{toast && (
<Toast
message={toast.message}
type={toast.type}
onDismiss={() => setToast(null)}
/>
)}
</ToastContainer>
</div>
);
}

View File

@@ -45,7 +45,8 @@ import { SplitPane } from "@/components/ui/split-pane";
import { WebviewPreview } from "./WebviewPreview";
import { FileExplorerPanelEnhanced } from "./FileExplorerPanelEnhanced";
import { GitPanelEnhanced } from "./GitPanelEnhanced";
import { FileEditorEnhanced } from "./FileEditorEnhanced";
// 动态导入 FileEditorEnhanced 以减少初始包大小
const FileEditorEnhanced = React.lazy(() => import("./FileEditorEnhanced"));
import { SlashCommandsManager } from "./SlashCommandsManager";
import type { ClaudeStreamMessage } from "./AgentExecution";
import { useVirtualizer } from "@tanstack/react-virtual";
@@ -1904,11 +1905,17 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
<div className={cn("h-full w-full", layout.activeView === 'terminal' ? 'hidden' : 'block')}>
{layout.activeView === 'editor' && layout.editingFile ? (
// 文件编辑器视图
<React.Suspense fallback={
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
}>
<FileEditorEnhanced
filePath={layout.editingFile}
onClose={closeFileEditor}
className="h-full"
/>
</React.Suspense>
) : layout.activeView === 'preview' && layout.previewUrl ? (
// 预览视图
<SplitPane

View File

@@ -29,6 +29,7 @@ export const MCPImportExport: React.FC<MCPImportExportProps> = ({
const [importingDesktop, setImportingDesktop] = useState(false);
const [importingJson, setImportingJson] = useState(false);
const [importScope, setImportScope] = useState("local");
const [exporting, setExporting] = useState(false);
/**
* Imports servers from Claude Desktop
@@ -142,11 +143,84 @@ export const MCPImportExport: React.FC<MCPImportExportProps> = ({
};
/**
* Handles exporting servers (placeholder)
* Handles exporting servers
*/
const handleExport = () => {
// TODO: Implement export functionality
onError("Export functionality coming soon!");
const handleExport = async () => {
try {
setExporting(true);
const result = await api.mcpExportServers();
if (result.servers.length === 0) {
onError("No MCP servers configured to export");
return;
}
let jsonContent: string;
let defaultFileName: string;
if (result.format === "single" && result.servers.length === 1) {
// Single server format
const server = result.servers[0];
const exportData: any = {
type: server.transport,
};
if (server.transport === "stdio") {
exportData.command = server.command;
exportData.args = server.args;
exportData.env = server.env;
} else if (server.transport === "sse") {
exportData.url = server.url;
}
jsonContent = JSON.stringify(exportData, null, 2);
defaultFileName = `mcp-server-${server.name}.json`;
} else {
// Multiple servers format
const exportData: any = {
mcpServers: {}
};
for (const server of result.servers) {
const serverConfig: any = {
command: server.command || "",
args: server.args,
env: server.env
};
if (server.transport === "sse") {
serverConfig.url = server.url;
}
exportData.mcpServers[server.name] = serverConfig;
}
jsonContent = JSON.stringify(exportData, null, 2);
defaultFileName = "mcp-servers.json";
}
// Use Tauri's save dialog
const { save } = await import('@tauri-apps/plugin-dialog');
const filePath = await save({
defaultPath: defaultFileName,
filters: [{
name: 'JSON',
extensions: ['json']
}]
});
if (filePath) {
// Use Tauri's file system API to write the file
const { writeTextFile } = await import('@tauri-apps/plugin-fs');
await writeTextFile(filePath, jsonContent);
onError(`Successfully exported ${result.servers.length} server(s) to ${filePath}`);
}
} catch (error: any) {
console.error("Failed to export servers:", error);
onError(error.toString() || "Failed to export servers");
} finally {
setExporting(false);
}
};
/**
@@ -273,12 +347,12 @@ export const MCPImportExport: React.FC<MCPImportExportProps> = ({
</div>
</Card>
{/* Export (Coming Soon) */}
<Card className="p-4 opacity-60">
{/* Export Configuration */}
<Card className="p-4 hover:bg-accent/5 transition-colors">
<div className="space-y-3">
<div className="flex items-start gap-3">
<div className="p-2.5 bg-muted rounded-lg">
<Upload className="h-5 w-5 text-muted-foreground" />
<div className="p-2.5 bg-green-500/10 rounded-lg">
<Upload className="h-5 w-5 text-green-500" />
</div>
<div className="flex-1">
<h4 className="text-sm font-medium">{t('mcp.exportConfiguration')}</h4>
@@ -289,12 +363,21 @@ export const MCPImportExport: React.FC<MCPImportExportProps> = ({
</div>
<Button
onClick={handleExport}
disabled={true}
variant="secondary"
disabled={exporting}
variant="outline"
className="w-full gap-2"
>
{exporting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t('mcp.exporting')}
</>
) : (
<>
<Upload className="h-4 w-4" />
{t('mcp.exportComingSoon')}
{t('mcp.exportConfiguration')}
</>
)}
</Button>
</div>
</Card>

View File

@@ -138,7 +138,7 @@ export const MCPServerList: React.FC<MCPServerListProps> = ({
// TODO: Show result in a toast or modal
console.log("Test result:", result);
} catch (error) {
console.error("Failed to test connection:", error);
console.error("Failed to test.md connection:", error);
trackEvent.mcpConnectionError({
server_name: name,

File diff suppressed because it is too large Load Diff

View File

@@ -227,6 +227,7 @@ export const Terminal: React.FC<TerminalProps> = ({
if (terminalRef.current) {
xterm.open(terminalRef.current);
} else {
console.error('[Terminal] Terminal container ref is null');
return;
}

View File

@@ -0,0 +1,119 @@
import React from 'react';
import { Sun, Moon, Monitor, Palette, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useTheme } from '@/hooks/useTheme';
import { useTranslation } from '@/hooks/useTranslation';
import { cn } from '@/lib/utils';
interface ThemeSwitcherProps {
className?: string;
showText?: boolean;
}
/**
* 主题快速切换组件
*
* @example
* <ThemeSwitcher />
* <ThemeSwitcher showText={true} className="ml-2" />
*/
export const ThemeSwitcher: React.FC<ThemeSwitcherProps> = ({
className,
showText = false
}) => {
const { theme, setTheme } = useTheme();
const { currentLanguage } = useTranslation();
const themes = [
{
key: 'light',
name: currentLanguage === 'zh' ? '浅色' : 'Light',
icon: Sun,
description: currentLanguage === 'zh' ? '明亮模式' : 'Bright mode'
},
{
key: 'gray',
name: currentLanguage === 'zh' ? '灰色' : 'Gray',
icon: Monitor,
description: currentLanguage === 'zh' ? '舒适模式' : 'Comfortable mode'
},
{
key: 'dark',
name: currentLanguage === 'zh' ? '深色' : 'Dark',
icon: Moon,
description: currentLanguage === 'zh' ? '暗黑模式' : 'Dark mode'
},
{
key: 'custom',
name: currentLanguage === 'zh' ? '自定义' : 'Custom',
icon: Palette,
description: currentLanguage === 'zh' ? '个性化' : 'Personalized'
}
] as const;
const getCurrentThemeIcon = () => {
const currentTheme = themes.find(t => t.key === theme);
const IconComponent = currentTheme?.icon || Monitor;
return IconComponent;
};
const getCurrentThemeName = () => {
const currentTheme = themes.find(t => t.key === theme);
return currentTheme?.name || (currentLanguage === 'zh' ? '主题' : 'Theme');
};
const handleThemeChange = async (themeKey: string) => {
try {
await setTheme(themeKey as any);
} catch (error) {
console.error('Failed to change theme:', error);
}
};
const IconComponent = getCurrentThemeIcon();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn("gap-2", className)}
title={currentLanguage === 'zh' ? '切换主题' : 'Switch theme'}
>
<IconComponent className="h-4 w-4" />
{showText && <span className="hidden sm:inline">{getCurrentThemeName()}</span>}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{themes.map((themeOption) => {
const ThemeIcon = themeOption.icon;
return (
<DropdownMenuItem
key={themeOption.key}
onClick={() => handleThemeChange(themeOption.key)}
className="flex items-center justify-between cursor-pointer"
>
<div className="flex items-center gap-3">
<ThemeIcon className="h-4 w-4" />
<div className="flex flex-col">
<span className="font-medium">{themeOption.name}</span>
<span className="text-xs text-muted-foreground">{themeOption.description}</span>
</div>
</div>
{theme === themeOption.key && (
<Check className="h-4 w-4 text-primary" />
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -4,6 +4,7 @@ import { Circle, FileText, Settings, ExternalLink, BarChart3, Network, Info, Bot
import { Button } from "@/components/ui/button";
import { Popover } from "@/components/ui/popover";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
import { useTranslation } from "@/hooks/useTranslation";
import { api, type ClaudeVersionStatus } from "@/lib/api";
import { cn } from "@/lib/utils";
@@ -240,6 +241,9 @@ export const Topbar: React.FC<TopbarProps> = ({
{/* Language Switcher */}
<LanguageSwitcher />
{/* Theme Switcher */}
<ThemeSwitcher />
<Button
variant="ghost"
size="icon"

View File

@@ -1,5 +1,5 @@
import { motion } from "framer-motion";
import { Bot, FolderCode, BarChart, ServerCog, FileText, Settings, Network } from "lucide-react";
import { Bot, FolderCode, BarChart, ServerCog, FileText, Settings, Network, Router } from "lucide-react";
import { useTranslation } from "@/hooks/useTranslation";
import { Button } from "@/components/ui/button";
import { ClaudiaLogoMinimal } from "@/components/ClaudiaLogo";
@@ -61,6 +61,15 @@ export function WelcomePage({ onNavigate, onNewSession }: WelcomePageProps) {
bgColor: "bg-orange-500/10",
view: "mcp"
},
{
id: "ccr-router",
icon: Router,
title: t("welcome.ccrRouter"),
subtitle: t("welcome.ccrRouterDesc"),
color: "text-orange-500",
bgColor: "bg-orange-500/10",
view: "ccr-router"
},
{
id: "claude-md",
icon: FileText,
@@ -147,7 +156,7 @@ export function WelcomePage({ onNavigate, onNewSession }: WelcomePageProps) {
</div>
{/* Bottom Feature Cards */}
<div className="grid grid-cols-4 gap-6 mb-10">
<div className="grid grid-cols-5 gap-6 mb-10">
{bottomFeatures.map((feature, index) => (
<motion.div
key={feature.id}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -453,9 +453,10 @@ export interface ImportServerResult {
/** 中转站适配器类型 */
export type RelayStationAdapter =
| 'packycode' // PackyCode 平台(默认)
| 'newapi' // NewAPI 兼容平台
| 'oneapi' // OneAPI 兼容平台
| 'yourapi' // YourAPI 特定平台
| 'deepseek' // DeepSeek v3.1
| 'glm' // 智谱GLM
| 'qwen' // 千问Qwen
| 'kimi' // Kimi k2
| 'custom'; // 自定义简单配置
/** 认证方式 */
@@ -473,7 +474,7 @@ export interface RelayStation {
adapter: RelayStationAdapter; // 适配器类型
auth_method: AuthMethod; // 认证方式
system_token: string; // 系统令牌
user_id?: string; // 用户 IDNewAPI 必需
user_id?: string; // 用户 ID可选
adapter_config?: Record<string, any>; // 适配器特定配置
enabled: boolean; // 启用状态
created_at: number; // 创建时间
@@ -537,6 +538,15 @@ export interface ConnectionTestResult {
error?: string; // 错误信息
}
/** 导入结果统计 */
export interface ImportResult {
total: number; // 总数
imported: number; // 成功导入数
skipped: number; // 跳过数(重复)
failed: number; // 失败数
message: string; // 结果消息
}
/** Token 信息 */
export interface TokenInfo {
id: string;
@@ -592,9 +602,10 @@ export interface PackycodeUserQuota {
balance_usd: number; // 账户余额(美元)
total_spent_usd: number; // 总消费(美元)
plan_type: string; // 计划类型 (pro, basic, etc.)
plan_expires_at: string; // 计划过期时间
plan_expires_at?: string; // 计划过期时间
username?: string; // 用户名
email?: string; // 邮箱
opus_enabled?: boolean; // 是否启用Opus模型
}
/**
@@ -1661,7 +1672,7 @@ export const api = {
try {
return await invoke<string>("mcp_test_connection", { name });
} catch (error) {
console.error("Failed to test MCP connection:", error);
console.error("Failed to test.md MCP connection:", error);
throw error;
}
},
@@ -1714,6 +1725,29 @@ export const api = {
}
},
/**
* Export configuration for MCP server
*/
async mcpExportServers(): Promise<{
servers: Array<{
name: string;
transport: string;
command?: string;
args: string[];
env: Record<string, string>;
url?: string;
scope: string;
}>;
format: string;
}> {
try {
return await invoke("mcp_export_servers");
} catch (error) {
console.error("Failed to export MCP servers:", error);
throw error;
}
},
/**
* Get the stored Claude binary path from settings
* @returns Promise resolving to the path if set, null otherwise
@@ -2243,6 +2277,19 @@ export const api = {
}
},
/**
* Flush system DNS cache
* @returns Promise resolving to success message
*/
async flushDns(): Promise<string> {
try {
return await invoke<string>("flush_dns");
} catch (error) {
console.error("Failed to flush DNS:", error);
throw error;
}
},
/**
* Gets current API config from Claude settings
* @returns Promise resolving to current config info
@@ -2256,6 +2303,39 @@ export const api = {
}
},
/**
* Exports all relay stations configuration
* @returns Promise resolving to array of relay stations
*/
async relayStationsExport(): Promise<RelayStation[]> {
try {
return await invoke<RelayStation[]>("relay_stations_export");
} catch (error) {
console.error("Failed to export relay stations:", error);
throw error;
}
},
/**
* Imports relay stations configuration
* @param stations - Array of relay stations to import
* @param clearExisting - Whether to clear existing stations before import
* @returns Promise resolving to success message
*/
async relayStationsImport(stations: CreateRelayStationRequest[], clearExisting: boolean = false): Promise<ImportResult> {
try {
return await invoke<ImportResult>("relay_stations_import", {
request: {
stations,
clear_existing: clearExisting
}
});
} catch (error) {
console.error("Failed to import relay stations:", error);
throw error;
}
},
/**
* Gets relay station information
* @param stationId - The relay station ID
@@ -2288,13 +2368,13 @@ export const api = {
/**
* Tests relay station connection
* @param stationId - The relay station ID
* @returns Promise resolving to connection test result
* @returns Promise resolving to connection test.md result
*/
async relayStationTestConnection(stationId: string): Promise<ConnectionTestResult> {
try {
return await invoke<ConnectionTestResult>("relay_station_test_connection", { stationId });
} catch (error) {
console.error("Failed to test connection:", error);
console.error("Failed to test.md connection:", error);
throw error;
}
},
@@ -2401,14 +2481,14 @@ export const api = {
// ============= PackyCode Nodes =============
/**
* Tests all PackyCode nodes and returns speed test results
* @returns Promise resolving to array of node speed test results
* Tests all PackyCode nodes and returns speed test.md results
* @returns Promise resolving to array of node speed test.md results
*/
async testAllPackycodeNodes(): Promise<NodeSpeedTestResult[]> {
try {
return await invoke<NodeSpeedTestResult[]>("test_all_packycode_nodes");
} catch (error) {
console.error("Failed to test PackyCode nodes:", error);
console.error("Failed to test.md PackyCode nodes:", error);
throw error;
}
},
@@ -2620,3 +2700,118 @@ export const api = {
}
}
};
// CCR (Claude Code Router) Related Interfaces
export interface CcrServiceStatus {
is_running: boolean;
port?: number;
endpoint?: string;
has_ccr_binary: boolean;
ccr_version?: string;
process_id?: number;
raw_output?: string;
}
export interface CcrServiceInfo {
status: CcrServiceStatus;
message: string;
}
// CCR API methods
export const ccrApi = {
/**
* Check if CCR is installed
*/
async checkInstallation(): Promise<boolean> {
try {
return await invoke<boolean>("check_ccr_installation");
} catch (error) {
console.error("Failed to check CCR installation:", error);
throw error;
}
},
/**
* Get CCR version
*/
async getVersion(): Promise<string> {
try {
return await invoke<string>("get_ccr_version");
} catch (error) {
console.error("Failed to get CCR version:", error);
throw error;
}
},
/**
* Get CCR service status
*/
async getServiceStatus(): Promise<CcrServiceStatus> {
try {
return await invoke<CcrServiceStatus>("get_ccr_service_status");
} catch (error) {
console.error("Failed to get CCR service status:", error);
throw error;
}
},
/**
* Start CCR service
*/
async startService(): Promise<CcrServiceInfo> {
try {
return await invoke<CcrServiceInfo>("start_ccr_service");
} catch (error) {
console.error("Failed to start CCR service:", error);
throw error;
}
},
/**
* Stop CCR service
*/
async stopService(): Promise<CcrServiceInfo> {
try {
return await invoke<CcrServiceInfo>("stop_ccr_service");
} catch (error) {
console.error("Failed to stop CCR service:", error);
throw error;
}
},
/**
* Restart CCR service
*/
async restartService(): Promise<CcrServiceInfo> {
try {
return await invoke<CcrServiceInfo>("restart_ccr_service");
} catch (error) {
console.error("Failed to restart CCR service:", error);
throw error;
}
},
/**
* Open CCR UI
*/
async openUI(): Promise<string> {
try {
return await invoke<string>("open_ccr_ui");
} catch (error) {
console.error("Failed to open CCR UI:", error);
throw error;
}
},
/**
* Get CCR config file path
*/
async getConfigPath(): Promise<string> {
try {
return await invoke<string>("get_ccr_config_path");
} catch (error) {
console.error("Failed to get CCR config path:", error);
throw error;
}
}
};

View File

@@ -33,6 +33,12 @@ i18n
zh: {
common: zh,
},
'zh-CN': {
common: zh,
},
'zh-TW': {
common: zh,
},
},
// 命名空间配置
@@ -48,7 +54,7 @@ i18n
},
// 白名单支持的语言
supportedLngs: ['en', 'zh'],
supportedLngs: ['en', 'zh', 'zh-CN', 'zh-TW'],
// 非显式支持的语言回退到en
nonExplicitSupportedLngs: true,

View File

@@ -125,7 +125,9 @@
"claudeMdDesc": "Edit Claude configuration files",
"settings": "Settings",
"settingsDesc": "App settings and configuration",
"quickStartSession": "Quick Start New Session"
"quickStartSession": "Quick Start New Session",
"ccrRouter": "CCR Router",
"ccrRouterDesc": "Claude Code Router configuration management"
},
"projects": {
"title": "Projects",
@@ -510,6 +512,7 @@
"importFromClaudeDesktop": "Import from Claude Desktop",
"importFromClaudeDesktopDescription": "Automatically imports all MCP servers from Claude Desktop. Installs to user scope (available across all projects).",
"importing": "Importing...",
"exporting": "Exporting...",
"importFromJSON": "Import from JSON",
"importFromJSONDescription": "Import server configuration from a JSON file",
"chooseJSONFile": "Choose JSON File",
@@ -861,10 +864,11 @@
"adapterType": "Adapter Type",
"authMethod": "Authentication Method",
"systemToken": "System Token",
"getApiKey": "Get API Key",
"tokenPlaceholder": "Enter your API token",
"tokenRequired": "System token is required",
"userId": "User ID",
"userIdPlaceholder": "Required for NewAPI/OneAPI",
"userIdPlaceholder": "Optional",
"enabled": "Enabled",
"testConnection": "Test Connection",
"connectionSuccess": "Connection successful",
@@ -881,6 +885,14 @@
"disabledSuccess": "Relay station disabled successfully",
"toggleEnableFailed": "Failed to toggle relay station status",
"syncConfig": "Sync Config",
"configPreview": "Config Preview",
"viewJson": "View JSON",
"configSaved": "Config saved",
"invalidJson": "Invalid JSON format",
"saveFailed": "Save failed",
"flushDns": "Flush DNS",
"flushDnsSuccess": "DNS cache flushed",
"flushDnsFailed": "DNS flush failed",
"syncFailed": "Failed to sync configuration",
"currentConfig": "Current Configuration",
"notConfigured": "Not configured",
@@ -890,14 +902,17 @@
"busService": "Bus",
"taxiServiceDesc": "Fast & Stable (share.packycode.com)",
"busServiceDesc": "Shared Economy (packycode.com)",
"selectService": "Select a service type",
"fixedUrl": "Fixed URL",
"taxiServiceNote": "Select a node or use auto-selection for optimal performance",
"busServiceNote": "Select a node or use auto-selection for optimal performance",
"nodeSelection": "Node Selection",
"selectNode": "Select a node",
"autoSelect": "Auto-select fastest",
"autoSelectDesc": "Will automatically test and select the fastest node",
"selectedNode": "Selected",
"speedTest": "Speed Test",
"testingNodes": "Testing node speeds...",
"testing": "Testing",
"bestNodeSelected": "Best node selected",
"testSpeed": "Test Speed",
"testResults": "Speed Test Results",
"failed": "Failed",
@@ -907,7 +922,20 @@
"autoSelectFailed": "Failed to auto-select node",
"selectingBestNode": "Testing nodes to find the fastest...",
"packycodeTokenNote": "PackyCode uses API Key authentication only",
"enabledNote": "Enable this station to make it available for use"
"enabledNote": "Enable this station to make it available for use",
"export": "Export Config",
"import": "Import Config",
"exportSuccess": "Relay stations exported successfully",
"exportFailed": "Failed to export",
"importSuccess": "Relay stations imported successfully",
"importFailed": "Failed to import",
"importConfirm": "Import {{count}} relay station(s)? This will not delete existing stations.",
"importing": "Importing...",
"importTotal": "Total",
"importSuccess": "Success",
"importSkipped": "Skipped (duplicate)",
"importFailed": "Failed",
"allDuplicate": "All configurations already exist, nothing imported"
},
"status": {
"connected": "Connected",

View File

@@ -122,7 +122,9 @@
"claudeMdDesc": "编辑 Claude 配置文件",
"settings": "设置",
"settingsDesc": "应用设置和配置",
"quickStartSession": "快速开始新会话"
"quickStartSession": "快速开始新会话",
"ccrRouter": "CCR 路由",
"ccrRouterDesc": "Claude Code Router 配置管理"
},
"projects": {
"title": "项目",
@@ -492,6 +494,7 @@
"importFromClaudeDesktop": "从 Claude Desktop 导入",
"importFromClaudeDesktopDescription": "自动导入 Claude Desktop 中的所有 MCP 服务器。安装到用户范围(所有项目可用)。",
"importing": "导入中...",
"exporting": "导出中...",
"importFromJSON": "从 JSON 导入",
"importFromJSONDescription": "从 JSON 文件导入服务器配置",
"chooseJSONFile": "选择 JSON 文件",
@@ -788,10 +791,11 @@
"adapterType": "适配器类型",
"authMethod": "认证方式",
"systemToken": "系统令牌",
"getApiKey": "获取 API Key",
"tokenPlaceholder": "输入您的 API 令牌",
"tokenRequired": "系统令牌必填",
"userId": "用户 ID",
"userIdPlaceholder": "NewAPI/OneAPI 必需",
"userIdPlaceholder": "可选",
"enabled": "启用",
"testConnection": "测试连接",
"connectionSuccess": "连接成功",
@@ -808,6 +812,14 @@
"disabledSuccess": "中转站禁用成功",
"toggleEnableFailed": "切换中转站状态失败",
"syncConfig": "同步配置",
"configPreview": "配置预览",
"viewJson": "查看 JSON",
"configSaved": "配置已保存",
"invalidJson": "JSON 格式无效",
"saveFailed": "保存失败",
"flushDns": "刷新 DNS",
"flushDnsSuccess": "DNS 缓存已刷新",
"flushDnsFailed": "DNS 刷新失败",
"syncFailed": "同步配置失败",
"currentConfig": "当前配置",
"notConfigured": "未配置",
@@ -817,14 +829,17 @@
"busService": "公交车",
"taxiServiceDesc": "高速稳定 (share.packycode.com)",
"busServiceDesc": "共享经济 (packycode.com)",
"selectService": "选择服务类型",
"fixedUrl": "固定地址",
"taxiServiceNote": "选择节点或使用自动选择以获得最佳性能",
"busServiceNote": "选择节点或使用自动选择以获得最佳性能",
"nodeSelection": "节点选择",
"selectNode": "选择节点",
"autoSelect": "自动选择最快",
"autoSelectDesc": "将自动测试并选择最快的节点",
"selectedNode": "已选择",
"speedTest": "节点测速",
"testingNodes": "正在测试节点速度...",
"testing": "测试中",
"bestNodeSelected": "已选择最快节点",
"testSpeed": "测速",
"testResults": "测速结果",
"failed": "失败",
@@ -834,7 +849,20 @@
"autoSelectFailed": "自动选择节点失败",
"selectingBestNode": "正在测试节点以寻找最快的...",
"packycodeTokenNote": "PackyCode 仅支持 API Key 认证方式",
"enabledNote": "启用此中转站以使其可用"
"enabledNote": "启用此中转站以使其可用",
"export": "导出配置",
"import": "导入配置",
"exportSuccess": "中转站配置已导出",
"exportFailed": "导出失败",
"importSuccess": "中转站配置已导入",
"importFailed": "导入失败",
"importConfirm": "确认导入 {{count}} 个中转站配置?这不会删除现有配置。",
"importing": "正在导入...",
"importTotal": "总计",
"importSuccess": "成功",
"importSkipped": "跳过(重复)",
"importFailed": "失败",
"allDuplicate": "所有配置都已存在,未导入任何新配置"
},
"status": {
"connected": "已连接",
@@ -862,4 +890,3 @@
"title": "警告"
}
}

View File

@@ -51,10 +51,17 @@ export default defineConfig(async () => ({
'react-vendor': ['react', 'react-dom'],
'ui-vendor': ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu', '@radix-ui/react-select', '@radix-ui/react-tabs', '@radix-ui/react-tooltip', '@radix-ui/react-switch', '@radix-ui/react-popover'],
'editor-vendor': ['@uiw/react-md-editor'],
'monaco-editor': ['monaco-editor', '@monaco-editor/react'],
'syntax-vendor': ['react-syntax-highlighter'],
// Animation and motion
'framer-motion': ['framer-motion'],
// Tauri and other utilities
'tauri': ['@tauri-apps/api', '@tauri-apps/plugin-dialog', '@tauri-apps/plugin-shell'],
'tauri': ['@tauri-apps/api', '@tauri-apps/plugin-dialog', '@tauri-apps/plugin-shell', '@tauri-apps/plugin-fs', '@tauri-apps/plugin-clipboard-manager'],
'utils': ['date-fns', 'clsx', 'tailwind-merge'],
// Charts and visualization
'recharts': ['recharts'],
// Virtual scrolling
'virtual': ['@tanstack/react-virtual'],
},
},
},