48 Commits

Author SHA1 Message Date
46e8502359 发布 1.3.0
Some checks failed
Build Linux / Build Linux x86_64 (push) Has been cancelled
Build Test / Build Test (Linux) (push) Has been cancelled
Build Test / Build Test (Windows) (push) Has been cancelled
Build Test / Build Test (macOS) (push) Has been cancelled
Build Test / Build Test Summary (push) Has been cancelled
2025-11-08 19:12:10 +08:00
fff3c4d952 修复告警 2025-11-07 16:40:46 +08:00
bdf2e499bc 抽离公共模块
Some checks failed
Build Linux / Build Linux x86_64 (push) Has been cancelled
Build Test / Build Test (Linux) (push) Has been cancelled
Build Test / Build Test (Windows) (push) Has been cancelled
Build Test / Build Test (macOS) (push) Has been cancelled
Build Test / Build Test Summary (push) Has been cancelled
2025-10-27 01:19:14 +08:00
11aac96b53 修复编译告警 2025-10-26 19:44:35 +08:00
f8e74d6dba 调整文字说明 2025-10-26 16:41:14 +08:00
4654c996a7 增加全平台测速 2025-10-26 16:10:01 +08:00
f04594e56f 增加隐藏详细信息 2025-10-26 12:49:41 +08:00
3ee320b2b4 修复打包错误 2025-10-26 09:59:54 +08:00
7111e82932 调整价格 2025-10-26 09:39:40 +08:00
46c6a27958 修复打包错误 2025-10-26 09:20:59 +08:00
ba4fb6b45e 优化展示信息 2025-10-26 09:03:05 +08:00
ad4f2bec6f 新增源配置文件管理 2025-10-26 04:47:19 +08:00
6fd07b0bc0 新增源配置文件管理 2025-10-26 03:56:42 +08:00
d0973caf37 完善文件替换规则 2025-10-26 03:16:49 +08:00
e16b47c2bc 整合自定义 json 2025-10-26 02:36:51 +08:00
b38c5d451d 整合自定义 json 2025-10-26 02:32:51 +08:00
249aeb0dc2 修复过长 bug 2025-10-26 01:31:21 +08:00
7b45152606 修复过长 bug 2025-10-26 01:30:21 +08:00
9d8af49fb2 修复过长 bug 2025-10-26 01:29:06 +08:00
1544622289 删除无用文件 2025-10-22 14:55:28 +08:00
bdd729c729 删除无用文件 2025-10-21 17:24:58 +08:00
9808b09a57 删除多余引用 2025-10-21 16:43:07 +08:00
900b1d6d9f 增加提示词管理 2025-10-21 16:38:08 +08:00
14750e0895 增加提示词管理 2025-10-21 16:38:01 +08:00
7021ab6bec 增加提示词管理 2025-10-21 15:08:31 +08:00
0e32c6e64c 修复 i18n 2025-10-17 17:20:46 +08:00
9d30fd0dac 增加直接创建会话 2025-10-16 11:34:10 +08:00
b2be1ac401 修复错误bug 2025-10-15 10:26:57 +08:00
39be84c15f 修复错误bug 2025-10-13 22:58:21 +08:00
72a51fac24 修复快速开始新对话以及点击项目无法跳转 2025-10-13 21:47:52 +08:00
7d3941780f 修正 版本号 2025-10-13 16:32:17 +08:00
f6c00aee61 Release v1.2.3: 项目搜索排序优化和设置简化
## 主要更新

### 项目管理优化
- 项目列表按最近会话时间排序(最新在上)
- 新增项目名称搜索功能
- 优化页面布局,新建会话按钮与搜索框同行显示
- 添加搜索结果统计和清空功能

### 设置页面优化
- 简化聊天记录保留期设置
- 移除永久保存特殊值处理
- 优化设置界面交互体验

### 国际化完善
- 完善中英双语翻译
- 移除未使用的翻译键

### 后端改进
- 优化项目列表性能
- 支持从JSONL文件读取最新会话时间
- 改进项目排序逻辑
2025-10-11 17:11:20 +08:00
cc6ae4bfed 调整保存时间 2025-10-11 16:52:22 +08:00
25db9ed1f3 增加永久存储记录信息
完善 i18n
2025-10-11 14:55:34 +08:00
5bae979ed6 修复 i18n 2025-10-11 13:25:53 +08:00
b64702337b 增加claude code 项目查询
优化历史记录排序
2025-10-11 13:16:49 +08:00
e7775ce8ed 修复agents历史记录跳转 2025-10-11 13:00:52 +08:00
6aefec3312 新增中转站模块可拖拽 2025-10-11 12:22:14 +08:00
4046140413 新增中转站模块可拖拽 2025-10-11 12:22:00 +08:00
e05a286653 修复快捷键关闭 2025-10-11 11:08:00 +08:00
b79cd015ab 完善默认模型 2025-10-11 01:35:33 +08:00
1de00d9c4f 增加滚动条 2025-10-11 01:24:31 +08:00
fed1e63c34 增加默认模型映射 2025-10-11 01:24:18 +08:00
27bc42d872 新增节点 2025-09-28 15:07:56 +08:00
e76f0fefb4 更新版本
Some checks failed
Build Linux / Build Linux x86_64 (push) Has been cancelled
Build Test / Build Test (Linux) (push) Has been cancelled
Build Test / Build Test (Windows) (push) Has been cancelled
Build Test / Build Test (macOS) (push) Has been cancelled
Build Test / Build Test Summary (push) Has been cancelled
2025-09-28 11:03:09 +08:00
e3e35ff3b3 调整节点 2025-09-28 11:02:49 +08:00
2d5d230ff8 调整节点 2025-09-27 22:05:32 +08:00
564c9d77f6 修复导航栏claude版本不显示
Some checks failed
Build Linux / Build Linux x86_64 (push) Has been cancelled
Build Test / Build Test (Linux) (push) Has been cancelled
Build Test / Build Test (Windows) (push) Has been cancelled
Build Test / Build Test (macOS) (push) Has been cancelled
Build Test / Build Test Summary (push) Has been cancelled
2025-09-16 17:14:05 +08:00
96 changed files with 10633 additions and 4242 deletions

192
.github/workflows/README.md vendored Normal file
View File

@@ -0,0 +1,192 @@
# GitHub Actions 工作流说明
本项目包含多个 GitHub Actions 工作流,适用于不同的使用场景。
## 📋 工作流列表
### 1. `build-opensource.yml` - 开源发布(推荐)
**用途**:正式版本发布,适合开源项目分发
**触发条件**
- 创建版本标签 (`v*`)
- 手动触发
**特点**
- ✅ 无需代码签名
- ✅ 自动创建 GitHub Release
- ✅ 支持所有平台
- ✅ 生成用户友好的安装包
**使用方法**
```bash
# 创建版本发布
git tag v1.0.0
git push origin v1.0.0
```
---
### 2. `dev-ci.yml` - 开发测试
**用途**PR 和开发分支的自动化测试
**触发条件**
- Push 到 `dev`, `develop`, `feature/*` 分支
- 创建 PR 到 `main``dev`
**特点**
- ✅ 代码格式检查
- ✅ Clippy 静态分析
- ✅ TypeScript 类型检查
- ✅ 单元测试
- ✅ 构建验证
**检查项目**
- Rust 格式化 (`cargo fmt`)
- Rust 代码质量 (`cargo clippy`)
- TypeScript 类型 (`tsc`)
- 测试运行 (`cargo test`)
---
### 3. `quick-build.yml` - 快速构建
**用途**:快速测试构建,不创建发布
**触发条件**
- 仅手动触发
**特点**
- ✅ 可选择特定平台
- ✅ 最小化配置
- ✅ 快速构建
- ✅ 保存构建产物 7 天
**使用方法**
1. GitHub → Actions → Quick Build
2. 选择目标平台
3. 点击 Run workflow
---
### 4. `build.yml` - 完整构建(需要签名)
**用途**:需要代码签名的正式发布
**要求**
- ❗ 需要配置 Apple 证书
- ❗ 需要 GitHub Secrets
**不推荐用于**
- 开源项目
- 个人开发
- 没有 Apple 开发者账号的情况
---
### 5. `build-unsigned.yml` - 未签名构建
**用途**:不需要签名的完整构建
**特点**
- ✅ 支持所有平台
- ✅ 无需证书配置
- ⚠️ macOS 用户需要手动信任
---
## 🎯 推荐使用方案
### 开源项目
使用 **`build-opensource.yml`**
- 简单配置
- 自动发布
- 用户友好
### 日常开发
使用 **`dev-ci.yml`**
- 自动化测试
- 代码质量保证
- PR 检查
### 快速测试
使用 **`quick-build.yml`**
- 手动触发
- 选择平台
- 快速验证
## 🚀 快速开始
### 1. 首次设置
```bash
# 确保工作流文件存在
ls -la .github/workflows/
# 推送到 GitHub
git add .github/
git commit -m "添加 GitHub Actions 工作流"
git push origin main
```
### 2. 创建发布
```bash
# 更新版本号
# 编辑 src-tauri/Cargo.toml, src-tauri/tauri.conf.json, package.json
# 提交更改
git add .
git commit -m "chore: bump version to v1.0.0"
# 创建标签并推送
git tag v1.0.0
git push origin v1.0.0
# 工作流会自动运行并创建 Release Draft
```
### 3. 开发测试
```bash
# 创建功能分支
git checkout -b feature/new-feature
# 推送会自动触发测试
git push origin feature/new-feature
```
## 📝 注意事项
1. **开源项目不需要代码签名**
- 用户需要手动信任应用是正常的
- 这不影响应用的功能
2. **构建产物保留时间**
- Release: 永久保存
- Artifacts: 7 天后自动删除
3. **并行构建**
- 所有平台同时构建
- 一个平台失败不影响其他平台
## 🔧 故障排除
### 构建失败
1. 检查 Actions 日志
2. 确认依赖版本正确
3. 本地测试构建:`bun run tauri build`
### Linux 构建问题
确保安装所有依赖:
```bash
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev
```
### Windows 构建问题
- 确保使用 Windows Server 2019 或更高版本
- 检查 Visual Studio Build Tools
## 📚 相关文档
- [Tauri 构建文档](https://tauri.app/v1/guides/building/)
- [GitHub Actions 文档](https://docs.github.com/en/actions)
- [项目 README](../../README.md)

View File

@@ -17,14 +17,14 @@ jobs:
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -36,10 +36,10 @@ jobs:
uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
# model: "claude-opus-4-20250514"
# Direct prompt for automated review (no @claude mention needed)
direct_prompt: |
Please review this pull request and provide feedback on:
@@ -50,24 +50,24 @@ jobs:
- Test coverage
Be constructive and helpful in your feedback.
# Optional: Customize review based on file types
# direct_prompt: |
# Review this PR focusing on:
# - For TypeScript files: Type safety and proper interface usage
# - For API endpoints: Security, input validation, and error handling
# - For React components: Performance, accessibility, and best practices
# - For tests: Coverage, edge cases, and test quality
# - For tests: Coverage, edge cases, and test.md quality
# Optional: Different prompts for different authors
# direct_prompt: |
# ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
# ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
# 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
# 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
# Optional: Add specific tools for running tests or linting
# allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
# allowed_tools: "Bash(npm run test.md),Bash(npm run lint),Bash(npm run typecheck)"
# Optional: Skip review for certain conditions
# if: |
# !contains(github.event.pull_request.title, '[skip-review]') &&

View File

@@ -34,26 +34,26 @@ jobs:
uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
model: "claude-opus-4-20250514"
# Optional: Customize the trigger phrase (default: @claude)
# trigger_phrase: "/claude"
# Optional: Trigger when specific user is assigned to an issue
# assignee_trigger: "claude-bot"
# Optional: Allow Claude to run specific commands
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test.md:*),Bash(npm run lint:*)"
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test
# NODE_ENV: test.md

6
.gitignore vendored
View File

@@ -31,9 +31,13 @@ temp_lib/
.cursor/
AGENTS.md
CLAUDE.md
CLAUDE.md.backup*
*_TASK.md
# Claude project-specific files
.claude/
.env
# Local tooling artifacts
.serena/
.env

22
CHANGELOG.md Normal file
View File

@@ -0,0 +1,22 @@
# Changelog
All notable changes to this project will be documented in this file.
## [1.3.0] - 2025-11-07
### Added
- Prompt file library (`src/components/PromptFilesManager.tsx`, `src/stores/promptFilesStore.ts`, `src/lib/api.ts`, `src-tauri/src/commands/prompt_files.rs`) with full CRUD, tagging/search, side-by-side preview/editor, CLAUDE.md import/export, and one-click apply/deactivate workflows.
- Cross-adapter API node manager (`src/components/NodeManager`, `src/lib/api.ts`, `src-tauri/src/utils/node_tester.rs`) that seeds default nodes, lets users persist custom endpoints, toggles availability, and runs single/bulk latency tests before wiring a relay station.
- Smart quick-start sessions (`src/App.tsx`, `src/components/WelcomePage.tsx`, `src/components/TabContent.tsx`, `src-tauri/src/commands/smart_sessions.rs`) so a single click spawns a ready-to-use Claude tab with toast/analytics feedback even when no project is selected yet.
### Improved
- Relay station workflow overhaul (`src/components/RelayStationManager.tsx`, `src/components/SortableStationItem.tsx`): drag-and-drop ordering, advanced toolbelt (DNS flush, JSON diff, hide/show details), raw source config backup editing, granular import/export progress, and tighter NodeSelector integration.
- Backend infrastructure hardening (`src-tauri/src/http_client.rs`, `src-tauri/src/utils/node_tester.rs`, `src-tauri/src/commands/*`, `src-tauri/src/utils/error.rs`): unified HTTP client presets, resilient node testing pipeline, richer error surfaces, and refactored usage/index storage to reduce duplication.
- Tab and onboarding UX (`src/App.tsx`, `src/components/WelcomePage.tsx`, `src/components/TabContent.tsx`, `src/components/Topbar.tsx`): deterministic tab creation, global events for smart sessions, better toasts, and hidden-detail toggles that keep the interface clean by default.
### Fixed
- Addressed multiple Tauri packaging and startup regressions (notably in `src-tauri/src/main.rs`, `src-tauri/src/commands/claude.rs`, `src-tauri/src/commands/filesystem.rs`) so macOS/Windows/Linux bundles install cleanly.
- Resolved quick-start/new-session routing bugs and project-card navigation glitches by synchronizing welcome-page actions with the tab manager (`src/components/WelcomePage.tsx`, `src/components/TabContent.tsx`).
- Filled missing translations and eliminated noisy runtime warnings across locales/components (`src/locales/en/common.json`, `src/locales/zh/common.json`, `src/components/ClaudeCodeSession.tsx`, `src/components/WelcomePage.tsx`).
- Patched long-text overflow & formatting regressions in prompts, station cards, and queues via new clamps/breakpoints (`src/components/PromptFilesManager.tsx`, `src/components/ClaudeCodeSession.tsx`, `src/components/SortableStationItem.tsx`, `src/components/SessionList.tsx`).

View File

@@ -4,6 +4,9 @@
"": {
"name": "claudia",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.1",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-context-menu": "^2.2.15",
@@ -123,6 +126,14 @@
"@babel/types": ["@babel/types@7.27.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q=="],
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
"@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="],

View File

@@ -1,7 +1,7 @@
{
"name": "claudia",
"private": true,
"version": "1.2.0",
"version": "1.3.0",
"license": "AGPL-3.0",
"type": "module",
"scripts": {
@@ -11,9 +11,16 @@
"preview": "vite preview",
"tauri": "tauri",
"check": "tsc --noEmit && cd src-tauri && cargo check",
"postinstall": "node ./scripts/copy-monaco.mjs"
"postinstall": "node ./scripts/copy-monaco.mjs",
"create-todo": "bash ./scripts/create-todo.sh",
"archive-todo": "bash ./scripts/archive-todo.sh",
"todo-report": "bash ./scripts/todo-report.sh",
"pre-commit": "bash ./scripts/pre-commit-check.sh"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.1",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-context-menu": "^2.2.15",

2
src-tauri/Cargo.lock generated
View File

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

View File

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

View File

@@ -61,7 +61,10 @@ pub fn find_claude_binary(app_handle: &tauri::AppHandle) -> Result<String, Strin
// On Windows, if stored path exists but is not executable (shell script), try .cmd version
#[cfg(target_os = "windows")]
if path_buf.exists() && !stored_path.ends_with(".cmd") && !stored_path.ends_with(".exe") {
if path_buf.exists()
&& !stored_path.ends_with(".cmd")
&& !stored_path.ends_with(".exe")
{
// Test if the current path works by trying to get version
if let Err(_) = get_claude_version(&stored_path) {
// If it fails, try the .cmd version
@@ -71,7 +74,10 @@ pub fn find_claude_binary(app_handle: &tauri::AppHandle) -> Result<String, Strin
if let Ok(_) = get_claude_version(&cmd_path) {
final_path = cmd_path;
path_buf = cmd_path_buf;
info!("Using .cmd version instead of shell script: {}", final_path);
info!(
"Using .cmd version instead of shell script: {}",
final_path
);
}
}
}
@@ -202,7 +208,19 @@ fn find_which_installations() -> Vec<ClaudeInstallation> {
let mut installations = Vec::new();
match Command::new(command_name).arg("claude").output() {
// Create command with enhanced PATH for production environments
let mut cmd = Command::new(command_name);
cmd.arg("claude");
// In production (DMG), we need to ensure proper PATH is set
let enhanced_path = build_enhanced_path();
debug!(
"Using enhanced PATH for {}: {}",
command_name, enhanced_path
);
cmd.env("PATH", enhanced_path);
match cmd.output() {
Ok(output) if output.status.success() => {
let output_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
@@ -234,7 +252,10 @@ fn find_which_installations() -> Vec<ClaudeInstallation> {
// Convert /c/path to C:\path
let windows_path = path.replace("/c/", "C:\\").replace("/", "\\");
windows_path
} else if path.starts_with("/") && path.len() > 3 && path.chars().nth(2) == Some('/') {
} else if path.starts_with("/")
&& path.len() > 3
&& path.chars().nth(2) == Some('/')
{
// Convert /X/path to X:\path where X is drive letter
let drive = path.chars().nth(1).unwrap();
let rest = &path[3..];
@@ -275,7 +296,10 @@ fn find_which_installations() -> Vec<ClaudeInstallation> {
// Verify the path exists
if !PathBuf::from(&final_path).exists() {
warn!("Path from '{}' does not exist: {}", command_name, final_path);
warn!(
"Path from '{}' does not exist: {}",
command_name, final_path
);
continue;
}
@@ -401,11 +425,16 @@ fn find_standard_installations() -> Vec<ClaudeInstallation> {
}
// Also check if claude is available in PATH (without full path)
if let Ok(output) = Command::new("claude").arg("--version").output() {
let mut path_cmd = Command::new("claude");
path_cmd.arg("--version");
path_cmd.env("PATH", build_enhanced_path());
if let Ok(output) = path_cmd.output() {
if output.status.success() {
debug!("claude is available in PATH");
// Combine stdout and stderr for robust version extraction
let mut combined: Vec<u8> = Vec::with_capacity(output.stdout.len() + output.stderr.len() + 1);
let mut combined: Vec<u8> =
Vec::with_capacity(output.stdout.len() + output.stderr.len() + 1);
combined.extend_from_slice(&output.stdout);
if !output.stderr.is_empty() {
combined.extend_from_slice(b"\n");
@@ -427,11 +456,16 @@ fn find_standard_installations() -> Vec<ClaudeInstallation> {
/// Get Claude version by running --version command
fn get_claude_version(path: &str) -> Result<Option<String>, String> {
match Command::new(path).arg("--version").output() {
// Use the helper function to create command with proper environment
let mut cmd = create_command_with_env(path);
cmd.arg("--version");
match cmd.output() {
Ok(output) => {
if output.status.success() {
// Combine stdout and stderr for robust version extraction
let mut combined: Vec<u8> = Vec::with_capacity(output.stdout.len() + output.stderr.len() + 1);
let mut combined: Vec<u8> =
Vec::with_capacity(output.stdout.len() + output.stderr.len() + 1);
combined.extend_from_slice(&output.stdout);
if !output.stderr.is_empty() {
combined.extend_from_slice(b"\n");
@@ -464,7 +498,8 @@ fn extract_version_from_output(stdout: &[u8]) -> Option<String> {
// - A dot, followed by
// - One or more digits
// - Optionally followed by pre-release/build metadata
let version_regex = regex::Regex::new(r"(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?)").ok()?;
let version_regex =
regex::Regex::new(r"(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?)").ok()?;
if let Some(captures) = version_regex.captures(&output_str) {
if let Some(version_match) = captures.get(1) {
@@ -556,11 +591,15 @@ pub fn create_command_with_env(program: &str) -> Command {
info!("Creating command for: {}", program);
// Build enhanced PATH for production environments (DMG/App Bundle)
let enhanced_path = build_enhanced_path();
debug!("Enhanced PATH: {}", enhanced_path);
cmd.env("PATH", enhanced_path.clone());
// Inherit essential environment variables from parent process
for (key, value) in std::env::vars() {
// Pass through PATH and other essential environment variables
if key == "PATH"
|| key == "HOME"
// Pass through essential environment variables (excluding PATH which we set above)
if key == "HOME"
|| key == "USER"
|| key == "SHELL"
|| key == "LANG"
@@ -595,7 +634,13 @@ pub fn create_command_with_env(program: &str) -> Command {
if program.contains("/.nvm/versions/node/") {
if let Some(node_bin_dir) = std::path::Path::new(program).parent() {
// Ensure the Node.js bin directory is in PATH
let current_path = std::env::var("PATH").unwrap_or_default();
let current_path = cmd
.get_envs()
.find(|(k, _)| k.to_str() == Some("PATH"))
.and_then(|(_, v)| v)
.and_then(|v| v.to_str())
.unwrap_or(&enhanced_path)
.to_string();
let node_bin_str = node_bin_dir.to_string_lossy();
if !current_path.contains(&node_bin_str.as_ref()) {
let new_path = format!("{}:{}", node_bin_str, current_path);
@@ -607,3 +652,73 @@ pub fn create_command_with_env(program: &str) -> Command {
cmd
}
/// Build an enhanced PATH that includes all possible Claude installation locations
/// This is especially important for DMG/packaged applications where PATH may be limited
fn build_enhanced_path() -> String {
let mut paths = Vec::new();
// Start with current PATH
if let Ok(current_path) = std::env::var("PATH") {
paths.push(current_path);
}
// Add standard system paths that might be missing in packaged apps
let system_paths = vec![
"/usr/local/bin",
"/usr/bin",
"/bin",
"/opt/homebrew/bin",
"/opt/homebrew/sbin",
];
for path in system_paths {
if PathBuf::from(path).exists() {
paths.push(path.to_string());
}
}
// Add user-specific paths
if let Ok(home) = std::env::var("HOME") {
let user_paths = vec![
format!("{}/.local/bin", home),
format!("{}/.claude/local", home),
format!("{}/.npm-global/bin", home),
format!("{}/.yarn/bin", home),
format!("{}/.bun/bin", home),
format!("{}/bin", home),
format!("{}/.config/yarn/global/node_modules/.bin", home),
format!("{}/node_modules/.bin", home),
];
for path in user_paths {
if PathBuf::from(&path).exists() {
paths.push(path);
}
}
// Add all NVM node versions
let nvm_dir = PathBuf::from(&home).join(".nvm/versions/node");
if nvm_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&nvm_dir) {
for entry in entries.flatten() {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
let bin_path = entry.path().join("bin");
if bin_path.exists() {
paths.push(bin_path.to_string_lossy().to_string());
}
}
}
}
}
}
// Remove duplicates while preserving order
let mut seen = std::collections::HashSet::new();
let unique_paths: Vec<String> = paths
.into_iter()
.filter(|path| seen.insert(path.clone()))
.collect();
unique_paths.join(":")
}

View File

@@ -1,13 +1,13 @@
use std::fs;
use std::path::PathBuf;
use std::collections::HashMap;
use crate::commands::relay_stations::RelayStation;
use dirs::home_dir;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use dirs::home_dir;
use crate::commands::relay_stations::RelayStation;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
/// Claude 配置文件结构
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ClaudeConfig {
#[serde(default)]
pub env: ClaudeEnv,
@@ -24,7 +24,7 @@ pub struct ClaudeConfig {
pub extra_fields: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StatusLineConfig {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub config_type: Option<String>,
@@ -39,11 +39,17 @@ pub struct StatusLineConfig {
#[derive(Debug, Clone, Serialize, Deserialize)]
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>,
#[serde(rename = "ANTHROPIC_BASE_URL", skip_serializing_if = "Option::is_none")]
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>,
// 使用 flatten 来支持任何其他环境变量
#[serde(flatten)]
@@ -84,7 +90,7 @@ pub fn get_config_backup_path() -> Result<PathBuf, String> {
/// 读取 Claude 配置文件
pub fn read_claude_config() -> Result<ClaudeConfig, String> {
let config_path = get_claude_config_path()?;
if !config_path.exists() {
// 如果配置文件不存在,创建默认配置
return Ok(ClaudeConfig {
@@ -96,14 +102,14 @@ pub fn read_claude_config() -> Result<ClaudeConfig, String> {
extra_fields: HashMap::new(),
});
}
let content = fs::read_to_string(&config_path)
.map_err(|e| format!("读取配置文件失败: {}", e))?;
let content =
fs::read_to_string(&config_path).map_err(|e| format!("读取配置文件失败: {}", e))?;
// 首先尝试解析为 JSON Value以便处理可能的格式问题
let mut json_value: Value = serde_json::from_str(&content)
.map_err(|e| format!("解析配置文件失败: {}", e))?;
let mut json_value: Value =
serde_json::from_str(&content).map_err(|e| format!("解析配置文件失败: {}", e))?;
// 如果JSON解析成功再转换为ClaudeConfig
if let Some(obj) = json_value.as_object_mut() {
// 确保必要的字段存在
@@ -111,58 +117,54 @@ pub fn read_claude_config() -> Result<ClaudeConfig, String> {
obj.insert("env".to_string(), json!({}));
}
}
serde_json::from_value(json_value)
.map_err(|e| format!("转换配置结构失败: {}", e))
serde_json::from_value(json_value).map_err(|e| format!("转换配置结构失败: {}", e))
}
/// 写入 Claude 配置文件
pub fn write_claude_config(config: &ClaudeConfig) -> Result<(), String> {
let config_path = get_claude_config_path()?;
log::info!("尝试写入配置文件到: {:?}", config_path);
// 确保目录存在
if let Some(parent) = config_path.parent() {
log::info!("确保目录存在: {:?}", parent);
fs::create_dir_all(parent)
.map_err(|e| {
let error_msg = format!("创建配置目录失败: {}", e);
log::error!("{}", error_msg);
error_msg
})?;
fs::create_dir_all(parent).map_err(|e| {
let error_msg = format!("创建配置目录失败: {}", e);
log::error!("{}", error_msg);
error_msg
})?;
}
let content = serde_json::to_string_pretty(config)
.map_err(|e| {
let error_msg = format!("序列化配置失败: {}", e);
log::error!("{}", error_msg);
error_msg
})?;
let content = serde_json::to_string_pretty(config).map_err(|e| {
let error_msg = format!("序列化配置失败: {}", e);
log::error!("{}", error_msg);
error_msg
})?;
log::info!("准备写入内容:\n{}", content);
fs::write(&config_path, &content)
.map_err(|e| {
let error_msg = format!("写入配置文件失败: {} (路径: {:?})", e, config_path);
log::error!("{}", error_msg);
error_msg
})?;
fs::write(&config_path, &content).map_err(|e| {
let error_msg = format!("写入配置文件失败: {} (路径: {:?})", e, config_path);
log::error!("{}", error_msg);
error_msg
})?;
log::info!("配置文件写入成功: {:?}", config_path);
Ok(())
}
/// 备份当前配置
#[allow(dead_code)]
pub fn backup_claude_config() -> Result<(), String> {
let config_path = get_claude_config_path()?;
let backup_path = get_config_backup_path()?;
if config_path.exists() {
fs::copy(&config_path, &backup_path)
.map_err(|e| format!("备份配置文件失败: {}", e))?;
fs::copy(&config_path, &backup_path).map_err(|e| format!("备份配置文件失败: {}", e))?;
}
Ok(())
}
@@ -170,90 +172,127 @@ pub fn backup_claude_config() -> Result<(), String> {
pub fn restore_claude_config() -> Result<(), String> {
let config_path = get_claude_config_path()?;
let backup_path = get_config_backup_path()?;
if !backup_path.exists() {
return Err("备份文件不存在".to_string());
}
fs::copy(&backup_path, &config_path)
.map_err(|e| format!("恢复配置文件失败: {}", e))?;
fs::copy(&backup_path, &config_path).map_err(|e| format!("恢复配置文件失败: {}", e))?;
Ok(())
}
/// 根据中转站配置更新 Claude 配置(仅更新 API 相关字段
/// 根据中转站配置更新 Claude 配置(先恢复源文件,再应用配置
pub fn apply_relay_station_to_config(station: &RelayStation) -> Result<(), String> {
// 先备份当前配置
backup_claude_config()?;
// 读取当前配置
log::info!("[CLAUDE_CONFIG] Applying relay station: {}", station.name);
// 第一步:确保源文件备份存在(如果不存在则创建)
let backup_path = get_config_backup_path()?;
let config_path = get_claude_config_path()?;
if !backup_path.exists() {
if config_path.exists() {
log::info!("[CLAUDE_CONFIG] Creating source backup on first use");
init_source_backup()?;
} else {
log::warn!("[CLAUDE_CONFIG] No source config found, will create default");
}
}
// 第二步:恢复源文件备份(确保使用干净的基准配置)
if backup_path.exists() {
log::info!("[CLAUDE_CONFIG] Restoring source config from backup");
fs::copy(&backup_path, &config_path).map_err(|e| {
log::error!("[CLAUDE_CONFIG] Failed to restore source config: {}", e);
format!("恢复源配置文件失败: {}", e)
})?;
}
// 第三步:读取恢复后的配置(现在是源文件或默认配置)
let mut config = read_claude_config()?;
// 仅更新这三个关键字段,保留其他所有配置不变:
// 第四步:仅更新中转站相关字段,保留其他所有配置
// 1. ANTHROPIC_BASE_URL
config.env.anthropic_base_url = Some(station.api_url.clone());
// 2. ANTHROPIC_AUTH_TOKEN
log::info!("[CLAUDE_CONFIG] Set ANTHROPIC_BASE_URL: {}", station.api_url);
// 2. ANTHROPIC_AUTH_TOKEN
config.env.anthropic_auth_token = Some(station.system_token.clone());
log::info!("[CLAUDE_CONFIG] Set ANTHROPIC_AUTH_TOKEN");
// 3. apiKeyHelper - 设置为 echo 格式
config.api_key_helper = Some(format!("echo '{}'", station.system_token));
// 如果是特定适配器,可能需要特殊处理 URL 格式
match station.adapter.as_str() {
"packycode" => {
// PackyCode 使用原始配置,不做特殊处理
log::info!("[CLAUDE_CONFIG] Set apiKeyHelper");
// 第五步:处理 adapter_config 中的自定义字段(合并而非覆盖)
if let Some(ref adapter_config) = station.adapter_config {
log::info!("[CLAUDE_CONFIG] Merging adapter_config: {:?}", adapter_config);
// 遍历 adapter_config 中的所有字段
for (key, value) in adapter_config {
match key.as_str() {
// 已知的字段直接写入对应位置
"model" => {
if let Some(model_value) = value.as_str() {
config.model = Some(model_value.to_string());
log::info!("[CLAUDE_CONFIG] Set model: {}", model_value);
}
}
// 其他字段写入到 extra_fields 中
_ => {
config.extra_fields.insert(key.clone(), value.clone());
log::info!("[CLAUDE_CONFIG] Set extra field {}: {:?}", key, value);
}
}
}
"custom" => {
// 自定义适配器,使用原始配置
}
_ => {}
}
// 写入更新后的配置
// 第六步:写入更新后的配置
write_claude_config(&config)?;
log::info!("已将中转站 {} 的 API 配置apiKeyHelper, ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN应用到 Claude 配置文件", station.name);
log::info!("[CLAUDE_CONFIG] Successfully applied station config (merged with source config)");
Ok(())
}
/// 清除中转站配置(恢复默认
/// 清除中转站配置(恢复源文件备份
pub fn clear_relay_station_from_config() -> Result<(), String> {
// 尝试从备份恢复原始的配置
let backup_config = if let Ok(backup_path) = get_config_backup_path() {
if backup_path.exists() {
let content = fs::read_to_string(&backup_path).ok();
content.and_then(|c| serde_json::from_str::<ClaudeConfig>(&c).ok())
} else {
None
}
log::info!("[CLAUDE_CONFIG] Clearing relay station config");
// 恢复源文件备份
let backup_path = get_config_backup_path()?;
let config_path = get_claude_config_path()?;
if backup_path.exists() {
log::info!("[CLAUDE_CONFIG] Restoring from source backup");
fs::copy(&backup_path, &config_path).map_err(|e| {
log::error!("[CLAUDE_CONFIG] Failed to restore: {}", e);
format!("恢复源配置文件失败: {}", e)
})?;
log::info!("[CLAUDE_CONFIG] Successfully restored source config");
} else {
None
};
// 读取当前配置
let mut config = read_claude_config()?;
// 清除 API URL 和 Token
config.env.anthropic_base_url = None;
config.env.anthropic_auth_token = None;
// 恢复原始的 apiKeyHelper如果有备份的话
if let Some(backup) = backup_config {
config.api_key_helper = backup.api_key_helper;
// 如果备份中有 ANTHROPIC_AUTH_TOKEN也恢复它
if backup.env.anthropic_auth_token.is_some() {
config.env.anthropic_auth_token = backup.env.anthropic_auth_token;
}
} else {
// 如果没有备份,清除 apiKeyHelper
config.api_key_helper = None;
log::warn!("[CLAUDE_CONFIG] No source backup found, creating empty config");
// 如果没有备份,创建一个最小配置
let empty_config = ClaudeConfig::default();
write_claude_config(&empty_config)?;
}
// 写入更新后的配置
write_claude_config(&config)?;
log::info!("已清除 Claude 配置文件中的中转站设置");
Ok(())
}
/// 初始化源文件备份(仅在首次启用中转站时调用)
pub fn init_source_backup() -> Result<(), String> {
let config_path = get_claude_config_path()?;
let backup_path = get_config_backup_path()?;
if !backup_path.exists() && config_path.exists() {
log::info!("[CLAUDE_CONFIG] Creating initial source backup");
fs::copy(&config_path, &backup_path).map_err(|e| {
log::error!("[CLAUDE_CONFIG] Failed to create source backup: {}", e);
format!("创建源文件备份失败: {}", e)
})?;
log::info!("[CLAUDE_CONFIG] Source backup created at: {:?}", backup_path);
}
Ok(())
}
@@ -267,4 +306,4 @@ pub fn get_current_api_url() -> Result<Option<String>, String> {
pub fn get_current_api_token() -> Result<Option<String>, String> {
let config = read_claude_config()?;
Ok(config.env.anthropic_auth_token)
}
}

View File

@@ -10,8 +10,8 @@ use std::io::{BufRead, BufReader};
use std::process::Stdio;
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Manager, State};
use tauri_plugin_shell::ShellExt;
use tauri_plugin_shell::process::CommandEvent;
use tauri_plugin_shell::ShellExt;
use tokio::io::{AsyncBufReadExt, BufReader as TokioBufReader};
use tokio::process::Command;
@@ -233,7 +233,7 @@ pub fn init_database(app: &AppHandle) -> SqliteResult<Connection> {
icon TEXT NOT NULL,
system_prompt TEXT NOT NULL,
default_task TEXT,
model TEXT NOT NULL DEFAULT 'sonnet',
model TEXT NOT NULL DEFAULT 'claude-sonnet-4-20250514',
enable_file_read BOOLEAN NOT NULL DEFAULT 1,
enable_file_write BOOLEAN NOT NULL DEFAULT 1,
enable_network BOOLEAN NOT NULL DEFAULT 0,
@@ -247,7 +247,7 @@ pub fn init_database(app: &AppHandle) -> SqliteResult<Connection> {
// Add columns to existing table if they don't exist
let _ = conn.execute("ALTER TABLE agents ADD COLUMN default_task TEXT", []);
let _ = conn.execute(
"ALTER TABLE agents ADD COLUMN model TEXT DEFAULT 'sonnet'",
"ALTER TABLE agents ADD COLUMN model TEXT DEFAULT 'claude-sonnet-4-20250514'",
[],
);
let _ = conn.execute("ALTER TABLE agents ADD COLUMN hooks TEXT", []);
@@ -321,7 +321,6 @@ pub fn init_database(app: &AppHandle) -> SqliteResult<Connection> {
[],
)?;
// Create settings table for app-wide settings
conn.execute(
"CREATE TABLE IF NOT EXISTS app_settings (
@@ -344,6 +343,39 @@ pub fn init_database(app: &AppHandle) -> SqliteResult<Connection> {
[],
)?;
// Create model mappings table for configurable model aliases
conn.execute(
"CREATE TABLE IF NOT EXISTS model_mappings (
alias TEXT PRIMARY KEY,
model_name TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)",
[],
)?;
// Initialize default model mappings if empty
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM model_mappings", [], |row| row.get(0))
.unwrap_or(0);
if count == 0 {
conn.execute(
"INSERT INTO model_mappings (alias, model_name) VALUES ('sonnet', 'claude-sonnet-4-20250514')",
[],
)?;
conn.execute(
"INSERT INTO model_mappings (alias, model_name) VALUES ('opus', 'claude-opus-4-1-20250805')",
[],
)?;
conn.execute(
"INSERT INTO model_mappings (alias, model_name) VALUES ('haiku', 'claude-haiku-4-20250410')",
[],
)?;
}
// Initialize prompt files tables
crate::commands::prompt_files::init_prompt_files_tables(&conn)?;
Ok(conn)
}
@@ -397,7 +429,7 @@ pub async fn create_agent(
hooks: Option<String>,
) -> Result<Agent, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let model = model.unwrap_or_else(|| "sonnet".to_string());
let model = model.unwrap_or_else(|| "claude-sonnet-4-20250514".to_string());
let enable_file_read = enable_file_read.unwrap_or(true);
let enable_file_write = enable_file_write.unwrap_or(true);
let enable_network = enable_network.unwrap_or(false);
@@ -453,7 +485,7 @@ pub async fn update_agent(
hooks: Option<String>,
) -> Result<Agent, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let model = model.unwrap_or_else(|| "sonnet".to_string());
let model = model.unwrap_or_else(|| "claude-sonnet-4-20250514".to_string());
// Build dynamic query based on provided parameters
let mut query =
@@ -549,7 +581,7 @@ pub async fn get_agent(db: State<'_, AgentDb>, id: i64) -> Result<Agent, String>
icon: row.get(2)?,
system_prompt: row.get(3)?,
default_task: row.get(4)?,
model: row.get::<_, String>(5).unwrap_or_else(|_| "sonnet".to_string()),
model: row.get::<_, String>(5).unwrap_or_else(|_| "claude-sonnet-4-20250514".to_string()),
enable_file_read: row.get::<_, bool>(6).unwrap_or(true),
enable_file_write: row.get::<_, bool>(7).unwrap_or(true),
enable_network: row.get::<_, bool>(8).unwrap_or(false),
@@ -694,38 +726,49 @@ pub async fn execute_agent(
// Get the agent from database
let agent = get_agent(db.clone(), agent_id).await?;
let execution_model = model.unwrap_or(agent.model.clone());
// Resolve model alias to actual model name using mappings
let resolved_model = get_model_by_alias(&db, &execution_model).unwrap_or_else(|_| {
warn!("Model alias '{}' not found, using as-is", execution_model);
execution_model.clone()
});
info!("Resolved model: {} -> {}", execution_model, resolved_model);
// Create .claude/settings.json with agent hooks if it doesn't exist
if let Some(hooks_json) = &agent.hooks {
let claude_dir = std::path::Path::new(&project_path).join(".claude");
let settings_path = claude_dir.join("settings.json");
// Create .claude directory if it doesn't exist
if !claude_dir.exists() {
std::fs::create_dir_all(&claude_dir)
.map_err(|e| format!("Failed to create .claude directory: {}", e))?;
info!("Created .claude directory at: {:?}", claude_dir);
}
// Check if settings.json already exists
if !settings_path.exists() {
// Parse the hooks JSON
let hooks: serde_json::Value = serde_json::from_str(hooks_json)
.map_err(|e| format!("Failed to parse agent hooks: {}", e))?;
// Create a settings object with just the hooks
let settings = serde_json::json!({
"hooks": hooks
});
// Write the settings file
let settings_content = serde_json::to_string_pretty(&settings)
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
std::fs::write(&settings_path, settings_content)
.map_err(|e| format!("Failed to write settings.json: {}", e))?;
info!("Created settings.json with agent hooks at: {:?}", settings_path);
info!(
"Created settings.json with agent hooks at: {:?}",
settings_path
);
} else {
info!("settings.json already exists at: {:?}", settings_path);
}
@@ -759,7 +802,7 @@ pub async fn execute_agent(
"--system-prompt".to_string(),
agent.system_prompt.clone(),
"--model".to_string(),
execution_model.clone(),
resolved_model.clone(), // Use resolved model name
"--output-format".to_string(),
"stream-json".to_string(),
"--verbose".to_string(),
@@ -768,9 +811,34 @@ pub async fn execute_agent(
// Execute based on whether we should use sidecar or system binary
if should_use_sidecar(&claude_path) {
spawn_agent_sidecar(app, run_id, agent_id, agent.name.clone(), args, project_path, task, execution_model, db, registry).await
spawn_agent_sidecar(
app,
run_id,
agent_id,
agent.name.clone(),
args,
project_path,
task,
resolved_model,
db,
registry,
)
.await
} else {
spawn_agent_system(app, run_id, agent_id, agent.name.clone(), claude_path, args, project_path, task, execution_model, db, registry).await
spawn_agent_system(
app,
run_id,
agent_id,
agent.name.clone(),
claude_path,
args,
project_path,
task,
resolved_model,
db,
registry,
)
.await
}
}
@@ -789,25 +857,21 @@ fn create_agent_sidecar_command(
.shell()
.sidecar("claude-code")
.map_err(|e| format!("Failed to create sidecar command: {}", e))?;
// Add all arguments
sidecar_cmd = sidecar_cmd.args(args);
// Set working directory
sidecar_cmd = sidecar_cmd.current_dir(project_path);
// Pass through proxy environment variables if they exist (only uppercase)
for (key, value) in std::env::vars() {
if key == "HTTP_PROXY"
|| key == "HTTPS_PROXY"
|| key == "NO_PROXY"
|| key == "ALL_PROXY"
{
if key == "HTTP_PROXY" || key == "HTTPS_PROXY" || key == "NO_PROXY" || key == "ALL_PROXY" {
debug!("Setting proxy env var for agent sidecar: {}={}", key, value);
sidecar_cmd = sidecar_cmd.env(&key, &value);
}
}
Ok(sidecar_cmd)
}
@@ -818,17 +882,17 @@ fn create_agent_system_command(
project_path: &str,
) -> Command {
let mut cmd = create_command_with_env(claude_path);
// Add all arguments
for arg in args {
cmd.arg(arg);
}
cmd.current_dir(project_path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
cmd
}
@@ -858,7 +922,10 @@ async fn spawn_agent_sidecar(
// Get the PID from child
let pid = child.pid();
let now = chrono::Utc::now().to_rfc3339();
info!("✅ Claude sidecar process spawned successfully with PID: {}", pid);
info!(
"✅ Claude sidecar process spawned successfully with PID: {}",
pid
);
// Update the database with PID and status
{
@@ -942,14 +1009,15 @@ async fn spawn_agent_sidecar(
// Extract session ID from JSONL output
if let Ok(json) = serde_json::from_str::<JsonValue>(&line) {
if json.get("type").and_then(|t| t.as_str()) == Some("system") &&
json.get("subtype").and_then(|s| s.as_str()) == Some("init") {
if json.get("type").and_then(|t| t.as_str()) == Some("system")
&& json.get("subtype").and_then(|s| s.as_str()) == Some("init")
{
if let Some(sid) = json.get("session_id").and_then(|s| s.as_str()) {
if let Ok(mut current_session_id) = session_id_clone.lock() {
if current_session_id.is_empty() {
*current_session_id = sid.to_string();
info!("🔑 Extracted session ID: {}", sid);
// Update database immediately with session ID
if let Ok(conn) = Connection::open(&db_path_for_sidecar) {
match conn.execute(
@@ -983,8 +1051,11 @@ async fn spawn_agent_sidecar(
let _ = app_handle.emit("agent-error", &line);
}
CommandEvent::Terminated(payload) => {
info!("Claude sidecar process terminated with code: {:?}", payload.code);
info!(
"Claude sidecar process terminated with code: {:?}",
payload.code
);
// Get the session ID
let extracted_session_id = if let Ok(sid) = session_id.lock() {
sid.clone()
@@ -1009,7 +1080,10 @@ async fn spawn_agent_sidecar(
}
}
info!("📖 Finished reading Claude sidecar events. Total lines: {}", line_count);
info!(
"📖 Finished reading Claude sidecar events. Total lines: {}",
line_count
);
});
Ok(run_id)
@@ -1121,14 +1195,15 @@ async fn spawn_agent_system(
// Extract session ID from JSONL output
if let Ok(json) = serde_json::from_str::<JsonValue>(&line) {
// Claude Code uses "session_id" (underscore), not "sessionId"
if json.get("type").and_then(|t| t.as_str()) == Some("system") &&
json.get("subtype").and_then(|s| s.as_str()) == Some("init") {
if json.get("type").and_then(|t| t.as_str()) == Some("system")
&& json.get("subtype").and_then(|s| s.as_str()) == Some("init")
{
if let Some(sid) = json.get("session_id").and_then(|s| s.as_str()) {
if let Ok(mut current_session_id) = session_id_clone.lock() {
if current_session_id.is_empty() {
*current_session_id = sid.to_string();
info!("🔑 Extracted session ID: {}", sid);
// Update database immediately with session ID
if let Ok(conn) = Connection::open(&db_path_for_stdout) {
match conn.execute(
@@ -1141,7 +1216,10 @@ async fn spawn_agent_system(
}
}
Err(e) => {
error!("❌ Failed to update session ID immediately: {}", e);
error!(
"❌ Failed to update session ID immediately: {}",
e
);
}
}
}
@@ -1301,7 +1379,10 @@ async fn spawn_agent_system(
// Update the run record with session ID and mark as completed - open a new connection
if let Ok(conn) = Connection::open(&db_path_for_monitor) {
info!("🔄 Updating database with extracted session ID: {}", extracted_session_id);
info!(
"🔄 Updating database with extracted session ID: {}",
extracted_session_id
);
match conn.execute(
"UPDATE agent_runs SET session_id = ?1, status = 'completed', completed_at = CURRENT_TIMESTAMP WHERE id = ?2",
params![extracted_session_id, run_id],
@@ -1318,7 +1399,10 @@ async fn spawn_agent_system(
}
}
} else {
error!("❌ Failed to open database to update session ID for run {}", run_id);
error!(
"❌ Failed to open database to update session ID for run {}",
run_id
);
}
// Cleanup will be handled by the cleanup_finished_processes function
@@ -1378,10 +1462,8 @@ pub async fn list_running_sessions(
// Cross-check with the process registry to ensure accuracy
// Get actually running processes from the registry
let registry_processes = registry.0.get_running_agent_processes()?;
let registry_run_ids: std::collections::HashSet<i64> = registry_processes
.iter()
.map(|p| p.run_id)
.collect();
let registry_run_ids: std::collections::HashSet<i64> =
registry_processes.iter().map(|p| p.run_id).collect();
// Filter out any database entries that aren't actually running in the registry
// This handles cases where processes crashed without updating the database
@@ -1574,7 +1656,7 @@ pub async fn get_session_output(
// Find the correct project directory by searching for the session file
let projects_dir = claude_dir.join("projects");
// Check if projects directory exists
if !projects_dir.exists() {
log::error!("Projects directory not found at: {:?}", projects_dir);
@@ -1583,15 +1665,18 @@ pub async fn get_session_output(
// Search for the session file in all project directories
let mut session_file_path = None;
log::info!("Searching for session file {} in all project directories", run.session_id);
log::info!(
"Searching for session file {} in all project directories",
run.session_id
);
if let Ok(entries) = std::fs::read_dir(&projects_dir) {
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if path.is_dir() {
let dir_name = path.file_name().unwrap_or_default().to_string_lossy();
log::debug!("Checking project directory: {}", dir_name);
let potential_session_file = path.join(format!("{}.jsonl", run.session_id));
if potential_session_file.exists() {
log::info!("Found session file at: {:?}", potential_session_file);
@@ -1611,7 +1696,11 @@ pub async fn get_session_output(
match tokio::fs::read_to_string(&session_path).await {
Ok(content) => Ok(content),
Err(e) => {
log::error!("Failed to read session file {}: {}", session_path.display(), e);
log::error!(
"Failed to read session file {}: {}",
session_path.display(),
e
);
// Fallback to live output if file read fails
let live_output = registry.0.get_live_output(run_id)?;
Ok(live_output)
@@ -1619,7 +1708,10 @@ pub async fn get_session_output(
}
} else {
// If session file not found, try the old method as fallback
log::warn!("Session file not found for {}, trying legacy method", run.session_id);
log::warn!(
"Session file not found for {}, trying legacy method",
run.session_id
);
match read_session_jsonl(&run.session_id, &run.project_path).await {
Ok(content) => Ok(content),
Err(_) => {
@@ -2125,7 +2217,7 @@ pub async fn load_agent_session_history(
.join(".claude");
let projects_dir = claude_dir.join("projects");
if !projects_dir.exists() {
log::error!("Projects directory not found at: {:?}", projects_dir);
return Err("Projects directory not found".to_string());
@@ -2133,15 +2225,18 @@ pub async fn load_agent_session_history(
// Search for the session file in all project directories
let mut session_file_path = None;
log::info!("Searching for session file {} in all project directories", session_id);
log::info!(
"Searching for session file {} in all project directories",
session_id
);
if let Ok(entries) = std::fs::read_dir(&projects_dir) {
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if path.is_dir() {
let dir_name = path.file_name().unwrap_or_default().to_string_lossy();
log::debug!("Checking project directory: {}", dir_name);
let potential_session_file = path.join(format!("{}.jsonl", session_id));
if potential_session_file.exists() {
log::info!("Found session file at: {:?}", potential_session_file);
@@ -2176,3 +2271,71 @@ pub async fn load_agent_session_history(
Err(format!("Session file not found: {}", session_id))
}
}
/// Model mapping structure
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ModelMapping {
pub alias: String,
pub model_name: String,
pub updated_at: String,
}
/// Get all model mappings
#[tauri::command]
pub async fn get_model_mappings(db: State<'_, AgentDb>) -> Result<Vec<ModelMapping>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT alias, model_name, updated_at FROM model_mappings ORDER BY alias")
.map_err(|e| e.to_string())?;
let mappings = stmt
.query_map([], |row| {
Ok(ModelMapping {
alias: row.get(0)?,
model_name: row.get(1)?,
updated_at: row.get(2)?,
})
})
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
Ok(mappings)
}
/// Update a model mapping
#[tauri::command]
pub async fn update_model_mapping(
db: State<'_, AgentDb>,
alias: String,
model_name: String,
) -> Result<(), String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
conn.execute(
"INSERT OR REPLACE INTO model_mappings (alias, model_name, updated_at) VALUES (?1, ?2, CURRENT_TIMESTAMP)",
params![alias, model_name],
)
.map_err(|e| e.to_string())?;
Ok(())
}
/// Get model name by alias (with fallback)
fn get_model_by_alias(db: &AgentDb, alias: &str) -> Result<String, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// If alias looks like a full model name (contains 'claude-'), return it directly
if alias.starts_with("claude-") {
return Ok(alias.to_string());
}
// Otherwise, look up the mapping
conn.query_row(
"SELECT model_name FROM model_mappings WHERE alias = ?1",
params![alias],
|row| row.get(0),
)
.map_err(|_| format!("Model alias '{}' not found in mappings", alias))
}

View File

@@ -0,0 +1,365 @@
use anyhow::{Context, Result};
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use uuid::Uuid;
// 导入公共模块
use crate::types::node_test::NodeTestResult;
use crate::utils::node_tester;
/// API 节点数据结构
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiNode {
pub id: String,
pub name: String,
pub url: String,
pub adapter: String,
pub description: Option<String>,
pub enabled: bool,
pub is_default: bool,
pub created_at: String,
pub updated_at: String,
}
/// 创建节点请求
#[derive(Debug, Deserialize)]
pub struct CreateApiNodeRequest {
pub name: String,
pub url: String,
pub adapter: String,
pub description: Option<String>,
}
/// 更新节点请求
#[derive(Debug, Deserialize)]
pub struct UpdateApiNodeRequest {
pub name: Option<String>,
pub url: Option<String>,
pub description: Option<String>,
pub enabled: Option<bool>,
}
/// 获取数据库连接
fn get_connection() -> Result<Connection> {
let db_path = get_nodes_db_path()?;
let conn = Connection::open(&db_path)
.context(format!("Failed to open database at {:?}", db_path))?;
Ok(conn)
}
/// 获取节点数据库路径
fn get_nodes_db_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("Could not find home directory")?;
let db_dir = home.join(".claudia");
std::fs::create_dir_all(&db_dir).context("Failed to create database directory")?;
Ok(db_dir.join("api_nodes.db"))
}
/// 初始化数据库表
pub fn init_nodes_db() -> Result<()> {
let conn = get_connection()?;
conn.execute(
"CREATE TABLE IF NOT EXISTS api_nodes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
adapter TEXT NOT NULL,
description TEXT,
enabled INTEGER DEFAULT 1,
is_default INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)",
[],
)?;
// 创建索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_api_nodes_adapter ON api_nodes(adapter)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_api_nodes_enabled ON api_nodes(enabled)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_api_nodes_is_default ON api_nodes(is_default)",
[],
)?;
Ok(())
}
/// 预设节点配置
const DEFAULT_NODES: &[(&str, &str, &str, &str)] = &[
// PackyCode
("🚌 默认节点", "https://www.packyapi.com", "packycode", "PackyCode 默认节点"),
("⚖️ 负载均衡", "https://api-slb.packyapi.com", "packycode", "PackyCode 负载均衡节点"),
// DeepSeek
("默认节点", "https://api.deepseek.com/anthropic", "deepseek", "DeepSeek 官方节点"),
// GLM
("默认节点", "https://open.bigmodel.cn/api/anthropic", "glm", "智谱 GLM 官方节点"),
// Qwen
("默认节点", "https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy", "qwen", "通义千问官方节点"),
// Kimi
("默认节点", "https://api.moonshot.cn/anthropic", "kimi", "Moonshot Kimi 官方节点"),
// MiniMax
("默认节点", "https://api.minimaxi.com/anthropic", "minimax", "MiniMax 官方节点"),
("备用节点", "https://api.minimaxi.io/anthropic", "minimax", "MiniMax 备用节点"),
];
/// 初始化预设节点
#[tauri::command]
pub async fn init_default_nodes() -> Result<(), String> {
let conn = get_connection().map_err(|e| e.to_string())?;
let now = chrono::Utc::now().to_rfc3339();
for (name, url, adapter, description) in DEFAULT_NODES {
// 检查是否已存在
let exists: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM api_nodes WHERE url = ?1",
params![url],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
if !exists {
let id = Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO api_nodes (id, name, url, adapter, description, enabled, is_default, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, 1, 1, ?6, ?7)",
params![id, name, url, adapter, description, now, now],
)
.map_err(|e| e.to_string())?;
}
}
Ok(())
}
/// 获取节点列表
#[tauri::command]
pub async fn list_api_nodes(
adapter: Option<String>,
enabled_only: Option<bool>,
) -> Result<Vec<ApiNode>, String> {
let conn = get_connection().map_err(|e| e.to_string())?;
let mut sql = "SELECT id, name, url, adapter, description, enabled, is_default, created_at, updated_at FROM api_nodes WHERE 1=1".to_string();
if let Some(adapter_filter) = &adapter {
sql.push_str(&format!(" AND adapter = '{}'", adapter_filter));
}
if enabled_only.unwrap_or(false) {
sql.push_str(" AND enabled = 1");
}
sql.push_str(" ORDER BY is_default DESC, created_at ASC");
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
let nodes = stmt
.query_map([], |row| {
Ok(ApiNode {
id: row.get(0)?,
name: row.get(1)?,
url: row.get(2)?,
adapter: row.get(3)?,
description: row.get(4)?,
enabled: row.get::<_, i32>(5)? != 0,
is_default: row.get::<_, i32>(6)? != 0,
created_at: row.get(7)?,
updated_at: row.get(8)?,
})
})
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(nodes)
}
/// 创建节点
#[tauri::command]
pub async fn create_api_node(request: CreateApiNodeRequest) -> Result<ApiNode, String> {
let conn = get_connection().map_err(|e| e.to_string())?;
let id = Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
// 检查 URL 是否已存在
let exists: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM api_nodes WHERE url = ?1",
params![&request.url],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
if exists {
return Err("节点 URL 已存在".to_string());
}
conn.execute(
"INSERT INTO api_nodes (id, name, url, adapter, description, enabled, is_default, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, 1, 0, ?6, ?7)",
params![
&id,
&request.name,
&request.url,
&request.adapter,
&request.description,
&now,
&now
],
)
.map_err(|e| e.to_string())?;
Ok(ApiNode {
id,
name: request.name,
url: request.url,
adapter: request.adapter,
description: request.description,
enabled: true,
is_default: false,
created_at: now.clone(),
updated_at: now,
})
}
/// 更新节点
#[tauri::command]
pub async fn update_api_node(id: String, request: UpdateApiNodeRequest) -> Result<ApiNode, String> {
let conn = get_connection().map_err(|e| e.to_string())?;
let now = chrono::Utc::now().to_rfc3339();
// 检查节点是否存在
let exists: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM api_nodes WHERE id = ?1",
params![&id],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
if !exists {
return Err("节点不存在".to_string());
}
// 构建动态 SQL
let mut updates = Vec::new();
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(name) = &request.name {
updates.push("name = ?");
params_vec.push(Box::new(name.clone()));
}
if let Some(url) = &request.url {
updates.push("url = ?");
params_vec.push(Box::new(url.clone()));
}
if let Some(description) = &request.description {
updates.push("description = ?");
params_vec.push(Box::new(description.clone()));
}
if let Some(enabled) = request.enabled {
updates.push("enabled = ?");
params_vec.push(Box::new(if enabled { 1 } else { 0 }));
}
updates.push("updated_at = ?");
params_vec.push(Box::new(now.clone()));
params_vec.push(Box::new(id.clone()));
let sql = format!(
"UPDATE api_nodes SET {} WHERE id = ?",
updates.join(", ")
);
let params_refs: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect();
conn.execute(&sql, params_refs.as_slice())
.map_err(|e| e.to_string())?;
// 获取更新后的节点
let node = conn
.query_row(
"SELECT id, name, url, adapter, description, enabled, is_default, created_at, updated_at FROM api_nodes WHERE id = ?1",
params![&id],
|row| {
Ok(ApiNode {
id: row.get(0)?,
name: row.get(1)?,
url: row.get(2)?,
adapter: row.get(3)?,
description: row.get(4)?,
enabled: row.get::<_, i32>(5)? != 0,
is_default: row.get::<_, i32>(6)? != 0,
created_at: row.get(7)?,
updated_at: row.get(8)?,
})
},
)
.map_err(|e| e.to_string())?;
Ok(node)
}
/// 删除节点
#[tauri::command]
pub async fn delete_api_node(id: String) -> Result<(), String> {
let conn = get_connection().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM api_nodes WHERE id = ?1", params![&id])
.map_err(|e| e.to_string())?;
Ok(())
}
/// 测试单个节点
#[tauri::command]
pub async fn test_api_node(url: String, timeout_ms: Option<u64>) -> Result<NodeTestResult, String> {
let timeout = timeout_ms.unwrap_or(5000);
// 使用公共节点测试器
let mut result = node_tester::test_node_connectivity(&url, timeout).await;
// 添加节点 ID 和名称(如果有)
result.node_id = Some(String::new());
result.node_name = Some(String::new());
Ok(result)
}
/// 批量测试节点
#[tauri::command]
pub async fn test_all_api_nodes(
adapter: Option<String>,
timeout_ms: Option<u64>,
) -> Result<Vec<NodeTestResult>, String> {
let nodes = list_api_nodes(adapter, Some(true)).await?;
let timeout = timeout_ms.unwrap_or(5000);
// 提取所有节点的 URL
let urls: Vec<String> = nodes.iter().map(|n| n.url.clone()).collect();
// 使用公共节点测试器批量测试
let mut results = node_tester::test_nodes_batch(urls, timeout).await;
// 添加节点 ID 和名称
for (i, result) in results.iter_mut().enumerate() {
if let Some(node) = nodes.get(i) {
result.node_id = Some(node.id.clone());
result.node_name = Some(node.name.clone());
}
}
Ok(results)
}

View File

@@ -1,10 +1,10 @@
use serde::{Deserialize, Serialize};
use std::process::{Command, Stdio};
use log::{debug, error, info};
use std::net::TcpStream;
use std::time::Duration;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::net::TcpStream;
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::time::Duration;
// 全局变量存储找到的 CCR 路径
static CCR_PATH: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
@@ -50,10 +50,12 @@ fn get_possible_ccr_paths() -> Vec<String> {
let mut paths: Vec<String> = Vec::new();
// PATH 中的候选名(稍后用 PATH 遍历拼接,这里仅保留可直接执行名)
paths.extend(candidate_binaries().into_iter().map(|s| s.to_string()));
// 获取用户主目录
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default();
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_default();
#[cfg(target_os = "macos")]
{
// macOS 特定路径
@@ -71,22 +73,28 @@ fn get_possible_ccr_paths() -> Vec<String> {
paths.push(format!("/usr/local/lib/node_modules/.bin/{}", bin));
paths.push(format!("/opt/homebrew/lib/node_modules/.bin/{}", bin));
}
// 添加常见的 Node.js 版本路径
for version in &["v16", "v18", "v20", "v21", "v22"] {
paths.push(format!("{}/.nvm/versions/node/{}.*/bin/ccr", home, version));
}
}
#[cfg(target_os = "windows")]
{
// Windows 特定路径
let program_files = std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".to_string());
let program_files_x86 = std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| "C:\\Program Files (x86)".to_string());
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| format!("{}\\AppData\\Roaming", home));
let program_files =
std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".to_string());
let program_files_x86 = std::env::var("ProgramFiles(x86)")
.unwrap_or_else(|_| "C:\\Program Files (x86)".to_string());
let appdata =
std::env::var("APPDATA").unwrap_or_else(|_| format!("{}\\AppData\\Roaming", home));
for bin in [
"ccr.exe", "ccr.cmd", "claude-code-router.exe", "claude-code-router.cmd",
"ccr.exe",
"ccr.cmd",
"claude-code-router.exe",
"claude-code-router.cmd",
] {
paths.push(bin.to_string());
paths.push(format!("{}\\npm\\{}", appdata, bin));
@@ -95,7 +103,7 @@ fn get_possible_ccr_paths() -> Vec<String> {
paths.push(format!("{}\\AppData\\Roaming\\npm\\{}", home, bin));
}
}
#[cfg(target_os = "linux")]
{
// Linux 特定路径
@@ -107,15 +115,19 @@ fn get_possible_ccr_paths() -> Vec<String> {
paths.push(format!("/usr/lib/node_modules/.bin/{}", bin));
}
}
paths
}
/// 获取扩展的 PATH 环境变量
fn get_extended_path() -> String {
let mut extended_path = std::env::var("PATH").unwrap_or_default();
let separator = if cfg!(target_os = "windows") { ";" } else { ":" };
let separator = if cfg!(target_os = "windows") {
";"
} else {
":"
};
// 添加常见的额外路径
let additional_paths = if cfg!(target_os = "macos") {
vec![
@@ -129,12 +141,9 @@ fn get_extended_path() -> String {
} else if cfg!(target_os = "windows") {
vec![]
} else {
vec![
"/usr/local/bin",
"/opt/bin",
]
vec!["/usr/local/bin", "/opt/bin"]
};
// 添加用户特定路径
if let Ok(home) = std::env::var("HOME") {
let user_paths = if cfg!(target_os = "macos") {
@@ -149,7 +158,9 @@ fn get_extended_path() -> String {
for entry in entries.flatten() {
let p = entry.path().join("bin");
if p.exists() {
if let Some(s) = p.to_str() { list.push(s.to_string()); }
if let Some(s) = p.to_str() {
list.push(s.to_string());
}
}
}
}
@@ -161,7 +172,9 @@ fn get_extended_path() -> String {
for entry in entries.flatten() {
let p = entry.path().join("bin");
if p.exists() {
if let Some(s) = p.to_str() { list.push(s.to_string()); }
if let Some(s) = p.to_str() {
list.push(s.to_string());
}
}
}
}
@@ -172,16 +185,16 @@ fn get_extended_path() -> String {
for entry in entries.flatten() {
let p = entry.path().join("installation").join("bin");
if p.exists() {
if let Some(s) = p.to_str() { list.push(s.to_string()); }
if let Some(s) = p.to_str() {
list.push(s.to_string());
}
}
}
}
list
} else if cfg!(target_os = "windows") {
if let Ok(appdata) = std::env::var("APPDATA") {
vec![
format!("{}\\npm", appdata),
]
vec![format!("{}\\npm", appdata)]
} else {
vec![]
}
@@ -191,7 +204,7 @@ fn get_extended_path() -> String {
format!("{}/.npm-global/bin", home),
]
};
for path in user_paths {
if std::path::Path::new(&path).exists() && !extended_path.contains(&path) {
extended_path.push_str(separator);
@@ -199,7 +212,7 @@ fn get_extended_path() -> String {
}
}
}
// 添加系统额外路径
for path in additional_paths {
if std::path::Path::new(path).exists() && !extended_path.contains(path) {
@@ -207,7 +220,7 @@ fn get_extended_path() -> String {
extended_path.push_str(path);
}
}
extended_path
}
@@ -219,34 +232,40 @@ fn find_ccr_via_shell() -> Option<String> {
} else {
"command -v ccr || which ccr || command -v claude-code-router || which claude-code-router"
};
let shell = if cfg!(target_os = "windows") {
"cmd"
} else {
"sh"
};
let shell_args = if cfg!(target_os = "windows") {
vec!["/C", shell_cmd]
} else {
vec!["-c", shell_cmd]
};
if let Ok(output) = Command::new(shell)
.args(&shell_args)
.env("PATH", get_extended_path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output() {
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).lines().next().unwrap_or("").trim().to_string();
let path = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
if !path.is_empty() && test_ccr_command(&path) {
info!("Found ccr via shell: {}", path);
return Some(path);
}
}
}
// 如果标准方法失败,尝试加载用户的 shell 配置
if !cfg!(target_os = "windows") {
let home = std::env::var("HOME").ok()?;
@@ -255,18 +274,27 @@ fn find_ccr_via_shell() -> Option<String> {
format!("{}/.zshrc", home),
format!("{}/.profile", home),
];
for config in shell_configs {
if std::path::Path::new(&config).exists() {
let cmd = format!("source {} && (command -v ccr || command -v claude-code-router)", config);
let cmd = format!(
"source {} && (command -v ccr || command -v claude-code-router)",
config
);
if let Ok(output) = Command::new("sh")
.args(&["-c", &cmd])
.env("PATH", get_extended_path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output() {
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).lines().next().unwrap_or("").trim().to_string();
let path = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
if !path.is_empty() && test_ccr_command(&path) {
info!("Found ccr via shell config {}: {}", config, path);
return Some(path);
@@ -276,7 +304,7 @@ fn find_ccr_via_shell() -> Option<String> {
}
}
}
None
}
@@ -288,7 +316,7 @@ fn find_ccr_path() -> Option<String> {
return cached.clone();
}
}
// 硬编码检查最常见的路径(针对打包应用的特殊处理)
let home = std::env::var("HOME").unwrap_or_default();
let mut hardcoded_paths: Vec<String> = Vec::new();
@@ -296,7 +324,7 @@ fn find_ccr_path() -> Option<String> {
hardcoded_paths.push(format!("/usr/local/bin/{}", bin));
hardcoded_paths.push(format!("/opt/homebrew/bin/{}", bin));
}
// 动态添加 NVM 路径
let nvm_base = format!("{}/.nvm/versions/node", home);
if std::path::Path::new(&nvm_base).exists() {
@@ -313,9 +341,9 @@ fn find_ccr_path() -> Option<String> {
}
}
}
info!("Checking hardcoded paths: {:?}", hardcoded_paths);
for path in &hardcoded_paths {
if std::path::Path::new(path).exists() {
// 对于打包应用,存在即认为可用,不进行执行测试
@@ -326,10 +354,10 @@ fn find_ccr_path() -> Option<String> {
return Some(path.to_string());
}
}
// 获取扩展的 PATH
let extended_path = get_extended_path();
// 首先尝试通过 shell 查找(最可靠)
if let Some(path) = find_ccr_via_shell() {
if let Ok(mut cached) = CCR_PATH.lock() {
@@ -337,7 +365,7 @@ fn find_ccr_path() -> Option<String> {
}
return Some(path);
}
// 然后尝试使用带有扩展 PATH 的 which/command -v 命令
for name in ["ccr", "claude-code-router"] {
if let Ok(output) = Command::new("sh")
@@ -346,9 +374,15 @@ fn find_ccr_path() -> Option<String> {
.arg(format!("command -v {} || which {}", name, name))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output() {
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).lines().next().unwrap_or("").trim().to_string();
let path = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
if !path.is_empty() && test_ccr_command(&path) {
info!("Found {} using shell which: {}", name, path);
if let Ok(mut cached) = CCR_PATH.lock() {
@@ -359,9 +393,13 @@ fn find_ccr_path() -> Option<String> {
}
}
}
// 然后检查扩展后的 PATH
let separator = if cfg!(target_os = "windows") { ";" } else { ":" };
let separator = if cfg!(target_os = "windows") {
";"
} else {
":"
};
for path_dir in extended_path.split(separator) {
for name in candidate_binaries() {
let candidate = if cfg!(target_os = "windows") {
@@ -378,10 +416,10 @@ fn find_ccr_path() -> Option<String> {
}
}
}
// 最后尝试预定义的路径列表
let possible_paths = get_possible_ccr_paths();
for path in &possible_paths {
// 处理通配符路径 (仅限 Unix-like 系统)
if path.contains('*') {
@@ -408,8 +446,11 @@ fn find_ccr_path() -> Option<String> {
return Some(path.clone());
}
}
error!("CCR not found in any location. Original PATH: {:?}", std::env::var("PATH"));
error!(
"CCR not found in any location. Original PATH: {:?}",
std::env::var("PATH")
);
error!("Extended PATH: {}", extended_path);
error!("Searched paths: {:?}", possible_paths);
None
@@ -423,7 +464,7 @@ fn test_ccr_command(path: &str) -> bool {
debug!("CCR path does not exist: {}", path);
return false;
}
// 如果是符号链接,解析真实路径
let real_path = if path_obj.is_symlink() {
match std::fs::read_link(path) {
@@ -447,9 +488,12 @@ fn test_ccr_command(path: &str) -> bool {
} else {
path.to_string()
};
debug!("Testing CCR command at: {} (real path: {})", path, real_path);
debug!(
"Testing CCR command at: {} (real path: {})",
path, real_path
);
// 如果是 .js 文件,使用 node 来执行
if real_path.ends_with(".js") {
let output = Command::new("node")
@@ -459,7 +503,7 @@ fn test_ccr_command(path: &str) -> bool {
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output();
match output {
Ok(result) => {
let success = result.status.success();
@@ -511,10 +555,10 @@ pub async fn check_ccr_installation() -> Result<bool, String> {
#[tauri::command]
pub async fn get_ccr_version() -> Result<String, String> {
let ccr_path = find_ccr_path().ok_or("CCR not found")?;
// 尝试多个版本命令参数
let version_args = vec!["--version", "-v", "version"];
for arg in version_args {
let output = if ccr_path.contains("node_modules") || ccr_path.contains(".nvm") {
Command::new("sh")
@@ -532,7 +576,7 @@ pub async fn get_ccr_version() -> Result<String, String> {
.stderr(Stdio::piped())
.output()
};
if let Ok(result) = output {
if result.status.success() {
let version = String::from_utf8_lossy(&result.stdout);
@@ -543,7 +587,7 @@ pub async fn get_ccr_version() -> Result<String, String> {
}
}
}
Err("Unable to get CCR version".to_string())
}
@@ -552,7 +596,7 @@ pub async fn get_ccr_version() -> Result<String, String> {
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");
let original_path = std::env::var("PATH").unwrap_or_else(|_| "PATH not found".to_string());
@@ -567,7 +611,9 @@ pub async fn get_ccr_service_status() -> Result<CcrServiceStatus, String> {
for entry in entries.flatten() {
let p = entry.path().join("bin");
if p.exists() {
if let Some(s) = p.to_str() { scan_dirs.push(s.to_string()); }
if let Some(s) = p.to_str() {
scan_dirs.push(s.to_string());
}
}
}
}
@@ -580,7 +626,9 @@ pub async fn get_ccr_service_status() -> Result<CcrServiceStatus, String> {
for entry in entries.flatten() {
let p = entry.path().join("bin");
if p.exists() {
if let Some(s) = p.to_str() { scan_dirs.push(s.to_string()); }
if let Some(s) = p.to_str() {
scan_dirs.push(s.to_string());
}
}
}
}
@@ -590,7 +638,9 @@ pub async fn get_ccr_service_status() -> Result<CcrServiceStatus, String> {
for entry in entries.flatten() {
let p = entry.path().join("installation").join("bin");
if p.exists() {
if let Some(s) = p.to_str() { scan_dirs.push(s.to_string()); }
if let Some(s) = p.to_str() {
scan_dirs.push(s.to_string());
}
}
}
}
@@ -613,7 +663,8 @@ pub async fn get_ccr_service_status() -> Result<CcrServiceStatus, String> {
.env("PATH", get_extended_path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output() {
.output()
{
Ok(output) => {
if output.status.success() {
let version = String::from_utf8_lossy(&output.stdout);
@@ -623,7 +674,7 @@ pub async fn get_ccr_service_status() -> Result<CcrServiceStatus, String> {
format!("Direct execution FAILED: {}", stderr.trim())
}
}
Err(e) => format!("Direct execution ERROR: {}", e)
Err(e) => format!("Direct execution ERROR: {}", e),
}
} else {
"No candidate binary found in Node manager dirs".to_string()
@@ -638,7 +689,9 @@ pub async fn get_ccr_service_status() -> Result<CcrServiceStatus, String> {
let files: Vec<String> = entries
.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().to_str().map(|s| s.to_string()))
.filter(|name| name.contains("ccr") || name.contains("claude-code-router"))
.filter(|name| {
name.contains("ccr") || name.contains("claude-code-router")
})
.collect();
if !files.is_empty() {
scan_summary.push(format!("{} -> {:?}", dir, files));
@@ -662,7 +715,7 @@ pub async fn get_ccr_service_status() -> Result<CcrServiceStatus, String> {
direct_test,
scan_summary.join("; ")
);
return Ok(CcrServiceStatus {
is_running: false,
port: None,
@@ -677,7 +730,7 @@ pub async fn get_ccr_service_status() -> Result<CcrServiceStatus, String> {
// 获取版本信息
let ccr_version = get_ccr_version().await.ok();
debug!("CCR version: {:?}", ccr_version);
// 获取 CCR 路径
let ccr_path = find_ccr_path().ok_or("CCR not found")?;
@@ -686,23 +739,23 @@ pub async fn get_ccr_service_status() -> Result<CcrServiceStatus, String> {
// 如果是 Node.js 安装的路径,可能需要使用 node 来执行
let mut c = Command::new("sh");
c.arg("-c")
.arg(format!("{} status", ccr_path))
.env("PATH", get_extended_path())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
.arg(format!("{} status", ccr_path))
.env("PATH", get_extended_path())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
c
} else {
let mut c = Command::new(&ccr_path);
c.arg("status")
.env("PATH", get_extended_path())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
.env("PATH", get_extended_path())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
c
};
info!("Executing ccr status command at path: {}", ccr_path);
let output = cmd.output();
let output = match output {
Ok(o) => o,
Err(e) => {
@@ -718,50 +771,56 @@ pub async fn get_ccr_service_status() -> Result<CcrServiceStatus, String> {
});
}
};
let status_output = String::from_utf8_lossy(&output.stdout);
let stderr_output = String::from_utf8_lossy(&output.stderr);
info!("CCR status command exit code: {:?}", output.status.code());
info!("CCR status stdout length: {}", status_output.len());
info!("CCR status stdout: {}", status_output);
info!("CCR status stderr: {}", stderr_output);
// 检查状态 - 明确检测运行和停止状态
let is_running = if status_output.contains("") || status_output.contains("Status: Not Running") {
// 明确显示未运行
false
} else if status_output.contains("") || status_output.contains("Status: Running") {
// 明确显示运行中
true
} else if status_output.contains("Process ID:") && status_output.contains("Port:") {
// 包含进程ID和端口信息可能在运行
true
} else {
// 默认认为未运行
false
};
let is_running =
if status_output.contains("") || status_output.contains("Status: Not Running") {
// 明确显示未运行
false
} else if status_output.contains("") || status_output.contains("Status: Running") {
// 明确显示运行中
true
} else if status_output.contains("Process ID:") && status_output.contains("Port:") {
// 包含进程ID和端口信息可能在运行
true
} else {
// 默认认为未运行
false
};
info!("CCR service running detection - is_running: {}", is_running);
// 尝试从输出中提取端口、端点和进程ID信息
let mut port = None;
let mut endpoint = None;
let mut process_id = None;
if is_running {
// 提取端口信息 - 支持多种格式
for line in status_output.lines() {
info!("Parsing line for port: {}", line);
// 检查是否包含端口信息
if line.contains("Port:") || line.contains("port:") || line.contains("端口:") || line.contains("🌐") {
if line.contains("Port:")
|| line.contains("port:")
|| line.contains("端口:")
|| line.contains("🌐")
{
// 查找数字
let numbers: String = line.chars()
let numbers: String = line
.chars()
.skip_while(|c| !c.is_numeric())
.take_while(|c| c.is_numeric())
.collect();
if !numbers.is_empty() {
if let Ok(port_num) = numbers.parse::<u16>() {
port = Some(port_num);
@@ -771,19 +830,24 @@ pub async fn get_ccr_service_status() -> Result<CcrServiceStatus, String> {
}
}
}
// 提取API端点信息 - 支持多种格式
for line in status_output.lines() {
info!("Parsing line for endpoint: {}", line);
if line.contains("API Endpoint:") || line.contains("Endpoint:") ||
line.contains("http://") || line.contains("https://") || line.contains("📡") {
if line.contains("API Endpoint:")
|| line.contains("Endpoint:")
|| line.contains("http://")
|| line.contains("https://")
|| line.contains("📡")
{
// 尝试提取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")) {
if url.contains(":") && (url.contains("localhost") || url.contains("127.0.0.1"))
{
endpoint = Some(url.to_string());
info!("Successfully extracted endpoint: {}", url);
break;
@@ -791,17 +855,22 @@ pub async fn get_ccr_service_status() -> Result<CcrServiceStatus, String> {
}
}
}
// 提取进程ID信息 - 支持多种格式
for line in status_output.lines() {
info!("Parsing line for PID: {}", line);
if line.contains("Process ID:") || line.contains("PID:") || line.contains("pid:") || line.contains("🆔") {
if line.contains("Process ID:")
|| line.contains("PID:")
|| line.contains("pid:")
|| line.contains("🆔")
{
// 查找数字
let numbers: String = line.chars()
let numbers: String = line
.chars()
.skip_while(|c| !c.is_numeric())
.take_while(|c| c.is_numeric())
.collect();
if !numbers.is_empty() {
if let Ok(pid_num) = numbers.parse::<u32>() {
process_id = Some(pid_num);
@@ -811,7 +880,7 @@ pub async fn get_ccr_service_status() -> Result<CcrServiceStatus, String> {
}
}
}
// 如果没有找到具体信息,使用默认值
if port.is_none() {
port = Some(3456);
@@ -828,7 +897,8 @@ pub async fn get_ccr_service_status() -> Result<CcrServiceStatus, String> {
if !is_running {
info!("Status command didn't detect running service, checking port 3456...");
// 尝试连接默认端口
match TcpStream::connect_timeout(&"127.0.0.1:3456".parse().unwrap(), Duration::from_secs(1)) {
match TcpStream::connect_timeout(&"127.0.0.1:3456".parse().unwrap(), Duration::from_secs(1))
{
Ok(_) => {
info!("Port 3456 is open, service appears to be running");
return Ok(CcrServiceStatus {
@@ -846,7 +916,7 @@ pub async fn get_ccr_service_status() -> Result<CcrServiceStatus, String> {
}
}
}
Ok(CcrServiceStatus {
is_running,
port,
@@ -892,7 +962,7 @@ pub async fn start_ccr_service() -> Result<CcrServiceInfo, String> {
// 再次检查状态
let new_status = get_ccr_service_status().await?;
if new_status.is_running {
Ok(CcrServiceInfo {
status: new_status,
@@ -926,7 +996,7 @@ pub async fn stop_ccr_service() -> Result<CcrServiceInfo, String> {
// 检查新状态
let new_status = get_ccr_service_status().await?;
Ok(CcrServiceInfo {
status: new_status,
message: "CCR service stopped successfully".to_string(),
@@ -959,7 +1029,7 @@ pub async fn restart_ccr_service() -> Result<CcrServiceInfo, String> {
// 检查新状态
let new_status = get_ccr_service_status().await?;
Ok(CcrServiceInfo {
status: new_status,
message: "CCR service restarted successfully".to_string(),
@@ -998,12 +1068,9 @@ pub async fn open_ccr_ui() -> Result<String, 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");
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())
}

View File

@@ -10,7 +10,6 @@ use tauri::{AppHandle, Emitter, Manager};
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
/// Global state to track current Claude process
pub struct ClaudeProcessState {
pub current_process: Arc<Mutex<Option<Child>>>,
@@ -35,6 +34,8 @@ pub struct Project {
pub sessions: Vec<String>,
/// Unix timestamp when the project directory was created
pub created_at: u64,
/// Unix timestamp of the most recent session (last modified time of newest JSONL file)
pub last_session_time: u64,
}
/// Represents a session with its metadata
@@ -265,22 +266,18 @@ fn create_command_with_env(program: &str) -> Command {
}
/// Creates a system binary command with the given arguments
fn create_system_command(
claude_path: &str,
args: Vec<String>,
project_path: &str,
) -> Command {
fn create_system_command(claude_path: &str, args: Vec<String>, project_path: &str) -> Command {
let mut cmd = create_command_with_env(claude_path);
// Add all arguments
for arg in args {
cmd.arg(arg);
}
cmd.current_dir(project_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
cmd
}
@@ -291,16 +288,31 @@ pub async fn watch_claude_project_directory(
app_handle: tauri::AppHandle,
) -> Result<(), String> {
use crate::file_watcher::FileWatcherState;
log::info!("Starting to watch Claude project directory for project: {}", project_path);
let project_path_buf = PathBuf::from(&project_path);
// 支持直接传入位于 ~/.claude 或 ~/.claudia 下的特殊目录(例如智能会话)
if (project_path.contains("/.claude/") || project_path.contains("/.claudia/"))
&& project_path_buf.exists()
{
let file_watcher_state = app_handle.state::<FileWatcherState>();
let path_str = project_path_buf.to_string_lossy().to_string();
return file_watcher_state
.with_manager(|manager| manager.watch_path(&path_str, false))
.map_err(|e| format!("Failed to watch Claude project directory: {}", e));
}
log::info!(
"Starting to watch Claude project directory for project: {}",
project_path
);
let claude_dir = get_claude_dir().map_err(|e| e.to_string())?;
let projects_dir = claude_dir.join("projects");
if !projects_dir.exists() {
return Err("Claude projects directory does not exist".to_string());
}
// 找到对应项目的目录
if let Ok(entries) = std::fs::read_dir(&projects_dir) {
for entry in entries {
@@ -313,17 +325,19 @@ pub async fn watch_claude_project_directory(
// 找到了对应的项目目录,开始监控
let file_watcher_state = app_handle.state::<FileWatcherState>();
let path_str = path.to_string_lossy().to_string();
return file_watcher_state.with_manager(|manager| {
manager.watch_path(&path_str, false)
}).map_err(|e| format!("Failed to watch Claude project directory: {}", e));
return file_watcher_state
.with_manager(|manager| manager.watch_path(&path_str, false))
.map_err(|e| {
format!("Failed to watch Claude project directory: {}", e)
});
}
}
}
}
}
}
Err("Could not find Claude project directory for the given project path".to_string())
}
@@ -334,16 +348,29 @@ pub async fn unwatch_claude_project_directory(
app_handle: tauri::AppHandle,
) -> Result<(), String> {
use crate::file_watcher::FileWatcherState;
log::info!("Stopping watch on Claude project directory for project: {}", project_path);
let project_path_buf = PathBuf::from(&project_path);
// 对智能会话等位于 ~/.claude* 下的目录执行直接取消
if project_path.contains("/.claude/") || project_path.contains("/.claudia/") {
let file_watcher_state = app_handle.state::<FileWatcherState>();
let path_str = project_path_buf.to_string_lossy().to_string();
return file_watcher_state
.with_manager(|manager| manager.unwatch_path(&path_str))
.map_err(|e| format!("Failed to stop watching Claude project directory: {}", e));
}
log::info!(
"Stopping watch on Claude project directory for project: {}",
project_path
);
let claude_dir = get_claude_dir().map_err(|e| e.to_string())?;
let projects_dir = claude_dir.join("projects");
if !projects_dir.exists() {
return Ok(()); // 目录不存在,视为成功
}
// 找到对应项目的目录
if let Ok(entries) = std::fs::read_dir(&projects_dir) {
for entry in entries {
@@ -356,17 +383,22 @@ pub async fn unwatch_claude_project_directory(
// 找到了对应的项目目录,停止监控
let file_watcher_state = app_handle.state::<FileWatcherState>();
let path_str = path.to_string_lossy().to_string();
return file_watcher_state.with_manager(|manager| {
manager.unwatch_path(&path_str)
}).map_err(|e| format!("Failed to stop watching Claude project directory: {}", e));
return file_watcher_state
.with_manager(|manager| manager.unwatch_path(&path_str))
.map_err(|e| {
format!(
"Failed to stop watching Claude project directory: {}",
e
)
});
}
}
}
}
}
}
Ok(())
}
@@ -422,6 +454,8 @@ pub async fn list_projects() -> Result<Vec<Project>, String> {
// List all JSONL files (sessions) in this project directory
let mut sessions = Vec::new();
let mut last_session_time = created_at; // Default to project creation time
if let Ok(session_entries) = fs::read_dir(&path) {
for session_entry in session_entries.flatten() {
let session_path = session_entry.path();
@@ -431,6 +465,21 @@ pub async fn list_projects() -> Result<Vec<Project>, String> {
if let Some(session_id) = session_path.file_stem().and_then(|s| s.to_str())
{
sessions.push(session_id.to_string());
// Get the modified time of this session file
if let Ok(metadata) = fs::metadata(&session_path) {
if let Ok(modified) = metadata.modified() {
let modified_time = modified
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Update last_session_time if this file is newer
if modified_time > last_session_time {
last_session_time = modified_time;
}
}
}
}
}
}
@@ -441,12 +490,13 @@ pub async fn list_projects() -> Result<Vec<Project>, String> {
path: project_path,
sessions,
created_at,
last_session_time,
});
}
}
// Sort projects by creation time (newest first)
projects.sort_by(|a, b| b.created_at.cmp(&a.created_at));
// Sort projects by last session time (newest first)
projects.sort_by(|a, b| b.last_session_time.cmp(&a.last_session_time));
log::info!("Found {} projects", projects.len());
Ok(projects)
@@ -623,102 +673,38 @@ pub async fn get_system_prompt() -> Result<String, String> {
/// Checks if Claude Code is installed and gets its version
#[tauri::command]
pub async fn check_claude_version(app: AppHandle) -> Result<ClaudeVersionStatus, String> {
pub async fn check_claude_version(_app: AppHandle) -> Result<ClaudeVersionStatus, String> {
log::info!("Checking Claude Code version");
let claude_path = match find_claude_binary(&app) {
Ok(path) => path,
Err(e) => {
return Ok(ClaudeVersionStatus {
is_installed: false,
version: None,
output: e,
});
}
};
// Try to find Claude installations with versions
let installations = crate::claude_binary::discover_claude_installations();
use log::debug;debug!("Claude path: {}", claude_path);
// In production builds, we can't check the version directly
#[cfg(not(debug_assertions))]
{
log::warn!("Cannot check claude version in production build");
// If we found a path (either stored or in common locations), assume it's installed
if claude_path != "claude" && PathBuf::from(&claude_path).exists() {
return Ok(ClaudeVersionStatus {
is_installed: true,
version: None,
output: "Claude binary found at: ".to_string() + &claude_path,
});
} else {
return Ok(ClaudeVersionStatus {
is_installed: false,
version: None,
output: "Cannot verify Claude installation in production build. Please ensure Claude Code is installed.".to_string(),
});
}
if installations.is_empty() {
return Ok(ClaudeVersionStatus {
is_installed: false,
version: None,
output: "Claude Code not found. Please ensure it's installed.".to_string(),
});
}
#[cfg(debug_assertions)]
{
let output = std::process::Command::new(claude_path)
.arg("--version")
.output();
// Find the best installation (highest version or first found)
let best_installation = installations
.into_iter()
.max_by(|a, b| match (&a.version, &b.version) {
(Some(v1), Some(v2)) => v1.cmp(v2),
(Some(_), None) => std::cmp::Ordering::Greater,
(None, Some(_)) => std::cmp::Ordering::Less,
(None, None) => std::cmp::Ordering::Equal,
})
.unwrap(); // Safe because we checked is_empty() above
match output {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
// Use regex to directly extract version pattern (e.g., "1.0.41")
let version_regex = regex::Regex::new(r"(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?)").ok();
log::info!("Found Claude installation: {:?}", best_installation);
// Combine stdout and stderr for version extraction (some tools write version to stderr)
let mut version_src = stdout.clone();
if !stderr.is_empty() {
version_src.push('\n');
version_src.push_str(&stderr);
}
let version = if let Some(regex) = version_regex {
regex
.captures(&version_src)
.and_then(|captures| captures.get(1))
.map(|m| m.as_str().to_string())
} else {
None
};
let full_output = if stderr.is_empty() {
stdout.clone()
} else {
format!("{}\n{}", stdout, stderr)
};
// Check if the output matches the expected format
// Expected format: "1.0.17 (Claude Code)" or similar
let is_valid =
stdout.contains("(Claude Code)")
|| stdout.contains("Claude Code")
|| stderr.contains("(Claude Code)")
|| stderr.contains("Claude Code");
Ok(ClaudeVersionStatus {
is_installed: is_valid && output.status.success(),
version,
output: full_output.trim().to_string(),
})
}
Err(e) => {
log::error!("Failed to run claude command: {}", e);
Ok(ClaudeVersionStatus {
is_installed: false,
version: None,
output: format!("Command not found: {}", e),
})
}
}
}
Ok(ClaudeVersionStatus {
is_installed: true,
version: best_installation.version,
output: format!("Claude binary found at: {}", best_installation.path),
})
}
/// Saves the CLAUDE.md system prompt file
@@ -752,6 +738,48 @@ pub async fn save_claude_settings(settings: serde_json::Value) -> Result<String,
Ok("Settings saved successfully".to_string())
}
/// Reads the Claude settings backup file
#[tauri::command]
pub async fn get_claude_settings_backup() -> Result<ClaudeSettings, String> {
log::info!("Reading Claude settings backup");
let claude_dir = get_claude_dir().map_err(|e| e.to_string())?;
let backup_path = claude_dir.join("settings.backup.json");
if !backup_path.exists() {
log::warn!("Settings backup file not found, returning empty settings");
return Ok(ClaudeSettings {
data: serde_json::json!({}),
});
}
let content = fs::read_to_string(&backup_path)
.map_err(|e| format!("Failed to read settings backup file: {}", e))?;
let data: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse settings backup JSON: {}", e))?;
Ok(ClaudeSettings { data })
}
/// Saves the Claude settings backup file
#[tauri::command]
pub async fn save_claude_settings_backup(settings: serde_json::Value) -> Result<String, String> {
log::info!("Saving Claude settings backup");
let claude_dir = get_claude_dir().map_err(|e| e.to_string())?;
let backup_path = claude_dir.join("settings.backup.json");
// Pretty print the JSON with 2-space indentation
let json_string = serde_json::to_string_pretty(&settings)
.map_err(|e| format!("Failed to serialize settings backup: {}", e))?;
fs::write(&backup_path, json_string)
.map_err(|e| format!("Failed to write settings backup file: {}", e))?;
Ok("Settings backup saved successfully".to_string())
}
/// Recursively finds all CLAUDE.md files in a project directory
#[tauri::command]
pub async fn find_claude_md_files(project_path: String) -> Result<Vec<ClaudeMdFile>, String> {
@@ -908,8 +936,6 @@ pub async fn load_session_history(
Ok(messages)
}
/// Execute a new interactive Claude Code session with streaming output
#[tauri::command]
pub async fn execute_claude_code(
@@ -925,13 +951,13 @@ pub async fn execute_claude_code(
);
let claude_path = find_claude_binary(&app)?;
// Map opus-plan to the appropriate Claude CLI parameter
let claude_model = match model.as_str() {
"opus-plan" => "opusplan".to_string(),
_ => model.clone()
_ => model.clone(),
};
let args = vec![
"-p".to_string(),
prompt.clone(),
@@ -962,13 +988,13 @@ pub async fn continue_claude_code(
);
let claude_path = find_claude_binary(&app)?;
// Map opus-plan to the appropriate Claude CLI parameter
let claude_model = match model.as_str() {
"opus-plan" => "opusplan".to_string(),
_ => model.clone()
_ => model.clone(),
};
let args = vec![
"-c".to_string(), // Continue flag
"-p".to_string(),
@@ -1002,13 +1028,13 @@ pub async fn resume_claude_code(
);
let claude_path = find_claude_binary(&app)?;
// Map opus-plan to the appropriate Claude CLI parameter
let claude_model = match model.as_str() {
"opus-plan" => "opusplan".to_string(),
_ => model.clone()
_ => model.clone(),
};
let args = vec![
"--resume".to_string(),
session_id.clone(),
@@ -1045,8 +1071,12 @@ pub async fn cancel_claude_execution(
let registry = app.state::<crate::process::ProcessRegistryState>();
match registry.0.get_claude_session_by_id(sid) {
Ok(Some(process_info)) => {
log::info!("Found process in registry for session {}: run_id={}, PID={}",
sid, process_info.run_id, process_info.pid);
log::info!(
"Found process in registry for session {}: run_id={}, PID={}",
sid,
process_info.run_id,
process_info.pid
);
match registry.0.kill_process(process_info.run_id).await {
Ok(success) => {
if success {
@@ -1079,7 +1109,10 @@ pub async fn cancel_claude_execution(
if let Some(mut child) = current_process.take() {
// Try to get the PID before killing
let pid = child.id();
log::info!("Attempting to kill Claude process via ClaudeProcessState with PID: {:?}", pid);
log::info!(
"Attempting to kill Claude process via ClaudeProcessState with PID: {:?}",
pid
);
// Kill the process
match child.kill().await {
@@ -1088,8 +1121,11 @@ pub async fn cancel_claude_execution(
killed = true;
}
Err(e) => {
log::error!("Failed to kill Claude process via ClaudeProcessState: {}", e);
log::error!(
"Failed to kill Claude process via ClaudeProcessState: {}",
e
);
// Method 3: If we have a PID, try system kill as last resort
if let Some(pid) = pid {
log::info!("Attempting system kill as last resort for PID: {}", pid);
@@ -1102,7 +1138,7 @@ pub async fn cancel_claude_execution(
.args(["-KILL", &pid.to_string()])
.output()
};
match kill_result {
Ok(output) if output.status.success() => {
log::info!("Successfully killed process via system command");
@@ -1135,18 +1171,18 @@ pub async fn cancel_claude_execution(
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let _ = app.emit(&format!("claude-complete:{}", sid), false);
}
// Also emit generic events for backward compatibility
let _ = app.emit("claude-cancelled", true);
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let _ = app.emit("claude-complete", false);
if killed {
log::info!("Claude process cancellation completed successfully");
} else if !attempted_methods.is_empty() {
log::warn!("Claude process cancellation attempted but process may have already exited. Attempted methods: {:?}", attempted_methods);
}
Ok(())
}
@@ -1173,9 +1209,15 @@ pub async fn get_claude_session_output(
}
/// Helper function to spawn Claude process and handle streaming
async fn spawn_claude_process(app: AppHandle, mut cmd: Command, prompt: String, model: String, project_path: String) -> Result<(), String> {
use tokio::io::{AsyncBufReadExt, BufReader};
async fn spawn_claude_process(
app: AppHandle,
mut cmd: Command,
prompt: String,
model: String,
project_path: String,
) -> Result<(), String> {
use std::sync::Mutex;
use tokio::io::{AsyncBufReadExt, BufReader};
// Spawn the process
let mut child = cmd
@@ -1188,10 +1230,7 @@ async fn spawn_claude_process(app: AppHandle, mut cmd: Command, prompt: String,
// Get the child PID for logging
let pid = child.id().unwrap_or(0);
log::info!(
"Spawned Claude process with PID: {:?}",
pid
);
log::info!("Spawned Claude process with PID: {:?}", pid);
// Create readers first (before moving child)
let stdout_reader = BufReader::new(stdout);
@@ -1226,7 +1265,7 @@ async fn spawn_claude_process(app: AppHandle, mut cmd: Command, prompt: String,
let mut lines = stdout_reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
log::debug!("Claude stdout: {}", line);
// Parse the line to check for init message with session ID
if let Ok(msg) = serde_json::from_str::<serde_json::Value>(&line) {
if msg["type"] == "system" && msg["subtype"] == "init" {
@@ -1235,7 +1274,7 @@ async fn spawn_claude_process(app: AppHandle, mut cmd: Command, prompt: String,
if session_id_guard.is_none() {
*session_id_guard = Some(claude_session_id.to_string());
log::info!("Extracted Claude session ID: {}", claude_session_id);
// Now register with ProcessRegistry using Claude's session ID
match registry_clone.register_claude_session(
claude_session_id.to_string(),
@@ -1257,12 +1296,12 @@ async fn spawn_claude_process(app: AppHandle, mut cmd: Command, prompt: String,
}
}
}
// Store live output in registry if we have a run_id
if let Some(run_id) = *run_id_holder_clone.lock().unwrap() {
let _ = registry_clone.append_live_output(run_id, &line);
}
// Emit the line to the frontend with session isolation if we have session ID
if let Some(ref session_id) = *session_id_holder_clone.lock().unwrap() {
let _ = app_handle.emit(&format!("claude-output:{}", session_id), &line);
@@ -1306,10 +1345,8 @@ async fn spawn_claude_process(app: AppHandle, mut cmd: Command, prompt: String,
// Add a small delay to ensure all messages are processed
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
if let Some(ref session_id) = *session_id_holder_clone3.lock().unwrap() {
let _ = app_handle_wait.emit(
&format!("claude-complete:{}", session_id),
status.success(),
);
let _ = app_handle_wait
.emit(&format!("claude-complete:{}", session_id), status.success());
}
// Also emit to the generic event for backward compatibility
let _ = app_handle_wait.emit("claude-complete", status.success());
@@ -1319,8 +1356,8 @@ async fn spawn_claude_process(app: AppHandle, mut cmd: Command, prompt: String,
// Add a small delay to ensure all messages are processed
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
if let Some(ref session_id) = *session_id_holder_clone3.lock().unwrap() {
let _ = app_handle_wait
.emit(&format!("claude-complete:{}", session_id), false);
let _ =
app_handle_wait.emit(&format!("claude-complete:{}", session_id), false);
}
// Also emit to the generic event for backward compatibility
let _ = app_handle_wait.emit("claude-complete", false);
@@ -1340,7 +1377,6 @@ async fn spawn_claude_process(app: AppHandle, mut cmd: Command, prompt: String,
Ok(())
}
/// Lists files and directories in a given path
#[tauri::command]
pub async fn list_directory_contents(directory_path: String) -> Result<Vec<FileEntry>, String> {
@@ -2057,78 +2093,92 @@ pub async fn track_session_messages(
/// Gets hooks configuration from settings at specified scope
#[tauri::command]
pub async fn get_hooks_config(scope: String, project_path: Option<String>) -> Result<serde_json::Value, String> {
log::info!("Getting hooks config for scope: {}, project: {:?}", scope, project_path);
pub async fn get_hooks_config(
scope: String,
project_path: Option<String>,
) -> Result<serde_json::Value, String> {
log::info!(
"Getting hooks config for scope: {}, project: {:?}",
scope,
project_path
);
let settings_path = match scope.as_str() {
"user" => {
get_claude_dir()
.map_err(|e| e.to_string())?
.join("settings.json")
},
"user" => get_claude_dir()
.map_err(|e| e.to_string())?
.join("settings.json"),
"project" => {
let path = project_path.ok_or("Project path required for project scope")?;
PathBuf::from(path).join(".claude").join("settings.json")
},
}
"local" => {
let path = project_path.ok_or("Project path required for local scope")?;
PathBuf::from(path).join(".claude").join("settings.local.json")
},
_ => return Err("Invalid scope".to_string())
PathBuf::from(path)
.join(".claude")
.join("settings.local.json")
}
_ => return Err("Invalid scope".to_string()),
};
if !settings_path.exists() {
log::info!("Settings file does not exist at {:?}, returning empty hooks", settings_path);
log::info!(
"Settings file does not exist at {:?}, returning empty hooks",
settings_path
);
return Ok(serde_json::json!({}));
}
let content = fs::read_to_string(&settings_path)
.map_err(|e| format!("Failed to read settings: {}", e))?;
let settings: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse settings: {}", e))?;
Ok(settings.get("hooks").cloned().unwrap_or(serde_json::json!({})))
let settings: serde_json::Value =
serde_json::from_str(&content).map_err(|e| format!("Failed to parse settings: {}", e))?;
Ok(settings
.get("hooks")
.cloned()
.unwrap_or(serde_json::json!({})))
}
/// Updates hooks configuration in settings at specified scope
#[tauri::command]
pub async fn update_hooks_config(
scope: String,
scope: String,
hooks: serde_json::Value,
project_path: Option<String>
project_path: Option<String>,
) -> Result<String, String> {
log::info!("Updating hooks config for scope: {}, project: {:?}", scope, project_path);
log::info!(
"Updating hooks config for scope: {}, project: {:?}",
scope,
project_path
);
let settings_path = match scope.as_str() {
"user" => {
get_claude_dir()
.map_err(|e| e.to_string())?
.join("settings.json")
},
"user" => get_claude_dir()
.map_err(|e| e.to_string())?
.join("settings.json"),
"project" => {
let path = project_path.ok_or("Project path required for project scope")?;
let claude_dir = PathBuf::from(path).join(".claude");
fs::create_dir_all(&claude_dir)
.map_err(|e| format!("Failed to create .claude directory: {}", e))?;
claude_dir.join("settings.json")
},
}
"local" => {
let path = project_path.ok_or("Project path required for local scope")?;
let claude_dir = PathBuf::from(path).join(".claude");
fs::create_dir_all(&claude_dir)
.map_err(|e| format!("Failed to create .claude directory: {}", e))?;
claude_dir.join("settings.local.json")
},
_ => return Err("Invalid scope".to_string())
}
_ => return Err("Invalid scope".to_string()),
};
// Read existing settings or create new
let mut settings = if settings_path.exists() {
let content = fs::read_to_string(&settings_path)
.map_err(|e| format!("Failed to read settings: {}", e))?;
serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse settings: {}", e))?
serde_json::from_str(&content).map_err(|e| format!("Failed to parse settings: {}", e))?
} else {
serde_json::json!({})
};
@@ -2139,7 +2189,7 @@ pub async fn update_hooks_config(
// Write back with pretty formatting
let json_string = serde_json::to_string_pretty(&settings)
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
fs::write(&settings_path, json_string)
.map_err(|e| format!("Failed to write settings: {}", e))?;
@@ -2154,9 +2204,9 @@ pub async fn validate_hook_command(command: String) -> Result<serde_json::Value,
// Validate syntax without executing
let mut cmd = std::process::Command::new("bash");
cmd.arg("-n") // Syntax check only
.arg("-c")
.arg(&command);
.arg("-c")
.arg(&command);
match cmd.output() {
Ok(output) => {
if output.status.success() {
@@ -2172,6 +2222,6 @@ pub async fn validate_hook_command(command: String) -> Result<serde_json::Value,
}))
}
}
Err(e) => Err(format!("Failed to validate command: {}", e))
Err(e) => Err(format!("Failed to validate command: {}", e)),
}
}

View File

@@ -1,8 +1,8 @@
use crate::file_watcher::FileWatcherState;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use tauri::State;
use crate::file_watcher::FileWatcherState;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FileNode {
@@ -23,15 +23,13 @@ pub struct FileSystemChange {
/// 读取文件内容
#[tauri::command]
pub async fn read_file(path: String) -> Result<String, String> {
fs::read_to_string(&path)
.map_err(|e| format!("Failed to read file: {}", e))
fs::read_to_string(&path).map_err(|e| format!("Failed to read file: {}", e))
}
/// 写入文件内容
#[tauri::command]
pub async fn write_file(path: String, content: String) -> Result<(), String> {
fs::write(&path, content)
.map_err(|e| format!("Failed to write file: {}", e))
fs::write(&path, content).map_err(|e| format!("Failed to write file: {}", e))
}
/// 读取目录树结构
@@ -47,20 +45,21 @@ pub async fn read_directory_tree(
}
let max_depth = max_depth.unwrap_or(5);
let ignore_patterns = ignore_patterns.unwrap_or_else(|| vec![
String::from("node_modules"),
String::from(".git"),
String::from("target"),
String::from("dist"),
String::from("build"),
String::from(".idea"),
String::from(".vscode"),
String::from("__pycache__"),
String::from(".DS_Store"),
]);
let ignore_patterns = ignore_patterns.unwrap_or_else(|| {
vec![
String::from("node_modules"),
String::from(".git"),
String::from("target"),
String::from("dist"),
String::from("build"),
String::from(".idea"),
String::from(".vscode"),
String::from("__pycache__"),
String::from(".DS_Store"),
]
});
read_directory_recursive(path, 0, max_depth, &ignore_patterns)
.map_err(|e| e.to_string())
read_directory_recursive(path, 0, max_depth, &ignore_patterns).map_err(|e| e.to_string())
}
fn read_directory_recursive(
@@ -69,28 +68,29 @@ fn read_directory_recursive(
max_depth: u32,
ignore_patterns: &[String],
) -> std::io::Result<FileNode> {
let name = path.file_name()
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let metadata = fs::metadata(path)?;
let node = if metadata.is_dir() {
let mut children = Vec::new();
if current_depth < max_depth {
// Check if directory should be ignored
let should_ignore = ignore_patterns.iter().any(|pattern| {
&name == pattern || name.starts_with('.')
});
let should_ignore = ignore_patterns
.iter()
.any(|pattern| &name == pattern || name.starts_with('.'));
if !should_ignore {
let entries = fs::read_dir(path)?;
for entry in entries {
let entry = entry?;
let child_path = entry.path();
// Skip symlinks to avoid infinite loops
if let Ok(meta) = entry.metadata() {
if !meta.file_type().is_symlink() {
@@ -105,25 +105,24 @@ fn read_directory_recursive(
}
}
}
// Sort children: directories first, then files, alphabetically
children.sort_by(|a, b| {
match (a.file_type.as_str(), b.file_type.as_str()) {
("directory", "file") => std::cmp::Ordering::Less,
("file", "directory") => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
}
children.sort_by(|a, b| match (a.file_type.as_str(), b.file_type.as_str()) {
("directory", "file") => std::cmp::Ordering::Less,
("file", "directory") => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
});
}
}
FileNode {
name,
path: path.to_string_lossy().to_string(),
file_type: String::from("directory"),
children: Some(children),
size: None,
modified: metadata.modified()
modified: metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs()),
@@ -135,13 +134,14 @@ fn read_directory_recursive(
file_type: String::from("file"),
children: None,
size: Some(metadata.len()),
modified: metadata.modified()
modified: metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs()),
}
};
Ok(node)
}
@@ -162,7 +162,7 @@ pub async fn search_files_by_name(
let mut results = Vec::new();
search_recursive(base_path, &query_lower, &mut results, max_results)?;
Ok(results)
}
@@ -176,8 +176,7 @@ fn search_recursive(
return Ok(());
}
let entries = fs::read_dir(dir)
.map_err(|e| format!("Failed to read directory: {}", e))?;
let entries = fs::read_dir(dir).map_err(|e| format!("Failed to read directory: {}", e))?;
for entry in entries {
if results.len() >= max_results {
@@ -186,7 +185,8 @@ fn search_recursive(
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let path = entry.path();
let file_name = path.file_name()
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
@@ -197,10 +197,11 @@ fn search_recursive(
if path.is_dir() {
// Skip hidden directories and common ignore patterns
if !file_name.starts_with('.')
if !file_name.starts_with('.')
&& file_name != "node_modules"
&& file_name != "target"
&& file_name != "dist" {
&& file_name != "dist"
{
let _ = search_recursive(&path, query, results, max_results);
}
}
@@ -217,10 +218,10 @@ pub async fn get_file_info(path: String) -> Result<FileNode, String> {
return Err(format!("Path does not exist: {}", path.display()));
}
let metadata = fs::metadata(path)
.map_err(|e| format!("Failed to get metadata: {}", e))?;
let metadata = fs::metadata(path).map_err(|e| format!("Failed to get metadata: {}", e))?;
let name = path.file_name()
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
@@ -228,18 +229,19 @@ pub async fn get_file_info(path: String) -> Result<FileNode, String> {
Ok(FileNode {
name,
path: path.to_string_lossy().to_string(),
file_type: if metadata.is_dir() {
String::from("directory")
} else {
String::from("file")
file_type: if metadata.is_dir() {
String::from("directory")
} else {
String::from("file")
},
children: None,
size: if metadata.is_file() {
Some(metadata.len())
} else {
None
size: if metadata.is_file() {
Some(metadata.len())
} else {
None
},
modified: metadata.modified()
modified: metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs()),
@@ -254,10 +256,8 @@ pub async fn watch_directory(
recursive: Option<bool>,
) -> Result<(), String> {
let recursive = recursive.unwrap_or(false);
watcher_state.with_manager(|manager| {
manager.watch_path(&path, recursive)
})
watcher_state.with_manager(|manager| manager.watch_path(&path, recursive))
}
/// 停止监听指定路径
@@ -266,9 +266,7 @@ pub async fn unwatch_directory(
watcher_state: State<'_, FileWatcherState>,
path: String,
) -> Result<(), String> {
watcher_state.with_manager(|manager| {
manager.unwatch_path(&path)
})
watcher_state.with_manager(|manager| manager.unwatch_path(&path))
}
/// 获取当前监听的路径列表
@@ -276,9 +274,7 @@ pub async fn unwatch_directory(
pub async fn get_watched_paths(
watcher_state: State<'_, FileWatcherState>,
) -> Result<Vec<String>, String> {
watcher_state.with_manager(|manager| {
Ok(manager.get_watched_paths())
})
watcher_state.with_manager(|manager| Ok(manager.get_watched_paths()))
}
/// 获取文件树(简化版,供文件浏览器使用)
@@ -302,9 +298,9 @@ pub async fn get_file_tree(project_path: String) -> Result<Vec<FileNode>, String
];
// 增加最大深度为 10以支持更深的文件夹结构
let root_node = read_directory_recursive(path, 0, 10, &ignore_patterns)
.map_err(|e| e.to_string())?;
let root_node =
read_directory_recursive(path, 0, 10, &ignore_patterns).map_err(|e| e.to_string())?;
// Return children of root node if it has any
Ok(root_node.children.unwrap_or_default())
}
}

View File

@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
use std::process::Command;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GitStatus {
@@ -94,14 +94,13 @@ pub async fn get_git_status(path: String) -> Result<GitStatus, String> {
.output()
.ok();
let remote_url = remote_output
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
});
let remote_url = remote_output.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
});
let is_clean = staged.is_empty() && modified.is_empty() && untracked.is_empty();
@@ -161,7 +160,14 @@ fn get_tracking_info(path: &Path) -> Result<(u32, u32), String> {
Ok((ahead, behind))
}
fn parse_git_status(status_text: &str) -> (Vec<GitFileStatus>, Vec<GitFileStatus>, Vec<GitFileStatus>, Vec<GitFileStatus>) {
fn parse_git_status(
status_text: &str,
) -> (
Vec<GitFileStatus>,
Vec<GitFileStatus>,
Vec<GitFileStatus>,
Vec<GitFileStatus>,
) {
let mut staged = Vec::new();
let mut modified = Vec::new();
let mut untracked = Vec::new();
@@ -197,7 +203,7 @@ fn parse_git_status(status_text: &str) -> (Vec<GitFileStatus>, Vec<GitFileStatus
status: "modified".to_string(),
staged: false,
});
},
}
"A " | "AM" => staged.push(GitFileStatus {
path: file_path,
status: "added".to_string(),
@@ -360,7 +366,7 @@ pub async fn get_git_branches(path: String) -> Result<Vec<GitBranch>, String> {
for line in branch_text.lines() {
let is_current = line.starts_with('*');
let line = line.trim_start_matches('*').trim();
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.is_empty() {
continue;
@@ -404,11 +410,11 @@ pub async fn get_git_diff(
let mut cmd = Command::new("git");
cmd.arg("diff");
if staged.unwrap_or(false) {
cmd.arg("--cached");
}
if let Some(file) = file_path {
cmd.arg(file);
}
@@ -440,19 +446,19 @@ mod tests {
fn test_parse_git_status() {
let status_text = "?? test-untracked.txt\nA staged-file.txt\n M modified-file.txt";
let (staged, modified, untracked, conflicted) = parse_git_status(status_text);
println!("Untracked files: {:?}", untracked);
println!("Staged files: {:?}", staged);
println!("Modified files: {:?}", modified);
assert_eq!(untracked.len(), 1);
assert_eq!(untracked[0].path, "test-untracked.txt");
assert_eq!(untracked[0].status, "untracked");
assert_eq!(staged.len(), 1);
assert_eq!(staged[0].path, "staged-file.txt");
assert_eq!(modified.len(), 1);
assert_eq!(modified[0].path, "modified-file.txt");
}
}
}

View File

@@ -1,6 +1,6 @@
use tauri::command;
use serde::{Deserialize, Serialize};
use crate::i18n;
use serde::{Deserialize, Serialize};
use tauri::command;
#[derive(Debug, Serialize, Deserialize)]
pub struct LanguageSettings {
@@ -14,14 +14,16 @@ pub async fn get_current_language() -> Result<String, String> {
#[command]
pub async fn set_language(locale: String) -> Result<(), String> {
i18n::set_locale(&locale)
.map_err(|e| format!("Failed to set language: {}", e))?;
i18n::set_locale(&locale).map_err(|e| format!("Failed to set language: {}", e))?;
log::info!("Language changed to: {}", locale);
Ok(())
}
#[command]
pub async fn get_supported_languages() -> Result<Vec<String>, String> {
Ok(i18n::SUPPORTED_LOCALES.iter().map(|&s| s.to_string()).collect())
}
Ok(i18n::SUPPORTED_LOCALES
.iter()
.map(|&s| s.to_string())
.collect())
}

View File

@@ -751,7 +751,7 @@ pub async fn mcp_export_servers(app: AppHandle) -> Result<MCPExportResult, Strin
// Get all servers
let servers = mcp_list(app.clone()).await?;
if servers.is_empty() {
return Ok(MCPExportResult {
servers: vec![],
@@ -761,7 +761,7 @@ pub async fn mcp_export_servers(app: AppHandle) -> Result<MCPExportResult, Strin
// Get detailed information for each server
let mut export_configs = Vec::new();
for server in &servers {
match mcp_get(app.clone(), server.name.clone()).await {
Ok(detailed_server) => {
@@ -792,7 +792,12 @@ pub async fn mcp_export_servers(app: AppHandle) -> Result<MCPExportResult, Strin
}
Ok(MCPExportResult {
format: if export_configs.len() == 1 { "single" } else { "multiple" }.to_string(),
format: if export_configs.len() == 1 {
"single"
} else {
"multiple"
}
.to_string(),
servers: export_configs,
})
}

View File

@@ -1,18 +1,21 @@
pub mod agents;
pub mod api_nodes;
pub mod ccr;
pub mod claude;
pub mod mcp;
pub mod usage;
pub mod usage_index;
pub mod usage_cache;
pub mod storage;
pub mod slash_commands;
pub mod proxy;
pub mod language;
pub mod relay_stations;
pub mod relay_adapters;
pub mod packycode_nodes;
pub mod filesystem;
pub mod git;
pub mod terminal;
pub mod ccr;
pub mod language;
pub mod mcp;
pub mod packycode_nodes;
pub mod prompt_files;
pub mod proxy;
pub mod relay_adapters;
pub mod relay_stations;
pub mod slash_commands;
pub mod smart_sessions;
pub mod storage;
pub mod system;
pub mod terminal;
pub mod usage;
pub mod usage_cache;
pub mod usage_index;

View File

@@ -1,16 +1,18 @@
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
use reqwest::Client;
use tauri::command;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use tauri::command;
// 导入公共模块
use crate::types::node_test::NodeTestResult;
use crate::utils::node_tester;
/// PackyCode 节点类型
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NodeType {
Direct, // 直连节点
Backup, // 备用节点
Emergency, // 紧急节点(非紧急情况不要使用)
Direct, // 直连节点
Backup, // 备用节点
Emergency, // 紧急节点(非紧急情况不要使用)
}
/// PackyCode 节点信息
@@ -24,90 +26,88 @@ pub struct PackycodeNode {
pub available: Option<bool>, // 是否可用
}
/// 节点测速结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeSpeedTestResult {
pub node: PackycodeNode,
pub response_time: u64,
pub success: bool,
pub error: Option<String>,
}
/// 获取所有 PackyCode 节点
pub fn get_all_nodes() -> Vec<PackycodeNode> {
vec![
// 直连节点
// 公交车节点 (Bus Service)
PackycodeNode {
name: "直连1".to_string(),
name: "公交车默认节点".to_string(),
url: "https://api.packycode.com".to_string(),
node_type: NodeType::Direct,
description: "默认直连节点".to_string(),
description: "默认公交车直连节点".to_string(),
response_time: None,
available: None,
},
PackycodeNode {
name: "直连2 (HK-CN2)".to_string(),
name: "公交车 HK-CN2".to_string(),
url: "https://api-hk-cn2.packycode.com".to_string(),
node_type: NodeType::Direct,
description: "香港 CN2 线路".to_string(),
description: "香港 CN2 线路(公交车)".to_string(),
response_time: None,
available: None,
},
PackycodeNode {
name: "直连3 (US-CMIN2)".to_string(),
url: "https://api-us-cmin2.packycode.com".to_string(),
name: "公交车 HK-G".to_string(),
url: "https://api-hk-g.packycode.com".to_string(),
node_type: NodeType::Direct,
description: "美国 CMIN2 线路".to_string(),
description: "香港 G 线路(公交车)".to_string(),
response_time: None,
available: None,
},
PackycodeNode {
name: "直连4 (US-4837)".to_string(),
url: "https://api-us-4837.packycode.com".to_string(),
node_type: NodeType::Direct,
description: "美国 4837 线路".to_string(),
response_time: None,
available: None,
},
// 备用节点
PackycodeNode {
name: "备用1 (US-CN2)".to_string(),
url: "https://api-us-cn2.packycode.com".to_string(),
node_type: NodeType::Backup,
description: "美国 CN2 备用线路".to_string(),
response_time: None,
available: None,
},
PackycodeNode {
name: "备用2 (CF-Pro)".to_string(),
name: "公交车 CF-Pro".to_string(),
url: "https://api-cf-pro.packycode.com".to_string(),
node_type: NodeType::Backup,
description: "CloudFlare Pro 备用线路".to_string(),
response_time: None,
available: None,
},
// 紧急节点
PackycodeNode {
name: "测试节点1".to_string(),
url: "https://api-test.packyme.com".to_string(),
node_type: NodeType::Emergency,
description: "测试节点(非紧急情况勿用)".to_string(),
node_type: NodeType::Direct,
description: "CloudFlare Pro 线路(公交车)".to_string(),
response_time: None,
available: None,
},
PackycodeNode {
name: "测试节点2".to_string(),
url: "https://api-test-custom.packycode.com".to_string(),
node_type: NodeType::Emergency,
description: "自定义测试节点(非紧急情况勿用".to_string(),
name: "公交车 US-CN2".to_string(),
url: "https://api-us-cn2.packycode.com".to_string(),
node_type: NodeType::Direct,
description: "美国 CN2 线路(公交车".to_string(),
response_time: None,
available: None,
},
// 滴滴车节点 (Taxi Service)
PackycodeNode {
name: "滴滴车默认节点".to_string(),
url: "https://share-api.packycode.com".to_string(),
node_type: NodeType::Direct,
description: "默认滴滴车直连节点".to_string(),
response_time: None,
available: None,
},
PackycodeNode {
name: "测试节点3".to_string(),
url: "https://api-tmp-test.dzz.ai".to_string(),
node_type: NodeType::Emergency,
description: "临时测试节点(非紧急情况勿用".to_string(),
name: "滴滴车 HK-CN2".to_string(),
url: "https://share-api-hk-cn2.packycode.com".to_string(),
node_type: NodeType::Direct,
description: "香港 CN2 线路(滴滴车".to_string(),
response_time: None,
available: None,
},
PackycodeNode {
name: "滴滴车 HK-G".to_string(),
url: "https://share-api-hk-g.packycode.com".to_string(),
node_type: NodeType::Direct,
description: "香港 G 线路(滴滴车)".to_string(),
response_time: None,
available: None,
},
PackycodeNode {
name: "滴滴车 CF-Pro".to_string(),
url: "https://share-api-cf-pro.packycode.com".to_string(),
node_type: NodeType::Direct,
description: "CloudFlare Pro 线路(滴滴车)".to_string(),
response_time: None,
available: None,
},
PackycodeNode {
name: "滴滴车 US-CN2".to_string(),
url: "https://share-api-us-cn2.packycode.com".to_string(),
node_type: NodeType::Direct,
description: "美国 CN2 线路(滴滴车)".to_string(),
response_time: None,
available: None,
},
@@ -115,101 +115,39 @@ pub fn get_all_nodes() -> Vec<PackycodeNode> {
}
/// 测试单个节点速度(仅测试网络延时,不需要认证)
async fn test_node_speed(node: &PackycodeNode) -> NodeSpeedTestResult {
let client = Client::builder()
.timeout(Duration::from_secs(3)) // 减少超时时间
.danger_accept_invalid_certs(true) // 接受自签名证书
.build()
.unwrap_or_else(|_| Client::new());
let start_time = Instant::now();
// 使用 GET 请求到根路径,这是最简单的 ping 测试
// 不需要 token只测试网络延迟
#[allow(dead_code)]
async fn test_node_speed(node: &PackycodeNode) -> NodeTestResult {
let url = format!("{}/", node.url.trim_end_matches('/'));
match client
.get(&url)
.timeout(Duration::from_secs(3))
.send()
.await
{
Ok(_response) => {
let response_time = start_time.elapsed().as_millis() as u64;
// 只要能连接到服务器就算成功(不管状态码)
// 因为我们只是测试延迟,不是测试 API 功能
let success = response_time < 3000; // 小于 3 秒就算成功
NodeSpeedTestResult {
node: PackycodeNode {
response_time: Some(response_time),
available: Some(success),
..node.clone()
},
response_time,
success,
error: if success { None } else { Some("响应时间过长".to_string()) },
}
}
Err(e) => {
let response_time = start_time.elapsed().as_millis() as u64;
// 如果是超时错误,特别标记
let error_msg = if e.is_timeout() {
"连接超时".to_string()
} else if e.is_connect() {
"无法连接".to_string()
} else {
format!("网络错误: {}", e)
};
NodeSpeedTestResult {
node: PackycodeNode {
response_time: Some(response_time),
available: Some(false),
..node.clone()
},
response_time,
success: false,
error: Some(error_msg),
}
}
}
let mut result = node_tester::test_node_connectivity(&url, 3000).await;
// 添加节点名称
result.node_name = Some(node.name.clone());
result
}
/// 测试所有节点速度(不需要 token只测试延迟
#[command]
pub async fn test_all_packycode_nodes() -> Result<Vec<NodeSpeedTestResult>, String> {
pub async fn test_all_packycode_nodes() -> Result<Vec<NodeTestResult>, String> {
let nodes = get_all_nodes();
let mut results = Vec::new();
// 并发测试所有节点
let futures: Vec<_> = nodes
let urls: Vec<String> = nodes
.iter()
.map(|node| test_node_speed(node))
.map(|n| format!("{}/", n.url.trim_end_matches('/')))
.collect();
// 等待所有测试完成
for (i, future) in futures.into_iter().enumerate() {
let result = future.await;
log::info!("节点 {} 测速结果: {}ms, 成功: {}",
nodes[i].name,
result.response_time,
result.success
);
results.push(result);
}
// 按响应时间排序(成功的节点优先,然后按延迟排序)
results.sort_by(|a, b| {
match (a.success, b.success) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.response_time.cmp(&b.response_time),
// 使用公共批量测试
let mut results = node_tester::test_nodes_batch(urls, 3000).await;
// 添加节点名称
for (i, result) in results.iter_mut().enumerate() {
if let Some(node) = nodes.get(i) {
result.node_name = Some(node.name.clone());
}
});
}
// 按响应时间排序(成功的节点优先)
node_tester::sort_by_response_time(&mut results);
Ok(results)
}
@@ -217,60 +155,45 @@ pub async fn test_all_packycode_nodes() -> Result<Vec<NodeSpeedTestResult>, Stri
#[command]
pub async fn auto_select_best_node() -> Result<PackycodeNode, String> {
let nodes = get_all_nodes();
let mut best_node: Option<(PackycodeNode, u64)> = None;
// 只测试直连和备用节点,过滤掉紧急节点
let test_nodes: Vec<_> = nodes
.into_iter()
.filter(|n| matches!(n.node_type, NodeType::Direct | NodeType::Backup))
.collect();
log::info!("开始测试 {} 个节点...", test_nodes.len());
// 并发测试所有节点
let futures: Vec<_> = test_nodes
// 提取 URL 列表
let urls: Vec<String> = test_nodes
.iter()
.map(|node| test_node_speed(node))
.map(|n| format!("{}/", n.url.trim_end_matches('/')))
.collect();
// 收集结果并找出最佳节点
for (i, future) in futures.into_iter().enumerate() {
let result = future.await;
log::info!("节点 {} - 延迟: {}ms, 可用: {}",
test_nodes[i].name,
result.response_time,
result.success
// 使用公共批量测试
let results = node_tester::test_nodes_batch(urls, 3000).await;
// 查找最快的节点
if let Some(fastest) = node_tester::find_fastest_node(&results) {
// 根据 URL 找到对应的节点
let best_node = test_nodes
.into_iter()
.find(|n| {
let node_url = format!("{}/", n.url.trim_end_matches('/'));
node_url == fastest.url
})
.ok_or_else(|| "未找到匹配的节点".to_string())?;
log::info!(
"最佳节点选择: {} (延迟: {}ms)",
best_node.name,
fastest.response_time_ms.unwrap_or(0)
);
if result.success {
match &best_node {
None => {
log::info!("初始最佳节点: {}", result.node.name);
best_node = Some((result.node, result.response_time));
},
Some((_, best_time)) if result.response_time < *best_time => {
log::info!("发现更快节点: {} ({}ms < {}ms)",
result.node.name,
result.response_time,
best_time
);
best_node = Some((result.node, result.response_time));
}
_ => {}
}
}
}
match best_node {
Some((node, time)) => {
log::info!("最佳节点选择: {} (延迟: {}ms)", node.name, time);
Ok(node)
},
None => {
log::error!("没有找到可用的节点");
Err("没有找到可用的节点".to_string())
}
Ok(best_node)
} else {
log::error!("没有找到可用的节点");
Err("没有找到可用的节点".to_string())
}
}
@@ -278,4 +201,4 @@ pub async fn auto_select_best_node() -> Result<PackycodeNode, String> {
#[command]
pub fn get_packycode_nodes() -> Vec<PackycodeNode> {
get_all_nodes()
}
}

View File

@@ -0,0 +1,540 @@
use chrono::Utc;
use dirs;
use log::{error, info};
use rusqlite::{params, Connection, Result as SqliteResult, Row};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::{command, State};
use uuid::Uuid;
use crate::commands::agents::AgentDb;
/// 提示词文件
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromptFile {
pub id: String,
pub name: String,
pub description: Option<String>,
pub content: String,
pub tags: Vec<String>,
pub is_active: bool,
pub created_at: i64,
pub updated_at: i64,
pub last_used_at: Option<i64>,
pub display_order: i32,
}
/// 创建提示词文件请求
#[derive(Debug, Serialize, Deserialize)]
pub struct CreatePromptFileRequest {
pub name: String,
pub description: Option<String>,
pub content: String,
pub tags: Vec<String>,
}
/// 更新提示词文件请求
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdatePromptFileRequest {
pub id: String,
pub name: String,
pub description: Option<String>,
pub content: String,
pub tags: Vec<String>,
}
impl PromptFile {
pub fn from_row(row: &Row) -> Result<Self, rusqlite::Error> {
let tags_str: String = row.get("tags")?;
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(PromptFile {
id: row.get("id")?,
name: row.get("name")?,
description: row.get("description")?,
content: row.get("content")?,
tags,
is_active: row.get::<_, i32>("is_active")? == 1,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
last_used_at: row.get("last_used_at")?,
display_order: row.get("display_order")?,
})
}
}
/// 初始化提示词文件数据库表
pub fn init_prompt_files_tables(conn: &Connection) -> SqliteResult<()> {
conn.execute(
"CREATE TABLE IF NOT EXISTS prompt_files (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
content TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
is_active INTEGER DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_used_at INTEGER,
display_order INTEGER DEFAULT 0
)",
[],
)?;
// 创建索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_prompt_files_active ON prompt_files(is_active)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_prompt_files_name ON prompt_files(name)",
[],
)?;
info!("Prompt files tables initialized");
Ok(())
}
/// 列出所有提示词文件
#[command]
pub async fn prompt_files_list(db: State<'_, AgentDb>) -> Result<Vec<PromptFile>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT id, name, description, content, tags, is_active, created_at, updated_at,
last_used_at, display_order
FROM prompt_files
ORDER BY display_order ASC, created_at DESC",
)
.map_err(|e| e.to_string())?;
let files = stmt
.query_map([], |row| PromptFile::from_row(row))
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
Ok(files)
}
/// 获取单个提示词文件
#[command]
pub async fn prompt_file_get(id: String, db: State<'_, AgentDb>) -> Result<PromptFile, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let file = conn
.query_row(
"SELECT id, name, description, content, tags, is_active, created_at, updated_at,
last_used_at, display_order
FROM prompt_files
WHERE id = ?1",
params![id],
|row| PromptFile::from_row(row),
)
.map_err(|e| format!("提示词文件不存在: {}", e))?;
Ok(file)
}
/// 创建提示词文件
#[command]
pub async fn prompt_file_create(
request: CreatePromptFileRequest,
db: State<'_, AgentDb>,
) -> Result<PromptFile, String> {
info!("Creating prompt file: {}", request.name);
let id = {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// 检查名称是否已存在
let exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM prompt_files WHERE name = ?1",
params![request.name],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
if exists {
return Err(format!("提示词文件名称已存在: {}", request.name));
}
let id = Uuid::new_v4().to_string();
let now = Utc::now().timestamp();
let tags_json = serde_json::to_string(&request.tags).unwrap_or_else(|_| "[]".to_string());
// 获取当前最大 display_order
let max_order: i32 = conn
.query_row(
"SELECT COALESCE(MAX(display_order), 0) FROM prompt_files",
[],
|row| row.get(0),
)
.unwrap_or(0);
conn.execute(
"INSERT INTO prompt_files
(id, name, description, content, tags, is_active, created_at, updated_at, display_order)
VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?6, ?7)",
params![
id.clone(),
request.name,
request.description,
request.content,
tags_json,
now,
max_order + 1
],
)
.map_err(|e| format!("创建提示词文件失败: {}", e))?;
id
}; // conn is dropped here
prompt_file_get(id, db).await
}
/// 更新提示词文件
#[command]
pub async fn prompt_file_update(
request: UpdatePromptFileRequest,
db: State<'_, AgentDb>,
) -> Result<PromptFile, String> {
info!("Updating prompt file: {}", request.id);
let id = {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// 检查文件是否存在
let exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM prompt_files WHERE id = ?1",
params![request.id],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
if !exists {
return Err("提示词文件不存在".to_string());
}
// 检查名称冲突(排除自己)
let name_conflict: bool = conn
.query_row(
"SELECT COUNT(*) FROM prompt_files WHERE name = ?1 AND id != ?2",
params![request.name, request.id],
|row| {
let count: i32 = row.get(0)?;
Ok(count > 0)
},
)
.map_err(|e| e.to_string())?;
if name_conflict {
return Err(format!("提示词文件名称已存在: {}", request.name));
}
let now = Utc::now().timestamp();
let tags_json = serde_json::to_string(&request.tags).unwrap_or_else(|_| "[]".to_string());
conn.execute(
"UPDATE prompt_files
SET name = ?1, description = ?2, content = ?3, tags = ?4, updated_at = ?5
WHERE id = ?6",
params![
request.name,
request.description,
request.content,
tags_json,
now,
request.id.clone()
],
)
.map_err(|e| format!("更新提示词文件失败: {}", e))?;
request.id
}; // conn is dropped here
prompt_file_get(id, db).await
}
/// 删除提示词文件
#[command]
pub async fn prompt_file_delete(id: String, db: State<'_, AgentDb>) -> Result<(), String> {
info!("Deleting prompt file: {}", id);
let conn = db.0.lock().map_err(|e| e.to_string())?;
let deleted = conn
.execute("DELETE FROM prompt_files WHERE id = ?1", params![id])
.map_err(|e| format!("删除提示词文件失败: {}", e))?;
if deleted == 0 {
return Err("提示词文件不存在".to_string());
}
Ok(())
}
/// 获取 Claude 配置目录路径(~/.claude
fn get_claude_config_dir() -> Result<PathBuf, String> {
let home_dir = dirs::home_dir()
.ok_or_else(|| "无法获取主目录".to_string())?;
let claude_dir = home_dir.join(".claude");
// 确保目录存在
if !claude_dir.exists() {
fs::create_dir_all(&claude_dir)
.map_err(|e| format!("创建 .claude 目录失败: {}", e))?;
info!("创建 Claude 配置目录: {:?}", claude_dir);
}
Ok(claude_dir)
}
/// 应用提示词文件(替换本地 CLAUDE.md 或指定目标路径)
#[command]
pub async fn prompt_file_apply(
id: String,
target_path: Option<String>,
db: State<'_, AgentDb>,
) -> Result<String, String> {
info!("Applying prompt file: {} to {:?}", id, target_path);
// 1. 从数据库读取提示词文件
let file = prompt_file_get(id.clone(), db.clone()).await?;
// 2. 确定目标路径(兼容传入目录或文件)
// - 若传入目录:拼接 CLAUDE.md
// - 若传入文件:直接写入该文件
// - 若未传入:默认 ~/.claude/CLAUDE.md
let claude_md_path = if let Some(path) = target_path {
let p = PathBuf::from(path);
let is_dir = p.is_dir();
// 对于不存在路径,依据文件名扩展名进行语义判断
let looks_like_file = p
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.eq_ignore_ascii_case("claude.md") || n.to_lowercase().ends_with(".md"))
.unwrap_or(false);
if is_dir || (!looks_like_file && !p.exists()) {
// 目录(或看起来像目录的不存在路径)
p.join("CLAUDE.md")
} else {
// 明确的文件路径
p
}
} else {
// 默认使用 ~/.claude/CLAUDE.md和 settings.json 同目录)
get_claude_config_dir()?.join("CLAUDE.md")
};
// 确保父目录存在
if let Some(parent) = claude_md_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("创建目标目录失败: {}", e))?;
}
}
// 3. 备份现有文件(如果存在)- 使用时间戳避免触发文件监视
if claude_md_path.exists() {
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
let backup_path = claude_md_path.with_file_name(format!("CLAUDE.md.backup.{}", timestamp));
fs::copy(&claude_md_path, &backup_path)
.map_err(|e| format!("备份文件失败: {}", e))?;
info!("Backed up existing CLAUDE.md to {:?}", backup_path);
}
// 4. 写入新内容
fs::write(&claude_md_path, &file.content)
.map_err(|e| format!("写入文件失败: {}", e))?;
// 5. 更新数据库状态
let conn = db.0.lock().map_err(|e| e.to_string())?;
// 将所有文件的 is_active 设为 0
conn.execute("UPDATE prompt_files SET is_active = 0", [])
.map_err(|e| format!("更新激活状态失败: {}", e))?;
// 将当前文件设为激活并更新最后使用时间
let now = Utc::now().timestamp();
conn.execute(
"UPDATE prompt_files SET is_active = 1, last_used_at = ?1 WHERE id = ?2",
params![now, id],
)
.map_err(|e| format!("更新激活状态失败: {}", e))?;
info!("Applied prompt file to {:?}", claude_md_path);
Ok(claude_md_path.to_string_lossy().to_string())
}
/// 取消使用当前提示词文件
#[command]
pub async fn prompt_file_deactivate(db: State<'_, AgentDb>) -> Result<(), String> {
info!("Deactivating all prompt files");
let conn = db.0.lock().map_err(|e| e.to_string())?;
conn.execute("UPDATE prompt_files SET is_active = 0", [])
.map_err(|e| format!("取消激活失败: {}", e))?;
Ok(())
}
/// 从当前 CLAUDE.md 导入
#[command]
pub async fn prompt_file_import_from_claude_md(
name: String,
description: Option<String>,
source_path: Option<String>,
db: State<'_, AgentDb>,
) -> Result<PromptFile, String> {
info!("Importing from CLAUDE.md: {:?}", source_path);
// 1. 确定源文件路径
let claude_md_path = if let Some(path) = source_path {
PathBuf::from(path)
} else {
// 默认从 ~/.claude/CLAUDE.md 导入
get_claude_config_dir()?.join("CLAUDE.md")
};
// 2. 读取文件内容
if !claude_md_path.exists() {
return Err("CLAUDE.md 文件不存在".to_string());
}
let content = fs::read_to_string(&claude_md_path)
.map_err(|e| format!("读取文件失败: {}", e))?;
// 3. 自动提取标签(简单实现:从内容中提取关键词)
let tags = extract_tags_from_content(&content);
// 4. 创建提示词文件
let request = CreatePromptFileRequest {
name,
description,
content,
tags,
};
prompt_file_create(request, db).await
}
/// 从内容中提取标签(简单实现)
fn extract_tags_from_content(content: &str) -> Vec<String> {
let mut tags = Vec::new();
let content_lower = content.to_lowercase();
// 常见技术栈关键词
let keywords = [
"react",
"vue",
"angular",
"typescript",
"javascript",
"node",
"nodejs",
"express",
"nest",
"python",
"django",
"flask",
"rust",
"go",
"java",
"spring",
"frontend",
"backend",
"fullstack",
"api",
"rest",
"graphql",
"database",
"mongodb",
"postgresql",
"mysql",
"redis",
"docker",
"kubernetes",
"aws",
"testing",
"jest",
"vitest",
];
for keyword in keywords.iter() {
if content_lower.contains(keyword) {
tags.push(keyword.to_string());
}
}
// 限制标签数量
tags.truncate(10);
tags
}
/// 导出提示词文件
#[command]
pub async fn prompt_file_export(
id: String,
export_path: String,
db: State<'_, AgentDb>,
) -> Result<(), String> {
info!("Exporting prompt file {} to {}", id, export_path);
let file = prompt_file_get(id, db).await?;
fs::write(&export_path, &file.content)
.map_err(|e| format!("导出文件失败: {}", e))?;
Ok(())
}
/// 更新提示词文件排序
#[command]
pub async fn prompt_files_update_order(
ids: Vec<String>,
db: State<'_, AgentDb>,
) -> Result<(), String> {
info!("Updating prompt files order");
let conn = db.0.lock().map_err(|e| e.to_string())?;
for (index, id) in ids.iter().enumerate() {
conn.execute(
"UPDATE prompt_files SET display_order = ?1 WHERE id = ?2",
params![index as i32, id],
)
.map_err(|e| format!("更新排序失败: {}", e))?;
}
Ok(())
}
/// 批量导入提示词文件
#[command]
pub async fn prompt_files_import_batch(
files: Vec<CreatePromptFileRequest>,
db: State<'_, AgentDb>,
) -> Result<Vec<PromptFile>, String> {
info!("Batch importing {} prompt files", files.len());
let mut imported = Vec::new();
for request in files {
match prompt_file_create(request, db.clone()).await {
Ok(file) => imported.push(file),
Err(e) => error!("Failed to import file: {}", e),
}
}
Ok(imported)
}

View File

@@ -1,6 +1,6 @@
use rusqlite::params;
use serde::{Deserialize, Serialize};
use tauri::State;
use rusqlite::params;
use crate::commands::agents::AgentDb;
@@ -29,9 +29,9 @@ impl Default for ProxySettings {
#[tauri::command]
pub async fn get_proxy_settings(db: State<'_, AgentDb>) -> Result<ProxySettings, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let mut settings = ProxySettings::default();
// Query each proxy setting
let keys = vec![
("proxy_enabled", "enabled"),
@@ -40,7 +40,7 @@ pub async fn get_proxy_settings(db: State<'_, AgentDb>) -> Result<ProxySettings,
("proxy_no", "no_proxy"),
("proxy_all", "all_proxy"),
];
for (db_key, field) in keys {
if let Ok(value) = conn.query_row(
"SELECT value FROM app_settings WHERE key = ?1",
@@ -57,7 +57,7 @@ pub async fn get_proxy_settings(db: State<'_, AgentDb>) -> Result<ProxySettings,
}
}
}
Ok(settings)
}
@@ -68,33 +68,40 @@ pub async fn save_proxy_settings(
settings: ProxySettings,
) -> Result<(), String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// Save each setting
let values = vec![
("proxy_enabled", settings.enabled.to_string()),
("proxy_http", settings.http_proxy.clone().unwrap_or_default()),
("proxy_https", settings.https_proxy.clone().unwrap_or_default()),
(
"proxy_http",
settings.http_proxy.clone().unwrap_or_default(),
),
(
"proxy_https",
settings.https_proxy.clone().unwrap_or_default(),
),
("proxy_no", settings.no_proxy.clone().unwrap_or_default()),
("proxy_all", settings.all_proxy.clone().unwrap_or_default()),
];
for (key, value) in values {
conn.execute(
"INSERT OR REPLACE INTO app_settings (key, value) VALUES (?1, ?2)",
params![key, value],
).map_err(|e| format!("Failed to save {}: {}", key, e))?;
)
.map_err(|e| format!("Failed to save {}: {}", key, e))?;
}
// Apply the proxy settings immediately to the current process
apply_proxy_settings(&settings);
Ok(())
}
/// Apply proxy settings as environment variables
pub fn apply_proxy_settings(settings: &ProxySettings) {
log::info!("Applying proxy settings: enabled={}", settings.enabled);
if !settings.enabled {
// Clear proxy environment variables if disabled
log::info!("Clearing proxy environment variables");
@@ -109,7 +116,7 @@ pub fn apply_proxy_settings(settings: &ProxySettings) {
std::env::remove_var("all_proxy");
return;
}
// Ensure NO_PROXY includes localhost by default
let mut no_proxy_list = vec!["localhost", "127.0.0.1", "::1", "0.0.0.0"];
if let Some(user_no_proxy) = &settings.no_proxy {
@@ -118,7 +125,7 @@ pub fn apply_proxy_settings(settings: &ProxySettings) {
}
}
let no_proxy_value = no_proxy_list.join(",");
// Set proxy environment variables (uppercase is standard)
if let Some(http_proxy) = &settings.http_proxy {
if !http_proxy.is_empty() {
@@ -126,25 +133,25 @@ pub fn apply_proxy_settings(settings: &ProxySettings) {
std::env::set_var("HTTP_PROXY", http_proxy);
}
}
if let Some(https_proxy) = &settings.https_proxy {
if !https_proxy.is_empty() {
log::info!("Setting HTTPS_PROXY={}", https_proxy);
std::env::set_var("HTTPS_PROXY", https_proxy);
}
}
// Always set NO_PROXY to include localhost
log::info!("Setting NO_PROXY={}", no_proxy_value);
std::env::set_var("NO_PROXY", &no_proxy_value);
if let Some(all_proxy) = &settings.all_proxy {
if !all_proxy.is_empty() {
log::info!("Setting ALL_PROXY={}", all_proxy);
std::env::set_var("ALL_PROXY", all_proxy);
}
}
// Log current proxy environment variables for debugging
log::info!("Current proxy environment variables:");
for (key, value) in std::env::vars() {
@@ -152,4 +159,4 @@ pub fn apply_proxy_settings(settings: &ProxySettings) {
log::info!(" {}={}", key, value);
}
}
}
}

View File

@@ -1,24 +1,15 @@
use anyhow::Result;
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::time::Duration;
use tauri::{command, State};
use crate::commands::agents::AgentDb;
use crate::commands::relay_stations::{RelayStationAdapter, RelayStation};
use crate::commands::relay_stations::{RelayStation, RelayStationAdapter};
use crate::http_client;
use crate::i18n;
// 创建HTTP客户端的辅助函数
fn create_http_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(10))
.build()
.expect("Failed to create HTTP client")
}
/// 中转站信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StationInfo {
@@ -89,25 +80,47 @@ pub struct TokenPaginationResponse {
pub trait StationAdapter: Send + Sync {
/// 获取中转站信息
async fn get_station_info(&self, station: &RelayStation) -> Result<StationInfo>;
/// 获取用户信息
async fn get_user_info(&self, station: &RelayStation, user_id: &str) -> Result<UserInfo>;
/// 测试连接
async fn test_connection(&self, station: &RelayStation) -> Result<ConnectionTestResult>;
/// 获取使用日志
async fn get_usage_logs(&self, station: &RelayStation, user_id: &str, page: Option<usize>, size: Option<usize>) -> Result<Value>;
async fn get_usage_logs(
&self,
station: &RelayStation,
user_id: &str,
page: Option<usize>,
size: Option<usize>,
) -> Result<Value>;
/// 列出 Tokens
async fn list_tokens(&self, station: &RelayStation, page: Option<usize>, size: Option<usize>) -> Result<TokenPaginationResponse>;
async fn list_tokens(
&self,
station: &RelayStation,
page: Option<usize>,
size: Option<usize>,
) -> Result<TokenPaginationResponse>;
/// 创建 Token
async fn create_token(&self, station: &RelayStation, name: &str, quota: Option<i64>) -> Result<TokenInfo>;
async fn create_token(
&self,
station: &RelayStation,
name: &str,
quota: Option<i64>,
) -> Result<TokenInfo>;
/// 更新 Token
async fn update_token(&self, station: &RelayStation, token_id: &str, name: Option<&str>, quota: Option<i64>) -> Result<TokenInfo>;
async fn update_token(
&self,
station: &RelayStation,
token_id: &str,
name: Option<&str>,
quota: Option<i64>,
) -> Result<TokenInfo>;
/// 删除 Token
async fn delete_token(&self, station: &RelayStation, token_id: &str) -> Result<String>;
}
@@ -120,8 +133,9 @@ impl StationAdapter for PackycodeAdapter {
async fn get_station_info(&self, station: &RelayStation) -> Result<StationInfo> {
// PackyCode 使用简单的健康检查端点
let url = format!("{}/health", station.api_url.trim_end_matches('/'));
let client = create_http_client();
let client = http_client::default_client()
.map_err(|e| anyhow::anyhow!("创建 HTTP 客户端失败: {}", e))?;
let response = client
.get(&url)
.header("X-API-Key", &station.system_token)
@@ -137,7 +151,10 @@ impl StationAdapter for PackycodeAdapter {
metadata: Some({
let mut map = HashMap::new();
map.insert("adapter_type".to_string(), json!("packycode"));
map.insert("support_features".to_string(), json!(["quota_query", "usage_stats"]));
map.insert(
"support_features".to_string(),
json!(["quota_query", "usage_stats"]),
);
map
}),
quota_per_unit: Some(1),
@@ -150,8 +167,9 @@ impl StationAdapter for PackycodeAdapter {
async fn get_user_info(&self, station: &RelayStation, _user_id: &str) -> Result<UserInfo> {
// PackyCode 用户信息获取
let url = format!("{}/user/info", station.api_url.trim_end_matches('/'));
let client = create_http_client();
let client = http_client::default_client()
.map_err(|e| anyhow::anyhow!("创建 HTTP 客户端失败: {}", e))?;
let response = client
.get(&url)
.header("X-API-Key", &station.system_token)
@@ -159,24 +177,23 @@ impl StationAdapter for PackycodeAdapter {
.await?;
let data: Value = response.json().await?;
Ok(UserInfo {
id: "packycode_user".to_string(),
username: data.get("username")
username: data
.get("username")
.and_then(|v| v.as_str())
.unwrap_or("PackyCode用户")
.to_string(),
display_name: Some("PackyCode用户".to_string()),
email: data.get("email")
email: data
.get("email")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
quota: data.get("quota")
.and_then(|v| v.as_i64())
.unwrap_or(0),
used_quota: data.get("used_quota")
.and_then(|v| v.as_i64())
.unwrap_or(0),
request_count: data.get("request_count")
quota: data.get("quota").and_then(|v| v.as_i64()).unwrap_or(0),
used_quota: data.get("used_quota").and_then(|v| v.as_i64()).unwrap_or(0),
request_count: data
.get("request_count")
.and_then(|v| v.as_i64())
.unwrap_or(0),
group: "default".to_string(),
@@ -186,7 +203,7 @@ impl StationAdapter for PackycodeAdapter {
async fn test_connection(&self, station: &RelayStation) -> Result<ConnectionTestResult> {
let start_time = std::time::Instant::now();
match self.get_station_info(station).await {
Ok(info) => {
let response_time = start_time.elapsed().as_millis() as u64;
@@ -194,8 +211,10 @@ impl StationAdapter for PackycodeAdapter {
success: true,
response_time,
message: format!("{} - 连接成功", info.name),
details: Some(format!("服务版本: {}",
info.version.unwrap_or_else(|| "Unknown".to_string()))),
details: Some(format!(
"服务版本: {}",
info.version.unwrap_or_else(|| "Unknown".to_string())
)),
})
}
Err(e) => {
@@ -210,7 +229,13 @@ impl StationAdapter for PackycodeAdapter {
}
}
async fn get_usage_logs(&self, _station: &RelayStation, _user_id: &str, _page: Option<usize>, _size: Option<usize>) -> Result<Value> {
async fn get_usage_logs(
&self,
_station: &RelayStation,
_user_id: &str,
_page: Option<usize>,
_size: Option<usize>,
) -> Result<Value> {
// PackyCode 暂不支持详细使用日志
Ok(json!({
"logs": [],
@@ -218,21 +243,45 @@ impl StationAdapter for PackycodeAdapter {
}))
}
async fn list_tokens(&self, _station: &RelayStation, _page: Option<usize>, _size: Option<usize>) -> Result<TokenPaginationResponse> {
async fn list_tokens(
&self,
_station: &RelayStation,
_page: Option<usize>,
_size: Option<usize>,
) -> Result<TokenPaginationResponse> {
// PackyCode 使用单一 Token不支持多 Token 管理
Err(anyhow::anyhow!(i18n::t("relay_adapter.packycode_single_token")))
Err(anyhow::anyhow!(i18n::t(
"relay_adapter.packycode_single_token"
)))
}
async fn create_token(&self, _station: &RelayStation, _name: &str, _quota: Option<i64>) -> Result<TokenInfo> {
Err(anyhow::anyhow!(i18n::t("relay_adapter.packycode_single_token")))
async fn create_token(
&self,
_station: &RelayStation,
_name: &str,
_quota: Option<i64>,
) -> Result<TokenInfo> {
Err(anyhow::anyhow!(i18n::t(
"relay_adapter.packycode_single_token"
)))
}
async fn update_token(&self, _station: &RelayStation, _token_id: &str, _name: Option<&str>, _quota: Option<i64>) -> Result<TokenInfo> {
Err(anyhow::anyhow!(i18n::t("relay_adapter.packycode_single_token")))
async fn update_token(
&self,
_station: &RelayStation,
_token_id: &str,
_name: Option<&str>,
_quota: Option<i64>,
) -> Result<TokenInfo> {
Err(anyhow::anyhow!(i18n::t(
"relay_adapter.packycode_single_token"
)))
}
async fn delete_token(&self, _station: &RelayStation, _token_id: &str) -> Result<String> {
Err(anyhow::anyhow!(i18n::t("relay_adapter.packycode_single_token")))
Err(anyhow::anyhow!(i18n::t(
"relay_adapter.packycode_single_token"
)))
}
}
@@ -272,63 +321,90 @@ impl StationAdapter for CustomAdapter {
async fn test_connection(&self, station: &RelayStation) -> Result<ConnectionTestResult> {
let start_time = std::time::Instant::now();
// 尝试简单的 GET 请求测试连接
let client = create_http_client();
let client = http_client::create_client(
http_client::ClientConfig::new().timeout(5)
).map_err(|e| anyhow::anyhow!("创建 HTTP 客户端失败: {}", e))?;
let response = client
.get(&station.api_url)
.header("Authorization", format!("Bearer {}", station.system_token))
.timeout(Duration::from_secs(5))
.send()
.await;
let response_time = start_time.elapsed().as_millis() as u64;
match response {
Ok(resp) => {
Ok(ConnectionTestResult {
success: resp.status().is_success(),
response_time,
message: if resp.status().is_success() {
format!("{} - 连接成功", station.name)
} else {
format!("HTTP {}: 服务器响应错误", resp.status())
},
details: Some(format!("响应状态: {}", resp.status())),
})
}
Err(e) => {
Ok(ConnectionTestResult {
success: false,
response_time,
message: format!("连接失败: {}", e),
details: None,
})
}
Ok(resp) => Ok(ConnectionTestResult {
success: resp.status().is_success(),
response_time,
message: if resp.status().is_success() {
format!("{} - 连接成功", station.name)
} else {
format!("HTTP {}: 服务器响应错误", resp.status())
},
details: Some(format!("响应状态: {}", resp.status())),
}),
Err(e) => Ok(ConnectionTestResult {
success: false,
response_time,
message: format!("连接失败: {}", e),
details: None,
}),
}
}
async fn get_usage_logs(&self, _station: &RelayStation, _user_id: &str, _page: Option<usize>, _size: Option<usize>) -> Result<Value> {
async fn get_usage_logs(
&self,
_station: &RelayStation,
_user_id: &str,
_page: Option<usize>,
_size: Option<usize>,
) -> Result<Value> {
Ok(json!({
"logs": [],
"message": "自定义适配器暂不支持使用日志查询"
}))
}
async fn list_tokens(&self, _station: &RelayStation, _page: Option<usize>, _size: Option<usize>) -> Result<TokenPaginationResponse> {
Err(anyhow::anyhow!(i18n::t("relay_adapter.token_management_not_available")))
async fn list_tokens(
&self,
_station: &RelayStation,
_page: Option<usize>,
_size: Option<usize>,
) -> Result<TokenPaginationResponse> {
Err(anyhow::anyhow!(i18n::t(
"relay_adapter.token_management_not_available"
)))
}
async fn create_token(&self, _station: &RelayStation, _name: &str, _quota: Option<i64>) -> Result<TokenInfo> {
Err(anyhow::anyhow!(i18n::t("relay_adapter.token_management_not_available")))
async fn create_token(
&self,
_station: &RelayStation,
_name: &str,
_quota: Option<i64>,
) -> Result<TokenInfo> {
Err(anyhow::anyhow!(i18n::t(
"relay_adapter.token_management_not_available"
)))
}
async fn update_token(&self, _station: &RelayStation, _token_id: &str, _name: Option<&str>, _quota: Option<i64>) -> Result<TokenInfo> {
Err(anyhow::anyhow!(i18n::t("relay_adapter.token_management_not_available")))
async fn update_token(
&self,
_station: &RelayStation,
_token_id: &str,
_name: Option<&str>,
_quota: Option<i64>,
) -> Result<TokenInfo> {
Err(anyhow::anyhow!(i18n::t(
"relay_adapter.token_management_not_available"
)))
}
async fn delete_token(&self, _station: &RelayStation, _token_id: &str) -> Result<String> {
Err(anyhow::anyhow!(i18n::t("relay_adapter.token_management_not_available")))
Err(anyhow::anyhow!(i18n::t(
"relay_adapter.token_management_not_available"
)))
}
}
@@ -349,20 +425,19 @@ pub fn create_adapter(adapter_type: &RelayStationAdapter) -> Box<dyn StationAdap
#[command]
pub async fn relay_station_get_info(
station_id: String,
db: State<'_, AgentDb>
db: State<'_, AgentDb>,
) -> Result<StationInfo, String> {
// 获取中转站配置
let station = crate::commands::relay_stations::relay_station_get(station_id, db).await?;
// 创建适配器
let adapter = create_adapter(&station.adapter);
// 获取站点信息
adapter.get_station_info(&station).await
.map_err(|e| {
log::error!("Failed to get station info: {}", e);
i18n::t("relay_adapter.get_info_failed")
})
adapter.get_station_info(&station).await.map_err(|e| {
log::error!("Failed to get station info: {}", e);
i18n::t("relay_adapter.get_info_failed")
})
}
/// 获取用户信息
@@ -370,12 +445,14 @@ pub async fn relay_station_get_info(
pub async fn relay_station_get_user_info(
station_id: String,
user_id: String,
db: State<'_, AgentDb>
db: State<'_, AgentDb>,
) -> Result<UserInfo, String> {
let station = crate::commands::relay_stations::relay_station_get(station_id, db).await?;
let adapter = create_adapter(&station.adapter);
adapter.get_user_info(&station, &user_id).await
adapter
.get_user_info(&station, &user_id)
.await
.map_err(|e| {
log::error!("Failed to get user info: {}", e);
i18n::t("relay_adapter.get_user_info_failed")
@@ -386,16 +463,15 @@ pub async fn relay_station_get_user_info(
#[command]
pub async fn relay_station_test_connection(
station_id: String,
db: State<'_, AgentDb>
db: State<'_, AgentDb>,
) -> Result<ConnectionTestResult, String> {
let station = crate::commands::relay_stations::relay_station_get(station_id, db).await?;
let adapter = create_adapter(&station.adapter);
adapter.test_connection(&station).await
.map_err(|e| {
log::error!("Connection test failed: {}", e);
i18n::t("relay_adapter.connection_test_failed")
})
adapter.test_connection(&station).await.map_err(|e| {
log::error!("Connection test failed: {}", e);
i18n::t("relay_adapter.connection_test_failed")
})
}
/// 获取使用日志
@@ -405,12 +481,14 @@ pub async fn relay_station_get_usage_logs(
user_id: String,
page: Option<usize>,
size: Option<usize>,
db: State<'_, AgentDb>
db: State<'_, AgentDb>,
) -> Result<Value, String> {
let station = crate::commands::relay_stations::relay_station_get(station_id, db).await?;
let adapter = create_adapter(&station.adapter);
adapter.get_usage_logs(&station, &user_id, page, size).await
adapter
.get_usage_logs(&station, &user_id, page, size)
.await
.map_err(|e| {
log::error!("Failed to get usage logs: {}", e);
i18n::t("relay_adapter.get_usage_logs_failed")
@@ -423,12 +501,14 @@ pub async fn relay_station_list_tokens(
station_id: String,
page: Option<usize>,
size: Option<usize>,
db: State<'_, AgentDb>
db: State<'_, AgentDb>,
) -> Result<TokenPaginationResponse, String> {
let station = crate::commands::relay_stations::relay_station_get(station_id, db).await?;
let adapter = create_adapter(&station.adapter);
adapter.list_tokens(&station, page, size).await
adapter
.list_tokens(&station, page, size)
.await
.map_err(|e| {
log::error!("Failed to list tokens: {}", e);
i18n::t("relay_adapter.list_tokens_failed")
@@ -441,12 +521,14 @@ pub async fn relay_station_create_token(
station_id: String,
name: String,
quota: Option<i64>,
db: State<'_, AgentDb>
db: State<'_, AgentDb>,
) -> Result<TokenInfo, String> {
let station = crate::commands::relay_stations::relay_station_get(station_id, db).await?;
let adapter = create_adapter(&station.adapter);
adapter.create_token(&station, &name, quota).await
adapter
.create_token(&station, &name, quota)
.await
.map_err(|e| {
log::error!("Failed to create token: {}", e);
i18n::t("relay_adapter.create_token_failed")
@@ -460,12 +542,14 @@ pub async fn relay_station_update_token(
token_id: String,
name: Option<String>,
quota: Option<i64>,
db: State<'_, AgentDb>
db: State<'_, AgentDb>,
) -> Result<TokenInfo, String> {
let station = crate::commands::relay_stations::relay_station_get(station_id, db).await?;
let adapter = create_adapter(&station.adapter);
adapter.update_token(&station, &token_id, name.as_deref(), quota).await
adapter
.update_token(&station, &token_id, name.as_deref(), quota)
.await
.map_err(|e| {
log::error!("Failed to update token: {}", e);
i18n::t("relay_adapter.update_token_failed")
@@ -477,12 +561,14 @@ pub async fn relay_station_update_token(
pub async fn relay_station_delete_token(
station_id: String,
token_id: String,
db: State<'_, AgentDb>
db: State<'_, AgentDb>,
) -> Result<String, String> {
let station = crate::commands::relay_stations::relay_station_get(station_id, db).await?;
let adapter = create_adapter(&station.adapter);
adapter.delete_token(&station, &token_id).await
adapter
.delete_token(&station, &token_id)
.await
.map_err(|e| {
log::error!("Failed to delete token: {}", e);
i18n::t("relay_adapter.delete_token_failed")
@@ -493,7 +579,7 @@ pub async fn relay_station_delete_token(
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackycodeUserQuota {
pub daily_budget_usd: f64, // 日预算(美元)
pub daily_spent_usd: f64, // 日已使用(美元)
pub daily_spent_usd: f64, // 日已使用(美元)
pub monthly_budget_usd: f64, // 月预算(美元)
pub monthly_spent_usd: f64, // 月已使用(美元)
pub balance_usd: f64, // 账户余额(美元)
@@ -509,32 +595,31 @@ pub struct PackycodeUserQuota {
#[command]
pub async fn packycode_get_user_quota(
station_id: String,
db: State<'_, AgentDb>
db: State<'_, AgentDb>,
) -> Result<PackycodeUserQuota, String> {
let station = crate::commands::relay_stations::relay_station_get(station_id, db).await
let station = crate::commands::relay_stations::relay_station_get(station_id, db)
.await
.map_err(|e| format!("Failed to get station: {}", e))?;
if station.adapter.as_str() != "packycode" {
return Err("此功能仅支持 PackyCode 中转站".to_string());
}
// 根据服务类型构建不同的 URL
let url = if station.api_url.contains("share-api") || station.api_url.contains("share.packycode") {
// 滴滴车服务
"https://share.packycode.com/api/backend/users/info"
} else {
// 公交车服务
"https://www.packycode.com/api/backend/users/info"
};
let client = Client::builder()
.timeout(Duration::from_secs(30))
.no_proxy() // 禁用所有代理
.build()
let url =
if station.api_url.contains("share-api") || station.api_url.contains("share.packycode") {
// 滴滴车服务
"https://share.packycode.com/api/backend/users/info"
} else {
// 公交车服务
"https://www.packycode.com/api/backend/users/info"
};
let client = http_client::secure_client()
.map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?;
log::info!("正在请求 PackyCode 用户信息: {}", url);
let response = client
.get(url)
.header("Authorization", format!("Bearer {}", station.system_token))
@@ -564,15 +649,19 @@ pub async fn packycode_get_user_quota(
});
}
let data: Value = response.json().await
let data: Value = response
.json()
.await
.map_err(|e| format!("解析响应失败: {}", e))?;
// 辅助函数:将值转换为 f64
let to_f64 = |v: &Value| -> f64 {
if v.is_null() {
0.0
} else if v.is_string() {
v.as_str().and_then(|s| s.parse::<f64>().ok()).unwrap_or(0.0)
v.as_str()
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0)
} else if v.is_f64() {
v.as_f64().unwrap_or(0.0)
} else if v.is_i64() {
@@ -581,7 +670,7 @@ pub async fn packycode_get_user_quota(
0.0
}
};
Ok(PackycodeUserQuota {
daily_budget_usd: to_f64(data.get("daily_budget_usd").unwrap_or(&Value::Null)),
daily_spent_usd: to_f64(data.get("daily_spent_usd").unwrap_or(&Value::Null)),
@@ -589,20 +678,23 @@ pub async fn packycode_get_user_quota(
monthly_spent_usd: to_f64(data.get("monthly_spent_usd").unwrap_or(&Value::Null)),
balance_usd: to_f64(data.get("balance_usd").unwrap_or(&Value::Null)),
total_spent_usd: to_f64(data.get("total_spent_usd").unwrap_or(&Value::Null)),
plan_type: data.get("plan_type")
plan_type: data
.get("plan_type")
.and_then(|v| v.as_str())
.unwrap_or("basic")
.to_string(),
plan_expires_at: data.get("plan_expires_at")
plan_expires_at: data
.get("plan_expires_at")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
username: data.get("username")
username: data
.get("username")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
email: data.get("email")
email: data
.get("email")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
opus_enabled: data.get("opus_enabled")
.and_then(|v| v.as_bool()),
opus_enabled: data.get("opus_enabled").and_then(|v| v.as_bool()),
})
}
}

View File

@@ -1,14 +1,14 @@
use anyhow::Result;
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension, Row};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tauri::{command, State};
use anyhow::Result;
use chrono::Utc;
use rusqlite::{params, Connection, Row, OptionalExtension};
use uuid::Uuid;
use crate::claude_config;
use crate::commands::agents::AgentDb;
use crate::i18n;
use crate::claude_config;
/// 中转站适配器类型
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -39,26 +39,27 @@ impl RelayStationAdapter {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthMethod {
BearerToken, // Bearer Token 认证(推荐)
ApiKey, // API Key 认证
Custom, // 自定义认证方式
BearerToken, // Bearer Token 认证(推荐)
ApiKey, // API Key 认证
Custom, // 自定义认证方式
}
/// 中转站配置(完整版本)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayStation {
pub id: String, // 唯一标识符
pub name: String, // 显示名称
pub description: Option<String>, // 描述信息
pub api_url: String, // API 基础 URL
pub adapter: RelayStationAdapter, // 适配器类型
pub auth_method: AuthMethod, // 认证方式
pub system_token: String, // 系统令牌
pub user_id: Option<String>, // 用户 ID可选
pub id: String, // 唯一标识符
pub name: String, // 显示名称
pub description: Option<String>, // 描述信息
pub api_url: String, // API 基础 URL
pub adapter: RelayStationAdapter, // 适配器类型
pub auth_method: AuthMethod, // 认证方式
pub system_token: String, // 系统令牌
pub user_id: Option<String>, // 用户 ID可选
pub adapter_config: Option<HashMap<String, serde_json::Value>>, // 适配器特定配置
pub enabled: bool, // 启用状态
pub created_at: i64, // 创建时间
pub updated_at: i64, // 更新时间
pub enabled: bool, // 启用状态
pub display_order: i32, // 显示顺序
pub created_at: i64, // 创建时间
pub updated_at: i64, // 更新时间
}
/// 创建中转站请求(无自动生成字段)
@@ -93,34 +94,34 @@ pub struct UpdateRelayStationRequest {
/// 站点信息(统一格式)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StationInfo {
pub name: String, // 站点名称
pub announcement: Option<String>, // 公告信息
pub api_url: String, // API 地址
pub version: Option<String>, // 版本信息
pub metadata: Option<HashMap<String, serde_json::Value>>, // 扩展元数据
pub quota_per_unit: Option<i64>, // 单位配额(用于价格转换)
pub name: String, // 站点名称
pub announcement: Option<String>, // 公告信息
pub api_url: String, // API 地址
pub version: Option<String>, // 版本信息
pub metadata: Option<HashMap<String, serde_json::Value>>, // 扩展元数据
pub quota_per_unit: Option<i64>, // 单位配额(用于价格转换)
}
/// 用户信息(统一格式)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserInfo {
pub user_id: String, // 用户 ID
pub username: Option<String>, // 用户名
pub email: Option<String>, // 邮箱
pub balance_remaining: Option<f64>, // 剩余余额(美元)
pub amount_used: Option<f64>, // 已用金额(美元)
pub request_count: Option<i64>, // 请求次数
pub status: Option<String>, // 账户状态
pub metadata: Option<HashMap<String, serde_json::Value>>, // 原始数据
pub user_id: String, // 用户 ID
pub username: Option<String>, // 用户名
pub email: Option<String>, // 邮箱
pub balance_remaining: Option<f64>, // 剩余余额(美元)
pub amount_used: Option<f64>, // 已用金额(美元)
pub request_count: Option<i64>, // 请求次数
pub status: Option<String>, // 账户状态
pub metadata: Option<HashMap<String, serde_json::Value>>, // 原始数据
}
/// 连接测试结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionTestResult {
pub success: bool, // 连接是否成功
pub response_time: Option<u64>, // 响应时间(毫秒)
pub message: String, // 结果消息
pub error: Option<String>, // 错误信息
pub success: bool, // 连接是否成功
pub response_time: Option<u64>, // 响应时间(毫秒)
pub message: String, // 结果消息
pub error: Option<String>, // 错误信息
}
/// Token 信息
@@ -152,18 +153,34 @@ impl RelayStation {
let auth_method_str: String = row.get("auth_method")?;
let adapter_config_str: Option<String> = row.get("adapter_config")?;
let adapter = serde_json::from_str(&format!("\"{}\"", adapter_str))
.map_err(|_| rusqlite::Error::InvalidColumnType(0, "adapter".to_string(), rusqlite::types::Type::Text))?;
let auth_method = serde_json::from_str(&format!("\"{}\"", auth_method_str))
.map_err(|_| rusqlite::Error::InvalidColumnType(0, "auth_method".to_string(), rusqlite::types::Type::Text))?;
let adapter = serde_json::from_str(&format!("\"{}\"", adapter_str)).map_err(|_| {
rusqlite::Error::InvalidColumnType(
0,
"adapter".to_string(),
rusqlite::types::Type::Text,
)
})?;
let auth_method =
serde_json::from_str(&format!("\"{}\"", auth_method_str)).map_err(|_| {
rusqlite::Error::InvalidColumnType(
0,
"auth_method".to_string(),
rusqlite::types::Type::Text,
)
})?;
let adapter_config = if let Some(config_str) = adapter_config_str {
if config_str.trim().is_empty() {
None
} else {
Some(serde_json::from_str(&config_str)
.map_err(|_| rusqlite::Error::InvalidColumnType(0, "adapter_config".to_string(), rusqlite::types::Type::Text))?)
Some(serde_json::from_str(&config_str).map_err(|_| {
rusqlite::Error::InvalidColumnType(
0,
"adapter_config".to_string(),
rusqlite::types::Type::Text,
)
})?)
}
} else {
None
@@ -180,6 +197,7 @@ impl RelayStation {
user_id: row.get("user_id")?,
adapter_config,
enabled: row.get::<_, i32>("enabled")? == 1,
display_order: row.get::<_, i32>("display_order").unwrap_or(0),
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
})
@@ -202,6 +220,7 @@ pub fn init_relay_stations_tables(conn: &Connection) -> Result<()> {
user_id TEXT,
adapter_config TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
display_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
@@ -244,13 +263,21 @@ pub async fn relay_stations_list(db: State<'_, AgentDb>) -> Result<Vec<RelayStat
i18n::t("database.init_failed")
})?;
let mut stmt = conn.prepare("SELECT * FROM relay_stations ORDER BY created_at DESC")
// 添加 display_order 列(如果不存在)
let _ = conn.execute(
"ALTER TABLE relay_stations ADD COLUMN display_order INTEGER NOT NULL DEFAULT 0",
[],
);
let mut stmt = conn
.prepare("SELECT * FROM relay_stations ORDER BY display_order ASC, 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))
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")
@@ -267,22 +294,21 @@ pub async fn relay_stations_list(db: State<'_, AgentDb>) -> Result<Vec<RelayStat
/// 获取单个中转站
#[command]
pub async fn relay_station_get(
id: String,
db: State<'_, AgentDb>
) -> Result<RelayStation, String> {
pub async fn relay_station_get(id: String, db: State<'_, AgentDb>) -> Result<RelayStation, String> {
let conn = db.0.lock().map_err(|e| {
log::error!("Failed to acquire database lock: {}", e);
i18n::t("database.lock_failed")
})?;
let mut stmt = conn.prepare("SELECT * FROM relay_stations WHERE id = ?1")
let mut stmt = conn
.prepare("SELECT * FROM relay_stations WHERE id = ?1")
.map_err(|e| {
log::error!("Failed to prepare statement: {}", e);
i18n::t("database.query_failed")
})?;
let station = stmt.query_row(params![id], |row| RelayStation::from_row(row))
let station = stmt
.query_row(params![id], |row| RelayStation::from_row(row))
.map_err(|e| {
log::error!("Failed to get relay station {}: {}", id, e);
i18n::t("relay_station.not_found")
@@ -296,7 +322,7 @@ pub async fn relay_station_get(
#[command]
pub async fn relay_station_create(
request: CreateRelayStationRequest,
db: State<'_, AgentDb>
db: State<'_, AgentDb>,
) -> Result<RelayStation, String> {
let conn = db.0.lock().map_err(|e| {
log::error!("Failed to acquire database lock: {}", e);
@@ -317,26 +343,33 @@ pub async fn relay_station_create(
let adapter_str = serde_json::to_string(&request.adapter)
.map_err(|_| i18n::t("relay_station.invalid_adapter"))?
.trim_matches('"').to_string();
.trim_matches('"')
.to_string();
let auth_method_str = serde_json::to_string(&request.auth_method)
.map_err(|_| i18n::t("relay_station.invalid_auth_method"))?
.trim_matches('"').to_string();
.trim_matches('"')
.to_string();
let adapter_config_str = request.adapter_config.as_ref()
// 记录adapter_config日志
log::info!("[CREATE] Received adapter_config: {:?}", request.adapter_config);
let adapter_config_str = request
.adapter_config
.as_ref()
.map(|config| serde_json::to_string(config))
.transpose()
.map_err(|_| i18n::t("relay_station.invalid_config"))?;
log::info!("[CREATE] Serialized adapter_config_str: {:?}", adapter_config_str);
// 如果要启用这个新中转站,先禁用所有其他中转站
if request.enabled {
conn.execute(
"UPDATE relay_stations SET enabled = 0",
[],
).map_err(|e| {
log::error!("Failed to disable other relay stations: {}", e);
i18n::t("relay_station.create_failed")
})?;
conn.execute("UPDATE relay_stations SET enabled = 0", [])
.map_err(|e| {
log::error!("Failed to disable other relay stations: {}", e);
i18n::t("relay_station.create_failed")
})?;
}
conn.execute(
@@ -375,11 +408,13 @@ pub async fn relay_station_create(
user_id: request.user_id,
adapter_config: request.adapter_config,
enabled: request.enabled,
display_order: 0,
created_at: now,
updated_at: now,
};
log::info!("Created relay station: {} ({})", station.name, id);
log::info!("[CREATE] Created relay station: {} ({})", station.name, id);
log::info!("[CREATE] Final station.adapter_config: {:?}", station.adapter_config);
Ok(station)
}
@@ -387,7 +422,7 @@ pub async fn relay_station_create(
#[command]
pub async fn relay_station_update(
request: UpdateRelayStationRequest,
db: State<'_, AgentDb>
db: State<'_, AgentDb>,
) -> Result<RelayStation, String> {
let conn = db.0.lock().map_err(|e| {
log::error!("Failed to acquire database lock: {}", e);
@@ -401,52 +436,64 @@ pub async fn relay_station_update(
let adapter_str = serde_json::to_string(&request.adapter)
.map_err(|_| i18n::t("relay_station.invalid_adapter"))?
.trim_matches('"').to_string();
.trim_matches('"')
.to_string();
let auth_method_str = serde_json::to_string(&request.auth_method)
.map_err(|_| i18n::t("relay_station.invalid_auth_method"))?
.trim_matches('"').to_string();
.trim_matches('"')
.to_string();
let adapter_config_str = request.adapter_config.as_ref()
// 记录adapter_config日志
log::info!("[UPDATE] Received adapter_config: {:?}", request.adapter_config);
let adapter_config_str = request
.adapter_config
.as_ref()
.map(|config| serde_json::to_string(config))
.transpose()
.map_err(|_| i18n::t("relay_station.invalid_config"))?;
log::info!("[UPDATE] Serialized adapter_config_str: {:?}", adapter_config_str);
// 如果要启用这个中转站,先禁用所有其他中转站
if request.enabled {
conn.execute(
"UPDATE relay_stations SET enabled = 0 WHERE id != ?1",
params![request.id],
).map_err(|e| {
)
.map_err(|e| {
log::error!("Failed to disable other relay stations: {}", e);
i18n::t("relay_station.update_failed")
})?;
}
let rows_affected = conn.execute(
r#"
let rows_affected = conn
.execute(
r#"
UPDATE relay_stations
SET name = ?2, description = ?3, api_url = ?4, adapter = ?5, auth_method = ?6,
system_token = ?7, user_id = ?8, adapter_config = ?9, enabled = ?10, updated_at = ?11
WHERE id = ?1
"#,
params![
request.id,
request.name,
request.description,
request.api_url,
adapter_str,
auth_method_str,
request.system_token,
request.user_id,
adapter_config_str,
if request.enabled { 1 } else { 0 },
now
],
).map_err(|e| {
log::error!("Failed to update relay station: {}", e);
i18n::t("relay_station.update_failed")
})?;
params![
request.id,
request.name,
request.description,
request.api_url,
adapter_str,
auth_method_str,
request.system_token,
request.user_id,
adapter_config_str,
if request.enabled { 1 } else { 0 },
now
],
)
.map_err(|e| {
log::error!("Failed to update relay station: {}", e);
i18n::t("relay_station.update_failed")
})?;
if rows_affected == 0 {
return Err(i18n::t("relay_station.not_found"));
@@ -463,26 +510,26 @@ pub async fn relay_station_update(
user_id: request.user_id,
adapter_config: request.adapter_config,
enabled: request.enabled,
created_at: 0, // 不重要,前端可以重新获取
display_order: 0, // 保持原有顺序
created_at: 0, // 不重要,前端可以重新获取
updated_at: now,
};
log::info!("Updated relay station: {} ({})", station.name, request.id);
log::info!("[UPDATE] Updated relay station: {} ({})", station.name, request.id);
log::info!("[UPDATE] Final station.adapter_config: {:?}", station.adapter_config);
Ok(station)
}
/// 删除中转站
#[command]
pub async fn relay_station_delete(
id: String,
db: State<'_, AgentDb>
) -> Result<String, String> {
pub async fn relay_station_delete(id: String, db: State<'_, AgentDb>) -> Result<String, String> {
let conn = db.0.lock().map_err(|e| {
log::error!("Failed to acquire database lock: {}", e);
i18n::t("database.lock_failed")
})?;
let rows_affected = conn.execute("DELETE FROM relay_stations WHERE id = ?1", params![id])
let rows_affected = conn
.execute("DELETE FROM relay_stations WHERE id = ?1", params![id])
.map_err(|e| {
log::error!("Failed to delete relay station: {}", e);
i18n::t("relay_station.delete_failed")
@@ -501,7 +548,7 @@ pub async fn relay_station_delete(
pub async fn relay_station_toggle_enable(
id: String,
enabled: bool,
db: State<'_, AgentDb>
db: State<'_, AgentDb>,
) -> Result<String, String> {
let conn = db.0.lock().map_err(|e| {
log::error!("Failed to acquire database lock: {}", e);
@@ -515,15 +562,16 @@ pub async fn relay_station_toggle_enable(
conn.execute(
"UPDATE relay_stations SET enabled = 0, updated_at = ?1 WHERE id != ?2",
params![now, id],
).map_err(|e| {
)
.map_err(|e| {
log::error!("Failed to disable other relay stations: {}", e);
i18n::t("relay_station.update_failed")
})?;
// 获取要启用的中转站信息
let station = relay_station_get_internal(&conn, &id)?;
// 将中转站配置应用到 Claude 配置文件
// 将中转站配置应用到 Claude 配置文件(会自动确保源文件备份存在)
claude_config::apply_relay_station_to_config(&station).map_err(|e| {
log::error!("Failed to apply relay station config: {}", e);
format!("配置文件写入失败: {}", e)
@@ -538,13 +586,15 @@ pub async fn relay_station_toggle_enable(
}
// 更新目标中转站的启用状态
let rows_affected = conn.execute(
"UPDATE relay_stations SET enabled = ?1, updated_at = ?2 WHERE id = ?3",
params![if enabled { 1 } else { 0 }, now, id],
).map_err(|e| {
log::error!("Failed to toggle relay station enable status: {}", e);
i18n::t("relay_station.update_failed")
})?;
let rows_affected = conn
.execute(
"UPDATE relay_stations SET enabled = ?1, updated_at = ?2 WHERE id = ?3",
params![if enabled { 1 } else { 0 }, now, id],
)
.map_err(|e| {
log::error!("Failed to toggle relay station enable status: {}", e);
i18n::t("relay_station.update_failed")
})?;
if rows_affected == 0 {
return Err(i18n::t("relay_station.not_found"));
@@ -560,25 +610,29 @@ pub async fn relay_station_toggle_enable(
/// 内部方法:获取单个中转站
fn relay_station_get_internal(conn: &Connection, id: &str) -> Result<RelayStation, String> {
let mut stmt = conn.prepare(
"SELECT * FROM relay_stations WHERE id = ?1"
).map_err(|e| {
log::error!("Failed to prepare statement: {}", e);
i18n::t("database.query_failed")
})?;
let mut stmt = conn
.prepare("SELECT * FROM relay_stations WHERE id = ?1")
.map_err(|e| {
log::error!("Failed to prepare statement: {}", e);
i18n::t("database.query_failed")
})?;
let station = stmt.query_row(params![id], |row| {
RelayStation::from_row(row)
}).map_err(|e| {
log::error!("Failed to get relay station: {}", e);
i18n::t("relay_station.not_found")
})?;
let station = stmt
.query_row(params![id], |row| RelayStation::from_row(row))
.map_err(|e| {
log::error!("Failed to get relay station: {}", e);
i18n::t("relay_station.not_found")
})?;
Ok(station)
}
/// 输入验证
fn validate_relay_station_request(name: &str, api_url: &str, system_token: &str) -> Result<(), String> {
fn validate_relay_station_request(
name: &str,
api_url: &str,
system_token: &str,
) -> Result<(), String> {
if name.trim().is_empty() {
return Err(i18n::t("relay_station.name_required"));
}
@@ -588,14 +642,20 @@ fn validate_relay_station_request(name: &str, api_url: &str, system_token: &str)
}
// 验证 URL 格式
let parsed_url = url::Url::parse(api_url)
.map_err(|_| i18n::t("relay_station.invalid_url"))?;
let parsed_url = url::Url::parse(api_url).map_err(|_| i18n::t("relay_station.invalid_url"))?;
// 允许本地开发环境使用 HTTP
let is_localhost = parsed_url.host_str()
.map(|host| host == "localhost" || host == "127.0.0.1" || host == "::1" || host.starts_with("192.168.") || host.starts_with("10."))
let is_localhost = parsed_url
.host_str()
.map(|host| {
host == "localhost"
|| host == "127.0.0.1"
|| host == "::1"
|| host.starts_with("192.168.")
|| host.starts_with("10.")
})
.unwrap_or(false);
// 非本地环境必须使用 HTTPS
if !is_localhost && !api_url.starts_with("https://") {
return Err(i18n::t("relay_station.https_required"));
@@ -610,7 +670,10 @@ fn validate_relay_station_request(name: &str, api_url: &str, system_token: &str)
}
// 检查 Token 是否包含特殊字符
if system_token.chars().any(|c| c.is_whitespace() || c.is_control()) {
if system_token
.chars()
.any(|c| c.is_whitespace() || c.is_control())
{
return Err(i18n::t("relay_station.token_invalid_chars"));
}
@@ -623,47 +686,52 @@ pub fn mask_token(token: &str) -> String {
if token.len() <= 8 {
"*".repeat(token.len())
} else {
format!("{}...{}", &token[..4], &token[token.len()-4..])
format!("{}...{}", &token[..4], &token[token.len() - 4..])
}
}
/// 手动同步中转站配置到 Claude 配置文件
#[command]
pub async fn relay_station_sync_config(
db: State<'_, AgentDb>
) -> Result<String, String> {
pub async fn relay_station_sync_config(db: State<'_, AgentDb>) -> Result<String, String> {
let conn = db.0.lock().map_err(|e| {
log::error!("Failed to acquire database lock: {}", e);
i18n::t("database.lock_failed")
})?;
// 查找当前启用的中转站
let mut stmt = conn.prepare(
"SELECT * FROM relay_stations WHERE enabled = 1 LIMIT 1"
).map_err(|e| {
log::error!("Failed to prepare statement: {}", e);
i18n::t("database.query_failed")
})?;
let mut stmt = conn
.prepare("SELECT * FROM relay_stations WHERE enabled = 1 LIMIT 1")
.map_err(|e| {
log::error!("Failed to prepare statement: {}", e);
i18n::t("database.query_failed")
})?;
let station_opt = stmt.query_row([], |row| {
RelayStation::from_row(row)
}).optional().map_err(|e| {
log::error!("Failed to query enabled relay station: {}", e);
i18n::t("database.query_failed")
})?;
let station_opt = stmt
.query_row([], |row| RelayStation::from_row(row))
.optional()
.map_err(|e| {
log::error!("Failed to query enabled relay station: {}", e);
i18n::t("database.query_failed")
})?;
if let Some(station) = station_opt {
// 应用中转站配置
claude_config::apply_relay_station_to_config(&station)
.map_err(|e| format!("配置同步失败: {}", e))?;
log::info!("Synced relay station {} config to Claude settings", station.name);
Ok(format!("已同步中转站 {} 的配置到 Claude 设置", station.name))
log::info!(
"Synced relay station {} config to Claude settings",
station.name
);
Ok(format!(
"已同步中转站 {} 的配置到 Claude 设置",
station.name
))
} else {
// 没有启用的中转站,清除配置
claude_config::clear_relay_station_from_config()
.map_err(|e| format!("清除配置失败: {}", e))?;
log::info!("Cleared relay station config from Claude settings");
Ok("已清除 Claude 设置中的中转站配置".to_string())
}
@@ -672,9 +740,8 @@ pub async fn relay_station_sync_config(
/// 恢复 Claude 配置备份
#[command]
pub async fn relay_station_restore_config() -> Result<String, String> {
claude_config::restore_claude_config()
.map_err(|e| format!("恢复配置失败: {}", e))?;
claude_config::restore_claude_config().map_err(|e| format!("恢复配置失败: {}", e))?;
log::info!("Restored Claude config from backup");
Ok("已从备份恢复 Claude 配置".to_string())
}
@@ -683,21 +750,22 @@ pub async fn relay_station_restore_config() -> Result<String, String> {
#[command]
pub async fn relay_station_get_current_config() -> Result<HashMap<String, Option<String>>, String> {
let mut config = HashMap::new();
config.insert(
"api_url".to_string(),
claude_config::get_current_api_url().unwrap_or(None)
claude_config::get_current_api_url().unwrap_or(None),
);
config.insert(
"api_token".to_string(),
claude_config::get_current_api_token().unwrap_or(None)
claude_config::get_current_api_token()
.unwrap_or(None)
.map(|token: String| {
// 脱敏显示 token
mask_token(&token)
})
}),
);
Ok(config)
}
@@ -715,13 +783,15 @@ pub async fn relay_stations_export(db: State<'_, AgentDb>) -> Result<Vec<RelaySt
i18n::t("database.init_failed")
})?;
let mut stmt = conn.prepare("SELECT * FROM relay_stations ORDER BY created_at DESC")
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))
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")
@@ -739,24 +809,24 @@ pub async fn relay_stations_export(db: State<'_, AgentDb>) -> Result<Vec<RelaySt
/// 导入结果统计
#[derive(Debug, Serialize, Deserialize)]
pub struct ImportResult {
pub total: usize, // 总数
pub imported: usize, // 成功导入数
pub skipped: usize, // 跳过数(重复)
pub failed: usize, // 失败数
pub message: String, // 结果消息
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, // 是否清除现有配置
pub clear_existing: bool, // 是否清除现有配置
}
#[command]
pub async fn relay_stations_import(
request: ImportRelayStationsRequest,
db: State<'_, AgentDb>
db: State<'_, AgentDb>,
) -> Result<ImportResult, String> {
let mut conn = db.0.lock().map_err(|e| {
log::error!("Failed to acquire database lock: {}", e);
@@ -777,30 +847,31 @@ pub async fn relay_stations_import(
// 如果需要清除现有配置
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")
})?;
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")
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 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 {
@@ -826,7 +897,11 @@ pub async fn relay_stations_import(
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) {
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;
@@ -838,22 +913,30 @@ pub async fn relay_stations_import(
});
if is_duplicate {
log::info!("Skipping duplicate station: {} ({})", station_request.name, station_request.api_url);
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();
.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();
.trim_matches('"')
.to_string();
let adapter_config_str = station_request.adapter_config.as_ref()
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"))?;
@@ -897,9 +980,9 @@ pub async fn relay_stations_import(
"导入完成:总计 {} 个,成功 {} 个,跳过 {} 个(重复),失败 {}",
total, imported_count, skipped_count, failed_count
);
log::info!("{}", message);
Ok(ImportResult {
total,
imported: imported_count,
@@ -907,4 +990,47 @@ pub async fn relay_stations_import(
failed: failed_count,
message,
})
}
}
/// 更新中转站排序
/// @author yovinchen
#[command]
pub async fn relay_station_update_order(
station_ids: Vec<String>,
db: State<'_, AgentDb>,
) -> Result<(), String> {
let conn = db.0.lock().map_err(|e| {
log::error!("Failed to acquire database lock: {}", e);
i18n::t("database.lock_failed")
})?;
// 开始事务
let tx = conn.unchecked_transaction().map_err(|e| {
log::error!("Failed to start transaction: {}", e);
i18n::t("database.transaction_failed")
})?;
// 更新每个中转站的排序
for (index, station_id) in station_ids.iter().enumerate() {
tx.execute(
"UPDATE relay_stations SET display_order = ?1, updated_at = ?2 WHERE id = ?3",
params![index as i32, Utc::now().timestamp(), station_id],
)
.map_err(|e| {
log::error!("Failed to update station order: {}", e);
i18n::t("database.update_failed")
})?;
}
// 提交事务
tx.commit().map_err(|e| {
log::error!("Failed to commit transaction: {}", e);
i18n::t("database.transaction_failed")
})?;
log::info!(
"Updated display order for {} relay stations",
station_ids.len()
);
Ok(())
}

View File

@@ -45,13 +45,13 @@ struct CommandFrontmatter {
/// Parse a markdown file with optional YAML frontmatter
fn parse_markdown_with_frontmatter(content: &str) -> Result<(Option<CommandFrontmatter>, String)> {
let lines: Vec<&str> = content.lines().collect();
// Check if the file starts with YAML frontmatter
if lines.is_empty() || lines[0] != "---" {
// No frontmatter
return Ok((None, content.to_string()));
}
// Find the end of frontmatter
let mut frontmatter_end = None;
for (i, line) in lines.iter().enumerate().skip(1) {
@@ -60,12 +60,12 @@ fn parse_markdown_with_frontmatter(content: &str) -> Result<(Option<CommandFront
break;
}
}
if let Some(end) = frontmatter_end {
// Extract frontmatter
let frontmatter_content = lines[1..end].join("\n");
let body_content = lines[(end + 1)..].join("\n");
// Parse YAML
match serde_yaml::from_str::<CommandFrontmatter>(&frontmatter_content) {
Ok(frontmatter) => Ok((Some(frontmatter), body_content)),
@@ -86,20 +86,20 @@ fn extract_command_info(file_path: &Path, base_path: &Path) -> Result<(String, O
let relative_path = file_path
.strip_prefix(base_path)
.context("Failed to get relative path")?;
// Remove .md extension
let path_without_ext = relative_path
.with_extension("")
.to_string_lossy()
.to_string();
// Split into components
let components: Vec<&str> = path_without_ext.split('/').collect();
if components.is_empty() {
return Err(anyhow::anyhow!("Invalid command path"));
}
if components.len() == 1 {
// No namespace
Ok((components[0].to_string(), None))
@@ -112,44 +112,43 @@ fn extract_command_info(file_path: &Path, base_path: &Path) -> Result<(String, O
}
/// Load a single command from a markdown file
fn load_command_from_file(
file_path: &Path,
base_path: &Path,
scope: &str,
) -> Result<SlashCommand> {
fn load_command_from_file(file_path: &Path, base_path: &Path, scope: &str) -> Result<SlashCommand> {
debug!("Loading command from: {:?}", file_path);
// Read file content
let content = fs::read_to_string(file_path)
.context("Failed to read command file")?;
let content = fs::read_to_string(file_path).context("Failed to read command file")?;
// Parse frontmatter
let (frontmatter, body) = parse_markdown_with_frontmatter(&content)?;
// Extract command info
let (name, namespace) = extract_command_info(file_path, base_path)?;
// Build full command (no scope prefix, just /command or /namespace:command)
let full_command = match &namespace {
Some(ns) => format!("/{ns}:{name}"),
None => format!("/{name}"),
};
// Generate unique ID
let id = format!("{}-{}", scope, file_path.to_string_lossy().replace('/', "-"));
let id = format!(
"{}-{}",
scope,
file_path.to_string_lossy().replace('/', "-")
);
// Check for special content
let has_bash_commands = body.contains("!`");
let has_file_references = body.contains('@');
let accepts_arguments = body.contains("$ARGUMENTS");
// Extract metadata from frontmatter
let (description, allowed_tools) = if let Some(fm) = frontmatter {
(fm.description, fm.allowed_tools.unwrap_or_default())
} else {
(None, Vec::new())
};
Ok(SlashCommand {
id,
name,
@@ -171,18 +170,18 @@ fn find_markdown_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
if !dir.exists() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
// Skip hidden files/directories
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with('.') {
continue;
}
}
if path.is_dir() {
find_markdown_files(&path, files)?;
} else if path.is_file() {
@@ -193,7 +192,7 @@ fn find_markdown_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
}
}
}
Ok(())
}
@@ -252,16 +251,16 @@ pub async fn slash_commands_list(
) -> Result<Vec<SlashCommand>, String> {
info!("Discovering slash commands");
let mut commands = Vec::new();
// Add default commands
commands.extend(create_default_commands());
// Load project commands if project path is provided
if let Some(proj_path) = project_path {
let project_commands_dir = PathBuf::from(&proj_path).join(".claude").join("commands");
if project_commands_dir.exists() {
debug!("Scanning project commands at: {:?}", project_commands_dir);
let mut md_files = Vec::new();
if let Err(e) = find_markdown_files(&project_commands_dir, &mut md_files) {
error!("Failed to find project command files: {}", e);
@@ -280,13 +279,13 @@ pub async fn slash_commands_list(
}
}
}
// Load user commands
if let Some(home_dir) = dirs::home_dir() {
let user_commands_dir = home_dir.join(".claude").join("commands");
if user_commands_dir.exists() {
debug!("Scanning user commands at: {:?}", user_commands_dir);
let mut md_files = Vec::new();
if let Err(e) = find_markdown_files(&user_commands_dir, &mut md_files) {
error!("Failed to find user command files: {}", e);
@@ -305,7 +304,7 @@ pub async fn slash_commands_list(
}
}
}
info!("Found {} slash commands", commands.len());
Ok(commands)
}
@@ -314,17 +313,17 @@ pub async fn slash_commands_list(
#[tauri::command]
pub async fn slash_command_get(command_id: String) -> Result<SlashCommand, String> {
debug!("Getting slash command: {}", command_id);
// Parse the ID to determine scope and reconstruct file path
let parts: Vec<&str> = command_id.split('-').collect();
if parts.len() < 2 {
return Err("Invalid command ID".to_string());
}
// The actual implementation would need to reconstruct the path and reload the command
// For now, we'll list all commands and find the matching one
let commands = slash_commands_list(None).await?;
commands
.into_iter()
.find(|cmd| cmd.id == command_id)
@@ -343,16 +342,16 @@ pub async fn slash_command_save(
project_path: Option<String>,
) -> Result<SlashCommand, String> {
info!("Saving slash command: {} in scope: {}", name, scope);
// Validate inputs
if name.is_empty() {
return Err("Command name cannot be empty".to_string());
}
if !["project", "user"].contains(&scope.as_str()) {
return Err("Invalid scope. Must be 'project' or 'user'".to_string());
}
// Determine base directory
let base_dir = if scope == "project" {
if let Some(proj_path) = project_path {
@@ -366,7 +365,7 @@ pub async fn slash_command_save(
.join(".claude")
.join("commands")
};
// Build file path
let mut file_path = base_dir.clone();
if let Some(ns) = &namespace {
@@ -374,41 +373,40 @@ pub async fn slash_command_save(
file_path = file_path.join(component);
}
}
// Create directories if needed
fs::create_dir_all(&file_path)
.map_err(|e| format!("Failed to create directories: {}", e))?;
fs::create_dir_all(&file_path).map_err(|e| format!("Failed to create directories: {}", e))?;
// Add filename
file_path = file_path.join(format!("{}.md", name));
// Build content with frontmatter
let mut full_content = String::new();
// Add frontmatter if we have metadata
if description.is_some() || !allowed_tools.is_empty() {
full_content.push_str("---\n");
if let Some(desc) = &description {
full_content.push_str(&format!("description: {}\n", desc));
}
if !allowed_tools.is_empty() {
full_content.push_str("allowed-tools:\n");
for tool in &allowed_tools {
full_content.push_str(&format!(" - {}\n", tool));
}
}
full_content.push_str("---\n\n");
}
full_content.push_str(&content);
// Write file
fs::write(&file_path, &full_content)
.map_err(|e| format!("Failed to write command file: {}", e))?;
// Load and return the saved command
load_command_from_file(&file_path, &base_dir, &scope)
.map_err(|e| format!("Failed to load saved command: {}", e))
@@ -416,35 +414,38 @@ pub async fn slash_command_save(
/// Delete a slash command
#[tauri::command]
pub async fn slash_command_delete(command_id: String, project_path: Option<String>) -> Result<String, String> {
pub async fn slash_command_delete(
command_id: String,
project_path: Option<String>,
) -> Result<String, String> {
info!("Deleting slash command: {}", command_id);
// First, we need to determine if this is a project command by parsing the ID
let is_project_command = command_id.starts_with("project-");
// If it's a project command and we don't have a project path, error out
if is_project_command && project_path.is_none() {
return Err("Project path required to delete project commands".to_string());
}
// List all commands (including project commands if applicable)
let commands = slash_commands_list(project_path).await?;
// Find the command by ID
let command = commands
.into_iter()
.find(|cmd| cmd.id == command_id)
.ok_or_else(|| format!("Command not found: {}", command_id))?;
// Delete the file
fs::remove_file(&command.file_path)
.map_err(|e| format!("Failed to delete command file: {}", e))?;
// Clean up empty directories
if let Some(parent) = Path::new(&command.file_path).parent() {
let _ = remove_empty_dirs(parent);
}
Ok(format!("Deleted command: {}", command.full_command))
}
@@ -453,18 +454,18 @@ fn remove_empty_dirs(dir: &Path) -> Result<()> {
if !dir.exists() {
return Ok(());
}
// Check if directory is empty
let is_empty = fs::read_dir(dir)?.next().is_none();
if is_empty {
fs::remove_dir(dir)?;
// Try to remove parent if it's also empty
if let Some(parent) = dir.parent() {
let _ = remove_empty_dirs(parent);
}
}
Ok(())
}

View File

@@ -0,0 +1,445 @@
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::AppHandle;
use uuid::Uuid;
/// 智能会话结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmartSessionResult {
/// 会话ID
pub session_id: String,
/// 项目路径
pub project_path: String,
/// 显示名称
pub display_name: String,
/// 创建时间
pub created_at: DateTime<Utc>,
/// 会话类型
pub session_type: String,
}
/// 智能会话配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmartSessionConfig {
/// 是否启用智能会话
pub enabled: bool,
/// 基础目录
pub base_directory: PathBuf,
/// 命名模式
pub naming_pattern: String,
/// 是否启用自动清理
pub auto_cleanup_enabled: bool,
/// 自动清理天数
pub auto_cleanup_days: u32,
/// 模板文件
pub template_files: Vec<TemplateFile>,
}
/// 模板文件定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateFile {
/// 文件路径
pub path: String,
/// 文件内容
pub content: String,
/// 是否可执行
pub executable: bool,
}
/// 智能会话记录
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmartSession {
/// 会话ID
pub id: String,
/// 显示名称
pub display_name: String,
/// 项目路径
pub project_path: String,
/// 创建时间
pub created_at: DateTime<Utc>,
/// 最后访问时间
pub last_accessed: DateTime<Utc>,
/// 会话类型
pub session_type: String,
}
impl Default for SmartSessionConfig {
fn default() -> Self {
let base_directory = dirs::home_dir()
.unwrap_or_default()
.join(".claudia")
.join("smart-sessions");
Self {
enabled: true,
base_directory,
naming_pattern: "chat-{timestamp}".to_string(),
auto_cleanup_enabled: true,
auto_cleanup_days: 30,
template_files: vec![
TemplateFile {
path: "CLAUDE.md".to_string(),
content: include_str!("../templates/smart_session_claude.md").to_string(),
executable: false,
},
TemplateFile {
path: "README.md".to_string(),
content: "# Smart Quick Start Session\n\nThis is an automatically created workspace by Claudia.\n\nCreated at: {created_at}\nSession ID: {session_id}\n".to_string(),
executable: false,
},
TemplateFile {
path: ".gitignore".to_string(),
content: "# Claudia Smart Session\n*.log\n.DS_Store\n.env\nnode_modules/\n".to_string(),
executable: false,
},
],
}
}
}
/// 获取智能会话配置文件路径
fn get_config_path() -> Result<PathBuf> {
let claudia_dir = dirs::home_dir()
.context("Failed to get home directory")?
.join(".claudia");
fs::create_dir_all(&claudia_dir).context("Failed to create .claudia directory")?;
Ok(claudia_dir.join("smart_sessions_config.json"))
}
/// 加载智能会话配置
pub fn load_smart_session_config() -> Result<SmartSessionConfig> {
let config_path = get_config_path()?;
if !config_path.exists() {
let default_config = SmartSessionConfig::default();
save_smart_session_config(&default_config)?;
return Ok(default_config);
}
let config_content =
fs::read_to_string(&config_path).context("Failed to read smart session config")?;
let config: SmartSessionConfig =
serde_json::from_str(&config_content).context("Failed to parse smart session config")?;
Ok(config)
}
/// 保存智能会话配置
pub fn save_smart_session_config(config: &SmartSessionConfig) -> Result<()> {
let config_path = get_config_path()?;
let config_content =
serde_json::to_string_pretty(config).context("Failed to serialize smart session config")?;
fs::write(&config_path, config_content).context("Failed to write smart session config")?;
Ok(())
}
/// 生成智能会话路径
pub fn generate_smart_session_path(
config: &SmartSessionConfig,
session_name: Option<String>,
) -> Result<PathBuf> {
let timestamp = chrono::Utc::now();
let session_name = session_name.unwrap_or_else(|| match config.naming_pattern.as_str() {
"chat-{timestamp}" => format!("chat-{}", timestamp.format("%Y-%m-%d-%H%M%S")),
"session-{date}" => format!("session-{}", timestamp.format("%Y-%m-%d")),
"conversation-{datetime}" => format!("conversation-{}", timestamp.format("%Y%m%d_%H%M%S")),
_ => format!("chat-{}", timestamp.format("%Y-%m-%d-%H%M%S")),
});
let session_path = config.base_directory.join(&session_name);
// 确保路径唯一
if session_path.exists() {
let uuid = Uuid::new_v4().to_string()[..8].to_string();
let unique_name = format!("{}-{}", session_name, uuid);
Ok(config.base_directory.join(unique_name))
} else {
Ok(session_path)
}
}
/// 创建智能会话环境
pub fn create_smart_session_environment(session_path: &PathBuf) -> Result<()> {
let config = load_smart_session_config()?;
// 创建主目录
fs::create_dir_all(session_path).context("Failed to create smart session directory")?;
// 创建 .claude 子目录
let claude_dir = session_path.join(".claude");
fs::create_dir_all(&claude_dir).context("Failed to create .claude directory")?;
// 创建基础 Claude 设置文件
let claude_settings = serde_json::json!({
"smart_session": true,
"created_by": "claudia",
"created_at": chrono::Utc::now().to_rfc3339(),
"session_path": session_path.to_string_lossy()
});
let settings_path = claude_dir.join("settings.json");
fs::write(
&settings_path,
serde_json::to_string_pretty(&claude_settings)?,
)
.context("Failed to write Claude settings")?;
// 创建模板文件
let session_id = Uuid::new_v4().to_string();
let created_at = chrono::Utc::now().to_rfc3339();
for template in &config.template_files {
let file_path = session_path.join(&template.path);
// 创建父目录(如果需要)
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent)
.context("Failed to create template file parent directory")?;
}
// 替换模板变量
let content = template
.content
.replace("{session_id}", &session_id)
.replace("{created_at}", &created_at)
.replace("{project_path}", &session_path.to_string_lossy());
fs::write(&file_path, content)
.context(format!("Failed to write template file: {}", template.path))?;
// 设置可执行权限(如果需要)
#[cfg(unix)]
if template.executable {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&file_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&file_path, perms)?;
}
}
log::info!(
"Created smart session environment at: {}",
session_path.display()
);
Ok(())
}
/// 获取智能会话历史文件路径
fn get_sessions_history_path() -> Result<PathBuf> {
let claudia_dir = dirs::home_dir()
.context("Failed to get home directory")?
.join(".claudia");
fs::create_dir_all(&claudia_dir).context("Failed to create .claudia directory")?;
Ok(claudia_dir.join("smart_sessions_history.json"))
}
/// 保存智能会话记录
pub fn save_smart_session_record(session_path: &PathBuf) -> Result<String> {
let session_id = Uuid::new_v4().to_string();
let now = chrono::Utc::now();
let session = SmartSession {
id: session_id.clone(),
display_name: session_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Unnamed Session")
.to_string(),
project_path: session_path.to_string_lossy().to_string(),
created_at: now,
last_accessed: now,
session_type: "smart".to_string(),
};
let history_path = get_sessions_history_path()?;
let mut sessions: Vec<SmartSession> = if history_path.exists() {
let content =
fs::read_to_string(&history_path).context("Failed to read sessions history")?;
serde_json::from_str(&content).unwrap_or_default()
} else {
Vec::new()
};
sessions.push(session);
let history_content =
serde_json::to_string_pretty(&sessions).context("Failed to serialize sessions history")?;
fs::write(&history_path, history_content).context("Failed to write sessions history")?;
Ok(session_id)
}
/// 列出所有智能会话
pub fn list_smart_sessions() -> Result<Vec<SmartSession>> {
let history_path = get_sessions_history_path()?;
if !history_path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&history_path).context("Failed to read sessions history")?;
let sessions: Vec<SmartSession> =
serde_json::from_str(&content).context("Failed to parse sessions history")?;
// 过滤仍然存在的会话
let existing_sessions: Vec<SmartSession> = sessions
.into_iter()
.filter(|session| {
let path = PathBuf::from(&session.project_path);
path.exists()
})
.collect();
Ok(existing_sessions)
}
/// 清理过期的智能会话
pub fn cleanup_old_smart_sessions(days: u32) -> Result<u32> {
let config = load_smart_session_config()?;
if !config.auto_cleanup_enabled {
return Ok(0);
}
let cutoff_time = chrono::Utc::now() - chrono::Duration::days(days as i64);
let sessions = list_smart_sessions()?;
let mut cleaned_count = 0u32;
let mut remaining_sessions = Vec::new();
for session in sessions {
if session.last_accessed < cutoff_time {
// 删除会话目录
let session_path = PathBuf::from(&session.project_path);
if session_path.exists() {
if let Err(e) = fs::remove_dir_all(&session_path) {
log::warn!(
"Failed to remove session directory {}: {}",
session_path.display(),
e
);
} else {
cleaned_count += 1;
log::info!("Cleaned up expired session: {}", session.display_name);
}
}
} else {
remaining_sessions.push(session);
}
}
// 更新历史记录
if cleaned_count > 0 {
let history_path = get_sessions_history_path()?;
let history_content = serde_json::to_string_pretty(&remaining_sessions)
.context("Failed to serialize updated sessions history")?;
fs::write(&history_path, history_content)
.context("Failed to write updated sessions history")?;
}
Ok(cleaned_count)
}
// Tauri 命令实现
/// 创建智能快速开始会话
#[tauri::command]
pub async fn create_smart_quick_start_session(
_app: AppHandle,
session_name: Option<String>,
) -> Result<SmartSessionResult, String> {
log::info!("Creating smart quick start session: {:?}", session_name);
let config =
load_smart_session_config().map_err(|e| format!("Failed to load config: {}", e))?;
if !config.enabled {
return Err("Smart sessions are disabled".to_string());
}
// 1. 生成唯一的会话路径
let session_path = generate_smart_session_path(&config, session_name)
.map_err(|e| format!("Failed to generate session path: {}", e))?;
// 2. 创建目录结构和环境
create_smart_session_environment(&session_path)
.map_err(|e| format!("Failed to create session environment: {}", e))?;
// 3. 保存到历史记录
let session_id = save_smart_session_record(&session_path)
.map_err(|e| format!("Failed to save session record: {}", e))?;
let display_name = session_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Smart Session")
.to_string();
let result = SmartSessionResult {
session_id,
project_path: session_path.to_string_lossy().to_string(),
display_name,
created_at: chrono::Utc::now(),
session_type: "smart".to_string(),
};
log::info!(
"Smart session created successfully: {}",
result.project_path
);
Ok(result)
}
/// 获取智能会话配置
#[tauri::command]
pub async fn get_smart_session_config() -> Result<SmartSessionConfig, String> {
load_smart_session_config().map_err(|e| format!("Failed to load smart session config: {}", e))
}
/// 更新智能会话配置
#[tauri::command]
pub async fn update_smart_session_config(config: SmartSessionConfig) -> Result<(), String> {
save_smart_session_config(&config)
.map_err(|e| format!("Failed to save smart session config: {}", e))
}
/// 列出智能会话
#[tauri::command]
pub async fn list_smart_sessions_command() -> Result<Vec<SmartSession>, String> {
list_smart_sessions().map_err(|e| format!("Failed to list smart sessions: {}", e))
}
/// 切换智能会话模式
#[tauri::command]
pub async fn toggle_smart_session_mode(enabled: bool) -> Result<(), String> {
let mut config =
load_smart_session_config().map_err(|e| format!("Failed to load config: {}", e))?;
config.enabled = enabled;
save_smart_session_config(&config).map_err(|e| format!("Failed to save config: {}", e))?;
log::info!("Smart session mode toggled: {}", enabled);
Ok(())
}
/// 清理过期智能会话
#[tauri::command]
pub async fn cleanup_old_smart_sessions_command(days: u32) -> Result<u32, String> {
cleanup_old_smart_sessions(days).map_err(|e| format!("Failed to cleanup old sessions: {}", e))
}

View File

@@ -1,10 +1,10 @@
use super::agents::AgentDb;
use anyhow::Result;
use rusqlite::{params, Connection, Result as SqliteResult, types::ValueRef};
use rusqlite::{params, types::ValueRef, Connection, Result as SqliteResult};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value as JsonValue};
use std::collections::HashMap;
use tauri::{AppHandle, Manager, State};
use super::agents::AgentDb;
/// Represents metadata about a database table
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -50,37 +50,35 @@ pub struct QueryResult {
#[tauri::command]
pub async fn storage_list_tables(db: State<'_, AgentDb>) -> Result<Vec<TableInfo>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// Query for all tables
let mut stmt = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
.map_err(|e| e.to_string())?;
let table_names: Vec<String> = stmt
.query_map([], |row| row.get(0))
.map_err(|e| e.to_string())?
.collect::<SqliteResult<Vec<_>>>()
.map_err(|e| e.to_string())?;
drop(stmt);
let mut tables = Vec::new();
for table_name in table_names {
// Get row count
let row_count: i64 = conn
.query_row(
&format!("SELECT COUNT(*) FROM {}", table_name),
[],
|row| row.get(0),
)
.query_row(&format!("SELECT COUNT(*) FROM {}", table_name), [], |row| {
row.get(0)
})
.unwrap_or(0);
// Get column information
let mut pragma_stmt = conn
.prepare(&format!("PRAGMA table_info({})", table_name))
.map_err(|e| e.to_string())?;
let columns: Vec<ColumnInfo> = pragma_stmt
.query_map([], |row| {
Ok(ColumnInfo {
@@ -95,14 +93,14 @@ pub async fn storage_list_tables(db: State<'_, AgentDb>) -> Result<Vec<TableInfo
.map_err(|e| e.to_string())?
.collect::<SqliteResult<Vec<_>>>()
.map_err(|e| e.to_string())?;
tables.push(TableInfo {
name: table_name,
row_count,
columns,
});
}
Ok(tables)
}
@@ -117,17 +115,17 @@ pub async fn storage_read_table(
searchQuery: Option<String>,
) -> Result<TableData, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// Validate table name to prevent SQL injection
if !is_valid_table_name(&conn, &tableName)? {
return Err("Invalid table name".to_string());
}
// Get column information
let mut pragma_stmt = conn
.prepare(&format!("PRAGMA table_info({})", tableName))
.map_err(|e| e.to_string())?;
let columns: Vec<ColumnInfo> = pragma_stmt
.query_map([], |row| {
Ok(ColumnInfo {
@@ -142,9 +140,9 @@ pub async fn storage_read_table(
.map_err(|e| e.to_string())?
.collect::<SqliteResult<Vec<_>>>()
.map_err(|e| e.to_string())?;
drop(pragma_stmt);
// Build query with optional search
let (query, count_query) = if let Some(search) = &searchQuery {
// Create search conditions for all text columns
@@ -153,7 +151,7 @@ pub async fn storage_read_table(
.filter(|col| col.type_name.contains("TEXT") || col.type_name.contains("VARCHAR"))
.map(|col| format!("{} LIKE '%{}%'", col.name, search.replace("'", "''")))
.collect();
if search_conditions.is_empty() {
(
format!("SELECT * FROM {} LIMIT ? OFFSET ?", tableName),
@@ -162,7 +160,10 @@ pub async fn storage_read_table(
} else {
let where_clause = search_conditions.join(" OR ");
(
format!("SELECT * FROM {} WHERE {} LIMIT ? OFFSET ?", tableName, where_clause),
format!(
"SELECT * FROM {} WHERE {} LIMIT ? OFFSET ?",
tableName, where_clause
),
format!("SELECT COUNT(*) FROM {} WHERE {}", tableName, where_clause),
)
}
@@ -172,25 +173,23 @@ pub async fn storage_read_table(
format!("SELECT COUNT(*) FROM {}", tableName),
)
};
// Get total row count
let total_rows: i64 = conn
.query_row(&count_query, [], |row| row.get(0))
.unwrap_or(0);
// Calculate pagination
let offset = (page - 1) * pageSize;
let total_pages = (total_rows as f64 / pageSize as f64).ceil() as i64;
// Query data
let mut data_stmt = conn
.prepare(&query)
.map_err(|e| e.to_string())?;
let mut data_stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
let rows: Vec<Map<String, JsonValue>> = data_stmt
.query_map(params![pageSize, offset], |row| {
let mut row_map = Map::new();
for (idx, col) in columns.iter().enumerate() {
let value = match row.get_ref(idx)? {
ValueRef::Null => JsonValue::Null,
@@ -203,17 +202,20 @@ pub async fn storage_read_table(
}
}
ValueRef::Text(s) => JsonValue::String(String::from_utf8_lossy(s).to_string()),
ValueRef::Blob(b) => JsonValue::String(base64::Engine::encode(&base64::engine::general_purpose::STANDARD, b)),
ValueRef::Blob(b) => JsonValue::String(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
b,
)),
};
row_map.insert(col.name.clone(), value);
}
Ok(row_map)
})
.map_err(|e| e.to_string())?
.collect::<SqliteResult<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(TableData {
table_name: tableName,
columns,
@@ -235,49 +237,52 @@ pub async fn storage_update_row(
updates: HashMap<String, JsonValue>,
) -> Result<(), String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// Validate table name
if !is_valid_table_name(&conn, &tableName)? {
return Err("Invalid table name".to_string());
}
// Build UPDATE query
let set_clauses: Vec<String> = updates
.keys()
.enumerate()
.map(|(idx, key)| format!("{} = ?{}", key, idx + 1))
.collect();
let where_clauses: Vec<String> = primaryKeyValues
.keys()
.enumerate()
.map(|(idx, key)| format!("{} = ?{}", key, idx + updates.len() + 1))
.collect();
let query = format!(
"UPDATE {} SET {} WHERE {}",
tableName,
set_clauses.join(", "),
where_clauses.join(" AND ")
);
// Prepare parameters
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
// Add update values
for value in updates.values() {
params.push(json_to_sql_value(value)?);
}
// Add where clause values
for value in primaryKeyValues.values() {
params.push(json_to_sql_value(value)?);
}
// Execute update
conn.execute(&query, rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())))
.map_err(|e| format!("Failed to update row: {}", e))?;
conn.execute(
&query,
rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())),
)
.map_err(|e| format!("Failed to update row: {}", e))?;
Ok(())
}
@@ -290,35 +295,38 @@ pub async fn storage_delete_row(
primaryKeyValues: HashMap<String, JsonValue>,
) -> Result<(), String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// Validate table name
if !is_valid_table_name(&conn, &tableName)? {
return Err("Invalid table name".to_string());
}
// Build DELETE query
let where_clauses: Vec<String> = primaryKeyValues
.keys()
.enumerate()
.map(|(idx, key)| format!("{} = ?{}", key, idx + 1))
.collect();
let query = format!(
"DELETE FROM {} WHERE {}",
tableName,
where_clauses.join(" AND ")
);
// Prepare parameters
let params: Vec<Box<dyn rusqlite::ToSql>> = primaryKeyValues
.values()
.map(json_to_sql_value)
.collect::<Result<Vec<_>, _>>()?;
// Execute delete
conn.execute(&query, rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())))
.map_err(|e| format!("Failed to delete row: {}", e))?;
conn.execute(
&query,
rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())),
)
.map_err(|e| format!("Failed to delete row: {}", e))?;
Ok(())
}
@@ -331,35 +339,40 @@ pub async fn storage_insert_row(
values: HashMap<String, JsonValue>,
) -> Result<i64, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// Validate table name
if !is_valid_table_name(&conn, &tableName)? {
return Err("Invalid table name".to_string());
}
// Build INSERT query
let columns: Vec<&String> = values.keys().collect();
let placeholders: Vec<String> = (1..=columns.len())
.map(|i| format!("?{}", i))
.collect();
let placeholders: Vec<String> = (1..=columns.len()).map(|i| format!("?{}", i)).collect();
let query = format!(
"INSERT INTO {} ({}) VALUES ({})",
tableName,
columns.iter().map(|c| c.as_str()).collect::<Vec<_>>().join(", "),
columns
.iter()
.map(|c| c.as_str())
.collect::<Vec<_>>()
.join(", "),
placeholders.join(", ")
);
// Prepare parameters
let params: Vec<Box<dyn rusqlite::ToSql>> = values
.values()
.map(json_to_sql_value)
.collect::<Result<Vec<_>, _>>()?;
// Execute insert
conn.execute(&query, rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())))
.map_err(|e| format!("Failed to insert row: {}", e))?;
conn.execute(
&query,
rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())),
)
.map_err(|e| format!("Failed to insert row: {}", e))?;
Ok(conn.last_insert_rowid())
}
@@ -370,20 +383,20 @@ pub async fn storage_execute_sql(
query: String,
) -> Result<QueryResult, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
// Check if it's a SELECT query
let is_select = query.trim().to_uppercase().starts_with("SELECT");
if is_select {
// Handle SELECT queries
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
let column_count = stmt.column_count();
// Get column names
let columns: Vec<String> = (0..column_count)
.map(|i| stmt.column_name(i).unwrap_or("").to_string())
.collect();
// Execute query and collect results
let rows: Vec<Vec<JsonValue>> = stmt
.query_map([], |row| {
@@ -399,8 +412,13 @@ pub async fn storage_execute_sql(
JsonValue::String(f.to_string())
}
}
ValueRef::Text(s) => JsonValue::String(String::from_utf8_lossy(s).to_string()),
ValueRef::Blob(b) => JsonValue::String(base64::Engine::encode(&base64::engine::general_purpose::STANDARD, b)),
ValueRef::Text(s) => {
JsonValue::String(String::from_utf8_lossy(s).to_string())
}
ValueRef::Blob(b) => JsonValue::String(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
b,
)),
};
row_values.push(value);
}
@@ -409,7 +427,7 @@ pub async fn storage_execute_sql(
.map_err(|e| e.to_string())?
.collect::<SqliteResult<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(QueryResult {
columns,
rows,
@@ -419,7 +437,7 @@ pub async fn storage_execute_sql(
} else {
// Handle non-SELECT queries (INSERT, UPDATE, DELETE, etc.)
let rows_affected = conn.execute(&query, []).map_err(|e| e.to_string())?;
Ok(QueryResult {
columns: vec![],
rows: vec![],
@@ -435,13 +453,12 @@ pub async fn storage_reset_database(app: AppHandle) -> Result<(), String> {
{
// Drop all existing tables within a scoped block
let db_state = app.state::<AgentDb>();
let conn = db_state.0.lock()
.map_err(|e| e.to_string())?;
let conn = db_state.0.lock().map_err(|e| e.to_string())?;
// Disable foreign key constraints temporarily to allow dropping tables
conn.execute("PRAGMA foreign_keys = OFF", [])
.map_err(|e| format!("Failed to disable foreign keys: {}", e))?;
// Drop tables - order doesn't matter with foreign keys disabled
conn.execute("DROP TABLE IF EXISTS agent_runs", [])
.map_err(|e| format!("Failed to drop agent_runs table: {}", e))?;
@@ -449,34 +466,31 @@ pub async fn storage_reset_database(app: AppHandle) -> Result<(), String> {
.map_err(|e| format!("Failed to drop agents table: {}", e))?;
conn.execute("DROP TABLE IF EXISTS app_settings", [])
.map_err(|e| format!("Failed to drop app_settings table: {}", e))?;
// Re-enable foreign key constraints
conn.execute("PRAGMA foreign_keys = ON", [])
.map_err(|e| format!("Failed to re-enable foreign keys: {}", e))?;
// Connection is automatically dropped at end of scope
}
// Re-initialize the database which will recreate all tables empty
let new_conn = init_database(&app).map_err(|e| format!("Failed to reset database: {}", e))?;
// Update the managed state with the new connection
{
let db_state = app.state::<AgentDb>();
let mut conn_guard = db_state.0.lock()
.map_err(|e| e.to_string())?;
let mut conn_guard = db_state.0.lock().map_err(|e| e.to_string())?;
*conn_guard = new_conn;
}
// Run VACUUM to optimize the database
{
let db_state = app.state::<AgentDb>();
let conn = db_state.0.lock()
.map_err(|e| e.to_string())?;
conn.execute("VACUUM", [])
.map_err(|e| e.to_string())?;
let conn = db_state.0.lock().map_err(|e| e.to_string())?;
conn.execute("VACUUM", []).map_err(|e| e.to_string())?;
}
Ok(())
}
@@ -489,7 +503,7 @@ fn is_valid_table_name(conn: &Connection, table_name: &str) -> Result<bool, Stri
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
Ok(count > 0)
}
@@ -513,4 +527,4 @@ fn json_to_sql_value(value: &JsonValue) -> Result<Box<dyn rusqlite::ToSql>, Stri
}
/// Initialize the agents database (re-exported from agents module)
use super::agents::init_database;
use super::agents::init_database;

View File

@@ -15,7 +15,11 @@ pub async fn flush_dns() -> Result<String, String> {
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 });
return Err(if err.is_empty() {
"ipconfig /flushdns failed".into()
} else {
err
});
}
}
@@ -31,7 +35,11 @@ pub async fn flush_dns() -> Result<String, String> {
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 });
return Err(if err.is_empty() {
"dscacheutil -flushcache failed".into()
} else {
err
});
}
}
@@ -41,7 +49,13 @@ pub async fn flush_dns() -> Result<String, String> {
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"]),
(
"sh",
vec![
"-c",
"service nscd restart || service dnsmasq restart || rc-service nscd restart",
],
),
];
for (cmd, args) in attempts {
@@ -59,4 +73,3 @@ pub async fn flush_dns() -> Result<String, String> {
Err("No supported DNS flush method succeeded on this Linux system".into())
}
}

View File

@@ -1,12 +1,12 @@
use std::collections::HashMap;
use std::sync::Arc;
use anyhow::Result;
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::Arc;
use tauri::{AppHandle, Emitter, State};
use tokio::sync::Mutex;
use uuid::Uuid;
use anyhow::Result;
use portable_pty::{native_pty_system, CommandBuilder, PtySize, Child, MasterPty};
use std::io::{Read, Write};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerminalSession {
@@ -19,8 +19,8 @@ pub struct TerminalSession {
/// Terminal child process wrapper
pub struct TerminalChild {
writer: Arc<Mutex<Box<dyn Write + Send>>>,
_master: Box<dyn MasterPty + Send>, // Keep master PTY alive
_child: Box<dyn Child + Send + Sync>, // Keep child process alive
_master: Box<dyn MasterPty + Send>, // Keep master PTY alive
_child: Box<dyn Child + Send + Sync>, // Keep child process alive
}
/// State for managing terminal sessions
@@ -34,37 +34,46 @@ pub async fn create_terminal_session(
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);
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));
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))?;
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();
log::info!("Using shell: {}", shell);
let mut cmd = CommandBuilder::new(&shell);
// Set shell-specific arguments
if cfg!(target_os = "windows") {
if shell.contains("pwsh") {
@@ -72,7 +81,7 @@ pub async fn create_terminal_session(
cmd.arg("-NoLogo");
cmd.arg("-NoExit");
} else if shell.contains("powershell") {
// Windows PowerShell - stay interactive
// Windows PowerShell - stay interactive
cmd.arg("-NoLogo");
cmd.arg("-NoExit");
} else {
@@ -87,10 +96,10 @@ pub async fn create_terminal_session(
cmd.arg("-il");
}
}
// Set working directory
cmd.cwd(working_directory.clone());
// Set environment variables based on platform
if cfg!(target_os = "windows") {
// Windows-specific environment
@@ -105,40 +114,65 @@ pub async fn create_terminal_session(
// Unix-specific environment
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
cmd.env("LANG", std::env::var("LANG").unwrap_or_else(|_| "en_US.UTF-8".to_string()));
cmd.env("LC_ALL", std::env::var("LC_ALL").unwrap_or_else(|_| "en_US.UTF-8".to_string()));
cmd.env("LC_CTYPE", std::env::var("LC_CTYPE").unwrap_or_else(|_| "en_US.UTF-8".to_string()));
cmd.env(
"LANG",
std::env::var("LANG").unwrap_or_else(|_| "en_US.UTF-8".to_string()),
);
cmd.env(
"LC_ALL",
std::env::var("LC_ALL").unwrap_or_else(|_| "en_US.UTF-8".to_string()),
);
cmd.env(
"LC_CTYPE",
std::env::var("LC_CTYPE").unwrap_or_else(|_| "en_US.UTF-8".to_string()),
);
// Inherit other Unix environment variables
for (key, value) in std::env::vars() {
if !key.starts_with("TERM") && !key.starts_with("COLORTERM") &&
!key.starts_with("LC_") && !key.starts_with("LANG") &&
!key.starts_with("TAURI_") && !key.starts_with("VITE_") {
if !key.starts_with("TERM")
&& !key.starts_with("COLORTERM")
&& !key.starts_with("LC_")
&& !key.starts_with("LANG")
&& !key.starts_with("TAURI_")
&& !key.starts_with("VITE_")
{
cmd.env(&key, &value);
}
}
}
// Spawn the shell process
let child = pty_pair.slave.spawn_command(cmd)
let child = pty_pair
.slave
.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn shell: {}", e))?;
log::info!("Shell process spawned successfully for session: {}", session_id);
log::info!(
"Shell process spawned successfully for session: {}",
session_id
);
// Get writer for stdin
let writer = pty_pair.master.take_writer()
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()
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];
log::info!("PTY reader thread started for session: {}", session_id_clone);
log::info!(
"PTY reader thread started for session: {}",
session_id_clone
);
loop {
match reader.read(&mut buffer) {
Ok(0) => {
@@ -147,30 +181,43 @@ pub async fn create_terminal_session(
}
Ok(n) => {
let data = String::from_utf8_lossy(&buffer[..n]).to_string();
log::debug!("PTY reader got {} bytes for session {}: {:?}", n, session_id_clone, data);
let _ = app_handle_clone.emit(&format!("terminal-output:{}", session_id_clone), &data);
log::debug!(
"PTY reader got {} bytes for session {}: {:?}",
n,
session_id_clone,
data
);
let _ = app_handle_clone
.emit(&format!("terminal-output:{}", session_id_clone), &data);
}
Err(e) => {
log::error!("Error reading PTY output for session {}: {}", session_id_clone, e);
log::error!(
"Error reading PTY output for session {}: {}",
session_id_clone,
e
);
break;
}
}
}
log::debug!("PTY reader thread finished for session: {}", session_id_clone);
log::debug!(
"PTY reader thread finished for session: {}",
session_id_clone
);
});
// Store the session with PTY writer, master PTY and child process
let terminal_child = TerminalChild {
writer: Arc::new(Mutex::new(writer)),
_master: pty_pair.master,
_child: child,
};
{
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)
}
@@ -183,22 +230,27 @@ pub async fn send_terminal_input(
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())
writer
.write_all(input.as_bytes())
.map_err(|e| format!("Failed to write to terminal: {}", e))?;
writer.flush()
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))
Err(format!(
"Terminal session not found or not active: {}",
session_id
))
}
/// Closes a terminal session
@@ -208,11 +260,11 @@ pub async fn close_terminal_session(
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 {
@@ -226,8 +278,9 @@ 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()
let sessions: Vec<String> = state
.iter()
.filter_map(|(id, (session, _))| {
if session.is_active {
Some(id.clone())
@@ -236,7 +289,7 @@ pub async fn list_terminal_sessions(
}
})
.collect();
Ok(sessions)
}
@@ -251,7 +304,10 @@ pub async fn resize_terminal(
// 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);
log::warn!(
"Terminal resize not currently supported for session: {}",
session_id
);
Ok(())
}
@@ -262,25 +318,25 @@ pub async fn cleanup_terminal_sessions(
) -> 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)
}
@@ -288,9 +344,17 @@ pub async fn cleanup_terminal_sessions(
fn get_default_shell() -> String {
if cfg!(target_os = "windows") {
// Try PowerShell Core (pwsh) first, then Windows PowerShell, fallback to cmd
if std::process::Command::new("pwsh").arg("--version").output().is_ok() {
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() {
} else if std::process::Command::new("powershell")
.arg("-Version")
.output()
.is_ok()
{
"powershell".to_string()
} else {
"cmd.exe".to_string()
@@ -307,4 +371,4 @@ fn get_default_shell() -> String {
}
})
}
}
}

View File

@@ -71,36 +71,61 @@ pub struct ProjectUsage {
// Claude pricing constants (per million tokens)
// 最新价格表 (2025-01)
// Claude 4.x 系列
// 注意Cache Writes 使用 5m (5分钟) 的价格1h 价格更高
// Claude Opus 系列
const OPUS_4_1_INPUT_PRICE: f64 = 15.0;
const OPUS_4_1_OUTPUT_PRICE: f64 = 75.0;
const OPUS_4_1_CACHE_WRITE_PRICE: f64 = 18.75;
const OPUS_4_1_CACHE_WRITE_PRICE: f64 = 18.75; // 5m cache writes
const OPUS_4_1_CACHE_READ_PRICE: f64 = 1.50;
const OPUS_4_INPUT_PRICE: f64 = 15.0;
const OPUS_4_OUTPUT_PRICE: f64 = 75.0;
const OPUS_4_CACHE_WRITE_PRICE: f64 = 18.75; // 5m cache writes
const OPUS_4_CACHE_READ_PRICE: f64 = 1.50;
const OPUS_3_INPUT_PRICE: f64 = 15.0;
const OPUS_3_OUTPUT_PRICE: f64 = 75.0;
const OPUS_3_CACHE_WRITE_PRICE: f64 = 18.75; // 5m cache writes
const OPUS_3_CACHE_READ_PRICE: f64 = 1.50;
// Claude Sonnet 系列
const SONNET_4_5_INPUT_PRICE: f64 = 3.0;
const SONNET_4_5_OUTPUT_PRICE: f64 = 15.0;
const SONNET_4_5_CACHE_WRITE_PRICE: f64 = 3.75; // 5m cache writes
const SONNET_4_5_CACHE_READ_PRICE: f64 = 0.30;
const SONNET_4_INPUT_PRICE: f64 = 3.0;
const SONNET_4_OUTPUT_PRICE: f64 = 15.0;
const SONNET_4_CACHE_WRITE_PRICE: f64 = 3.75;
const SONNET_4_CACHE_WRITE_PRICE: f64 = 3.75; // 5m cache writes
const SONNET_4_CACHE_READ_PRICE: f64 = 0.30;
// Claude 3.x 系列 (旧版本,价格可能不同)
// Sonnet 3.7/3.5
const SONNET_3_INPUT_PRICE: f64 = 3.0;
const SONNET_3_OUTPUT_PRICE: f64 = 15.0;
const SONNET_3_CACHE_WRITE_PRICE: f64 = 3.75;
const SONNET_3_CACHE_READ_PRICE: f64 = 0.30;
const SONNET_3_7_INPUT_PRICE: f64 = 3.0;
const SONNET_3_7_OUTPUT_PRICE: f64 = 15.0;
const SONNET_3_7_CACHE_WRITE_PRICE: f64 = 3.75; // 5m cache writes
const SONNET_3_7_CACHE_READ_PRICE: f64 = 0.30;
// Opus 3 - 假设与 Opus 4.1 相同
const OPUS_3_INPUT_PRICE: f64 = 15.0;
const OPUS_3_OUTPUT_PRICE: f64 = 75.0;
const OPUS_3_CACHE_WRITE_PRICE: f64 = 18.75;
const OPUS_3_CACHE_READ_PRICE: f64 = 1.50;
const SONNET_3_5_INPUT_PRICE: f64 = 3.0;
const SONNET_3_5_OUTPUT_PRICE: f64 = 15.0;
const SONNET_3_5_CACHE_WRITE_PRICE: f64 = 3.75; // 5m cache writes
const SONNET_3_5_CACHE_READ_PRICE: f64 = 0.30;
// Claude Haiku 系列
const HAIKU_4_5_INPUT_PRICE: f64 = 1.0;
const HAIKU_4_5_OUTPUT_PRICE: f64 = 5.0;
const HAIKU_4_5_CACHE_WRITE_PRICE: f64 = 1.25; // 5m cache writes
const HAIKU_4_5_CACHE_READ_PRICE: f64 = 0.10;
// Haiku 3.5 - 最具性价比
const HAIKU_3_5_INPUT_PRICE: f64 = 0.80;
const HAIKU_3_5_OUTPUT_PRICE: f64 = 4.0;
const HAIKU_3_5_CACHE_WRITE_PRICE: f64 = 1.0;
const HAIKU_3_5_CACHE_WRITE_PRICE: f64 = 1.0; // 5m cache writes
const HAIKU_3_5_CACHE_READ_PRICE: f64 = 0.08;
const HAIKU_3_INPUT_PRICE: f64 = 0.25;
const HAIKU_3_OUTPUT_PRICE: f64 = 1.25;
const HAIKU_3_CACHE_WRITE_PRICE: f64 = 0.30; // 5m cache writes
const HAIKU_3_CACHE_READ_PRICE: f64 = 0.03;
#[derive(Debug, Deserialize)]
struct JsonlEntry {
timestamp: String,
@@ -151,41 +176,125 @@ fn calculate_cost(model: &str, usage: &UsageData) -> f64 {
// 独立的模型价格匹配函数,更精确的模型识别
fn match_model_prices(model_lower: &str) -> (f64, f64, f64, f64) {
// Claude Opus 4.1 (最新最强)
// Claude Opus 系列
if model_lower.contains("opus") && (model_lower.contains("4-1") || model_lower.contains("4.1")) {
(OPUS_4_1_INPUT_PRICE, OPUS_4_1_OUTPUT_PRICE, OPUS_4_1_CACHE_WRITE_PRICE, OPUS_4_1_CACHE_READ_PRICE)
(
OPUS_4_1_INPUT_PRICE,
OPUS_4_1_OUTPUT_PRICE,
OPUS_4_1_CACHE_WRITE_PRICE,
OPUS_4_1_CACHE_READ_PRICE,
)
}
// Claude Sonnet 4
else if model_lower.contains("sonnet") && (model_lower.contains("-4-") || model_lower.contains("sonnet-4")) {
(SONNET_4_INPUT_PRICE, SONNET_4_OUTPUT_PRICE, SONNET_4_CACHE_WRITE_PRICE, SONNET_4_CACHE_READ_PRICE)
else if model_lower.contains("opus") && model_lower.contains("4") && !model_lower.contains("4-1") && !model_lower.contains("4.1") {
(
OPUS_4_INPUT_PRICE,
OPUS_4_OUTPUT_PRICE,
OPUS_4_CACHE_WRITE_PRICE,
OPUS_4_CACHE_READ_PRICE,
)
}
// Claude Haiku 3.5
else if model_lower.contains("haiku") {
(HAIKU_3_5_INPUT_PRICE, HAIKU_3_5_OUTPUT_PRICE, HAIKU_3_5_CACHE_WRITE_PRICE, HAIKU_3_5_CACHE_READ_PRICE)
}
// Claude 3.x Sonnet 系列3.7, 3.5
else if model_lower.contains("sonnet") &&
(model_lower.contains("3-7") || model_lower.contains("3.7") ||
model_lower.contains("3-5") || model_lower.contains("3.5")) {
(SONNET_3_INPUT_PRICE, SONNET_3_OUTPUT_PRICE, SONNET_3_CACHE_WRITE_PRICE, SONNET_3_CACHE_READ_PRICE)
}
// Claude 3 Opus (旧版)
else if model_lower.contains("opus") && model_lower.contains("3") {
(OPUS_3_INPUT_PRICE, OPUS_3_OUTPUT_PRICE, OPUS_3_CACHE_WRITE_PRICE, OPUS_3_CACHE_READ_PRICE)
(
OPUS_3_INPUT_PRICE,
OPUS_3_OUTPUT_PRICE,
OPUS_3_CACHE_WRITE_PRICE,
OPUS_3_CACHE_READ_PRICE,
)
}
// 默认 Sonnet(未明确版本号时)
// Claude Sonnet 系列
else if model_lower.contains("sonnet") && (model_lower.contains("4-5") || model_lower.contains("4.5")) {
(
SONNET_4_5_INPUT_PRICE,
SONNET_4_5_OUTPUT_PRICE,
SONNET_4_5_CACHE_WRITE_PRICE,
SONNET_4_5_CACHE_READ_PRICE,
)
}
else if model_lower.contains("sonnet") && (model_lower.contains("-4-") || model_lower.contains("sonnet-4") || model_lower.contains("4-20")) {
(
SONNET_4_INPUT_PRICE,
SONNET_4_OUTPUT_PRICE,
SONNET_4_CACHE_WRITE_PRICE,
SONNET_4_CACHE_READ_PRICE,
)
}
else if model_lower.contains("sonnet") && (model_lower.contains("3-7") || model_lower.contains("3.7")) {
(
SONNET_3_7_INPUT_PRICE,
SONNET_3_7_OUTPUT_PRICE,
SONNET_3_7_CACHE_WRITE_PRICE,
SONNET_3_7_CACHE_READ_PRICE,
)
}
else if model_lower.contains("sonnet") && (model_lower.contains("3-5") || model_lower.contains("3.5")) {
(
SONNET_3_5_INPUT_PRICE,
SONNET_3_5_OUTPUT_PRICE,
SONNET_3_5_CACHE_WRITE_PRICE,
SONNET_3_5_CACHE_READ_PRICE,
)
}
// Claude Haiku 系列
else if model_lower.contains("haiku") && (model_lower.contains("4-5") || model_lower.contains("4.5")) {
(
HAIKU_4_5_INPUT_PRICE,
HAIKU_4_5_OUTPUT_PRICE,
HAIKU_4_5_CACHE_WRITE_PRICE,
HAIKU_4_5_CACHE_READ_PRICE,
)
}
else if model_lower.contains("haiku") && (model_lower.contains("3-5") || model_lower.contains("3.5")) {
(
HAIKU_3_5_INPUT_PRICE,
HAIKU_3_5_OUTPUT_PRICE,
HAIKU_3_5_CACHE_WRITE_PRICE,
HAIKU_3_5_CACHE_READ_PRICE,
)
}
else if model_lower.contains("haiku") && model_lower.contains("3") && !model_lower.contains("3-5") && !model_lower.contains("3.5") {
(
HAIKU_3_INPUT_PRICE,
HAIKU_3_OUTPUT_PRICE,
HAIKU_3_CACHE_WRITE_PRICE,
HAIKU_3_CACHE_READ_PRICE,
)
}
// 默认 Sonnet未明确版本号时使用 Sonnet 4 作为默认)
else if model_lower.contains("sonnet") {
(SONNET_3_INPUT_PRICE, SONNET_3_OUTPUT_PRICE, SONNET_3_CACHE_WRITE_PRICE, SONNET_3_CACHE_READ_PRICE)
(
SONNET_4_INPUT_PRICE,
SONNET_4_OUTPUT_PRICE,
SONNET_4_CACHE_WRITE_PRICE,
SONNET_4_CACHE_READ_PRICE,
)
}
// 默认 Opus未明确版本号时假设是最新版
// 默认 Opus未明确版本号时假设是最新版 4.1
else if model_lower.contains("opus") {
(OPUS_4_1_INPUT_PRICE, OPUS_4_1_OUTPUT_PRICE, OPUS_4_1_CACHE_WRITE_PRICE, OPUS_4_1_CACHE_READ_PRICE)
(
OPUS_4_1_INPUT_PRICE,
OPUS_4_1_OUTPUT_PRICE,
OPUS_4_1_CACHE_WRITE_PRICE,
OPUS_4_1_CACHE_READ_PRICE,
)
}
// 未知模型
// 默认 Haiku未明确版本号时使用 Haiku 3.5
else if model_lower.contains("haiku") {
(
HAIKU_3_5_INPUT_PRICE,
HAIKU_3_5_OUTPUT_PRICE,
HAIKU_3_5_CACHE_WRITE_PRICE,
HAIKU_3_5_CACHE_READ_PRICE,
)
}
// 未知模型 - 使用 Sonnet 4 作为默认(用户要求的默认价格)
else {
log::warn!("Unknown model for cost calculation: {}", model_lower);
// 默认使用 Sonnet 3 的价格(保守估计)
(SONNET_3_INPUT_PRICE, SONNET_3_OUTPUT_PRICE, SONNET_3_CACHE_WRITE_PRICE, SONNET_3_CACHE_READ_PRICE)
log::warn!("Unknown model for cost calculation: {}, using Sonnet 4 prices as default", model_lower);
(
SONNET_4_INPUT_PRICE,
SONNET_4_OUTPUT_PRICE,
SONNET_4_CACHE_WRITE_PRICE,
SONNET_4_CACHE_READ_PRICE,
)
}
}
@@ -236,7 +345,8 @@ pub fn parse_jsonl_file(
// 智能去重策略
let has_io_tokens = usage.input_tokens.unwrap_or(0) > 0
|| usage.output_tokens.unwrap_or(0) > 0;
let has_cache_tokens = usage.cache_creation_input_tokens.unwrap_or(0) > 0
let has_cache_tokens = usage.cache_creation_input_tokens.unwrap_or(0)
> 0
|| usage.cache_read_input_tokens.unwrap_or(0) > 0;
let should_skip = if has_io_tokens {
@@ -254,7 +364,9 @@ pub fn parse_jsonl_file(
}
} else if has_cache_tokens {
// 缓存令牌:使用 message_id + request_id 宽松去重
if let (Some(msg_id), Some(req_id)) = (&message.id, &entry.request_id) {
if let (Some(msg_id), Some(req_id)) =
(&message.id, &entry.request_id)
{
let unique_hash = format!("cache:{}:{}", msg_id, req_id);
if processed_hashes.contains(&unique_hash) {
true
@@ -287,13 +399,16 @@ pub fn parse_jsonl_file(
.unwrap_or_else(|| encoded_project_name.to_string());
// 转换时间戳为本地时间格式
let local_timestamp = if let Ok(dt) = DateTime::parse_from_rfc3339(&entry.timestamp) {
// 转换为本地时区并格式化为 ISO 格式
dt.with_timezone(&Local).format("%Y-%m-%d %H:%M:%S%.3f").to_string()
} else {
// 如果解析失败,保留原始时间戳
entry.timestamp.clone()
};
let local_timestamp =
if let Ok(dt) = DateTime::parse_from_rfc3339(&entry.timestamp) {
// 转换为本地时区并格式化为 ISO 格式
dt.with_timezone(&Local)
.format("%Y-%m-%d %H:%M:%S%.3f")
.to_string()
} else {
// 如果解析失败,保留原始时间戳
entry.timestamp.clone()
};
entries.push(UsageEntry {
timestamp: local_timestamp,
@@ -414,7 +529,9 @@ pub fn get_usage_stats(days: Option<u32>) -> Result<UsageStats, String> {
// 处理新的本地时间格式 "YYYY-MM-DD HH:MM:SS.sss"
let date = if e.timestamp.contains(' ') {
// 新格式:直接解析日期部分
e.timestamp.split(' ').next()
e.timestamp
.split(' ')
.next()
.and_then(|date_str| NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok())
} else if let Ok(dt) = DateTime::parse_from_rfc3339(&e.timestamp) {
// 旧格式RFC3339 格式
@@ -487,7 +604,12 @@ pub fn get_usage_stats(days: Option<u32>) -> Result<UsageStats, String> {
// 处理新的本地时间格式 "YYYY-MM-DD HH:MM:SS.sss"
let date = if entry.timestamp.contains(' ') {
// 新格式:直接提取日期部分
entry.timestamp.split(' ').next().unwrap_or(&entry.timestamp).to_string()
entry
.timestamp
.split(' ')
.next()
.unwrap_or(&entry.timestamp)
.to_string()
} else if let Ok(dt) = DateTime::parse_from_rfc3339(&entry.timestamp) {
// 旧格式RFC3339 格式
dt.with_timezone(&Local).date_naive().to_string()
@@ -631,7 +753,9 @@ pub fn get_usage_by_date_range(start_date: String, end_date: String) -> Result<U
// 处理新的本地时间格式 "YYYY-MM-DD HH:MM:SS.sss"
let date = if e.timestamp.contains(' ') {
// 新格式:直接解析日期部分
e.timestamp.split(' ').next()
e.timestamp
.split(' ')
.next()
.and_then(|date_str| NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok())
} else if let Ok(dt) = DateTime::parse_from_rfc3339(&e.timestamp) {
// 旧格式RFC3339 格式
@@ -716,7 +840,12 @@ pub fn get_usage_by_date_range(start_date: String, end_date: String) -> Result<U
// 处理新的本地时间格式 "YYYY-MM-DD HH:MM:SS.sss"
let date = if entry.timestamp.contains(' ') {
// 新格式:直接提取日期部分
entry.timestamp.split(' ').next().unwrap_or(&entry.timestamp).to_string()
entry
.timestamp
.split(' ')
.next()
.unwrap_or(&entry.timestamp)
.to_string()
} else if let Ok(dt) = DateTime::parse_from_rfc3339(&entry.timestamp) {
// 旧格式RFC3339 格式
dt.with_timezone(&Local).date_naive().to_string()
@@ -889,7 +1018,9 @@ pub fn get_session_stats(
// 处理新的本地时间格式 "YYYY-MM-DD HH:MM:SS.sss"
let date = if e.timestamp.contains(' ') {
// 新格式:直接解析日期部分
e.timestamp.split(' ').next()
e.timestamp
.split(' ')
.next()
.and_then(|date_str| NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok())
} else if let Ok(dt) = DateTime::parse_from_rfc3339(&e.timestamp) {
// 旧格式RFC3339 格式

View File

@@ -9,15 +9,14 @@ use tauri::{command, State};
use walkdir::WalkDir;
use super::usage::{
UsageStats, ModelUsage, DailyUsage, ProjectUsage, UsageEntry,
parse_jsonl_file
parse_jsonl_file, DailyUsage, ModelUsage, ProjectUsage, UsageEntry, UsageStats,
};
#[derive(Default)]
pub struct UsageCacheState {
pub conn: Arc<Mutex<Option<Connection>>>,
pub last_scan_time: Arc<Mutex<Option<i64>>>,
pub is_scanning: Arc<Mutex<bool>>, // 防止并发扫描
pub is_scanning: Arc<Mutex<bool>>, // 防止并发扫描
}
#[derive(Debug, Serialize, Deserialize)]
@@ -44,10 +43,10 @@ fn ensure_parent_dir(p: &Path) -> std::io::Result<()> {
pub fn init_cache_db() -> rusqlite::Result<Connection> {
let path = db_path();
ensure_parent_dir(&path).map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", &"WAL")?;
// Create schema
conn.execute_batch(
r#"
@@ -86,7 +85,7 @@ pub fn init_cache_db() -> rusqlite::Result<Connection> {
CREATE INDEX IF NOT EXISTS idx_entries_model ON usage_entries(model);
"#,
)?;
Ok(conn)
}
@@ -100,18 +99,22 @@ fn get_file_mtime_ms(path: &Path) -> i64 {
}
fn get_file_size(path: &Path) -> i64 {
fs::metadata(path)
.map(|m| m.len() as i64)
.unwrap_or(0)
fs::metadata(path).map(|m| m.len() as i64).unwrap_or(0)
}
fn generate_unique_hash(entry: &UsageEntry, has_io_tokens: bool, has_cache_tokens: bool) -> String {
if has_io_tokens {
// For I/O tokens: use session_id + timestamp + model
format!("io:{}:{}:{}", entry.session_id, entry.timestamp, entry.model)
format!(
"io:{}:{}:{}",
entry.session_id, entry.timestamp, entry.model
)
} else if has_cache_tokens {
// For cache tokens: use timestamp + model + project
format!("cache:{}:{}:{}", entry.timestamp, entry.model, entry.project_path)
format!(
"cache:{}:{}:{}",
entry.timestamp, entry.model, entry.project_path
)
} else {
// Fallback
format!("other:{}:{}", entry.timestamp, entry.session_id)
@@ -133,12 +136,12 @@ pub async fn usage_scan_update(state: State<'_, UsageCacheState>) -> Result<Scan
}
*is_scanning = true;
}
// 确保在函数退出时重置扫描状态
struct ScanGuard<'a> {
is_scanning: &'a Arc<Mutex<bool>>,
}
impl<'a> Drop for ScanGuard<'a> {
fn drop(&mut self) {
if let Ok(mut is_scanning) = self.is_scanning.lock() {
@@ -146,57 +149,59 @@ pub async fn usage_scan_update(state: State<'_, UsageCacheState>) -> Result<Scan
}
}
}
let _guard = ScanGuard {
is_scanning: &state.is_scanning,
};
let start_time = Utc::now().timestamp_millis();
// Initialize or get connection
let mut conn_guard = state.conn.lock().map_err(|e| e.to_string())?;
if conn_guard.is_none() {
*conn_guard = Some(init_cache_db().map_err(|e| e.to_string())?);
}
let conn = conn_guard.as_mut().unwrap();
let claude_path = dirs::home_dir()
.ok_or("Failed to get home directory")?
.join(".claude");
let projects_dir = claude_path.join("projects");
// Get existing scanned files from DB
let mut existing_files: HashMap<String, (i64, i64)> = HashMap::new();
{
let mut stmt = conn
.prepare("SELECT file_path, file_size, mtime_ms FROM scanned_files")
.map_err(|e| e.to_string())?;
let rows = stmt.query_map(params![], |row| {
Ok((
row.get::<_, String>(0)?,
(row.get::<_, i64>(1)?, row.get::<_, i64>(2)?),
))
}).map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![], |row| {
Ok((
row.get::<_, String>(0)?,
(row.get::<_, i64>(1)?, row.get::<_, i64>(2)?),
))
})
.map_err(|e| e.to_string())?;
for row in rows {
if let Ok((path, data)) = row {
existing_files.insert(path, data);
}
}
}
// Find all .jsonl files
let mut files_to_process = Vec::new();
let mut all_current_files = HashSet::new();
if let Ok(projects) = fs::read_dir(&projects_dir) {
for project in projects.flatten() {
if project.file_type().map(|t| t.is_dir()).unwrap_or(false) {
let project_name = project.file_name().to_string_lossy().to_string();
let project_path = project.path();
WalkDir::new(&project_path)
.into_iter()
.filter_map(Result::ok)
@@ -205,17 +210,19 @@ pub async fn usage_scan_update(state: State<'_, UsageCacheState>) -> Result<Scan
let path = entry.path().to_path_buf();
let path_str = path.to_string_lossy().to_string();
all_current_files.insert(path_str.clone());
// Check if file needs processing
let current_size = get_file_size(&path);
let current_mtime = get_file_mtime_ms(&path);
let needs_processing = if let Some((stored_size, stored_mtime)) = existing_files.get(&path_str) {
let needs_processing = if let Some((stored_size, stored_mtime)) =
existing_files.get(&path_str)
{
current_size != *stored_size || current_mtime != *stored_mtime
} else {
true // New file
};
if needs_processing {
files_to_process.push((path, project_name.clone()));
}
@@ -223,23 +230,23 @@ pub async fn usage_scan_update(state: State<'_, UsageCacheState>) -> Result<Scan
}
}
}
let mut files_scanned = 0u32;
let mut entries_added = 0u32;
let mut entries_skipped = 0u32;
// Process files that need updating
let tx = conn.transaction().map_err(|e| e.to_string())?;
for (file_path, project_name) in files_to_process {
let path_str = file_path.to_string_lossy().to_string();
let file_size = get_file_size(&file_path);
let mtime_ms = get_file_mtime_ms(&file_path);
// Parse the JSONL file and get entries
let mut processed_hashes = HashSet::new();
let entries = parse_jsonl_file(&file_path, &project_name, &mut processed_hashes);
// Insert or update file record
tx.execute(
"INSERT INTO scanned_files (file_path, file_size, mtime_ms, last_scanned_ms, entry_count)
@@ -251,13 +258,13 @@ pub async fn usage_scan_update(state: State<'_, UsageCacheState>) -> Result<Scan
entry_count = excluded.entry_count",
params![path_str, file_size, mtime_ms, start_time, entries.len() as i64],
).map_err(|e| e.to_string())?;
// Insert usage entries
for entry in entries {
let has_io_tokens = entry.input_tokens > 0 || entry.output_tokens > 0;
let has_cache_tokens = entry.cache_creation_tokens > 0 || entry.cache_read_tokens > 0;
let unique_hash = generate_unique_hash(&entry, has_io_tokens, has_cache_tokens);
let result = tx.execute(
"INSERT INTO usage_entries (
timestamp, model, input_tokens, output_tokens,
@@ -279,34 +286,40 @@ pub async fn usage_scan_update(state: State<'_, UsageCacheState>) -> Result<Scan
unique_hash,
],
);
match result {
Ok(n) if n > 0 => entries_added += 1,
_ => entries_skipped += 1,
}
}
files_scanned += 1;
}
// Remove entries for files that no longer exist
for (old_path, _) in existing_files {
if !all_current_files.contains(&old_path) {
tx.execute("DELETE FROM usage_entries WHERE file_path = ?1", params![old_path])
.map_err(|e| e.to_string())?;
tx.execute("DELETE FROM scanned_files WHERE file_path = ?1", params![old_path])
.map_err(|e| e.to_string())?;
tx.execute(
"DELETE FROM usage_entries WHERE file_path = ?1",
params![old_path],
)
.map_err(|e| e.to_string())?;
tx.execute(
"DELETE FROM scanned_files WHERE file_path = ?1",
params![old_path],
)
.map_err(|e| e.to_string())?;
}
}
tx.commit().map_err(|e| e.to_string())?;
// Update last scan time
let mut last_scan = state.last_scan_time.lock().map_err(|e| e.to_string())?;
*last_scan = Some(start_time);
let scan_time_ms = (Utc::now().timestamp_millis() - start_time) as u64;
Ok(ScanResult {
files_scanned,
entries_added,
@@ -325,16 +338,16 @@ pub async fn usage_get_stats_cached(
let conn_guard = state.conn.lock().map_err(|e| e.to_string())?;
conn_guard.is_none()
};
if needs_init {
// 首次调用,需要初始化和扫描
usage_scan_update(state.clone()).await?;
}
// 移除自动扫描逻辑,让系统只在手动触发时扫描
let conn_guard = state.conn.lock().map_err(|e| e.to_string())?;
let conn = conn_guard.as_ref().ok_or("Database not initialized")?;
// Build date filter
let date_filter = if let Some(d) = days {
let cutoff = Local::now().naive_local().date() - chrono::Duration::days(d as i64);
@@ -342,12 +355,17 @@ pub async fn usage_get_stats_cached(
} else {
None
};
// Query total stats
let (total_cost, total_input, total_output, total_cache_creation, total_cache_read): (f64, i64, i64, i64, i64) =
if let Some(cutoff) = &date_filter {
conn.query_row(
"SELECT
let (total_cost, total_input, total_output, total_cache_creation, total_cache_read): (
f64,
i64,
i64,
i64,
i64,
) = if let Some(cutoff) = &date_filter {
conn.query_row(
"SELECT
COALESCE(SUM(cost), 0.0),
COALESCE(SUM(input_tokens), 0),
COALESCE(SUM(output_tokens), 0),
@@ -355,40 +373,60 @@ pub async fn usage_get_stats_cached(
COALESCE(SUM(cache_read_tokens), 0)
FROM usage_entries
WHERE timestamp >= ?1",
params![cutoff],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
).map_err(|e| e.to_string())?
} else {
conn.query_row(
"SELECT
params![cutoff],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
},
)
.map_err(|e| e.to_string())?
} else {
conn.query_row(
"SELECT
COALESCE(SUM(cost), 0.0),
COALESCE(SUM(input_tokens), 0),
COALESCE(SUM(output_tokens), 0),
COALESCE(SUM(cache_creation_tokens), 0),
COALESCE(SUM(cache_read_tokens), 0)
FROM usage_entries",
params![],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
).map_err(|e| e.to_string())?
};
params![],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
},
)
.map_err(|e| e.to_string())?
};
let total_tokens = total_input + total_output + total_cache_creation + total_cache_read;
// Get session count
let total_sessions: i64 = if let Some(cutoff) = &date_filter {
conn.query_row(
"SELECT COUNT(DISTINCT session_id) FROM usage_entries WHERE timestamp >= ?1",
params![cutoff],
|row| row.get(0),
).map_err(|e| e.to_string())?
)
.map_err(|e| e.to_string())?
} else {
conn.query_row(
"SELECT COUNT(DISTINCT session_id) FROM usage_entries",
params![],
|row| row.get(0),
).map_err(|e| e.to_string())?
)
.map_err(|e| e.to_string())?
};
// Get stats by model
let mut by_model = Vec::new();
{
@@ -418,9 +456,9 @@ pub async fn usage_get_stats_cached(
GROUP BY model
ORDER BY total_cost DESC"
};
let mut stmt = conn.prepare(query).map_err(|e| e.to_string())?;
// Create closure once to avoid type mismatch
let create_model_usage = |row: &rusqlite::Row| -> rusqlite::Result<ModelUsage> {
Ok(ModelUsage {
@@ -434,22 +472,26 @@ pub async fn usage_get_stats_cached(
total_tokens: 0, // Will calculate below
})
};
let rows = if let Some(cutoff) = &date_filter {
stmt.query_map(params![cutoff], create_model_usage).map_err(|e| e.to_string())?
stmt.query_map(params![cutoff], create_model_usage)
.map_err(|e| e.to_string())?
} else {
stmt.query_map(params![], create_model_usage).map_err(|e| e.to_string())?
stmt.query_map(params![], create_model_usage)
.map_err(|e| e.to_string())?
};
for row in rows {
if let Ok(mut usage) = row {
usage.total_tokens = usage.input_tokens + usage.output_tokens +
usage.cache_creation_tokens + usage.cache_read_tokens;
usage.total_tokens = usage.input_tokens
+ usage.output_tokens
+ usage.cache_creation_tokens
+ usage.cache_read_tokens;
by_model.push(usage);
}
}
}
// Get daily stats
let mut by_date = Vec::new();
{
@@ -483,19 +525,21 @@ pub async fn usage_get_stats_cached(
GROUP BY DATE(timestamp)
ORDER BY date DESC"
};
let mut stmt = conn.prepare(query).map_err(|e| e.to_string())?;
// Create closure once to avoid type mismatch
let create_daily_usage = |row: &rusqlite::Row| -> rusqlite::Result<DailyUsage> {
let models_str: String = row.get(8)?;
let models_used: Vec<String> = models_str.split(',').map(|s| s.to_string()).collect();
Ok(DailyUsage {
date: row.get(0)?,
total_cost: row.get(1)?,
total_tokens: (row.get::<_, i64>(2)? + row.get::<_, i64>(3)? +
row.get::<_, i64>(4)? + row.get::<_, i64>(5)?) as u64,
total_tokens: (row.get::<_, i64>(2)?
+ row.get::<_, i64>(3)?
+ row.get::<_, i64>(4)?
+ row.get::<_, i64>(5)?) as u64,
input_tokens: row.get::<_, i64>(2)? as u64,
output_tokens: row.get::<_, i64>(3)? as u64,
cache_creation_tokens: row.get::<_, i64>(4)? as u64,
@@ -504,20 +548,22 @@ pub async fn usage_get_stats_cached(
models_used,
})
};
let rows = if let Some(cutoff) = &date_filter {
stmt.query_map(params![cutoff], create_daily_usage).map_err(|e| e.to_string())?
stmt.query_map(params![cutoff], create_daily_usage)
.map_err(|e| e.to_string())?
} else {
stmt.query_map(params![], create_daily_usage).map_err(|e| e.to_string())?
stmt.query_map(params![], create_daily_usage)
.map_err(|e| e.to_string())?
};
for row in rows {
if let Ok(daily) = row {
by_date.push(daily);
}
}
}
// Get project stats
let mut by_project = Vec::new();
{
@@ -543,9 +589,9 @@ pub async fn usage_get_stats_cached(
GROUP BY project_path
ORDER BY total_cost DESC"
};
let mut stmt = conn.prepare(query).map_err(|e| e.to_string())?;
// Create closure once to avoid type mismatch
let create_project_usage = |row: &rusqlite::Row| -> rusqlite::Result<ProjectUsage> {
Ok(ProjectUsage {
@@ -557,17 +603,20 @@ pub async fn usage_get_stats_cached(
last_used: row.get(4)?,
})
};
let rows = if let Some(cutoff) = &date_filter {
stmt.query_map(params![cutoff], create_project_usage).map_err(|e| e.to_string())?
stmt.query_map(params![cutoff], create_project_usage)
.map_err(|e| e.to_string())?
} else {
stmt.query_map(params![], create_project_usage).map_err(|e| e.to_string())?
stmt.query_map(params![], create_project_usage)
.map_err(|e| e.to_string())?
};
for row in rows {
if let Ok(mut project) = row {
// Extract project name from path
project.project_name = project.project_path
project.project_name = project
.project_path
.split('/')
.last()
.unwrap_or(&project.project_path)
@@ -576,7 +625,7 @@ pub async fn usage_get_stats_cached(
}
}
}
Ok(UsageStats {
total_cost,
total_tokens: total_tokens as u64,
@@ -594,20 +643,20 @@ pub async fn usage_get_stats_cached(
#[command]
pub async fn usage_clear_cache(state: State<'_, UsageCacheState>) -> Result<String, String> {
let mut conn_guard = state.conn.lock().map_err(|e| e.to_string())?;
if let Some(conn) = conn_guard.as_mut() {
conn.execute("DELETE FROM usage_entries", params![])
.map_err(|e| e.to_string())?;
conn.execute("DELETE FROM scanned_files", params![])
.map_err(|e| e.to_string())?;
// 重置last scan time
let mut last_scan = state.last_scan_time.lock().map_err(|e| e.to_string())?;
*last_scan = None;
return Ok("Cache cleared successfully. All costs will be recalculated.".to_string());
}
Ok("No cache to clear.".to_string())
}
@@ -615,37 +664,39 @@ pub async fn usage_clear_cache(state: State<'_, UsageCacheState>) -> Result<Stri
pub async fn check_files_changed(state: &State<'_, UsageCacheState>) -> Result<bool, String> {
let conn_guard = state.conn.lock().map_err(|e| e.to_string())?;
let conn = conn_guard.as_ref().ok_or("Database not initialized")?;
let claude_path = dirs::home_dir()
.ok_or("Failed to get home directory")?
.join(".claude");
let projects_dir = claude_path.join("projects");
// 获取已知文件的修改时间和大小
let mut stmt = conn
.prepare("SELECT file_path, file_size, mtime_ms FROM scanned_files")
.map_err(|e| e.to_string())?;
let mut known_files = std::collections::HashMap::new();
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
(row.get::<_, i64>(1)?, row.get::<_, i64>(2)?),
))
}).map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
(row.get::<_, i64>(1)?, row.get::<_, i64>(2)?),
))
})
.map_err(|e| e.to_string())?;
for row in rows {
if let Ok((path, data)) = row {
known_files.insert(path, data);
}
}
// 快速检查是否有文件变化
if let Ok(projects) = fs::read_dir(&projects_dir) {
for project in projects.flatten() {
if project.file_type().map(|t| t.is_dir()).unwrap_or(false) {
let project_path = project.path();
for entry in walkdir::WalkDir::new(&project_path)
.into_iter()
.filter_map(Result::ok)
@@ -655,7 +706,7 @@ pub async fn check_files_changed(state: &State<'_, UsageCacheState>) -> Result<b
let path_str = path.to_string_lossy().to_string();
let current_size = get_file_size(path);
let current_mtime = get_file_mtime_ms(path);
if let Some((stored_size, stored_mtime)) = known_files.get(&path_str) {
if current_size != *stored_size || current_mtime != *stored_mtime {
return Ok(true); // 发现变化
@@ -667,7 +718,7 @@ pub async fn check_files_changed(state: &State<'_, UsageCacheState>) -> Result<b
}
}
}
Ok(false) // 没有变化
}
@@ -681,4 +732,4 @@ pub async fn usage_force_scan(state: State<'_, UsageCacheState>) -> Result<ScanR
pub async fn usage_check_updates(state: State<'_, UsageCacheState>) -> Result<bool, String> {
// 检查是否有文件更新
check_files_changed(&state).await
}
}

View File

@@ -32,14 +32,20 @@ pub struct UsageSummary {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportResult { pub inserted: u64, pub skipped: u64, pub errors: u64 }
pub struct ImportResult {
pub inserted: u64,
pub skipped: u64,
pub errors: u64,
}
fn db_path_for(project_root: &Path) -> PathBuf {
project_root.join(".claudia/cache/usage.sqlite")
}
fn ensure_parent_dir(p: &Path) -> std::io::Result<()> {
if let Some(dir) = p.parent() { std::fs::create_dir_all(dir)?; }
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir)?;
}
Ok(())
}
@@ -101,7 +107,9 @@ fn sha256_file(path: &Path) -> std::io::Result<String> {
let mut buf = [0u8; 8192];
loop {
let n = file.read(&mut buf)?;
if n == 0 { break; }
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(format!("{:x}", hasher.finalize()))
@@ -124,9 +132,13 @@ fn count_lines_chars_tokens(path: &Path) -> std::io::Result<(u64, u64, u64)> {
fn should_exclude(rel: &str, excludes: &HashSet<String>) -> bool {
// simple prefix/segment check
let default = ["node_modules/", "dist/", "target/", ".git/" ];
if default.iter().any(|p| rel.starts_with(p)) { return true; }
if rel.ends_with(".lock") { return true; }
let default = ["node_modules/", "dist/", "target/", ".git/"];
if default.iter().any(|p| rel.starts_with(p)) {
return true;
}
if rel.ends_with(".lock") {
return true;
}
excludes.iter().any(|p| rel.starts_with(p))
}
@@ -137,34 +149,56 @@ pub async fn usage_scan_index(
state: State<'_, UsageIndexState>,
) -> Result<String, String> {
let project = PathBuf::from(project_root.clone());
if !project.is_dir() { return Err("project_root is not a directory".into()); }
if !project.is_dir() {
return Err("project_root is not a directory".into());
}
let job_id = uuid::Uuid::new_v4().to_string();
{
let mut jobs = state.jobs.lock().map_err(|e| e.to_string())?;
jobs.insert(job_id.clone(), ScanProgress{ processed:0, total:0, started_ts: Utc::now().timestamp_millis(), finished_ts: None});
jobs.insert(
job_id.clone(),
ScanProgress {
processed: 0,
total: 0,
started_ts: Utc::now().timestamp_millis(),
finished_ts: None,
},
);
}
let excludes: HashSet<String> = exclude.unwrap_or_default().into_iter().collect();
let state_jobs = state.jobs.clone();
let job_id_task = job_id.clone();
let job_id_ret = job_id.clone();
tauri::async_runtime::spawn(async move {
let mut conn = match open_db(&project) { Ok(c)=>c, Err(e)=>{ log::error!("DB open error: {}", e); return; } };
let mut conn = match open_db(&project) {
Ok(c) => c,
Err(e) => {
log::error!("DB open error: {}", e);
return;
}
};
// First pass: count total
let mut total: u64 = 0;
for entry in WalkDir::new(&project).into_iter().filter_map(Result::ok) {
if entry.file_type().is_file() {
if let Ok(rel) = entry.path().strip_prefix(&project) {
let rel = rel.to_string_lossy().replace('\\',"/");
if should_exclude(&format!("{}/", rel).trim_end_matches('/'), &excludes) { continue; }
let rel = rel.to_string_lossy().replace('\\', "/");
if should_exclude(&format!("{}/", rel).trim_end_matches('/'), &excludes) {
continue;
}
total += 1;
}
}
}
{
if let Ok(mut jobs) = state_jobs.lock() { if let Some(p) = jobs.get_mut(&job_id_task){ p.total = total; } }
if let Ok(mut jobs) = state_jobs.lock() {
if let Some(p) = jobs.get_mut(&job_id_task) {
p.total = total;
}
}
}
// Cache existing file meta
let mut existing: HashMap<String,(i64,i64,String,i64)> = HashMap::new(); // rel -> (size, mtime, sha, file_id)
let mut existing: HashMap<String, (i64, i64, String, i64)> = HashMap::new(); // rel -> (size, mtime, sha, file_id)
{
let stmt = conn.prepare("SELECT id, rel_path, size_bytes, mtime_ms, sha256 FROM files WHERE project_root=?1").ok();
if let Some(mut st) = stmt {
@@ -176,7 +210,11 @@ pub async fn usage_scan_index(
let sha: String = row.get(4)?;
Ok((rel, (size, mtime, sha, id)))
});
if let Ok(rows) = rows { for r in rows.flatten(){ existing.insert(r.0, r.1); } }
if let Ok(rows) = rows {
for r in rows.flatten() {
existing.insert(r.0, r.1);
}
}
}
}
@@ -188,17 +226,37 @@ pub async fn usage_scan_index(
for entry in WalkDir::new(&project).into_iter().filter_map(Result::ok) {
if entry.file_type().is_file() {
if let Ok(relp) = entry.path().strip_prefix(&project) {
let rel = relp.to_string_lossy().replace('\\',"/");
let rel = relp.to_string_lossy().replace('\\', "/");
let rel_norm = rel.clone();
if should_exclude(&format!("{}/", rel_norm).trim_end_matches('/'), &excludes) { continue; }
let md = match entry.metadata() { Ok(m)=>m, Err(_)=>{ continue } };
if should_exclude(
&format!("{}/", rel_norm).trim_end_matches('/'),
&excludes,
) {
continue;
}
let md = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let size = md.len() as i64;
let mtime = md.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as i64).unwrap_or(0);
let mtime = md
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let mut content_changed = true;
let sha: String;
if let Some((esize, emtime, esha, _fid)) = existing.get(&rel_norm) {
if *esize == size && *emtime == mtime { content_changed = false; sha = esha.clone(); }
else { sha = sha256_file(entry.path()).unwrap_or_default(); if sha == *esha { content_changed = false; } }
if *esize == size && *emtime == mtime {
content_changed = false;
sha = esha.clone();
} else {
sha = sha256_file(entry.path()).unwrap_or_default();
if sha == *esha {
content_changed = false;
}
}
} else {
sha = sha256_file(entry.path()).unwrap_or_default();
}
@@ -211,14 +269,19 @@ pub async fn usage_scan_index(
).ok();
// get file_id
let file_id: i64 = tx.query_row(
"SELECT id FROM files WHERE project_root=?1 AND rel_path=?2",
params![project.to_string_lossy(), rel_norm], |row| row.get(0)
).unwrap_or(-1);
let file_id: i64 = tx
.query_row(
"SELECT id FROM files WHERE project_root=?1 AND rel_path=?2",
params![project.to_string_lossy(), rel_norm],
|row| row.get(0),
)
.unwrap_or(-1);
// metrics
if content_changed {
if let Ok((lines, chars, tokens)) = count_lines_chars_tokens(entry.path()) {
if let Ok((lines, chars, tokens)) =
count_lines_chars_tokens(entry.path())
{
tx.execute(
"INSERT INTO file_metrics(file_id, snapshot_ts, lines, tokens, chars) VALUES (?1,?2,?3,?4,?5)",
params![file_id, now, lines as i64, tokens as i64, chars as i64]
@@ -228,13 +291,29 @@ pub async fn usage_scan_index(
"SELECT lines, tokens, snapshot_ts FROM file_metrics WHERE file_id=?1 ORDER BY snapshot_ts DESC LIMIT 1 OFFSET 1",
params![file_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))
).ok();
let (added_l, removed_l, added_t, removed_t, prev_ts, change_type) = match prev {
None => (lines as i64, 0, tokens as i64, 0, None, "created".to_string()),
Some((pl, pt, pts)) => {
let dl = lines as i64 - pl; let dt = tokens as i64 - pt;
(dl.max(0), (-dl).max(0), dt.max(0), (-dt).max(0), Some(pts), "modified".to_string())
}
};
let (added_l, removed_l, added_t, removed_t, prev_ts, change_type) =
match prev {
None => (
lines as i64,
0,
tokens as i64,
0,
None,
"created".to_string(),
),
Some((pl, pt, pts)) => {
let dl = lines as i64 - pl;
let dt = tokens as i64 - pt;
(
dl.max(0),
(-dl).max(0),
dt.max(0),
(-dt).max(0),
Some(pts),
"modified".to_string(),
)
}
};
tx.execute(
"INSERT INTO file_diffs(file_id, snapshot_ts, prev_snapshot_ts, added_lines, removed_lines, added_tokens, removed_tokens, change_type) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
params![file_id, now, prev_ts, added_l, removed_l, added_t, removed_t, change_type]
@@ -243,22 +322,42 @@ pub async fn usage_scan_index(
}
seen.insert(rel_norm);
processed += 1;
if let Ok(mut jobs) = state_jobs.lock() { if let Some(p) = jobs.get_mut(&job_id_task){ p.processed = processed; } }
if let Ok(mut jobs) = state_jobs.lock() {
if let Some(p) = jobs.get_mut(&job_id_task) {
p.processed = processed;
}
}
}
}
}
// deletions: files in DB but not seen
let mut to_delete: Vec<(i64,i64,i64)> = Vec::new(); // (file_id, last_lines, last_tokens)
let mut to_delete: Vec<(i64, i64, i64)> = Vec::new(); // (file_id, last_lines, last_tokens)
{
let stmt = tx.prepare("SELECT f.id, m.lines, m.tokens FROM files f LEFT JOIN file_metrics m ON m.file_id=f.id WHERE f.project_root=?1 AND m.snapshot_ts=(SELECT MAX(snapshot_ts) FROM file_metrics WHERE file_id=f.id)").ok();
if let Some(mut st) = stmt {
let rows = st.query_map(params![project.to_string_lossy()], |row| Ok((row.get(0)?, row.get::<_,Option<i64>>(1).unwrap_or(None).unwrap_or(0), row.get::<_,Option<i64>>(2).unwrap_or(None).unwrap_or(0)))) ;
if let Ok(rows) = rows { for r in rows.flatten() { to_delete.push(r); } }
let rows = st.query_map(params![project.to_string_lossy()], |row| {
Ok((
row.get(0)?,
row.get::<_, Option<i64>>(1).unwrap_or(None).unwrap_or(0),
row.get::<_, Option<i64>>(2).unwrap_or(None).unwrap_or(0),
))
});
if let Ok(rows) = rows {
for r in rows.flatten() {
to_delete.push(r);
}
}
}
}
for (fid, last_lines, last_tokens) in to_delete {
let rel: String = tx.query_row("SELECT rel_path FROM files WHERE id=?1", params![fid], |r| r.get(0)).unwrap_or_default();
let rel: String = tx
.query_row(
"SELECT rel_path FROM files WHERE id=?1",
params![fid],
|r| r.get(0),
)
.unwrap_or_default();
if !seen.contains(&rel) {
tx.execute(
"INSERT INTO file_diffs(file_id, snapshot_ts, prev_snapshot_ts, added_lines, removed_lines, added_tokens, removed_tokens, change_type) VALUES (?1,?2,NULL,0,?3,0,?4,'deleted')",
@@ -270,41 +369,76 @@ pub async fn usage_scan_index(
tx.commit().ok();
}
if let Ok(mut jobs) = state_jobs.lock() { if let Some(p) = jobs.get_mut(&job_id_task){ p.finished_ts = Some(Utc::now().timestamp_millis()); } }
if let Ok(mut jobs) = state_jobs.lock() {
if let Some(p) = jobs.get_mut(&job_id_task) {
p.finished_ts = Some(Utc::now().timestamp_millis());
}
}
});
Ok(job_id_ret)
}
#[tauri::command]
pub fn usage_scan_progress(job_id: String, state: State<'_, UsageIndexState>) -> Result<ScanProgress, String> {
pub fn usage_scan_progress(
job_id: String,
state: State<'_, UsageIndexState>,
) -> Result<ScanProgress, String> {
let jobs = state.jobs.lock().map_err(|e| e.to_string())?;
jobs.get(&job_id).cloned().ok_or_else(|| "job not found".into())
jobs.get(&job_id)
.cloned()
.ok_or_else(|| "job not found".into())
}
#[tauri::command]
pub fn usage_get_summary(project_root: String) -> Result<UsageSummary, String> {
let project = PathBuf::from(project_root);
let conn = open_db(&project).map_err(|e| e.to_string())?;
let files: u64 = conn.query_row("SELECT COUNT(*) FROM files WHERE project_root=?1", params![project.to_string_lossy()], |r| r.get::<_,i64>(0)).unwrap_or(0) as u64;
let mut lines: u64 = 0; let mut tokens: u64 = 0; let mut last_ts: Option<i64> = None;
let files: u64 = conn
.query_row(
"SELECT COUNT(*) FROM files WHERE project_root=?1",
params![project.to_string_lossy()],
|r| r.get::<_, i64>(0),
)
.unwrap_or(0) as u64;
let mut lines: u64 = 0;
let mut tokens: u64 = 0;
let mut last_ts: Option<i64> = None;
let mut stmt = conn.prepare("SELECT MAX(snapshot_ts), SUM(lines), SUM(tokens) FROM file_metrics WHERE file_id IN (SELECT id FROM files WHERE project_root=?1)").map_err(|e| e.to_string())?;
let res = stmt.query_row(params![project.to_string_lossy()], |r| {
Ok((r.get::<_,Option<i64>>(0)?, r.get::<_,Option<i64>>(1)?, r.get::<_,Option<i64>>(2)?))
Ok((
r.get::<_, Option<i64>>(0)?,
r.get::<_, Option<i64>>(1)?,
r.get::<_, Option<i64>>(2)?,
))
});
if let Ok((mx, lsum, tsum)) = res { last_ts = mx; lines = lsum.unwrap_or(0) as u64; tokens = tsum.unwrap_or(0) as u64; }
Ok(UsageSummary{ files, tokens, lines, last_scan_ts: last_ts })
if let Ok((mx, lsum, tsum)) = res {
last_ts = mx;
lines = lsum.unwrap_or(0) as u64;
tokens = tsum.unwrap_or(0) as u64;
}
Ok(UsageSummary {
files,
tokens,
lines,
last_scan_ts: last_ts,
})
}
#[derive(Debug, Deserialize)]
struct ExternalDiff {
rel_path: String,
snapshot_ts: i64,
#[serde(default)] prev_snapshot_ts: Option<i64>,
#[serde(default)] added_lines: i64,
#[serde(default)] removed_lines: i64,
#[serde(default)] added_tokens: i64,
#[serde(default)] removed_tokens: i64,
#[serde(default)]
prev_snapshot_ts: Option<i64>,
#[serde(default)]
added_lines: i64,
#[serde(default)]
removed_lines: i64,
#[serde(default)]
added_tokens: i64,
#[serde(default)]
removed_tokens: i64,
change_type: String,
}
@@ -313,19 +447,33 @@ pub fn usage_import_diffs(project_root: String, path: String) -> Result<ImportRe
let project = PathBuf::from(project_root);
let mut conn = open_db(&project).map_err(|e| e.to_string())?;
let data = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let mut inserted=0u64; let mut skipped=0u64; let mut errors=0u64;
let mut inserted = 0u64;
let mut skipped = 0u64;
let mut errors = 0u64;
let tx = conn.transaction().map_err(|e| e.to_string())?;
// try as JSON array
let mut diffs: Vec<ExternalDiff> = Vec::new();
match serde_json::from_str::<serde_json::Value>(&data) {
Ok(serde_json::Value::Array(arr)) => {
for v in arr { if let Ok(d) = serde_json::from_value::<ExternalDiff>(v) { diffs.push(d); } }
},
for v in arr {
if let Ok(d) = serde_json::from_value::<ExternalDiff>(v) {
diffs.push(d);
}
}
}
_ => {
// try NDJSON
for line in data.lines() {
let l = line.trim(); if l.is_empty() { continue; }
match serde_json::from_str::<ExternalDiff>(l) { Ok(d)=>diffs.push(d), Err(_)=>{ errors+=1; } }
let l = line.trim();
if l.is_empty() {
continue;
}
match serde_json::from_str::<ExternalDiff>(l) {
Ok(d) => diffs.push(d),
Err(_) => {
errors += 1;
}
}
}
}
}
@@ -336,18 +484,31 @@ pub fn usage_import_diffs(project_root: String, path: String) -> Result<ImportRe
ON CONFLICT(project_root, rel_path) DO NOTHING",
params![project.to_string_lossy(), d.rel_path],
).ok();
let file_id: Option<i64> = tx.query_row(
"SELECT id FROM files WHERE project_root=?1 AND rel_path=?2",
params![project.to_string_lossy(), d.rel_path], |r| r.get(0)
).ok();
let file_id: Option<i64> = tx
.query_row(
"SELECT id FROM files WHERE project_root=?1 AND rel_path=?2",
params![project.to_string_lossy(), d.rel_path],
|r| r.get(0),
)
.ok();
if let Some(fid) = file_id {
let res = tx.execute(
"INSERT INTO file_diffs(file_id, snapshot_ts, prev_snapshot_ts, added_lines, removed_lines, added_tokens, removed_tokens, change_type) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
params![fid, d.snapshot_ts, d.prev_snapshot_ts, d.added_lines, d.removed_lines, d.added_tokens, d.removed_tokens, d.change_type]
);
if res.is_ok() { inserted+=1; } else { skipped+=1; }
} else { errors+=1; }
if res.is_ok() {
inserted += 1;
} else {
skipped += 1;
}
} else {
errors += 1;
}
}
tx.commit().map_err(|e| e.to_string())?;
Ok(ImportResult{ inserted, skipped, errors })
Ok(ImportResult {
inserted,
skipped,
errors,
})
}

View File

@@ -31,7 +31,7 @@ impl FileWatcherManager {
/// 监听指定路径(文件或目录)
pub fn watch_path(&self, path: &str, recursive: bool) -> Result<(), String> {
let path_buf = PathBuf::from(path);
// 检查路径是否存在
if !path_buf.exists() {
return Err(format!("Path does not exist: {}", path));
@@ -52,20 +52,19 @@ impl FileWatcherManager {
// 创建文件监听器
let mut watcher = RecommendedWatcher::new(
move |res: Result<Event, notify::Error>| {
match res {
Ok(event) => {
Self::handle_event(event, &app_handle, &last_events);
}
Err(e) => {
log::error!("Watch error: {:?}", e);
}
move |res: Result<Event, notify::Error>| match res {
Ok(event) => {
Self::handle_event(event, &app_handle, &last_events);
}
Err(e) => {
log::error!("Watch error: {:?}", e);
}
},
Config::default()
.with_poll_interval(Duration::from_secs(1))
.with_compare_contents(false),
).map_err(|e| format!("Failed to create watcher: {}", e))?;
)
.map_err(|e| format!("Failed to create watcher: {}", e))?;
// 开始监听
let mode = if recursive {
@@ -89,7 +88,7 @@ impl FileWatcherManager {
/// 停止监听指定路径
pub fn unwatch_path(&self, path: &str) -> Result<(), String> {
let mut watchers = self.watchers.lock().unwrap();
if watchers.remove(path).is_some() {
log::info!("Stopped watching path: {}", path);
Ok(())
@@ -108,7 +107,11 @@ impl FileWatcherManager {
}
/// 处理文件系统事件
fn handle_event(event: Event, app_handle: &AppHandle, last_events: &Arc<Mutex<HashMap<PathBuf, SystemTime>>>) {
fn handle_event(
event: Event,
app_handle: &AppHandle,
last_events: &Arc<Mutex<HashMap<PathBuf, SystemTime>>>,
) {
// 过滤不需要的事件
let change_type = match event.kind {
EventKind::Create(_) => "created",
@@ -123,10 +126,12 @@ impl FileWatcherManager {
let now = SystemTime::now();
let should_emit = {
let mut last_events = last_events.lock().unwrap();
if let Some(last_time) = last_events.get(&path) {
// 如果距离上次事件不到500ms忽略
if now.duration_since(*last_time).unwrap_or(Duration::ZERO) < Duration::from_millis(500) {
if now.duration_since(*last_time).unwrap_or(Duration::ZERO)
< Duration::from_millis(500)
{
false
} else {
last_events.insert(path.clone(), now);
@@ -192,4 +197,4 @@ impl FileWatcherState {
None => Err("File watcher manager not initialized".to_string()),
}
}
}
}

View File

@@ -0,0 +1,221 @@
/// 公共 HTTP 客户端模块
///
/// 提供统一的 HTTP 客户端创建接口,消除代码重复
/// 支持多种预设配置和自定义配置
use anyhow::Result;
use reqwest::Client;
use std::time::Duration;
/// HTTP 客户端配置
#[derive(Debug, Clone)]
pub struct ClientConfig {
/// 超时时间(秒)
pub timeout_secs: u64,
/// 是否接受无效证书(用于开发/测试)
pub accept_invalid_certs: bool,
/// 是否使用系统代理
pub use_proxy: bool,
/// 自定义 User-Agent
pub user_agent: Option<String>,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
timeout_secs: 10,
accept_invalid_certs: false,
use_proxy: true,
user_agent: Some("Claudia/1.0".to_string()),
}
}
}
impl ClientConfig {
/// 创建新的配置
pub fn new() -> Self {
Self::default()
}
/// 设置超时时间
pub fn timeout(mut self, secs: u64) -> Self {
self.timeout_secs = secs;
self
}
/// 设置是否接受无效证书
pub fn accept_invalid_certs(mut self, accept: bool) -> Self {
self.accept_invalid_certs = accept;
self
}
/// 设置是否使用代理
pub fn use_proxy(mut self, use_proxy: bool) -> Self {
self.use_proxy = use_proxy;
self
}
/// 设置 User-Agent
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = Some(user_agent.into());
self
}
}
/// 创建 HTTP 客户端(使用自定义配置)
///
/// # Example
/// ```
/// use claudia_lib::http_client::{ClientConfig, create_client};
///
/// let config = ClientConfig::new()
/// .timeout(5)
/// .accept_invalid_certs(true);
/// let client = create_client(config)?;
/// ```
pub fn create_client(config: ClientConfig) -> Result<Client> {
let mut builder = Client::builder().timeout(Duration::from_secs(config.timeout_secs));
if config.accept_invalid_certs {
builder = builder.danger_accept_invalid_certs(true);
}
if !config.use_proxy {
builder = builder.no_proxy();
}
if let Some(user_agent) = config.user_agent {
builder = builder.user_agent(user_agent);
}
Ok(builder.build()?)
}
/// 创建默认 HTTP 客户端
///
/// 配置:
/// - 超时: 10 秒
/// - 接受无效证书: 否
/// - 使用代理: 是
/// - User-Agent: "Claudia/1.0"
///
/// # Example
/// ```
/// use claudia_lib::http_client::default_client;
///
/// let client = default_client()?;
/// ```
pub fn default_client() -> Result<Client> {
create_client(ClientConfig::default())
}
/// 创建快速客户端(用于节点测速)
///
/// 配置:
/// - 超时: 3 秒
/// - 接受无效证书: 是
/// - 使用代理: 是
/// - User-Agent: "Claudia/1.0"
///
/// # Example
/// ```
/// use claudia_lib::http_client::fast_client;
///
/// let client = fast_client()?;
/// ```
#[allow(dead_code)]
pub fn fast_client() -> Result<Client> {
create_client(
ClientConfig::default()
.timeout(3)
.accept_invalid_certs(true),
)
}
/// 创建安全客户端(用于 PackyCode API
///
/// 配置:
/// - 超时: 30 秒
/// - 接受无效证书: 否
/// - 使用代理: 否(禁用代理)
/// - User-Agent: "Claudia"
///
/// # Example
/// ```
/// use claudia_lib::http_client::secure_client;
///
/// let client = secure_client()?;
/// ```
pub fn secure_client() -> Result<Client> {
create_client(
ClientConfig::default()
.timeout(30)
.use_proxy(false)
.user_agent("Claudia"),
)
}
/// 创建长超时客户端(用于大文件传输等)
///
/// 配置:
/// - 超时: 60 秒
/// - 接受无效证书: 否
/// - 使用代理: 是
/// - User-Agent: "Claudia/1.0"
#[allow(dead_code)]
pub fn long_timeout_client() -> Result<Client> {
create_client(ClientConfig::default().timeout(60))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = ClientConfig::default();
assert_eq!(config.timeout_secs, 10);
assert!(!config.accept_invalid_certs);
assert!(config.use_proxy);
assert_eq!(config.user_agent, Some("Claudia/1.0".to_string()));
}
#[test]
fn test_config_builder() {
let config = ClientConfig::new()
.timeout(5)
.accept_invalid_certs(true)
.use_proxy(false)
.user_agent("TestAgent");
assert_eq!(config.timeout_secs, 5);
assert!(config.accept_invalid_certs);
assert!(!config.use_proxy);
assert_eq!(config.user_agent, Some("TestAgent".to_string()));
}
#[test]
fn test_create_default_client() {
let result = default_client();
assert!(result.is_ok());
}
#[test]
fn test_create_fast_client() {
let result = fast_client();
assert!(result.is_ok());
}
#[test]
fn test_create_secure_client() {
let result = secure_client();
assert!(result.is_ok());
}
#[test]
fn test_create_custom_client() {
let config = ClientConfig::new().timeout(15).use_proxy(false);
let result = create_client(config);
assert!(result.is_ok());
}
}

View File

@@ -33,7 +33,7 @@ impl SimpleI18n {
#[allow(dead_code)]
pub fn t(&self, key: &str) -> String {
let locale = self.get_current_locale();
// 简单的翻译映射,避免复杂的 FluentBundle
match (locale.as_str(), key) {
// 英文翻译
@@ -42,41 +42,69 @@ impl SimpleI18n {
("en-US", "error-failed-to-delete") => "Failed to delete".to_string(),
("en-US", "agent-not-found") => "Agent not found".to_string(),
("en-US", "claude-not-installed") => "Claude Code is not installed".to_string(),
// Relay Station English translations
("en-US", "relay_adapter.custom_no_test") => "Custom configuration, connection test skipped".to_string(),
("en-US", "relay_adapter.packycode_single_token") => "PackyCode only supports single API key".to_string(),
("en-US", "relay_adapter.user_info_not_available") => "User info not available for this configuration".to_string(),
("en-US", "relay_adapter.usage_logs_not_available") => "Usage logs not available for this configuration".to_string(),
("en-US", "relay_adapter.token_management_not_available") => "Token management not available for this configuration".to_string(),
("en-US", "relay_adapter.custom_no_test") => {
"Custom configuration, connection test skipped".to_string()
}
("en-US", "relay_adapter.packycode_single_token") => {
"PackyCode only supports single API key".to_string()
}
("en-US", "relay_adapter.user_info_not_available") => {
"User info not available for this configuration".to_string()
}
("en-US", "relay_adapter.usage_logs_not_available") => {
"Usage logs not available for this configuration".to_string()
}
("en-US", "relay_adapter.token_management_not_available") => {
"Token management not available for this configuration".to_string()
}
("en-US", "relay_adapter.connection_success") => "Connection successful".to_string(),
("en-US", "relay_adapter.api_error") => "API returned error".to_string(),
("en-US", "relay_adapter.parse_error") => "Failed to parse response".to_string(),
("en-US", "relay_adapter.http_error") => "HTTP request failed".to_string(),
("en-US", "relay_adapter.network_error") => "Network connection failed".to_string(),
("en-US", "relay_station.enabled_success") => "Relay station enabled successfully".to_string(),
("en-US", "relay_station.disabled_success") => "Relay station disabled successfully".to_string(),
("en-US", "relay_station.enabled_success") => {
"Relay station enabled successfully".to_string()
}
("en-US", "relay_station.disabled_success") => {
"Relay station disabled successfully".to_string()
}
("en-US", "relay_station.name_required") => "Station name is required".to_string(),
("en-US", "relay_station.api_url_required") => "API URL is required".to_string(),
("en-US", "relay_station.invalid_url") => "Invalid URL format".to_string(),
("en-US", "relay_station.https_required") => "API URL must use HTTPS protocol for security".to_string(),
("en-US", "relay_station.https_required") => {
"API URL must use HTTPS protocol for security".to_string()
}
("en-US", "relay_station.token_required") => "API token is required".to_string(),
("en-US", "relay_station.token_too_short") => "API token is too short (minimum 10 characters)".to_string(),
("en-US", "relay_station.token_invalid_chars") => "API token contains invalid characters".to_string(),
("en-US", "relay_station.token_too_short") => {
"API token is too short (minimum 10 characters)".to_string()
}
("en-US", "relay_station.token_invalid_chars") => {
"API token contains invalid characters".to_string()
}
// 中文翻译
("zh-CN", "error-failed-to-create") => "创建失败".to_string(),
("zh-CN", "error-failed-to-update") => "更新失败".to_string(),
("zh-CN", "error-failed-to-delete") => "删除失败".to_string(),
("zh-CN", "agent-not-found") => "未找到智能体".to_string(),
("zh-CN", "claude-not-installed") => "未安装 Claude Code".to_string(),
// Relay Station Chinese translations
("zh-CN", "relay_adapter.custom_no_test") => "自定义配置,跳过连接测试".to_string(),
("zh-CN", "relay_adapter.packycode_single_token") => "PackyCode 仅支持单个 API 密钥".to_string(),
("zh-CN", "relay_adapter.user_info_not_available") => "该配置不支持用户信息查询".to_string(),
("zh-CN", "relay_adapter.usage_logs_not_available") => "该配置不支持使用日志查询".to_string(),
("zh-CN", "relay_adapter.token_management_not_available") => "该配置不支持 Token 管理".to_string(),
("zh-CN", "relay_adapter.packycode_single_token") => {
"PackyCode 仅支持单个 API 密钥".to_string()
}
("zh-CN", "relay_adapter.user_info_not_available") => {
"该配置不支持用户信息查询".to_string()
}
("zh-CN", "relay_adapter.usage_logs_not_available") => {
"该配置不支持使用日志查询".to_string()
}
("zh-CN", "relay_adapter.token_management_not_available") => {
"该配置不支持 Token 管理".to_string()
}
("zh-CN", "relay_adapter.connection_success") => "连接成功".to_string(),
("zh-CN", "relay_adapter.api_error") => "API 返回错误".to_string(),
("zh-CN", "relay_adapter.parse_error") => "解析响应失败".to_string(),
@@ -87,11 +115,15 @@ impl SimpleI18n {
("zh-CN", "relay_station.name_required") => "中转站名称不能为空".to_string(),
("zh-CN", "relay_station.api_url_required") => "API地址不能为空".to_string(),
("zh-CN", "relay_station.invalid_url") => "无效的URL格式".to_string(),
("zh-CN", "relay_station.https_required") => "出于安全考虑API地址必须使用HTTPS协议".to_string(),
("zh-CN", "relay_station.https_required") => {
"出于安全考虑API地址必须使用HTTPS协议".to_string()
}
("zh-CN", "relay_station.token_required") => "API令牌不能为空".to_string(),
("zh-CN", "relay_station.token_too_short") => "API令牌太短至少需要10个字符".to_string(),
("zh-CN", "relay_station.token_too_short") => {
"API令牌太短至少需要10个字符".to_string()
}
("zh-CN", "relay_station.token_invalid_chars") => "API令牌包含无效字符".to_string(),
// 默认情况
_ => key.to_string(),
}
@@ -118,4 +150,4 @@ pub fn set_locale(locale: &str) -> Result<(), Box<dyn std::error::Error>> {
pub fn get_current_locale() -> String {
get_i18n().get_current_locale()
}
}

View File

@@ -5,9 +5,12 @@ pub mod checkpoint;
pub mod claude_binary;
pub mod claude_config;
pub mod commands;
pub mod process;
pub mod i18n;
pub mod file_watcher;
pub mod http_client;
pub mod i18n;
pub mod process;
pub mod types;
pub mod utils;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {

View File

@@ -3,93 +3,107 @@
mod checkpoint;
mod claude_binary;
mod commands;
mod process;
mod i18n;
mod claude_config;
mod commands;
mod file_watcher;
mod http_client;
mod i18n;
mod process;
mod types;
mod utils;
use checkpoint::state::CheckpointState;
use commands::agents::{
cleanup_finished_processes, create_agent, delete_agent, execute_agent, export_agent,
export_agent_to_file, fetch_github_agent_content, fetch_github_agents, get_agent,
get_agent_run, get_agent_run_with_real_time_metrics, get_claude_binary_path,
get_live_session_output, get_session_output, get_session_status, import_agent,
import_agent_from_file, import_agent_from_github, init_database, kill_agent_session,
list_agent_runs, list_agent_runs_with_metrics, list_agents, list_claude_installations,
list_running_sessions, load_agent_session_history, set_claude_binary_path, stream_session_output, update_agent, AgentDb,
get_live_session_output, get_model_mappings, get_session_output, get_session_status,
import_agent, import_agent_from_file, import_agent_from_github, init_database,
kill_agent_session, list_agent_runs, list_agent_runs_with_metrics, list_agents,
list_claude_installations, list_running_sessions, load_agent_session_history,
set_claude_binary_path, stream_session_output, update_agent, update_model_mapping, AgentDb,
};
use commands::claude::{
cancel_claude_execution, check_auto_checkpoint, check_claude_version, cleanup_old_checkpoints,
clear_checkpoint_manager, continue_claude_code, create_checkpoint, execute_claude_code,
find_claude_md_files, fork_from_checkpoint, get_checkpoint_diff, get_checkpoint_settings,
get_checkpoint_state_stats, get_claude_session_output, get_claude_settings, get_project_sessions,
get_checkpoint_state_stats, get_claude_session_output, get_claude_settings,
get_claude_settings_backup, get_hooks_config, get_project_sessions,
get_recently_modified_files, get_session_timeline, get_system_prompt, list_checkpoints,
list_directory_contents, list_projects, list_running_claude_sessions, load_session_history,
open_new_session, read_claude_md_file, restore_checkpoint, resume_claude_code,
save_claude_md_file, save_claude_settings, save_system_prompt, search_files,
track_checkpoint_message, track_session_messages, update_checkpoint_settings,
get_hooks_config, update_hooks_config, validate_hook_command,
watch_claude_project_directory, unwatch_claude_project_directory,
ClaudeProcessState,
save_claude_md_file, save_claude_settings, save_claude_settings_backup, save_system_prompt,
search_files, track_checkpoint_message, track_session_messages,
unwatch_claude_project_directory, update_checkpoint_settings, update_hooks_config,
validate_hook_command, watch_claude_project_directory, ClaudeProcessState,
};
use commands::mcp::{
mcp_add, mcp_add_from_claude_desktop, mcp_add_json, mcp_get, mcp_get_server_status, mcp_list,
mcp_read_project_config, mcp_remove, mcp_reset_project_choices, mcp_save_project_config,
mcp_serve, mcp_test_connection, mcp_export_servers,
mcp_add, mcp_add_from_claude_desktop, mcp_add_json, mcp_export_servers, mcp_get,
mcp_get_server_status, mcp_list, mcp_read_project_config, mcp_remove,
mcp_reset_project_choices, mcp_save_project_config, mcp_serve, mcp_test_connection,
};
use commands::ccr::{
check_ccr_installation, get_ccr_config_path, get_ccr_service_status, get_ccr_version,
open_ccr_ui, restart_ccr_service, start_ccr_service, stop_ccr_service,
};
use commands::prompt_files::{
prompt_file_apply, prompt_file_create, prompt_file_deactivate, prompt_file_delete,
prompt_file_export, prompt_file_get, prompt_file_import_from_claude_md,
prompt_file_update, prompt_files_import_batch, prompt_files_list,
prompt_files_update_order,
};
use commands::filesystem::{
get_file_info, get_file_tree, get_watched_paths, read_directory_tree, read_file,
search_files_by_name, unwatch_directory, watch_directory, write_file,
};
use commands::git::{
get_git_branches, get_git_commits, get_git_diff, get_git_history, get_git_status,
};
use commands::language::{get_current_language, get_supported_languages, set_language};
use commands::packycode_nodes::{
auto_select_best_node, get_packycode_nodes, test_all_packycode_nodes,
};
use commands::proxy::{apply_proxy_settings, get_proxy_settings, save_proxy_settings};
use commands::relay_adapters::{
packycode_get_user_quota, relay_station_create_token, relay_station_delete_token,
relay_station_get_info, relay_station_get_usage_logs, relay_station_get_user_info,
relay_station_list_tokens, relay_station_test_connection, relay_station_update_token,
};
use commands::relay_stations::{
relay_station_create, relay_station_delete, relay_station_get,
relay_station_get_current_config, relay_station_restore_config, relay_station_sync_config,
relay_station_toggle_enable, relay_station_update, relay_station_update_order,
relay_stations_export, relay_stations_import, relay_stations_list,
};
use commands::smart_sessions::{
cleanup_old_smart_sessions_command, create_smart_quick_start_session, get_smart_session_config,
list_smart_sessions_command, toggle_smart_session_mode, update_smart_session_config,
};
use commands::storage::{
storage_delete_row, storage_execute_sql, storage_insert_row, storage_list_tables,
storage_read_table, storage_reset_database, storage_update_row,
};
use commands::system::flush_dns;
use commands::terminal::{
cleanup_terminal_sessions, close_terminal_session, create_terminal_session,
list_terminal_sessions, resize_terminal, send_terminal_input, TerminalState,
};
use commands::usage::{
get_session_stats, get_usage_by_date_range, get_usage_details, get_usage_stats,
};
use commands::usage_cache::{
usage_check_updates, usage_clear_cache, usage_force_scan, usage_get_stats_cached,
usage_scan_update, UsageCacheState,
};
use commands::usage_index::{
usage_get_summary, usage_import_diffs, usage_scan_index, usage_scan_progress, UsageIndexState,
};
use commands::usage_cache::{
usage_scan_update, usage_get_stats_cached, usage_clear_cache, usage_force_scan, usage_check_updates, UsageCacheState,
};
use commands::storage::{
storage_list_tables, storage_read_table, storage_update_row, storage_delete_row,
storage_insert_row, storage_execute_sql, storage_reset_database,
};
use commands::proxy::{get_proxy_settings, save_proxy_settings, apply_proxy_settings};
use commands::language::{get_current_language, set_language, get_supported_languages};
use commands::relay_stations::{
relay_stations_list, relay_station_get, relay_station_create, relay_station_update,
relay_station_delete, relay_station_toggle_enable, relay_station_sync_config,
relay_station_restore_config, relay_station_get_current_config,
relay_stations_export, relay_stations_import,
};
use commands::relay_adapters::{
relay_station_get_info, relay_station_get_user_info,
relay_station_test_connection, relay_station_get_usage_logs, relay_station_list_tokens,
relay_station_create_token, relay_station_update_token, relay_station_delete_token,
packycode_get_user_quota,
};
use commands::packycode_nodes::{
test_all_packycode_nodes, auto_select_best_node, get_packycode_nodes,
};
use commands::filesystem::{
read_directory_tree, search_files_by_name, get_file_info, watch_directory,
read_file, write_file, get_file_tree, unwatch_directory, get_watched_paths,
};
use commands::git::{
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 file_watcher::FileWatcherState;
use process::ProcessRegistryState;
use std::sync::Mutex;
use tauri::Manager;
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
use tauri::Manager;
use tauri_plugin_log::{Target, TargetKind};
fn main() {
@@ -100,39 +114,107 @@ fn main() {
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_log::Builder::new()
.level(log::LevelFilter::Debug)
.targets([
Target::new(TargetKind::LogDir { file_name: None }),
Target::new(TargetKind::Stdout),
])
.build())
.plugin(
tauri_plugin_log::Builder::new()
.level(log::LevelFilter::Debug)
.targets([
Target::new(TargetKind::LogDir { file_name: None }),
Target::new(TargetKind::Stdout),
])
.build(),
)
// App menu: include standard Edit actions so OS hotkeys (Undo/Redo/Cut/Copy/Paste/Select All)
// work across all pages, plus a DevTools toggle.
.menu(|app| {
let toggle_devtools = MenuItemBuilder::new("Toggle DevTools")
.id("toggle-devtools")
.accelerator("CmdOrCtrl+Alt+I")
.build(app)
.unwrap();
// Create a proper "Edit" submenu (macOS expects standard edit actions under Edit)
let edit_menu = SubmenuBuilder::new(app, "Edit")
.undo()
.redo()
.separator()
.cut()
.copy()
.paste()
.select_all()
.build()
.unwrap();
#[cfg(target_os = "macos")]
{
use tauri::menu::AboutMetadataBuilder;
MenuBuilder::new(app)
.item(&edit_menu)
.separator()
// DevTools toggle
.item(&toggle_devtools)
.build()
// Create macOS app menu with Quit
let app_menu = SubmenuBuilder::new(app, "Claudia")
.about(Some(
AboutMetadataBuilder::new()
.version(Some(env!("CARGO_PKG_VERSION")))
.build(),
))
.separator()
.quit()
.build()
.unwrap();
let edit_menu = SubmenuBuilder::new(app, "Edit")
.undo()
.redo()
.separator()
.cut()
.copy()
.paste()
.select_all()
.build()
.unwrap();
let window_menu = SubmenuBuilder::new(app, "Window")
.close_window()
.minimize()
.separator()
.item(
&MenuItemBuilder::new("Toggle DevTools")
.id("toggle-devtools")
.accelerator("CmdOrCtrl+Alt+I")
.build(app)
.unwrap(),
)
.build()
.unwrap();
MenuBuilder::new(app)
.item(&app_menu)
.item(&edit_menu)
.item(&window_menu)
.build()
}
#[cfg(not(target_os = "macos"))]
{
let toggle_devtools = MenuItemBuilder::new("Toggle DevTools")
.id("toggle-devtools")
.accelerator("CmdOrCtrl+Alt+I")
.build(app)
.unwrap();
let close_window = MenuItemBuilder::new("Close Window")
.id("close-window")
.accelerator("CmdOrCtrl+W")
.build(app)
.unwrap();
let quit = MenuItemBuilder::new("Quit")
.id("quit")
.accelerator("CmdOrCtrl+Q")
.build(app)
.unwrap();
let edit_menu = SubmenuBuilder::new(app, "Edit")
.undo()
.redo()
.separator()
.cut()
.copy()
.paste()
.select_all()
.build()
.unwrap();
MenuBuilder::new(app)
.item(&edit_menu)
.separator()
.item(&toggle_devtools)
.separator()
.item(&close_window)
.separator()
.item(&quit)
.build()
}
})
.on_menu_event(|app, event| {
if event.id() == "toggle-devtools" {
@@ -144,7 +226,11 @@ fn main() {
.setup(|app| {
// Initialize agents database
let conn = init_database(&app.handle()).expect("Failed to initialize agents database");
// Initialize API nodes database
commands::api_nodes::init_nodes_db()
.expect("Failed to initialize API nodes database");
// Load and apply proxy settings from the database
{
let db = AgentDb(Mutex::new(conn));
@@ -152,7 +238,7 @@ fn main() {
Ok(conn) => {
// Directly query proxy settings from the database
let mut settings = commands::proxy::ProxySettings::default();
let keys = vec![
("proxy_enabled", "enabled"),
("proxy_http", "http_proxy"),
@@ -160,7 +246,7 @@ fn main() {
("proxy_no", "no_proxy"),
("proxy_all", "all_proxy"),
];
for (db_key, field) in keys {
if let Ok(value) = conn.query_row(
"SELECT value FROM app_settings WHERE key = ?1",
@@ -169,15 +255,23 @@ fn main() {
) {
match field {
"enabled" => settings.enabled = value == "true",
"http_proxy" => settings.http_proxy = Some(value).filter(|s| !s.is_empty()),
"https_proxy" => settings.https_proxy = Some(value).filter(|s| !s.is_empty()),
"no_proxy" => settings.no_proxy = Some(value).filter(|s| !s.is_empty()),
"all_proxy" => settings.all_proxy = Some(value).filter(|s| !s.is_empty()),
"http_proxy" => {
settings.http_proxy = Some(value).filter(|s| !s.is_empty())
}
"https_proxy" => {
settings.https_proxy = Some(value).filter(|s| !s.is_empty())
}
"no_proxy" => {
settings.no_proxy = Some(value).filter(|s| !s.is_empty())
}
"all_proxy" => {
settings.all_proxy = Some(value).filter(|s| !s.is_empty())
}
_ => {}
}
}
}
log::info!("Loaded proxy settings: enabled={}", settings.enabled);
settings
}
@@ -186,11 +280,11 @@ fn main() {
commands::proxy::ProxySettings::default()
}
};
// Apply the proxy settings
apply_proxy_settings(&proxy_settings);
}
// Re-open the connection for the app to manage
let conn = init_database(&app.handle()).expect("Failed to initialize agents database");
app.manage(AgentDb(Mutex::new(conn)));
@@ -218,7 +312,7 @@ fn main() {
// Initialize process registry
app.manage(ProcessRegistryState::default());
// Initialize file watcher state
let file_watcher_state = FileWatcherState::new();
file_watcher_state.init(app.handle().clone());
@@ -247,11 +341,13 @@ fn main() {
list_projects,
get_project_sessions,
get_claude_settings,
get_claude_settings_backup,
open_new_session,
get_system_prompt,
check_claude_version,
save_system_prompt,
save_claude_settings,
save_claude_settings_backup,
watch_claude_project_directory,
unwatch_claude_project_directory,
find_claude_md_files,
@@ -270,7 +366,6 @@ fn main() {
get_hooks_config,
update_hooks_config,
validate_hook_command,
// Checkpoint Management
create_checkpoint,
restore_checkpoint,
@@ -286,7 +381,6 @@ fn main() {
get_checkpoint_settings,
clear_checkpoint_manager,
get_checkpoint_state_stats,
// Agent Management
list_agents,
create_agent,
@@ -316,26 +410,24 @@ fn main() {
fetch_github_agents,
fetch_github_agent_content,
import_agent_from_github,
get_model_mappings,
update_model_mapping,
// Usage & Analytics
get_usage_stats,
get_usage_by_date_range,
get_usage_details,
get_session_stats,
// File Usage Index (SQLite)
usage_scan_index,
usage_scan_progress,
usage_get_summary,
usage_import_diffs,
// Usage Cache Management
usage_scan_update,
usage_get_stats_cached,
usage_clear_cache,
usage_force_scan,
usage_check_updates,
// MCP (Model Context Protocol)
mcp_add,
mcp_list,
@@ -350,7 +442,6 @@ fn main() {
mcp_read_project_config,
mcp_save_project_config,
mcp_export_servers,
// Storage Management
storage_list_tables,
storage_read_table,
@@ -359,22 +450,37 @@ fn main() {
storage_insert_row,
storage_execute_sql,
storage_reset_database,
// Smart Sessions Management
create_smart_quick_start_session,
get_smart_session_config,
update_smart_session_config,
list_smart_sessions_command,
toggle_smart_session_mode,
cleanup_old_smart_sessions_command,
// Slash Commands
commands::slash_commands::slash_commands_list,
commands::slash_commands::slash_command_get,
commands::slash_commands::slash_command_save,
commands::slash_commands::slash_command_delete,
// Prompt Files Management (Database Based)
prompt_files_list,
prompt_file_get,
prompt_file_create,
prompt_file_update,
prompt_file_delete,
prompt_file_apply,
prompt_file_deactivate,
prompt_file_import_from_claude_md,
prompt_file_export,
prompt_files_update_order,
prompt_files_import_batch,
// Proxy Settings
get_proxy_settings,
save_proxy_settings,
// Language Settings
get_current_language,
set_language,
get_supported_languages,
// Relay Stations
relay_stations_list,
relay_station_get,
@@ -387,6 +493,7 @@ fn main() {
relay_station_get_current_config,
relay_stations_export,
relay_stations_import,
relay_station_update_order,
relay_station_get_info,
relay_station_get_user_info,
relay_station_test_connection,
@@ -396,12 +503,18 @@ fn main() {
relay_station_update_token,
relay_station_delete_token,
packycode_get_user_quota,
// PackyCode Nodes
test_all_packycode_nodes,
auto_select_best_node,
get_packycode_nodes,
// API Nodes Management
commands::api_nodes::init_default_nodes,
commands::api_nodes::list_api_nodes,
commands::api_nodes::create_api_node,
commands::api_nodes::update_api_node,
commands::api_nodes::delete_api_node,
commands::api_nodes::test_api_node,
commands::api_nodes::test_all_api_nodes,
// File System
read_directory_tree,
search_files_by_name,
@@ -412,14 +525,12 @@ fn main() {
read_file,
write_file,
get_file_tree,
// Git
get_git_status,
get_git_history,
get_git_branches,
get_git_diff,
get_git_commits,
// Terminal
create_terminal_session,
send_terminal_input,
@@ -427,7 +538,6 @@ fn main() {
list_terminal_sessions,
resize_terminal,
cleanup_terminal_sessions,
// CCR (Claude Code Router)
check_ccr_installation,
get_ccr_version,
@@ -437,7 +547,6 @@ fn main() {
restart_ccr_service,
open_ccr_ui,
get_ccr_config_path,
// System utilities
flush_dns,
])

View File

@@ -7,13 +7,8 @@ use tokio::process::Child;
/// Type of process being tracked
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ProcessType {
AgentRun {
agent_id: i64,
agent_name: String,
},
ClaudeSession {
session_id: String,
},
AgentRun { agent_id: i64, agent_name: String },
ClaudeSession { session_id: String },
}
/// Information about a running agent process
@@ -72,7 +67,10 @@ impl ProcessRegistry {
) -> Result<(), String> {
let process_info = ProcessInfo {
run_id,
process_type: ProcessType::AgentRun { agent_id, agent_name },
process_type: ProcessType::AgentRun {
agent_id,
agent_name,
},
pid,
started_at: Utc::now(),
project_path,
@@ -96,7 +94,10 @@ impl ProcessRegistry {
) -> Result<(), String> {
let process_info = ProcessInfo {
run_id,
process_type: ProcessType::AgentRun { agent_id, agent_name },
process_type: ProcessType::AgentRun {
agent_id,
agent_name,
},
pid,
started_at: Utc::now(),
project_path,
@@ -106,7 +107,7 @@ impl ProcessRegistry {
// For sidecar processes, we register without the child handle since it's managed differently
let mut processes = self.processes.lock().map_err(|e| e.to_string())?;
let process_handle = ProcessHandle {
info: process_info,
child: Arc::new(Mutex::new(None)), // No tokio::process::Child handle for sidecar
@@ -127,7 +128,7 @@ impl ProcessRegistry {
model: String,
) -> Result<i64, String> {
let run_id = self.generate_id()?;
let process_info = ProcessInfo {
run_id,
process_type: ProcessType::ClaudeSession { session_id },
@@ -140,7 +141,7 @@ impl ProcessRegistry {
// Register without child - Claude sessions use ClaudeProcessState for process management
let mut processes = self.processes.lock().map_err(|e| e.to_string())?;
let process_handle = ProcessHandle {
info: process_info,
child: Arc::new(Mutex::new(None)), // No child handle for Claude sessions
@@ -175,25 +176,24 @@ impl ProcessRegistry {
let processes = self.processes.lock().map_err(|e| e.to_string())?;
Ok(processes
.values()
.filter_map(|handle| {
match &handle.info.process_type {
ProcessType::ClaudeSession { .. } => Some(handle.info.clone()),
_ => None,
}
.filter_map(|handle| match &handle.info.process_type {
ProcessType::ClaudeSession { .. } => Some(handle.info.clone()),
_ => None,
})
.collect())
}
/// Get a specific Claude session by session ID
pub fn get_claude_session_by_id(&self, session_id: &str) -> Result<Option<ProcessInfo>, String> {
pub fn get_claude_session_by_id(
&self,
session_id: &str,
) -> Result<Option<ProcessInfo>, String> {
let processes = self.processes.lock().map_err(|e| e.to_string())?;
Ok(processes
.values()
.find(|handle| {
match &handle.info.process_type {
ProcessType::ClaudeSession { session_id: sid } => sid == session_id,
_ => false,
}
.find(|handle| match &handle.info.process_type {
ProcessType::ClaudeSession { session_id: sid } => sid == session_id,
_ => false,
})
.map(|handle| handle.info.clone()))
}
@@ -221,11 +221,9 @@ impl ProcessRegistry {
let processes = self.processes.lock().map_err(|e| e.to_string())?;
Ok(processes
.values()
.filter_map(|handle| {
match &handle.info.process_type {
ProcessType::AgentRun { .. } => Some(handle.info.clone()),
_ => None,
}
.filter_map(|handle| match &handle.info.process_type {
ProcessType::AgentRun { .. } => Some(handle.info.clone()),
_ => None,
})
.collect())
}
@@ -273,17 +271,26 @@ impl ProcessRegistry {
}
}
} else {
warn!("No child handle available for process {} (PID: {}), attempting system kill", run_id, pid);
warn!(
"No child handle available for process {} (PID: {}), attempting system kill",
run_id, pid
);
false // Process handle not available, try fallback
}
};
// If direct kill didn't work, try system command as fallback
if !kill_sent {
info!("Attempting fallback kill for process {} (PID: {})", run_id, pid);
info!(
"Attempting fallback kill for process {} (PID: {})",
run_id, pid
);
match self.kill_process_by_pid(run_id, pid) {
Ok(true) => return Ok(true),
Ok(false) => warn!("Fallback kill also failed for process {} (PID: {})", run_id, pid),
Ok(false) => warn!(
"Fallback kill also failed for process {} (PID: {})",
run_id, pid
),
Err(e) => error!("Error during fallback kill: {}", e),
}
// Continue with the rest of the cleanup even if fallback failed

View File

@@ -0,0 +1,30 @@
# Smart Session Claude Configuration
This is an automatically created smart session workspace by Claudia.
## Session Information
- **Created**: {created_at}
- **Session ID**: {session_id}
- **Workspace Path**: {project_path}
## Quick Start
This workspace is pre-configured for immediate use with Claude Code. You can start coding right away!
## Available Tools
- File editing and creation
- Terminal access
- Git integration
- And all Claude Code features
## Notes
- This is a temporary workspace created for quick experimentation
- Files will be automatically cleaned up based on your Claudia settings
- You can convert this to a permanent project anytime
---
Generated by [Claudia](https://github.com/yovinchen/claudia) - Claude Code GUI

View File

@@ -0,0 +1,2 @@
/// 节点测试相关类型定义
pub mod node_test;

View File

@@ -0,0 +1,379 @@
/// 节点测试数据结构
///
/// 统一的节点连通性测试结果类型,用于替代分散在各模块的重复定义
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// 节点测试状态
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TestStatus {
/// 测试成功
Success,
/// 测试失败
Failure,
/// 超时
Timeout,
}
impl TestStatus {
/// 判断测试是否成功
pub fn is_success(&self) -> bool {
matches!(self, TestStatus::Success)
}
/// 判断测试是否失败
#[allow(dead_code)]
pub fn is_failure(&self) -> bool {
!self.is_success()
}
/// 转换为字符串
#[allow(dead_code)]
pub fn as_str(&self) -> &'static str {
match self {
TestStatus::Success => "success",
TestStatus::Failure => "failure",
TestStatus::Timeout => "timeout",
}
}
}
/// 统一的节点测试结果
///
/// 整合了之前分散在 relay_adapters.rs, api_nodes.rs, packycode_nodes.rs 中的
/// ConnectionTestResult 和 NodeTestResult
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeTestResult {
/// 节点 ID可选用于数据库查询
#[serde(skip_serializing_if = "Option::is_none")]
pub node_id: Option<String>,
/// 节点名称(可选,用于显示)
#[serde(skip_serializing_if = "Option::is_none")]
pub node_name: Option<String>,
/// 节点 URL
pub url: String,
/// 测试状态
pub status: TestStatus,
/// 响应时间(毫秒)
#[serde(skip_serializing_if = "Option::is_none")]
pub response_time_ms: Option<u64>,
/// 状态消息
pub message: String,
/// 错误详情(失败时提供)
#[serde(skip_serializing_if = "Option::is_none")]
pub error_details: Option<String>,
/// 额外元数据(用于扩展)
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, serde_json::Value>>,
}
impl NodeTestResult {
/// 创建成功的测试结果
///
/// # Example
/// ```
/// use claudia_lib::types::node_test::NodeTestResult;
///
/// let result = NodeTestResult::success(
/// "https://api.example.com".to_string(),
/// 150
/// );
/// assert!(result.status.is_success());
/// assert_eq!(result.response_time_ms, Some(150));
/// ```
#[allow(dead_code)]
pub fn success(url: String, response_time: u64) -> Self {
Self {
node_id: None,
node_name: None,
url,
status: TestStatus::Success,
response_time_ms: Some(response_time),
message: "连接成功".to_string(),
error_details: None,
metadata: None,
}
}
/// 创建成功的测试结果(带自定义消息)
pub fn success_with_message(url: String, response_time: u64, message: String) -> Self {
Self {
node_id: None,
node_name: None,
url,
status: TestStatus::Success,
response_time_ms: Some(response_time),
message,
error_details: None,
metadata: None,
}
}
/// 创建失败的测试结果
///
/// # Example
/// ```
/// use claudia_lib::types::node_test::NodeTestResult;
///
/// let result = NodeTestResult::failure(
/// "https://api.example.com".to_string(),
/// "Connection refused".to_string()
/// );
/// assert!(result.status.is_failure());
/// ```
pub fn failure(url: String, error: String) -> Self {
Self {
node_id: None,
node_name: None,
url,
status: TestStatus::Failure,
response_time_ms: None,
message: "连接失败".to_string(),
error_details: Some(error),
metadata: None,
}
}
/// 创建失败的测试结果(带响应时间)
pub fn failure_with_time(url: String, response_time: u64, error: String) -> Self {
Self {
node_id: None,
node_name: None,
url,
status: TestStatus::Failure,
response_time_ms: Some(response_time),
message: "连接失败".to_string(),
error_details: Some(error),
metadata: None,
}
}
/// 创建超时的测试结果
///
/// # Example
/// ```
/// use claudia_lib::types::node_test::NodeTestResult;
///
/// let result = NodeTestResult::timeout(
/// "https://api.example.com".to_string(),
/// 5000
/// );
/// assert_eq!(result.status, TestStatus::Timeout);
/// ```
pub fn timeout(url: String, timeout_ms: u64) -> Self {
Self {
node_id: None,
node_name: None,
url,
status: TestStatus::Timeout,
response_time_ms: Some(timeout_ms),
message: "连接超时".to_string(),
error_details: Some(format!("请求超过 {} 毫秒未响应", timeout_ms)),
metadata: None,
}
}
/// 设置节点 ID
#[allow(dead_code)]
pub fn with_node_id(mut self, node_id: String) -> Self {
self.node_id = Some(node_id);
self
}
/// 设置节点名称
#[allow(dead_code)]
pub fn with_node_name(mut self, node_name: String) -> Self {
self.node_name = Some(node_name);
self
}
/// 设置元数据
#[allow(dead_code)]
pub fn with_metadata(mut self, metadata: HashMap<String, serde_json::Value>) -> Self {
self.metadata = Some(metadata);
self
}
/// 添加单个元数据项
#[allow(dead_code)]
pub fn add_metadata(mut self, key: String, value: serde_json::Value) -> Self {
if self.metadata.is_none() {
self.metadata = Some(HashMap::new());
}
if let Some(ref mut meta) = self.metadata {
meta.insert(key, value);
}
self
}
/// 判断测试是否成功
pub fn is_success(&self) -> bool {
self.status.is_success()
}
/// 判断测试是否失败
#[allow(dead_code)]
pub fn is_failure(&self) -> bool {
self.status.is_failure()
}
/// 获取响应时间(如果有)
#[allow(dead_code)]
pub fn response_time(&self) -> Option<u64> {
self.response_time_ms
}
/// 获取错误信息(如果有)
#[allow(dead_code)]
pub fn error(&self) -> Option<&str> {
self.error_details.as_deref()
}
}
/// 批量测试结果统计
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchTestSummary {
/// 总测试数
pub total: usize,
/// 成功数
pub success: usize,
/// 失败数
pub failure: usize,
/// 超时数
pub timeout: usize,
/// 平均响应时间(毫秒)
pub avg_response_time: Option<f64>,
/// 最快响应时间(毫秒)
pub min_response_time: Option<u64>,
/// 最慢响应时间(毫秒)
pub max_response_time: Option<u64>,
}
impl BatchTestSummary {
/// 从测试结果列表生成统计摘要
#[allow(dead_code)]
pub fn from_results(results: &[NodeTestResult]) -> Self {
let total = results.len();
let success = results.iter().filter(|r| r.is_success()).count();
let timeout = results
.iter()
.filter(|r| r.status == TestStatus::Timeout)
.count();
let failure = total - success;
let response_times: Vec<u64> = results
.iter()
.filter_map(|r| r.response_time_ms)
.collect();
let avg_response_time = if !response_times.is_empty() {
let sum: u64 = response_times.iter().sum();
Some(sum as f64 / response_times.len() as f64)
} else {
None
};
let min_response_time = response_times.iter().copied().min();
let max_response_time = response_times.iter().copied().max();
Self {
total,
success,
failure,
timeout,
avg_response_time,
min_response_time,
max_response_time,
}
}
/// 获取成功率(百分比)
#[allow(dead_code)]
pub fn success_rate(&self) -> f64 {
if self.total == 0 {
0.0
} else {
(self.success as f64 / self.total as f64) * 100.0
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_success_result() {
let result = NodeTestResult::success("https://api.example.com".to_string(), 150);
assert!(result.is_success());
assert_eq!(result.response_time(), Some(150));
assert!(result.error().is_none());
assert_eq!(result.status, TestStatus::Success);
}
#[test]
fn test_failure_result() {
let result = NodeTestResult::failure(
"https://api.example.com".to_string(),
"Connection refused".to_string(),
);
assert!(result.is_failure());
assert_eq!(result.error(), Some("Connection refused"));
assert_eq!(result.status, TestStatus::Failure);
}
#[test]
fn test_timeout_result() {
let result = NodeTestResult::timeout("https://api.example.com".to_string(), 5000);
assert_eq!(result.status, TestStatus::Timeout);
assert!(result.error().is_some());
}
#[test]
fn test_builder_pattern() {
let result = NodeTestResult::success("https://api.example.com".to_string(), 100)
.with_node_id("node-123".to_string())
.with_node_name("Test Node".to_string())
.add_metadata("region".to_string(), serde_json::json!("us-west"));
assert_eq!(result.node_id, Some("node-123".to_string()));
assert_eq!(result.node_name, Some("Test Node".to_string()));
assert!(result.metadata.is_some());
}
#[test]
fn test_batch_summary() {
let results = vec![
NodeTestResult::success("http://1".to_string(), 100),
NodeTestResult::success("http://2".to_string(), 200),
NodeTestResult::failure("http://3".to_string(), "error".to_string()),
NodeTestResult::timeout("http://4".to_string(), 5000),
];
let summary = BatchTestSummary::from_results(&results);
assert_eq!(summary.total, 4);
assert_eq!(summary.success, 2);
assert_eq!(summary.failure, 2);
assert_eq!(summary.timeout, 1);
assert_eq!(summary.success_rate(), 50.0);
assert!(summary.avg_response_time.is_some());
}
#[test]
fn test_test_status() {
assert!(TestStatus::Success.is_success());
assert!(!TestStatus::Failure.is_success());
assert!(TestStatus::Failure.is_failure());
assert_eq!(TestStatus::Success.as_str(), "success");
assert_eq!(TestStatus::Timeout.as_str(), "timeout");
}
}

View File

@@ -0,0 +1,244 @@
/// 错误处理工具模块
///
/// 提供统一的错误转换函数,减少样板代码
use anyhow::Result;
/// 将 anyhow::Result 转换为 Result<T, String>
///
/// 这是最常用的错误转换函数,用于 Tauri 命令的返回值
///
/// # Example
/// ```
/// use claudia_lib::utils::error::to_string_error;
/// use anyhow::Result;
///
/// fn some_operation() -> Result<String> {
/// Ok("success".to_string())
/// }
///
/// #[tauri::command]
/// async fn my_command() -> Result<String, String> {
/// to_string_error(some_operation())
/// }
/// ```
#[allow(dead_code)]
pub fn to_string_error<T>(result: Result<T>) -> Result<T, String> {
result.map_err(|e| e.to_string())
}
/// 将 anyhow::Result 转换为 Result<T, String>,并添加上下文信息
///
/// # Example
/// ```
/// use claudia_lib::utils::error::to_string_error_ctx;
/// use anyhow::Result;
///
/// fn database_operation() -> Result<String> {
/// Ok("data".to_string())
/// }
///
/// #[tauri::command]
/// async fn get_data() -> Result<String, String> {
/// to_string_error_ctx(
/// database_operation(),
/// "获取数据失败"
/// )
/// }
/// ```
#[allow(dead_code)]
pub fn to_string_error_ctx<T>(result: Result<T>, context: &str) -> Result<T, String> {
result.map_err(|e| format!("{}: {}", context, e))
}
/// 将 rusqlite::Error 转换为用户友好的错误消息
///
/// # Example
/// ```
/// use claudia_lib::utils::error::db_error_to_string;
/// use rusqlite::{Connection, Error};
///
/// fn query_database() -> Result<String, String> {
/// let conn = Connection::open("test.db")
/// .map_err(db_error_to_string)?;
/// // ...
/// Ok("result".to_string())
/// }
/// ```
#[allow(dead_code)]
pub fn db_error_to_string(e: rusqlite::Error) -> String {
match e {
rusqlite::Error::QueryReturnedNoRows => "查询未返回任何行".to_string(),
rusqlite::Error::SqliteFailure(err, msg) => {
let code = err.extended_code;
let description = msg.unwrap_or_else(|| "未知数据库错误".to_string());
format!("数据库错误 (代码 {}): {}", code, description)
}
rusqlite::Error::InvalidColumnType(idx, name, type_) => {
format!("列类型错误: 列 {} (索引 {}) 的类型为 {:?}", name, idx, type_)
}
rusqlite::Error::InvalidColumnIndex(idx) => {
format!("无效的列索引: {}", idx)
}
rusqlite::Error::InvalidColumnName(name) => {
format!("无效的列名: {}", name)
}
rusqlite::Error::ExecuteReturnedResults => "执行语句返回了结果(应使用查询)".to_string(),
rusqlite::Error::InvalidQuery => "无效的查询语句".to_string(),
_ => format!("数据库错误: {}", e),
}
}
/// 将 reqwest::Error 转换为用户友好的错误消息
///
/// # Example
/// ```
/// use claudia_lib::utils::error::http_error_to_string;
/// use reqwest::Error;
///
/// async fn fetch_data(url: &str) -> Result<String, String> {
/// let response = reqwest::get(url)
/// .await
/// .map_err(http_error_to_string)?;
/// response.text().await.map_err(http_error_to_string)
/// }
/// ```
#[allow(dead_code)]
pub fn http_error_to_string(e: reqwest::Error) -> String {
if e.is_timeout() {
format!("请求超时: {}", e)
} else if e.is_connect() {
format!("连接失败: {}", e)
} else if e.is_status() {
format!(
"HTTP 错误: {}",
e.status()
.map(|s| s.to_string())
.unwrap_or_else(|| "未知状态".to_string())
)
} else if e.is_decode() {
format!("解码响应失败: {}", e)
} else if e.is_request() {
format!("构建请求失败: {}", e)
} else {
format!("HTTP 请求错误: {}", e)
}
}
/// 将 serde_json::Error 转换为用户友好的错误消息
#[allow(dead_code)]
pub fn json_error_to_string(e: serde_json::Error) -> String {
format!("JSON 解析错误: {}", e)
}
/// 将 std::io::Error 转换为用户友好的错误消息
#[allow(dead_code)]
pub fn io_error_to_string(e: std::io::Error) -> String {
use std::io::ErrorKind;
match e.kind() {
ErrorKind::NotFound => format!("文件或目录不存在: {}", e),
ErrorKind::PermissionDenied => format!("权限不足: {}", e),
ErrorKind::AlreadyExists => format!("文件或目录已存在: {}", e),
ErrorKind::WouldBlock => "操作将会阻塞".to_string(),
ErrorKind::InvalidInput => format!("无效的输入: {}", e),
ErrorKind::InvalidData => format!("无效的数据: {}", e),
ErrorKind::TimedOut => "操作超时".to_string(),
ErrorKind::WriteZero => "无法写入数据".to_string(),
ErrorKind::Interrupted => "操作被中断".to_string(),
ErrorKind::UnexpectedEof => "意外的文件结束".to_string(),
_ => format!("IO 错误: {}", e),
}
}
/// 组合多个错误消息
///
/// # Example
/// ```
/// use claudia_lib::utils::error::combine_errors;
///
/// let errors = vec![
/// "错误 1: 连接失败".to_string(),
/// "错误 2: 超时".to_string(),
/// ];
/// let combined = combine_errors(&errors);
/// // 输出: "发生 2 个错误: 错误 1: 连接失败; 错误 2: 超时"
/// ```
#[allow(dead_code)]
pub fn combine_errors(errors: &[String]) -> String {
if errors.is_empty() {
"无错误".to_string()
} else if errors.len() == 1 {
errors[0].clone()
} else {
format!("发生 {} 个错误: {}", errors.len(), errors.join("; "))
}
}
/// 创建带前缀的错误消息
#[allow(dead_code)]
pub fn prefixed_error(prefix: &str, error: &str) -> String {
format!("{}: {}", prefix, error)
}
/// 为错误添加建议
#[allow(dead_code)]
pub fn error_with_suggestion(error: &str, suggestion: &str) -> String {
format!("{}。建议: {}", error, suggestion)
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::anyhow;
#[test]
fn test_to_string_error() {
let result: Result<String> = Err(anyhow!("测试错误"));
let converted = to_string_error(result);
assert!(converted.is_err());
assert_eq!(converted.unwrap_err(), "测试错误");
}
#[test]
fn test_to_string_error_ctx() {
let result: Result<String> = Err(anyhow!("原始错误"));
let converted = to_string_error_ctx(result, "操作失败");
assert!(converted.is_err());
assert_eq!(converted.unwrap_err(), "操作失败: 原始错误");
}
#[test]
fn test_db_error_to_string() {
let error = rusqlite::Error::QueryReturnedNoRows;
assert_eq!(db_error_to_string(error), "查询未返回任何行");
}
#[test]
fn test_combine_errors() {
let errors = vec!["错误1".to_string(), "错误2".to_string()];
let combined = combine_errors(&errors);
assert!(combined.contains("错误1"));
assert!(combined.contains("错误2"));
assert!(combined.contains("2 个错误"));
}
#[test]
fn test_prefixed_error() {
let error = prefixed_error("数据库", "连接失败");
assert_eq!(error, "数据库: 连接失败");
}
#[test]
fn test_error_with_suggestion() {
let error = error_with_suggestion("无法连接到服务器", "检查网络连接");
assert_eq!(error, "无法连接到服务器。建议: 检查网络连接");
}
#[test]
fn test_io_error_conversions() {
let error = std::io::Error::new(std::io::ErrorKind::NotFound, "文件不存在");
let converted = io_error_to_string(error);
assert!(converted.contains("文件或目录不存在"));
}
}

View File

@@ -0,0 +1,3 @@
/// 工具函数模块
pub mod error;
pub mod node_tester;

View File

@@ -0,0 +1,267 @@
/// 通用节点测试器
///
/// 提供统一的节点连通性测试功能,替代分散在各模块的重复实现
use crate::http_client::{self, ClientConfig};
use crate::types::node_test::{NodeTestResult, TestStatus};
use std::time::Instant;
/// 测试单个节点的连通性
///
/// 使用 HEAD 请求测试节点是否可访问,这是最轻量的测试方式
///
/// # Arguments
/// * `url` - 节点 URL
/// * `timeout_ms` - 超时时间(毫秒)
///
/// # Example
/// ```
/// use claudia_lib::utils::node_tester::test_node_connectivity;
///
/// #[tokio::main]
/// async fn main() {
/// let result = test_node_connectivity("https://api.example.com", 5000).await;
/// if result.is_success() {
/// println!("节点可用,响应时间: {}ms", result.response_time().unwrap());
/// }
/// }
/// ```
pub async fn test_node_connectivity(url: &str, timeout_ms: u64) -> NodeTestResult {
let start = Instant::now();
// 创建快速客户端
let client = match http_client::create_client(
ClientConfig::new()
.timeout(timeout_ms / 1000)
.accept_invalid_certs(true), // 节点测速允许自签名证书
) {
Ok(c) => c,
Err(e) => {
return NodeTestResult::failure(
url.to_string(),
format!("创建 HTTP 客户端失败: {}", e),
);
}
};
// 使用 HEAD 请求测试连通性
match client.head(url).send().await {
Ok(response) => {
let response_time = start.elapsed().as_millis() as u64;
let status_code = response.status();
// 2xx, 3xx, 4xx 都视为成功(说明服务器在线)
// 只有 5xx 或网络错误才视为失败
if status_code.is_success()
|| status_code.is_redirection()
|| status_code.is_client_error()
{
NodeTestResult::success_with_message(
url.to_string(),
response_time,
format!("连接成功 (HTTP {})", status_code.as_u16()),
)
} else {
NodeTestResult::failure_with_time(
url.to_string(),
response_time,
format!("服务器错误 (HTTP {})", status_code.as_u16()),
)
}
}
Err(e) => {
let response_time = start.elapsed().as_millis() as u64;
// 根据错误类型返回不同的结果
if e.is_timeout() {
NodeTestResult::timeout(url.to_string(), response_time)
} else if e.is_connect() {
NodeTestResult::failure_with_time(
url.to_string(),
response_time,
format!("无法连接到服务器: {}", e),
)
} else {
NodeTestResult::failure_with_time(
url.to_string(),
response_time,
format!("网络错误: {}", e),
)
}
}
}
}
/// 批量测试节点连通性(并发)
///
/// 同时测试多个节点,提高测试效率
///
/// # Arguments
/// * `urls` - 节点 URL 列表
/// * `timeout_ms` - 每个节点的超时时间(毫秒)
///
/// # Example
/// ```
/// use claudia_lib::utils::node_tester::test_nodes_batch;
///
/// #[tokio::main]
/// async fn main() {
/// let urls = vec![
/// "https://api1.example.com".to_string(),
/// "https://api2.example.com".to_string(),
/// ];
/// let results = test_nodes_batch(urls, 5000).await;
/// println!("测试完成,成功: {}", results.iter().filter(|r| r.is_success()).count());
/// }
/// ```
pub async fn test_nodes_batch(urls: Vec<String>, timeout_ms: u64) -> Vec<NodeTestResult> {
// 创建所有测试任务
let futures: Vec<_> = urls
.iter()
.map(|url| test_node_connectivity(url, timeout_ms))
.collect();
// 并发执行所有测试
futures::future::join_all(futures).await
}
/// 批量测试节点连通性(顺序)
///
/// 按顺序测试节点,适用于需要限制并发的场景
///
/// # Arguments
/// * `urls` - 节点 URL 列表
/// * `timeout_ms` - 每个节点的超时时间(毫秒)
#[allow(dead_code)]
pub async fn test_nodes_sequential(urls: Vec<String>, timeout_ms: u64) -> Vec<NodeTestResult> {
let mut results = Vec::new();
for url in urls {
let result = test_node_connectivity(&url, timeout_ms).await;
results.push(result);
}
results
}
/// 查找最快的节点
///
/// 从测试结果中找出响应时间最短的成功节点
///
/// # Example
/// ```
/// use claudia_lib::utils::node_tester::{test_nodes_batch, find_fastest_node};
///
/// #[tokio::main]
/// async fn main() {
/// let urls = vec!["https://api1.com".to_string(), "https://api2.com".to_string()];
/// let results = test_nodes_batch(urls, 5000).await;
/// if let Some(fastest) = find_fastest_node(&results) {
/// println!("最快节点: {}, 响应时间: {}ms", fastest.url, fastest.response_time().unwrap());
/// }
/// }
/// ```
pub fn find_fastest_node(results: &[NodeTestResult]) -> Option<&NodeTestResult> {
results
.iter()
.filter(|r| r.is_success() && r.response_time().is_some())
.min_by_key(|r| r.response_time().unwrap())
}
/// 过滤成功的节点
#[allow(dead_code)]
pub fn filter_successful_nodes(results: &[NodeTestResult]) -> Vec<&NodeTestResult> {
results.iter().filter(|r| r.is_success()).collect()
}
/// 过滤失败的节点
#[allow(dead_code)]
pub fn filter_failed_nodes(results: &[NodeTestResult]) -> Vec<&NodeTestResult> {
results.iter().filter(|r| r.is_failure()).collect()
}
/// 按响应时间排序(从快到慢)
pub fn sort_by_response_time(results: &mut [NodeTestResult]) {
results.sort_by(|a, b| {
// 成功的节点优先
match (a.status, b.status) {
(TestStatus::Success, TestStatus::Success) => {
// 都成功,按响应时间排序
match (a.response_time_ms, b.response_time_ms) {
(Some(t1), Some(t2)) => t1.cmp(&t2),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
}
(TestStatus::Success, _) => std::cmp::Ordering::Less,
(_, TestStatus::Success) => std::cmp::Ordering::Greater,
_ => {
// 都失败,按响应时间排序
match (a.response_time_ms, b.response_time_ms) {
(Some(t1), Some(t2)) => t1.cmp(&t2),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_invalid_url() {
let result = test_node_connectivity("invalid-url", 1000).await;
assert!(result.is_failure());
}
#[test]
fn test_sort_by_response_time() {
let mut results = vec![
NodeTestResult::success("http://3".to_string(), 300),
NodeTestResult::success("http://1".to_string(), 100),
NodeTestResult::failure("http://4".to_string(), "error".to_string()),
NodeTestResult::success("http://2".to_string(), 200),
];
sort_by_response_time(&mut results);
// 成功的节点应该在前面,且按响应时间排序
assert_eq!(results[0].url, "http://1");
assert_eq!(results[1].url, "http://2");
assert_eq!(results[2].url, "http://3");
assert_eq!(results[3].url, "http://4");
}
#[test]
fn test_find_fastest_node() {
let results = vec![
NodeTestResult::success("http://1".to_string(), 200),
NodeTestResult::success("http://2".to_string(), 100), // 最快
NodeTestResult::failure("http://3".to_string(), "error".to_string()),
];
let fastest = find_fastest_node(&results);
assert!(fastest.is_some());
assert_eq!(fastest.unwrap().url, "http://2");
}
#[test]
fn test_filter_nodes() {
let results = vec![
NodeTestResult::success("http://1".to_string(), 100),
NodeTestResult::failure("http://2".to_string(), "error".to_string()),
NodeTestResult::success("http://3".to_string(), 200),
];
let successful = filter_successful_nodes(&results);
assert_eq!(successful.len(), 2);
let failed = filter_failed_nodes(&results);
assert_eq!(failed.len(), 1);
}
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Claudia",
"version": "1.2.0",
"version": "1.3.0",
"identifier": "claudia.asterisk.so",
"build": {
"beforeDevCommand": "bun run dev",
@@ -18,7 +18,7 @@
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost blob: data:; style-src 'self' 'unsafe-inline' blob: data: asset: https://asset.localhost; style-src-elem 'self' 'unsafe-inline' blob: data: asset: https://asset.localhost; style-src-attr 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval' https://app.posthog.com https://*.posthog.com https://*.i.posthog.com https://*.assets.i.posthog.com; worker-src 'self' blob: asset: https://asset.localhost; font-src 'self' data: blob: asset: https://asset.localhost; connect-src 'self' ipc: http://ipc.localhost https://ipc.localhost https://app.posthog.com https://*.posthog.com https://*.i.posthog.com https://api.packycode.com https://api-hk-cn2.packycode.com https://api-us-cmin2.packycode.com https://api-us-4837.packycode.com https://api-us-cn2.packycode.com https://api-cf-pro.packycode.com https://share-api.packycode.com https://share-api-cf-pro.packycode.com https://share-api-hk-cn2.packycode.com",
"csp": "default-src 'self'; img-src 'self' asset: https://asset.localhost blob: data:; style-src 'self' 'unsafe-inline' blob: data: asset: https://asset.localhost; style-src-elem 'self' 'unsafe-inline' blob: data: asset: https://asset.localhost; style-src-attr 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval' https://app.posthog.com https://*.posthog.com https://*.i.posthog.com https://*.assets.i.posthog.com; worker-src 'self' blob: asset: https://asset.localhost; font-src 'self' data: blob: asset: https://asset.localhost; connect-src 'self' ipc: http://ipc.localhost https://ipc.localhost https://app.posthog.com https://*.posthog.com https://*.i.posthog.com https://api.packycode.com https://api-hk-cn2.packycode.com https://api-hk-g.packycode.com https://api-cf-pro.packycode.com https://api-us-cn2.packycode.com https://share-api.packycode.com https://share-api-hk-cn2.packycode.com https://share-api-hk-g.packycode.com https://share-api-cf-pro.packycode.com https://share-api-us-cn2.packycode.com",
"assetProtocol": {
"enable": true,
"scope": [
@@ -37,7 +37,7 @@
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico",

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { useState, useEffect, lazy, Suspense } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Plus, Loader2, ArrowLeft } from "lucide-react";
import { api, type Project, type Session, type ClaudeMdFile } from "@/lib/api";
import { Loader2, ArrowLeft } from "lucide-react";
import { api, type Project, type Session } from "@/lib/api";
import { OutputCacheProvider } from "@/lib/outputCache";
import { TabProvider } from "@/contexts/TabContext";
import { ThemeProvider } from "@/contexts/ThemeContext";
@@ -10,12 +10,7 @@ import { ProjectList } from "@/components/ProjectList";
import { SessionList } from "@/components/SessionList";
import { RunningClaudeSessions } from "@/components/RunningClaudeSessions";
import { Topbar } from "@/components/Topbar";
import { MarkdownEditor } from "@/components/MarkdownEditor";
import { ClaudeFileEditor } from "@/components/ClaudeFileEditor";
import { Settings } from "@/components/Settings";
import { CCAgents } from "@/components/CCAgents";
import { UsageDashboard } from "@/components/UsageDashboard";
import { MCPManager } from "@/components/MCPManager";
import { NFOCredits } from "@/components/NFOCredits";
import { ClaudeBinaryDialog } from "@/components/ClaudeBinaryDialog";
import { Toast, ToastContainer } from "@/components/ui/toast";
@@ -30,13 +25,17 @@ import { useTranslation } from "@/hooks/useTranslation";
import { WelcomePage } from "@/components/WelcomePage";
import RelayStationManager from "@/components/RelayStationManager";
import { CcrRouterManager } from "@/components/CcrRouterManager";
import { PromptFilesManager } from "@/components";
import i18n from "@/lib/i18n";
// Lazy load these components to match TabContent's dynamic imports
const Settings = lazy(() => import('@/components/Settings').then(m => ({ default: m.Settings })));
const UsageDashboard = lazy(() => import('@/components/UsageDashboard').then(m => ({ default: m.UsageDashboard })));
const MCPManager = lazy(() => import('@/components/MCPManager').then(m => ({ default: m.MCPManager })));
type View =
| "welcome"
| "projects"
| "editor"
| "claude-file-editor"
| "settings"
| "cc-agents"
| "create-agent"
@@ -46,6 +45,7 @@ type View =
| "mcp"
| "relay-stations"
| "ccr-router"
| "prompt-files"
| "usage-dashboard"
| "project-settings"
| "tabs"; // New view for tab-based interface
@@ -56,11 +56,19 @@ type View =
function AppContent() {
const { t } = useTranslation();
const [view, setView] = useState<View>("welcome");
const { createClaudeMdTab, createSettingsTab, createUsageTab, createMCPTab } = useTabState();
const {
createSettingsTab,
createUsageTab,
createMCPTab,
createChatTab,
canAddTab,
updateTab,
switchToTab
} = useTabState();
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [sessions, setSessions] = useState<Session[]>([]);
const [editingClaudeFile, setEditingClaudeFile] = useState<ClaudeMdFile | null>(null);
// Removed: project-level ClaudeFileEditor state
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showNFO, setShowNFO] = useState(false);
@@ -69,6 +77,41 @@ function AppContent() {
const [projectForSettings, setProjectForSettings] = useState<Project | null>(null);
const [previousView] = useState<View>("welcome");
const [showAgentsModal, setShowAgentsModal] = useState(false);
const translateWithFallback = (
primaryKey: string,
params: Record<string, unknown> = {},
fallbackKeys: string[] = [],
fallbackDefault: string | ((params: Record<string, unknown>) => string) = primaryKey
) => {
const defaultNamespace = Array.isArray(i18n.options?.defaultNS)
? i18n.options.defaultNS[0] ?? "common"
: (i18n.options?.defaultNS ?? "common");
const candidateKeys = [primaryKey, ...fallbackKeys];
const rawLanguage = i18n.language || i18n.resolvedLanguage;
const normalizedLanguage = rawLanguage?.split('-')[0];
const localesToTry = [rawLanguage, normalizedLanguage, 'en'].filter(Boolean) as string[];
const missingToken = '__i18n_missing__';
for (const key of candidateKeys) {
for (const locale of localesToTry) {
const fixedT = i18n.getFixedT(locale, defaultNamespace);
const translated = fixedT(key, {
...params,
defaultValue: missingToken,
});
if (translated !== missingToken) {
return translated;
}
}
}
return typeof fallbackDefault === 'function'
? (fallbackDefault as (params: Record<string, unknown>) => string)(params)
: fallbackDefault;
};
// Initialize analytics lifecycle tracking
useAppLifecycle();
@@ -88,7 +131,6 @@ function AppContent() {
const backendLocale = frontendLang === 'zh' ? 'zh-CN' : 'en-US';
// Sync to backend
await api.setLanguage(backendLocale);
console.log('Backend language initialized to:', backendLocale);
} catch (error) {
console.error('Failed to initialize backend language:', error);
}
@@ -96,6 +138,13 @@ function AppContent() {
initializeBackendLanguage();
}, []); // Run once on app startup
// Update document title based on current language
useEffect(() => {
try {
document.title = `${t('app.name')} - ${t('app.tagline')}`;
} catch {}
}, [t]);
// Track when user reaches different journey stages
useEffect(() => {
@@ -185,6 +234,27 @@ function AppContent() {
};
}, []);
// Listen for a global request to switch to the tabbed interface
useEffect(() => {
const handleSwitchToTabs = (event: Event) => {
// Accept optional tabId in event detail
const detail = (event as CustomEvent).detail || {};
const tabId = detail.tabId as string | undefined;
setView('tabs');
if (tabId) {
// Wait a tick for TabManager to mount, then switch to the tab
setTimeout(() => {
window.dispatchEvent(new CustomEvent('switch-to-tab', { detail: { tabId } }));
}, 50);
}
};
window.addEventListener('switch-to-tabs', handleSwitchToTabs as EventListener);
return () => {
window.removeEventListener('switch-to-tabs', handleSwitchToTabs as EventListener);
};
}, []);
/**
* Loads all projects from the ~/.claude/projects directory
*/
@@ -223,9 +293,88 @@ function AppContent() {
/**
* Opens a new Claude Code session in the interactive UI
*/
const handleNewSession = async () => {
handleViewChange("tabs");
// The tab system will handle creating a new chat tab
const handleNewSession = () => {
if (!canAddTab()) {
return;
}
const newTabId = createChatTab();
if (view !== "tabs") {
setView("tabs");
} else {
window.dispatchEvent(new CustomEvent('switch-to-tab', { detail: { tabId: newTabId } }));
}
};
/**
* Creates a smart quick start session and opens it in a new tab
* @author yovinchen
*/
const handleSmartQuickStart = async () => {
try {
if (!canAddTab()) {
setToast({ message: t('maximumTabsReached'), type: "error" });
return;
}
// Create smart session
const smartSession = await api.createSmartQuickStartSession();
// Create a new tab for the smart session
const newTabId = createChatTab();
const sessionDisplayName = smartSession.display_name || t('messages.smartSessionDefaultTitle');
// 直接更新新建标签的会话上下文,避免依赖事件时序
updateTab(newTabId, {
type: 'chat',
title: sessionDisplayName,
initialProjectPath: smartSession.project_path,
sessionData: null,
status: 'active'
});
// 切换到标签页视图并激活新标签
if (view !== "tabs") {
setView("tabs");
}
switchToTab(newTabId);
// Show success message若主键缺失则回退到默认提示
const successMessage = translateWithFallback(
'messages.smartSessionCreated',
{ name: sessionDisplayName },
['messages.smartSessionDefaultToast'],
`Smart session '${sessionDisplayName}' is ready to use.`
);
setToast({
message: successMessage,
type: "success"
});
trackEvent.journeyMilestone({
journey_stage: 'smart_session',
milestone_reached: 'smart_session_created',
time_to_milestone_ms: Date.now() - performance.timing.navigationStart
});
} catch (error) {
console.error('Failed to create smart session:', error);
const rawError = error instanceof Error ? error.message : String(error);
const fallbackErrorMessage = translateWithFallback(
'messages.failedToCreateSmartSession',
{ error: rawError },
['messages.failedToCreateSmartSessionFallback'],
`Failed to create smart session: ${rawError}`
);
setToast({
message: fallbackErrorMessage,
type: "error"
});
}
};
/**
@@ -239,18 +388,7 @@ function AppContent() {
/**
* Handles editing a CLAUDE.md file from a project
*/
const handleEditClaudeFile = (file: ClaudeMdFile) => {
setEditingClaudeFile(file);
handleViewChange("claude-file-editor");
};
/**
* Returns from CLAUDE.md file editor to projects view
*/
const handleBackFromClaudeFileEditor = () => {
setEditingClaudeFile(null);
handleViewChange("projects");
};
// Removed project-level ClaudeFileEditor routes and handlers
/**
* Handles view changes with navigation protection
@@ -276,37 +414,48 @@ function AppContent() {
<WelcomePage
onNavigate={handleViewChange}
onNewSession={handleNewSession}
onSmartQuickStart={handleSmartQuickStart}
/>
);
case "relay-stations":
return (
<RelayStationManager onBack={() => handleViewChange("welcome")} />
<div className="h-full overflow-hidden">
<RelayStationManager onBack={() => handleViewChange("welcome")} />
</div>
);
case "ccr-router":
return (
<CcrRouterManager onBack={() => handleViewChange("welcome")} />
<div className="h-full overflow-hidden">
<CcrRouterManager onBack={() => handleViewChange("welcome")} />
</div>
);
case "cc-agents":
return (
<CCAgents
onBack={() => handleViewChange("welcome")}
/>
<div className="h-full overflow-hidden">
<CCAgents
onBack={() => handleViewChange("welcome")}
/>
</div>
);
case "editor":
// Removed old direct CLAUDE.md editor view
case "prompt-files":
return (
<div className="flex-1 overflow-hidden">
<MarkdownEditor onBack={() => handleViewChange("welcome")} />
<div className="h-full overflow-hidden">
<PromptFilesManager onBack={() => handleViewChange("welcome")} />
</div>
);
case "settings":
return (
<div className="flex-1 flex flex-col" style={{ minHeight: 0 }}>
<Settings onBack={() => handleViewChange("welcome")} />
<div className="h-full overflow-hidden">
<Suspense fallback={<div className="flex items-center justify-center h-full"><Loader2 className="h-6 w-6 animate-spin" /></div>}>
<Settings onBack={() => handleViewChange("welcome")} />
</Suspense>
</div>
);
@@ -371,7 +520,6 @@ function AppContent() {
sessions={sessions}
projectPath={selectedProject.path}
onBack={handleBack}
onEditClaudeFile={handleEditClaudeFile}
onSessionClick={(session) => {
// Navigate to session detail view in tabs mode
setView("tabs");
@@ -395,32 +543,16 @@ function AppContent() {
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.3 }}
>
{/* New session button at the top */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="mb-4"
>
<Button
onClick={handleNewSession}
size="default"
className="w-full max-w-md"
>
<Plus className="mr-2 h-4 w-4" />
{t('newClaudeCodeSession')}
</Button>
</motion.div>
{/* Running Claude Sessions */}
<RunningClaudeSessions />
{/* Project list */}
{/* Project list with integrated new session button */}
{projects.length > 0 ? (
<ProjectList
projects={projects}
onProjectClick={handleProjectClick}
onProjectSettings={handleProjectSettings}
onNewSession={handleNewSession}
loading={loading}
className="animate-fade-in"
/>
@@ -439,13 +571,7 @@ function AppContent() {
</div>
);
case "claude-file-editor":
return editingClaudeFile ? (
<ClaudeFileEditor
file={editingClaudeFile}
onBack={handleBackFromClaudeFileEditor}
/>
) : null;
// Removed: claude-file-editor view
case "tabs":
return (
@@ -455,28 +581,38 @@ function AppContent() {
<TabContent />
</div>
</div>
);
);
case "usage-dashboard":
return (
<UsageDashboard onBack={() => handleViewChange("welcome")} />
<div className="h-full overflow-hidden">
<Suspense fallback={<div className="flex items-center justify-center h-full"><Loader2 className="h-6 w-6 animate-spin" /></div>}>
<UsageDashboard onBack={() => handleViewChange("welcome")} />
</Suspense>
</div>
);
case "mcp":
return (
<MCPManager onBack={() => handleViewChange("welcome")} />
<div className="h-full overflow-hidden">
<Suspense fallback={<div className="flex items-center justify-center h-full"><Loader2 className="h-6 w-6 animate-spin" /></div>}>
<MCPManager onBack={() => handleViewChange("welcome")} />
</Suspense>
</div>
);
case "project-settings":
if (projectForSettings) {
return (
<ProjectSettings
project={projectForSettings}
onBack={() => {
setProjectForSettings(null);
handleViewChange(previousView || "projects");
}}
/>
<div className="h-full overflow-hidden">
<ProjectSettings
project={projectForSettings}
onBack={() => {
setProjectForSettings(null);
handleViewChange(previousView || "projects");
}}
/>
</div>
);
}
break;
@@ -490,12 +626,12 @@ function AppContent() {
<div className="h-screen bg-background flex flex-col">
{/* Topbar */}
<Topbar
onClaudeClick={() => view === 'tabs' ? createClaudeMdTab() : handleViewChange('editor')}
onSettingsClick={() => view === 'tabs' ? createSettingsTab() : handleViewChange('settings')}
onUsageClick={() => view === 'tabs' ? createUsageTab() : handleViewChange('usage-dashboard')}
onMCPClick={() => view === 'tabs' ? createMCPTab() : handleViewChange('mcp')}
onInfoClick={() => setShowNFO(true)}
onAgentsClick={() => view === 'tabs' ? setShowAgentsModal(true) : handleViewChange('cc-agents')}
onPromptFilesClick={() => handleViewChange('prompt-files')}
/>
{/* Analytics Consent Banner */}

View File

@@ -274,7 +274,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
const selected = await open({
directory: true,
multiple: false,
title: "Select Project Directory"
title: t('webview.selectProjectDirectory')
});
if (selected) {
@@ -576,7 +576,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
<div>
<h1 className="text-xl font-bold">{t('agents.execute')}: {agent.name}</h1>
<p className="text-sm text-muted-foreground">
{model === 'opus' ? 'Claude 4.1 Opus' : 'Claude 4 Sonnet'}
{model === 'opus' ? t('agents.opusName') : t('agents.sonnetName')}
</p>
</div>
</div>
@@ -669,7 +669,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
<div className="w-1.5 h-1.5 rounded-full bg-primary-foreground" />
)}
</div>
<span>Claude 4 Sonnet</span>
<span>{t('agents.sonnetName')}</span>
</div>
</button>
@@ -695,7 +695,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
<div className="w-1.5 h-1.5 rounded-full bg-primary-foreground" />
)}
</div>
<span>Claude 4.1 Opus</span>
<span>{t('agents.opusName')}</span>
</div>
</button>
</div>
@@ -772,7 +772,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
<div className="flex items-center justify-center h-full">
<div className="flex items-center gap-3">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-sm text-muted-foreground">Initializing agent...</span>
<span className="text-sm text-muted-foreground">{t('agents.initializing') || 'Initializing agent...'}</span>
</div>
</div>
)}
@@ -826,11 +826,11 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-2">
{renderIcon()}
<h2 className="text-lg font-semibold">{agent.name} - Output</h2>
<h2 className="text-lg font-semibold">{agent.name} - {t('app.output')}</h2>
{isRunning && (
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-xs text-green-600 font-medium">Running</span>
<span className="text-xs text-green-600 font-medium">{t('agents.statusRunning')}</span>
</div>
)}
</div>
@@ -878,7 +878,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
className="flex items-center gap-2"
>
<X className="h-4 w-4" />
Close
{t('app.close')}
</Button>
</div>
</div>
@@ -914,7 +914,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
<div className="flex items-center justify-center h-full">
<div className="flex items-center gap-3">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-sm text-muted-foreground">Initializing agent...</span>
<span className="text-sm text-muted-foreground">{t('agents.initializing') || 'Initializing agent...'}</span>
</div>
</div>
)}
@@ -967,8 +967,8 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
<Tabs value={activeHooksTab} onValueChange={setActiveHooksTab} className="flex-1 flex flex-col overflow-hidden">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="project">Project Settings</TabsTrigger>
<TabsTrigger value="local">Local Settings</TabsTrigger>
<TabsTrigger value="project">{t('agents.projectSettings') || 'Project Settings'}</TabsTrigger>
<TabsTrigger value="local">{t('agents.localSettings') || 'Local Settings'}</TabsTrigger>
</TabsList>
<TabsContent value="project" className="flex-1 overflow-auto">

View File

@@ -58,6 +58,7 @@ export function AgentRunOutputViewer({
tabId,
className
}: AgentRunOutputViewerProps) {
const { t } = useTranslation();
const { updateTabTitle, updateTabStatus } = useTabState();
const [run, setRun] = useState<AgentRunWithMetrics | null>(null);
@@ -143,25 +144,17 @@ export function AgentRunOutputViewer({
const loadOutput = async (skipCache = false) => {
if (!run?.id) return;
console.log('[AgentRunOutputViewer] Loading output for run:', {
runId: run.id,
status: run.status,
sessionId: run.session_id,
skipCache
});
try {
// Check cache first if not skipping cache
if (!skipCache) {
const cached = getCachedOutput(run.id);
if (cached) {
console.log('[AgentRunOutputViewer] Found cached output');
const cachedJsonlLines = cached.output.split('\n').filter(line => line.trim());
setRawJsonlOutput(cachedJsonlLines);
setMessages(cached.messages);
// If cache is recent (less than 5 seconds old) and session isn't running, use cache only
if (Date.now() - cached.lastUpdated < 5000 && run.status !== 'running') {
console.log('[AgentRunOutputViewer] Using recent cache, skipping refresh');
return;
}
}
@@ -171,10 +164,8 @@ export function AgentRunOutputViewer({
// If we have a session_id, try to load from JSONL file first
if (run.session_id && run.session_id !== '') {
console.log('[AgentRunOutputViewer] Attempting to load from JSONL with session_id:', run.session_id);
try {
const history = await api.loadAgentSessionHistory(run.session_id);
console.log('[AgentRunOutputViewer] Successfully loaded JSONL history:', history.length, 'messages');
// Convert history to messages format
const loadedMessages: ClaudeStreamMessage[] = history.map(entry => ({
@@ -195,29 +186,22 @@ export function AgentRunOutputViewer({
// Set up live event listeners for running sessions
if (run.status === 'running') {
console.log('[AgentRunOutputViewer] Setting up live listeners for running session');
setupLiveEventListeners();
try {
await api.streamSessionOutput(run.id);
} catch (streamError) {
console.warn('[AgentRunOutputViewer] Failed to start streaming, will poll instead:', streamError);
}
}
return;
} catch (err) {
console.warn('[AgentRunOutputViewer] Failed to load from JSONL:', err);
console.warn('[AgentRunOutputViewer] Falling back to regular output method');
// Fallback path will be used if JSONL loading fails
}
} else {
console.log('[AgentRunOutputViewer] No session_id available, using fallback method');
}
// Fallback to the original method if JSONL loading fails or no session_id
console.log('[AgentRunOutputViewer] Using getSessionOutput fallback');
const rawOutput = await api.getSessionOutput(run.id);
console.log('[AgentRunOutputViewer] Received raw output:', rawOutput.length, 'characters');
// Parse JSONL output into messages
const jsonlLines = rawOutput.split('\n').filter(line => line.trim());
@@ -232,7 +216,6 @@ export function AgentRunOutputViewer({
console.error("[AgentRunOutputViewer] Failed to parse message:", err, line);
}
}
console.log('[AgentRunOutputViewer] Parsed', parsedMessages.length, 'messages from output');
setMessages(parsedMessages);
// Update cache
@@ -245,18 +228,16 @@ export function AgentRunOutputViewer({
// Set up live event listeners for running sessions
if (run.status === 'running') {
console.log('[AgentRunOutputViewer] Setting up live listeners for running session (fallback)');
setupLiveEventListeners();
try {
await api.streamSessionOutput(run.id);
} catch (streamError) {
console.warn('[AgentRunOutputViewer] Failed to start streaming (fallback), will poll instead:', streamError);
}
}
} catch (error) {
console.error('Failed to load agent output:', error);
setToast({ message: 'Failed to load agent output', type: 'error' });
setToast({ message: t('app.failedToLoadSessionOutput'), type: 'error' });
} finally {
setLoading(false);
}
@@ -285,7 +266,6 @@ export function AgentRunOutputViewer({
try {
// Skip messages during initial load phase
if (isInitialLoadRef.current) {
console.log('[AgentRunOutputViewer] Skipping message during initial load');
return;
}
@@ -306,12 +286,12 @@ export function AgentRunOutputViewer({
});
const completeUnlisten = await listen<boolean>(`agent-complete:${run!.id}`, () => {
setToast({ message: 'Agent execution completed', type: 'success' });
setToast({ message: t('app.agentExecutionCompleted'), type: 'success' });
// Don't set status here as the parent component should handle it
});
const cancelUnlisten = await listen<boolean>(`agent-cancelled:${run!.id}`, () => {
setToast({ message: 'Agent execution was cancelled', type: 'error' });
setToast({ message: t('app.agentExecutionCancelled'), type: 'error' });
});
unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten, cancelUnlisten];
@@ -325,7 +305,7 @@ export function AgentRunOutputViewer({
const jsonl = rawJsonlOutput.join('\n');
await navigator.clipboard.writeText(jsonl);
setCopyPopoverOpen(false);
setToast({ message: 'Output copied as JSONL', type: 'success' });
setToast({ message: t('webview.sessionOutputCopiedJsonl'), type: 'success' });
};
const handleCopyAsMarkdown = async () => {
@@ -383,7 +363,7 @@ export function AgentRunOutputViewer({
await navigator.clipboard.writeText(markdown);
setCopyPopoverOpen(false);
setToast({ message: 'Output copied as Markdown', type: 'success' });
setToast({ message: t('webview.sessionOutputCopiedMarkdown'), type: 'success' });
};
const handleRefresh = async () => {
@@ -403,8 +383,7 @@ export function AgentRunOutputViewer({
const success = await api.killAgentSession(run.id);
if (success) {
console.log(`[AgentRunOutputViewer] Successfully stopped agent session ${run.id}`);
setToast({ message: 'Agent execution stopped', type: 'success' });
setToast({ message: t('agentRun.executionStopped'), type: 'success' });
// Clean up listeners
unlistenRefs.current.forEach(unlisten => unlisten());
@@ -431,15 +410,11 @@ export function AgentRunOutputViewer({
// Refresh the output to get updated status
await loadOutput(true);
} else {
console.warn(`[AgentRunOutputViewer] Failed to stop agent session ${run.id} - it may have already finished`);
setToast({ message: 'Failed to stop agent - it may have already finished', type: 'error' });
setToast({ message: t('agentRun.stopFailed'), type: 'error' });
}
} catch (err) {
console.error('[AgentRunOutputViewer] Failed to stop agent:', err);
setToast({
message: `Failed to stop execution: ${err instanceof Error ? err.message : 'Unknown error'}`,
type: 'error'
});
setToast({ message: t('agentRun.stopFailed'), type: 'error' });
}
};
@@ -537,7 +512,7 @@ export function AgentRunOutputViewer({
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">Loading agent run...</p>
<p className="text-muted-foreground">{t('app.loadingAgentRun')}</p>
</div>
</div>
);
@@ -559,7 +534,7 @@ export function AgentRunOutputViewer({
{run.status === 'running' && (
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-xs text-green-600 font-medium">Running</span>
<span className="text-xs text-green-600 font-medium">{t('agents.statusRunning')}</span>
</div>
)}
</CardTitle>
@@ -568,7 +543,7 @@ export function AgentRunOutputViewer({
</p>
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-2">
<Badge variant="outline" className="text-xs">
{run.model === 'opus' ? 'Claude 4.1 Opus' : 'Claude 4 Sonnet'}
{run.model === 'opus' ? t('agents.opusName') : t('agents.sonnetName')}
</Badge>
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
@@ -601,7 +576,7 @@ export function AgentRunOutputViewer({
className="h-8 px-2"
>
<Copy className="h-4 w-4 mr-1" />
Copy
{t('app.copyOutput')}
<ChevronDown className="h-3 w-3 ml-1" />
</Button>
}
@@ -633,7 +608,7 @@ export function AgentRunOutputViewer({
variant="ghost"
size="sm"
onClick={() => setIsFullscreen(!isFullscreen)}
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
title={isFullscreen ? t('webview.exitFullScreen') : t('webview.enterFullScreen')}
className="h-8 px-2"
>
{isFullscreen ? (
@@ -647,7 +622,7 @@ export function AgentRunOutputViewer({
size="sm"
onClick={handleRefresh}
disabled={refreshing}
title="Refresh output"
title={t('app.refresh')}
className="h-8 px-2"
>
<RotateCcw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
@@ -658,7 +633,7 @@ export function AgentRunOutputViewer({
size="sm"
onClick={handleStop}
disabled={refreshing}
title="Stop execution"
title={t('agents.stop')}
className="h-8 px-2 text-destructive hover:text-destructive"
>
<StopCircle className="h-4 w-4" />
@@ -672,12 +647,12 @@ export function AgentRunOutputViewer({
<div className="flex items-center justify-center h-full">
<div className="flex items-center space-x-2">
<RefreshCw className="h-4 w-4 animate-spin" />
<span>Loading output...</span>
<span>{t('app.loadingOutput')}</span>
</div>
</div>
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<p>No output available yet</p>
<p>{t('app.noOutput')}</p>
</div>
) : (
<div
@@ -767,7 +742,7 @@ export function AgentRunOutputViewer({
disabled={refreshing}
>
<StopCircle className="h-4 w-4 mr-2" />
Stop
{t('agents.stop')}
</Button>
)}
<Button
@@ -776,7 +751,7 @@ export function AgentRunOutputViewer({
onClick={() => setIsFullscreen(false)}
>
<Minimize2 className="h-4 w-4 mr-2" />
Exit Fullscreen
{t('webview.exitFullScreen')}
</Button>
</div>
</div>
@@ -788,7 +763,7 @@ export function AgentRunOutputViewer({
<div className="max-w-4xl mx-auto space-y-2">
{messages.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
No output available yet
{t('app.noOutput')}
</div>
) : (
<>
@@ -828,4 +803,4 @@ export function AgentRunOutputViewer({
);
}
export default AgentRunOutputViewer;
export default AgentRunOutputViewer;

View File

@@ -103,7 +103,7 @@ export const AgentRunView: React.FC<AgentRunViewProps> = ({
}
} catch (err) {
console.error("Failed to load run:", err);
setError("Failed to load execution details");
setError(t('agentRun.loadFailed'));
} finally {
setLoading(false);
}
@@ -198,7 +198,7 @@ export const AgentRunView: React.FC<AgentRunViewProps> = ({
type: "result",
subtype: "error",
is_error: true,
result: "Execution stopped by user",
result: t('agentRun.executionStopped'),
duration_ms: 0,
usage: {
input_tokens: 0,
@@ -235,8 +235,8 @@ export const AgentRunView: React.FC<AgentRunViewProps> = ({
if (error || !run) {
return (
<div className={cn("flex flex-col items-center justify-center h-full", className)}>
<p className="text-destructive mb-4">{error || "Run not found"}</p>
<Button onClick={onBack}>Go Back</Button>
<p className="text-destructive mb-4">{error || t('agentRun.runNotFound')}</p>
<Button onClick={onBack}>{t('app.back')}</Button>
</div>
);
}
@@ -264,7 +264,7 @@ export const AgentRunView: React.FC<AgentRunViewProps> = ({
{renderIcon(run.agent_icon)}
<div>
<h2 className="text-lg font-semibold">{run.agent_name}</h2>
<p className="text-xs text-muted-foreground">Execution History</p>
<p className="text-xs text-muted-foreground">{t('agents.executionHistory')}</p>
</div>
</div>
</div>
@@ -278,7 +278,7 @@ export const AgentRunView: React.FC<AgentRunViewProps> = ({
className="text-destructive hover:text-destructive"
>
<StopCircle className="h-4 w-4 mr-1" />
Stop
{t('agents.stop')}
</Button>
)}
@@ -326,10 +326,10 @@ export const AgentRunView: React.FC<AgentRunViewProps> = ({
<CardContent className="p-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium">Task:</h3>
<h3 className="text-sm font-medium">{t('app.task')}:</h3>
<p className="text-sm text-muted-foreground flex-1">{run.task}</p>
<Badge variant="outline" className="text-xs">
{run.model === 'opus' ? 'Claude 4.1 Opus' : 'Claude 4 Sonnet'}
{run.model === 'opus' ? t('agents.opusName') : t('agents.sonnetName')}
</Badge>
</div>
@@ -349,7 +349,7 @@ export const AgentRunView: React.FC<AgentRunViewProps> = ({
{run.metrics?.total_tokens && (
<div className="flex items-center gap-1">
<Hash className="h-3 w-3" />
<span>{run.metrics.total_tokens} tokens</span>
<span>{run.metrics.total_tokens} {t('usage.tokens')}</span>
</div>
)}

View File

@@ -98,12 +98,19 @@ export const AgentRunsList: React.FC<AgentRunsListProps> = ({
};
const handleRunClick = (run: AgentRunWithMetrics) => {
if (!run.id) {
console.error('[AgentRunsList] Cannot open run - no ID available:', run);
return;
}
// If there's a callback, use it (for full-page navigation)
if (onRunClick) {
onRunClick(run);
} else if (run.id) {
} else {
// Otherwise, open in new tab
createAgentTab(run.id.toString(), run.agent_name);
const tabId = createAgentTab(run.id.toString(), run.agent_name);
window.dispatchEvent(new CustomEvent('switch-to-tabs', { detail: { tabId } }));
}
};
@@ -215,4 +222,4 @@ export const AgentRunsList: React.FC<AgentRunsListProps> = ({
</>
);
};
};

View File

@@ -150,11 +150,11 @@ export const AgentsModal: React.FC<AgentsModalProps> = ({ open, onOpenChange })
if (filePath) {
const agent = await api.importAgentFromFile(filePath as string);
loadAgents(); // Refresh list
setToast({ message: `Agent "${agent.name}" imported successfully`, type: "success" });
setToast({ message: t('agents.importedSuccessfully', { name: agent.name }), type: "success" });
}
} catch (error) {
console.error('Failed to import agent:', error);
setToast({ message: "Failed to import agent", type: "error" });
setToast({ message: t('agents.importFailed'), type: "error" });
}
};
@@ -175,11 +175,11 @@ export const AgentsModal: React.FC<AgentsModalProps> = ({ open, onOpenChange })
if (filePath) {
await invoke('write_file', { path: filePath, content: JSON.stringify(exportData, null, 2) });
setToast({ message: "Agent exported successfully", type: "success" });
setToast({ message: t('agents.exportedSuccessfully', { name: agent.name }), type: "success" });
}
} catch (error) {
console.error('Failed to export agent:', error);
setToast({ message: "Failed to export agent", type: "error" });
setToast({ message: t('agents.exportFailed'), type: "error" });
}
};
@@ -424,7 +424,7 @@ export const AgentsModal: React.FC<AgentsModalProps> = ({ open, onOpenChange })
onImportSuccess={() => {
setShowGitHubBrowser(false);
loadAgents(); // Refresh the agents list
setToast({ message: "Agent imported successfully", type: "success" });
setToast({ message: t('agents.importedSuccessfully'), type: "success" });
}}
/>

View File

@@ -442,7 +442,7 @@ export const CCAgents: React.FC<CCAgentsProps> = ({ onBack, className }) => {
variant="ghost"
onClick={() => handleExportAgent(agent)}
className="flex items-center gap-1"
title="Export agent to .claudia.json"
title={t('agents.exportToFile')}
>
<Upload className="h-3 w-3" />
{t('agents.export')}

View File

@@ -7,12 +7,14 @@ 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';
import { useTranslation } from '@/hooks/useTranslation';
interface CcrRouterManagerProps {
onBack: () => void;
}
export function CcrRouterManager({ onBack }: CcrRouterManagerProps) {
const { t } = useTranslation();
const [serviceStatus, setServiceStatus] = useState<CcrServiceStatus | null>(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
@@ -34,7 +36,7 @@ export function CcrRouterManager({ onBack }: CcrRouterManagerProps) {
} catch (error) {
console.error("Failed to load CCR service status:", error);
setToast({
message: `加载CCR服务状态失败: ${error}`,
message: t('ccr.loadStatusFailed', { error: String(error) }),
type: "error"
});
} finally {
@@ -63,7 +65,7 @@ export function CcrRouterManager({ onBack }: CcrRouterManagerProps) {
} catch (error) {
console.error("Failed to start CCR service:", error);
setToast({
message: `启动CCR服务失败: ${error}`,
message: t('ccr.startFailed', { error: String(error) }),
type: "error"
});
} finally {
@@ -83,7 +85,7 @@ export function CcrRouterManager({ onBack }: CcrRouterManagerProps) {
} catch (error) {
console.error("Failed to stop CCR service:", error);
setToast({
message: `停止CCR服务失败: ${error}`,
message: t('ccr.stopFailed', { error: String(error) }),
type: "error"
});
} finally {
@@ -103,7 +105,7 @@ export function CcrRouterManager({ onBack }: CcrRouterManagerProps) {
} catch (error) {
console.error("Failed to restart CCR service:", error);
setToast({
message: `重启CCR服务失败: ${error}`,
message: t('ccr.restartFailed', { error: String(error) }),
type: "error"
});
} finally {
@@ -118,14 +120,14 @@ export function CcrRouterManager({ onBack }: CcrRouterManagerProps) {
// 如果服务未运行,先尝试启动
if (!serviceStatus?.is_running) {
setToast({
message: "检测到服务未运行,正在启动...",
message: t('ccr.serviceStarting'),
type: "info"
});
const startResult = await ccrApi.startService();
setServiceStatus(startResult.status);
if (!startResult.status.is_running) {
throw new Error("服务启动失败");
throw new Error(t('ccr.serviceStartFailed'));
}
// 等待服务完全启动
@@ -134,7 +136,7 @@ export function CcrRouterManager({ onBack }: CcrRouterManagerProps) {
await ccrApi.openUI();
setToast({
message: "正在打开CCR UI...",
message: t('ccr.openingUI'),
type: "info"
});
@@ -145,7 +147,7 @@ export function CcrRouterManager({ onBack }: CcrRouterManagerProps) {
} catch (error) {
console.error("Failed to open CCR UI:", error);
setToast({
message: `打开CCR UI失败: ${error}`,
message: t('ccr.openUIFailed', { error: String(error) }),
type: "error"
});
} finally {
@@ -159,7 +161,7 @@ export function CcrRouterManager({ onBack }: CcrRouterManagerProps) {
if (!serviceStatus?.is_running) {
setActionLoading(true);
setToast({
message: "检测到服务未运行,正在启动...",
message: t('ccr.serviceStarting'),
type: "info"
});
@@ -167,7 +169,7 @@ export function CcrRouterManager({ onBack }: CcrRouterManagerProps) {
setServiceStatus(startResult.status);
if (!startResult.status.is_running) {
throw new Error("服务启动失败");
throw new Error(t('ccr.serviceStartFailed'));
}
// 等待服务完全启动
@@ -178,14 +180,14 @@ export function CcrRouterManager({ onBack }: CcrRouterManagerProps) {
if (serviceStatus?.endpoint) {
open(`${serviceStatus.endpoint}/ui/`);
setToast({
message: "正在打开CCR管理界面...",
message: t('ccr.openingAdmin'),
type: "info"
});
}
} catch (error) {
console.error("Failed to open CCR UI in browser:", error);
setToast({
message: `打开管理界面失败: ${error}`,
message: t('ccr.openAdminFailed', { error: String(error) }),
type: "error"
});
setActionLoading(false);

View File

@@ -32,6 +32,7 @@ import { Popover } from "@/components/ui/popover";
import { useTranslation } from "react-i18next";
import { api, type Session } from "@/lib/api";
import { cn } from "@/lib/utils";
import { useTabState } from "@/hooks/useTabState";
import { open } from "@tauri-apps/plugin-dialog";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { StreamMessage } from "./StreamMessage";
@@ -77,6 +78,10 @@ interface ClaudeCodeSessionProps {
* Initial project path (for new sessions)
*/
initialProjectPath?: string;
/**
* Tab ID (for syncing state back to tab)
*/
tabId?: string;
/**
* Callback to go back
*/
@@ -104,18 +109,20 @@ interface ClaudeCodeSessionProps {
export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
session,
initialProjectPath = "",
tabId,
onBack,
onProjectSettings,
className,
onStreamingChange,
}) => {
const { t } = useTranslation();
const { updateTab } = useTabState();
const layoutManager = useLayoutManager(initialProjectPath || session?.project_path);
const {
layout,
breakpoints,
toggleFileExplorer,
toggleGitPanel,
const {
layout,
breakpoints,
toggleFileExplorer,
toggleGitPanel,
toggleTimeline,
setPanelWidth,
setSplitPosition: setLayoutSplitPosition,
@@ -128,7 +135,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
closeTerminal,
toggleTerminalMaximize
} = layoutManager;
const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || "");
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -347,6 +354,14 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
queuedPromptsRef.current = queuedPrompts;
}, [queuedPrompts]);
// 当父组件通过智能会话或外部导航注入项目路径时,确保初次渲染即可进入工作区而非停留在创建页
useEffect(() => {
if (!session && initialProjectPath && projectPath.trim().length === 0) {
setProjectPath(initialProjectPath);
setError(null);
}
}, [initialProjectPath, projectPath, session]);
// Get effective session info (from prop or extracted) - use useMemo to ensure it updates
const effectiveSession = useMemo(() => {
if (session) return session;
@@ -432,17 +447,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
});
// Debug logging
useEffect(() => {
console.log('[ClaudeCodeSession] State update:', {
projectPath,
session,
extractedSessionInfo,
effectiveSession,
messagesCount: messages.length,
isLoading
});
}, [projectPath, session, extractedSessionInfo, effectiveSession, messages.length, isLoading]);
// Load session history if resuming
useEffect(() => {
if (session) {
@@ -466,7 +470,34 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
useEffect(() => {
onStreamingChange?.(isLoading, claudeSessionId);
}, [isLoading, claudeSessionId, onStreamingChange]);
// Sync projectPath to tab when it changes (for persistence)
useEffect(() => {
if (tabId && projectPath && !session) {
// Only update for new sessions (not resumed sessions)
// This ensures the path is saved when user selects/enters it
updateTab(tabId, {
initialProjectPath: projectPath
});
}
}, [projectPath, tabId, session, updateTab]);
// Auto-start file monitoring when project path is available
useEffect(() => {
if (projectPath && !isFileWatching) {
// Auto-start file monitoring for smart sessions or when project path is set
const timeoutId = setTimeout(async () => {
try {
await startFileWatching();
} catch (error) {
console.warn('[ClaudeCodeSession] Failed to auto-start file monitoring:', error);
}
}, 500); // Small delay to ensure component is fully mounted
return () => clearTimeout(timeoutId);
}
}, [projectPath, isFileWatching, startFileWatching]);
// 滚动到顶部
const scrollToTop = useCallback(() => {
if (parentRef.current) {
@@ -552,7 +583,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
if (activeSession) {
// Session is still active, reconnect to its stream
console.log('[ClaudeCodeSession] Found active session, reconnecting:', session.id);
// IMPORTANT: Set claudeSessionId before reconnecting
setClaudeSessionId(session.id);
@@ -569,11 +599,9 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
};
const reconnectToSession = async (sessionId: string) => {
console.log('[ClaudeCodeSession] Reconnecting to session:', sessionId);
// Prevent duplicate listeners
if (isListeningRef.current) {
console.log('[ClaudeCodeSession] Already listening to session, skipping reconnect');
return;
}
@@ -590,7 +618,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
// Set up session-specific listeners
const outputUnlisten = await listen<string>(`claude-output:${sessionId}`, async (event) => {
try {
console.log('[ClaudeCodeSession] Received claude-output on reconnect:', event.payload);
if (!isMountedRef.current) return;
@@ -612,8 +639,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
}
});
const completeUnlisten = await listen<boolean>(`claude-complete:${sessionId}`, async (event) => {
console.log('[ClaudeCodeSession] Received claude-complete on reconnect:', event.payload);
const completeUnlisten = await listen<boolean>(`claude-complete:${sessionId}`, async () => {
if (isMountedRef.current) {
setIsLoading(false);
hasActiveSessionRef.current = false;
@@ -634,7 +660,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
const selected = await open({
directory: true,
multiple: false,
title: "Select Project Directory"
title: t('webview.selectProjectDirectory')
});
if (selected) {
@@ -644,15 +670,14 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
} catch (err) {
console.error("Failed to select directory:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
setError(`Failed to select directory: ${errorMessage}`);
setError(t('app.selectDirectoryFailed', { message: errorMessage }));
}
};
const handleSendPrompt = async (prompt: string, model: "sonnet" | "opus" | "opus-plan") => {
console.log('[ClaudeCodeSession] handleSendPrompt called with:', { prompt, model, projectPath, claudeSessionId, effectiveSession });
if (!projectPath) {
setError("Please select a project directory first");
setError(t('app.selectProjectFirst'));
return;
}
@@ -699,13 +724,11 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
// generic ones to prevent duplicate handling.
// --------------------------------------------------------------------
console.log('[ClaudeCodeSession] Setting up generic event listeners first');
let currentSessionId: string | null = claudeSessionId || effectiveSession?.id || null;
// Helper to attach session-specific listeners **once we are sure**
const attachSessionSpecificListeners = async (sid: string) => {
console.log('[ClaudeCodeSession] Attaching session-specific listeners for', sid);
const specificOutputUnlisten = await listen<string>(`claude-output:${sid}`, (evt) => {
handleStreamMessage(evt.payload);
@@ -717,7 +740,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
});
const specificCompleteUnlisten = await listen<boolean>(`claude-complete:${sid}`, (evt) => {
console.log('[ClaudeCodeSession] Received claude-complete (scoped):', evt.payload);
processComplete(evt.payload);
});
@@ -735,7 +757,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
const msg = JSON.parse(event.payload) as ClaudeStreamMessage;
if (msg.type === 'system' && msg.subtype === 'init' && msg.session_id) {
if (!currentSessionId || currentSessionId !== msg.session_id) {
console.log('[ClaudeCodeSession] Detected new session_id from generic listener:', msg.session_id);
currentSessionId = msg.session_id;
setClaudeSessionId(msg.session_id);
@@ -943,7 +964,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
});
const genericCompleteUnlisten = await listen<boolean>('claude-complete', (evt) => {
console.log('[ClaudeCodeSession] Received claude-complete (generic):', evt.payload);
processComplete(evt.payload);
});
@@ -1010,12 +1030,10 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
// Execute the appropriate command
if (effectiveSession && !isFirstPrompt) {
console.log('[ClaudeCodeSession] Resuming session:', effectiveSession.id);
trackEvent.sessionResumed(effectiveSession.id);
trackEvent.modelSelected(model);
await api.resumeClaudeCode(projectPath, effectiveSession.id, prompt, model);
} else {
console.log('[ClaudeCodeSession] Starting new session');
setIsFirstPrompt(false);
trackEvent.sessionCreated(model, 'prompt_input');
trackEvent.modelSelected(model);
@@ -1310,7 +1328,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
};
const handlePreviewUrlChange = (url: string) => {
console.log('[ClaudeCodeSession] Preview URL changed to:', url);
openLayoutPreview(url);
};
@@ -1319,7 +1336,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
isMountedRef.current = true;
return () => {
console.log('[ClaudeCodeSession] Component unmounting, cleaning up listeners');
isMountedRef.current = false;
isListeningRef.current = false;
@@ -1503,7 +1519,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p></p>
<p>{t('claudeSession.scrollToTop', 'Scroll to top')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -1524,7 +1540,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p></p>
<p>{t('claudeSession.scrollToBottom', 'Scroll to bottom')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -1535,7 +1551,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</div>
);
const projectPathInput = !session && (
const projectPathInput = !session && !projectPath && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -1649,7 +1665,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
<div className="flex items-center gap-1.5 text-xs bg-muted/50 rounded-full px-2.5 py-1">
<Hash className="h-3 w-3 text-muted-foreground" />
<span className="font-mono">{totalTokens.toLocaleString()}</span>
<span className="text-muted-foreground">tokens</span>
<span className="text-muted-foreground">{t('usage.tokens')}</span>
</div>
)}
@@ -1689,7 +1705,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</Button>
</TooltipTrigger>
<TooltipContent>
<p>File Explorer</p>
<p>{t('app.fileExplorer')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -1710,7 +1726,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Git Panel</p>
<p>{t('app.gitPanel')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -1731,7 +1747,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{isFileWatching ? '停止文件监控' : '启动文件监控'}</p>
<p>{isFileWatching ? t('claudeSession.stopFileWatch', 'Stop file watching') : t('claudeSession.startFileWatch', 'Start file watching')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@@ -1,181 +0,0 @@
import React, { useState, useEffect } from "react";
import MDEditor from "@uiw/react-md-editor";
import { motion } from "framer-motion";
import { ArrowLeft, Save, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Toast, ToastContainer } from "@/components/ui/toast";
import { api, type ClaudeMdFile } from "@/lib/api";
import { cn } from "@/lib/utils";
import { useTranslation } from "@/hooks/useTranslation";
interface ClaudeFileEditorProps {
/**
* The CLAUDE.md file to edit
*/
file: ClaudeMdFile;
/**
* Callback to go back to the previous view
*/
onBack: () => void;
/**
* Optional className for styling
*/
className?: string;
}
/**
* ClaudeFileEditor component for editing project-specific CLAUDE.md files
*
* @example
* <ClaudeFileEditor
* file={claudeMdFile}
* onBack={() => setEditingFile(null)}
* />
*/
export const ClaudeFileEditor: React.FC<ClaudeFileEditorProps> = ({
file,
onBack,
className,
}) => {
const { t } = useTranslation();
const [content, setContent] = useState<string>("");
const [originalContent, setOriginalContent] = useState<string>("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const hasChanges = content !== originalContent;
// Load the file content on mount
useEffect(() => {
loadFileContent();
}, [file.absolute_path]);
const loadFileContent = async () => {
try {
setLoading(true);
setError(null);
const fileContent = await api.readClaudeMdFile(file.absolute_path);
setContent(fileContent);
setOriginalContent(fileContent);
} catch (err) {
console.error("Failed to load file:", err);
setError(t('loadFileFailed'));
} finally {
setLoading(false);
}
};
const handleSave = async () => {
try {
setSaving(true);
setError(null);
setToast(null);
await api.saveClaudeMdFile(file.absolute_path, content);
setOriginalContent(content);
setToast({ message: t('fileSavedSuccess'), type: "success" });
} catch (err) {
console.error("Failed to save file:", err);
setError(t('saveFileFailed'));
setToast({ message: t('saveFileFailed'), type: "error" });
} finally {
setSaving(false);
}
};
const handleBack = () => {
if (hasChanges) {
const confirmLeave = window.confirm(
t('unsavedChangesConfirm')
);
if (!confirmLeave) return;
}
onBack();
};
return (
<div className={cn("flex flex-col h-full bg-background", className)}>
<div className="w-full max-w-5xl mx-auto flex flex-col h-full">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="flex items-center justify-between p-4 border-b border-border"
>
<div className="flex items-center space-x-3">
<Button
variant="ghost"
size="icon"
onClick={handleBack}
className="h-8 w-8"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="min-w-0 flex-1">
<h2 className="text-lg font-semibold truncate">{file.relative_path}</h2>
<p className="text-xs text-muted-foreground">
{t('editProjectSpecificPrompt')}
</p>
</div>
</div>
<Button
onClick={handleSave}
disabled={!hasChanges || saving}
size="sm"
>
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{saving ? t('saving') : t('app.save')}
</Button>
</motion.div>
{/* Error display */}
{error && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-4 mt-4 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive"
>
{error}
</motion.div>
)}
{/* Editor */}
<div className="flex-1 p-4 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="h-full rounded-lg border border-border overflow-hidden shadow-sm" data-color-mode="dark">
<MDEditor
value={content}
onChange={(val) => setContent(val || "")}
preview="edit"
height="100%"
visibleDragbar={false}
/>
</div>
)}
</div>
</div>
{/* Toast Notification */}
<ToastContainer>
{toast && (
<Toast
message={toast.message}
type={toast.type}
onDismiss={() => setToast(null)}
/>
)}
</ToastContainer>
</div>
);
};

View File

@@ -2,6 +2,7 @@ import React, { Component, ReactNode } from "react";
import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import i18n from "@/lib/i18n";
interface ErrorBoundaryProps {
children: ReactNode;
@@ -51,14 +52,14 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
<div className="flex items-start gap-4">
<AlertCircle className="h-8 w-8 text-destructive flex-shrink-0 mt-0.5" />
<div className="flex-1 space-y-2">
<h3 className="text-lg font-semibold">Something went wrong</h3>
<h3 className="text-lg font-semibold">{i18n.t('errorBoundary.somethingWentWrong')}</h3>
<p className="text-sm text-muted-foreground">
An error occurred while rendering this component.
{i18n.t('errorBoundary.errorOccurred')}
</p>
{this.state.error.message && (
<details className="mt-2">
<summary className="text-sm cursor-pointer text-muted-foreground hover:text-foreground">
Error details
{i18n.t('errorBoundary.errorDetails')}
</summary>
<pre className="mt-2 text-xs bg-muted p-2 rounded overflow-auto">
{this.state.error.message}
@@ -70,7 +71,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
size="sm"
className="mt-4"
>
Try again
{i18n.t('errorBoundary.tryAgain')}
</Button>
</div>
</div>

View File

@@ -245,8 +245,6 @@ const FloatingPromptInputInner = (
// Extract image paths from prompt text
const extractImagePaths = (text: string): string[] => {
console.log('[extractImagePaths] Input text length:', text.length);
// Updated regex to handle both quoted and unquoted paths
// Pattern 1: @"path with spaces or data URLs" - quoted paths
// Pattern 2: @path - unquoted paths (continues until @ or end)
@@ -257,11 +255,9 @@ const FloatingPromptInputInner = (
// First, extract quoted paths (including data URLs)
let matches = Array.from(text.matchAll(quotedRegex));
console.log('[extractImagePaths] Quoted matches:', matches.length);
for (const match of matches) {
const path = match[1]; // No need to trim, quotes preserve exact path
console.log('[extractImagePaths] Processing quoted path:', path.startsWith('data:') ? 'data URL' : path);
// For data URLs, use as-is; for file paths, convert to absolute
const fullPath = path.startsWith('data:')
@@ -278,15 +274,12 @@ const FloatingPromptInputInner = (
// Then extract unquoted paths (typically file paths)
matches = Array.from(textWithoutQuoted.matchAll(unquotedRegex));
console.log('[extractImagePaths] Unquoted matches:', matches.length);
for (const match of matches) {
const path = match[1].trim();
// Skip if it looks like a data URL fragment (shouldn't happen with proper quoting)
if (path.includes('data:')) continue;
console.log('[extractImagePaths] Processing unquoted path:', path);
// Convert relative path to absolute if needed
const fullPath = path.startsWith('/') ? path : (projectPath ? `${projectPath}/${path}` : path);
@@ -295,16 +288,12 @@ const FloatingPromptInputInner = (
}
}
const uniquePaths = Array.from(pathsSet);
console.log('[extractImagePaths] Final extracted paths (unique):', uniquePaths.length);
return uniquePaths;
return Array.from(pathsSet);
};
// Update embedded images when prompt changes
useEffect(() => {
console.log('[useEffect] Prompt changed:', prompt);
const imagePaths = extractImagePaths(prompt);
console.log('[useEffect] Setting embeddedImages to:', imagePaths);
setEmbeddedImages(imagePaths);
}, [prompt, projectPath]);
@@ -422,7 +411,6 @@ const FloatingPromptInputInner = (
(newCursorPosition > 1 && /\s/.test(newValue[newCursorPosition - 2]));
if (isStartOfCommand) {
console.log('[FloatingPromptInput] / detected for slash command');
setShowSlashCommandPicker(true);
setSlashCommandQuery("");
setCursorPosition(newCursorPosition);
@@ -431,7 +419,6 @@ const FloatingPromptInputInner = (
// Check if @ was just typed
if (projectPath?.trim() && newValue.length > prompt.length && newValue[newCursorPosition - 1] === '@') {
console.log('[FloatingPromptInput] @ detected, projectPath:', projectPath);
setShowFilePicker(true);
setFilePickerQuery("");
setCursorPosition(newCursorPosition);

View File

@@ -1,173 +0,0 @@
import React, { useState, useEffect } from "react";
import MDEditor from "@uiw/react-md-editor";
import { motion } from "framer-motion";
import { ArrowLeft, Save, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Toast, ToastContainer } from "@/components/ui/toast";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import { useTranslation } from "@/hooks/useTranslation";
interface MarkdownEditorProps {
/**
* Callback to go back to the main view
*/
onBack: () => void;
/**
* Optional className for styling
*/
className?: string;
}
/**
* MarkdownEditor component for editing the CLAUDE.md system prompt
*
* @example
* <MarkdownEditor onBack={() => setView('main')} />
*/
export const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
onBack,
className,
}) => {
const { t } = useTranslation();
const [content, setContent] = useState<string>("");
const [originalContent, setOriginalContent] = useState<string>("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const hasChanges = content !== originalContent;
// Load the system prompt on mount
useEffect(() => {
loadSystemPrompt();
}, []);
const loadSystemPrompt = async () => {
try {
setLoading(true);
setError(null);
const prompt = await api.getSystemPrompt();
setContent(prompt);
setOriginalContent(prompt);
} catch (err) {
console.error("Failed to load system prompt:", err);
setError(t('usage.loadClaudemdFailed'));
} finally {
setLoading(false);
}
};
const handleSave = async () => {
try {
setSaving(true);
setError(null);
setToast(null);
await api.saveSystemPrompt(content);
setOriginalContent(content);
setToast({ message: t('usage.claudemdSavedSuccess'), type: "success" });
} catch (err) {
console.error("Failed to save system prompt:", err);
setError(t('usage.saveClaudemdFailed'));
setToast({ message: t('usage.saveClaudemdFailed'), type: "error" });
} finally {
setSaving(false);
}
};
const handleBack = () => {
if (hasChanges) {
const confirmLeave = window.confirm(
t('usage.unsavedChangesConfirm')
);
if (!confirmLeave) return;
}
onBack();
};
return (
<div className={cn("flex flex-col h-full bg-background", className)}>
<div className="w-full max-w-5xl mx-auto flex flex-col h-full">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="flex items-center justify-between p-4 border-b border-border"
>
<div className="flex items-center space-x-3">
<Button
variant="ghost"
size="icon"
onClick={handleBack}
className="h-8 w-8"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h2 className="text-lg font-semibold">CLAUDE.md</h2>
<p className="text-xs text-muted-foreground">
{t('usage.editSystemPrompt')}
</p>
</div>
</div>
<Button
onClick={handleSave}
disabled={!hasChanges || saving}
size="sm"
>
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{saving ? t('app.saving') : t('app.save')}
</Button>
</motion.div>
{/* Error display */}
{error && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mx-4 mt-4 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive"
>
{error}
</motion.div>
)}
{/* Editor */}
<div className="flex-1 p-4 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="h-full rounded-lg border border-border overflow-hidden shadow-sm" data-color-mode="dark">
<MDEditor
value={content}
onChange={(val) => setContent(val || "")}
preview="edit"
height="100%"
visibleDragbar={false}
/>
</div>
)}
</div>
</div>
{/* Toast Notification */}
<ToastContainer>
{toast && (
<Toast
message={toast.message}
type={toast.type}
onDismiss={() => setToast(null)}
/>
)}
</ToastContainer>
</div>
);
};

View File

@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { openUrl } from "@tauri-apps/plugin-opener";
import asteriskLogo from "@/assets/nfo/asterisk-logo.png";
import keygennMusic from "@/assets/nfo/claudia-nfo.ogg";
import { useTranslation } from "@/hooks/useTranslation";
interface NFOCreditsProps {
/**
@@ -22,6 +23,7 @@ interface NFOCreditsProps {
* <NFOCredits onClose={() => setShowNFO(false)} />
*/
export const NFOCredits: React.FC<NFOCreditsProps> = ({ onClose }) => {
const { t } = useTranslation();
const audioRef = useRef<HTMLAudioElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const [isMuted, setIsMuted] = useState(false);
@@ -159,10 +161,10 @@ export const NFOCredits: React.FC<NFOCreditsProps> = ({ onClose }) => {
await openUrl("https://github.com/getAsterisk/claudia/issues/new");
}}
className="flex items-center gap-1 h-auto px-2 py-1"
title="File a bug"
title={t('app.fileABug')}
>
<Github className="h-3 w-3" />
<span className="text-xs">File a bug</span>
<span className="text-xs">{t('app.fileABug')}</span>
</Button>
<Button
variant="ghost"

View File

@@ -0,0 +1,630 @@
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertCircle,
CheckCircle2,
Settings,
Plus,
Edit,
Trash2,
Zap,
Loader2,
} from 'lucide-react';
import * as api from '@/lib/api';
import type { ApiNode, CreateApiNodeRequest, NodeTestResult } from '@/lib/api';
interface NodeSelectorProps {
adapter: api.RelayStationAdapter;
value?: string;
onChange: (url: string, node?: ApiNode) => void;
allowManualInput?: boolean;
showToast?: (message: string, type: 'success' | 'error') => void;
}
/**
* 节点选择器组件
* 用于中转站表单中选择API节点
*/
export const NodeSelector: React.FC<NodeSelectorProps> = ({
adapter,
value = '',
onChange,
allowManualInput = true,
showToast = (msg, _type) => console.log(msg), // 默认使用 console.log
}) => {
const [showDialog, setShowDialog] = useState(false);
const [nodes, setNodes] = useState<ApiNode[]>([]);
const [currentNode, setCurrentNode] = useState<ApiNode | null>(null);
useEffect(() => {
loadNodes();
}, [adapter]);
useEffect(() => {
if (value && nodes.length > 0) {
const node = nodes.find(n => n.url === value);
setCurrentNode(node || null);
}
}, [value, nodes]);
const loadNodes = async () => {
try {
const allNodes = await api.listApiNodes(adapter, true);
setNodes(allNodes);
} catch (error) {
console.error('Failed to load nodes:', error);
}
};
const handleSelectNode = (node: ApiNode) => {
onChange(node.url, node);
setShowDialog(false);
};
const handleSaveCustomNode = async () => {
if (!value.trim() || value.startsWith('http') === false) {
showToast('请输入有效的 URL', 'error');
return;
}
// 检查是否已存在
const existingNode = nodes.find(n => n.url === value);
if (existingNode) {
showToast('该节点已存在', 'error');
return;
}
try {
await api.createApiNode({
name: `自定义节点 - ${new URL(value).hostname}`,
url: value,
adapter: adapter,
description: '用户手动添加的节点',
});
showToast('节点保存成功', 'success');
loadNodes();
} catch (error) {
showToast('保存失败', 'error');
console.error(error);
}
};
return (
<div className="space-y-2">
<Label> *</Label>
<div className="flex gap-2">
<Input
value={value}
onChange={(e) => allowManualInput && onChange(e.target.value)}
placeholder="https://api.example.com"
readOnly={!allowManualInput}
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setShowDialog(true)}
title="管理节点"
>
<Settings className="h-4 w-4" />
</Button>
{allowManualInput && value && !currentNode && value.startsWith('http') && (
<Button
type="button"
variant="outline"
size="icon"
onClick={handleSaveCustomNode}
title="保存为节点"
>
<Plus className="h-4 w-4" />
</Button>
)}
</div>
{currentNode && adapter !== 'custom' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>📍 {currentNode.name}</span>
{currentNode.is_default && (
<Badge variant="secondary" className="text-xs"></Badge>
)}
</div>
)}
<NodeManagerDialog
open={showDialog}
onOpenChange={setShowDialog}
adapter={adapter}
onSelectNode={handleSelectNode}
currentUrl={value}
showToast={showToast}
/>
</div>
);
};
interface NodeManagerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
adapter?: api.RelayStationAdapter;
onSelectNode?: (node: ApiNode) => void;
currentUrl?: string;
showToast?: (message: string, type: 'success' | 'error') => void;
}
/**
* 从中间截断 URL保留开头和结尾
*/
const truncateUrl = (url: string, maxLength: number = 50): string => {
if (url.length <= maxLength) return url;
const start = Math.floor(maxLength * 0.6);
const end = Math.floor(maxLength * 0.4);
return url.substring(0, start) + '...' + url.substring(url.length - end);
};
/**
* 节点管理弹窗
* 支持增删改查和测速功能
*/
const NodeManagerDialog: React.FC<NodeManagerDialogProps> = ({
open,
onOpenChange,
adapter: filterAdapter,
onSelectNode,
currentUrl,
showToast = (msg) => console.log(msg),
}) => {
const [nodes, setNodes] = useState<ApiNode[]>([]);
const [loading, setLoading] = useState(false);
const [testing, setTesting] = useState(false);
const [testResults, setTestResults] = useState<Record<string, NodeTestResult>>({});
const [editingNode, setEditingNode] = useState<ApiNode | null>(null);
const [showForm, setShowForm] = useState(false);
const [enabledOnly, setEnabledOnly] = useState(false);
useEffect(() => {
if (open) {
loadNodes();
// 首次打开时初始化预设节点
api.initDefaultNodes().catch(console.error);
}
}, [open, filterAdapter, enabledOnly]);
const loadNodes = async () => {
setLoading(true);
try {
const allNodes = await api.listApiNodes(filterAdapter, enabledOnly);
setNodes(allNodes);
} catch (error) {
showToast('加载节点失败', 'error');
console.error(error);
} finally {
setLoading(false);
}
};
const handleTestAll = async () => {
setTesting(true);
setTestResults({});
try {
const results = await api.testAllApiNodes(filterAdapter, 5000);
const resultsMap: Record<string, NodeTestResult> = {};
results.forEach(r => {
resultsMap[r.node_id] = r;
});
setTestResults(resultsMap);
} catch (error) {
showToast('测速失败', 'error');
console.error(error);
} finally {
setTesting(false);
}
};
const handleTestOne = async (node: ApiNode) => {
setTestResults(prev => ({
...prev,
[node.id]: { ...testResults[node.id], status: 'testing' } as NodeTestResult,
}));
try {
const result = await api.testApiNode(node.url, 5000);
setTestResults(prev => ({
...prev,
[node.id]: { ...result, node_id: node.id, name: node.name },
}));
} catch (error) {
setTestResults(prev => ({
...prev,
[node.id]: {
node_id: node.id,
url: node.url,
name: node.name,
response_time: null,
status: 'failed',
error: String(error),
},
}));
}
};
const handleDelete = async (node: ApiNode) => {
try {
await api.deleteApiNode(node.id);
// 直接从列表中移除,不重新加载
setNodes(prev => prev.filter(n => n.id !== node.id));
// 同时移除测试结果
setTestResults(prev => {
const newResults = { ...prev };
delete newResults[node.id];
return newResults;
});
showToast('删除成功', 'success');
} catch (error) {
showToast('删除失败', 'error');
console.error(error);
}
};
const handleToggleEnable = async (node: ApiNode) => {
try {
await api.updateApiNode(node.id, { enabled: !node.enabled });
loadNodes();
} catch (error) {
showToast('更新失败', 'error');
console.error(error);
}
};
const getStatusBadge = (node: ApiNode) => {
const result = testResults[node.id];
if (!result) {
return <Badge variant="outline" className="text-xs"></Badge>;
}
if (result.status === 'testing') {
return (
<Badge variant="outline" className="text-xs">
<Loader2 className="h-3 w-3 animate-spin mr-1" />
</Badge>
);
}
if (result.status === 'success') {
return (
<Badge variant="default" className="text-xs bg-green-600">
<CheckCircle2 className="h-3 w-3 mr-1" />
{result.response_time}ms
</Badge>
);
}
return (
<Badge variant="destructive" className="text-xs">
<AlertCircle className="h-3 w-3 mr-1" />
</Badge>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center justify-between">
<div>
<DialogTitle></DialogTitle>
<DialogDescription> API </DialogDescription>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleTestAll}
disabled={testing || nodes.length === 0}
>
<Zap className="h-4 w-4 mr-2" />
{testing ? '测速中...' : '全部测速'}
</Button>
</div>
</div>
</DialogHeader>
<div className="space-y-4">
{/* 工具栏 */}
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
onClick={() => {
setEditingNode(null);
setShowForm(true);
}}
>
<Plus className="h-4 w-4 mr-2" />
</Button>
<div className="flex items-center gap-2 ml-auto">
<Switch
checked={enabledOnly}
onCheckedChange={setEnabledOnly}
/>
<Label className="text-sm"></Label>
</div>
</div>
{/* 节点列表 */}
{loading ? (
<div className="text-center py-8">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
</div>
) : nodes.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
</div>
) : (
<div className="space-y-2">
{nodes.map((node) => (
<div
key={node.id}
className={`p-3 border rounded-lg flex items-center justify-between transition-all ${
currentUrl === node.url
? 'ring-2 ring-blue-500 bg-blue-50/50 dark:bg-blue-950/20'
: 'hover:bg-muted/50 cursor-pointer'
}`}
onClick={(e) => {
// 如果点击的是操作按钮区域,不触发选择
if ((e.target as HTMLElement).closest('.action-buttons')) {
return;
}
if (onSelectNode) {
onSelectNode(node);
}
}}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div onClick={(e) => e.stopPropagation()}>
<Switch
checked={node.enabled}
onCheckedChange={() => handleToggleEnable(node)}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{node.name}</span>
{node.is_default && (
<Badge variant="secondary" className="text-xs"></Badge>
)}
{getStatusBadge(node)}
</div>
<div
className="text-sm text-muted-foreground font-mono"
title={node.url}
>
{truncateUrl(node.url, 60)}
</div>
{node.description && (
<div className="text-xs text-muted-foreground mt-1">{node.description}</div>
)}
</div>
</div>
<div className="flex items-center gap-1 action-buttons" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="sm"
onClick={() => handleTestOne(node)}
disabled={testResults[node.id]?.status === 'testing'}
title="测速"
>
<Zap className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingNode(node);
setShowForm(true);
}}
title="编辑"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(node)}
className="text-red-500 hover:text-red-700"
title="删除"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
{/* 添加/编辑表单对话框 */}
<NodeFormDialog
open={showForm}
onOpenChange={setShowForm}
node={editingNode}
defaultAdapter={filterAdapter}
onSuccess={() => {
setShowForm(false);
setEditingNode(null);
loadNodes();
}}
showToast={showToast}
/>
</DialogContent>
</Dialog>
);
};
interface NodeFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
node?: ApiNode | null;
defaultAdapter?: api.RelayStationAdapter;
onSuccess: () => void;
showToast?: (message: string, type: 'success' | 'error') => void;
}
/**
* 节点添加/编辑表单
*/
const NodeFormDialog: React.FC<NodeFormDialogProps> = ({
open,
onOpenChange,
node,
defaultAdapter,
onSuccess,
showToast = (msg) => console.log(msg),
}) => {
const [submitting, setSubmitting] = useState(false);
const [formData, setFormData] = useState<CreateApiNodeRequest>({
name: '',
url: '',
adapter: defaultAdapter || 'packycode',
description: '',
});
useEffect(() => {
if (node) {
setFormData({
name: node.name,
url: node.url,
adapter: node.adapter,
description: node.description || '',
});
} else {
setFormData({
name: '',
url: '',
adapter: defaultAdapter || 'packycode',
description: '',
});
}
}, [node, defaultAdapter, open]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
if (node) {
await api.updateApiNode(node.id, {
name: formData.name,
url: formData.url,
description: formData.description,
});
showToast('更新成功', 'success');
} else {
await api.createApiNode(formData);
showToast('创建成功', 'success');
}
onSuccess();
} catch (error) {
showToast(node ? '更新失败' : '创建失败', 'error');
console.error(error);
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{node ? '编辑节点' : '添加节点'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name"> *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="例如:🚀 我的自定义节点"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="url"> *</Label>
<Input
id="url"
type="url"
value={formData.url}
onChange={(e) => setFormData(prev => ({ ...prev, url: e.target.value }))}
placeholder="https://api.example.com"
required
/>
</div>
{!node && (
<div className="space-y-2">
<Label htmlFor="adapter"> *</Label>
<Select
value={formData.adapter}
onValueChange={(value) => setFormData(prev => ({ ...prev, adapter: value as api.RelayStationAdapter }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="packycode">PackyCode</SelectItem>
<SelectItem value="deepseek">DeepSeek</SelectItem>
<SelectItem value="glm"> GLM</SelectItem>
<SelectItem value="qwen"></SelectItem>
<SelectItem value="kimi">Moonshot Kimi</SelectItem>
<SelectItem value="minimax">MiniMax</SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Input
id="description"
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="节点描述信息"
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? '保存中...' : '保存'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};
export default NodeSelector;

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useMemo } from "react";
import { motion } from "framer-motion";
import {
FolderOpen,
@@ -6,11 +6,15 @@ import {
FileText,
ChevronRight,
Settings,
MoreVertical
MoreVertical,
Search,
X,
Plus
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
@@ -36,6 +40,10 @@ interface ProjectListProps {
* Callback when hooks configuration is clicked
*/
onProjectSettings?: (project: Project) => void;
/**
* Callback when new session button is clicked
*/
onNewSession?: () => void;
/**
* Whether the list is currently loading
*/
@@ -69,24 +77,118 @@ export const ProjectList: React.FC<ProjectListProps> = ({
projects,
onProjectClick,
onProjectSettings,
onNewSession,
className,
}) => {
const { t } = useTranslation();
const [currentPage, setCurrentPage] = useState(1);
const [searchQuery, setSearchQuery] = useState("");
// Sort and filter projects
const filteredAndSortedProjects = useMemo(() => {
// First, sort by last_session_time in descending order (newest first)
let sorted = [...projects].sort((a, b) => b.last_session_time - a.last_session_time);
// Then filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
sorted = sorted.filter(project =>
getProjectName(project.path).toLowerCase().includes(query)
);
}
return sorted;
}, [projects, searchQuery]);
// Calculate pagination
const totalPages = Math.ceil(projects.length / ITEMS_PER_PAGE);
const totalPages = Math.ceil(filteredAndSortedProjects.length / ITEMS_PER_PAGE);
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const currentProjects = projects.slice(startIndex, endIndex);
const currentProjects = filteredAndSortedProjects.slice(startIndex, endIndex);
// Reset to page 1 if projects change
// Reset to page 1 if projects or search query changes
React.useEffect(() => {
setCurrentPage(1);
}, [projects.length]);
}, [projects.length, searchQuery]);
return (
<div className={cn("space-y-4", className)}>
{/* Action bar with new session button and search */}
<div className="flex flex-col lg:flex-row gap-3 items-stretch lg:items-center justify-between">
{/* New session button */}
{onNewSession && (
<Button
onClick={onNewSession}
size="default"
className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-lg hover:shadow-xl transition-all duration-200 lg:w-auto w-full group relative overflow-hidden"
>
{/* Gradient overlay effect */}
<div className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/10 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-500" />
<Plus className="mr-2 h-4 w-4 relative z-10" />
<span className="relative z-10">{t('newClaudeCodeSession')}</span>
</Button>
)}
{/* Search and results info */}
<div className="flex flex-col sm:flex-row sm:items-center gap-3 flex-1 lg:max-w-2xl">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t('searchProjects')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-9 h-10"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
onClick={() => setSearchQuery("")}
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0 hover:bg-muted"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
{/* Results info */}
<div className="text-sm text-muted-foreground whitespace-nowrap">
{searchQuery ? (
<span>
{t('showingResults')}: <span className="font-semibold text-foreground">{filteredAndSortedProjects.length}</span> / {projects.length}
</span>
) : (
<span>
{t('totalProjects')}: <span className="font-semibold text-foreground">{projects.length}</span>
</span>
)}
</div>
</div>
</div>
{/* Empty state */}
{filteredAndSortedProjects.length === 0 && searchQuery && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center py-12"
>
<Search className="h-12 w-12 text-muted-foreground mx-auto mb-4 opacity-50" />
<h3 className="text-lg font-semibold mb-2">{t('noSearchResults')}</h3>
<p className="text-sm text-muted-foreground mb-4">
{t('noProjectsMatchSearch')} "{searchQuery}"
</p>
<Button
variant="outline"
onClick={() => setSearchQuery("")}
>
{t('clearSearch')}
</Button>
</motion.div>
)}
{/* Project grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{currentProjects.map((project, index) => (
<motion.div
@@ -100,26 +202,30 @@ export const ProjectList: React.FC<ProjectListProps> = ({
}}
>
<Card
className="p-4 hover:shadow-md transition-all duration-200 cursor-pointer group h-full"
className="p-4 hover:shadow-lg hover:border-primary/50 transition-all duration-200 cursor-pointer group h-full relative overflow-hidden"
onClick={() => onProjectClick(project)}
>
<div className="flex flex-col h-full">
{/* Hover gradient effect */}
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
<div className="flex flex-col h-full relative z-10">
<div className="flex-1">
<div className="flex items-start justify-between mb-2">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
<FolderOpen className="h-5 w-5 text-primary shrink-0" />
<h3 className="font-semibold text-base truncate">
<div className="p-2 rounded-lg bg-primary/10 group-hover:bg-primary/20 transition-colors">
<FolderOpen className="h-4 w-4 text-primary" />
</div>
<h3 className="font-semibold text-base truncate group-hover:text-primary transition-colors">
{getProjectName(project.path)}
</h3>
</div>
{project.sessions.length > 0 && (
<Badge variant="secondary" className="shrink-0 ml-2">
<Badge variant="secondary" className="shrink-0 ml-2 group-hover:bg-primary group-hover:text-primary-foreground transition-colors">
{project.sessions.length}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mb-3 font-mono truncate">
<p className="text-xs text-muted-foreground mb-4 font-mono truncate bg-muted/50 rounded px-2 py-1">
{project.path}
</p>
</div>

View File

@@ -3,6 +3,7 @@
*/
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { HooksEditor } from '@/components/HooksEditor';
import { SlashCommandsManager } from '@/components/SlashCommandsManager';
import { api } from '@/lib/api';
@@ -33,6 +34,7 @@ export const ProjectSettings: React.FC<ProjectSettingsProps> = ({
onBack,
className
}) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('commands');
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
@@ -89,7 +91,7 @@ export const ProjectSettings: React.FC<ProjectSettingsProps> = ({
</Button>
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<h2 className="text-xl font-semibold">Project Settings</h2>
<h2 className="text-xl font-semibold">{t('agents.projectSettings')}</h2>
</div>
</div>
</div>
@@ -109,15 +111,15 @@ export const ProjectSettings: React.FC<ProjectSettingsProps> = ({
<TabsList className="mb-6">
<TabsTrigger value="commands" className="gap-2">
<Command className="h-4 w-4" />
Slash Commands
{t('slashCommands.slashCommands')}
</TabsTrigger>
<TabsTrigger value="project" className="gap-2">
<GitBranch className="h-4 w-4" />
Project Hooks
{t('hooks.projectHooks', 'Project Hooks')}
</TabsTrigger>
<TabsTrigger value="local" className="gap-2">
<Shield className="h-4 w-4" />
Local Hooks
{t('hooks.localHooks', 'Local Hooks')}
</TabsTrigger>
</TabsList>
@@ -125,7 +127,7 @@ export const ProjectSettings: React.FC<ProjectSettingsProps> = ({
<Card className="p-6">
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold mb-2">Project Slash Commands</h3>
<h3 className="text-lg font-semibold mb-2">{t('slashCommands.projectSlashCommands')}</h3>
<p className="text-sm text-muted-foreground mb-4">
Custom commands that are specific to this project. These commands are stored in
<code className="mx-1 px-2 py-1 bg-muted rounded text-xs">.claude/slash-commands/</code>
@@ -145,7 +147,7 @@ export const ProjectSettings: React.FC<ProjectSettingsProps> = ({
<Card className="p-6">
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold mb-2">Project Hooks</h3>
<h3 className="text-lg font-semibold mb-2">{t('hooks.projectHooks', 'Project Hooks')}</h3>
<p className="text-sm text-muted-foreground mb-4">
These hooks apply to all users working on this project. They are stored in
<code className="mx-1 px-2 py-1 bg-muted rounded text-xs">.claude/settings.json</code>
@@ -165,7 +167,7 @@ export const ProjectSettings: React.FC<ProjectSettingsProps> = ({
<Card className="p-6">
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold mb-2">Local Hooks</h3>
<h3 className="text-lg font-semibold mb-2">{t('hooks.localHooks', 'Local Hooks')}</h3>
<p className="text-sm text-muted-foreground mb-4">
These hooks only apply to your machine. They are stored in
<code className="mx-1 px-2 py-1 bg-muted rounded text-xs">.claude/settings.local.json</code>
@@ -177,7 +179,7 @@ export const ProjectSettings: React.FC<ProjectSettingsProps> = ({
<AlertTriangle className="h-5 w-5 text-yellow-600" />
<div className="flex-1">
<p className="text-sm text-yellow-600">
Local settings file is not in .gitignore
{t('projectSettings.gitignoreLocalWarning')}
</p>
</div>
<Button
@@ -185,7 +187,7 @@ export const ProjectSettings: React.FC<ProjectSettingsProps> = ({
variant="outline"
onClick={addToGitIgnore}
>
Add to .gitignore
{t('projectSettings.addToGitignore')}
</Button>
</div>
)}

View File

@@ -0,0 +1,237 @@
import React, { useState, useEffect } from 'react';
import { Loader2, Save, Eye, EyeOff, X, Tag as TagIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog';
import MonacoEditor from '@monaco-editor/react';
import ReactMarkdown from 'react-markdown';
import { usePromptFilesStore } from '@/stores/promptFilesStore';
import type { PromptFile } from '@/lib/api';
interface PromptFileEditorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
file?: PromptFile;
onSuccess: () => void;
}
export const PromptFileEditor: React.FC<PromptFileEditorProps> = ({
open,
onOpenChange,
file,
onSuccess,
}) => {
const { createFile, updateFile } = usePromptFilesStore();
const [saving, setSaving] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [content, setContent] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [tagInput, setTagInput] = useState('');
useEffect(() => {
if (file) {
setName(file.name);
setDescription(file.description || '');
setContent(file.content);
setTags(file.tags);
} else {
setName('');
setDescription('');
setContent('');
setTags([]);
}
}, [file, open]);
const handleAddTag = () => {
const trimmed = tagInput.trim().toLowerCase();
if (trimmed && !tags.includes(trimmed)) {
setTags([...tags, trimmed]);
setTagInput('');
}
};
const handleRemoveTag = (tag: string) => {
setTags(tags.filter((t) => t !== tag));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddTag();
}
};
const handleSave = async () => {
if (!name.trim()) return;
setSaving(true);
try {
if (file) {
await updateFile({
id: file.id,
name: name.trim(),
description: description.trim() || undefined,
content: content,
tags,
});
} else {
await createFile({
name: name.trim(),
description: description.trim() || undefined,
content: content,
tags,
});
}
onSuccess();
} catch (error) {
// Error handling is done in the store
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{file ? '编辑提示词文件' : '创建提示词文件'}</DialogTitle>
<DialogDescription>
{file ? '修改提示词文件的内容和信息' : '创建一个新的提示词文件模板'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Basic Info */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name"> *</Label>
<Input
id="name"
placeholder="例如: React 项目指南"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Input
id="description"
placeholder="简短描述..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
{/* Tags */}
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2 mb-2">
{tags.map((tag) => (
<Badge key={tag} variant="secondary" className="flex items-center gap-1">
{tag}
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => handleRemoveTag(tag)}
/>
</Badge>
))}
</div>
<div className="flex gap-2">
<Input
placeholder="添加标签(按 Enter"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleKeyDown}
/>
<Button type="button" variant="outline" onClick={handleAddTag}>
<TagIcon className="h-4 w-4" />
</Button>
</div>
</div>
{/* Content Editor */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label> *</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowPreview(!showPreview)}
>
{showPreview ? (
<>
<EyeOff className="mr-2 h-4 w-4" />
</>
) : (
<>
<Eye className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
{showPreview ? (
<div className="border rounded-lg p-4 max-h-[400px] overflow-y-auto prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown>{content}</ReactMarkdown>
</div>
) : (
<div className="border rounded-lg overflow-hidden" style={{ height: '400px' }}>
<MonacoEditor
language="markdown"
theme="vs-dark"
value={content}
onChange={(value) => setContent(value || '')}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 14,
wordWrap: 'on',
lineNumbers: 'on',
automaticLayout: true,
}}
/>
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
</Button>
<Button onClick={handleSave} disabled={!name.trim() || !content.trim() || saving}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default PromptFileEditor;

View File

@@ -0,0 +1,133 @@
import React from 'react';
import { save } from '@tauri-apps/plugin-dialog';
import { usePromptFilesStore } from '@/stores/promptFilesStore';
import { useTranslation } from '@/hooks/useTranslation';
import { Edit, Play, Tag as TagIcon, Clock, Calendar } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog';
import ReactMarkdown from 'react-markdown';
import type { PromptFile } from '@/lib/api';
interface PromptFilePreviewProps {
open: boolean;
onOpenChange: (open: boolean) => void;
file: PromptFile;
onEdit: () => void;
onApply: () => void;
}
export const PromptFilePreview: React.FC<PromptFilePreviewProps> = ({
open,
onOpenChange,
file,
onEdit,
onApply,
}) => {
const { applyFile } = usePromptFilesStore();
const { t } = useTranslation();
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
const handleApplyToCustom = async () => {
const selectedPath = await save({
defaultPath: 'CLAUDE.md',
filters: [
{ name: 'Markdown', extensions: ['md'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (!selectedPath) return;
await applyFile(file.id, String(selectedPath));
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl">{file.name}</DialogTitle>
{file.description && (
<DialogDescription className="text-base mt-2">{file.description}</DialogDescription>
)}
</DialogHeader>
{/* Metadata */}
<div className="flex flex-wrap gap-4 py-4 border-y text-sm text-muted-foreground">
{file.tags.length > 0 && (
<div className="flex items-center gap-2">
<TagIcon className="h-4 w-4" />
<div className="flex gap-1 flex-wrap">
{file.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
</div>
)}
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
: {formatDate(file.created_at)}
</div>
{file.updated_at !== file.created_at && (
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" />
: {formatDate(file.updated_at)}
</div>
)}
{file.last_used_at && (
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" />
使: {formatDate(file.last_used_at)}
</div>
)}
</div>
{/* Content */}
<div className="prose prose-sm dark:prose-invert max-w-none py-4">
<ReactMarkdown>{file.content}</ReactMarkdown>
</div>
<DialogFooter className="flex items-center justify-between">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={onEdit}>
<Edit className="mr-2 h-4 w-4" />
</Button>
{!file.is_active && (
<Button onClick={onApply}>
<Play className="mr-2 h-4 w-4" />
使
</Button>
)}
<Button variant="outline" onClick={handleApplyToCustom}>
<Play className="mr-2 h-4 w-4" />
{/** 使用管理页 i18n key避免重复 */}
{t('promptFiles.applyToCustomPath')}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default PromptFilePreview;

View File

@@ -0,0 +1,631 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
FileText,
Plus,
Search,
Upload,
ArrowLeft,
Check,
Edit,
Trash2,
Eye,
Play,
AlertCircle,
Loader2,
Tag,
Clock,
CheckCircle2,
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { usePromptFilesStore } from '@/stores/promptFilesStore';
import { useTranslation } from '@/hooks/useTranslation';
import type { PromptFile } from '@/lib/api';
import { cn } from '@/lib/utils';
import { PromptFileEditor } from './PromptFileEditor';
import { PromptFilePreview } from './PromptFilePreview';
import { save } from '@tauri-apps/plugin-dialog';
interface PromptFilesManagerProps {
onBack?: () => void;
className?: string;
}
export const PromptFilesManager: React.FC<PromptFilesManagerProps> = ({ onBack, className }) => {
const { t } = useTranslation();
const {
files,
isLoading,
error,
loadFiles,
deleteFile,
applyFile,
deactivateAll,
importFromClaudeMd,
clearError,
} = usePromptFilesStore();
const [searchQuery, setSearchQuery] = useState('');
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false);
const [showPreviewDialog, setShowPreviewDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
const [selectedFile, setSelectedFile] = useState<PromptFile | null>(null);
const [applyingFileId, setApplyingFileId] = useState<string | null>(null);
const [syncingFileId, setSyncingFileId] = useState<string | null>(null);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
useEffect(() => {
loadFiles();
}, [loadFiles]);
useEffect(() => {
if (error) {
showToast(error, 'error');
clearError();
}
}, [error, clearError]);
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
const handleApply = async (file: PromptFile) => {
setApplyingFileId(file.id);
try {
const path = await applyFile(file.id);
showToast(`已应用到: ${path}`, 'success');
} catch (error) {
showToast('应用失败', 'error');
} finally {
setApplyingFileId(null);
}
};
// 应用到自定义路径(文件路径),跨平台
const handleApplyToCustom = async (file: PromptFile) => {
try {
const selectedPath = await save({
defaultPath: 'CLAUDE.md',
filters: [
{ name: 'Markdown', extensions: ['md'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (!selectedPath) return; // 用户取消
setApplyingFileId(file.id);
const resultPath = await applyFile(file.id, String(selectedPath));
showToast(`已应用到: ${resultPath}`, 'success');
await loadFiles();
} catch (error) {
showToast(t('promptFiles.applyToCustomPathFailed'), 'error');
} finally {
setApplyingFileId(null);
}
};
const handleDeactivate = async () => {
try {
await deactivateAll();
showToast('已取消使用', 'success');
} catch (error) {
showToast('取消失败', 'error');
}
};
const handleSync = async (file: PromptFile) => {
setSyncingFileId(file.id);
try {
// 同步当前激活的文件到 ~/.claude/CLAUDE.md
const path = await applyFile(file.id);
showToast(`文件已同步到: ${path}`, 'success');
await loadFiles(); // 重新加载以更新状态
} catch (error) {
showToast('同步失败', 'error');
} finally {
setSyncingFileId(null);
}
};
const handleDelete = async () => {
if (!selectedFile) return;
try {
await deleteFile(selectedFile.id);
setShowDeleteDialog(false);
setSelectedFile(null);
showToast('删除成功', 'success');
} catch (error) {
showToast('删除失败', 'error');
}
};
const handleImportFromClaudeMd = async (name: string, description?: string) => {
try {
await importFromClaudeMd(name, description);
setShowImportDialog(false);
showToast('导入成功', 'success');
} catch (error) {
showToast('导入失败', 'error');
}
};
const openPreview = (file: PromptFile) => {
setSelectedFile(file);
setShowPreviewDialog(true);
};
const openEdit = (file: PromptFile) => {
setSelectedFile(file);
setShowEditDialog(true);
};
const openDelete = (file: PromptFile) => {
setSelectedFile(file);
setShowDeleteDialog(true);
};
const filteredFiles = files.filter((file) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
file.name.toLowerCase().includes(query) ||
file.description?.toLowerCase().includes(query) ||
file.tags.some((tag) => tag.toLowerCase().includes(query))
);
});
const activeFiles = filteredFiles.filter((f) => f.is_active);
const inactiveFiles = filteredFiles.filter((f) => !f.is_active);
return (
<div className={cn('h-full flex flex-col overflow-hidden', className)}>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{onBack && (
<Button variant="ghost" size="sm" onClick={onBack} className="flex items-center gap-2">
<ArrowLeft className="h-4 w-4" />
{t('app.back')}
</Button>
)}
<div>
<h1 className="text-3xl font-bold">{t('promptFiles.title')}</h1>
<p className="text-muted-foreground">{t('promptFiles.description')}</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowImportDialog(true)}>
<Upload className="mr-2 h-4 w-4" />
CLAUDE.md
</Button>
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索提示词文件..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* Error Display */}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Loading State */}
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{/* Active File */}
{!isLoading && activeFiles.length > 0 && (
<div>
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-600" />
使
</h2>
{activeFiles.map((file) => (
<Card key={file.id} className="border-green-200 dark:border-green-900 bg-green-50/50 dark:bg-green-950/20">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
{file.name}
<Badge variant="secondary" className="bg-green-100 dark:bg-green-900">
使
</Badge>
</CardTitle>
{file.description && (
<CardDescription className="mt-2">{file.description}</CardDescription>
)}
</div>
</div>
<div className="flex items-center gap-4 mt-3 text-sm text-muted-foreground">
{file.tags.length > 0 && (
<div className="flex items-center gap-1">
<Tag className="h-3 w-3" />
{file.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{file.tags.length > 3 && <span className="text-xs">+{file.tags.length - 3}</span>}
</div>
)}
{file.last_used_at && (
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(file.last_used_at * 1000).toLocaleString('zh-CN')}
</div>
)}
</div>
</CardHeader>
<CardContent>
<div className="flex gap-2 flex-wrap">
<Button
variant="default"
size="sm"
onClick={() => handleSync(file)}
disabled={syncingFileId === file.id}
>
{syncingFileId === file.id ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleApplyToCustom(file)}
disabled={applyingFileId === file.id}
>
<Play className="mr-2 h-4 w-4" />
{t('promptFiles.applyToCustomPath')}
</Button>
<Button variant="outline" size="sm" onClick={() => openPreview(file)}>
<Eye className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => openEdit(file)}>
<Edit className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={handleDeactivate}>
使
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* All Prompt Files */}
{!isLoading && (
<div>
<h2 className="text-lg font-semibold mb-3">
({inactiveFiles.length})
</h2>
{inactiveFiles.length === 0 ? (
<Card className="p-12">
<div className="text-center">
<FileText className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">
{searchQuery ? '没有找到匹配的提示词文件' : '还没有提示词文件'}
</p>
{!searchQuery && (
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="mr-2 h-4 w-4" />
</Button>
)}
</div>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{inactiveFiles.map((file) => (
<Card key={file.id} className="hover:shadow-md transition-shadow flex flex-col">
<CardHeader className="flex-1">
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{file.name}</span>
</CardTitle>
<CardDescription className="text-sm line-clamp-2 min-h-[1.25rem]">
{file.description || ' '}
</CardDescription>
{file.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{file.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
{file.tags.length > 3 && (
<span className="text-xs text-muted-foreground">
+{file.tags.length - 3}
</span>
)}
</div>
)}
</CardHeader>
<CardContent className="space-y-2 pt-4">
<Button
className="w-full"
size="sm"
onClick={() => handleApply(file)}
disabled={applyingFileId === file.id}
>
{applyingFileId === file.id ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Play className="mr-2 h-4 w-4 flex-shrink-0" />
使
</>
)}
</Button>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => handleApplyToCustom(file)}
disabled={applyingFileId === file.id}
>
<Play className="mr-2 h-4 w-4" />
{t('promptFiles.applyToCustomPath')}
</Button>
<div className="flex gap-2 justify-center">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => openPreview(file)}
title="查看内容"
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => openEdit(file)}
title="编辑"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => openDelete(file)}
title="删除"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
)}
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
{selectedFile && (
<div className="py-4">
<p className="font-medium">{selectedFile.name}</p>
{selectedFile.description && (
<p className="text-sm text-muted-foreground mt-1">{selectedFile.description}</p>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setShowDeleteDialog(false)}>
</Button>
<Button variant="destructive" onClick={handleDelete}>
<Trash2 className="mr-2 h-4 w-4" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Import Dialog */}
<ImportFromClaudeMdDialog
open={showImportDialog}
onOpenChange={setShowImportDialog}
onImport={handleImportFromClaudeMd}
/>
{/* Create/Edit Dialogs */}
{showCreateDialog && (
<PromptFileEditor
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
onSuccess={() => {
setShowCreateDialog(false);
showToast('创建成功', 'success');
}}
/>
)}
{showEditDialog && selectedFile && (
<PromptFileEditor
open={showEditDialog}
onOpenChange={setShowEditDialog}
file={selectedFile}
onSuccess={() => {
setShowEditDialog(false);
setSelectedFile(null);
showToast('更新成功', 'success');
}}
/>
)}
{/* Preview Dialog */}
{showPreviewDialog && selectedFile && (
<PromptFilePreview
open={showPreviewDialog}
onOpenChange={setShowPreviewDialog}
file={selectedFile}
onEdit={() => {
setShowPreviewDialog(false);
openEdit(selectedFile);
}}
onApply={() => {
setShowPreviewDialog(false);
handleApply(selectedFile);
}}
/>
)}
</div>
</div>
{/* Toast */}
<AnimatePresence>
{toast && (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 50 }}
className="fixed bottom-4 right-4 z-50"
>
<Alert variant={toast.type === 'error' ? 'destructive' : 'default'} className="shadow-lg">
{toast.type === 'success' ? (
<Check className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<AlertDescription>{toast.message}</AlertDescription>
</Alert>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
// Import from CLAUDE.md Dialog
const ImportFromClaudeMdDialog: React.FC<{
open: boolean;
onOpenChange: (open: boolean) => void;
onImport: (name: string, description?: string) => Promise<void>;
}> = ({ open, onOpenChange, onImport }) => {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [importing, setImporting] = useState(false);
const handleImport = async () => {
if (!name.trim()) return;
setImporting(true);
try {
await onImport(name, description || undefined);
setName('');
setDescription('');
} finally {
setImporting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle> CLAUDE.md </DialogTitle>
<DialogDescription> CLAUDE.md </DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium"> *</label>
<Input
placeholder="例如: 我的项目指南"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Input
placeholder="简短描述这个提示词文件的用途"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleImport} disabled={!name.trim() || importing}>
{importing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default PromptFilesManager;

File diff suppressed because it is too large Load Diff

View File

@@ -46,7 +46,7 @@ export const RunningClaudeSessions: React.FC<RunningClaudeSessionsProps> = ({
setError(null);
} catch (err) {
console.error("Failed to load running sessions:", err);
setError("Failed to load running sessions");
setError(t('runningSessions.loadFailed'));
} finally {
setLoading(false);
}
@@ -101,10 +101,10 @@ export const RunningClaudeSessions: React.FC<RunningClaudeSessionsProps> = ({
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<h3 className="text-sm font-medium">Active Claude Sessions</h3>
<h3 className="text-sm font-medium">{t('runningSessions.title')}</h3>
</div>
<span className="text-xs text-muted-foreground">
({runningSessions.length} running)
{t('runningSessions.countRunning', { count: runningSessions.length })}
</span>
</div>
@@ -137,7 +137,7 @@ export const RunningClaudeSessions: React.FC<RunningClaudeSessionsProps> = ({
{sessionId.substring(0, 20)}...
</p>
<span className="text-xs text-green-600 font-medium">
Running
{t('runningSessions.running')}
</span>
</div>
@@ -163,7 +163,7 @@ export const RunningClaudeSessions: React.FC<RunningClaudeSessionsProps> = ({
className="flex-shrink-0"
>
<Play className="h-3 w-3 mr-1" />
Resume
{t('runningSessions.resume')}
</Button>
</div>
</CardContent>

View File

@@ -8,6 +8,7 @@ import { ClaudeMemoriesDropdown } from "@/components/ClaudeMemoriesDropdown";
import { cn } from "@/lib/utils";
import { formatUnixTimestamp, formatISOTimestamp, truncateText, getFirstLine } from "@/lib/date-utils";
import type { Session, ClaudeMdFile } from "@/lib/api";
import { useTranslation } from "@/hooks/useTranslation";
interface SessionListProps {
/**
@@ -57,6 +58,7 @@ export const SessionList: React.FC<SessionListProps> = ({
onEditClaudeFile,
className,
}) => {
const { t } = useTranslation();
const [currentPage, setCurrentPage] = useState(1);
// Calculate pagination
@@ -89,7 +91,7 @@ export const SessionList: React.FC<SessionListProps> = ({
<div className="flex-1 min-w-0">
<h2 className="text-base font-medium truncate">{projectPath}</h2>
<p className="text-xs text-muted-foreground">
{sessions.length} session{sessions.length !== 1 ? 's' : ''}
{t('projects.sessionCount', { count: sessions.length })}
</p>
</div>
</motion.div>
@@ -149,7 +151,7 @@ export const SessionList: React.FC<SessionListProps> = ({
<div className="space-y-1">
<div className="flex items-center space-x-1 text-xs text-muted-foreground">
<MessageSquare className="h-3 w-3" />
<span>First message:</span>
<span>{t('sessions.firstMessage')}</span>
</div>
<p className="text-xs line-clamp-2 text-foreground/80">
{truncateText(getFirstLine(session.first_message), 100)}
@@ -173,7 +175,7 @@ export const SessionList: React.FC<SessionListProps> = ({
{session.todo_data && (
<div className="flex items-center space-x-1">
<Calendar className="h-3 w-3" />
<span>Has todo</span>
<span>{t('sessions.hasTodo')}</span>
</div>
)}
</div>

View File

@@ -189,7 +189,7 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
}
} catch (error) {
console.error('Failed to load session output:', error);
setToast({ message: 'Failed to load session output', type: 'error' });
setToast({ message: t('app.failedToLoadSessionOutput'), type: 'error' });
} finally {
setLoading(false);
}
@@ -223,12 +223,12 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
});
const completeUnlisten = await listen<boolean>(`agent-complete:${session.id}`, () => {
setToast({ message: 'Agent execution completed', type: 'success' });
setToast({ message: t('app.agentExecutionCompleted'), type: 'success' });
// Don't set status here as the parent component should handle it
});
const cancelUnlisten = await listen<boolean>(`agent-cancelled:${session.id}`, () => {
setToast({ message: 'Agent execution was cancelled', type: 'error' });
setToast({ message: t('app.agentExecutionCancelled'), type: 'error' });
});
unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten, cancelUnlisten];
@@ -242,7 +242,7 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
const jsonl = rawJsonlOutput.join('\n');
await navigator.clipboard.writeText(jsonl);
setCopyPopoverOpen(false);
setToast({ message: 'Output copied as JSONL', type: 'success' });
setToast({ message: t('webview.sessionOutputCopiedJsonl'), type: 'success' });
};
const handleCopyAsMarkdown = async () => {
@@ -297,7 +297,7 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
await navigator.clipboard.writeText(markdown);
setCopyPopoverOpen(false);
setToast({ message: 'Output copied as Markdown', type: 'success' });
setToast({ message: t('webview.sessionOutputCopiedMarkdown'), type: 'success' });
};
@@ -305,10 +305,10 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
setRefreshing(true);
try {
await loadOutput(true); // Skip cache when manually refreshing
setToast({ message: 'Output refreshed', type: 'success' });
setToast({ message: t('app.outputRefreshed'), type: 'success' });
} catch (error) {
console.error('Failed to refresh output:', error);
setToast({ message: 'Failed to refresh output', type: 'error' });
setToast({ message: t('app.failedToRefreshOutput'), type: 'error' });
} finally {
setRefreshing(false);
}
@@ -388,7 +388,7 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
<div className="flex items-center space-x-3">
<div className="text-2xl">{session.agent_icon}</div>
<div>
<CardTitle className="text-base">{session.agent_name} - Output</CardTitle>
<CardTitle className="text-base">{session.agent_name} - {t('app.output')}</CardTitle>
<div className="flex items-center space-x-2 mt-1">
<Badge variant={session.status === 'running' ? 'default' : 'secondary'}>
{session.status}
@@ -396,7 +396,7 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
{session.status === 'running' && (
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse mr-1"></div>
Live
{t('agentRun.live')}
</Badge>
)}
<span className="text-xs text-muted-foreground">
@@ -459,7 +459,7 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
size="sm"
onClick={refreshOutput}
disabled={refreshing}
title="Refresh output"
title={t('app.refresh')}
>
<RotateCcw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
@@ -474,7 +474,7 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
<div className="flex items-center justify-center h-full">
<div className="flex items-center space-x-2">
<RefreshCw className="h-4 w-4 animate-spin" />
<span>Loading output...</span>
<span>{t('app.loadingOutput')}</span>
</div>
</div>
) : (
@@ -498,14 +498,14 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
{session.status === 'running' ? (
<>
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground mb-2" />
<p className="text-muted-foreground">Waiting for output...</p>
<p className="text-muted-foreground">{t('app.waitingForOutput')}</p>
<p className="text-xs text-muted-foreground mt-1">
Agent is running but no output received yet
{t('app.agentRunningNoOutput')}
</p>
</>
) : (
<>
<p className="text-muted-foreground">No output available</p>
<p className="text-muted-foreground">{t('app.noOutput')}</p>
<Button
variant="outline"
size="sm"
@@ -514,7 +514,7 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
disabled={refreshing}
>
{refreshing ? <RefreshCw className="h-4 w-4 animate-spin mr-2" /> : <RotateCcw className="h-4 w-4 mr-2" />}
Refresh
{t('app.refresh')}
</Button>
</>
)}
@@ -551,11 +551,11 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-2">
<div className="text-2xl">{session.agent_icon}</div>
<h2 className="text-lg font-semibold">{session.agent_name} - Output</h2>
<h2 className="text-lg font-semibold">{session.agent_name} - {t('app.output')}</h2>
{session.status === 'running' && (
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-xs text-green-600 font-medium">Running</span>
<span className="text-xs text-green-600 font-medium">{t('agents.statusRunning')}</span>
</div>
)}
</div>
@@ -605,7 +605,7 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
className="flex items-center gap-2"
>
<X className="h-4 w-4" />
Close
{t('app.close')}
</Button>
</div>
</div>
@@ -632,14 +632,14 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
{session.status === 'running' ? (
<>
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground mb-2" />
<p className="text-muted-foreground">Waiting for output...</p>
<p className="text-muted-foreground">{t('app.waitingForOutput')}</p>
<p className="text-xs text-muted-foreground mt-1">
Agent is running but no output received yet
{t('app.agentRunningNoOutput')}
</p>
</>
) : (
<>
<p className="text-muted-foreground">No output available</p>
<p className="text-muted-foreground">{t('app.noOutput')}</p>
</>
)}
</div>

View File

@@ -21,7 +21,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import {
api,
type ClaudeSettings,
type ClaudeInstallation
type ClaudeInstallation,
type ModelMapping
} from "@/lib/api";
import { cn } from "@/lib/utils";
import { Toast, ToastContainer } from "@/components/ui/toast";
@@ -99,11 +100,17 @@ export const Settings: React.FC<SettingsProps> = ({
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false);
const trackEvent = useTrackEvent();
// Model mappings state
const [modelMappings, setModelMappings] = useState<ModelMapping[]>([]);
const [loadingMappings, setLoadingMappings] = useState(false);
const [modelMappingsChanged, setModelMappingsChanged] = useState(false);
// Load settings on mount
useEffect(() => {
loadSettings();
loadClaudeBinaryPath();
loadAnalyticsSettings();
loadModelMappings();
}, []);
/**
@@ -117,6 +124,52 @@ export const Settings: React.FC<SettingsProps> = ({
}
};
/**
* Loads model mappings
* @author yovinchen
*/
const loadModelMappings = async () => {
try {
setLoadingMappings(true);
const mappings = await api.getModelMappings();
console.log("Loaded model mappings:", mappings);
setModelMappings(mappings);
} catch (err) {
console.error("Failed to load model mappings:", err);
setToast({ message: t('settings.modelMappings.loadFailed'), type: "error" });
} finally {
setLoadingMappings(false);
}
};
/**
* Updates a model mapping
* @author yovinchen
*/
const updateModelMapping = (alias: string, modelName: string) => {
setModelMappings(prev =>
prev.map(m => (m.alias === alias ? { ...m, model_name: modelName } : m))
);
setModelMappingsChanged(true);
};
/**
* Saves model mappings
* @author yovinchen
*/
const saveModelMappings = async () => {
try {
for (const mapping of modelMappings) {
await api.updateModelMapping(mapping.alias, mapping.model_name);
}
setModelMappingsChanged(false);
setToast({ message: t('settings.modelMappings.saved'), type: "success" });
} catch (err) {
console.error("Failed to save model mappings:", err);
setToast({ message: t('settings.modelMappings.saveFailed'), type: "error" });
}
};
/**
* Loads the current Claude binary path
*/
@@ -233,6 +286,11 @@ export const Settings: React.FC<SettingsProps> = ({
setProxySettingsChanged(false);
}
// Save model mappings if changed
if (modelMappingsChanged) {
await saveModelMappings();
}
setToast({ message: t('settings.saveButton.settingsSavedSuccess'), type: "success" });
} catch (err) {
console.error("Failed to save settings:", err);
@@ -396,22 +454,23 @@ export const Settings: React.FC<SettingsProps> = ({
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<div className="flex-1 overflow-y-auto p-4">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid grid-cols-9 w-full">
<TabsTrigger value="general">{t('settings.general')}</TabsTrigger>
<TabsTrigger value="permissions">{t('settings.permissionsTab')}</TabsTrigger>
<TabsTrigger value="environment">{t('settings.environmentTab')}</TabsTrigger>
<TabsTrigger value="advanced">{t('settings.advancedTab')}</TabsTrigger>
<TabsTrigger value="hooks">{t('settings.hooksTab')}</TabsTrigger>
<TabsTrigger value="commands">{t('settings.commands')}</TabsTrigger>
<TabsTrigger value="storage">{t('settings.storage')}</TabsTrigger>
<TabsTrigger value="proxy">{t('settings.proxy')}</TabsTrigger>
<TabsTrigger value="analytics">{t('settings.analyticsTab')}</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="p-4">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid grid-cols-9 w-full sticky top-0 z-10 bg-background">
<TabsTrigger value="general">{t('settings.general')}</TabsTrigger>
<TabsTrigger value="permissions">{t('settings.permissionsTab')}</TabsTrigger>
<TabsTrigger value="environment">{t('settings.environmentTab')}</TabsTrigger>
<TabsTrigger value="advanced">{t('settings.advancedTab')}</TabsTrigger>
<TabsTrigger value="hooks">{t('settings.hooksTab')}</TabsTrigger>
<TabsTrigger value="commands">{t('settings.commands')}</TabsTrigger>
<TabsTrigger value="storage">{t('settings.storage')}</TabsTrigger>
<TabsTrigger value="proxy">{t('settings.proxy')}</TabsTrigger>
<TabsTrigger value="analytics">{t('settings.analyticsTab')}</TabsTrigger>
</TabsList>
{/* General Settings */}
<TabsContent value="general" className="space-y-6">
<TabsContent value="general" className="space-y-6 mt-6">
<Card className="p-6 space-y-6">
<div>
<h3 className="text-base font-semibold mb-4">{t('settings.generalSettings')}</h3>
@@ -633,13 +692,69 @@ export const Settings: React.FC<SettingsProps> = ({
</p>
)}
</div>
{/* Model Mappings Configuration */}
<div className="space-y-4">
<div>
<Label className="text-sm font-medium mb-2 block">{t('settings.modelMappings.title')}</Label>
<p className="text-xs text-muted-foreground mb-4">
{t('settings.modelMappings.description')}
</p>
</div>
{loadingMappings ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-3">
{modelMappings.map((mapping) => (
<div key={mapping.alias} className="space-y-2">
<Label htmlFor={`model-${mapping.alias}`} className="text-sm">
{mapping.alias}
</Label>
<Input
id={`model-${mapping.alias}`}
value={mapping.model_name}
onChange={(e) => updateModelMapping(mapping.alias, e.target.value)}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{mapping.alias === 'sonnet' && t('settings.modelMappings.aliasDescriptions.sonnet')}
{mapping.alias === 'opus' && t('settings.modelMappings.aliasDescriptions.opus')}
{mapping.alias === 'haiku' && t('settings.modelMappings.aliasDescriptions.haiku')}
</p>
</div>
))}
{modelMappings.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">{t('settings.modelMappings.emptyTitle')}</p>
<p className="text-xs mt-2">{t('settings.modelMappings.emptySubtitle')}</p>
</div>
)}
{modelMappingsChanged && (
<p className="text-xs text-amber-600 dark:text-amber-400">
{t('settings.modelMappings.changedNotice')}
</p>
)}
<div className="pt-2">
<p className="text-xs text-muted-foreground">
<strong>{t('settings.modelMappings.note')}</strong> {t('settings.modelMappings.noteContent')}
</p>
</div>
</div>
)}
</div>
</div>
</div>
</Card>
</TabsContent>
{/* Permissions Settings */}
<TabsContent value="permissions" className="space-y-6">
<TabsContent value="permissions" className="space-y-6 mt-6">
<Card className="p-6">
<div className="space-y-6">
<div>
@@ -760,7 +875,7 @@ export const Settings: React.FC<SettingsProps> = ({
</TabsContent>
{/* Environment Variables */}
<TabsContent value="environment" className="space-y-6">
<TabsContent value="environment" className="space-y-6 mt-6">
<Card className="p-6">
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -834,7 +949,7 @@ export const Settings: React.FC<SettingsProps> = ({
</Card>
</TabsContent>
{/* Advanced Settings */}
<TabsContent value="advanced" className="space-y-6">
<TabsContent value="advanced" className="space-y-6 mt-6">
<Card className="p-6">
<div className="space-y-6">
<div>
@@ -873,7 +988,7 @@ export const Settings: React.FC<SettingsProps> = ({
</TabsContent>
{/* Hooks Settings */}
<TabsContent value="hooks" className="space-y-6">
<TabsContent value="hooks" className="space-y-6 mt-6">
<Card className="p-6">
<div className="space-y-4">
<div>
@@ -898,19 +1013,21 @@ export const Settings: React.FC<SettingsProps> = ({
</TabsContent>
{/* Commands Tab */}
<TabsContent value="commands">
<TabsContent value="commands" className="mt-6">
<Card className="p-6">
<SlashCommandsManager className="p-0" />
</Card>
</TabsContent>
{/* Removed CLAUDE.md management tab from Settings */}
{/* Storage Tab */}
<TabsContent value="storage">
<TabsContent value="storage" className="mt-6">
<StorageTab />
</TabsContent>
{/* Proxy Settings */}
<TabsContent value="proxy">
<TabsContent value="proxy" className="mt-6">
<Card className="p-6">
<ProxySettings
setToast={setToast}
@@ -923,7 +1040,7 @@ export const Settings: React.FC<SettingsProps> = ({
</TabsContent>
{/* Analytics Settings */}
<TabsContent value="analytics" className="space-y-6">
<TabsContent value="analytics" className="space-y-6 mt-6">
<Card className="p-6 space-y-6">
<div>
<div className="flex items-center gap-3 mb-4">
@@ -1013,6 +1130,7 @@ export const Settings: React.FC<SettingsProps> = ({
</Card>
</TabsContent>
</Tabs>
</div>
</div>
)}
</div>

View File

@@ -0,0 +1,165 @@
import React, { useState, useEffect } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Edit,
Trash2,
Globe,
GripVertical,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import {
RelayStation,
RelayStationAdapter,
} from '@/lib/api';
interface SortableStationItemProps {
station: RelayStation;
getStatusBadge: (station: RelayStation) => React.ReactNode;
getAdapterDisplayName: (adapter: RelayStationAdapter) => string;
setSelectedStation: (station: RelayStation) => void;
setShowEditDialog: (show: boolean) => void;
openDeleteDialog: (station: RelayStation) => void;
}
/**
* 可排序的中转站卡片组件
* @author yovinchen
*/
export const SortableStationItem: React.FC<SortableStationItemProps> = ({
station,
getStatusBadge,
getAdapterDisplayName,
setSelectedStation,
setShowEditDialog,
openDeleteDialog,
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
isOver,
} = useSortable({ id: station.id });
// 展开/收起状态,从 localStorage 读取
const [isExpanded, setIsExpanded] = useState(() => {
const saved = localStorage.getItem(`relay-station-expanded-${station.id}`);
return saved !== null ? JSON.parse(saved) : true; // 默认展开
});
// 保存展开状态到 localStorage
useEffect(() => {
localStorage.setItem(`relay-station-expanded-${station.id}`, JSON.stringify(isExpanded));
}, [isExpanded, station.id]);
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
// 是否有详情内容需要显示
const hasDetails = station.description;
return (
<Card
ref={setNodeRef}
style={style}
className={`relative transition-all duration-200 ${
isDragging
? 'shadow-2xl ring-2 ring-blue-500 scale-105 z-50'
: isOver
? 'ring-2 ring-blue-400 ring-offset-2 bg-blue-50 dark:bg-blue-950/50 scale-102'
: 'hover:shadow-md'
}`}
>
<CardHeader className="pb-2 pt-3 px-3">
<div className="flex justify-between items-center">
<div
className="flex items-center flex-1 min-w-0 mr-2 cursor-grab active:cursor-grabbing"
{...attributes}
{...listeners}
>
<div className="mr-2 flex-shrink-0">
<GripVertical className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-sm font-medium">{station.name}</CardTitle>
<CardDescription className="text-xs mt-0.5">
{getAdapterDisplayName(station.adapter)}
</CardDescription>
</div>
</div>
<div className="flex items-center gap-1">
{getStatusBadge(station)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={isDragging}
onClick={(e) => {
e.stopPropagation();
setSelectedStation(station);
setShowEditDialog(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-700"
disabled={isDragging}
onClick={(e) => {
e.stopPropagation();
openDeleteDialog(station);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-1 pb-3 px-3">
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center flex-1 min-w-0">
<Globe className="mr-1.5 h-3 w-3 flex-shrink-0" />
<span className="truncate">{station.api_url}</span>
</div>
{hasDetails && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="ml-2 p-0.5 hover:bg-accent rounded transition-colors flex-shrink-0"
aria-label={isExpanded ? "收起详情" : "展开详情"}
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
)}
</div>
{/* 详情内容(可折叠) */}
{isExpanded && hasDetails && (
<>
{station.description && (
<p className="text-xs text-muted-foreground line-clamp-2">
{station.description}
</p>
)}
</>
)}
</div>
</CardContent>
</Card>
);
};

View File

@@ -133,7 +133,7 @@ export const StorageTab: React.FC = () => {
}
} catch (err) {
console.error("Failed to load tables:", err);
setError("Failed to load tables");
setError(t('storageTab.loadTablesFailed'));
} finally {
setLoading(false);
}
@@ -158,7 +158,7 @@ export const StorageTab: React.FC = () => {
setCurrentPage(page);
} catch (err) {
console.error("Failed to load table data:", err);
setError("Failed to load table data");
setError(t('storageTab.loadTableDataFailed'));
} finally {
setLoading(false);
}
@@ -205,7 +205,7 @@ export const StorageTab: React.FC = () => {
setEditingRow(null);
} catch (err) {
console.error("Failed to update row:", err);
setError("Failed to update row");
setError(t('storageTab.updateRowFailed'));
} finally {
setLoading(false);
}
@@ -225,7 +225,7 @@ export const StorageTab: React.FC = () => {
setDeletingRow(null);
} catch (err) {
console.error("Failed to delete row:", err);
setError("Failed to delete row");
setError(t('storageTab.deleteRowFailed'));
} finally {
setLoading(false);
}
@@ -244,7 +244,7 @@ export const StorageTab: React.FC = () => {
setNewRow(null);
} catch (err) {
console.error("Failed to insert row:", err);
setError("Failed to insert row");
setError(t('storageTab.insertRowFailed'));
} finally {
setLoading(false);
}
@@ -269,7 +269,7 @@ export const StorageTab: React.FC = () => {
}
} catch (err) {
console.error("Failed to execute SQL:", err);
setSqlError(err instanceof Error ? err.message : "Failed to execute SQL");
setSqlError(err instanceof Error ? err.message : t('storageTab.executeSqlFailed'));
} finally {
setLoading(false);
}
@@ -287,14 +287,14 @@ export const StorageTab: React.FC = () => {
setTableData(null);
setShowResetConfirm(false);
setToast({
message: "Database Reset Complete: The database has been restored to its default state with empty tables (agents, agent_runs, app_settings).",
message: t('storageTab.resetSuccess'),
type: "success",
});
} catch (err) {
console.error("Failed to reset database:", err);
setError("Failed to reset database");
setError(t('storageTab.resetDatabaseFailed'));
setToast({
message: "Reset Failed: Failed to reset the database. Please try again.",
message: t('storageTab.resetFailed'),
type: "error",
});
} finally {
@@ -337,7 +337,7 @@ export const StorageTab: React.FC = () => {
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Database className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">Database Storage</h3>
<h3 className="text-sm font-semibold">{t('storageTab.title')}</h3>
</div>
<div className="flex items-center gap-2">
<Button
@@ -347,7 +347,7 @@ export const StorageTab: React.FC = () => {
className="gap-2 h-8 text-xs"
>
<Terminal className="h-3 w-3" />
SQL Query
{t('storageTab.sqlQuery')}
</Button>
<Button
variant="destructive"
@@ -356,7 +356,7 @@ export const StorageTab: React.FC = () => {
className="gap-2 h-8 text-xs"
>
<RefreshCw className="h-3 w-3" />
Reset DB
{t('storageTab.resetDbShort')}
</Button>
</div>
</div>
@@ -365,7 +365,7 @@ export const StorageTab: React.FC = () => {
<div className="flex items-center gap-3">
<Select value={selectedTable} onValueChange={setSelectedTable}>
<SelectTrigger className="w-[200px] h-8 text-xs">
<SelectValue placeholder="Select a table">
<SelectValue placeholder={t('storageTab.selectTable')}>
{selectedTable && (
<div className="flex items-center gap-2">
<Table className="h-3 w-3" />
@@ -380,7 +380,7 @@ export const StorageTab: React.FC = () => {
<div className="flex items-center justify-between w-full">
<span>{table.name}</span>
<span className="text-[10px] text-muted-foreground ml-2">
{table.row_count} rows
{table.row_count} {t('storageTab.rows')}
</span>
</div>
</SelectItem>
@@ -391,7 +391,7 @@ export const StorageTab: React.FC = () => {
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
placeholder="Search in table..."
placeholder={t('storageTab.searchInTable')}
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="pl-8 h-8 text-xs"
@@ -406,7 +406,7 @@ export const StorageTab: React.FC = () => {
className="gap-2 h-8 text-xs"
>
<Plus className="h-3 w-3" />
New Row
{t('storageTab.newRow')}
</Button>
)}
</div>
@@ -437,7 +437,7 @@ export const StorageTab: React.FC = () => {
</th>
))}
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground">
Actions
{t('storageTab.actions')}
</th>
</tr>
</thead>
@@ -520,9 +520,11 @@ export const StorageTab: React.FC = () => {
{tableData.total_pages > 1 && (
<div className="flex items-center justify-between p-3 border-t">
<div className="text-xs text-muted-foreground">
Showing {(currentPage - 1) * pageSize + 1} to{" "}
{Math.min(currentPage * pageSize, tableData.total_rows)} of{" "}
{tableData.total_rows} rows
{t('storageTab.pagination.showing', {
from: (currentPage - 1) * pageSize + 1,
to: Math.min(currentPage * pageSize, tableData.total_rows),
total: tableData.total_rows
})}
</div>
<div className="flex items-center gap-2">
<Button
@@ -533,10 +535,10 @@ export const StorageTab: React.FC = () => {
className="h-7 text-xs"
>
<ChevronLeft className="h-3 w-3" />
Previous
{t('app.previous')}
</Button>
<div className="text-xs">
Page {currentPage} of {tableData.total_pages}
{t('storageTab.pagination.pageOf', { page: currentPage, total: tableData.total_pages })}
</div>
<Button
variant="outline"
@@ -545,7 +547,7 @@ export const StorageTab: React.FC = () => {
disabled={currentPage === tableData.total_pages}
className="h-7 text-xs"
>
Next
{t('app.next')}
<ChevronRight className="h-3 w-3" />
</Button>
</div>
@@ -575,9 +577,9 @@ export const StorageTab: React.FC = () => {
<Dialog open={!!editingRow} onOpenChange={() => setEditingRow(null)}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Row</DialogTitle>
<DialogTitle>{t('storageTab.editRow')}</DialogTitle>
<DialogDescription>
Update the values for this row in the {selectedTable} table.
{t('storageTab.editRowDesc', { table: selectedTable })}
</DialogDescription>
</DialogHeader>
{editingRow && tableData && (
@@ -588,7 +590,7 @@ export const StorageTab: React.FC = () => {
{column.name}
{column.pk && (
<span className="text-xs text-muted-foreground ml-2">
(Primary Key)
({t('storageTab.primaryKey')})
</span>
)}
</Label>
@@ -622,9 +624,9 @@ export const StorageTab: React.FC = () => {
/>
)}
<p className="text-xs text-muted-foreground">
Type: {column.type_name}
{column.notnull && ", NOT NULL"}
{column.dflt_value && `, Default: ${column.dflt_value}`}
{t('storageTab.type')}: {column.type_name}
{column.notnull && `, ${t('storageTab.notNull')}`}
{column.dflt_value && `, ${t('storageTab.default')}: ${column.dflt_value}`}
</p>
</div>
))}
@@ -632,7 +634,7 @@ export const StorageTab: React.FC = () => {
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditingRow(null)}>
Cancel
{t('app.cancel')}
</Button>
<Button
onClick={() => handleUpdateRow(editingRow!)}
@@ -641,7 +643,7 @@ export const StorageTab: React.FC = () => {
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Update"
t('app.update')
)}
</Button>
</DialogFooter>
@@ -652,9 +654,9 @@ export const StorageTab: React.FC = () => {
<Dialog open={!!newRow} onOpenChange={() => setNewRow(null)}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>New Row</DialogTitle>
<DialogTitle>{t('storageTab.newRow')}</DialogTitle>
<DialogDescription>
Add a new row to the {selectedTable} table.
{t('storageTab.newRowDesc', { table: selectedTable })}
</DialogDescription>
</DialogHeader>
{newRow && tableData && (
@@ -665,7 +667,7 @@ export const StorageTab: React.FC = () => {
{column.name}
{column.notnull && (
<span className="text-xs text-destructive ml-2">
(Required)
({t('validation.required')})
</span>
)}
</Label>
@@ -697,8 +699,8 @@ export const StorageTab: React.FC = () => {
/>
)}
<p className="text-xs text-muted-foreground">
Type: {column.type_name}
{column.dflt_value && `, Default: ${column.dflt_value}`}
{t('storageTab.type')}: {column.type_name}
{column.dflt_value && `, ${t('storageTab.default')}: ${column.dflt_value}`}
</p>
</div>
))}
@@ -706,7 +708,7 @@ export const StorageTab: React.FC = () => {
)}
<DialogFooter>
<Button variant="outline" onClick={() => setNewRow(null)}>
Cancel
{t('app.cancel')}
</Button>
<Button
onClick={() => handleInsertRow(newRow!)}
@@ -715,7 +717,7 @@ export const StorageTab: React.FC = () => {
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Insert"
t('storageTab.insert')
)}
</Button>
</DialogFooter>
@@ -726,10 +728,9 @@ export const StorageTab: React.FC = () => {
<Dialog open={!!deletingRow} onOpenChange={() => setDeletingRow(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Row</DialogTitle>
<DialogTitle>{t('storageTab.deleteRow')}</DialogTitle>
<DialogDescription>
Are you sure you want to delete this row? This action cannot be
undone.
{t('storageTab.deleteRowConfirm')}
</DialogDescription>
</DialogHeader>
{deletingRow && (
@@ -752,7 +753,7 @@ export const StorageTab: React.FC = () => {
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingRow(null)}>
Cancel
{t('app.cancel')}
</Button>
<Button
variant="destructive"
@@ -762,7 +763,7 @@ export const StorageTab: React.FC = () => {
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Delete"
t('app.delete')
)}
</Button>
</DialogFooter>
@@ -773,18 +774,15 @@ export const StorageTab: React.FC = () => {
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reset Database</DialogTitle>
<DialogTitle>{t('storageTab.resetDatabaseTitle')}</DialogTitle>
<DialogDescription>
This will delete all data and recreate the database with its default structure
(empty tables for agents, agent_runs, and app_settings). The database will be
restored to the same state as when you first installed the app. This action
cannot be undone.
{t('storageTab.resetDatabaseDesc')}
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-3 p-4 rounded-md bg-destructive/10 text-destructive">
<AlertTriangle className="h-5 w-5" />
<span className="text-sm font-medium">
All your agents, runs, and settings will be permanently deleted!
{t('storageTab.resetWarning')}
</span>
</div>
<DialogFooter>
@@ -792,7 +790,7 @@ export const StorageTab: React.FC = () => {
variant="outline"
onClick={() => setShowResetConfirm(false)}
>
Cancel
{t('app.cancel')}
</Button>
<Button
variant="destructive"
@@ -802,7 +800,7 @@ export const StorageTab: React.FC = () => {
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Reset Database"
t('storageTab.resetDatabaseTitle')
)}
</Button>
</DialogFooter>
@@ -813,19 +811,19 @@ export const StorageTab: React.FC = () => {
<Dialog open={showSqlEditor} onOpenChange={setShowSqlEditor}>
<DialogContent className="max-w-4xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>SQL Query Editor</DialogTitle>
<DialogTitle>{t('storageTab.sqlEditorTitle')}</DialogTitle>
<DialogDescription>
Execute raw SQL queries on the database. Use with caution.
{t('storageTab.sqlEditorDesc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="sql-query">SQL Query</Label>
<Label htmlFor="sql-query">{t('storageTab.sqlQuery')}</Label>
<Textarea
id="sql-query"
value={sqlQuery}
onChange={(e) => setSqlQuery(e.target.value)}
placeholder="SELECT * FROM agents LIMIT 10;"
placeholder={t('storageTab.sqlQueryPlaceholder')}
className="font-mono text-sm h-32"
/>
</div>
@@ -845,11 +843,10 @@ export const StorageTab: React.FC = () => {
<div className="p-3 rounded-md bg-green-500/10 text-green-600 dark:text-green-400 text-sm">
<div className="flex items-center gap-2">
<Check className="h-4 w-4" />
Query executed successfully. {sqlResult.rows_affected} rows
affected.
{t('storageTab.queryExecuted')} {sqlResult.rows_affected} {t('storageTab.rowsAffected')}
{sqlResult.last_insert_rowid && (
<span>
Last insert ID: {sqlResult.last_insert_rowid}
{t('storageTab.lastInsertId')}: {sqlResult.last_insert_rowid}
</span>
)}
</div>
@@ -927,7 +924,7 @@ export const StorageTab: React.FC = () => {
setSqlError(null);
}}
>
Close
{t('app.close')}
</Button>
<Button
onClick={handleExecuteSql}

View File

@@ -3,12 +3,11 @@ import { motion, AnimatePresence } from 'framer-motion';
import { useTabState } from '@/hooks/useTabState';
import { useScreenTracking } from '@/hooks/useAnalytics';
import { Tab } from '@/contexts/TabContext';
import { Loader2, Plus } from 'lucide-react';
import { api, type Project, type Session, type ClaudeMdFile } from '@/lib/api';
import { Loader2 } from 'lucide-react';
import { api, type Project, type Session } from '@/lib/api';
import { ProjectList } from '@/components/ProjectList';
import { SessionList } from '@/components/SessionList';
import { RunningClaudeSessions } from '@/components/RunningClaudeSessions';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/hooks/useTranslation';
// Lazy load heavy components
@@ -19,7 +18,7 @@ const CreateAgent = lazy(() => import('@/components/CreateAgent').then(m => ({ d
const UsageDashboard = lazy(() => import('@/components/UsageDashboard').then(m => ({ default: m.UsageDashboard })));
const MCPManager = lazy(() => import('@/components/MCPManager').then(m => ({ default: m.MCPManager })));
const Settings = lazy(() => import('@/components/Settings').then(m => ({ default: m.Settings })));
const MarkdownEditor = lazy(() => import('@/components/MarkdownEditor').then(m => ({ default: m.MarkdownEditor })));
// Removed MarkdownEditor (direct CLAUDE.md editor)
// const ClaudeFileEditor = lazy(() => import('@/components/ClaudeFileEditor').then(m => ({ default: m.ClaudeFileEditor })));
// Import non-lazy components for projects view
@@ -147,12 +146,6 @@ const TabPanel: React.FC<TabPanelProps> = ({ tab, isActive }) => {
initialProjectPath: session.project_path,
});
}}
onEditClaudeFile={(file: ClaudeMdFile) => {
// Open CLAUDE.md file in a new tab
window.dispatchEvent(new CustomEvent('open-claude-file', {
detail: { file }
}));
}}
/>
</motion.div>
) : (
@@ -162,36 +155,20 @@ const TabPanel: React.FC<TabPanelProps> = ({ tab, isActive }) => {
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
{/* New session button at the top */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="mb-4"
>
<Button
onClick={handleNewSession}
size="default"
className="w-full max-w-md"
>
<Plus className="mr-2 h-4 w-4" />
{t('newClaudeCodeSession')}
</Button>
</motion.div>
{/* Running Claude Sessions */}
{/* Running Claude Sessions - moved before project list */}
<RunningClaudeSessions />
{/* Project list */}
{/* Project list - now includes new session button and search */}
{projects.length > 0 ? (
<ProjectList
projects={projects}
onProjectClick={handleProjectClick}
onProjectSettings={(project) => {
// Project settings functionality can be added here if needed
console.log('Project settings clicked for:', project);
}}
onNewSession={handleNewSession}
loading={loading}
className="animate-fade-in"
/>
@@ -213,8 +190,10 @@ const TabPanel: React.FC<TabPanelProps> = ({ tab, isActive }) => {
case 'chat':
return (
<ClaudeCodeSession
key={`${tab.id}-${tab.initialProjectPath || 'no-path'}`} // Force re-render when path changes
session={tab.sessionData} // Pass the full session object if available
initialProjectPath={tab.initialProjectPath || tab.sessionId}
tabId={tab.id} // Pass tabId for state synchronization
onBack={() => {
// Go back to projects view in the same tab
updateTab(tab.id, {
@@ -226,9 +205,12 @@ const TabPanel: React.FC<TabPanelProps> = ({ tab, isActive }) => {
);
case 'agent':
if (!tab.agentRunId) {
console.error('[TabContent] No agentRunId in tab:', tab);
return <div className="p-4">{t('messages.noAgentRunIdSpecified')}</div>;
}
return (
<AgentRunOutputViewer
agentRunId={tab.agentRunId}
@@ -246,16 +228,9 @@ const TabPanel: React.FC<TabPanelProps> = ({ tab, isActive }) => {
case 'settings':
return <Settings onBack={() => {}} />;
case 'claude-md':
return <MarkdownEditor onBack={() => {}} />;
// Removed 'claude-md' tab type
case 'claude-file':
if (!tab.claudeFileId) {
return <div className="p-4">{t('messages.noClaudeFileIdSpecified')}</div>;
}
// Note: We need to get the actual file object for ClaudeFileEditor
// For now, returning a placeholder
return <div className="p-4">{t('messages.claudeFileEditorNotImplemented')}</div>;
// Removed 'claude-file' tab type
case 'agent-execution':
if (!tab.agentData) {
@@ -291,31 +266,35 @@ const TabPanel: React.FC<TabPanelProps> = ({ tab, isActive }) => {
}
};
// Only render content when the tab is active or was previously active (to keep state)
// This prevents unnecessary unmounting/remounting
const shouldRenderContent = isActive;
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className={`h-full w-full ${panelVisibilityClass}`}
>
<Suspense
fallback={
<div className="flex items-center justify-center h-full">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
}
>
{renderContent()}
</Suspense>
</motion.div>
<div className={`h-full w-full ${panelVisibilityClass}`}>
{shouldRenderContent && (
<Suspense
fallback={
<div className="flex items-center justify-center h-full">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
}
>
{renderContent()}
</Suspense>
)}
</div>
);
};
export const TabContent: React.FC = () => {
const { t } = useTranslation();
const { tabs, activeTabId, createChatTab, findTabBySessionId, createClaudeFileTab, createAgentExecutionTab, createCreateAgentTab, createImportAgentTab, closeTab, updateTab } = useTabState();
const { tabs, activeTabId, createChatTab, findTabBySessionId, createAgentExecutionTab, createCreateAgentTab, createImportAgentTab, closeTab, updateTab } = useTabState();
const [hasInitialized, setHasInitialized] = React.useState(false);
// Debug: Monitor activeTabId changes
useEffect(() => {
}, [activeTabId, tabs]);
// Auto redirect to home when no tabs (but not on initial load)
useEffect(() => {
@@ -358,11 +337,6 @@ export const TabContent: React.FC = () => {
}
};
const handleOpenClaudeFile = (event: CustomEvent) => {
const { file } = event.detail;
createClaudeFileTab(file.id, file.name || 'CLAUDE.md');
};
const handleOpenAgentExecution = (event: CustomEvent) => {
const { agent, tabId } = event.detail;
createAgentExecutionTab(agent, tabId);
@@ -401,35 +375,53 @@ export const TabContent: React.FC = () => {
}
};
const handleCreateSmartSessionTab = (event: CustomEvent) => {
const { tabId, sessionData } = event.detail;
console.log('[TabContent] Handling create-smart-session-tab:', { tabId, sessionData });
// Update the existing tab with smart session data and switch immediately
const displayName = sessionData.display_name || t('smartSessionDefaultTitle');
updateTab(tabId, {
type: 'chat',
title: displayName,
initialProjectPath: sessionData.project_path,
sessionData: null, // No existing session, this is a new session workspace
});
// Force immediate tab switch without delay
setTimeout(() => {
window.dispatchEvent(new CustomEvent('switch-to-tab', { detail: { tabId } }));
}, 0);
};
window.addEventListener('open-session-in-tab', handleOpenSessionInTab as EventListener);
window.addEventListener('open-claude-file', handleOpenClaudeFile as EventListener);
window.addEventListener('open-agent-execution', handleOpenAgentExecution as EventListener);
window.addEventListener('open-create-agent-tab', handleOpenCreateAgentTab);
window.addEventListener('open-import-agent-tab', handleOpenImportAgentTab);
window.addEventListener('close-tab', handleCloseTab as EventListener);
window.addEventListener('claude-session-selected', handleClaudeSessionSelected as EventListener);
window.addEventListener('create-smart-session-tab', handleCreateSmartSessionTab as EventListener);
return () => {
window.removeEventListener('open-session-in-tab', handleOpenSessionInTab as EventListener);
window.removeEventListener('open-claude-file', handleOpenClaudeFile as EventListener);
window.removeEventListener('open-agent-execution', handleOpenAgentExecution as EventListener);
window.removeEventListener('open-create-agent-tab', handleOpenCreateAgentTab);
window.removeEventListener('open-import-agent-tab', handleOpenImportAgentTab);
window.removeEventListener('close-tab', handleCloseTab as EventListener);
window.removeEventListener('claude-session-selected', handleClaudeSessionSelected as EventListener);
window.removeEventListener('create-smart-session-tab', handleCreateSmartSessionTab as EventListener);
};
}, [createChatTab, findTabBySessionId, createClaudeFileTab, createAgentExecutionTab, createCreateAgentTab, createImportAgentTab, closeTab, updateTab]);
}, [createChatTab, findTabBySessionId, createAgentExecutionTab, createCreateAgentTab, createImportAgentTab, closeTab, updateTab]);
return (
<div className="flex-1 h-full relative">
<AnimatePresence mode="wait">
{tabs.map((tab) => (
<TabPanel
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
/>
))}
</AnimatePresence>
{tabs.map((tab) => (
<TabPanel
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
/>
))}
{tabs.length === 0 && (
<div className="flex items-center justify-center h-full text-muted-foreground">

View File

@@ -1,10 +1,11 @@
import React, { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence, Reorder } from 'framer-motion';
import { X, Plus, MessageSquare, Bot, AlertCircle, Loader2, Folder, BarChart, Server, Settings, FileText } from 'lucide-react';
import { X, Plus, MessageSquare, Bot, AlertCircle, Loader2, Folder, BarChart, Server, Settings } from 'lucide-react';
import { useTabState } from '@/hooks/useTabState';
import { Tab, useTabContext } from '@/contexts/TabContext';
import { cn } from '@/lib/utils';
import { useTrackEvent } from '@/hooks';
import { useTranslation as useAppTranslation } from '@/hooks/useTranslation';
interface TabItemProps {
tab: Tab;
@@ -16,6 +17,7 @@ interface TabItemProps {
}
const TabItem: React.FC<TabItemProps> = ({ tab, isActive, onClose, onClick, isDragging = false, setDraggedTabId }) => {
const { t } = useAppTranslation();
const [isHovered, setIsHovered] = useState(false);
const getIcon = () => {
@@ -32,9 +34,6 @@ const TabItem: React.FC<TabItemProps> = ({ tab, isActive, onClose, onClick, isDr
return Server;
case 'settings':
return Settings;
case 'claude-md':
case 'claude-file':
return FileText;
case 'agent-execution':
return Bot;
case 'create-agent':
@@ -103,7 +102,7 @@ const TabItem: React.FC<TabItemProps> = ({ tab, isActive, onClose, onClick, isDr
{tab.hasUnsavedChanges && !statusIcon && (
<span
className="w-1.5 h-1.5 bg-primary rounded-full"
title="Unsaved changes"
title={t('app.unsavedChanges')}
/>
)}
</div>
@@ -120,7 +119,7 @@ const TabItem: React.FC<TabItemProps> = ({ tab, isActive, onClose, onClick, isDr
"focus:outline-none focus:ring-1 focus:ring-destructive/50",
(isHovered || isActive) ? "opacity-100" : "opacity-0"
)}
title={`Close ${tab.title}`}
title={`${t('app.close')} ${tab.title}`}
tabIndex={-1}
>
<X className="w-3 h-3" />
@@ -135,6 +134,7 @@ interface TabManagerProps {
}
export const TabManager: React.FC<TabManagerProps> = ({ className }) => {
const { t } = useAppTranslation();
const {
tabs,
activeTabId,
@@ -173,6 +173,9 @@ export const TabManager: React.FC<TabManagerProps> = ({ className }) => {
// Listen for keyboard shortcut events
useEffect(() => {
const handleCreateTab = () => {
if (!canAddTab()) {
return;
}
createChatTab();
trackEvent.tabCreated('chat');
};
@@ -215,16 +218,16 @@ export const TabManager: React.FC<TabManagerProps> = ({ className }) => {
if (session && canAddTab()) {
// Create a new chat tab with the session data
const tabId = createChatTab();
// Update the tab with session data
setTimeout(() => {
updateTab(tabId, {
type: 'chat',
title: session.project_path.split('/').pop() || 'Session',
sessionId: session.id,
sessionData: session,
initialProjectPath: projectPath || session.project_path,
});
}, 100);
// Update the tab with session data immediately
updateTab(tabId, {
type: 'chat',
title: session.project_path.split('/').pop() || 'Session',
sessionId: session.id,
sessionData: session,
initialProjectPath: projectPath || session.project_path,
});
// Switch to the new tab immediately
switchToTab(tabId);
}
};
@@ -243,7 +246,7 @@ export const TabManager: React.FC<TabManagerProps> = ({ className }) => {
window.removeEventListener('switch-to-tab-by-index', handleTabByIndex as EventListener);
window.removeEventListener('open-session-tab', handleOpenSessionTab as EventListener);
};
}, [tabs, activeTabId, createChatTab, closeTab, switchToTab, updateTab, canAddTab]);
}, [tabs, activeTabId, createChatTab, closeTab, switchToTab, updateTab, canAddTab, trackEvent]);
// Check scroll buttons visibility
const checkScrollButtons = () => {
@@ -342,7 +345,7 @@ export const TabManager: React.FC<TabManagerProps> = ({ className }) => {
"transition-colors duration-200 flex items-center justify-center",
"bg-background/98 backdrop-blur-xl backdrop-saturate-[1.8] shadow-sm border border-border/60"
)}
title="Scroll tabs left"
title={t('tabs.scrollLeft')}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M15 18l-6-6 6-6" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
@@ -396,7 +399,7 @@ export const TabManager: React.FC<TabManagerProps> = ({ className }) => {
"transition-colors duration-200 flex items-center justify-center",
"bg-background/98 backdrop-blur-xl backdrop-saturate-[1.8] shadow-sm border border-border/60"
)}
title="Scroll tabs right"
title={t('tabs.scrollRight')}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M9 18l6-6-6-6" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
@@ -416,7 +419,7 @@ export const TabManager: React.FC<TabManagerProps> = ({ className }) => {
? "hover:bg-muted/80 hover:border-border text-muted-foreground hover:text-foreground hover:shadow-sm"
: "opacity-50 cursor-not-allowed bg-muted/30"
)}
title={canAddTab() ? "Browse projects (Ctrl+T)" : `Maximum tabs reached (${tabs.length}/20)`}
title={canAddTab() ? t('tabs.browseProjectsShortcut') : t('tabs.maximumTabsReached', { count: tabs.length })}
>
<Plus className="w-3.5 h-3.5" />
</button>
@@ -424,4 +427,4 @@ export const TabManager: React.FC<TabManagerProps> = ({ className }) => {
);
};
export default TabManager;
export default TabManager;

View File

@@ -54,21 +54,6 @@ export const Terminal: React.FC<TerminalProps> = ({
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 };
}, []);
@@ -87,10 +72,6 @@ export const Terminal: React.FC<TerminalProps> = ({
if (dims.actualCellWidth) actualCharWidth = dims.actualCellWidth;
if (dims.actualCellHeight) actualLineHeight = dims.actualCellHeight;
console.log('[Terminal] Using actual char dimensions:', {
actualCharWidth,
actualLineHeight
});
}
} catch (e) {
// 使用默认值
@@ -108,22 +89,8 @@ export const Terminal: React.FC<TerminalProps> = ({
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);
@@ -163,7 +130,6 @@ export const Terminal: React.FC<TerminalProps> = ({
const initializeTerminal = async () => {
try {
console.log('[Terminal] Initializing...');
isInitializedRef.current = true;
// 先计算初始尺寸
@@ -247,23 +213,11 @@ export const Terminal: React.FC<TerminalProps> = ({
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 });
@@ -277,8 +231,16 @@ export const Terminal: React.FC<TerminalProps> = ({
resizeTerminal();
}, 150);
// 如果没有有效的 projectPath,跳过创建终端会话
if (!projectPath || projectPath.trim() === '') {
if (xtermRef.current) {
xtermRef.current.write('\r\n\x1b[33mNo project directory selected. Please select a project to use the terminal.\x1b[0m\r\n');
}
return;
}
// 创建终端会话
const newSessionId = await api.createTerminalSession(projectPath || process.cwd());
const newSessionId = await api.createTerminalSession(projectPath);
if (!isMounted) {
await api.closeTerminalSession(newSessionId);
@@ -306,8 +268,6 @@ export const Terminal: React.FC<TerminalProps> = ({
}
});
console.log('[Terminal] Initialized with session:', newSessionId);
} catch (error) {
console.error('[Terminal] Failed to initialize:', error);
if (xtermRef.current && isMounted) {

View File

@@ -294,7 +294,7 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{isCurrent && (
<Badge variant="default" className="text-xs">Current</Badge>
<Badge variant="default" className="text-xs">{t('checkpoint.current') || 'Current'}</Badge>
)}
<span className="text-xs font-mono text-muted-foreground">
{node.checkpoint.id.slice(0, 8)}
@@ -309,7 +309,7 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
)}
<p className="text-xs text-muted-foreground line-clamp-2">
{node.checkpoint.metadata.userPrompt || "No prompt"}
{node.checkpoint.metadata.userPrompt || t('checkpoint.noPrompt') || 'No prompt'}
</p>
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
@@ -341,7 +341,7 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
<RotateCcw className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Restore to this checkpoint</TooltipContent>
<TooltipContent>{t('checkpoint.restoreToThis') || 'Restore to this checkpoint'}</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -360,7 +360,7 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
<GitFork className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Fork from this checkpoint</TooltipContent>
<TooltipContent>{t('checkpoint.forkFromThis') || 'Fork from this checkpoint'}</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -379,7 +379,7 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
<Diff className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Compare with another checkpoint</TooltipContent>
<TooltipContent>{t('checkpoint.compareWithAnother') || 'Compare with another checkpoint'}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
@@ -442,7 +442,7 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
disabled={isLoading}
>
<Save className="h-3 w-3 mr-1" />
Checkpoint
{t('checkpoint.createCheckpoint')}
</Button>
</div>
@@ -469,18 +469,18 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Checkpoint</DialogTitle>
<DialogTitle>{t('checkpoint.createCheckpoint')}</DialogTitle>
<DialogDescription>
Save the current state of your session with an optional description.
{t('checkpoint.createCheckpointDesc') || 'Save the current state of your session with an optional description.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="description">Description (optional)</Label>
<Label htmlFor="description">{t('checkpoint.descriptionOptional') || 'Description (optional)'}</Label>
<Input
id="description"
placeholder="e.g., Before major refactoring"
placeholder={t('checkpoint.descriptionPlaceholder') || 'e.g., Before major refactoring'}
value={checkpointDescription}
onChange={(e) => setCheckpointDescription(e.target.value)}
onKeyPress={(e) => {
@@ -498,13 +498,13 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
onClick={() => setShowCreateDialog(false)}
disabled={isLoading}
>
Cancel
{t('app.cancel')}
</Button>
<Button
onClick={handleCreateCheckpoint}
disabled={isLoading}
>
Create Checkpoint
{t('checkpoint.createCheckpoint')}
</Button>
</DialogFooter>
</DialogContent>
@@ -514,7 +514,7 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
<Dialog open={showDiffDialog} onOpenChange={setShowDiffDialog}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Checkpoint Comparison</DialogTitle>
<DialogTitle>{t('checkpoint.checkpointComparison') || 'Checkpoint Comparison'}</DialogTitle>
<DialogDescription>
Changes between "{selectedCheckpoint?.description || selectedCheckpoint?.id.slice(0, 8)}"
and "{compareCheckpoint?.description || compareCheckpoint?.id.slice(0, 8)}"
@@ -527,19 +527,19 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
<div className="grid grid-cols-3 gap-4">
<Card>
<CardContent className="p-3">
<div className="text-xs text-muted-foreground">Modified Files</div>
<div className="text-xs text-muted-foreground">{t('checkpoint.modifiedFiles') || 'Modified Files'}</div>
<div className="text-2xl font-bold">{diff.modifiedFiles.length}</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<div className="text-xs text-muted-foreground">Added Files</div>
<div className="text-xs text-muted-foreground">{t('checkpoint.addedFiles') || 'Added Files'}</div>
<div className="text-2xl font-bold text-green-600">{diff.addedFiles.length}</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<div className="text-xs text-muted-foreground">Deleted Files</div>
<div className="text-xs text-muted-foreground">{t('checkpoint.deletedFiles') || 'Deleted Files'}</div>
<div className="text-2xl font-bold text-red-600">{diff.deletedFiles.length}</div>
</CardContent>
</Card>
@@ -548,14 +548,14 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
{/* Token delta */}
<div className="flex items-center justify-center">
<Badge variant={diff.tokenDelta > 0 ? "default" : "secondary"}>
{diff.tokenDelta > 0 ? "+" : ""}{diff.tokenDelta.toLocaleString()} tokens
{diff.tokenDelta > 0 ? "+" : ""}{diff.tokenDelta.toLocaleString()} {t('usage.tokens')}
</Badge>
</div>
{/* File lists */}
{diff.modifiedFiles.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2">Modified Files</h4>
<h4 className="text-sm font-medium mb-2">{t('checkpoint.modifiedFiles') || 'Modified Files'}</h4>
<div className="space-y-1">
{diff.modifiedFiles.map((file) => (
<div key={file.path} className="flex items-center justify-between text-xs">
@@ -572,7 +572,7 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
{diff.addedFiles.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2">Added Files</h4>
<h4 className="text-sm font-medium mb-2">{t('checkpoint.addedFiles') || 'Added Files'}</h4>
<div className="space-y-1">
{diff.addedFiles.map((file) => (
<div key={file} className="text-xs font-mono text-green-600">
@@ -585,7 +585,7 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
{diff.deletedFiles.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2">Deleted Files</h4>
<h4 className="text-sm font-medium mb-2">{t('checkpoint.deletedFiles') || 'Deleted Files'}</h4>
<div className="space-y-1">
{diff.deletedFiles.map((file) => (
<div key={file} className="text-xs font-mono text-red-600">
@@ -607,7 +607,7 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
setCompareCheckpoint(null);
}}
>
Close
{t('app.close')}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -635,6 +635,7 @@ export const BashWidget: React.FC<{
description?: string;
result?: any;
}> = ({ command, description, result }) => {
const { t } = useTranslation();
// Extract result content if available
let resultContent = '';
let isError = false;
@@ -660,7 +661,7 @@ export const BashWidget: React.FC<{
<div className="rounded-lg border bg-zinc-950 overflow-hidden">
<div className="px-4 py-2 bg-zinc-900/50 flex items-center gap-2 border-b">
<Terminal className="h-3.5 w-3.5 text-green-500" />
<span className="text-xs font-mono text-muted-foreground">Terminal</span>
<span className="text-xs font-mono text-muted-foreground">{t('widgets.terminal.title')}</span>
{description && (
<>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
@@ -671,7 +672,7 @@ export const BashWidget: React.FC<{
{!result && (
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse" />
<span>Running...</span>
<span>{t('widgets.common.running')}</span>
</div>
)}
</div>
@@ -688,7 +689,7 @@ export const BashWidget: React.FC<{
? "border-red-500/20 bg-red-500/5 text-red-400"
: "border-green-500/20 bg-green-500/5 text-green-300"
)}>
{resultContent || (isError ? "Command failed" : "Command completed")}
{resultContent || (isError ? t('widgets.bash.commandFailed') : t('widgets.bash.commandCompleted'))}
</div>
)}
</div>

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { Circle, FileText, Settings, ExternalLink, BarChart3, Network, Info, Bot } from "lucide-react";
import { Circle, Settings, ExternalLink, BarChart3, Network, Info, Bot, Files } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover } from "@/components/ui/popover";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
@@ -10,10 +10,7 @@ import { api, type ClaudeVersionStatus } from "@/lib/api";
import { cn } from "@/lib/utils";
interface TopbarProps {
/**
* Callback when CLAUDE.md is clicked
*/
onClaudeClick: () => void;
// Removed direct CLAUDE.md editor entry to avoid duplication
/**
* Callback when Settings is clicked
*/
@@ -34,6 +31,10 @@ interface TopbarProps {
* Callback when Agents is clicked
*/
onAgentsClick?: () => void;
/**
* Callback when Prompt Files is clicked
*/
onPromptFilesClick?: () => void;
/**
* Optional className for styling
*/
@@ -45,19 +46,19 @@ interface TopbarProps {
*
* @example
* <Topbar
* onClaudeClick={() => setView('editor')}
* // CLAUDE.md direct editor removed; use Prompt Files manager instead
* onSettingsClick={() => setView('settings')}
* onUsageClick={() => setView('usage-dashboard')}
* onMCPClick={() => setView('mcp')}
* />
*/
export const Topbar: React.FC<TopbarProps> = ({
onClaudeClick,
onSettingsClick,
onUsageClick,
onMCPClick,
onInfoClick,
onAgentsClick,
onPromptFilesClick,
className,
}) => {
const { t } = useTranslation();
@@ -112,7 +113,7 @@ export const Topbar: React.FC<TopbarProps> = ({
// Emit event to return to home
window.dispatchEvent(new CustomEvent('switch-to-welcome'));
}}
title="Return to Home"
title={t('app.returnHome')}
>
<div className="flex items-center space-x-2 text-xs">
<Circle
@@ -208,15 +209,19 @@ export const Topbar: React.FC<TopbarProps> = ({
{t('navigation.usage')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={onClaudeClick}
className="text-xs"
>
<FileText className="mr-2 h-3 w-3" />
CLAUDE.md
</Button>
{/* Removed old direct CLAUDE.md editor button */}
{onPromptFilesClick && (
<Button
variant="ghost"
size="sm"
onClick={onPromptFilesClick}
className="text-xs"
>
<Files className="mr-2 h-3 w-3" />
{t('navigation.promptFiles')}
</Button>
)}
<Button
variant="ghost"
@@ -256,4 +261,4 @@ export const Topbar: React.FC<TopbarProps> = ({
</div>
</motion.div>
);
};
};

View File

@@ -93,7 +93,6 @@ const WebviewPreviewComponent: React.FC<WebviewPreviewProps> = ({
// Debug: Log initial URL on mount
useEffect(() => {
console.log('[WebviewPreview] Component mounted with initialUrl:', initialUrl, 'isMaximized:', isMaximized);
}, []);
// Focus management for full screen mode
@@ -127,7 +126,6 @@ const WebviewPreviewComponent: React.FC<WebviewPreviewProps> = ({
const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`);
const finalUrl = urlObj.href;
console.log('[WebviewPreview] Navigating to:', finalUrl);
setCurrentUrl(finalUrl);
setInputUrl(finalUrl);
setHasError(false);
@@ -152,12 +150,10 @@ const WebviewPreviewComponent: React.FC<WebviewPreviewProps> = ({
const handleGoBack = () => {
// In real implementation, this would call webview.goBack()
console.log("Go back");
};
const handleGoForward = () => {
// In real implementation, this would call webview.goForward()
console.log("Go forward");
};
const handleRefresh = () => {
@@ -176,7 +172,7 @@ const WebviewPreviewComponent: React.FC<WebviewPreviewProps> = ({
className={cn("flex flex-col h-full bg-background border-l", className)}
tabIndex={-1}
role="region"
aria-label="Web preview"
aria-label={t('webview.preview')}
>
{/* Browser Top Bar */}
<div className="border-b bg-muted/30 flex-shrink-0">
@@ -326,7 +322,7 @@ const WebviewPreviewComponent: React.FC<WebviewPreviewProps> = ({
ref={iframeRef}
src={currentUrl}
className="absolute inset-0 w-full h-full border-0"
title="Preview"
title={t('webview.preview')}
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
onLoad={() => setIsLoading(false)}
onError={() => {
@@ -354,4 +350,4 @@ const WebviewPreviewComponent: React.FC<WebviewPreviewProps> = ({
);
};
export const WebviewPreview = React.memo(WebviewPreviewComponent);
export const WebviewPreview = React.memo(WebviewPreviewComponent);

View File

@@ -1,16 +1,19 @@
import { motion } from "framer-motion";
import { Bot, FolderCode, BarChart, ServerCog, FileText, Settings, Network, Router } from "lucide-react";
import { Bot, FolderCode, BarChart, ServerCog, FileText, Settings, Network, Router, Zap, FolderOpen, Loader2 } from "lucide-react";
import { useTranslation } from "@/hooks/useTranslation";
import { Button } from "@/components/ui/button";
import { ClaudiaLogoMinimal } from "@/components/ClaudiaLogo";
import { useState } from "react";
interface WelcomePageProps {
onNavigate: (view: string) => void;
onNewSession: () => void;
onSmartQuickStart?: () => void;
}
export function WelcomePage({ onNavigate, onNewSession }: WelcomePageProps) {
export function WelcomePage({ onNavigate, onNewSession, onSmartQuickStart }: WelcomePageProps) {
const { t } = useTranslation();
const [isCreatingSmartSession, setIsCreatingSmartSession] = useState(false);
const mainFeatures = [
{
@@ -71,13 +74,13 @@ export function WelcomePage({ onNavigate, onNewSession }: WelcomePageProps) {
view: "ccr-router"
},
{
id: "claude-md",
id: "prompt-files",
icon: FileText,
title: t("welcome.claudeMd"),
subtitle: t("welcome.claudeMdDesc"),
title: t("welcome.promptFiles"),
subtitle: t("welcome.promptFilesDesc"),
color: "text-orange-500",
bgColor: "bg-orange-500/10",
view: "editor"
view: "prompt-files"
},
{
id: "settings",
@@ -98,6 +101,17 @@ export function WelcomePage({ onNavigate, onNewSession }: WelcomePageProps) {
onNewSession();
};
const handleSmartQuickStartClick = async () => {
if (!onSmartQuickStart) return;
setIsCreatingSmartSession(true);
try {
await onSmartQuickStart();
} finally {
setIsCreatingSmartSession(false);
}
};
return (
<div className="flex items-center justify-center min-h-screen bg-background overflow-hidden">
<div className="w-full max-w-6xl px-8 -mt-20">
@@ -191,7 +205,7 @@ export function WelcomePage({ onNavigate, onNewSession }: WelcomePageProps) {
))}
</div>
{/* Quick Action Button */}
{/* Quick Action Buttons */}
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
@@ -201,21 +215,47 @@ export function WelcomePage({ onNavigate, onNewSession }: WelcomePageProps) {
type: "spring",
stiffness: 100
}}
className="flex justify-center"
className="flex justify-center gap-4"
>
{/* 智能快速开始 - 新功能 */}
{onSmartQuickStart && (
<Button
size="lg"
className="relative px-8 py-6 text-lg font-semibold bg-orange-500 hover:bg-orange-600 text-white border-0 shadow-2xl hover:shadow-orange-500/25 transition-all duration-300 hover:scale-105 rounded-2xl group overflow-hidden"
onClick={handleSmartQuickStartClick}
disabled={isCreatingSmartSession}
>
{/* Shimmer effect on button */}
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer" />
</div>
<span className="relative z-10 flex items-center gap-2">
{isCreatingSmartSession ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
{t("welcome.creatingSmartSession")}
</>
) : (
<>
<Zap className="w-5 h-5" />
{t("welcome.smartQuickStart")}
</>
)}
</span>
</Button>
)}
{/* 传统快速开始 - 保持原功能 */}
<Button
size="lg"
className="relative px-10 py-7 text-lg font-semibold bg-orange-500 hover:bg-orange-600 text-white border-0 shadow-2xl hover:shadow-orange-500/25 transition-all duration-300 hover:scale-105 rounded-2xl group overflow-hidden"
variant="outline"
className="relative px-8 py-6 text-lg font-semibold border-2 border-orange-500 text-orange-500 hover:bg-orange-500 hover:text-white transition-all duration-300 hover:scale-105 rounded-2xl group"
onClick={handleButtonClick}
>
{/* Shimmer effect on button */}
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer" />
</div>
<span className="relative z-10 flex items-center gap-2">
<span className="text-2xl">+</span>
{t("welcome.quickStartSession")}
<FolderOpen className="w-5 h-5" />
{t("welcome.choosePathStart")}
</span>
</Button>
</motion.div>

View File

@@ -90,7 +90,7 @@ export const SessionHeader: React.FC<SessionHeaderProps> = React.memo(({
className="flex items-center gap-2"
>
<FolderOpen className="h-4 w-4" />
Select Project
{t('webview.selectProjectDirectory')}
</Button>
)}
</div>
@@ -104,7 +104,7 @@ export const SessionHeader: React.FC<SessionHeaderProps> = React.memo(({
</Badge>
{totalTokens > 0 && (
<Badge variant="secondary" className="text-xs">
{totalTokens.toLocaleString()} tokens
{totalTokens.toLocaleString()} {t('usage.tokens')}
</Badge>
)}
</div>
@@ -165,13 +165,13 @@ export const SessionHeader: React.FC<SessionHeaderProps> = React.memo(({
{onProjectSettings && projectPath && (
<DropdownMenuItem onClick={onProjectSettings}>
<Settings className="h-4 w-4 mr-2" />
Project Settings
{t('agents.projectSettings')}
</DropdownMenuItem>
)}
{onSlashCommandsSettings && projectPath && (
<DropdownMenuItem onClick={onSlashCommandsSettings}>
<Command className="h-4 w-4 mr-2" />
Slash Commands
{t('slashCommands.slashCommands')}
</DropdownMenuItem>
)}
</DropdownMenuContent>

View File

@@ -26,6 +26,9 @@ export * from "./ui/toast";
export * from "./ui/tooltip";
export * from "./SlashCommandPicker";
export * from "./SlashCommandsManager";
export { default as PromptFilesManager } from "./PromptFilesManager";
export { default as PromptFileEditor } from "./PromptFileEditor";
export { default as PromptFilePreview } from "./PromptFilePreview";
export * from "./ui/popover";
export * from "./ui/pagination";
export * from "./ui/split-pane";

View File

@@ -1,6 +1,7 @@
import React from "react";
import { Terminal, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslation } from "@/hooks/useTranslation";
interface BashWidgetProps {
command: string;
@@ -9,6 +10,7 @@ interface BashWidgetProps {
}
export const BashWidget: React.FC<BashWidgetProps> = ({ command, description, result }) => {
const { t } = useTranslation();
// Extract result content if available
let resultContent = '';
let isError = false;
@@ -34,7 +36,7 @@ export const BashWidget: React.FC<BashWidgetProps> = ({ command, description, re
<div className="rounded-lg border bg-zinc-950 overflow-hidden">
<div className="px-4 py-2 bg-zinc-900/50 flex items-center gap-2 border-b">
<Terminal className="h-3.5 w-3.5 text-green-500" />
<span className="text-xs font-mono text-muted-foreground">Terminal</span>
<span className="text-xs font-mono text-muted-foreground">{t('widgets.terminal.title')}</span>
{description && (
<>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
@@ -45,7 +47,7 @@ export const BashWidget: React.FC<BashWidgetProps> = ({ command, description, re
{!result && (
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse" />
<span>Running...</span>
<span>{t('widgets.common.running')}</span>
</div>
)}
</div>
@@ -62,7 +64,7 @@ export const BashWidget: React.FC<BashWidgetProps> = ({ command, description, re
? "border-red-500/20 bg-red-500/5 text-red-400"
: "border-green-500/20 bg-green-500/5 text-green-300"
)}>
{resultContent || (isError ? "Command failed" : "Command completed")}
{resultContent || (isError ? t('widgets.bash.commandFailed') : t('widgets.bash.commandCompleted'))}
</div>
)}
</div>

View File

@@ -3,7 +3,7 @@ import { useTranslation } from '@/hooks/useTranslation';
export interface Tab {
id: string;
type: 'chat' | 'agent' | 'projects' | 'usage' | 'mcp' | 'settings' | 'claude-md' | 'claude-file' | 'agent-execution' | 'create-agent' | 'import-agent';
type: 'chat' | 'agent' | 'projects' | 'usage' | 'mcp' | 'settings' | 'agent-execution' | 'create-agent' | 'import-agent';
title: string;
sessionId?: string; // for chat tabs
sessionData?: any; // for chat tabs - stores full session object
@@ -42,9 +42,13 @@ export const TabProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [tabs, setTabs] = useState<Tab[]>([]);
const [activeTabId, setActiveTabId] = useState<string | null>(null);
// Always start with a fresh CC Projects tab
// Ensure there is always at least one projects tab, but avoid clobbering existing tabs
useEffect(() => {
// Create default projects tab
if (tabs.length > 0) {
return;
}
const defaultTab: Tab = {
id: generateTabId(),
type: 'projects',
@@ -52,12 +56,44 @@ export const TabProvider: React.FC<{ children: React.ReactNode }> = ({ children
status: 'idle',
hasUnsavedChanges: false,
order: 0,
icon: 'folder',
createdAt: new Date(),
updatedAt: new Date()
};
setTabs([defaultTab]);
setActiveTabId(defaultTab.id);
}, [t]);
}, [tabs.length, t]);
// Keep the projects tab title in sync with the active locale without resetting other tabs
useEffect(() => {
if (tabs.length === 0) {
return;
}
const translatedTitle = t('ccProjects');
setTabs(prevTabs => {
let hasChanges = false;
const updatedTabs = prevTabs.map(tab => {
if (tab.type === 'projects' && tab.title !== translatedTitle) {
hasChanges = true;
return {
...tab,
title: translatedTitle
};
}
return tab;
});
return hasChanges ? updatedTabs : prevTabs;
});
}, [t, tabs.length]);
// Guard against an empty activeTabId when tabs exist
useEffect(() => {
if (!activeTabId && tabs.length > 0) {
setActiveTabId(tabs[0].id);
}
}, [activeTabId, tabs]);
// Tab persistence disabled - no longer saving to localStorage
// useEffect(() => {
@@ -90,6 +126,7 @@ export const TabProvider: React.FC<{ children: React.ReactNode }> = ({ children
setTabs(prevTabs => [...prevTabs, newTab]);
setActiveTabId(newTab.id);
return newTab.id;
}, [tabs.length, t]);
@@ -127,10 +164,11 @@ export const TabProvider: React.FC<{ children: React.ReactNode }> = ({ children
}, []);
const setActiveTab = useCallback((id: string) => {
if (tabs.find(tab => tab.id === id)) {
const tabExists = tabs.find(tab => tab.id === id);
if (tabExists) {
setActiveTabId(id);
}
}, [tabs]);
}, [tabs, activeTabId]);
const reorderTabs = useCallback((startIndex: number, endIndex: number) => {
setTabs(prevTabs => {

View File

@@ -10,8 +10,6 @@ const TAB_SCREEN_NAMES: Record<string, string> = {
'usage': 'usage_dashboard',
'mcp': 'mcp_manager',
'settings': 'settings',
'claude-md': 'markdown_editor',
'claude-file': 'file_editor',
'agent-execution': 'agent_execution',
'create-agent': 'create_agent',
'import-agent': 'import_agent',

View File

@@ -20,8 +20,7 @@ interface UseTabStateReturn {
createUsageTab: () => string | null;
createMCPTab: () => string | null;
createSettingsTab: () => string | null;
createClaudeMdTab: () => string | null;
createClaudeFileTab: (fileId: string, fileName: string) => string;
// Removed: createClaudeFileTab
createCreateAgentTab: () => string;
createImportAgentTab: () => string;
closeTab: (id: string, force?: boolean) => Promise<boolean>;
@@ -82,7 +81,7 @@ export const useTabState = (): UseTabStateReturn => {
return existingTab.id;
}
return addTab({
const newTabId = addTab({
type: 'agent',
title: agentName,
agentRunId,
@@ -90,6 +89,7 @@ export const useTabState = (): UseTabStateReturn => {
hasUnsavedChanges: false,
icon: 'bot'
});
return newTabId;
}, [addTab, tabs, setActiveTab]);
const createProjectsTab = useCallback((): string | null => {
@@ -160,40 +160,9 @@ export const useTabState = (): UseTabStateReturn => {
});
}, [addTab, tabs, setActiveTab, t]);
const createClaudeMdTab = useCallback((): string | null => {
// Check if claude-md tab already exists (singleton)
const existingTab = tabs.find(tab => tab.type === 'claude-md');
if (existingTab) {
setActiveTab(existingTab.id);
return existingTab.id;
}
// Removed createClaudeMdTab: using Prompt Files manager instead
return addTab({
type: 'claude-md',
title: t('messages.claudemdTitle'),
status: 'idle',
hasUnsavedChanges: false,
icon: 'file-text'
});
}, [addTab, tabs, setActiveTab, t]);
const createClaudeFileTab = useCallback((fileId: string, fileName: string): string => {
// Check if tab already exists for this file
const existingTab = tabs.find(tab => tab.type === 'claude-file' && tab.claudeFileId === fileId);
if (existingTab) {
setActiveTab(existingTab.id);
return existingTab.id;
}
return addTab({
type: 'claude-file',
title: fileName,
claudeFileId: fileId,
status: 'idle',
hasUnsavedChanges: false,
icon: 'file-text'
});
}, [addTab, tabs, setActiveTab]);
// Removed: project-level CLAUDE.md file tab creation
const createAgentExecutionTab = useCallback((agent: any, _tabId: string): string => {
return addTab({
@@ -327,8 +296,6 @@ export const useTabState = (): UseTabStateReturn => {
createUsageTab,
createMCPTab,
createSettingsTab,
createClaudeMdTab,
createClaudeFileTab,
createCreateAgentTab,
createImportAgentTab,
closeTab,
@@ -346,4 +313,4 @@ export const useTabState = (): UseTabStateReturn => {
findTabByType,
canAddTab
};
};
};

View File

@@ -313,7 +313,7 @@ export interface MemoryWarningProperties {
// User Journey properties
export interface UserJourneyProperties {
journey_stage: 'onboarding' | 'first_chat' | 'first_agent' | 'power_user';
journey_stage: 'onboarding' | 'first_chat' | 'first_agent' | 'power_user' | 'smart_session';
milestone_reached?: string;
time_to_milestone_ms?: number;
}

View File

@@ -29,6 +29,8 @@ export interface Project {
sessions: string[];
/** Unix timestamp when the project directory was created */
created_at: number;
/** Unix timestamp of the most recent session (last modified time of newest JSONL file) */
last_session_time: number;
}
/**
@@ -419,6 +421,62 @@ export interface SlashCommand {
accepts_arguments: boolean;
}
/**
* Represents a prompt file (CLAUDE.md template)
*/
export interface PromptFile {
/** Unique identifier */
id: string;
/** File name */
name: string;
/** Description */
description?: string;
/** Markdown content */
content: string;
/** Tags for categorization */
tags: string[];
/** Whether this is the currently active file */
is_active: boolean;
/** Unix timestamp when created */
created_at: number;
/** Unix timestamp when last updated */
updated_at: number;
/** Unix timestamp when last used */
last_used_at?: number;
/** Display order */
display_order: number;
}
/**
* Request to create a new prompt file
*/
export interface CreatePromptFileRequest {
/** File name */
name: string;
/** Description */
description?: string;
/** Markdown content */
content: string;
/** Tags */
tags: string[];
}
/**
* Request to update an existing prompt file
*/
export interface UpdatePromptFileRequest {
/** File ID */
id: string;
/** File name */
name: string;
/** Description */
description?: string;
/** Markdown content */
content: string;
/** Tags */
tags: string[];
}
/**
* Result of adding a server
*/
@@ -457,6 +515,7 @@ export type RelayStationAdapter =
| 'glm' // 智谱GLM
| 'qwen' // 千问Qwen
| 'kimi' // Kimi k2
| 'minimax' // MiniMax M2
| 'custom'; // 自定义简单配置
/** 认证方式 */
@@ -771,6 +830,40 @@ export const api = {
}
},
/**
* Reads the Claude settings backup file
* @returns Promise resolving to the backup settings object
*/
async getClaudeSettingsBackup(): Promise<ClaudeSettings> {
try {
const result = await invoke<{ data: ClaudeSettings }>("get_claude_settings_backup");
console.log("Raw result from get_claude_settings_backup:", result);
if (result && typeof result === 'object' && 'data' in result) {
return result.data;
}
return result as ClaudeSettings;
} catch (error) {
console.error("Failed to get Claude settings backup:", error);
throw error;
}
},
/**
* Saves the Claude settings backup file
* @param settings - The backup settings object to save
* @returns Promise resolving when the backup settings are saved
*/
async saveClaudeSettingsBackup(settings: ClaudeSettings): Promise<string> {
try {
return await invoke<string>("save_claude_settings_backup", { settings });
} catch (error) {
console.error("Failed to save Claude settings backup:", error);
throw error;
}
},
/**
* Finds all CLAUDE.md files in a project directory
* @param projectPath - The absolute path to the project
@@ -2119,6 +2212,150 @@ export const api = {
}
},
// ================================
// Prompt Files Management
// ================================
/**
* Lists all prompt files
*/
async promptFilesList(): Promise<PromptFile[]> {
try {
return await invoke<PromptFile[]>("prompt_files_list");
} catch (error) {
console.error("Failed to list prompt files:", error);
throw error;
}
},
/**
* Gets a single prompt file by ID
*/
async promptFileGet(id: string): Promise<PromptFile> {
try {
return await invoke<PromptFile>("prompt_file_get", { id });
} catch (error) {
console.error("Failed to get prompt file:", error);
throw error;
}
},
/**
* Creates a new prompt file
*/
async promptFileCreate(request: CreatePromptFileRequest): Promise<PromptFile> {
try {
return await invoke<PromptFile>("prompt_file_create", { request });
} catch (error) {
console.error("Failed to create prompt file:", error);
throw error;
}
},
/**
* Updates an existing prompt file
*/
async promptFileUpdate(request: UpdatePromptFileRequest): Promise<PromptFile> {
try {
return await invoke<PromptFile>("prompt_file_update", { request });
} catch (error) {
console.error("Failed to update prompt file:", error);
throw error;
}
},
/**
* Deletes a prompt file
*/
async promptFileDelete(id: string): Promise<void> {
try {
await invoke<void>("prompt_file_delete", { id });
} catch (error) {
console.error("Failed to delete prompt file:", error);
throw error;
}
},
/**
* Applies a prompt file (replaces local CLAUDE.md)
*/
async promptFileApply(id: string, targetPath?: string): Promise<string> {
try {
return await invoke<string>("prompt_file_apply", { id, targetPath });
} catch (error) {
console.error("Failed to apply prompt file:", error);
throw error;
}
},
/**
* Deactivates all prompt files
*/
async promptFileDeactivate(): Promise<void> {
try {
await invoke<void>("prompt_file_deactivate");
} catch (error) {
console.error("Failed to deactivate prompt files:", error);
throw error;
}
},
/**
* Imports a prompt file from CLAUDE.md
*/
async promptFileImportFromClaudeMd(
name: string,
description?: string,
sourcePath?: string
): Promise<PromptFile> {
try {
return await invoke<PromptFile>("prompt_file_import_from_claude_md", {
name,
description,
sourcePath,
});
} catch (error) {
console.error("Failed to import from CLAUDE.md:", error);
throw error;
}
},
/**
* Exports a prompt file
*/
async promptFileExport(id: string, exportPath: string): Promise<void> {
try {
await invoke<void>("prompt_file_export", { id, exportPath });
} catch (error) {
console.error("Failed to export prompt file:", error);
throw error;
}
},
/**
* Updates the display order of prompt files
*/
async promptFilesUpdateOrder(ids: string[]): Promise<void> {
try {
await invoke<void>("prompt_files_update_order", { ids });
} catch (error) {
console.error("Failed to update prompt files order:", error);
throw error;
}
},
/**
* Batch imports prompt files
*/
async promptFilesImportBatch(files: CreatePromptFileRequest[]): Promise<PromptFile[]> {
try {
return await invoke<PromptFile[]>("prompt_files_import_batch", { files });
} catch (error) {
console.error("Failed to batch import prompt files:", error);
throw error;
}
},
// ================================
// Language Settings
// ================================
@@ -2478,6 +2715,21 @@ export const api = {
}
},
/**
* Updates the display order of relay stations
* @author yovinchen
* @param stationIds - Array of station IDs in the new order
* @returns Promise resolving when order is updated
*/
async relayStationUpdateOrder(stationIds: string[]): Promise<void> {
try {
return await invoke<void>("relay_station_update_order", { stationIds });
} catch (error) {
console.error("Failed to update relay station order:", error);
throw error;
}
},
// ============= PackyCode Nodes =============
/**
@@ -2698,6 +2950,122 @@ export const api = {
console.error("Failed to cleanup terminal sessions:", error);
throw error;
}
},
/**
* Get all model mappings
* @author yovinchen
*/
async getModelMappings(): Promise<ModelMapping[]> {
try {
return await invoke<ModelMapping[]>("get_model_mappings");
} catch (error) {
console.error("Failed to get model mappings:", error);
throw error;
}
},
/**
* Update a model mapping
* @author yovinchen
*/
async updateModelMapping(alias: string, modelName: string): Promise<void> {
try {
await invoke("update_model_mapping", { alias, modelName });
} catch (error) {
console.error("Failed to update model mapping:", error);
throw error;
}
},
// ============= Smart Sessions Management =============
/**
* 创建智能快速开始会话
* @author yovinchen
* @param sessionName - 可选的会话名称
* @returns Promise resolving to smart session result
*/
async createSmartQuickStartSession(sessionName?: string): Promise<SmartSessionResult> {
try {
return await invoke<SmartSessionResult>("create_smart_quick_start_session", { sessionName });
} catch (error) {
console.error("Failed to create smart quick start session:", error);
throw error;
}
},
/**
* 获取智能会话配置
* @author yovinchen
* @returns Promise resolving to smart session configuration
*/
async getSmartSessionConfig(): Promise<SmartSessionConfig> {
try {
return await invoke<SmartSessionConfig>("get_smart_session_config");
} catch (error) {
console.error("Failed to get smart session config:", error);
throw error;
}
},
/**
* 更新智能会话配置
* @author yovinchen
* @param config - 智能会话配置
* @returns Promise resolving when configuration is updated
*/
async updateSmartSessionConfig(config: SmartSessionConfig): Promise<void> {
try {
await invoke<void>("update_smart_session_config", { config });
} catch (error) {
console.error("Failed to update smart session config:", error);
throw error;
}
},
/**
* 列出所有智能会话
* @author yovinchen
* @returns Promise resolving to array of smart sessions
*/
async listSmartSessions(): Promise<SmartSession[]> {
try {
return await invoke<SmartSession[]>("list_smart_sessions_command");
} catch (error) {
console.error("Failed to list smart sessions:", error);
throw error;
}
},
/**
* 切换智能会话模式
* @author yovinchen
* @param enabled - 是否启用智能会话
* @returns Promise resolving when mode is toggled
*/
async toggleSmartSessionMode(enabled: boolean): Promise<void> {
try {
await invoke<void>("toggle_smart_session_mode", { enabled });
} catch (error) {
console.error("Failed to toggle smart session mode:", error);
throw error;
}
},
/**
* 清理过期的智能会话
* @author yovinchen
* @param days - 清理多少天前的会话
* @returns Promise resolving to number of cleaned sessions
*/
async cleanupOldSmartSessions(days: number): Promise<number> {
try {
return await invoke<number>("cleanup_old_smart_sessions_command", { days });
} catch (error) {
console.error("Failed to cleanup old smart sessions:", error);
throw error;
}
}
};
@@ -2815,3 +3183,165 @@ export const ccrApi = {
}
}
};
/**
* Model mapping structure
* @author yovinchen
*/
export interface ModelMapping {
alias: string;
model_name: string;
updated_at: string;
}
// ============= Smart Sessions Types =============
/** 智能会话结果 */
export interface SmartSessionResult {
/** 会话ID */
session_id: string;
/** 项目路径 */
project_path: string;
/** 显示名称 */
display_name: string;
/** 创建时间 */
created_at: string;
/** 会话类型 */
session_type: string;
}
/** 智能会话配置 */
export interface SmartSessionConfig {
/** 是否启用智能会话 */
enabled: boolean;
/** 基础目录 */
base_directory: string;
/** 命名模式 */
naming_pattern: string;
/** 是否启用自动清理 */
auto_cleanup_enabled: boolean;
/** 自动清理天数 */
auto_cleanup_days: number;
/** 模板文件 */
template_files: TemplateFile[];
}
/** 模板文件定义 */
export interface TemplateFile {
/** 文件路径 */
path: string;
/** 文件内容 */
content: string;
/** 是否可执行 */
executable: boolean;
}
/** 智能会话记录 */
export interface SmartSession {
/** 会话ID */
id: string;
/** 显示名称 */
display_name: string;
/** 项目路径 */
project_path: string;
/** 创建时间 */
created_at: string;
/** 最后访问时间 */
last_accessed: string;
/** 会话类型 */
session_type: string;
}
// ============================================================
// API Nodes Management
// ============================================================
export interface ApiNode {
id: string;
name: string;
url: string;
adapter: RelayStationAdapter;
description?: string;
enabled: boolean;
is_default: boolean;
created_at: string;
updated_at: string;
}
export interface CreateApiNodeRequest {
name: string;
url: string;
adapter: RelayStationAdapter;
description?: string;
}
export interface UpdateApiNodeRequest {
name?: string;
url?: string;
description?: string;
enabled?: boolean;
}
export interface NodeTestResult {
node_id: string;
url: string;
name: string;
response_time: number | null;
status: 'testing' | 'success' | 'failed';
error?: string;
}
/**
* 初始化预设节点
*/
export async function initDefaultNodes(): Promise<void> {
return invoke('init_default_nodes');
}
/**
* 获取节点列表
*/
export async function listApiNodes(
adapter?: RelayStationAdapter,
enabledOnly?: boolean
): Promise<ApiNode[]> {
return invoke('list_api_nodes', { adapter, enabledOnly });
}
/**
* 创建节点
*/
export async function createApiNode(request: CreateApiNodeRequest): Promise<ApiNode> {
return invoke('create_api_node', { request });
}
/**
* 更新节点
*/
export async function updateApiNode(id: string, request: UpdateApiNodeRequest): Promise<ApiNode> {
return invoke('update_api_node', { id, request });
}
/**
* 删除节点(预设节点不可删除)
*/
export async function deleteApiNode(id: string): Promise<void> {
return invoke('delete_api_node', { id });
}
/**
* 测试单个节点
*/
export async function testApiNode(url: string, timeoutMs?: number): Promise<NodeTestResult> {
return invoke('test_api_node', { url, timeoutMs });
}
/**
* 批量测试节点
*/
export async function testAllApiNodes(
adapter?: RelayStationAdapter,
timeoutMs?: number
): Promise<NodeTestResult[]> {
return invoke('test_all_api_nodes', { adapter, timeoutMs });
}

View File

@@ -16,14 +16,15 @@ const languageDetectorOptions = {
checkWhitelist: true,
};
const i18nDebug = (typeof import.meta !== 'undefined' && (import.meta as any).env?.VITE_I18N_DEBUG === 'true');
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
// 回退语言
fallbackLng: 'en',
// 调试模式(开发环境)
debug: process.env.NODE_ENV === 'development',
debug: i18nDebug,
// 语言资源
resources: {

View File

@@ -12,6 +12,7 @@
"name": "Claudia",
"welcome": "Welcome to Claudia",
"tagline": "Powerful Claude Code session management tool",
"returnHome": "Return to Home",
"loading": "Loading...",
"error": "Error",
"success": "Success",
@@ -99,8 +100,22 @@
"justNow": "just now",
"minutesAgo": "{{count}} minute{{plural}} ago",
"hoursAgo": "{{count}} hour{{plural}} ago",
"daysAgo": "{{count}} day{{plural}} ago"
"daysAgo": "{{count}} day{{plural}} ago",
"loadingOutput": "Loading output...",
"waitingForOutput": "Waiting for output...",
"noOutput": "No output available",
"agentRunningNoOutput": "Agent is running but no output received yet",
"failedToLoadSessionOutput": "Failed to load session output",
"outputRefreshed": "Output refreshed",
"fileABug": "File a bug",
"failedToRefreshOutput": "Failed to refresh output",
"loadingAgentRun": "Loading agent run...",
"agentExecutionCompleted": "Agent execution completed",
"agentExecutionCancelled": "Agent execution was cancelled",
"selectProjectFirst": "Please select a project directory first",
"selectDirectoryFailed": "Failed to select directory: {{message}}"
},
"claudeSession": {},
"navigation": {
"projects": "CC Projects",
"agents": "Agent Management",
@@ -108,6 +123,7 @@
"usage": "Usage Dashboard",
"mcp": "MCP Manager",
"relayStations": "Relay Stations",
"promptFiles": "CLAUDE.md",
"about": "About"
},
"welcome": {
@@ -123,9 +139,14 @@
"mcpBrokerDesc": "Manage MCP servers",
"claudeMd": "CLAUDE.md",
"claudeMdDesc": "Edit Claude configuration files",
"promptFiles": "CLAUDE.md",
"promptFilesDesc": "Manage and switch CLAUDE.md files",
"settings": "Settings",
"settingsDesc": "App settings and configuration",
"quickStartSession": "Quick Start New Session",
"smartQuickStart": "Smart Quick Start",
"choosePathStart": "Choose Path & Start",
"creatingSmartSession": "Creating smart session...",
"ccrRouter": "CCR Router",
"ccrRouterDesc": "Claude Code Router configuration management"
},
@@ -138,7 +159,27 @@
"sessions": "Sessions",
"noSessions": "No sessions found",
"lastModified": "Last Modified",
"sessionHistory": "Session History"
"sessionHistory": "Session History",
"sessionCount": "{{count}} sessions"
},
"sessions": {
"firstMessage": "First message:",
"hasTodo": "Has todo"
},
"runningSessions": {
"title": "Active Claude Sessions",
"countRunning": "({{count}} running)",
"running": "Running",
"resume": "Resume",
"loadFailed": "Failed to load running sessions"
},
"widgets": {
"terminal": { "title": "Terminal" },
"common": { "running": "Running..." },
"bash": {
"commandFailed": "Command failed",
"commandCompleted": "Command completed"
}
},
"agents": {
"title": "Agent Management",
@@ -153,6 +194,11 @@
"enterTask": "Enter the task for the agent",
"hooks": "Hooks",
"configureHooks": "Configure Hooks",
"configureHooksDesc": "Configure hooks that run before, during, and after tool executions. Changes are saved immediately.",
"projectSettings": "Project Settings",
"localSettings": "Local Settings",
"initializing": "Initializing agent...",
"exportToFile": "Export agent to .claudia.json",
"fullscreen": "Fullscreen",
"stop": "Stop",
"agentName": "Agent Name",
@@ -274,6 +320,54 @@
"createFirstProjectCommand": "Create your first project command",
"createFirstCommand": "Create your first command"
},
"promptFiles": {
"title": "CLAUDE.md",
"description": "Manage and switch CLAUDE.md files",
"create": "New",
"createFile": "Create Prompt File",
"editFile": "Edit Prompt File",
"deleteFile": "Delete Prompt File",
"fileName": "File Name",
"fileDescription": "Description",
"fileContent": "File Content",
"tags": "Tags",
"addTag": "Add Tag",
"currentActive": "Currently Active",
"allFiles": "All Prompt Files",
"noFiles": "No prompt files yet",
"noFilesDesc": "Create your first prompt file template",
"createFirst": "Create First Prompt File",
"useFile": "Use This File",
"viewContent": "View Content",
"deactivate": "Deactivate",
"applySuccess": "Applied to",
"applyFailed": "Apply Failed",
"createSuccess": "Created Successfully",
"createFailed": "Create Failed",
"updateSuccess": "Updated Successfully",
"updateFailed": "Update Failed",
"deleteSuccess": "Deleted Successfully",
"deleteFailed": "Delete Failed",
"deleteConfirm": "Are you sure you want to delete this prompt file? This action cannot be undone.",
"importFromClaudeMd": "Import from CLAUDE.md",
"importFromClaudeMdDesc": "Import the current project's CLAUDE.md file as a prompt template",
"importSuccess": "Imported Successfully",
"importFailed": "Import Failed",
"nameRequired": "File name is required",
"contentRequired": "File content is required",
"preview": "Preview",
"edit": "Edit",
"inUse": "In Use",
"applyToCustomPath": "Apply to Custom Path",
"applyToCustomPathFailed": "Apply to Custom Path Failed",
"lastUsed": "Last Used",
"createdAt": "Created At",
"updatedAt": "Updated At",
"syncToClaudeDir": "Sync to .claude",
"syncing": "Syncing...",
"syncSuccess": "Synced to {{path}}",
"syncFailed": "Sync Failed"
},
"hooks": {
"hooksConfiguration": "Hooks Configuration",
"configureShellCommands": "Configure shell commands to execute at various points in Claude Code's lifecycle.",
@@ -317,7 +411,13 @@
"condition": "Condition",
"matchesRegex": "Matches regex",
"message": "Message",
"enterShellCommand": "Enter shell command..."
"enterShellCommand": "Enter shell command...",
"projectHooks": "Project Hooks",
"localHooks": "Local Hooks"
},
"projectSettings": {
"gitignoreLocalWarning": "Local settings file is not in .gitignore",
"addToGitignore": "Add to .gitignore"
},
"settings": {
"title": "Settings",
@@ -487,8 +587,43 @@
"allowRuleExample": "e.g., Bash(npm run test:*)",
"denyRuleExample": "e.g., Bash(curl:*)",
"apiKeyHelperPath": "/path/to/generate_api_key.sh"
},
"modelMappings": {
"title": "Model alias mappings",
"description": "Configure actual model versions for aliases (sonnet, opus, haiku)",
"loadFailed": "Failed to load model mappings",
"saved": "Model mappings saved",
"saveFailed": "Failed to save model mappings",
"emptyTitle": "No model mappings configured",
"emptySubtitle": "Database may not be initialized yet. Try restarting the app.",
"changedNotice": "Model mappings changed. Click Save to apply.",
"note": "Note:",
"noteContent": "Agents using aliases will resolve to configured versions. For example, sonnet → claude-sonnet-4-20250514.",
"aliasDescriptions": {
"sonnet": "Balanced model for most tasks",
"opus": "Most capable flagship model for complex tasks",
"haiku": "Fast, lightweight model"
}
}
},
"ccr": {
"loadStatusFailed": "Failed to load CCR service status: {{error}}",
"startFailed": "Failed to start CCR service: {{error}}",
"stopFailed": "Failed to stop CCR service: {{error}}",
"restartFailed": "Failed to restart CCR service: {{error}}",
"serviceStarting": "Service not running, starting...",
"serviceStartFailed": "Service failed to start",
"openingUI": "Opening CCR UI...",
"openUIFailed": "Failed to open CCR UI: {{error}}",
"openingAdmin": "Opening CCR admin...",
"openAdminFailed": "Failed to open admin UI: {{error}}"
},
"claudeSession": {
"scrollToTop": "Scroll to top",
"scrollToBottom": "Scroll to bottom",
"startFileWatch": "Start file watching",
"stopFileWatch": "Stop file watching"
},
"mcp": {
"title": "MCP Server Management",
"servers": "Servers",
@@ -616,7 +751,6 @@
"hourlyUsageToday": "24-Hour Usage Pattern",
"last24HoursPattern": "Last 24 Hours Usage Pattern",
"noUsageData": "No usage data available for the selected period",
"totalProjects": "Total Projects",
"avgProjectCost": "Average Project Cost",
"topProjectCost": "Highest Project Cost",
"projectCostDistribution": "Project Cost Distribution",
@@ -718,6 +852,11 @@
"claudeCodeNotFound": "Claude Code not found",
"selectClaudeInstallation": "Select Claude Installation",
"installClaudeCode": "Install Claude Code",
"smartSessionCreated": "Smart session '{{name}}' is ready to use.",
"smartSessionDefaultToast": "Smart session '{{name}}' is ready to use.",
"smartSessionDefaultTitle": "Smart Session",
"failedToCreateSmartSession": "Failed to create smart session: {{error}}",
"failedToCreateSmartSessionFallback": "Failed to create smart session: {{error}}",
"noTabsOpen": "No tabs open",
"clickPlusToStartChat": "Click the + button to start a new chat",
"noAgentRunIdSpecified": "No agent run ID specified",
@@ -762,6 +901,18 @@
"checkpointSettingsTitle": "Checkpoint Settings",
"experimentalFeature": "Experimental Feature",
"checkpointWarning": "Checkpointing may affect directory structure or cause data loss. Use with caution.",
"createCheckpointDesc": "Save the current state of your session with an optional description.",
"descriptionOptional": "Description (optional)",
"descriptionPlaceholder": "e.g., Before major refactoring",
"current": "Current",
"restoreToThis": "Restore to this checkpoint",
"forkFromThis": "Fork from this checkpoint",
"compareWithAnother": "Compare with another checkpoint",
"checkpointComparison": "Checkpoint Comparison",
"modifiedFiles": "Modified Files",
"addedFiles": "Added Files",
"deletedFiles": "Deleted Files",
"noPrompt": "No prompt",
"automaticCheckpoints": "Automatic Checkpoints",
"automaticCheckpointsDesc": "Automatically create checkpoints based on the selected strategy",
"checkpointStrategy": "Checkpoint Strategy",
@@ -785,6 +936,12 @@
"cleanupCheckpointsFailed": "Failed to cleanup checkpoints",
"removedOldCheckpoints": "Removed {{count}} old checkpoints"
},
"searchProjects": "Search projects...",
"showingResults": "Showing results",
"totalProjects": "Total projects",
"noSearchResults": "No search results",
"noProjectsMatchSearch": "No projects match your search for",
"clearSearch": "Clear search",
"placeholders": {
"searchProjects": "Search projects...",
"searchAgents": "Search agents...",
@@ -935,7 +1092,10 @@
"importSuccess": "Success",
"importSkipped": "Skipped (duplicate)",
"importFailed": "Failed",
"allDuplicate": "All configurations already exist, nothing imported"
"allDuplicate": "All configurations already exist, nothing imported",
"customJson": "Custom JSON Configuration",
"customJsonOptional": "Optional",
"customJsonNote": "Enter JSON to merge into adapter_config; clear input to remove all custom config"
},
"status": {
"connected": "Connected",
@@ -962,4 +1122,60 @@
"warning": {
"title": "Warning"
}
,
"agentRun": {
"runNotFound": "Run not found",
"loadFailed": "Failed to load execution details",
"executionStopped": "Execution stopped by user",
"live": "Live",
"stopFailed": "Failed to stop agent - it may have already finished"
},
"tabs": {
"scrollLeft": "Scroll tabs left",
"scrollRight": "Scroll tabs right",
"browseProjectsShortcut": "Browse projects (Ctrl+T)",
"maximumTabsReached": "Maximum tabs reached ({{count}}/20)"
},
"storageTab": {
"title": "Database Storage",
"sqlQuery": "SQL Query",
"resetDbShort": "Reset DB",
"selectTable": "Select a table",
"rows": "rows",
"searchInTable": "Search in table...",
"newRow": "New Row",
"actions": "Actions",
"pagination": {
"showing": "Showing {{from}} to {{to}} of {{total}} rows",
"pageOf": "Page {{page}} of {{total}}"
},
"editRow": "Edit Row",
"editRowDesc": "Update the values for this row in the {{table}} table.",
"primaryKey": "Primary Key",
"type": "Type",
"notNull": "NOT NULL",
"default": "Default",
"insert": "Insert",
"newRowDesc": "Add a new row to the {{table}} table.",
"deleteRow": "Delete Row",
"deleteRowConfirm": "Are you sure you want to delete this row? This action cannot be undone.",
"resetDatabaseTitle": "Reset Database",
"resetDatabaseDesc": "This will delete all data and recreate the database with its default structure (empty tables for agents, agent_runs, and app_settings). The database will be restored to the same state as when you first installed the app. This action cannot be undone.",
"resetWarning": "All your agents, runs, and settings will be permanently deleted!",
"sqlEditorTitle": "SQL Query Editor",
"sqlEditorDesc": "Execute raw SQL queries on the database. Use with caution.",
"sqlQueryPlaceholder": "SELECT * FROM agents LIMIT 10;",
"queryExecuted": "Query executed successfully.",
"rowsAffected": "rows affected.",
"lastInsertId": "Last insert ID",
"loadTablesFailed": "Failed to load tables",
"loadTableDataFailed": "Failed to load table data",
"updateRowFailed": "Failed to update row",
"deleteRowFailed": "Failed to delete row",
"insertRowFailed": "Failed to insert row",
"executeSqlFailed": "Failed to execute SQL",
"resetDatabaseFailed": "Failed to reset database",
"resetFailed": "Reset failed: please try again.",
"resetSuccess": "Database has been reset to its default state with empty tables (agents, agent_runs, app_settings)."
}
}

View File

@@ -12,6 +12,7 @@
"name": "Claudia",
"welcome": "欢迎使用 Claudia",
"tagline": "强大的 Claude Code 会话管理工具",
"returnHome": "返回首页",
"loading": "加载中...",
"error": "错误",
"success": "成功",
@@ -96,7 +97,19 @@
"justNow": "刚刚",
"minutesAgo": "{{count}} 分钟前",
"hoursAgo": "{{count}} 小时前",
"daysAgo": "{{count}} 天前"
"daysAgo": "{{count}} 天前",
"loadingOutput": "加载输出中...",
"waitingForOutput": "等待输出...",
"noOutput": "暂无输出",
"agentRunningNoOutput": "智能体正在运行,但尚未收到输出",
"failedToLoadSessionOutput": "加载会话输出失败",
"outputRefreshed": "输出已刷新",
"failedToRefreshOutput": "刷新输出失败",
"agentExecutionCompleted": "智能体执行已完成",
"agentExecutionCancelled": "智能体执行已取消",
"loadingAgentRun": "正在加载运行详情...",
"selectProjectFirst": "请先选择项目目录",
"selectDirectoryFailed": "选择目录失败:{{message}}"
},
"navigation": {
"projects": "Claude Code 项目",
@@ -105,6 +118,7 @@
"usage": "用量仪表板",
"mcp": "MCP 管理器",
"relayStations": "中转站",
"promptFiles": "CLAUDE.md",
"about": "关于"
},
"welcome": {
@@ -120,9 +134,14 @@
"mcpBrokerDesc": "管理 MCP 服务器",
"claudeMd": "CLAUDE.md",
"claudeMdDesc": "编辑 Claude 配置文件",
"promptFiles": "CLAUDE.md",
"promptFilesDesc": "管理和切换 CLAUDE.md 文件",
"settings": "设置",
"settingsDesc": "应用设置和配置",
"quickStartSession": "快速开始新会话",
"smartQuickStart": "智能快速开始",
"choosePathStart": "选择路径开始",
"creatingSmartSession": "正在创建智能会话...",
"ccrRouter": "CCR 路由",
"ccrRouterDesc": "Claude Code Router 配置管理"
},
@@ -135,7 +154,27 @@
"sessions": "会话",
"noSessions": "未找到会话",
"lastModified": "最近修改",
"sessionHistory": "会话历史"
"sessionHistory": "会话历史",
"sessionCount": "共 {{count}} 个会话"
},
"sessions": {
"firstMessage": "首条消息:",
"hasTodo": "包含待办"
},
"runningSessions": {
"title": "运行中的 Claude 会话",
"countRunning": "{{count}} 个运行中)",
"running": "运行中",
"resume": "恢复",
"loadFailed": "加载运行中的会话失败"
},
"widgets": {
"terminal": { "title": "终端" },
"common": { "running": "运行中..." },
"bash": {
"commandFailed": "命令执行失败",
"commandCompleted": "命令执行完成"
}
},
"agents": {
"title": "Agent 管理",
@@ -202,6 +241,11 @@
"enterTask": "输入智能体的任务",
"hooks": "钩子",
"configureHooks": "配置钩子",
"configureHooksDesc": "配置在工具执行前、中、后运行的钩子。更改将立即保存。",
"projectSettings": "项目设置",
"localSettings": "本地设置",
"initializing": "正在初始化智能体...",
"exportToFile": "导出为 .claudia.json",
"fullscreen": "全屏",
"stop": "停止",
"selectProjectPathAndTask": "选择项目路径并输入任务以运行智能体",
@@ -263,6 +307,54 @@
"createFirstProjectCommand": "创建您的第一个项目命令",
"createFirstCommand": "创建您的第一个命令"
},
"promptFiles": {
"title": "CLAUDE.md",
"description": "管理和切换 CLAUDE.md 文件",
"create": "新建",
"createFile": "创建提示词文件",
"editFile": "编辑提示词文件",
"deleteFile": "删除提示词文件",
"fileName": "文件名称",
"fileDescription": "描述",
"fileContent": "文件内容",
"tags": "标签",
"addTag": "添加标签",
"currentActive": "当前使用",
"allFiles": "全部提示词文件",
"noFiles": "还没有提示词文件",
"noFilesDesc": "创建第一个提示词文件模板",
"createFirst": "创建第一个提示词文件",
"useFile": "使用此文件",
"viewContent": "查看内容",
"deactivate": "取消使用",
"applySuccess": "已应用到",
"applyFailed": "应用失败",
"createSuccess": "创建成功",
"createFailed": "创建失败",
"updateSuccess": "更新成功",
"updateFailed": "更新失败",
"deleteSuccess": "删除成功",
"deleteFailed": "删除失败",
"deleteConfirm": "确定要删除这个提示词文件吗?此操作无法撤销。",
"importFromClaudeMd": "从 CLAUDE.md 导入",
"importFromClaudeMdDesc": "导入当前项目的 CLAUDE.md 文件作为提示词模板",
"importSuccess": "导入成功",
"importFailed": "导入失败",
"nameRequired": "文件名称不能为空",
"contentRequired": "文件内容不能为空",
"preview": "预览",
"edit": "编辑",
"inUse": "使用中",
"lastUsed": "最后使用",
"createdAt": "创建于",
"updatedAt": "更新于",
"syncToClaudeDir": "同步到 .claude",
"syncing": "同步中...",
"syncSuccess": "已同步到 {{path}}",
"syncFailed": "同步失败",
"applyToCustomPath": "应用到自定义路径",
"applyToCustomPathFailed": "应用到自定义路径失败"
},
"hooks": {
"hooksConfiguration": "钩子配置",
"configureShellCommands": "配置在 Claude Code 生命周期的各个阶段执行的 shell 命令。",
@@ -306,7 +398,13 @@
"condition": "条件",
"matchesRegex": "匹配正则表达式",
"message": "消息",
"enterShellCommand": "输入 shell 命令..."
"enterShellCommand": "输入 shell 命令...",
"projectHooks": "项目钩子",
"localHooks": "本地钩子"
},
"projectSettings": {
"gitignoreLocalWarning": "本地设置文件未添加到 .gitignore",
"addToGitignore": "添加到 .gitignore"
},
"settings": {
"title": "设置",
@@ -469,7 +567,42 @@
"path": "路径",
"source": "来源",
"version": "版本",
"versionUnknown": "版本未知"
"versionUnknown": "版本未知",
"modelMappings": {
"title": "模型别名映射",
"description": "配置别名sonnet、opus、haiku对应的实际模型版本",
"loadFailed": "加载模型映射失败",
"saved": "模型映射已保存",
"saveFailed": "保存模型映射失败",
"emptyTitle": "暂无模型映射配置",
"emptySubtitle": "数据库初始化可能未完成,请尝试重启应用",
"changedNotice": "模型映射已修改,点击保存以应用更改",
"note": "说明:",
"noteContent": "Agent 执行时会根据此配置解析模型别名,例如 sonnet → claude-sonnet-4-20250514。",
"aliasDescriptions": {
"sonnet": "平衡性能与成本的主力模型",
"opus": "最强大的旗舰模型,适合复杂任务",
"haiku": "快速响应的轻量级模型"
}
}
},
"ccr": {
"loadStatusFailed": "加载 CCR 服务状态失败:{{error}}",
"startFailed": "启动 CCR 服务失败:{{error}}",
"stopFailed": "停止 CCR 服务失败:{{error}}",
"restartFailed": "重启 CCR 服务失败:{{error}}",
"serviceStarting": "检测到服务未运行,正在启动...",
"serviceStartFailed": "服务启动失败",
"openingUI": "正在打开 CCR UI...",
"openUIFailed": "打开 CCR UI 失败:{{error}}",
"openingAdmin": "正在打开 CCR 管理界面...",
"openAdminFailed": "打开管理界面失败:{{error}}"
},
"claudeSession": {
"scrollToTop": "滚动到顶部",
"scrollToBottom": "滚动到底部",
"startFileWatch": "启动文件监控",
"stopFileWatch": "停止文件监控"
},
"mcp": {
"title": "MCP 服务器管理",
@@ -597,7 +730,6 @@
"hourlyUsageToday": "24小时使用模式",
"last24HoursPattern": "过去24小时使用模式",
"noUsageData": "选定时期内无用量数据",
"totalProjects": "项目总数",
"avgProjectCost": "平均项目成本",
"topProjectCost": "最高项目成本",
"projectCostDistribution": "项目成本分布",
@@ -663,6 +795,11 @@
"claudeCodeNotFound": "未找到 Claude Code",
"selectClaudeInstallation": "选择 Claude 安装",
"installClaudeCode": "安装 Claude Code",
"smartSessionCreated": "智能会话「{{name}}」已就绪。",
"smartSessionDefaultToast": "智能会话「{{name}}」已就绪。",
"smartSessionDefaultTitle": "智能会话",
"failedToCreateSmartSession": "创建智能会话失败:{{error}}",
"failedToCreateSmartSessionFallback": "创建智能会话失败:{{error}}",
"session": "会话",
"letClaudeDecide": "让 Claude 决定",
"basicReasoning": "基础推理",
@@ -679,6 +816,9 @@
"checkpoint": {
"title": "检查点",
"createCheckpoint": "创建检查点",
"createCheckpointDesc": "保存会话当前状态,并可填写可选说明。",
"descriptionOptional": "描述(可选)",
"descriptionPlaceholder": "例如:大型重构前",
"restoreCheckpoint": "恢复检查点",
"deleteCheckpoint": "删除检查点",
"checkpointName": "检查点名称",
@@ -689,6 +829,15 @@
"checkpointSettingsTitle": "检查点设置",
"experimentalFeature": "实验性功能",
"checkpointWarning": "检查点可能会影响目录结构或导致数据丢失。请谨慎使用。",
"current": "当前",
"restoreToThis": "恢复到此检查点",
"forkFromThis": "从此检查点分叉",
"compareWithAnother": "与另一个检查点比较",
"checkpointComparison": "检查点对比",
"modifiedFiles": "修改的文件",
"addedFiles": "新增的文件",
"deletedFiles": "删除的文件",
"noPrompt": "无提示",
"automaticCheckpoints": "自动检查点",
"automaticCheckpointsDesc": "根据所选策略自动创建检查点",
"checkpointStrategy": "检查点策略",
@@ -712,6 +861,12 @@
"cleanupCheckpointsFailed": "清理检查点失败",
"removedOldCheckpoints": "已删除 {{count}} 个旧检查点"
},
"searchProjects": "搜索项目...",
"showingResults": "显示结果",
"totalProjects": "总项目数",
"noSearchResults": "未找到搜索结果",
"noProjectsMatchSearch": "没有项目匹配搜索",
"clearSearch": "清除搜索",
"placeholders": {
"searchProjects": "搜索项目...",
"searchAgents": "搜索智能体...",
@@ -862,7 +1017,10 @@
"importSuccess": "成功",
"importSkipped": "跳过(重复)",
"importFailed": "失败",
"allDuplicate": "所有配置都已存在,未导入任何新配置"
"allDuplicate": "所有配置都已存在,未导入任何新配置",
"customJson": "自定义JSON配置",
"customJsonOptional": "可选",
"customJsonNote": "输入JSON将合并到adapter_config中清空输入框将清除所有自定义配置"
},
"status": {
"connected": "已连接",
@@ -889,4 +1047,60 @@
"warning": {
"title": "警告"
}
,
"agentRun": {
"runNotFound": "未找到运行记录",
"loadFailed": "加载执行详情失败",
"executionStopped": "已由用户停止执行",
"live": "实时",
"stopFailed": "停止失败,可能已结束"
},
"tabs": {
"scrollLeft": "向左滚动标签",
"scrollRight": "向右滚动标签",
"browseProjectsShortcut": "浏览项目Ctrl+T",
"maximumTabsReached": "已达到最大标签数({{count}}/20"
},
"storageTab": {
"title": "数据库存储",
"sqlQuery": "SQL 查询",
"resetDbShort": "重置库",
"selectTable": "选择数据表",
"rows": "行",
"searchInTable": "在表中搜索...",
"newRow": "新增行",
"actions": "操作",
"pagination": {
"showing": "显示第 {{from}}-{{to}} 条,共 {{total}} 条",
"pageOf": "第 {{page}} / {{total}} 页"
},
"editRow": "编辑行",
"editRowDesc": "更新 {{table}} 表中此行的值。",
"primaryKey": "主键",
"type": "类型",
"notNull": "非空",
"default": "默认值",
"insert": "插入",
"newRowDesc": "向 {{table}} 表添加一行。",
"deleteRow": "删除行",
"deleteRowConfirm": "确定要删除此行吗?该操作无法撤销。",
"resetDatabaseTitle": "重置数据库",
"resetDatabaseDesc": "这将删除所有数据并使用默认结构重新创建数据库agents、agent_runs、app_settings 为空表)。数据库将恢复到首次安装应用时的状态。该操作无法撤销。",
"resetWarning": "您所有的智能体、运行记录和设置都将被永久删除!",
"sqlEditorTitle": "SQL 查询编辑器",
"sqlEditorDesc": "在数据库上执行原始 SQL 语句,请谨慎使用。",
"sqlQueryPlaceholder": "SELECT * FROM agents LIMIT 10;",
"queryExecuted": "查询执行成功。",
"rowsAffected": "行受影响。",
"lastInsertId": "最后插入 ID",
"loadTablesFailed": "加载数据表失败",
"loadTableDataFailed": "加载数据失败",
"updateRowFailed": "更新行失败",
"deleteRowFailed": "删除行失败",
"insertRowFailed": "插入行失败",
"executeSqlFailed": "执行 SQL 失败",
"resetDatabaseFailed": "重置数据库失败",
"resetFailed": "重置失败,请重试。",
"resetSuccess": "数据库已重置为默认状态agents、agent_runs、app_settings 为空表)。"
}
}

View File

@@ -17,6 +17,43 @@ try {
console.error("[Monaco] loader.config failed:", e);
}
// 全局捕获未处理的Promise拒绝防止Monaco Editor错误
window.addEventListener('unhandledrejection', (event) => {
const error = event.reason;
if (error && (error.message || error.toString() || '').toLowerCase().includes('url is not valid')) {
event.preventDefault();
// 不输出任何日志,完全静默
}
});
// 全局捕获window.onerror
window.addEventListener('error', (event) => {
if (event.error && (event.error.message || event.error.toString() || '').toLowerCase().includes('url is not valid')) {
event.preventDefault();
return true;
}
});
// 捕获console.error并过滤Monaco错误
const originalConsoleError = console.error;
console.error = (...args) => {
const message = args.join(' ');
if (message.includes('URL is not valid')) {
return; // 静默过滤
}
originalConsoleError.apply(console, args);
};
// 捕获console.warn并过滤Monaco警告
const originalConsoleWarn = console.warn;
console.warn = (...args) => {
const message = args.join(' ');
if (message.includes('Monaco') && message.includes('URL')) {
return; // 静默过滤
}
originalConsoleWarn.apply(console, args);
};
// Initialize analytics before rendering (will no-op if no consent or no key)
analytics.initialize();

View File

@@ -0,0 +1,202 @@
import { create } from 'zustand';
import { api, type PromptFile, type CreatePromptFileRequest, type UpdatePromptFileRequest } from '@/lib/api';
interface PromptFilesState {
// Data
files: PromptFile[];
activeFile: PromptFile | null;
// UI state
isLoading: boolean;
error: string | null;
// Actions
loadFiles: () => Promise<void>;
getFile: (id: string) => Promise<PromptFile>;
createFile: (request: CreatePromptFileRequest) => Promise<PromptFile>;
updateFile: (request: UpdatePromptFileRequest) => Promise<PromptFile>;
deleteFile: (id: string) => Promise<void>;
applyFile: (id: string, targetPath?: string) => Promise<string>;
deactivateAll: () => Promise<void>;
importFromClaudeMd: (name: string, description?: string, sourcePath?: string) => Promise<PromptFile>;
exportFile: (id: string, exportPath: string) => Promise<void>;
updateOrder: (ids: string[]) => Promise<void>;
importBatch: (files: CreatePromptFileRequest[]) => Promise<PromptFile[]>;
clearError: () => void;
}
export const usePromptFilesStore = create<PromptFilesState>((set, get) => ({
// Initial state
files: [],
activeFile: null,
isLoading: false,
error: null,
// Load all prompt files
loadFiles: async () => {
set({ isLoading: true, error: null });
try {
const files = await api.promptFilesList();
const activeFile = files.find(f => f.is_active) || null;
set({ files, activeFile, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to load prompt files',
isLoading: false
});
}
},
// Get a single file
getFile: async (id: string) => {
try {
const file = await api.promptFileGet(id);
return file;
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to get prompt file' });
throw error;
}
},
// Create a new file
createFile: async (request: CreatePromptFileRequest) => {
set({ isLoading: true, error: null });
try {
const file = await api.promptFileCreate(request);
await get().loadFiles(); // Reload to get updated list
set({ isLoading: false });
return file;
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to create prompt file',
isLoading: false
});
throw error;
}
},
// Update an existing file
updateFile: async (request: UpdatePromptFileRequest) => {
set({ isLoading: true, error: null });
try {
const file = await api.promptFileUpdate(request);
await get().loadFiles(); // Reload to get updated list
set({ isLoading: false });
return file;
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to update prompt file',
isLoading: false
});
throw error;
}
},
// Delete a file
deleteFile: async (id: string) => {
set({ isLoading: true, error: null });
try {
await api.promptFileDelete(id);
await get().loadFiles(); // Reload to get updated list
set({ isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to delete prompt file',
isLoading: false
});
throw error;
}
},
// Apply a file (replace CLAUDE.md)
applyFile: async (id: string, targetPath?: string) => {
set({ isLoading: true, error: null });
try {
const resultPath = await api.promptFileApply(id, targetPath);
await get().loadFiles(); // Reload to update active state
set({ isLoading: false });
return resultPath;
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to apply prompt file',
isLoading: false
});
throw error;
}
},
// Deactivate all files
deactivateAll: async () => {
set({ isLoading: true, error: null });
try {
await api.promptFileDeactivate();
await get().loadFiles(); // Reload to update active state
set({ isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to deactivate prompt files',
isLoading: false
});
throw error;
}
},
// Import from CLAUDE.md
importFromClaudeMd: async (name: string, description?: string, sourcePath?: string) => {
set({ isLoading: true, error: null });
try {
const file = await api.promptFileImportFromClaudeMd(name, description, sourcePath);
await get().loadFiles(); // Reload to get updated list
set({ isLoading: false });
return file;
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to import from CLAUDE.md',
isLoading: false
});
throw error;
}
},
// Export a file
exportFile: async (id: string, exportPath: string) => {
try {
await api.promptFileExport(id, exportPath);
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to export prompt file' });
throw error;
}
},
// Update display order
updateOrder: async (ids: string[]) => {
try {
await api.promptFilesUpdateOrder(ids);
await get().loadFiles(); // Reload to get updated order
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to update order' });
throw error;
}
},
// Batch import files
importBatch: async (files: CreatePromptFileRequest[]) => {
set({ isLoading: true, error: null });
try {
const imported = await api.promptFilesImportBatch(files);
await get().loadFiles(); // Reload to get updated list
set({ isLoading: false });
return imported;
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to batch import prompt files',
isLoading: false
});
throw error;
}
},
// Clear error
clearError: () => set({ error: null }),
}));

48
src/types/api-nodes.ts Normal file
View File

@@ -0,0 +1,48 @@
import { RelayStationAdapter } from '@/lib/api';
/**
* API 节点数据结构
*/
export interface ApiNode {
id: string;
name: string;
url: string;
adapter: RelayStationAdapter;
description?: string;
enabled: boolean;
is_default: boolean;
created_at: string;
updated_at: string;
}
/**
* 创建节点请求
*/
export interface CreateApiNodeRequest {
name: string;
url: string;
adapter: RelayStationAdapter;
description?: string;
}
/**
* 更新节点请求
*/
export interface UpdateApiNodeRequest {
name?: string;
url?: string;
description?: string;
enabled?: boolean;
}
/**
* 节点测试结果
*/
export interface NodeTestResult {
node_id: string;
url: string;
name: string;
response_time: number | null;
status: 'testing' | 'success' | 'failed';
error?: string;
}

View File

@@ -7,63 +7,63 @@ const host = process.env.TAURI_DEV_HOST;
// https://vitejs.dev/config/
export default defineConfig(async () => ({
plugins: [react(), tailwindcss()],
plugins: [react(), tailwindcss()],
// Path resolution
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
// Build configuration for code splitting
build: {
// Increase chunk size warning limit to 2000 KB
chunkSizeWarningLimit: 2000,
rollupOptions: {
output: {
// Manual chunks for better code splitting
manualChunks: {
// Vendor chunks
'react-vendor': ['react', 'react-dom'],
'ui-vendor': ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu', '@radix-ui/react-select', '@radix-ui/react-tabs', '@radix-ui/react-tooltip', '@radix-ui/react-switch', '@radix-ui/react-popover'],
'editor-vendor': ['@uiw/react-md-editor'],
'monaco-editor': ['monaco-editor', '@monaco-editor/react'],
'syntax-vendor': ['react-syntax-highlighter'],
// Animation and motion
'framer-motion': ['framer-motion'],
// Tauri and other utilities
'tauri': ['@tauri-apps/api', '@tauri-apps/plugin-dialog', '@tauri-apps/plugin-shell', '@tauri-apps/plugin-fs', '@tauri-apps/plugin-clipboard-manager'],
'utils': ['date-fns', 'clsx', 'tailwind-merge'],
// Charts and visualization
'recharts': ['recharts'],
// Virtual scrolling
'virtual': ['@tanstack/react-virtual'],
// Path resolution
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
// Build configuration for code splitting
build: {
// Increase chunk size warning limit to 3500 KB (Monaco Editor is ~3346 KB)
chunkSizeWarningLimit: 3500,
rollupOptions: {
output: {
// Manual chunks for better code splitting
manualChunks: {
// Vendor chunks
'react-vendor': ['react', 'react-dom'],
'ui-vendor': ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu', '@radix-ui/react-select', '@radix-ui/react-tabs', '@radix-ui/react-tooltip', '@radix-ui/react-switch', '@radix-ui/react-popover'],
'editor-vendor': ['@uiw/react-md-editor'],
'monaco-editor': ['monaco-editor', '@monaco-editor/react'],
'syntax-vendor': ['react-syntax-highlighter'],
// Animation and motion
'framer-motion': ['framer-motion'],
// Tauri and other utilities
'tauri': ['@tauri-apps/api', '@tauri-apps/plugin-dialog', '@tauri-apps/plugin-shell', '@tauri-apps/plugin-fs', '@tauri-apps/plugin-clipboard-manager'],
'utils': ['date-fns', 'clsx', 'tailwind-merge'],
// Charts and visualization
'recharts': ['recharts'],
// Virtual scrolling
'virtual': ['@tanstack/react-virtual'],
},
},
},
},
},
},
}));