feat(admin): 添加网络代理配置和钱包交易功能
- 在管理面板中集成网络代理配置显示和平台代理设置 - 添加钱包摘要和交易列表API接口及数据管理 - 实现SSE流式响应中的错误处理机制 - 添加全局HTTP代理环境变量配置支持 - 更新平台表单以支持代理模式选择和自定义代理地址 - 集成钱包交易查询过滤和分页功能 - 优化API错误详情解析和显示格式
This commit is contained in:
@@ -133,6 +133,7 @@ export function AdminPage(props: {
|
||||
<PlatformManagementPanel
|
||||
baseModels={props.data.baseModels}
|
||||
message={props.operationMessage}
|
||||
networkProxyConfig={props.data.networkProxyConfig}
|
||||
platformModels={props.data.models}
|
||||
platforms={props.data.platforms}
|
||||
pricingRuleSets={props.data.pricingRuleSets}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
AssistantRuntimeProvider,
|
||||
ComposerPrimitive,
|
||||
ErrorPrimitive,
|
||||
MessagePrimitive,
|
||||
ThreadPrimitive,
|
||||
useMessage,
|
||||
useMessagePartText,
|
||||
useLocalRuntime,
|
||||
useThread,
|
||||
@@ -19,11 +21,12 @@ import { mermaid } from '@streamdown/mermaid';
|
||||
import type { GatewayApiKey, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||
import { Bot, ChevronDown, Image as ImageIcon, MessageSquarePlus, Paperclip, Send, Sparkles, Video } from 'lucide-react';
|
||||
import { Badge, Button, Select, Textarea } from '../components/ui';
|
||||
import { createImageGenerationTask, createVideoGenerationTask, getTask, streamChatCompletionText } from '../api';
|
||||
import { GatewayApiError, createImageGenerationTask, createVideoGenerationTask, getTask, streamChatCompletionText } from '../api';
|
||||
import type { PlaygroundMode } from '../types';
|
||||
import {
|
||||
defaultMediaGenerationSettings,
|
||||
deriveMediaModelCapabilities,
|
||||
gatewayTaskErrorText,
|
||||
mediaRequestPayload,
|
||||
MediaSettingsPopover,
|
||||
MediaTaskBoard,
|
||||
@@ -172,7 +175,7 @@ export function PlaygroundPage(props: {
|
||||
.then((detail) => {
|
||||
if (!isMountedRef.current) return;
|
||||
setMediaRuns((current) => updateMediaRun(current, run.localId, {
|
||||
error: detail.error,
|
||||
error: gatewayTaskErrorText(detail, '任务执行失败'),
|
||||
status: detail.status,
|
||||
task: detail,
|
||||
}));
|
||||
@@ -240,7 +243,7 @@ export function PlaygroundPage(props: {
|
||||
setMediaRuns((current) => updateMediaRun(current, localId, { status: response.task.status, task: response.task }));
|
||||
const detail = await pollTaskUntilSettled(credential, response.task);
|
||||
setMediaRuns((current) => updateMediaRun(current, localId, {
|
||||
error: detail.error,
|
||||
error: gatewayTaskErrorText(detail, '任务执行失败'),
|
||||
status: detail.status,
|
||||
task: detail,
|
||||
}));
|
||||
@@ -453,13 +456,13 @@ function AssistantChatPlayground(props: {
|
||||
async *run({ abortSignal, messages }) {
|
||||
if (!props.token) {
|
||||
props.onLogin();
|
||||
throw new Error('请先登录后再测试模型。');
|
||||
throw new GatewayApiError('请先登录后再测试模型。');
|
||||
}
|
||||
if (!activeApiKeySecret) {
|
||||
throw new Error('请选择可用于测试的 API Key;如果列表为空,请刷新或重新创建一个 Key。');
|
||||
throw new GatewayApiError('请选择可用于测试的 API Key;如果列表为空,请刷新或重新创建一个 Key。');
|
||||
}
|
||||
if (!props.selectedModel) {
|
||||
throw new Error('当前没有可用的大模型,请确认用户组权限或平台模型配置。');
|
||||
throw new GatewayApiError('当前没有可用的大模型,请确认用户组权限或平台模型配置。');
|
||||
}
|
||||
let text = '';
|
||||
for await (const delta of streamChatCompletionText(
|
||||
@@ -696,6 +699,8 @@ function AssistantChatComposer(props: {
|
||||
}
|
||||
|
||||
function AssistantMessage() {
|
||||
const hasError = useMessage((state) => state.status?.type === 'incomplete' && state.status.reason === 'error');
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root className="assistantMessage">
|
||||
<MessagePrimitive.If user>
|
||||
@@ -704,16 +709,19 @@ function AssistantMessage() {
|
||||
</div>
|
||||
</MessagePrimitive.If>
|
||||
<MessagePrimitive.If assistant>
|
||||
<div className="assistantBubble assistant">
|
||||
<div className={hasError ? 'assistantBubble assistant error' : 'assistantBubble assistant'}>
|
||||
<MessagePrimitive.Parts components={{ Text: AssistantMarkdownText }} />
|
||||
<MessagePrimitive.If hasContent={false}>
|
||||
<span className="assistantTyping">模型正在回复...</span>
|
||||
</MessagePrimitive.If>
|
||||
<MessagePrimitive.Error>
|
||||
<strong>调用失败</strong>
|
||||
<ErrorPrimitive.Message className="assistantErrorMessage" />
|
||||
</MessagePrimitive.Error>
|
||||
{!hasError && (
|
||||
<MessagePrimitive.If hasContent={false}>
|
||||
<span className="assistantTyping">模型正在回复...</span>
|
||||
</MessagePrimitive.If>
|
||||
)}
|
||||
</div>
|
||||
</MessagePrimitive.If>
|
||||
<MessagePrimitive.Error>
|
||||
<div className="assistantBubble error">调用失败,请检查模型、平台凭证或测试模式配置。</div>
|
||||
</MessagePrimitive.Error>
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Popover as AntPopover } from 'antd';
|
||||
import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, RotateCcw, Search, ShieldCheck, Trash2, UserRound } from 'lucide-react';
|
||||
import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayTask, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||
import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, ReceiptText, RotateCcw, Search, ShieldCheck, Trash2, UserRound } from 'lucide-react';
|
||||
import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayTask, GatewayWalletAccount, GatewayWalletTransaction, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||
import type { ConsoleData } from '../app-state';
|
||||
import { EntityTable } from '../components/EntityTable';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, DateTimePicker, DateTimeRangePicker, FormDialog, Input, Label, Select, Table, TableCell, TableFooter, TableHead, TablePageActions, TableRow, TableToolbar, TableViewportLayout, Tabs } from '../components/ui';
|
||||
import { AccessPermissionEditor, countAccessPermissionRules } from './admin/AccessPermissionEditor';
|
||||
import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery } from '../types';
|
||||
import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery, WorkspaceTransactionQuery } from '../types';
|
||||
|
||||
const tabs = [
|
||||
{ value: 'overview', label: '个人总览', icon: <UserRound size={15} /> },
|
||||
{ value: 'billing', label: '余额充值', icon: <CreditCard size={15} /> },
|
||||
{ value: 'apiKeys', label: 'API Key', icon: <KeyRound size={15} /> },
|
||||
{ value: 'tasks', label: '任务记录', icon: <ListChecks size={15} /> },
|
||||
{ value: 'transactions', label: '消费记录', icon: <ReceiptText size={15} /> },
|
||||
] satisfies Array<{ value: WorkspaceSection; label: string; icon: ReactNode }>;
|
||||
|
||||
const taskPageSizeOptions = [10, 20, 50];
|
||||
@@ -28,12 +29,15 @@ export function WorkspacePage(props: {
|
||||
state: LoadState;
|
||||
taskQuery: WorkspaceTaskQuery;
|
||||
taskTotal: number;
|
||||
transactionQuery: WorkspaceTransactionQuery;
|
||||
transactionTotal: number;
|
||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
||||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||||
onSectionChange: (value: WorkspaceSection) => void;
|
||||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
onTaskQueryChange: (value: WorkspaceTaskQuery) => void;
|
||||
onTransactionQueryChange: (value: WorkspaceTransactionQuery) => void;
|
||||
onUseApiKeyForPlayground: (apiKeyId?: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -42,9 +46,10 @@ export function WorkspacePage(props: {
|
||||
<Tabs className="sideTabs" value={props.section} tabs={tabs} onValueChange={props.onSectionChange} />
|
||||
<div className="subPageContent">
|
||||
{props.section === 'overview' && <WorkspaceOverview data={props.data} />}
|
||||
{props.section === 'billing' && <BillingPanel />}
|
||||
{props.section === 'billing' && <BillingPanel walletAccounts={props.data.walletAccounts} />}
|
||||
{props.section === 'apiKeys' && <ApiKeyPanel {...props} />}
|
||||
{props.section === 'tasks' && <TaskPanel data={props.data} query={props.taskQuery} total={props.taskTotal} onQueryChange={props.onTaskQueryChange} />}
|
||||
{props.section === 'transactions' && <ConsumptionPanel data={props.data} query={props.transactionQuery} total={props.transactionTotal} onQueryChange={props.onTransactionQueryChange} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,7 +87,9 @@ function WorkspaceOverview(props: { data: ConsoleData }) {
|
||||
);
|
||||
}
|
||||
|
||||
function BillingPanel() {
|
||||
function BillingPanel(props: { walletAccounts: GatewayWalletAccount[] }) {
|
||||
const primaryWallet = primaryWalletAccount(props.walletAccounts);
|
||||
const availableBalance = primaryWallet ? primaryWallet.balance - primaryWallet.frozenBalance : 0;
|
||||
return (
|
||||
<section className="contentGrid two">
|
||||
<Card>
|
||||
@@ -90,9 +97,15 @@ function BillingPanel() {
|
||||
<CardTitle>余额</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="balanceCard">
|
||||
<span>resource</span>
|
||||
<strong>0.00</strong>
|
||||
<Badge variant="secondary">local</Badge>
|
||||
<span>{primaryWallet?.currency ?? 'resource'}</span>
|
||||
<strong>{formatMoney(primaryWallet?.balance ?? 0)}</strong>
|
||||
<Badge variant={primaryWallet?.status === 'active' || !primaryWallet ? 'success' : 'secondary'}>{primaryWallet?.status ?? 'active'}</Badge>
|
||||
<div className="walletMetricGrid">
|
||||
<InfoItem label="可用余额" value={formatMoney(availableBalance)} />
|
||||
<InfoItem label="冻结余额" value={formatMoney(primaryWallet?.frozenBalance ?? 0)} />
|
||||
<InfoItem label="累计充值" value={formatMoney(primaryWallet?.totalRecharged ?? 0)} />
|
||||
<InfoItem label="累计消费" value={formatMoney(primaryWallet?.totalSpent ?? 0)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
@@ -111,6 +124,233 @@ function BillingPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function ConsumptionPanel(props: {
|
||||
data: ConsoleData;
|
||||
query: WorkspaceTransactionQuery;
|
||||
total: number;
|
||||
onQueryChange: (value: WorkspaceTransactionQuery) => void;
|
||||
}) {
|
||||
const transactions = props.data.walletTransactions;
|
||||
const transactionQuery = props.query;
|
||||
const [pageJump, setPageJump] = useState(String(transactionQuery.page));
|
||||
const pageSizeOptions = useMemo(() => {
|
||||
return Array.from(new Set([...taskPageSizeOptions, transactionQuery.pageSize])).sort((a, b) => a - b);
|
||||
}, [transactionQuery.pageSize]);
|
||||
const totalPages = Math.max(1, Math.ceil(props.total / transactionQuery.pageSize));
|
||||
const currentPage = Math.min(transactionQuery.page, totalPages);
|
||||
const pageStart = props.total ? Math.min((currentPage - 1) * transactionQuery.pageSize + 1, props.total) : 0;
|
||||
const pageEnd = Math.min(currentPage * transactionQuery.pageSize, props.total);
|
||||
const hasActiveFilters = Boolean(transactionQuery.query || transactionQuery.createdFrom || transactionQuery.createdTo);
|
||||
|
||||
useEffect(() => {
|
||||
if (transactionQuery.page > totalPages) {
|
||||
props.onQueryChange({ ...transactionQuery, page: totalPages });
|
||||
}
|
||||
}, [props.onQueryChange, transactionQuery, totalPages]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageJump(String(currentPage));
|
||||
}, [currentPage]);
|
||||
|
||||
function updateQuery(value: string) {
|
||||
props.onQueryChange({ ...transactionQuery, query: value, page: 1 });
|
||||
}
|
||||
|
||||
function updateCreatedRange(value: { from: string; to: string }) {
|
||||
props.onQueryChange({ ...transactionQuery, createdFrom: value.from, createdTo: value.to, page: 1 });
|
||||
}
|
||||
|
||||
function updatePageSize(value: string) {
|
||||
const nextPageSize = Number(value);
|
||||
props.onQueryChange({ ...transactionQuery, page: 1, pageSize: Number.isFinite(nextPageSize) ? nextPageSize : 10 });
|
||||
}
|
||||
|
||||
function updatePage(page: number) {
|
||||
props.onQueryChange({ ...transactionQuery, page });
|
||||
}
|
||||
|
||||
function submitPageJump() {
|
||||
const parsed = Number(pageJump);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
setPageJump(String(currentPage));
|
||||
return;
|
||||
}
|
||||
updatePage(Math.min(totalPages, Math.max(1, Math.floor(parsed))));
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
props.onQueryChange({
|
||||
query: '',
|
||||
createdFrom: '',
|
||||
createdTo: '',
|
||||
page: 1,
|
||||
pageSize: transactionQuery.pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="shTableViewportCard walletTransactionViewport">
|
||||
<CardContent className="shTableViewportPanel">
|
||||
<TableViewportLayout>
|
||||
<TableToolbar className="walletTransactionFilters">
|
||||
<Label className="taskRecordSearchLabel">
|
||||
搜索
|
||||
<span className="taskRecordSearchBox">
|
||||
<Search size={15} />
|
||||
<Input
|
||||
value={transactionQuery.query}
|
||||
placeholder="搜索任务 / 模型 / 平台 / 类型 / API Key / RequestID"
|
||||
onChange={(event) => updateQuery(event.target.value)}
|
||||
/>
|
||||
</span>
|
||||
</Label>
|
||||
<Label className="taskRecordRangeLabel">
|
||||
消费时间
|
||||
<DateTimeRangePicker
|
||||
from={transactionQuery.createdFrom}
|
||||
fromPlaceholder="开始日期"
|
||||
to={transactionQuery.createdTo}
|
||||
toPlaceholder="结束日期"
|
||||
onChange={updateCreatedRange}
|
||||
/>
|
||||
</Label>
|
||||
<Button type="button" variant="outline" size="sm" disabled={!hasActiveFilters} onClick={resetFilters}>
|
||||
<RotateCcw size={14} />
|
||||
重置
|
||||
</Button>
|
||||
</TableToolbar>
|
||||
|
||||
{transactions.length ? (
|
||||
<Table className="shTableViewport walletTransactionTable" density="compact">
|
||||
<TableRow className="shTableHeader">
|
||||
<TableHead>消费时间</TableHead>
|
||||
<TableHead>模型</TableHead>
|
||||
<TableHead>平台</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>API Key</TableHead>
|
||||
<TableHead>Token 消耗</TableHead>
|
||||
<TableHead>扣费</TableHead>
|
||||
<TableHead>余额变化</TableHead>
|
||||
<TableHead>任务</TableHead>
|
||||
</TableRow>
|
||||
{transactions.map((transaction) => (
|
||||
<WalletTransactionRecord key={transaction.id} transaction={transaction} />
|
||||
))}
|
||||
</Table>
|
||||
) : (
|
||||
<div className="emptyState">
|
||||
<strong>暂无消费记录</strong>
|
||||
<span>完成可计费任务后会生成消费流水。</span>
|
||||
</div>
|
||||
)}
|
||||
<TableFooter>
|
||||
<div className="shTableFooterGroup">
|
||||
<Label>
|
||||
每页
|
||||
<Select size="sm" value={String(transactionQuery.pageSize)} onChange={(event) => updatePageSize(event.target.value)}>
|
||||
{pageSizeOptions.map((option) => (
|
||||
<option key={option} value={option}>{option} 条</option>
|
||||
))}
|
||||
</Select>
|
||||
</Label>
|
||||
<span>共 {props.total} 条 · {pageStart}-{pageEnd}</span>
|
||||
</div>
|
||||
<form
|
||||
className="shTablePageJump"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submitPageJump();
|
||||
}}
|
||||
>
|
||||
<span>第 {currentPage} / {totalPages} 页</span>
|
||||
<span>跳至</span>
|
||||
<Input
|
||||
aria-label="跳转页码"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
size="xs"
|
||||
type="number"
|
||||
value={pageJump}
|
||||
onChange={(event) => setPageJump(event.target.value)}
|
||||
/>
|
||||
<span>页</span>
|
||||
<Button type="submit" variant="outline" size="xs" disabled={totalPages <= 1}>跳转</Button>
|
||||
</form>
|
||||
<TablePageActions>
|
||||
<Button type="button" variant="outline" size="sm" disabled={currentPage <= 1} onClick={() => updatePage(Math.max(1, currentPage - 1))}>
|
||||
<ChevronLeft size={14} />
|
||||
上一页
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={currentPage >= totalPages} onClick={() => updatePage(Math.min(totalPages, currentPage + 1))}>
|
||||
下一页
|
||||
<ChevronRight size={14} />
|
||||
</Button>
|
||||
</TablePageActions>
|
||||
</TableFooter>
|
||||
</TableViewportLayout>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function WalletTransactionRecord(props: { transaction: GatewayWalletTransaction }) {
|
||||
const transaction = props.transaction;
|
||||
const metadata = transaction.metadata;
|
||||
const referenceId = transaction.referenceId || metadataString(transaction.metadata, 'taskId');
|
||||
const requestId = metadataString(metadata, 'requestId');
|
||||
const model = metadataString(metadata, 'resolvedModel') || metadataString(metadata, 'platformModelAlias') || metadataString(metadata, 'providerModel') || metadataString(metadata, 'model');
|
||||
const requestedModel = metadataString(metadata, 'requestedModel');
|
||||
const platform = metadataString(metadata, 'platformName') || metadataString(metadata, 'platformKey') || metadataString(metadata, 'provider') || metadataString(metadata, 'clientId');
|
||||
const provider = metadataString(metadata, 'provider');
|
||||
const taskType = metadataString(metadata, 'modelType') || metadataString(metadata, 'kind');
|
||||
const apiKey = metadataString(metadata, 'apiKeyName') || metadataString(metadata, 'apiKeyPrefix') || metadataString(metadata, 'apiKeyId');
|
||||
const chargeAmount = transactionChargeAmount(transaction);
|
||||
const chargeCurrency = transactionChargeCurrency(transaction);
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>{formatDateTime(transaction.createdAt)}</TableCell>
|
||||
<TableCell className="walletTransactionModelCell">
|
||||
<span className="walletTransactionPrimaryCell">
|
||||
<strong>{model || '-'}</strong>
|
||||
{requestedModel && requestedModel !== model && <small>{requestedModel}</small>}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="walletTransactionPlatformCell">
|
||||
<span className="walletTransactionPrimaryCell">
|
||||
<strong>{platform || '-'}</strong>
|
||||
{provider && provider !== platform && <small>{provider}</small>}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="walletTransactionPrimaryCell">
|
||||
<strong>{taskType || '-'}</strong>
|
||||
<small>{transactionTypeLabel(transaction.transactionType)}</small>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{apiKey || '-'}</TableCell>
|
||||
<TableCell className="walletTransactionTokenCell">{formatTokenUsage(metadataObject(metadata, 'usage'))}</TableCell>
|
||||
<TableCell>
|
||||
<span className="walletTransactionAmount" data-direction={transaction.direction}>
|
||||
{transaction.direction === 'debit' ? '-' : '+'}{formatMoney(chargeAmount)} {chargeCurrency}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="walletBalanceChange">
|
||||
<span>{formatMoney(transaction.balanceBefore)}</span>
|
||||
<span>{formatMoney(transaction.balanceAfter)}</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="walletTransactionRef">
|
||||
{referenceId ? <code title={referenceId}>{referenceId}</code> : '-'}
|
||||
{requestId && <small title={requestId}>RequestID {requestId}</small>}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyPanel(props: {
|
||||
apiKeyForm: ApiKeyForm;
|
||||
apiKeySecret: string;
|
||||
@@ -657,11 +897,35 @@ function taskAttemptFailureReason(attempt: NonNullable<GatewayTask['attempts']>[
|
||||
if (detail && code && detail !== code) return `${detail}(${code})`;
|
||||
return detail || code || '失败';
|
||||
}
|
||||
|
||||
function formatCellValue(value: unknown) {
|
||||
if (value === undefined || value === null || value === '') return '-';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function primaryWalletAccount(accounts: GatewayWalletAccount[]) {
|
||||
return accounts.find((account) => account.currency === 'resource') ?? accounts[0];
|
||||
}
|
||||
|
||||
function formatMoney(value: unknown) {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(numericValue)) return '0.00';
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(numericValue);
|
||||
}
|
||||
|
||||
function transactionTypeLabel(value: string) {
|
||||
if (value === 'task_billing') return '任务扣费';
|
||||
if (value === 'admin_adjust') return '余额调整';
|
||||
if (value === 'recharge') return '充值';
|
||||
if (value === 'refund') return '退款';
|
||||
if (value === 'reserve') return '冻结';
|
||||
if (value === 'release') return '释放';
|
||||
return value || '-';
|
||||
}
|
||||
|
||||
function firstText(...values: Array<unknown>) {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
@@ -674,6 +938,34 @@ function metadataString(metadata: Record<string, unknown> | undefined, key: stri
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function metadataNumber(metadata: Record<string, unknown> | undefined, key: string) {
|
||||
const value = metadata?.[key];
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const numericValue = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(numericValue) ? numericValue : null;
|
||||
}
|
||||
|
||||
function metadataObject(metadata: Record<string, unknown> | undefined, key: string): Record<string, unknown> {
|
||||
const value = metadata?.[key];
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function objectString(value: Record<string, unknown>, key: string) {
|
||||
const next = value[key];
|
||||
return typeof next === 'string' && next.trim() ? next.trim() : '';
|
||||
}
|
||||
|
||||
function transactionChargeAmount(transaction: GatewayWalletTransaction) {
|
||||
return metadataNumber(transaction.metadata, 'finalChargeAmount') ?? transaction.amount;
|
||||
}
|
||||
|
||||
function transactionChargeCurrency(transaction: GatewayWalletTransaction) {
|
||||
const summary = metadataObject(transaction.metadata, 'billingSummary');
|
||||
const finalCharge = metadataObject(summary, 'finalCharge');
|
||||
return objectString(finalCharge, 'currency') || objectString(summary, 'currency') || transaction.currency || 'resource';
|
||||
}
|
||||
|
||||
function formatTokenUsage(usage: Record<string, unknown>) {
|
||||
const input = tokenValue(usage.inputTokens ?? usage.promptTokens ?? usage.input_tokens ?? usage.prompt_tokens);
|
||||
const output = tokenValue(usage.outputTokens ?? usage.completionTokens ?? usage.output_tokens ?? usage.completion_tokens);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Boxes, CheckCircle2, KeyRound, Pencil, Plus, RotateCcw, Search, ServerCog, ShieldCheck, SlidersHorizontal, Trash2, X } from 'lucide-react';
|
||||
import { Boxes, CheckCircle2, Globe2, KeyRound, Pencil, Plus, RotateCcw, Search, ServerCog, ShieldCheck, SlidersHorizontal, Trash2, X } from 'lucide-react';
|
||||
import type { BaseModelCatalogItem, CatalogProvider, IntegrationPlatform, PlatformModel, PricingRuleSet } from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, EmptyState, FormDialog, Input, Label, ScreenMessage, Select, Table, TableCell, TableHead, TableRow } from '../../components/ui';
|
||||
import type { LoadState, PlatformWithModelsInput } from '../../types';
|
||||
@@ -23,6 +23,7 @@ import { ModelCatalogCard } from './ModelCatalogCard';
|
||||
export function PlatformManagementPanel(props: {
|
||||
baseModels: BaseModelCatalogItem[];
|
||||
message: string;
|
||||
networkProxyConfig: { globalHttpProxy?: string; globalHttpProxySet: boolean; globalHttpProxySource?: string } | null;
|
||||
platforms: IntegrationPlatform[];
|
||||
platformModels: PlatformModel[];
|
||||
pricingRuleSets: PricingRuleSet[];
|
||||
@@ -37,6 +38,7 @@ export function PlatformManagementPanel(props: {
|
||||
const [modelQuery, setModelQuery] = useState('');
|
||||
const [selectedPlatformId, setSelectedPlatformId] = useState('');
|
||||
const [validationMessage, setValidationMessage] = useState('');
|
||||
const [globalProxyNoticeOpen, setGlobalProxyNoticeOpen] = useState(false);
|
||||
const [editingPlatform, setEditingPlatform] = useState<IntegrationPlatform | null>(null);
|
||||
const [pendingDeletePlatform, setPendingDeletePlatform] = useState<IntegrationPlatform | null>(null);
|
||||
const providerMap = useMemo(() => new Map(props.providers.map((item) => [item.providerKey, item])), [props.providers]);
|
||||
@@ -109,6 +111,17 @@ export function PlatformManagementPanel(props: {
|
||||
});
|
||||
}
|
||||
|
||||
function updateProxyMode(proxyMode: PlatformWizardForm['proxyMode']) {
|
||||
if (proxyMode === 'global') {
|
||||
setGlobalProxyNoticeOpen(true);
|
||||
}
|
||||
setForm({
|
||||
...form,
|
||||
proxyMode,
|
||||
httpProxy: proxyMode === 'custom' ? form.httpProxy : '',
|
||||
});
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const validationMessage = validatePlatformForm(form, selectedModels.length, { allowEmptyCredentials: Boolean(editingPlatform) });
|
||||
@@ -254,6 +267,30 @@ export function PlatformManagementPanel(props: {
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection icon={<Globe2 size={16} />} title="网络代理">
|
||||
<Label>
|
||||
代理模式
|
||||
<Select
|
||||
value={form.proxyMode}
|
||||
onChange={(event) => updateProxyMode(event.target.value as PlatformWizardForm['proxyMode'])}
|
||||
>
|
||||
<option value="none">不使用代理</option>
|
||||
<option value="global">使用全局代理</option>
|
||||
<option value="custom">自定义代理</option>
|
||||
</Select>
|
||||
</Label>
|
||||
{form.proxyMode === 'custom' && (
|
||||
<Label>
|
||||
HTTP 代理
|
||||
<Input
|
||||
value={form.httpProxy}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
onChange={(event) => setForm({ ...form, httpProxy: event.target.value })}
|
||||
/>
|
||||
</Label>
|
||||
)}
|
||||
</FormSection>
|
||||
|
||||
<FormSection icon={<SlidersHorizontal size={16} />} title="路由与计费">
|
||||
<Label>
|
||||
定价规则
|
||||
@@ -296,6 +333,19 @@ export function PlatformManagementPanel(props: {
|
||||
/>
|
||||
</FormSection>
|
||||
</FormDialog>
|
||||
<ConfirmDialog
|
||||
cancelLabel="关闭"
|
||||
confirmLabel="知道了"
|
||||
confirmVariant="default"
|
||||
description="使用全局代理前,请确认网关服务已设置 AI_GATEWAY_GLOBAL_HTTP_PROXY 或 GLOBAL_HTTP_PROXY 环境变量;也兼容 HTTP_PROXY、HTTPS_PROXY、ALL_PROXY。修改环境变量后需要重启服务才会生效。"
|
||||
loading={props.state === 'loading'}
|
||||
open={globalProxyNoticeOpen}
|
||||
title="使用全局代理"
|
||||
onCancel={() => setGlobalProxyNoticeOpen(false)}
|
||||
onConfirm={() => setGlobalProxyNoticeOpen(false)}
|
||||
>
|
||||
<p className="platformProxyStatus">当前设置代理服务器:{globalProxyStatusText(props.networkProxyConfig)}</p>
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
confirmLabel="删除平台"
|
||||
description="已绑定的模型会一并删除,删除后不可恢复。"
|
||||
@@ -868,6 +918,7 @@ function platformToForm(
|
||||
const config = platform.config ?? {};
|
||||
const retryPolicy = platform.retryPolicy ?? {};
|
||||
const rateLimitPolicy = platform.rateLimitPolicy ?? {};
|
||||
const networkProxy = readNetworkProxyConfig(config);
|
||||
const currentModels = platformModels.filter((model) => model.platformId === platform.id);
|
||||
return {
|
||||
...createEmptyPlatformForm(platform.provider, defaults),
|
||||
@@ -892,6 +943,8 @@ function platformToForm(
|
||||
tpmLimit: readLimit(rateLimitPolicy, 'tpm_total'),
|
||||
concurrencyLimit: readLimit(rateLimitPolicy, 'concurrent'),
|
||||
testMode: readBoolean(config, 'testMode', false),
|
||||
proxyMode: networkProxy.proxyMode,
|
||||
httpProxy: networkProxy.httpProxy,
|
||||
supportBase64Input: readBoolean(config, 'supportBase64Input', true),
|
||||
supportUrlInput: readBoolean(config, 'supportUrlInput', true),
|
||||
selectedModelIds: platformModelBaseIds(platform, baseModels, currentModels),
|
||||
@@ -969,6 +1022,16 @@ function readBoolean(source: Record<string, unknown>, key: string, fallback: boo
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function readNetworkProxyConfig(config: Record<string, unknown>): Pick<PlatformWizardForm, 'proxyMode' | 'httpProxy'> {
|
||||
const networkProxy = readRecord(config.networkProxy);
|
||||
const mode = readString(networkProxy.mode || config.proxyMode);
|
||||
const httpProxy = readString(networkProxy.httpProxy || config.httpProxy);
|
||||
if (mode === 'global' || mode === 'custom') {
|
||||
return { proxyMode: mode, httpProxy };
|
||||
}
|
||||
return { proxyMode: 'none', httpProxy: '' };
|
||||
}
|
||||
|
||||
function readNumber(source: Record<string, unknown>, key: string) {
|
||||
const value = source[key];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
@@ -1056,7 +1119,19 @@ function platformRuntimeSummary(platform: IntegrationPlatform) {
|
||||
const retryPolicy = platform.retryPolicy ?? {};
|
||||
const retryEnabled = readBoolean(retryPolicy, 'enabled', true);
|
||||
const maxAttempts = readNumber(retryPolicy, 'maxAttempts') ?? 2;
|
||||
return `优先级 ${platform.priority} · ${retryEnabled ? `最多重试 ${maxAttempts} 次` : '不重试'}`;
|
||||
return `优先级 ${platform.priority} · ${retryEnabled ? `最多重试 ${maxAttempts} 次` : '不重试'} · ${proxyModeText(readNetworkProxyConfig(platform.config ?? {}).proxyMode)}`;
|
||||
}
|
||||
|
||||
function proxyModeText(mode: PlatformWizardForm['proxyMode']) {
|
||||
if (mode === 'global') return '全局代理';
|
||||
if (mode === 'custom') return '自定义代理';
|
||||
return '直连';
|
||||
}
|
||||
|
||||
function globalProxyStatusText(config: { globalHttpProxy?: string; globalHttpProxySet: boolean } | null) {
|
||||
if (!config) return '读取中';
|
||||
const proxy = config.globalHttpProxy?.trim() ?? '';
|
||||
return config.globalHttpProxySet && proxy ? proxy : '未设置';
|
||||
}
|
||||
|
||||
function formatLimit(value: number) {
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface PlatformWizardForm {
|
||||
tpmLimit: string;
|
||||
concurrencyLimit: string;
|
||||
testMode: boolean;
|
||||
proxyMode: 'none' | 'global' | 'custom';
|
||||
httpProxy: string;
|
||||
supportBase64Input: boolean;
|
||||
supportUrlInput: boolean;
|
||||
modelDiscountFactor: string;
|
||||
@@ -75,6 +77,8 @@ export function createEmptyPlatformForm(provider = '', defaults?: ProviderConnec
|
||||
tpmLimit: '',
|
||||
concurrencyLimit: '',
|
||||
testMode: false,
|
||||
proxyMode: 'none',
|
||||
httpProxy: '',
|
||||
supportBase64Input: true,
|
||||
supportUrlInput: true,
|
||||
modelDiscountFactor: '',
|
||||
@@ -130,6 +134,7 @@ export function platformPayload(form: PlatformWizardForm, options: { preserveEmp
|
||||
credentials,
|
||||
config: {
|
||||
testMode: form.testMode,
|
||||
networkProxy: networkProxyPayload(form),
|
||||
supportBase64Input: form.supportBase64Input,
|
||||
supportUrlInput: form.supportUrlInput,
|
||||
source: 'gateway-admin',
|
||||
@@ -250,6 +255,7 @@ export function validatePlatformForm(form: PlatformWizardForm, selectedCount: nu
|
||||
if (form.authType === 'AccessKey-SecretKey' && (!form.accessKey.trim() || !form.secretKey.trim()) && !form.testMode) {
|
||||
return '请填写 AccessKey 和 SecretKey,或开启测试模式。';
|
||||
}
|
||||
if (form.proxyMode === 'custom' && !form.httpProxy.trim()) return '请填写自定义 HTTP 代理地址。';
|
||||
if (selectedCount === 0) return '请至少添加一个模型。';
|
||||
return '';
|
||||
}
|
||||
@@ -322,6 +328,16 @@ function rateLimitPolicyPayload(form: Pick<PlatformWizardForm, 'rpmLimit' | 'rps
|
||||
return rules.length ? { rules } : {};
|
||||
}
|
||||
|
||||
function networkProxyPayload(form: PlatformWizardForm) {
|
||||
if (form.proxyMode === 'custom') {
|
||||
return {
|
||||
mode: 'custom',
|
||||
httpProxy: form.httpProxy.trim(),
|
||||
};
|
||||
}
|
||||
return { mode: form.proxyMode };
|
||||
}
|
||||
|
||||
function limitRule(metric: string, value: string, windowSeconds = 60) {
|
||||
const limit = positiveNumber(value, 0);
|
||||
if (!limit) return undefined;
|
||||
|
||||
@@ -47,6 +47,32 @@ export interface MediaGenerationRun {
|
||||
task?: GatewayTask;
|
||||
}
|
||||
|
||||
export function gatewayTaskErrorText(task: GatewayTask | undefined, fallback = '任务失败') {
|
||||
if (!task) return fallback;
|
||||
const result = recordFromUnknown(task.result);
|
||||
const resultError = recordFromUnknown(result?.error);
|
||||
const metrics = recordFromUnknown(task.metrics);
|
||||
const hasErrorState = task.status === 'failed'
|
||||
|| task.status === 'cancelled'
|
||||
|| Boolean(task.error || task.errorMessage || task.errorCode || resultError || metrics?.error);
|
||||
if (!hasErrorState) return '';
|
||||
const message = firstString(
|
||||
task.errorMessage,
|
||||
task.error,
|
||||
resultError?.message,
|
||||
result?.message,
|
||||
metrics?.error,
|
||||
fallback,
|
||||
);
|
||||
const meta = uniqueStrings([
|
||||
firstString(task.errorCode, resultError?.code, metrics?.errorCode) ? `错误码: ${firstString(task.errorCode, resultError?.code, metrics?.errorCode)}` : '',
|
||||
firstString(task.requestId, resultError?.requestId, resultError?.request_id, metrics?.requestId) ? `RequestID: ${firstString(task.requestId, resultError?.requestId, resultError?.request_id, metrics?.requestId)}` : '',
|
||||
task.id ? `TaskID: ${task.id}` : '',
|
||||
]);
|
||||
if (!message && !meta.length) return '';
|
||||
return meta.length ? `${message || fallback}(${meta.join(',')})` : message;
|
||||
}
|
||||
|
||||
interface AspectRatioOption {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -487,6 +513,7 @@ function MediaTaskCard(props: {
|
||||
const unit = props.run.mode === 'video' ? '条' : '张';
|
||||
const isPending = props.run.status === 'submitting' || props.run.status === 'queued' || props.run.status === 'running';
|
||||
const backdropItem = expectedCount === 1 && items[0]?.type === 'image' ? items[0] : undefined;
|
||||
const errorText = mediaRunErrorText(props.run);
|
||||
|
||||
return (
|
||||
<article className="mediaTaskItem" data-status={props.run.status}>
|
||||
@@ -517,7 +544,12 @@ function MediaTaskCard(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{props.run.error && <p className="mediaTaskError">{props.run.error}</p>}
|
||||
{errorText && (
|
||||
<div className="mediaTaskError">
|
||||
<strong>错误详情</strong>
|
||||
<span>{errorText}</span>
|
||||
</div>
|
||||
)}
|
||||
<footer className="mediaTaskActions">
|
||||
{items[0] ? (
|
||||
<Button asChild size="sm" variant="secondary">
|
||||
@@ -593,6 +625,10 @@ function mediaResultItems(run: MediaGenerationRun): MediaResultItem[] {
|
||||
.filter((item): item is MediaResultItem => Boolean(item));
|
||||
}
|
||||
|
||||
function mediaRunErrorText(run: MediaGenerationRun) {
|
||||
return gatewayTaskErrorText(run.task, '') || run.error || '';
|
||||
}
|
||||
|
||||
function mediaResultItemFromEntry(entry: unknown, mode: Exclude<PlaygroundMode, 'chat'>): MediaResultItem | undefined {
|
||||
const record = recordFromUnknown(entry);
|
||||
if (!record) return undefined;
|
||||
|
||||
Reference in New Issue
Block a user