Commit 554ac319 by Apple\Apple

🎨 refactor(playground): Refactor the structure of the playground and implement…

🎨 refactor(playground): Refactor the structure of the playground and implement responsive design adaptation
parent f3dd960c
......@@ -11,6 +11,7 @@ import { API, getLogo, getSystemName, showError } from '../helpers/index.js';
import { setStatusData } from '../helpers/data.js';
import { UserContext } from '../context/User/index.js';
import { StatusContext } from '../context/Status/index.js';
import { useLocation } from 'react-router-dom';
const { Sider, Content, Header, Footer } = Layout;
const PageLayout = () => {
......@@ -18,6 +19,9 @@ const PageLayout = () => {
const [statusState, statusDispatch] = useContext(StatusContext);
const [styleState, styleDispatch] = useContext(StyleContext);
const { i18n } = useTranslation();
const location = useLocation();
const isPlaygroundRoute = location.pathname === '/console/playground';
const loadUser = () => {
let user = localStorage.getItem('user');
......@@ -144,14 +148,16 @@ const PageLayout = () => {
>
<App />
</Content>
<Layout.Footer
style={{
flex: '0 0 auto',
width: '100%',
}}
>
<FooterBar />
</Layout.Footer>
{!isPlaygroundRoute && (
<Layout.Footer
style={{
flex: '0 0 auto',
width: '100%',
}}
>
<FooterBar />
</Layout.Footer>
)}
</Layout>
</Layout>
<ToastContainer />
......
import React from 'react';
import {
Card,
Chat,
Typography,
Button,
} from '@douyinfe/semi-ui';
import {
MessageSquare,
Eye,
EyeOff,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import CustomInputRender from './CustomInputRender';
const ChatArea = ({
chatRef,
message,
inputs,
styleState,
showDebugPanel,
roleInfo,
onMessageSend,
onMessageCopy,
onMessageReset,
onMessageDelete,
onStopGenerator,
onClearMessages,
onToggleDebugPanel,
renderCustomChatContent,
renderChatBoxAction,
}) => {
const { t } = useTranslation();
const renderInputArea = React.useCallback((props) => {
return <CustomInputRender {...props} />;
}, []);
return (
<Card
className="!rounded-2xl h-full"
bodyStyle={{ padding: 0, height: 'calc(100vh - 101px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
{/* 聊天头部 */}
{styleState.isMobile ? (
<div className="pt-4"></div>
) : (
<div className="px-6 py-4 bg-gradient-to-r from-purple-500 to-blue-500 rounded-t-2xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur flex items-center justify-center">
<MessageSquare size={20} className="text-white" />
</div>
<div>
<Typography.Title heading={5} className="!text-white mb-0">
{t('AI 对话')}
</Typography.Title>
<Typography.Text className="!text-white/80 text-sm hidden sm:inline">
{inputs.model || t('选择模型开始对话')}
</Typography.Text>
</div>
</div>
<div className="flex items-center gap-2">
<Button
icon={showDebugPanel ? <EyeOff size={14} /> : <Eye size={14} />}
onClick={onToggleDebugPanel}
theme="borderless"
type="primary"
size="small"
className="!rounded-lg !text-white/80 hover:!text-white hover:!bg-white/10"
>
{showDebugPanel ? t('隐藏调试') : t('显示调试')}
</Button>
</div>
</div>
</div>
)}
{/* 聊天内容区域 */}
<div className="flex-1 overflow-hidden">
<Chat
ref={chatRef}
chatBoxRenderConfig={{
renderChatBoxContent: renderCustomChatContent,
renderChatBoxAction: renderChatBoxAction,
renderChatBoxTitle: () => null,
}}
renderInputArea={renderInputArea}
roleConfig={roleInfo}
style={{
height: '100%',
maxWidth: '100%',
overflow: 'hidden'
}}
chats={message}
onMessageSend={onMessageSend}
onMessageCopy={onMessageCopy}
onMessageReset={onMessageReset}
onMessageDelete={onMessageDelete}
showClearContext
showStopGenerate
onStopGenerator={onStopGenerator}
onClear={onClearMessages}
className="h-full"
placeholder={t('请输入您的问题...')}
/>
</div>
</Card>
);
};
export default ChatArea;
\ No newline at end of file
import React, { useRef } from 'react';
import {
Button,
Typography,
Toast,
Modal,
Dropdown,
} from '@douyinfe/semi-ui';
import {
Download,
Upload,
RotateCcw,
Settings2,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { exportConfig, importConfig, clearConfig, hasStoredConfig, getConfigTimestamp } from './configStorage';
const ConfigManager = ({
currentConfig,
onConfigImport,
onConfigReset,
styleState,
}) => {
const { t } = useTranslation();
const fileInputRef = useRef(null);
const handleExport = () => {
try {
exportConfig(currentConfig);
Toast.success({
content: t('配置已导出到下载文件夹'),
duration: 3,
});
} catch (error) {
Toast.error({
content: t('导出配置失败: ') + error.message,
duration: 3,
});
}
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = async (event) => {
const file = event.target.files[0];
if (!file) return;
try {
const importedConfig = await importConfig(file);
Modal.confirm({
title: t('确认导入配置'),
content: t('导入的配置将覆盖当前设置,是否继续?'),
okText: t('确定导入'),
cancelText: t('取消'),
onOk: () => {
onConfigImport(importedConfig);
Toast.success({
content: t('配置导入成功'),
duration: 3,
});
},
});
} catch (error) {
Toast.error({
content: t('导入配置失败: ') + error.message,
duration: 3,
});
} finally {
// 重置文件输入,允许重复选择同一文件
event.target.value = '';
}
};
const handleReset = () => {
Modal.confirm({
title: t('重置配置'),
content: t('将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?'),
okText: t('确定重置'),
cancelText: t('取消'),
okButtonProps: {
type: 'danger',
},
onOk: () => {
clearConfig();
onConfigReset();
Toast.success({
content: t('配置已重置为默认值'),
duration: 3,
});
},
});
};
const getConfigStatus = () => {
if (hasStoredConfig()) {
const timestamp = getConfigTimestamp();
if (timestamp) {
const date = new Date(timestamp);
return t('上次保存: ') + date.toLocaleString();
}
return t('已有保存的配置');
}
return t('暂无保存的配置');
};
const dropdownItems = [
{
node: 'item',
name: 'export',
onClick: handleExport,
children: (
<div className="flex items-center gap-2">
<Download size={14} />
{t('导出配置')}
</div>
),
},
{
node: 'item',
name: 'import',
onClick: handleImportClick,
children: (
<div className="flex items-center gap-2">
<Upload size={14} />
{t('导入配置')}
</div>
),
},
{
node: 'divider',
},
{
node: 'item',
name: 'reset',
onClick: handleReset,
children: (
<div className="flex items-center gap-2 text-red-600">
<RotateCcw size={14} />
{t('重置配置')}
</div>
),
},
];
if (styleState.isMobile) {
// 移动端显示简化的下拉菜单
return (
<>
<Dropdown
trigger="click"
position="bottomLeft"
showTick
menu={dropdownItems}
>
<Button
icon={<Settings2 size={14} />}
theme="borderless"
type="tertiary"
size="small"
className="!rounded-lg !text-gray-600 hover:!text-blue-600 hover:!bg-blue-50"
/>
</Dropdown>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</>
);
}
// 桌面端显示紧凑的按钮组
return (
<div className="space-y-3">
{/* 配置状态信息,使用较小的字体 */}
<div className="text-center">
<Typography.Text className="text-xs text-gray-500">
{getConfigStatus()}
</Typography.Text>
</div>
{/* 紧凑的按钮布局 */}
<div className="flex gap-2">
<Button
icon={<Download size={12} />}
size="small"
theme="solid"
type="primary"
onClick={handleExport}
className="!rounded-lg flex-1 !text-xs !h-7"
>
{t('导出')}
</Button>
<Button
icon={<Upload size={12} />}
size="small"
theme="outline"
type="primary"
onClick={handleImportClick}
className="!rounded-lg flex-1 !text-xs !h-7"
>
{t('导入')}
</Button>
<Button
icon={<RotateCcw size={12} />}
size="small"
theme="borderless"
type="danger"
onClick={handleReset}
className="!rounded-lg !text-xs !h-7 !px-2"
style={{ minWidth: 'auto' }}
/>
</div>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</div>
);
};
export default ConfigManager;
\ No newline at end of file
import React from 'react';
const CustomInputRender = (props) => {
const { detailProps } = props;
const { clearContextNode, uploadNode, inputNode, sendNode, onClick } = detailProps;
const styledSendNode = React.cloneElement(sendNode, {
className: `!rounded-full !bg-purple-500 hover:!bg-purple-600 flex-shrink-0 ${sendNode.props.className || ''}`
});
return (
<div className="p-2 sm:p-4">
<div
className="flex items-end gap-2 sm:gap-3 p-2 bg-gray-50 rounded-xl sm:rounded-2xl shadow-sm hover:shadow-md transition-shadow"
style={{ border: '1px solid var(--semi-color-border)' }}
onClick={onClick}
>
<div className="flex-1">
{inputNode}
</div>
{styledSendNode}
</div>
</div>
);
};
export default CustomInputRender;
\ No newline at end of file
import React from 'react';
import {
Card,
Typography,
Tabs,
TabPane,
Button,
} from '@douyinfe/semi-ui';
import {
Code,
FileText,
Zap,
Clock,
X,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
const DebugPanel = ({
debugData,
activeDebugTab,
onActiveDebugTabChange,
styleState,
onCloseDebugPanel,
}) => {
const { t } = useTranslation();
return (
<Card
className="!rounded-2xl h-full flex flex-col"
bodyStyle={{
padding: styleState.isMobile ? '16px' : '24px',
height: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
<div className="flex items-center justify-between mb-6 flex-shrink-0">
<div className="flex items-center">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-blue-500 flex items-center justify-center mr-3">
<Code size={20} className="text-white" />
</div>
<Typography.Title heading={5} className="mb-0">
{t('调试信息')}
</Typography.Title>
</div>
{/* 移动端关闭按钮 */}
{styleState.isMobile && onCloseDebugPanel && (
<Button
icon={<X size={16} />}
onClick={onCloseDebugPanel}
theme="borderless"
type="tertiary"
size="small"
className="!rounded-lg"
/>
)}
</div>
<div className="flex-1 overflow-hidden debug-panel">
<Tabs
type="line"
className="h-full"
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
activeKey={activeDebugTab}
onChange={onActiveDebugTabChange}
>
<TabPane tab={
<div className="flex items-center gap-2">
<FileText size={16} />
{t('请求体')}
</div>
} itemKey="request">
<div className="h-full overflow-y-auto bg-gray-50 rounded-lg p-4 model-settings-scroll">
{debugData.request ? (
<pre className="debug-code text-gray-700 whitespace-pre-wrap break-words">
{JSON.stringify(debugData.request, null, 2)}
</pre>
) : (
<Typography.Text type="secondary" className="text-sm">
{t('暂无请求数据')}
</Typography.Text>
)}
</div>
</TabPane>
<TabPane tab={
<div className="flex items-center gap-2">
<Zap size={16} />
{t('响应内容')}
</div>
} itemKey="response">
<div className="h-full overflow-y-auto bg-gray-50 rounded-lg p-4 model-settings-scroll">
{debugData.response ? (
<pre className="debug-code text-gray-700 whitespace-pre-wrap break-words">
{debugData.response}
</pre>
) : (
<Typography.Text type="secondary" className="text-sm">
{t('暂无响应数据')}
</Typography.Text>
)}
</div>
</TabPane>
</Tabs>
</div>
{debugData.timestamp && (
<div className="flex items-center gap-2 mt-4 pt-4 flex-shrink-0">
<Clock size={14} className="text-gray-500" />
<Typography.Text className="text-xs text-gray-500">
{t('最后更新')}: {new Date(debugData.timestamp).toLocaleString()}
</Typography.Text>
</div>
)}
</Card>
);
};
export default DebugPanel;
\ No newline at end of file
import React from 'react';
import { Button } from '@douyinfe/semi-ui';
import {
Settings,
Eye,
EyeOff,
} from 'lucide-react';
const FloatingButtons = ({
styleState,
showSettings,
showDebugPanel,
onToggleSettings,
onToggleDebugPanel,
}) => {
if (!styleState.isMobile) return null;
return (
<>
{/* 设置按钮 */}
{!showSettings && (
<Button
icon={<Settings size={18} />}
style={{
position: 'fixed',
right: 16,
bottom: 90,
zIndex: 1000,
width: 36,
height: 36,
borderRadius: '50%',
padding: 0,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
background: 'linear-gradient(to right, #8b5cf6, #6366f1)',
}}
onClick={onToggleSettings}
theme='solid'
type='primary'
className="lg:hidden"
/>
)}
{/* 调试按钮 */}
{!showSettings && (
<Button
icon={showDebugPanel ? <EyeOff size={18} /> : <Eye size={18} />}
onClick={onToggleDebugPanel}
theme="solid"
type={showDebugPanel ? "danger" : "primary"}
style={{
position: 'fixed',
right: 16,
bottom: 140,
zIndex: 1000,
width: 36,
height: 36,
borderRadius: '50%',
padding: 0,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
background: showDebugPanel
? 'linear-gradient(to right, #e11d48, #be123c)'
: 'linear-gradient(to right, #4f46e5, #6366f1)',
}}
className="lg:hidden !rounded-full !p-0"
/>
)}
</>
);
};
export default FloatingButtons;
\ No newline at end of file
import React from 'react';
import {
Input,
Typography,
Button,
} from '@douyinfe/semi-ui';
import { IconFile } from '@douyinfe/semi-icons';
import {
FileText,
Plus,
X,
} from 'lucide-react';
const ImageUrlInput = ({ imageUrls, onImageUrlsChange }) => {
const handleAddImageUrl = () => {
const newUrls = [...imageUrls, ''];
onImageUrlsChange(newUrls);
};
const handleUpdateImageUrl = (index, value) => {
const newUrls = [...imageUrls];
newUrls[index] = value;
onImageUrlsChange(newUrls);
};
const handleRemoveImageUrl = (index) => {
const newUrls = imageUrls.filter((_, i) => i !== index);
onImageUrlsChange(newUrls);
};
return (
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FileText size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
图片地址
</Typography.Text>
<Typography.Text className="text-xs text-gray-400">
(多模态对话)
</Typography.Text>
</div>
<Button
icon={<Plus size={14} />}
size="small"
theme="solid"
type="primary"
onClick={handleAddImageUrl}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={imageUrls.length >= 5}
/>
</div>
{imageUrls.length === 0 ? (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
点击 + 按钮添加图片URL,支持最多5张图片
</Typography.Text>
) : (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
已添加 {imageUrls.length}/5 张图
</Typography.Text>
)}
<div className="space-y-2 max-h-32 overflow-y-auto">
{imageUrls.map((url, index) => (
<div key={index} className="flex items-center gap-2">
<div className="flex-1">
<Input
placeholder={`https://example.com/image${index + 1}.jpg`}
value={url}
onChange={(value) => handleUpdateImageUrl(index, value)}
className="!rounded-lg"
size="small"
prefix={<IconFile size='small' />}
/>
</div>
<Button
icon={<X size={12} />}
size="small"
theme="borderless"
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"
/>
</div>
))}
</div>
</div>
);
};
export default ImageUrlInput;
\ No newline at end of file
import React from 'react';
import {
Button,
Tooltip,
} from '@douyinfe/semi-ui';
import {
RefreshCw,
Copy,
Trash2,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
const MessageActions = ({ message, styleState, onMessageReset, onMessageCopy, onMessageDelete, isAnyMessageGenerating = false }) => {
const { t } = useTranslation();
const isLoading = message.status === 'loading' || message.status === 'incomplete';
const shouldDisableActions = isAnyMessageGenerating;
return (
<div className="flex items-center gap-0.5">
{!isLoading && (
<Tooltip content={shouldDisableActions ? t('正在生成中,请稍候...') : t('重试')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<RefreshCw size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onMessageReset(message)}
disabled={shouldDisableActions}
className={`!rounded-md ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-blue-600 hover:!bg-blue-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={t('重试')}
/>
</Tooltip>
)}
{message.content && (
<Tooltip content={t('复制')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Copy size={styleState.isMobile ? 12 : 14} />}
onClick={() => onMessageCopy(message)}
className={`!rounded-md !text-gray-400 hover:!text-green-600 hover:!bg-green-50 ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={t('复制')}
/>
</Tooltip>
)}
{!isLoading && (
<Tooltip content={shouldDisableActions ? t('正在生成中,请稍候...') : t('删除')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Trash2 size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onMessageDelete(message)}
disabled={shouldDisableActions}
className={`!rounded-md ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-red-600 hover:!bg-red-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={t('删除')}
/>
</Tooltip>
)}
</div>
);
};
export default MessageActions;
\ No newline at end of file
import React from 'react';
import {
Typography,
MarkdownRender,
} from '@douyinfe/semi-ui';
import {
ChevronRight,
ChevronUp,
Brain,
Loader2,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
const MessageContent = ({ message, className, styleState, onToggleReasoningExpansion }) => {
const { t } = useTranslation();
if (message.status === 'error') {
return (
<div className={`${className} flex items-center p-4 bg-red-50 rounded-xl`}>
<Typography.Text type="danger" className="text-sm">
{message.content || t('请求发生错误')}
</Typography.Text>
</div>
);
}
const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
let currentExtractedThinkingContent = null;
let currentDisplayableFinalContent = message.content || "";
let thinkingSource = null;
if (message.role === 'assistant') {
let baseContentForDisplay = message.content || "";
let combinedThinkingContent = "";
if (message.reasoningContent) {
combinedThinkingContent = message.reasoningContent;
thinkingSource = 'reasoningContent';
}
if (baseContentForDisplay.includes('<think>')) {
const thinkTagRegex = /<think>([\s\S]*?)<\/think>/g;
let match;
let thoughtsFromPairedTags = [];
let replyParts = [];
let lastIndex = 0;
while ((match = thinkTagRegex.exec(baseContentForDisplay)) !== null) {
replyParts.push(baseContentForDisplay.substring(lastIndex, match.index));
thoughtsFromPairedTags.push(match[1]);
lastIndex = match.index + match[0].length;
}
replyParts.push(baseContentForDisplay.substring(lastIndex));
if (thoughtsFromPairedTags.length > 0) {
const pairedThoughtsStr = thoughtsFromPairedTags.join('\n\n---\n\n');
if (combinedThinkingContent) {
combinedThinkingContent += '\n\n---\n\n' + pairedThoughtsStr;
} else {
combinedThinkingContent = pairedThoughtsStr;
}
thinkingSource = thinkingSource ? thinkingSource + ' & <think> tags' : '<think> tags';
}
baseContentForDisplay = replyParts.join('');
}
if (isThinkingStatus) {
const lastOpenThinkIndex = baseContentForDisplay.lastIndexOf('<think>');
if (lastOpenThinkIndex !== -1) {
const fragmentAfterLastOpen = baseContentForDisplay.substring(lastOpenThinkIndex);
if (!fragmentAfterLastOpen.includes('</think>')) {
const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
if (unclosedThought) {
if (combinedThinkingContent) {
combinedThinkingContent += '\n\n---\n\n' + unclosedThought;
} else {
combinedThinkingContent = unclosedThought;
}
thinkingSource = thinkingSource ? thinkingSource + ' + streaming <think>' : 'streaming <think>';
}
baseContentForDisplay = baseContentForDisplay.substring(0, lastOpenThinkIndex);
}
}
}
currentExtractedThinkingContent = combinedThinkingContent || null;
currentDisplayableFinalContent = baseContentForDisplay.replace(/<\/?think>/g, '').trim();
}
const headerText = isThinkingStatus ? t('思考中...') : t('思考过程');
const finalExtractedThinkingContent = currentExtractedThinkingContent;
const finalDisplayableFinalContent = currentDisplayableFinalContent;
if (message.role === 'assistant' &&
isThinkingStatus &&
!finalExtractedThinkingContent &&
(!finalDisplayableFinalContent || finalDisplayableFinalContent.trim() === '')) {
return (
<div className={`${className} flex items-center gap-2 sm:gap-4 p-4 sm:p-6 bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl sm:rounded-2xl`}>
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg">
<Loader2 className="animate-spin text-white" size={styleState.isMobile ? 16 : 20} />
</div>
<div className="flex flex-col">
<Typography.Text strong className="text-gray-800 text-sm sm:text-base">
{t('正在思考...')}
</Typography.Text>
<Typography.Text className="text-gray-500 text-xs sm:text-sm">
AI 正在分析您的问题
</Typography.Text>
</div>
</div>
);
}
return (
<div className={className}>
{/* 渲染推理内容 */}
{message.role === 'assistant' && finalExtractedThinkingContent && (
<div className="bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50 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/40 hover:to-purple-50/60 transition-all"
onClick={() => onToggleReasoningExpansion(message.id)}
>
<div className="flex items-center gap-2 sm:gap-4">
<div className="w-6 h-6 sm:w-8 sm:h-8 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg">
<Brain className="text-white" size={styleState.isMobile ? 12 : 16} />
</div>
<div className="flex flex-col">
<Typography.Text strong className="text-gray-800 text-sm sm:text-base">
{headerText}
</Typography.Text>
{thinkingSource && (
<Typography.Text className="text-gray-500 text-xs mt-0.5 hidden sm:block">
来源: {thinkingSource}
</Typography.Text>
)}
</div>
</div>
<div className="flex items-center gap-2 sm:gap-3">
{isThinkingStatus && (
<div className="flex items-center gap-1 sm:gap-2">
<Loader2 className="animate-spin text-purple-500" size={styleState.isMobile ? 14 : 18} />
<Typography.Text className="text-purple-600 text-xs sm:text-sm font-medium">
思考中
</Typography.Text>
</div>
)}
{!isThinkingStatus && (
<div className="w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-purple-100 flex items-center justify-center">
{message.isReasoningExpanded ?
<ChevronUp size={styleState.isMobile ? 12 : 16} className="text-purple-600" /> :
<ChevronRight size={styleState.isMobile ? 12 : 16} className="text-purple-600" />
}
</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`}
>
{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 max-h-50 overflow-y-auto">
<div className="prose prose-xs sm:prose-sm prose-purple max-w-none text-xs sm:text-sm">
<MarkdownRender raw={finalExtractedThinkingContent} />
</div>
</div>
</div>
)}
</div>
</div>
)}
{/* 渲染消息内容 */}
{(() => {
// 处理多模态内容(文本+图片)
if (Array.isArray(message.content)) {
const textContent = message.content.find(item => item.type === 'text');
const imageContents = message.content.filter(item => item.type === 'image_url');
return (
<div>
{/* 显示图片 */}
{imageContents.length > 0 && (
<div className="mb-3 space-y-2">
{imageContents.map((imgItem, index) => (
<div key={index} className="max-w-sm">
<img
src={imgItem.image_url.url}
alt={`用户上传的图片 ${index + 1}`}
className="rounded-lg max-w-full h-auto shadow-sm border"
style={{ maxHeight: '300px' }}
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'block';
}}
/>
<div
className="text-red-500 text-sm p-2 bg-red-50 rounded-lg border border-red-200"
style={{ display: 'none' }}
>
图片加载失败: {imgItem.image_url.url}
</div>
</div>
))}
</div>
)}
{/* 显示文本内容 */}
{textContent && textContent.text && textContent.text.trim() !== '' && (
<div className="prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm">
<MarkdownRender raw={textContent.text} />
</div>
)}
</div>
);
}
// 处理纯文本内容或助手回复
if (typeof message.content === 'string') {
if (message.role === 'assistant') {
// 助手回复使用处理后的内容
if (finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') {
return (
<div className="prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm">
<MarkdownRender raw={finalDisplayableFinalContent} />
</div>
);
}
} else {
// 用户文本消息
return (
<div className="prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm">
<MarkdownRender raw={message.content} />
</div>
);
}
}
return null;
})()}
</div>
);
};
export default MessageContent;
\ No newline at end of file
import React from 'react';
import {
Input,
Slider,
Typography,
Button,
Tag,
} from '@douyinfe/semi-ui';
import {
Hash,
Thermometer,
Target,
Repeat,
Ban,
Shuffle,
Check,
X,
} from 'lucide-react';
const ParameterControl = ({
inputs,
parameterEnabled,
onInputChange,
onParameterToggle,
}) => {
return (
<>
{/* Temperature */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.temperature ? '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" />
<Typography.Text strong className="text-sm">
Temperature
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.temperature}
</Tag>
</div>
<Button
theme={parameterEnabled.temperature ? 'solid' : 'borderless'}
type={parameterEnabled.temperature ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.temperature ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('temperature')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
控制输出的随机性和创造性
</Typography.Text>
<Slider
step={0.1}
min={0.1}
max={1}
value={inputs.temperature}
onChange={(value) => onInputChange('temperature', value)}
className="mt-2"
disabled={!parameterEnabled.temperature}
/>
</div>
{/* Top P */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.top_p ? '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" />
<Typography.Text strong className="text-sm">
Top P
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.top_p}
</Tag>
</div>
<Button
theme={parameterEnabled.top_p ? 'solid' : 'borderless'}
type={parameterEnabled.top_p ? 'primary' : 'tertiary'}
size="small"
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"
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
核采样,控制词汇选择的多样性
</Typography.Text>
<Slider
step={0.1}
min={0.1}
max={1}
value={inputs.top_p}
onChange={(value) => onInputChange('top_p', value)}
className="mt-2"
disabled={!parameterEnabled.top_p}
/>
</div>
{/* Frequency Penalty */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.frequency_penalty ? '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" />
<Typography.Text strong className="text-sm">
Frequency Penalty
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.frequency_penalty}
</Tag>
</div>
<Button
theme={parameterEnabled.frequency_penalty ? 'solid' : 'borderless'}
type={parameterEnabled.frequency_penalty ? 'primary' : 'tertiary'}
size="small"
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"
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
频率惩罚,减少重复词汇的出现
</Typography.Text>
<Slider
step={0.1}
min={-2}
max={2}
value={inputs.frequency_penalty}
onChange={(value) => onInputChange('frequency_penalty', value)}
className="mt-2"
disabled={!parameterEnabled.frequency_penalty}
/>
</div>
{/* Presence Penalty */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.presence_penalty ? '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" />
<Typography.Text strong className="text-sm">
Presence Penalty
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.presence_penalty}
</Tag>
</div>
<Button
theme={parameterEnabled.presence_penalty ? 'solid' : 'borderless'}
type={parameterEnabled.presence_penalty ? 'primary' : 'tertiary'}
size="small"
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"
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
存在惩罚,鼓励讨论新话题
</Typography.Text>
<Slider
step={0.1}
min={-2}
max={2}
value={inputs.presence_penalty}
onChange={(value) => onInputChange('presence_penalty', value)}
className="mt-2"
disabled={!parameterEnabled.presence_penalty}
/>
</div>
{/* MaxTokens */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.max_tokens ? '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" />
<Typography.Text strong className="text-sm">
Max Tokens
</Typography.Text>
</div>
<Button
theme={parameterEnabled.max_tokens ? 'solid' : 'borderless'}
type={parameterEnabled.max_tokens ? 'primary' : 'tertiary'}
size="small"
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"
/>
</div>
<Input
placeholder='MaxTokens'
name='max_tokens'
required
autoComplete='new-password'
defaultValue={0}
value={inputs.max_tokens}
onChange={(value) => onInputChange('max_tokens', value)}
className="!rounded-lg"
disabled={!parameterEnabled.max_tokens}
/>
</div>
{/* Seed */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.seed ? '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" />
<Typography.Text strong className="text-sm">
Seed
</Typography.Text>
<Typography.Text className="text-xs text-gray-400">
(可选,用于复现结果)
</Typography.Text>
</div>
<Button
theme={parameterEnabled.seed ? 'solid' : 'borderless'}
type={parameterEnabled.seed ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.seed ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('seed')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
/>
</div>
<Input
placeholder='随机种子 (留空为随机)'
name='seed'
autoComplete='new-password'
value={inputs.seed || ''}
onChange={(value) => onInputChange('seed', value === '' ? null : value)}
className="!rounded-lg"
disabled={!parameterEnabled.seed}
/>
</div>
</>
);
};
export default ParameterControl;
\ No newline at end of file
import React from 'react';
import {
Card,
Select,
TextArea,
Typography,
Button,
Switch,
Divider,
} from '@douyinfe/semi-ui';
import {
Sparkles,
Users,
Type,
ToggleLeft,
X,
} 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';
const SettingsPanel = ({
inputs,
parameterEnabled,
models,
groups,
systemPrompt,
styleState,
showDebugPanel,
onInputChange,
onParameterToggle,
onSystemPromptChange,
onCloseSettings,
onConfigImport,
onConfigReset,
}) => {
const { t } = useTranslation();
const currentConfig = {
inputs,
parameterEnabled,
systemPrompt,
showDebugPanel,
};
return (
<Card
className={`!rounded-2xl h-full flex flex-col ${styleState.isMobile ? 'rounded-none border-none shadow-none' : ''}`}
bodyStyle={{
padding: styleState.isMobile ? '24px' : '24px 24px 16px 24px',
height: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
{styleState.isMobile && (
<div className="flex items-center justify-between mb-4">
{/* 移动端显示配置管理下拉菜单和关闭按钮 */}
<ConfigManager
currentConfig={currentConfig}
onConfigImport={onConfigImport}
onConfigReset={onConfigReset}
styleState={styleState}
/>
<Button
icon={<X size={16} />}
onClick={onCloseSettings}
theme="borderless"
type="tertiary"
size="small"
className="!rounded-lg !text-gray-600 hover:!text-red-600 hover:!bg-red-50"
/>
</div>
)}
<div className="space-y-6 overflow-y-auto flex-1 pr-2 model-settings-scroll">
{/* 分组选择 */}
<div>
<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>
</div>
<Select
placeholder={t('请选择分组')}
name='group'
required
selection
onChange={(value) => onInputChange('group', value)}
value={inputs.group}
autoComplete='new-password'
optionList={groups}
renderOptionItem={renderGroupOption}
style={{ width: '100%' }}
className="!rounded-lg"
/>
</div>
{/* 模型选择 */}
<div>
<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>
</div>
<Select
placeholder={t('请选择模型')}
name='model'
required
selection
searchPosition='dropdown'
filter
onChange={(value) => onInputChange('model', value)}
value={inputs.model}
autoComplete='new-password'
optionList={models}
className="!rounded-lg"
/>
</div>
{/* 图片URL输入 */}
<ImageUrlInput
imageUrls={inputs.imageUrls}
onImageUrlsChange={(urls) => onInputChange('imageUrls', urls)}
/>
{/* 参数控制组件 */}
<ParameterControl
inputs={inputs}
parameterEnabled={parameterEnabled}
onInputChange={onInputChange}
onParameterToggle={onParameterToggle}
/>
{/* 流式输出开关 */}
<div>
<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>
</div>
<Switch
checked={inputs.stream}
onChange={(checked) => onInputChange('stream', checked)}
checkedText="开"
uncheckedText="关"
size="small"
/>
</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>
{/* 桌面端的配置管理放在底部 */}
{!styleState.isMobile && (
<div className="flex-shrink-0 mt-4 pt-3">
<ConfigManager
currentConfig={currentConfig}
onConfigImport={onConfigImport}
onConfigReset={onConfigReset}
styleState={styleState}
/>
</div>
)}
</Card>
);
};
export default SettingsPanel;
\ No newline at end of file
const STORAGE_KEY = 'playground_config';
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: [],
},
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,
};
/**
* 保存配置到 localStorage
* @param {Object} config - 要保存的配置对象
*/
export const saveConfig = (config) => {
try {
const configToSave = {
...config,
timestamp: new Date().toISOString(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(configToSave));
console.log('配置已保存到本地存储');
} catch (error) {
console.error('保存配置失败:', error);
}
};
/**
* 从 localStorage 加载配置
* @returns {Object} 配置对象,如果不存在则返回默认配置
*/
export const loadConfig = () => {
try {
const savedConfig = localStorage.getItem(STORAGE_KEY);
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig);
const mergedConfig = {
inputs: {
...DEFAULT_CONFIG.inputs,
...parsedConfig.inputs,
},
parameterEnabled: {
...DEFAULT_CONFIG.parameterEnabled,
...parsedConfig.parameterEnabled,
},
systemPrompt: parsedConfig.systemPrompt || DEFAULT_CONFIG.systemPrompt,
showDebugPanel: parsedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
};
console.log('配置已从本地存储加载');
return mergedConfig;
}
} catch (error) {
console.error('加载配置失败:', error);
}
console.log('使用默认配置');
return DEFAULT_CONFIG;
};
/**
* 清除保存的配置
*/
export const clearConfig = () => {
try {
localStorage.removeItem(STORAGE_KEY);
console.log('配置已清除');
} catch (error) {
console.error('清除配置失败:', error);
}
};
/**
* 检查是否有保存的配置
* @returns {boolean} 是否存在保存的配置
*/
export const hasStoredConfig = () => {
try {
return localStorage.getItem(STORAGE_KEY) !== null;
} catch (error) {
console.error('检查配置失败:', error);
return false;
}
};
/**
* 获取配置的最后保存时间
* @returns {string|null} 最后保存时间的 ISO 字符串
*/
export const getConfigTimestamp = () => {
try {
const savedConfig = localStorage.getItem(STORAGE_KEY);
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig);
return parsedConfig.timestamp || null;
}
} catch (error) {
console.error('获取配置时间戳失败:', error);
}
return null;
};
/**
* 导出配置为 JSON 文件
* @param {Object} config - 要导出的配置
*/
export const exportConfig = (config) => {
try {
const configToExport = {
...config,
exportTime: new Date().toISOString(),
version: '1.0',
};
const dataStr = JSON.stringify(configToExport, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(dataBlob);
link.download = `playground-config-${new Date().toISOString().split('T')[0]}.json`;
link.click();
URL.revokeObjectURL(link.href);
console.log('配置已导出');
} catch (error) {
console.error('导出配置失败:', error);
}
};
/**
* 从文件导入配置
* @param {File} file - 包含配置的 JSON 文件
* @returns {Promise<Object>} 导入的配置对象
*/
export const importConfig = (file) => {
return new Promise((resolve, reject) => {
try {
const reader = new FileReader();
reader.onload = (e) => {
try {
const importedConfig = JSON.parse(e.target.result);
if (importedConfig.inputs && importedConfig.parameterEnabled) {
console.log('配置已从文件导入');
resolve(importedConfig);
} else {
reject(new Error('配置文件格式无效'));
}
} catch (parseError) {
reject(new Error('解析配置文件失败: ' + parseError.message));
}
};
reader.onerror = () => reject(new Error('读取文件失败'));
reader.readAsText(file);
} catch (error) {
reject(new Error('导入配置失败: ' + error.message));
}
});
};
\ No newline at end of file
export { default as SettingsPanel } from './SettingsPanel';
export { default as ChatArea } from './ChatArea';
export { default as DebugPanel } from './DebugPanel';
export { default as MessageContent } from './MessageContent';
export { default as MessageActions } from './MessageActions';
export { default as CustomInputRender } from './CustomInputRender';
export { default as ParameterControl } from './ParameterControl';
export { default as ImageUrlInput } from './ImageUrlInput';
export { default as FloatingButtons } from './FloatingButtons';
export { default as ConfigManager } from './ConfigManager';
export {
saveConfig,
loadConfig,
clearConfig,
hasStoredConfig,
getConfigTimestamp,
exportConfig,
importConfig,
} from './configStorage';
\ No newline at end of file
......@@ -6,60 +6,26 @@ import {
getUserIdFromLocalStorage,
showError,
getLogo,
isMobile,
} from '../../helpers/index.js';
import {
Card,
Chat,
Input,
Layout,
Select,
Slider,
TextArea,
Typography,
Button,
MarkdownRender,
Tag,
Tabs,
TabPane,
Toast,
Tooltip,
Modal,
} from '@douyinfe/semi-ui';
import { SSE } from 'sse';
import {
Settings,
Sparkles,
ChevronRight,
ChevronUp,
Brain,
Zap,
MessageSquare,
SlidersHorizontal,
Hash,
Thermometer,
Type,
Users,
Loader2,
Target,
Repeat,
Ban,
Shuffle,
ToggleLeft,
Code,
Eye,
EyeOff,
FileText,
Clock,
Check,
X,
Copy,
RefreshCw,
Trash2,
} from 'lucide-react';
import { StyleContext } from '../../context/Style/index.js';
import { useTranslation } from 'react-i18next';
import { renderGroupOption, truncateText, stringToColor } from '../../helpers/render.js';
import { IconSend } from '@douyinfe/semi-icons';
import { stringToColor } from '../../helpers/render.js';
import SettingsPanel from '../../components/playground/SettingsPanel';
import ChatArea from '../../components/playground/ChatArea';
import DebugPanel from '../../components/playground/DebugPanel';
import MessageContent from '../../components/playground/MessageContent';
import MessageActions from '../../components/playground/MessageActions';
import FloatingButtons from '../../components/playground/FloatingButtons';
import { saveConfig, loadConfig } from '../../components/playground/configStorage';
let id = 4;
function getId() {
......@@ -118,36 +84,19 @@ const Playground = () => {
},
];
const defaultModel = 'deepseek-r1';
const [inputs, setInputs] = useState({
model: defaultModel,
group: '',
max_tokens: 0,
temperature: 0,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
seed: null,
stream: true,
});
const [parameterEnabled, setParameterEnabled] = useState({
max_tokens: true,
temperature: true,
top_p: false,
frequency_penalty: false,
presence_penalty: false,
seed: false,
});
const savedConfig = loadConfig();
const [inputs, setInputs] = useState(savedConfig.inputs);
const [parameterEnabled, setParameterEnabled] = useState(savedConfig.parameterEnabled);
const [systemPrompt, setSystemPrompt] = useState(savedConfig.systemPrompt);
const [showDebugPanel, setShowDebugPanel] = useState(savedConfig.showDebugPanel);
const [searchParams, setSearchParams] = useSearchParams();
const [status, setStatus] = useState({});
const [systemPrompt, setSystemPrompt] = useState(
'You are a helpful assistant. You can help me by answering my questions. You can also ask me questions.',
);
const [message, setMessage] = useState(defaultMessage);
const [models, setModels] = useState([]);
const [groups, setGroups] = useState([]);
const [showSettings, setShowSettings] = useState(true);
const [showDebugPanel, setShowDebugPanel] = useState(true);
const [showSettings, setShowSettings] = useState(false);
const [debugData, setDebugData] = useState({
request: null,
response: null,
......@@ -156,6 +105,35 @@ const Playground = () => {
const [activeDebugTab, setActiveDebugTab] = useState('request');
const [styleState, styleDispatch] = useContext(StyleContext);
const sseSourceRef = useRef(null);
const chatRef = useRef(null);
const saveConfigTimeoutRef = useRef(null);
const debouncedSaveConfig = useCallback(() => {
if (saveConfigTimeoutRef.current) {
clearTimeout(saveConfigTimeoutRef.current);
}
saveConfigTimeoutRef.current = setTimeout(() => {
const configToSave = {
inputs,
parameterEnabled,
systemPrompt,
showDebugPanel,
};
saveConfig(configToSave);
}, 1000);
}, [inputs, parameterEnabled, systemPrompt, showDebugPanel]);
useEffect(() => {
debouncedSaveConfig();
return () => {
if (saveConfigTimeoutRef.current) {
clearTimeout(saveConfigTimeoutRef.current);
}
};
}, [debouncedSaveConfig]);
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
......@@ -168,6 +146,38 @@ const Playground = () => {
}));
};
const handleConfigImport = useCallback((importedConfig) => {
if (importedConfig.inputs) {
setInputs(prev => ({
...prev,
...importedConfig.inputs,
}));
}
if (importedConfig.parameterEnabled) {
setParameterEnabled(prev => ({
...prev,
...importedConfig.parameterEnabled,
}));
}
if (importedConfig.systemPrompt) {
setSystemPrompt(importedConfig.systemPrompt);
}
if (typeof importedConfig.showDebugPanel === 'boolean') {
setShowDebugPanel(importedConfig.showDebugPanel);
}
}, []);
const handleConfigReset = useCallback(() => {
const defaultConfig = loadConfig();
setInputs(defaultConfig.inputs);
setParameterEnabled(defaultConfig.parameterEnabled);
setSystemPrompt(defaultConfig.systemPrompt);
setShowDebugPanel(defaultConfig.showDebugPanel);
}, []);
useEffect(() => {
if (searchParams.get('expired')) {
showError(t('未登录或登录已过期,请重新登录!'));
......@@ -181,6 +191,23 @@ const Playground = () => {
loadGroups();
}, [searchParams, t]);
useEffect(() => {
const handleResize = () => {
styleDispatch({
type: 'set_is_mobile',
payload: isMobile(),
});
};
handleResize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [styleDispatch]);
const loadModels = async () => {
let res = await API.get(`/api/user/models`);
const { success, message, data } = res.data;
......@@ -190,9 +217,10 @@ const Playground = () => {
value: model,
}));
setModels(localModelOptions);
const hasDefault = localModelOptions.some(option => option.value === defaultModel);
if (!hasDefault && localModelOptions.length > 0) {
setInputs((inputs) => ({ ...inputs, model: localModelOptions[0].value }));
const hasCurrentModel = localModelOptions.some(option => option.value === inputs.model);
if (!hasCurrentModel && localModelOptions.length > 0) {
handleInputChange('model', localModelOptions[0].value);
}
} else {
showError(t(message));
......@@ -204,7 +232,7 @@ const Playground = () => {
const { success, message, data } = res.data;
if (success) {
let localGroupOptions = Object.entries(data).map(([group, info]) => ({
label: truncateText(info.desc, '50%'),
label: info.desc.length > 20 ? info.desc.substring(0, 20) + '...' : info.desc,
value: group,
ratio: info.ratio,
fullLabel: info.desc,
......@@ -239,7 +267,11 @@ const Playground = () => {
}
setGroups(localGroupOptions);
handleInputChange('group', localGroupOptions[0].value);
const hasCurrentGroup = localGroupOptions.some(option => option.value === inputs.group);
if (!hasCurrentGroup) {
handleInputChange('group', localGroupOptions[0].value);
}
} else {
showError(t(message));
}
......@@ -533,11 +565,31 @@ const Playground = () => {
(content, attachment) => {
console.log('attachment: ', attachment);
setMessage((prevMessage) => {
let messageContent;
const validImageUrls = inputs.imageUrls.filter(url => url.trim() !== '');
if (validImageUrls.length > 0) {
messageContent = [
{
type: 'text',
text: content,
},
...validImageUrls.map(url => ({
type: 'image_url',
image_url: {
url: url.trim(),
},
})),
];
} else {
messageContent = content;
}
const newMessage = [
...prevMessage,
{
role: 'user',
content: content,
content: messageContent,
createAt: Date.now(),
id: getId(),
},
......@@ -603,7 +655,7 @@ const Playground = () => {
return newMessage;
});
},
[getSystemMessage, inputs, setMessage],
[getSystemMessage, inputs, setMessage, parameterEnabled],
);
const completeMessage = useCallback((status = 'complete') => {
......@@ -860,762 +912,159 @@ const Playground = () => {
}
}, [setMessage]);
const DebugToggle = () => {
return (
<Button
icon={showDebugPanel ? <EyeOff size={14} /> : <Eye size={14} />}
onClick={() => setShowDebugPanel(!showDebugPanel)}
theme="borderless"
type="tertiary"
size="small"
className="!rounded-lg !text-gray-600 hover:!text-purple-600 hover:!bg-purple-50"
>
{showDebugPanel ? t('隐藏调试') : t('显示调试')}
</Button>
);
};
const SettingsToggle = () => {
if (!styleState.isMobile) return null;
return (
<Button
icon={<Settings size={16} />}
style={{
position: 'absolute',
left: showSettings ? -10 : -20,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 1000,
width: 40,
height: 40,
borderRadius: '0 20px 20px 0',
padding: 0,
boxShadow: '2px 0 8px rgba(0, 0, 0, 0.15)',
}}
onClick={() => setShowSettings(!showSettings)}
theme='solid'
type='primary'
/>
const toggleReasoningExpansion = (messageId) => {
setMessage(prevMessages =>
prevMessages.map(msg =>
msg.id === messageId && msg.role === 'assistant'
? { ...msg, isReasoningExpanded: !msg.isReasoningExpanded }
: msg
)
);
};
function CustomInputRender(props) {
const { detailProps } = props;
const { clearContextNode, uploadNode, inputNode, sendNode, onClick } =
detailProps;
return (
<div className="p-4">
<div
className="flex items-end gap-3 p-4 bg-gray-50 rounded-2xl shadow-sm hover:shadow-md transition-shadow"
style={{ border: '1px solid var(--semi-color-border)' }}
onClick={onClick}
>
<div className="flex-1">
{inputNode}
</div>
<Button
theme="solid"
type="primary"
className="!rounded-lg !bg-purple-500 hover:!bg-purple-600 flex-shrink-0"
icon={<IconSend />}
>
{t('发送')}
</Button>
</div>
</div>
);
}
const renderInputArea = useCallback((props) => {
return <CustomInputRender {...props} />;
}, []);
const renderChatBoxAction = useCallback((props) => {
const { message } = props;
const isLoading = message.status === 'loading' || message.status === 'incomplete';
return (
<div className="flex items-center gap-0.5">
{!isLoading && (
<Tooltip content={t('重试')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<RefreshCw size={14} />}
onClick={() => handleMessageReset(message)}
className="!rounded-md !text-gray-400 hover:!text-blue-600 hover:!bg-blue-50 !w-7 !h-7 !p-0 transition-all"
aria-label={t('重试')}
/>
</Tooltip>
)}
{message.content && (
<Tooltip content={t('复制')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Copy size={14} />}
onClick={() => handleMessageCopy(message)}
className="!rounded-md !text-gray-400 hover:!text-green-600 hover:!bg-green-50 !w-7 !h-7 !p-0 transition-all"
aria-label={t('复制')}
/>
</Tooltip>
)}
{!isLoading && (
<Tooltip content={t('删除')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Trash2 size={14} />}
onClick={() => handleMessageDelete(message)}
className="!rounded-md !text-gray-400 hover:!text-red-600 hover:!bg-red-50 !w-7 !h-7 !p-0 transition-all"
aria-label={t('删除')}
/>
</Tooltip>
)}
</div>
);
}, [handleMessageReset, handleMessageCopy, handleMessageDelete, t]);
const renderCustomChatContent = useCallback(
({ message, className }) => {
if (message.status === 'error') {
return (
<div className={`${className} flex items-center p-4 bg-red-50 rounded-xl`}>
<Typography.Text type="danger" className="text-sm">
{message.content || t('请求发生错误')}
</Typography.Text>
</div>
);
}
const toggleReasoningExpansion = (messageId) => {
setMessage(prevMessages =>
prevMessages.map(msg =>
msg.id === messageId && msg.role === 'assistant'
? { ...msg, isReasoningExpanded: !msg.isReasoningExpanded }
: msg
)
);
};
const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
let currentExtractedThinkingContent = null;
let currentDisplayableFinalContent = message.content || "";
let thinkingSource = null;
if (message.role === 'assistant') {
let baseContentForDisplay = message.content || "";
let combinedThinkingContent = "";
if (message.reasoningContent) {
combinedThinkingContent = message.reasoningContent;
thinkingSource = 'reasoningContent';
}
if (baseContentForDisplay.includes('<think>')) {
const thinkTagRegex = /<think>([\s\S]*?)<\/think>/g;
let match;
let thoughtsFromPairedTags = [];
let replyParts = [];
let lastIndex = 0;
while ((match = thinkTagRegex.exec(baseContentForDisplay)) !== null) {
replyParts.push(baseContentForDisplay.substring(lastIndex, match.index));
thoughtsFromPairedTags.push(match[1]);
lastIndex = match.index + match[0].length;
}
replyParts.push(baseContentForDisplay.substring(lastIndex));
if (thoughtsFromPairedTags.length > 0) {
const pairedThoughtsStr = thoughtsFromPairedTags.join('\n\n---\n\n');
if (combinedThinkingContent) {
combinedThinkingContent += '\n\n---\n\n' + pairedThoughtsStr;
} else {
combinedThinkingContent = pairedThoughtsStr;
}
thinkingSource = thinkingSource ? thinkingSource + ' & <think> tags' : '<think> tags';
}
baseContentForDisplay = replyParts.join('');
}
if (isThinkingStatus) {
const lastOpenThinkIndex = baseContentForDisplay.lastIndexOf('<think>');
if (lastOpenThinkIndex !== -1) {
const fragmentAfterLastOpen = baseContentForDisplay.substring(lastOpenThinkIndex);
if (!fragmentAfterLastOpen.includes('</think>')) {
const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
if (unclosedThought) {
if (combinedThinkingContent) {
combinedThinkingContent += '\n\n---\n\n' + unclosedThought;
} else {
combinedThinkingContent = unclosedThought;
}
thinkingSource = thinkingSource ? thinkingSource + ' + streaming <think>' : 'streaming <think>';
}
baseContentForDisplay = baseContentForDisplay.substring(0, lastOpenThinkIndex);
}
}
}
currentExtractedThinkingContent = combinedThinkingContent || null;
currentDisplayableFinalContent = baseContentForDisplay.replace(/<\/?think>/g, '').trim();
}
const headerText = isThinkingStatus ? t('思考中...') : t('思考过程');
const finalExtractedThinkingContent = currentExtractedThinkingContent;
const finalDisplayableFinalContent = currentDisplayableFinalContent;
if (message.role === 'assistant' &&
isThinkingStatus &&
!finalExtractedThinkingContent &&
(!finalDisplayableFinalContent || finalDisplayableFinalContent.trim() === '')) {
return (
<div className={`${className} flex items-center gap-4 p-6 bg-gradient-to-r from-purple-50 to-indigo-50 rounded-2xl`}>
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg">
<Loader2 className="animate-spin text-white" size={20} />
</div>
<div className="flex flex-col">
<Typography.Text strong className="text-gray-800 text-base">
{t('正在思考...')}
</Typography.Text>
<Typography.Text className="text-gray-500 text-sm">
AI 正在分析您的问题
</Typography.Text>
</div>
</div>
);
}
return (
<div className={className}>
{message.role === 'assistant' && finalExtractedThinkingContent && (
<div className="bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50 rounded-2xl mb-4 overflow-hidden shadow-sm backdrop-blur-sm">
<div
className="flex items-center justify-between p-5 cursor-pointer hover:bg-gradient-to-r hover:from-white/40 hover:to-purple-50/60 transition-all"
onClick={() => toggleReasoningExpansion(message.id)}
>
<div className="flex items-center gap-4">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg">
<Brain className="text-white" size={16} />
</div>
<div className="flex flex-col">
<Typography.Text strong className="text-gray-800 text-base">
{headerText}
</Typography.Text>
{thinkingSource && (
<Typography.Text className="text-gray-500 text-xs mt-0.5">
来源: {thinkingSource}
</Typography.Text>
)}
</div>
</div>
<div className="flex items-center gap-3">
{isThinkingStatus && (
<div className="flex items-center gap-2">
<Loader2 className="animate-spin text-purple-500" size={18} />
<Typography.Text className="text-purple-600 text-sm font-medium">
思考中
</Typography.Text>
</div>
)}
{!isThinkingStatus && (
<div className="w-6 h-6 rounded-full bg-purple-100 flex items-center justify-center">
{message.isReasoningExpanded ?
<ChevronUp size={16} className="text-purple-600" /> :
<ChevronRight size={16} className="text-purple-600" />
}
</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`}
>
{message.isReasoningExpanded && (
<div className="p-5 pt-4">
<div className="bg-white/70 backdrop-blur-sm rounded-xl p-4 shadow-inner overflow-x-auto max-h-50 overflow-y-auto">
<div className="prose prose-sm prose-purple max-w-none">
<MarkdownRender raw={finalExtractedThinkingContent} />
</div>
</div>
</div>
)}
</div>
</div>
)}
{(finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') && (
<div className="prose prose-sm prose-gray max-w-none overflow-x-auto">
<MarkdownRender raw={finalDisplayableFinalContent} />
</div>
)}
</div>
<MessageContent
message={message}
className={className}
styleState={styleState}
onToggleReasoningExpansion={toggleReasoningExpansion}
/>
);
},
[t, setMessage],
[styleState],
);
const renderChatBoxAction = useCallback((props) => {
const { message: currentMessage } = props;
const isAnyMessageGenerating = message.some(msg => msg.status === 'loading' || msg.status === 'incomplete');
return (
<MessageActions
message={currentMessage}
styleState={styleState}
onMessageReset={handleMessageReset}
onMessageCopy={handleMessageCopy}
onMessageDelete={handleMessageDelete}
isAnyMessageGenerating={isAnyMessageGenerating}
/>
);
}, [handleMessageReset, handleMessageCopy, handleMessageDelete, styleState, message]);
return (
<div className="min-h-screen bg-gray-50">
<Layout style={{ height: '100vh', background: 'transparent' }}>
<div className="h-full bg-gray-50">
<Layout style={{ height: '100%', background: 'transparent' }} className="flex flex-col md:flex-row">
{(showSettings || !styleState.isMobile) && (
<Layout.Sider
style={{
background: 'transparent',
borderRight: 'none',
flexShrink: 0,
minWidth: 320,
maxWidth: 320,
height: 'calc(100vh - 100px)',
minWidth: styleState.isMobile ? '100%' : 320,
maxWidth: styleState.isMobile ? '100%' : 320,
height: styleState.isMobile ? 'auto' : 'calc(100vh - 100px)',
overflow: 'auto',
position: styleState.isMobile ? 'fixed' : 'relative',
zIndex: styleState.isMobile ? 1000 : 1,
width: '100%',
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
width={320}
width={styleState.isMobile ? '100%' : 320}
className={styleState.isMobile ? 'bg-white shadow-lg' : ''}
>
<Card className="!rounded-2xl h-full flex flex-col" bodyStyle={{ padding: '24px', height: '100%', display: 'flex', flexDirection: 'column' }}>
<div className="flex items-center justify-between mb-6 flex-shrink-0">
<div className="flex items-center">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-purple-500 to-blue-500 flex items-center justify-center mr-3">
<SlidersHorizontal size={20} className="text-white" />
</div>
<Typography.Title heading={5} className="mb-0">
{t('模型设置')}
</Typography.Title>
</div>
<DebugToggle />
</div>
<div className="space-y-6 overflow-y-auto flex-1 pr-2 model-settings-scroll">
{/* 分组选择 */}
<div>
<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>
</div>
<Select
placeholder={t('请选择分组')}
name='group'
required
selection
onChange={(value) => handleInputChange('group', value)}
value={inputs.group}
autoComplete='new-password'
optionList={groups}
renderOptionItem={renderGroupOption}
style={{ width: '100%' }}
className="!rounded-lg"
/>
</div>
{/* 模型选择 */}
<div>
<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>
</div>
<Select
placeholder={t('请选择模型')}
name='model'
required
selection
searchPosition='dropdown'
filter
onChange={(value) => handleInputChange('model', value)}
value={inputs.model}
autoComplete='new-password'
optionList={models}
className="!rounded-lg"
/>
</div>
{/* Temperature */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.temperature ? '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" />
<Typography.Text strong className="text-sm">
Temperature
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.temperature}
</Tag>
</div>
<Button
theme={parameterEnabled.temperature ? 'solid' : 'borderless'}
type={parameterEnabled.temperature ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.temperature ? <Check size={10} /> : <X size={10} />}
onClick={() => handleParameterToggle('temperature')}
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0"
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
控制输出的随机性和创造性
</Typography.Text>
<Slider
step={0.1}
min={0.1}
max={1}
value={inputs.temperature}
onChange={(value) => handleInputChange('temperature', value)}
className="mt-2"
disabled={!parameterEnabled.temperature}
/>
</div>
{/* Top P */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.top_p ? '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" />
<Typography.Text strong className="text-sm">
Top P
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.top_p}
</Tag>
</div>
<Button
theme={parameterEnabled.top_p ? 'solid' : 'borderless'}
type={parameterEnabled.top_p ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.top_p ? <Check size={10} /> : <X size={10} />}
onClick={() => handleParameterToggle('top_p')}
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0"
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
核采样,控制词汇选择的多样性
</Typography.Text>
<Slider
step={0.1}
min={0.1}
max={1}
value={inputs.top_p}
onChange={(value) => handleInputChange('top_p', value)}
className="mt-2"
disabled={!parameterEnabled.top_p}
/>
</div>
{/* Frequency Penalty */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.frequency_penalty ? '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" />
<Typography.Text strong className="text-sm">
Frequency Penalty
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.frequency_penalty}
</Tag>
</div>
<Button
theme={parameterEnabled.frequency_penalty ? 'solid' : 'borderless'}
type={parameterEnabled.frequency_penalty ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.frequency_penalty ? <Check size={10} /> : <X size={10} />}
onClick={() => handleParameterToggle('frequency_penalty')}
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0"
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
频率惩罚,减少重复词汇的出现
</Typography.Text>
<Slider
step={0.1}
min={-2}
max={2}
value={inputs.frequency_penalty}
onChange={(value) => handleInputChange('frequency_penalty', value)}
className="mt-2"
disabled={!parameterEnabled.frequency_penalty}
/>
</div>
{/* Presence Penalty */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.presence_penalty ? '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" />
<Typography.Text strong className="text-sm">
Presence Penalty
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.presence_penalty}
</Tag>
</div>
<Button
theme={parameterEnabled.presence_penalty ? 'solid' : 'borderless'}
type={parameterEnabled.presence_penalty ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.presence_penalty ? <Check size={10} /> : <X size={10} />}
onClick={() => handleParameterToggle('presence_penalty')}
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0"
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
存在惩罚,鼓励讨论新话题
</Typography.Text>
<Slider
step={0.1}
min={-2}
max={2}
value={inputs.presence_penalty}
onChange={(value) => handleInputChange('presence_penalty', value)}
className="mt-2"
disabled={!parameterEnabled.presence_penalty}
/>
</div>
{/* MaxTokens */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.max_tokens ? '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" />
<Typography.Text strong className="text-sm">
Max Tokens
</Typography.Text>
</div>
<Button
theme={parameterEnabled.max_tokens ? 'solid' : 'borderless'}
type={parameterEnabled.max_tokens ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.max_tokens ? <Check size={10} /> : <X size={10} />}
onClick={() => handleParameterToggle('max_tokens')}
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0"
/>
</div>
<Input
placeholder='MaxTokens'
name='max_tokens'
required
autoComplete='new-password'
defaultValue={0}
value={inputs.max_tokens}
onChange={(value) => handleInputChange('max_tokens', value)}
className="!rounded-lg"
disabled={!parameterEnabled.max_tokens}
/>
</div>
{/* Seed */}
<div className={`transition-opacity duration-200 ${!parameterEnabled.seed ? '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" />
<Typography.Text strong className="text-sm">
Seed
</Typography.Text>
<Typography.Text className="text-xs text-gray-400">
(可选,用于复现结果)
</Typography.Text>
</div>
<Button
theme={parameterEnabled.seed ? 'solid' : 'borderless'}
type={parameterEnabled.seed ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.seed ? <Check size={10} /> : <X size={10} />}
onClick={() => handleParameterToggle('seed')}
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0"
/>
</div>
<Input
placeholder='随机种子 (留空为随机)'
name='seed'
autoComplete='new-password'
value={inputs.seed || ''}
onChange={(value) => handleInputChange('seed', value === '' ? null : value)}
className="!rounded-lg"
disabled={!parameterEnabled.seed}
/>
</div>
{/* Stream Toggle */}
<div>
<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>
</div>
<Button
theme={inputs.stream ? 'solid' : 'borderless'}
type={inputs.stream ? 'primary' : 'tertiary'}
size="small"
onClick={() => handleInputChange('stream', !inputs.stream)}
className="!rounded-full"
>
{inputs.stream ? '开启' : '关闭'}
</Button>
</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={(value) => setSystemPrompt(value)}
className="!rounded-lg"
maxHeight={200}
/>
</div>
</div>
</Card>
<SettingsPanel
inputs={inputs}
parameterEnabled={parameterEnabled}
models={models}
groups={groups}
systemPrompt={systemPrompt}
styleState={styleState}
showSettings={showSettings}
showDebugPanel={showDebugPanel}
onInputChange={handleInputChange}
onParameterToggle={handleParameterToggle}
onSystemPromptChange={setSystemPrompt}
onCloseSettings={() => setShowSettings(false)}
onConfigImport={handleConfigImport}
onConfigReset={handleConfigReset}
/>
</Layout.Sider>
)}
<Layout.Content className="relative flex-1 overflow-hidden">
<div className="px-4 overflow-hidden flex gap-4" style={{ height: 'calc(100vh - 100px)' }}>
<div className="sm:px-4 overflow-hidden flex flex-col lg:flex-row gap-2 sm:gap-4 h-[calc(100vh-100px)]">
<div className="flex-1 flex flex-col">
<SettingsToggle />
<Card
className="!rounded-2xl h-full"
bodyStyle={{ padding: 0, height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
{/* 聊天头部 */}
<div className="px-6 py-4 bg-gradient-to-r from-purple-500 to-blue-500 rounded-t-2xl">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur flex items-center justify-center">
<MessageSquare size={20} className="text-white" />
</div>
<div>
<Typography.Title heading={5} className="!text-white mb-0">
{t('AI 对话')}
</Typography.Title>
<Typography.Text className="!text-white/80 text-sm">
{inputs.model || t('选择模型开始对话')}
</Typography.Text>
</div>
</div>
</div>
{/* 聊天内容区域 */}
<div className="flex-1 overflow-hidden">
<Chat
chatBoxRenderConfig={{
renderChatBoxContent: renderCustomChatContent,
renderChatBoxAction: renderChatBoxAction,
}}
renderInputArea={renderInputArea}
roleConfig={roleInfo}
style={{
height: '100%',
maxWidth: '100%',
overflow: 'hidden'
}}
chats={message}
onMessageSend={onMessageSend}
onMessageCopy={handleMessageCopy}
onMessageReset={handleMessageReset}
onMessageDelete={handleMessageDelete}
showClearContext
showStopGenerate
onStopGenerator={onStopGenerator}
onClear={() => setMessage([])}
className="h-full"
placeholder={t('请输入您的问题...')}
/>
</div>
</Card>
<ChatArea
chatRef={chatRef}
message={message}
inputs={inputs}
styleState={styleState}
showDebugPanel={showDebugPanel}
roleInfo={roleInfo}
onMessageSend={onMessageSend}
onMessageCopy={handleMessageCopy}
onMessageReset={handleMessageReset}
onMessageDelete={handleMessageDelete}
onStopGenerator={onStopGenerator}
onClearMessages={() => setMessage([])}
onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
renderCustomChatContent={renderCustomChatContent}
renderChatBoxAction={renderChatBoxAction}
/>
</div>
{/* 调试面板 */}
{showDebugPanel && (
<div className="w-96 flex-shrink-0">
<Card className="!rounded-2xl h-full flex flex-col" bodyStyle={{ padding: '24px', height: '100%', display: 'flex', flexDirection: 'column' }}>
<div className="flex items-center mb-6 flex-shrink-0">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-blue-500 flex items-center justify-center mr-3">
<Code size={20} className="text-white" />
</div>
<Typography.Title heading={5} className="mb-0">
{t('调试信息')}
</Typography.Title>
</div>
<div className="flex-1 overflow-hidden debug-panel">
<Tabs
type="line"
className="h-full"
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
activeKey={activeDebugTab}
onChange={setActiveDebugTab}
>
<TabPane tab={
<div className="flex items-center gap-2">
<FileText size={16} />
{t('请求体')}
</div>
} itemKey="request">
<div className="h-full overflow-y-auto bg-gray-50 rounded-lg p-4 model-settings-scroll">
{debugData.request ? (
<pre className="debug-code text-gray-700 whitespace-pre-wrap break-words">
{JSON.stringify(debugData.request, null, 2)}
</pre>
) : (
<Typography.Text type="secondary" className="text-sm">
{t('暂无请求数据')}
</Typography.Text>
)}
</div>
</TabPane>
<TabPane tab={
<div className="flex items-center gap-2">
<Zap size={16} />
{t('响应内容')}
</div>
} itemKey="response">
<div className="h-full overflow-y-auto bg-gray-50 rounded-lg p-4 model-settings-scroll">
{debugData.response ? (
<pre className="debug-code text-gray-700 whitespace-pre-wrap break-words">
{debugData.response}
</pre>
) : (
<Typography.Text type="secondary" className="text-sm">
{t('暂无响应数据')}
</Typography.Text>
)}
</div>
</TabPane>
</Tabs>
</div>
{debugData.timestamp && (
<div className="flex items-center gap-2 mt-4 pt-4 flex-shrink-0">
<Clock size={14} className="text-gray-500" />
<Typography.Text className="text-xs text-gray-500">
{t('最后更新')}: {new Date(debugData.timestamp).toLocaleString()}
</Typography.Text>
</div>
)}
</Card>
{/* 调试面板 - 桌面端 */}
{showDebugPanel && !styleState.isMobile && (
<div className="w-96 flex-shrink-0 h-full">
<DebugPanel
debugData={debugData}
activeDebugTab={activeDebugTab}
onActiveDebugTabChange={setActiveDebugTab}
styleState={styleState}
/>
</div>
)}
</div>
{/* 调试面板 - 移动端覆盖层 */}
{showDebugPanel && styleState.isMobile && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 1000,
backgroundColor: 'white',
overflow: 'auto',
}}
className="shadow-lg"
>
<DebugPanel
debugData={debugData}
activeDebugTab={activeDebugTab}
onActiveDebugTabChange={setActiveDebugTab}
styleState={styleState}
showDebugPanel={showDebugPanel}
onCloseDebugPanel={() => setShowDebugPanel(false)}
/>
</div>
)}
{/* 浮动按钮 */}
<FloatingButtons
styleState={styleState}
showSettings={showSettings}
showDebugPanel={showDebugPanel}
onToggleSettings={() => setShowSettings(!showSettings)}
onToggleDebugPanel={() => setShowDebugPanel(!showDebugPanel)}
/>
</Layout.Content>
</Layout>
</div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment