Compare commits
14 Commits
96eb05856e
...
main
Author | SHA1 | Date | |
---|---|---|---|
e4245a6fed | |||
d90169ac6d | |||
7d15edd0c1 | |||
b2e6f40269 | |||
21c0a9e583 | |||
a91b3ebbb2 | |||
e8fe999d16 | |||
71adf8416a | |||
6e834a1a7c | |||
c015fcf5f0 | |||
452c2ccf9b | |||
830f6e42a8 | |||
b940cfd70d | |||
4588c89557 |
2
.github/workflows/build-opensource.yml
vendored
2
.github/workflows/build-opensource.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
# macOS 通用版本
|
# macOS 通用版本
|
||||||
- platform: 'macos-latest'
|
- platform: 'macos-latest'
|
||||||
target: 'universal-apple-darwin'
|
target: ''
|
||||||
name: 'macOS'
|
name: 'macOS'
|
||||||
|
|
||||||
# Linux x64
|
# Linux x64
|
||||||
|
87
README.md
87
README.md
@@ -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)。
|
> 基于 [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 项目,快速恢复会话
|
- 自动检测 Claude 项目,快速恢复会话
|
||||||
- 版本控制检查点,支持分支回滚
|
- 版本控制检查点,支持分支回滚
|
||||||
- 实时同步项目状态和历史
|
- 实时同步项目状态和历史
|
||||||
|
- Git Panel 集成,查看文件变更 diff
|
||||||
|
|
||||||
### 🤖 AI 代理系统
|
### 🤖 AI 代理系统
|
||||||
- 创建自定义代理,后台独立执行
|
- 创建自定义代理,后台独立执行
|
||||||
- 详细运行日志,精细权限控制
|
- 详细运行日志,精细权限控制
|
||||||
- 非阻塞操作,高效任务管理
|
- 非阻塞操作,高效任务管理
|
||||||
|
- 支持多代理并发运行
|
||||||
|
|
||||||
### 🔗 API 中转站
|
### 🔗 API 中转站
|
||||||
- 支持 PackyCode、Custom 等多平台
|
- 支持 PackyCode(公交车/滴滴车)、DeepSeek、GLM、Qwen、Kimi 等平台
|
||||||
- 实时额度查询,一键切换配置
|
- 实时额度查询,公交车/滴滴车服务切换
|
||||||
- 自动同步到 Claude 设置
|
- Token 脱敏显示,安全配置管理
|
||||||
|
- 自动同步到 Claude 设置,一键刷新 DNS
|
||||||
|
|
||||||
### 📊 使用分析
|
### 📊 使用分析
|
||||||
- 实时成本跟踪,Token 详细统计
|
- 实时成本跟踪,Token 详细统计
|
||||||
- 可视化图表,数据导出分析
|
- 可视化图表,数据导出分析
|
||||||
- 按模型、项目、时间段分类
|
- 按模型、项目、时间段分类
|
||||||
|
- SQLite 缓存优化,毫秒级查询响应
|
||||||
|
|
||||||
### 🎨 编辑增强
|
### 🎨 编辑增强
|
||||||
- Monaco 编辑器,40+ 语言高亮
|
- Monaco 编辑器,40+ 语言高亮
|
||||||
- 智能补全,实时诊断错误
|
- 智能补全,实时诊断错误
|
||||||
- 多光标编辑,代码自动格式化
|
- 多光标编辑,代码自动格式化
|
||||||
|
- 主题快速切换,深色/浅色模式
|
||||||
|
|
||||||
### 📁 文件监听
|
### 📁 文件监听
|
||||||
- 跨平台实时监听,外部修改同步
|
- 跨平台实时监听,外部修改同步
|
||||||
- 防抖机制,轮询降级方案
|
- 防抖机制,轮询降级方案
|
||||||
- 基于 Rust notify 高性能
|
- 基于 Rust notify 高性能
|
||||||
|
- 支持大型项目文件变更追踪
|
||||||
|
|
||||||
|
### 🔌 MCP 服务器
|
||||||
|
- Model Context Protocol 支持
|
||||||
|
- 服务器配置管理,一键启用/禁用
|
||||||
|
- 支持多服务器并发管理
|
||||||
|
- 与 Claude 设置深度集成
|
||||||
|
|
||||||
|
### 🖥️ 终端集成
|
||||||
|
- 内置终端支持,执行命令行操作
|
||||||
|
- 会话数据持久化,历史记录保留
|
||||||
|
- 支持多终端标签页
|
||||||
|
- 自动识别系统 Shell
|
||||||
|
|
||||||
|
### 🎯 Claude Code Review (CCR)
|
||||||
|
- 集成 Claude Code Review 功能
|
||||||
|
- 一键启动/停止 CCR 服务
|
||||||
|
- 自动检测安装状态
|
||||||
|
- 支持打开 CCR UI 界面
|
||||||
|
|
||||||
## 🚀 安装使用
|
## 🚀 安装使用
|
||||||
|
|
||||||
@@ -70,12 +94,23 @@
|
|||||||
- `claude` 命令在 PATH 中可用
|
- `claude` 命令在 PATH 中可用
|
||||||
|
|
||||||
### 快速开始
|
### 快速开始
|
||||||
1. 启动 Claudia
|
1. 下载并安装 Claudia
|
||||||
2. 选择 CC 代理 或 CC 项目模式
|
2. 启动应用,选择 CC 代理或 CC 项目模式
|
||||||
3. 创建代理或管理会话
|
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
|
- **前端**: React 18 + TypeScript + Vite 6 + Tailwind CSS v4 + shadcn/ui
|
||||||
- **后端**: Rust + Tauri 2 + SQLite
|
- **后端**: Rust + Tauri 2 + SQLite (rusqlite)
|
||||||
- **编辑器**: Monaco Editor
|
- **编辑器**: Monaco Editor (VS Code 内核)
|
||||||
|
- **国际化**: i18next + fluent (中英双语)
|
||||||
|
- **文件监听**: notify crate (跨平台)
|
||||||
|
- **包管理**: Bun (替代 npm/yarn)
|
||||||
|
|
||||||
### 开发命令
|
### 开发命令
|
||||||
```bash
|
```bash
|
||||||
bun run tauri dev # 启动开发服务器
|
bun run tauri dev # 启动开发服务器
|
||||||
bunx tsc --noEmit # 类型检查
|
bunx tsc --noEmit # 类型检查
|
||||||
cd src-tauri && cargo test # Rust 测试
|
cd src-tauri && cargo test.md # Rust 测试
|
||||||
bun run check # 完整检查
|
bun run check # 完整检查
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -126,8 +164,21 @@ bun run check # 完整检查
|
|||||||
```
|
```
|
||||||
claudia/
|
claudia/
|
||||||
├── src/ # React 前端
|
├── src/ # React 前端
|
||||||
|
│ ├── components/ # UI 组件
|
||||||
|
│ ├── hooks/ # 自定义 Hooks
|
||||||
|
│ ├── lib/ # 工具库和 API
|
||||||
|
│ ├── locales/ # 国际化资源
|
||||||
|
│ └── stores/ # Zustand 状态管理
|
||||||
├── src-tauri/ # Rust 后端
|
├── 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/) - 安全桌面应用框架
|
- [Tauri](https://tauri.app/) - 安全高效的桌面应用框架
|
||||||
- [Asterisk Claudia](https://github.com/getAsterisk/claudia) - 原始项目
|
- [Asterisk Claudia](https://github.com/getAsterisk/claudia) - 原始项目灵感
|
||||||
- [Claude](https://claude.ai) by Anthropic
|
- [Claude](https://claude.ai) by Anthropic - AI 核心能力
|
||||||
|
- [Monaco Editor](https://microsoft.github.io/monaco-editor/) - 强大的代码编辑器
|
||||||
|
- [shadcn/ui](https://ui.shadcn.com/) - 现代化 UI 组件库
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
20
bun.lock
20
bun.lock
@@ -11,6 +11,7 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
"@radix-ui/react-popover": "^1.1.4",
|
"@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-radio-group": "^1.3.7",
|
||||||
"@radix-ui/react-select": "^2.1.3",
|
"@radix-ui/react-select": "^2.1.3",
|
||||||
"@radix-ui/react-switch": "^1.1.3",
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"@tauri-apps/api": "^2.1.1",
|
"@tauri-apps/api": "^2.1.1",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.0",
|
"@tauri-apps/plugin-clipboard-manager": "^2.3.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.0.2",
|
"@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-global-shortcut": "^2.0.0",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-shell": "^2.0.1",
|
"@tauri-apps/plugin-shell": "^2.0.1",
|
||||||
@@ -50,6 +52,10 @@
|
|||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss": "^4.1.8",
|
"tailwindcss": "^4.1.8",
|
||||||
|
"xterm": "^5.3.0",
|
||||||
|
"xterm-addon-search": "^0.13.0",
|
||||||
|
"xterm-addon-unicode11": "^0.6.0",
|
||||||
|
"xterm-addon-web-links": "^0.9.0",
|
||||||
"zod": "^3.24.1",
|
"zod": "^3.24.1",
|
||||||
"zustand": "^5.0.6",
|
"zustand": "^5.0.6",
|
||||||
},
|
},
|
||||||
@@ -341,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-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-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=="],
|
"@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=="],
|
||||||
@@ -485,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-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-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=="],
|
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.0.0" } }, "sha512-yAbauwp8BCHIhhA48NN8rEf6OtfZBPCgTOCa10gmtoVCpmic5Bq+1Ba7C+NZOjogedkSiV7hAotjYnnbUVmYrw=="],
|
||||||
@@ -1363,6 +1373,14 @@
|
|||||||
|
|
||||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||||
|
|
||||||
|
"xterm": ["xterm@5.3.0", "https://registry.npmmirror.com/xterm/-/xterm-5.3.0.tgz", {}, "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg=="],
|
||||||
|
|
||||||
|
"xterm-addon-search": ["xterm-addon-search@0.13.0", "https://registry.npmmirror.com/xterm-addon-search/-/xterm-addon-search-0.13.0.tgz", { "peerDependencies": { "xterm": "^5.0.0" } }, "sha512-sDUwG4CnqxUjSEFh676DlS3gsh3XYCzAvBPSvJ5OPgF3MRL3iHLPfsb06doRicLC2xXNpeG2cWk8x1qpESWJMA=="],
|
||||||
|
|
||||||
|
"xterm-addon-unicode11": ["xterm-addon-unicode11@0.6.0", "https://registry.npmmirror.com/xterm-addon-unicode11/-/xterm-addon-unicode11-0.6.0.tgz", { "peerDependencies": { "xterm": "^5.0.0" } }, "sha512-5pkb8YoS/deRtNqQRw8t640mu+Ga8B2MG3RXGQu0bwgcfr8XiXIRI880TWM49ICAHhTmnOLPzIIBIjEnCq7k2A=="],
|
||||||
|
|
||||||
|
"xterm-addon-web-links": ["xterm-addon-web-links@0.9.0", "https://registry.npmmirror.com/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz", { "peerDependencies": { "xterm": "^5.0.0" } }, "sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q=="],
|
||||||
|
|
||||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||||
|
|
||||||
"yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
"yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
@@ -1401,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-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=="],
|
"@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=="],
|
"@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=="],
|
||||||
|
@@ -21,6 +21,7 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
"@radix-ui/react-popover": "^1.1.4",
|
"@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-radio-group": "^1.3.7",
|
||||||
"@radix-ui/react-select": "^2.1.3",
|
"@radix-ui/react-select": "^2.1.3",
|
||||||
"@radix-ui/react-switch": "^1.1.3",
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
"@tauri-apps/api": "^2.1.1",
|
"@tauri-apps/api": "^2.1.1",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.0",
|
"@tauri-apps/plugin-clipboard-manager": "^2.3.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.0.2",
|
"@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-global-shortcut": "^2.0.0",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-shell": "^2.0.1",
|
"@tauri-apps/plugin-shell": "^2.0.1",
|
||||||
@@ -60,6 +62,10 @@
|
|||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss": "^4.1.8",
|
"tailwindcss": "^4.1.8",
|
||||||
|
"xterm": "^5.3.0",
|
||||||
|
"xterm-addon-search": "^0.13.0",
|
||||||
|
"xterm-addon-unicode11": "^0.6.0",
|
||||||
|
"xterm-addon-web-links": "^0.9.0",
|
||||||
"zod": "^3.24.1",
|
"zod": "^3.24.1",
|
||||||
"zustand": "^5.0.6"
|
"zustand": "^5.0.6"
|
||||||
},
|
},
|
||||||
|
151
src-tauri/Cargo.lock
generated
151
src-tauri/Cargo.lock
generated
@@ -736,6 +736,7 @@ dependencies = [
|
|||||||
"notify",
|
"notify",
|
||||||
"objc",
|
"objc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"portable-pty",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -1250,7 +1251,7 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
"toml",
|
"toml",
|
||||||
"vswhom",
|
"vswhom",
|
||||||
"winreg",
|
"winreg 0.55.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1419,10 +1420,21 @@ version = "0.3.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
|
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memoffset",
|
"memoffset 0.9.1",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filedescriptor"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filetime"
|
name = "filetime"
|
||||||
version = "0.2.25"
|
version = "0.2.25"
|
||||||
@@ -2462,6 +2474,15 @@ dependencies = [
|
|||||||
"unic-langid",
|
"unic-langid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ioctl-rs"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -2846,6 +2867,15 @@ version = "2.7.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memoffset"
|
||||||
|
version = "0.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memoffset"
|
name = "memoffset"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -2980,6 +3010,20 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.25.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"memoffset 0.6.5",
|
||||||
|
"pin-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.30.1"
|
version = "0.30.1"
|
||||||
@@ -2990,7 +3034,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
"memoffset",
|
"memoffset 0.9.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3743,6 +3787,27 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-pty"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"downcast-rs",
|
||||||
|
"filedescriptor",
|
||||||
|
"lazy_static",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"nix 0.25.1",
|
||||||
|
"serial",
|
||||||
|
"shared_library",
|
||||||
|
"shell-words",
|
||||||
|
"winapi",
|
||||||
|
"winreg 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -4655,6 +4720,48 @@ dependencies = [
|
|||||||
"unsafe-libyaml",
|
"unsafe-libyaml",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serial"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86"
|
||||||
|
dependencies = [
|
||||||
|
"serial-core",
|
||||||
|
"serial-unix",
|
||||||
|
"serial-windows",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serial-core"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serial-unix"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7"
|
||||||
|
dependencies = [
|
||||||
|
"ioctl-rs",
|
||||||
|
"libc",
|
||||||
|
"serial-core",
|
||||||
|
"termios",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serial-windows"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"serial-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serialize-to-javascript"
|
name = "serialize-to-javascript"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -4708,6 +4815,22 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shared_library"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shell-words"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -5494,6 +5617,15 @@ dependencies = [
|
|||||||
"utf-8",
|
"utf-8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "termios"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thin-slice"
|
name = "thin-slice"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -5879,7 +6011,7 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
|
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memoffset",
|
"memoffset 0.9.1",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
@@ -6874,6 +7006,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winreg"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winreg"
|
name = "winreg"
|
||||||
version = "0.55.0"
|
version = "0.55.0"
|
||||||
@@ -7075,7 +7216,7 @@ dependencies = [
|
|||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
"hex",
|
"hex",
|
||||||
"nix",
|
"nix 0.30.1",
|
||||||
"ordered-stream",
|
"ordered-stream",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
|
@@ -48,6 +48,7 @@ reqwest = { version = "0.12", features = ["json", "native-tls-vendored"] }
|
|||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
portable-pty = "0.8"
|
||||||
which = "7"
|
which = "7"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
zstd = "0.13"
|
zstd = "0.13"
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::collections::HashMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use dirs::home_dir;
|
use dirs::home_dir;
|
||||||
@@ -16,9 +17,27 @@ pub struct ClaudeConfig {
|
|||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
#[serde(rename = "apiKeyHelper", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "apiKeyHelper", skip_serializing_if = "Option::is_none")]
|
||||||
pub api_key_helper: Option<String>,
|
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 {
|
pub struct ClaudeEnv {
|
||||||
#[serde(rename = "ANTHROPIC_AUTH_TOKEN", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "ANTHROPIC_AUTH_TOKEN", skip_serializing_if = "Option::is_none")]
|
||||||
pub anthropic_auth_token: Option<String>,
|
pub anthropic_auth_token: Option<String>,
|
||||||
@@ -26,6 +45,20 @@ pub struct ClaudeEnv {
|
|||||||
pub anthropic_base_url: Option<String>,
|
pub anthropic_base_url: Option<String>,
|
||||||
#[serde(rename = "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", skip_serializing_if = "Option::is_none")]
|
||||||
pub disable_nonessential_traffic: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
@@ -59,6 +92,8 @@ pub fn read_claude_config() -> Result<ClaudeConfig, String> {
|
|||||||
permissions: Some(ClaudePermissions::default()),
|
permissions: Some(ClaudePermissions::default()),
|
||||||
model: None,
|
model: None,
|
||||||
api_key_helper: None,
|
api_key_helper: None,
|
||||||
|
status_line: None,
|
||||||
|
extra_fields: HashMap::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +181,7 @@ pub fn restore_claude_config() -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 根据中转站配置更新 Claude 配置
|
/// 根据中转站配置更新 Claude 配置(仅更新 API 相关字段)
|
||||||
pub fn apply_relay_station_to_config(station: &RelayStation) -> Result<(), String> {
|
pub fn apply_relay_station_to_config(station: &RelayStation) -> Result<(), String> {
|
||||||
// 先备份当前配置
|
// 先备份当前配置
|
||||||
backup_claude_config()?;
|
backup_claude_config()?;
|
||||||
@@ -154,30 +189,21 @@ pub fn apply_relay_station_to_config(station: &RelayStation) -> Result<(), Strin
|
|||||||
// 读取当前配置
|
// 读取当前配置
|
||||||
let mut config = read_claude_config()?;
|
let mut config = read_claude_config()?;
|
||||||
|
|
||||||
// 更新 API URL
|
// 仅更新这三个关键字段,保留其他所有配置不变:
|
||||||
|
// 1. ANTHROPIC_BASE_URL
|
||||||
config.env.anthropic_base_url = Some(station.api_url.clone());
|
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());
|
config.env.anthropic_auth_token = Some(station.system_token.clone());
|
||||||
|
|
||||||
// 将中转站的 token 也设置到 apiKeyHelper
|
// 3. apiKeyHelper - 设置为 echo 格式
|
||||||
// 格式:echo 'token'
|
|
||||||
config.api_key_helper = Some(format!("echo '{}'", station.system_token));
|
config.api_key_helper = Some(format!("echo '{}'", station.system_token));
|
||||||
|
|
||||||
// 如果是特定适配器,可能需要特殊处理
|
// 如果是特定适配器,可能需要特殊处理 URL 格式
|
||||||
match station.adapter.as_str() {
|
match station.adapter.as_str() {
|
||||||
"packycode" => {
|
"packycode" => {
|
||||||
// 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" => {
|
"custom" => {
|
||||||
// 自定义适配器,使用原始配置
|
// 自定义适配器,使用原始配置
|
||||||
}
|
}
|
||||||
@@ -187,7 +213,7 @@ pub fn apply_relay_station_to_config(station: &RelayStation) -> Result<(), Strin
|
|||||||
// 写入更新后的配置
|
// 写入更新后的配置
|
||||||
write_claude_config(&config)?;
|
write_claude_config(&config)?;
|
||||||
|
|
||||||
log::info!("已将中转站 {} 的配置应用到 Claude 配置文件", station.name);
|
log::info!("已将中转站 {} 的 API 配置(apiKeyHelper, ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN)应用到 Claude 配置文件", station.name);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
337
src-tauri/src/commands/ccr.rs
Normal file
337
src-tauri/src/commands/ccr.rs
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use log::{debug, error, info};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct CcrServiceStatus {
|
||||||
|
pub is_running: bool,
|
||||||
|
pub port: Option<u16>,
|
||||||
|
pub endpoint: Option<String>,
|
||||||
|
pub has_ccr_binary: bool,
|
||||||
|
pub ccr_version: Option<String>,
|
||||||
|
pub process_id: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CcrServiceInfo {
|
||||||
|
pub status: CcrServiceStatus,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查 CCR 是否已安装
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_ccr_installation() -> Result<bool, String> {
|
||||||
|
// 直接尝试执行 ccr --version 命令来检测是否安装
|
||||||
|
// 这比使用 which 命令更可靠,特别是在打包后的应用中
|
||||||
|
let output = Command::new("ccr")
|
||||||
|
.arg("--version")
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output();
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(result) => Ok(result.status.success()),
|
||||||
|
Err(e) => {
|
||||||
|
// 如果命令执行失败,可能是因为 ccr 未安装或不在 PATH 中
|
||||||
|
debug!("CCR installation check failed: {}", e);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 CCR 版本信息
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_ccr_version() -> Result<String, String> {
|
||||||
|
// 尝试多个版本命令参数
|
||||||
|
let version_args = vec!["--version", "-v", "version"];
|
||||||
|
|
||||||
|
for arg in version_args {
|
||||||
|
let output = Command::new("ccr")
|
||||||
|
.arg(arg)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output();
|
||||||
|
|
||||||
|
if let Ok(result) = output {
|
||||||
|
if result.status.success() {
|
||||||
|
let version = String::from_utf8_lossy(&result.stdout);
|
||||||
|
let trimmed = version.trim().to_string();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Ok(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("Unable to get CCR version".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查 CCR 服务状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_ccr_service_status() -> Result<CcrServiceStatus, String> {
|
||||||
|
// 首先检查 ccr 二进制是否存在
|
||||||
|
let has_ccr_binary = check_ccr_installation().await.unwrap_or(false);
|
||||||
|
|
||||||
|
if !has_ccr_binary {
|
||||||
|
info!("CCR binary not found in PATH");
|
||||||
|
return Ok(CcrServiceStatus {
|
||||||
|
is_running: false,
|
||||||
|
port: None,
|
||||||
|
endpoint: None,
|
||||||
|
has_ccr_binary: false,
|
||||||
|
ccr_version: None,
|
||||||
|
process_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取版本信息
|
||||||
|
let ccr_version = get_ccr_version().await.ok();
|
||||||
|
debug!("CCR version: {:?}", ccr_version);
|
||||||
|
|
||||||
|
// 检查服务状态
|
||||||
|
let output = Command::new("ccr")
|
||||||
|
.arg("status")
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output();
|
||||||
|
|
||||||
|
let output = match output {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to execute ccr status: {}", e);
|
||||||
|
return Ok(CcrServiceStatus {
|
||||||
|
is_running: false,
|
||||||
|
port: None,
|
||||||
|
endpoint: None,
|
||||||
|
has_ccr_binary: true,
|
||||||
|
ccr_version,
|
||||||
|
process_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let status_output = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let stderr_output = String::from_utf8_lossy(&output.stderr);
|
||||||
|
|
||||||
|
debug!("CCR status stdout: {}", status_output);
|
||||||
|
debug!("CCR status stderr: {}", stderr_output);
|
||||||
|
|
||||||
|
// 更宽松的运行状态检测
|
||||||
|
let is_running = output.status.success() &&
|
||||||
|
(status_output.contains("Running") ||
|
||||||
|
status_output.contains("running") ||
|
||||||
|
status_output.contains("✅") ||
|
||||||
|
status_output.contains("Port:"));
|
||||||
|
|
||||||
|
// 尝试从输出中提取端口、端点和进程ID信息
|
||||||
|
let mut port = None;
|
||||||
|
let mut endpoint = None;
|
||||||
|
let mut process_id = None;
|
||||||
|
|
||||||
|
if is_running {
|
||||||
|
// 提取端口信息 - 支持多种格式
|
||||||
|
for line in status_output.lines() {
|
||||||
|
if line.contains("Port:") || line.contains("port:") {
|
||||||
|
// 尝试提取端口号
|
||||||
|
if let Some(port_str) = line.split(':').last() {
|
||||||
|
// 清理字符串,只保留数字
|
||||||
|
let cleaned: String = port_str.chars()
|
||||||
|
.filter(|c| c.is_numeric())
|
||||||
|
.collect();
|
||||||
|
if let Ok(port_num) = cleaned.parse::<u16>() {
|
||||||
|
port = Some(port_num);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取API端点信息 - 支持多种格式
|
||||||
|
for line in status_output.lines() {
|
||||||
|
if line.contains("API Endpoint:") || line.contains("Endpoint:") || line.contains("http://") || line.contains("https://") {
|
||||||
|
// 尝试提取URL
|
||||||
|
if let Some(start) = line.find("http") {
|
||||||
|
let url_part = &line[start..];
|
||||||
|
// 找到URL的结束位置(空格或行尾)
|
||||||
|
let end = url_part.find(char::is_whitespace).unwrap_or(url_part.len());
|
||||||
|
let url = &url_part[..end];
|
||||||
|
if url.contains(":") && (url.contains("localhost") || url.contains("127.0.0.1")) {
|
||||||
|
endpoint = Some(url.to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取进程ID信息 - 支持多种格式
|
||||||
|
for line in status_output.lines() {
|
||||||
|
if line.contains("Process ID:") || line.contains("PID:") || line.contains("pid:") {
|
||||||
|
// 尝试提取PID
|
||||||
|
if let Some(pid_str) = line.split(':').last() {
|
||||||
|
// 清理字符串,只保留数字
|
||||||
|
let cleaned: String = pid_str.chars()
|
||||||
|
.filter(|c| c.is_numeric())
|
||||||
|
.collect();
|
||||||
|
if let Ok(pid_num) = cleaned.parse::<u32>() {
|
||||||
|
process_id = Some(pid_num);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有找到具体信息,使用默认值
|
||||||
|
if port.is_none() {
|
||||||
|
port = Some(3456);
|
||||||
|
debug!("Using default port: 3456");
|
||||||
|
}
|
||||||
|
if endpoint.is_none() {
|
||||||
|
let port_num = port.unwrap_or(3456);
|
||||||
|
endpoint = Some(format!("http://localhost:{}", port_num));
|
||||||
|
debug!("Using default endpoint: {:?}", endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CcrServiceStatus {
|
||||||
|
is_running,
|
||||||
|
port,
|
||||||
|
endpoint,
|
||||||
|
has_ccr_binary,
|
||||||
|
ccr_version,
|
||||||
|
process_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动 CCR 服务
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_ccr_service() -> Result<CcrServiceInfo, String> {
|
||||||
|
// 先检查是否已安装
|
||||||
|
if !check_ccr_installation().await.unwrap_or(false) {
|
||||||
|
return Err("CCR is not installed. Please install claude-code-router first.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查当前状态
|
||||||
|
let current_status = get_ccr_service_status().await?;
|
||||||
|
if current_status.is_running {
|
||||||
|
return Ok(CcrServiceInfo {
|
||||||
|
status: current_status,
|
||||||
|
message: "CCR service is already running".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动服务
|
||||||
|
let _output = Command::new("ccr")
|
||||||
|
.arg("start")
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("Failed to start ccr service: {}", e))?;
|
||||||
|
|
||||||
|
// 等待一下让服务启动
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
// 再次检查状态
|
||||||
|
let new_status = get_ccr_service_status().await?;
|
||||||
|
|
||||||
|
if new_status.is_running {
|
||||||
|
Ok(CcrServiceInfo {
|
||||||
|
status: new_status,
|
||||||
|
message: "CCR service started successfully".to_string(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err("Failed to start CCR service".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止 CCR 服务
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stop_ccr_service() -> Result<CcrServiceInfo, String> {
|
||||||
|
if !check_ccr_installation().await.unwrap_or(false) {
|
||||||
|
return Err("CCR is not installed".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = Command::new("ccr")
|
||||||
|
.arg("stop")
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to stop ccr service: {}", e))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let error = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!("Failed to stop CCR service: {}", error));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查新状态
|
||||||
|
let new_status = get_ccr_service_status().await?;
|
||||||
|
|
||||||
|
Ok(CcrServiceInfo {
|
||||||
|
status: new_status,
|
||||||
|
message: "CCR service stopped successfully".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重启 CCR 服务
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn restart_ccr_service() -> Result<CcrServiceInfo, String> {
|
||||||
|
if !check_ccr_installation().await.unwrap_or(false) {
|
||||||
|
return Err("CCR is not installed".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = Command::new("ccr")
|
||||||
|
.arg("restart")
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to restart ccr service: {}", e))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let error = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!("Failed to restart CCR service: {}", error));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待服务重启
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
|
// 检查新状态
|
||||||
|
let new_status = get_ccr_service_status().await?;
|
||||||
|
|
||||||
|
Ok(CcrServiceInfo {
|
||||||
|
status: new_status,
|
||||||
|
message: "CCR service restarted successfully".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开 CCR UI
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_ccr_ui() -> Result<String, String> {
|
||||||
|
if !check_ccr_installation().await.unwrap_or(false) {
|
||||||
|
return Err("CCR is not installed".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查服务状态
|
||||||
|
let status = get_ccr_service_status().await?;
|
||||||
|
if !status.is_running {
|
||||||
|
// 如果服务未运行,尝试启动
|
||||||
|
let _start_result = start_ccr_service().await?;
|
||||||
|
// 再等待一下
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行 ccr ui 命令
|
||||||
|
let _output = Command::new("ccr")
|
||||||
|
.arg("ui")
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("Failed to open ccr ui: {}", e))?;
|
||||||
|
|
||||||
|
Ok("CCR UI opening...".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 CCR 配置路径
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_ccr_config_path() -> Result<String, String> {
|
||||||
|
let home_dir = dirs::home_dir()
|
||||||
|
.ok_or("Could not find home directory")?;
|
||||||
|
|
||||||
|
let config_path = home_dir
|
||||||
|
.join(".claude-code-router")
|
||||||
|
.join("config.json");
|
||||||
|
|
||||||
|
Ok(config_path.to_string_lossy().to_string())
|
||||||
|
}
|
@@ -13,3 +13,6 @@ pub mod relay_adapters;
|
|||||||
pub mod packycode_nodes;
|
pub mod packycode_nodes;
|
||||||
pub mod filesystem;
|
pub mod filesystem;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
|
pub mod terminal;
|
||||||
|
pub mod ccr;
|
||||||
|
pub mod system;
|
||||||
|
File diff suppressed because it is too large
Load Diff
@@ -15,9 +15,10 @@ use crate::claude_config;
|
|||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum RelayStationAdapter {
|
pub enum RelayStationAdapter {
|
||||||
Packycode, // PackyCode 平台(放在第一位)
|
Packycode, // PackyCode 平台(放在第一位)
|
||||||
Newapi, // NewAPI 兼容平台
|
Deepseek, // DeepSeek v3.1
|
||||||
Oneapi, // OneAPI 兼容平台
|
Glm, // 智谱GLM
|
||||||
Yourapi, // YourAPI 特定平台
|
Qwen, // 千问Qwen
|
||||||
|
Kimi, // Kimi k2
|
||||||
Custom, // 自定义简单配置
|
Custom, // 自定义简单配置
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,9 +26,10 @@ impl RelayStationAdapter {
|
|||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
RelayStationAdapter::Packycode => "packycode",
|
RelayStationAdapter::Packycode => "packycode",
|
||||||
RelayStationAdapter::Newapi => "newapi",
|
RelayStationAdapter::Deepseek => "deepseek",
|
||||||
RelayStationAdapter::Oneapi => "oneapi",
|
RelayStationAdapter::Glm => "glm",
|
||||||
RelayStationAdapter::Yourapi => "yourapi",
|
RelayStationAdapter::Qwen => "qwen",
|
||||||
|
RelayStationAdapter::Kimi => "kimi",
|
||||||
RelayStationAdapter::Custom => "custom",
|
RelayStationAdapter::Custom => "custom",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +54,7 @@ pub struct RelayStation {
|
|||||||
pub adapter: RelayStationAdapter, // 适配器类型
|
pub adapter: RelayStationAdapter, // 适配器类型
|
||||||
pub auth_method: AuthMethod, // 认证方式
|
pub auth_method: AuthMethod, // 认证方式
|
||||||
pub system_token: String, // 系统令牌
|
pub system_token: String, // 系统令牌
|
||||||
pub user_id: Option<String>, // 用户 ID(NewAPI 必需)
|
pub user_id: Option<String>, // 用户 ID(可选)
|
||||||
pub adapter_config: Option<HashMap<String, serde_json::Value>>, // 适配器特定配置
|
pub adapter_config: Option<HashMap<String, serde_json::Value>>, // 适配器特定配置
|
||||||
pub enabled: bool, // 启用状态
|
pub enabled: bool, // 启用状态
|
||||||
pub created_at: i64, // 创建时间
|
pub created_at: i64, // 创建时间
|
||||||
@@ -697,4 +699,212 @@ pub async fn relay_station_get_current_config() -> Result<HashMap<String, Option
|
|||||||
);
|
);
|
||||||
|
|
||||||
Ok(config)
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
62
src-tauri/src/commands/system.rs
Normal file
62
src-tauri/src/commands/system.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
268
src-tauri/src/commands/terminal.rs
Normal file
268
src-tauri/src/commands/terminal.rs
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use anyhow::Result;
|
||||||
|
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TerminalSession {
|
||||||
|
pub id: String,
|
||||||
|
pub working_directory: String,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminal child process wrapper
|
||||||
|
pub struct TerminalChild {
|
||||||
|
writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State for managing terminal sessions
|
||||||
|
pub type TerminalState = Arc<Mutex<HashMap<String, (TerminalSession, Option<TerminalChild>)>>>;
|
||||||
|
|
||||||
|
/// Creates a new terminal session using PTY
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_terminal_session(
|
||||||
|
working_directory: String,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
terminal_state: State<'_, TerminalState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let session_id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
log::info!("Creating terminal session: {} in {}", session_id, working_directory);
|
||||||
|
|
||||||
|
// Check if working directory exists
|
||||||
|
if !std::path::Path::new(&working_directory).exists() {
|
||||||
|
return Err(format!("Working directory does not exist: {}", working_directory));
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = TerminalSession {
|
||||||
|
id: session_id.clone(),
|
||||||
|
working_directory: working_directory.clone(),
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
is_active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create PTY system
|
||||||
|
let pty_system = native_pty_system();
|
||||||
|
|
||||||
|
// Create PTY pair with size
|
||||||
|
let pty_pair = pty_system.openpty(PtySize {
|
||||||
|
rows: 30,
|
||||||
|
cols: 120,
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
}).map_err(|e| format!("Failed to create PTY: {}", e))?;
|
||||||
|
|
||||||
|
// Get shell command
|
||||||
|
let shell = get_default_shell();
|
||||||
|
let mut cmd = CommandBuilder::new(&shell);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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()));
|
||||||
|
|
||||||
|
// 继承其他环境变量
|
||||||
|
for (key, value) in std::env::vars() {
|
||||||
|
if !key.starts_with("TERM") && !key.starts_with("COLORTERM") && !key.starts_with("LC_") && !key.starts_with("LANG") {
|
||||||
|
cmd.env(&key, &value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn the shell process
|
||||||
|
let _child = pty_pair.slave.spawn_command(cmd)
|
||||||
|
.map_err(|e| format!("Failed to spawn shell: {}", e))?;
|
||||||
|
|
||||||
|
// Get writer for stdin
|
||||||
|
let writer = pty_pair.master.take_writer()
|
||||||
|
.map_err(|e| format!("Failed to get PTY writer: {}", e))?;
|
||||||
|
|
||||||
|
// Start reading output in background
|
||||||
|
let session_id_clone = session_id.clone();
|
||||||
|
let app_handle_clone = app_handle.clone();
|
||||||
|
let mut reader = pty_pair.master.try_clone_reader()
|
||||||
|
.map_err(|e| format!("Failed to get PTY reader: {}", e))?;
|
||||||
|
|
||||||
|
// Spawn reader thread
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut buffer = [0u8; 4096];
|
||||||
|
loop {
|
||||||
|
match reader.read(&mut buffer) {
|
||||||
|
Ok(0) => break, // EOF
|
||||||
|
Ok(n) => {
|
||||||
|
let data = String::from_utf8_lossy(&buffer[..n]).to_string();
|
||||||
|
let _ = app_handle_clone.emit(&format!("terminal-output:{}", session_id_clone), &data);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Error reading PTY output: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log::debug!("PTY reader thread finished for session: {}", session_id_clone);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store the session with PTY writer
|
||||||
|
let terminal_child = TerminalChild {
|
||||||
|
writer: Arc::new(Mutex::new(writer)),
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut state = terminal_state.lock().await;
|
||||||
|
state.insert(session_id.clone(), (session, Some(terminal_child)));
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("Terminal session created successfully: {}", session_id);
|
||||||
|
Ok(session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends input to a terminal session
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn send_terminal_input(
|
||||||
|
session_id: String,
|
||||||
|
input: String,
|
||||||
|
terminal_state: State<'_, TerminalState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let state = terminal_state.lock().await;
|
||||||
|
|
||||||
|
if let Some((_session, child_opt)) = state.get(&session_id) {
|
||||||
|
if let Some(child) = child_opt {
|
||||||
|
log::debug!("Sending input to terminal {}: {:?}", session_id, input);
|
||||||
|
|
||||||
|
// Write to PTY
|
||||||
|
let mut writer = child.writer.lock().await;
|
||||||
|
writer.write_all(input.as_bytes())
|
||||||
|
.map_err(|e| format!("Failed to write to terminal: {}", e))?;
|
||||||
|
writer.flush()
|
||||||
|
.map_err(|e| format!("Failed to flush terminal input: {}", e))?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!("Terminal session not found or not active: {}", session_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes a terminal session
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn close_terminal_session(
|
||||||
|
session_id: String,
|
||||||
|
terminal_state: State<'_, TerminalState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut state = terminal_state.lock().await;
|
||||||
|
|
||||||
|
if let Some((mut session, _child)) = state.remove(&session_id) {
|
||||||
|
session.is_active = false;
|
||||||
|
// PTY and child process will be dropped automatically
|
||||||
|
|
||||||
|
log::info!("Closed terminal session: {}", session_id);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("Terminal session not found: {}", session_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists all active terminal sessions
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_terminal_sessions(
|
||||||
|
terminal_state: State<'_, TerminalState>,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
let state = terminal_state.lock().await;
|
||||||
|
|
||||||
|
let sessions: Vec<String> = state.iter()
|
||||||
|
.filter_map(|(id, (session, _))| {
|
||||||
|
if session.is_active {
|
||||||
|
Some(id.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resizes a terminal session
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn resize_terminal(
|
||||||
|
session_id: String,
|
||||||
|
_cols: u16,
|
||||||
|
_rows: u16,
|
||||||
|
_terminal_state: State<'_, TerminalState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Note: With the current architecture, resize is not supported
|
||||||
|
// To support resize, we would need to keep a reference to the PTY master
|
||||||
|
// or use a different approach
|
||||||
|
log::warn!("Terminal resize not currently supported for session: {}", session_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cleanup orphaned terminal sessions
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn cleanup_terminal_sessions(
|
||||||
|
terminal_state: State<'_, TerminalState>,
|
||||||
|
) -> Result<u32, String> {
|
||||||
|
let mut state = terminal_state.lock().await;
|
||||||
|
let mut cleaned_up = 0;
|
||||||
|
|
||||||
|
let mut to_remove = Vec::new();
|
||||||
|
|
||||||
|
for (id, (session, _child)) in state.iter() {
|
||||||
|
if !session.is_active {
|
||||||
|
to_remove.push(id.clone());
|
||||||
|
cleaned_up += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the sessions
|
||||||
|
for id in to_remove {
|
||||||
|
state.remove(&id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if cleaned_up > 0 {
|
||||||
|
log::info!("Cleaned up {} orphaned terminal sessions", cleaned_up);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(cleaned_up)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the default shell for the current platform
|
||||||
|
fn get_default_shell() -> String {
|
||||||
|
if cfg!(target_os = "windows") {
|
||||||
|
// Try PowerShell first, 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()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unix-like systems: try zsh, bash, then sh
|
||||||
|
std::env::var("SHELL").unwrap_or_else(|_| {
|
||||||
|
if std::path::Path::new("/bin/zsh").exists() {
|
||||||
|
"/bin/zsh".to_string()
|
||||||
|
} else if std::path::Path::new("/bin/bash").exists() {
|
||||||
|
"/bin/bash".to_string()
|
||||||
|
} else {
|
||||||
|
"/bin/sh".to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@@ -58,6 +58,7 @@ use commands::relay_stations::{
|
|||||||
relay_stations_list, relay_station_get, relay_station_create, relay_station_update,
|
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_delete, relay_station_toggle_enable, relay_station_sync_config,
|
||||||
relay_station_restore_config, relay_station_get_current_config,
|
relay_station_restore_config, relay_station_get_current_config,
|
||||||
|
relay_stations_export, relay_stations_import,
|
||||||
};
|
};
|
||||||
use commands::relay_adapters::{
|
use commands::relay_adapters::{
|
||||||
relay_station_get_info, relay_station_get_user_info,
|
relay_station_get_info, relay_station_get_user_info,
|
||||||
@@ -75,6 +76,15 @@ use commands::filesystem::{
|
|||||||
use commands::git::{
|
use commands::git::{
|
||||||
get_git_status, get_git_history, get_git_branches, get_git_diff, get_git_commits,
|
get_git_status, get_git_history, get_git_branches, get_git_diff, get_git_commits,
|
||||||
};
|
};
|
||||||
|
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 process::ProcessRegistryState;
|
||||||
use file_watcher::FileWatcherState;
|
use file_watcher::FileWatcherState;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -88,6 +98,7 @@ fn main() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
.plugin(tauri_plugin_log::Builder::new()
|
.plugin(tauri_plugin_log::Builder::new()
|
||||||
.level(log::LevelFilter::Debug)
|
.level(log::LevelFilter::Debug)
|
||||||
@@ -220,6 +231,9 @@ fn main() {
|
|||||||
app.manage(UsageIndexState::default());
|
app.manage(UsageIndexState::default());
|
||||||
app.manage(UsageCacheState::default());
|
app.manage(UsageCacheState::default());
|
||||||
|
|
||||||
|
// Initialize Terminal state
|
||||||
|
app.manage(TerminalState::default());
|
||||||
|
|
||||||
// Optionally auto-open DevTools if env var is set (works in packaged builds)
|
// Optionally auto-open DevTools if env var is set (works in packaged builds)
|
||||||
if std::env::var("TAURI_OPEN_DEVTOOLS").ok().as_deref() == Some("1") {
|
if std::env::var("TAURI_OPEN_DEVTOOLS").ok().as_deref() == Some("1") {
|
||||||
if let Some(win) = app.get_webview_window("main") {
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
@@ -370,6 +384,8 @@ fn main() {
|
|||||||
relay_station_sync_config,
|
relay_station_sync_config,
|
||||||
relay_station_restore_config,
|
relay_station_restore_config,
|
||||||
relay_station_get_current_config,
|
relay_station_get_current_config,
|
||||||
|
relay_stations_export,
|
||||||
|
relay_stations_import,
|
||||||
relay_station_get_info,
|
relay_station_get_info,
|
||||||
relay_station_get_user_info,
|
relay_station_get_user_info,
|
||||||
relay_station_test_connection,
|
relay_station_test_connection,
|
||||||
@@ -402,6 +418,27 @@ fn main() {
|
|||||||
get_git_branches,
|
get_git_branches,
|
||||||
get_git_diff,
|
get_git_diff,
|
||||||
get_git_commits,
|
get_git_commits,
|
||||||
|
|
||||||
|
// Terminal
|
||||||
|
create_terminal_session,
|
||||||
|
send_terminal_input,
|
||||||
|
close_terminal_session,
|
||||||
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
@@ -28,22 +28,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"fs": {
|
|
||||||
"scope": [
|
|
||||||
"$HOME/**"
|
|
||||||
],
|
|
||||||
"allow": [
|
|
||||||
"readFile",
|
|
||||||
"writeFile",
|
|
||||||
"readDir",
|
|
||||||
"copyFile",
|
|
||||||
"createDir",
|
|
||||||
"removeDir",
|
|
||||||
"removeFile",
|
|
||||||
"renameFile",
|
|
||||||
"exists"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"shell": {
|
"shell": {
|
||||||
"open": true
|
"open": true
|
||||||
}
|
}
|
||||||
|
@@ -29,6 +29,7 @@ import { useAppLifecycle, useTrackEvent } from "@/hooks";
|
|||||||
import { useTranslation } from "@/hooks/useTranslation";
|
import { useTranslation } from "@/hooks/useTranslation";
|
||||||
import { WelcomePage } from "@/components/WelcomePage";
|
import { WelcomePage } from "@/components/WelcomePage";
|
||||||
import RelayStationManager from "@/components/RelayStationManager";
|
import RelayStationManager from "@/components/RelayStationManager";
|
||||||
|
import { CcrRouterManager } from "@/components/CcrRouterManager";
|
||||||
import i18n from "@/lib/i18n";
|
import i18n from "@/lib/i18n";
|
||||||
|
|
||||||
type View =
|
type View =
|
||||||
@@ -44,6 +45,7 @@ type View =
|
|||||||
| "agent-run-view"
|
| "agent-run-view"
|
||||||
| "mcp"
|
| "mcp"
|
||||||
| "relay-stations"
|
| "relay-stations"
|
||||||
|
| "ccr-router"
|
||||||
| "usage-dashboard"
|
| "usage-dashboard"
|
||||||
| "project-settings"
|
| "project-settings"
|
||||||
| "tabs"; // New view for tab-based interface
|
| "tabs"; // New view for tab-based interface
|
||||||
@@ -282,6 +284,11 @@ function AppContent() {
|
|||||||
<RelayStationManager onBack={() => handleViewChange("welcome")} />
|
<RelayStationManager onBack={() => handleViewChange("welcome")} />
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "ccr-router":
|
||||||
|
return (
|
||||||
|
<CcrRouterManager onBack={() => handleViewChange("welcome")} />
|
||||||
|
);
|
||||||
|
|
||||||
case "cc-agents":
|
case "cc-agents":
|
||||||
return (
|
return (
|
||||||
<CCAgents
|
<CCAgents
|
||||||
|
463
src/components/CcrRouterManager.tsx
Normal file
463
src/components/CcrRouterManager.tsx
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
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();
|
||||||
|
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://www.npmjs.com/package/@musistudio/claude-code-router")}
|
||||||
|
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 提供商(OpenRouter、DeepSeek、Gemini 等)</li>
|
||||||
|
<li>智能路由规则,根据令牌数量和请求类型自动选择</li>
|
||||||
|
<li>Web UI 管理界面,方便配置和监控</li>
|
||||||
|
<li>无需 Anthropic 账户即可使用 Claude Code</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toast Container */}
|
||||||
|
<ToastContainer>
|
||||||
|
{toast && (
|
||||||
|
<Toast
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
onDismiss={() => setToast(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ToastContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"
|
|||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Terminal,
|
Terminal as TerminalIcon,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Copy,
|
Copy,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -22,7 +22,8 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
FileX,
|
FileX,
|
||||||
Clock
|
Clock,
|
||||||
|
Square
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -64,6 +65,7 @@ import { FlexLayoutContainer } from "@/components/layout/FlexLayoutContainer";
|
|||||||
import { MainContentArea } from "@/components/layout/MainContentArea";
|
import { MainContentArea } from "@/components/layout/MainContentArea";
|
||||||
import { SidePanel } from "@/components/layout/SidePanel";
|
import { SidePanel } from "@/components/layout/SidePanel";
|
||||||
import { ChatView } from "@/components/layout/ChatView";
|
import { ChatView } from "@/components/layout/ChatView";
|
||||||
|
import { Terminal } from "@/components/Terminal";
|
||||||
|
|
||||||
interface ClaudeCodeSessionProps {
|
interface ClaudeCodeSessionProps {
|
||||||
/**
|
/**
|
||||||
@@ -120,7 +122,10 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
openFileEditor,
|
openFileEditor,
|
||||||
closeFileEditor,
|
closeFileEditor,
|
||||||
openPreview: openLayoutPreview,
|
openPreview: openLayoutPreview,
|
||||||
closePreview: closeLayoutPreview
|
closePreview: closeLayoutPreview,
|
||||||
|
openTerminal,
|
||||||
|
closeTerminal,
|
||||||
|
toggleTerminalMaximize
|
||||||
} = layoutManager;
|
} = layoutManager;
|
||||||
|
|
||||||
const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || "");
|
const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || "");
|
||||||
@@ -1389,7 +1394,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{displayableMessages.length === 0 ? (
|
{displayableMessages.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full min-h-[200px] text-muted-foreground">
|
<div className="flex flex-col items-center justify-center h-full min-h-[200px] text-muted-foreground">
|
||||||
<Terminal className="h-12 w-12 mb-3 opacity-50" />
|
<TerminalIcon className="h-12 w-12 mb-3 opacity-50" />
|
||||||
<p className="text-sm">开始对话或等待消息加载...</p>
|
<p className="text-sm">开始对话或等待消息加载...</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1560,6 +1565,29 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If terminal is maximized, render only the Terminal in full screen
|
||||||
|
if (layout.activeView === 'terminal' && layout.isTerminalMaximized) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 bg-background"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<Terminal
|
||||||
|
onClose={closeTerminal}
|
||||||
|
isMaximized={layout.isTerminalMaximized}
|
||||||
|
onToggleMaximize={toggleTerminalMaximize}
|
||||||
|
projectPath={projectPath}
|
||||||
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// If preview is maximized, render only the WebviewPreview in full screen
|
// If preview is maximized, render only the WebviewPreview in full screen
|
||||||
if (layout.activeView === 'preview' && layout.previewUrl && isPreviewMaximized) {
|
if (layout.activeView === 'preview' && layout.previewUrl && isPreviewMaximized) {
|
||||||
return (
|
return (
|
||||||
@@ -1604,7 +1632,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Terminal className="h-5 w-5 text-muted-foreground" />
|
<TerminalIcon className="h-5 w-5 text-muted-foreground" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h1 className="text-xl font-bold">{t('app.claudeCodeSession')}</h1>
|
<h1 className="text-xl font-bold">{t('app.claudeCodeSession')}</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -1624,6 +1652,27 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Terminal Toggle */}
|
||||||
|
{projectPath && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={openTerminal}
|
||||||
|
className={cn("h-8 w-8", layout.activeView === 'terminal' && "text-primary")}
|
||||||
|
>
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>终端</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* File Explorer Toggle */}
|
{/* File Explorer Toggle */}
|
||||||
{projectPath && (
|
{projectPath && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -1840,14 +1889,27 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
visible: true,
|
visible: true,
|
||||||
content: (
|
content: (
|
||||||
<MainContentArea isEditing={layout.activeView === 'editor'}>
|
<MainContentArea isEditing={layout.activeView === 'editor'}>
|
||||||
{layout.activeView === 'editor' && layout.editingFile ? (
|
{/* 终端始终渲染,通过显示/隐藏控制 */}
|
||||||
// 文件编辑器视图
|
<div className={cn("absolute inset-0", layout.activeView === 'terminal' ? 'block' : 'hidden')}>
|
||||||
<FileEditorEnhanced
|
<Terminal
|
||||||
filePath={layout.editingFile}
|
onClose={closeTerminal}
|
||||||
onClose={closeFileEditor}
|
isMaximized={layout.isTerminalMaximized}
|
||||||
className="h-full"
|
onToggleMaximize={toggleTerminalMaximize}
|
||||||
|
projectPath={projectPath}
|
||||||
|
className="h-full w-full"
|
||||||
/>
|
/>
|
||||||
) : layout.activeView === 'preview' && layout.previewUrl ? (
|
</div>
|
||||||
|
|
||||||
|
{/* 其他视图 */}
|
||||||
|
<div className={cn("h-full w-full", layout.activeView === 'terminal' ? 'hidden' : 'block')}>
|
||||||
|
{layout.activeView === 'editor' && layout.editingFile ? (
|
||||||
|
// 文件编辑器视图
|
||||||
|
<FileEditorEnhanced
|
||||||
|
filePath={layout.editingFile}
|
||||||
|
onClose={closeFileEditor}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
) : layout.activeView === 'preview' && layout.previewUrl ? (
|
||||||
// 预览视图
|
// 预览视图
|
||||||
<SplitPane
|
<SplitPane
|
||||||
left={
|
left={
|
||||||
@@ -1885,24 +1947,24 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
minRightWidth={400}
|
minRightWidth={400}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// 默认聊天视图
|
// 默认聊天视图
|
||||||
<ChatView
|
<ChatView
|
||||||
projectPathInput={projectPathInput}
|
projectPathInput={projectPathInput}
|
||||||
messagesList={messagesList}
|
messagesList={messagesList}
|
||||||
floatingInput={
|
floatingInput={
|
||||||
<div className="w-full max-w-5xl mx-auto px-4">
|
<div className="w-full max-w-5xl mx-auto px-4">
|
||||||
<FloatingPromptInput
|
<FloatingPromptInput
|
||||||
ref={floatingPromptRef}
|
ref={floatingPromptRef}
|
||||||
onSend={handleSendPrompt}
|
onSend={handleSendPrompt}
|
||||||
onCancel={handleCancelExecution}
|
onCancel={handleCancelExecution}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
disabled={!projectPath}
|
disabled={!projectPath}
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
floatingElements={
|
floatingElements={
|
||||||
<>
|
<>
|
||||||
{/* 文件监控展开面板 */}
|
{/* 文件监控展开面板 */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -2055,7 +2117,8 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</MainContentArea>
|
</MainContentArea>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
@@ -1,11 +1,11 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Network,
|
Network,
|
||||||
Globe,
|
Globe,
|
||||||
Terminal,
|
Terminal,
|
||||||
Trash2,
|
Trash2,
|
||||||
Play,
|
Play,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -57,7 +57,7 @@ export const MCPServerList: React.FC<MCPServerListProps> = ({
|
|||||||
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
|
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
|
||||||
const [copiedServer, setCopiedServer] = useState<string | null>(null);
|
const [copiedServer, setCopiedServer] = useState<string | null>(null);
|
||||||
const [connectedServers] = useState<string[]>([]);
|
const [connectedServers] = useState<string[]>([]);
|
||||||
|
|
||||||
// Analytics tracking
|
// Analytics tracking
|
||||||
const trackEvent = useTrackEvent();
|
const trackEvent = useTrackEvent();
|
||||||
|
|
||||||
@@ -103,18 +103,18 @@ export const MCPServerList: React.FC<MCPServerListProps> = ({
|
|||||||
const handleRemoveServer = async (name: string) => {
|
const handleRemoveServer = async (name: string) => {
|
||||||
try {
|
try {
|
||||||
setRemovingServer(name);
|
setRemovingServer(name);
|
||||||
|
|
||||||
// Check if server was connected
|
// Check if server was connected
|
||||||
const wasConnected = connectedServers.includes(name);
|
const wasConnected = connectedServers.includes(name);
|
||||||
|
|
||||||
await api.mcpRemove(name);
|
await api.mcpRemove(name);
|
||||||
|
|
||||||
// Track server removal
|
// Track server removal
|
||||||
trackEvent.mcpServerRemoved({
|
trackEvent.mcpServerRemoved({
|
||||||
server_name: name,
|
server_name: name,
|
||||||
was_connected: wasConnected
|
was_connected: wasConnected
|
||||||
});
|
});
|
||||||
|
|
||||||
onServerRemoved(name);
|
onServerRemoved(name);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to remove server:", error);
|
console.error("Failed to remove server:", error);
|
||||||
@@ -131,15 +131,15 @@ export const MCPServerList: React.FC<MCPServerListProps> = ({
|
|||||||
setTestingServer(name);
|
setTestingServer(name);
|
||||||
const result = await api.mcpTestConnection(name);
|
const result = await api.mcpTestConnection(name);
|
||||||
const server = servers.find(s => s.name === name);
|
const server = servers.find(s => s.name === name);
|
||||||
|
|
||||||
// Track connection result - result is a string message
|
// Track connection result - result is a string message
|
||||||
trackEvent.mcpServerConnected(name, true, server?.transport || 'unknown');
|
trackEvent.mcpServerConnected(name, true, server?.transport || 'unknown');
|
||||||
|
|
||||||
// TODO: Show result in a toast or modal
|
// TODO: Show result in a toast or modal
|
||||||
console.log("Test result:", result);
|
console.log("Test result:", result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to test connection:", error);
|
console.error("Failed to test.md connection:", error);
|
||||||
|
|
||||||
trackEvent.mcpConnectionError({
|
trackEvent.mcpConnectionError({
|
||||||
server_name: name,
|
server_name: name,
|
||||||
error_type: 'test_failed',
|
error_type: 'test_failed',
|
||||||
@@ -202,7 +202,7 @@ export const MCPServerList: React.FC<MCPServerListProps> = ({
|
|||||||
const renderServerItem = (server: MCPServer) => {
|
const renderServerItem = (server: MCPServer) => {
|
||||||
const isExpanded = expandedServers.has(server.name);
|
const isExpanded = expandedServers.has(server.name);
|
||||||
const isCopied = copiedServer === server.name;
|
const isCopied = copiedServer === server.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={server.name}
|
key={server.name}
|
||||||
@@ -226,7 +226,7 @@ export const MCPServerList: React.FC<MCPServerListProps> = ({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{server.command && !isExpanded && (
|
{server.command && !isExpanded && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-xs text-muted-foreground font-mono truncate pl-9 flex-1" title={server.command}>
|
<p className="text-xs text-muted-foreground font-mono truncate pl-9 flex-1" title={server.command}>
|
||||||
@@ -243,7 +243,7 @@ export const MCPServerList: React.FC<MCPServerListProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{server.transport === "sse" && server.url && !isExpanded && (
|
{server.transport === "sse" && server.url && !isExpanded && (
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<p className="text-xs text-muted-foreground font-mono truncate pl-9" title={server.url}>
|
<p className="text-xs text-muted-foreground font-mono truncate pl-9" title={server.url}>
|
||||||
@@ -251,14 +251,14 @@ export const MCPServerList: React.FC<MCPServerListProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{Object.keys(server.env).length > 0 && !isExpanded && (
|
{Object.keys(server.env).length > 0 && !isExpanded && (
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground pl-9">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground pl-9">
|
||||||
<span>{t('mcp.environmentVariablesCount', { count: Object.keys(server.env).length })}</span>
|
<span>{t('mcp.environmentVariablesCount', { count: Object.keys(server.env).length })}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -288,7 +288,7 @@ export const MCPServerList: React.FC<MCPServerListProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded Details */}
|
{/* Expanded Details */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -328,7 +328,7 @@ export const MCPServerList: React.FC<MCPServerListProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{server.args && server.args.length > 0 && (
|
{server.args && server.args.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-xs font-medium text-muted-foreground">{t('mcp.arguments')}</p>
|
<p className="text-xs font-medium text-muted-foreground">{t('mcp.arguments')}</p>
|
||||||
@@ -342,7 +342,7 @@ export const MCPServerList: React.FC<MCPServerListProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{server.transport === "sse" && server.url && (
|
{server.transport === "sse" && server.url && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-xs font-medium text-muted-foreground">{t('mcp.url')}</p>
|
<p className="text-xs font-medium text-muted-foreground">{t('mcp.url')}</p>
|
||||||
@@ -351,7 +351,7 @@ export const MCPServerList: React.FC<MCPServerListProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{Object.keys(server.env).length > 0 && (
|
{Object.keys(server.env).length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-xs font-medium text-muted-foreground">{t('mcp.environmentVariables')}</p>
|
<p className="text-xs font-medium text-muted-foreground">{t('mcp.environmentVariables')}</p>
|
||||||
@@ -433,4 +433,4 @@ export const MCPServerList: React.FC<MCPServerListProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
File diff suppressed because it is too large
Load Diff
460
src/components/Terminal.tsx
Normal file
460
src/components/Terminal.tsx
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import { Terminal as XTerm } from 'xterm';
|
||||||
|
import { WebLinksAddon } from 'xterm-addon-web-links';
|
||||||
|
import { SearchAddon } from 'xterm-addon-search';
|
||||||
|
import 'xterm/css/xterm.css';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { X, Maximize2, Minimize2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
interface TerminalProps {
|
||||||
|
className?: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
isMaximized?: boolean;
|
||||||
|
onToggleMaximize?: () => void;
|
||||||
|
projectPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Terminal: React.FC<TerminalProps> = ({
|
||||||
|
className,
|
||||||
|
onClose,
|
||||||
|
isMaximized = false,
|
||||||
|
onToggleMaximize,
|
||||||
|
projectPath
|
||||||
|
}) => {
|
||||||
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const xtermRef = useRef<XTerm | null>(null);
|
||||||
|
const isInitializedRef = useRef(false);
|
||||||
|
const unlistenRef = useRef<(() => void) | null>(null);
|
||||||
|
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
|
const [terminalSize, setTerminalSize] = useState({ cols: 80, rows: 24 });
|
||||||
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
|
|
||||||
|
// 计算终端应该有的尺寸
|
||||||
|
const calculateOptimalSize = useCallback(() => {
|
||||||
|
if (!terminalRef.current) return { cols: 80, rows: 24 };
|
||||||
|
|
||||||
|
const container = terminalRef.current;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
// 获取或估算字符尺寸
|
||||||
|
const fontSize = 14; // 我们设置的字体大小
|
||||||
|
const charWidth = fontSize * 0.6; // 等宽字体的典型宽度比例
|
||||||
|
const lineHeight = fontSize * 1.2; // 行高
|
||||||
|
|
||||||
|
// 计算能容纳的最大列数和行数
|
||||||
|
const availableWidth = rect.width - 2;
|
||||||
|
const availableHeight = rect.height - 2;
|
||||||
|
|
||||||
|
const cols = Math.max(80, Math.floor(availableWidth / charWidth));
|
||||||
|
const rows = Math.max(24, Math.floor(availableHeight / lineHeight));
|
||||||
|
|
||||||
|
// 计算实际使用的宽度
|
||||||
|
const usedWidth = cols * charWidth;
|
||||||
|
const unusedWidth = availableWidth - usedWidth;
|
||||||
|
const percentUsed = ((usedWidth / availableWidth) * 100).toFixed(1);
|
||||||
|
|
||||||
|
console.log('[Terminal] Size calculation:', {
|
||||||
|
containerWidth: rect.width,
|
||||||
|
availableWidth,
|
||||||
|
charWidth,
|
||||||
|
cols,
|
||||||
|
usedWidth,
|
||||||
|
unusedWidth,
|
||||||
|
percentUsed: `${percentUsed}%`,
|
||||||
|
message: unusedWidth > 10 ? `还有 ${unusedWidth.toFixed(1)}px 未使用` : '宽度使用正常'
|
||||||
|
});
|
||||||
|
|
||||||
|
return { cols, rows };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 调整终端大小
|
||||||
|
const resizeTerminal = useCallback(() => {
|
||||||
|
if (!xtermRef.current || !terminalRef.current) return;
|
||||||
|
|
||||||
|
// 先尝试获取实际的字符尺寸
|
||||||
|
let actualCharWidth = 8.4; // 默认值
|
||||||
|
let actualLineHeight = 16.8; // 默认值
|
||||||
|
|
||||||
|
try {
|
||||||
|
const core = (xtermRef.current as any)._core;
|
||||||
|
if (core && core._renderService && core._renderService.dimensions) {
|
||||||
|
const dims = core._renderService.dimensions;
|
||||||
|
if (dims.actualCellWidth) actualCharWidth = dims.actualCellWidth;
|
||||||
|
if (dims.actualCellHeight) actualLineHeight = dims.actualCellHeight;
|
||||||
|
|
||||||
|
console.log('[Terminal] Using actual char dimensions:', {
|
||||||
|
actualCharWidth,
|
||||||
|
actualLineHeight
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 使用默认值
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用实际字符尺寸计算新的列数和行数
|
||||||
|
const rect = terminalRef.current.getBoundingClientRect();
|
||||||
|
const availableWidth = rect.width - 2;
|
||||||
|
const availableHeight = rect.height - 2;
|
||||||
|
|
||||||
|
// 更新容器宽度显示
|
||||||
|
setContainerWidth(rect.width);
|
||||||
|
|
||||||
|
const newCols = Math.max(80, Math.floor(availableWidth / actualCharWidth));
|
||||||
|
const newRows = Math.max(24, Math.floor(availableHeight / actualLineHeight));
|
||||||
|
|
||||||
|
// 计算宽度使用情况
|
||||||
|
const usedWidth = newCols * actualCharWidth;
|
||||||
|
const unusedWidth = availableWidth - usedWidth;
|
||||||
|
const percentUsed = ((usedWidth / availableWidth) * 100).toFixed(1);
|
||||||
|
|
||||||
|
// 只有当尺寸真的改变时才调整
|
||||||
|
if (newCols !== terminalSize.cols || newRows !== terminalSize.rows) {
|
||||||
|
console.log('[Terminal] Resizing:', {
|
||||||
|
from: terminalSize,
|
||||||
|
to: { cols: newCols, rows: newRows },
|
||||||
|
containerWidth: rect.width,
|
||||||
|
availableWidth,
|
||||||
|
usedWidth,
|
||||||
|
unusedWidth,
|
||||||
|
percentUsed: `${percentUsed}%`
|
||||||
|
});
|
||||||
|
|
||||||
|
setTerminalSize({ cols: newCols, rows: newRows });
|
||||||
|
xtermRef.current.resize(newCols, newRows);
|
||||||
|
|
||||||
|
// 更新后端
|
||||||
|
if (sessionId) {
|
||||||
|
api.resizeTerminal(sessionId, newCols, newRows).catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强制刷新渲染
|
||||||
|
try {
|
||||||
|
const core = (xtermRef.current as any)._core;
|
||||||
|
if (core && core._renderService && core._renderService._onIntersectionChange) {
|
||||||
|
core._renderService._onIntersectionChange({ intersectionRatio: 1 });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略错误,某些版本的 xterm 可能没有这个方法
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [terminalSize, sessionId]);
|
||||||
|
|
||||||
|
// 防抖的resize处理
|
||||||
|
const handleResize = useCallback(() => {
|
||||||
|
if (resizeTimeoutRef.current) {
|
||||||
|
clearTimeout(resizeTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeTimeoutRef.current = setTimeout(() => {
|
||||||
|
resizeTerminal();
|
||||||
|
}, 100);
|
||||||
|
}, [resizeTerminal]);
|
||||||
|
|
||||||
|
// 初始化终端
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitializedRef.current || !terminalRef.current) return;
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const initializeTerminal = async () => {
|
||||||
|
try {
|
||||||
|
console.log('[Terminal] Initializing...');
|
||||||
|
isInitializedRef.current = true;
|
||||||
|
|
||||||
|
// 先计算初始尺寸
|
||||||
|
const initialSize = calculateOptimalSize();
|
||||||
|
setTerminalSize(initialSize);
|
||||||
|
|
||||||
|
// 创建终端实例
|
||||||
|
const xterm = new XTerm({
|
||||||
|
cols: initialSize.cols,
|
||||||
|
rows: initialSize.rows,
|
||||||
|
theme: {
|
||||||
|
background: '#1e1e1e',
|
||||||
|
foreground: '#d4d4d4',
|
||||||
|
cursor: '#ffffff',
|
||||||
|
cursorAccent: '#000000',
|
||||||
|
selectionBackground: '#264f78',
|
||||||
|
black: '#000000',
|
||||||
|
red: '#cd3131',
|
||||||
|
green: '#0dbc79',
|
||||||
|
yellow: '#e5e510',
|
||||||
|
blue: '#2472c8',
|
||||||
|
magenta: '#bc3fbc',
|
||||||
|
cyan: '#11a8cd',
|
||||||
|
white: '#e5e5e5',
|
||||||
|
brightBlack: '#666666',
|
||||||
|
brightRed: '#f14c4c',
|
||||||
|
brightGreen: '#23d18b',
|
||||||
|
brightYellow: '#f5f543',
|
||||||
|
brightBlue: '#3b8eea',
|
||||||
|
brightMagenta: '#d670d6',
|
||||||
|
brightCyan: '#29b8db',
|
||||||
|
brightWhite: '#e5e5e5',
|
||||||
|
},
|
||||||
|
fontFamily: '"MesloLGS NF", "JetBrainsMono Nerd Font", "FiraCode Nerd Font", "Hack Nerd Font", "JetBrains Mono", "SF Mono", "Monaco", "Consolas", "Courier New", monospace',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 'normal',
|
||||||
|
fontWeightBold: 'bold',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
letterSpacing: 0,
|
||||||
|
scrollback: 10000,
|
||||||
|
convertEol: true,
|
||||||
|
cursorBlink: true,
|
||||||
|
cursorStyle: 'block',
|
||||||
|
drawBoldTextInBrightColors: true,
|
||||||
|
macOptionIsMeta: true,
|
||||||
|
rightClickSelectsWord: true,
|
||||||
|
allowProposedApi: true,
|
||||||
|
// @ts-ignore
|
||||||
|
rendererType: 'canvas',
|
||||||
|
windowsMode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加插件
|
||||||
|
const webLinksAddon = new WebLinksAddon();
|
||||||
|
const searchAddon = new SearchAddon();
|
||||||
|
|
||||||
|
xterm.loadAddon(webLinksAddon);
|
||||||
|
xterm.loadAddon(searchAddon);
|
||||||
|
|
||||||
|
// 打开终端
|
||||||
|
if (terminalRef.current) {
|
||||||
|
xterm.open(terminalRef.current);
|
||||||
|
} else {
|
||||||
|
|
||||||
|
console.error('[Terminal] Terminal container ref is null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存引用
|
||||||
|
xtermRef.current = xterm;
|
||||||
|
|
||||||
|
// 延迟一下确保渲染完成,然后获取实际字符尺寸并调整
|
||||||
|
setTimeout(() => {
|
||||||
|
if (xtermRef.current && terminalRef.current) {
|
||||||
|
// 尝试获取实际的字符尺寸
|
||||||
|
try {
|
||||||
|
const core = (xtermRef.current as any)._core;
|
||||||
|
if (core && core._renderService && core._renderService.dimensions) {
|
||||||
|
const dims = core._renderService.dimensions;
|
||||||
|
const actualCharWidth = dims.actualCellWidth || dims.scaledCellWidth;
|
||||||
|
const actualLineHeight = dims.actualCellHeight || dims.scaledCellHeight;
|
||||||
|
|
||||||
|
if (actualCharWidth && actualLineHeight) {
|
||||||
|
console.log('[Terminal] Actual character dimensions:', {
|
||||||
|
charWidth: actualCharWidth,
|
||||||
|
lineHeight: actualLineHeight
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用实际尺寸重新计算
|
||||||
|
const rect = terminalRef.current.getBoundingClientRect();
|
||||||
|
const availableWidth = rect.width - 2;
|
||||||
|
const newCols = Math.floor(availableWidth / actualCharWidth);
|
||||||
|
|
||||||
|
console.log('[Terminal] Recalculating with actual dimensions:', {
|
||||||
|
availableWidth,
|
||||||
|
actualCharWidth,
|
||||||
|
newCols,
|
||||||
|
currentCols: xtermRef.current.cols
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newCols > xtermRef.current.cols) {
|
||||||
|
xtermRef.current.resize(newCols, xtermRef.current.rows);
|
||||||
|
setTerminalSize({ cols: newCols, rows: xtermRef.current.rows });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Terminal] Could not get actual char dimensions:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resizeTerminal();
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
// 创建终端会话
|
||||||
|
const newSessionId = await api.createTerminalSession(projectPath || process.cwd());
|
||||||
|
|
||||||
|
if (!isMounted) {
|
||||||
|
await api.closeTerminalSession(newSessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionId(newSessionId);
|
||||||
|
setIsConnected(true);
|
||||||
|
|
||||||
|
// 监听终端输出
|
||||||
|
const unlisten = await api.listenToTerminalOutput(newSessionId, (data: string) => {
|
||||||
|
if (xtermRef.current && isMounted) {
|
||||||
|
xtermRef.current.write(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
unlistenRef.current = unlisten;
|
||||||
|
|
||||||
|
// 监听数据输入
|
||||||
|
xterm.onData((data) => {
|
||||||
|
if (newSessionId && isMounted) {
|
||||||
|
api.sendTerminalInput(newSessionId, data).catch((error) => {
|
||||||
|
console.error('[Terminal] Failed to send input:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Terminal] Initialized with session:', newSessionId);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Terminal] Failed to initialize:', error);
|
||||||
|
if (xtermRef.current && isMounted) {
|
||||||
|
xtermRef.current.write('\r\n\x1b[31mFailed to start terminal session\x1b[0m\r\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeTerminal();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
|
||||||
|
if (resizeTimeoutRef.current) {
|
||||||
|
clearTimeout(resizeTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unlistenRef.current) {
|
||||||
|
unlistenRef.current();
|
||||||
|
unlistenRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
api.closeTerminalSession(sessionId).catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xtermRef.current) {
|
||||||
|
xtermRef.current.dispose();
|
||||||
|
xtermRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isInitializedRef.current = false;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
api.cleanupTerminalSessions().catch(console.error);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
}, []); // 只运行一次
|
||||||
|
|
||||||
|
// 监听容器大小变化
|
||||||
|
useEffect(() => {
|
||||||
|
if (!terminalRef.current) return;
|
||||||
|
|
||||||
|
// 初始化时立即获取容器宽度
|
||||||
|
const rect = terminalRef.current.getBoundingClientRect();
|
||||||
|
setContainerWidth(rect.width);
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const { width } = entry.contentRect;
|
||||||
|
setContainerWidth(width);
|
||||||
|
}
|
||||||
|
handleResize();
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(terminalRef.current);
|
||||||
|
|
||||||
|
// 监听窗口大小变化
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, [handleResize]);
|
||||||
|
|
||||||
|
// 最大化状态改变时调整大小
|
||||||
|
useEffect(() => {
|
||||||
|
handleResize();
|
||||||
|
}, [isMaximized, handleResize]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col h-full w-full bg-[#1e1e1e]', className)}>
|
||||||
|
{/* 终端头部 */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 bg-gray-900 border-b border-gray-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
isConnected ? 'bg-green-500' : 'bg-red-500'
|
||||||
|
)} />
|
||||||
|
<span className="text-sm text-gray-300">
|
||||||
|
Terminal {sessionId ? `(${sessionId.slice(0, 8)})` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{projectPath && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{projectPath}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{terminalSize.cols}×{terminalSize.rows}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-yellow-400">
|
||||||
|
容器宽度: {containerWidth.toFixed(0)}px
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{onToggleMaximize && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onToggleMaximize}
|
||||||
|
className="h-6 w-6 text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
{isMaximized ? (
|
||||||
|
<Minimize2 className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Maximize2 className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onClose && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-6 w-6 text-gray-400 hover:text-white hover:bg-red-600"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 终端主体 */}
|
||||||
|
<div className="flex-1 relative overflow-hidden">
|
||||||
|
<div
|
||||||
|
ref={terminalRef}
|
||||||
|
className="absolute inset-0 p-1"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#1e1e1e',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isConnected && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2" />
|
||||||
|
<p className="text-gray-300 text-sm">正在连接终端...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Terminal;
|
119
src/components/ThemeSwitcher.tsx
Normal file
119
src/components/ThemeSwitcher.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@@ -4,6 +4,7 @@ import { Circle, FileText, Settings, ExternalLink, BarChart3, Network, Info, Bot
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Popover } from "@/components/ui/popover";
|
import { Popover } from "@/components/ui/popover";
|
||||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||||
|
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
||||||
import { useTranslation } from "@/hooks/useTranslation";
|
import { useTranslation } from "@/hooks/useTranslation";
|
||||||
import { api, type ClaudeVersionStatus } from "@/lib/api";
|
import { api, type ClaudeVersionStatus } from "@/lib/api";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -240,6 +241,9 @@ export const Topbar: React.FC<TopbarProps> = ({
|
|||||||
{/* Language Switcher */}
|
{/* Language Switcher */}
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
|
|
||||||
|
{/* Theme Switcher */}
|
||||||
|
<ThemeSwitcher />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { motion } from "framer-motion";
|
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 { useTranslation } from "@/hooks/useTranslation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ClaudiaLogoMinimal } from "@/components/ClaudiaLogo";
|
import { ClaudiaLogoMinimal } from "@/components/ClaudiaLogo";
|
||||||
@@ -61,6 +61,15 @@ export function WelcomePage({ onNavigate, onNewSession }: WelcomePageProps) {
|
|||||||
bgColor: "bg-orange-500/10",
|
bgColor: "bg-orange-500/10",
|
||||||
view: "mcp"
|
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",
|
id: "claude-md",
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
@@ -147,7 +156,7 @@ export function WelcomePage({ onNavigate, onNewSession }: WelcomePageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Feature Cards */}
|
{/* 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) => (
|
{bottomFeatures.map((feature, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={feature.id}
|
key={feature.id}
|
||||||
|
@@ -104,10 +104,10 @@ export const FlexLayoutContainer: React.FC<FlexLayoutContainerProps> = ({
|
|||||||
key={panel.id}
|
key={panel.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative h-full',
|
'relative h-full',
|
||||||
isMain ? 'flex-1 min-w-0' : 'overflow-hidden',
|
isMain ? 'flex-1 min-w-0 w-full overflow-hidden' : 'overflow-hidden',
|
||||||
panel.className
|
panel.className
|
||||||
)}
|
)}
|
||||||
style={!isMain ? { width, flexShrink: 0 } : undefined}
|
style={!isMain ? { width, flexShrink: 0 } : { width: '100%' }}
|
||||||
>
|
>
|
||||||
{panel.content}
|
{panel.content}
|
||||||
|
|
||||||
|
@@ -14,7 +14,7 @@ export const MainContentArea: React.FC<MainContentAreaProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'h-full w-full flex flex-col',
|
'h-full w-full flex flex-col overflow-hidden',
|
||||||
'bg-background',
|
'bg-background',
|
||||||
isEditing && 'relative',
|
isEditing && 'relative',
|
||||||
className
|
className
|
||||||
|
59
src/components/ui/alert.tsx
Normal file
59
src/components/ui/alert.tsx
Normal 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 }
|
26
src/components/ui/progress.tsx
Normal file
26
src/components/ui/progress.tsx
Normal 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 }
|
@@ -9,9 +9,12 @@ interface LayoutState {
|
|||||||
showTimeline: boolean;
|
showTimeline: boolean;
|
||||||
splitPosition: number;
|
splitPosition: number;
|
||||||
isCompactMode: boolean;
|
isCompactMode: boolean;
|
||||||
activeView: 'chat' | 'editor' | 'preview'; // 新增:当前活动视图
|
activeView: 'chat' | 'editor' | 'preview' | 'terminal'; // 新增终端视图
|
||||||
editingFile: string | null; // 新增:正在编辑的文件
|
editingFile: string | null; // 新增:正在编辑的文件
|
||||||
previewUrl: string | null; // 新增:预览URL
|
previewUrl: string | null; // 新增:预览URL
|
||||||
|
isTerminalMaximized: boolean; // 新增:终端是否最大化
|
||||||
|
isTerminalOpen: boolean; // 新增:终端是否打开(保持会话)
|
||||||
|
previousView: 'chat' | 'editor' | 'preview' | null; // 新增:记录终端打开前的视图
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LayoutBreakpoints {
|
interface LayoutBreakpoints {
|
||||||
@@ -35,6 +38,9 @@ const DEFAULT_LAYOUT: LayoutState = {
|
|||||||
activeView: 'chat', // 默认显示聊天视图
|
activeView: 'chat', // 默认显示聊天视图
|
||||||
editingFile: null,
|
editingFile: null,
|
||||||
previewUrl: null,
|
previewUrl: null,
|
||||||
|
isTerminalMaximized: false, // 默认终端不最大化
|
||||||
|
isTerminalOpen: false, // 默认终端关闭
|
||||||
|
previousView: null, // 默认无前一个视图
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY = 'claudia_layout_preferences';
|
const STORAGE_KEY = 'claudia_layout_preferences';
|
||||||
@@ -298,6 +304,40 @@ export function useLayoutManager(projectPath?: string) {
|
|||||||
previewUrl: null,
|
previewUrl: null,
|
||||||
});
|
});
|
||||||
}, [saveLayout]);
|
}, [saveLayout]);
|
||||||
|
|
||||||
|
// 打开/切换终端
|
||||||
|
const openTerminal = useCallback(() => {
|
||||||
|
if (layout.activeView === 'terminal') {
|
||||||
|
// 如果终端已经显示,收起它(恢复之前的视图)
|
||||||
|
saveLayout({
|
||||||
|
activeView: layout.previousView || 'chat',
|
||||||
|
previousView: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 显示终端,记住当前视图
|
||||||
|
const prev = layout.activeView as 'chat' | 'editor' | 'preview';
|
||||||
|
saveLayout({
|
||||||
|
activeView: 'terminal',
|
||||||
|
isTerminalOpen: true,
|
||||||
|
previousView: prev,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [layout.activeView, layout.previousView, saveLayout]);
|
||||||
|
|
||||||
|
// 关闭终端(恢复之前的视图)
|
||||||
|
const closeTerminal = useCallback(() => {
|
||||||
|
saveLayout({
|
||||||
|
activeView: layout.previousView || 'chat',
|
||||||
|
previousView: null,
|
||||||
|
});
|
||||||
|
}, [layout.previousView, saveLayout]);
|
||||||
|
|
||||||
|
// 切换终端最大化状态
|
||||||
|
const toggleTerminalMaximize = useCallback(() => {
|
||||||
|
saveLayout({
|
||||||
|
isTerminalMaximized: !layout.isTerminalMaximized,
|
||||||
|
});
|
||||||
|
}, [layout.isTerminalMaximized, saveLayout]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layout,
|
layout,
|
||||||
@@ -319,5 +359,9 @@ export function useLayoutManager(projectPath?: string) {
|
|||||||
openPreview,
|
openPreview,
|
||||||
closePreview,
|
closePreview,
|
||||||
switchToChatView,
|
switchToChatView,
|
||||||
|
// 终端相关方法
|
||||||
|
openTerminal,
|
||||||
|
closeTerminal,
|
||||||
|
toggleTerminalMaximize,
|
||||||
};
|
};
|
||||||
}
|
}
|
395
src/lib/api.ts
395
src/lib/api.ts
@@ -2,7 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
import type { HooksConfiguration } from '@/types/hooks';
|
import type { HooksConfiguration } from '@/types/hooks';
|
||||||
|
|
||||||
/** Process type for tracking in ProcessRegistry */
|
/** Process type for tracking in ProcessRegistry */
|
||||||
export type ProcessType =
|
export type ProcessType =
|
||||||
| { AgentRun: { agent_id: number; agent_name: string } }
|
| { AgentRun: { agent_id: number; agent_name: string } }
|
||||||
| { ClaudeSession: { session_id: string } };
|
| { ClaudeSession: { session_id: string } };
|
||||||
|
|
||||||
@@ -451,15 +451,16 @@ export interface ImportServerResult {
|
|||||||
// ================================
|
// ================================
|
||||||
|
|
||||||
/** 中转站适配器类型 */
|
/** 中转站适配器类型 */
|
||||||
export type RelayStationAdapter =
|
export type RelayStationAdapter =
|
||||||
| 'packycode' // PackyCode 平台(默认)
|
| 'packycode' // PackyCode 平台(默认)
|
||||||
| 'newapi' // NewAPI 兼容平台
|
| 'deepseek' // DeepSeek v3.1
|
||||||
| 'oneapi' // OneAPI 兼容平台
|
| 'glm' // 智谱GLM
|
||||||
| 'yourapi' // YourAPI 特定平台
|
| 'qwen' // 千问Qwen
|
||||||
|
| 'kimi' // Kimi k2
|
||||||
| 'custom'; // 自定义简单配置
|
| 'custom'; // 自定义简单配置
|
||||||
|
|
||||||
/** 认证方式 */
|
/** 认证方式 */
|
||||||
export type AuthMethod =
|
export type AuthMethod =
|
||||||
| 'bearer_token' // Bearer Token 认证(推荐)
|
| 'bearer_token' // Bearer Token 认证(推荐)
|
||||||
| 'api_key' // API Key 认证
|
| 'api_key' // API Key 认证
|
||||||
| 'custom'; // 自定义认证方式
|
| 'custom'; // 自定义认证方式
|
||||||
@@ -473,7 +474,7 @@ export interface RelayStation {
|
|||||||
adapter: RelayStationAdapter; // 适配器类型
|
adapter: RelayStationAdapter; // 适配器类型
|
||||||
auth_method: AuthMethod; // 认证方式
|
auth_method: AuthMethod; // 认证方式
|
||||||
system_token: string; // 系统令牌
|
system_token: string; // 系统令牌
|
||||||
user_id?: string; // 用户 ID(NewAPI 必需)
|
user_id?: string; // 用户 ID(可选)
|
||||||
adapter_config?: Record<string, any>; // 适配器特定配置
|
adapter_config?: Record<string, any>; // 适配器特定配置
|
||||||
enabled: boolean; // 启用状态
|
enabled: boolean; // 启用状态
|
||||||
created_at: number; // 创建时间
|
created_at: number; // 创建时间
|
||||||
@@ -537,6 +538,15 @@ export interface ConnectionTestResult {
|
|||||||
error?: string; // 错误信息
|
error?: string; // 错误信息
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 导入结果统计 */
|
||||||
|
export interface ImportResult {
|
||||||
|
total: number; // 总数
|
||||||
|
imported: number; // 成功导入数
|
||||||
|
skipped: number; // 跳过数(重复)
|
||||||
|
failed: number; // 失败数
|
||||||
|
message: string; // 结果消息
|
||||||
|
}
|
||||||
|
|
||||||
/** Token 信息 */
|
/** Token 信息 */
|
||||||
export interface TokenInfo {
|
export interface TokenInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -560,7 +570,7 @@ export interface TokenPaginationResponse {
|
|||||||
// ============= PackyCode Nodes =============
|
// ============= PackyCode Nodes =============
|
||||||
|
|
||||||
/** PackyCode 节点类型 */
|
/** PackyCode 节点类型 */
|
||||||
export type NodeType =
|
export type NodeType =
|
||||||
| 'direct' // 直连节点
|
| 'direct' // 直连节点
|
||||||
| 'backup' // 备用节点
|
| 'backup' // 备用节点
|
||||||
| 'emergency'; // 紧急节点
|
| 'emergency'; // 紧急节点
|
||||||
@@ -585,16 +595,17 @@ export interface NodeSpeedTestResult {
|
|||||||
|
|
||||||
/** PackyCode 用户额度信息 */
|
/** PackyCode 用户额度信息 */
|
||||||
export interface PackycodeUserQuota {
|
export interface PackycodeUserQuota {
|
||||||
daily_budget_usd: number; // 日预算(美元)
|
daily_budget_usd: number; // 日预算(美元)
|
||||||
daily_spent_usd: number; // 日已使用(美元)
|
daily_spent_usd: number; // 日已使用(美元)
|
||||||
monthly_budget_usd: number; // 月预算(美元)
|
monthly_budget_usd: number; // 月预算(美元)
|
||||||
monthly_spent_usd: number; // 月已使用(美元)
|
monthly_spent_usd: number; // 月已使用(美元)
|
||||||
balance_usd: number; // 账户余额(美元)
|
balance_usd: number; // 账户余额(美元)
|
||||||
total_spent_usd: number; // 总消费(美元)
|
total_spent_usd: number; // 总消费(美元)
|
||||||
plan_type: string; // 计划类型 (pro, basic, etc.)
|
plan_type: string; // 计划类型 (pro, basic, etc.)
|
||||||
plan_expires_at: string; // 计划过期时间
|
plan_expires_at?: string; // 计划过期时间
|
||||||
username?: string; // 用户名
|
username?: string; // 用户名
|
||||||
email?: string; // 邮箱
|
email?: string; // 邮箱
|
||||||
|
opus_enabled?: boolean; // 是否启用Opus模型
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -677,13 +688,13 @@ export const api = {
|
|||||||
try {
|
try {
|
||||||
const result = await invoke<{ data: ClaudeSettings }>("get_claude_settings");
|
const result = await invoke<{ data: ClaudeSettings }>("get_claude_settings");
|
||||||
console.log("Raw result from get_claude_settings:", result);
|
console.log("Raw result from get_claude_settings:", result);
|
||||||
|
|
||||||
// The Rust backend returns ClaudeSettings { data: ... }
|
// The Rust backend returns ClaudeSettings { data: ... }
|
||||||
// We need to extract the data field
|
// We need to extract the data field
|
||||||
if (result && typeof result === 'object' && 'data' in result) {
|
if (result && typeof result === 'object' && 'data' in result) {
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the result is already the settings object, return it
|
// If the result is already the settings object, return it
|
||||||
return result as ClaudeSettings;
|
return result as ClaudeSettings;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -804,7 +815,7 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Agent API methods
|
// Agent API methods
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists all CC agents
|
* Lists all CC agents
|
||||||
* @returns Promise resolving to an array of agents
|
* @returns Promise resolving to an array of agents
|
||||||
@@ -829,17 +840,17 @@ export const api = {
|
|||||||
* @returns Promise resolving to the created agent
|
* @returns Promise resolving to the created agent
|
||||||
*/
|
*/
|
||||||
async createAgent(
|
async createAgent(
|
||||||
name: string,
|
name: string,
|
||||||
icon: string,
|
icon: string,
|
||||||
system_prompt: string,
|
system_prompt: string,
|
||||||
default_task?: string,
|
default_task?: string,
|
||||||
model?: string,
|
model?: string,
|
||||||
hooks?: string
|
hooks?: string
|
||||||
): Promise<Agent> {
|
): Promise<Agent> {
|
||||||
try {
|
try {
|
||||||
return await invoke<Agent>('create_agent', {
|
return await invoke<Agent>('create_agent', {
|
||||||
name,
|
name,
|
||||||
icon,
|
icon,
|
||||||
systemPrompt: system_prompt,
|
systemPrompt: system_prompt,
|
||||||
defaultTask: default_task,
|
defaultTask: default_task,
|
||||||
model,
|
model,
|
||||||
@@ -863,19 +874,19 @@ export const api = {
|
|||||||
* @returns Promise resolving to the updated agent
|
* @returns Promise resolving to the updated agent
|
||||||
*/
|
*/
|
||||||
async updateAgent(
|
async updateAgent(
|
||||||
id: number,
|
id: number,
|
||||||
name: string,
|
name: string,
|
||||||
icon: string,
|
icon: string,
|
||||||
system_prompt: string,
|
system_prompt: string,
|
||||||
default_task?: string,
|
default_task?: string,
|
||||||
model?: string,
|
model?: string,
|
||||||
hooks?: string
|
hooks?: string
|
||||||
): Promise<Agent> {
|
): Promise<Agent> {
|
||||||
try {
|
try {
|
||||||
return await invoke<Agent>('update_agent', {
|
return await invoke<Agent>('update_agent', {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
icon,
|
icon,
|
||||||
systemPrompt: system_prompt,
|
systemPrompt: system_prompt,
|
||||||
defaultTask: default_task,
|
defaultTask: default_task,
|
||||||
model,
|
model,
|
||||||
@@ -1544,9 +1555,9 @@ export const api = {
|
|||||||
* Tracks a batch of messages for a session for checkpointing
|
* Tracks a batch of messages for a session for checkpointing
|
||||||
*/
|
*/
|
||||||
trackSessionMessages: (
|
trackSessionMessages: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
messages: string[]
|
messages: string[]
|
||||||
): Promise<void> =>
|
): Promise<void> =>
|
||||||
invoke("track_session_messages", { sessionId, projectId, projectPath, messages }),
|
invoke("track_session_messages", { sessionId, projectId, projectPath, messages }),
|
||||||
@@ -1661,7 +1672,7 @@ export const api = {
|
|||||||
try {
|
try {
|
||||||
return await invoke<string>("mcp_test_connection", { name });
|
return await invoke<string>("mcp_test_connection", { name });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to test MCP connection:", error);
|
console.error("Failed to test.md MCP connection:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2243,6 +2254,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
|
* Gets current API config from Claude settings
|
||||||
* @returns Promise resolving to current config info
|
* @returns Promise resolving to current config info
|
||||||
@@ -2256,6 +2280,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
|
* Gets relay station information
|
||||||
* @param stationId - The relay station ID
|
* @param stationId - The relay station ID
|
||||||
@@ -2288,13 +2345,13 @@ export const api = {
|
|||||||
/**
|
/**
|
||||||
* Tests relay station connection
|
* Tests relay station connection
|
||||||
* @param stationId - The relay station ID
|
* @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> {
|
async relayStationTestConnection(stationId: string): Promise<ConnectionTestResult> {
|
||||||
try {
|
try {
|
||||||
return await invoke<ConnectionTestResult>("relay_station_test_connection", { stationId });
|
return await invoke<ConnectionTestResult>("relay_station_test_connection", { stationId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to test connection:", error);
|
console.error("Failed to test.md connection:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2308,9 +2365,9 @@ export const api = {
|
|||||||
* @returns Promise resolving to usage logs
|
* @returns Promise resolving to usage logs
|
||||||
*/
|
*/
|
||||||
async relayStationGetUsageLogs(
|
async relayStationGetUsageLogs(
|
||||||
stationId: string,
|
stationId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
page?: number,
|
page?: number,
|
||||||
size?: number
|
size?: number
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
@@ -2329,8 +2386,8 @@ export const api = {
|
|||||||
* @returns Promise resolving to token pagination response
|
* @returns Promise resolving to token pagination response
|
||||||
*/
|
*/
|
||||||
async relayStationListTokens(
|
async relayStationListTokens(
|
||||||
stationId: string,
|
stationId: string,
|
||||||
page?: number,
|
page?: number,
|
||||||
size?: number
|
size?: number
|
||||||
): Promise<TokenPaginationResponse> {
|
): Promise<TokenPaginationResponse> {
|
||||||
try {
|
try {
|
||||||
@@ -2349,8 +2406,8 @@ export const api = {
|
|||||||
* @returns Promise resolving to created token info
|
* @returns Promise resolving to created token info
|
||||||
*/
|
*/
|
||||||
async relayStationCreateToken(
|
async relayStationCreateToken(
|
||||||
stationId: string,
|
stationId: string,
|
||||||
name: string,
|
name: string,
|
||||||
quota?: number
|
quota?: number
|
||||||
): Promise<TokenInfo> {
|
): Promise<TokenInfo> {
|
||||||
try {
|
try {
|
||||||
@@ -2370,9 +2427,9 @@ export const api = {
|
|||||||
* @returns Promise resolving to updated token info
|
* @returns Promise resolving to updated token info
|
||||||
*/
|
*/
|
||||||
async relayStationUpdateToken(
|
async relayStationUpdateToken(
|
||||||
stationId: string,
|
stationId: string,
|
||||||
tokenId: string,
|
tokenId: string,
|
||||||
name?: string,
|
name?: string,
|
||||||
quota?: number
|
quota?: number
|
||||||
): Promise<TokenInfo> {
|
): Promise<TokenInfo> {
|
||||||
try {
|
try {
|
||||||
@@ -2399,16 +2456,16 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ============= PackyCode Nodes =============
|
// ============= PackyCode Nodes =============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests all PackyCode nodes and returns speed test results
|
* Tests all PackyCode nodes and returns speed test.md results
|
||||||
* @returns Promise resolving to array of node speed test results
|
* @returns Promise resolving to array of node speed test.md results
|
||||||
*/
|
*/
|
||||||
async testAllPackycodeNodes(): Promise<NodeSpeedTestResult[]> {
|
async testAllPackycodeNodes(): Promise<NodeSpeedTestResult[]> {
|
||||||
try {
|
try {
|
||||||
return await invoke<NodeSpeedTestResult[]>("test_all_packycode_nodes");
|
return await invoke<NodeSpeedTestResult[]>("test_all_packycode_nodes");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to test PackyCode nodes:", error);
|
console.error("Failed to test.md PackyCode nodes:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2454,7 +2511,7 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ============= File System Watching =============
|
// ============= File System Watching =============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts watching a directory for file system changes
|
* Starts watching a directory for file system changes
|
||||||
* @param directoryPath - The directory path to watch
|
* @param directoryPath - The directory path to watch
|
||||||
@@ -2485,7 +2542,7 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ============= Claude Project Directory Watching =============
|
// ============= Claude Project Directory Watching =============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts watching Claude project directory for the given project path
|
* Starts watching Claude project directory for the given project path
|
||||||
* @param projectPath - The project path to find the corresponding Claude directory
|
* @param projectPath - The project path to find the corresponding Claude directory
|
||||||
@@ -2512,5 +2569,225 @@ export const api = {
|
|||||||
console.error("Failed to unwatch Claude project directory:", error);
|
console.error("Failed to unwatch Claude project directory:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============= Terminal API =============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new terminal session using Zellij
|
||||||
|
* @param workingDirectory - The working directory for the terminal session
|
||||||
|
* @returns Promise resolving to the session ID
|
||||||
|
*/
|
||||||
|
async createTerminalSession(workingDirectory: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await invoke<string>("create_terminal_session", { workingDirectory });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create terminal session:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends input to a terminal session
|
||||||
|
* @param sessionId - The terminal session ID
|
||||||
|
* @param input - The input data to send
|
||||||
|
* @returns Promise resolving when input is sent
|
||||||
|
*/
|
||||||
|
async sendTerminalInput(sessionId: string, input: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
return await invoke<void>("send_terminal_input", { sessionId, input });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send terminal input:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen to terminal output for a session
|
||||||
|
* @param sessionId - The terminal session ID
|
||||||
|
* @param callback - Callback function to handle output
|
||||||
|
* @returns Promise resolving to unlisten function
|
||||||
|
*/
|
||||||
|
async listenToTerminalOutput(sessionId: string, callback: (data: string) => void): Promise<() => void> {
|
||||||
|
try {
|
||||||
|
const { listen } = await import("@tauri-apps/api/event");
|
||||||
|
const unlisten = await listen<string>(`terminal-output:${sessionId}`, (event) => {
|
||||||
|
callback(event.payload);
|
||||||
|
});
|
||||||
|
return unlisten;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to listen to terminal output:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes a terminal session
|
||||||
|
* @param sessionId - The terminal session ID to close
|
||||||
|
* @returns Promise resolving when session is closed
|
||||||
|
*/
|
||||||
|
async closeTerminalSession(sessionId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
return await invoke<void>("close_terminal_session", { sessionId });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to close terminal session:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all active terminal sessions
|
||||||
|
* @returns Promise resolving to array of active terminal session IDs
|
||||||
|
*/
|
||||||
|
async listTerminalSessions(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
return await invoke<string[]>("list_terminal_sessions");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to list terminal sessions:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resizes a terminal session
|
||||||
|
* @param sessionId - The terminal session ID
|
||||||
|
* @param cols - Number of columns
|
||||||
|
* @param rows - Number of rows
|
||||||
|
* @returns Promise resolving when resize is complete
|
||||||
|
*/
|
||||||
|
async resizeTerminal(sessionId: string, cols: number, rows: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
return await invoke<void>("resize_terminal", { sessionId, cols, rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to resize terminal:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup orphaned terminal sessions
|
||||||
|
* @returns Promise resolving to the number of sessions cleaned up
|
||||||
|
*/
|
||||||
|
async cleanupTerminalSessions(): Promise<number> {
|
||||||
|
try {
|
||||||
|
return await invoke<number>("cleanup_terminal_sessions");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to cleanup terminal sessions:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@@ -125,7 +125,9 @@
|
|||||||
"claudeMdDesc": "Edit Claude configuration files",
|
"claudeMdDesc": "Edit Claude configuration files",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"settingsDesc": "App settings and configuration",
|
"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": {
|
"projects": {
|
||||||
"title": "Projects",
|
"title": "Projects",
|
||||||
@@ -861,10 +863,11 @@
|
|||||||
"adapterType": "Adapter Type",
|
"adapterType": "Adapter Type",
|
||||||
"authMethod": "Authentication Method",
|
"authMethod": "Authentication Method",
|
||||||
"systemToken": "System Token",
|
"systemToken": "System Token",
|
||||||
|
"getApiKey": "Get API Key",
|
||||||
"tokenPlaceholder": "Enter your API token",
|
"tokenPlaceholder": "Enter your API token",
|
||||||
"tokenRequired": "System token is required",
|
"tokenRequired": "System token is required",
|
||||||
"userId": "User ID",
|
"userId": "User ID",
|
||||||
"userIdPlaceholder": "Required for NewAPI/OneAPI",
|
"userIdPlaceholder": "Optional",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"testConnection": "Test Connection",
|
"testConnection": "Test Connection",
|
||||||
"connectionSuccess": "Connection successful",
|
"connectionSuccess": "Connection successful",
|
||||||
@@ -881,6 +884,14 @@
|
|||||||
"disabledSuccess": "Relay station disabled successfully",
|
"disabledSuccess": "Relay station disabled successfully",
|
||||||
"toggleEnableFailed": "Failed to toggle relay station status",
|
"toggleEnableFailed": "Failed to toggle relay station status",
|
||||||
"syncConfig": "Sync Config",
|
"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",
|
"syncFailed": "Failed to sync configuration",
|
||||||
"currentConfig": "Current Configuration",
|
"currentConfig": "Current Configuration",
|
||||||
"notConfigured": "Not configured",
|
"notConfigured": "Not configured",
|
||||||
@@ -890,14 +901,17 @@
|
|||||||
"busService": "Bus",
|
"busService": "Bus",
|
||||||
"taxiServiceDesc": "Fast & Stable (share.packycode.com)",
|
"taxiServiceDesc": "Fast & Stable (share.packycode.com)",
|
||||||
"busServiceDesc": "Shared Economy (packycode.com)",
|
"busServiceDesc": "Shared Economy (packycode.com)",
|
||||||
"selectService": "Select a service type",
|
"taxiServiceNote": "Select a node or use auto-selection for optimal performance",
|
||||||
"fixedUrl": "Fixed URL",
|
|
||||||
"busServiceNote": "Select a node or use auto-selection for optimal performance",
|
"busServiceNote": "Select a node or use auto-selection for optimal performance",
|
||||||
"nodeSelection": "Node Selection",
|
"nodeSelection": "Node Selection",
|
||||||
"selectNode": "Select a node",
|
"selectNode": "Select a node",
|
||||||
"autoSelect": "Auto-select fastest",
|
"autoSelect": "Auto-select fastest",
|
||||||
"autoSelectDesc": "Will automatically test and select the fastest node",
|
"autoSelectDesc": "Will automatically test and select the fastest node",
|
||||||
"selectedNode": "Selected",
|
"selectedNode": "Selected",
|
||||||
|
"speedTest": "Speed Test",
|
||||||
|
"testingNodes": "Testing node speeds...",
|
||||||
|
"testing": "Testing",
|
||||||
|
"bestNodeSelected": "Best node selected",
|
||||||
"testSpeed": "Test Speed",
|
"testSpeed": "Test Speed",
|
||||||
"testResults": "Speed Test Results",
|
"testResults": "Speed Test Results",
|
||||||
"failed": "Failed",
|
"failed": "Failed",
|
||||||
@@ -907,7 +921,20 @@
|
|||||||
"autoSelectFailed": "Failed to auto-select node",
|
"autoSelectFailed": "Failed to auto-select node",
|
||||||
"selectingBestNode": "Testing nodes to find the fastest...",
|
"selectingBestNode": "Testing nodes to find the fastest...",
|
||||||
"packycodeTokenNote": "PackyCode uses API Key authentication only",
|
"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": {
|
"status": {
|
||||||
"connected": "Connected",
|
"connected": "Connected",
|
||||||
|
@@ -122,7 +122,9 @@
|
|||||||
"claudeMdDesc": "编辑 Claude 配置文件",
|
"claudeMdDesc": "编辑 Claude 配置文件",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"settingsDesc": "应用设置和配置",
|
"settingsDesc": "应用设置和配置",
|
||||||
"quickStartSession": "快速开始新会话"
|
"quickStartSession": "快速开始新会话",
|
||||||
|
"ccrRouter": "CCR 路由",
|
||||||
|
"ccrRouterDesc": "Claude Code Router 配置管理"
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
"title": "项目",
|
"title": "项目",
|
||||||
@@ -788,10 +790,11 @@
|
|||||||
"adapterType": "适配器类型",
|
"adapterType": "适配器类型",
|
||||||
"authMethod": "认证方式",
|
"authMethod": "认证方式",
|
||||||
"systemToken": "系统令牌",
|
"systemToken": "系统令牌",
|
||||||
|
"getApiKey": "获取 API Key",
|
||||||
"tokenPlaceholder": "输入您的 API 令牌",
|
"tokenPlaceholder": "输入您的 API 令牌",
|
||||||
"tokenRequired": "系统令牌必填",
|
"tokenRequired": "系统令牌必填",
|
||||||
"userId": "用户 ID",
|
"userId": "用户 ID",
|
||||||
"userIdPlaceholder": "NewAPI/OneAPI 必需",
|
"userIdPlaceholder": "可选",
|
||||||
"enabled": "启用",
|
"enabled": "启用",
|
||||||
"testConnection": "测试连接",
|
"testConnection": "测试连接",
|
||||||
"connectionSuccess": "连接成功",
|
"connectionSuccess": "连接成功",
|
||||||
@@ -808,6 +811,14 @@
|
|||||||
"disabledSuccess": "中转站禁用成功",
|
"disabledSuccess": "中转站禁用成功",
|
||||||
"toggleEnableFailed": "切换中转站状态失败",
|
"toggleEnableFailed": "切换中转站状态失败",
|
||||||
"syncConfig": "同步配置",
|
"syncConfig": "同步配置",
|
||||||
|
"configPreview": "配置预览",
|
||||||
|
"viewJson": "查看 JSON",
|
||||||
|
"configSaved": "配置已保存",
|
||||||
|
"invalidJson": "JSON 格式无效",
|
||||||
|
"saveFailed": "保存失败",
|
||||||
|
"flushDns": "刷新 DNS",
|
||||||
|
"flushDnsSuccess": "DNS 缓存已刷新",
|
||||||
|
"flushDnsFailed": "DNS 刷新失败",
|
||||||
"syncFailed": "同步配置失败",
|
"syncFailed": "同步配置失败",
|
||||||
"currentConfig": "当前配置",
|
"currentConfig": "当前配置",
|
||||||
"notConfigured": "未配置",
|
"notConfigured": "未配置",
|
||||||
@@ -817,14 +828,17 @@
|
|||||||
"busService": "公交车",
|
"busService": "公交车",
|
||||||
"taxiServiceDesc": "高速稳定 (share.packycode.com)",
|
"taxiServiceDesc": "高速稳定 (share.packycode.com)",
|
||||||
"busServiceDesc": "共享经济 (packycode.com)",
|
"busServiceDesc": "共享经济 (packycode.com)",
|
||||||
"selectService": "选择服务类型",
|
"taxiServiceNote": "选择节点或使用自动选择以获得最佳性能",
|
||||||
"fixedUrl": "固定地址",
|
|
||||||
"busServiceNote": "选择节点或使用自动选择以获得最佳性能",
|
"busServiceNote": "选择节点或使用自动选择以获得最佳性能",
|
||||||
"nodeSelection": "节点选择",
|
"nodeSelection": "节点选择",
|
||||||
"selectNode": "选择节点",
|
"selectNode": "选择节点",
|
||||||
"autoSelect": "自动选择最快",
|
"autoSelect": "自动选择最快",
|
||||||
"autoSelectDesc": "将自动测试并选择最快的节点",
|
"autoSelectDesc": "将自动测试并选择最快的节点",
|
||||||
"selectedNode": "已选择",
|
"selectedNode": "已选择",
|
||||||
|
"speedTest": "节点测速",
|
||||||
|
"testingNodes": "正在测试节点速度...",
|
||||||
|
"testing": "测试中",
|
||||||
|
"bestNodeSelected": "已选择最快节点",
|
||||||
"testSpeed": "测速",
|
"testSpeed": "测速",
|
||||||
"testResults": "测速结果",
|
"testResults": "测速结果",
|
||||||
"failed": "失败",
|
"failed": "失败",
|
||||||
@@ -834,7 +848,20 @@
|
|||||||
"autoSelectFailed": "自动选择节点失败",
|
"autoSelectFailed": "自动选择节点失败",
|
||||||
"selectingBestNode": "正在测试节点以寻找最快的...",
|
"selectingBestNode": "正在测试节点以寻找最快的...",
|
||||||
"packycodeTokenNote": "PackyCode 仅支持 API Key 认证方式",
|
"packycodeTokenNote": "PackyCode 仅支持 API Key 认证方式",
|
||||||
"enabledNote": "启用此中转站以使其可用"
|
"enabledNote": "启用此中转站以使其可用",
|
||||||
|
"export": "导出配置",
|
||||||
|
"import": "导入配置",
|
||||||
|
"exportSuccess": "中转站配置已导出",
|
||||||
|
"exportFailed": "导出失败",
|
||||||
|
"importSuccess": "中转站配置已导入",
|
||||||
|
"importFailed": "导入失败",
|
||||||
|
"importConfirm": "确认导入 {{count}} 个中转站配置?这不会删除现有配置。",
|
||||||
|
"importing": "正在导入...",
|
||||||
|
"importTotal": "总计",
|
||||||
|
"importSuccess": "成功",
|
||||||
|
"importSkipped": "跳过(重复)",
|
||||||
|
"importFailed": "失败",
|
||||||
|
"allDuplicate": "所有配置都已存在,未导入任何新配置"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"connected": "已连接",
|
"connected": "已连接",
|
||||||
@@ -862,4 +889,3 @@
|
|||||||
"title": "警告"
|
"title": "警告"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,6 +1,63 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "./styles/grid-layout.css";
|
@import "./styles/grid-layout.css";
|
||||||
|
|
||||||
|
/* xterm.js 全宽度样式 */
|
||||||
|
.xterm-full-width {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-full-width .xterm {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-full-width .xterm-viewport {
|
||||||
|
width: 100% !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-full-width .xterm-screen {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-full-width .xterm-helper-textarea {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-full-width canvas {
|
||||||
|
width: 100% !important;
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* xterm.js 修复右侧边框 - 确保所有子元素完全填充 */
|
||||||
|
.xterm-full-width .xterm-rows {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保 xterm 容器内部元素正确对齐 */
|
||||||
|
.xterm-full-width .xterm-scroll-area {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 强制覆盖 xterm 内联样式 */
|
||||||
|
.xterm-full-width > div {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移除任何可能的内边距或边距 */
|
||||||
|
.xterm-full-width .terminal {
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom scrollbar hiding */
|
/* Custom scrollbar hiding */
|
||||||
.scrollbar-hide {
|
.scrollbar-hide {
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
Reference in New Issue
Block a user