feat: add file storage settings and uploads

This commit is contained in:
2026-05-13 20:23:45 +08:00
parent 0d0d0b9115
commit fc5dfd6bc5
21 changed files with 3401 additions and 72 deletions
+76 -1
View File
@@ -2,6 +2,10 @@ import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import type {
BaseModelCatalogItem,
CatalogProvider,
FileStorageChannel,
FileStorageSettings,
FileStorageSettingsUpdateRequest,
FileStorageChannelUpsertRequest,
GatewayAccessRuleBatchRequest,
GatewayAccessRule,
GatewayAccessRuleUpsertRequest,
@@ -34,17 +38,21 @@ import {
batchApiKeyAccessRules,
createAccessRule,
createApiKey,
createFileStorageChannel,
createGatewayUser,
createPlatform,
createTenant,
createUserGroup,
deleteAccessRule,
deleteApiKey,
deleteFileStorageChannel,
deleteGatewayUser,
deletePlatform,
deleteTenant,
deleteUserGroup,
getHealth,
listFileStorageChannels,
getFileStorageSettings,
getNetworkProxyConfig,
getRunnerPolicy,
getWalletSummary,
@@ -78,6 +86,8 @@ import {
setUserWalletBalance,
type HealthResponse,
updateAccessRule,
updateFileStorageChannel,
updateFileStorageSettings,
updateGatewayUser,
updatePlatform,
updatePlatformDynamicPriority,
@@ -135,6 +145,8 @@ type DataKey =
| 'playgroundModels'
| 'modelCatalog'
| 'networkProxyConfig'
| 'fileStorageChannels'
| 'fileStorageSettings'
| 'platforms'
| 'models'
| 'providers'
@@ -179,6 +191,8 @@ export function App() {
});
const [playgroundModels, setPlaygroundModels] = useState<PlatformModel[]>([]);
const [networkProxyConfig, setNetworkProxyConfig] = useState<GatewayNetworkProxyConfig | null>(null);
const [fileStorageChannels, setFileStorageChannels] = useState<FileStorageChannel[]>([]);
const [fileStorageSettings, setFileStorageSettings] = useState<FileStorageSettings | null>(null);
const [providers, setProviders] = useState<CatalogProvider[]>([]);
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
@@ -296,6 +310,8 @@ export function App() {
auditLogs,
apiKeys,
baseModels,
fileStorageChannels,
fileStorageSettings,
modelCatalog,
models,
networkProxyConfig,
@@ -315,7 +331,7 @@ export function App() {
users,
walletAccounts,
walletTransactions,
}), [accessRules, apiKeys, auditLogs, baseModels, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]);
}), [accessRules, apiKeys, auditLogs, baseModels, fileStorageChannels, fileStorageSettings, modelCatalog, modelRateLimits, modelRateLimitsUpdatedAt, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]);
async function refresh(nextToken = token) {
await ensureRouteData(nextToken, true);
@@ -388,6 +404,12 @@ export function App() {
case 'networkProxyConfig':
setNetworkProxyConfig(await getNetworkProxyConfig(nextToken));
return;
case 'fileStorageChannels':
setFileStorageChannels((await listFileStorageChannels(nextToken)).items);
return;
case 'fileStorageSettings':
setFileStorageSettings(await getFileStorageSettings(nextToken));
return;
case 'playgroundModels':
setPlaygroundModels((await listPlayableModels(nextToken)).items);
return;
@@ -818,6 +840,53 @@ export function App() {
}
}
async function saveFileStorageChannel(input: FileStorageChannelUpsertRequest, channelId?: string) {
setCoreState('loading');
setCoreMessage('');
try {
const item = channelId
? await updateFileStorageChannel(token, channelId, input)
: await createFileStorageChannel(token, input);
setFileStorageChannels((current) => [item, ...current.filter((channel) => channel.id !== item.id)]);
setCoreState('ready');
setCoreMessage(channelId ? '文件存储渠道已更新。' : '文件存储渠道已新增。');
} catch (err) {
setCoreState('error');
setCoreMessage(err instanceof Error ? err.message : channelId ? '更新文件存储渠道失败' : '新增文件存储渠道失败');
throw err;
}
}
async function saveFileStorageSettings(input: FileStorageSettingsUpdateRequest) {
setCoreState('loading');
setCoreMessage('');
try {
const settings = await updateFileStorageSettings(token, input);
setFileStorageSettings(settings);
setCoreState('ready');
setCoreMessage('文件存储全局策略已更新。');
} catch (err) {
setCoreState('error');
setCoreMessage(err instanceof Error ? err.message : '更新文件存储全局策略失败');
throw err;
}
}
async function removeFileStorageChannel(channelId: string) {
setCoreState('loading');
setCoreMessage('');
try {
await deleteFileStorageChannel(token, channelId);
setFileStorageChannels((current) => current.filter((channel) => channel.id !== channelId));
setCoreState('ready');
setCoreMessage('文件存储渠道已删除。');
} catch (err) {
setCoreState('error');
setCoreMessage(err instanceof Error ? err.message : '删除文件存储渠道失败');
throw err;
}
}
async function batchSaveAPIKeyAccessRules(input: GatewayAccessRuleBatchRequest) {
setCoreState('loading');
setCoreMessage('');
@@ -867,6 +936,7 @@ export function App() {
setModelCatalog({ items: [], filters: { capabilities: [], providers: [] }, summary: { modelCount: 0, sourceCount: 0 } });
setPlaygroundModels([]);
setNetworkProxyConfig(null);
setFileStorageChannels([]);
setProviders([]);
setBaseModels([]);
setPricingRules([]);
@@ -1039,6 +1109,7 @@ export function App() {
onDeletePricingRuleSet={removePricingRuleSet}
onDeleteRuntimePolicySet={removeRuntimePolicySet}
onDeleteAccessRule={removeAccessRule}
onDeleteFileStorageChannel={removeFileStorageChannel}
onDeleteTenant={removeTenant}
onDeleteUser={removeUser}
onDeleteUserGroup={removeUserGroup}
@@ -1054,6 +1125,8 @@ export function App() {
onSaveRuntimePolicySet={saveRuntimePolicySet}
onBatchAccessRules={batchSaveAccessRules}
onSaveAccessRule={saveAccessRule}
onSaveFileStorageChannel={saveFileStorageChannel}
onSaveFileStorageSettings={saveFileStorageSettings}
onSaveTenant={saveTenant}
onSaveUser={saveUser}
onSetUserWalletBalance={saveUserWalletBalance}
@@ -1267,6 +1340,8 @@ function dataKeysForRoute(
return ['auditLogs'];
case 'accessRules':
return ['accessRules', 'userGroups', 'platforms', 'models'];
case 'systemSettings':
return ['fileStorageSettings', 'fileStorageChannels'];
default:
return [];
}
+130 -1
View File
@@ -5,6 +5,10 @@ import type {
CatalogProvider,
CatalogProviderUpsertRequest,
CreatedGatewayApiKey,
FileStorageChannel,
FileStorageSettings,
FileStorageSettingsUpdateRequest,
FileStorageChannelUpsertRequest,
GatewayAccessRuleBatchRequest,
GatewayAccessRule,
GatewayAccessRuleUpsertRequest,
@@ -601,10 +605,17 @@ export async function createImageGenerationTask(
model: string;
prompt: string;
aspect_ratio?: string;
content?: Array<Record<string, unknown>>;
count?: number;
height?: number;
image?: string | string[];
image_url?: string | string[];
image_urls?: string[];
images?: string[];
n?: number;
quality?: string;
referenceImage?: string | string[];
reference_image?: string | string[];
resolution?: string;
runMode?: string;
simulation?: boolean;
@@ -622,7 +633,26 @@ export async function createImageGenerationTask(
export async function createImageEditTask(
token: string,
input: { model: string; prompt: string; image?: string; mask?: string; runMode?: string; simulation?: boolean },
input: {
model: string;
prompt: string;
aspect_ratio?: string;
content?: Array<Record<string, unknown>>;
count?: number;
height?: number;
image?: string | string[];
image_url?: string | string[];
image_urls?: string[];
images?: string[];
mask?: string;
n?: number;
quality?: string;
resolution?: string;
runMode?: string;
simulation?: boolean;
size?: string;
width?: number;
},
): Promise<{ task: GatewayTask; next: Record<string, string> }> {
return request<{ task: GatewayTask; next: Record<string, string> }>('/api/v1/images/edits', {
body: input,
@@ -636,6 +666,9 @@ export async function createVideoGenerationTask(
token: string,
input: {
audio?: boolean;
audioUrl?: string | string[];
audio_url?: string | string[];
content?: Array<Record<string, unknown>>;
model: string;
prompt: string;
aspect_ratio?: string;
@@ -643,12 +676,24 @@ export async function createVideoGenerationTask(
duration?: number;
duration_seconds?: number;
height?: number;
image?: string | string[];
imageUrl?: string | string[];
image_url?: string | string[];
imageUrls?: string[];
image_urls?: string[];
n?: number;
output_audio?: boolean;
referenceAudio?: string | string[];
referenceVideo?: string | string[];
reference_audio?: string | string[];
reference_image?: string | string[];
reference_video?: string | string[];
resolution?: string;
runMode?: string;
simulation?: boolean;
size?: string;
videoUrl?: string | string[];
video_url?: string | string[];
width?: number;
},
): Promise<{ task: GatewayTask; next: Record<string, string> }> {
@@ -660,6 +705,41 @@ export async function createVideoGenerationTask(
});
}
export interface GatewayFileUploadResponse extends Record<string, unknown> {
fileUrl?: string;
file_url?: string;
url?: string;
}
export async function uploadFileToStorage(
token: string,
file: File,
source = 'ai-gateway-playground',
): Promise<GatewayFileUploadResponse> {
const form = new FormData();
form.append('file', file);
form.append('source', source);
const response = await fetch(`${API_BASE}/v1/files/upload`, {
body: form,
headers: {
Authorization: `Bearer ${token}`,
},
method: 'POST',
});
const body = await response.text();
if (!response.ok) {
throw new GatewayApiError(parseErrorDetails(body, response.status, `Request failed: ${response.status}`));
}
if (!body) return {};
try {
const parsed = JSON.parse(body) as unknown;
return recordFromUnknown(parsed) ? (parsed as GatewayFileUploadResponse) : {};
} catch {
return { url: body };
}
}
export async function estimatePricing(
token: string,
input: Record<string, unknown>,
@@ -758,6 +838,55 @@ export async function getNetworkProxyConfig(token: string): Promise<GatewayNetwo
return request<GatewayNetworkProxyConfig>('/api/admin/config/network-proxy', { token });
}
export async function listFileStorageChannels(token: string): Promise<ListResponse<FileStorageChannel>> {
return request<ListResponse<FileStorageChannel>>('/api/admin/system/file-storage/channels', { token });
}
export async function getFileStorageSettings(token: string): Promise<FileStorageSettings> {
return request<FileStorageSettings>('/api/admin/system/file-storage/settings', { token });
}
export async function updateFileStorageSettings(
token: string,
input: FileStorageSettingsUpdateRequest,
): Promise<FileStorageSettings> {
return request<FileStorageSettings>('/api/admin/system/file-storage/settings', {
body: input,
method: 'PATCH',
token,
});
}
export async function createFileStorageChannel(
token: string,
input: FileStorageChannelUpsertRequest,
): Promise<FileStorageChannel> {
return request<FileStorageChannel>('/api/admin/system/file-storage/channels', {
body: input,
method: 'POST',
token,
});
}
export async function updateFileStorageChannel(
token: string,
channelId: string,
input: FileStorageChannelUpsertRequest,
): Promise<FileStorageChannel> {
return request<FileStorageChannel>(`/api/admin/system/file-storage/channels/${channelId}`, {
body: input,
method: 'PATCH',
token,
});
}
export async function deleteFileStorageChannel(token: string, channelId: string): Promise<void> {
await request<void>(`/api/admin/system/file-storage/channels/${channelId}`, {
method: 'DELETE',
token,
});
}
async function request<T>(
path: string,
options: { token?: string; auth?: boolean; method?: string; body?: unknown; headers?: Record<string, string> } = {},
+4
View File
@@ -1,6 +1,8 @@
import type {
BaseModelCatalogItem,
CatalogProvider,
FileStorageChannel,
FileStorageSettings,
GatewayAccessRule,
GatewayApiKey,
GatewayAuditLog,
@@ -27,6 +29,8 @@ export interface ConsoleData {
auditLogs: GatewayAuditLog[];
apiKeys: GatewayApiKey[];
baseModels: BaseModelCatalogItem[];
fileStorageChannels: FileStorageChannel[];
fileStorageSettings: FileStorageSettings | null;
modelCatalog: ModelCatalogResponse;
models: PlatformModel[];
networkProxyConfig: GatewayNetworkProxyConfig | null;
+19 -1
View File
@@ -1,8 +1,10 @@
import type { ReactNode } from 'react';
import { Boxes, Building2, Gauge, History, KeyRound, Route, ServerCog, ShieldCheck, UsersRound, Workflow } from 'lucide-react';
import { Boxes, Building2, Gauge, History, KeyRound, Route, ServerCog, Settings, ShieldCheck, UsersRound, Workflow } from 'lucide-react';
import type {
BaseModelUpsertRequest,
CatalogProviderUpsertRequest,
FileStorageChannelUpsertRequest,
FileStorageSettingsUpdateRequest,
GatewayAccessRuleBatchRequest,
GatewayAccessRuleUpsertRequest,
GatewayTenantUpsertRequest,
@@ -29,6 +31,7 @@ import { PricingRulesPanel } from './admin/PricingRulesPanel';
import { ProviderManagementPanel } from './admin/ProviderManagementPanel';
import { RealtimeLoadPanel } from './admin/RealtimeLoadPanel';
import { RuntimePoliciesPanel } from './admin/RuntimePoliciesPanel';
import { SystemSettingsPanel } from './admin/SystemSettingsPanel';
const tabs = [
{ value: 'overview', label: '总览', icon: <Workflow size={15} /> },
@@ -42,6 +45,7 @@ const tabs = [
{ value: 'users', label: '用户', icon: <UsersRound size={15} /> },
{ value: 'userGroups', label: '用户组', icon: <UsersRound size={15} /> },
{ value: 'accessRules', label: '模型权限', icon: <KeyRound size={15} /> },
{ value: 'systemSettings', label: '系统设置', icon: <Settings size={15} /> },
{ value: 'auditLogs', label: '审计日志', icon: <History size={15} /> },
] satisfies Array<{ value: AdminSection; label: string; icon: ReactNode }>;
@@ -57,6 +61,7 @@ export function AdminPage(props: {
onDeletePricingRuleSet: (ruleSetId: string) => Promise<void>;
onDeleteRuntimePolicySet: (policySetId: string) => Promise<void>;
onDeleteAccessRule: (ruleId: string) => Promise<void>;
onDeleteFileStorageChannel: (channelId: string) => Promise<void>;
onDeleteTenant: (tenantId: string) => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>;
onDeleteUserGroup: (groupId: string) => Promise<void>;
@@ -72,6 +77,8 @@ export function AdminPage(props: {
onSaveRunnerPolicy: (input: GatewayRunnerPolicyUpsertRequest) => Promise<void>;
onSaveRuntimePolicySet: (input: RuntimePolicySetUpsertRequest, policySetId?: string) => Promise<void>;
onSaveAccessRule: (input: GatewayAccessRuleUpsertRequest, ruleId?: string) => Promise<void>;
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
onSetUserWalletBalance: (userId: string, input: WalletBalanceAdjustmentRequest) => Promise<void>;
@@ -172,6 +179,17 @@ export function AdminPage(props: {
{props.section === 'users' && <UsersPanel {...identityPanelProps(props)} />}
{props.section === 'userGroups' && <UserGroupsPanel {...identityPanelProps(props)} />}
{props.section === 'auditLogs' && <AuditLogsPanel auditLogs={props.data.auditLogs} message={props.operationMessage} />}
{props.section === 'systemSettings' && (
<SystemSettingsPanel
channels={props.data.fileStorageChannels}
settings={props.data.fileStorageSettings}
message={props.operationMessage}
state={props.state}
onDeleteFileStorageChannel={props.onDeleteFileStorageChannel}
onSaveFileStorageChannel={props.onSaveFileStorageChannel}
onSaveFileStorageSettings={props.onSaveFileStorageSettings}
/>
)}
</div>
</div>
</div>
+15 -5
View File
@@ -34,6 +34,7 @@ export function ApiDocsPage(props: {
onTaskFormChange: (value: TaskForm) => void;
}) {
const current = docs.find((item) => item.key === props.activeDocSection) ?? docs[0];
const isFileDoc = current.key === 'files';
const bodyExample = useMemo(() => requestBodyExample(props.taskForm), [props.taskForm]);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -87,7 +88,7 @@ export function ApiDocsPage(props: {
<h2>Header </h2>
<Button type="button" variant="secondary" size="sm"></Button>
</header>
<ParamRow name="Content-Type" type="string" required value="application/json" />
<ParamRow name="Content-Type" type="string" required value={isFileDoc ? 'multipart/form-data' : 'application/json'} />
<ParamRow name="Accept" type="string" required value="application/json" />
<ParamRow name="Authorization" type="string" value="Bearer {{YOUR_API_KEY}}" />
</section>
@@ -97,10 +98,19 @@ export function ApiDocsPage(props: {
<h2>Body </h2>
<Badge variant="outline">application/json</Badge>
</header>
<ParamRow name="model" type="string" required value="模型 ID 或别名" />
<ParamRow name="messages / prompt" type="array|string" required value="对话消息或图片提示词" />
<ParamRow name="simulation" type="boolean" value="测试模式开关" />
<ParamRow name="stream" type="boolean" value="对话进度流式返回" />
{isFileDoc ? (
<>
<ParamRow name="file" type="file" required value="multipart 文件字段" />
<ParamRow name="source" type="string" value="上传来源标记" />
</>
) : (
<>
<ParamRow name="model" type="string" required value="模型 ID 或别名" />
<ParamRow name="messages / prompt" type="array|string" required value="对话消息或图片提示词" />
<ParamRow name="simulation" type="boolean" value="测试模式开关" />
<ParamRow name="stream" type="boolean" value="对话进度流式返回" />
</>
)}
</section>
</main>
+458 -31
View File
@@ -19,9 +19,9 @@ import { code } from '@streamdown/code';
import { math } from '@streamdown/math';
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 { Bot, ChevronDown, FileText, Image as ImageIcon, LoaderCircle, MessageSquarePlus, Music2, Paperclip, Send, Sparkles, Video, X } from 'lucide-react';
import { Badge, Button, Select, Textarea } from '../components/ui';
import { GatewayApiError, createImageGenerationTask, createVideoGenerationTask, pollTaskUntilSettled, streamChatCompletionText, taskIsPending } from '../api';
import { GatewayApiError, createImageEditTask, createImageGenerationTask, createVideoGenerationTask, pollTaskUntilSettled, streamChatCompletionText, taskIsPending, uploadFileToStorage } from '../api';
import type { PlaygroundMode } from '../types';
import {
defaultMediaGenerationSettings,
@@ -58,6 +58,18 @@ interface ModelOption {
value: string;
}
type PlaygroundUploadKind = 'audio' | 'file' | 'image' | 'video';
interface PlaygroundUpload {
contentType: string;
id: string;
kind: PlaygroundUploadKind;
name: string;
raw: Record<string, unknown>;
size: number;
url: string;
}
const modeOptions: Array<{ description: string; icon: ReactNode; label: string; value: PlaygroundMode }> = [
{ value: 'chat', label: '大模型对话', description: '对话、推理、结构化输出', icon: <Bot size={16} /> },
{ value: 'image', label: '图像生成', description: '文生图、图像编辑参数预览', icon: <ImageIcon size={16} /> },
@@ -82,6 +94,35 @@ const quickPrompts: Record<PlaygroundMode, string[]> = {
video: ['5 秒运镜', '首帧转视频', '宣传短片'],
};
const mediaUploadAccept = 'image/*,video/*,audio/*';
const chatUploadAccept = [
mediaUploadAccept,
'.csv',
'.doc',
'.docx',
'.json',
'.jsonl',
'.md',
'.markdown',
'.pdf',
'.ppt',
'.pptx',
'.txt',
'.xls',
'.xlsx',
'.yaml',
'.yml',
'application/json',
'application/msword',
'application/pdf',
'application/vnd.ms-excel',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/*',
].join(',');
const publicWorks = [
{ title: '雨夜霓虹街区', type: '图像生成', image: 'https://picsum.photos/seed/easyai-neon-city/720/960' },
{ title: '玻璃温室晨光', type: '图像生成', image: 'https://picsum.photos/seed/easyai-glasshouse/720/540' },
@@ -119,13 +160,17 @@ export function PlaygroundPage(props: {
const [mediaSettings, setMediaSettings] = useState(defaultMediaGenerationSettings);
const [mediaRuns, setMediaRuns] = useState<MediaGenerationRun[]>(readStoredMediaRuns);
const [mediaMessage, setMediaMessage] = useState('');
const [mediaUploadMessage, setMediaUploadMessage] = useState('');
const [mediaUploads, setMediaUploads] = useState<PlaygroundUpload[]>([]);
const [mediaUploading, setMediaUploading] = useState(false);
const isMountedRef = useRef(false);
const pendingMediaModelRef = useRef('');
const resumedTaskIdsRef = useRef(new Set<string>());
const activeMode = useMemo(() => modeOptions.find((item) => item.value === props.mode) ?? modeOptions[0], [props.mode]);
const effectiveImageHasReference = imageHasReference || (props.mode === 'image' && mediaUploads.some((item) => item.kind === 'image'));
const modelOptions = useMemo(
() => buildModelOptions(filterModelsForMode(props.models, props.mode, imageHasReference, videoMode)),
[imageHasReference, props.mode, props.models, videoMode],
() => buildModelOptions(filterModelsForMode(props.models, props.mode, effectiveImageHasReference, videoMode)),
[effectiveImageHasReference, props.mode, props.models, videoMode],
);
const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
const activeApiKeySecret = activeApiKeyId ? props.apiKeySecretsById[activeApiKeyId] ?? '' : '';
@@ -170,6 +215,38 @@ export function PlaygroundPage(props: {
writeStoredMediaRuns(mediaRuns);
}, [mediaRuns]);
async function uploadMediaFiles(files: File[]) {
if (!files.length) return;
const credential = activeApiKeySecret || props.token;
if (!props.token) {
props.onLogin();
return;
}
if (!credential) {
setMediaUploadMessage('请选择可用于测试的 API Key 后再上传。');
return;
}
setMediaUploading(true);
setMediaUploadMessage('');
try {
const { items, warnings } = await uploadPlaygroundFiles(credential, files, {
allowFiles: false,
source: `ai-gateway-playground-${props.mode}`,
});
if (items.length) {
setMediaUploads((current) => [...current, ...items]);
if (props.mode === 'image' && items.some((item) => item.kind === 'image')) {
setImageHasReference(true);
}
}
setMediaUploadMessage(warnings[0] ?? (items.length ? `已上传 ${items.length} 个参考素材。` : ''));
} catch (err) {
setMediaUploadMessage(err instanceof Error ? err.message : '文件上传失败');
} finally {
setMediaUploading(false);
}
}
useEffect(() => {
const credential = activeApiKeySecret || props.token;
if (!credential) return;
@@ -227,6 +304,7 @@ export function PlaygroundPage(props: {
}
const localId = newLocalId();
const runUploads = overrides ? [] : mediaUploads;
const modelLabel = modelOptions.find((item) => item.value === runModel)?.label ?? runModel;
const run: MediaGenerationRun = {
createdAt: new Date().toISOString(),
@@ -242,15 +320,24 @@ export function PlaygroundPage(props: {
setMediaRuns((current) => [...current, run]);
setMediaMessage('');
try {
const uploadPayload = mediaUploadRequestPayload(runUploads, runMode);
const requestPayload = {
model: runModel,
prompt: trimmedPrompt,
prompt: promptWithUploadSummary(trimmedPrompt, runUploads),
...mediaRequestPayload(runSettings, runMode),
...uploadPayload,
};
const response = runMode === 'video'
? await createVideoGenerationTask(credential, requestPayload)
: await createImageGenerationTask(credential, requestPayload);
: runUploads.some((item) => item.kind === 'image')
? await createImageEditTask(credential, requestPayload)
: await createImageGenerationTask(credential, requestPayload);
setMediaRuns((current) => updateMediaRun(current, localId, { status: response.task.status, task: response.task }));
if (!overrides) {
setMediaUploads([]);
setMediaUploadMessage('');
setImageHasReference(false);
}
void pollMediaRunUntilSettled(credential, localId, response.task);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '生成任务提交失败';
@@ -325,9 +412,13 @@ export function PlaygroundPage(props: {
prompt={prompt}
selectedApiKeyId={activeApiKeyId}
selectedModel={selectedModel}
imageHasReference={imageHasReference}
imageHasReference={effectiveImageHasReference}
mediaSettings={mediaSettings}
mediaCapabilities={mediaCapabilities}
uploadAccept={mediaUploadAccept}
uploadMessage={mediaUploadMessage}
uploads={mediaUploads}
uploading={mediaUploading}
videoMode={videoMode}
onApiKeyChange={props.onApiKeyChange}
onCreateApiKey={props.onCreateApiKey}
@@ -336,7 +427,15 @@ export function PlaygroundPage(props: {
onModeChange={props.onModeChange}
onModelChange={setSelectedModel}
onPromptChange={setPrompt}
onRemoveUpload={(id) => setMediaUploads((current) => {
const next = current.filter((item) => item.id !== id);
if (!next.some((item) => item.kind === 'image')) {
setImageHasReference(false);
}
return next;
})}
onSubmit={() => void submitMediaTask()}
onUploadFiles={(files) => void uploadMediaFiles(files)}
onVideoModeChange={setVideoMode}
/>
);
@@ -491,6 +590,42 @@ function AssistantChatPlayground(props: {
const canRun = Boolean(props.token && props.selectedModel && activeApiKeySecret);
const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById);
const initialMessages = useMemo(() => readStoredChatMessages(), []);
const [chatUploadMessage, setChatUploadMessage] = useState('');
const [chatUploads, setChatUploads] = useState<PlaygroundUpload[]>([]);
const [chatUploading, setChatUploading] = useState(false);
const chatUploadsRef = useRef(chatUploads);
useEffect(() => {
chatUploadsRef.current = chatUploads;
}, [chatUploads]);
async function uploadChatFiles(files: File[]) {
if (!files.length) return;
if (!props.token) {
props.onLogin();
return;
}
if (!activeApiKeySecret) {
setChatUploadMessage('请选择可用于测试的 API Key 后再上传。');
return;
}
setChatUploading(true);
setChatUploadMessage('');
try {
const { items, warnings } = await uploadPlaygroundFiles(activeApiKeySecret, files, {
allowFiles: true,
source: 'ai-gateway-playground-chat',
});
if (items.length) {
setChatUploads((current) => [...current, ...items]);
}
setChatUploadMessage(warnings[0] ?? (items.length ? `已上传 ${items.length} 个附件。` : ''));
} catch (err) {
setChatUploadMessage(err instanceof Error ? err.message : '文件上传失败');
} finally {
setChatUploading(false);
}
}
const adapter = useMemo<ChatModelAdapter>(() => ({
async *run({ abortSignal, messages }) {
if (!props.token) {
@@ -503,11 +638,17 @@ function AssistantChatPlayground(props: {
if (!props.selectedModel) {
throw new GatewayApiError('当前没有可用的大模型,请确认用户组权限或平台模型配置。');
}
const requestUploads = chatUploadsRef.current;
if (requestUploads.length) {
chatUploadsRef.current = [];
setChatUploads([]);
setChatUploadMessage('');
}
let text = '';
for await (const delta of streamChatCompletionText(
activeApiKeySecret,
{
messages: toGatewayChatMessages(messages),
messages: toGatewayChatMessages(messages, requestUploads),
model: props.selectedModel,
},
abortSignal,
@@ -541,10 +682,16 @@ function AssistantChatPlayground(props: {
selectedModel={props.selectedModel}
token={props.token}
activeApiKeySecret={activeApiKeySecret}
uploadAccept={chatUploadAccept}
uploadMessage={chatUploadMessage}
uploads={chatUploads}
uploading={chatUploading}
onApiKeyChange={props.onApiKeyChange}
onCreateApiKey={props.onCreateApiKey}
onModeChange={props.onModeChange}
onModelChange={props.onModelChange}
onRemoveUpload={(id) => setChatUploads((current) => current.filter((item) => item.id !== id))}
onUploadFiles={(files) => void uploadChatFiles(files)}
/>
</div>
</ThreadPrimitive.Empty>
@@ -573,10 +720,16 @@ function AssistantChatPlayground(props: {
placeholder={assistantPlaceholder(props.token, props.selectedModel, activeApiKeySecret)}
selectedApiKeyId={activeApiKeyId}
selectedModel={props.selectedModel}
uploadAccept={chatUploadAccept}
uploadMessage={chatUploadMessage}
uploads={chatUploads}
uploading={chatUploading}
onApiKeyChange={props.onApiKeyChange}
onCreateApiKey={props.onCreateApiKey}
onModeChange={props.onModeChange}
onModelChange={props.onModelChange}
onRemoveUpload={(id) => setChatUploads((current) => current.filter((item) => item.id !== id))}
onUploadFiles={(files) => void uploadChatFiles(files)}
/>
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
@@ -645,10 +798,16 @@ function AssistantEmptyState(props: {
selectedApiKeyId: string;
selectedModel: string;
token: string;
uploadAccept: string;
uploadMessage: string;
uploads: PlaygroundUpload[];
uploading: boolean;
onApiKeyChange: (apiKeyId: string) => void;
onCreateApiKey: () => void;
onModeChange: (mode: PlaygroundMode) => void;
onModelChange: (value: string) => void;
onRemoveUpload: (id: string) => void;
onUploadFiles: (files: File[]) => void;
}) {
const activeMode = modeOptions.find((item) => item.value === 'chat') ?? modeOptions[0];
const placeholder = props.canRun ? placeholderByMode.chat : assistantPlaceholder(props.token, props.selectedModel, props.activeApiKeySecret);
@@ -666,10 +825,16 @@ function AssistantEmptyState(props: {
placeholder={placeholder}
selectedApiKeyId={props.selectedApiKeyId}
selectedModel={props.selectedModel}
uploadAccept={props.uploadAccept}
uploadMessage={props.uploadMessage}
uploads={props.uploads}
uploading={props.uploading}
onApiKeyChange={props.onApiKeyChange}
onCreateApiKey={props.onCreateApiKey}
onModeChange={props.onModeChange}
onModelChange={props.onModelChange}
onRemoveUpload={props.onRemoveUpload}
onUploadFiles={props.onUploadFiles}
/>
</div>
);
@@ -685,24 +850,41 @@ function AssistantChatComposer(props: {
placeholder: string;
selectedApiKeyId: string;
selectedModel: string;
uploadAccept?: string;
uploadMessage?: string;
uploads?: PlaygroundUpload[];
uploading?: boolean;
onApiKeyChange: (apiKeyId: string) => void;
onCreateApiKey: () => void;
onModeChange: (mode: PlaygroundMode) => void;
onModelChange: (value: string) => void;
onRemoveUpload?: (id: string) => void;
onUploadFiles?: (files: File[]) => void;
}) {
const className = ['playgroundComposer', 'assistantChatComposer', props.docked ? 'assistantDockComposer' : 'assistantEmptyComposer'].join(' ');
return (
<ComposerPrimitive.Root className={className}>
<div className="composerBody">
<button type="button" className="composerUpload" aria-label="上传参考" disabled>
<Paperclip size={18} />
</button>
<ComposerPrimitive.Input
className="assistantEmptyInput"
disabled={!props.canRun}
placeholder={props.placeholder}
<ComposerUploadButton
accept={props.uploadAccept ?? chatUploadAccept}
active={Boolean(props.uploads?.length)}
disabled={!props.canRun || !props.onUploadFiles}
uploading={props.uploading}
onFiles={props.onUploadFiles}
/>
<div className="composerInputStack">
<ComposerPrimitive.Input
className="assistantEmptyInput"
disabled={!props.canRun}
placeholder={props.placeholder}
/>
<UploadAttachmentList
message={props.uploadMessage}
uploads={props.uploads ?? []}
onRemove={props.onRemoveUpload}
/>
</div>
</div>
<div className="composerFooter">
<Select value="chat" onChange={(event) => props.onModeChange(event.target.value as PlaygroundMode)}>
@@ -780,14 +962,25 @@ function AssistantMarkdownText() {
);
}
function toGatewayChatMessages(messages: readonly ThreadMessage[]) {
return messages
function toGatewayChatMessages(messages: readonly ThreadMessage[], uploads: PlaygroundUpload[] = []) {
const gatewayMessages = messages
.filter((message) => message.role === 'user' || message.role === 'assistant')
.map((message) => ({
content: threadMessageText(message),
role: message.role,
}))
.filter((message) => message.content.trim().length > 0);
let lastUserIndex = -1;
gatewayMessages.forEach((message, index) => {
if (message.role === 'user') lastUserIndex = index;
});
if (lastUserIndex >= 0 && uploads.length) {
gatewayMessages[lastUserIndex] = {
...gatewayMessages[lastUserIndex],
content: promptWithUploadSummary(gatewayMessages[lastUserIndex].content, uploads),
};
}
return gatewayMessages;
}
function threadMessageText(message: ThreadMessage) {
@@ -914,6 +1107,10 @@ function Composer(props: {
prompt: string;
selectedApiKeyId?: string;
selectedModel?: string;
uploadAccept?: string;
uploadMessage?: string;
uploads?: PlaygroundUpload[];
uploading?: boolean;
videoMode?: VideoCreateMode;
onApiKeyChange?: (apiKeyId: string) => void;
onCreateApiKey?: () => void;
@@ -922,7 +1119,9 @@ function Composer(props: {
onModeChange: (mode: PlaygroundMode) => void;
onModelChange: (value: string) => void;
onPromptChange: (value: string) => void;
onRemoveUpload?: (id: string) => void;
onSubmit?: () => void;
onUploadFiles?: (files: File[]) => void;
onVideoModeChange?: (value: VideoCreateMode) => void;
}) {
const quickItems = quickPrompts[props.mode];
@@ -930,21 +1129,26 @@ function Composer(props: {
return (
<div className={props.compact ? 'playgroundComposer compact' : 'playgroundComposer'}>
<div className="composerBody">
<button
type="button"
className="composerUpload"
aria-label="上传参考"
data-active={props.imageHasReference === true}
onClick={() => props.mode === 'image' && props.onImageReferenceChange?.(!props.imageHasReference)}
>
<Paperclip size={18} />
</button>
<Textarea
size={props.compact ? 'sm' : 'md'}
value={props.prompt}
placeholder={placeholderByMode[props.mode]}
onChange={(event) => props.onPromptChange(event.target.value)}
<ComposerUploadButton
accept={props.uploadAccept ?? mediaUploadAccept}
active={Boolean(props.uploads?.length) || props.imageHasReference === true}
disabled={!props.onUploadFiles}
uploading={props.uploading}
onFiles={props.onUploadFiles}
/>
<div className="composerInputStack">
<Textarea
size={props.compact ? 'sm' : 'md'}
value={props.prompt}
placeholder={placeholderByMode[props.mode]}
onChange={(event) => props.onPromptChange(event.target.value)}
/>
<UploadAttachmentList
message={props.uploadMessage}
uploads={props.uploads ?? []}
onRemove={props.onRemoveUpload}
/>
</div>
</div>
<div className="composerFooter">
<Select value={props.mode} onChange={(event) => props.onModeChange(event.target.value as PlaygroundMode)}>
@@ -992,6 +1196,81 @@ function Composer(props: {
);
}
function ComposerUploadButton(props: {
accept: string;
active?: boolean;
disabled?: boolean;
uploading?: boolean;
onFiles?: (files: File[]) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const disabled = props.disabled || props.uploading;
return (
<>
<button
type="button"
className="composerUpload"
aria-label="上传附件"
data-active={props.active === true}
disabled={disabled}
onClick={() => inputRef.current?.click()}
>
{props.uploading ? <LoaderCircle className="composerUploadSpinner" size={18} /> : <Paperclip size={18} />}
</button>
<input
ref={inputRef}
type="file"
multiple
hidden
accept={props.accept}
disabled={disabled}
onChange={(event) => {
const files = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = '';
props.onFiles?.(files);
}}
/>
</>
);
}
function UploadAttachmentList(props: {
message?: string;
uploads: PlaygroundUpload[];
onRemove?: (id: string) => void;
}) {
if (!props.uploads.length && !props.message) return null;
return (
<div className="composerUploadArea">
{props.uploads.length > 0 && (
<div className="composerUploadList">
{props.uploads.map((item) => (
<span className="composerUploadChip" key={item.id} title={`${item.name} · ${item.url}`}>
{uploadKindIcon(item.kind)}
<span>{item.name}</span>
<small>{formatFileSize(item.size)}</small>
{props.onRemove && (
<button type="button" aria-label={`移除 ${item.name}`} onClick={() => props.onRemove?.(item.id)}>
<X size={13} />
</button>
)}
</span>
))}
</div>
)}
{props.message && <div className="composerUploadMessage">{props.message}</div>}
</div>
);
}
function uploadKindIcon(kind: PlaygroundUploadKind) {
if (kind === 'image') return <ImageIcon size={14} />;
if (kind === 'video') return <Video size={14} />;
if (kind === 'audio') return <Music2 size={14} />;
return <FileText size={14} />;
}
function ApiKeySelect(props: {
apiKeySecretsById: Record<string, string>;
apiKeys: GatewayApiKey[];
@@ -1019,6 +1298,154 @@ function ApiKeySelect(props: {
);
}
async function uploadPlaygroundFiles(
token: string,
files: File[],
options: { allowFiles: boolean; source: string },
): Promise<{ items: PlaygroundUpload[]; warnings: string[] }> {
const accepted: Array<{ file: File; kind: PlaygroundUploadKind }> = [];
const warnings: string[] = [];
files.forEach((file) => {
const kind = acceptedUploadKind(file, options.allowFiles);
if (!kind) {
warnings.push(options.allowFiles
? `已跳过 ${file.name},聊天仅支持图片、视频、音频和常见文档。`
: `已跳过 ${file.name},当前场景仅支持图片、视频和音频。`);
return;
}
accepted.push({ file, kind });
});
if (!accepted.length) return { items: [], warnings };
const items = await Promise.all(accepted.map(async ({ file, kind }) => {
const response = await uploadFileToStorage(token, file, options.source);
const url = uploadResponseUrl(response);
if (!url) {
throw new Error(`${file.name} 上传成功,但网关没有返回可用文件 URL。`);
}
return {
contentType: file.type || '',
id: newLocalId(),
kind,
name: file.name || '未命名文件',
raw: response,
size: file.size,
url,
};
}));
return { items, warnings };
}
function acceptedUploadKind(file: File, allowFiles: boolean): PlaygroundUploadKind | undefined {
const mime = file.type.toLowerCase();
const extension = fileExtension(file.name);
if (mime.startsWith('image/') || imageExtensions.has(extension)) return 'image';
if (mime.startsWith('video/') || videoExtensions.has(extension)) return 'video';
if (mime.startsWith('audio/') || audioExtensions.has(extension)) return 'audio';
if (allowFiles && (documentExtensions.has(extension) || documentMimes.has(mime))) return 'file';
return undefined;
}
const imageExtensions = new Set(['avif', 'bmp', 'gif', 'heic', 'heif', 'jpeg', 'jpg', 'png', 'svg', 'tif', 'tiff', 'webp']);
const videoExtensions = new Set(['avi', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'webm']);
const audioExtensions = new Set(['aac', 'flac', 'm4a', 'mp3', 'oga', 'ogg', 'opus', 'wav', 'weba']);
const documentExtensions = new Set(['csv', 'doc', 'docx', 'json', 'jsonl', 'md', 'markdown', 'pdf', 'ppt', 'pptx', 'txt', 'xls', 'xlsx', 'yaml', 'yml']);
const documentMimes = new Set([
'application/json',
'application/msword',
'application/pdf',
'application/vnd.ms-excel',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/csv',
'text/markdown',
'text/plain',
'text/yaml',
]);
function fileExtension(name: string) {
const index = name.lastIndexOf('.');
return index >= 0 ? name.slice(index + 1).toLowerCase() : '';
}
function uploadResponseUrl(response: Record<string, unknown>) {
const data = recordFromUnknown(response.data);
const file = recordFromUnknown(response.file);
const result = recordFromUnknown(response.result);
return firstString(
response.url,
response.fileUrl,
response.file_url,
response.objectUrl,
response.object_url,
response.downloadUrl,
response.download_url,
data?.url,
data?.fileUrl,
data?.file_url,
file?.url,
file?.fileUrl,
file?.file_url,
result?.url,
result?.fileUrl,
result?.file_url,
);
}
function promptWithUploadSummary(prompt: string, uploads: PlaygroundUpload[]) {
if (!uploads.length) return prompt;
const lines = uploads.map((item) => `- ${uploadKindLabel(item.kind)} ${item.name}: ${item.url}`);
return `${prompt}\n\n参考附件:\n${lines.join('\n')}`;
}
function mediaUploadRequestPayload(uploads: PlaygroundUpload[], mode: Exclude<PlaygroundMode, 'chat'>) {
const images = uploads.filter((item) => item.kind === 'image').map((item) => item.url);
const videos = uploads.filter((item) => item.kind === 'video').map((item) => item.url);
const audios = uploads.filter((item) => item.kind === 'audio').map((item) => item.url);
const payload: Record<string, string | string[]> = {};
if (mode === 'image') {
if (images.length) {
payload.image = singleOrMany(images);
payload.images = images;
}
return payload;
}
if (images.length) {
payload.image = singleOrMany(images);
payload.image_url = images[0];
payload.images = images;
payload.reference_image = singleOrMany(images);
}
if (videos.length) {
payload.reference_video = singleOrMany(videos);
payload.video_url = videos[0];
}
if (audios.length) {
payload.reference_audio = singleOrMany(audios);
payload.audio_url = audios[0];
}
return payload;
}
function singleOrMany(values: string[]) {
return values.length === 1 ? values[0] : values;
}
function uploadKindLabel(kind: PlaygroundUploadKind) {
if (kind === 'image') return '图片';
if (kind === 'video') return '视频';
if (kind === 'audio') return '音频';
return '文件';
}
function formatFileSize(size: number) {
if (!Number.isFinite(size) || size <= 0) return '';
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
function filterModelsForMode(models: PlatformModel[], mode: PlaygroundMode, hasReference: boolean, videoMode: VideoCreateMode) {
if (mode === 'chat') {
return filterWithFallback(models, ['text_generate', 'chat', 'responses', 'text']);
@@ -0,0 +1,444 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Database, Pencil, Plus, RotateCcw, Save, ServerCog, Trash2 } from 'lucide-react';
import type { FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select, Tabs, Textarea } from '../../components/ui';
import type { LoadState } from '../../types';
type SystemSettingsTab = 'fileStorage';
type FileStorageChannelForm = {
apiKey: string;
apiKeyPreview: string;
channelKey: string;
configJson: string;
name: string;
priority: string;
provider: string;
retryPolicyJson: string;
scenes: string[];
status: string;
uploadUrl: string;
};
const defaultUploadUrl = 'http://127.0.0.1:3001/v1/files/upload';
const defaultRetryPolicy = {
enabled: true,
maxRetries: 3,
backoffSeconds: [60, 120, 180],
strategy: 'exponential',
};
const providerOptions = [
{ value: 'server_main_openapi', label: 'server-main OpenAPI' },
{ value: 'aliyun_oss', label: '阿里云 OSS' },
{ value: 'tencent_cos', label: '腾讯云 COS' },
];
const defaultScenes = ['upload', 'image_result'];
const sceneOptions = [
{ value: 'upload', label: '上传', description: 'OpenAPI / 管理端主动上传文件' },
{ value: 'image_result', label: '返图', description: '模型返回 base64 / buffer 图片或视频后的转存' },
];
const resultUploadPolicyOptions = [
{ value: 'default', label: '默认:仅非链接资源转存', description: 'URL 结果直接保存;base64 / buffer 等结果转存后保存 URL' },
{ value: 'upload_all', label: '全部转存', description: 'URL、base64、buffer 等返图结果都会转存到当前文件渠道' },
{ value: 'upload_none', label: '全部不转存', description: '返图结果原样保存,不触发文件存储上传' },
];
export function SystemSettingsPanel(props: {
channels: FileStorageChannel[];
message: string;
settings: FileStorageSettings | null;
state: LoadState;
onDeleteFileStorageChannel: (channelId: string) => Promise<void>;
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
}) {
const [activeTab, setActiveTab] = useState<SystemSettingsTab>('fileStorage');
const [dialogOpen, setDialogOpen] = useState(false);
const [editingChannel, setEditingChannel] = useState<FileStorageChannel | null>(null);
const [pendingDeleteChannel, setPendingDeleteChannel] = useState<FileStorageChannel | null>(null);
const [form, setForm] = useState<FileStorageChannelForm>(() => defaultChannelForm());
const [settingsPolicy, setSettingsPolicy] = useState(() => normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
const [localError, setLocalError] = useState('');
useEffect(() => {
setSettingsPolicy(normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
}, [props.settings?.resultUploadPolicy]);
function openCreateDialog() {
setEditingChannel(null);
setForm(defaultChannelForm(`server-main-${Date.now().toString(36)}`));
setLocalError('');
setDialogOpen(true);
}
function editChannel(channel: FileStorageChannel) {
setEditingChannel(channel);
setForm(channelToForm(channel));
setLocalError('');
setDialogOpen(true);
}
function closeDialog() {
setEditingChannel(null);
setForm(defaultChannelForm());
setLocalError('');
setDialogOpen(false);
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLocalError('');
if (form.scenes.length === 0) {
setLocalError('请至少选择一个适用场景。');
return;
}
try {
await props.onSaveFileStorageChannel(formToPayload(form), editingChannel?.id);
closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '文件存储渠道保存失败');
}
}
async function deleteChannel(channel: FileStorageChannel) {
try {
await props.onDeleteFileStorageChannel(channel.id);
setPendingDeleteChannel(null);
if (editingChannel?.id === channel.id) closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '文件存储渠道删除失败');
}
}
async function saveSettings() {
setLocalError('');
try {
await props.onSaveFileStorageSettings({ resultUploadPolicy: normalizeResultUploadPolicy(settingsPolicy) });
} catch (err) {
setLocalError(err instanceof Error ? err.message : '文件存储全局策略保存失败');
}
}
return (
<div className="pageStack">
<Card>
<CardHeader>
<div>
<CardTitle></CardTitle>
<p className="mutedText">使 60/120/180 退</p>
</div>
<Badge variant="secondary">{props.channels.length} </Badge>
</CardHeader>
<CardContent>
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
<Tabs
value={activeTab}
tabs={[{ value: 'fileStorage', label: '文件存储', icon: <Database size={15} /> }]}
onValueChange={setActiveTab}
/>
</CardContent>
</Card>
{activeTab === 'fileStorage' && (
<section className="fileStoragePanel">
<div className="fileStorageSettingsCard">
<div>
<strong></strong>
<span></span>
</div>
<Label>
<Select value={settingsPolicy} onChange={(event) => setSettingsPolicy(event.target.value)}>
{resultUploadPolicyOptions.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
</Select>
<small>{resultUploadPolicyDescription(settingsPolicy)}</small>
</Label>
<Button type="button" onClick={saveSettings} disabled={props.state === 'loading'}>
<Save size={15} />
</Button>
</div>
<div className="fileStorageToolbar">
<div>
<strong></strong>
<span>server-main OpenAPI API Key</span>
</div>
<Button type="button" onClick={openCreateDialog}>
<Plus size={15} />
</Button>
</div>
<div className="fileStorageGrid">
{props.channels.map((channel) => (
<article className="fileStorageCard" key={channel.id}>
<header>
<div className="iconBox"><ServerCog size={18} /></div>
<div>
<strong>{channel.name}</strong>
<span>{channel.channelKey}</span>
</div>
<Badge variant={channel.status === 'enabled' ? 'success' : 'secondary'}>{channel.status}</Badge>
</header>
<div className="fileStorageMeta">
<span>: {providerLabel(channel.provider)}</span>
<span>: {sceneSummary(channel.scenes)}</span>
<span>: {channel.priority}</span>
<span>: {retryPolicySummary(channel.retryPolicy)}</span>
{channel.uploadUrl && <span>: {channel.uploadUrl}</span>}
{apiKeyPreview(channel) && <span>API Key: {apiKeyPreview(channel)}</span>}
{channel.lastError && <span>: {channel.lastError}</span>}
</div>
<footer>
<Button type="button" variant="outline" size="sm" onClick={() => editChannel(channel)}>
<Pencil size={14} />
</Button>
<Button type="button" variant="destructive" size="sm" onClick={() => setPendingDeleteChannel(channel)}>
<Trash2 size={14} />
</Button>
</footer>
</article>
))}
{!props.channels.length && (
<Card>
<CardContent className="emptyState">
<strong></strong>
</CardContent>
</Card>
)}
</div>
</section>
)}
<FormDialog
ariaLabel={editingChannel ? '编辑文件存储渠道' : '新增文件存储渠道'}
bodyClassName="fileStorageDialogBody"
eyebrow={editingChannel ? 'Edit Storage Channel' : 'New Storage Channel'}
footer={(
<>
<Button type="submit" disabled={props.state === 'loading'}>
{editingChannel ? <Save size={15} /> : <Plus size={15} />}
{editingChannel ? '保存渠道' : '新增渠道'}
</Button>
<Button type="button" variant="outline" onClick={closeDialog}>
<RotateCcw size={15} />
</Button>
</>
)}
open={dialogOpen}
title={editingChannel ? '编辑文件存储渠道' : '新增文件存储渠道'}
onClose={closeDialog}
onSubmit={submit}
>
<Label>
<Input value={form.channelKey} onChange={(event) => setForm({ ...form, channelKey: event.target.value })} placeholder="server-main-openapi" />
</Label>
<Label>
<Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="server-main OpenAPI" />
</Label>
<Label>
<Select value={form.provider} onChange={(event) => setForm({ ...form, provider: event.target.value })}>
{providerOptions.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
</Select>
</Label>
<Label>
<Select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>
<option value="enabled">enabled</option>
<option value="disabled">disabled</option>
</Select>
</Label>
<Label className="spanTwo">
<div className="fileStorageSceneGrid">
{sceneOptions.map((scene) => (
<FileStorageSceneToggle
checked={form.scenes.includes(scene.value)}
description={scene.description}
key={scene.value}
label={scene.label}
onChange={(checked) => setForm({ ...form, scenes: nextScenes(form.scenes, scene.value, checked) })}
/>
))}
</div>
</Label>
<Label className="spanTwo">
<Input value={form.uploadUrl} onChange={(event) => setForm({ ...form, uploadUrl: event.target.value })} placeholder={defaultUploadUrl} />
</Label>
<Label className="platformCredentialField">
API Key
<Input value={form.apiKey} onChange={(event) => setForm({ ...form, apiKey: event.target.value })} placeholder={credentialInputPlaceholder(form.apiKeyPreview)} />
<small></small>
</Label>
<Label>
<Input type="number" min={1} value={form.priority} onChange={(event) => setForm({ ...form, priority: event.target.value })} />
</Label>
<Label className="spanTwo">
JSON
<Textarea value={form.retryPolicyJson} onChange={(event) => setForm({ ...form, retryPolicyJson: event.target.value })} />
</Label>
<Label className="spanTwo">
JSON
<Textarea value={form.configJson} onChange={(event) => setForm({ ...form, configJson: event.target.value })} />
</Label>
</FormDialog>
<ConfirmDialog
confirmLabel="删除渠道"
description="删除后该文件存储渠道不会再参与上传轮转。"
loading={props.state === 'loading'}
open={Boolean(pendingDeleteChannel)}
title={`确认删除文件存储渠道 ${pendingDeleteChannel?.name ?? ''}`}
onCancel={() => setPendingDeleteChannel(null)}
onConfirm={() => pendingDeleteChannel ? deleteChannel(pendingDeleteChannel) : undefined}
/>
</div>
);
}
function defaultChannelForm(channelKey = ''): FileStorageChannelForm {
return {
apiKey: '',
apiKeyPreview: '',
channelKey,
configJson: '{}',
name: 'server-main OpenAPI',
priority: '100',
provider: 'server_main_openapi',
retryPolicyJson: stringifyJson(defaultRetryPolicy),
scenes: defaultScenes,
status: 'disabled',
uploadUrl: defaultUploadUrl,
};
}
function channelToForm(channel: FileStorageChannel): FileStorageChannelForm {
const preview = apiKeyPreview(channel);
return {
apiKey: preview,
apiKeyPreview: preview,
channelKey: channel.channelKey,
configJson: stringifyJson(channel.config ?? {}),
name: channel.name,
priority: String(channel.priority || 100),
provider: channel.provider || 'server_main_openapi',
retryPolicyJson: stringifyJson(channel.retryPolicy ?? defaultRetryPolicy),
scenes: normalizeScenes(channel.scenes),
status: channel.status || 'disabled',
uploadUrl: channel.uploadUrl || defaultUploadUrl,
};
}
function formToPayload(form: FileStorageChannelForm): FileStorageChannelUpsertRequest {
return {
apiKey: apiKeyPayloadValue(form),
channelKey: form.channelKey.trim(),
config: parseJsonObject(form.configJson, '扩展配置 JSON'),
name: form.name.trim(),
priority: Number(form.priority) || 100,
provider: form.provider,
retryPolicy: parseJsonObject(form.retryPolicyJson, '重试策略 JSON'),
scenes: normalizeScenes(form.scenes),
status: form.status,
uploadUrl: form.uploadUrl.trim(),
};
}
function parseJsonObject(value: string, label: string) {
try {
const parsed = JSON.parse(value || '{}') as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${label} 必须是对象`);
}
return parsed as Record<string, unknown>;
} catch (err) {
if (err instanceof Error && err.message.includes(label)) throw err;
throw new Error(`${label} 格式不正确`);
}
}
function stringifyJson(value: unknown) {
return JSON.stringify(value ?? {}, null, 2);
}
function providerLabel(provider: string) {
return providerOptions.find((item) => item.value === provider)?.label ?? provider;
}
function sceneSummary(scenes: string[] | undefined) {
return normalizeScenes(scenes).map((scene) => sceneOptions.find((item) => item.value === scene)?.label ?? scene).join(' / ');
}
function normalizeResultUploadPolicy(value: string | undefined) {
const normalized = (value || 'default').trim();
return resultUploadPolicyOptions.some((item) => item.value === normalized) ? normalized : 'default';
}
function resultUploadPolicyDescription(value: string | undefined) {
const normalized = normalizeResultUploadPolicy(value);
return resultUploadPolicyOptions.find((item) => item.value === normalized)?.description ?? '';
}
function normalizeScenes(scenes: string[] | undefined) {
const next = Array.from(new Set((scenes ?? []).map((scene) => scene.trim()).filter(Boolean)));
return next.length ? next : [...defaultScenes];
}
function nextScenes(current: string[], scene: string, checked: boolean) {
if (checked) return normalizeScenes([...current, scene]);
return current.filter((item) => item !== scene);
}
function retryPolicySummary(policy?: Record<string, unknown>) {
const maxRetries = numberFromUnknown(policy?.maxRetries) || 3;
const backoff = Array.isArray(policy?.backoffSeconds) ? policy?.backoffSeconds.join('/') : '60/120/180';
return `${maxRetries} 次 · ${backoff}s`;
}
function apiKeyPreview(channel: FileStorageChannel) {
const value = channel.credentialsPreview?.apiKey;
return typeof value === 'string' ? value : '';
}
function apiKeyPayloadValue(form: FileStorageChannelForm) {
const value = form.apiKey.trim();
if (form.apiKeyPreview && value === form.apiKeyPreview) return undefined;
return value || (form.apiKeyPreview ? '' : undefined);
}
function credentialInputPlaceholder(preview: string) {
return preview ? '填写新凭证以覆盖当前值' : 'sk-...';
}
function FileStorageSceneToggle(props: { checked: boolean; description: string; label: string; onChange: (checked: boolean) => void }) {
return (
<label className="platformToggle">
<input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.target.checked)} />
<span>
<strong>{props.label}</strong>
<small>{props.description}</small>
</span>
</label>
);
}
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 0;
}
+1
View File
@@ -39,6 +39,7 @@ const adminPaths: Record<AdminSection, string> = {
auditLogs: '/admin/audit-logs',
runtime: '/admin/runtime',
accessRules: '/admin/access-rules',
systemSettings: '/admin/system-settings',
};
const docsPaths: Record<ApiDocSection, string> = {
+120
View File
@@ -1812,6 +1812,121 @@
justify-content: flex-end;
}
.fileStoragePanel {
display: grid;
gap: 14px;
}
.fileStorageToolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 10px;
background: #fff;
}
.fileStorageSettingsCard {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 380px) auto;
align-items: center;
gap: 14px;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 10px;
background: #fff;
}
.fileStorageSettingsCard > div {
display: grid;
gap: 3px;
}
.fileStorageSettingsCard > label {
min-width: 0;
}
.fileStorageToolbar > div {
display: grid;
gap: 3px;
}
.fileStorageToolbar strong {
color: var(--text-normal);
}
.fileStorageToolbar span,
.fileStorageSettingsCard span,
.fileStorageMeta span {
color: var(--muted-foreground);
font-size: var(--font-size-xs);
line-height: 1.45;
}
.fileStorageGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.fileStorageCard {
display: grid;
gap: 12px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 10px;
background: #fff;
}
.fileStorageCard header,
.fileStorageCard footer {
display: flex;
align-items: center;
gap: 10px;
}
.fileStorageCard header > div:nth-child(2) {
display: grid;
min-width: 0;
flex: 1;
gap: 3px;
}
.fileStorageCard strong,
.fileStorageCard header span,
.fileStorageMeta span {
overflow: hidden;
text-overflow: ellipsis;
}
.fileStorageMeta {
display: grid;
gap: 7px;
}
.fileStorageMeta span {
padding: 7px 9px;
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--surface-subtle);
}
.fileStorageCard footer {
justify-content: flex-end;
}
.fileStorageDialogBody {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.fileStorageSceneGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.runtimePolicyDialog {
width: min(980px, 100%);
}
@@ -1983,6 +2098,7 @@
.providerCatalogGrid,
.baseModelGrid,
.runtimePolicyGrid,
.fileStorageGrid,
.platformGrid,
.accessPermissionGrid,
.platformModelChoices {
@@ -2023,7 +2139,11 @@
.platformModelRow,
.platformModelToolbar,
.runtimePolicyGrid,
.fileStorageGrid,
.fileStorageSettingsCard,
.fileStorageSceneGrid,
.runtimePolicyFormBody,
.fileStorageDialogBody,
.runtimePolicyRows,
.runnerActionGrid,
.accessPermissionGrid,
+82
View File
@@ -63,6 +63,88 @@
transform: rotate(-8deg);
}
.composerUpload:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.composerUploadSpinner {
animation: composer-upload-spin 0.9s linear infinite;
}
.composerInputStack {
display: grid;
min-width: 0;
gap: 8px;
}
.composerUploadArea {
display: grid;
gap: 6px;
}
.composerUploadList {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: 6px;
}
.composerUploadChip {
display: inline-flex;
max-width: min(100%, 320px);
min-height: 28px;
align-items: center;
gap: 6px;
padding: 4px 7px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-muted);
color: var(--text-normal);
font-size: var(--font-size-xs);
}
.composerUploadChip span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.composerUploadChip small {
flex: 0 0 auto;
color: var(--muted-foreground);
}
.composerUploadChip button {
display: grid;
width: 18px;
height: 18px;
place-items: center;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--text-soft);
padding: 0;
}
.composerUploadChip button:hover {
background: var(--surface);
color: var(--text-normal);
}
.composerUploadMessage {
color: var(--muted-foreground);
font-size: var(--font-size-xs);
line-height: var(--line-height-relaxed);
}
@keyframes composer-upload-spin {
to {
transform: rotate(360deg);
}
}
.composerBody .shTextarea {
min-height: 88px;
border: 0;
+2 -1
View File
@@ -17,7 +17,8 @@ export type AdminSection =
| 'userGroups'
| 'auditLogs'
| 'runtime'
| 'accessRules';
| 'accessRules'
| 'systemSettings';
export interface LoginForm {
account: string;