🎨 refactor(playground): Refactor the structure of the playground and implement responsive design adaptation
This commit is contained in:
@@ -11,6 +11,7 @@ import { API, getLogo, getSystemName, showError } from '../helpers/index.js';
|
|||||||
import { setStatusData } from '../helpers/data.js';
|
import { setStatusData } from '../helpers/data.js';
|
||||||
import { UserContext } from '../context/User/index.js';
|
import { UserContext } from '../context/User/index.js';
|
||||||
import { StatusContext } from '../context/Status/index.js';
|
import { StatusContext } from '../context/Status/index.js';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
const { Sider, Content, Header, Footer } = Layout;
|
const { Sider, Content, Header, Footer } = Layout;
|
||||||
|
|
||||||
const PageLayout = () => {
|
const PageLayout = () => {
|
||||||
@@ -18,6 +19,9 @@ const PageLayout = () => {
|
|||||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||||
const [styleState, styleDispatch] = useContext(StyleContext);
|
const [styleState, styleDispatch] = useContext(StyleContext);
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const isPlaygroundRoute = location.pathname === '/console/playground';
|
||||||
|
|
||||||
const loadUser = () => {
|
const loadUser = () => {
|
||||||
let user = localStorage.getItem('user');
|
let user = localStorage.getItem('user');
|
||||||
@@ -144,14 +148,16 @@ const PageLayout = () => {
|
|||||||
>
|
>
|
||||||
<App />
|
<App />
|
||||||
</Content>
|
</Content>
|
||||||
<Layout.Footer
|
{!isPlaygroundRoute && (
|
||||||
style={{
|
<Layout.Footer
|
||||||
flex: '0 0 auto',
|
style={{
|
||||||
width: '100%',
|
flex: '0 0 auto',
|
||||||
}}
|
width: '100%',
|
||||||
>
|
}}
|
||||||
<FooterBar />
|
>
|
||||||
</Layout.Footer>
|
<FooterBar />
|
||||||
|
</Layout.Footer>
|
||||||
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
|||||||
112
web/src/components/playground/ChatArea.js
Normal file
112
web/src/components/playground/ChatArea.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
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;
|
||||||
234
web/src/components/playground/ConfigManager.js
Normal file
234
web/src/components/playground/ConfigManager.js
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
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;
|
||||||
27
web/src/components/playground/CustomInputRender.js
Normal file
27
web/src/components/playground/CustomInputRender.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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;
|
||||||
120
web/src/components/playground/DebugPanel.js
Normal file
120
web/src/components/playground/DebugPanel.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
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;
|
||||||
71
web/src/components/playground/FloatingButtons.js
Normal file
71
web/src/components/playground/FloatingButtons.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
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;
|
||||||
92
web/src/components/playground/ImageUrlInput.js
Normal file
92
web/src/components/playground/ImageUrlInput.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
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;
|
||||||
69
web/src/components/playground/MessageActions.js
Normal file
69
web/src/components/playground/MessageActions.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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;
|
||||||
248
web/src/components/playground/MessageContent.js
Normal file
248
web/src/components/playground/MessageContent.js
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
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;
|
||||||
234
web/src/components/playground/ParameterControl.js
Normal file
234
web/src/components/playground/ParameterControl.js
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
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;
|
||||||
195
web/src/components/playground/SettingsPanel.js
Normal file
195
web/src/components/playground/SettingsPanel.js
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
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;
|
||||||
178
web/src/components/playground/configStorage.js
Normal file
178
web/src/components/playground/configStorage.js
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
20
web/src/components/playground/index.js
Normal file
20
web/src/components/playground/index.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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';
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user