完成世界书、骰子、apiconfig页面处理

This commit is contained in:
2026-04-30 01:35:10 +08:00
parent a3e3711b2b
commit ba9b925c32
4602 changed files with 785225 additions and 23 deletions

View File

@@ -0,0 +1,77 @@
.sidebar-left {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-tabs {
display: flex;
border-bottom: 1px solid var(--color-border);
background-color: var(--color-bg-tertiary);
padding: 0 var(--spacing-sm);
flex-shrink: 0;
}
.tab-button {
flex: 1;
padding: var(--spacing-md) var(--spacing-sm);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all var(--transition-normal);
font-size: 13px;
color: var(--color-text-secondary);
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
font-weight: 500;
}
.tab-button:hover {
background-color: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.tab-button.active {
color: var(--color-accent);
font-weight: 600;
border-bottom-color: var(--color-accent);
background: var(--color-accent-ultra-light);
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: var(--spacing-lg);
min-height: 0;
}
/* Tab placeholder for empty states */
.tab-placeholder {
padding: var(--spacing-lg);
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: var(--color-text-muted);
}
.tab-placeholder h3 {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: var(--spacing-sm);
}
.tab-placeholder p {
font-size: 0.85rem;
line-height: 1.5;
}

View File

@@ -0,0 +1,41 @@
// frontend-react/src/components/SideBarLeft/SideBarLeft.jsx
import React from 'react';
import './SideBarLeft.css';
import { useSideBarLeftStore } from '../../Store/indexStore';
import useSideBarRightStore from '../../Store/SideBarLeft/SideBarLeftSlice';
import Gallery from './tabs/Gallery';
import CharacterCard from './tabs/CharacterCard';
import ApiConfig from './tabs/ApiConfig';
import Presets from './tabs/Presets';
import WorldBook from './tabs/WorldBook';
const SideBarLeft = () => {
const { activeTab, tabs, setActiveTab } = useSideBarLeftStore();
return (
<div className="sidebar-left">
<div className="sidebar-tabs">
{tabs.map(tab => (
<button
key={tab.id}
className={`tab-button ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
title={tab.title}
>
{tab.label}
</button>
))}
</div>
<div className="sidebar-content">
{activeTab === 'gallery' && <Gallery />}
{activeTab === 'character' && <CharacterCard />}
{activeTab === 'api' && <ApiConfig />}
{activeTab === 'presets' && <Presets />}
{activeTab === 'worldbook' && <WorldBook />}
</div>
</div>
);
};
export default SideBarLeft;

View File

@@ -0,0 +1 @@
export { default } from './SideBarLeft';

View File

@@ -0,0 +1,738 @@
/* ApiConfig - Compact Modern Design */
.api-config-container {
padding: var(--spacing-md);
color: var(--color-text-primary);
height: 100%;
overflow-y: auto;
}
.api-config-title {
margin: 0 0 var(--spacing-md) 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-text-primary);
padding-bottom: var(--spacing-sm);
border-bottom: 1px solid var(--color-border);
}
.api-config-form {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
/* 配置区域标签样式 - Compact Pill style */
.config-tabs {
display: flex;
gap: 2px;
padding: 2px;
background-color: var(--color-bg-tertiary);
border-radius: var(--radius-sm);
}
.config-tab {
flex: 1;
padding: var(--spacing-xs) var(--spacing-sm);
background: transparent;
border: none;
border-radius: var(--radius-xs);
cursor: pointer;
font-weight: 500;
font-size: 0.8rem;
color: var(--color-text-secondary);
transition: all var(--transition-fast);
white-space: nowrap;
}
.config-tab:hover {
color: var(--color-text-primary);
background-color: var(--color-bg-elevated);
}
.config-tab.active {
color: var(--color-text-primary);
background-color: var(--color-bg-secondary);
box-shadow: var(--shadow-xs);
}
/* 配置管理区域 - Integrated Layout */
.profile-manager {
padding: var(--spacing-sm);
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.profile-header {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.profile-header > label {
font-weight: 500;
color: var(--color-text-secondary);
font-size: 0.8rem;
}
.profile-controls {
display: flex;
gap: var(--spacing-xs);
align-items: center;
}
.profile-select-input {
flex: 1;
min-width: 0;
}
.profile-buttons {
display: flex;
gap: var(--spacing-xs);
flex-shrink: 0;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-section {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.form-row {
display: flex;
gap: var(--spacing-sm);
}
.form-group.flex-1 {
flex: 1;
min-width: 0;
}
.form-group.flex-auto {
flex: 0 0 auto;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.form-group label {
font-weight: 500;
color: var(--color-text-secondary);
font-size: 0.8rem;
}
.form-control {
width: 100%;
padding: 6px var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-xs);
font-size: 0.85rem;
color: var(--color-text-primary);
background-color: var(--color-bg-primary);
transition: all var(--transition-fast);
box-sizing: border-box;
}
.form-control:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-ultra-light);
}
.form-control::placeholder {
color: var(--color-text-muted);
opacity: 0.6;
}
textarea.form-control {
resize: vertical;
min-height: 60px;
font-family: inherit;
}
.form-text {
display: block;
margin-top: 2px;
font-size: 0.7rem;
color: var(--color-text-muted);
}
.form-hint {
display: block;
margin-top: 2px;
font-size: 0.7rem;
color: var(--color-text-muted);
font-style: italic;
}
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-sm);
padding-top: var(--spacing-md);
border-top: 1px solid var(--color-border);
}
.btn {
padding: 6px var(--spacing-md);
border: none;
border-radius: var(--radius-xs);
cursor: pointer;
font-weight: 500;
font-size: 0.85rem;
transition: all var(--transition-fast);
white-space: nowrap;
}
.btn-sm {
padding: 4px var(--spacing-sm);
font-size: 0.8rem;
}
.btn-full {
width: 100%;
}
/* Primary Button - Save/Create */
.btn-primary {
background-color: var(--color-accent);
color: white;
}
.btn-primary:hover {
background-color: var(--color-accent-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary:disabled {
background-color: var(--color-bg-tertiary);
color: var(--color-text-muted);
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* Secondary Button - Connect/Reset */
.btn-secondary {
background-color: var(--color-bg-tertiary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.btn-secondary:hover {
background-color: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Danger Button - Delete */
.btn-danger {
background-color: var(--color-error);
color: white;
}
.btn-danger:hover {
background-color: #dc2626;
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.btn-danger:active {
transform: translateY(0);
}
/* Text Button - Minimal */
.btn-text {
background-color: transparent;
color: var(--color-text-secondary);
padding: 6px var(--spacing-sm);
}
.btn-text:hover {
color: var(--color-text-primary);
background-color: var(--color-bg-tertiary);
}
/* Notification styles */
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius-md);
color: white;
font-weight: 500;
font-size: 0.9rem;
box-shadow: var(--shadow-xl);
animation: slideIn 0.3s ease-out;
z-index: 1000;
}
.notification-success {
background-color: var(--color-success);
}
.notification-info {
background-color: var(--color-info);
}
.notification-error {
background-color: var(--color-error);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Modified indicator on tabs */
.modified-indicator {
color: var(--color-error);
font-size: 0.6rem;
margin-left: 4px;
}
/* Modal styles - Modern & Elegant */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-content {
background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
width: 90%;
max-width: 480px;
max-height: 85vh;
overflow: hidden;
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideUp {
from {
transform: translateY(30px) scale(0.95);
opacity: 0;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
.modal-title {
margin: 0;
padding: var(--spacing-lg) var(--spacing-lg) var(--spacing-md);
font-size: 1.15rem;
font-weight: 600;
color: var(--color-text-primary);
border-bottom: 1px solid var(--color-border);
background: linear-gradient(to right, var(--color-accent-ultra-light), transparent);
}
.modal-body {
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
max-height: 400px;
overflow-y: auto;
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
border: 1px solid transparent;
}
.checkbox-label:hover {
background-color: var(--color-bg-elevated);
border-color: var(--color-border);
transform: translateX(2px);
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--color-accent);
border-radius: 4px;
}
.checkbox-text {
flex: 1;
font-size: 0.9rem;
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.modified-badge {
font-size: 0.7rem;
padding: 2px 8px;
background: linear-gradient(135deg, var(--color-accent-ultra-light), var(--color-accent));
color: white;
border-radius: var(--radius-xs);
font-weight: 600;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
padding: var(--spacing-md) var(--spacing-lg);
border-top: 1px solid var(--color-border);
background-color: var(--color-bg-tertiary);
}
/* Modal Hint Text - Enhanced */
.modal-hint {
margin: var(--spacing-md) 0 var(--spacing-sm) 0;
padding: var(--spacing-sm) var(--spacing-md);
font-size: 0.8rem;
color: var(--color-text-secondary);
background-color: var(--color-bg-tertiary);
border-left: 3px solid var(--color-accent);
border-radius: var(--radius-xs);
}
.workflow-manager {
margin-top: var(--spacing-lg);
padding: var(--spacing-md);
background-color: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.workflow-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
}
.workflow-header h4 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text-primary);
}
.workflow-actions {
display: flex;
gap: var(--spacing-xs);
}
.workflow-list {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
max-height: 300px;
overflow-y: auto;
}
.workflow-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
}
.workflow-item:hover {
border-color: var(--color-accent);
background-color: var(--color-bg-elevated);
}
.workflow-info {
flex: 1;
min-width: 0;
}
.workflow-name {
font-size: 0.9rem;
font-weight: 500;
color: var(--color-text-primary);
margin-bottom: 2px;
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.default-badge {
font-size: 0.7rem;
padding: 2px 6px;
background-color: var(--color-accent);
color: white;
border-radius: var(--radius-xs);
font-weight: 600;
}
.workflow-meta {
font-size: 0.75rem;
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.separator {
opacity: 0.5;
}
.delete-btn {
padding: 4px 8px;
font-size: 1rem;
opacity: 0.6;
transition: opacity var(--transition-fast);
}
.delete-btn:hover {
opacity: 1;
}
.empty-state {
text-align: center;
padding: var(--spacing-xl);
color: var(--color-text-muted);
}
.empty-state p {
margin: var(--spacing-xs) 0;
}
.empty-state .hint {
font-size: 0.8rem;
opacity: 0.7;
}
.workflow-hint {
margin-top: var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-md);
background-color: var(--color-bg-secondary);
border-left: 3px solid var(--color-accent);
border-radius: var(--radius-xs);
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.workflow-hint p {
margin: 0 0 var(--spacing-xs) 0;
font-weight: 600;
}
.workflow-hint ul {
margin: 0;
padding-left: var(--spacing-md);
}
.workflow-hint li {
margin: var(--spacing-xs) 0;
line-height: 1.5;
}
/* ==================== Compact Modern Design ==================== */
/* Mode Toggle - Pill Style (Linear/Vercel) */
.mode-toggle {
display: inline-flex;
gap: 2px;
padding: 2px;
background-color: var(--color-bg-tertiary);
border-radius: 6px;
margin-bottom: var(--spacing-sm);
}
.mode-btn {
padding: 4px 12px;
font-size: 0.8rem;
font-weight: 500;
border: none;
border-radius: 4px;
background-color: transparent;
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.15s ease;
line-height: 1;
}
.mode-btn:hover {
color: var(--color-text-primary);
background-color: rgba(255, 255, 255, 0.05);
}
.mode-btn.active {
background-color: var(--color-bg-elevated);
color: var(--color-text-primary);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
/* Compact Workflow Manager */
.workflow-manager-compact {
margin-top: var(--spacing-sm);
padding: var(--spacing-xs);
background-color: var(--color-bg-tertiary);
border-radius: 6px;
}
.workflow-header-compact {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
margin-bottom: 4px;
}
.workflow-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.workflow-actions-compact {
display: flex;
gap: 2px;
}
.btn-icon {
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 4px;
background-color: transparent;
color: var(--color-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
transition: all 0.15s ease;
}
.btn-icon:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.1);
color: var(--color-text-primary);
}
.btn-icon:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-icon-danger {
composes: btn-icon;
color: var(--color-error);
}
.btn-icon-danger:hover {
background-color: rgba(239, 68, 68, 0.1);
}
.workflow-list-compact {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 150px;
overflow-y: auto;
}
.workflow-item-compact {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
background-color: var(--color-bg-secondary);
border-radius: 4px;
transition: background-color 0.15s ease;
}
.workflow-item-compact:hover {
background-color: var(--color-bg-elevated);
}
.workflow-name-compact {
font-size: 0.8rem;
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: 6px;
}
.badge-default {
font-size: 0.65rem;
padding: 1px 4px;
background-color: var(--color-accent);
color: white;
border-radius: 3px;
font-weight: 600;
}
.empty-hint {
padding: 12px;
text-align: center;
font-size: 0.75rem;
color: var(--color-text-muted);
}
/* Modal Hint Text */
.modal-hint {
margin: 0 0 var(--spacing-md) 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
}

View File

@@ -0,0 +1,828 @@
// frontend-react/src/components/SideBarLeft/tabs/ApiConfig/ApiConfig.jsx
import React, { useEffect, useState } from 'react';
import useApiConfigStore from '../../../../Store/SideBarLeft/ApiConfigSlice';
import useSideBarLeftStore from '../../../../Store/SideBarLeft/SideBarLeftSlice';
import ComfyUIWorkflowManager from './ComfyUIWorkflowManager';
import './ApiConfig.css';
const ApiConfig = () => {
// 从store中获取状态和方法
const {
profiles,
currentProfile,
activeMap,
loading,
error,
fetchProfiles,
fetchProfile,
saveProfile,
deleteProfile,
setActiveConfig,
testConnection,
notification
} = useApiConfigStore();
// 本地表单状态 - 包含 4 个 API 配置
const [formData, setFormData] = useState({
mainLLM: { id: '', name: '', apiUrl: '', apiKey: '', model: '' },
// 生图配置 - 支持本地和云端两种模式
imageModel: {
mode: 'local', // 'local' | 'cloud'
// 本地 ComfyUI 配置
local: {
apiUrl: 'http://comfyui:8188',
websocketEnabled: true,
queueTimeout: 300,
defaultWorkflow: 'default_txt2img.json'
},
// 云端 API 配置
cloud: {
provider: 'dall-e',
apiUrl: 'https://api.openai.com/v1/images/generations',
apiKey: '',
model: 'dall-e-3'
}
},
secondaryLLM: { id: '', name: '', apiUrl: '', apiKey: '', model: '' },
ragEmbedding: { id: '', name: '', apiUrl: '', apiKey: '', model: '' }
});
// 当前选中的配置文件 ID
const [selectedProfileId, setSelectedProfileId] = useState('');
// 当前编辑的类别
const [currentCategory, setCurrentCategory] = useState('mainLLM');
// 可用模型列表
const [availableModels, setAvailableModels] = useState([]);
// 显示保存对话框
const [showSaveModal, setShowSaveModal] = useState(false);
// 新配置文件名称
const [newProfileName, setNewProfileName] = useState('');
// 要保存的 API 类别(勾选状态)
const [apisToSave, setApisToSave] = useState({
mainLLM: false,
imageModel: false,
secondaryLLM: false,
ragEmbedding: false
});
// 跟踪哪些 API 有修改
const [modifiedApis, setModifiedApis] = useState({});
// 获取当前激活的分页
const { activeTab } = useSideBarLeftStore();
// 记录上一次的分页状态
const prevActiveTabRef = React.useRef(activeTab);
// 组件加载时获取配置文件列表 - 只在切换到API分页时刷新
useEffect(() => {
// 检测是否从其他分页切换到API分页
if (activeTab === 'api' && prevActiveTabRef.current !== 'api') {
fetchProfiles();
}
// 更新上一次的分页状态
prevActiveTabRef.current = activeTab;
}, [activeTab, fetchProfiles]);
// 当选中配置文件时,加载该配置
useEffect(() => {
if (selectedProfileId) {
loadProfile(selectedProfileId);
}
}, [selectedProfileId]);
// 加载配置文件
const loadProfile = async (profileId) => {
try {
const profile = await fetchProfile(profileId);
// 合并到本地状态(服务器提供的覆盖,未提供的保留本地)
setFormData(prev => ({
...prev,
...profile.apis
}));
// 清除修改标记
setModifiedApis({});
} catch (err) {
console.error('加载配置文件失败:', err);
}
};
// 处理输入变化 - 支持嵌套路径
const handleChange = (e, path = null) => {
const { name, value, type, checked } = e.target;
const newValue = type === 'checkbox' ? checked : value;
if (path) {
// 嵌套路径更新,例如: ['imageModel', 'local', 'apiUrl']
setFormData(prev => {
const updated = { ...prev };
let current = updated;
// 导航到倒数第二层
for (let i = 0; i < path.length - 1; i++) {
current = current[path[i]];
}
// 更新最后一层
current[path[path.length - 1]] = newValue;
return updated;
});
// 标记 imageModel 为已修改
setModifiedApis(prev => ({
...prev,
imageModel: true
}));
} else {
// 扁平结构更新(用于其他 API
setFormData(prev => ({
...prev,
[currentCategory]: {
...prev[currentCategory],
[name]: newValue
}
}));
// 标记当前类别的 API 已被修改
setModifiedApis(prev => ({
...prev,
[currentCategory]: true
}));
}
};
// 切换生图模式
const handleImageModeChange = (mode) => {
setFormData(prev => ({
...prev,
imageModel: {
...prev.imageModel,
mode
}
}));
setModifiedApis(prev => ({
...prev,
imageModel: true
}));
};
// 处理类别切换
const handleTabChange = (categoryId) => {
setCurrentCategory(categoryId);
setAvailableModels([]);
};
// 打开保存/新建配置文件对话框
const handleOpenSaveModal = () => {
// 默认选中所有有修改的 API
setApisToSave(modifiedApis);
// 如果是新建,清空名称;如果是更新,加载现有名称
if (!selectedProfileId) {
setNewProfileName('');
} else {
const currentProfile = profiles.find(p => p.id === selectedProfileId);
setNewProfileName(currentProfile?.name || '');
}
setShowSaveModal(true);
};
// 处理保存配置文件
const handleSave = async () => {
// 收集要保存的 API 配置
const apisToSaveData = {};
Object.keys(apisToSave).forEach(category => {
if (apisToSave[category]) {
apisToSaveData[category] = formData[category];
}
});
if (Object.keys(apisToSaveData).length === 0) {
alert('请至少选择一个 API 配置进行保存');
return;
}
// 判断是新建还是更新
const isNewProfile = !selectedProfileId;
const profileId = selectedProfileId || `profile-${Date.now()}`;
const profileName = isNewProfile ? newProfileName : (profiles.find(p => p.id === selectedProfileId)?.name || profileId);
try {
await saveProfile(profileId, profileName, apisToSaveData);
setSelectedProfileId(profileId);
setShowSaveModal(false);
// 清除修改标记
setModifiedApis(prev => {
const newModified = { ...prev };
Object.keys(apisToSave).forEach(category => {
if (apisToSave[category]) {
delete newModified[category];
}
});
return newModified;
});
} catch (err) {
console.error('保存失败:', err);
}
};
// 处理重置
const handleReset = () => {
if (selectedProfileId) {
loadProfile(selectedProfileId);
} else {
// 清空当前类别的配置
setFormData(prev => ({
...prev,
[currentCategory]: { id: '', name: '', apiUrl: '', apiKey: '', model: '' }
}));
setModifiedApis(prev => {
const newModified = { ...prev };
delete newModified[currentCategory];
return newModified;
});
}
};
// 处理连接测试并获取模型列表
const handleConnect = async () => {
const currentApi = formData[currentCategory];
if (!currentApi.apiUrl) {
alert('请先填写 API 地址');
return;
}
if (!currentApi.apiKey) {
alert('请先填写 API 密钥');
return;
}
try {
const response = await fetch('/api/api-config/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
apiUrl: currentApi.apiUrl,
apiKey: currentApi.apiKey,
category: currentCategory,
model: currentApi.model || ''
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || '获取模型列表失败');
}
const result = await response.json();
if (result.success) {
setAvailableModels(result.models);
if (result.models.length === 0) {
alert('未找到可用模型');
} else {
// 显示成功消息
console.log(`成功获取 ${result.models.length} 个模型`);
}
} else {
alert(`获取失败: ${result.message}`);
}
} catch (err) {
console.error('获取模型列表失败:', err);
alert('获取模型列表失败: ' + err.message);
}
};
// 处理模型选择
const handleModelSelect = (selectedModel) => {
setFormData(prev => ({
...prev,
[currentCategory]: {
...prev[currentCategory],
model: selectedModel
}
}));
};
// 测试 ComfyUI 连接
const testComfyUIConnection = async (apiUrl) => {
if (!apiUrl) {
alert('请先填写 API 地址');
return;
}
try {
setLoading(true);
const response = await fetch('/api/api-config/test-comfyui-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiUrl })
});
const result = await response.json();
if (result.success) {
alert(`连接成功!\n\nVRAM: ${(result.stats.vram_free / 1024 / 1024 / 1024).toFixed(2)} GB 可用\n设备: ${result.stats.device}`);
} else {
alert(`连接失败: ${result.message}`);
}
} catch (err) {
alert('测试失败: ' + err.message);
} finally {
setLoading(false);
}
};
// 测试云端 API 连接
const testCloudConnection = async (cloudConfig) => {
if (!cloudConfig.apiKey) {
alert('请先填写 API Key');
return;
}
try {
setLoading(true);
const response = await fetch('/api/api-config/test-cloud-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cloudConfig)
});
const result = await response.json();
if (result.success) {
alert('连接成功!');
} else {
alert(`连接失败: ${result.message}`);
}
} catch (err) {
alert('测试失败: ' + err.message);
} finally {
setLoading(false);
}
};
// 处理从列表中选择配置文件
const handleSelectProfile = (e) => {
const profileId = e.target.value;
setSelectedProfileId(profileId);
};
// 处理删除配置文件
const handleDelete = async () => {
if (selectedProfileId && confirm('确定要删除此配置文件吗?')) {
await deleteProfile(selectedProfileId);
setSelectedProfileId('');
handleCreateNew();
}
};
// 处理设为默认配置
const handleSetActive = async () => {
if (!selectedProfileId) {
alert('请先保存配置文件');
return;
}
await setActiveConfig(currentCategory, selectedProfileId);
};
// 配置区域标签(简短名称 + Tooltip
const configTabs = [
{ id: 'mainLLM', label: '核心', tooltip: '主 LLM 模型 - 用于主要对话和推理' },
{ id: 'imageModel', label: '生图', tooltip: '图像生成模型 - 本地 ComfyUI 或云端 API' },
{ id: 'secondaryLLM', label: '辅助', tooltip: '副 LLM 模型 - 用于二次校验或特殊任务' },
{ id: 'ragEmbedding', label: '向量', tooltip: 'RAG 嵌入模型 - 用于文本向量化和检索' }
];
// 判断当前 API 密钥是否是脱敏的
const isApiKeyMasked = formData[currentCategory].apiKey && formData[currentCategory].apiKey.endsWith('****');
// 计算有修改的 API 数量
const modifiedCount = Object.keys(modifiedApis).filter(k => modifiedApis[k]).length;
return (
<div className="api-config-container">
{/* 移除标题,节省空间 */}
{error && <div className="notification notification-error">{error}</div>}
{notification.show && (
<div className={`notification notification-${notification.type}`}>
{notification.message}
</div>
)}
<div className="api-config-form">
{/* 配置管理区域 - 移到顶部 */}
<div className="profile-manager">
<div className="profile-header">
<label htmlFor="profileSelect">配置文件</label>
<div className="profile-controls">
<select
id="profileSelect"
value={selectedProfileId}
onChange={handleSelectProfile}
className="form-control profile-select-input"
>
<option value="">新建配置文件...</option>
{profiles.map(profile => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</select>
<div className="profile-buttons">
{!selectedProfileId ? (
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleOpenSaveModal}
>
+ 新建
</button>
) : (
<>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={handleSetActive}
disabled={activeMap[currentCategory] === selectedProfileId}
>
设为默认
</button>
<button
type="button"
className="btn btn-danger btn-sm"
onClick={handleDelete}
>
删除
</button>
</>
)}
</div>
</div>
</div>
</div>
{/* 配置区域标签 */}
<div className="config-tabs">
{configTabs.map(tab => (
<button
key={tab.id}
className={`config-tab ${currentCategory === tab.id ? 'active' : ''}`}
onClick={() => handleTabChange(tab.id)}
title={tab.tooltip}
>
{tab.label}
{modifiedApis[tab.id] && <span className="modified-indicator"></span>}
</button>
))}
</div>
{/* 生图模式选择 - 紧凑版 */}
{currentCategory === 'imageModel' && (
<div className="mode-toggle">
<button
type="button"
className={`mode-btn ${formData.imageModel.mode === 'local' ? 'active' : ''}`}
onClick={() => handleImageModeChange('local')}
>
🖥 本地
</button>
<button
type="button"
className={`mode-btn ${formData.imageModel.mode === 'cloud' ? 'active' : ''}`}
onClick={() => handleImageModeChange('cloud')}
>
云端
</button>
</div>
)}
{/* API配置表单 */}
<div className="form-section">
{/* 生图模型 - 本地 ComfyUI 模式 */}
{currentCategory === 'imageModel' && formData.imageModel.mode === 'local' ? (
<>
<div className="form-group">
<label htmlFor="local-apiUrl">API 地址</label>
<input
type="text"
id="local-apiUrl"
value={formData.imageModel.local.apiUrl}
onChange={(e) => handleChange(e, ['imageModel', 'local', 'apiUrl'])}
placeholder="http://comfyui:8188"
className="form-control"
/>
<span className="form-hint">
Docker 环境使用 http://comfyui:8188本地运行使用 http://localhost:8188
</span>
</div>
<div className="form-row">
<div className="form-group half">
<label htmlFor="local-websocket">启用 WebSocket</label>
<label className="switch-label">
<input
type="checkbox"
id="local-websocket"
checked={formData.imageModel.local.websocketEnabled}
onChange={(e) => handleChange(e, ['imageModel', 'local', 'websocketEnabled'])}
/>
<span className="switch-slider"></span>
</label>
</div>
<div className="form-group half">
<label htmlFor="local-timeout">队列超时</label>
<input
type="number"
id="local-timeout"
value={formData.imageModel.local.queueTimeout}
onChange={(e) => handleChange(e, ['imageModel', 'local', 'queueTimeout'])}
min="30"
max="600"
className="form-control"
/>
</div>
</div>
<div className="form-group">
<label htmlFor="local-workflow">默认工作流</label>
<select
id="local-workflow"
value={formData.imageModel.local.defaultWorkflow}
onChange={(e) => handleChange(e, ['imageModel', 'local', 'defaultWorkflow'])}
className="form-control"
>
<option value="default_txt2img.json">文生图默认</option>
</select>
<span className="form-hint">
可上传自定义工作流文件
</span>
</div>
<div className="form-group">
<button
type="button"
className="btn btn-secondary"
onClick={() => testComfyUIConnection(formData.imageModel.local.apiUrl)}
disabled={loading}
>
{loading ? '测试中...' : '测试连接'}
</button>
</div>
</>
) : currentCategory === 'imageModel' && formData.imageModel.mode === 'cloud' ? (
/* 生图模型 - 云端 API 模式 */
<>
<div className="form-group">
<label htmlFor="cloud-provider">服务提供商</label>
<select
id="cloud-provider"
value={formData.imageModel.cloud.provider}
onChange={(e) => handleChange(e, ['imageModel', 'cloud', 'provider'])}
className="form-control"
>
<option value="dall-e">DALL-E 3 (OpenAI)</option>
<option value="stability">Stable Diffusion (Stability AI)</option>
</select>
</div>
<div className="form-group">
<label htmlFor="cloud-apiKey">API Key</label>
<input
type="password"
id="cloud-apiKey"
value={formData.imageModel.cloud.apiKey}
onChange={(e) => handleChange(e, ['imageModel', 'cloud', 'apiKey'])}
placeholder="sk-..."
className="form-control"
/>
</div>
<div className="form-group">
<label htmlFor="cloud-model">模型</label>
<select
id="cloud-model"
value={formData.imageModel.cloud.model}
onChange={(e) => handleChange(e, ['imageModel', 'cloud', 'model'])}
className="form-control"
>
{formData.imageModel.cloud.provider === 'dall-e' && (
<>
<option value="dall-e-3">DALL-E 3</option>
<option value="dall-e-2">DALL-E 2</option>
</>
)}
{formData.imageModel.cloud.provider === 'stability' && (
<>
<option value="sd-xl-1024">SD-XL 1024</option>
<option value="sd-2-1">SD 2.1</option>
</>
)}
</select>
</div>
<div className="form-group">
<button
type="button"
className="btn btn-secondary"
onClick={() => testCloudConnection(formData.imageModel.cloud)}
disabled={loading}
>
{loading ? '测试中...' : '测试连接'}
</button>
</div>
</>
) : (
/* 其他 API 类型主LLM、副LLM、嵌入模型*/
<>
<div className="form-group">
<label htmlFor="apiUrl">API 地址</label>
<input
type="text"
id="apiUrl"
name="apiUrl"
value={formData[currentCategory].apiUrl}
onChange={handleChange}
placeholder="https://api.openai.com/v1"
className="form-control"
/>
</div>
<div className="form-group">
<label htmlFor="apiKey">密钥</label>
<input
type="password"
id="apiKey"
name="apiKey"
value={isApiKeyMasked ? '' : formData[currentCategory].apiKey}
onChange={handleChange}
placeholder={isApiKeyMasked ? '已保存(留空保持不变)' : 'sk-...'}
className="form-control"
/>
{isApiKeyMasked && (
<span className="form-hint">当前密钥已加密存储</span>
)}
</div>
<div className="form-row">
<div className="form-group flex-1">
<label htmlFor="model">模型</label>
<input
type="text"
id="model"
name="model"
value={formData[currentCategory].model}
onChange={handleChange}
placeholder="gpt-3.5-turbo"
className="form-control"
/>
</div>
<div className="form-group flex-auto">
<label>&nbsp;</label>
<button
type="button"
className="btn btn-secondary btn-full"
onClick={handleConnect}
disabled={loading}
>
{loading ? '连接中...' : '获取模型列表'}
</button>
</div>
</div>
{availableModels.length > 0 && (
<div className="form-group">
<label htmlFor="modelSelect">选择模型</label>
<select
id="modelSelect"
value={formData[currentCategory].model}
onChange={(e) => handleModelSelect(e.target.value)}
className="form-control"
>
<option value="">从列表中选择...</option>
{availableModels.map((model, index) => (
<option key={index} value={model}>
{model}
</option>
))}
</select>
</div>
)}
</>
)}
</div>
{/* ComfyUI 工作流管理器 - 仅在选择生图模型时显示 */}
{currentCategory === 'imageModel' && formData.imageModel.mode === 'local' && (
<ComfyUIWorkflowManager apiUrl={formData.imageModel.local?.apiUrl} />
)}
{/* 底部操作栏 */}
<div className="form-actions">
<button
type="button"
className="btn btn-text"
onClick={handleReset}
disabled={loading}
>
重置
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleOpenSaveModal}
disabled={loading || modifiedCount === 0}
>
{loading ? '保存中...' : modifiedCount > 0 ? `保存配置 (${modifiedCount})` : '保存配置'}
</button>
</div>
</div>
{/* 保存/更新配置文件对话框 */}
{showSaveModal && (
<div className="modal-overlay">
<div className="modal-content">
<h3 className="modal-title">
{selectedProfileId ? '更新配置文件' : '新建配置文件'}
</h3>
{/* 如果是新建,显示名称输入框 */}
{!selectedProfileId && (
<div className="form-group" style={{ padding: 'var(--spacing-lg) var(--spacing-lg) 0' }}>
<label htmlFor="newProfileName">配置文件名称</label>
<input
type="text"
id="newProfileName"
value={newProfileName}
onChange={(e) => setNewProfileName(e.target.value)}
placeholder="例如:我的默认配置"
className="form-control"
autoFocus
/>
</div>
)}
<p className="modal-hint">
{selectedProfileId
? '选择要更新的 API 配置(未选中的将保持不变):'
: '选择要包含在此配置文件中的 API 配置:'}
</p>
<div className="modal-body">
{configTabs.map(tab => (
<label key={tab.id} className="checkbox-label">
<input
type="checkbox"
checked={apisToSave[tab.id] || false}
onChange={(e) => setApisToSave(prev => ({
...prev,
[tab.id]: e.target.checked
}))}
/>
<span className="checkbox-text">
{tab.label}
{modifiedApis[tab.id] && <span className="modified-badge">已修改</span>}
</span>
</label>
))}
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-text"
onClick={() => setShowSaveModal(false)}
>
取消
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleSave}
>
{selectedProfileId ? '保存更新' : '创建配置文件'}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default ApiConfig;

View File

@@ -0,0 +1,482 @@
// frontend-react/src/components/SideBarLeft/tabs/ApiConfig/ApiConfig.jsx
import React, { useEffect, useState } from 'react';
import useApiConfigStore from '../../../../Store/SideBarLeft/ApiConfigSlice';
import './ApiConfig.css';
const ApiConfig = () => {
// 从store中获取状态和方法
const {
profiles, // 所有配置文件列表
currentProfile, // 当前加载的配置文件
activeMap, // 激活配置映射 { category: profileId }
loading,
error,
fetchProfiles, // 获取所有配置文件列表
fetchProfile, // 获取单个配置文件
saveProfile, // 保存配置文件(批量)
deleteProfile, // 删除配置文件
setActiveConfig, // 设置激活配置
testConnection, // 测试连接
notification
} = useApiConfigStore();
// 本地表单状态 - 包含 4 个 API 配置
const [formData, setFormData] = useState({
mainLLM: {
id: '',
name: '',
apiUrl: '',
apiKey: '',
model: '',
},
imageModel: {
id: '',
name: '',
apiUrl: '',
apiKey: '',
model: '',
},
secondaryLLM: {
id: '',
name: '',
apiUrl: '',
apiKey: '',
model: '',
},
ragEmbedding: {
id: '',
name: '',
apiUrl: '',
apiKey: '',
model: '',
}
});
// 当前选中的配置文件 ID
const [selectedProfileId, setSelectedProfileId] = useState('');
// 当前编辑的类别
const [currentCategory, setCurrentCategory] = useState('mainLLM');
// 可用模型列表
const [availableModels, setAvailableModels] = useState([]);
// 显示保存对话框
const [showSaveModal, setShowSaveModal] = useState(false);
// 要保存的 API 类别(勾选状态)
const [apisToSave, setApisToSave] = useState({
mainLLM: false,
imageModel: false,
secondaryLLM: false,
ragEmbedding: false
});
// 跟踪哪些 API 有修改
const [modifiedApis, setModifiedApis] = useState({});
// 组件加载时获取配置文件列表
useEffect(() => {
fetchProfiles();
}, [fetchProfiles]);
// 当选中配置文件时,加载该配置
useEffect(() => {
if (selectedProfileId) {
loadProfile(selectedProfileId);
}
}, [selectedProfileId]);
// 加载配置文件
const loadProfile = async (profileId) => {
try {
const profile = await fetchProfile(profileId);
// 合并到本地状态(服务器提供的覆盖,未提供的保留本地)
setFormData(prev => ({
...prev,
...profile.apis // 只覆盖服务器返回的 API
}));
// 清除修改标记
setModifiedApis({});
} catch (err) {
console.error('加载配置文件失败:', err);
}
};
// 处理输入变化
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[currentCategory]: {
...prev[currentCategory],
[name]: value
}
}));
// 标记当前类别的 API 已被修改
setModifiedApis(prev => ({
...prev,
[currentCategory]: true
}));
};
// 处理类别切换
const handleTabChange = (categoryId) => {
setCurrentCategory(categoryId);
setAvailableModels([]); // 清空模型列表
};
// 打开保存对话框
const handleOpenSaveModal = () => {
// 默认选中所有有修改的 API
setApisToSave(modifiedApis);
setShowSaveModal(true);
};
// 处理保存配置文件
const handleSave = async () => {
if (!selectedProfileId && !formData[currentCategory].name) {
alert('请先输入配置文件名称');
return;
}
// 收集要保存的 API 配置
const apisToSaveData = {};
Object.keys(apisToSave).forEach(category => {
if (apisToSave[category]) {
apisToSaveData[category] = formData[category];
}
});
if (Object.keys(apisToSaveData).length === 0) {
alert('请至少选择一个 API 配置进行保存');
return;
}
const profileId = selectedProfileId || `profile-${Date.now()}`;
const profileName = formData[currentCategory].name || profileId;
try {
await saveProfile(profileId, profileName, apisToSaveData);
setSelectedProfileId(profileId);
setShowSaveModal(false);
// 清除修改标记
setModifiedApis(prev => {
const newModified = { ...prev };
Object.keys(apisToSave).forEach(category => {
if (apisToSave[category]) {
delete newModified[category];
}
});
return newModified;
});
} catch (err) {
console.error('保存失败:', err);
}
};
// 处理重置 (重置为当前激活配置)
const handleReset = () => {
loadActiveConfig();
};
// 新增:处理连接测试并获取模型列表
const handleConnect = async () => {
if (!formData.apiUrl || !formData.apiKey) {
alert('请先填写 API URL 和 API Key');
return;
}
try {
const models = await fetchModels(formData.apiUrl, formData.apiKey, formData.category);
setAvailableModels(models);
if (models.length === 0) {
alert('未找到可用模型');
} else {
showNotification('success', `成功获取 ${models.length} 个模型`);
}
} catch (err) {
alert('连接失败: ' + err.message);
}
};
// 新增:处理模型选择
const handleModelSelect = (selectedModel) => {
setFormData(prev => ({ ...prev, model: selectedModel }));
};
// 处理从列表中选择配置
const handleSelectProfile = async (e) => {
const profileId = e.target.value;
setSelectedProfileId(profileId);
if (profileId) {
const profile = allApis.find(api => api.id === profileId);
if (profile) {
setFormData({ ...profile });
// 自动设置为当前激活配置
await setActiveConfig(formData.category, profileId);
}
} else {
// 如果选择了空,清空表单但保留 category
setFormData(prev => ({
...prev,
id: '',
name: '',
apiUrl: '',
apiKey: '',
model: '',
temperature: 0.7,
maxTokens: 2048,
systemPrompt: ''
}));
}
};
// 处理新建配置
const handleCreateNew = () => {
setFormData(prev => ({
...prev,
id: '',
name: `新配置 ${new Date().toLocaleTimeString()}`,
apiUrl: '',
apiKey: '',
model: ''
}));
setSelectedProfileId('');
};
// 处理删除配置
const handleDelete = async () => {
if (selectedProfileId && confirm('确定要删除此配置吗?')) {
await deleteApiConfig(selectedProfileId);
// 删除后重置表单
if (selectedProfileId === activeMap[formData.category]) {
// 如果删除的是当前激活的配置,重置
handleReset();
} else {
// 否则只清空选中状态
setSelectedProfileId('');
}
}
};
// 处理设为当前激活配置
const handleSetActive = async () => {
if (!formData.id) {
alert('请先保存配置');
return;
}
await setActiveConfig(formData.category, formData.id);
};
// 过滤出当前类别的配置列表
const currentCategoryProfiles = allApis.filter(api => api.category === formData.category);
// 配置区域标签
const configTabs = [
{ id: 'mainLLM', label: '主LLM模型' },
{ id: 'imageModel', label: '生图模型' },
{ id: 'secondaryLLM', label: '副LLM模型' },
{ id: 'ragEmbedding', label: 'RAG嵌入模型' }
];
// 判断API密钥是否是脱敏的
const isApiKeyMasked = formData.apiKey && formData.apiKey.endsWith('****');
return (
<div className="api-config-container">
<h2 className="api-config-title">API配置</h2>
{error && <div className="notification notification-error">{error}</div>}
{notification.show && (
<div className={`notification notification-${notification.type}`}>
{notification.message}
</div>
)}
<div className="api-config-form">
{/* 配置区域标签 */}
<div className="config-tabs">
{configTabs.map(tab => (
<button
key={tab.id}
className={`config-tab ${formData.category === tab.id ? 'active' : ''}`}
onClick={() => handleTabChange(tab.id)}
>
{tab.label}
</button>
))}
</div>
{/* 配置管理区域 - 整合选择和操作 */}
<div className="profile-manager">
<div className="profile-header">
<label htmlFor="profileSelect">配置方案</label>
<div className="profile-controls">
<select
id="profileSelect"
value={selectedProfileId}
onChange={handleSelectProfile}
className="form-control profile-select-input"
>
<option value="">新建配置...</option>
{currentCategoryProfiles.map(profile => (
<option key={profile.id} value={profile.id}>
{profile.name} {activeMap[formData.category] === profile.id ? '●' : ''}
</option>
))}
</select>
<div className="profile-buttons">
{!selectedProfileId ? (
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleCreateNew}
>
+ 新建
</button>
) : (
<>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={handleSetActive}
disabled={activeMap[formData.category] === selectedProfileId}
>
设为默认
</button>
<button
type="button"
className="btn btn-danger btn-sm"
onClick={handleDelete}
>
删除
</button>
</>
)}
</div>
</div>
</div>
</div>
{/* API配置表单 */}
<div className="form-section">
<div className="form-group">
<label htmlFor="name">名称</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="例如GPT-4 生产环境"
className="form-control"
/>
</div>
<div className="form-group">
<label htmlFor="apiUrl">API 地址</label>
<input
type="text"
id="apiUrl"
name="apiUrl"
value={formData.apiUrl}
onChange={handleChange}
placeholder="https://api.openai.com/v1"
className="form-control"
/>
</div>
<div className="form-group">
<label htmlFor="apiKey">密钥</label>
<input
type="password"
id="apiKey"
name="apiKey"
value={isApiKeyMasked ? '' : formData.apiKey}
onChange={handleChange}
placeholder={isApiKeyMasked ? '已保存(留空保持不变)' : 'sk-...'}
className="form-control"
/>
{isApiKeyMasked && (
<span className="form-hint">当前密钥已加密存储</span>
)}
</div>
<div className="form-row">
<div className="form-group flex-1">
<label htmlFor="model">模型</label>
<input
type="text"
id="model"
name="model"
value={formData.model}
onChange={handleChange}
placeholder="gpt-3.5-turbo"
className="form-control"
/>
</div>
<div className="form-group flex-auto">
<label>&nbsp;</label>
<button
type="button"
className="btn btn-secondary btn-full"
onClick={handleConnect}
disabled={loading}
>
{loading ? '连接中...' : '获取模型列表'}
</button>
</div>
</div>
{availableModels.length > 0 && (
<div className="form-group">
<label htmlFor="modelSelect">选择模型</label>
<select
id="modelSelect"
value={formData.model}
onChange={(e) => handleModelSelect(e.target.value)}
className="form-control"
>
<option value="">从列表中选择...</option>
{availableModels.map((model, index) => (
<option key={index} value={model}>
{model}
</option>
))}
</select>
</div>
)}
</div>
{/* 底部操作栏 */}
<div className="form-actions">
<button
type="button"
className="btn btn-text"
onClick={handleReset}
disabled={loading}
>
重置
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleSave}
disabled={loading}
>
{loading ? '保存中...' : '保存配置'}
</button>
</div>
</div>
</div>
);
};
export default ApiConfig;

View File

@@ -0,0 +1,484 @@
// frontend-react/src/components/SideBarLeft/tabs/ApiConfig/ApiConfig.jsx
import React, { useEffect, useState } from 'react';
import useApiConfigStore from '../../../../Store/SideBarLeft/ApiConfigSlice';
import './ApiConfig.css';
const ApiConfig = () => {
// 从store中获取状态和方法
const {
profiles,
currentProfile,
activeMap,
loading,
error,
fetchProfiles,
fetchProfile,
saveProfile,
deleteProfile,
setActiveConfig,
testConnection,
notification
} = useApiConfigStore();
// 本地表单状态 - 包含 4 个 API 配置
const [formData, setFormData] = useState({
mainLLM: { id: '', name: '', apiUrl: '', apiKey: '', model: '' },
imageModel: { id: '', name: '', apiUrl: '', apiKey: '', model: '' },
secondaryLLM: { id: '', name: '', apiUrl: '', apiKey: '', model: '' },
ragEmbedding: { id: '', name: '', apiUrl: '', apiKey: '', model: '' }
});
// 当前选中的配置文件 ID
const [selectedProfileId, setSelectedProfileId] = useState('');
// 当前编辑的类别
const [currentCategory, setCurrentCategory] = useState('mainLLM');
// 可用模型列表
const [availableModels, setAvailableModels] = useState([]);
// 显示保存对话框
const [showSaveModal, setShowSaveModal] = useState(false);
// 要保存的 API 类别(勾选状态)
const [apisToSave, setApisToSave] = useState({
mainLLM: false,
imageModel: false,
secondaryLLM: false,
ragEmbedding: false
});
// 跟踪哪些 API 有修改
const [modifiedApis, setModifiedApis] = useState({});
// 组件加载时获取配置文件列表
useEffect(() => {
fetchProfiles();
}, [fetchProfiles]);
// 当选中配置文件时,加载该配置
useEffect(() => {
if (selectedProfileId) {
loadProfile(selectedProfileId);
}
}, [selectedProfileId]);
// 加载配置文件
const loadProfile = async (profileId) => {
try {
const profile = await fetchProfile(profileId);
// 合并到本地状态(服务器提供的覆盖,未提供的保留本地)
setFormData(prev => ({
...prev,
...profile.apis
}));
// 清除修改标记
setModifiedApis({});
} catch (err) {
console.error('加载配置文件失败:', err);
}
};
// 处理输入变化
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[currentCategory]: {
...prev[currentCategory],
[name]: value
}
}));
// 标记当前类别的 API 已被修改
setModifiedApis(prev => ({
...prev,
[currentCategory]: true
}));
};
// 处理类别切换
const handleTabChange = (categoryId) => {
setCurrentCategory(categoryId);
setAvailableModels([]);
};
// 打开保存对话框
const handleOpenSaveModal = () => {
// 默认选中所有有修改的 API
setApisToSave(modifiedApis);
setShowSaveModal(true);
};
// 处理保存配置文件
const handleSave = async () => {
// 收集要保存的 API 配置
const apisToSaveData = {};
Object.keys(apisToSave).forEach(category => {
if (apisToSave[category]) {
apisToSaveData[category] = formData[category];
}
});
if (Object.keys(apisToSaveData).length === 0) {
alert('请至少选择一个 API 配置进行保存');
return;
}
const profileId = selectedProfileId || `profile-${Date.now()}`;
const profileName = formData[currentCategory].name || profileId;
try {
await saveProfile(profileId, profileName, apisToSaveData);
setSelectedProfileId(profileId);
setShowSaveModal(false);
// 清除修改标记
setModifiedApis(prev => {
const newModified = { ...prev };
Object.keys(apisToSave).forEach(category => {
if (apisToSave[category]) {
delete newModified[category];
}
});
return newModified;
});
} catch (err) {
console.error('保存失败:', err);
}
};
// 处理重置
const handleReset = () => {
if (selectedProfileId) {
loadProfile(selectedProfileId);
} else {
// 清空当前类别的配置
setFormData(prev => ({
...prev,
[currentCategory]: { id: '', name: '', apiUrl: '', apiKey: '', model: '' }
}));
setModifiedApis(prev => {
const newModified = { ...prev };
delete newModified[currentCategory];
return newModified;
});
}
};
// 处理连接测试并获取模型列表
const handleConnect = async () => {
const currentApi = formData[currentCategory];
if (!currentApi.apiUrl || !currentApi.apiKey) {
alert('请先填写 API 地址和密钥');
return;
}
try {
const models = await testConnection(currentApi.apiUrl, currentApi.apiKey, currentCategory);
setAvailableModels(models);
if (models.length === 0) {
alert('未找到可用模型');
}
} catch (err) {
alert('连接失败: ' + err.message);
}
};
// 处理模型选择
const handleModelSelect = (selectedModel) => {
setFormData(prev => ({
...prev,
[currentCategory]: {
...prev[currentCategory],
model: selectedModel
}
}));
};
// 处理从列表中选择配置文件
const handleSelectProfile = (e) => {
const profileId = e.target.value;
setSelectedProfileId(profileId);
};
// 处理新建配置文件
const handleCreateNew = () => {
setSelectedProfileId('');
setFormData(prev => ({
...prev,
[currentCategory]: { id: '', name: '', apiUrl: '', apiKey: '', model: '' }
}));
setModifiedApis({});
};
// 处理删除配置文件
const handleDelete = async () => {
if (selectedProfileId && confirm('确定要删除此配置文件吗?')) {
await deleteProfile(selectedProfileId);
setSelectedProfileId('');
handleCreateNew();
}
};
// 处理设为默认配置
const handleSetActive = async () => {
if (!selectedProfileId) {
alert('请先保存配置文件');
return;
}
await setActiveConfig(currentCategory, selectedProfileId);
};
// 配置区域标签
const configTabs = [
{ id: 'mainLLM', label: '主LLM模型' },
{ id: 'imageModel', label: '生图模型' },
{ id: 'secondaryLLM', label: '副LLM模型' },
{ id: 'ragEmbedding', label: 'RAG嵌入模型' }
];
// 判断当前 API 密钥是否是脱敏的
const isApiKeyMasked = formData[currentCategory].apiKey && formData[currentCategory].apiKey.endsWith('****');
// 计算有修改的 API 数量
const modifiedCount = Object.keys(modifiedApis).filter(k => modifiedApis[k]).length;
return (
<div className="api-config-container">
<h2 className="api-config-title">API配置</h2>
{error && <div className="notification notification-error">{error}</div>}
{notification.show && (
<div className={`notification notification-${notification.type}`}>
{notification.message}
</div>
)}
<div className="api-config-form">
{/* 配置区域标签 */}
<div className="config-tabs">
{configTabs.map(tab => (
<button
key={tab.id}
className={`config-tab ${currentCategory === tab.id ? 'active' : ''}`}
onClick={() => handleTabChange(tab.id)}
>
{tab.label}
{modifiedApis[tab.id] && <span className="modified-indicator"></span>}
</button>
))}
</div>
{/* 配置管理区域 */}
<div className="profile-manager">
<div className="profile-header">
<label htmlFor="profileSelect">配置文件</label>
<div className="profile-controls">
<select
id="profileSelect"
value={selectedProfileId}
onChange={handleSelectProfile}
className="form-control profile-select-input"
>
<option value="">新建配置文件...</option>
{profiles.map(profile => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</select>
<div className="profile-buttons">
{!selectedProfileId ? (
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleCreateNew}
>
+ 新建
</button>
) : (
<>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={handleSetActive}
disabled={activeMap[currentCategory] === selectedProfileId}
>
设为默认
</button>
<button
type="button"
className="btn btn-danger btn-sm"
onClick={handleDelete}
>
删除
</button>
</>
)}
</div>
</div>
</div>
</div>
{/* API配置表单 */}
<div className="form-section">
<div className="form-group">
<label htmlFor="name">名称</label>
<input
type="text"
id="name"
name="name"
value={formData[currentCategory].name}
onChange={handleChange}
placeholder="例如GPT-4 生产环境"
className="form-control"
/>
</div>
<div className="form-group">
<label htmlFor="apiUrl">API 地址</label>
<input
type="text"
id="apiUrl"
name="apiUrl"
value={formData[currentCategory].apiUrl}
onChange={handleChange}
placeholder="https://api.openai.com/v1"
className="form-control"
/>
</div>
<div className="form-group">
<label htmlFor="apiKey">密钥</label>
<input
type="password"
id="apiKey"
name="apiKey"
value={isApiKeyMasked ? '' : formData[currentCategory].apiKey}
onChange={handleChange}
placeholder={isApiKeyMasked ? '已保存(留空保持不变)' : 'sk-...'}
className="form-control"
/>
{isApiKeyMasked && (
<span className="form-hint">当前密钥已加密存储</span>
)}
</div>
<div className="form-row">
<div className="form-group flex-1">
<label htmlFor="model">模型</label>
<input
type="text"
id="model"
name="model"
value={formData[currentCategory].model}
onChange={handleChange}
placeholder="gpt-3.5-turbo"
className="form-control"
/>
</div>
<div className="form-group flex-auto">
<label>&nbsp;</label>
<button
type="button"
className="btn btn-secondary btn-full"
onClick={handleConnect}
disabled={loading}
>
{loading ? '连接中...' : '获取模型列表'}
</button>
</div>
</div>
{availableModels.length > 0 && (
<div className="form-group">
<label htmlFor="modelSelect">选择模型</label>
<select
id="modelSelect"
value={formData[currentCategory].model}
onChange={(e) => handleModelSelect(e.target.value)}
className="form-control"
>
<option value="">从列表中选择...</option>
{availableModels.map((model, index) => (
<option key={index} value={model}>
{model}
</option>
))}
</select>
</div>
)}
</div>
{/* 底部操作栏 */}
<div className="form-actions">
<button
type="button"
className="btn btn-text"
onClick={handleReset}
disabled={loading}
>
重置
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleOpenSaveModal}
disabled={loading || modifiedCount === 0}
>
{loading ? '保存中...' : modifiedCount > 0 ? `保存配置 (${modifiedCount})` : '保存配置'}
</button>
</div>
</div>
{/* 保存对话框 */}
{showSaveModal && (
<div className="modal-overlay">
<div className="modal-content">
<h3 className="modal-title">选择要保存的配置</h3>
<div className="modal-body">
{configTabs.map(tab => (
<label key={tab.id} className="checkbox-label">
<input
type="checkbox"
checked={apisToSave[tab.id] || false}
onChange={(e) => setApisToSave(prev => ({
...prev,
[tab.id]: e.target.checked
}))}
/>
<span className="checkbox-text">
{tab.label}
{modifiedApis[tab.id] && <span className="modified-badge">已修改</span>}
</span>
</label>
))}
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-text"
onClick={() => setShowSaveModal(false)}
>
取消
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleSave}
>
保存选中的配置
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default ApiConfig;

View File

@@ -0,0 +1,160 @@
// frontend-react/src/components/SideBarLeft/tabs/ApiConfig/ComfyUIWorkflowManager.jsx
import React, { useState, useEffect } from 'react';
import './ApiConfig.css';
const ComfyUIWorkflowManager = ({ apiUrl }) => {
const [workflows, setWorkflows] = useState([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
// 加载工作流列表
const loadWorkflows = async () => {
try {
setLoading(true);
const response = await fetch('/api/api-config/comfyui/workflows');
if (!response.ok) throw new Error('Failed to load workflows');
const data = await response.json();
setWorkflows(data);
} catch (err) {
console.error('加载工作流失败:', err);
alert('加载工作流失败: ' + err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadWorkflows();
}, []);
// 上传工作流文件
const handleUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!file.name.endsWith('.json')) {
alert('请上传 JSON 文件');
return;
}
const formData = new FormData();
formData.append('file', file);
try {
setUploading(true);
const response = await fetch('/api/api-config/comfyui/workflows/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Upload failed');
}
const result = await response.json();
alert(`上传成功: ${result.filename}`);
// 重新加载列表
await loadWorkflows();
} catch (err) {
console.error('上传失败:', err);
alert('上传失败: ' + err.message);
} finally {
setUploading(false);
// 清空文件输入
e.target.value = '';
}
};
// 删除工作流
const handleDelete = async (filename) => {
if (!confirm(`确定要删除工作流 "${filename}" 吗?`)) {
return;
}
try {
const response = await fetch(`/api/api-config/comfyui/workflows/${filename}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Delete failed');
}
const result = await response.json();
alert(result.message);
// 重新加载列表
await loadWorkflows();
} catch (err) {
console.error('删除失败:', err);
alert('删除失败: ' + err.message);
}
};
// 格式化文件大小
const formatSize = (bytes) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
};
return (
<div className="workflow-manager-compact">
<div className="workflow-header-compact">
<span className="workflow-label">工作流</span>
<div className="workflow-actions-compact">
<label className="btn-icon" title="导入工作流">
<input
type="file"
accept=".json"
onChange={handleUpload}
disabled={uploading}
style={{ display: 'none' }}
/>
{uploading ? '⏳' : '📤'}
</label>
<button
className="btn-icon"
onClick={loadWorkflows}
disabled={loading}
title="刷新"
>
{loading ? '⏳' : '🔄'}
</button>
</div>
</div>
<div className="workflow-list-compact">
{workflows.length === 0 ? (
<div className="empty-hint">无工作流</div>
) : (
workflows.map((workflow) => (
<div key={workflow.filename} className="workflow-item-compact">
<span className="workflow-name-compact">
{workflow.filename === 'default_txt2img.json' && (
<span className="badge-default">默认</span>
)}
{workflow.name}
</span>
{workflow.filename !== 'default_txt2img.json' && (
<button
className="btn-icon-danger"
onClick={() => handleDelete(workflow.filename)}
title="删除"
>
🗑
</button>
)}
</div>
))
)}
</div>
</div>
);
};
export default ComfyUIWorkflowManager;

View File

@@ -0,0 +1 @@
export { default } from './ApiConfig';

View File

@@ -0,0 +1,115 @@
/* ==================== 角色卡页面 - 简洁现代化风格 ==================== */
.character-card-content {
display: flex;
flex-direction: column;
height: 100%;
padding: 8px;
gap: 8px;
background: var(--color-bg-primary);
overflow-y: auto;
}
/* 标题栏 */
.tab-header {
display: flex;
align-items: center;
padding: 6px 8px;
background: var(--color-bg-tertiary);
border-radius: 4px;
border: 1px solid var(--color-border-light);
}
.title-text {
font-size: 12px;
font-weight: 600;
color: var(--color-text-primary);
}
/* 操作按钮 */
.tab-actions {
display: flex;
gap: 6px;
padding: 6px 8px;
}
.action-btn {
padding: 6px 12px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 12px;
transition: all 0.15s ease;
font-weight: 500;
flex: 1;
}
.action-btn:hover {
background: var(--color-accent);
border-color: var(--color-accent);
color: white;
}
/* 角色列表 */
.character-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px;
}
.character-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.character-item:hover {
border-color: var(--color-accent);
background: var(--color-bg-tertiary);
}
.character-avatar {
font-size: 24px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-primary);
border-radius: 50%;
border: 1px solid var(--color-border);
}
.character-info {
flex: 1;
min-width: 0;
}
.character-name {
font-size: 13px;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.character-desc {
font-size: 11px;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,41 @@
// frontend-react/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.jsx
import React, { useState } from 'react';
import './CharacterCard.css';
const CharacterCard = () => {
const [characters, setCharacters] = useState([
{ id: 1, name: '冒险者', description: '勇敢的探险家', avatar: '🗡️' },
{ id: 2, name: '魔法师', description: '精通奥术', avatar: '🔮' },
{ id: 3, name: '商人', description: '精明的交易者', avatar: '💰' },
]);
return (
<div className="character-card-content">
{/* 标题栏 */}
<div className="tab-header">
<span className="title-text">角色卡</span>
</div>
{/* 操作按钮 */}
<div className="tab-actions">
<button className="action-btn">+ 新建</button>
<button className="action-btn">📥 导入</button>
</div>
{/* 角色列表 */}
<div className="character-list">
{characters.map(char => (
<div key={char.id} className="character-item">
<span className="character-avatar">{char.avatar}</span>
<div className="character-info">
<div className="character-name">{char.name}</div>
<div className="character-desc">{char.description}</div>
</div>
</div>
))}
</div>
</div>
);
};
export default CharacterCard;

View File

@@ -0,0 +1,2 @@
// frontend-react/src/components/SideBarLeft/tabs/CharacterCard/index.js
export { default } from './CharacterCard';

View File

@@ -0,0 +1,13 @@
import React from 'react';
import './Gallery.css';
const Gallery = () => {
return (
<div className="gallery-content">
<h2>画廊</h2>
{/* 在这里实现画廊的具体内容 */}
</div>
);
};
export default Gallery;

View File

@@ -0,0 +1 @@
export { default } from './Gallery';

View File

@@ -0,0 +1,735 @@
/* 主容器 */
.preset-panel {
display: flex;
flex-direction: column;
height: 100%;
padding: 12px;
gap: 12px;
background: #f8f9fa;
overflow-y: auto;
}
/* 工具提示 */
.tooltip {
position: fixed;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.85);
color: white;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
z-index: 1000;
pointer-events: none;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
transform: translate(-50%, -100%);
margin-top: -6px;
}
/* 预设头部 */
.preset-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background: white;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.preset-select-container {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
}
.preset-label {
font-size: 12px;
font-weight: 600;
color: #2c3e50;
white-space: nowrap;
}
.preset-select {
flex: 1;
padding: 6px 10px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
font-size: 12px;
color: #495057;
cursor: pointer;
transition: all 0.2s ease;
}
.preset-select:hover {
border-color: #ced4da;
background: white;
}
.preset-select:focus {
outline: none;
border-color: #4a6cf7;
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
}
.preset-select:disabled {
background: #f1f3f5;
cursor: not-allowed;
color: #adb5bd;
}
/* 操作按钮 */
.preset-actions {
display: flex;
gap: 6px;
}
.preset-action-btn {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
background: white;
border: 1px solid #e9ecef;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
}
.preset-action-btn:hover {
background: #f8f9fa;
border-color: #ced4da;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.preset-action-btn:active {
transform: translateY(0);
}
/* 对话框 */
.preset-save-dialog,
.preset-edit-dialog,
.preset-import-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 16px;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
min-width: 280px;
}
.preset-save-dialog input,
.preset-edit-dialog input {
width: 100%;
padding: 8px 10px;
margin-bottom: 12px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
font-size: 13px;
transition: all 0.2s ease;
}
.preset-save-dialog input:focus,
.preset-edit-dialog input:focus {
outline: none;
border-color: #4a6cf7;
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
}
.preset-import-dialog textarea {
width: 100%;
padding: 8px 10px;
margin-bottom: 12px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
font-size: 12px;
font-family: inherit;
resize: vertical;
min-height: 100px;
}
.preset-import-dialog textarea:focus {
outline: none;
border-color: #4a6cf7;
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
}
.dialog-buttons {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.dialog-buttons button {
padding: 6px 12px;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.dialog-buttons button:first-child {
background: #4a6cf7;
color: white;
}
.dialog-buttons button:first-child:hover {
background: #3a5ce5;
}
.dialog-buttons button:last-child {
background: #f1f3f5;
color: #495057;
}
.dialog-buttons button:last-child:hover {
background: #e9ecef;
}
/* 组件编辑对话框 */
.component-edit-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
min-width: 450px;
max-width: 80vw;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #e9ecef;
}
.dialog-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #2c3e50;
}
.dialog-header .close-btn {
background: none;
border: none;
font-size: 20px;
color: #adb5bd;
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s ease;
}
.dialog-header .close-btn:hover {
color: #495057;
}
.dialog-content {
padding: 16px;
flex: 1;
overflow-y: auto;
}
.component-textarea {
width: 100%;
padding: 10px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
font-size: 13px;
font-family: inherit;
line-height: 1.5;
resize: none;
transition: all 0.2s ease;
}
.component-textarea:focus {
outline: none;
border-color: #4a6cf7;
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
}
.component-textarea[readonly] {
background: #f1f3f5;
cursor: default;
}
.dialog-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-top: 1px solid #e9ecef;
}
.token-count {
font-size: 11px;
color: #6c757d;
font-weight: 500;
}
/* 参数设置区域 */
.preset-parameters-container {
background: white;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.parameters-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: #f8f9fa;
cursor: pointer;
transition: background 0.2s ease;
}
.parameters-header:hover {
background: #f1f3f5;
}
.parameters-header span:first-child {
font-size: 13px;
font-weight: 600;
color: #2c3e50;
}
.expand-icon {
font-size: 10px;
color: #6c757d;
transition: transform 0.3s ease;
}
.expand-icon.expanded {
transform: rotate(180deg);
}
.preset-parameters {
padding: 12px;
}
.parameter-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.parameter-row:last-child {
margin-bottom: 0;
}
.parameter-label {
width: 100px;
font-size: 12px;
font-weight: 500;
color: #495057;
}
.parameter-slider {
flex: 1;
-webkit-appearance: none;
height: 4px;
background: #e9ecef;
border-radius: 2px;
outline: none;
cursor: pointer;
}
.parameter-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: #4a6cf7;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
.parameter-slider::-webkit-slider-thumb:hover {
background: #3a5ce5;
transform: scale(1.1);
}
.parameter-slider::-moz-range-thumb {
width: 14px;
height: 14px;
background: #4a6cf7;
border: none;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
.parameter-slider::-moz-range-thumb:hover {
background: #3a5ce5;
transform: scale(1.1);
}
.parameter-number {
width: 60px;
padding: 4px 6px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
font-size: 12px;
text-align: center;
transition: all 0.2s ease;
}
.parameter-number:focus {
outline: none;
border-color: #4a6cf7;
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
}
.parameter-input {
flex: 1;
padding: 6px 10px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
font-size: 12px;
transition: all 0.2s ease;
}
.parameter-input:focus {
outline: none;
border-color: #4a6cf7;
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
}
.parameter-toggles {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e9ecef;
}
.toggle-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.toggle-label {
font-size: 12px;
font-weight: 500;
color: #495057;
}
.toggle-checkbox {
width: 38px;
height: 20px;
-webkit-appearance: none;
background: #e9ecef;
border-radius: 10px;
position: relative;
cursor: pointer;
transition: all 0.3s ease;
}
.toggle-checkbox::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
top: 2px;
left: 2px;
transition: all 0.3s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
.toggle-checkbox:checked {
background: #4a6cf7;
}
.toggle-checkbox:checked::after {
left: 20px;
}
/* 预设组件区域 */
.preset-components-section {
flex: 1;
display: flex;
flex-direction: column;
background: white;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.components-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
.components-header h3 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #2c3e50;
}
.add-component-btn {
padding: 4px 10px;
background: #4a6cf7;
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.add-component-btn:hover {
background: #3a5ce5;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(74, 108, 247, 0.2);
}
.components-list {
flex: 1;
overflow-y: auto;
padding: 6px;
}
.drag-indicator {
height: 3px;
background: #4a6cf7;
border-radius: 2px;
opacity: 0;
transition: opacity 0.2s ease;
margin: 3px 0;
}
.drag-indicator.visible {
opacity: 0.5;
}
.prompt-component-item {
display: flex;
align-items: center;
padding: 8px 10px;
margin-bottom: 6px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
transition: all 0.2s ease;
cursor: grab;
}
.prompt-component-item:hover {
background: white;
border-color: #ced4da;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.prompt-component-item.disabled {
opacity: 0.6;
}
.prompt-component-item.marker {
background: rgba(74, 108, 247, 0.05);
border-color: rgba(74, 108, 247, 0.2);
}
.prompt-component-item.dragging {
opacity: 0.5;
cursor: grabbing;
}
.component-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.component-controls {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.drag-handle {
color: #adb5bd;
cursor: grab;
font-size: 12px;
user-select: none;
transition: color 0.2s ease;
}
.drag-handle:hover {
color: #6c757d;
}
.toggle-btn {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: white;
border: 1px solid #e9ecef;
border-radius: 3px;
cursor: pointer;
font-size: 10px;
transition: all 0.2s ease;
}
.toggle-btn:hover {
border-color: #ced4da;
background: #f8f9fa;
}
.toggle-btn.enabled {
background: #4a6cf7;
color: white;
border-color: #4a6cf7;
}
.toggle-btn.enabled:hover {
background: #3a5ce5;
}
.component-name {
font-size: 12px;
font-weight: 500;
color: #2c3e50;
flex: 1;
}
.component-marker-badge {
padding: 1px 6px;
background: rgba(74, 108, 247, 0.1);
color: #4a6cf7;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
}
.component-actions {
display: flex;
align-items: center;
gap: 6px;
}
.edit-btn {
padding: 3px 6px;
background: white;
border: 1px solid #e9ecef;
border-radius: 3px;
font-size: 10px;
font-weight: 500;
color: #495057;
cursor: pointer;
transition: all 0.2s ease;
}
.edit-btn:hover {
background: #f8f9fa;
border-color: #ced4da;
}
.delete-btn {
padding: 3px 6px;
background: white;
border: 1px solid #e9ecef;
border-radius: 3px;
font-size: 10px;
font-weight: 500;
color: #dc3545;
cursor: pointer;
transition: all 0.2s ease;
}
.delete-btn:hover {
background: #fff5f5;
border-color: #ff6b6b;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-bg-tertiary);
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: var(--color-scrollbar);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-scrollbar-hover);
}
/* 响应式设计 */
@media (max-width: 768px) {
.preset-panel {
padding: 10px;
gap: 10px;
}
.preset-header {
flex-direction: column;
align-items: stretch;
}
.preset-select-container {
flex-direction: column;
align-items: stretch;
}
.preset-actions {
justify-content: center;
}
.component-edit-dialog {
min-width: 85vw;
}
}

View File

@@ -0,0 +1,801 @@
import React, { useState, useEffect } from 'react';
import usePresetStore from '../../../../Store/SideBarLeft/PresetSlice';
import useSideBarLeftStore from '../../../../Store/SideBarLeft/SideBarLeftSlice';
import './Presets.css';
const PresetPanel = () => {
const {
selectedPreset,
parameters,
presets,
isLoadingPresets,
promptComponents,
setSelectedPreset,
updateParameter,
saveCurrentAsPreset,
editPresetName: updatePresetName,
isParametersExpanded,
toggleParametersExpanded,
fetchPresets,
setPromptComponents,
toggleComponentEnabled,
updateComponent,
addComponent,
removeComponent,
moveComponent
} = usePresetStore();
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
const [showComponentEditDialog, setShowComponentEditDialog] = useState(false);
const [newPresetName, setNewPresetName] = useState('');
const [editPresetId, setEditPresetId] = useState('');
const [editPresetName, setEditPresetName] = useState('');
const [importPresetData, setImportPresetData] = useState('');
const [tooltip, setTooltip] = useState({ visible: false, content: '', x: 0, y: 0 });
// 组件编辑状态
const [editingComponentIndex, setEditingComponentIndex] = useState(-1);
const [editComponentContent, setEditComponentContent] = useState('');
const [isEditing, setIsEditing] = useState(false);
// 拖拽状态
const [draggedItem, setDraggedItem] = useState(null);
const [dragOverItem, setDragOverItem] = useState(null);
// 获取当前激活的分页
const { activeTab } = useSideBarLeftStore();
// 记录上一次的分页状态
const prevActiveTabRef = React.useRef(activeTab);
// 参数描述映射
const parameterDescriptions = {
temperature: "生成温度,控制随机性(0-2)",
frequency_penalty: "频率惩罚降低重复token概率",
presence_penalty: "存在惩罚,鼓励谈论新话题",
top_p: "核采样,控制词汇选择范围",
top_k: "随机采样范围从概率最高的K个词中选择",
max_context: "上下文窗口大小(Token上限)",
max_tokens: "单次回复的最大长度",
max_context_unlocked: "是否允许超出限制的上下文",
stream_openai: "是否使用流式输出",
seed: "随机种子(-1为随机)",
n: "生成回复的数量"
};
// 显示工具提示
const showTooltip = (event, content) => {
setTooltip({
visible: true,
content,
x: event.clientX,
y: event.clientY
});
};
// 隐藏工具提示
const hideTooltip = () => {
setTooltip({ ...tooltip, visible: false });
};
// 处理参数更新
const handleParameterChange = (name, value) => {
let convertedValue = value;
if (name === 'temperature' || name === 'frequency_penalty' || name === 'presence_penalty' || name === 'top_p') {
convertedValue = parseFloat(value);
} else if (name === 'top_k' || name === 'max_context' || name === 'max_tokens' || name === 'seed' || name === 'n') {
convertedValue = parseInt(value, 10);
} else if (name === 'max_context_unlocked' || name === 'stream_openai') {
convertedValue = value;
}
updateParameter({ name, value: convertedValue });
};
// 加载预设列表 - 只在切换到预设分页时刷新
useEffect(() => {
// 检测是否从其他分页切换到预设分页
if (activeTab === 'presets' && prevActiveTabRef.current !== 'presets') {
fetchPresets();
}
// 更新上一次的分页状态
prevActiveTabRef.current = activeTab;
}, [activeTab, fetchPresets]);
// 保存当前设置为预设
const handleSavePreset = async () => {
if (newPresetName.trim()) {
try {
await saveCurrentAsPreset({ name: newPresetName });
setNewPresetName('');
setShowSaveDialog(false);
// 重新加载预设列表
fetchPresets();
} catch (error) {
console.error('保存预设失败:', error);
alert('保存预设失败: ' + error.message);
}
}
};
// 编辑预设名称
const handleEditPreset = async () => {
if (editPresetId && editPresetName.trim()) {
try {
await updatePresetName(editPresetId, editPresetName);
setEditPresetId('');
setEditPresetName('');
setShowEditDialog(false);
// 重新加载预设列表
fetchPresets();
} catch (error) {
console.error('编辑预设名称失败:', error);
alert('编辑预设名称失败: ' + error.message);
}
}
};
// 导入预设
const handleImportPreset = async () => {
try {
const importedPreset = JSON.parse(importPresetData);
if (importedPreset.name && importedPreset.parameters) {
await saveCurrentAsPreset({ name: importedPreset.name });
// 更新参数
Object.keys(importedPreset.parameters).forEach(key => {
updateParameter({ name: key, value: importedPreset.parameters[key] });
});
// 更新组件列表
if (importedPreset.promptComponents) {
setPromptComponents(importedPreset.promptComponents);
}
setImportPresetData('');
setShowImportDialog(false);
// 重新加载预设列表
fetchPresets();
}
} catch (error) {
console.error('导入预设失败:', error);
alert('导入预设失败: ' + error.message);
}
};
// 导出预设
const handleExportPreset = () => {
if (selectedPreset) {
const preset = presets.find(p => p.id === selectedPreset);
if (preset) {
const exportData = {
...preset,
parameters,
promptComponents
};
const dataStr = JSON.stringify(exportData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `${preset.name}.json`;
link.click();
URL.revokeObjectURL(url);
}
}
};
// 添加新组件
const handleAddNewComponent = () => {
const newComponent = {
identifier: `component_${Date.now()}`,
name: '新组件',
content: '',
role: 0,
system_prompt: false,
marker: false,
enabled: true
};
addComponent(newComponent);
};
// 开始编辑组件
const handleStartEditComponent = (index) => {
setEditingComponentIndex(index);
setEditComponentContent(promptComponents[index].content);
setIsEditing(true);
setShowComponentEditDialog(true);
};
// 查看组件内容
const handleViewComponent = (index) => {
setEditingComponentIndex(index);
setEditComponentContent(promptComponents[index].content);
setIsEditing(false);
setShowComponentEditDialog(true);
};
// 保存组件编辑
const handleSaveComponentEdit = () => {
if (editingComponentIndex >= 0 && isEditing) {
updateComponent(editingComponentIndex, { content: editComponentContent });
}
setEditingComponentIndex(-1);
setEditComponentContent('');
setShowComponentEditDialog(false);
};
// 取消组件编辑
const handleCancelComponentEdit = () => {
setEditingComponentIndex(-1);
setEditComponentContent('');
setShowComponentEditDialog(false);
};
// 关闭组件查看对话框
const handleCloseComponentView = () => {
setEditingComponentIndex(-1);
setEditComponentContent('');
setShowComponentEditDialog(false);
};
// 切换组件启用状态
const handleToggleComponentEnabled = (index) => {
toggleComponentEnabled(index);
};
// 删除组件
const handleDeleteComponent = (index) => {
if (window.confirm('确定要删除这个组件吗?')) {
removeComponent(index);
}
};
// 拖拽开始
const handleDragStart = (e, index) => {
setDraggedItem(index);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', index.toString());
setTimeout(() => {
e.target.classList.add('dragging');
}, 0);
};
// 拖拽结束
const handleDragEnd = (e) => {
setDraggedItem(null);
setDragOverItem(null);
e.target.classList.remove('dragging');
};
// 拖拽经过
const handleDragOver = (e, index) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (draggedItem === null || draggedItem === index) return;
setDragOverItem(index);
};
// 放置
const handleDrop = (e, index) => {
e.preventDefault();
if (draggedItem === null || draggedItem === index) return;
moveComponent(draggedItem, index);
setDraggedItem(null);
setDragOverItem(null);
};
return (
<div className="preset-panel">
{/* 工具提示 */}
{tooltip.visible && (
<div
className="tooltip"
style={{ left: `${tooltip.x}px`, top: `${tooltip.y}px` }}
>
{tooltip.content}
</div>
)}
{/* 顶部:预设选择与操作 */}
<div className="preset-header">
{/* 预设选择下拉框 */}
<div className="preset-select-container">
<label
className="preset-label"
onMouseEnter={(e) => showTooltip(e, "选择预设配置")}
onMouseLeave={hideTooltip}
>
预设:
</label>
<select
className="preset-select"
value={selectedPreset}
onChange={(e) => setSelectedPreset(e.target.value)}
disabled={isLoadingPresets}
>
<option value="">{isLoadingPresets ? "加载中..." : "选择预设..."}</option>
{presets.map(preset => (
<option key={preset.id} value={preset.id}>{preset.name}</option>
))}
</select>
</div>
{/* 操作按钮 */}
<div className="preset-actions">
<button
className="preset-action-btn"
onClick={() => setShowSaveDialog(true)}
onMouseEnter={(e) => showTooltip(e, "保存当前设置为新预设")}
onMouseLeave={hideTooltip}
>
💾
</button>
<button
className="preset-action-btn"
onClick={() => {
if (selectedPreset) {
const preset = presets.find(p => p.id === selectedPreset);
if (preset) {
setEditPresetId(selectedPreset);
setEditPresetName(preset.name);
setShowEditDialog(true);
}
}
}}
onMouseEnter={(e) => showTooltip(e, "编辑当前预设")}
onMouseLeave={hideTooltip}
>
</button>
<button
className="preset-action-btn"
onClick={() => setShowImportDialog(true)}
onMouseEnter={(e) => showTooltip(e, "导入预设")}
onMouseLeave={hideTooltip}
>
📥
</button>
<button
className="preset-action-btn"
onClick={handleExportPreset}
onMouseEnter={(e) => showTooltip(e, "导出当前预设")}
onMouseLeave={hideTooltip}
>
📤
</button>
</div>
</div>
{/* 保存预设对话框 */}
{showSaveDialog && (
<div className="preset-save-dialog">
<input
type="text"
value={newPresetName}
onChange={(e) => setNewPresetName(e.target.value)}
placeholder="预设名称"
/>
<div className="dialog-buttons">
<button onClick={handleSavePreset}>保存</button>
<button onClick={() => setShowSaveDialog(false)}>取消</button>
</div>
</div>
)}
{/* 编辑预设对话框 */}
{showEditDialog && (
<div className="preset-edit-dialog">
<input
type="text"
value={editPresetName}
onChange={(e) => setEditPresetName(e.target.value)}
placeholder="预设名称"
/>
<div className="dialog-buttons">
<button onClick={handleEditPreset}>保存</button>
<button onClick={() => setShowEditDialog(false)}>取消</button>
</div>
</div>
)}
{/* 导入预设对话框 */}
{showImportDialog && (
<div className="preset-import-dialog">
<textarea
className="import-textarea"
value={importPresetData}
onChange={(e) => setImportPresetData(e.target.value)}
placeholder="粘贴预设JSON数据"
rows="5"
/>
<div className="dialog-buttons">
<button onClick={handleImportPreset}>导入</button>
<button onClick={() => setShowImportDialog(false)}>取消</button>
</div>
</div>
)}
{/* 编辑/查看组件内容对话框 */}
{showComponentEditDialog && (
<div className="component-edit-dialog">
<div className="dialog-header">
<h3>{isEditing ? '编辑' : '查看'}组件: {editingComponentIndex >= 0 && promptComponents[editingComponentIndex].name}</h3>
<button className="close-btn" onClick={handleCloseComponentView}>×</button>
</div>
<div className="dialog-content">
<textarea
value={editComponentContent}
onChange={(e) => setEditComponentContent(e.target.value)}
className="component-textarea"
readOnly={!isEditing}
rows={20}
/>
</div>
<div className="dialog-footer">
<span className="token-count">
{editComponentContent ? editComponentContent.length : 0}
</span>
{isEditing && (
<div className="dialog-buttons">
<button onClick={handleSaveComponentEdit}>保存</button>
<button onClick={handleCancelComponentEdit}>取消</button>
</div>
)}
</div>
</div>
)}
{/* 参数设置区域 */}
<div className="preset-parameters-container">
<div
className="parameters-header"
onClick={toggleParametersExpanded}
>
<span>参数设置</span>
<span className={`expand-icon ${isParametersExpanded ? 'expanded' : ''}`}></span>
</div>
{isParametersExpanded && (
<div className="preset-parameters">
{/* 温度滑块 */}
<div className="parameter-row">
<label
className="parameter-label"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.temperature)}
onMouseLeave={hideTooltip}
>
Temperature
</label>
<input
type="range"
min="0"
max="2"
step="0.1"
value={parameters.temperature}
onChange={(e) => handleParameterChange('temperature', e.target.value)}
className="parameter-slider"
/>
<input
type="number"
min="0"
max="2"
step="0.1"
value={parameters.temperature}
onChange={(e) => handleParameterChange('temperature', e.target.value)}
className="parameter-number"
/>
</div>
{/* 频率惩罚滑块 */}
<div className="parameter-row">
<label
className="parameter-label"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.frequency_penalty)}
onMouseLeave={hideTooltip}
>
Frequency Penalty
</label>
<input
type="range"
min="-2"
max="2"
step="0.1"
value={parameters.frequency_penalty}
onChange={(e) => handleParameterChange('frequency_penalty', e.target.value)}
className="parameter-slider"
/>
<input
type="number"
min="-2"
max="2"
step="0.1"
value={parameters.frequency_penalty}
onChange={(e) => handleParameterChange('frequency_penalty', e.target.value)}
className="parameter-number"
/>
</div>
{/* 存在惩罚滑块 */}
<div className="parameter-row">
<label
className="parameter-label"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.presence_penalty)}
onMouseLeave={hideTooltip}
>
Presence Penalty
</label>
<input
type="range"
min="-2"
max="2"
step="0.1"
value={parameters.presence_penalty}
onChange={(e) => handleParameterChange('presence_penalty', e.target.value)}
className="parameter-slider"
/>
<input
type="number"
min="-2"
max="2"
step="0.1"
value={parameters.presence_penalty}
onChange={(e) => handleParameterChange('presence_penalty', e.target.value)}
className="parameter-number"
/>
</div>
{/* Top P 滑块 */}
<div className="parameter-row">
<label
className="parameter-label"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.top_p)}
onMouseLeave={hideTooltip}
>
Top P
</label>
<input
type="range"
min="0"
max="1"
step="0.05"
value={parameters.top_p}
onChange={(e) => handleParameterChange('top_p', e.target.value)}
className="parameter-slider"
/>
<input
type="number"
min="0"
max="1"
step="0.05"
value={parameters.top_p}
onChange={(e) => handleParameterChange('top_p', e.target.value)}
className="parameter-number"
/>
</div>
{/* Top K 输入框 */}
<div className="parameter-row">
<label
className="parameter-label"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.top_k)}
onMouseLeave={hideTooltip}
>
Top K
</label>
<input
type="number"
min="0"
value={parameters.top_k}
onChange={(e) => handleParameterChange('top_k', e.target.value)}
className="parameter-input"
/>
</div>
{/* 最大上下文输入框 */}
<div className="parameter-row">
<label
className="parameter-label"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.max_context)}
onMouseLeave={hideTooltip}
>
Max Context
</label>
<input
type="number"
min="1"
max="10000000"
value={parameters.max_context}
onChange={(e) => handleParameterChange('max_context', e.target.value)}
className="parameter-input"
/>
</div>
{/* 最大Token输入框 */}
<div className="parameter-row">
<label
className="parameter-label"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.max_tokens)}
onMouseLeave={hideTooltip}
>
Max Tokens
</label>
<input
type="number"
min="1"
max="100000"
value={parameters.max_tokens}
onChange={(e) => handleParameterChange('max_tokens', e.target.value)}
className="parameter-input"
/>
</div>
{/* 随机种子输入框 */}
<div className="parameter-row">
<label
className="parameter-label"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.seed)}
onMouseLeave={hideTooltip}
>
Seed
</label>
<input
type="number"
value={parameters.seed}
onChange={(e) => handleParameterChange('seed', e.target.value)}
className="parameter-input"
/>
</div>
{/* 生成数量输入框 */}
<div className="parameter-row">
<label
className="parameter-label"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.n)}
onMouseLeave={hideTooltip}
>
N (生成数量)
</label>
<input
type="number"
min="1"
value={parameters.n}
onChange={(e) => handleParameterChange('n', e.target.value)}
className="parameter-input"
/>
</div>
{/* 开关选项 */}
<div className="parameter-toggles">
<div className="toggle-row">
<label
className="toggle-label"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.max_context_unlocked)}
onMouseLeave={hideTooltip}
>
Max Context Unlocked
</label>
<input
type="checkbox"
checked={parameters.max_context_unlocked}
onChange={(e) => handleParameterChange('max_context_unlocked', e.target.checked)}
className="toggle-checkbox"
/>
</div>
<div className="toggle-row">
<label
className="toggle-label"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.stream_openai)}
onMouseLeave={hideTooltip}
>
Stream Output
</label>
<input
type="checkbox"
checked={parameters.stream_openai}
onChange={(e) => handleParameterChange('stream_openai', e.target.checked)}
className="toggle-checkbox"
/>
</div>
</div>
</div>
)}
</div>
{/* 预设组件列表 */}
<div className="preset-components-section">
<div className="components-header">
<h3>预设组件</h3>
<button
onClick={handleAddNewComponent}
className="add-component-btn"
onMouseEnter={(e) => showTooltip(e, "添加新组件")}
onMouseLeave={hideTooltip}
>
+ 添加组件
</button>
</div>
<div className="components-list draggable-container">
{promptComponents.map((component, index) => (
<React.Fragment key={component.identifier}>
{/* 拖拽指示器 - 在组件上方 */}
<div
className={`drag-indicator ${dragOverItem === index ? 'visible' : ''}`}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={(e) => handleDrop(e, index)}
/>
{/* 组件项 */}
<div
className={`prompt-component-item ${!component.enabled ? 'disabled' : ''} ${component.marker ? 'marker' : ''} ${draggedItem === index ? 'dragging' : ''}`}
draggable={!component.marker}
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<div className="component-header">
<div className="component-controls">
<div className="drag-handle"></div>
<button
className={`toggle-btn ${component.enabled ? 'enabled' : 'disabled'}`}
onClick={() => handleToggleComponentEnabled(index)}
onMouseEnter={(e) => showTooltip(e, component.enabled ? "禁用组件" : "启用组件")}
onMouseLeave={hideTooltip}
>
{component.enabled ? '✓' : '○'}
</button>
<span className="component-name">{component.name}</span>
{component.marker && (
<span className="component-marker-badge">🔒</span>
)}
</div>
<div className="component-actions">
<button
className="edit-btn"
onClick={() => handleStartEditComponent(index)}
onMouseEnter={(e) => showTooltip(e, "编辑/查看组件")}
onMouseLeave={hideTooltip}
>
</button>
<span className="token-count">
{component.content ? component.content.length : 0}
</span>
{!component.marker && (
<button
className="delete-btn"
onClick={() => handleDeleteComponent(index)}
onMouseEnter={(e) => showTooltip(e, "删除组件")}
onMouseLeave={hideTooltip}
>
🗑
</button>
)}
</div>
</div>
</div>
</React.Fragment>
))}
{/* 最后一个拖拽指示器 - 在列表末尾 */}
<div
className={`drag-indicator ${dragOverItem === promptComponents.length ? 'visible' : ''}`}
onDragOver={(e) => handleDragOver(e, promptComponents.length)}
onDrop={(e) => handleDrop(e, promptComponents.length)}
/>
</div>
</div>
</div>
);
};
export default PresetPanel;

