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,355 @@
import React, { useState } from "react";
import { X, Play, Wand2 } from "lucide-react";
import { Provider, UsageScript } from "../types";
import { AppType } from "../lib/tauri-api";
import JsonEditor from "./JsonEditor";
import * as prettier from "prettier/standalone";
import * as parserBabel from "prettier/parser-babel";
import * as pluginEstree from "prettier/plugins/estree";
interface UsageScriptModalProps {
provider: Provider;
appType: AppType;
onClose: () => void;
onSave: (script: UsageScript) => void;
onNotify?: (
message: string,
type: "success" | "error",
duration?: number
) => void;
}
// 预设模板JS 对象字面量格式)
const PRESET_TEMPLATES: Record<string, string> = {
: `({
request: {
url: "{{baseUrl}}/user/balance",
method: "GET",
headers: {
"Authorization": "Bearer {{apiKey}}",
"User-Agent": "cc-switch/1.0"
}
},
extractor: function(response) {
return {
isValid: response.is_active || true,
remaining: response.balance,
unit: "USD"
};
}
})`,
NewAPI: `({
request: {
url: "{{baseUrl}}/api/usage/token",
method: "GET",
headers: {
Authorization: "Bearer {{apiKey}}",
},
},
extractor: function (response) {
if (response.code) {
if (response.data.unlimited_quota) {
return {
planName: response.data.name,
total: -1,
used: response.data.total_used / 500000,
unit: "USD",
};
}
return {
isValid: true,
planName: response.data.name,
total: response.data.total_granted / 500000,
used: response.data.total_used / 500000,
remaining: response.data.total_available / 500000,
unit: "USD",
};
}
if (response.error) {
return {
isValid: false,
invalidMessage: response.error.message,
};
}
},
})`,
};
const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
provider,
appType,
onClose,
onSave,
onNotify,
}) => {
const [script, setScript] = useState<UsageScript>(() => {
return (
provider.meta?.usage_script || {
enabled: false,
language: "javascript",
code: PRESET_TEMPLATES["通用模板"],
timeout: 10,
}
);
});
const [testing, setTesting] = useState(false);
const handleSave = () => {
// 验证脚本格式
if (script.enabled && !script.code.trim()) {
onNotify?.("脚本配置不能为空", "error");
return;
}
// 基本的 JS 语法检查(检查是否包含 return 语句)
if (script.enabled && !script.code.includes("return")) {
onNotify?.("脚本必须包含 return 语句", "error", 5000);
return;
}
onSave(script);
onClose();
onNotify?.("用量查询配置已保存", "success", 2000);
};
const handleTest = async () => {
setTesting(true);
try {
const result = await window.api.queryProviderUsage(
provider.id,
appType
);
if (result.success && result.data && result.data.length > 0) {
// 显示所有套餐数据
const summary = result.data
.map((plan) => {
const planInfo = plan.planName ? `[${plan.planName}]` : "";
return `${planInfo} 剩余: ${plan.remaining} ${plan.unit}`;
})
.join(", ");
onNotify?.(`测试成功!${summary}`, "success", 3000);
} else {
onNotify?.(`测试失败: ${result.error || "无数据返回"}`, "error", 5000);
}
} catch (error: any) {
onNotify?.(`测试失败: ${error?.message || "未知错误"}`, "error", 5000);
} finally {
setTesting(false);
}
};
const handleFormat = async () => {
try {
const formatted = await prettier.format(script.code, {
parser: "babel",
plugins: [parserBabel as any, pluginEstree as any],
semi: true,
singleQuote: false,
tabWidth: 2,
printWidth: 80,
});
setScript({ ...script, code: formatted.trim() });
onNotify?.("格式化成功", "success", 1000);
} catch (error: any) {
onNotify?.(`格式化失败: ${error?.message || "语法错误"}`, "error", 3000);
}
};
const handleUsePreset = (presetName: string) => {
const preset = PRESET_TEMPLATES[presetName];
if (preset) {
setScript({ ...script, code: preset });
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
- {provider.name}
</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* 启用开关 */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={script.enabled}
onChange={(e) =>
setScript({ ...script, enabled: e.target.checked })
}
className="w-4 h-4"
/>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
</span>
</label>
{script.enabled && (
<>
{/* 预设模板选择 */}
<div>
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-gray-100">
</label>
<div className="flex gap-2">
{Object.keys(PRESET_TEMPLATES).map((name) => (
<button
key={name}
onClick={() => handleUsePreset(name)}
className="px-3 py-1.5 text-xs bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
>
{name}
</button>
))}
</div>
</div>
{/* 脚本编辑器 */}
<div>
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-gray-100">
JavaScript
</label>
<JsonEditor
value={script.code}
onChange={(code) => setScript({ ...script, code })}
height="300px"
language="javascript"
/>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
: <code>{"{{apiKey}}"}</code>,{" "}
<code>{"{{baseUrl}}"}</code> | extractor API JSON
</p>
</div>
{/* 配置选项 */}
<div className="grid grid-cols-2 gap-4">
<label className="block">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
</span>
<input
type="number"
min="2"
max="30"
value={script.timeout || 10}
onChange={(e) =>
setScript({ ...script, timeout: parseInt(e.target.value) })
}
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
/>
</label>
</div>
{/* 脚本说明 */}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm text-gray-700 dark:text-gray-300">
<h4 className="font-medium mb-2"></h4>
<div className="space-y-3 text-xs">
<div>
<strong></strong>
<pre className="mt-1 p-2 bg-white/50 dark:bg-black/20 rounded text-[10px] overflow-x-auto">
{`({
request: {
url: "{{baseUrl}}/api/usage",
method: "POST",
headers: {
"Authorization": "Bearer {{apiKey}}",
"User-Agent": "cc-switch/1.0"
},
body: JSON.stringify({ key: "value" }) // 可选
},
extractor: function(response) {
// response 是 API 返回的 JSON 数据
return {
isValid: !response.error,
remaining: response.balance,
unit: "USD"
};
}
})`}
</pre>
</div>
<div>
<strong>extractor </strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li> <code>isValid</code>: </li>
<li> <code>invalidMessage</code>: isValid false </li>
<li> <code>remaining</code>: </li>
<li> <code>unit</code>: "USD"</li>
<li> <code>planName</code>: </li>
<li> <code>total</code>: </li>
<li> <code>used</code>: </li>
<li> <code>extra</code>: </li>
</ul>
</div>
<div className="text-gray-600 dark:text-gray-400">
<strong>💡 </strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li> <code>{"{{apiKey}}"}</code> <code>{"{{baseUrl}}"}</code> </li>
<li> extractor ES2020+ </li>
<li> <code>()</code> </li>
</ul>
</div>
</div>
</div>
</>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex gap-2">
<button
onClick={handleTest}
disabled={!script.enabled || testing}
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Play size={14} />
{testing ? "测试中..." : "测试脚本"}
</button>
<button
onClick={handleFormat}
disabled={!script.enabled}
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="格式化代码 (Prettier)"
>
<Wand2 size={14} />
</button>
</div>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
</button>
<button
onClick={handleSave}
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors"
>
</button>
</div>
</div>
</div>
</div>
);
};
export default UsageScriptModal;