feat(admin): 添加网络代理配置和钱包交易功能

- 在管理面板中集成网络代理配置显示和平台代理设置
- 添加钱包摘要和交易列表API接口及数据管理
- 实现SSE流式响应中的错误处理机制
- 添加全局HTTP代理环境变量配置支持
- 更新平台表单以支持代理模式选择和自定义代理地址
- 集成钱包交易查询过滤和分页功能
- 优化API错误详情解析和显示格式
This commit is contained in:
2026-05-11 23:02:10 +08:00
parent c992f1de60
commit f550c0acd5
30 changed files with 1455 additions and 76 deletions
+1
View File
@@ -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}
+21 -13
View File
@@ -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>
);
}
+300 -8
View File
@@ -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) {
+16
View File
@@ -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;
+37 -1
View File
@@ -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;