Files
metapi/src/web/pages/ModelTester.tsx
T

2984 lines
114 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api.js';
import { clearAuthSession, getAuthToken } from '../authSession.js';
import {
DEBUG_TABS,
DEFAULT_INPUTS,
DEFAULT_MODE_STATE,
DEFAULT_PARAMETER_ENABLED,
MODEL_TESTER_STORAGE_KEY,
MESSAGE_STATUS,
buildApiPayload,
buildEmbeddingsRequestEnvelope,
buildFileUploadRequestEnvelope,
buildGeminiNativeConversationProxyEnvelope,
buildImagesEditRequestEnvelope,
buildImagesGenerationsRequestEnvelope,
buildRawProxyRequestEnvelope,
buildSearchRequestEnvelope,
buildVideoCreateRequestEnvelope,
buildVideoInspectRequestEnvelope,
countConversationTurns,
collectModelTesterModelNames,
createLoadingAssistantMessage,
createMessage,
createConversationUserMessage,
extractConversationUploadedFilesFromMessage,
filterModelTesterModelNames,
finalizeIncompleteMessage,
findLastLoadingAssistantIndex,
parseCustomRequestBody,
parseModelTesterSession,
processThinkTags,
resolveConversationReplayFiles,
serializeModelTesterSession,
syncCustomRequestBodyToMessages,
syncMessagesToCustomRequestBody,
type ChatMessage,
type ConversationDraftFile,
type ConversationContentPart,
type ConversationUploadedFile,
type DebugTab,
type ModelTesterInputs,
type ModelTesterModeState,
type ParameterEnabled,
type PlaygroundMode,
type PlaygroundMultipartFile,
type TestTargetFormat,
type TestChatPayload,
} from './helpers/modelTesterSession.js';
import {
buildConversationFileAccept,
buildConversationFileHint,
isConversationUploadedFileSupported,
resolveConversationFileCapability,
} from './helpers/conversationFileCapabilities.js';
import ConversationComposer from './model-tester/ConversationComposer.js';
import DebugPanel from './model-tester/DebugPanel.js';
import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { useIsMobile } from '../components/useIsMobile.js';
import { tr } from '../i18n.js';
type ChatJobResponse = {
jobId: string;
status: 'pending' | 'succeeded' | 'failed' | 'cancelled';
result?: unknown;
error?: unknown;
};
type DebugTimelineEntry = {
at: string;
level: 'info' | 'warn' | 'error';
text: string;
};
type UploadState = {
name: string;
mimeType: string;
dataUrl: string;
};
type ConversationFileState = ConversationDraftFile;
const POLL_INTERVAL_MS = 1200;
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const createConversationFileLocalId = () =>
`draft-file-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const summarizeModeRequest = (
mode: PlaygroundMode,
input: string,
modeState: ModelTesterModeState,
videoAction: 'get' | 'delete',
): string => {
if (mode === 'embeddings') return input.trim() || modeState.embeddingsInput.trim() || 'Embedding request';
if (mode === 'search') return input.trim() || modeState.searchQuery.trim() || 'Search request';
if (mode === 'images.generate' || mode === 'images.edit') return input.trim() || modeState.imagesPrompt.trim() || 'Image request';
if (mode === 'videos.create') return input.trim() || modeState.videosPrompt.trim() || 'Video request';
if (mode === 'videos.inspect') {
const id = input.trim() || modeState.videosInspectId.trim();
return `${videoAction.toUpperCase()} ${id || 'video'}`;
}
return input.trim();
};
const readFileAsDataUrl = (file: File): Promise<string> => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
reader.onerror = () => reject(reader.error || new Error('读取文件失败'));
reader.readAsDataURL(file);
});
const formatJson = (value: unknown): string => {
if (typeof value === 'string') {
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
const extractErrorMessage = (error: unknown): string => {
const data = error as any;
return data?.error?.message || data?.message || 'request failed';
};
const extractClaudeMessageContent = (result: any): { content: string; reasoningContent: string } => {
const blocks = Array.isArray(result?.content) ? result.content : [];
const contentParts: string[] = [];
const reasoningParts: string[] = [];
for (const block of blocks) {
if (!block || typeof block !== 'object') continue;
if (block.type === 'text' && typeof block.text === 'string') {
contentParts.push(block.text);
continue;
}
if (block.type === 'thinking' && typeof block.thinking === 'string') {
reasoningParts.push(block.thinking);
continue;
}
if (typeof block.text === 'string') {
contentParts.push(block.text);
}
}
return {
content: contentParts.join(''),
reasoningContent: reasoningParts.join('\n\n'),
};
};
const extractResponsesContent = (result: any): { content: string; reasoningContent: string } => {
const contentParts: string[] = [];
const reasoningParts: string[] = [];
const pushContent = (value: unknown) => {
if (typeof value === 'string' && value.length > 0) contentParts.push(value);
};
const pushReasoning = (value: unknown) => {
if (typeof value === 'string' && value.length > 0) reasoningParts.push(value);
};
const directOutputText = result?.output_text;
if (typeof directOutputText === 'string') {
pushContent(directOutputText);
} else if (Array.isArray(directOutputText)) {
for (const item of directOutputText) {
if (typeof item === 'string') {
pushContent(item);
continue;
}
if (item && typeof item === 'object' && typeof (item as any).text === 'string') {
pushContent((item as any).text);
}
}
}
const outputs = Array.isArray(result?.output)
? result.output
: (result && typeof result === 'object' && (Array.isArray(result?.content) || typeof result?.type === 'string'))
? [result]
: [];
for (const item of outputs) {
if (!item || typeof item !== 'object') continue;
const itemType = typeof item.type === 'string' ? item.type : '';
if (itemType === 'output_text') {
pushContent(item.text);
continue;
}
if (itemType === 'reasoning') {
if (typeof item.summary_text === 'string') pushReasoning(item.summary_text);
if (typeof item.reasoning === 'string') pushReasoning(item.reasoning);
if (Array.isArray(item.summary)) {
for (const summaryItem of item.summary) {
if (summaryItem && typeof summaryItem === 'object' && typeof (summaryItem as any).text === 'string') {
pushReasoning((summaryItem as any).text);
}
}
}
}
const content = Array.isArray((item as any).content) ? (item as any).content : [];
for (const block of content) {
if (!block || typeof block !== 'object') continue;
const blockType = typeof (block as any).type === 'string' ? (block as any).type : '';
if (blockType === 'output_text' || blockType === 'text') {
pushContent((block as any).text);
continue;
}
if (blockType.includes('reasoning')) {
pushReasoning((block as any).text);
}
}
}
return {
content: contentParts.join(''),
reasoningContent: reasoningParts.join('\n\n'),
};
};
const extractAssistantResult = (result: unknown): { content: string; reasoningContent: string } => {
const data = result as any;
let content = '';
let reasoning = '';
if (Array.isArray(data?.choices)) {
const choice = data.choices[0];
const maybeContent = choice?.message?.content ?? choice?.text ?? '';
content = typeof maybeContent === 'string'
? maybeContent
: Array.isArray(maybeContent)
? maybeContent
.map((item) => item?.text ?? '')
.join('')
: '';
reasoning = choice?.message?.reasoning_content || choice?.message?.reasoning || '';
} else if (data?.type === 'message' && Array.isArray(data?.content)) {
const parsedClaude = extractClaudeMessageContent(data);
content = parsedClaude.content;
reasoning = parsedClaude.reasoningContent;
} else if (data?.object === 'response' || Array.isArray(data?.output) || typeof data?.output_text === 'string') {
const parsedResponses = extractResponsesContent(data);
content = parsedResponses.content;
reasoning = parsedResponses.reasoningContent;
} else if (Array.isArray(data?.candidates)) {
const parts = data?.candidates?.[0]?.content?.parts;
if (Array.isArray(parts)) {
content = parts
.filter((item: any) => !(item?.thought === true))
.map((item: any) => (typeof item?.text === 'string' ? item.text : ''))
.join('');
reasoning = parts
.filter((item: any) => item?.thought === true)
.map((item: any) => (typeof item?.text === 'string' ? item.text : ''))
.join('');
}
}
const processed = processThinkTags(content, reasoning);
if (!processed.content && processed.reasoningContent) {
return {
content: '[Only reasoning returned]',
reasoningContent: processed.reasoningContent,
};
}
if (!processed.content && !processed.reasoningContent) {
return {
content: formatJson(result),
reasoningContent: '',
};
}
return processed;
};
const replaceMessageAt = (messages: ChatMessage[], index: number, nextMessage: ChatMessage): ChatMessage[] => [
...messages.slice(0, index),
nextMessage,
...messages.slice(index + 1),
];
const applyAssistantSuccess = (messages: ChatMessage[], result: unknown): ChatMessage[] => {
const { content, reasoningContent } = extractAssistantResult(result);
const targetIndex = findLastLoadingAssistantIndex(messages);
if (targetIndex === -1) {
return [...messages, createMessage('assistant', content, {
status: MESSAGE_STATUS.COMPLETE,
reasoningContent: reasoningContent || null,
isThinkingComplete: true,
isReasoningExpanded: false,
hasAutoCollapsed: true,
})];
}
const current = messages[targetIndex];
return replaceMessageAt(messages, targetIndex, {
...current,
content,
reasoningContent: reasoningContent || null,
status: MESSAGE_STATUS.COMPLETE,
isThinkingComplete: true,
isReasoningExpanded: false,
hasAutoCollapsed: true,
});
};
const applyAssistantError = (messages: ChatMessage[], errorMessage: string): ChatMessage[] => {
const targetIndex = findLastLoadingAssistantIndex(messages);
if (targetIndex === -1) {
return [...messages, createMessage('assistant', errorMessage, {
status: MESSAGE_STATUS.ERROR,
isThinkingComplete: true,
})];
}
const current = messages[targetIndex];
return replaceMessageAt(messages, targetIndex, {
...current,
content: errorMessage,
status: MESSAGE_STATUS.ERROR,
isThinkingComplete: true,
});
};
const applyAssistantStopped = (messages: ChatMessage[]): ChatMessage[] => {
const targetIndex = findLastLoadingAssistantIndex(messages);
if (targetIndex === -1) return messages;
const current = messages[targetIndex];
return replaceMessageAt(messages, targetIndex, finalizeIncompleteMessage({
...current,
content: current.content || 'Generation stopped.',
}));
};
const applyAssistantDelta = (
messages: ChatMessage[],
delta: { contentDelta?: string; reasoningDelta?: string },
): ChatMessage[] => {
const targetIndex = findLastLoadingAssistantIndex(messages);
if (targetIndex === -1) return messages;
const current = messages[targetIndex];
let next: ChatMessage = {
...current,
status: MESSAGE_STATUS.INCOMPLETE,
};
const toIncrementalText = (existingText: string, incomingText?: string): string => {
if (!incomingText) return '';
if (!existingText) return incomingText;
if (incomingText === existingText) return '';
if (incomingText.startsWith(existingText)) {
return incomingText.slice(existingText.length);
}
if (existingText.endsWith(incomingText)) return '';
const maxOverlap = Math.min(existingText.length, incomingText.length);
const MIN_OVERLAP = 8;
for (let overlap = maxOverlap; overlap >= MIN_OVERLAP; overlap -= 1) {
if (existingText.slice(-overlap) === incomingText.slice(0, overlap)) {
return incomingText.slice(overlap);
}
}
return incomingText;
};
if (delta.reasoningDelta) {
const existingReasoning = next.reasoningContent || '';
const reasoningAppend = toIncrementalText(existingReasoning, delta.reasoningDelta);
if (reasoningAppend) {
next = {
...next,
reasoningContent: existingReasoning + reasoningAppend,
isThinkingComplete: false,
};
}
}
if (delta.contentDelta) {
const existingContent = next.content || '';
const contentAppend = toIncrementalText(existingContent, delta.contentDelta);
if (!contentAppend) {
return replaceMessageAt(messages, targetIndex, next);
}
const hasReasoning = Boolean(next.reasoningContent);
const shouldAutoCollapse = hasReasoning && !next.hasAutoCollapsed;
next = {
...next,
content: existingContent + contentAppend,
isReasoningExpanded: shouldAutoCollapse ? false : next.isReasoningExpanded,
hasAutoCollapsed: shouldAutoCollapse || next.hasAutoCollapsed,
};
}
return replaceMessageAt(messages, targetIndex, next);
};
const parseStreamErrorText = async (response: Response): Promise<string> => {
try {
const text = await response.text();
if (!text) return `HTTP ${response.status}`;
try {
const parsed = JSON.parse(text);
return extractErrorMessage(parsed);
} catch {
return text;
}
} catch {
return `HTTP ${response.status}`;
}
};
const parseSseBlock = (block: string): { event: string; data: string | null } => {
const lines = block.split(/\r?\n/);
let event = 'message';
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith('event:')) {
event = line.slice(6).trim();
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart());
}
}
return {
event,
data: dataLines.length > 0 ? dataLines.join('\n') : null,
};
};
const parseAnyStreamDelta = (eventPayload: any): {
contentDelta?: string;
reasoningDelta?: string;
done?: boolean;
} => {
if (!eventPayload || typeof eventPayload !== 'object') return {};
if (Array.isArray(eventPayload.choices)) {
const choice = eventPayload.choices[0];
const delta = choice?.delta || {};
const reasoningDelta = typeof delta.reasoning_content === 'string'
? delta.reasoning_content
: typeof delta.reasoning === 'string'
? delta.reasoning
: '';
const contentDelta = typeof delta.content === 'string'
? delta.content
: typeof choice?.message?.content === 'string'
? choice.message.content
: '';
return {
contentDelta: contentDelta || undefined,
reasoningDelta: reasoningDelta || undefined,
done: Boolean(choice?.finish_reason),
};
}
if (typeof eventPayload.type === 'string') {
// Responses stream emits a full-text summary again in several "done" events
// (output_text.done/content_part.done/output_item.done/response.completed).
// Treat those as structural events only; otherwise UI appends duplicate text.
if (eventPayload.type === 'response.output_item.added' || eventPayload.type === 'response.output_item.done') {
return {};
}
if (eventPayload.type === 'response.content_part.added' || eventPayload.type === 'response.content_part.done') {
return {};
}
if (eventPayload.type === 'response.content_part.delta') {
const delta = eventPayload.delta;
if (typeof delta === 'string') return { contentDelta: delta || undefined };
if (delta && typeof delta === 'object') {
const parsed = extractResponsesContent(delta);
if (parsed.content || parsed.reasoningContent) {
return {
contentDelta: parsed.content || undefined,
reasoningDelta: parsed.reasoningContent || undefined,
};
}
const text = typeof (delta as any).text === 'string' ? (delta as any).text : '';
return { contentDelta: text || undefined };
}
}
if (eventPayload.type === 'response.output_text.delta') {
const text = typeof eventPayload.delta === 'string'
? eventPayload.delta
: typeof eventPayload.text === 'string'
? eventPayload.text
: '';
return { contentDelta: text || undefined };
}
if (eventPayload.type === 'response.reasoning_summary_text.delta' || eventPayload.type === 'response.reasoning.delta') {
const text = typeof eventPayload.delta === 'string'
? eventPayload.delta
: typeof eventPayload.text === 'string'
? eventPayload.text
: '';
return { reasoningDelta: text || undefined };
}
if (eventPayload.type === 'response.output_text.done') return {};
if (eventPayload.type === 'response.completed' || eventPayload.type === 'response.failed') {
return { done: true };
}
if (eventPayload.type === 'content_block_delta') {
const delta = eventPayload.delta || {};
const deltaType = typeof delta.type === 'string' ? delta.type : '';
const text = typeof delta.text === 'string' ? delta.text : '';
if (deltaType === 'thinking_delta') {
return { reasoningDelta: text || undefined };
}
return { contentDelta: text || undefined };
}
if (eventPayload.type === 'content_block_start') {
const block = eventPayload.content_block || {};
const text = typeof block.text === 'string' ? block.text : '';
return { contentDelta: text || undefined };
}
if (eventPayload.type === 'message_delta') {
const stopReason = eventPayload?.delta?.stop_reason || eventPayload?.stop_reason;
return { done: Boolean(stopReason) };
}
if (eventPayload.type === 'message_stop') {
return { done: true };
}
}
if (Array.isArray(eventPayload.candidates)) {
const parts = eventPayload?.candidates?.[0]?.content?.parts;
if (Array.isArray(parts)) {
const reasoningDelta = parts
.filter((item: any) => item?.thought === true)
.map((item: any) => (typeof item?.text === 'string' ? item.text : ''))
.join('');
const contentDelta = parts
.filter((item: any) => !(item?.thought === true))
.map((item: any) => (typeof item?.text === 'string' ? item.text : ''))
.join('');
return {
contentDelta: contentDelta || undefined,
reasoningDelta: reasoningDelta || undefined,
done: Boolean(eventPayload?.candidates?.[0]?.finishReason),
};
}
}
return {};
};
const toNumber = (value: string, fallback: number): number => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const splitCsvOrLines = (value: string): string[] =>
value
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean);
const CONVERSATION_MODE_OPTIONS: Array<{ value: PlaygroundMode; label: string }> = [
{ value: 'conversation', label: '对话' },
{ value: 'embeddings', label: 'Embeddings' },
{ value: 'search', label: 'Search' },
{ value: 'images.generate', label: '图片生成' },
{ value: 'images.edit', label: '图片编辑' },
{ value: 'videos.create', label: '视频创建' },
{ value: 'videos.inspect', label: '视频查询/删除' },
];
const PROTOCOL_OPTIONS: Array<{ value: PlaygroundProtocol; label: string }> = [
{ value: 'openai', label: 'OpenAI (/v1/chat/completions)' },
{ value: 'responses', label: 'OpenAI Responses (/v1/responses)' },
{ value: 'claude', label: 'Claude (/v1/messages)' },
{ value: 'gemini', label: 'Gemini Native (/gemini/v1beta/models/*)' },
];
const inputBaseStyle: React.CSSProperties = {
width: '100%',
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
transition: 'border-color 0.2s',
};
function ParameterRow(props: {
title: string;
valueText?: string;
enabled: boolean;
onToggle: () => void;
disabled?: boolean;
children: React.ReactNode;
}) {
const {
title,
valueText,
enabled,
onToggle,
disabled,
children,
} = props;
return (
<div style={{ marginBottom: 12, opacity: enabled ? 1 : 0.55 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>
{title}
{valueText && <span style={{ marginLeft: 6, color: 'var(--color-primary)' }}>{valueText}</span>}
</div>
<label style={{ fontSize: 12 }}>
<input type="checkbox" checked={enabled} onChange={onToggle} disabled={disabled} /> 启用
</label>
</div>
{children}
</div>
);
}
export default function ModelTester() {
const isMobile = useIsMobile();
const [models, setModels] = useState<string[]>([]);
const [modelSearch, setModelSearch] = useState('');
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [inputs, setInputs] = useState<ModelTesterInputs>(DEFAULT_INPUTS);
const [modeState, setModeState] = useState<ModelTesterModeState>(DEFAULT_MODE_STATE);
const [parameterEnabled, setParameterEnabled] = useState<ParameterEnabled>(DEFAULT_PARAMETER_ENABLED);
const [sending, setSending] = useState(false);
const [loadingModels, setLoadingModels] = useState(true);
const [error, setError] = useState('');
const [pendingPayload, setPendingPayload] = useState<TestChatPayload | null>(null);
const [pendingJobId, setPendingJobId] = useState<string | null>(null);
const [customRequestMode, setCustomRequestMode] = useState(false);
const [customRequestBody, setCustomRequestBody] = useState('');
const [showDebugPanel, setShowDebugPanel] = useState(false);
const debugPanelPresence = useAnimatedVisibility(showDebugPanel, 220);
const [activeDebugTab, setActiveDebugTab] = useState<DebugTab>(DEBUG_TABS.PREVIEW);
const [debugRequest, setDebugRequest] = useState('');
const [debugResponse, setDebugResponse] = useState('');
const [debugPreview, setDebugPreview] = useState('');
const [debugTimeline, setDebugTimeline] = useState<DebugTimelineEntry[]>([]);
const [debugTimestamp, setDebugTimestamp] = useState('');
const [nonConversationResult, setNonConversationResult] = useState<unknown>(null);
const [searchQueryValue, setSearchQueryValue] = useState('');
const [searchAllowedDomains, setSearchAllowedDomains] = useState('');
const [searchBlockedDomains, setSearchBlockedDomains] = useState('');
const [searchMaxResults, setSearchMaxResults] = useState(10);
const [embeddingInputText, setEmbeddingInputText] = useState('');
const [assetPrompt, setAssetPrompt] = useState('');
const [videoInspectId, setVideoInspectId] = useState('');
const [videoInspectAction, setVideoInspectAction] = useState<'GET' | 'DELETE'>('GET');
const [imageSourceFile, setImageSourceFile] = useState<UploadState | null>(null);
const [imageMaskFile, setImageMaskFile] = useState<UploadState | null>(null);
const [conversationFiles, setConversationFiles] = useState<ConversationFileState[]>([]);
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
const chatEndRef = useRef<HTMLDivElement>(null);
const conversationFileInputRef = useRef<HTMLInputElement>(null);
const restoredSessionRef = useRef<ReturnType<typeof parseModelTesterSession>>(null);
const streamAbortRef = useRef<AbortController | null>(null);
const streamStopRequestedRef = useRef(false);
const conversationFileCapability = useMemo(
() => resolveConversationFileCapability(inputs.protocol),
[inputs.protocol],
);
const conversationFileSupported = conversationFileCapability.supported;
const conversationFileAccept = useMemo(
() => buildConversationFileAccept(conversationFileCapability),
[conversationFileCapability],
);
const conversationFileHint = useMemo(
() => buildConversationFileHint(conversationFileCapability),
[conversationFileCapability],
);
const pushDebug = useCallback((level: DebugTimelineEntry['level'], text: string) => {
const now = new Date().toISOString();
setDebugTimeline((prev) => {
const next = [...prev, { at: now, level, text }];
return next.length > 200 ? next.slice(next.length - 200) : next;
});
setDebugTimestamp(now);
}, []);
const updateInput = useCallback(<K extends keyof ModelTesterInputs>(key: K, value: ModelTesterInputs[K]) => {
setInputs((prev) => {
if (key === 'protocol') {
return {
...prev,
protocol: value as ModelTesterInputs['protocol'],
};
}
return { ...prev, [key]: value };
});
}, []);
const updateModeState = useCallback(<K extends keyof ModelTesterModeState>(key: K, value: ModelTesterModeState[K]) => {
setModeState((prev) => ({ ...prev, [key]: value }));
}, []);
const updateProtocol = useCallback((protocol: PlaygroundProtocol) => {
setInputs((prev) => ({
...prev,
protocol,
}));
}, []);
const toggleParameter = useCallback((key: keyof ParameterEnabled) => {
setParameterEnabled((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
useEffect(() => {
const restored = parseModelTesterSession(localStorage.getItem(MODEL_TESTER_STORAGE_KEY));
restoredSessionRef.current = restored;
if (!restored) return;
setMessages(restored.messages);
setInput(restored.input);
setInputs(restored.inputs);
setModeState(restored.modeState);
setParameterEnabled(restored.parameterEnabled);
setPendingPayload(restored.pendingPayload);
setPendingJobId(restored.pendingJobId || null);
setCustomRequestMode(restored.customRequestMode);
setCustomRequestBody(restored.customRequestBody);
setShowDebugPanel(restored.showDebugPanel);
setActiveDebugTab(restored.activeDebugTab);
setEmbeddingInputText(restored.modeState.embeddingsInput);
setSearchQueryValue(restored.modeState.searchQuery);
setSearchAllowedDomains(restored.modeState.searchAllowedDomains);
setSearchBlockedDomains(restored.modeState.searchBlockedDomains);
setAssetPrompt(restored.modeState.imagesPrompt || restored.modeState.videosPrompt);
setVideoInspectId(restored.modeState.videosInspectId);
setVideoInspectAction(restored.inputs.videoInspectAction === 'delete' ? 'DELETE' : 'GET');
setConversationFiles(restored.conversationFiles);
if (restored.pendingJobId) {
setSending(true);
setError('发现未完成的任务,正在重新连接...');
pushDebug('info', `恢复任务 ${restored.pendingJobId}。`);
} else if (restored.pendingPayload) {
setError('发现未完成的请求快照,点击重试继续。');
pushDebug('warn', '恢复待处理的请求快照。');
}
}, [pushDebug]);
useEffect(() => {
const fetchModels = async () => {
setLoadingModels(true);
try {
const [marketResult, routesResult] = await Promise.allSettled([
api.getModelsMarketplace({ includePricing: false }),
api.getRoutes(),
]);
if (marketResult.status === 'rejected' && routesResult.status === 'rejected') {
throw marketResult.reason || routesResult.reason || new Error('failed to fetch models');
}
const names = collectModelTesterModelNames(
marketResult.status === 'fulfilled' ? marketResult.value : null,
routesResult.status === 'fulfilled' ? routesResult.value : null,
);
setModels(names);
const restoredModel = restoredSessionRef.current?.inputs.model || '';
const currentModel = inputs.model || '';
const nextModel = restoredModel && names.includes(restoredModel)
? restoredModel
: currentModel && names.includes(currentModel)
? currentModel
: names[0] || '';
if (nextModel) {
setInputs((prev) => ({ ...prev, model: nextModel }));
}
} catch {
setError('加载模型列表失败。');
pushDebug('error', '获取模型列表失败。');
} finally {
setLoadingModels(false);
}
};
void fetchModels();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!inputs.model) return;
localStorage.setItem(MODEL_TESTER_STORAGE_KEY, serializeModelTesterSession({
input,
inputs,
parameterEnabled,
messages,
conversationFiles,
modeState: {
embeddingsInput: embeddingInputText,
searchQuery: searchQueryValue,
searchAllowedDomains,
searchBlockedDomains,
imagesPrompt: inputs.mode === 'images.generate' || inputs.mode === 'images.edit' ? assetPrompt : '',
imagesMaskDataUrl: imageMaskFile?.dataUrl || '',
videosPrompt: inputs.mode === 'videos.create' ? assetPrompt : '',
videosInspectId: videoInspectId,
extraJson: customRequestBody,
},
pendingPayload,
pendingJobId,
customRequestMode,
customRequestBody,
showDebugPanel,
activeDebugTab,
}));
}, [
activeDebugTab,
customRequestBody,
customRequestMode,
input,
inputs,
messages,
conversationFiles,
assetPrompt,
customRequestBody,
embeddingInputText,
imageMaskFile?.dataUrl,
parameterEnabled,
pendingJobId,
pendingPayload,
searchAllowedDomains,
searchBlockedDomains,
searchQueryValue,
showDebugPanel,
videoInspectId,
]);
const handleUploadChange = useCallback(async (
fileList: FileList | null,
setter: React.Dispatch<React.SetStateAction<UploadState | null>>,
) => {
const file = fileList?.[0];
if (!file) {
setter(null);
return;
}
try {
const dataUrl = await readFileAsDataUrl(file);
setter({
name: file.name,
mimeType: file.type || 'application/octet-stream',
dataUrl,
});
} catch (readError: any) {
setError(readError?.message || '读取文件失败');
}
}, []);
const handleConversationFilesChange = useCallback(async (fileList: FileList | null) => {
const files = Array.from(fileList || []);
if (files.length <= 0) return;
try {
const nextFiles = await Promise.all(files.map(async (file) => ({
localId: createConversationFileLocalId(),
name: file.name,
mimeType: file.type || 'application/octet-stream',
dataUrl: await readFileAsDataUrl(file),
fileId: null,
status: 'pending' as const,
errorMessage: null,
})));
const acceptedFiles = nextFiles.filter((file) => isConversationUploadedFileSupported(
conversationFileCapability,
{ filename: file.name, mimeType: file.mimeType },
));
const rejectedFiles = nextFiles.filter((file) => !isConversationUploadedFileSupported(
conversationFileCapability,
{ filename: file.name, mimeType: file.mimeType },
));
if (acceptedFiles.length > 0) {
setConversationFiles((prev) => [...prev, ...acceptedFiles]);
pushDebug('info', `已添加 ${acceptedFiles.length} 个会话附件。`);
}
if (rejectedFiles.length > 0) {
const message = `当前协议不支持这些会话附件:${rejectedFiles.map((file) => file.name).join('、')}。${conversationFileHint}`;
setError(message);
pushDebug('warn', message);
}
} catch (readError: any) {
const message = readError?.message || '读取附件失败';
setError(message);
pushDebug('error', message);
}
}, [conversationFileCapability, conversationFileHint, pushDebug]);
const removeConversationFile = useCallback((localId: string) => {
if (sending) return;
setConversationFiles((prev) => prev.filter((item) => item.localId !== localId));
}, [sending]);
const uploadConversationFiles = useCallback(async (): Promise<ConversationUploadedFile[]> => {
if (conversationFiles.length <= 0) return [];
const uploaded: ConversationUploadedFile[] = [];
for (const item of conversationFiles) {
if (item.fileId) {
uploaded.push({
fileId: item.fileId,
filename: item.name,
mimeType: item.mimeType,
});
continue;
}
setConversationFiles((prev) => prev.map((entry) => (
entry.localId === item.localId
? { ...entry, status: 'uploading', errorMessage: null }
: entry
)));
pushDebug('info', `正在上传附件:${item.name}`);
try {
const result = await api.proxyTest(buildFileUploadRequestEnvelope({
name: item.name,
mimeType: item.mimeType,
dataUrl: item.dataUrl,
})) as { id?: unknown; filename?: unknown; mime_type?: unknown };
const fileId = typeof result?.id === 'string' ? result.id.trim() : '';
if (!fileId) {
throw new Error('上传成功但未返回 file_id');
}
const filename = typeof result?.filename === 'string' && result.filename.trim()
? result.filename.trim()
: item.name;
const mimeType = typeof result?.mime_type === 'string' && result.mime_type.trim()
? result.mime_type.trim()
: item.mimeType;
setConversationFiles((prev) => prev.map((entry) => (
entry.localId === item.localId
? { ...entry, fileId, name: filename, mimeType, status: 'uploaded', errorMessage: null }
: entry
)));
uploaded.push({ fileId, filename, mimeType });
pushDebug('info', `附件上传完成:${filename} -> ${fileId}`);
} catch (uploadError: any) {
const message = uploadError?.message || '附件上传失败';
setConversationFiles((prev) => prev.map((entry) => (
entry.localId === item.localId
? { ...entry, status: 'error', errorMessage: message }
: entry
)));
throw new Error(`${item.name}: ${message}`);
}
}
return uploaded;
}, [conversationFiles, pushDebug]);
const inlineConversationFiles = useCallback((): ConversationUploadedFile[] =>
conversationFiles.map((item) => ({
fileId: item.fileId,
filename: item.name,
mimeType: item.mimeType,
data: item.dataUrl,
})), [conversationFiles]);
const ensureSupportedConversationFiles = useCallback((files: ConversationUploadedFile[]): boolean => {
const unsupported = files.filter((file) => !isConversationUploadedFileSupported(conversationFileCapability, file));
if (unsupported.length <= 0) return true;
const names = unsupported.map((file, index) => {
const filename = typeof file.filename === 'string' ? file.filename.trim() : '';
return filename || `附件${index + 1}`;
});
const message = `当前协议不支持这些会话附件:${names.join('、')}。${conversationFileHint}`;
setError(message);
pushDebug('warn', message);
return false;
}, [conversationFileCapability, conversationFileHint, pushDebug]);
const loadLocalConversationFile = useCallback(async (fileId: string) => {
const resolved = await api.getProxyFileContentDataUrl(fileId) as {
filename?: string | null;
mimeType?: string | null;
data: string;
};
return {
filename: resolved.filename || null,
mimeType: resolved.mimeType || null,
data: resolved.data,
};
}, []);
const buildConversationMessagesWithSystem = useCallback((baseMessages: ChatMessage[]) => {
if (!inputs.systemPrompt.trim()) return baseMessages;
return [
createMessage('system', inputs.systemPrompt.trim()),
...baseMessages,
];
}, [inputs.systemPrompt]);
const buildClaudeBodyFromMessages = useCallback((baseMessages: ChatMessage[]) => {
const effectiveMessages = buildConversationMessagesWithSystem(baseMessages);
const systemContents = effectiveMessages
.filter((message) => message.role === 'system' || message.role === 'developer')
.map((message) => message.content.trim())
.filter(Boolean);
const downstreamMessages = effectiveMessages
.filter((message) => message.role !== 'system' && message.role !== 'developer')
.map((message) => ({
role: message.role === 'assistant' ? 'assistant' : 'user',
content: message.content,
}));
return {
model: inputs.model,
stream: inputs.stream,
max_tokens: parameterEnabled.max_tokens ? inputs.max_tokens : DEFAULT_INPUTS.max_tokens,
...(systemContents.length > 0 ? { system: systemContents.join('\n\n') } : {}),
...(parameterEnabled.temperature ? { temperature: inputs.temperature } : {}),
...(parameterEnabled.top_p ? { top_p: inputs.top_p } : {}),
messages: downstreamMessages,
};
}, [buildConversationMessagesWithSystem, inputs.max_tokens, inputs.model, inputs.stream, inputs.temperature, inputs.top_p, parameterEnabled.max_tokens, parameterEnabled.temperature, parameterEnabled.top_p]);
const buildResponsesBodyFromMessages = useCallback((baseMessages: ChatMessage[]) => {
const effectiveMessages = buildConversationMessagesWithSystem(baseMessages);
const systemContents = effectiveMessages
.filter((message) => message.role === 'system' || message.role === 'developer')
.map((message) => message.content.trim())
.filter(Boolean);
const downstreamMessages = effectiveMessages
.filter((message) => message.role !== 'system' && message.role !== 'developer')
.map((message) => ({
role: message.role === 'assistant' ? 'assistant' : 'user',
content: message.content,
}));
return {
model: inputs.model,
stream: inputs.stream,
...(parameterEnabled.temperature ? { temperature: inputs.temperature } : {}),
...(parameterEnabled.top_p ? { top_p: inputs.top_p } : {}),
...(parameterEnabled.max_tokens ? { max_output_tokens: inputs.max_tokens } : {}),
...(systemContents.length > 0 ? { instructions: systemContents.join('\n\n') } : {}),
input: downstreamMessages.length === 1 && downstreamMessages[0].role === 'user' && systemContents.length === 0
? downstreamMessages[0].content
: downstreamMessages,
};
}, [buildConversationMessagesWithSystem, inputs.max_tokens, inputs.model, inputs.stream, inputs.temperature, inputs.top_p, parameterEnabled.max_tokens, parameterEnabled.temperature, parameterEnabled.top_p]);
const buildConversationProxyEnvelope = useCallback((baseMessages: ChatMessage[]): ProxyTestEnvelope => {
const normalizedMessages = buildConversationMessagesWithSystem(baseMessages);
if (customRequestMode) {
const path = inputs.protocol === 'gemini'
? `/gemini/v1beta/models/${encodeURIComponent(inputs.model)}:generateContent${inputs.stream ? '?alt=sse' : ''}`
: inputs.protocol === 'claude'
? '/v1/messages'
: inputs.protocol === 'responses'
? '/v1/responses'
: '/v1/chat/completions';
return {
method: 'POST',
path,
requestKind: 'json',
stream: inputs.stream,
jobMode: false,
rawMode: true,
rawJsonText: customRequestBody,
};
}
if (inputs.protocol === 'claude') {
return {
method: 'POST',
path: '/v1/messages',
requestKind: 'json',
stream: inputs.stream,
jobMode: false,
rawMode: false,
jsonBody: buildClaudeBodyFromMessages(baseMessages),
};
}
if (inputs.protocol === 'responses') {
return {
method: 'POST',
path: '/v1/responses',
requestKind: 'json',
stream: inputs.stream,
jobMode: false,
rawMode: false,
jsonBody: buildResponsesBodyFromMessages(baseMessages),
};
}
if (inputs.protocol === 'gemini') {
return buildGeminiNativeConversationProxyEnvelope(baseMessages, inputs, parameterEnabled);
}
const openAiEnvelope = buildApiPayload(normalizedMessages, { ...inputs, protocol: 'openai' }, parameterEnabled);
const openAiPayload = (openAiEnvelope.jsonBody && typeof openAiEnvelope.jsonBody === 'object')
? { ...(openAiEnvelope.jsonBody as Record<string, unknown>) }
: {};
return {
method: 'POST',
path: '/v1/chat/completions',
requestKind: 'json',
stream: inputs.stream,
jobMode: false,
rawMode: false,
jsonBody: openAiPayload,
};
}, [buildApiPayload, buildClaudeBodyFromMessages, buildConversationMessagesWithSystem, buildResponsesBodyFromMessages, customRequestBody, customRequestMode, inputs, parameterEnabled]);
const buildModeProxyEnvelope = useCallback((): ProxyTestEnvelope | null => {
if (inputs.mode === 'embeddings') {
const trimmed = embeddingInputText.trim();
if (!trimmed) return null;
return {
method: 'POST',
path: '/v1/embeddings',
requestKind: 'json',
stream: false,
jobMode: false,
rawMode: customRequestMode,
...(customRequestMode
? { rawJsonText: customRequestBody }
: { jsonBody: { model: inputs.model, input: trimmed } }),
};
}
if (inputs.mode === 'search') {
if (!searchQueryValue.trim()) return null;
return {
method: 'POST',
path: '/v1/search',
requestKind: 'json',
stream: false,
jobMode: false,
rawMode: customRequestMode,
...(customRequestMode
? { rawJsonText: customRequestBody }
: {
jsonBody: {
model: inputs.model || '__search',
query: searchQueryValue.trim(),
max_results: Math.max(1, Math.min(20, Math.trunc(searchMaxResults || 10))),
...(splitCsvOrLines(searchAllowedDomains).length > 0 ? { allowed_domains: splitCsvOrLines(searchAllowedDomains) } : {}),
...(splitCsvOrLines(searchBlockedDomains).length > 0 ? { blocked_domains: splitCsvOrLines(searchBlockedDomains) } : {}),
},
}),
};
}
if (inputs.mode === 'images.generate') {
if (!assetPrompt.trim()) return null;
return {
method: 'POST',
path: '/v1/images/generations',
requestKind: 'json',
stream: false,
jobMode: false,
rawMode: customRequestMode,
...(customRequestMode
? { rawJsonText: customRequestBody }
: { jsonBody: { model: inputs.model, prompt: assetPrompt.trim() } }),
};
}
if (inputs.mode === 'images.edit') {
if (!assetPrompt.trim() || !imageSourceFile) return null;
return {
method: 'POST',
path: '/v1/images/edits',
requestKind: 'multipart',
stream: false,
jobMode: false,
rawMode: false,
multipartFields: {
model: inputs.model,
prompt: assetPrompt.trim(),
},
multipartFiles: [
{
field: 'image',
name: imageSourceFile.name,
mimeType: imageSourceFile.mimeType,
dataUrl: imageSourceFile.dataUrl,
},
...(imageMaskFile ? [{
field: 'mask',
name: imageMaskFile.name,
mimeType: imageMaskFile.mimeType,
dataUrl: imageMaskFile.dataUrl,
}] : []),
],
};
}
if (inputs.mode === 'videos.create') {
if (!assetPrompt.trim()) return null;
if (imageSourceFile) {
return {
method: 'POST',
path: '/v1/videos',
requestKind: 'multipart',
stream: false,
jobMode: false,
rawMode: false,
multipartFields: {
model: inputs.model,
prompt: assetPrompt.trim(),
},
multipartFiles: [
{
field: 'input_reference',
name: imageSourceFile.name,
mimeType: imageSourceFile.mimeType,
dataUrl: imageSourceFile.dataUrl,
},
],
};
}
return {
method: 'POST',
path: '/v1/videos',
requestKind: 'json',
stream: false,
jobMode: false,
rawMode: customRequestMode,
...(customRequestMode
? { rawJsonText: customRequestBody }
: { jsonBody: { model: inputs.model, prompt: assetPrompt.trim() } }),
};
}
if (inputs.mode === 'videos.inspect') {
if (!videoInspectId.trim()) return null;
return {
method: videoInspectAction,
path: `/v1/videos/${encodeURIComponent(videoInspectId.trim())}`,
requestKind: 'empty',
stream: false,
jobMode: false,
rawMode: false,
};
}
return null;
}, [assetPrompt, customRequestBody, customRequestMode, embeddingInputText, imageMaskFile, imageSourceFile, inputs.mode, inputs.model, searchAllowedDomains, searchBlockedDomains, searchMaxResults, searchQueryValue, videoInspectAction, videoInspectId]);
const previewPayload = useMemo(() => {
if (inputs.mode !== 'conversation') {
return buildModeProxyEnvelope();
}
if (customRequestMode) {
const raw = customRequestBody.trim();
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return { _error: '自定义请求体中的 JSON 无效', raw };
}
}
if (inputs.protocol === 'gemini') {
return buildConversationProxyEnvelope(messages);
}
return buildApiPayload(buildConversationMessagesWithSystem(messages), inputs, parameterEnabled);
}, [buildConversationMessagesWithSystem, buildConversationProxyEnvelope, buildModeProxyEnvelope, customRequestBody, customRequestMode, inputs, messages, parameterEnabled]);
useEffect(() => {
setDebugPreview(formatJson(previewPayload));
}, [previewPayload]);
const finalizeJob = useCallback((jobId: string) => {
void api.deleteProxyTestJob(jobId).catch(() => { });
}, []);
useEffect(() => {
if (!pendingJobId) return;
let active = true;
setSending(true);
const pollTask = async () => {
while (active) {
try {
const status = await api.getProxyTestJob(pendingJobId) as ChatJobResponse;
if (!active) return;
if (status.status === 'pending') {
await wait(POLL_INTERVAL_MS);
continue;
}
if (status.status === 'succeeded') {
setMessages((prev) => applyAssistantSuccess(prev, status.result));
setError('');
setDebugResponse(formatJson(status.result));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
pushDebug('info', `任务 ${pendingJobId} 已成功。`);
} else if (status.status === 'cancelled') {
setMessages((prev) => applyAssistantStopped(prev));
setError('生成已取消。');
setDebugResponse(formatJson(status.error));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
pushDebug('warn', `任务 ${pendingJobId} 已取消。`);
} else {
const message = extractErrorMessage(status.error);
setMessages((prev) => applyAssistantError(prev, message));
setError(message);
setDebugResponse(formatJson(status.error));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
pushDebug('error', `任务 ${pendingJobId} 失败:${message}`);
}
setPendingJobId(null);
setPendingPayload(null);
setSending(false);
finalizeJob(pendingJobId);
return;
} catch (pollError) {
const message = (pollError as any)?.message || '未知轮询错误';
pushDebug('warn', `轮询 ${pendingJobId} 失败一次:${message}`);
await wait(POLL_INTERVAL_MS);
}
}
};
void pollTask();
return () => {
active = false;
};
}, [finalizeJob, pendingJobId, pushDebug]);
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const turnCount = useMemo(() => countConversationTurns(messages), [messages]);
const filteredModels = useMemo(
() => filterModelTesterModelNames(models, modelSearch),
[modelSearch, models],
);
const currentModelVisible = useMemo(
() => filteredModels.includes(inputs.model),
[filteredModels, inputs.model],
);
const modelCountText = useMemo(() => {
if (!modelSearch.trim()) return `共 ${models.length} 个模型`;
return `匹配 ${filteredModels.length} / ${models.length}`;
}, [filteredModels.length, modelSearch, models.length]);
const modelSelectOptions = useMemo(
() => filteredModels.map((item) => ({ value: item, label: item })),
[filteredModels],
);
const canSend = useMemo(() => {
if (sending || pendingJobId || !inputs.model) return false;
if (inputs.mode !== 'conversation') {
if (inputs.mode === 'embeddings') return Boolean(embeddingInputText.trim());
if (inputs.mode === 'search') return Boolean(searchQueryValue.trim());
if (inputs.mode === 'images.generate') return Boolean(assetPrompt.trim());
if (inputs.mode === 'images.edit') return Boolean(assetPrompt.trim()) && Boolean(imageSourceFile);
if (inputs.mode === 'videos.create') return Boolean(assetPrompt.trim());
if (inputs.mode === 'videos.inspect') return Boolean(videoInspectId.trim());
return false;
}
const hasPrompt = input.trim().length > 0;
if (!customRequestMode) return hasPrompt || (conversationFileSupported && conversationFiles.length > 0);
return hasPrompt || customRequestBody.trim().length > 0;
}, [assetPrompt, conversationFileSupported, conversationFiles.length, customRequestBody, customRequestMode, embeddingInputText, imageSourceFile, input, inputs.mode, inputs.model, pendingJobId, searchQueryValue, sending, videoInspectId]);
const startChatJob = useCallback(async (payload: TestChatPayload) => {
try {
setError('');
setPendingPayload(payload);
const created = await api.startProxyTestJob(payload) as { jobId: string };
setPendingJobId(created.jobId);
setSending(true);
pushDebug('info', `已创建任务 ${created.jobId}。`);
} catch (e: any) {
const message = e?.message || '请求失败';
setMessages((prev) => applyAssistantError(prev, message));
setError(message);
setSending(false);
setDebugResponse(formatJson({ error: { message } }));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
pushDebug('error', `创建任务失败:${message}`);
}
}, [pushDebug]);
const startStream = useCallback(async (payload: TestChatPayload) => {
const controller = new AbortController();
streamAbortRef.current = controller;
streamStopRequestedRef.current = false;
setSending(true);
setPendingJobId(null);
setPendingPayload(payload);
pushDebug('info', '已开始流式请求。');
const decoder = new TextDecoder('utf-8');
let buffer = '';
const rawEvents: string[] = [];
const appendRawEvent = (raw: string) => {
rawEvents.push(raw);
if (rawEvents.length > 500) {
rawEvents.splice(0, rawEvents.length - 500);
}
setDebugResponse(rawEvents.join('\n'));
};
try {
const response = await api.proxyTestStream(payload, controller.signal);
if (response.status === 401 || response.status === 403) {
const hadToken = Boolean(getAuthToken(localStorage));
clearAuthSession(localStorage);
if (hadToken) window.location.reload();
throw new Error('会话已过期');
}
if (!response.ok) {
throw new Error(await parseStreamErrorText(response));
}
if (!response.body) {
throw new Error('流式响应体为空');
}
setActiveDebugTab(DEBUG_TABS.RESPONSE);
const reader = response.body.getReader();
let doneReceived = false;
let hasAnyContent = false;
let hasAnyReasoning = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split(/\r?\n\r?\n/);
buffer = chunks.pop() || '';
for (const chunk of chunks) {
const parsed = parseSseBlock(chunk);
if (!parsed.data) continue;
appendRawEvent(parsed.data);
if (parsed.data === '[DONE]') {
doneReceived = true;
pushDebug('info', '收到流式 [DONE] 信号。');
continue;
}
let eventPayload: any;
try {
eventPayload = JSON.parse(parsed.data);
} catch {
pushDebug('warn', `忽略非 JSON 的 SSE 数据块 (event=${parsed.event})。`);
continue;
}
if (eventPayload?.error) {
throw new Error(extractErrorMessage(eventPayload));
}
const delta = parseAnyStreamDelta(eventPayload);
if (typeof delta.reasoningDelta === 'string' && delta.reasoningDelta.trim().length > 0) {
hasAnyReasoning = true;
}
if (typeof delta.contentDelta === 'string' && delta.contentDelta.trim().length > 0) {
hasAnyContent = true;
}
if (delta.reasoningDelta || delta.contentDelta) {
setMessages((prev) => applyAssistantDelta(prev, {
reasoningDelta: delta.reasoningDelta,
contentDelta: delta.contentDelta,
}));
}
if (delta.done) doneReceived = true;
}
}
const emptyOutput = !hasAnyContent && !hasAnyReasoning;
setMessages((prev) => {
const idx = findLastLoadingAssistantIndex(prev);
if (idx === -1) return prev;
const finalized = finalizeIncompleteMessage(prev[idx]);
if (emptyOutput && !(finalized.content || '').trim() && !(finalized.reasoningContent || '').trim()) {
return replaceMessageAt(prev, idx, {
...finalized,
content: '空回复(上游未返回任何内容)',
status: MESSAGE_STATUS.ERROR,
isThinkingComplete: true,
});
}
return replaceMessageAt(prev, idx, {
...finalized,
status: MESSAGE_STATUS.COMPLETE,
isThinkingComplete: true,
});
});
setPendingPayload(null);
if (emptyOutput) {
const message = '上游返回空内容';
setError(message);
pushDebug('error', '流式传输完成但内容为空。');
} else {
setError('');
pushDebug(doneReceived ? 'info' : 'warn', doneReceived
? '流式传输已成功完成。'
: '流式传输未收到 [DONE] 信号,已在本地完成。');
}
} catch (streamError: any) {
const abortedByUser = controller.signal.aborted && streamStopRequestedRef.current;
const abortedUnexpectedly = controller.signal.aborted
|| streamError?.name === 'AbortError'
|| streamError?.message === 'This operation was aborted'
|| streamError?.message === 'The user aborted a request.';
if (abortedByUser) {
setMessages((prev) => applyAssistantStopped(prev));
setError('生成已停止。');
pushDebug('warn', '流式传输被用户中止。');
} else if (abortedUnexpectedly) {
const message = '流式连接中断,请重试。';
setMessages((prev) => applyAssistantError(prev, message));
setError(message);
pushDebug('error', `流式传输异常中断:${streamError?.message || 'AbortError'}`);
} else {
const rawMsg = streamError?.message || '流式请求失败';
const message = rawMsg === 'This operation was aborted' ? '操作已中止' : rawMsg;
setMessages((prev) => applyAssistantError(prev, message));
setError(message);
pushDebug('error', `流式传输失败:${message}`);
}
} finally {
if (streamAbortRef.current === controller) streamAbortRef.current = null;
streamStopRequestedRef.current = false;
setSending(false);
}
}, [pushDebug]);
const startProxyStream = useCallback(async (
envelope: ProxyTestEnvelope,
nextMessages: ChatMessage[],
) => {
const controller = new AbortController();
streamAbortRef.current = controller;
streamStopRequestedRef.current = false;
setSending(true);
setPendingJobId(null);
setPendingPayload(null);
setMessages(nextMessages);
setError('');
setActiveDebugTab(DEBUG_TABS.RESPONSE);
pushDebug('info', `已开始代理流式请求:${envelope.path}`);
const decoder = new TextDecoder('utf-8');
let buffer = '';
const rawEvents: string[] = [];
const appendRawEvent = (raw: string) => {
rawEvents.push(raw);
if (rawEvents.length > 500) {
rawEvents.splice(0, rawEvents.length - 500);
}
setDebugResponse(rawEvents.join('\n'));
};
try {
const response = await api.proxyTestStream(envelope, controller.signal);
if (!response.ok) {
throw new Error(await parseStreamErrorText(response));
}
if (!response.body) {
throw new Error('流式响应体为空');
}
const reader = response.body.getReader();
let doneReceived = false;
let hasAnyContent = false;
let hasAnyReasoning = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split(/\r?\n\r?\n/);
buffer = chunks.pop() || '';
for (const chunk of chunks) {
const parsed = parseSseBlock(chunk);
if (!parsed.data) continue;
appendRawEvent(parsed.data);
if (parsed.data === '[DONE]') {
doneReceived = true;
continue;
}
let eventPayload: any;
try {
eventPayload = JSON.parse(parsed.data);
} catch {
continue;
}
if (eventPayload?.error) {
throw new Error(extractErrorMessage(eventPayload));
}
const delta = parseAnyStreamDelta(eventPayload);
if (typeof delta.reasoningDelta === 'string' && delta.reasoningDelta.trim().length > 0) {
hasAnyReasoning = true;
}
if (typeof delta.contentDelta === 'string' && delta.contentDelta.trim().length > 0) {
hasAnyContent = true;
}
if (delta.reasoningDelta || delta.contentDelta) {
setMessages((prev) => applyAssistantDelta(prev, {
reasoningDelta: delta.reasoningDelta,
contentDelta: delta.contentDelta,
}));
}
if (delta.done) doneReceived = true;
}
}
const emptyOutput = !hasAnyContent && !hasAnyReasoning;
setMessages((prev) => {
const idx = findLastLoadingAssistantIndex(prev);
if (idx === -1) return prev;
const finalized = finalizeIncompleteMessage(prev[idx]);
if (emptyOutput && !(finalized.content || '').trim() && !(finalized.reasoningContent || '').trim()) {
return replaceMessageAt(prev, idx, {
...finalized,
content: '空回复(上游未返回任何内容)',
status: MESSAGE_STATUS.ERROR,
isThinkingComplete: true,
});
}
return replaceMessageAt(prev, idx, {
...finalized,
status: MESSAGE_STATUS.COMPLETE,
isThinkingComplete: true,
});
});
if (emptyOutput) {
const message = '上游返回空内容';
setError(message);
pushDebug('error', '代理流式传输完成但内容为空。');
} else {
setError('');
pushDebug(doneReceived ? 'info' : 'warn', doneReceived
? '代理流式传输已成功完成。'
: '代理流式传输未收到 [DONE] 信号,已在本地完成。');
}
} catch (streamError: any) {
const abortedByUser = controller.signal.aborted && streamStopRequestedRef.current;
const abortedUnexpectedly = controller.signal.aborted
|| streamError?.name === 'AbortError'
|| streamError?.message === 'This operation was aborted'
|| streamError?.message === 'The user aborted a request.';
if (abortedByUser) {
setMessages((prev) => applyAssistantStopped(prev));
setError('生成已停止。');
} else if (abortedUnexpectedly) {
setMessages((prev) => applyAssistantError(prev, '流式连接中断,请重试。'));
setError('流式连接中断,请重试。');
} else {
const message = streamError?.message || '流式请求失败';
setMessages((prev) => applyAssistantError(prev, message));
setError(message);
}
} finally {
if (streamAbortRef.current === controller) streamAbortRef.current = null;
streamStopRequestedRef.current = false;
setSending(false);
}
}, [pushDebug]);
const dispatchPayload = useCallback(async (
nextMessages: ChatMessage[],
payload: TestChatPayload,
options?: { syncedCustomBody?: string },
) => {
setMessages(nextMessages);
if (options?.syncedCustomBody !== undefined) {
setCustomRequestBody(options.syncedCustomBody);
}
setError('');
setPendingPayload(payload);
setDebugRequest(formatJson(payload));
setDebugResponse('');
setActiveDebugTab(DEBUG_TABS.REQUEST);
setDebugTimestamp(new Date().toISOString());
if (payload.stream) {
await startStream(payload);
} else {
await startChatJob(payload);
}
}, [startChatJob, startStream]);
const dispatchProxyEnvelope = useCallback(async (envelope: ProxyTestEnvelope, nextMessages?: ChatMessage[]) => {
setError('');
setDebugRequest(formatJson(envelope.rawMode ? { path: envelope.path, rawJsonText: envelope.rawJsonText } : envelope));
setDebugResponse('');
setActiveDebugTab(DEBUG_TABS.REQUEST);
setDebugTimestamp(new Date().toISOString());
if (envelope.stream && nextMessages) {
await startProxyStream(envelope, nextMessages);
return;
}
setSending(true);
try {
const result = await api.proxyTest(envelope);
setDebugResponse(formatJson(result));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
setNonConversationResult(result);
if (nextMessages) {
setMessages((prev) => applyAssistantSuccess(nextMessages, result));
}
setError('');
pushDebug('info', `代理请求成功:${envelope.path}`);
} catch (requestError: any) {
const message = requestError?.message || '请求失败';
if (nextMessages) {
setMessages((prev) => applyAssistantError(nextMessages, message));
}
setError(message);
setDebugResponse(formatJson({ error: { message } }));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
pushDebug('error', `代理请求失败:${message}`);
} finally {
setSending(false);
}
}, [pushDebug, startProxyStream]);
const buildPayloadWithMessages = useCallback((nextMessages: ChatMessage[]): {
payload: TestChatPayload | null;
syncedCustomBody?: string;
} => {
const effectiveMessages = buildConversationMessagesWithSystem(nextMessages);
return {
payload: customRequestMode
? buildRawProxyRequestEnvelope(
'POST',
buildConversationProxyEnvelope(effectiveMessages).path,
'json',
customRequestBody,
{ stream: inputs.stream, jobMode: !inputs.stream },
)
: buildApiPayload(
effectiveMessages,
{ ...inputs, protocol: inputs.protocol as TestTargetFormat },
parameterEnabled,
),
syncedCustomBody: customRequestMode
? customRequestBody
: syncMessagesToCustomRequestBody(customRequestBody, effectiveMessages, inputs),
};
}, [buildConversationMessagesWithSystem, buildConversationProxyEnvelope, customRequestBody, customRequestMode, inputs, parameterEnabled]);
const sendWithPrompt = useCallback(async (
prompt: string,
baseMessages: ChatMessage[],
files: ConversationUploadedFile[] = [],
) => {
let resolvedFiles = files;
try {
resolvedFiles = await resolveConversationReplayFiles(files, inputs.protocol, loadLocalConversationFile);
} catch (resolveError: any) {
const message = resolveError?.message || '读取会话附件失败';
setError(message);
pushDebug('error', message);
return;
}
if (!ensureSupportedConversationFiles(resolvedFiles)) {
return;
}
const userMessage = createConversationUserMessage(prompt, resolvedFiles);
const loadingAssistant = createLoadingAssistantMessage();
const nextMessages = [...baseMessages, userMessage, loadingAssistant];
const useProxyTransport = inputs.protocol === 'gemini' || customRequestMode;
if (useProxyTransport) {
await dispatchProxyEnvelope(buildConversationProxyEnvelope(nextMessages), nextMessages);
return;
}
const { payload, syncedCustomBody } = buildPayloadWithMessages(nextMessages);
if (!payload) {
setError('自定义请求体无效或不包含消息。');
pushDebug('error', '从自定义请求体构建请求失败。');
return;
}
await dispatchPayload(nextMessages, payload, { syncedCustomBody });
}, [buildConversationProxyEnvelope, buildPayloadWithMessages, createConversationUserMessage, customRequestMode, dispatchPayload, dispatchProxyEnvelope, ensureSupportedConversationFiles, inputs.protocol, loadLocalConversationFile, pushDebug]);
const sendModeRequest = useCallback(async () => {
const envelope = buildModeProxyEnvelope();
if (!envelope) {
setError('请先补全当前模式所需的输入。');
return;
}
await dispatchProxyEnvelope(envelope);
}, [buildModeProxyEnvelope, dispatchProxyEnvelope]);
const send = useCallback(async () => {
if (!canSend) return;
if (inputs.mode !== 'conversation') {
await sendModeRequest();
return;
}
const trimmed = input.trim();
if (conversationFiles.length > 0 && customRequestMode) {
const message = '自定义请求模式不会自动注入会话附件,请先关闭自定义请求模式或移除附件。';
setError(message);
pushDebug('warn', message);
return;
}
if (conversationFiles.length > 0 && !conversationFileSupported) {
const message = conversationFileCapability.reason || '当前协议暂不支持会话附件。';
setError(message);
pushDebug('warn', message);
return;
}
if (!customRequestMode && conversationFileSupported && conversationFiles.length > 0) {
setSending(true);
try {
const draftFiles = inlineConversationFiles();
if (!ensureSupportedConversationFiles(draftFiles)) {
setSending(false);
return;
}
const uploadedFiles = conversationFileCapability.documentMode === 'inline_only'
? draftFiles
: await uploadConversationFiles();
setInput('');
setConversationFiles([]);
await sendWithPrompt(trimmed, messages, uploadedFiles);
} catch (uploadError: any) {
const message = uploadError?.message || '附件上传失败';
setError(message);
pushDebug('error', message);
setSending(false);
}
return;
}
if (trimmed.length > 0) {
setInput('');
await sendWithPrompt(trimmed, messages);
return;
}
if (!customRequestMode) return;
const payload = parseCustomRequestBody(customRequestBody);
if (!payload) {
setError('自定义请求体必须是有效的 JSON 且包含非空消息。');
pushDebug('error', '发送被阻止:无效的自定义请求体。');
return;
}
const nextMessages = [...messages, createLoadingAssistantMessage()];
await dispatchPayload(
nextMessages,
buildRawProxyRequestEnvelope(
'POST',
buildConversationProxyEnvelope(nextMessages).path,
'json',
customRequestBody,
{ stream: inputs.stream, jobMode: !inputs.stream },
),
);
}, [canSend, conversationFileCapability, conversationFileSupported, conversationFiles.length, customRequestBody, customRequestMode, dispatchPayload, ensureSupportedConversationFiles, inlineConversationFiles, input, inputs.mode, messages, pushDebug, sendModeRequest, sendWithPrompt, uploadConversationFiles]);
const retryPending = useCallback(async () => {
if (sending || pendingJobId || !pendingPayload) return;
const nextMessages = (() => {
const copied = [...messages];
const last = copied[copied.length - 1];
if (last?.role === 'assistant' && (last.status === MESSAGE_STATUS.ERROR || last.status === MESSAGE_STATUS.COMPLETE)) {
copied.pop();
}
copied.push(createLoadingAssistantMessage());
return copied;
})();
pushDebug('info', '正在重试待处理的请求。');
await dispatchPayload(nextMessages, pendingPayload);
}, [dispatchPayload, messages, pendingJobId, pendingPayload, pushDebug, sending]);
const stopGenerating = useCallback(async () => {
let hadWork = false;
if (streamAbortRef.current) {
hadWork = true;
streamStopRequestedRef.current = true;
try {
streamAbortRef.current.abort();
} catch {
// no-op
}
streamAbortRef.current = null;
}
if (pendingJobId) {
hadWork = true;
const jobId = pendingJobId;
setPendingJobId(null);
try {
await api.deleteProxyTestJob(jobId);
} catch {
// no-op
}
}
if (!hadWork) return;
setSending(false);
setMessages((prev) => applyAssistantStopped(prev));
setError('生成已停止。');
pushDebug('warn', '生成已被用户停止。');
}, [pendingJobId, pushDebug]);
const clearChat = useCallback(() => {
if (pendingJobId) {
void api.deleteProxyTestJob(pendingJobId).catch(() => { });
}
if (streamAbortRef.current) {
streamStopRequestedRef.current = true;
try {
streamAbortRef.current.abort();
} catch {
// no-op
}
streamAbortRef.current = null;
}
setMessages([]);
setPendingPayload(null);
setPendingJobId(null);
setInput('');
setError('');
setSending(false);
setEditingMessageId(null);
setEditValue('');
setDebugRequest('');
setDebugResponse('');
setDebugPreview('');
setDebugTimeline([]);
setDebugTimestamp('');
setNonConversationResult(null);
setSearchQueryValue('');
setSearchAllowedDomains('');
setSearchBlockedDomains('');
setSearchMaxResults(10);
setEmbeddingInputText('');
setAssetPrompt('');
setVideoInspectId('');
setVideoInspectAction('GET');
setImageSourceFile(null);
setImageMaskFile(null);
setConversationFiles([]);
localStorage.removeItem(MODEL_TESTER_STORAGE_KEY);
pushDebug('info', '对话已清除。');
}, [pendingJobId, pushDebug]);
const toggleReasoning = useCallback((messageId: string) => {
setMessages((prev) => prev.map((message) => {
if (message.id !== messageId || message.role !== 'assistant') return message;
return { ...message, isReasoningExpanded: !message.isReasoningExpanded };
}));
}, []);
const copyMessage = useCallback(async (message: ChatMessage) => {
const text = [
message.reasoningContent ? `[reasoning]\n${message.reasoningContent}` : '',
message.content,
].filter(Boolean).join('\n\n').trim();
if (!text) {
setError('没有可复制的文本内容。');
return;
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
const area = document.createElement('textarea');
area.value = text;
document.body.appendChild(area);
area.select();
document.execCommand('copy');
document.body.removeChild(area);
}
pushDebug('info', `已复制消息 ${message.id}。`);
} catch {
setError('复制失败,请手动复制。');
}
}, [pushDebug]);
const deleteMessage = useCallback((target: ChatMessage) => {
if (sending) return;
setMessages((prev) => {
const index = prev.findIndex((msg) => msg.id === target.id);
if (index === -1) return prev;
if (target.role === 'user' && prev[index + 1]?.role === 'assistant') {
return prev.filter((_, idx) => idx !== index && idx !== index + 1);
}
return prev.filter((msg) => msg.id !== target.id);
});
setEditingMessageId(null);
setEditValue('');
pushDebug('info', `已删除消息 ${target.id}。`);
}, [pushDebug, sending]);
const toggleAssistantRole = useCallback((target: ChatMessage) => {
if (!(target.role === 'assistant' || target.role === 'system')) return;
if (sending) return;
setMessages((prev) => prev.map((msg) => {
if (msg.id !== target.id) return msg;
return { ...msg, role: msg.role === 'assistant' ? 'system' : 'assistant' };
}));
}, [sending]);
const resetFromMessage = useCallback((target: ChatMessage) => {
if (sending || pendingJobId) return;
const index = messages.findIndex((msg) => msg.id === target.id);
if (index === -1) return;
let userIndex = -1;
if (target.role === 'user') {
userIndex = index;
} else {
for (let i = index - 1; i >= 0; i -= 1) {
if (messages[i].role === 'user') {
userIndex = i;
break;
}
}
}
if (userIndex === -1) {
setError('未找到可重试的用户消息。');
return;
}
const base = messages.slice(0, userIndex);
const prompt = messages[userIndex].content;
const files = extractConversationUploadedFilesFromMessage(messages[userIndex]);
setEditingMessageId(null);
setEditValue('');
void sendWithPrompt(prompt, base, files);
}, [messages, pendingJobId, sendWithPrompt, sending]);
const startEditMessage = useCallback((target: ChatMessage) => {
if (sending) return;
setEditingMessageId(target.id);
setEditValue(target.content);
}, [sending]);
const cancelEditMessage = useCallback(() => {
setEditingMessageId(null);
setEditValue('');
}, []);
const saveEditMessage = useCallback((retry = false) => {
if (!editingMessageId) return;
const targetIndex = messages.findIndex((message) => message.id === editingMessageId);
if (targetIndex === -1) {
cancelEditMessage();
return;
}
const nextContent = editValue;
const target = messages[targetIndex];
const updated = messages.map((message, index) => (index === targetIndex
? { ...message, content: nextContent }
: message));
setMessages(updated);
setEditingMessageId(null);
setEditValue('');
if (retry && target.role === 'user') {
const base = updated.slice(0, targetIndex);
void sendWithPrompt(nextContent, base, extractConversationUploadedFilesFromMessage(target));
}
}, [cancelEditMessage, editValue, editingMessageId, messages, sendWithPrompt]);
const syncMessageToBody = useCallback(() => {
const nextBody = syncMessagesToCustomRequestBody(customRequestBody, messages, inputs);
setCustomRequestBody(nextBody);
pushDebug('info', '已将消息同步到自定义请求体。');
}, [customRequestBody, inputs, messages, pushDebug]);
const syncBodyToMessage = useCallback(() => {
const nextMessages = syncCustomRequestBodyToMessages(customRequestBody);
if (!nextMessages) {
setError('自定义请求体中没有有效的消息。');
return;
}
setMessages(nextMessages);
pushDebug('info', '已将自定义请求体同步到消息。');
}, [customRequestBody, pushDebug]);
const formatCustomBody = useCallback(() => {
try {
const parsed = JSON.parse(customRequestBody);
setCustomRequestBody(JSON.stringify(parsed, null, 2));
setError('');
} catch (formatError: any) {
setError(`JSON 解析错误:${formatError?.message || '无效的 JSON'}`);
}
}, [customRequestBody]);
const debugTabContent = useMemo(() => {
if (activeDebugTab === DEBUG_TABS.PREVIEW) return debugPreview;
if (activeDebugTab === DEBUG_TABS.REQUEST) return debugRequest;
return debugResponse;
}, [activeDebugTab, debugPreview, debugRequest, debugResponse]);
const layoutColumns = isMobile
? '1fr'
: debugPanelPresence.shouldRender
? '340px minmax(0, 1fr) 360px'
: '340px minmax(0, 1fr)';
if (loadingModels) {
return (
<div className="animate-fade-in">
<div className="skeleton" style={{ width: 200, height: 28, marginBottom: 20 }} />
<div className="skeleton" style={{ height: 120, marginBottom: 12, borderRadius: 'var(--radius-md)' }} />
<div className="skeleton" style={{ height: 520, borderRadius: 'var(--radius-md)' }} />
</div>
);
}
return (
<div className="animate-fade-in">
<div className="page-header">
<div>
<h2 className="page-title">{tr('模型测试')}</h2>
<p style={{ fontSize: 13, color: 'var(--color-text-muted)', margin: '4px 0 0' }}>
支持流式输出、任务模式、自定义请求体和调试面板。
</p>
</div>
<div className="page-actions">
<button
onClick={() => setShowDebugPanel((prev) => !prev)}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{showDebugPanel ? '隐藏调试' : '显示调试'}
</button>
<button
onClick={() => { void retryPending(); }}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
disabled={sending || !!pendingJobId || !pendingPayload}
>
重试
</button>
<button
onClick={() => { void stopGenerating(); }}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
disabled={!pendingJobId && !streamAbortRef.current}
>
停止
</button>
<button
onClick={clearChat}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
disabled={messages.length === 0 && !pendingPayload && !pendingJobId}
>
清除
</button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? 'repeat(2, minmax(0, 1fr))' : 'repeat(4, 1fr)', gap: 12, marginBottom: 16 }} className="animate-slide-up stagger-1">
<div className="stat-summary-card stat-summary-purple">
<div className="stat-summary-card-label">模型数量</div>
<div className="stat-summary-card-value">{models.length}</div>
</div>
<div className="stat-summary-card stat-summary-blue">
<div className="stat-summary-card-label">当前模型</div>
<div className="stat-summary-card-value" style={{ fontSize: 14, wordBreak: 'break-all' }}>{inputs.model || '未选择'}</div>
</div>
<div className="stat-summary-card stat-summary-green">
<div className="stat-summary-card-label">对话轮数</div>
<div className="stat-summary-card-value">{turnCount}</div>
</div>
<div className="stat-summary-card stat-summary-orange">
<div className="stat-summary-card-label">模式</div>
<div className="stat-summary-card-value" style={{ fontSize: 14 }}>
{inputs.mode === 'conversation'
? (customRequestMode ? '自定义请求' : (inputs.stream ? '流式' : '任务模式'))
: inputs.mode}
{' / '}
{inputs.protocol === 'claude'
? 'Claude'
: inputs.protocol === 'responses'
? 'OpenAI Responses'
: inputs.protocol === 'gemini'
? 'Gemini'
: 'OpenAI'}
</div>
</div>
</div>
<div
className="animate-slide-up stagger-2"
style={{
display: 'grid',
gridTemplateColumns: layoutColumns,
gap: 16,
alignItems: 'stretch',
}}
>
<div className="card" style={{ padding: 16, minHeight: isMobile ? 'auto' : 680, maxHeight: isMobile ? 'none' : 740, overflowY: isMobile ? 'visible' : 'auto', order: isMobile ? 2 : 0 }}>
<h3 style={{ margin: '0 0 12px', fontSize: 15 }}>设置</h3>
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6, fontWeight: 600 }}>
测试模式
</div>
<ModernSelect
value={inputs.mode}
onChange={(next) => {
if (!next) return;
updateInput('mode', next as PlaygroundMode);
}}
options={CONVERSATION_MODE_OPTIONS}
placeholder="请选择测试模式"
/>
</div>
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6, fontWeight: 600 }}>模型</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 6, flexDirection: isMobile ? 'column' : 'row' }}>
<input
value={modelSearch}
onChange={(event) => setModelSearch(event.target.value)}
placeholder="搜索模型(支持名称片段)"
style={{
...inputBaseStyle,
flex: 1,
marginBottom: 0,
}}
disabled={models.length === 0}
/>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', whiteSpace: 'nowrap' }}
onClick={() => setModelSearch('')}
disabled={!modelSearch}
>
清空
</button>
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 6 }}>
{modelCountText}
</div>
<ModernSelect
value={currentModelVisible ? inputs.model : ''}
onChange={(next) => {
if (!next) return;
updateInput('model', next);
}}
options={modelSelectOptions}
placeholder={
!currentModelVisible && !!inputs.model
? `当前模型已被筛选:${inputs.model}`
: (models.length === 0
? '暂无模型'
: (filteredModels.length === 0 ? '未找到匹配模型' : '请选择模型'))
}
disabled={models.length === 0 || customRequestMode || filteredModels.length === 0}
emptyLabel="未找到匹配模型"
menuMaxHeight={300}
/>
{!currentModelVisible && !!inputs.model && (
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 4 }}>
当前模型已被筛选:{inputs.model}
</div>
)}
{customRequestMode && (
<div style={{ fontSize: 11, color: 'var(--color-warning)', marginTop: 4 }}>
自定义请求模式下模型选择将被忽略。
</div>
)}
</div>
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6, fontWeight: 600 }}>
协议 / 输出格式
</div>
<ModernSelect
value={inputs.protocol}
onChange={(next) => {
if (!next) return;
updateProtocol(next as PlaygroundProtocol);
}}
options={PROTOCOL_OPTIONS}
placeholder="请选择协议"
/>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 4 }}>
对话模式下可模拟 OpenAI / Responses / Claude / Gemini Native。
</div>
</div>
{inputs.mode === 'conversation' && (
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6, fontWeight: 600 }}>
System Prompt
</div>
<textarea
value={inputs.systemPrompt}
onChange={(event) => updateInput('systemPrompt', event.target.value)}
rows={4}
placeholder="可选的系统提示词,会在发送时独立注入请求。"
style={{
...inputBaseStyle,
resize: 'vertical',
fontFamily: 'inherit',
lineHeight: 1.5,
}}
/>
</div>
)}
<div style={{ marginBottom: 14, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>流式输出</div>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={inputs.stream}
onChange={(event) => updateInput('stream', event.target.checked)}
disabled={customRequestMode || inputs.mode !== 'conversation'}
/>
启用
</label>
</div>
{inputs.mode !== 'conversation' && (
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 14 }}>
当前模式默认走同步请求;Search / Embeddings / Images / Videos 会通过通用 proxy tester 直达对应接口。
</div>
)}
<div style={{ marginBottom: 14, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>自定义请求体</div>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={customRequestMode}
onChange={(event) => setCustomRequestMode(event.target.checked)}
/>
启用
</label>
</div>
<div className={`anim-collapse ${customRequestMode ? 'is-open' : ''}`.trim()} style={{ marginBottom: 14 }}>
<div className="anim-collapse-inner">
<textarea
value={customRequestBody}
onChange={(event) => setCustomRequestBody(event.target.value)}
rows={11}
placeholder='{"model":"gpt-4o-mini","targetFormat":"claude","messages":[{"role":"user","content":"hello"}],"stream":true}'
style={{
...inputBaseStyle,
resize: 'vertical',
fontFamily: 'var(--font-mono)',
lineHeight: 1.5,
}}
/>
<div style={{ marginTop: 8, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={formatCustomBody}>
格式化 JSON
</button>
{inputs.mode === 'conversation' && (
<>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={syncMessageToBody}>
消息 -&gt; 请求体
</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={syncBodyToMessage}>
请求体 -&gt; 消息
</button>
</>
)}
</div>
</div>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8, fontWeight: 600 }}>
采样参数
</div>
<ParameterRow
title="温度"
valueText={inputs.temperature.toFixed(2)}
enabled={parameterEnabled.temperature}
onToggle={() => toggleParameter('temperature')}
disabled={customRequestMode}
>
<input
type="range"
min={0}
max={2}
step={0.1}
value={inputs.temperature}
onChange={(event) => updateInput('temperature', toNumber(event.target.value, inputs.temperature))}
style={{ width: '100%', accentColor: 'var(--color-primary)' }}
disabled={!parameterEnabled.temperature || customRequestMode}
/>
</ParameterRow>
<ParameterRow
title="Top P"
valueText={inputs.top_p.toFixed(2)}
enabled={parameterEnabled.top_p}
onToggle={() => toggleParameter('top_p')}
disabled={customRequestMode}
>
<input
type="range"
min={0}
max={1}
step={0.05}
value={inputs.top_p}
onChange={(event) => updateInput('top_p', toNumber(event.target.value, inputs.top_p))}
style={{ width: '100%', accentColor: 'var(--color-primary)' }}
disabled={!parameterEnabled.top_p || customRequestMode}
/>
</ParameterRow>
<ParameterRow
title="频率惩罚"
valueText={inputs.frequency_penalty.toFixed(2)}
enabled={parameterEnabled.frequency_penalty}
onToggle={() => toggleParameter('frequency_penalty')}
disabled={customRequestMode}
>
<input
type="range"
min={-2}
max={2}
step={0.1}
value={inputs.frequency_penalty}
onChange={(event) => updateInput('frequency_penalty', toNumber(event.target.value, inputs.frequency_penalty))}
style={{ width: '100%', accentColor: 'var(--color-primary)' }}
disabled={!parameterEnabled.frequency_penalty || customRequestMode}
/>
</ParameterRow>
<ParameterRow
title="存在惩罚"
valueText={inputs.presence_penalty.toFixed(2)}
enabled={parameterEnabled.presence_penalty}
onToggle={() => toggleParameter('presence_penalty')}
disabled={customRequestMode}
>
<input
type="range"
min={-2}
max={2}
step={0.1}
value={inputs.presence_penalty}
onChange={(event) => updateInput('presence_penalty', toNumber(event.target.value, inputs.presence_penalty))}
style={{ width: '100%', accentColor: 'var(--color-primary)' }}
disabled={!parameterEnabled.presence_penalty || customRequestMode}
/>
</ParameterRow>
<ParameterRow
title="最大 Token 数"
enabled={parameterEnabled.max_tokens}
onToggle={() => toggleParameter('max_tokens')}
disabled={customRequestMode}
>
<input
type="number"
value={inputs.max_tokens}
min={1}
step={1}
onChange={(event) => updateInput('max_tokens', toNumber(event.target.value, inputs.max_tokens))}
style={inputBaseStyle}
disabled={!parameterEnabled.max_tokens || customRequestMode}
/>
</ParameterRow>
<ParameterRow
title="随机种子"
valueText={inputs.seed === null ? '自动' : String(inputs.seed)}
enabled={parameterEnabled.seed}
onToggle={() => toggleParameter('seed')}
disabled={customRequestMode}
>
<input
type="number"
value={inputs.seed ?? ''}
min={0}
step={1}
placeholder="可选种子值"
onChange={(event) => {
const raw = event.target.value.trim();
updateInput('seed', raw.length === 0 ? null : toNumber(raw, 0));
}}
style={inputBaseStyle}
disabled={!parameterEnabled.seed || customRequestMode}
/>
</ParameterRow>
</div>
<div className="card" style={{ padding: 0, overflow: 'hidden', minHeight: isMobile ? 'auto' : 680, maxHeight: isMobile ? 'none' : 740, display: 'flex', flexDirection: 'column', order: isMobile ? 1 : 0 }}>
<div style={{
padding: '14px 16px',
borderBottom: '1px solid var(--color-border-light)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'var(--color-bg-card)',
}}>
<div style={{ fontSize: 14, fontWeight: 700 }}>对话</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{sending ? '生成中...' : '就绪'}
</div>
</div>
<div style={{ flex: 1, minHeight: 280, overflowY: 'auto', padding: 18, background: 'var(--color-bg)' }}>
{inputs.mode !== 'conversation' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{
padding: 14,
border: '1px solid var(--color-border-light)',
borderRadius: 'var(--radius-md)',
background: 'var(--color-bg-card)',
}}>
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 6 }}>
{inputs.mode === 'embeddings' ? 'Embeddings 结果'
: inputs.mode === 'search' ? 'Search 结果'
: inputs.mode.startsWith('images') ? '图片结果'
: '视频任务结果'}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
新模式走通用 proxy tester;结果同时会写入右侧调试面板。
</div>
</div>
{Array.isArray((nonConversationResult as any)?.data) && (nonConversationResult as any).data.some((item: any) => item?.url || item?.b64_json) && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 }}>
{(nonConversationResult as any).data.map((item: any, index: number) => {
const imageSrc = typeof item?.url === 'string'
? item.url
: (typeof item?.b64_json === 'string' ? `data:image/png;base64,${item.b64_json}` : '');
if (!imageSrc) return null;
return (
<div key={`image-${index}`} style={{
border: '1px solid var(--color-border-light)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
background: 'var(--color-bg-card)',
}}>
<img src={imageSrc} alt={`generated-${index}`} style={{ width: '100%', display: 'block' }} />
</div>
);
})}
</div>
)}
<pre style={{
margin: 0,
padding: 14,
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-light)',
background: 'var(--color-bg-card)',
fontSize: 12,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'var(--font-mono)',
}}>
{nonConversationResult ? formatJson(nonConversationResult) : '// 暂无结果'}
</pre>
</div>
) : messages.length === 0 ? (
<div className="empty-state" style={{ padding: '40px 0' }}>
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M8 10h.01M12 14h.01M16 10h.01M9 16h6M12 3C7.03 3 3 6.582 3 11c0 2.2 1.003 4.193 2.63 5.64V21l3.376-1.847A10.76 10.76 0 0012 19c4.97 0 9-3.582 9-8s-4.03-8-9-8z" />
</svg>
<div className="empty-state-title">开始对话测试</div>
<div className="empty-state-desc">支持流式模式、自定义请求体模式和可恢复的任务。</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{messages.map((message) => {
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
const isLoading = message.status === MESSAGE_STATUS.LOADING || message.status === MESSAGE_STATUS.INCOMPLETE;
const isError = message.status === MESSAGE_STATUS.ERROR;
const showReasoning = Boolean(message.reasoningContent);
const isEditing = editingMessageId === message.id;
const fileParts = Array.isArray(message.parts)
? message.parts.filter((part): part is Extract<ConversationContentPart, { type: 'input_file' }> => part.type === 'input_file')
: [];
return (
<div
key={message.id}
className="animate-fade-in"
style={{
display: 'flex',
gap: 10,
flexDirection: isUser ? 'row-reverse' : 'row',
}}
>
<div style={{
width: 30,
height: 30,
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 12,
fontWeight: 700,
flexShrink: 0,
background: isUser
? 'linear-gradient(135deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 58%, white))'
: (isSystem
? 'linear-gradient(135deg, color-mix(in srgb, var(--color-text-secondary) 88%, black), color-mix(in srgb, var(--color-text-muted) 70%, white))'
: (isError
? 'linear-gradient(135deg, var(--color-danger), color-mix(in srgb, var(--color-danger) 68%, white))'
: 'linear-gradient(135deg, var(--color-success), color-mix(in srgb, var(--color-success) 62%, white))')),
color: 'white',
}}>
{isUser ? 'U' : (isSystem ? 'SYS' : 'AI')}
</div>
<div style={{ maxWidth: isMobile ? '100%' : '78%', display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0, flex: isMobile ? 1 : 'initial' }}>
{showReasoning && (
<div style={{
border: '1px solid color-mix(in srgb, var(--color-primary) 28%, transparent)',
background: 'color-mix(in srgb, var(--color-primary) 9%, var(--color-bg-card))',
borderRadius: '10px',
overflow: 'hidden',
}}>
<button
onClick={() => toggleReasoning(message.id)}
style={{
width: '100%',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
border: 'none',
background: 'transparent',
cursor: 'pointer',
padding: '8px 10px',
fontSize: 12,
fontWeight: 600,
color: 'var(--color-primary)',
}}
>
<span>{isLoading ? '思考中...' : '推理过程'}</span>
<span>{message.isReasoningExpanded ? '▼' : '▶'}</span>
</button>
<div className={`anim-collapse ${message.isReasoningExpanded ? 'is-open' : ''}`.trim()}>
<div className="anim-collapse-inner">
<div style={{
padding: '8px 10px',
borderTop: '1px solid color-mix(in srgb, var(--color-primary) 20%, transparent)',
fontSize: 12,
lineHeight: 1.7,
whiteSpace: 'pre-wrap',
color: 'var(--color-text-secondary)',
}}>
{message.reasoningContent}
</div>
</div>
</div>
</div>
)}
<div style={{
padding: '10px 12px',
borderRadius: isUser ? '12px 12px 3px 12px' : '12px 12px 12px 3px',
background: isUser ? 'var(--color-primary)' : (isError ? 'var(--color-danger-soft)' : 'var(--color-bg-card)'),
color: isUser ? 'white' : 'var(--color-text-primary)',
border: isUser ? 'none' : (isError ? '1px solid color-mix(in srgb, var(--color-danger) 32%, transparent)' : '1px solid var(--color-border-light)'),
fontSize: 13,
lineHeight: 1.65,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
boxShadow: 'var(--shadow-sm)',
minHeight: 24,
}}>
{isEditing ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<textarea
value={editValue}
onChange={(event) => setEditValue(event.target.value)}
rows={3}
style={{ ...inputBaseStyle, resize: 'vertical', background: 'var(--color-bg-card)', color: 'var(--color-text-primary)' }}
/>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{message.role === 'user' && (
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => saveEditMessage(true)}>
保存并重试
</button>
)}
<button className="btn btn-primary" onClick={() => saveEditMessage(false)}>保存</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={cancelEditMessage}>取消</button>
</div>
</div>
) : (
<>
{isLoading && <span className="spinner spinner-sm" style={{ marginRight: 6, verticalAlign: 'middle' }} />}
{message.content || (isLoading ? '思考中...' : '')}
</>
)}
</div>
{!isEditing && fileParts.length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 8 }}>
{fileParts.map((part, index) => (
<span
key={`${message.id}-file-${part.fileId || part.filename || index}`}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '4px 8px',
borderRadius: 999,
border: '1px solid var(--color-border-light)',
background: 'var(--color-bg-subtle)',
color: 'var(--color-text-secondary)',
fontSize: 11,
maxWidth: '100%',
}}
title={part.fileId || part.filename || '附件'}
>
<span>📎</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{part.filename || part.fileId || '附件'}
</span>
</span>
))}
</div>
)}
{!isEditing && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{!isLoading && (
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', padding: '4px 8px', fontSize: 11 }} onClick={() => resetFromMessage(message)} disabled={sending || Boolean(pendingJobId)}>
重试
</button>
)}
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', padding: '4px 8px', fontSize: 11 }} onClick={() => { void copyMessage(message); }}>
复制
</button>
{!isLoading && (
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', padding: '4px 8px', fontSize: 11 }} onClick={() => startEditMessage(message)} disabled={sending}>
编辑
</button>
)}
{!isLoading && (
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', padding: '4px 8px', fontSize: 11 }} onClick={() => deleteMessage(message)} disabled={sending}>
删除
</button>
)}
{(message.role === 'assistant' || message.role === 'system') && !isLoading && (
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', padding: '4px 8px', fontSize: 11 }} onClick={() => toggleAssistantRole(message)} disabled={sending}>
{message.role === 'assistant' ? '转为系统' : '转为助手'}
</button>
)}
</div>
)}
</div>
</div>
);
})}
<div ref={chatEndRef} />
</div>
)}
</div>
<div style={{ borderTop: '1px solid var(--color-border-light)', padding: 14, background: 'var(--color-bg-card)' }}>
{error && (
<div className="alert alert-error animate-scale-in" style={{ marginBottom: 10 }}>
{error}
</div>
)}
{inputs.mode === 'conversation' ? (
<ConversationComposer
isMobile={isMobile}
sending={sending}
customRequestMode={customRequestMode}
conversationFileCapability={conversationFileCapability}
conversationFileSupported={conversationFileSupported}
conversationFileAccept={conversationFileAccept}
conversationFileHint={conversationFileHint}
conversationFiles={conversationFiles}
conversationFileInputRef={conversationFileInputRef}
input={input}
canSend={canSend}
inputBaseStyle={inputBaseStyle}
onInputChange={setInput}
onFilesChange={handleConversationFilesChange}
onRemoveConversationFile={removeConversationFile}
onSend={send}
onStop={stopGenerating}
/>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{inputs.mode === 'embeddings' && (
<textarea
value={embeddingInputText}
onChange={(event) => setEmbeddingInputText(event.target.value)}
rows={4}
placeholder="输入 embeddings 测试文本,支持单条或多行。"
style={{ ...inputBaseStyle, resize: 'vertical' }}
/>
)}
{inputs.mode === 'search' && (
<>
<textarea
value={searchQueryValue}
onChange={(event) => setSearchQueryValue(event.target.value)}
rows={3}
placeholder="输入搜索查询"
style={{ ...inputBaseStyle, resize: 'vertical' }}
/>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr 120px', gap: 10 }}>
<input value={searchAllowedDomains} onChange={(event) => setSearchAllowedDomains(event.target.value)} placeholder="allowed_domains (逗号分隔)" style={inputBaseStyle} />
<input value={searchBlockedDomains} onChange={(event) => setSearchBlockedDomains(event.target.value)} placeholder="blocked_domains (逗号分隔)" style={inputBaseStyle} />
<input value={searchMaxResults} onChange={(event) => setSearchMaxResults(toNumber(event.target.value, 10))} type="number" min={1} max={20} style={inputBaseStyle} />
</div>
</>
)}
{(inputs.mode === 'images.generate' || inputs.mode === 'images.edit' || inputs.mode === 'videos.create') && (
<>
<textarea
value={assetPrompt}
onChange={(event) => setAssetPrompt(event.target.value)}
rows={3}
placeholder={inputs.mode === 'videos.create' ? '输入视频生成提示词' : '输入图片提示词'}
style={{ ...inputBaseStyle, resize: 'vertical' }}
/>
{(inputs.mode === 'images.edit' || inputs.mode === 'videos.create') && (
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : (inputs.mode === 'images.edit' ? '1fr 1fr' : '1fr'), gap: 10 }}>
<label style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
<div style={{ marginBottom: 6 }}>{inputs.mode === 'images.edit' ? '原图' : '参考图'}</div>
<input type="file" accept="image/*" onChange={(event) => { void handleUploadChange(event.target.files, setImageSourceFile); }} />
</label>
{inputs.mode === 'images.edit' && (
<label style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
<div style={{ marginBottom: 6 }}>Mask</div>
<input type="file" accept="image/*" onChange={(event) => { void handleUploadChange(event.target.files, setImageMaskFile); }} />
</label>
)}
</div>
)}
</>
)}
{inputs.mode === 'videos.inspect' && (
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 160px', gap: 10 }}>
<input
value={videoInspectId}
onChange={(event) => setVideoInspectId(event.target.value)}
placeholder="输入 public video id"
style={inputBaseStyle}
/>
<ModernSelect
value={videoInspectAction}
onChange={(next) => {
if (!next) return;
setVideoInspectAction(next as 'GET' | 'DELETE');
}}
options={[
{ value: 'GET', label: 'GET' },
{ value: 'DELETE', label: 'DELETE' },
]}
/>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
onClick={() => { void send(); }}
disabled={!canSend}
className="btn btn-primary"
style={{ minWidth: 120, height: 42 }}
>
发送请求
</button>
</div>
</div>
)}
</div>
</div>
<DebugPanel
presence={debugPanelPresence}
isMobile={isMobile}
debugTimestamp={debugTimestamp}
activeDebugTab={activeDebugTab}
onTabChange={setActiveDebugTab}
debugTabContent={debugTabContent}
debugTimeline={debugTimeline}
/>
</div>
</div>
);
}