feat: Add custom request body editor with persistent message storage

- Add CustomRequestEditor component with JSON validation and real-time formatting
- Implement bidirectional sync between chat messages and custom request body
- Add persistent local storage for chat messages (separate from config)
- Remove redundant System Prompt field in custom mode
- Refactor configuration storage to separate messages and settings

New Features:
• Custom request body mode with JSON editor and syntax highlighting
• Real-time bidirectional synchronization between chat UI and custom request body
• Persistent message storage that survives page refresh
• Enhanced configuration export/import including message data
• Improved parameter organization with collapsible sections

Technical Changes:
• Add loadMessages/saveMessages functions in configStorage
• Update usePlaygroundState hook to handle message persistence
• Refactor SettingsPanel to remove System Prompt in custom mode
• Add STORAGE_KEYS constants for better storage key management
• Implement debounced auto-save for both config and messages
• Add hash-based change detection to prevent unnecessary updates

UI/UX Improvements:
• Disabled state styling for parameters in custom mode
• Warning banners and visual feedback for mode switching
• Mobile-responsive design for custom request editor
• Consistent styling with existing design system
This commit is contained in:
Apple\Apple
2025-06-01 17:07:36 +08:00
parent ffdedde6ac
commit 5107f1b84a
17 changed files with 1199 additions and 380 deletions

View File

