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
This commit is contained in:
Sirhexs
2025-10-15 09:15:25 +08:00
committed by GitHub
parent 59644b29e6
commit 3e4df2c96a
18 changed files with 1179 additions and 10 deletions

View File

@@ -0,0 +1,207 @@
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(())
}