Feat/proxy server (#355)

* feat(proxy): implement local HTTP proxy server with multi-provider failover

Add a complete HTTP proxy server implementation built on Axum framework,
enabling local API request forwarding with automatic provider failover
and load balancing capabilities.

Backend Implementation (Rust):
- Add proxy server module with 7 core components:
  * server.rs: Axum HTTP server lifecycle management (start/stop/status)
  * router.rs: API routing configuration for Claude/OpenAI/Gemini endpoints
  * handlers.rs: Request/response handling and transformation
  * forwarder.rs: Upstream forwarding logic with retry mechanism (652 lines)
  * error.rs: Comprehensive error handling and HTTP status mapping
  * types.rs: Shared types (ProxyConfig, ProxyStatus, ProxyServerInfo)
  * health.rs: Provider health check infrastructure

Service Layer:
- Add ProxyService (services/proxy.rs, 157 lines):
  * Manage proxy server lifecycle
  * Handle configuration updates
  * Track runtime status and metrics

Database Layer:
- Add proxy configuration DAO (dao/proxy.rs, 242 lines):
  * Persist proxy settings (listen address, port, timeout)
  * Store provider priority and availability flags
- Update schema with proxy_config table (schema.rs):
  * Support runtime configuration persistence

Tauri Commands:
- Add 6 command endpoints (commands/proxy.rs):
  * start_proxy_server: Launch proxy server
  * stop_proxy_server: Gracefully shutdown server
  * get_proxy_status: Query runtime status
  * get_proxy_config: Retrieve current configuration
  * update_proxy_config: Modify settings without restart
  * is_proxy_running: Check server state

Frontend Implementation (React + TypeScript):
- Add ProxyPanel component (222 lines):
  * Real-time server status display
  * Start/stop controls
  * Provider availability monitoring
- Add ProxySettingsDialog component (420 lines):
  * Configuration editor (address, port, timeout)
  * Provider priority management
  * Settings validation
- Add React hooks:
  * useProxyConfig: Manage proxy configuration state
  * useProxyStatus: Poll and display server status
- Add TypeScript types (types/proxy.ts):
  * Define ProxyConfig, ProxyStatus interfaces

Provider Integration:
- Extend Provider model with availability field (providers.rs):
  * Track provider health for failover logic
- Update ProviderCard UI to display proxy status
- Integrate proxy controls in Settings page

Dependencies:
- Add Axum 0.7 (async web framework)
- Add Tower 0.4 (middleware and service abstractions)
- Add Tower-HTTP (CORS layer)
- Add Tokio sync primitives (oneshot, RwLock)

Technical Details:
- Graceful shutdown via oneshot channel
- Shared state with Arc<RwLock<T>> for thread-safe config updates
- CORS enabled for cross-origin frontend access
- Request/response streaming support
- Automatic retry with exponential backoff (forwarder)
- API key extraction from multiple config formats (Claude/Codex/Gemini)

File Statistics:
- 41 files changed
- 3491 insertions(+), 41 deletions(-)
- Core modules: 1393 lines (server + forwarder + handlers)
- Frontend UI: 642 lines (ProxyPanel + ProxySettingsDialog)
- Database/DAO: 326 lines

This implementation provides the foundation for advanced features like:
- Multi-provider load balancing
- Automatic failover on provider errors
- Request logging and analytics
- Usage tracking and cost monitoring

* fix(proxy): resolve UI/UX issues and database constraint error

Simplify proxy control interface and fix database persistence issues:

Backend Fixes:
- Fix NOT NULL constraint error in proxy_config.created_at field
  * Use COALESCE to preserve created_at on updates
  * Ensure proper INSERT OR REPLACE behavior
- Remove redundant enabled field validation on startup
  * Auto-enable when user clicks start button
  * Persist enabled state after successful start
- Preserve enabled state during config updates
  * Prevent accidental service shutdown on config save

Frontend Improvements:
- Remove duplicate proxy enable switch from settings dialog
  * Keep only runtime toggle in ProxyPanel
  * Simplify user experience with single control point
- Hide proxy target button when proxy service is stopped
  * Add isProxyRunning prop to ProviderCard
  * Conditionally render proxy controls based on service status
- Update form schema to omit enabled field
  * Managed automatically by backend

Files: 5 changed, 81 insertions(+), 94 deletions(-)

* fix(proxy): improve URL building and Gemini request handling

- Refactor URL construction with version path deduplication (/v1, /v1beta)
- Preserve query parameters for Gemini API requests
- Support GOOGLE_GEMINI_API_KEY field name (with fallback)
- Change default proxy port from 5000 to 15721
- Fix test: use Option type for is_proxy_target field

* refactor(proxy): remove unused request handlers and routes

- Remove unused GET/DELETE request forwarding methods
- Remove count_tokens, get/delete response handlers
- Simplify router by removing unused endpoints
- Keep only essential routes: /v1/messages, /v1/responses, /v1beta/*

* Merge branch 'main' into feat/proxy-server

* fix(proxy): resolve clippy warnings for dead code and uninlined format args

- Add #[allow(dead_code)] to unused ProviderUnhealthy variant
- Inline format string arguments in handlers.rs and codex.rs log macros
- Refactor error response handling to properly pass through upstream errors
- Add URL deduplication logic for /v1/v1 paths in CodexAdapter

* feat(proxy): implement provider adapter pattern with OpenRouter support

This major refactoring introduces a modular provider adapter architecture
to support format transformation between different AI API formats.

New features:
- Add ProviderAdapter trait for unified provider abstraction
- Implement Claude, Codex, and Gemini adapters with specific logic
- Add Anthropic ↔ OpenAI format transformation for OpenRouter compatibility
- Support model mapping from provider configuration (ANTHROPIC_MODEL, etc.)
- Add OpenRouter preset to Claude provider presets

Refactoring:
- Extract authentication logic into auth.rs with AuthInfo and AuthStrategy
- Move URL building and request transformation to individual adapters
- Simplify ProviderRouter to only use proxy target providers
- Refactor RequestForwarder to use adapter-based request/response handling
- Use whitelist mode for header forwarding (only pass necessary headers)

Architecture:
- providers/adapter.rs: ProviderAdapter trait definition
- providers/auth.rs: AuthInfo, AuthStrategy types
- providers/claude.rs: Claude adapter with OpenRouter detection
- providers/codex.rs: Codex (OpenAI) adapter
- providers/gemini.rs: Gemini (Google) adapter
- providers/models/: Anthropic and OpenAI API data models
- providers/transform.rs: Bidirectional format transformation

* feat(proxy): add streaming SSE transform and thinking parameter support

New features:
- Add OpenAI → Anthropic SSE streaming response transformation
- Support thinking parameter detection for reasoning model selection
- Add ANTHROPIC_REASONING_MODEL config option for extended thinking

Changes:
- streaming.rs: Implement SSE event parsing and Anthropic format conversion
- transform.rs: Add thinking detection logic and reasoning model mapping
- handlers.rs: Integrate streaming transform for OpenRouter compatibility
- Cargo.toml: Add async-stream and bytes dependencies

* feat(db): add usage tracking schema and types

Add database tables for proxy request logs and model pricing.
Extend Provider and error types to support usage statistics.

* feat(proxy): implement usage tracking subsystem

Add request logger with automatic cost calculation.
Implement token parser for Claude/OpenAI/Gemini responses.
Add cost calculator based on model pricing configuration.

* feat(proxy): integrate usage logging into request handlers

Add usage logging to forwarder and streaming handlers.
Track token usage and costs for each proxy request.

* feat(commands): add usage statistics Tauri commands

Register usage commands for summary, trends, logs, and pricing.
Expose usage stats service through Tauri command layer.

* feat(api): add frontend usage API and query hooks

Add TypeScript types for usage statistics.
Implement usage API with Tauri invoke calls.
Add TanStack Query hooks for usage data fetching.

* feat(ui): add usage dashboard components

Add UsageDashboard with summary cards, trend chart, and data tables.
Implement model pricing configuration panel.
Add request log viewer with filtering and detail panel.

* fix(ui): integrate usage dashboard and fix type errors

Add usage dashboard tab to settings page.
Fix UsageScriptModal TypeScript type annotations.

* deps: add recharts for charts and rust_decimal/uuid for usage tracking

- recharts: Chart visualization for usage trends
- rust_decimal: Precise cost calculations
- uuid: Request ID generation

* feat(proxy): add ProviderType enum for fine-grained provider detection

Introduce ProviderType enum to distinguish between different provider
implementations (Claude, ClaudeAuth, Codex, Gemini, GeminiCli, OpenRouter).
This enables proper authentication handling and request transformation
based on the actual provider type rather than just AppType.

- Add ProviderType enum with detection logic from config
- Enhance Claude adapter with OpenRouter detection
- Enhance Gemini adapter with CLI mode detection
- Add helper methods for provider type inference

* feat(database): extend schema with streaming and timing fields

Add new columns to proxy_request_logs table for enhanced usage tracking:
- first_token_ms and duration_ms for performance metrics
- provider_type and is_streaming for request classification
- cost_multiplier for flexible pricing

Update model pricing with accurate rates for Claude/GPT/Gemini models.
Add ensure_model_pricing_seeded() call on database initialization.
Add test for model pricing auto-seeding verification.

* feat(proxy/usage): enhance token parser and logger for multi-format support

Parser enhancements:
- Add OpenAI Chat Completions format parsing (prompt_tokens/completion_tokens)
- Add model field to TokenUsage for actual model name extraction
- Add from_codex_response_adjusted() for proper cache token handling
- Add debug logging for better stream event tracing

Logger enhancements:
- Add first_token_ms, provider_type, is_streaming, cost_multiplier fields
- Extend RequestLog struct with full metadata tracking
- Update log_with_calculation() signature for new fields

Calculator: Update tests with model field in TokenUsage.

* feat(proxy): enhance proxy server with session tracking and OpenAI route

Error handling:
- Add StreamIdleTimeout and AuthError variants for better error classification

Module exports:
- Export ResponseType, StreamHandler, NonStreamHandler from response_handler
- Export ProxySession, ClientFormat from session module

Server routing:
- Add /v1/chat/completions route for OpenAI Chat Completions API

Handlers:
- Add log_usage_with_session() for enhanced usage tracking with session context
- Add first_token_ms timing measurement for streaming responses
- Use SseUsageCollector with start_time for accurate latency calculation
- Track is_streaming flag in usage logs

* feat(services): add pagination and enhanced filtering for request logs

Usage stats service:
- Change get_request_logs() from limit/offset to page/page_size pagination
- Return PaginatedLogs with total count, page, and page_size
- Add appType and providerName filters with LIKE search
- Add is_streaming, first_token_ms, duration_ms to RequestLogDetail
- Join with providers table for provider name lookup

Commands:
- Update get_request_logs command signature for pagination params

Module exports:
- Export PaginatedLogs struct

* feat(frontend): update usage types and API for pagination support

Types (usage.ts):
- Add isStreaming, firstTokenMs, durationMs to RequestLog
- Add PaginatedLogs interface with data, total, page, pageSize
- Change LogFilters: providerId -> appType + providerName

API (usage.ts):
- Change getRequestLogs params from limit/offset to page/pageSize
- Return PaginatedLogs instead of RequestLog[]
- Pass filters object directly to backend

Query (usage.ts):
- Update usageKeys.logs key generation for pagination
- Update useRequestLogs hook signature

* refactor(ui): enhance RequestLogTable with filtering and pagination

UI improvements:
- Add filter bar with app type, provider name, model, status selectors
- Add date range picker (startDate/endDate)
- Add search/reset/refresh buttons

Pagination:
- Implement proper page-based pagination with page info display
- Show total count and current page range
- Add prev/next navigation buttons

Features:
- Default to last 24 hours filter
- Streamlined table columns layout
- Query invalidation on refresh

* style(config): format mcpPresets code style

Apply consistent formatting to createNpxCommand function and
sequential-thinking server configuration.

* fix(ui): update SettingsPage tab styles for improved appearance (#342)

* feat(model-test): add provider model availability testing

Implement standalone model testing feature to verify provider API connectivity:
- Add ModelTestService for Claude/Codex/Gemini endpoint testing
- Create model_test_logs table for test result persistence
- Add test button to ProviderCard with loading state
- Include ModelTestConfigPanel for customizing test parameters

* fix(proxy): resolve token parsing for OpenRouter streaming responses

Problem:
- OpenRouter and similar third-party services return streaming responses
  where input_tokens appear in message_delta instead of message_start
- The previous implementation only extracted input_tokens from message_start,
  causing input_tokens to be recorded as 0 for these providers

Changes:
- streaming.rs: Add prompt_tokens field to Usage struct and include
  input_tokens in the transformed message_delta event when converting
  OpenAI format to Anthropic format
- parser.rs: Update from_claude_stream_events() to handle input_tokens
  from both message_start (native Claude API) and message_delta (OpenRouter)
  - Use if-let pattern instead of direct unwrap for safer parsing
  - Only update input_tokens from message_delta if not already set
- logger.rs: Adjust test parameters to match updated function signature

Tests:
- Add test_openrouter_stream_parsing() for OpenRouter format validation
- Add test_native_claude_stream_parsing() for native Claude API validation

* fix(pricing): standardize model ID format for pricing lookup

Normalize model IDs by removing vendor prefixes and converting dots to hyphens to ensure consistent pricing lookups across different API response formats.

Changes:
- Update seed data to use hyphen format (e.g., gpt-5-1, gemini-2-5-pro)
- Add normalize_model_id() function to strip vendor prefixes (anthropic/, openai/)
- Convert dots to hyphens in model IDs (claude-haiku-4.5 → claude-haiku-4-5)
- Try both original and normalized IDs for exact matching
- Use normalized ID for suffix-based fallback matching
- Add comprehensive test cases for prefix and dot handling
- Add warning log when no pricing found

This ensures pricing lookups work correctly for:
- Models with vendor prefixes: anthropic/claude-haiku-4.5
- Models with dots in version: claude-sonnet-4.5
- Models with date suffixes: claude-haiku-4-5-20240229

* style(rust): apply clippy formatting suggestions

Apply automatic clippy fixes for uninlined_format_args warnings across Rust codebase. Replace format string placeholders with inline variable syntax for improved readability.

Changes:
- Convert format!("{}", var) to format!("{var}")
- Apply to model_test.rs, parser.rs, and usage_stats.rs
- Fix line length issues by breaking long function calls
- Improve code formatting consistency

All changes are automatic formatting with no functional impact.

* fix(ui): restore card borders in usage statistics panels

Restore proper card styling for ModelTestConfigPanel and PricingConfigPanel by adding back border and rounded-lg classes. The transparent background styling was causing visual inconsistency.

Changes:
- Replace border-none bg-transparent shadow-none with border rounded-lg
- Apply to both loading and error states for consistency
- Format TypeScript code for better readability
- Break long function signatures across multiple lines

This ensures the usage statistics panels have consistent visual appearance with proper borders and rounded corners.

* feat(pricing): add GPT-5 Codex model pricing presets

Add pricing configuration for GPT-5 Codex variants to support cost tracking for Codex-specific models.

Changes:
- Add gpt-5-codex model with standard GPT-5 pricing
- Add gpt-5-1-codex model with standard GPT-5.1 pricing
- Input: $1.25/M tokens, Output: $10/M tokens
- Cache read: $0.125/M tokens, Cache creation: $0

This ensures accurate cost calculation for Codex API requests using GPT-5 Codex models.
This commit is contained in:
YoVinchen
2025-12-05 11:26:41 +08:00
committed by GitHub
parent bf9228093b
commit b1103c8a59
85 changed files with 13856 additions and 105 deletions
+2
View File
@@ -74,6 +74,7 @@ function App() {
switchProvider,
deleteProvider,
saveUsageScript,
setProxyTarget,
} = useProviderActions(activeApp);
// 监听来自托盘菜单的切换事件
@@ -313,6 +314,7 @@ function App() {
appId={activeApp}
isLoading={isLoading}
onSwitch={switchProvider}
onSetProxyTarget={setProxyTarget}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
onDuplicate={handleDuplicateProvider}
+2 -2
View File
@@ -2,7 +2,7 @@ import React, { useState } from "react";
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { Provider, UsageScript } from "@/types";
import { Provider, UsageScript, UsageData } from "@/types";
import { usageApi, type AppId } from "@/lib/api";
import JsonEditor from "./JsonEditor";
import * as prettier from "prettier/standalone";
@@ -220,7 +220,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
);
if (result.success && result.data && result.data.length > 0) {
const summary = result.data
.map((plan) => {
.map((plan: UsageData) => {
const planInfo = plan.planName ? `[${plan.planName}]` : "";
return `${planInfo} ${t("usage.remaining")} ${plan.remaining} ${plan.unit}`;
})
+31 -1
View File
@@ -1,22 +1,35 @@
import { BarChart3, Check, Copy, Edit, Play, Trash2 } from "lucide-react";
import {
BarChart3,
Check,
Copy,
Edit,
Loader2,
Play,
TestTube2,
Trash2,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ProviderActionsProps {
isCurrent: boolean;
isTesting?: boolean;
onSwitch: () => void;
onEdit: () => void;
onDuplicate: () => void;
onTest?: () => void;
onConfigureUsage: () => void;
onDelete: () => void;
}
export function ProviderActions({
isCurrent,
isTesting,
onSwitch,
onEdit,
onDuplicate,
onTest,
onConfigureUsage,
onDelete,
}: ProviderActionsProps) {
@@ -70,6 +83,23 @@ export function ProviderActions({
<Copy className="h-4 w-4" />
</Button>
{onTest && (
<Button
size="icon"
variant="ghost"
onClick={onTest}
disabled={isTesting}
title={t("modelTest.testProvider", "测试模型")}
className={iconButtonClass}
>
{isTesting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube2 className="h-4 w-4" />
)}
</Button>
)}
<Button
size="icon"
variant="ghost"
+50 -8
View File
@@ -11,6 +11,8 @@ import { cn } from "@/lib/utils";
import { ProviderActions } from "@/components/providers/ProviderActions";
import { ProviderIcon } from "@/components/ProviderIcon";
import UsageFooter from "@/components/UsageFooter";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
interface DragHandleProps {
attributes: DraggableAttributes;
@@ -28,6 +30,10 @@ interface ProviderCardProps {
onConfigureUsage: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onDuplicate: (provider: Provider) => void;
onTest?: (provider: Provider) => void;
isTesting?: boolean;
onSetProxyTarget: (provider: Provider) => void;
isProxyRunning: boolean;
dragHandleProps?: DragHandleProps;
}
@@ -76,6 +82,10 @@ export function ProviderCard({
onConfigureUsage,
onOpenWebsite,
onDuplicate,
onTest,
isTesting,
onSetProxyTarget,
isProxyRunning,
dragHandleProps,
}: ProviderCardProps) {
const { t } = useTranslation();
@@ -164,14 +174,44 @@ export function ProviderCard({
</span>
)}
<span
className={cn(
"rounded-full bg-green-500/10 px-2 py-0.5 text-xs font-medium text-green-500 dark:text-green-400 transition-opacity duration-200",
isCurrent ? "opacity-100" : "opacity-0 pointer-events-none",
)}
>
{t("provider.currentlyUsing")}
</span>
{/* 代理目标开关 - 仅在代理服务运行时显示 */}
{isProxyRunning && (
<div
className="flex items-center gap-2 ml-2"
onClick={(e) => e.stopPropagation()}
>
<Switch
id={`proxy-target-switch-${provider.id}`}
checked={provider.isProxyTarget || false}
onCheckedChange={(checked) => {
if (checked && !provider.isProxyTarget) {
onSetProxyTarget(provider);
}
}}
disabled={provider.isProxyTarget}
className="scale-75 data-[state=checked]:bg-purple-500"
/>
{provider.isProxyTarget && (
<Label
htmlFor={`proxy-target-switch-${provider.id}`}
className="text-xs font-medium text-purple-500 dark:text-purple-400 cursor-pointer"
>
{t("provider.proxyTarget", { defaultValue: "代理目标" })}
</Label>
)}
{!provider.isProxyTarget && (
<Label
htmlFor={`proxy-target-switch-${provider.id}`}
className="text-xs text-muted-foreground cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
>
{t("provider.setAsProxyTarget", {
defaultValue: "设为代理",
})}
</Label>
)}
</div>
)}
</div>
{displayUrl && (
@@ -208,9 +248,11 @@ export function ProviderCard({
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0">
<ProviderActions
isCurrent={isCurrent}
isTesting={isTesting}
onSwitch={() => onSwitch(provider)}
onEdit={() => onEdit(provider)}
onDuplicate={() => onDuplicate(provider)}
onTest={onTest ? () => onTest(provider) : undefined}
onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)}
/>
+30
View File
@@ -9,6 +9,8 @@ import type { CSSProperties } from "react";
import type { Provider } from "@/types";
import type { AppId } from "@/lib/api";
import { useDragSort } from "@/hooks/useDragSort";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useModelTest } from "@/hooks/useModelTest";
import { ProviderCard } from "@/components/providers/ProviderCard";
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
@@ -24,6 +26,7 @@ interface ProviderListProps {
onOpenWebsite: (url: string) => void;
onCreate?: () => void;
isLoading?: boolean;
onSetProxyTarget: (provider: Provider) => void;
}
export function ProviderList({
@@ -38,12 +41,23 @@ export function ProviderList({
onOpenWebsite,
onCreate,
isLoading = false,
onSetProxyTarget,
}: ProviderListProps) {
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
providers,
appId,
);
// 获取代理服务运行状态
const { isRunning: isProxyRunning } = useProxyStatus();
// 模型测试
const { testProvider, isTesting } = useModelTest(appId);
const handleTest = (provider: Provider) => {
testProvider(provider.id, provider.name);
};
if (isLoading) {
return (
<div className="space-y-3">
@@ -87,6 +101,10 @@ export function ProviderList({
onDuplicate={onDuplicate}
onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite}
onTest={handleTest}
isTesting={isTesting(provider.id)}
onSetProxyTarget={onSetProxyTarget}
isProxyRunning={isProxyRunning}
/>
))}
</div>
@@ -105,6 +123,10 @@ interface SortableProviderCardProps {
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onTest: (provider: Provider) => void;
isTesting: boolean;
onSetProxyTarget: (provider: Provider) => void;
isProxyRunning: boolean;
}
function SortableProviderCard({
@@ -117,6 +139,10 @@ function SortableProviderCard({
onDuplicate,
onConfigureUsage,
onOpenWebsite,
onTest,
isTesting,
onSetProxyTarget,
isProxyRunning,
}: SortableProviderCardProps) {
const {
setNodeRef,
@@ -146,6 +172,10 @@ function SortableProviderCard({
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
}
onOpenWebsite={onOpenWebsite}
onTest={onTest}
isTesting={isTesting}
onSetProxyTarget={onSetProxyTarget}
isProxyRunning={isProxyRunning}
dragHandleProps={{
attributes,
listeners,
+222
View File
@@ -0,0 +1,222 @@
import { useState } from "react";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { Settings, Activity, Clock, TrendingUp, Server } from "lucide-react";
import { ProxySettingsDialog } from "./ProxySettingsDialog";
import { toast } from "sonner";
export function ProxyPanel() {
const { status, isRunning, start, stop, isPending } = useProxyStatus();
const [showSettings, setShowSettings] = useState(false);
const handleToggle = async () => {
try {
if (isRunning) {
await stop();
} else {
await start();
}
} catch (error) {
console.error("Toggle proxy failed:", error);
}
};
const formatUptime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`;
} else if (minutes > 0) {
return `${minutes}m ${secs}s`;
} else {
return `${secs}s`;
}
};
return (
<>
<section className="space-y-6 rounded-xl border border-white/10 glass-card p-6">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
<Server className="h-5 w-5" />
</div>
<div>
<h3 className="text-base font-semibold text-foreground">
</h3>
<p className="text-sm text-muted-foreground">
{isRunning
? `运行中 · ${status?.address}:${status?.port}`
: "已停止"}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Badge
variant={isRunning ? "default" : "secondary"}
className="gap-1.5"
>
<Activity
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
/>
{isRunning ? "运行中" : "已停止"}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={() => setShowSettings(true)}
disabled={isPending}
aria-label="打开代理设置"
>
<Settings className="h-4 w-4" />
</Button>
<Switch
checked={isRunning}
onCheckedChange={handleToggle}
disabled={isPending}
aria-label={isRunning ? "停止代理服务" : "启动代理服务"}
/>
</div>
</div>
{isRunning && status ? (
<div className="space-y-6">
<div className="rounded-lg border border-white/10 bg-muted/40 p-4 space-y-4">
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
</p>
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center">
<code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60">
http://{status.address}:{status.port}
</code>
<Button
size="sm"
variant="outline"
onClick={() => {
navigator.clipboard.writeText(
`http://${status.address}:${status.port}`,
);
toast.success("地址已复制");
}}
>
</Button>
</div>
</div>
<div className="pt-3 border-t border-white/10 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
</p>
{status.active_targets && status.active_targets.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2">
{status.active_targets.map((target) => (
<div
key={target.app_type}
className="flex items-center justify-between rounded-md border border-white/10 bg-background/60 px-2 py-1.5 text-xs"
>
<span className="text-muted-foreground">
{target.app_type}
</span>
<span
className="ml-2 font-medium truncate text-foreground"
title={target.provider_name}
>
{target.provider_name}
</span>
</div>
))}
</div>
) : status.current_provider ? (
<p className="text-sm text-muted-foreground">
Provider{" "}
<span className="font-medium text-foreground">
{status.current_provider}
</span>
</p>
) : (
<p className="text-sm text-yellow-600 dark:text-yellow-400">
Provider
</p>
)}
</div>
</div>
<div className="grid gap-3 md:grid-cols-4">
<StatCard
icon={<Activity className="h-4 w-4" />}
label="活跃连接"
value={status.active_connections}
/>
<StatCard
icon={<TrendingUp className="h-4 w-4" />}
label="总请求数"
value={status.total_requests}
/>
<StatCard
icon={<Clock className="h-4 w-4" />}
label="成功率"
value={`${status.success_rate.toFixed(1)}%`}
variant={status.success_rate > 90 ? "success" : "warning"}
/>
<StatCard
icon={<Clock className="h-4 w-4" />}
label="运行时间"
value={formatUptime(status.uptime_seconds)}
/>
</div>
</div>
) : (
<div className="text-center py-10 text-muted-foreground">
<div className="mx-auto w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Server className="h-8 w-8" />
</div>
<p className="text-base font-medium text-foreground mb-1">
</p>
<p className="text-sm text-muted-foreground">
使
</p>
</div>
)}
</section>
<ProxySettingsDialog open={showSettings} onOpenChange={setShowSettings} />
</>
);
}
interface StatCardProps {
icon: React.ReactNode;
label: string;
value: string | number;
variant?: "default" | "success" | "warning";
}
function StatCard({ icon, label, value, variant = "default" }: StatCardProps) {
const variantStyles = {
default: "",
success: "border-green-500/40 bg-green-500/5",
warning: "border-yellow-500/40 bg-yellow-500/5",
};
return (
<div
className={`rounded-lg border border-white/10 bg-white/70 p-4 text-sm text-muted-foreground dark:bg-white/5 ${variantStyles[variant]}`}
>
<div className="flex items-center gap-2 text-muted-foreground mb-2">
{icon}
<span className="text-xs font-medium uppercase tracking-wide">
{label}
</span>
</div>
<p className="text-xl font-semibold text-foreground">{value}</p>
</div>
);
}
@@ -0,0 +1,397 @@
/**
* 代理服务设置对话框
*/
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormDescription,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useProxyConfig } from "@/hooks/useProxyConfig";
import { useEffect, useMemo } from "react";
import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import type { ProxyConfig } from "@/types/proxy";
// 表单数据类型(不包含 enabled 字段,该字段由后端自动管理)
type ProxyConfigForm = Omit<ProxyConfig, "enabled">;
const createProxyConfigSchema = (t: TFunction) => {
const requestTimeoutSchema = z
.number()
.min(
0,
t("proxy.settings.validation.timeoutNonNegative", {
defaultValue: "超时时间不能为负数",
}),
)
.max(
600,
t("proxy.settings.validation.timeoutMax", {
defaultValue: "超时时间最多600秒",
}),
)
.refine((value) => value === 0 || value >= 10, {
message: t("proxy.settings.validation.timeoutRange", {
defaultValue: "请输入 0 或 10-600 之间的数值",
}),
});
return z.object({
listen_address: z.string().regex(
/^(\d{1,3}\.){3}\d{1,3}$/,
t("proxy.settings.validation.addressInvalid", {
defaultValue: "请输入有效的IP地址",
}),
),
listen_port: z
.number()
.min(
1024,
t("proxy.settings.validation.portMin", {
defaultValue: "端口必须大于1024",
}),
)
.max(
65535,
t("proxy.settings.validation.portMax", {
defaultValue: "端口必须小于65535",
}),
),
max_retries: z
.number()
.min(
0,
t("proxy.settings.validation.retryMin", {
defaultValue: "重试次数不能为负",
}),
)
.max(
10,
t("proxy.settings.validation.retryMax", {
defaultValue: "重试次数不能超过10",
}),
),
request_timeout: requestTimeoutSchema,
enable_logging: z.boolean(),
});
};
interface ProxySettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ProxySettingsDialog({
open,
onOpenChange,
}: ProxySettingsDialogProps) {
const { config, isLoading, updateConfig, isUpdating } = useProxyConfig();
const { t } = useTranslation();
const schema = useMemo(() => createProxyConfigSchema(t), [t]);
const closePanel = () => onOpenChange(false);
const form = useForm<ProxyConfigForm>({
resolver: zodResolver(schema),
defaultValues: {
listen_address: "127.0.0.1",
listen_port: 5000,
max_retries: 3,
request_timeout: 300,
enable_logging: true,
},
});
// 当配置加载完成后更新表单
useEffect(() => {
if (config) {
form.reset({
...config,
});
}
}, [config, form]);
const onSubmit = async (data: ProxyConfigForm) => {
try {
// 添加 enabled 字段(从当前配置中获取,保持不变)
const configToSave: ProxyConfig = {
...data,
enabled: config?.enabled ?? true,
};
await updateConfig(configToSave);
closePanel();
} catch (error) {
console.error("Save config failed:", error);
}
};
const formId = "proxy-settings-form";
return (
<FullScreenPanel
isOpen={open}
title={t("proxy.settings.title", { defaultValue: "代理服务设置" })}
onClose={closePanel}
footer={
<>
<Button
type="button"
variant="outline"
onClick={closePanel}
disabled={isUpdating}
>
{t("common.cancel", { defaultValue: "取消" })}
</Button>
<Button
type="submit"
form={formId}
disabled={isUpdating || isLoading}
>
{isUpdating
? t("common.saving", { defaultValue: "保存中..." })
: t("proxy.settings.actions.save", { defaultValue: "保存配置" })}
</Button>
</>
}
>
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
{t("proxy.settings.description", {
defaultValue:
"配置本地代理服务器的监听地址、端口和运行参数,保存后立即生效。",
})}
</p>
<Alert className="border-emerald-500/40 bg-emerald-500/10">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
{t("proxy.settings.alert.autoApply", {
defaultValue:
"保存后将自动同步到正在运行的代理服务,无需手动重启。",
})}
</AlertDescription>
</Alert>
<Form {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<section className="space-y-4 rounded-xl border border-white/10 glass-card p-6">
<div>
<h3 className="text-base font-semibold text-foreground">
{t("proxy.settings.basic.title", {
defaultValue: "基础设置",
})}
</h3>
<p className="text-sm text-muted-foreground">
{t("proxy.settings.basic.description", {
defaultValue: "配置代理服务监听的地址与端口。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="listen_address"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.listenAddress.label", {
defaultValue: "监听地址",
})}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"proxy.settings.fields.listenAddress.placeholder",
{ defaultValue: "127.0.0.1" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.listenAddress.description", {
defaultValue:
"代理服务器监听的 IP 地址(推荐 127.0.0.1",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="listen_port"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.listenPort.label", {
defaultValue: "监听端口",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.listenPort.placeholder",
{ defaultValue: "5000" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.listenPort.description", {
defaultValue:
"代理服务器监听的端口号(1024 ~ 65535",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</section>
<section className="space-y-4 rounded-xl border border-white/10 glass-card p-6">
<div>
<h3 className="text-base font-semibold text-foreground">
{t("proxy.settings.advanced.title", {
defaultValue: "高级参数",
})}
</h3>
<p className="text-sm text-muted-foreground">
{t("proxy.settings.advanced.description", {
defaultValue: "控制请求的稳定性和日志记录。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="max_retries"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.maxRetries.label", {
defaultValue: "最大重试次数",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.maxRetries.placeholder",
{ defaultValue: "3" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.maxRetries.description", {
defaultValue: "请求失败时的重试次数(0 ~ 10)",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="request_timeout"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.requestTimeout.label", {
defaultValue: "请求超时(秒)",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.requestTimeout.placeholder",
{ defaultValue: "0(不限)或 300" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.requestTimeout.description", {
defaultValue:
"单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="enable_logging"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border border-white/10 bg-background/60 p-4">
<div className="space-y-1">
<FormLabel>
{t("proxy.settings.fields.enableLogging.label", {
defaultValue: "启用日志记录",
})}
</FormLabel>
<FormDescription>
{t("proxy.settings.fields.enableLogging.description", {
defaultValue: "记录所有代理请求,便于排查问题",
})}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</section>
</form>
</Form>
</div>
</FullScreenPanel>
);
}
+6
View File
@@ -0,0 +1,6 @@
/**
* 代理功能组件导出
*/
export { ProxyPanel } from "./ProxyPanel";
export { ProxySettingsDialog } from "./ProxySettingsDialog";
+22 -1
View File
@@ -17,6 +17,10 @@ import { WindowSettings } from "@/components/settings/WindowSettings";
import { DirectorySettings } from "@/components/settings/DirectorySettings";
import { ImportExportSection } from "@/components/settings/ImportExportSection";
import { AboutSection } from "@/components/settings/AboutSection";
import { ProxyPanel } from "@/components/proxy";
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
import { UsageDashboard } from "@/components/usage/UsageDashboard";
import { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport";
import { useTranslation } from "react-i18next";
@@ -162,13 +166,16 @@ export function SettingsPage({
onValueChange={setActiveTab}
className="flex flex-col h-full"
>
<TabsList className="grid w-full grid-cols-3 mb-6 glass rounded-lg">
<TabsList className="grid w-full grid-cols-4 mb-6 glass rounded-lg">
<TabsTrigger value="general">
{t("settings.tabGeneral")}
</TabsTrigger>
<TabsTrigger value="advanced">
{t("settings.tabAdvanced")}
</TabsTrigger>
<TabsTrigger value="usage">
{t("usage.title", "使用统计")}
</TabsTrigger>
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
</TabsList>
@@ -205,6 +212,16 @@ export function SettingsPage({
onBrowseDirectory={browseDirectory}
onResetDirectory={resetDirectory}
/>
{/* 代理服务面板 */}
<ProxyPanel />
{/* 模型定价配置 */}
<PricingConfigPanel />
{/* 模型测试配置 */}
<ModelTestConfigPanel />
<ImportExportSection
status={importStatus}
selectedFile={selectedFile}
@@ -242,6 +259,10 @@ export function SettingsPage({
<TabsContent value="about" className="mt-0">
<AboutSection isPortable={isPortable} />
</TabsContent>
<TabsContent value="usage" className="mt-0">
<UsageDashboard />
</TabsContent>
</div>
</Tabs>
)}
+59
View File
@@ -0,0 +1,59 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };
+75
View File
@@ -0,0 +1,75 @@
import { useTranslation } from "react-i18next";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useModelStats } from "@/lib/query/usage";
export function ModelStatsTable() {
const { t } = useTranslation();
const { data: stats, isLoading } = useModelStats();
if (isLoading) {
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
}
return (
<div className="rounded-md bg-card/60 shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("usage.model", "模型")}</TableHead>
<TableHead className="text-right">
{t("usage.requests", "请求数")}
</TableHead>
<TableHead className="text-right">
{t("usage.tokens", "Tokens")}
</TableHead>
<TableHead className="text-right">
{t("usage.totalCost", "总成本")}
</TableHead>
<TableHead className="text-right">
{t("usage.avgCost", "平均成本")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats?.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="text-center text-muted-foreground"
>
{t("usage.noData", "暂无数据")}
</TableCell>
</TableRow>
) : (
stats?.map((stat) => (
<TableRow key={stat.model}>
<TableCell className="font-mono text-sm">
{stat.model}
</TableCell>
<TableCell className="text-right">
{stat.requestCount.toLocaleString()}
</TableCell>
<TableCell className="text-right">
{stat.totalTokens.toLocaleString()}
</TableCell>
<TableCell className="text-right">
${parseFloat(stat.totalCost).toFixed(4)}
</TableCell>
<TableCell className="text-right">
${parseFloat(stat.avgCostPerRequest).toFixed(6)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,225 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ChevronDown, ChevronRight, Save, Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
getModelTestConfig,
saveModelTestConfig,
type ModelTestConfig,
} from "@/lib/api/model-test";
export function ModelTestConfigPanel() {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [config, setConfig] = useState<ModelTestConfig>({
claudeModel: "claude-haiku-4-5-20251001",
codexModel: "gpt-5.1-low",
geminiModel: "gemini-3-pro-low",
testPrompt: "ping",
timeoutSecs: 15,
});
useEffect(() => {
loadConfig();
}, []);
async function loadConfig() {
try {
setIsLoading(true);
setError(null);
const data = await getModelTestConfig();
setConfig(data);
} catch (e) {
setError(String(e));
} finally {
setIsLoading(false);
}
}
async function handleSave() {
try {
setIsSaving(true);
await saveModelTestConfig(config);
toast.success(t("modelTest.configSaved", "模型测试配置已保存"));
} catch (e) {
toast.error(
t("modelTest.configSaveFailed", "保存失败") + ": " + String(e),
);
} finally {
setIsSaving(false);
}
}
if (isLoading) {
return (
<Card className="border rounded-lg">
<CardHeader
className="cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<ChevronRight className="h-4 w-4" />
<CardTitle className="text-base">
{t("modelTest.configTitle", "模型测试配置")}
</CardTitle>
</div>
</CardHeader>
</Card>
);
}
return (
<Card className="border rounded-lg">
<CardHeader
className="cursor-pointer select-none"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<div>
<CardTitle className="text-base">
{t("modelTest.configTitle", "模型测试配置")}
</CardTitle>
{!isExpanded && (
<CardDescription className="mt-1">
{t(
"modelTest.configDesc",
"配置模型测试使用的默认模型和提示词",
)}
</CardDescription>
)}
</div>
</div>
</CardHeader>
{isExpanded && (
<CardContent className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="claudeModel">
{t("modelTest.claudeModel", "Claude 测试模型")}
</Label>
<Input
id="claudeModel"
value={config.claudeModel}
onChange={(e) =>
setConfig({ ...config, claudeModel: e.target.value })
}
placeholder="claude-haiku-4-5-20251001"
/>
</div>
<div className="space-y-2">
<Label htmlFor="codexModel">
{t("modelTest.codexModel", "Codex 测试模型")}
</Label>
<Input
id="codexModel"
value={config.codexModel}
onChange={(e) =>
setConfig({ ...config, codexModel: e.target.value })
}
placeholder="gpt-5.1-low"
/>
</div>
<div className="space-y-2">
<Label htmlFor="geminiModel">
{t("modelTest.geminiModel", "Gemini 测试模型")}
</Label>
<Input
id="geminiModel"
value={config.geminiModel}
onChange={(e) =>
setConfig({ ...config, geminiModel: e.target.value })
}
placeholder="gemini-3-pro-low"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="testPrompt">
{t("modelTest.testPrompt", "测试提示词")}
</Label>
<Input
id="testPrompt"
value={config.testPrompt}
onChange={(e) =>
setConfig({ ...config, testPrompt: e.target.value })
}
placeholder="ping"
/>
<p className="text-xs text-muted-foreground">
{t(
"modelTest.testPromptHint",
"发送给模型的测试消息,建议使用简短内容以减少 token 消耗",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="timeoutSecs">
{t("modelTest.timeout", "超时时间(秒)")}
</Label>
<Input
id="timeoutSecs"
type="number"
min={5}
max={60}
value={config.timeoutSecs}
onChange={(e) =>
setConfig({
...config,
timeoutSecs: parseInt(e.target.value) || 15,
})
}
/>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("common.saving", "保存中...")}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t("common.save", "保存")}
</>
)}
</Button>
</div>
</CardContent>
)}
</Card>
);
}
+289
View File
@@ -0,0 +1,289 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useModelPricing, useDeleteModelPricing } from "@/lib/query/usage";
import { PricingEditModal } from "./PricingEditModal";
import type { ModelPricing } from "@/types/usage";
import { Plus, Pencil, Trash2, ChevronDown, ChevronRight } from "lucide-react";
export function PricingConfigPanel() {
const { t } = useTranslation();
const { data: pricing, isLoading, error } = useModelPricing();
const deleteMutation = useDeleteModelPricing();
const [editingModel, setEditingModel] = useState<ModelPricing | null>(null);
const [isAddingNew, setIsAddingNew] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
const handleDelete = (modelId: string) => {
deleteMutation.mutate(modelId, {
onSuccess: () => {
setDeleteConfirm(null);
},
});
};
const handleAddNew = () => {
setIsAddingNew(true);
setEditingModel({
modelId: "",
displayName: "",
inputCostPerMillion: "0",
outputCostPerMillion: "0",
cacheReadCostPerMillion: "0",
cacheCreationCostPerMillion: "0",
});
};
if (isLoading) {
return (
<Card className="border rounded-lg">
<CardHeader
className="cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<ChevronRight className="h-4 w-4" />
<CardTitle className="text-base">
{t("usage.modelPricing", "模型定价")}
</CardTitle>
</div>
</CardHeader>
</Card>
);
}
if (error) {
return (
<Card className="border rounded-lg">
<CardHeader
className="cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<CardTitle className="text-base">
{t("usage.modelPricing", "模型定价")}
</CardTitle>
</div>
</CardHeader>
{isExpanded && (
<CardContent>
<Alert variant="destructive">
<AlertDescription>
{t("usage.loadPricingError", "加载定价数据失败")}:{" "}
{String(error)}
</AlertDescription>
</Alert>
</CardContent>
)}
</Card>
);
}
return (
<Card className="border rounded-lg">
<CardHeader
className="cursor-pointer select-none"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<div>
<CardTitle className="text-base">
{t("usage.modelPricing", "模型定价")}
{pricing && pricing.length > 0 && (
<span className="ml-2 text-sm font-normal text-muted-foreground">
({pricing.length})
</span>
)}
</CardTitle>
{!isExpanded && (
<CardDescription className="mt-1">
{t(
"usage.modelPricingDesc",
"配置各模型的 Token 成本(每百万 tokens 的 USD 价格,支持 * 与 ? 通配)",
)}
</CardDescription>
)}
</div>
</div>
<Button
onClick={(e) => {
e.stopPropagation();
handleAddNew();
}}
size="sm"
>
<Plus className="mr-1 h-4 w-4" />
{t("common.add", "新增")}
</Button>
</div>
</CardHeader>
{isExpanded && (
<CardContent>
{!pricing || pricing.length === 0 ? (
<Alert>
<AlertDescription>
{t(
"usage.noPricingData",
'暂无定价数据。点击"新增"添加模型定价配置。',
)}
</AlertDescription>
</Alert>
) : (
<div className="rounded-md bg-card/60 shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("usage.model", "模型")}</TableHead>
<TableHead>{t("usage.displayName", "显示名称")}</TableHead>
<TableHead className="text-right">
{t("usage.inputCost", "输入成本")}
</TableHead>
<TableHead className="text-right">
{t("usage.outputCost", "输出成本")}
</TableHead>
<TableHead className="text-right">
{t("usage.cacheReadCost", "缓存读取")}
</TableHead>
<TableHead className="text-right">
{t("usage.cacheWriteCost", "缓存写入")}
</TableHead>
<TableHead className="text-right">
{t("common.actions", "操作")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pricing.map((model) => (
<TableRow key={model.modelId}>
<TableCell className="font-mono text-sm">
{model.modelId}
</TableCell>
<TableCell>{model.displayName}</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.inputCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.outputCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.cacheReadCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.cacheCreationCostPerMillion}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => {
setIsAddingNew(false);
setEditingModel(model);
}}
title={t("common.edit", "编辑")}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteConfirm(model.modelId)}
title={t("common.delete", "删除")}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
)}
{editingModel && (
<PricingEditModal
model={editingModel}
isNew={isAddingNew}
onClose={() => {
setEditingModel(null);
setIsAddingNew(false);
}}
/>
)}
<Dialog
open={!!deleteConfirm}
onOpenChange={() => setDeleteConfirm(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("usage.deleteConfirmTitle", "确认删除")}
</DialogTitle>
<DialogDescription>
{t(
"usage.deleteConfirmDesc",
"确定要删除此模型定价配置吗?此操作无法撤销。",
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
{t("common.cancel", "取消")}
</Button>
<Button
variant="destructive"
onClick={() => deleteConfirm && handleDelete(deleteConfirm)}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending
? t("common.deleting", "删除中...")
: t("common.delete", "删除")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
+219
View File
@@ -0,0 +1,219 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useUpdateModelPricing } from "@/lib/query/usage";
import type { ModelPricing } from "@/types/usage";
interface PricingEditModalProps {
model: ModelPricing;
isNew?: boolean;
onClose: () => void;
}
export function PricingEditModal({
model,
isNew = false,
onClose,
}: PricingEditModalProps) {
const { t } = useTranslation();
const updatePricing = useUpdateModelPricing();
const [formData, setFormData] = useState({
modelId: model.modelId,
displayName: model.displayName,
inputCost: model.inputCostPerMillion,
outputCost: model.outputCostPerMillion,
cacheReadCost: model.cacheReadCostPerMillion,
cacheCreationCost: model.cacheCreationCostPerMillion,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// 验证模型 ID
if (isNew && !formData.modelId.trim()) {
toast.error(t("usage.modelIdRequired", "模型 ID 不能为空"));
return;
}
// 验证非负数
const values = [
formData.inputCost,
formData.outputCost,
formData.cacheReadCost,
formData.cacheCreationCost,
];
for (const value of values) {
const num = parseFloat(value);
if (isNaN(num) || num < 0) {
toast.error(t("usage.invalidPrice", "价格必须为非负数"));
return;
}
}
try {
await updatePricing.mutateAsync({
modelId: isNew ? formData.modelId : model.modelId,
displayName: formData.displayName,
inputCost: formData.inputCost,
outputCost: formData.outputCost,
cacheReadCost: formData.cacheReadCost,
cacheCreationCost: formData.cacheCreationCost,
});
toast.success(
isNew
? t("usage.pricingAdded", "定价已添加")
: t("usage.pricingUpdated", "定价已更新"),
);
onClose();
} catch (error) {
toast.error(String(error));
}
};
return (
<Dialog open onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isNew
? t("usage.addPricing", "新增定价")
: `${t("usage.editPricing", "编辑定价")} - ${model.modelId}`}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{isNew && (
<div className="space-y-2">
<Label htmlFor="modelId">{t("usage.modelId", "模型 ID")}</Label>
<Input
id="modelId"
value={formData.modelId}
onChange={(e) =>
setFormData({ ...formData, modelId: e.target.value })
}
placeholder="例如: claude-3-5-sonnet-20241022"
required
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="displayName">
{t("usage.displayName", "显示名称")}
</Label>
<Input
id="displayName"
value={formData.displayName}
onChange={(e) =>
setFormData({ ...formData, displayName: e.target.value })
}
placeholder="例如: Claude 3.5 Sonnet"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="inputCost">
{t("usage.inputCostPerMillion", "输入成本 (每百万 tokens, USD)")}
</Label>
<Input
id="inputCost"
type="number"
step="0.01"
min="0"
value={formData.inputCost}
onChange={(e) =>
setFormData({ ...formData, inputCost: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="outputCost">
{t("usage.outputCostPerMillion", "输出成本 (每百万 tokens, USD)")}
</Label>
<Input
id="outputCost"
type="number"
step="0.01"
min="0"
value={formData.outputCost}
onChange={(e) =>
setFormData({ ...formData, outputCost: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="cacheReadCost">
{t(
"usage.cacheReadCostPerMillion",
"缓存读取成本 (每百万 tokens, USD)",
)}
</Label>
<Input
id="cacheReadCost"
type="number"
step="0.01"
min="0"
value={formData.cacheReadCost}
onChange={(e) =>
setFormData({ ...formData, cacheReadCost: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="cacheCreationCost">
{t(
"usage.cacheCreationCostPerMillion",
"缓存写入成本 (每百万 tokens, USD)",
)}
</Label>
<Input
id="cacheCreationCost"
type="number"
step="0.01"
min="0"
value={formData.cacheCreationCost}
onChange={(e) =>
setFormData({ ...formData, cacheCreationCost: e.target.value })
}
required
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel", "取消")}
</Button>
<Button type="submit" disabled={updatePricing.isPending}>
{updatePricing.isPending
? t("common.saving", "保存中...")
: isNew
? t("common.add", "新增")
: t("common.save", "保存")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,81 @@
import { useTranslation } from "react-i18next";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useProviderStats } from "@/lib/query/usage";
export function ProviderStatsTable() {
const { t } = useTranslation();
const { data: stats, isLoading } = useProviderStats();
if (isLoading) {
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
}
return (
<div className="rounded-md bg-card/60 shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("usage.provider", "Provider")}</TableHead>
<TableHead className="text-right">
{t("usage.requests", "请求数")}
</TableHead>
<TableHead className="text-right">
{t("usage.tokens", "Tokens")}
</TableHead>
<TableHead className="text-right">
{t("usage.cost", "成本")}
</TableHead>
<TableHead className="text-right">
{t("usage.successRate", "成功率")}
</TableHead>
<TableHead className="text-right">
{t("usage.avgLatency", "平均延迟")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats?.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
{t("usage.noData", "暂无数据")}
</TableCell>
</TableRow>
) : (
stats?.map((stat) => (
<TableRow key={stat.providerId}>
<TableCell className="font-medium">
{stat.providerName}
</TableCell>
<TableCell className="text-right">
{stat.requestCount.toLocaleString()}
</TableCell>
<TableCell className="text-right">
{stat.totalTokens.toLocaleString()}
</TableCell>
<TableCell className="text-right">
${parseFloat(stat.totalCost).toFixed(4)}
</TableCell>
<TableCell className="text-right">
{stat.successRate.toFixed(1)}%
</TableCell>
<TableCell className="text-right">
{stat.avgLatencyMs}ms
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}
+247
View File
@@ -0,0 +1,247 @@
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useRequestDetail } from "@/lib/query/usage";
interface RequestDetailPanelProps {
requestId: string;
onClose: () => void;
}
export function RequestDetailPanel({
requestId,
onClose,
}: RequestDetailPanelProps) {
const { t } = useTranslation();
const { data: request, isLoading } = useRequestDetail(requestId);
if (isLoading) {
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<div className="h-[400px] animate-pulse rounded bg-gray-100" />
</DialogContent>
</Dialog>
);
}
if (!request) {
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t("usage.requestDetail", "请求详情")}</DialogTitle>
</DialogHeader>
<div className="text-center text-muted-foreground">
{t("usage.requestNotFound", "请求未找到")}
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t("usage.requestDetail", "请求详情")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 基本信息 */}
<div className="rounded-lg border p-4">
<h3 className="mb-3 font-semibold">
{t("usage.basicInfo", "基本信息")}
</h3>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div>
<dt className="text-muted-foreground">
{t("usage.requestId", "请求ID")}
</dt>
<dd className="font-mono">{request.requestId}</dd>
</div>
<div>
<dt className="text-muted-foreground">
{t("usage.time", "时间")}
</dt>
<dd>
{new Date(request.createdAt * 1000).toLocaleString("zh-CN")}
</dd>
</div>
<div>
<dt className="text-muted-foreground">
{t("usage.provider", "供应商")}
</dt>
<dd className="text-sm">
<span className="font-medium">
{request.providerName || t("usage.unknownProvider", "未知")}
</span>
<span className="ml-2 font-mono text-xs text-muted-foreground">
{request.providerId}
</span>
</dd>
</div>
<div>
<dt className="text-muted-foreground">
{t("usage.appType", "应用类型")}
</dt>
<dd>{request.appType}</dd>
</div>
<div>
<dt className="text-muted-foreground">
{t("usage.model", "模型")}
</dt>
<dd className="font-mono">{request.model}</dd>
</div>
<div>
<dt className="text-muted-foreground">
{t("usage.status", "状态")}
</dt>
<dd>
<span
className={`inline-flex rounded-full px-2 py-1 text-xs ${
request.statusCode >= 200 && request.statusCode < 300
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{request.statusCode}
</span>
</dd>
</div>
</dl>
</div>
{/* Token 使用量 */}
<div className="rounded-lg border p-4">
<h3 className="mb-3 font-semibold">
{t("usage.tokenUsage", "Token 使用量")}
</h3>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div>
<dt className="text-muted-foreground">
{t("usage.inputTokens", "输入 Tokens")}
</dt>
<dd className="font-mono">
{request.inputTokens.toLocaleString()}
</dd>
</div>
<div>
<dt className="text-muted-foreground">
{t("usage.outputTokens", "输出 Tokens")}
</dt>
<dd className="font-mono">
{request.outputTokens.toLocaleString()}
</dd>
</div>
<div>
<dt className="text-muted-foreground">
{t("usage.cacheReadTokens", "缓存读取")}
</dt>
<dd className="font-mono">
{request.cacheReadTokens.toLocaleString()}
</dd>
</div>
<div>
<dt className="text-muted-foreground">
{t("usage.cacheCreationTokens", "缓存写入")}
</dt>
<dd className="font-mono">
{request.cacheCreationTokens.toLocaleString()}
</dd>
</div>
<div className="col-span-2">
<dt className="text-muted-foreground">
{t("usage.totalTokens", "总计")}
</dt>
<dd className="text-lg font-semibold">
{(
request.inputTokens + request.outputTokens
).toLocaleString()}
</dd>
</div>
</dl>
</div>
{/* 成本明细 */}
<div className="rounded-lg border p-4">
<h3 className="mb-3 font-semibold">
{t("usage.costBreakdown", "成本明细")}
</h3>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div>
<dt className="text-muted-foreground">
{t("usage.inputCost", "输入成本")}
</dt>
<dd className="font-mono">
${parseFloat(request.inputCostUsd).toFixed(6)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">
{t("usage.outputCost", "输出成本")}
</dt>
<dd className="font-mono">
${parseFloat(request.outputCostUsd).toFixed(6)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">
{t("usage.cacheReadCost", "缓存读取成本")}
</dt>
<dd className="font-mono">
${parseFloat(request.cacheReadCostUsd).toFixed(6)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">
{t("usage.cacheCreationCost", "缓存写入成本")}
</dt>
<dd className="font-mono">
${parseFloat(request.cacheCreationCostUsd).toFixed(6)}
</dd>
</div>
<div className="col-span-2 border-t pt-3">
<dt className="text-muted-foreground">
{t("usage.totalCost", "总成本")}
</dt>
<dd className="text-lg font-semibold text-primary">
${parseFloat(request.totalCostUsd).toFixed(6)}
</dd>
</div>
</dl>
</div>
{/* 性能信息 */}
<div className="rounded-lg border p-4">
<h3 className="mb-3 font-semibold">
{t("usage.performance", "性能信息")}
</h3>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div>
<dt className="text-muted-foreground">
{t("usage.latency", "延迟")}
</dt>
<dd className="font-mono">{request.latencyMs}ms</dd>
</div>
</dl>
</div>
{/* 错误信息 */}
{request.errorMessage && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
<h3 className="mb-2 font-semibold text-red-800">
{t("usage.errorMessage", "错误信息")}
</h3>
<p className="text-sm text-red-700">{request.errorMessage}</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
+400
View File
@@ -0,0 +1,400 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useRequestLogs, usageKeys } from "@/lib/query/usage";
import { useQueryClient } from "@tanstack/react-query";
import type { LogFilters } from "@/types/usage";
import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react";
export function RequestLogTable() {
const { t } = useTranslation();
const queryClient = useQueryClient();
// 默认时间范围:过去24小时
const getDefaultFilters = (): LogFilters => {
const now = Math.floor(Date.now() / 1000);
const oneDayAgo = now - 24 * 60 * 60;
return { startDate: oneDayAgo, endDate: now };
};
const [filters, setFilters] = useState<LogFilters>(getDefaultFilters);
const [tempFilters, setTempFilters] = useState<LogFilters>(getDefaultFilters);
const [page, setPage] = useState(0);
const pageSize = 20;
const { data: result, isLoading } = useRequestLogs(filters, page, pageSize);
const logs = result?.data ?? [];
const total = result?.total ?? 0;
const totalPages = Math.ceil(total / pageSize);
const handleSearch = () => {
setFilters(tempFilters);
setPage(0);
};
const handleReset = () => {
const defaults = getDefaultFilters();
setTempFilters(defaults);
setFilters(defaults);
setPage(0);
};
const handleRefresh = () => {
queryClient.invalidateQueries({
queryKey: usageKeys.logs(filters, page, pageSize),
});
};
return (
<div className="space-y-4">
{/* 筛选栏 */}
<div className="flex flex-wrap items-center gap-2 rounded-md bg-card/60 p-3 shadow-sm">
<Select
value={tempFilters.appType || "all"}
onValueChange={(v) =>
setTempFilters({
...tempFilters,
appType: v === "all" ? undefined : v,
})
}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder={t("usage.endpoint", "端点")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("common.all", "全部")}</SelectItem>
<SelectItem value="claude">Claude</SelectItem>
<SelectItem value="codex">Codex</SelectItem>
<SelectItem value="gemini">Gemini</SelectItem>
</SelectContent>
</Select>
<Select
value={tempFilters.statusCode?.toString() || "all"}
onValueChange={(v) =>
setTempFilters({
...tempFilters,
statusCode: v === "all" ? undefined : parseInt(v),
})
}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder={t("usage.status", "状态码")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("common.all", "全部")}</SelectItem>
<SelectItem value="200">200</SelectItem>
<SelectItem value="400">400</SelectItem>
<SelectItem value="401">401</SelectItem>
<SelectItem value="429">429</SelectItem>
<SelectItem value="500">500</SelectItem>
</SelectContent>
</Select>
<Input
placeholder={t("usage.provider", "供应商名称")}
className="w-[140px]"
value={tempFilters.providerName || ""}
onChange={(e) =>
setTempFilters({
...tempFilters,
providerName: e.target.value || undefined,
})
}
/>
<Input
placeholder={t("usage.model", "模型名称")}
className="w-[140px]"
value={tempFilters.model || ""}
onChange={(e) =>
setTempFilters({
...tempFilters,
model: e.target.value || undefined,
})
}
/>
<Input
type="datetime-local"
className="w-[180px]"
value={
tempFilters.startDate
? new Date(tempFilters.startDate * 1000)
.toISOString()
.slice(0, 16)
: ""
}
onChange={(e) =>
setTempFilters({
...tempFilters,
startDate: e.target.value
? Math.floor(new Date(e.target.value).getTime() / 1000)
: undefined,
})
}
/>
<Input
type="datetime-local"
className="w-[180px]"
value={
tempFilters.endDate
? new Date(tempFilters.endDate * 1000).toISOString().slice(0, 16)
: ""
}
onChange={(e) =>
setTempFilters({
...tempFilters,
endDate: e.target.value
? Math.floor(new Date(e.target.value).getTime() / 1000)
: undefined,
})
}
/>
<div className="ml-auto flex gap-2">
<Button size="sm" onClick={handleSearch}>
<Search className="mr-1 h-4 w-4" />
{t("common.search", "查询")}
</Button>
<Button size="sm" variant="outline" onClick={handleReset}>
<X className="mr-1 h-4 w-4" />
{t("common.reset", "重置")}
</Button>
<Button size="sm" variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
{isLoading ? (
<div className="h-[400px] animate-pulse rounded bg-gray-100" />
) : (
<>
<div className="rounded-md bg-card/60 shadow-sm overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("usage.time", "时间")}</TableHead>
<TableHead>{t("usage.provider", "供应商")}</TableHead>
<TableHead className="min-w-[280px]">
{t("usage.billingModel", "计费模型")}
</TableHead>
<TableHead className="text-right">
{t("usage.inputTokens", "输入")}
</TableHead>
<TableHead className="text-right">
{t("usage.outputTokens", "输出")}
</TableHead>
<TableHead className="text-right min-w-[90px]">
{t("usage.cacheCreationTokens", "缓存写入")}
</TableHead>
<TableHead className="text-right min-w-[90px]">
{t("usage.cacheReadTokens", "缓存读取")}
</TableHead>
<TableHead className="text-right">
{t("usage.totalCost", "成本")}
</TableHead>
<TableHead className="text-center min-w-[140px]">
{t("usage.timingInfo", "用时/首字")}
</TableHead>
<TableHead>{t("usage.status", "状态")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.length === 0 ? (
<TableRow>
<TableCell
colSpan={10}
className="text-center text-muted-foreground"
>
{t("usage.noData", "暂无数据")}
</TableCell>
</TableRow>
) : (
logs.map((log) => (
<TableRow key={log.requestId}>
<TableCell>
{new Date(log.createdAt * 1000).toLocaleString("zh-CN")}
</TableCell>
<TableCell>
{log.providerName ||
t("usage.unknownProvider", "未知供应商")}
</TableCell>
<TableCell
className="font-mono text-sm max-w-[280px] truncate"
title={log.model}
>
{log.model}
</TableCell>
<TableCell className="text-right">
{log.inputTokens.toLocaleString()}
</TableCell>
<TableCell className="text-right">
{log.outputTokens.toLocaleString()}
</TableCell>
<TableCell className="text-right">
{log.cacheCreationTokens.toLocaleString()}
</TableCell>
<TableCell className="text-right">
{log.cacheReadTokens.toLocaleString()}
</TableCell>
<TableCell className="text-right">
${parseFloat(log.totalCostUsd).toFixed(6)}
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
{(() => {
const durationSec =
(log.durationMs ?? log.latencyMs) / 1000;
const durationColor =
durationSec <= 5
? "bg-green-100 text-green-800"
: durationSec <= 120
? "bg-orange-100 text-orange-800"
: "bg-red-200 text-red-900";
return (
<span
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${durationColor}`}
>
{Math.round(durationSec)}s
</span>
);
})()}
{log.isStreaming &&
log.firstTokenMs != null &&
(() => {
const firstSec = log.firstTokenMs / 1000;
const firstColor =
firstSec <= 5
? "bg-green-100 text-green-800"
: firstSec <= 120
? "bg-orange-100 text-orange-800"
: "bg-red-200 text-red-900";
return (
<span
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${firstColor}`}
>
{firstSec.toFixed(1)}s
</span>
);
})()}
<span
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${
log.isStreaming
? "bg-blue-100 text-blue-800"
: "bg-purple-100 text-purple-800"
}`}
>
{log.isStreaming
? t("usage.stream", "流")
: t("usage.nonStream", "非流")}
</span>
</div>
</TableCell>
<TableCell>
<span
className={`inline-flex rounded-full px-2 py-1 text-xs ${
log.statusCode >= 200 && log.statusCode < 300
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{log.statusCode}
</span>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 分页控件 */}
{total > 0 && (
<div className="flex items-center justify-between px-2">
<span className="text-sm text-muted-foreground">
{t("usage.totalRecords", "共 {{total}} 条记录", { total })}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
>
<ChevronLeft className="h-4 w-4" />
</Button>
{/* 页码按钮 */}
{(() => {
const pages: (number | string)[] = [];
if (totalPages <= 7) {
for (let i = 0; i < totalPages; i++) pages.push(i);
} else {
pages.push(0);
if (page > 2) pages.push("...");
for (
let i = Math.max(1, page - 1);
i <= Math.min(totalPages - 2, page + 1);
i++
) {
pages.push(i);
}
if (page < totalPages - 3) pages.push("...");
pages.push(totalPages - 1);
}
return pages.map((p, idx) =>
typeof p === "string" ? (
<span
key={`ellipsis-${idx}`}
className="px-2 text-muted-foreground"
>
...
</span>
) : (
<Button
key={p}
variant={p === page ? "default" : "outline"}
size="sm"
className="h-8 w-8 p-0"
onClick={() => setPage(p)}
>
{p + 1}
</Button>
),
);
})()}
<Button
variant="outline"
size="sm"
onClick={() => setPage(page + 1)}
disabled={page >= totalPages - 1}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card } from "@/components/ui/card";
import { UsageSummaryCards } from "./UsageSummaryCards";
import { UsageTrendChart } from "./UsageTrendChart";
import { RequestLogTable } from "./RequestLogTable";
import { ProviderStatsTable } from "./ProviderStatsTable";
import { ModelStatsTable } from "./ModelStatsTable";
import type { TimeRange } from "@/types/usage";
export function UsageDashboard() {
const { t } = useTranslation();
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30;
return (
<div className="space-y-6">
<div className="flex items-center justify-end">
<select
value={timeRange}
onChange={(e) => setTimeRange(e.target.value as TimeRange)}
className="rounded-md border px-3 py-1.5 text-sm"
>
<option value="1d">{t("usage.today", "今天")}</option>
<option value="7d">{t("usage.last7days", "过去 7 天")}</option>
<option value="30d">{t("usage.last30days", "过去 30 天")}</option>
</select>
</div>
<UsageSummaryCards days={days} />
<Card className="border-none bg-transparent p-0 shadow-none">
<UsageTrendChart days={days} />
</Card>
<Tabs defaultValue="logs" className="w-full">
<TabsList>
<TabsTrigger value="logs">
{t("usage.requestLogs", "请求日志")}
</TabsTrigger>
<TabsTrigger value="providers">
{t("usage.providerStats", "Provider 统计")}
</TabsTrigger>
<TabsTrigger value="models">
{t("usage.modelStats", "模型统计")}
</TabsTrigger>
</TabsList>
<TabsContent value="logs" className="mt-4">
<RequestLogTable />
</TabsContent>
<TabsContent value="providers" className="mt-4">
<ProviderStatsTable />
</TabsContent>
<TabsContent value="models" className="mt-4">
<ModelStatsTable />
</TabsContent>
</Tabs>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useUsageSummary } from "@/lib/query/usage";
interface UsageSummaryCardsProps {
days: number;
}
export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
const { t } = useTranslation();
const endDate = Math.floor(Date.now() / 1000);
const startDate = endDate - days * 24 * 60 * 60;
const { data: summary, isLoading } = useUsageSummary(startDate, endDate);
const totalRequests = summary?.totalRequests ?? 0;
const totalCost = parseFloat(summary?.totalCost || "0").toFixed(4);
const totalInputTokens = summary?.totalInputTokens ?? 0;
const totalOutputTokens = summary?.totalOutputTokens ?? 0;
const totalTokens = totalInputTokens + totalOutputTokens;
const cacheWriteTokens = summary?.totalCacheCreationTokens ?? 0;
const cacheReadTokens = summary?.totalCacheReadTokens ?? 0;
const totalCacheTokens = cacheWriteTokens + cacheReadTokens;
if (isLoading) {
return (
<div className="grid gap-4 md:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2">
<div className="h-4 w-24 animate-pulse rounded bg-gray-200" />
</CardHeader>
<CardContent>
<div className="h-8 w-32 animate-pulse rounded bg-gray-200" />
</CardContent>
</Card>
))}
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t("usage.totalRequests", "总请求数")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{totalRequests.toLocaleString()}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t("usage.totalCost", "总成本")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">${totalCost}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t("usage.totalTokens", "总 Token 数")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{totalTokens.toLocaleString()}
</div>
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
<div>
{t("usage.inputTokens", "输入")}:{" "}
{totalInputTokens.toLocaleString()}
</div>
<div>
{t("usage.outputTokens", "输出")}:{" "}
{totalOutputTokens.toLocaleString()}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t("usage.cacheTokens", "缓存 Token")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{totalCacheTokens.toLocaleString()}
</div>
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
<div>
{t("usage.cacheWrite", "写入")}:{" "}
{cacheWriteTokens.toLocaleString()}
</div>
<div>
{t("usage.cacheRead", "读取")}: {cacheReadTokens.toLocaleString()}
</div>
</div>
</CardContent>
</Card>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { useTranslation } from "react-i18next";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import { useUsageTrends } from "@/lib/query/usage";
interface UsageTrendChartProps {
days: number;
}
export function UsageTrendChart({ days }: UsageTrendChartProps) {
const { t } = useTranslation();
const { data: trends, isLoading } = useUsageTrends(days);
if (isLoading) {
return <div className="h-[320px] animate-pulse rounded bg-gray-100" />;
}
const isToday = days === 1;
const chartData =
trends?.map((stat) => {
const pointDate = new Date(stat.date);
return {
rawDate: stat.date,
label: isToday
? pointDate.toLocaleTimeString("zh-CN", { hour: "2-digit" })
: pointDate.toLocaleDateString("zh-CN", {
month: "2-digit",
day: "2-digit",
}),
hour: pointDate.getHours(),
inputTokens: stat.totalInputTokens,
outputTokens: stat.totalOutputTokens,
cacheCreationTokens: stat.totalCacheCreationTokens,
cacheReadTokens: stat.totalCacheReadTokens,
cost: parseFloat(stat.totalCost),
};
}) || [];
const hourlyData = (() => {
if (!isToday) return chartData;
const map = new Map<number, (typeof chartData)[number]>();
chartData.forEach((point) => {
map.set(point.hour ?? 0, point);
});
return Array.from({ length: 24 }, (_, hour) => {
const bucket = map.get(hour);
return {
label: `${hour.toString().padStart(2, "0")}:00`,
inputTokens: bucket?.inputTokens ?? 0,
outputTokens: bucket?.outputTokens ?? 0,
cacheCreationTokens: bucket?.cacheCreationTokens ?? 0,
cacheReadTokens: bucket?.cacheReadTokens ?? 0,
cost: bucket?.cost ?? 0,
};
});
})();
const displayData = isToday ? hourlyData : chartData;
const rangeLabel = isToday
? t("usage.rangeToday", "今天 (按小时)")
: days === 7
? t("usage.rangeLast7Days", "过去 7 天")
: t("usage.rangeLast30Days", "过去 30 天");
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">
{t("usage.trends", "使用趋势")}
</h3>
<p className="text-sm text-muted-foreground">{rangeLabel}</p>
</div>
<ResponsiveContainer width="100%" height={320}>
<LineChart data={displayData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="label" />
<YAxis
yAxisId="tokens"
label={{
value: t("usage.tokensAxis", "Tokens"),
angle: -90,
position: "insideLeft",
}}
/>
<YAxis
yAxisId="cost"
orientation="right"
label={{
value: t("usage.costAxis", "成本 (USD)"),
angle: 90,
position: "insideRight",
}}
/>
<Tooltip />
<Legend />
<Line
yAxisId="tokens"
type="monotone"
dataKey="inputTokens"
name={t("usage.inputTokens", "输入 Tokens")}
stroke="#2563eb"
strokeWidth={2}
dot={false}
isAnimationActive
/>
<Line
yAxisId="tokens"
type="monotone"
dataKey="outputTokens"
name={t("usage.outputTokens", "输出 Tokens")}
stroke="#16a34a"
strokeWidth={2}
dot={false}
isAnimationActive
/>
<Line
yAxisId="tokens"
type="monotone"
dataKey="cacheCreationTokens"
name={t("usage.cacheCreationTokens", "缓存写入")}
stroke="#f97316"
strokeWidth={2}
dot={false}
isAnimationActive
/>
<Line
yAxisId="tokens"
type="monotone"
dataKey="cacheReadTokens"
name={t("usage.cacheReadTokens", "缓存读取")}
stroke="#a855f7"
strokeWidth={2}
dot={false}
isAnimationActive
/>
<Line
yAxisId="cost"
type="monotone"
dataKey="cost"
name={t("usage.cost", "成本")}
stroke="#dc2626"
strokeWidth={2}
dot={false}
isAnimationActive
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
+18
View File
@@ -365,4 +365,22 @@ export const providerPresets: ProviderPreset[] = [
partnerPromotionKey: "packycode", // 促销信息 i18n key
icon: "packycode",
},
{
name: "OpenRouter",
websiteUrl: "https://openrouter.ai",
apiKeyUrl: "https://openrouter.ai/keys",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "anthropic/claude-sonnet-4.5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "anthropic/claude-haiku-4.5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-4.5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-4.5",
},
},
category: "aggregator",
icon: "openrouter",
iconColor: "#6366F1",
},
];
+11 -6
View File
@@ -6,16 +6,19 @@ export type McpPreset = Omit<McpServer, "enabled" | "description">;
// 创建跨平台 npx 命令配置
// Windows 需要使用 cmd /c wrapper 来执行 npx.cmd
// Mac/Linux 可以直接执行 npx
const createNpxCommand = (packageName: string, extraArgs: string[] = []): { command: string; args: string[] } => {
const createNpxCommand = (
packageName: string,
extraArgs: string[] = [],
): { command: string; args: string[] } => {
if (isWindows()) {
return {
command: 'cmd',
args: ['/c', 'npx', ...extraArgs, packageName]
command: "cmd",
args: ["/c", "npx", ...extraArgs, packageName],
};
} else {
return {
command: 'npx',
args: [...extraArgs, packageName]
command: "npx",
args: [...extraArgs, packageName],
};
}
};
@@ -66,7 +69,9 @@ export const mcpPresets: McpPreset[] = [
tags: ["stdio", "thinking", "reasoning"],
server: {
type: "stdio",
...createNpxCommand("@modelcontextprotocol/server-sequential-thinking", ["-y"]),
...createNpxCommand("@modelcontextprotocol/server-sequential-thinking", [
"-y",
]),
} as McpServerSpec,
homepage: "https://github.com/modelcontextprotocol/servers",
docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
+66
View File
@@ -0,0 +1,66 @@
import { useState, useCallback } from "react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { testProviderModel, type ModelTestResult } from "@/lib/api/model-test";
import type { AppId } from "@/lib/api";
export function useModelTest(appId: AppId) {
const { t } = useTranslation();
const [testingIds, setTestingIds] = useState<Set<string>>(new Set());
const testProvider = useCallback(
async (
providerId: string,
providerName: string,
): Promise<ModelTestResult | null> => {
setTestingIds((prev) => new Set(prev).add(providerId));
try {
const result = await testProviderModel(appId, providerId);
if (result.success) {
toast.success(
t("modelTest.success", {
name: providerName,
time: result.responseTimeMs,
defaultValue: `${providerName} 测试成功 (${result.responseTimeMs}ms)`,
}),
);
} else {
toast.error(
t("modelTest.failed", {
name: providerName,
error: result.message,
defaultValue: `${providerName} 测试失败: ${result.message}`,
}),
);
}
return result;
} catch (e) {
toast.error(
t("modelTest.error", {
name: providerName,
error: String(e),
defaultValue: `${providerName} 测试出错: ${String(e)}`,
}),
);
return null;
} finally {
setTestingIds((prev) => {
const next = new Set(prev);
next.delete(providerId);
return next;
});
}
},
[appId, t],
);
const isTesting = useCallback(
(providerId: string) => testingIds.has(providerId),
[testingIds],
);
return { testProvider, isTesting };
}
+13 -1
View File
@@ -9,6 +9,7 @@ import {
useUpdateProviderMutation,
useDeleteProviderMutation,
useSwitchProviderMutation,
useSetProxyTargetMutation,
} from "@/lib/query";
import { extractErrorMessage } from "@/utils/errorUtils";
@@ -24,6 +25,7 @@ export function useProviderActions(activeApp: AppId) {
const updateProviderMutation = useUpdateProviderMutation(activeApp);
const deleteProviderMutation = useDeleteProviderMutation(activeApp);
const switchProviderMutation = useSwitchProviderMutation(activeApp);
const setProxyTargetMutation = useSetProxyTargetMutation(activeApp);
// Claude 插件同步逻辑
const syncClaudePlugin = useCallback(
@@ -91,6 +93,14 @@ export function useProviderActions(activeApp: AppId) {
[switchProviderMutation, syncClaudePlugin],
);
// 设置代理目标
const setProxyTarget = useCallback(
async (provider: Provider) => {
await setProxyTargetMutation.mutateAsync(provider.id);
},
[setProxyTargetMutation],
);
// 删除供应商
const deleteProvider = useCallback(
async (id: string) => {
@@ -136,12 +146,14 @@ export function useProviderActions(activeApp: AppId) {
addProvider,
updateProvider,
switchProvider,
setProxyTarget,
deleteProvider,
saveUsageScript,
isLoading:
addProviderMutation.isPending ||
updateProviderMutation.isPending ||
deleteProviderMutation.isPending ||
switchProviderMutation.isPending,
switchProviderMutation.isPending ||
setProxyTargetMutation.isPending,
};
}
+42
View File
@@ -0,0 +1,42 @@
/**
* 代理配置管理 Hook
*/
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { toast } from "sonner";
import type { ProxyConfig } from "@/types/proxy";
/**
* 代理配置管理
*/
export function useProxyConfig() {
const queryClient = useQueryClient();
// 查询配置
const { data: config, isLoading } = useQuery({
queryKey: ["proxyConfig"],
queryFn: () => invoke<ProxyConfig>("get_proxy_config"),
});
// 更新配置
const updateMutation = useMutation({
mutationFn: (newConfig: ProxyConfig) =>
invoke("update_proxy_config", { config: newConfig }),
onSuccess: () => {
toast.success("代理配置已保存");
queryClient.invalidateQueries({ queryKey: ["proxyConfig"] });
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
},
onError: (error: Error) => {
toast.error(`保存失败: ${error.message}`);
},
});
return {
config,
isLoading,
updateConfig: updateMutation.mutateAsync,
isUpdating: updateMutation.isPending,
};
}
+70
View File
@@ -0,0 +1,70 @@
/**
* 代理服务状态管理 Hook
*/
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { toast } from "sonner";
import type { ProxyStatus, ProxyServerInfo } from "@/types/proxy";
/**
* 代理服务状态管理
*/
export function useProxyStatus() {
const queryClient = useQueryClient();
// 查询状态(自动轮询)
const { data: status, isLoading } = useQuery({
queryKey: ["proxyStatus"],
queryFn: () => invoke<ProxyStatus>("get_proxy_status"),
// 仅在服务运行时轮询
refetchInterval: (query) => (query.state.data?.running ? 2000 : false),
// 保持之前的数据,避免闪烁
placeholderData: (previousData) => previousData,
});
// 启动服务器
const startMutation = useMutation({
mutationFn: () => invoke<ProxyServerInfo>("start_proxy_server"),
onSuccess: (info) => {
toast.success(`代理服务已启动 - ${info.address}:${info.port}`);
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
},
onError: (error: Error) => {
toast.error(`启动失败: ${error.message}`);
},
});
// 停止服务器
const stopMutation = useMutation({
mutationFn: () => invoke("stop_proxy_server"),
onSuccess: () => {
toast.success("代理服务已停止");
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
},
onError: (error: Error) => {
toast.error(`停止失败: ${error.message}`);
},
});
// 检查是否运行中
const checkRunning = async () => {
try {
return await invoke<boolean>("is_proxy_running");
} catch {
return false;
}
};
return {
status,
isLoading,
isRunning: status?.running || false,
start: startMutation.mutateAsync,
stop: stopMutation.mutateAsync,
checkRunning,
isStarting: startMutation.isPending,
isStopping: stopMutation.isPending,
isPending: startMutation.isPending || stopMutation.isPending,
};
}
+89
View File
@@ -0,0 +1,89 @@
import { invoke } from "@tauri-apps/api/core";
import type { AppId } from "./types";
export interface ModelTestConfig {
claudeModel: string;
codexModel: string;
geminiModel: string;
testPrompt: string;
timeoutSecs: number;
}
export interface ModelTestResult {
success: boolean;
message: string;
responseTimeMs?: number;
httpStatus?: number;
modelUsed: string;
testedAt: number;
}
export interface ModelTestLog {
id: number;
providerId: string;
providerName: string;
appType: string;
model: string;
prompt: string;
success: boolean;
message: string;
responseTimeMs?: number;
httpStatus?: number;
testedAt: number;
}
/**
* 测试单个供应商的模型可用性
*/
export async function testProviderModel(
appType: AppId,
providerId: string,
): Promise<ModelTestResult> {
return invoke("test_provider_model", { appType, providerId });
}
/**
* 批量测试所有供应商
*/
export async function testAllProvidersModel(
appType: AppId,
proxyTargetsOnly: boolean = false,
): Promise<Array<[string, ModelTestResult]>> {
return invoke("test_all_providers_model", { appType, proxyTargetsOnly });
}
/**
* 获取模型测试配置
*/
export async function getModelTestConfig(): Promise<ModelTestConfig> {
return invoke("get_model_test_config");
}
/**
* 保存模型测试配置
*/
export async function saveModelTestConfig(
config: ModelTestConfig,
): Promise<void> {
return invoke("save_model_test_config", { config });
}
/**
* 获取模型测试日志
*/
export async function getModelTestLogs(
appType?: string,
providerId?: string,
limit?: number,
): Promise<ModelTestLog[]> {
return invoke("get_model_test_logs", { appType, providerId, limit });
}
/**
* 清理旧的测试日志
*/
export async function cleanupModelTestLogs(
keepCount?: number,
): Promise<number> {
return invoke("cleanup_model_test_logs", { keepCount });
}
+4
View File
@@ -38,6 +38,10 @@ export const providersApi = {
return await invoke("switch_provider", { id, app: appId });
},
async setProxyTarget(id: string, appId: AppId): Promise<boolean> {
return await invoke("set_proxy_target_provider", { id, app: appId });
},
async importDefault(appId: AppId): Promise<boolean> {
return await invoke("import_default_config", { app: appId });
},
+94 -47
View File
@@ -1,33 +1,25 @@
import { invoke } from "@tauri-apps/api/core";
import type {
UsageSummary,
DailyStats,
ProviderStats,
ModelStats,
RequestLog,
LogFilters,
ModelPricing,
ProviderLimitStatus,
PaginatedLogs,
} from "@/types/usage";
import type { UsageResult } from "@/types";
import type { AppId } from "./types";
import i18n from "@/i18n";
export const usageApi = {
async query(providerId: string, appId: AppId): Promise<UsageResult> {
try {
return await invoke("queryProviderUsage", {
providerId: providerId,
app: appId,
});
} catch (error: unknown) {
// 提取错误消息:优先使用后端返回的错误信息
const message =
typeof error === "string"
? error
: error instanceof Error
? error.message
: "";
// 如果没有错误消息,使用国际化的默认提示
return {
success: false,
error: message || i18n.t("errors.usage_query_failed"),
};
}
// Provider usage script methods
query: async (providerId: string, appId: AppId): Promise<UsageResult> => {
return invoke("queryProviderUsage", { providerId, app: appId });
},
async testScript(
testScript: async (
providerId: string,
appId: AppId,
scriptCode: string,
@@ -36,30 +28,85 @@ export const usageApi = {
baseUrl?: string,
accessToken?: string,
userId?: string,
): Promise<UsageResult> {
try {
return await invoke("testUsageScript", {
providerId: providerId,
app: appId,
scriptCode: scriptCode,
timeout: timeout,
apiKey: apiKey,
baseUrl: baseUrl,
accessToken: accessToken,
userId: userId,
});
} catch (error: unknown) {
const message =
typeof error === "string"
? error
: error instanceof Error
? error.message
: "";
): Promise<UsageResult> => {
return invoke("testUsageScript", {
providerId,
app: appId,
scriptCode,
timeout,
apiKey,
baseUrl,
accessToken,
userId,
});
},
return {
success: false,
error: message || i18n.t("errors.usage_query_failed"),
};
}
// Proxy usage statistics methods
getUsageSummary: async (
startDate?: number,
endDate?: number,
): Promise<UsageSummary> => {
return invoke("get_usage_summary", { startDate, endDate });
},
getUsageTrends: async (days: number): Promise<DailyStats[]> => {
return invoke("get_usage_trends", { days });
},
getProviderStats: async (): Promise<ProviderStats[]> => {
return invoke("get_provider_stats");
},
getModelStats: async (): Promise<ModelStats[]> => {
return invoke("get_model_stats");
},
getRequestLogs: async (
filters: LogFilters,
page: number = 0,
pageSize: number = 20,
): Promise<PaginatedLogs> => {
return invoke("get_request_logs", {
filters,
page,
pageSize,
});
},
getRequestDetail: async (requestId: string): Promise<RequestLog | null> => {
return invoke("get_request_detail", { requestId });
},
getModelPricing: async (): Promise<ModelPricing[]> => {
return invoke("get_model_pricing");
},
updateModelPricing: async (
modelId: string,
displayName: string,
inputCost: string,
outputCost: string,
cacheReadCost: string,
cacheCreationCost: string,
): Promise<void> => {
return invoke("update_model_pricing", {
modelId,
displayName,
inputCost,
outputCost,
cacheReadCost,
cacheCreationCost,
});
},
deleteModelPricing: async (modelId: string): Promise<void> => {
return invoke("delete_model_pricing", { modelId });
},
checkProviderLimits: async (
providerId: string,
appType: string,
): Promise<ProviderLimitStatus> => {
return invoke("check_provider_limits", { providerId, appType });
},
};
+28
View File
@@ -176,6 +176,34 @@ export const useSwitchProviderMutation = (appId: AppId) => {
});
};
export const useSetProxyTargetMutation = (appId: AppId) => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: async (providerId: string) => {
return await providersApi.setProxyTarget(providerId, appId);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["providers", appId] });
toast.success(
t("notifications.proxyTargetSet", {
defaultValue: "已设置代理目标",
}),
);
},
onError: (error: Error) => {
const detail = extractErrorMessage(error) || t("common.unknown");
toast.error(
t("notifications.setProxyTargetFailed", {
defaultValue: "设置代理目标失败: {{error}}",
error: detail,
}),
);
},
});
};
export const useSaveSettingsMutation = () => {
const queryClient = useQueryClient();
+120
View File
@@ -0,0 +1,120 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { usageApi } from "@/lib/api/usage";
import type { LogFilters } from "@/types/usage";
// Query keys
export const usageKeys = {
all: ["usage"] as const,
summary: (startDate?: number, endDate?: number) =>
[...usageKeys.all, "summary", startDate, endDate] as const,
trends: (days: number) => [...usageKeys.all, "trends", days] as const,
providerStats: () => [...usageKeys.all, "provider-stats"] as const,
modelStats: () => [...usageKeys.all, "model-stats"] as const,
logs: (filters: LogFilters, page: number, pageSize: number) =>
[...usageKeys.all, "logs", filters, page, pageSize] as const,
detail: (requestId: string) =>
[...usageKeys.all, "detail", requestId] as const,
pricing: () => [...usageKeys.all, "pricing"] as const,
limits: (providerId: string, appType: string) =>
[...usageKeys.all, "limits", providerId, appType] as const,
};
// Hooks
export function useUsageSummary(startDate?: number, endDate?: number) {
return useQuery({
queryKey: usageKeys.summary(startDate, endDate),
queryFn: () => usageApi.getUsageSummary(startDate, endDate),
});
}
export function useUsageTrends(days: number) {
return useQuery({
queryKey: usageKeys.trends(days),
queryFn: () => usageApi.getUsageTrends(days),
});
}
export function useProviderStats() {
return useQuery({
queryKey: usageKeys.providerStats(),
queryFn: usageApi.getProviderStats,
});
}
export function useModelStats() {
return useQuery({
queryKey: usageKeys.modelStats(),
queryFn: usageApi.getModelStats,
});
}
export function useRequestLogs(
filters: LogFilters,
page: number = 0,
pageSize: number = 20,
) {
return useQuery({
queryKey: usageKeys.logs(filters, page, pageSize),
queryFn: () => usageApi.getRequestLogs(filters, page, pageSize),
});
}
export function useRequestDetail(requestId: string) {
return useQuery({
queryKey: usageKeys.detail(requestId),
queryFn: () => usageApi.getRequestDetail(requestId),
enabled: !!requestId,
});
}
export function useModelPricing() {
return useQuery({
queryKey: usageKeys.pricing(),
queryFn: usageApi.getModelPricing,
});
}
export function useProviderLimits(providerId: string, appType: string) {
return useQuery({
queryKey: usageKeys.limits(providerId, appType),
queryFn: () => usageApi.checkProviderLimits(providerId, appType),
enabled: !!providerId && !!appType,
});
}
export function useUpdateModelPricing() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (params: {
modelId: string;
displayName: string;
inputCost: string;
outputCost: string;
cacheReadCost: string;
cacheCreationCost: string;
}) =>
usageApi.updateModelPricing(
params.modelId,
params.displayName,
params.inputCost,
params.outputCost,
params.cacheReadCost,
params.cacheCreationCost,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: usageKeys.pricing() });
},
});
}
export function useDeleteModelPricing() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (modelId: string) => usageApi.deleteModelPricing(modelId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: usageKeys.pricing() });
},
});
}
+2
View File
@@ -23,6 +23,8 @@ export interface Provider {
// 图标配置
icon?: string; // 图标名称(如 "openai", "anthropic"
iconColor?: string; // 图标颜色(Hex 格式,如 "#00A67E"
// 新增:是否为代理目标
isProxyTarget?: boolean;
}
export interface AppConfig {
+61
View File
@@ -0,0 +1,61 @@
export interface ProxyConfig {
enabled: boolean;
listen_address: string;
listen_port: number;
max_retries: number;
request_timeout: number;
enable_logging: boolean;
}
export interface ProxyStatus {
running: boolean;
address: string;
port: number;
active_connections: number;
total_requests: number;
success_requests: number;
failed_requests: number;
success_rate: number;
uptime_seconds: number;
current_provider: string | null;
current_provider_id: string | null;
last_request_at: string | null;
last_error: string | null;
failover_count: number;
active_targets?: ActiveTarget[];
}
export interface ActiveTarget {
app_type: string;
provider_name: string;
provider_id: string;
}
export interface ProxyServerInfo {
address: string;
port: number;
started_at: string;
}
export interface ProviderHealth {
provider_id: string;
app_type: string;
is_healthy: boolean;
consecutive_failures: number;
last_success_at: string | null;
last_failure_at: string | null;
last_error: string | null;
updated_at: string;
}
export interface ProxyUsageRecord {
provider_id: string;
app_type: string;
endpoint: string;
request_tokens: number | null;
response_tokens: number | null;
status_code: number;
latency_ms: number;
error: string | null;
timestamp: string;
}
+114
View File
@@ -0,0 +1,114 @@
// 使用统计相关类型定义
export interface TokenUsage {
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
}
export interface RequestLog {
requestId: string;
providerId: string;
providerName?: string;
appType: string;
model: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
inputCostUsd: string;
outputCostUsd: string;
cacheReadCostUsd: string;
cacheCreationCostUsd: string;
totalCostUsd: string;
isStreaming: boolean;
latencyMs: number;
firstTokenMs?: number;
durationMs?: number;
statusCode: number;
errorMessage?: string;
createdAt: number;
}
export interface PaginatedLogs {
data: RequestLog[];
total: number;
page: number;
pageSize: number;
}
export interface ModelPricing {
modelId: string;
displayName: string;
inputCostPerMillion: string;
outputCostPerMillion: string;
cacheReadCostPerMillion: string;
cacheCreationCostPerMillion: string;
}
export interface UsageSummary {
totalRequests: number;
totalCost: string;
totalInputTokens: number;
totalOutputTokens: number;
totalCacheCreationTokens: number;
totalCacheReadTokens: number;
successRate: number;
}
export interface DailyStats {
date: string;
requestCount: number;
totalCost: string;
totalTokens: number;
totalInputTokens: number;
totalOutputTokens: number;
totalCacheCreationTokens: number;
totalCacheReadTokens: number;
}
export interface ProviderStats {
providerId: string;
providerName: string;
requestCount: number;
totalTokens: number;
totalCost: string;
successRate: number;
avgLatencyMs: number;
}
export interface ModelStats {
model: string;
requestCount: number;
totalTokens: number;
totalCost: string;
avgCostPerRequest: string;
}
export interface LogFilters {
appType?: string;
providerName?: string;
model?: string;
statusCode?: number;
startDate?: number;
endDate?: number;
}
export interface ProviderLimitStatus {
providerId: string;
dailyUsage: string;
dailyLimit?: string;
dailyExceeded: boolean;
monthlyUsage: string;
monthlyLimit?: string;
monthlyExceeded: boolean;
}
export type TimeRange = "1d" | "7d" | "30d";
export interface StatsFilters {
timeRange: TimeRange;
providerId?: string;
appType?: string;
}