@@ -20,13 +20,21 @@ const ConfigManager = ({
onConfigImport,
onConfigReset,
styleState,
messages,
}) => {
const { t } = useTranslation();
const fileInputRef = useRef(null);
const handleExport = () => {
try {
exportConfig(currentConfig);
// 在导出前先保存当前配置,确保导出的是最新内容
const configWithTimestamp = {
...currentConfig,
timestamp: new Date().toISOString(),
};
localStorage.setItem('playground_config', JSON.stringify(configWithTimestamp));
exportConfig(currentConfig, messages);
Toast.success({
content: t('配置已导出到下载文件夹'),
duration: 3,
@@ -84,11 +92,31 @@ const ConfigManager = ({
type: 'danger',
},
onOk: () => {
clearConfig();
onConfigReset();
Toast.success({
content: t('配置已重置为默认值'),
duration: 3,
// 询问是否同时重置消息
Modal.confirm({
title: t('重置选项'),
content: t('是否同时重置对话消息?选择"是"将清空所有对话记录并恢复默认示例;选择"否"将保留当前对话记录。'),
okText: t('同时重置消息'),
cancelText: t('仅重置配置'),
okButtonProps: {
type: 'danger',
},
onOk: () => {
clearConfig();
onConfigReset({ resetMessages: true });
Toast.success({
content: t('配置和消息已全部重置'),
duration: 3,
});
},
onCancel: () => {
clearConfig();
onConfigReset({ resetMessages: false });
Toast.success({
content: t('配置已重置,对话消息已保留'),
duration: 3,
});
},
});
},
});

View File

@@ -0,0 +1,197 @@
import React, { useState, useEffect } from 'react';
import {
Card,
TextArea,
Typography,
Button,
Switch,
Banner,
Tag,
} from '@douyinfe/semi-ui';
import {
Code,
Edit,
Check,
X,
AlertTriangle,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
const CustomRequestEditor = ({
customRequestMode,
customRequestBody,
onCustomRequestModeChange,
onCustomRequestBodyChange,
defaultPayload,
}) => {
const { t } = useTranslation();
const [isValid, setIsValid] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [localValue, setLocalValue] = useState(customRequestBody || '');
// 当切换到自定义模式时用默认payload初始化
useEffect(() => {
if (customRequestMode && (!customRequestBody || customRequestBody.trim() === '')) {
const defaultJson = defaultPayload ? JSON.stringify(defaultPayload, null, 2) : '';
setLocalValue(defaultJson);
onCustomRequestBodyChange(defaultJson);
}
}, [customRequestMode, defaultPayload, customRequestBody, onCustomRequestBodyChange]);
// 同步外部传入的customRequestBody到本地状态
useEffect(() => {
if (customRequestBody !== localValue) {
setLocalValue(customRequestBody || '');
validateJson(customRequestBody || '');
}
}, [customRequestBody]);
// 验证JSON格式
const validateJson = (value) => {
if (!value.trim()) {
setIsValid(true);
setErrorMessage('');
return true;
}
try {
JSON.parse(value);
setIsValid(true);
setErrorMessage('');
return true;
} catch (error) {
setIsValid(false);
setErrorMessage(`JSON格式错误: ${error.message}`);
return false;
}
};
const handleValueChange = (value) => {
setLocalValue(value);
validateJson(value);
// 始终保存用户输入让预览逻辑处理JSON解析错误
onCustomRequestBodyChange(value);
};
const handleModeToggle = (enabled) => {
onCustomRequestModeChange(enabled);
if (enabled && defaultPayload) {
const defaultJson = JSON.stringify(defaultPayload, null, 2);
setLocalValue(defaultJson);
onCustomRequestBodyChange(defaultJson);
}
};
const formatJson = () => {
try {
const parsed = JSON.parse(localValue);
const formatted = JSON.stringify(parsed, null, 2);
setLocalValue(formatted);
onCustomRequestBodyChange(formatted);
setIsValid(true);
setErrorMessage('');
} catch (error) {
// 如果格式化失败,保持原样
}
};
return (
<div className="space-y-4">
{/* 自定义模式开关 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Code size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
自定义请求体模式
</Typography.Text>
{customRequestMode && (
<Tag color="green" size="small" shape='circle'>
已启用
</Tag>
)}
</div>
<Switch
checked={customRequestMode}
onChange={handleModeToggle}
checkedText="开"
uncheckedText="关"
size="small"
/>
</div>
{customRequestMode && (
<>
{/* 提示信息 */}
<Banner
type="warning"
description="启用此模式后将使用您自定义的请求体发送API请求模型配置面板的参数设置将被忽略。"
icon={<AlertTriangle size={16} />}
className="!rounded-lg"
closable={false}
/>
{/* JSON编辑器 */}
<div>
<div className="flex items-center justify-between mb-2">
<Typography.Text strong className="text-sm">
请求体 JSON
</Typography.Text>
<div className="flex items-center gap-2">
{isValid ? (
<div className="flex items-center gap-1 text-green-600">
<Check size={14} />
<Typography.Text className="text-xs">
格式正确
</Typography.Text>
</div>
) : (
<div className="flex items-center gap-1 text-red-600">
<X size={14} />
<Typography.Text className="text-xs">
格式错误
</Typography.Text>
</div>
)}
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Edit size={14} />}
onClick={formatJson}
disabled={!isValid}
className="!rounded-lg"
>
格式化
</Button>
</div>
</div>
<TextArea
value={localValue}
onChange={handleValueChange}
placeholder='{"model": "gpt-4o", "messages": [...], ...}'
autosize={{ minRows: 8, maxRows: 20 }}
className={`!rounded-lg font-mono text-sm ${!isValid ? '!border-red-500' : ''}`}
style={{
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
lineHeight: '1.5',
}}
/>
{!isValid && errorMessage && (
<Typography.Text type="danger" className="text-xs mt-1 block">
{errorMessage}
</Typography.Text>
)}
<Typography.Text className="text-xs text-gray-500 mt-2 block">
请输入有效的JSON格式的请求体您可以参考预览面板中的默认请求体格式
</Typography.Text>
</div>
</>
)}
</div>
);
};
export default CustomRequestEditor;

View File

@@ -24,6 +24,7 @@ const DebugPanel = ({
onActiveDebugTabChange,
styleState,
onCloseDebugPanel,
customRequestMode,
}) => {
const { t } = useTranslation();
@@ -128,6 +129,11 @@ const DebugPanel = ({
<div className="flex items-center gap-2">
<Eye size={16} />
{t('预览请求体')}
{customRequestMode && (
<span className="px-1.5 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full">
自定义
</span>
)}
</div>
} itemKey="preview">
<CodeViewer

View File

@@ -13,7 +13,7 @@ import {
Image,
} from 'lucide-react';
const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnabledChange }) => {
const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnabledChange, disabled = false }) => {
const handleAddImageUrl = () => {
const newUrls = [...imageUrls, ''];
onImageUrlsChange(newUrls);
@@ -31,13 +31,18 @@ const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnab
};
return (
<div>
<div className={disabled ? 'opacity-50' : ''}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Image size={16} className={imageEnabled ? "text-blue-500" : "text-gray-400"} />
<Image size={16} className={imageEnabled && !disabled ? "text-blue-500" : "text-gray-400"} />
<Typography.Text strong className="text-sm">
图片地址
</Typography.Text>
{disabled && (
<Typography.Text className="text-xs text-orange-600">
(已在自定义模式中忽略)
</Typography.Text>
)}
</div>
<div className="flex items-center gap-2">
<Switch
@@ -47,6 +52,7 @@ const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnab
uncheckedText="停用"
size="small"
className="flex-shrink-0"
disabled={disabled}
/>
<Button
icon={<Plus size={14} />}
@@ -55,26 +61,26 @@ const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnab
type="primary"
onClick={handleAddImageUrl}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={!imageEnabled || imageUrls.length >= 5}
disabled={!imageEnabled || imageUrls.length >= 5 || disabled}
/>
</div>
</div>
{!imageEnabled ? (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
图片发送已停用启用后可添加图片URL进行多模态对话
{disabled ? '图片功能在自定义请求体模式下不可用' : '启用后可添加图片URL进行多模态对话'}
</Typography.Text>
) : imageUrls.length === 0 ? (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
点击 + 按钮添加图片URL支持最多5张图片
{disabled ? '图片功能在自定义请求体模式下不可用' : '点击 + 按钮添加图片URL支持最多5张图片'}
</Typography.Text>
) : (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
已添加 {imageUrls.length}/5 张图片
已添加 {imageUrls.length}/5 张图片{disabled ? ' (自定义模式下不可用)' : ''}
</Typography.Text>
)}
<div className={`space-y-2 max-h-32 overflow-y-auto ${!imageEnabled ? 'opacity-50' : ''}`}>
<div className={`space-y-2 max-h-32 overflow-y-auto ${!imageEnabled || disabled ? 'opacity-50' : ''}`}>
{imageUrls.map((url, index) => (
<div key={index} className="flex items-center gap-2">
<div className="flex-1">
@@ -85,7 +91,7 @@ const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnab
className="!rounded-lg"
size="small"
prefix={<IconFile size='small' />}
disabled={!imageEnabled}
disabled={!imageEnabled || disabled}
/>
</div>
<Button
@@ -95,7 +101,7 @@ const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnab
type="danger"
onClick={() => handleRemoveImageUrl(index)}
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0 !text-red-500 hover:!bg-red-50 flex-shrink-0"
disabled={!imageEnabled}
disabled={!imageEnabled || disabled}
/>
</div>
))}

View File

@@ -0,0 +1,56 @@
import React from 'react';
import MessageContent from './MessageContent';
import MessageActions from './MessageActions';
import SettingsPanel from './SettingsPanel';
import DebugPanel from './DebugPanel';
// 优化的消息内容组件
export const OptimizedMessageContent = React.memo(MessageContent, (prevProps, nextProps) => {
// 只有这些属性变化时才重新渲染
return (
prevProps.message.id === nextProps.message.id &&
prevProps.message.content === nextProps.message.content &&
prevProps.message.status === nextProps.message.status &&
prevProps.message.isReasoningExpanded === nextProps.message.isReasoningExpanded &&
prevProps.isEditing === nextProps.isEditing &&
prevProps.editValue === nextProps.editValue &&
prevProps.styleState.isMobile === nextProps.styleState.isMobile
);
});
// 优化的消息操作组件
export const OptimizedMessageActions = React.memo(MessageActions, (prevProps, nextProps) => {
return (
prevProps.message.id === nextProps.message.id &&
prevProps.isAnyMessageGenerating === nextProps.isAnyMessageGenerating &&
prevProps.isEditing === nextProps.isEditing
);
});
// 优化的设置面板组件
export const OptimizedSettingsPanel = React.memo(SettingsPanel, (prevProps, nextProps) => {
return (
JSON.stringify(prevProps.inputs) === JSON.stringify(nextProps.inputs) &&
JSON.stringify(prevProps.parameterEnabled) === JSON.stringify(nextProps.parameterEnabled) &&
JSON.stringify(prevProps.models) === JSON.stringify(nextProps.models) &&
JSON.stringify(prevProps.groups) === JSON.stringify(nextProps.groups) &&
prevProps.customRequestMode === nextProps.customRequestMode &&
prevProps.customRequestBody === nextProps.customRequestBody &&
prevProps.showDebugPanel === nextProps.showDebugPanel &&
prevProps.showSettings === nextProps.showSettings &&
JSON.stringify(prevProps.previewPayload) === JSON.stringify(nextProps.previewPayload) &&
JSON.stringify(prevProps.messages) === JSON.stringify(nextProps.messages)
);
});
// 优化的调试面板组件
export const OptimizedDebugPanel = React.memo(DebugPanel, (prevProps, nextProps) => {
return (
prevProps.show === nextProps.show &&
prevProps.activeTab === nextProps.activeTab &&
JSON.stringify(prevProps.debugData) === JSON.stringify(nextProps.debugData) &&
JSON.stringify(prevProps.previewPayload) === JSON.stringify(nextProps.previewPayload) &&
prevProps.customRequestMode === nextProps.customRequestMode &&
prevProps.showDebugPanel === nextProps.showDebugPanel
);
});

View File

@@ -22,11 +22,12 @@ const ParameterControl = ({
parameterEnabled,
onInputChange,
onParameterToggle,
disabled = false,
}) => {
return (
<>
{/* Temperature */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.temperature ? 'opacity-50' : ''}`}>
<div className={`transition-opacity duration-200 ${!parameterEnabled.temperature || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Thermometer size={16} className="text-gray-500" />
@@ -44,6 +45,7 @@ const ParameterControl = ({
icon={parameterEnabled.temperature ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('temperature')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
@@ -56,12 +58,12 @@ const ParameterControl = ({
value={inputs.temperature}
onChange={(value) => onInputChange('temperature', value)}
className="mt-2"
disabled={!parameterEnabled.temperature}
disabled={!parameterEnabled.temperature || disabled}
/>
</div>
{/* Top P */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.top_p ? 'opacity-50' : ''}`}>
<div className={`transition-opacity duration-200 ${!parameterEnabled.top_p || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Target size={16} className="text-gray-500" />
@@ -79,6 +81,7 @@ const ParameterControl = ({
icon={parameterEnabled.top_p ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('top_p')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
@@ -91,12 +94,12 @@ const ParameterControl = ({
value={inputs.top_p}
onChange={(value) => onInputChange('top_p', value)}
className="mt-2"
disabled={!parameterEnabled.top_p}
disabled={!parameterEnabled.top_p || disabled}
/>
</div>
{/* Frequency Penalty */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.frequency_penalty ? 'opacity-50' : ''}`}>
<div className={`transition-opacity duration-200 ${!parameterEnabled.frequency_penalty || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Repeat size={16} className="text-gray-500" />
@@ -114,6 +117,7 @@ const ParameterControl = ({
icon={parameterEnabled.frequency_penalty ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('frequency_penalty')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
@@ -126,12 +130,12 @@ const ParameterControl = ({
value={inputs.frequency_penalty}
onChange={(value) => onInputChange('frequency_penalty', value)}
className="mt-2"
disabled={!parameterEnabled.frequency_penalty}
disabled={!parameterEnabled.frequency_penalty || disabled}
/>
</div>
{/* Presence Penalty */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.presence_penalty ? 'opacity-50' : ''}`}>
<div className={`transition-opacity duration-200 ${!parameterEnabled.presence_penalty || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Ban size={16} className="text-gray-500" />
@@ -149,6 +153,7 @@ const ParameterControl = ({
icon={parameterEnabled.presence_penalty ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('presence_penalty')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
@@ -161,12 +166,12 @@ const ParameterControl = ({
value={inputs.presence_penalty}
onChange={(value) => onInputChange('presence_penalty', value)}
className="mt-2"
disabled={!parameterEnabled.presence_penalty}
disabled={!parameterEnabled.presence_penalty || disabled}
/>
</div>
{/* MaxTokens */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.max_tokens ? 'opacity-50' : ''}`}>
<div className={`transition-opacity duration-200 ${!parameterEnabled.max_tokens || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Hash size={16} className="text-gray-500" />
@@ -181,6 +186,7 @@ const ParameterControl = ({
icon={parameterEnabled.max_tokens ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('max_tokens')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Input
@@ -192,12 +198,12 @@ const ParameterControl = ({
value={inputs.max_tokens}
onChange={(value) => onInputChange('max_tokens', value)}
className="!rounded-lg"
disabled={!parameterEnabled.max_tokens}
disabled={!parameterEnabled.max_tokens || disabled}
/>
</div>
{/* Seed */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.seed ? 'opacity-50' : ''}`}>
<div className={`transition-opacity duration-200 ${!parameterEnabled.seed || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Shuffle size={16} className="text-gray-500" />
@@ -215,6 +221,7 @@ const ParameterControl = ({
icon={parameterEnabled.seed ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('seed')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Input
@@ -224,7 +231,7 @@ const ParameterControl = ({
value={inputs.seed || ''}
onChange={(value) => onInputChange('seed', value === '' ? null : value)}
className="!rounded-lg"
disabled={!parameterEnabled.seed}
disabled={!parameterEnabled.seed || disabled}
/>
</div>
</>

View File

@@ -7,42 +7,49 @@ import {
Button,
Switch,
Divider,
Banner,
} from '@douyinfe/semi-ui';
import {
Sparkles,
Users,
Type,
ToggleLeft,
X,
AlertTriangle,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { renderGroupOption } from '../../helpers/render.js';
import ParameterControl from './ParameterControl';
import ImageUrlInput from './ImageUrlInput';
import ConfigManager from './ConfigManager';
import CustomRequestEditor from './CustomRequestEditor';
const SettingsPanel = ({
inputs,
parameterEnabled,
models,
groups,
systemPrompt,
styleState,
showDebugPanel,
customRequestMode,
customRequestBody,
onInputChange,
onParameterToggle,
onSystemPromptChange,
onCloseSettings,
onConfigImport,
onConfigReset,
onCustomRequestModeChange,
onCustomRequestBodyChange,
previewPayload,
messages,
}) => {
const { t } = useTranslation();
const currentConfig = {
inputs,
parameterEnabled,
systemPrompt,
showDebugPanel,
customRequestMode,
customRequestBody,
};
return (
@@ -63,6 +70,7 @@ const SettingsPanel = ({
onConfigImport={onConfigImport}
onConfigReset={onConfigReset}
styleState={styleState}
messages={messages}
/>
<Button
icon={<X size={16} />}
@@ -76,13 +84,27 @@ const SettingsPanel = ({
)}
<div className="space-y-6 overflow-y-auto flex-1 pr-2 model-settings-scroll">
{/* 自定义请求体编辑器 */}
<CustomRequestEditor
customRequestMode={customRequestMode}
customRequestBody={customRequestBody}
onCustomRequestModeChange={onCustomRequestModeChange}
onCustomRequestBodyChange={onCustomRequestBodyChange}
defaultPayload={previewPayload}
/>
{/* 分组选择 */}
<div>
<div className={customRequestMode ? 'opacity-50' : ''}>
<div className="flex items-center gap-2 mb-2">
<Users size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
{t('分组')}
</Typography.Text>
{customRequestMode && (
<Typography.Text className="text-xs text-orange-600">
(已在自定义模式中忽略)
</Typography.Text>
)}
</div>
<Select
placeholder={t('请选择分组')}
@@ -96,16 +118,22 @@ const SettingsPanel = ({
renderOptionItem={renderGroupOption}
style={{ width: '100%' }}
className="!rounded-lg"
disabled={customRequestMode}
/>
</div>
{/* 模型选择 */}
<div>
<div className={customRequestMode ? 'opacity-50' : ''}>
<div className="flex items-center gap-2 mb-2">
<Sparkles size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
{t('模型')}
</Typography.Text>
{customRequestMode && (
<Typography.Text className="text-xs text-orange-600">
(已在自定义模式中忽略)
</Typography.Text>
)}
</div>
<Select
placeholder={t('请选择模型')}
@@ -119,33 +147,45 @@ const SettingsPanel = ({
autoComplete='new-password'
optionList={models}
className="!rounded-lg"
disabled={customRequestMode}
/>
</div>
{/* 图片URL输入 */}
<ImageUrlInput
imageUrls={inputs.imageUrls}
imageEnabled={inputs.imageEnabled}
onImageUrlsChange={(urls) => onInputChange('imageUrls', urls)}
onImageEnabledChange={(enabled) => onInputChange('imageEnabled', enabled)}
/>
<div className={customRequestMode ? 'opacity-50' : ''}>
<ImageUrlInput
imageUrls={inputs.imageUrls}
imageEnabled={inputs.imageEnabled}
onImageUrlsChange={(urls) => onInputChange('imageUrls', urls)}
onImageEnabledChange={(enabled) => onInputChange('imageEnabled', enabled)}
disabled={customRequestMode}
/>
</div>
{/* 参数控制组件 */}
<ParameterControl
inputs={inputs}
parameterEnabled={parameterEnabled}
onInputChange={onInputChange}
onParameterToggle={onParameterToggle}
/>
<div className={customRequestMode ? 'opacity-50' : ''}>
<ParameterControl
inputs={inputs}
parameterEnabled={parameterEnabled}
onInputChange={onInputChange}
onParameterToggle={onParameterToggle}
disabled={customRequestMode}
/>
</div>
{/* 流式输出开关 */}
<div>
<div className={customRequestMode ? 'opacity-50' : ''}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ToggleLeft size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
流式输出
</Typography.Text>
{customRequestMode && (
<Typography.Text className="text-xs text-orange-600">
(已在自定义模式中忽略)
</Typography.Text>
)}
</div>
<Switch
checked={inputs.stream}
@@ -153,30 +193,10 @@ const SettingsPanel = ({
checkedText="开"
uncheckedText="关"
size="small"
disabled={customRequestMode}
/>
</div>
</div>
{/* System Prompt */}
<div>
<div className="flex items-center gap-2 mb-2">
<Type size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
System Prompt
</Typography.Text>
</div>
<TextArea
placeholder='System Prompt'
name='system'
required
autoComplete='new-password'
autosize
defaultValue={systemPrompt}
onChange={onSystemPromptChange}
className="!rounded-lg"
maxHeight={200}
/>
</div>
</div>
{/* 桌面端的配置管理放在底部 */}
@@ -187,6 +207,7 @@ const SettingsPanel = ({
onConfigImport={onConfigImport}
onConfigReset={onConfigReset}
styleState={styleState}
messages={messages}
/>
</div>
)}

View File

@@ -0,0 +1,98 @@
import React from 'react';
import { Typography } from '@douyinfe/semi-ui';
import MarkdownRenderer from '../common/MarkdownRenderer';
import { ChevronRight, ChevronUp, Brain, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const ThinkingContent = ({
message,
finalExtractedThinkingContent,
thinkingSource,
styleState,
onToggleReasoningExpansion
}) => {
const { t } = useTranslation();
if (!finalExtractedThinkingContent) return null;
const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
const headerText = (isThinkingStatus && !message.isThinkingComplete) ? t('思考中...') : t('思考过程');
return (
<div className="rounded-xl sm:rounded-2xl mb-2 sm:mb-4 overflow-hidden shadow-sm backdrop-blur-sm">
<div
className="flex items-center justify-between p-3 sm:p-5 cursor-pointer hover:bg-gradient-to-r hover:from-white/20 hover:to-purple-50/30 transition-all"
style={{
background: 'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
position: 'relative'
}}
onClick={() => onToggleReasoningExpansion(message.id)}
>
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
</div>
<div className="flex items-center gap-2 sm:gap-4 relative">
<div className="w-6 h-6 sm:w-8 sm:h-8 rounded-full bg-white/20 flex items-center justify-center shadow-lg">
<Brain style={{ color: 'white' }} size={styleState.isMobile ? 12 : 16} />
</div>
<div className="flex flex-col">
<Typography.Text strong style={{ color: 'white' }} className="text-sm sm:text-base">
{headerText}
</Typography.Text>
{thinkingSource && (
<Typography.Text style={{ color: 'white' }} className="text-xs mt-0.5 opacity-80 hidden sm:block">
来源: {thinkingSource}
</Typography.Text>
)}
</div>
</div>
<div className="flex items-center gap-2 sm:gap-3 relative">
{isThinkingStatus && !message.isThinkingComplete && (
<div className="flex items-center gap-1 sm:gap-2">
<Loader2 style={{ color: 'white' }} className="animate-spin" size={styleState.isMobile ? 14 : 18} />
<Typography.Text style={{ color: 'white' }} className="text-xs sm:text-sm font-medium opacity-90">
思考中
</Typography.Text>
</div>
)}
{(!isThinkingStatus || message.isThinkingComplete) && (
<div className="w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-white/20 flex items-center justify-center">
{message.isReasoningExpanded ?
<ChevronUp size={styleState.isMobile ? 12 : 16} style={{ color: 'white' }} /> :
<ChevronRight size={styleState.isMobile ? 12 : 16} style={{ color: 'white' }} />
}
</div>
)}
</div>
</div>
<div
className={`transition-all duration-500 ease-out ${message.isReasoningExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
} overflow-hidden bg-gradient-to-br from-purple-50 via-indigo-50 to-violet-50`}
>
{message.isReasoningExpanded && (
<div className="p-3 sm:p-5 pt-2 sm:pt-4">
<div
className="bg-white/70 backdrop-blur-sm rounded-lg sm:rounded-xl p-2 shadow-inner overflow-x-auto overflow-y-auto thinking-content-scroll"
style={{
maxHeight: '200px',
minHeight: '100px',
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(0, 0, 0, 0.3) transparent'
}}
>
<div className="prose prose-xs sm:prose-sm prose-purple max-w-none text-xs sm:text-sm">
<MarkdownRenderer
content={finalExtractedThinkingContent}
className=""
/>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default ThinkingContent;

View File

@@ -1,30 +1,6 @@
const STORAGE_KEY = 'playground_config';
import { STORAGE_KEYS, DEFAULT_CONFIG } from '../../utils/constants';
const DEFAULT_CONFIG = {
inputs: {
model: 'deepseek-r1',
group: '',
max_tokens: 0,
temperature: 0,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
seed: null,
stream: true,
imageUrls: [],
imageEnabled: false,
},
parameterEnabled: {
max_tokens: true,
temperature: true,
top_p: false,
frequency_penalty: false,
presence_penalty: false,
seed: false,
},
systemPrompt: 'You are a helpful assistant. You can help me by answering my questions. You can also ask me questions.',
showDebugPanel: false,
};
const MESSAGES_STORAGE_KEY = 'playground_messages';
/**
* 保存配置到 localStorage
@@ -36,20 +12,37 @@ export const saveConfig = (config) => {
...config,
timestamp: new Date().toISOString(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(configToSave));
localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(configToSave));
console.log('配置已保存到本地存储');
} catch (error) {
console.error('保存配置失败:', error);
}
};
/**
* 保存消息到 localStorage
* @param {Array} messages - 要保存的消息数组
*/
export const saveMessages = (messages) => {
try {
const messagesToSave = {
messages,
timestamp: new Date().toISOString(),
};
localStorage.setItem(STORAGE_KEYS.MESSAGES, JSON.stringify(messagesToSave));
console.log('消息已保存到本地存储');
} catch (error) {
console.error('保存消息失败:', error);
}
};
/**
* 从 localStorage 加载配置
* @returns {Object} 配置对象,如果不存在则返回默认配置
*/
export const loadConfig = () => {
try {
const savedConfig = localStorage.getItem(STORAGE_KEY);
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig);
@@ -62,8 +55,9 @@ export const loadConfig = () => {
...DEFAULT_CONFIG.parameterEnabled,
...parsedConfig.parameterEnabled,
},
systemPrompt: parsedConfig.systemPrompt || DEFAULT_CONFIG.systemPrompt,
showDebugPanel: parsedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
customRequestMode: parsedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode,
customRequestBody: parsedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody,
};
console.log('配置已从本地存储加载');
@@ -77,25 +71,58 @@ export const loadConfig = () => {
return DEFAULT_CONFIG;
};
/**
* 从 localStorage 加载消息
* @returns {Array} 消息数组,如果不存在则返回 null
*/
export const loadMessages = () => {
try {
const savedMessages = localStorage.getItem(STORAGE_KEYS.MESSAGES);
if (savedMessages) {
const parsedMessages = JSON.parse(savedMessages);
console.log('消息已从本地存储加载');
return parsedMessages.messages || null;
}
} catch (error) {
console.error('加载消息失败:', error);
}
console.log('没有找到保存的消息');
return null;
};
/**
* 清除保存的配置
*/
export const clearConfig = () => {
try {
localStorage.removeItem(STORAGE_KEY);
console.log('配置已清除');
localStorage.removeItem(STORAGE_KEYS.CONFIG);
localStorage.removeItem(STORAGE_KEYS.MESSAGES); // 同时清除消息
console.log('配置和消息已清除');
} catch (error) {
console.error('清除配置失败:', error);
}
};
/**
* 清除保存的消息
*/
export const clearMessages = () => {
try {
localStorage.removeItem(STORAGE_KEYS.MESSAGES);
console.log('消息已清除');
} catch (error) {
console.error('清除消息失败:', error);
}
};
/**
* 检查是否有保存的配置
* @returns {boolean} 是否存在保存的配置
*/
export const hasStoredConfig = () => {
try {
return localStorage.getItem(STORAGE_KEY) !== null;
return localStorage.getItem(STORAGE_KEYS.CONFIG) !== null;
} catch (error) {
console.error('检查配置失败:', error);
return false;
@@ -108,7 +135,7 @@ export const hasStoredConfig = () => {
*/
export const getConfigTimestamp = () => {
try {
const savedConfig = localStorage.getItem(STORAGE_KEY);
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig);
return parsedConfig.timestamp || null;
@@ -120,13 +147,15 @@ export const getConfigTimestamp = () => {
};
/**
* 导出配置为 JSON 文件
* 导出配置为 JSON 文件(包含消息)
* @param {Object} config - 要导出的配置
* @param {Array} messages - 要导出的消息
*/
export const exportConfig = (config) => {
export const exportConfig = (config, messages = null) => {
try {
const configToExport = {
...config,
messages: messages || loadMessages(), // 包含消息数据
exportTime: new Date().toISOString(),
version: '1.0',
};
@@ -148,7 +177,7 @@ export const exportConfig = (config) => {
};
/**
* 从文件导入配置
* 从文件导入配置(包含消息)
* @param {File} file - 包含配置的 JSON 文件
* @returns {Promise<Object>} 导入的配置对象
*/
@@ -161,6 +190,11 @@ export const importConfig = (file) => {
const importedConfig = JSON.parse(e.target.result);
if (importedConfig.inputs && importedConfig.parameterEnabled) {
// 如果导入的配置包含消息,也一起导入
if (importedConfig.messages && Array.isArray(importedConfig.messages)) {
saveMessages(importedConfig.messages);
}
console.log('配置已从文件导入');
resolve(importedConfig);
} else {

View File

@@ -0,0 +1,70 @@
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { API, showError } from '../helpers/index.js';
import { API_ENDPOINTS } from '../utils/constants';
import { processModelsData, processGroupsData } from '../utils/apiUtils';
export const useDataLoader = (
userState,
inputs,
handleInputChange,
setModels,
setGroups
) => {
const { t } = useTranslation();
const loadModels = useCallback(async () => {
try {
const res = await API.get(API_ENDPOINTS.USER_MODELS);
const { success, message, data } = res.data;
if (success) {
const { modelOptions, selectedModel } = processModelsData(data, inputs.model);
setModels(modelOptions);
if (selectedModel !== inputs.model) {
handleInputChange('model', selectedModel);
}
} else {
showError(t(message));
}
} catch (error) {
showError(t('加载模型失败'));
}
}, [inputs.model, handleInputChange, setModels, t]);
const loadGroups = useCallback(async () => {
try {
const res = await API.get(API_ENDPOINTS.USER_GROUPS);
const { success, message, data } = res.data;
if (success) {
const userGroup = userState?.user?.group || JSON.parse(localStorage.getItem('user'))?.group;
const groupOptions = processGroupsData(data, userGroup);
setGroups(groupOptions);
const hasCurrentGroup = groupOptions.some(option => option.value === inputs.group);
if (!hasCurrentGroup) {
handleInputChange('group', groupOptions[0]?.value || '');
}
} else {
showError(t(message));
}
} catch (error) {
showError(t('加载分组失败'));
}
}, [userState, inputs.group, handleInputChange, setGroups, t]);
// 自动加载数据
useEffect(() => {
if (userState?.user) {
loadModels();
loadGroups();
}
}, [userState?.user, loadModels, loadGroups]);
return {
loadModels,
loadGroups
};
};

View File

@@ -0,0 +1,93 @@
import { useCallback, useState } from 'react';
import { Toast, Modal } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { getTextContent, buildApiPayload, createLoadingAssistantMessage } from '../utils/messageUtils';
import { MESSAGE_ROLES } from '../utils/constants';
export const useMessageEdit = (
setMessage,
inputs,
parameterEnabled,
sendRequest
) => {
const { t } = useTranslation();
const [editingMessageId, setEditingMessageId] = useState(null);
const [editValue, setEditValue] = useState('');
const handleMessageEdit = useCallback((targetMessage) => {
const editableContent = getTextContent(targetMessage);
setEditingMessageId(targetMessage.id);
setEditValue(editableContent);
}, []);
const handleEditSave = useCallback(() => {
if (!editingMessageId || !editValue.trim()) return;
setMessage(prevMessages => {
const messageIndex = prevMessages.findIndex(msg => msg.id === editingMessageId);
if (messageIndex === -1) return prevMessages;
const targetMessage = prevMessages[messageIndex];
let newContent;
if (Array.isArray(targetMessage.content)) {
newContent = targetMessage.content.map(item =>
item.type === 'text' ? { ...item, text: editValue.trim() } : item
);
} else {
newContent = editValue.trim();
}
const updatedMessages = prevMessages.map(msg =>
msg.id === editingMessageId ? { ...msg, content: newContent } : msg
);
// 处理用户消息编辑后的重新生成
if (targetMessage.role === MESSAGE_ROLES.USER) {
const hasSubsequentAssistantReply = messageIndex < prevMessages.length - 1 &&
prevMessages[messageIndex + 1].role === MESSAGE_ROLES.ASSISTANT;
if (hasSubsequentAssistantReply) {
Modal.confirm({
title: t('消息已编辑'),
content: t('检测到该消息后有AI回复是否删除后续回复并重新生成'),
okText: t('重新生成'),
cancelText: t('仅保存'),
onOk: () => {
const messagesUntilUser = updatedMessages.slice(0, messageIndex + 1);
setMessage(messagesUntilUser);
setTimeout(() => {
const payload = buildApiPayload(messagesUntilUser, null, inputs, parameterEnabled);
setMessage(prevMsg => [...prevMsg, createLoadingAssistantMessage()]);
sendRequest(payload, inputs.stream);
}, 100);
},
onCancel: () => setMessage(updatedMessages)
});
return prevMessages;
}
}
return updatedMessages;
});
setEditingMessageId(null);
setEditValue('');
Toast.success({ content: t('消息已更新'), duration: 2 });
}, [editingMessageId, editValue, t, inputs, parameterEnabled, sendRequest, setMessage]);
const handleEditCancel = useCallback(() => {
setEditingMessageId(null);
setEditValue('');
}, []);
return {
editingMessageId,
editValue,
setEditValue,
handleMessageEdit,
handleEditSave,
handleEditCancel
};
};

View File

@@ -1,6 +1,6 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { DEFAULT_MESSAGES, DEFAULT_CONFIG, DEBUG_TABS } from '../utils/constants';
import { loadConfig, saveConfig } from '../components/playground/configStorage';
import { loadConfig, saveConfig, loadMessages, saveMessages } from '../components/playground/configStorage';
export const usePlaygroundState = () => {
// 使用 ref 缓存初始配置,只加载一次
@@ -10,17 +10,23 @@ export const usePlaygroundState = () => {
}
const savedConfig = initialConfigRef.current;
// 加载保存的消息,如果没有则使用默认消息
const initialMessages = loadMessages() || DEFAULT_MESSAGES;
// 基础配置状态
const [inputs, setInputs] = useState(savedConfig.inputs || DEFAULT_CONFIG.inputs);
const [parameterEnabled, setParameterEnabled] = useState(
savedConfig.parameterEnabled || DEFAULT_CONFIG.parameterEnabled
);
const [systemPrompt, setSystemPrompt] = useState(
savedConfig.systemPrompt || DEFAULT_CONFIG.systemPrompt
);
const [showDebugPanel, setShowDebugPanel] = useState(
savedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel
);
const [customRequestMode, setCustomRequestMode] = useState(
savedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode
);
const [customRequestBody, setCustomRequestBody] = useState(
savedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody
);
// UI状态
const [showSettings, setShowSettings] = useState(false);
@@ -28,8 +34,8 @@ export const usePlaygroundState = () => {
const [groups, setGroups] = useState([]);
const [status, setStatus] = useState({});
// 消息相关状态
const [message, setMessage] = useState(DEFAULT_MESSAGES);
// 消息相关状态 - 使用加载的消息初始化
const [message, setMessage] = useState(initialMessages);
// 调试状态
const [debugData, setDebugData] = useState({
@@ -50,6 +56,7 @@ export const usePlaygroundState = () => {
const sseSourceRef = useRef(null);
const chatRef = useRef(null);
const saveConfigTimeoutRef = useRef(null);
const saveMessagesTimeoutRef = useRef(null);
// 配置更新函数
const handleInputChange = useCallback((name, value) => {
@@ -63,6 +70,17 @@ export const usePlaygroundState = () => {
}));
}, []);
// 消息保存函数
const debouncedSaveMessages = useCallback(() => {
if (saveMessagesTimeoutRef.current) {
clearTimeout(saveMessagesTimeoutRef.current);
}
saveMessagesTimeoutRef.current = setTimeout(() => {
saveMessages(message);
}, 1000);
}, [message]);
// 配置保存
const debouncedSaveConfig = useCallback(() => {
if (saveConfigTimeoutRef.current) {
@@ -73,12 +91,13 @@ export const usePlaygroundState = () => {
const configToSave = {
inputs,
parameterEnabled,
systemPrompt,
showDebugPanel,
customRequestMode,
customRequestBody,
};
saveConfig(configToSave);
}, 1000);
}, [inputs, parameterEnabled, systemPrompt, showDebugPanel]);
}, [inputs, parameterEnabled, showDebugPanel, customRequestMode, customRequestBody]);
// 配置导入/重置
const handleConfigImport = useCallback((importedConfig) => {
@@ -88,27 +107,60 @@ export const usePlaygroundState = () => {
if (importedConfig.parameterEnabled) {
setParameterEnabled(prev => ({ ...prev, ...importedConfig.parameterEnabled }));
}
if (importedConfig.systemPrompt) {
setSystemPrompt(importedConfig.systemPrompt);
}
if (typeof importedConfig.showDebugPanel === 'boolean') {
setShowDebugPanel(importedConfig.showDebugPanel);
}
if (importedConfig.customRequestMode) {
setCustomRequestMode(importedConfig.customRequestMode);
}
if (importedConfig.customRequestBody) {
setCustomRequestBody(importedConfig.customRequestBody);
}
// 如果导入的配置包含消息,也恢复消息
if (importedConfig.messages && Array.isArray(importedConfig.messages)) {
setMessage(importedConfig.messages);
}
}, []);
const handleConfigReset = useCallback(() => {
const handleConfigReset = useCallback((options = {}) => {
const { resetMessages = false } = options;
setInputs(DEFAULT_CONFIG.inputs);
setParameterEnabled(DEFAULT_CONFIG.parameterEnabled);
setSystemPrompt(DEFAULT_CONFIG.systemPrompt);
setShowDebugPanel(DEFAULT_CONFIG.showDebugPanel);
setCustomRequestMode(DEFAULT_CONFIG.customRequestMode);
setCustomRequestBody(DEFAULT_CONFIG.customRequestBody);
// 只有在明确指定时才重置消息
if (resetMessages) {
setMessage(DEFAULT_MESSAGES);
}
}, []);
// 监听消息变化并自动保存
useEffect(() => {
debouncedSaveMessages();
}, [debouncedSaveMessages]);
// 清理定时器
useEffect(() => {
return () => {
if (saveConfigTimeoutRef.current) {
clearTimeout(saveConfigTimeoutRef.current);
}
if (saveMessagesTimeoutRef.current) {
clearTimeout(saveMessagesTimeoutRef.current);
}
};
}, []);
return {
// 配置状态
inputs,
parameterEnabled,
systemPrompt,
showDebugPanel,
customRequestMode,
customRequestBody,
// UI状态
showSettings,
@@ -136,8 +188,9 @@ export const usePlaygroundState = () => {
// 更新函数
setInputs,
setParameterEnabled,
setSystemPrompt,
setShowDebugPanel,
setCustomRequestMode,
setCustomRequestBody,
setShowSettings,
setModels,
setGroups,
@@ -153,6 +206,7 @@ export const usePlaygroundState = () => {
handleInputChange,
handleParameterToggle,
debouncedSaveConfig,
debouncedSaveMessages,
handleConfigImport,
handleConfigReset,
};

View File

@@ -0,0 +1,111 @@
import { useCallback, useRef } from 'react';
import { MESSAGE_ROLES } from '../utils/constants';
export const useSyncMessageAndCustomBody = (
customRequestMode,
customRequestBody,
message,
inputs,
setCustomRequestBody,
setMessage,
debouncedSaveConfig
) => {
const isUpdatingFromMessage = useRef(false);
const isUpdatingFromCustomBody = useRef(false);
const lastMessageHash = useRef('');
const lastCustomBodyHash = useRef('');
const getMessageHash = useCallback((messages) => {
return JSON.stringify(messages.map(msg => ({
id: msg.id,
role: msg.role,
content: msg.content
})));
}, []);
const getCustomBodyHash = useCallback((customBody) => {
try {
const parsed = JSON.parse(customBody);
return JSON.stringify(parsed.messages || []);
} catch {
return '';
}
}, []);
const syncMessageToCustomBody = useCallback(() => {
if (!customRequestMode || isUpdatingFromCustomBody.current) return;
const currentMessageHash = getMessageHash(message);
if (currentMessageHash === lastMessageHash.current) return;
try {
isUpdatingFromMessage.current = true;
let customPayload;
try {
customPayload = JSON.parse(customRequestBody || '{}');
} catch {
customPayload = {
model: inputs.model || 'gpt-4o',
messages: [],
temperature: inputs.temperature || 0.7,
stream: inputs.stream !== false
};
}
customPayload.messages = message.map(msg => ({
role: msg.role,
content: msg.content
}));
const newCustomBody = JSON.stringify(customPayload, null, 2);
setCustomRequestBody(newCustomBody);
lastMessageHash.current = currentMessageHash;
lastCustomBodyHash.current = getCustomBodyHash(newCustomBody);
setTimeout(() => {
debouncedSaveConfig();
}, 0);
} finally {
isUpdatingFromMessage.current = false;
}
}, [customRequestMode, customRequestBody, message, inputs.model, inputs.temperature, inputs.stream, getMessageHash, getCustomBodyHash, setCustomRequestBody, debouncedSaveConfig]);
const syncCustomBodyToMessage = useCallback(() => {
if (!customRequestMode || isUpdatingFromMessage.current) return;
const currentCustomBodyHash = getCustomBodyHash(customRequestBody);
if (currentCustomBodyHash === lastCustomBodyHash.current) return;
try {
isUpdatingFromCustomBody.current = true;
const customPayload = JSON.parse(customRequestBody || '{}');
if (customPayload.messages && Array.isArray(customPayload.messages)) {
const newMessages = customPayload.messages.map((msg, index) => ({
id: msg.id || (index + 1).toString(),
role: msg.role || MESSAGE_ROLES.USER,
content: msg.content || '',
createAt: Date.now(),
...(msg.role === MESSAGE_ROLES.ASSISTANT && {
reasoningContent: msg.reasoningContent || '',
isReasoningExpanded: false
})
}));
setMessage(newMessages);
lastCustomBodyHash.current = currentCustomBodyHash;
lastMessageHash.current = getMessageHash(newMessages);
}
} catch (error) {
console.warn('同步自定义请求体到消息失败:', error);
} finally {
isUpdatingFromCustomBody.current = false;
}
}, [customRequestMode, customRequestBody, getCustomBodyHash, getMessageHash, setMessage]);
return {
syncMessageToCustomBody,
syncCustomBodyToMessage
};
};

View File

@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useCallback } from 'react';
import React, { useContext, useEffect, useCallback, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Layout, Toast, Modal } from '@douyinfe/semi-ui';
@@ -8,36 +8,37 @@ import { UserContext } from '../../context/User/index.js';
import { StyleContext } from '../../context/Style/index.js';
// Utils and hooks
import { API, showError, getLogo, isMobile } from '../../helpers/index.js';
import { getLogo } from '../../helpers/index.js';
import { stringToColor } from '../../helpers/render.js';
import { usePlaygroundState } from '../../hooks/usePlaygroundState.js';
import { useMessageActions } from '../../hooks/useMessageActions.js';
import { useApiRequest } from '../../hooks/useApiRequest.js';
import { useSyncMessageAndCustomBody } from '../../hooks/useSyncMessageAndCustomBody.js';
import { useMessageEdit } from '../../hooks/useMessageEdit.js';
import { useDataLoader } from '../../hooks/useDataLoader.js';
// Constants and utils
import {
DEFAULT_MESSAGES,
MESSAGE_ROLES,
API_ENDPOINTS
ERROR_MESSAGES
} from '../../utils/constants.js';
import {
buildMessageContent,
createMessage,
createLoadingAssistantMessage,
getTextContent
getTextContent,
buildApiPayload
} from '../../utils/messageUtils.js';
import {
buildApiPayload,
processModelsData,
processGroupsData
} from '../../utils/apiUtils.js';
// Components
import SettingsPanel from '../../components/playground/SettingsPanel.js';
import {
OptimizedSettingsPanel,
OptimizedDebugPanel,
OptimizedMessageContent,
OptimizedMessageActions
} from '../../components/playground/OptimizedComponents.js';
import ChatArea from '../../components/playground/ChatArea.js';
import DebugPanel from '../../components/playground/DebugPanel.js';
import MessageContent from '../../components/playground/MessageContent.js';
import MessageActions from '../../components/playground/MessageActions.js';
import FloatingButtons from '../../components/playground/FloatingButtons.js';
// 生成头像
@@ -67,8 +68,9 @@ const Playground = () => {
const {
inputs,
parameterEnabled,
systemPrompt,
showDebugPanel,
customRequestMode,
customRequestBody,
showSettings,
models,
groups,
@@ -77,8 +79,6 @@ const Playground = () => {
debugData,
activeDebugTab,
previewPayload,
editingMessageId,
editValue,
sseSourceRef,
chatRef,
handleInputChange,
@@ -94,10 +94,9 @@ const Playground = () => {
setDebugData,
setActiveDebugTab,
setPreviewPayload,
setEditingMessageId,
setEditValue,
setSystemPrompt,
setShowDebugPanel,
setCustomRequestMode,
setCustomRequestBody,
} = state;
// API 请求相关
@@ -108,6 +107,30 @@ const Playground = () => {
sseSourceRef
);
// 数据加载
useDataLoader(userState, inputs, handleInputChange, setModels, setGroups);
// 消息编辑
const {
editingMessageId,
editValue,
setEditValue,
handleMessageEdit,
handleEditSave,
handleEditCancel
} = useMessageEdit(setMessage, inputs, parameterEnabled, sendRequest);
// 消息和自定义请求体同步
const { syncMessageToCustomBody, syncCustomBodyToMessage } = useSyncMessageAndCustomBody(
customRequestMode,
customRequestBody,
message,
inputs,
setCustomRequestBody,
setMessage,
debouncedSaveConfig
);
// 角色信息
const roleInfo = {
user: {
@@ -130,12 +153,16 @@ const Playground = () => {
// 构建预览请求体
const constructPreviewPayload = useCallback(() => {
try {
const systemMessage = systemPrompt !== '' ? createMessage(
MESSAGE_ROLES.SYSTEM,
systemPrompt,
{ id: '1', createAt: 1715676751919 }
) : null;
// 如果是自定义请求体模式且有自定义内容,直接返回解析后的自定义请求体
if (customRequestMode && customRequestBody && customRequestBody.trim()) {
try {
return JSON.parse(customRequestBody);
} catch (parseError) {
console.warn('自定义请求体JSON解析失败回退到默认预览:', parseError);
}
}
// 默认预览逻辑
let messages = [...message];
// 如果没有用户消息,添加默认消息
@@ -145,7 +172,6 @@ const Playground = () => {
messages = [createMessage(MESSAGE_ROLES.USER, content)];
} else {
// 处理最后一个用户消息的图片
const lastUserMessageIndex = messages.length - 1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === MESSAGE_ROLES.USER) {
if (inputs.imageEnabled && inputs.imageUrls) {
@@ -161,33 +187,51 @@ const Playground = () => {
}
}
return buildApiPayload(messages, systemMessage, inputs, parameterEnabled);
return buildApiPayload(messages, null, inputs, parameterEnabled);
} catch (error) {
console.error('构造预览请求体失败:', error);
return null;
}
}, [inputs, parameterEnabled, systemPrompt, message]);
}, [inputs, parameterEnabled, message, customRequestMode, customRequestBody]);
// 发送消息
function onMessageSend(content, attachment) {
console.log('attachment: ', attachment);
const validImageUrls = inputs.imageUrls.filter(url => url.trim() !== '');
const messageContent = buildMessageContent(content, validImageUrls, inputs.imageEnabled);
const userMessage = createMessage(MESSAGE_ROLES.USER, messageContent);
// 创建用户消息和加载消息
const userMessage = createMessage(MESSAGE_ROLES.USER, content);
const loadingMessage = createLoadingAssistantMessage();
// 如果是自定义请求体模式
if (customRequestMode && customRequestBody) {
try {
const customPayload = JSON.parse(customRequestBody);
setMessage(prevMessage => {
const newMessages = [...prevMessage, userMessage, loadingMessage];
// 发送自定义请求体
sendRequest(customPayload, customPayload.stream !== false);
return newMessages;
});
return;
} catch (error) {
console.error('自定义请求体JSON解析失败:', error);
Toast.error(ERROR_MESSAGES.JSON_PARSE_ERROR);
return;
}
}
// 默认模式
const validImageUrls = inputs.imageUrls.filter(url => url.trim() !== '');
const messageContent = buildMessageContent(content, validImageUrls, inputs.imageEnabled);
const userMessageWithImages = createMessage(MESSAGE_ROLES.USER, messageContent);
setMessage(prevMessage => {
const newMessages = [...prevMessage, userMessage];
const newMessages = [...prevMessage, userMessageWithImages];
const systemMessage = systemPrompt !== '' ? createMessage(
MESSAGE_ROLES.SYSTEM,
systemPrompt,
{ id: '1', createAt: 1715676751919 }
) : null;
const payload = buildApiPayload(newMessages, systemMessage, inputs, parameterEnabled);
const payload = buildApiPayload(newMessages, null, inputs, parameterEnabled);
sendRequest(payload, inputs.stream);
// 禁用图片模式
@@ -201,127 +245,8 @@ const Playground = () => {
});
}
// 加载模型和分组
const loadModels = async () => {
try {
const res = await API.get(API_ENDPOINTS.USER_MODELS);
const { success, message, data } = res.data;
if (success) {
const { modelOptions, selectedModel } = processModelsData(data, inputs.model);
setModels(modelOptions);
if (selectedModel !== inputs.model) {
handleInputChange('model', selectedModel);
}
} else {
showError(t(message));
}
} catch (error) {
showError(t('加载模型失败'));
}
};
const loadGroups = async () => {
try {
const res = await API.get(API_ENDPOINTS.USER_GROUPS);
const { success, message, data } = res.data;
if (success) {
const userGroup = userState?.user?.group || JSON.parse(localStorage.getItem('user'))?.group;
const groupOptions = processGroupsData(data, userGroup);
setGroups(groupOptions);
const hasCurrentGroup = groupOptions.some(option => option.value === inputs.group);
if (!hasCurrentGroup) {
handleInputChange('group', groupOptions[0]?.value || '');
}
} else {
showError(t(message));
}
} catch (error) {
showError(t('加载分组失败'));
}
};
// 编辑消息相关
const handleMessageEdit = useCallback((targetMessage) => {
const editableContent = getTextContent(targetMessage);
setEditingMessageId(targetMessage.id);
setEditValue(editableContent);
}, [setEditingMessageId, setEditValue]);
const handleEditSave = useCallback(() => {
if (!editingMessageId || !editValue.trim()) return;
setMessage(prevMessages => {
const messageIndex = prevMessages.findIndex(msg => msg.id === editingMessageId);
if (messageIndex === -1) return prevMessages;
const targetMessage = prevMessages[messageIndex];
let newContent;
if (Array.isArray(targetMessage.content)) {
newContent = targetMessage.content.map(item =>
item.type === 'text' ? { ...item, text: editValue.trim() } : item
);
} else {
newContent = editValue.trim();
}
const updatedMessages = prevMessages.map(msg =>
msg.id === editingMessageId ? { ...msg, content: newContent } : msg
);
// 处理用户消息编辑后的重新生成
if (targetMessage.role === MESSAGE_ROLES.USER) {
const hasSubsequentAssistantReply = messageIndex < prevMessages.length - 1 &&
prevMessages[messageIndex + 1].role === MESSAGE_ROLES.ASSISTANT;
if (hasSubsequentAssistantReply) {
Modal.confirm({
title: t('消息已编辑'),
content: t('检测到该消息后有AI回复是否删除后续回复并重新生成'),
okText: t('重新生成'),
cancelText: t('仅保存'),
onOk: () => {
const messagesUntilUser = updatedMessages.slice(0, messageIndex + 1);
setMessage(messagesUntilUser);
setTimeout(() => {
const systemMessage = systemPrompt !== '' ? createMessage(
MESSAGE_ROLES.SYSTEM,
systemPrompt,
{ id: '1', createAt: 1715676751919 }
) : null;
const payload = buildApiPayload(messagesUntilUser, systemMessage, inputs, parameterEnabled);
setMessage(prevMsg => [...prevMsg, createLoadingAssistantMessage()]);
sendRequest(payload, inputs.stream);
}, 100);
},
onCancel: () => setMessage(updatedMessages)
});
return prevMessages;
}
}
return updatedMessages;
});
setEditingMessageId(null);
setEditValue('');
Toast.success({ content: t('消息已更新'), duration: 2 });
}, [editingMessageId, editValue, t, systemPrompt, inputs, parameterEnabled, sendRequest, setMessage, setEditingMessageId, setEditValue]);
const handleEditCancel = useCallback(() => {
setEditingMessageId(null);
setEditValue('');
}, [setEditingMessageId, setEditValue]);
// 切换推理展开状态
const toggleReasoningExpansion = (messageId) => {
const toggleReasoningExpansion = useCallback((messageId) => {
setMessage(prevMessages =>
prevMessages.map(msg =>
msg.id === messageId && msg.role === MESSAGE_ROLES.ASSISTANT
@@ -329,7 +254,7 @@ const Playground = () => {
: msg
)
);
};
}, [setMessage]);
// 渲染函数
const renderCustomChatContent = useCallback(
@@ -337,7 +262,7 @@ const Playground = () => {
const isCurrentlyEditing = editingMessageId === message.id;
return (
<MessageContent
<OptimizedMessageContent
message={message}
className={className}
styleState={styleState}
@@ -350,7 +275,7 @@ const Playground = () => {
/>
);
},
[styleState, editingMessageId, editValue, handleEditSave, handleEditCancel, setEditValue],
[styleState, editingMessageId, editValue, handleEditSave, handleEditCancel, setEditValue, toggleReasoningExpansion],
);
const renderChatBoxAction = useCallback((props) => {
@@ -361,7 +286,7 @@ const Playground = () => {
const isCurrentlyEditing = editingMessageId === currentMessage.id;
return (
<MessageActions
<OptimizedMessageActions
message={currentMessage}
styleState={styleState}
onMessageReset={messageActions.handleMessageReset}
@@ -376,47 +301,56 @@ const Playground = () => {
}, [messageActions, styleState, message, editingMessageId, handleMessageEdit]);
// Effects
// 同步消息和自定义请求体
useEffect(() => {
syncMessageToCustomBody();
}, [message, syncMessageToCustomBody]);
useEffect(() => {
syncCustomBodyToMessage();
}, [customRequestBody, syncCustomBodyToMessage]);
// 处理URL参数
useEffect(() => {
if (searchParams.get('expired')) {
showError(t('登录或登录已过期,请重新登录!'));
Toast.warning(t('登录过期,请重新登录!'));
}
const savedStatus = localStorage.getItem('status');
if (savedStatus) {
setStatus(JSON.parse(savedStatus));
}
loadModels();
loadGroups();
}, [searchParams, t]);
// 处理窗口大小变化
useEffect(() => {
const handleResize = () => {
styleDispatch({
type: 'set_is_mobile',
payload: isMobile(),
});
const mobile = window.innerWidth < 768;
if (styleState.isMobile !== mobile) {
styleDispatch({ type: 'SET_IS_MOBILE', payload: mobile });
}
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [styleDispatch]);
}, [styleState.isMobile, styleDispatch]);
// 构建预览payload
useEffect(() => {
const newPreviewPayload = constructPreviewPayload();
setPreviewPayload(newPreviewPayload);
setDebugData(prev => ({
...prev,
previewRequest: newPreviewPayload,
previewTimestamp: new Date().toISOString()
}));
}, [constructPreviewPayload, setPreviewPayload, setDebugData]);
const timer = setTimeout(() => {
const preview = constructPreviewPayload();
setPreviewPayload(preview);
setDebugData(prev => ({
...prev,
previewRequest: preview ? JSON.stringify(preview, null, 2) : null,
previewTimestamp: preview ? new Date().toISOString() : null
}));
}, 300);
// 监听配置变化并自动保存
return () => clearTimeout(timer);
}, [message, inputs, parameterEnabled, customRequestMode, customRequestBody, constructPreviewPayload, setPreviewPayload, setDebugData]);
// 自动保存配置
useEffect(() => {
debouncedSaveConfig();
}, [inputs, parameterEnabled, systemPrompt, showDebugPanel]);
}, [inputs, parameterEnabled, showDebugPanel, customRequestMode, customRequestBody, debouncedSaveConfig]);
return (
<div className="h-full bg-gray-50">
@@ -442,21 +376,25 @@ const Playground = () => {
width={styleState.isMobile ? '100%' : 320}
className={styleState.isMobile ? 'bg-white shadow-lg' : ''}
>
<SettingsPanel
<OptimizedSettingsPanel
inputs={inputs}
parameterEnabled={parameterEnabled}
models={models}
groups={groups}
systemPrompt={systemPrompt}
styleState={styleState}
showSettings={showSettings}
showDebugPanel={showDebugPanel}
customRequestMode={customRequestMode}
customRequestBody={customRequestBody}
onInputChange={handleInputChange}
onParameterToggle={handleParameterToggle}
onSystemPromptChange={setSystemPrompt}
onCloseSettings={() => setShowSettings(false)}
onConfigImport={handleConfigImport}
onConfigReset={handleConfigReset}
onCustomRequestModeChange={setCustomRequestMode}
onCustomRequestBodyChange={setCustomRequestBody}
previewPayload={previewPayload}
messages={message}
/>
</Layout.Sider>
)}
@@ -487,11 +425,12 @@ const Playground = () => {
{/* 调试面板 - 桌面端 */}
{showDebugPanel && !styleState.isMobile && (
<div className="w-96 flex-shrink-0 h-full">
<DebugPanel
<OptimizedDebugPanel
debugData={debugData}
activeDebugTab={activeDebugTab}
onActiveDebugTabChange={setActiveDebugTab}
styleState={styleState}
customRequestMode={customRequestMode}
/>
</div>
)}
@@ -512,13 +451,14 @@ const Playground = () => {
}}
className="shadow-lg"
>
<DebugPanel
<OptimizedDebugPanel
debugData={debugData}
activeDebugTab={activeDebugTab}
onActiveDebugTabChange={setActiveDebugTab}
styleState={styleState}
showDebugPanel={showDebugPanel}
onCloseDebugPanel={() => setShowDebugPanel(false)}
customRequestMode={customRequestMode}
/>
</div>
)}

View File

@@ -1,37 +1,42 @@
import { formatMessageForAPI } from './messageUtils';
// 构建API请求载荷
export const buildApiPayload = (messages, systemMessage, inputs, parameterEnabled) => {
const formattedMessages = messages.map(formatMessageForAPI);
export const buildApiPayload = (messages, systemPrompt, inputs, parameterEnabled) => {
const processedMessages = messages.map(formatMessageForAPI);
if (systemMessage) {
formattedMessages.unshift(formatMessageForAPI(systemMessage));
// 如果有系统提示,插入到消息开头
if (systemPrompt && systemPrompt.trim()) {
processedMessages.unshift({
role: 'system',
content: systemPrompt.trim()
});
}
const payload = {
messages: formattedMessages,
stream: inputs.stream,
model: inputs.model,
group: inputs.group,
messages: processedMessages,
stream: inputs.stream,
};
// 添加可选参数
const optionalParams = [
'max_tokens', 'temperature', 'top_p',
'frequency_penalty', 'presence_penalty', 'seed'
];
optionalParams.forEach(param => {
if (parameterEnabled[param]) {
if (param === 'max_tokens' && inputs[param] > 0) {
payload[param] = parseInt(inputs[param]);
} else if (param === 'seed' && inputs[param] !== null && inputs[param] !== '') {
payload[param] = parseInt(inputs[param]);
} else if (param !== 'max_tokens' && param !== 'seed') {
payload[param] = inputs[param];
}
}
});
// 添加启用的参数
if (parameterEnabled.temperature && inputs.temperature !== undefined) {
payload.temperature = inputs.temperature;
}
if (parameterEnabled.top_p && inputs.top_p !== undefined) {
payload.top_p = inputs.top_p;
}
if (parameterEnabled.max_tokens && inputs.max_tokens !== undefined) {
payload.max_tokens = inputs.max_tokens;
}
if (parameterEnabled.frequency_penalty && inputs.frequency_penalty !== undefined) {
payload.frequency_penalty = inputs.frequency_penalty;
}
if (parameterEnabled.presence_penalty && inputs.presence_penalty !== undefined) {
payload.presence_penalty = inputs.presence_penalty;
}
if (parameterEnabled.seed && inputs.seed !== undefined && inputs.seed !== null) {
payload.seed = inputs.seed;
}
return payload;
};

View File

@@ -1,21 +1,4 @@
// Playground 相关常量
export const DEFAULT_MESSAGES = [
{
role: 'user',
id: '2',
createAt: 1715676751919,
content: '你好',
},
{
role: 'assistant',
id: '3',
createAt: 1715676751919,
content: '你好,请问有什么可以帮助您的吗?',
reasoningContent: '',
isReasoningExpanded: false,
},
];
// ========== 消息相关常量 ==========
export const MESSAGE_STATUS = {
LOADING: 'loading',
INCOMPLETE: 'incomplete',
@@ -29,21 +12,42 @@ export const MESSAGE_ROLES = {
SYSTEM: 'system',
};
// 默认消息示例
export const DEFAULT_MESSAGES = [
{
role: MESSAGE_ROLES.USER,
id: '2',
createAt: 1715676751919,
content: '你好',
},
{
role: MESSAGE_ROLES.ASSISTANT,
id: '3',
createAt: 1715676751919,
content: '你好,请问有什么可以帮助您的吗?',
reasoningContent: '',
isReasoningExpanded: false,
},
];
// ========== UI 相关常量 ==========
export const DEBUG_TABS = {
PREVIEW: 'preview',
REQUEST: 'request',
RESPONSE: 'response',
};
// ========== API 相关常量 ==========
export const API_ENDPOINTS = {
CHAT_COMPLETIONS: '/pg/chat/completions',
USER_MODELS: '/api/user/models',
USER_GROUPS: '/api/user/self/groups',
};
// ========== 配置默认值 ==========
export const DEFAULT_CONFIG = {
inputs: {
model: 'gpt-4',
model: 'gpt-4o',
group: '',
temperature: 0.7,
top_p: 1,
@@ -65,14 +69,27 @@ export const DEFAULT_CONFIG = {
},
systemPrompt: '',
showDebugPanel: false,
customRequestMode: false,
customRequestBody: '',
};
// ========== 正则表达式 ==========
export const THINK_TAG_REGEX = /<think>([\s\S]*?)<\/think>/g;
// ========== 错误消息 ==========
export const ERROR_MESSAGES = {
NO_TEXT_CONTENT: '此消息没有可复制的文本内容',
INVALID_MESSAGE_TYPE: '无法复制此类型的消息内容',
COPY_FAILED: '复制失败,请手动选择文本复制',
COPY_HTTPS_REQUIRED: '复制功能需要 HTTPS 环境,请手动复制',
BROWSER_NOT_SUPPORTED: '浏览器不支持复制功能,请手动复制',
JSON_PARSE_ERROR: '自定义请求体格式错误请检查JSON格式',
API_REQUEST_ERROR: '请求发生错误',
NETWORK_ERROR: '网络连接失败或服务器无响应',
};
// ========== 存储键名 ==========
export const STORAGE_KEYS = {
CONFIG: 'playground_config',
MESSAGES: 'playground_messages',
};

View File

@@ -6,6 +6,8 @@ export const generateMessageId = () => `${messageId++}`;
// 提取消息中的文本内容
export const getTextContent = (message) => {
if (!message || !message.content) return '';
if (Array.isArray(message.content)) {
const textContent = message.content.find(item => item.type === 'text');
return textContent?.text || '';
@@ -15,12 +17,12 @@ export const getTextContent = (message) => {
// 处理 think 标签
export const processThinkTags = (content, reasoningContent = '') => {
if (!content.includes('<think>')) {
if (!content || !content.includes('<think>')) {
return { content, reasoningContent };
}
let thoughts = [];
let replyParts = [];
const thoughts = [];
const replyParts = [];
let lastIndex = 0;
let match;
@@ -33,14 +35,10 @@ export const processThinkTags = (content, reasoningContent = '') => {
replyParts.push(content.substring(lastIndex));
const processedContent = replyParts.join('').replace(/<\/?think>/g, '').trim();
let processedReasoningContent = reasoningContent;
if (thoughts.length > 0) {
const thoughtsStr = thoughts.join('\n\n---\n\n');
processedReasoningContent = reasoningContent
? `${reasoningContent}\n\n---\n\n${thoughtsStr}`
: thoughtsStr;
}
const thoughtsStr = thoughts.join('\n\n---\n\n');
const processedReasoningContent = reasoningContent && thoughtsStr
? `${reasoningContent}\n\n---\n\n${thoughtsStr}`
: reasoningContent || thoughtsStr;
return {
content: processedContent,
@@ -50,6 +48,8 @@ export const processThinkTags = (content, reasoningContent = '') => {
// 处理未完成的 think 标签
export const processIncompleteThinkTags = (content, reasoningContent = '') => {
if (!content) return { content: '', reasoningContent };
const lastOpenThinkIndex = content.lastIndexOf('<think>');
if (lastOpenThinkIndex === -1) {
return processThinkTags(content, reasoningContent);
@@ -59,13 +59,9 @@ export const processIncompleteThinkTags = (content, reasoningContent = '') => {
if (!fragmentAfterLastOpen.includes('</think>')) {
const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
const cleanContent = content.substring(0, lastOpenThinkIndex);
let processedReasoningContent = reasoningContent;
if (unclosedThought) {
processedReasoningContent = reasoningContent
? `${reasoningContent}\n\n---\n\n${unclosedThought}`
: unclosedThought;
}
const processedReasoningContent = unclosedThought
? reasoningContent ? `${reasoningContent}\n\n---\n\n${unclosedThought}` : unclosedThought
: reasoningContent;
return processThinkTags(cleanContent, processedReasoningContent);
}
@@ -75,11 +71,15 @@ export const processIncompleteThinkTags = (content, reasoningContent = '') => {
// 构建消息内容(包含图片)
export const buildMessageContent = (textContent, imageUrls = [], imageEnabled = false) => {
const validImageUrls = imageUrls.filter(url => url.trim() !== '');
if (!textContent && (!imageUrls || imageUrls.length === 0)) {
return '';
}
const validImageUrls = imageUrls.filter(url => url && url.trim() !== '');
if (imageEnabled && validImageUrls.length > 0) {
return [
{ type: 'text', text: textContent },
{ type: 'text', text: textContent || '' },
...validImageUrls.map(url => ({
type: 'image_url',
image_url: { url: url.trim() }
@@ -87,7 +87,7 @@ export const buildMessageContent = (textContent, imageUrls = [], imageEnabled =
];
}
return textContent;
return textContent || '';
};
// 创建新消息
@@ -114,12 +114,88 @@ export const createLoadingAssistantMessage = () => createMessage(
// 检查消息是否包含图片
export const hasImageContent = (message) => {
return Array.isArray(message.content) &&
return message &&
Array.isArray(message.content) &&
message.content.some(item => item.type === 'image_url');
};
// 格式化消息用于API请求
export const formatMessageForAPI = (message) => ({
role: message.role,
content: message.content
});
export const formatMessageForAPI = (message) => {
if (!message) return null;
return {
role: message.role,
content: message.content
};
};
// 验证消息是否有效
export const isValidMessage = (message) => {
return message &&
message.role &&
(message.content || message.content === '');
};
// 获取最后一条用户消息
export const getLastUserMessage = (messages) => {
if (!Array.isArray(messages)) return null;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === MESSAGE_ROLES.USER) {
return messages[i];
}
}
return null;
};
// 获取最后一条助手消息
export const getLastAssistantMessage = (messages) => {
if (!Array.isArray(messages)) return null;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === MESSAGE_ROLES.ASSISTANT) {
return messages[i];
}
}
return null;
};
// 构建API请求负载从apiUtils移动过来
export const buildApiPayload = (messages, systemPrompt, inputs, parameterEnabled) => {
const processedMessages = messages
.filter(isValidMessage)
.map(formatMessageForAPI)
.filter(Boolean);
// 如果有系统提示,插入到消息开头
if (systemPrompt && systemPrompt.trim()) {
processedMessages.unshift({
role: MESSAGE_ROLES.SYSTEM,
content: systemPrompt.trim()
});
}
const payload = {
model: inputs.model,
messages: processedMessages,
stream: inputs.stream,
};
// 添加启用的参数
const parameterMappings = {
temperature: 'temperature',
top_p: 'top_p',
max_tokens: 'max_tokens',
frequency_penalty: 'frequency_penalty',
presence_penalty: 'presence_penalty',
seed: 'seed'
};
Object.entries(parameterMappings).forEach(([key, param]) => {
if (parameterEnabled[key] && inputs[param] !== undefined && inputs[param] !== null) {
payload[param] = inputs[param];
}
});
return payload;
};