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
+91 -5
View File
@@ -7,11 +7,14 @@ import type {
GatewayAccessRuleUpsertRequest,
GatewayApiKey,
GatewayAuditLog,
GatewayNetworkProxyConfig,
GatewayTenantUpsertRequest,
GatewayTask,
GatewayUserUpsertRequest,
GatewayTenant,
GatewayUser,
GatewayWalletAccount,
GatewayWalletTransaction,
IntegrationPlatform,
ModelCatalogResponse,
PlatformModel,
@@ -39,9 +42,11 @@ import {
deleteTenant,
deleteUserGroup,
getHealth,
getNetworkProxyConfig,
getTask,
listAuditLogs,
getWalletSummary,
listAccessRules,
listAuditLogs,
listApiKeyAccessRules,
listApiKeys,
listBaseModels,
@@ -55,6 +60,7 @@ import {
listPricingRuleSets,
listRuntimePolicySets,
listTasks,
listWalletTransactions,
listPublicBaseModels,
listPublicCatalogProviders,
listRateLimitWindows,
@@ -112,6 +118,7 @@ import type {
RegisterForm,
TaskForm,
WorkspaceTaskQuery,
WorkspaceTransactionQuery,
WorkspaceSection,
} from './types';
@@ -121,6 +128,7 @@ type DataKey =
| 'playgroundApiKeys'
| 'playgroundModels'
| 'modelCatalog'
| 'networkProxyConfig'
| 'platforms'
| 'models'
| 'providers'
@@ -133,6 +141,8 @@ type DataKey =
| 'users'
| 'userGroups'
| 'tasks'
| 'wallet'
| 'walletTransactions'
| 'accessRules'
| 'auditLogs'
| 'apiKeys';
@@ -143,6 +153,7 @@ export function App() {
const [adminSection, setAdminSection] = useState<AdminSection>(initialRoute.adminSection);
const [workspaceSection, setWorkspaceSection] = useState<WorkspaceSection>(initialRoute.workspaceSection);
const [workspaceTaskQuery, setWorkspaceTaskQuery] = useState<WorkspaceTaskQuery>(initialRoute.workspaceTaskQuery);
const [workspaceTransactionQuery, setWorkspaceTransactionQuery] = useState<WorkspaceTransactionQuery>(() => defaultWorkspaceTransactionQuery());
const [apiDocSection, setApiDocSection] = useState<ApiDocSection>(initialRoute.apiDocSection);
const [playgroundMode, setPlaygroundMode] = useState<PlaygroundMode>(initialRoute.playgroundMode);
const [token, setToken] = useState(readStoredAccessToken);
@@ -159,6 +170,7 @@ export function App() {
summary: { modelCount: 0, sourceCount: 0 },
});
const [playgroundModels, setPlaygroundModels] = useState<PlatformModel[]>([]);
const [networkProxyConfig, setNetworkProxyConfig] = useState<GatewayNetworkProxyConfig | null>(null);
const [providers, setProviders] = useState<CatalogProvider[]>([]);
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
@@ -179,6 +191,9 @@ export function App() {
const [taskResult, setTaskResult] = useState<GatewayTask | null>(null);
const [tasks, setTasks] = useState<GatewayTask[]>([]);
const [taskTotal, setTaskTotal] = useState(0);
const [walletAccounts, setWalletAccounts] = useState<GatewayWalletAccount[]>([]);
const [walletTransactions, setWalletTransactions] = useState<GatewayWalletTransaction[]>([]);
const [walletTransactionTotal, setWalletTransactionTotal] = useState(0);
const [coreState, setCoreState] = useState<LoadState>('idle');
const [coreMessage, setCoreMessage] = useState('');
const [state, setState] = useState<LoadState>('idle');
@@ -187,6 +202,8 @@ export function App() {
const loadingDataKeysRef = useRef(new Set<DataKey>());
const loadedTaskQueryKeyRef = useRef('');
const currentTaskQueryKeyRef = useRef('');
const loadedTransactionQueryKeyRef = useRef('');
const currentTransactionQueryKeyRef = useRef('');
const { removeBaseModel, removeProvider, resetAllBaseModelsToDefault, resetBaseModelToDefault, saveBaseModel, saveProvider } = useCatalogOperations({
setBaseModels,
setCoreMessage,
@@ -207,14 +224,16 @@ export function App() {
token,
});
const taskListRequestKey = workspaceTaskQueryKey(workspaceTaskQuery);
const transactionListRequestKey = workspaceTransactionQueryKey(workspaceTransactionQuery);
currentTaskQueryKeyRef.current = taskListRequestKey;
currentTransactionQueryKeyRef.current = transactionListRequestKey;
useEffect(() => {
void ensureData(['health']);
}, []);
useEffect(() => {
void ensureRouteData(token);
}, [activePage, adminSection, taskListRequestKey, workspaceSection, token]);
}, [activePage, adminSection, taskListRequestKey, transactionListRequestKey, workspaceSection, token]);
useEffect(() => {
function handlePopState() {
applyRoute(parseAppRoute());
@@ -249,6 +268,7 @@ export function App() {
baseModels,
modelCatalog,
models,
networkProxyConfig,
platforms,
pricingRules,
pricingRuleSets,
@@ -260,7 +280,9 @@ export function App() {
tenants,
userGroups,
users,
}), [accessRules, apiKeys, auditLogs, baseModels, modelCatalog, models, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runtimePolicySets, taskResult, tasks, tenants, userGroups, users]);
walletAccounts,
walletTransactions,
}), [accessRules, apiKeys, auditLogs, baseModels, modelCatalog, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runtimePolicySets, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]);
async function refresh(nextToken = token) {
await ensureRouteData(nextToken, true);
@@ -275,6 +297,10 @@ export function App() {
loadedDataKeysRef.current.delete('tasks');
loadingDataKeysRef.current.delete('tasks');
}
if (activePage === 'workspace' && workspaceSection === 'transactions' && loadedTransactionQueryKeyRef.current !== transactionListRequestKey) {
loadedDataKeysRef.current.delete('walletTransactions');
loadingDataKeysRef.current.delete('walletTransactions');
}
await ensureData(dataKeysForRoute(activePage, adminSection, workspaceSection, Boolean(nextToken)), nextToken, force);
}
@@ -326,6 +352,9 @@ export function App() {
case 'modelCatalog':
setModelCatalog(await listModelCatalog(nextToken));
return;
case 'networkProxyConfig':
setNetworkProxyConfig(await getNetworkProxyConfig(nextToken));
return;
case 'playgroundModels':
setPlaygroundModels((await listPlayableModels(nextToken)).items);
return;
@@ -373,6 +402,20 @@ export function App() {
loadedTaskQueryKeyRef.current = requestKey;
}
return;
case 'wallet': {
const response = await getWalletSummary(nextToken);
setWalletAccounts(response.accounts);
return;
}
case 'walletTransactions': {
const requestKey = transactionListRequestKey;
const response = await listWalletTransactions(nextToken, { ...normalizeWorkspaceTransactionQuery(workspaceTransactionQuery), direction: 'debit' });
if (requestKey !== currentTransactionQueryKeyRef.current) return;
setWalletTransactions(response.items);
setWalletTransactionTotal(response.total ?? response.items.length);
loadedTransactionQueryKeyRef.current = requestKey;
return;
}
case 'accessRules':
setAccessRules((await (activePage === 'workspace' && workspaceSection === 'apiKeys'
? listApiKeyAccessRules(nextToken)
@@ -707,7 +750,7 @@ export function App() {
const detail = await getTask(credential, response.task.id);
setTaskResult(detail);
setTasks((current) => [detail, ...current.filter((item) => item.id !== detail.id)]);
invalidateDataKeys('tasks');
invalidateDataKeys('tasks', 'wallet', 'walletTransactions');
setCoreState('ready');
setCoreMessage(`${taskForm.kind} 已通过 ${apiKeySecret ? '本地 API Key' : '当前 Access Token'} 完成 simulation。`);
} catch (err) {
@@ -726,6 +769,7 @@ export function App() {
setModels([]);
setModelCatalog({ items: [], filters: { capabilities: [], providers: [] }, summary: { modelCount: 0, sourceCount: 0 } });
setPlaygroundModels([]);
setNetworkProxyConfig(null);
setProviders([]);
setBaseModels([]);
setPricingRules([]);
@@ -744,6 +788,10 @@ export function App() {
setTaskResult(null);
setTasks([]);
setTaskTotal(0);
setWalletAccounts([]);
setWalletTransactions([]);
setWalletTransactionTotal(0);
setWorkspaceTransactionQuery(defaultWorkspaceTransactionQuery());
setCoreMessage('');
navigatePath('/');
}
@@ -849,12 +897,15 @@ export function App() {
state={coreState}
taskQuery={workspaceTaskQuery}
taskTotal={taskTotal}
transactionQuery={workspaceTransactionQuery}
transactionTotal={walletTransactionTotal}
onBatchAccessRules={batchSaveAPIKeyAccessRules}
onDeleteApiKey={removeAPIKey}
onApiKeyFormChange={setApiKeyForm}
onSectionChange={navigateWorkspaceSection}
onSubmitApiKey={submitAPIKey}
onTaskQueryChange={navigateWorkspaceTaskQuery}
onTransactionQueryChange={setWorkspaceTransactionQuery}
onUseApiKeyForPlayground={useApiKeyForPlayground}
/>
) : (
@@ -1010,6 +1061,39 @@ function nonEmptyRecord(value: Record<string, unknown> | undefined) {
return value && Object.keys(value).length > 0 ? value : undefined;
}
function defaultWorkspaceTransactionQuery(): WorkspaceTransactionQuery {
return {
query: '',
createdFrom: '',
createdTo: '',
page: 1,
pageSize: 10,
};
}
function normalizeWorkspaceTransactionQuery(query: WorkspaceTransactionQuery): WorkspaceTransactionQuery {
return {
query: query.query.trim(),
createdFrom: query.createdFrom.trim(),
createdTo: query.createdTo.trim(),
page: positiveInt(query.page, 1),
pageSize: clampTransactionPageSize(query.pageSize),
};
}
function workspaceTransactionQueryKey(query: WorkspaceTransactionQuery) {
return JSON.stringify(normalizeWorkspaceTransactionQuery(query));
}
function positiveInt(value: number, fallback: number) {
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
}
function clampTransactionPageSize(value: number) {
const normalized = positiveInt(value, 10);
return Math.min(100, Math.max(1, normalized));
}
function dataKeysForRoute(
activePage: PageKey,
adminSection: AdminSection,
@@ -1027,8 +1111,10 @@ function dataKeysForRoute(
if (activePage === 'workspace') {
if (workspaceSection === 'overview') return ['users', 'userGroups', 'apiKeys'];
if (workspaceSection === 'billing') return ['wallet'];
if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'playgroundModels'];
if (workspaceSection === 'tasks') return ['tasks'];
if (workspaceSection === 'transactions') return ['wallet', 'walletTransactions'];
return [];
}
@@ -1045,7 +1131,7 @@ function dataKeysForRoute(
case 'baseModels':
return ['baseModels', 'providers', 'pricingRuleSets', 'runtimePolicySets'];
case 'platforms':
return ['platforms', 'models', 'providers', 'baseModels', 'pricingRuleSets'];
return ['platforms', 'models', 'providers', 'baseModels', 'pricingRuleSets', 'networkProxyConfig'];
case 'tenants':
return ['tenants', 'userGroups'];
case 'users':
+131 -16
View File
@@ -12,9 +12,11 @@ import type {
GatewayAuditLog,
GatewayTenant,
GatewayTenantUpsertRequest,
GatewayNetworkProxyConfig,
GatewayTask,
GatewayUser,
GatewayUserUpsertRequest,
GatewayWalletTransaction,
IntegrationPlatform,
ListResponse,
ModelCatalogResponse,
@@ -30,11 +32,35 @@ import type {
UserGroupUpsertRequest,
WalletAdjustmentResponse,
WalletBalanceAdjustmentRequest,
WalletSummaryResponse,
} from '@easyai-ai-gateway/contracts';
import type { PlatformCreateInput, PlatformModelBindingInput, WorkspaceTaskQuery } from './types';
const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088';
interface GatewayErrorDetails {
code?: string;
message: string;
requestId?: string;
status?: number;
taskId?: string;
}
export class GatewayApiError extends Error {
readonly details: GatewayErrorDetails;
constructor(details: GatewayErrorDetails | string) {
const normalized = typeof details === 'string' ? { message: details } : details;
super(formatGatewayErrorDetails(normalized));
this.name = 'GatewayApiError';
this.details = normalized;
}
toString() {
return this.message;
}
}
export interface HealthResponse {
ok: boolean;
service: string;
@@ -508,7 +534,7 @@ export async function* streamChatCompletionText(
});
if (!response.ok) {
const body = await response.text();
throw new Error(parseErrorMessage(body) || `Request failed: ${response.status}`);
throw new GatewayApiError(parseErrorDetails(body, response.status, `Request failed: ${response.status}`));
}
if (!response.body) {
return;
@@ -523,13 +549,15 @@ export async function* streamChatCompletionText(
const events = buffer.split(/\n\n/);
buffer = events.pop() ?? '';
for (const eventBlock of events) {
const delta = parseSSEBlockDelta(eventBlock);
if (delta) yield delta;
const event = parseSSEBlock(eventBlock);
if (event.error) throw new GatewayApiError(event.error);
if (event.delta) yield event.delta;
}
}
if (buffer.trim()) {
const delta = parseSSEBlockDelta(buffer);
if (delta) yield delta;
const event = parseSSEBlock(buffer);
if (event.error) throw new GatewayApiError(event.error);
if (event.delta) yield event.delta;
}
}
@@ -607,7 +635,7 @@ export async function estimatePricing(
}
export async function getTask(token: string, taskId: string): Promise<GatewayTask> {
return request<GatewayTask>(`/api/v1/tasks/${taskId}`, { token });
return request<GatewayTask>(`/api/workspace/tasks/${taskId}`, { token });
}
export async function listTasks(token: string, query: WorkspaceTaskQuery): Promise<ListResponse<GatewayTask>> {
@@ -619,7 +647,27 @@ export async function listTasks(token: string, query: WorkspaceTaskQuery): Promi
if (query.modelType) search.set('modelType', query.modelType);
if (query.createdFrom) search.set('createdFrom', query.createdFrom);
if (query.createdTo) search.set('createdTo', query.createdTo);
return request<ListResponse<GatewayTask>>(`/api/v1/tasks?${search.toString()}`, { token });
return request<ListResponse<GatewayTask>>(`/api/workspace/tasks?${search.toString()}`, { token });
}
export async function getWalletSummary(token: string): Promise<WalletSummaryResponse> {
return request<WalletSummaryResponse>('/api/workspace/wallet', { token });
}
export async function listWalletTransactions(
token: string,
input: { query?: string; direction?: string; transactionType?: string; createdFrom?: string; createdTo?: string; page?: number; pageSize?: number } = {},
): Promise<ListResponse<GatewayWalletTransaction>> {
const search = new URLSearchParams({
page: String(input.page ?? 1),
pageSize: String(input.pageSize ?? 50),
});
if (input.query) search.set('q', input.query);
if (input.direction) search.set('direction', input.direction);
if (input.transactionType) search.set('transactionType', input.transactionType);
if (input.createdFrom) search.set('createdFrom', input.createdFrom);
if (input.createdTo) search.set('createdTo', input.createdTo);
return request<ListResponse<GatewayWalletTransaction>>(`/api/workspace/wallet/transactions?${search.toString()}`, { token });
}
export function resolveApiAssetUrl(src: string) {
@@ -631,6 +679,10 @@ export async function listRateLimitWindows(token: string): Promise<ListResponse<
return request<ListResponse<RateLimitWindow>>('/api/admin/runtime/rate-limit-windows', { token });
}
export async function getNetworkProxyConfig(token: string): Promise<GatewayNetworkProxyConfig> {
return request<GatewayNetworkProxyConfig>('/api/admin/config/network-proxy', { token });
}
async function request<T>(
path: string,
options: { token?: string; auth?: boolean; method?: string; body?: unknown } = {},
@@ -649,7 +701,7 @@ async function request<T>(
});
if (!response.ok) {
const body = await response.text();
throw new Error(parseErrorMessage(body) || `Request failed: ${response.status}`);
throw new GatewayApiError(parseErrorDetails(body, response.status, `Request failed: ${response.status}`));
}
if (response.status === 204) {
return undefined as T;
@@ -658,33 +710,96 @@ async function request<T>(
}
function parseErrorMessage(body: string) {
return formatGatewayErrorDetails(parseErrorDetails(body));
}
function parseErrorDetails(body: string, status?: number, fallback = ''): GatewayErrorDetails {
if (!body) {
return '';
return { message: fallback, status };
}
try {
const parsed = JSON.parse(body) as { error?: { message?: string } };
return parsed.error?.message ?? body;
const parsed = JSON.parse(body) as unknown;
return errorDetailsFromParsed(parsed, status, fallback || body);
} catch {
return body;
return { message: body || fallback, status };
}
}
function parseSSEBlockDelta(block: string) {
function parseSSEBlock(block: string): { delta: string; error?: GatewayErrorDetails } {
const eventName = block
.split(/\n/)
.find((line) => line.startsWith('event:'))
?.replace(/^event:\s?/, '')
.trim();
const data = block
.split(/\n/)
.filter((line) => line.startsWith('data:'))
.map((line) => line.replace(/^data:\s?/, ''))
.join('\n')
.trim();
if (!data || data === '[DONE]') return '';
if (!data || data === '[DONE]') return { delta: '' };
try {
const parsed = JSON.parse(data) as {
choices?: Array<{ delta?: { content?: string }; message?: { content?: string } }>;
delta?: string;
error?: unknown;
output_text?: string;
};
return parsed.choices?.[0]?.delta?.content ?? parsed.delta ?? parsed.output_text ?? '';
if (eventName === 'error' || parsed.error) {
return { delta: '', error: errorDetailsFromParsed(parsed, undefined, data) };
}
return { delta: parsed.choices?.[0]?.delta?.content ?? parsed.delta ?? parsed.output_text ?? '' };
} catch {
return data;
if (eventName === 'error') return { delta: '', error: { message: data } };
return { delta: data };
}
}
function errorDetailsFromParsed(parsed: unknown, status?: number, fallback = ''): GatewayErrorDetails {
const root = recordFromUnknown(parsed);
if (!root) return { message: fallback, status };
const error = recordFromUnknown(root.error);
const message = firstString(
error?.message,
error?.error,
root.message,
root.errorMessage,
fallback,
);
return {
code: firstString(error?.code, error?.type, root.code, root.errorCode),
message: message || fallback,
requestId: firstString(error?.requestId, error?.request_id, root.requestId, root.request_id),
status: numberFromUnknown(error?.status) ?? numberFromUnknown(root.status) ?? status,
taskId: firstString(error?.taskId, error?.task_id, root.taskId, root.task_id),
};
}
function formatGatewayErrorDetails(details: GatewayErrorDetails) {
const message = details.message || '请求失败';
const meta = [
details.code ? `错误码: ${details.code}` : '',
details.status ? `状态: ${details.status}` : '',
details.requestId ? `RequestID: ${details.requestId}` : '',
details.taskId ? `TaskID: ${details.taskId}` : '',
].filter(Boolean);
return meta.length ? `${message}${meta.join('')}` : message;
}
function recordFromUnknown(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
return value as Record<string, unknown>;
}
function firstString(...values: unknown[]) {
return values.map((value) => typeof value === 'string' ? value.trim() : '').find(Boolean) ?? '';
}
function numberFromUnknown(value: unknown) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
+6
View File
@@ -4,9 +4,12 @@ import type {
GatewayAccessRule,
GatewayApiKey,
GatewayAuditLog,
GatewayNetworkProxyConfig,
GatewayTask,
GatewayTenant,
GatewayUser,
GatewayWalletAccount,
GatewayWalletTransaction,
IntegrationPlatform,
ModelCatalogResponse,
PlatformModel,
@@ -24,6 +27,7 @@ export interface ConsoleData {
baseModels: BaseModelCatalogItem[];
modelCatalog: ModelCatalogResponse;
models: PlatformModel[];
networkProxyConfig: GatewayNetworkProxyConfig | null;
platforms: IntegrationPlatform[];
pricingRules: PricingRule[];
pricingRuleSets: PricingRuleSet[];
@@ -35,6 +39,8 @@ export interface ConsoleData {
tenants: GatewayTenant[];
userGroups: UserGroup[];
users: GatewayUser[];
walletAccounts: GatewayWalletAccount[];
walletTransactions: GatewayWalletTransaction[];
}
export interface StatItem {
+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;
+1
View File
@@ -23,6 +23,7 @@ const workspacePaths: Record<WorkspaceSection, string> = {
billing: '/workspace/billing',
apiKeys: '/workspace/api-keys',
tasks: '/workspace/tasks',
transactions: '/workspace/transactions',
};
const adminPaths: Record<AdminSection, string> = {
+108
View File
@@ -423,11 +423,105 @@ strong {
line-height: 1.45;
overflow-wrap: anywhere;
}
.taskRecordJsonButton {
width: 100%;
justify-content: flex-start;
}
.walletMetricGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.walletTransactionViewport {
--sh-table-viewport-gap: 10px;
--sh-table-viewport-height: calc(100dvh - 90px);
margin-bottom: -52px;
}
.walletTransactionFilters {
grid-template-columns: minmax(260px, 1.2fr) minmax(360px, 1.1fr) auto;
gap: 10px;
}
.walletTransactionTable .shTableRow {
grid-template-columns: minmax(150px, 0.65fr) minmax(220px, 1fr) minmax(180px, 0.82fr) minmax(136px, 0.62fr) minmax(148px, 0.68fr) minmax(154px, 0.7fr) minmax(116px, 0.52fr) minmax(140px, 0.62fr) minmax(250px, 1.14fr);
min-width: 1494px;
align-items: start;
}
.walletTransactionPrimaryCell,
.walletTransactionRef {
display: grid;
min-width: 0;
gap: 3px;
line-height: 1.3;
}
.walletTransactionPrimaryCell strong,
.walletTransactionPrimaryCell small,
.walletTransactionRef small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.walletTransactionPrimaryCell strong {
color: var(--text-strong);
font-weight: var(--font-weight-semibold);
}
.walletTransactionPrimaryCell small,
.walletTransactionRef small {
color: var(--text-soft);
font-size: var(--font-size-xs);
}
.walletTransactionModelCell,
.walletTransactionPlatformCell,
.walletTransactionTokenCell {
overflow: visible;
white-space: normal;
}
.walletTransactionModelCell .walletTransactionPrimaryCell strong,
.walletTransactionModelCell .walletTransactionPrimaryCell small,
.walletTransactionPlatformCell .walletTransactionPrimaryCell strong,
.walletTransactionPlatformCell .walletTransactionPrimaryCell small {
overflow: visible;
text-overflow: clip;
white-space: normal;
word-break: break-word;
}
.walletTransactionAmount {
color: #166534;
font-weight: var(--font-weight-semibold);
}
.walletTransactionAmount[data-direction="debit"] {
color: #b42318;
}
.walletBalanceChange {
display: grid;
gap: 2px;
color: var(--text-soft);
line-height: 1.3;
}
.walletTransactionRef code {
display: block;
overflow: hidden;
color: var(--text-normal);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.taskJsonDialog {
width: min(920px, 100%);
}
@@ -741,6 +835,14 @@ strong {
grid-template-columns: 1fr;
}
.walletTransactionFilters {
grid-template-columns: 1fr;
}
.walletMetricGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.shTableFooter {
align-items: flex-start;
flex-direction: column;
@@ -750,3 +852,9 @@ strong {
flex-wrap: wrap;
}
}
@media (max-width: 560px) {
.walletMetricGrid {
grid-template-columns: 1fr;
}
}
+8
View File
@@ -1115,6 +1115,14 @@
font-weight: var(--font-weight-semibold);
}
.platformProxyStatus {
margin: 10px 0 0;
color: var(--text-normal);
font-size: 0.8125rem;
font-weight: var(--font-weight-semibold);
word-break: break-all;
}
.platformCredentialField {
gap: 6px;
}
+26
View File
@@ -811,11 +811,25 @@
}
.assistantBubble.error {
display: grid;
gap: 4px;
border-color: #f0d4d4;
background: #fff7f7;
color: #9f2f2f;
}
.assistantBubble.error strong {
color: #7f1d1d;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.assistantErrorMessage {
color: #9f2f2f;
line-height: var(--line-height-relaxed);
overflow-wrap: anywhere;
}
.assistantTyping {
color: var(--muted-foreground);
}
@@ -1423,6 +1437,8 @@
}
.mediaTaskError {
display: grid;
gap: 4px;
width: fit-content;
max-width: 100%;
padding: 8px 10px;
@@ -1431,6 +1447,16 @@
background: #fef2f2;
color: #991b1b;
font-size: var(--font-size-sm);
line-height: var(--line-height-relaxed);
}
.mediaTaskError strong {
color: #7f1d1d;
font-weight: var(--font-weight-semibold);
}
.mediaTaskError span {
overflow-wrap: anywhere;
}
.mediaTaskActions {
+9 -1
View File
@@ -3,7 +3,7 @@ export type AuthMode = 'login' | 'register' | 'external';
export type TaskKind = 'chat.completions' | 'images.generations' | 'images.edits';
export type PageKey = 'home' | 'playground' | 'models' | 'workspace' | 'admin' | 'docs';
export type PlaygroundMode = 'chat' | 'image' | 'video';
export type WorkspaceSection = 'overview' | 'billing' | 'apiKeys' | 'tasks';
export type WorkspaceSection = 'overview' | 'billing' | 'apiKeys' | 'tasks' | 'transactions';
export type ApiDocSection = 'chat' | 'imageGeneration' | 'imageEdit' | 'pricing' | 'files';
export type AdminSection =
| 'overview'
@@ -53,6 +53,14 @@ export interface WorkspaceTaskQuery {
pageSize: number;
}
export interface WorkspaceTransactionQuery {
query: string;
createdFrom: string;
createdTo: string;
page: number;
pageSize: number;
}
export interface PlatformForm {
provider: string;
platformKey: string;