Files
cc-switch/src-tauri/src/usage_script.rs
Sirhexs 3e4df2c96a feat: add provider usage query with JavaScript scripting support (#101)
* feat: add provider usage query functionality

- Updated `Cargo.toml` to include `regex` and `rquickjs` dependencies for usage script execution.
- Implemented `query_provider_usage` command in `commands.rs` to handle usage queries.
- Created `UsageScript` and `UsageData` structs in `provider.rs` for managing usage script configurations and results.
- Added `execute_usage_script` function in `usage_script.rs` to run user-defined scripts for querying usage.
- Enhanced `ProviderList` component to include a button for configuring usage scripts and a modal for editing scripts.
- Introduced `UsageFooter` component to display usage information and status.
- Added `UsageScriptModal` for editing and testing usage scripts with preset templates.
- Updated Tauri API to support querying provider usage.
- Modified types in `types.ts` to include structures for usage scripts and results.

* feat(usage): support multi-plan usage display for providers

- 【Feature】
  - Update `UsageResult` to support an array of `UsageData` for displaying multiple usage plans per provider.
  - Refactor `query_provider_usage` command to parse both single `UsageData` objects (for backward compatibility) and arrays of `UsageData`.
  - Enhance `usage_script` validation to accept either a single usage object or an array of usage objects.
- 【Frontend】
  - Redesign `UsageFooter` to iterate and display details for all available usage plans, introducing `UsagePlanItem` for individual plan rendering.
  - Improve usage display with color-coded remaining balance and clear plan information.
  - Update `UsageScriptModal` test notification to summarize all returned plans.
  - Remove redundant `isCurrent` prop from `UsageFooter` in `ProviderList`.
- 【Build】
  - Change frontend development server port from `3000` to `3005` in `tauri.conf.json` and `vite.config.mts`.

* feat(usage): enhance query flexibility and display
- 【`src/types.ts`, `src-tauri/src/provider.rs`】Make `UsageData` fields optional and introduce `extra` and `invalidMessage` for more flexible reporting.
  - `expiresAt` replaced by generic `extra` field.
  - `isValid`, `remaining`, `unit` are now optional.
  - Added `invalidMessage` to provide specific reasons for invalid status.
- 【`src-tauri/src/usage_script.rs`】Relax usage script result validation to accommodate optional fields in `UsageData`.
- 【`src/components/UsageFooter.tsx`】Update UI to display `extra` field and `invalidMessage`, and conditionally render `remaining` and `unit` based on availability.
- 【`src/components/UsageScriptModal.tsx`】
  - Add a new `NewAPI` preset template demonstrating advanced extractor logic for complex API responses.
  - Update script instructions to reflect optional fields and new variable syntax (`{{apiKey}}`).
  - Remove old "DeepSeek" and "OpenAI" templates.
  - Remove basic syntax check for `return` statement.
- 【`.vscode/settings.json`】Add `dish-ai-commit.base.language` setting.
- 【`src-tauri/src/commands.rs`】Adjust usage logging to handle optional `remaining` and `unit` fields.

* chore(config): remove VS Code settings from version control

- delete .vscode/settings.json to remove editor-specific configurations
- add /.vscode to .gitignore to prevent tracking of local VS Code settings
- ensure personalized editor preferences are not committed to the repository

* fix(provider): preserve usage script during provider update

- When updating a provider, the `usage_script` configuration within `ProviderMeta` was not explicitly merged.
- This could lead to the accidental loss of `usage_script` settings if the incoming `provider` object in the update request did not contain this field.
- Ensure `usage_script` is cloned from the existing provider's meta when merging `ProviderMeta` during an update.

* refactor(provider): enforce base_url for usage scripts and update dev ports
- 【Backend】
    - `src-tauri/src/commands.rs`: Made `ANTHROPIC_BASE_URL` a required field for Claude providers and `base_url` a required field in `config.toml` for Codex providers when extracting credentials for usage script execution. This improves error handling by explicitly failing if these critical URLs are missing or malformed.
- 【Frontend】
    - `src/App.tsx`, `src/components/ProviderList.tsx`: Passed `appType` prop to `ProviderList` component to ensure `updateProvider` calls within `handleSaveUsageScript` correctly identify the application type.
- 【Config】
    - `src-tauri/tauri.conf.json`, `vite.config.mts`: Updated development server ports from `3005` to `3000` to standardize local development environment.

* refactor(usage): improve usage data fetching logic

- Prevent redundant API calls by tracking last fetched parameters in `useEffect`.
- Avoid concurrent API requests by adding a guard in `fetchUsage`.
- Clear usage data and last fetch parameters when usage query is disabled.
- Add `queryProviderUsage` API declaration to `window.api` interface.

* fix(usage-script): ensure usage script updates and improve reactivity

- correctly update `usage_script` from new provider meta during updates
- replace full page reload with targeted provider data refresh after saving usage script settings
- trigger usage data fetch or clear when `usageEnabled` status changes in `UsageFooter`
- reduce logging verbosity for usage script execution in backend commands and script execution

* style(usage-footer): adjust usage plan item layout

- Decrease width of extra field column from 35% to 30%
- Increase width of usage information column from 40% to 45%
- Improve visual balance and readability of usage plan items
2025-10-15 09:15:25 +08:00

208 lines
7.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use reqwest::Client;
use rquickjs::{Context, Runtime, Function};
use serde_json::Value;
use std::collections::HashMap;
use std::time::Duration;
/// 执行用量查询脚本
pub async fn execute_usage_script(
script_code: &str,
api_key: &str,
base_url: &str,
timeout_secs: u64,
) -> Result<Value, String> {
// 1. 替换变量
let replaced = script_code
.replace("{{apiKey}}", api_key)
.replace("{{baseUrl}}", base_url);
// 2. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
let request_config = {
let runtime = Runtime::new().map_err(|e| format!("创建 JS 运行时失败: {}", e))?;
let context = Context::full(&runtime).map_err(|e| format!("创建 JS 上下文失败: {}", e))?;
context.with(|ctx| {
// 执行用户代码,获取配置对象
let config: rquickjs::Object = ctx
.eval(replaced.clone())
.map_err(|e| format!("解析配置失败: {}", e))?;
// 提取 request 配置
let request: rquickjs::Object = config
.get("request")
.map_err(|e| format!("缺少 request 配置: {}", e))?;
// 将 request 转换为 JSON 字符串
let request_json: String = ctx
.json_stringify(request)
.map_err(|e| format!("序列化 request 失败: {}", e))?
.ok_or("序列化返回 None")?
.get()
.map_err(|e| format!("获取字符串失败: {}", e))?;
Ok::<_, String>(request_json)
})?
}; // Runtime 和 Context 在这里被 drop
// 3. 解析 request 配置
let request: RequestConfig = serde_json::from_str(&request_config)
.map_err(|e| format!("request 配置格式错误: {}", e))?;
// 4. 发送 HTTP 请求
let response_data = send_http_request(&request, timeout_secs).await?;
// 5. 在独立作用域中执行 extractor确保 Runtime/Context 在函数结束前释放)
let result: Value = {
let runtime = Runtime::new().map_err(|e| format!("创建 JS 运行时失败: {}", e))?;
let context = Context::full(&runtime).map_err(|e| format!("创建 JS 上下文失败: {}", e))?;
context.with(|ctx| {
// 重新 eval 获取配置对象
let config: rquickjs::Object = ctx
.eval(replaced.clone())
.map_err(|e| format!("重新解析配置失败: {}", e))?;
// 提取 extractor 函数
let extractor: Function = config
.get("extractor")
.map_err(|e| format!("缺少 extractor 函数: {}", e))?;
// 将响应数据转换为 JS 值
let response_js: rquickjs::Value = ctx
.json_parse(response_data.as_str())
.map_err(|e| format!("解析响应 JSON 失败: {}", e))?;
// 调用 extractor(response)
let result_js: rquickjs::Value = extractor
.call((response_js,))
.map_err(|e| format!("执行 extractor 失败: {}", e))?;
// 转换为 JSON 字符串
let result_json: String = ctx
.json_stringify(result_js)
.map_err(|e| format!("序列化结果失败: {}", e))?
.ok_or("序列化返回 None")?
.get()
.map_err(|e| format!("获取字符串失败: {}", e))?;
// 解析为 serde_json::Value
serde_json::from_str(&result_json).map_err(|e| format!("JSON 解析失败: {}", e))
})?
}; // Runtime 和 Context 在这里被 drop
// 6. 验证返回值格式
validate_result(&result)?;
Ok(result)
}
/// 请求配置结构
#[derive(Debug, serde::Deserialize)]
struct RequestConfig {
url: String,
method: String,
#[serde(default)]
headers: HashMap<String, String>,
#[serde(default)]
body: Option<String>,
}
/// 发送 HTTP 请求
async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result<String, String> {
let client = Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.map_err(|e| format!("创建客户端失败: {}", e))?;
let method = config
.method
.parse()
.unwrap_or(reqwest::Method::GET);
let mut req = client.request(method.clone(), &config.url);
// 添加请求头
for (k, v) in &config.headers {
req = req.header(k, v);
}
// 添加请求体
if let Some(body) = &config.body {
req = req.body(body.clone());
}
// 发送请求
let resp = req
.send()
.await
.map_err(|e| format!("请求失败: {}", e))?;
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| format!("读取响应失败: {}", e))?;
if !status.is_success() {
let preview = if text.len() > 200 {
format!("{}...", &text[..200])
} else {
text.clone()
};
return Err(format!("HTTP {} : {}", status, preview));
}
Ok(text)
}
/// 验证脚本返回值(支持单对象或数组)
fn validate_result(result: &Value) -> Result<(), String> {
// 如果是数组,验证每个元素
if let Some(arr) = result.as_array() {
if arr.is_empty() {
return Err("脚本返回的数组不能为空".to_string());
}
for (idx, item) in arr.iter().enumerate() {
validate_single_usage(item)
.map_err(|e| format!("数组索引[{}]验证失败: {}", idx, e))?;
}
return Ok(());
}
// 如果是单对象,直接验证(向后兼容)
validate_single_usage(result)
}
/// 验证单个用量数据对象
fn validate_single_usage(result: &Value) -> Result<(), String> {
let obj = result.as_object().ok_or("脚本必须返回对象或对象数组")?;
// 所有字段均为可选,只进行类型检查
if obj.contains_key("isValid") && !result["isValid"].is_null() && !result["isValid"].is_boolean() {
return Err("isValid 必须是布尔值或 null".to_string());
}
if obj.contains_key("invalidMessage") && !result["invalidMessage"].is_null() && !result["invalidMessage"].is_string() {
return Err("invalidMessage 必须是字符串或 null".to_string());
}
if obj.contains_key("remaining") && !result["remaining"].is_null() && !result["remaining"].is_number() {
return Err("remaining 必须是数字或 null".to_string());
}
if obj.contains_key("unit") && !result["unit"].is_null() && !result["unit"].is_string() {
return Err("unit 必须是字符串或 null".to_string());
}
if obj.contains_key("total") && !result["total"].is_null() && !result["total"].is_number() {
return Err("total 必须是数字或 null".to_string());
}
if obj.contains_key("used") && !result["used"].is_null() && !result["used"].is_number() {
return Err("used 必须是数字或 null".to_string());
}
if obj.contains_key("planName") && !result["planName"].is_null() && !result["planName"].is_string() {
return Err("planName 必须是字符串或 null".to_string());
}
if obj.contains_key("extra") && !result["extra"].is_null() && !result["extra"].is_string() {
return Err("extra 必须是字符串或 null".to_string());
}
Ok(())
}