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:
@@ -246,6 +246,7 @@ pub async fn update_provider(
|
||||
}
|
||||
updated.meta = Some(crate::provider::ProviderMeta {
|
||||
custom_endpoints: merged_map,
|
||||
usage_script: new_meta.usage_script.clone(),
|
||||
});
|
||||
}
|
||||
// 旧 meta 不存在:使用入参(可能为 None)
|
||||
@@ -798,6 +799,172 @@ pub async fn validate_mcp_command(cmd: String) -> Result<bool, String> {
|
||||
claude_mcp::validate_command_in_path(&cmd)
|
||||
}
|
||||
|
||||
// =====================
|
||||
// 用量查询命令
|
||||
// =====================
|
||||
|
||||
/// 查询供应商用量
|
||||
#[tauri::command]
|
||||
pub async fn query_provider_usage(
|
||||
state: State<'_, AppState>,
|
||||
provider_id: Option<String>,
|
||||
providerId: Option<String>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
) -> Result<crate::provider::UsageResult, String> {
|
||||
use crate::provider::{UsageData, UsageResult};
|
||||
|
||||
// 解析参数
|
||||
let provider_id = provider_id
|
||||
.or(providerId)
|
||||
.ok_or("缺少 providerId 参数")?;
|
||||
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
// 1. 获取供应商配置并克隆所需数据
|
||||
let (api_key, base_url, usage_script_code, timeout) = {
|
||||
let config = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = config
|
||||
.get_manager(&app_type)
|
||||
.ok_or("应用类型不存在")?;
|
||||
|
||||
let provider = manager
|
||||
.providers
|
||||
.get(&provider_id)
|
||||
.ok_or("供应商不存在")?;
|
||||
|
||||
// 2. 检查脚本配置
|
||||
let usage_script = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.usage_script.as_ref())
|
||||
.ok_or("未配置用量查询脚本")?;
|
||||
|
||||
if !usage_script.enabled {
|
||||
return Err("用量查询未启用".to_string());
|
||||
}
|
||||
|
||||
// 3. 提取凭证和脚本配置
|
||||
let (api_key, base_url) = extract_credentials(provider, &app_type)?;
|
||||
let timeout = usage_script.timeout.unwrap_or(10);
|
||||
let code = usage_script.code.clone();
|
||||
|
||||
// 显式释放锁
|
||||
drop(config);
|
||||
|
||||
(api_key, base_url, code, timeout)
|
||||
};
|
||||
|
||||
// 5. 执行脚本
|
||||
let result = crate::usage_script::execute_usage_script(
|
||||
&usage_script_code,
|
||||
&api_key,
|
||||
&base_url,
|
||||
timeout,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 6. 构建结果(支持单对象或数组)
|
||||
match result {
|
||||
Ok(data) => {
|
||||
// 尝试解析为数组
|
||||
let usage_list: Vec<UsageData> = if data.is_array() {
|
||||
// 直接解析为数组
|
||||
serde_json::from_value(data)
|
||||
.map_err(|e| format!("数据格式错误: {}", e))?
|
||||
} else {
|
||||
// 单对象包装为数组(向后兼容)
|
||||
let single: UsageData = serde_json::from_value(data)
|
||||
.map_err(|e| format!("数据格式错误: {}", e))?;
|
||||
vec![single]
|
||||
};
|
||||
|
||||
Ok(UsageResult {
|
||||
success: true,
|
||||
data: Some(usage_list),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
Ok(UsageResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(e),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从供应商配置中提取 API Key 和 Base URL
|
||||
fn extract_credentials(
|
||||
provider: &crate::provider::Provider,
|
||||
app_type: &AppType,
|
||||
) -> Result<(String, String), String> {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let env = provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or("配置格式错误: 缺少 env")?;
|
||||
|
||||
let api_key = env
|
||||
.get("ANTHROPIC_AUTH_TOKEN")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("缺少 API Key")?
|
||||
.to_string();
|
||||
|
||||
let base_url = env
|
||||
.get("ANTHROPIC_BASE_URL")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("缺少 ANTHROPIC_BASE_URL 配置")?
|
||||
.to_string();
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
AppType::Codex => {
|
||||
let auth = provider
|
||||
.settings_config
|
||||
.get("auth")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or("配置格式错误: 缺少 auth")?;
|
||||
|
||||
let api_key = auth
|
||||
.get("OPENAI_API_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("缺少 API Key")?
|
||||
.to_string();
|
||||
|
||||
// 从 config TOML 中提取 base_url
|
||||
let config_toml = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let base_url = if config_toml.contains("base_url") {
|
||||
let re = regex::Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).unwrap();
|
||||
re.captures(config_toml)
|
||||
.and_then(|caps| caps.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
.ok_or("config.toml 中 base_url 格式错误")?
|
||||
} else {
|
||||
return Err("config.toml 中缺少 base_url 配置".to_string());
|
||||
};
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// 新:集中以 config.json 为 SSOT 的 MCP 配置命令
|
||||
// =====================
|
||||
|
||||
@@ -10,6 +10,7 @@ mod migration;
|
||||
mod provider;
|
||||
mod settings;
|
||||
mod speedtest;
|
||||
mod usage_script;
|
||||
mod store;
|
||||
|
||||
use store::AppState;
|
||||
@@ -429,6 +430,8 @@ pub fn run() {
|
||||
commands::upsert_claude_mcp_server,
|
||||
commands::delete_claude_mcp_server,
|
||||
commands::validate_mcp_command,
|
||||
// usage query
|
||||
commands::query_provider_usage,
|
||||
// New MCP via config.json (SSOT)
|
||||
commands::get_mcp_config,
|
||||
commands::upsert_mcp_server_in_config,
|
||||
|
||||
@@ -51,12 +51,59 @@ pub struct ProviderManager {
|
||||
pub current: String,
|
||||
}
|
||||
|
||||
/// 用量查询脚本配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UsageScript {
|
||||
pub enabled: bool,
|
||||
pub language: String,
|
||||
pub code: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout: Option<u64>,
|
||||
}
|
||||
|
||||
/// 用量数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UsageData {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "planName")]
|
||||
pub plan_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub extra: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "isValid")]
|
||||
pub is_valid: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "invalidMessage")]
|
||||
pub invalid_message: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub used: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub remaining: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unit: Option<String>,
|
||||
}
|
||||
|
||||
/// 用量查询结果(支持多套餐)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UsageResult {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Vec<UsageData>>, // 支持返回多个套餐
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 供应商元数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProviderMeta {
|
||||
/// 自定义端点列表(按 URL 去重存储)
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,
|
||||
/// 用量查询脚本配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage_script: Option<UsageScript>,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
|
||||
207
src-tauri/src/usage_script.rs
Normal file
207
src-tauri/src/usage_script.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user