View File

@@ -0,0 +1 @@
export { default } from './Presets';

View File

@@ -0,0 +1,756 @@
/* ==================== 世界书页面 - 简洁现代化风格 ==================== */
.worldbook-content {
display: flex;
flex-direction: column;
height: 100%;
padding: 8px;
gap: 8px;
background: var(--color-bg-primary);
overflow-y: auto;
}
/* ==================== 全局世界书区域 - SillyTavern 风格 ==================== */
.global-books-display {
padding: 8px;
background: var(--color-bg-secondary);
border-radius: 6px;
border: 1px solid var(--color-border);
}
.global-books-header {
display: flex;
align-items: center;
padding: 6px 8px;
margin-bottom: 6px;
border-bottom: 1px solid var(--color-border-light);
}
.global-books-header .title-text {
font-size: 11px;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.global-books-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
/* 使用 line-height 控制行高,更灵活 */
max-height: calc(2.4em + 6px); /* 两行 (1.2em × 2) + gap */
overflow-x: auto;
overflow-y: hidden;
align-content: flex-start;
padding-bottom: 4px;
}
/* 自定义滚动条样式 */
.global-books-list::-webkit-scrollbar {
height: 4px;
}
.global-books-list::-webkit-scrollbar-track {
background: var(--color-bg-tertiary);
border-radius: 2px;
}
.global-books-list::-webkit-scrollbar-thumb {
background: var(--color-scrollbar);
border-radius: 2px;
transition: all 0.2s ease;
}
.global-books-list::-webkit-scrollbar-thumb:hover {
background: var(--color-scrollbar-hover);
}
.global-book-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 11px;
line-height: 1.2; /* 设置行高,用于父容器计算 */
color: var(--color-text-primary);
transition: all 0.15s ease;
cursor: default;
white-space: nowrap;
}
.global-book-item:hover {
border-color: var(--color-accent);
background: var(--color-accent-light);
}
.global-book-name {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
padding: 0;
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 10px;
border-radius: 2px;
transition: all 0.15s ease;
line-height: 1;
flex-shrink: 0;
}
.btn-icon:hover {
background: var(--color-danger);
color: white;
}
.no-global-books {
font-size: 11px;
color: var(--color-text-muted);
text-align: center;
padding: 8px;
font-style: italic;
}
/* ==================== 世界书管理区域 ==================== */
.worldbook-management {
background: var(--color-bg-secondary);
border-radius: 4px;
border: 1px solid var(--color-border-light);
overflow: hidden;
margin-top: 8px; /* 与全局世界书保持间距 */
position: relative; /* 确保正常文档流 */
z-index: 1; /* 降低层级,不遮挡其他元素 */
}
.worldbook-header {
display: flex;
align-items: center;
padding: 8px 12px;
background: var(--color-bg-tertiary);
border-bottom: 1px solid var(--color-border-light);
}
.worldbook-header .title-text {
font-size: 12px;
font-weight: 600;
color: var(--color-text-primary);
}
/* 操作按钮组 */
.worldbook-actions {
display: flex;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border-light);
}
.action-btn {
padding: 6px 12px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 12px;
transition: all 0.15s ease;
font-weight: 500;
flex: 1;
min-width: 70px;
}
.action-btn:hover {
background: var(--color-accent);
border-color: var(--color-accent);
color: white;
}
.action-btn:active {
transform: scale(0.98);
}
/* 世界书选择区域 */
.worldbook-selector {
display: flex;
gap: 6px;
padding: 8px 12px;
align-items: center;
border-bottom: 1px solid var(--color-border-light);
}
.dropdown {
position: relative;
flex: 1;
}
.dropdown-btn {
width: 100%;
padding: 6px 10px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text-primary);
cursor: pointer;
text-align: left;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
transition: all 0.15s ease;
font-weight: 500;
}
.dropdown-btn:hover {
border-color: var(--color-accent);
}
.dropdown-btn:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-light);
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
margin-top: 4px;
max-height: 200px;
overflow-y: auto;
z-index: 1000;
box-shadow: var(--shadow-md);
}
.dropdown-item {
padding: 6px 10px;
cursor: pointer;
transition: all 0.15s ease;
font-size: 12px;
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border-light);
}
.dropdown-item:last-child {
border-bottom: none;
}
.dropdown-item:hover {
background: var(--color-bg-tertiary);
}
.dropdown-item.active {
background: var(--color-accent-light);
color: var(--color-accent);
font-weight: 600;
}
/* ==================== 条目列表区域 ==================== */
.entries-container {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
}
/* 分页控制栏 */
.pagination-controls {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid var(--color-border-light);
margin-bottom: 6px;
}
.pagination-info {
font-size: 11px;
color: var(--color-text-muted);
font-weight: 500;
}
.page-size-select {
padding: 3px 6px;
font-size: 11px;
border: 1px solid var(--color-border);
border-radius: 3px;
background: var(--color-bg-primary);
color: var(--color-text-primary);
cursor: pointer;
outline: none;
transition: all 0.15s ease;
}
.page-size-select:hover {
border-color: var(--color-accent);
}
.page-size-select:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-light);
}
/* 新版紧凑条目样式 */
.entry-item-compact {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 10px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
min-height: 65px;
}
.entry-item-compact:hover {
border-color: var(--color-accent);
background: var(--color-bg-tertiary);
}
.entry-item-compact.active {
background: var(--color-accent-light);
border-color: var(--color-accent);
border-left: 2px solid var(--color-accent);
}
/* 第一行:名称 + Token + 开关 */
.entry-row-top {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
overflow: hidden;
}
.entry-name-compact {
flex: 1;
font-weight: 500;
font-size: 12px;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.2px;
min-width: 0;
}
.entry-tokens {
font-size: 10px;
color: var(--color-text-muted);
font-weight: 500;
padding: 2px 6px;
background: var(--color-bg-tertiary);
border-radius: 8px;
white-space: nowrap;
flex-shrink: 0;
}
/* 第二行:插入位置下拉框 */
.entry-row-bottom {
display: flex;
align-items: center;
width: 100%;
}
.position-select {
width: 100%;
padding: 3px 6px;
font-size: 11px;
color: var(--color-text-secondary);
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer;
outline: none;
transition: all 0.15s ease;
}
.position-select:hover {
border-color: var(--color-accent);
}
.position-select:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-light);
}
/* 开关组件 */
.toggle-switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
cursor: pointer;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--color-border);
border-radius: 20px;
transition: all 0.2s ease;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background-color: white;
border-radius: 50%;
transition: all 0.2s ease;
}
.toggle-switch input:checked + .toggle-slider {
background: var(--color-accent);
}
.toggle-switch input:checked + .toggle-slider:before {
transform: translateX(16px);
}
/* 分页导航 */
.pagination-nav {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
padding: 10px 0;
margin-top: 6px;
border-top: 1px solid var(--color-border-light);
}
.page-btn {
padding: 5px 14px;
font-size: 12px;
font-weight: 500;
color: var(--color-accent);
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
}
.page-btn:hover:not(:disabled) {
background: var(--color-accent);
border-color: var(--color-accent);
color: white;
}
.page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.page-indicator {
font-size: 12px;
font-weight: 600;
color: var(--color-text-primary);
padding: 3px 10px;
background: var(--color-bg-tertiary);
border-radius: 3px;
}
/* 加载和错误状态 */
.loading, .error {
padding: 12px;
text-align: center;
font-size: 12px;
color: var(--color-text-muted);
}
.error {
color: var(--color-danger);
}
/* 添加条目按钮 */
.entries-container > .btn.btn-primary {
margin-top: 6px;
width: 100%;
padding: 10px;
font-size: 13px;
font-weight: 600;
background: var(--color-accent);
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
transition: all 0.15s ease;
}
.entries-container > .btn.btn-primary:hover {
background: var(--color-accent-dark);
}
.entries-container > .btn.btn-primary:active {
transform: scale(0.98);
}
/* 删除按钮 */
.btn.btn-danger {
padding: 6px 12px;
background: var(--color-danger);
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.15s ease;
}
.btn.btn-danger:hover {
background: var(--color-danger-dark);
}
.btn.btn-danger:active {
transform: scale(0.98);
}
/* ==================== 响应式设计 ==================== */
/* 小屏幕 (< 768px) */
@media (max-width: 768px) {
.worldbook-content {
padding: 6px;
gap: 6px;
}
.worldbook-actions {
flex-wrap: wrap;
}
.action-btn {
min-width: calc(50% - 3px);
}
.global-book-name-compact {
max-width: 80px;
}
.entry-name-compact {
font-size: 11px;
}
}
/* 中等屏幕 (768px - 1024px) */
@media (min-width: 769px) and (max-width: 1024px) {
.worldbook-content {
padding: 8px;
}
.global-book-name-compact {
max-width: 120px;
}
}
/* 大屏幕 (> 1024px) */
@media (min-width: 1025px) {
.worldbook-content {
padding: 10px;
gap: 8px;
}
.global-book-name-compact {
max-width: 150px;
}
}
/* ==================== 编辑面板样式 (保持不变) ==================== */
.edit-panel-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 9998;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.edit-panel-overlay.open {
opacity: 1;
visibility: visible;
}
.edit-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.95);
width: 90vw;
max-width: 1200px;
height: 85vh;
max-height: 900px;
background: var(--color-bg-primary);
z-index: 9999;
padding: 0;
overflow: hidden;
opacity: 0;
visibility: hidden;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
border-radius: 8px;
box-shadow: var(--shadow-xl);
display: flex;
flex-direction: column;
}
.edit-panel.open {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
visibility: visible;
}
.edit-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-secondary);
flex-shrink: 0;
}
.edit-panel-header h2 {
font-size: 16px;
color: var(--color-text-primary);
margin: 0;
font-weight: 600;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text-muted);
cursor: pointer;
font-size: 14px;
transition: all 0.15s ease;
}
.close-btn:hover {
background: var(--color-danger-light);
border-color: var(--color-danger);
color: var(--color-danger);
}
.edit-panel-content {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.form-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group.compact {
flex: 1;
min-width: 150px;
}
.form-label {
font-size: 12px;
font-weight: 500;
color: var(--color-text-secondary);
}
.form-input {
padding: 8px 10px;
font-size: 13px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg-primary);
color: var(--color-text-primary);
transition: all 0.15s ease;
}
.form-input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-light);
}
.content-textarea {
flex: 1;
min-height: 200px;
resize: vertical;
line-height: 1.6;
font-family: inherit;
}
.trigger-config-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.trigger-selector {
flex: 1;
min-width: 200px;
}
.trigger-config-panel {
flex: 2;
min-width: 300px;
}
.keyword-config {
display: flex;
flex-direction: column;
gap: 8px;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export { default } from './WorldBook';