完成世界书、骰子、apiconfig页面处理
This commit is contained in:
31
frontend/src/App.jsx
Normal file
31
frontend/src/App.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
// frontend-react/src/App.jsx
|
||||
import React from 'react';
|
||||
import TopBar from './components/TopBar';
|
||||
import { ChatBox } from './components/Mid';
|
||||
import SideBarLeft from './components/SideBarLeft';
|
||||
import SideBarRight from './components/SideBarRight';
|
||||
import './index.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="app">
|
||||
<TopBar />
|
||||
|
||||
{/* 主内容容器 */}
|
||||
<div className="main-container">
|
||||
{/* 左侧栏 - 预设面板 */}
|
||||
<SideBarLeft />
|
||||
|
||||
{/* 中间栏:聊天框 */}
|
||||
<div className="chat-area">
|
||||
<ChatBox />
|
||||
</div>
|
||||
|
||||
{/* 右侧栏 */}
|
||||
<SideBarRight />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
470
frontend/src/Store/Mid/ChatBoxSlice.jsx
Normal file
470
frontend/src/Store/Mid/ChatBoxSlice.jsx
Normal file
@@ -0,0 +1,470 @@
|
||||
// frontend-react/src/Store/Mid/ChatBoxSlice.jsx
|
||||
import { create } from 'zustand';
|
||||
import { subscribeWithSelector, persist } from 'zustand/middleware';
|
||||
import useApiConfigStore from '../SideBarLeft/ApiConfigSlice';
|
||||
import usePresetStore from '../SideBarLeft/PresetSlice';
|
||||
|
||||
const useChatBoxStore = create(
|
||||
subscribeWithSelector(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// WebSocket连接实例
|
||||
wsConnection: null,
|
||||
// 聊天历史消息列表
|
||||
messages: [],
|
||||
|
||||
// 用户名称
|
||||
userName: '',
|
||||
|
||||
// 角色名称
|
||||
characterName: '',
|
||||
|
||||
// 当前选中的角色
|
||||
currentRole: null,
|
||||
|
||||
// 当前选中的聊天
|
||||
currentChat: null,
|
||||
|
||||
// 是否正在加载
|
||||
isLoading: false,
|
||||
|
||||
// 是否正在生成
|
||||
isGenerating: false,
|
||||
|
||||
// 错误信息
|
||||
error: null,
|
||||
|
||||
// 选项状态
|
||||
options: {
|
||||
dynamicTable: false, // 动态表格
|
||||
streamOutput: false, // 流式输出
|
||||
imageWorkflow: false, // 生图工作流
|
||||
htmlRender: false, // HTML渲染
|
||||
},
|
||||
|
||||
// 设置消息列表
|
||||
setMessages: (messages) => set({ messages }),
|
||||
|
||||
// 设置用户名称
|
||||
setUserName: (userName) => set({ userName }),
|
||||
|
||||
// 设置角色名称
|
||||
setCharacterName: (characterName) => set({ characterName }),
|
||||
|
||||
// 设置当前角色
|
||||
setCurrentRole: (role) => set({ currentRole: role }),
|
||||
|
||||
// 设置当前聊天
|
||||
setCurrentChat: (chat) => set({ currentChat: chat }),
|
||||
|
||||
// 切换选项状态
|
||||
toggleOption: (optionName) => set((state) => ({
|
||||
options: {
|
||||
...state.options,
|
||||
[optionName]: !state.options[optionName]
|
||||
}
|
||||
})),
|
||||
|
||||
// 设置选项状态
|
||||
setOption: (optionName, value) => set((state) => ({
|
||||
options: {
|
||||
...state.options,
|
||||
[optionName]: value
|
||||
}
|
||||
})),
|
||||
|
||||
// 重置所有选项
|
||||
resetOptions: () => set({
|
||||
options: {
|
||||
dynamicTable: false,
|
||||
streamOutput: false,
|
||||
imageWorkflow: false,
|
||||
htmlRender: false
|
||||
}
|
||||
}),
|
||||
|
||||
// 同时设置角色和聊天
|
||||
setChatBoxRoleAndChat: (role, chat) => {
|
||||
console.log('[ChatBoxStore] setChatBoxRoleAndChat called with:', { role, chat });
|
||||
set({
|
||||
currentRole: role,
|
||||
currentChat: typeof chat === 'object' && chat !== null ? chat.chat_name : chat
|
||||
});
|
||||
},
|
||||
|
||||
// 设置生成状态
|
||||
setIsGenerating: (status) => set({ isGenerating: status }),
|
||||
|
||||
// 计算下一个楼层号
|
||||
getNextFloor: (messages) => {
|
||||
if (messages.length === 0) return 1;
|
||||
const maxFloor = Math.max(...messages.map(msg => msg.floor));
|
||||
return maxFloor + 1;
|
||||
},
|
||||
|
||||
// 发送消息
|
||||
sendMessage: async (content) => {
|
||||
const { messages, userName, characterName, currentRole, currentChat, options, wsConnection } = get();
|
||||
|
||||
// 获取 API 配置
|
||||
const apiConfigStore = useApiConfigStore.getState();
|
||||
|
||||
// 获取预设配置
|
||||
const presetStore = usePresetStore.getState();
|
||||
|
||||
// 关闭之前的WebSocket连接
|
||||
if (wsConnection) {
|
||||
wsConnection.close();
|
||||
}
|
||||
|
||||
// 计算下一个楼层号
|
||||
const nextFloor = get().getNextFloor(messages);
|
||||
|
||||
set({
|
||||
isGenerating: true,
|
||||
wsConnection: null, // 重置连接
|
||||
messages: [...messages, {
|
||||
id: Date.now(),
|
||||
floor: messages.length + 1,
|
||||
mes: content,
|
||||
is_user: true
|
||||
}]
|
||||
});
|
||||
|
||||
try {
|
||||
// 统一使用WebSocket处理流式和非流式输出
|
||||
const backendUrl = import.meta.env.VITE_API_URL || 'http://localhost:23337';
|
||||
const wsUrl = `${backendUrl.replace(/^http/, 'ws')}/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
console.log('[WebSocket] 正在建立连接...', { url: wsUrl });
|
||||
|
||||
// 添加连接超时处理
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.CONNECTING) {
|
||||
console.error('[WebSocket] 连接超时');
|
||||
ws.close();
|
||||
set({
|
||||
error: 'WebSocket connection timeout',
|
||||
isGenerating: false,
|
||||
wsConnection: null
|
||||
});
|
||||
}
|
||||
}, 5000); // 5秒超时
|
||||
|
||||
// 保存WebSocket连接到store
|
||||
set({ wsConnection: ws });
|
||||
|
||||
// 添加一个空的助手消息,稍后会更新
|
||||
const newMessageId = Date.now();
|
||||
const assistantFloor = nextFloor + 1; // 助手消息的楼层是用户消息楼层+1
|
||||
set((state) => ({
|
||||
messages: [...state.messages, {
|
||||
id: newMessageId,
|
||||
floor: state.messages.length + 1,
|
||||
mes: '',
|
||||
is_user: false
|
||||
}]
|
||||
}));
|
||||
|
||||
let assistantMessage = '';
|
||||
let isStreamComplete = false;
|
||||
|
||||
ws.onopen = () => {
|
||||
clearTimeout(connectionTimeout); // 清除超时定时器
|
||||
console.log('[WebSocket] 连接已建立', { readyState: ws.readyState });
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log('[WebSocket] 连接已关闭', {
|
||||
code: event.code,
|
||||
reason: event.reason,
|
||||
wasClean: event.wasClean
|
||||
});
|
||||
};
|
||||
|
||||
// 处理WebSocket消息
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('[WebSocket] 收到消息', { type: data.type, content: data.content });
|
||||
|
||||
if (data.type === 'chunk') {
|
||||
// 处理流式数据块
|
||||
assistantMessage += data.content;
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === newMessageId ? { ...msg, mes: assistantMessage } : msg
|
||||
)
|
||||
}));
|
||||
} else if (data.type === 'complete') {
|
||||
// 完成响应
|
||||
isStreamComplete = true;
|
||||
ws.close();
|
||||
set({ wsConnection: null, isGenerating: false });
|
||||
} else if (data.type === 'error') {
|
||||
// 错误处理
|
||||
set({
|
||||
error: data.message,
|
||||
isGenerating: false,
|
||||
wsConnection: null
|
||||
});
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理连接错误
|
||||
ws.onerror = (error) => {
|
||||
console.error('[WebSocket] 连接错误', { error, readyState: ws.readyState });
|
||||
set({
|
||||
error: 'WebSocket connection failed',
|
||||
isGenerating: false,
|
||||
wsConnection: null
|
||||
});
|
||||
};
|
||||
|
||||
// 处理连接关闭(非流式模式下会自动关闭)
|
||||
ws.onclose = () => {
|
||||
set({ wsConnection: null });
|
||||
if (!isStreamComplete && !options.streamOutput) {
|
||||
// 非流式模式下,一次性完成后自动关闭
|
||||
set({ isGenerating: false });
|
||||
}
|
||||
};
|
||||
|
||||
// 发送请求到WebSocket(确保连接已建立)
|
||||
// 发送请求到WebSocket(确保连接已建立)
|
||||
const sendAfterConnect = () => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
console.log('[WebSocket] 发送消息', { readyState: ws.readyState });
|
||||
ws.send(JSON.stringify({
|
||||
floor: nextFloor,
|
||||
mes: content,
|
||||
is_user: true,
|
||||
currentRole: currentRole,
|
||||
currentChat: currentChat,
|
||||
options: options,
|
||||
apiConfig: {
|
||||
api_url: apiConfigStore.allApis.find(api => api.category === 'text' && api.id === apiConfigStore.activeMap.text)?.api_url || '',
|
||||
api_key: apiConfigStore.allApis.find(api => api.category === 'text' && api.id === apiConfigStore.activeMap.text)?.api_key || ''
|
||||
},
|
||||
presetConfig: {
|
||||
selectedPreset: presetStore.selectedPreset,
|
||||
parameters: presetStore.parameters,
|
||||
promptComponents: presetStore.promptComponents
|
||||
},
|
||||
stream: options.streamOutput
|
||||
}));
|
||||
} else if (ws.readyState === WebSocket.CONNECTING) {
|
||||
// 如果正在连接,继续等待
|
||||
console.log('[WebSocket] 等待连接...', { readyState: ws.readyState });
|
||||
setTimeout(sendAfterConnect, 100);
|
||||
} else {
|
||||
// 连接失败或已关闭
|
||||
console.error('[WebSocket] 连接失败', { readyState: ws.readyState });
|
||||
set({
|
||||
error: 'WebSocket connection failed',
|
||||
isGenerating: false,
|
||||
wsConnection: null
|
||||
});
|
||||
}
|
||||
};
|
||||
// 等待连接建立后发送
|
||||
sendAfterConnect();
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error.message,
|
||||
isGenerating: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 终止生成
|
||||
stopGeneration: () => set((state) => {
|
||||
if (state.wsConnection) {
|
||||
state.wsConnection.close();
|
||||
}
|
||||
return {
|
||||
isGenerating: false,
|
||||
wsConnection: null
|
||||
};
|
||||
}),
|
||||
|
||||
// 加载聊天历史
|
||||
fetchChatHistory: async (roleName, chatName) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
// 确保chatName是字符串
|
||||
const actualChatName = typeof chatName === 'object' && chatName !== null ? chatName.chat_name : chatName;
|
||||
const response = await fetch(`/api/chat/${encodeURIComponent(roleName)}/${encodeURIComponent(actualChatName)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch chat history');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
set({
|
||||
messages: data.messages || [],
|
||||
userName: data.metadata?.user_name || 'User',
|
||||
characterName: data.metadata?.character_name || roleName || 'Assistant',
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error.message,
|
||||
isLoading: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 清空聊天历史
|
||||
clearChatHistory: () => set({
|
||||
messages: [],
|
||||
userName: '',
|
||||
characterName: '',
|
||||
error: null
|
||||
}),
|
||||
|
||||
// 更新特定消息的内容
|
||||
updateMessage: async (floor, content) => {
|
||||
const { currentRole, currentChat } = get();
|
||||
try {
|
||||
const response = await fetch(`/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/messages/${floor}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ mes: content })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update message');
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.floor === floor ? { ...msg, mes: content } : msg
|
||||
)
|
||||
}));
|
||||
} catch (error) {
|
||||
set({ error: error.message });
|
||||
}
|
||||
},
|
||||
|
||||
// 删除特定消息
|
||||
deleteMessage: async (floor) => {
|
||||
const { currentRole, currentChat } = get();
|
||||
try {
|
||||
const response = await fetch(`/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/messages/${floor}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete message');
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
messages: state.messages.filter((msg) => msg.floor !== floor)
|
||||
}));
|
||||
} catch (error) {
|
||||
set({ error: error.message });
|
||||
}
|
||||
},
|
||||
|
||||
// 创建新聊天
|
||||
createChat: async (roleName, chatName, metadata = {}) => {
|
||||
try {
|
||||
const response = await fetch(`/api/chat/${encodeURIComponent(roleName)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_name: chatName,
|
||||
metadata: {
|
||||
user_name: 'User',
|
||||
character_name: roleName,
|
||||
...metadata
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create chat');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
set({ error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 更新聊天元数据
|
||||
updateChatMetadata: async (roleName, chatName, metadata) => {
|
||||
try {
|
||||
const response = await fetch(`/api/chat/${encodeURIComponent(roleName)}/${encodeURIComponent(chatName)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ metadata })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update chat metadata');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
set({ error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 删除聊天
|
||||
deleteChat: async (roleName, chatName) => {
|
||||
try {
|
||||
const response = await fetch(`/api/chat/${encodeURIComponent(roleName)}/${encodeURIComponent(chatName)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete chat');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
set({ error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'chat-box-storage', // localStorage 中的键名
|
||||
partialize: (state) => ({
|
||||
// 只持久化选项状态,不持久化聊天历史等
|
||||
options: state.options
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// 监听角色和聊天变化,自动加载聊天历史
|
||||
useChatBoxStore.subscribe(
|
||||
(state) => ({ role: state.currentRole, chat: state.currentChat }),
|
||||
({ role, chat }, prev) => {
|
||||
// 只有当角色或聊天发生变化时才处理
|
||||
if (role !== prev.role || chat !== prev.chat) {
|
||||
// 确保角色和聊天都存在且不为null
|
||||
if (role && chat) {
|
||||
// 确保chat是字符串,如果是对象则提取chat_name
|
||||
const actualChat = typeof chat === 'object' && chat !== null ? chat.chat_name : chat;
|
||||
useChatBoxStore.getState().fetchChatHistory(role, actualChat);
|
||||
} else {
|
||||
useChatBoxStore.getState().clearChatHistory();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ equalityFn: (a, b) => a.role === b.role && a.chat === b.chat }
|
||||
);
|
||||
|
||||
export default useChatBoxStore;
|
||||
2
frontend/src/Store/Mid/index.js
Normal file
2
frontend/src/Store/Mid/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// Mid 区域相关的 Store
|
||||
export { default as useChatBoxStore } from './ChatBoxSlice';
|
||||
203
frontend/src/Store/SideBarLeft/ApiConfigSlice.jsx
Normal file
203
frontend/src/Store/SideBarLeft/ApiConfigSlice.jsx
Normal file
@@ -0,0 +1,203 @@
|
||||
// frontend-react/src/Store/SideBarLeft/ApiConfigSlice.jsx
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
|
||||
// 定义初始状态
|
||||
const initialState = {
|
||||
profiles: [], // 所有配置文件列表
|
||||
currentProfile: null, // 当前加载的配置文件
|
||||
activeMap: {}, // 当前激活的配置映射 { category: profileId }
|
||||
loading: false,
|
||||
error: null,
|
||||
notification: {
|
||||
show: false,
|
||||
message: '',
|
||||
type: 'info' // 'success', 'error', 'info'
|
||||
}
|
||||
};
|
||||
|
||||
// 创建 Store
|
||||
const useApiConfigStore = create(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// Action: 获取所有配置文件列表
|
||||
fetchProfiles: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await fetch('/api/api-config/profiles');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch profiles');
|
||||
}
|
||||
const data = await response.json();
|
||||
set({ profiles: data, loading: false });
|
||||
} catch (err) {
|
||||
set({ error: err.message, loading: false });
|
||||
get().showNotification('error', `获取配置文件列表失败: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
// Action: 获取单个配置文件
|
||||
fetchProfile: async (profileId) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await fetch(`/api/api-config/profiles/${profileId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch profile');
|
||||
}
|
||||
const data = await response.json();
|
||||
set({ currentProfile: data, loading: false });
|
||||
return data;
|
||||
} catch (err) {
|
||||
set({ error: err.message, loading: false });
|
||||
get().showNotification('error', `获取配置文件失败: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Action: 保存配置文件(批量、增量)
|
||||
saveProfile: async (profileId, name, apisToSave) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await fetch('/api/api-config/profiles', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
profileId,
|
||||
name,
|
||||
apis: apisToSave // 只包含要保存的 API
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Failed to save profile');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 更新本地状态
|
||||
set(state => {
|
||||
const existingIndex = state.profiles.findIndex(p => p.id === profileId);
|
||||
let newProfiles = [...state.profiles];
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// 更新现有配置文件
|
||||
newProfiles[existingIndex] = {
|
||||
id: result.id,
|
||||
name: result.name
|
||||
};
|
||||
} else {
|
||||
// 添加新配置文件
|
||||
newProfiles.push({
|
||||
id: result.id,
|
||||
name: result.name
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
profiles: newProfiles,
|
||||
currentProfile: result,
|
||||
loading: false
|
||||
};
|
||||
});
|
||||
|
||||
get().showNotification('success', '配置文件已保存');
|
||||
return result;
|
||||
} catch (err) {
|
||||
set({ error: err.message, loading: false });
|
||||
get().showNotification('error', `保存失败: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Action: 删除配置文件
|
||||
deleteProfile: async (profileId) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await fetch(`/api/api-config/profiles/${profileId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete profile');
|
||||
}
|
||||
|
||||
set(state => ({
|
||||
profiles: state.profiles.filter(p => p.id !== profileId),
|
||||
currentProfile: state.currentProfile?.id === profileId ? null : state.currentProfile,
|
||||
loading: false
|
||||
}));
|
||||
|
||||
get().showNotification('success', '配置文件已删除');
|
||||
} catch (err) {
|
||||
set({ error: err.message, loading: false });
|
||||
get().showNotification('error', `删除失败: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Action: 测试连接并获取模型列表
|
||||
testConnection: async (apiUrl, apiKey, category) => {
|
||||
try {
|
||||
const response = await fetch('/api/api-config/test-connection', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ apiUrl, apiKey, category, model: '' })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Connection test failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
return data.models || [];
|
||||
} else {
|
||||
throw new Error(data.message || '获取模型列表失败');
|
||||
}
|
||||
} catch (err) {
|
||||
get().showNotification('error', `连接测试失败: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Action: 设置激活配置
|
||||
setActiveConfig: async (category, profileId) => {
|
||||
try {
|
||||
set(state => ({
|
||||
activeMap: { ...state.activeMap, [category]: profileId }
|
||||
}));
|
||||
get().showNotification('success', '已设置为默认配置');
|
||||
} catch (err) {
|
||||
get().showNotification('error', `设置失败: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Action: 显示通知
|
||||
showNotification: (type, message) => {
|
||||
set({ notification: { show: true, type, message } });
|
||||
setTimeout(() => {
|
||||
set({ notification: { ...get().notification, show: false } });
|
||||
}, 3000);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'ApiConfigStore',
|
||||
partialize: (state) => ({
|
||||
activeMap: state.activeMap
|
||||
})
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// 导出 Hook
|
||||
export default useApiConfigStore;
|
||||
356
frontend/src/Store/SideBarLeft/PresetSlice.jsx
Normal file
356
frontend/src/Store/SideBarLeft/PresetSlice.jsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const usePresetStore = create((set, get) => ({
|
||||
// 预设选择
|
||||
selectedPreset: '',
|
||||
|
||||
// 核心参数
|
||||
parameters: {
|
||||
temperature: 1.0,
|
||||
frequency_penalty: 0.0,
|
||||
presence_penalty: 0.0,
|
||||
top_p: 1.0,
|
||||
top_k: 0,
|
||||
max_context: 1000000,
|
||||
max_tokens: 30000,
|
||||
max_context_unlocked: false,
|
||||
stream_openai: true,
|
||||
seed: -1,
|
||||
n: 1
|
||||
},
|
||||
|
||||
// 可用的预设列表 - 初始为空,将从后端加载
|
||||
presets: [],
|
||||
|
||||
// 是否正在加载预设列表
|
||||
isLoadingPresets: false,
|
||||
|
||||
// 参数设置折叠状态
|
||||
isParametersExpanded: true,
|
||||
|
||||
// 预设组件列表
|
||||
promptComponents: [
|
||||
{
|
||||
identifier: "dialogueExamples",
|
||||
name: "Chat Examples",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0
|
||||
},
|
||||
{
|
||||
identifier: "chatHistory",
|
||||
name: "Chat History",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0
|
||||
},
|
||||
{
|
||||
identifier: "worldInfoAfter",
|
||||
name: "World Info (after)",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0
|
||||
},
|
||||
{
|
||||
identifier: "worldInfoBefore",
|
||||
name: "World Info (before)",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0
|
||||
},
|
||||
{
|
||||
identifier: "charDescription",
|
||||
name: "Char Description",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0
|
||||
},
|
||||
{
|
||||
identifier: "charPersonality",
|
||||
name: "Char Personality",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0
|
||||
},
|
||||
{
|
||||
identifier: "scenario",
|
||||
name: "Scenario",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0
|
||||
},
|
||||
{
|
||||
identifier: "personaDescription",
|
||||
name: "Persona Description",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0
|
||||
}
|
||||
],
|
||||
|
||||
// 从后端加载预设列表
|
||||
fetchPresets: async () => {
|
||||
set({ isLoadingPresets: true });
|
||||
try {
|
||||
const response = await fetch('/api/presets');
|
||||
const data = await response.json();
|
||||
|
||||
// 转换为预设对象数组
|
||||
const presetList = data.presets.map(preset => ({
|
||||
id: preset.name,
|
||||
name: preset.name,
|
||||
description: preset.description,
|
||||
component_count: preset.component_count,
|
||||
temperature: preset.temperature
|
||||
}));
|
||||
|
||||
set({ presets: presetList, isLoadingPresets: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch presets:', error);
|
||||
set({ isLoadingPresets: false });
|
||||
}
|
||||
},
|
||||
|
||||
// 设置选中的预设
|
||||
setSelectedPreset: async (presetId) => {
|
||||
try {
|
||||
// 从后端获取预设的完整内容
|
||||
const response = await fetch(`/api/presets/${presetId}`);
|
||||
const presetData = await response.json();
|
||||
|
||||
// 记录原始数据用于调试
|
||||
console.log('从后端获取的预设数据:', presetData);
|
||||
|
||||
// 提取参数并更新状态,确保所有参数都有默认值
|
||||
const parameters = {
|
||||
temperature: presetData.temperature !== undefined ? presetData.temperature : 1.0,
|
||||
frequency_penalty: presetData.frequency_penalty !== undefined ? presetData.frequency_penalty : 0.0,
|
||||
presence_penalty: presetData.presence_penalty !== undefined ? presetData.presence_penalty : 0.0,
|
||||
top_p: presetData.top_p !== undefined ? presetData.top_p : 1.0,
|
||||
top_k: presetData.top_k !== undefined ? presetData.top_k : 0,
|
||||
max_context: presetData.openai_max_context !== undefined ? presetData.openai_max_context :
|
||||
(presetData.max_context !== undefined ? presetData.max_context : 1000000),
|
||||
max_tokens: presetData.openai_max_tokens !== undefined ? presetData.openai_max_tokens :
|
||||
(presetData.max_tokens !== undefined ? presetData.max_tokens : 30000),
|
||||
max_context_unlocked: presetData.max_context_unlocked !== undefined ? presetData.max_context_unlocked : false,
|
||||
stream_openai: presetData.stream_openai !== undefined ? presetData.stream_openai : true,
|
||||
seed: presetData.seed !== undefined ? presetData.seed : -1,
|
||||
n: presetData.n !== undefined ? presetData.n : 1
|
||||
};
|
||||
|
||||
// 记录映射后的参数用于调试
|
||||
console.log('映射后的参数:', parameters);
|
||||
|
||||
// 处理预设组件
|
||||
let components = [];
|
||||
if (presetData.prompts && Array.isArray(presetData.prompts)) {
|
||||
// 获取当前角色的prompt_order,添加更严格的检查
|
||||
const currentOrder = (presetData.prompt_order &&
|
||||
Array.isArray(presetData.prompt_order) &&
|
||||
presetData.prompt_order.length > 0 &&
|
||||
presetData.prompt_order[0] &&
|
||||
presetData.prompt_order[0].order &&
|
||||
Array.isArray(presetData.prompt_order[0].order))
|
||||
? presetData.prompt_order[0].order
|
||||
: [];
|
||||
|
||||
// 根据prompt_order排序组件
|
||||
components = presetData.prompts.map(prompt => {
|
||||
const orderItem = currentOrder.find(item => item && item.identifier === prompt.identifier);
|
||||
return {
|
||||
...prompt,
|
||||
enabled: orderItem ? orderItem.enabled : true,
|
||||
role: prompt.role !== undefined ? prompt.role : (prompt.system_prompt ? 0 : 1)
|
||||
};
|
||||
});
|
||||
|
||||
// 如果有prompt_order,按照它排序
|
||||
if (currentOrder.length > 0) {
|
||||
components.sort((a, b) => {
|
||||
const indexA = currentOrder.findIndex(item => item && item.identifier === a.identifier);
|
||||
const indexB = currentOrder.findIndex(item => item && item.identifier === b.identifier);
|
||||
return indexA - indexB;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 更新状态,确保参数容器展开
|
||||
set({
|
||||
selectedPreset: presetId,
|
||||
parameters,
|
||||
promptComponents: components,
|
||||
isParametersExpanded: true // 确保参数容器展开
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load preset:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 更新参数
|
||||
updateParameter: ({ name, value }) => set((state) => ({
|
||||
parameters: { ...state.parameters, [name]: value }
|
||||
})),
|
||||
|
||||
// 添加预设
|
||||
addPreset: (preset) => set((state) => ({
|
||||
presets: [...state.presets, preset]
|
||||
})),
|
||||
|
||||
// 保存当前设置为预设
|
||||
saveCurrentAsPreset: async ({ name }) => {
|
||||
const state = get();
|
||||
try {
|
||||
// 构建预设数据
|
||||
const presetData = {
|
||||
...state.parameters,
|
||||
prompts: state.promptComponents.map(component => ({
|
||||
identifier: component.identifier,
|
||||
name: component.name,
|
||||
content: component.content || '',
|
||||
role: component.role,
|
||||
system_prompt: component.system_prompt,
|
||||
marker: component.marker
|
||||
})),
|
||||
prompt_order: [{
|
||||
order: state.promptComponents.map(component => ({
|
||||
identifier: component.identifier,
|
||||
enabled: component.enabled !== false
|
||||
}))
|
||||
}]
|
||||
};
|
||||
|
||||
// 发送到后端
|
||||
const response = await fetch('/api/presets', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
preset_name: name,
|
||||
...presetData
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save preset');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 添加到本地预设列表
|
||||
const newPreset = {
|
||||
id: name,
|
||||
name,
|
||||
description: '',
|
||||
component_count: state.promptComponents.length,
|
||||
temperature: state.parameters.temperature
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
presets: [...state.presets, newPreset],
|
||||
selectedPreset: name
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to save preset:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 编辑预设名称
|
||||
editPresetName: async (presetId, newName) => {
|
||||
try {
|
||||
const response = await fetch(`/api/presets/${presetId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: newName
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update preset name');
|
||||
}
|
||||
|
||||
// 更新本地状态
|
||||
set((state) => ({
|
||||
presets: state.presets.map(preset =>
|
||||
preset.id === presetId ? { ...preset, name: newName } : preset
|
||||
)
|
||||
}));
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Failed to update preset name:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 切换参数设置折叠状态
|
||||
toggleParametersExpanded: () => set((state) => ({
|
||||
isParametersExpanded: !state.isParametersExpanded
|
||||
})),
|
||||
|
||||
// 设置预设组件列表
|
||||
setPromptComponents: (components) => set({ promptComponents: components }),
|
||||
|
||||
// 更新组件
|
||||
updateComponent: (index, updatedComponent) => set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
newComponents[index] = { ...newComponents[index], ...updatedComponent };
|
||||
return { promptComponents: newComponents };
|
||||
}),
|
||||
|
||||
// 切换组件启用状态
|
||||
toggleComponentEnabled: (index) => set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
newComponents[index] = {
|
||||
...newComponents[index],
|
||||
enabled: !newComponents[index].enabled
|
||||
};
|
||||
return { promptComponents: newComponents };
|
||||
}),
|
||||
|
||||
// 添加新组件
|
||||
addComponent: (component) => set((state) => ({
|
||||
promptComponents: [...state.promptComponents, component]
|
||||
})),
|
||||
|
||||
// 删除组件
|
||||
removeComponent: (index) => set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
newComponents.splice(index, 1);
|
||||
return { promptComponents: newComponents };
|
||||
}),
|
||||
|
||||
// 移动组件位置
|
||||
moveComponent: (fromIndex, toIndex) => set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
const [movedComponent] = newComponents.splice(fromIndex, 1);
|
||||
newComponents.splice(toIndex, 0, movedComponent);
|
||||
return { promptComponents: newComponents };
|
||||
}),
|
||||
|
||||
// 获取当前预设的prompt_order
|
||||
getPromptOrder: () => {
|
||||
const { promptComponents } = get();
|
||||
return promptComponents.map(component => ({
|
||||
identifier: component.identifier,
|
||||
enabled: component.enabled !== false
|
||||
}));
|
||||
}
|
||||
}));
|
||||
|
||||
export default usePresetStore;
|
||||
26
frontend/src/Store/SideBarLeft/SideBarLeftSlice.jsx
Normal file
26
frontend/src/Store/SideBarLeft/SideBarLeftSlice.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
const useSideBarLeftStore = create(
|
||||
persist(
|
||||
(set) => ({
|
||||
activeTab: 'gallery',
|
||||
|
||||
tabs: [
|
||||
{ id: 'gallery', label: '画廊', title: '查看和管理生成的图片' },
|
||||
{ id: 'character', label: '角色', title: '管理AI角色卡' },
|
||||
{ id: 'api', label: 'API', title: '配置LLM和生图、向量化API连接' },
|
||||
{ id: 'presets', label: '预设', title: '管理对话预设和系统提示词' },
|
||||
{ id: 'worldbook', label: '世界', title: '管理世界观设定和背景知识' }
|
||||
],
|
||||
|
||||
setActiveTab: (tab) => set({ activeTab: tab })
|
||||
}),
|
||||
{
|
||||
name: 'sidebar-left-storage', // localStorage key
|
||||
partialize: (state) => ({ activeTab: state.activeTab }) // 只保存 activeTab
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export default useSideBarLeftStore;
|
||||
688
frontend/src/Store/SideBarLeft/WorldBookSlice.jsx
Normal file
688
frontend/src/Store/SideBarLeft/WorldBookSlice.jsx
Normal file
@@ -0,0 +1,688 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
// LocalStorage 键名
|
||||
const GLOBAL_WORLDBOOKS_KEY = 'global_worldbooks';
|
||||
|
||||
// 辅助函数:从 LocalStorage 加载全局世界书
|
||||
const loadGlobalWorldBooks = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(GLOBAL_WORLDBOOKS_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch (error) {
|
||||
console.error('加载全局世界书失败:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 辅助函数:保存全局世界书到 LocalStorage
|
||||
const saveGlobalWorldBooks = (globalBooks) => {
|
||||
try {
|
||||
localStorage.setItem(GLOBAL_WORLDBOOKS_KEY, JSON.stringify(globalBooks));
|
||||
} catch (error) {
|
||||
console.error('保存全局世界书失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 辅助函数:处理 API 响应
|
||||
const handleResponse = async (response) => {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
return response.json().catch(() => ({}));
|
||||
};
|
||||
|
||||
// 辅助函数:处理文件下载
|
||||
const handleFileDownload = async (response, filename) => {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// 创建世界书 store
|
||||
const useWorldBookStore = create((set, get) => ({
|
||||
// 状态
|
||||
worldBooks: [], // 世界书列表
|
||||
globalWorldBooks: loadGlobalWorldBooks(), // 从 LocalStorage 初始化全局世界书列表
|
||||
currentWorldBook: null, // 当前选中的世界书
|
||||
currentEntries: [], // 当前世界书的条目列表
|
||||
currentEntry: null, // 当前选中的条目
|
||||
loading: false, // 加载状态
|
||||
error: null, // 错误信息
|
||||
success: false, // 操作成功状态
|
||||
message: '', // 成功或错误消息
|
||||
|
||||
// Actions
|
||||
clearError: () => set({ error: null, message: '' }),
|
||||
|
||||
clearSuccess: () => set({ success: false, message: '' }),
|
||||
|
||||
setCurrentWorldBook: (worldBook) => set({
|
||||
currentWorldBook: worldBook,
|
||||
currentEntries: [],
|
||||
currentEntry: null
|
||||
}),
|
||||
|
||||
setCurrentEntry: (entry) => set({ currentEntry: entry }),
|
||||
|
||||
resetCurrentWorldBook: () => set({
|
||||
currentWorldBook: null,
|
||||
currentEntries: [],
|
||||
currentEntry: null
|
||||
}),
|
||||
|
||||
// 异步操作:切换世界书的全局状态
|
||||
toggleGlobalWorldBook: async (name, isGlobal) => {
|
||||
set({ loading: true, error: null, success: false });
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('is_global', isGlobal);
|
||||
|
||||
const response = await fetch(`/api/worldbooks/${name}`, {
|
||||
method: 'PUT',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await handleResponse(response);
|
||||
|
||||
set(state => {
|
||||
const updatedWorldBooks = state.worldBooks.map(wb =>
|
||||
wb.name === data.name ? data : wb
|
||||
);
|
||||
|
||||
// 更新全局世界书列表
|
||||
let updatedGlobalBooks = [...state.globalWorldBooks];
|
||||
const globalIndex = updatedGlobalBooks.findIndex(wb => wb.name === data.name);
|
||||
|
||||
if (isGlobal) {
|
||||
// 如果是世界书被标记为全局
|
||||
if (globalIndex === -1) {
|
||||
// 如果不在全局列表中,添加它
|
||||
updatedGlobalBooks = [...updatedGlobalBooks, data];
|
||||
} else {
|
||||
// 如果已经在全局列表中,更新它
|
||||
updatedGlobalBooks[globalIndex] = data;
|
||||
}
|
||||
} else {
|
||||
// 如果世界书不再全局,从全局列表中移除
|
||||
if (globalIndex !== -1) {
|
||||
updatedGlobalBooks = updatedGlobalBooks.filter(wb => wb.name !== data.name);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存到 LocalStorage
|
||||
saveGlobalWorldBooks(updatedGlobalBooks);
|
||||
|
||||
return {
|
||||
loading: false,
|
||||
worldBooks: updatedWorldBooks,
|
||||
globalWorldBooks: updatedGlobalBooks,
|
||||
currentWorldBook: state.currentWorldBook?.name === data.name
|
||||
? data
|
||||
: state.currentWorldBook,
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error.message,
|
||||
success: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 异步操作:获取所有世界书
|
||||
fetchWorldBooks: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await fetch(`/api/worldbooks/`);
|
||||
const data = await handleResponse(response);
|
||||
|
||||
// 从 LocalStorage 获取全局世界书列表
|
||||
const globalBooks = loadGlobalWorldBooks();
|
||||
|
||||
set({
|
||||
loading: false,
|
||||
worldBooks: data,
|
||||
globalWorldBooks: globalBooks,
|
||||
error: null
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 异步操作:获取指定世界书
|
||||
fetchWorldBook: async (name) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await fetch(`/api/worldbooks/${name}`);
|
||||
const data = await handleResponse(response);
|
||||
set({
|
||||
loading: false,
|
||||
currentWorldBook: data,
|
||||
error: null
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 异步操作:创建世界书
|
||||
createWorldBook: async ({ name, is_global, file }) => {
|
||||
set({ loading: true, error: null, success: false });
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
if (is_global !== undefined) {
|
||||
formData.append('is_global', is_global);
|
||||
}
|
||||
if (file) {
|
||||
formData.append('file', file);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/worldbooks/`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await handleResponse(response);
|
||||
|
||||
set(state => {
|
||||
const newWorldBooks = [...state.worldBooks, data];
|
||||
let newGlobalBooks = [...state.globalWorldBooks];
|
||||
|
||||
// 如果是世界书被标记为全局,添加到全局列表
|
||||
if (data.is_global) {
|
||||
newGlobalBooks = [...newGlobalBooks, data];
|
||||
saveGlobalWorldBooks(newGlobalBooks);
|
||||
}
|
||||
|
||||
return {
|
||||
loading: false,
|
||||
worldBooks: newWorldBooks,
|
||||
globalWorldBooks: newGlobalBooks,
|
||||
currentWorldBook: data,
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error.message,
|
||||
success: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 异步操作:更新世界书
|
||||
updateWorldBook: async ({ name, is_global, file }) => {
|
||||
set({ loading: true, error: null, success: false });
|
||||
try {
|
||||
const formData = new FormData();
|
||||
if (is_global !== undefined) {
|
||||
formData.append('is_global', is_global);
|
||||
}
|
||||
if (file) {
|
||||
formData.append('file', file);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/worldbooks/${name}`, {
|
||||
method: 'PUT',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await handleResponse(response);
|
||||
|
||||
set(state => {
|
||||
const updatedWorldBooks = state.worldBooks.map(wb =>
|
||||
wb.name === data.name ? data : wb
|
||||
);
|
||||
|
||||
// 更新全局世界书列表
|
||||
let updatedGlobalBooks = [...state.globalWorldBooks];
|
||||
const globalIndex = updatedGlobalBooks.findIndex(wb => wb.name === data.name);
|
||||
|
||||
if (data.is_global) {
|
||||
// 如果是世界书被标记为全局
|
||||
if (globalIndex === -1) {
|
||||
// 如果不在全局列表中,添加它
|
||||
updatedGlobalBooks = [...updatedGlobalBooks, data];
|
||||
} else {
|
||||
// 如果已经在全局列表中,更新它
|
||||
updatedGlobalBooks[globalIndex] = data;
|
||||
}
|
||||
} else {
|
||||
// 如果世界书不再全局,从全局列表中移除
|
||||
if (globalIndex !== -1) {
|
||||
updatedGlobalBooks = updatedGlobalBooks.filter(wb => wb.name !== data.name);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存到 LocalStorage
|
||||
saveGlobalWorldBooks(updatedGlobalBooks);
|
||||
|
||||
return {
|
||||
loading: false,
|
||||
worldBooks: updatedWorldBooks,
|
||||
globalWorldBooks: updatedGlobalBooks,
|
||||
currentWorldBook: state.currentWorldBook?.name === data.name
|
||||
? data
|
||||
: state.currentWorldBook,
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error.message,
|
||||
success: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 异步操作:删除世界书
|
||||
deleteWorldBook: async (name) => {
|
||||
set({ loading: true, error: null, success: false });
|
||||
try {
|
||||
const response = await fetch(`/api/worldbooks/${name}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
await handleResponse(response);
|
||||
|
||||
set(state => {
|
||||
const filteredWorldBooks = state.worldBooks.filter(wb => wb.name !== name);
|
||||
const filteredGlobalBooks = state.globalWorldBooks.filter(wb => wb.name !== name);
|
||||
|
||||
// 保存到 LocalStorage
|
||||
saveGlobalWorldBooks(filteredGlobalBooks);
|
||||
|
||||
return {
|
||||
loading: false,
|
||||
worldBooks: filteredWorldBooks,
|
||||
globalWorldBooks: filteredGlobalBooks,
|
||||
currentWorldBook: state.currentWorldBook?.name === name
|
||||
? null
|
||||
: state.currentWorldBook,
|
||||
currentEntries: state.currentWorldBook?.name === name
|
||||
? []
|
||||
: state.currentEntries,
|
||||
currentEntry: state.currentWorldBook?.name === name
|
||||
? null
|
||||
: state.currentEntry,
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
return name;
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error.message,
|
||||
success: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 异步操作:获取世界书的所有条目
|
||||
fetchWorldBookEntries: async (name) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await fetch(`/api/worldbooks/${name}/entries`);
|
||||
const data = await handleResponse(response);
|
||||
|
||||
set(state => {
|
||||
if (state.currentWorldBook?.name === name) {
|
||||
return {
|
||||
loading: false,
|
||||
currentEntries: data,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
return { loading: false, error: null };
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 异步操作:获取世界书的指定条目
|
||||
fetchWorldBookEntry: async (name, uid) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await fetch(`/api/worldbooks/${name}/entries/${uid}`);
|
||||
const data = await handleResponse(response);
|
||||
|
||||
set(state => {
|
||||
if (state.currentWorldBook?.name === name) {
|
||||
return {
|
||||
loading: false,
|
||||
currentEntry: data,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
return { loading: false, error: null };
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 异步操作:创建世界书条目
|
||||
createWorldBookEntry: async (name, entryData) => {
|
||||
set({ loading: true, error: null, success: false });
|
||||
try {
|
||||
// 处理触发配置数据
|
||||
const processedEntryData = { ...entryData };
|
||||
if (processedEntryData.trigger_config && processedEntryData.trigger_config.triggers) {
|
||||
// 创建新的触发配置对象
|
||||
const triggerConfig = {
|
||||
triggers: {}
|
||||
};
|
||||
|
||||
// 处理每个触发策略
|
||||
for (const [strategy, triggerInfo] of Object.entries(processedEntryData.trigger_config.triggers)) {
|
||||
if (Array.isArray(triggerInfo) && triggerInfo.length >= 2) {
|
||||
triggerConfig.triggers[strategy] = [
|
||||
triggerInfo[0], // 是否启用
|
||||
triggerInfo[1] // 配置对象
|
||||
];
|
||||
} else {
|
||||
// 如果格式不正确,设置为不启用
|
||||
triggerConfig.triggers[strategy] = [false, null];
|
||||
}
|
||||
}
|
||||
|
||||
processedEntryData.trigger_config = triggerConfig;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/worldbooks/${name}/entries`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(processedEntryData)
|
||||
});
|
||||
|
||||
const data = await handleResponse(response);
|
||||
|
||||
set(state => {
|
||||
if (state.currentWorldBook?.name === name) {
|
||||
return {
|
||||
loading: false,
|
||||
currentEntries: [...state.currentEntries, data],
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
loading: false,
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error.message,
|
||||
success: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 异步操作:更新世界书条目
|
||||
updateWorldBookEntry: async (name, uid, entryData) => {
|
||||
set({ loading: true, error: null, success: false });
|
||||
try {
|
||||
// 处理触发配置数据
|
||||
const processedEntryData = { ...entryData };
|
||||
if (processedEntryData.trigger_config && processedEntryData.trigger_config.triggers) {
|
||||
// 创建新的触发配置对象
|
||||
const triggerConfig = {
|
||||
triggers: {}
|
||||
};
|
||||
|
||||
// 处理每个触发策略
|
||||
for (const [strategy, triggerInfo] of Object.entries(processedEntryData.trigger_config.triggers)) {
|
||||
if (Array.isArray(triggerInfo) && triggerInfo.length >= 2) {
|
||||
triggerConfig.triggers[strategy] = [
|
||||
triggerInfo[0], // 是否启用
|
||||
triggerInfo[1] // 配置对象
|
||||
];
|
||||
} else {
|
||||
// 如果格式不正确,设置为不启用
|
||||
triggerConfig.triggers[strategy] = [false, null];
|
||||
}
|
||||
}
|
||||
|
||||
processedEntryData.trigger_config = triggerConfig;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/worldbooks/${name}/entries/${uid}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(processedEntryData)
|
||||
});
|
||||
|
||||
const data = await handleResponse(response);
|
||||
|
||||
set(state => {
|
||||
if (state.currentWorldBook?.name === name) {
|
||||
const updatedEntries = state.currentEntries.map(entry =>
|
||||
entry.uid === data.uid ? data : entry
|
||||
);
|
||||
|
||||
return {
|
||||
loading: false,
|
||||
currentEntries: updatedEntries,
|
||||
currentEntry: state.currentEntry?.uid === data.uid
|
||||
? data
|
||||
: state.currentEntry,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
loading: false,
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error.message,
|
||||
success: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 异步操作:删除世界书条目
|
||||
deleteWorldBookEntry: async (name, uid) => {
|
||||
set({ loading: true, error: null, success: false });
|
||||
try {
|
||||
const response = await fetch(`/api/worldbooks/${name}/entries/${uid}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
await handleResponse(response);
|
||||
|
||||
set(state => {
|
||||
if (state.currentWorldBook?.name === name) {
|
||||
const filteredEntries = state.currentEntries.filter(entry => entry.uid !== uid);
|
||||
|
||||
return {
|
||||
loading: false,
|
||||
currentEntries: filteredEntries,
|
||||
currentEntry: state.currentEntry?.uid === uid
|
||||
? null
|
||||
: state.currentEntry,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
loading: false,
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
return { name, uid };
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error.message,
|
||||
success: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 异步操作:导入世界书
|
||||
importWorldBook: async (name, file) => {
|
||||
set({ loading: true, error: null, success: false });
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(`/api/worldbooks/${name}/import`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await handleResponse(response);
|
||||
|
||||
set(state => {
|
||||
const existingIndex = state.worldBooks.findIndex(wb => wb.name === data.name);
|
||||
let updatedWorldBooks;
|
||||
let updatedGlobalBooks = [...state.globalWorldBooks];
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
updatedWorldBooks = [...state.worldBooks];
|
||||
updatedWorldBooks[existingIndex] = data;
|
||||
|
||||
// 更新全局世界书列表
|
||||
const globalIndex = updatedGlobalBooks.findIndex(wb => wb.name === data.name);
|
||||
if (data.is_global) {
|
||||
if (globalIndex === -1) {
|
||||
updatedGlobalBooks = [...updatedGlobalBooks, data];
|
||||
} else {
|
||||
updatedGlobalBooks[globalIndex] = data;
|
||||
}
|
||||
} else {
|
||||
if (globalIndex !== -1) {
|
||||
updatedGlobalBooks = updatedGlobalBooks.filter(wb => wb.name !== data.name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updatedWorldBooks = [...state.worldBooks, data];
|
||||
|
||||
// 如果是世界书被标记为全局,添加到全局列表
|
||||
if (data.is_global) {
|
||||
updatedGlobalBooks = [...updatedGlobalBooks, data];
|
||||
}
|
||||
}
|
||||
|
||||
// 保存到 LocalStorage
|
||||
saveGlobalWorldBooks(updatedGlobalBooks);
|
||||
|
||||
return {
|
||||
loading: false,
|
||||
worldBooks: updatedWorldBooks,
|
||||
globalWorldBooks: updatedGlobalBooks,
|
||||
currentWorldBook: state.currentWorldBook?.name === data.name
|
||||
? data
|
||||
: state.currentWorldBook,
|
||||
success: true,
|
||||
message: '世界书导入成功'
|
||||
};
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error.message,
|
||||
success: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 异步操作:导出世界书
|
||||
exportWorldBook: async (name, format = 'internal') => {
|
||||
set({ loading: true, error: null, success: false });
|
||||
try {
|
||||
const response = await fetch(`/api/worldbooks/${name}/export?format=${format}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
// 处理文件下载
|
||||
const filename = format === 'sillytavern' ? `${name}_sillytavern.json` : `${name}.json`;
|
||||
await handleFileDownload(response, filename);
|
||||
|
||||
set({
|
||||
loading: false,
|
||||
success: true,
|
||||
message: '世界书导出成功'
|
||||
});
|
||||
|
||||
return { name, format };
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
error: error.message,
|
||||
success: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export default useWorldBookStore;
|
||||
5
frontend/src/Store/SideBarLeft/index.js
Normal file
5
frontend/src/Store/SideBarLeft/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// SideBarLeft 相关的 Store
|
||||
export { default as useSideBarLeftStore } from './SideBarLeftSlice';
|
||||
export { default as useApiConfigStore } from './ApiConfigSlice';
|
||||
export { default as usePresetStore } from './PresetSlice';
|
||||
export { default as useWorldBookStore } from './WorldBookSlice';
|
||||
44
frontend/src/Store/SideBarRight/SideBarRightSlice.jsx
Normal file
44
frontend/src/Store/SideBarRight/SideBarRightSlice.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
const useSideBarRightStore = create(
|
||||
persist(
|
||||
(set) => ({
|
||||
selectedTabs: ['dice', 'rag'],
|
||||
|
||||
allTabs: [
|
||||
{ id: 'dice', label: '骰子', title: '掷骰子和随机数生成工具', component: null },
|
||||
{ id: 'debug', label: '调试', title: '查看具体发送了哪些上下文', component: null },
|
||||
{ id: 'macros', label: '宏', title: '主要为快捷输入', component: null },
|
||||
{ id: 'table', label: '表格', title: '动态数据表格展示与编辑,可直接影响后端', component: null },
|
||||
{ id: 'rag', label: 'RAG', title: '查看具体召回了哪些条目', component: null }
|
||||
],
|
||||
|
||||
handleTabClick: (tabId) => set((state) => {
|
||||
if (state.selectedTabs.includes(tabId)) {
|
||||
// 如果已选中,则取消选中
|
||||
return { selectedTabs: state.selectedTabs.filter(id => id !== tabId) };
|
||||
} else if (state.selectedTabs.length < 2) {
|
||||
// 如果未选中且少于2个,则添加
|
||||
return { selectedTabs: [...state.selectedTabs, tabId] };
|
||||
} else {
|
||||
// 如果已有2个,则替换最早选中的
|
||||
return { selectedTabs: [...state.selectedTabs.slice(1), tabId] };
|
||||
}
|
||||
}),
|
||||
|
||||
// 设置特定标签的组件
|
||||
setTabComponent: (tabId, component) => set((state) => ({
|
||||
allTabs: state.allTabs.map(tab =>
|
||||
tab.id === tabId ? { ...tab, component } : tab
|
||||
)
|
||||
}))
|
||||
}),
|
||||
{
|
||||
name: 'sidebar-right-storage', // localStorage key
|
||||
partialize: (state) => ({ selectedTabs: state.selectedTabs }) // 只保存 selectedTabs
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export default useSideBarRightStore;
|
||||
2
frontend/src/Store/SideBarRight/index.js
Normal file
2
frontend/src/Store/SideBarRight/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// SideBarRight 相关的 Store
|
||||
export { default as useSideBarRightStore } from './SideBarRightSlice';
|
||||
412
frontend/src/Store/TopBar/RoleSelectorSlice.jsx
Normal file
412
frontend/src/Store/TopBar/RoleSelectorSlice.jsx
Normal file
@@ -0,0 +1,412 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
// 异步获取角色数据
|
||||
const fetchRoleData = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/chat', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 转换数据格式以适应前端需求
|
||||
const roleData = {};
|
||||
if (data.chat && Array.isArray(data.chat)) {
|
||||
data.chat.forEach(chat => {
|
||||
if (!roleData[chat.role_name]) {
|
||||
roleData[chat.role_name] = [];
|
||||
}
|
||||
// 保存完整的聊天信息
|
||||
roleData[chat.role_name].push({
|
||||
chat_name: chat.chat_name,
|
||||
user_name: chat.user_name,
|
||||
character_name: chat.character_name,
|
||||
last_modified: chat.last_modified,
|
||||
message_count: chat.message_count
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return roleData;
|
||||
} catch (error) {
|
||||
console.error('获取角色数据失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const useRoleSelectorStore = create((set, get) => ({
|
||||
// 状态
|
||||
roleData: {}, // 格式: {role_name: [{chat_name, user_name, character_name, last_modified, message_count}, ...]}
|
||||
selectedRole: null,
|
||||
selectedChat: null, // 现在存储完整的聊天对象
|
||||
hoveredRole: null,
|
||||
clickedRole: null,
|
||||
isLoading: false,
|
||||
searchTerm: '',
|
||||
editingRole: null,
|
||||
editingChat: null, // 现在存储聊天名称字符串
|
||||
showDeleteConfirm: null,
|
||||
deleteType: null,
|
||||
|
||||
|
||||
// 操作
|
||||
fetchRoleData: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const data = await fetchRoleData();
|
||||
set({ roleData: data, isLoading: false });
|
||||
} catch (error) {
|
||||
set({ isLoading: false });
|
||||
console.error('获取角色数据失败:', error);
|
||||
// 可以在这里添加用户提示,例如显示一个toast通知
|
||||
}
|
||||
},
|
||||
|
||||
setSelectedRole: (role) => set({ selectedRole: role }),
|
||||
|
||||
setSelectedChat: (chat) => set({ selectedChat: chat }), // chat现在是对象
|
||||
|
||||
setHoveredRole: (role) => set({ hoveredRole: role }),
|
||||
|
||||
setClickedRole: (role) => set({ clickedRole: role }),
|
||||
|
||||
setSearchTerm: (term) => set({ searchTerm: term }),
|
||||
|
||||
setEditingRole: (role) => set({ editingRole: role }),
|
||||
|
||||
setEditingChat: (chat) => set({ editingChat: chat }), // chat现在是字符串(聊天名称)
|
||||
|
||||
setShowDeleteConfirm: (name) => set({ showDeleteConfirm: name }),
|
||||
|
||||
setDeleteType: (type) => set({ deleteType: type }),
|
||||
|
||||
// 同时更新角色和聊天
|
||||
setSelectedRoleAndChat: (role, chat) => set({ selectedRole: role, selectedChat: chat }),
|
||||
|
||||
// 处理角色重命名
|
||||
// 处理角色重命名
|
||||
handleRenameRole: async (oldName, newName) => {
|
||||
const { roleData, selectedRole } = get();
|
||||
if (newName && newName !== oldName) {
|
||||
// 保存原始状态以便在失败时回滚
|
||||
const originalRoleData = JSON.parse(JSON.stringify(roleData));
|
||||
const originalSelectedRole = selectedRole;
|
||||
|
||||
try {
|
||||
// 获取该角色下的所有聊天
|
||||
const chats = roleData[oldName] || [];
|
||||
|
||||
// 为每个聊天更新元数据中的角色名称
|
||||
for (const chat of chats) {
|
||||
const response = await fetch(`/api/chat/${encodeURIComponent(oldName)}/${encodeURIComponent(chat.chat_name)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: { character_name: newName }
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update chat: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新本地状态
|
||||
const newRoleData = { ...roleData };
|
||||
newRoleData[newName] = chats;
|
||||
delete newRoleData[oldName];
|
||||
|
||||
if (selectedRole === oldName) {
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
selectedRole: newName,
|
||||
editingRole: null
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
editingRole: null
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重命名角色失败:', error);
|
||||
// 回滚到原始状态
|
||||
set({
|
||||
roleData: originalRoleData,
|
||||
selectedRole: originalSelectedRole,
|
||||
editingRole: null
|
||||
});
|
||||
// 可以在这里添加用户提示
|
||||
}
|
||||
} else {
|
||||
set({ editingRole: null });
|
||||
}
|
||||
},
|
||||
|
||||
// 处理聊天重命名
|
||||
handleRenameChat: async (oldChatName, newName) => {
|
||||
const { roleData, selectedRole, selectedChat } = get();
|
||||
if (newName && newName !== oldChatName) {
|
||||
// 保存原始状态以便在失败时回滚
|
||||
const originalRoleData = JSON.parse(JSON.stringify(roleData));
|
||||
const originalSelectedChat = selectedChat;
|
||||
|
||||
try {
|
||||
// 更新后端聊天名称
|
||||
const response = await fetch(`/api/chat/${encodeURIComponent(selectedRole)}/${encodeURIComponent(oldChatName)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: { chat_name: newName }
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to rename chat: ${response.status}`);
|
||||
}
|
||||
|
||||
// 更新本地状态
|
||||
const newRoleData = { ...roleData };
|
||||
const chatIndex = newRoleData[selectedRole].findIndex(chat => chat.chat_name === oldChatName);
|
||||
if (chatIndex !== -1) {
|
||||
// 创建一个新的聊天对象,只更新chat_name
|
||||
const updatedChat = { ...newRoleData[selectedRole][chatIndex], chat_name: newName };
|
||||
newRoleData[selectedRole][chatIndex] = updatedChat;
|
||||
|
||||
if (selectedChat && selectedChat.chat_name === oldChatName) {
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
selectedChat: updatedChat,
|
||||
editingChat: null
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
editingChat: null
|
||||
});
|
||||
}
|
||||
} else {
|
||||
set({ editingChat: null });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重命名聊天失败:', error);
|
||||
// 回滚到原始状态
|
||||
set({
|
||||
roleData: originalRoleData,
|
||||
selectedChat: originalSelectedChat,
|
||||
editingChat: null
|
||||
});
|
||||
// 可以在这里添加用户提示
|
||||
}
|
||||
} else {
|
||||
set({ editingChat: null });
|
||||
}
|
||||
},
|
||||
|
||||
// 确认删除
|
||||
// 确认删除
|
||||
confirmDelete: async () => {
|
||||
const { roleData, selectedRole, selectedChat, showDeleteConfirm, deleteType } = get();
|
||||
|
||||
// 保存原始状态以便在失败时回滚
|
||||
const originalRoleData = JSON.parse(JSON.stringify(roleData));
|
||||
const originalSelectedRole = selectedRole;
|
||||
const originalSelectedChat = selectedChat;
|
||||
|
||||
try {
|
||||
if (deleteType === 'role') {
|
||||
// 删除角色下的所有聊天
|
||||
const chats = roleData[showDeleteConfirm] || [];
|
||||
for (const chat of chats) {
|
||||
const response = await fetch(`/api/chat/${encodeURIComponent(showDeleteConfirm)}/${encodeURIComponent(chat.chat_name)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete chat: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
const newRoleData = { ...roleData };
|
||||
delete newRoleData[showDeleteConfirm];
|
||||
|
||||
if (selectedRole === showDeleteConfirm) {
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
selectedRole: null,
|
||||
selectedChat: null,
|
||||
showDeleteConfirm: null,
|
||||
deleteType: null
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
showDeleteConfirm: null,
|
||||
deleteType: null
|
||||
});
|
||||
}
|
||||
} else if (deleteType === 'chat') {
|
||||
// 删除单个聊天
|
||||
const response = await fetch(`/api/chat/${encodeURIComponent(selectedRole)}/${encodeURIComponent(showDeleteConfirm)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete chat: ${response.status}`);
|
||||
}
|
||||
|
||||
const newRoleData = { ...roleData };
|
||||
const chatIndex = newRoleData[selectedRole].findIndex(chat => chat.chat_name === showDeleteConfirm);
|
||||
if (chatIndex !== -1) {
|
||||
newRoleData[selectedRole].splice(chatIndex, 1);
|
||||
|
||||
if (selectedChat && selectedChat.chat_name === showDeleteConfirm) {
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
selectedChat: null,
|
||||
showDeleteConfirm: null,
|
||||
deleteType: null
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
showDeleteConfirm: null,
|
||||
deleteType: null
|
||||
});
|
||||
}
|
||||
} else {
|
||||
set({
|
||||
showDeleteConfirm: null,
|
||||
deleteType: null
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
// 回滚到原始状态
|
||||
set({
|
||||
roleData: originalRoleData,
|
||||
selectedRole: originalSelectedRole,
|
||||
selectedChat: originalSelectedChat,
|
||||
showDeleteConfirm: null,
|
||||
deleteType: null
|
||||
});
|
||||
// 可以在这里添加用户提示
|
||||
}
|
||||
},
|
||||
|
||||
// 取消删除
|
||||
cancelDelete: () => set({ showDeleteConfirm: null, deleteType: null }),
|
||||
|
||||
// 添加新角色
|
||||
handleAddRole: async () => {
|
||||
const { roleData } = get();
|
||||
const newRole = '新角色';
|
||||
|
||||
try {
|
||||
// 创建新角色(通过创建一个默认聊天)
|
||||
const response = await fetch(`/api/chat/${encodeURIComponent(newRole)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_name: '默认聊天',
|
||||
metadata: {
|
||||
user_name: 'User',
|
||||
character_name: newRole
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create role: ${response.status}`);
|
||||
}
|
||||
|
||||
const newRoleData = { ...roleData };
|
||||
newRoleData[newRole] = [{
|
||||
chat_name: '默认聊天',
|
||||
user_name: 'User',
|
||||
character_name: newRole,
|
||||
last_modified: '',
|
||||
message_count: 0
|
||||
}];
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
editingRole: newRole
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('添加角色失败:', error);
|
||||
// 可以在这里添加用户提示
|
||||
}
|
||||
},
|
||||
|
||||
// 添加新聊天
|
||||
handleAddChat: async () => {
|
||||
const { roleData, selectedRole } = get();
|
||||
if (!selectedRole) return;
|
||||
|
||||
const newChat = '新聊天';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/chat/${encodeURIComponent(selectedRole)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_name: newChat,
|
||||
metadata: {
|
||||
user_name: 'User',
|
||||
character_name: selectedRole
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create chat: ${response.status}`);
|
||||
}
|
||||
|
||||
const newRoleData = { ...roleData };
|
||||
newRoleData[selectedRole].push({
|
||||
chat_name: newChat,
|
||||
user_name: 'User',
|
||||
character_name: selectedRole,
|
||||
last_modified: '',
|
||||
message_count: 0
|
||||
});
|
||||
set({
|
||||
roleData: newRoleData,
|
||||
editingChat: newChat
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('添加聊天失败:', error);
|
||||
// 可以在这里添加用户提示
|
||||
}
|
||||
},
|
||||
|
||||
// 重置面板状态
|
||||
resetPanel: () => set({
|
||||
hoveredRole: null,
|
||||
clickedRole: null,
|
||||
editingRole: null,
|
||||
editingChat: null,
|
||||
showDeleteConfirm: null
|
||||
})
|
||||
}));
|
||||
|
||||
export default useRoleSelectorStore;
|
||||
2
frontend/src/Store/TopBar/index.js
Normal file
2
frontend/src/Store/TopBar/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// TopBar 相关的 Store
|
||||
export { default as useRoleSelectorStore } from './RoleSelectorSlice';
|
||||
6
frontend/src/Store/indexStore.jsx
Normal file
6
frontend/src/Store/indexStore.jsx
Normal file
@@ -0,0 +1,6 @@
|
||||
// frontend-react/src/store/index.js
|
||||
// 统一导出所有 Store,方便外部使用
|
||||
export { useRoleSelectorStore } from './TopBar';
|
||||
export { useSideBarLeftStore, useApiConfigStore, usePresetStore, useWorldBookStore } from './SideBarLeft';
|
||||
export { useSideBarRightStore } from './SideBarRight';
|
||||
export { useChatBoxStore } from './Mid';
|
||||
399
frontend/src/components/Mid/ChatBox/ChatBox.css
Normal file
399
frontend/src/components/Mid/ChatBox/ChatBox.css
Normal file
@@ -0,0 +1,399 @@
|
||||
/* frontend-react/src/components/Mid/ChatBox/ChatBox.css */
|
||||
.chat-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 80%;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
align-self: flex-end;
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
border-bottom-right-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.message.ai {
|
||||
align-self: flex-start;
|
||||
background-color: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
border-bottom-left-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.message-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.message-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.message-id {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.message-toolbar {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.toolbar-buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.toolbar-button:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.message-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-lg);
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message.user .bubble {
|
||||
background: transparent;
|
||||
color: white;
|
||||
border-bottom-right-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.message.ai .bubble {
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
border-bottom-left-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.edit-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.edit-textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.edit-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cancel-button, .save-button {
|
||||
padding: 5px 15px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.swipe-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.swipe-button {
|
||||
background: none;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.8rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.swipe-button:hover:not(:disabled) {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.swipe-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.swipe-counter {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.chat-input-wrapper {
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
background-color: var(--color-bg-secondary);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.options-wrapper {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.options-toggle {
|
||||
width: 24px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.options-toggle:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.options-toggle.active {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.options-toggle svg {
|
||||
display: none; /* Hide SVG, use text instead */
|
||||
}
|
||||
|
||||
.chat-options {
|
||||
position: absolute;
|
||||
bottom: calc(100% + var(--spacing-sm));
|
||||
left: 0;
|
||||
background-color: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-sm);
|
||||
box-shadow: var(--shadow-xl);
|
||||
z-index: var(--z-dropdown);
|
||||
min-width: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.option-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.option-checkbox input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
background-color: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.option-checkbox:hover .checkmark {
|
||||
border-color: var(--color-accent);
|
||||
background-color: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.option-checkbox input:checked ~ .checkmark {
|
||||
background-color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.checkmark:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
left: 5px;
|
||||
top: 2px;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.option-checkbox input:checked ~ .checkmark:after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.option-divider {
|
||||
height: 1px;
|
||||
background-color: var(--color-border-light);
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: transparent;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
resize: none; /* 禁用手动调整大小 */
|
||||
overflow-y: hidden; /* 隐藏滚动条 */
|
||||
min-height: 28px; /* 最小高度(一行) */
|
||||
max-height: 300px; /* 最大高度 */
|
||||
transition: height 0.15s ease; /* 平滑过渡 */
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message-input:focus {
|
||||
outline: none;
|
||||
background-color: var(--color-bg-primary);
|
||||
border-color: var(--color-border);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-ultra-light);
|
||||
}
|
||||
|
||||
.message-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
width: 24px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.send-button:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.send-button:active {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.send-button svg {
|
||||
display: none; /* Hide SVG, use text instead */
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
text-align: center;
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
343
frontend/src/components/Mid/ChatBox/ChatBox.jsx
Normal file
343
frontend/src/components/Mid/ChatBox/ChatBox.jsx
Normal file
@@ -0,0 +1,343 @@
|
||||
// frontend-react/src/components/ChatBox/ChatBox.jsx
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import useChatBoxStore from '../../../Store/Mid/ChatBoxSlice';
|
||||
import './ChatBox.css';
|
||||
|
||||
const ChatBox = () => {
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const messagesEndRef = useRef(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
const optionsRef = useRef(null);
|
||||
|
||||
// 新增:管理每条消息的当前显示的swipe版本
|
||||
const [currentSwipeId, setCurrentSwipeId] = useState({});
|
||||
|
||||
// 从 ChatBoxStore 获取状态和方法
|
||||
const {
|
||||
messages,
|
||||
isLoading,
|
||||
error,
|
||||
userName,
|
||||
characterName,
|
||||
updateMessage,
|
||||
isGenerating,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
options,
|
||||
toggleOption
|
||||
} = useChatBoxStore();
|
||||
|
||||
const [inputHeight, setInputHeight] = useState(42);
|
||||
|
||||
// 点击外部关闭选项面板
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (optionsRef.current && !optionsRef.current.contains(event.target) &&
|
||||
!event.target.closest('.options-button')) {
|
||||
setShowOptions(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showOptions) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showOptions]);
|
||||
|
||||
const handleInputHeight = (e) => {
|
||||
const textarea = e.target;
|
||||
|
||||
// 标准方案:先重置为 auto,再设置为 scrollHeight
|
||||
// 这是业界公认的最佳实践,确保高度计算准确
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = textarea.scrollHeight + 'px';
|
||||
};
|
||||
|
||||
// 处理发送或终止
|
||||
const handleSendOrStop = () => {
|
||||
if (isGenerating) {
|
||||
stopGeneration();
|
||||
} else {
|
||||
sendMessage(inputValue);
|
||||
setInputValue('');
|
||||
setInputHeight(24); // 重置为一行高度
|
||||
}
|
||||
};
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendOrStop();
|
||||
}
|
||||
};
|
||||
|
||||
// 自动滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
// 处理编辑消息
|
||||
const handleEdit = (message) => {
|
||||
setEditingId(message.floor);
|
||||
setEditContent(message.mes);
|
||||
};
|
||||
|
||||
// 保存编辑
|
||||
const handleSaveEdit = (messageId) => {
|
||||
// 调用 store 中的 updateMessage 方法
|
||||
updateMessage(messageId, editContent);
|
||||
setEditingId(null);
|
||||
setEditContent('');
|
||||
};
|
||||
|
||||
// 取消编辑
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditContent('');
|
||||
};
|
||||
|
||||
// 新增:处理swipe切换
|
||||
const handleSwipeChange = (messageId, direction) => {
|
||||
const message = messages.find(m => m.floor === messageId);
|
||||
if (message && message.swipes && message.swipes.length > 0) {
|
||||
const currentIndex = currentSwipeId[messageId] !== undefined
|
||||
? currentSwipeId[messageId]
|
||||
: message.swipe_id;
|
||||
const newIndex = currentIndex + direction;
|
||||
|
||||
if (newIndex >= 0 && newIndex < message.swipes.length) {
|
||||
setCurrentSwipeId(prev => ({
|
||||
...prev,
|
||||
[messageId]: newIndex
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 切换选项显示
|
||||
const toggleOptionsPanel = () => {
|
||||
setShowOptions(!showOptions);
|
||||
};
|
||||
|
||||
// 渲染单条消息
|
||||
const renderMessage = (message) => {
|
||||
const isUser = message.is_user;
|
||||
const isEditing = editingId === message.floor;
|
||||
|
||||
// 判断是否为最新消息
|
||||
const isLatestMessage = messages.length > 0 && message.floor === messages[messages.length - 1].floor;
|
||||
|
||||
// 根据消息类型设置显示名称
|
||||
const displayName = isUser ? userName : characterName;
|
||||
|
||||
// 确定当前显示的消息内容
|
||||
let currentMes = message.mes;
|
||||
let hasSwipes = message.swipes && message.swipes.length > 0;
|
||||
let currentSwipeIndex = message.swipe_id;
|
||||
|
||||
if (hasSwipes) {
|
||||
// 如果有swipes数组
|
||||
if (currentSwipeId[message.floor] !== undefined) {
|
||||
// 如果用户已经切换过版本,使用用户选择的版本
|
||||
currentSwipeIndex = currentSwipeId[message.floor];
|
||||
} else {
|
||||
// 否则使用默认的swipe_id
|
||||
currentSwipeIndex = message.swipe_id;
|
||||
}
|
||||
|
||||
if (currentSwipeIndex >= 0 && currentSwipeIndex < message.swipes.length) {
|
||||
currentMes = message.swipes[currentSwipeIndex];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={message.floor} className={`message ${isUser ? 'user' : 'ai'}`}>
|
||||
<div className="message-container">
|
||||
<div className="message-header">
|
||||
<span className="message-name">{displayName}</span>
|
||||
<span className="message-id">#{message.floor}</span>
|
||||
<div className="message-toolbar">
|
||||
<div className="toolbar-buttons">
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={() => handleEdit(message)}
|
||||
title="编辑"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
title="更多"
|
||||
>
|
||||
•••
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="message-content">
|
||||
{isEditing ? (
|
||||
<div className="edit-container">
|
||||
<textarea
|
||||
className="edit-textarea"
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
/>
|
||||
<div className="edit-buttons">
|
||||
<button
|
||||
className="cancel-button"
|
||||
onClick={handleCancelEdit}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="save-button"
|
||||
onClick={() => handleSaveEdit(message.floor)}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bubble">
|
||||
{options.htmlRender && !isUser ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: currentMes }} />
|
||||
) : (
|
||||
currentMes
|
||||
)}
|
||||
{hasSwipes && isLatestMessage && !isUser && (
|
||||
<div className="swipe-controls">
|
||||
<button
|
||||
className="swipe-button"
|
||||
onClick={() => handleSwipeChange(message.floor, -1)}
|
||||
disabled={currentSwipeIndex === 0}
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<span className="swipe-counter">
|
||||
{currentSwipeIndex + 1}/{message.swipes.length}
|
||||
</span>
|
||||
<button
|
||||
className="swipe-button"
|
||||
onClick={() => handleSwipeChange(message.floor, 1)}
|
||||
disabled={currentSwipeIndex === message.swipes.length - 1}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-box">
|
||||
<div className="chat-messages">
|
||||
{isLoading ? (
|
||||
<div className="loading">加载中...</div>
|
||||
) : error ? (
|
||||
<div className="error">{error}</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="loading">暂无消息</div>
|
||||
) : (
|
||||
messages.map(renderMessage)
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<div className="chat-input-wrapper">
|
||||
<div className="input-container">
|
||||
<div className="options-wrapper" ref={optionsRef}>
|
||||
<button
|
||||
className={`options-toggle ${showOptions ? 'active' : ''}`}
|
||||
title="Toggle Options"
|
||||
onClick={() => setShowOptions(!showOptions)}
|
||||
>
|
||||
{showOptions ? '×' : '≡'}
|
||||
</button>
|
||||
{showOptions && (
|
||||
<div className="chat-options">
|
||||
<label className="option-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.htmlRender}
|
||||
onChange={() => toggleOption('htmlRender')}
|
||||
/>
|
||||
<span className="checkmark"></span>
|
||||
<span className="option-label">HTML渲染</span>
|
||||
</label>
|
||||
<label className="option-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.streamOutput}
|
||||
onChange={() => toggleOption('streamOutput')}
|
||||
/>
|
||||
<span className="checkmark"></span>
|
||||
<span className="option-label">流式输出</span>
|
||||
</label>
|
||||
<label className="option-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.dynamicTable}
|
||||
onChange={() => toggleOption('dynamicTable')}
|
||||
/>
|
||||
<span className="checkmark"></span>
|
||||
<span className="option-label">动态表格</span>
|
||||
</label>
|
||||
|
||||
{/* 生图工作流选项 */}
|
||||
<label className="option-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.imageWorkflow}
|
||||
onChange={() => toggleOption('imageWorkflow')}
|
||||
/>
|
||||
<span className="checkmark"></span>
|
||||
<span className="option-label">🎨 生图工作流</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="chat-input-area">
|
||||
<textarea
|
||||
className="message-input"
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value);
|
||||
handleInputHeight(e);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
height: `${inputHeight}px`,
|
||||
overflowY: inputHeight >= 300 ? 'auto' : 'hidden'
|
||||
}}
|
||||
placeholder="Type your message..."
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className={`send-button ${isGenerating ? 'stopping' : ''}`}
|
||||
onClick={handleSendOrStop}
|
||||
disabled={!inputValue.trim() && !isGenerating}
|
||||
title="Send Message"
|
||||
>
|
||||
{isGenerating ? '■' : '>'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatBox;
|
||||
1
frontend/src/components/Mid/ChatBox/index.js
Normal file
1
frontend/src/components/Mid/ChatBox/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './ChatBox';
|
||||
3
frontend/src/components/Mid/index.js
Normal file
3
frontend/src/components/Mid/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// Mid 区域主组件
|
||||
// 目前主要包含 ChatBox
|
||||
export { default as ChatBox } from './ChatBox';
|
||||
77
frontend/src/components/SideBarLeft/SideBarLeft.css
Normal file
77
frontend/src/components/SideBarLeft/SideBarLeft.css
Normal 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;
|
||||
}
|
||||
41
frontend/src/components/SideBarLeft/SideBarLeft.jsx
Normal file
41
frontend/src/components/SideBarLeft/SideBarLeft.jsx
Normal 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;
|
||||
1
frontend/src/components/SideBarLeft/index.js
Normal file
1
frontend/src/components/SideBarLeft/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './SideBarLeft';
|
||||
738
frontend/src/components/SideBarLeft/tabs/ApiConfig/ApiConfig.css
Normal file
738
frontend/src/components/SideBarLeft/tabs/ApiConfig/ApiConfig.css
Normal 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);
|
||||
}
|
||||
828
frontend/src/components/SideBarLeft/tabs/ApiConfig/ApiConfig.jsx
Normal file
828
frontend/src/components/SideBarLeft/tabs/ApiConfig/ApiConfig.jsx
Normal 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> </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;
|
||||
@@ -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> </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;
|
||||
@@ -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> </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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ApiConfig';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
// frontend-react/src/components/SideBarLeft/tabs/CharacterCard/index.js
|
||||
export { default } from './CharacterCard';
|
||||
13
frontend/src/components/SideBarLeft/tabs/Gallery/Gallery.jsx
Normal file
13
frontend/src/components/SideBarLeft/tabs/Gallery/Gallery.jsx
Normal 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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './Gallery';
|
||||
735
frontend/src/components/SideBarLeft/tabs/Presets/Presets.css
Normal file
735
frontend/src/components/SideBarLeft/tabs/Presets/Presets.css
Normal 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;
|
||||
}
|
||||
}
|
||||
801
frontend/src/components/SideBarLeft/tabs/Presets/Presets.jsx
Normal file
801
frontend/src/components/SideBarLeft/tabs/Presets/Presets.jsx
Normal 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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './Presets';
|
||||
756
frontend/src/components/SideBarLeft/tabs/WorldBook/WorldBook.css
Normal file
756
frontend/src/components/SideBarLeft/tabs/WorldBook/WorldBook.css
Normal 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;
|
||||
}
|
||||
1070
frontend/src/components/SideBarLeft/tabs/WorldBook/WorldBook.jsx
Normal file
1070
frontend/src/components/SideBarLeft/tabs/WorldBook/WorldBook.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
export { default } from './WorldBook';
|
||||
163
frontend/src/components/SideBarRight/SideBarRight.css
Normal file
163
frontend/src/components/SideBarRight/SideBarRight.css
Normal file
@@ -0,0 +1,163 @@
|
||||
.sidebar-right {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
padding: 0 var(--spacing-sm);
|
||||
}
|
||||
|
||||
.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;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 当有两个页面时,第一个页面添加底部分隔线 */
|
||||
.panel-section.has-divider {
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
/* 当只有一个页面选中时,占据全部空间 */
|
||||
.panel-section:only-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* 分割线样式 */
|
||||
.resize-handle {
|
||||
height: 8px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
cursor: row-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background-color: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.resize-handle.dragging {
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.resize-handle-line {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background-color: var(--color-text-muted);
|
||||
border-radius: var(--radius-full);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.resize-handle:hover .resize-handle-line {
|
||||
width: 60px;
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.resize-handle.dragging .resize-handle-line {
|
||||
width: 80px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
/* 上下分割的内容区域 */
|
||||
.split-top,
|
||||
.split-bottom {
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* 未选择分页提示 */
|
||||
.no-tabs-selected {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tab-content:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Tab placeholder for empty states */
|
||||
.tab-placeholder {
|
||||
padding: var(--spacing-lg);
|
||||
height: 100%;
|
||||
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;
|
||||
}
|
||||
143
frontend/src/components/SideBarRight/SideBarRight.jsx
Normal file
143
frontend/src/components/SideBarRight/SideBarRight.jsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import './SideBarRight.css';
|
||||
import Dice from './tabs/Dice';
|
||||
import Debug from './tabs/Debug';
|
||||
import Macros from './tabs/Macros';
|
||||
import Table from './tabs/Table';
|
||||
import RagRecall from './tabs/RagRecall';
|
||||
import useSideBarRightStore from '../../Store/SideBarRight/SideBarRightSlice';
|
||||
|
||||
const SideBarRight = () => {
|
||||
const { selectedTabs, allTabs, handleTabClick, setTabComponent } = useSideBarRightStore();
|
||||
|
||||
// 从 localStorage 加载分割线位置,默认 50%
|
||||
const [splitPosition, setSplitPosition] = useState(() => {
|
||||
const saved = localStorage.getItem('sidebar_right_split');
|
||||
return saved ? parseFloat(saved) : 50;
|
||||
});
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
// 设置标签组件
|
||||
useEffect(() => {
|
||||
setTabComponent('dice', Dice);
|
||||
setTabComponent('debug', Debug);
|
||||
setTabComponent('macros', Macros);
|
||||
setTabComponent('table', Table);
|
||||
setTabComponent('rag', RagRecall);
|
||||
}, [setTabComponent]);
|
||||
|
||||
// 保存分割线位置到 localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebar_right_split', splitPosition.toString());
|
||||
}, [splitPosition]);
|
||||
|
||||
// 处理拖动
|
||||
const handleMouseDown = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!isDragging || !containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const offsetY = e.clientY - rect.top;
|
||||
const percentage = (offsetY / rect.height) * 100;
|
||||
|
||||
// 限制在 20% - 80% 之间
|
||||
const clampedPercentage = Math.min(Math.max(percentage, 20), 80);
|
||||
setSplitPosition(clampedPercentage);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
// 添加全局事件监听
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'row-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
} else {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
return (
|
||||
<div className="sidebar-right" ref={containerRef}>
|
||||
<div className="sidebar-tabs">
|
||||
{allTabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`tab-button ${selectedTabs.includes(tab.id) ? 'active' : ''}`}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
title={tab.title}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-content">
|
||||
{selectedTabs.length === 0 ? (
|
||||
<div className="no-tabs-selected">请选择一个分页</div>
|
||||
) : selectedTabs.length === 1 ? (
|
||||
// 单个分页时占满全部空间
|
||||
<div className="tab-content full-height">
|
||||
{(() => {
|
||||
const tab = allTabs.find(t => t.id === selectedTabs[0]);
|
||||
return tab.component ? <tab.component /> : <div>{tab.label}内容</div>;
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
// 两个分页时显示分割线
|
||||
<>
|
||||
<div
|
||||
className="tab-content split-top"
|
||||
style={{ height: `${splitPosition}%` }}
|
||||
>
|
||||
{(() => {
|
||||
const tab = allTabs.find(t => t.id === selectedTabs[0]);
|
||||
return tab.component ? <tab.component /> : <div>{tab.label}内容</div>;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`resize-handle ${isDragging ? 'dragging' : ''}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
title="拖动调整上下比例"
|
||||
>
|
||||
<div className="resize-handle-line"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="tab-content split-bottom"
|
||||
style={{ height: `${100 - splitPosition}%` }}
|
||||
>
|
||||
{(() => {
|
||||
const tab = allTabs.find(t => t.id === selectedTabs[1]);
|
||||
return tab.component ? <tab.component /> : <div>{tab.label}内容</div>;
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SideBarRight;
|
||||
1
frontend/src/components/SideBarRight/index.js
Normal file
1
frontend/src/components/SideBarRight/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './SideBarRight';
|
||||
12
frontend/src/components/SideBarRight/tabs/Debug/Debug.jsx
Normal file
12
frontend/src/components/SideBarRight/tabs/Debug/Debug.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
const Debug = () => {
|
||||
return (
|
||||
<div className="debug-panel">
|
||||
<h2>上下文调试</h2>
|
||||
<p>这是上下文调试面板的占位页面</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Debug;
|
||||
1
frontend/src/components/SideBarRight/tabs/Debug/index.js
Normal file
1
frontend/src/components/SideBarRight/tabs/Debug/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Debug';
|
||||
281
frontend/src/components/SideBarRight/tabs/Dice/Dice.css
Normal file
281
frontend/src/components/SideBarRight/tabs/Dice/Dice.css
Normal file
@@ -0,0 +1,281 @@
|
||||
/* ==================== Dice Panel Styles ==================== */
|
||||
.dice-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--color-bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 标题栏 */
|
||||
.dice-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.dice-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.dice-help {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.dice-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 输入区域 */
|
||||
.dice-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dice-input-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dice-input-label {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dice-input {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
background-color: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
font-family: 'Courier New', monospace;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.dice-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-light);
|
||||
}
|
||||
|
||||
.dice-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* VS 分隔符 */
|
||||
.dice-vs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent);
|
||||
background-color: var(--color-accent-ultra-light);
|
||||
border-radius: var(--radius-full);
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
/* 按钮区域 */
|
||||
.dice-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dice-roll-btn {
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
background-color: var(--color-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.dice-roll-btn:hover {
|
||||
background-color: var(--color-accent-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.dice-roll-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dice-clear-btn {
|
||||
padding: 8px 12px;
|
||||
background-color: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.dice-clear-btn:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-color: var(--color-text-muted);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* 结果展示区 */
|
||||
.dice-result {
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
padding: 12px;
|
||||
background-color: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dice-result-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 单次掷骰结果 */
|
||||
.dice-result-item {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.dice-result-item:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dice-result-time {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.dice-result-comparison {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dice-result-side {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dice-result-label {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dice-result-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent);
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.dice-result-detail {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.dice-result-winner {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-success);
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
padding: 4px 8px;
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.dice-result-tie {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-warning);
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
padding: 4px 8px;
|
||||
background-color: rgba(245, 158, 11, 0.1);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* 结果动画效果 */
|
||||
@keyframes resultFlash {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-result {
|
||||
animation: resultFlash 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.dice-content::-webkit-scrollbar,
|
||||
.dice-result::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.dice-content::-webkit-scrollbar-track,
|
||||
.dice-result::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dice-content::-webkit-scrollbar-thumb,
|
||||
.dice-result::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-scrollbar);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.dice-content::-webkit-scrollbar-thumb:hover,
|
||||
.dice-result::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-scrollbar-hover);
|
||||
}
|
||||
253
frontend/src/components/SideBarRight/tabs/Dice/Dice.jsx
Normal file
253
frontend/src/components/SideBarRight/tabs/Dice/Dice.jsx
Normal file
@@ -0,0 +1,253 @@
|
||||
// frontend-react/src/components/SideBarRight/tabs/Dice/Dice.jsx
|
||||
import React, { useState } from 'react';
|
||||
import './Dice.css';
|
||||
|
||||
const Dice = () => {
|
||||
// 从 localStorage 加载上次的输入
|
||||
const [leftInput, setLeftInput] = useState(() => {
|
||||
return localStorage.getItem('dice_left_input') || '';
|
||||
});
|
||||
|
||||
const [rightInput, setRightInput] = useState(() => {
|
||||
return localStorage.getItem('dice_right_input') || '';
|
||||
});
|
||||
|
||||
// 当前结果
|
||||
const [currentResult, setCurrentResult] = useState(null);
|
||||
|
||||
// 动画状态
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// 解析骰子表达式
|
||||
const parseDiceExpression = (expression) => {
|
||||
if (!expression || expression.trim() === '') return null;
|
||||
|
||||
const expr = expression.trim();
|
||||
|
||||
// 纯数字(固定值)
|
||||
if (/^\d+$/.test(expr)) {
|
||||
return {
|
||||
type: 'fixed',
|
||||
value: parseInt(expr),
|
||||
display: expr
|
||||
};
|
||||
}
|
||||
|
||||
// 解析多个骰子组合,如 "1d4+1d3"
|
||||
const parts = expr.split(/(?=[+-])/);
|
||||
const components = [];
|
||||
let total = 0;
|
||||
let detail = [];
|
||||
|
||||
for (const part of parts) {
|
||||
// 匹配 NdM 格式
|
||||
const diceMatch = part.match(/^(\d*)d(\d+)$/i);
|
||||
if (diceMatch) {
|
||||
const count = diceMatch[1] ? parseInt(diceMatch[1]) : 1;
|
||||
const sides = parseInt(diceMatch[2]);
|
||||
|
||||
// 掷骰子
|
||||
const rolls = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
rolls.push(Math.floor(Math.random() * sides) + 1);
|
||||
}
|
||||
|
||||
const sum = rolls.reduce((a, b) => a + b, 0);
|
||||
total += sum;
|
||||
|
||||
components.push({
|
||||
type: 'dice',
|
||||
count,
|
||||
sides,
|
||||
rolls,
|
||||
sum
|
||||
});
|
||||
|
||||
detail.push(`${count}d${sides}=[${rolls.join(',')}]`);
|
||||
} else {
|
||||
// 匹配固定数值(带符号)
|
||||
const fixedMatch = part.match(/^([+-])(\d+)$/);
|
||||
if (fixedMatch) {
|
||||
const sign = fixedMatch[1] === '+' ? 1 : -1;
|
||||
const value = parseInt(fixedMatch[2]);
|
||||
total += sign * value;
|
||||
detail.push(`${sign > 0 ? '+' : '-'}${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'mixed',
|
||||
value: total,
|
||||
components,
|
||||
display: expr,
|
||||
detail: detail.join(' ')
|
||||
};
|
||||
};
|
||||
|
||||
// 掷骰子
|
||||
const handleRoll = () => {
|
||||
if (!leftInput && !rightInput) return;
|
||||
|
||||
const leftResult = parseDiceExpression(leftInput);
|
||||
const rightResult = parseDiceExpression(rightInput);
|
||||
|
||||
const result = {
|
||||
timestamp: new Date().toLocaleTimeString('zh-CN', { hour12: false }),
|
||||
left: leftResult,
|
||||
right: rightResult
|
||||
};
|
||||
|
||||
// 触发动画
|
||||
setIsAnimating(true);
|
||||
setCurrentResult(result);
|
||||
|
||||
// 300ms 后结束动画
|
||||
setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// 清空结果
|
||||
const handleClear = () => {
|
||||
setCurrentResult(null);
|
||||
};
|
||||
|
||||
// 清空输入
|
||||
const handleClearInputs = () => {
|
||||
setLeftInput('');
|
||||
setRightInput('');
|
||||
localStorage.removeItem('dice_left_input');
|
||||
localStorage.removeItem('dice_right_input');
|
||||
};
|
||||
|
||||
// 渲染当前结果
|
||||
const renderCurrentResult = () => {
|
||||
if (!currentResult) {
|
||||
return (
|
||||
<div className="dice-result-empty">
|
||||
输入骰子表达式后点击掷骰子
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { left, right, timestamp } = currentResult;
|
||||
|
||||
// 如果只有一边有值,直接显示
|
||||
if (!left || !right) {
|
||||
const single = left || right;
|
||||
return (
|
||||
<div className={`dice-result-item ${isAnimating ? 'animate-result' : ''}`}>
|
||||
<div className="dice-result-time">{timestamp}</div>
|
||||
<div className="dice-result-side">
|
||||
<div className="dice-result-label">{left ? '左侧' : '右侧'}</div>
|
||||
<div className="dice-result-value">{single.value}</div>
|
||||
{single.detail && <div className="dice-result-detail">{single.detail}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 比较两边结果
|
||||
let winnerText = '';
|
||||
let winnerClass = '';
|
||||
|
||||
if (left.value > right.value) {
|
||||
winnerText = '左侧胜出';
|
||||
winnerClass = 'dice-result-winner';
|
||||
} else if (right.value > left.value) {
|
||||
winnerText = '右侧胜出';
|
||||
winnerClass = 'dice-result-winner';
|
||||
} else {
|
||||
winnerText = '平局';
|
||||
winnerClass = 'dice-result-tie';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`dice-result-item ${isAnimating ? 'animate-result' : ''}`}>
|
||||
<div className="dice-result-time">{timestamp}</div>
|
||||
<div className="dice-result-comparison">
|
||||
<div className="dice-result-side">
|
||||
<div className="dice-result-label">左侧</div>
|
||||
<div className="dice-result-value">{left.value}</div>
|
||||
{left.detail && <div className="dice-result-detail">{left.detail}</div>}
|
||||
</div>
|
||||
<div className="dice-result-side">
|
||||
<div className="dice-result-label">右侧</div>
|
||||
<div className="dice-result-value">{right.value}</div>
|
||||
{right.detail && <div className="dice-result-detail">{right.detail}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className={winnerClass}>{winnerText}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dice-panel">
|
||||
{/* 标题栏 */}
|
||||
<div className="dice-header">
|
||||
<span className="dice-title">骰子</span>
|
||||
<span className="dice-help" title="支持格式: 6, 1d4, 2d8-1, 1d4+1d3">?</span>
|
||||
</div>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="dice-content">
|
||||
{/* 输入区域 */}
|
||||
<div className="dice-inputs">
|
||||
<div className="dice-input-wrapper">
|
||||
<label className="dice-input-label">左侧</label>
|
||||
<input
|
||||
type="text"
|
||||
className="dice-input"
|
||||
placeholder="例如: 1d20"
|
||||
value={leftInput}
|
||||
onChange={(e) => {
|
||||
setLeftInput(e.target.value);
|
||||
localStorage.setItem('dice_left_input', e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRoll()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dice-vs">VS</div>
|
||||
|
||||
<div className="dice-input-wrapper">
|
||||
<label className="dice-input-label">右侧</label>
|
||||
<input
|
||||
type="text"
|
||||
className="dice-input"
|
||||
placeholder="例如: 1d20+3"
|
||||
value={rightInput}
|
||||
onChange={(e) => {
|
||||
setRightInput(e.target.value);
|
||||
localStorage.setItem('dice_right_input', e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRoll()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 按钮区域 */}
|
||||
<div className="dice-actions">
|
||||
<button className="dice-roll-btn" onClick={handleRoll}>
|
||||
🎲 掷骰子
|
||||
</button>
|
||||
<button className="dice-clear-btn" onClick={handleClearInputs} title="清空输入">
|
||||
✕
|
||||
</button>
|
||||
<button className="dice-clear-btn" onClick={handleClear} title="清空历史">
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 结果展示区 */}
|
||||
<div className="dice-result">
|
||||
{renderCurrentResult()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dice;
|
||||
1
frontend/src/components/SideBarRight/tabs/Dice/index.js
Normal file
1
frontend/src/components/SideBarRight/tabs/Dice/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Dice';
|
||||
12
frontend/src/components/SideBarRight/tabs/Macros/Macros.jsx
Normal file
12
frontend/src/components/SideBarRight/tabs/Macros/Macros.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
const Macros = () => {
|
||||
return (
|
||||
<div className="macros-panel">
|
||||
<h2>快捷宏</h2>
|
||||
<p>这是快捷宏面板的占位页面</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Macros;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './Macros';
|
||||
@@ -0,0 +1,102 @@
|
||||
/* ==================== RAG 召回页面 - 对称左侧风格 ==================== */
|
||||
|
||||
.rag-recall-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;
|
||||
justify-content: space-between;
|
||||
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);
|
||||
}
|
||||
|
||||
.recall-count {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
padding: 2px 8px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 召回列表 */
|
||||
.recall-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.recall-item {
|
||||
padding: 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.recall-item:hover {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.recall-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.recall-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.recall-score {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
padding: 2px 8px;
|
||||
background: var(--color-accent-light);
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recall-content {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// frontend-react/src/components/SideBarRight/tabs/RagRecall/RagRecall.jsx
|
||||
import React, { useState } from 'react';
|
||||
import './RagRecall.css';
|
||||
|
||||
const RagRecall = () => {
|
||||
const [recalls, setRecalls] = useState([
|
||||
{ id: 1, title: '世界设定', content: '这是一个充满魔法的世界...', score: 0.95 },
|
||||
{ id: 2, title: '角色背景', content: '主角是一位年轻的魔法师...', score: 0.87 },
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="rag-recall-content">
|
||||
{/* 标题栏 */}
|
||||
<div className="tab-header">
|
||||
<span className="title-text">RAG 召回</span>
|
||||
<span className="recall-count">{recalls.length}</span>
|
||||
</div>
|
||||
|
||||
{/* 召回列表 - 只显示最后两个 */}
|
||||
<div className="recall-list">
|
||||
{recalls.slice(-2).map(recall => (
|
||||
<div key={recall.id} className="recall-item">
|
||||
<div className="recall-header">
|
||||
<span className="recall-title">{recall.title}</span>
|
||||
<span className="recall-score">{(recall.score * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="recall-content">
|
||||
{recall.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RagRecall;
|
||||
@@ -0,0 +1,2 @@
|
||||
// frontend-react/src/components/SideBarRight/tabs/RagRecall/index.js
|
||||
export { default } from './RagRecall';
|
||||
12
frontend/src/components/SideBarRight/tabs/Table/Table.jsx
Normal file
12
frontend/src/components/SideBarRight/tabs/Table/Table.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
const Table = () => {
|
||||
return (
|
||||
<div className="table-panel">
|
||||
<h2>动态表格</h2>
|
||||
<p>这是动态表格面板的占位页面</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Table;
|
||||
1
frontend/src/components/SideBarRight/tabs/Table/index.js
Normal file
1
frontend/src/components/SideBarRight/tabs/Table/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Table';
|
||||
259
frontend/src/components/TopBar/TopBar.css
Normal file
259
frontend/src/components/TopBar/TopBar.css
Normal file
@@ -0,0 +1,259 @@
|
||||
/* ==================== TopBar - Reference Design ==================== */
|
||||
|
||||
.top-bar {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
box-shadow: var(--shadow-sm);
|
||||
z-index: 100; /* 降低层级,不遮挡悬浮提示 */
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.top-bar-content {
|
||||
width: 100%;
|
||||
padding: 0 var(--spacing-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* Status Section - Left side */
|
||||
.status-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Actions Section - Right side */
|
||||
.actions-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Status Badge Styles - Minimalist */
|
||||
.status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.status-badge:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 400;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* World Info badge - allow more space */
|
||||
.world-info-badge {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.world-info-badge .status-label {
|
||||
max-width: 330px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Action Button Styles - Badge-like (Similar to status-badge) */
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
cursor: pointer;
|
||||
font-size: 1.4rem; /* 更大的图标 */
|
||||
line-height: 1;
|
||||
min-height: 36px; /* 与 status-badge 一致 */
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
background-color: var(--color-accent-ultra-light);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
display: none; /* Hide SVG, use text instead */
|
||||
}
|
||||
|
||||
/* ==================== 弹出面板通用样式 ==================== */
|
||||
|
||||
.close-panel-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
margin-left: auto;
|
||||
transition: all var(--transition-fast);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.close-panel-button:hover {
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.panel-overlay {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: var(--z-modal-backdrop);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: var(--spacing-xl);
|
||||
animation: fadeIn var(--transition-normal);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-2xl);
|
||||
width: 90%;
|
||||
max-width: 1200px;
|
||||
max-height: calc(100vh - 80px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideDown var(--transition-smooth);
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--gradient-subtle);
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: var(--spacing-lg);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
background-color: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
/* TopBar 全局世界书列表 */
|
||||
.global-books-list-topbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.global-book-item-topbar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%);
|
||||
border: 1px solid rgba(102, 126, 234, 0.2);
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
color: #495057;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.global-book-item-topbar:hover {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.12) 0%, rgba(118, 75, 162, 0.12) 100%);
|
||||
border-color: rgba(102, 126, 234, 0.3);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
|
||||
.global-book-name-topbar {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.no-global-books-topbar {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 14px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main-container {
|
||||
margin-top: 50px;
|
||||
height: calc(100vh - 50px);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
237
frontend/src/components/TopBar/TopBar.jsx
Normal file
237
frontend/src/components/TopBar/TopBar.jsx
Normal file
@@ -0,0 +1,237 @@
|
||||
// frontend-react/src/components/TopBar/TopBar.jsx
|
||||
import React, {useState, useRef, useEffect} from 'react';
|
||||
import './TopBar.css';
|
||||
import ThemeToggle from './items/ThemeToggle';
|
||||
import useWorldBookStore from '../../Store/SideBarLeft/WorldBookSlice';
|
||||
import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice';
|
||||
|
||||
const Toolbar = () => {
|
||||
const [activePanel, setActivePanel] = useState(null);
|
||||
const panelRef = useRef(null);
|
||||
const [currentUserRole, setCurrentUserRole] = useState({ name: '', description: '' });
|
||||
|
||||
// 从 Store 获取全局世界书
|
||||
const { globalWorldBooks } = useWorldBookStore();
|
||||
|
||||
// 从 ApiConfigStore 获取核心和辅助 API 配置
|
||||
const { activeMap, fetchProfile } = useApiConfigStore();
|
||||
const [coreModel, setCoreModel] = useState('未设置');
|
||||
const [assistModel, setAssistModel] = useState('未设置');
|
||||
|
||||
// 加载核心和辅助 API 配置的模型名
|
||||
useEffect(() => {
|
||||
const loadApiModels = async () => {
|
||||
// 加载核心配置
|
||||
if (activeMap['core']) {
|
||||
try {
|
||||
const profile = await fetchProfile(activeMap['core']);
|
||||
const coreApi = profile?.apis?.find(api => api.category === 'core');
|
||||
setCoreModel(coreApi?.model || '未设置');
|
||||
} catch (e) {
|
||||
console.error('加载核心配置失败:', e);
|
||||
setCoreModel('未设置');
|
||||
}
|
||||
} else {
|
||||
setCoreModel('未设置');
|
||||
}
|
||||
|
||||
// 加载辅助配置
|
||||
if (activeMap['assist']) {
|
||||
try {
|
||||
const profile = await fetchProfile(activeMap['assist']);
|
||||
const assistApi = profile?.apis?.find(api => api.category === 'assist');
|
||||
setAssistModel(assistApi?.model || '未设置');
|
||||
} catch (e) {
|
||||
console.error('加载辅助配置失败:', e);
|
||||
setAssistModel('未设置');
|
||||
}
|
||||
} else {
|
||||
setAssistModel('未设置');
|
||||
}
|
||||
};
|
||||
|
||||
loadApiModels();
|
||||
}, [activeMap, fetchProfile]);
|
||||
|
||||
// 点击外部关闭面板
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (panelRef.current && !panelRef.current.contains(event.target)) {
|
||||
setActivePanel(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 加载当前用户角色
|
||||
useEffect(() => {
|
||||
const savedRole = localStorage.getItem('currentUserRole');
|
||||
if (savedRole) {
|
||||
try {
|
||||
setCurrentUserRole(JSON.parse(savedRole));
|
||||
} catch (e) {
|
||||
console.error('解析用户角色失败:', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 处理面板切换
|
||||
const handlePanelToggle = (panelName) => {
|
||||
if (activePanel === panelName) {
|
||||
setActivePanel(null);
|
||||
} else {
|
||||
setActivePanel(panelName);
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭面板
|
||||
const handleClosePanel = () => {
|
||||
setActivePanel(null);
|
||||
};
|
||||
|
||||
// 截断文本
|
||||
const truncateText = (text, maxLength = 20) => {
|
||||
if (!text) return '未选择';
|
||||
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="top-bar">
|
||||
<div className="top-bar-content">
|
||||
{/* 左侧:状态徽章区域 */}
|
||||
<div className="status-section">
|
||||
{/* 当前玩家角色 */}
|
||||
<div
|
||||
className="status-badge"
|
||||
title="当前玩家角色"
|
||||
onClick={() => handlePanelToggle('currentRole')}
|
||||
>
|
||||
<span className="status-icon">😊</span>
|
||||
<span className="status-label">{truncateText(currentUserRole.name || '未设置', 15)}</span>
|
||||
</div>
|
||||
|
||||
{/* 核心配置 */}
|
||||
<div className="status-badge" title={`核心配置: ${coreModel}`}>
|
||||
<span className="status-icon">🔌</span>
|
||||
<span className="status-label">{truncateText(coreModel, 12)}</span>
|
||||
</div>
|
||||
|
||||
{/* 辅助配置 */}
|
||||
<div className="status-badge" title={`辅助配置: ${assistModel}`}>
|
||||
<span className="status-icon">🔗</span>
|
||||
<span className="status-label">{truncateText(assistModel, 12)}</span>
|
||||
</div>
|
||||
|
||||
{/* 当前预设 */}
|
||||
<div className="status-badge" title="当前预设名">
|
||||
<span className="status-icon">⚙️</span>
|
||||
<span className="status-label">默认预设</span>
|
||||
</div>
|
||||
|
||||
{/* 激活的世界书 */}
|
||||
<div className="status-badge world-info-badge" title="已激活全局世界书" onClick={() => handlePanelToggle('worldBook')}>
|
||||
<span className="status-icon">📚</span>
|
||||
<span className="status-label">
|
||||
{globalWorldBooks.length > 0
|
||||
? globalWorldBooks.map(wb => wb.name).join(', ')
|
||||
: '无'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作按钮区域 */}
|
||||
<div className="actions-section">
|
||||
{/* 设置按钮 */}
|
||||
<button
|
||||
className="action-btn"
|
||||
title="设置"
|
||||
onClick={() => handlePanelToggle('settings')}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
|
||||
{/* 扩展按钮 */}
|
||||
<button
|
||||
className="action-btn"
|
||||
title="扩展"
|
||||
onClick={() => handlePanelToggle('extensions')}
|
||||
>
|
||||
⊞
|
||||
</button>
|
||||
|
||||
{/* 主题切换 */}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 全局世界书面板 */}
|
||||
{activePanel === 'worldBook' && (
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
<div className="panel-content">
|
||||
<div className="panel-header">
|
||||
<h3>全局世界书 ({globalWorldBooks.length})</h3>
|
||||
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
{globalWorldBooks.length > 0 ? (
|
||||
<div className="global-books-list-topbar">
|
||||
{globalWorldBooks.map(book => (
|
||||
<div key={book.name} className="global-book-item-topbar">
|
||||
<span className="global-book-name-topbar">{book.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="no-global-books-topbar">暂无全局世界书</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 设置面板 */}
|
||||
{activePanel === 'settings' && (
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
<div className="panel-content">
|
||||
<div className="panel-header">
|
||||
<h3>系统设置</h3>
|
||||
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<p>系统设置内容...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 拓展面板 */}
|
||||
{activePanel === 'extensions' && (
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
<div className="panel-content">
|
||||
<div className="panel-header">
|
||||
<h3>功能拓展</h3>
|
||||
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<p>功能拓展内容...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toolbar;
|
||||
1
frontend/src/components/TopBar/index.js
Normal file
1
frontend/src/components/TopBar/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './TopBar';
|
||||
@@ -0,0 +1,80 @@
|
||||
.current-user-role {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.role-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
background-color: #2a2a2a;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.role-preview {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background-color: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preview-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #6c63ff;
|
||||
}
|
||||
|
||||
.preview-description {
|
||||
font-size: 14px;
|
||||
color: #cccccc;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './CurrentUserRole';
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Theme Toggle - Inherits from action-btn */
|
||||
.theme-toggle {
|
||||
/* All styles inherited from .action-btn */
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './ThemeToggle.css';
|
||||
|
||||
const ThemeToggle = () => {
|
||||
const [theme, setTheme] = useState(() => {
|
||||
// 从 localStorage 读取主题,默认为 dark
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
return savedTheme || 'dark';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// 应用主题到 document
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
// 保存到 localStorage
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className="action-btn theme-toggle"
|
||||
onClick={toggleTheme}
|
||||
title={theme === 'light' ? '切换到夜间模式' : '切换到白天模式'}
|
||||
>
|
||||
{theme === 'light' ? '☾' : '☀'}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeToggle;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ThemeToggle';
|
||||
146
frontend/src/components/shared/Markdown2Html/Markdown2Html.jsx
Normal file
146
frontend/src/components/shared/Markdown2Html/Markdown2Html.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
// // npm install react react-dom react-markdown react-syntax-highlighter remark-math rehype-katex react-copy-to-clipboard mermaid katex
|
||||
// import React, { useState, useCallback, useEffect } from "react";
|
||||
// import ReactMarkdown from "react-markdown";
|
||||
// import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
// import { materialLight } from "react-syntax-highlighter/dist/cjs/styles/prism";
|
||||
// import remarkMath from "remark-math";
|
||||
// import rehypeKatex from "rehype-katex";
|
||||
// import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
// import mermaid from "mermaid";
|
||||
// import "katex/dist/katex.min.css";
|
||||
//
|
||||
// // Mermaid 初始化配置
|
||||
// // Mermaid是一个画图的语言
|
||||
// mermaid.initialize({
|
||||
// startOnLoad: false,
|
||||
// theme: "default",
|
||||
// securityLevel: "loose",
|
||||
// });
|
||||
//
|
||||
// const DownSvg = () => (
|
||||
// <svg width="12" height="12" viewBox="0 0 24 24">
|
||||
// <path d="M7 10l5 5 5-5z" fill="currentColor" />
|
||||
// </svg>
|
||||
// );
|
||||
//
|
||||
// const CopySvg = () => (
|
||||
// <svg width="14" height="14" viewBox="0 0 24 24">
|
||||
// <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" fill="currentColor" />
|
||||
// </svg>
|
||||
// );
|
||||
//
|
||||
//
|
||||
// const MermaidChart= ({ code }) => {
|
||||
// const [svg, setSvg] = useState("");
|
||||
// const [id] = useState(() => `mermaid-${Math.random().toString(36).substr(2, 9)}`);
|
||||
//
|
||||
// useEffect(() => {
|
||||
// try {
|
||||
// mermaid.parse(code);
|
||||
// mermaid.render(id, code).then(({ svg }) => setSvg(svg));
|
||||
// } catch (err) {
|
||||
// setSvg(`<pre>Error rendering mermaid: ${err}</pre>`);
|
||||
// }
|
||||
// }, [code, id]);
|
||||
//
|
||||
// return <div dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||
// };
|
||||
//
|
||||
// const MarkdownToHTML= ({ markdownRAW, className }) => {
|
||||
// const [isMermaidLoaded] = useState(true);
|
||||
// // 渲染代码块的方法
|
||||
// const CodeBlock = useCallback(
|
||||
// ({ node, inline, className, children, ...props } ) => {
|
||||
// const [isShowCode, setIsShowCode] = useState(true); // 添加展开与收起代码块状态
|
||||
// const [isShowCopy, setIsShowCopy] = useState(false);// 添加点击复制的状态
|
||||
// const match = /language-(\w+)/.exec(className || ""); // 这是用来匹配代码块对应语言的方法
|
||||
// const codeContent = String(children).replace(/\n$/, "");
|
||||
// // 处理复制成功提示
|
||||
// const handleCopy = () => {
|
||||
// setIsShowCopy(true);
|
||||
// setTimeout(() => setIsShowCopy(false), 1500);
|
||||
// };
|
||||
// //处理单行代码
|
||||
// if (inline) {
|
||||
// return <code className={className} {...props}>{children}</code>;
|
||||
// }
|
||||
//
|
||||
// // 处理 Mermaid 图表代码
|
||||
// if (match?.[1] === "mermaid" && isMermaidLoaded) {
|
||||
// return (
|
||||
// <div style={{ position: "relative", margin: "20px 0" }}>
|
||||
// <div className="code-header">
|
||||
// <div
|
||||
// style={{ cursor: "pointer", marginRight: "10px", transformOrigin: "8px" }}
|
||||
// className={isShowCode ? "code-rotate-down" : "code-rotate-right"}
|
||||
// onClick={() => setIsShowCode(!isShowCode)}
|
||||
// >
|
||||
// <DownSvg />
|
||||
// </div>
|
||||
// <div>{match[1]}</div>
|
||||
// <CopyToClipboard text={codeContent} onCopy={handleCopy}>
|
||||
// <div className="preview-code-copy" style={{ cursor: "pointer" }}>
|
||||
// {isShowCopy && <span className="copy-success">✓ 复制成功</span>}
|
||||
// <CopySvg />
|
||||
// </div>
|
||||
// </CopyToClipboard>
|
||||
// </div>
|
||||
// {isShowCode && <MermaidChart code={codeContent} />}
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// return (
|
||||
// <div style={{ position: "relative", padding:"0"}}>
|
||||
// <div className="code-header">
|
||||
// <div
|
||||
// style={{ cursor: "pointer", marginRight: "10px", transformOrigin: "8px" }}
|
||||
// className={isShowCode ? "code-rotate-down" : "code-rotate-right"}
|
||||
// onClick={() => setIsShowCode(!isShowCode)}
|
||||
// >
|
||||
// <DownSvg style={{
|
||||
// transform: isShowCode ? "rotate(0deg)" : "rotate(-90deg)",
|
||||
// transition: "transform 0.2s"
|
||||
// }} />
|
||||
// </div>
|
||||
// <div>{match?.[1] || "code"}</div>
|
||||
// <CopyToClipboard text={codeContent} onCopy={handleCopy}>
|
||||
// <div className="preview-code-copy" style={{ cursor: "pointer" }}>
|
||||
// {isShowCopy && <span className="copy-success">✓ 复制成功</span>}
|
||||
// <CopySvg />
|
||||
// </div>
|
||||
// </CopyToClipboard>
|
||||
// </div>
|
||||
// {isShowCode && (
|
||||
// <SyntaxHighlighter
|
||||
// style={materialLight}
|
||||
// language={match?.[1] || "text"}
|
||||
// PreTag="div"
|
||||
// showLineNumbers
|
||||
// {...props}
|
||||
// >
|
||||
// {codeContent}
|
||||
// </SyntaxHighlighter>
|
||||
// )}
|
||||
// </div>
|
||||
// );
|
||||
// },
|
||||
// [isMermaidLoaded]
|
||||
// );
|
||||
//
|
||||
// return (
|
||||
// <div
|
||||
// className={className} >
|
||||
// <ReactMarkdown
|
||||
// remarkPlugins={[remarkMath]}
|
||||
// rehypePlugins={[rehypeKatex]}
|
||||
// components={{ code: CodeBlock }}
|
||||
// >
|
||||
// {markdownRAW}
|
||||
// </ReactMarkdown>
|
||||
//
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
//
|
||||
// export default MarkdownToHTML;
|
||||
@@ -0,0 +1,25 @@
|
||||
// 下面这些包如果显示不存在则运行这个代码安装依赖:
|
||||
// npm install marked marked-highlight highlight.js
|
||||
import { marked } from "marked";
|
||||
import { markedHighlight } from "marked-highlight";
|
||||
import hljs from "highlight.js";
|
||||
import "highlight.js/styles/base16/github.css";
|
||||
|
||||
// 配置 marked 和 markedHighlight
|
||||
marked.use(
|
||||
markedHighlight({
|
||||
langPrefix: "hljs language-",
|
||||
highlight(code, lang) {
|
||||
const language = hljs.getLanguage(lang) ? lang : "plaintext"; // 如果语言不支持,回退为纯文本
|
||||
return hljs.highlight(code, { language }).value; // 使用 highlight.js 高亮代码
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Markdown 解析函数
|
||||
const MarkdownRenderer = (markdownString)=> {
|
||||
const res= marked.parse(markdownString).toString()
|
||||
return res; // 将 Markdown 转换为 HTML
|
||||
};
|
||||
|
||||
export default MarkdownRenderer;
|
||||
1
frontend/src/components/shared/Markdown2Html/index.js
Normal file
1
frontend/src/components/shared/Markdown2Html/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Markdown2Html';
|
||||
115
frontend/src/index.css
Normal file
115
frontend/src/index.css
Normal file
@@ -0,0 +1,115 @@
|
||||
/* Import global styles */
|
||||
@import './styles/variables.css';
|
||||
@import './styles/reset.css';
|
||||
|
||||
/* ==================== Main Layout ==================== */
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
gap: 0; /* Panels have their own borders */
|
||||
padding: 0;
|
||||
margin-top: 0; /* Ensure panels start from top */
|
||||
}
|
||||
|
||||
/* Smooth transitions for theme changes */
|
||||
.app,
|
||||
.app * {
|
||||
transition: background-color var(--transition-normal),
|
||||
color var(--transition-normal),
|
||||
border-color var(--transition-normal),
|
||||
box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
/* ==================== Panel Layout - 1:2:1 Ratio ==================== */
|
||||
.sidebar-left {
|
||||
flex: 0 0 25%; /* 左侧 25% */
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-right: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-xs);
|
||||
transition: box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.chat-area {
|
||||
flex: 0 0 50%; /* 中间 50% */
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--color-bg-primary);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Subtle gradient background for visual interest */
|
||||
.chat-area::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, rgba(109, 140, 255, 0.04) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 70%, rgba(109, 140, 255, 0.03) 0%, transparent 50%),
|
||||
linear-gradient(180deg, var(--color-bg-primary) 0%, var(--color-bg-subtle) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.chat-area > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sidebar-right {
|
||||
flex: 0 0 25%; /* 右侧 25% */
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-left: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-xs);
|
||||
transition: box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
/* Custom scrollbar for webkit browsers */
|
||||
.sidebar-left::-webkit-scrollbar,
|
||||
.sidebar-right::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar-left::-webkit-scrollbar-track,
|
||||
.sidebar-right::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar-left::-webkit-scrollbar-thumb,
|
||||
.sidebar-right::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-scrollbar);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.sidebar-left::-webkit-scrollbar-thumb:hover,
|
||||
.sidebar-right::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-scrollbar-hover);
|
||||
}
|
||||
14
frontend/src/main.jsx
Normal file
14
frontend/src/main.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './index.css';
|
||||
|
||||
// 获取 HTML 中的根元素
|
||||
const rootElement = document.getElementById('root');
|
||||
|
||||
// 创建 React 根节点并渲染 App 组件
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
132
frontend/src/styles/reset.css
Normal file
132
frontend/src/styles/reset.css
Normal file
@@ -0,0 +1,132 @@
|
||||
/* CSS Reset & Global Styles */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
img,
|
||||
video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Global animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Selection styling */
|
||||
::selection {
|
||||
background-color: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background-color: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* App layout */
|
||||
.app {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.main-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
150
frontend/src/styles/variables.css
Normal file
150
frontend/src/styles/variables.css
Normal file
@@ -0,0 +1,150 @@
|
||||
/* CSS Variables - Elegant Dark/Light Theme */
|
||||
:root {
|
||||
/* Colors - Dark Theme (Default) - Material Design inspired */
|
||||
/* Background layers - using deep grays, not pure black */
|
||||
--color-bg-primary: #121212; /* 主背景 - 深灰黑 */
|
||||
--color-bg-secondary: #1e1e1e; /* 次级背景 - 中灰黑 */
|
||||
--color-bg-tertiary: #2d2d2d; /* 三级背景 - 浅灰黑 */
|
||||
--color-bg-elevated: #252525; /* 浮层背景 */
|
||||
--color-bg-subtle: #1a1a1a; /* 微妙背景 */
|
||||
|
||||
/* Text colors - softer whites for better readability */
|
||||
--color-text-primary: #e0e0e0; /* 主文字 - 柔白 */
|
||||
--color-text-secondary: #9e9e9e; /* 次要文字 - 中灰 */
|
||||
--color-text-muted: #757575; /* 弱化文字 - 深灰 */
|
||||
--color-text-inverse: #121212; /* 反色文字 */
|
||||
|
||||
/* Border colors - subtle separation */
|
||||
--color-border: #333333; /* 边框 - 深灰 */
|
||||
--color-border-light: #2a2a2a; /* 浅色边框 */
|
||||
--color-border-focus: #404040; /* 聚焦边框 */
|
||||
|
||||
/* Scrollbar colors - Light gray for night mode */
|
||||
--color-scrollbar: #666666; /* 滚动条 - 亮灰色 */
|
||||
--color-scrollbar-hover: #888888; /* 滚动条悬停 - 更亮的灰色 */
|
||||
|
||||
/* Accent color - elegant blue-purple */
|
||||
--color-accent: #8b9cf7; /* 强调色 - 柔和蓝紫 */
|
||||
--color-accent-hover: #9dabff; /* 悬停 */
|
||||
--color-accent-active: #7a8be6; /* 激活 */
|
||||
--color-accent-light: rgba(139, 156, 247, 0.1); /* 浅色强调 */
|
||||
--color-accent-ultra-light: rgba(139, 156, 247, 0.05); /* 极浅强调 */
|
||||
|
||||
--color-success: #10b981;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
--color-info: #3b82f6;
|
||||
|
||||
/* Danger colors */
|
||||
--color-danger: #ef4444;
|
||||
--color-danger-light: rgba(239, 68, 68, 0.1);
|
||||
--color-danger-dark: #dc2626;
|
||||
|
||||
/* Accent variations */
|
||||
--color-accent-dark: #6d7de6;
|
||||
|
||||
/* Gradient backgrounds */
|
||||
--gradient-primary: linear-gradient(135deg, #6d8cff 0%, #8da7ff 100%);
|
||||
--gradient-subtle: linear-gradient(180deg, rgba(109, 140, 255, 0.05) 0%, transparent 100%);
|
||||
|
||||
/* Spacing - Compact but comfortable */
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 6px;
|
||||
--spacing-md: 12px;
|
||||
--spacing-lg: 18px;
|
||||
--spacing-xl: 24px;
|
||||
--spacing-2xl: 32px;
|
||||
--spacing-3xl: 40px;
|
||||
|
||||
/* Border Radius - Softer, more refined */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--radius-2xl: 24px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows - Subtle and layered for depth */
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.2), 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.25), 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.3), 0 4px 8px rgba(0, 0, 0, 0.25);
|
||||
--shadow-xl: 0 12px 24px rgba(0, 0, 0, 0.35), 0 6px 12px rgba(0, 0, 0, 0.3);
|
||||
--shadow-2xl: 0 20px 40px rgba(0, 0, 0, 0.4), 0 10px 20px rgba(0, 0, 0, 0.35);
|
||||
--shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||
|
||||
/* Transitions - Smooth and elegant */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-bounce: 500ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
--transition-smooth: 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
|
||||
/* Z-index layers */
|
||||
--z-dropdown: 1000;
|
||||
--z-sticky: 1020;
|
||||
--z-fixed: 1030;
|
||||
--z-modal-backdrop: 1040;
|
||||
--z-modal: 1050;
|
||||
--z-popover: 1060;
|
||||
--z-tooltip: 1070;
|
||||
}
|
||||
|
||||
/* Global theme transition */
|
||||
*, *::before, *::after {
|
||||
transition: background-color var(--transition-normal),
|
||||
border-color var(--transition-normal),
|
||||
color var(--transition-normal),
|
||||
box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
/* Light Theme - Soft and natural day mode */
|
||||
[data-theme='light'] {
|
||||
/* Background layers - warm whites, not harsh white */
|
||||
--color-bg-primary: #fafafa; /* 主背景 - 暖白 */
|
||||
--color-bg-secondary: #ffffff; /* 次级背景 - 纯白 */
|
||||
--color-bg-tertiary: #f5f5f5; /* 三级背景 - 浅灰 */
|
||||
--color-bg-elevated: #ffffff; /* 浮层背景 */
|
||||
--color-bg-subtle: #f0f0f0; /* 微妙背景 */
|
||||
|
||||
/* Text colors - soft grays for comfortable reading */
|
||||
--color-text-primary: #2c2c2c; /* 主文字 - 深灰 */
|
||||
--color-text-secondary: #666666; /* 次要文字 - 中灰 */
|
||||
--color-text-muted: #999999; /* 弱化文字 - 浅灰 */
|
||||
--color-text-inverse: #ffffff; /* 反色文字 */
|
||||
|
||||
/* Border colors - subtle separation */
|
||||
--color-border: #e0e0e0; /* 边框 - 浅灰 */
|
||||
--color-border-light: #ebebeb; /* 浅色边框 */
|
||||
--color-border-focus: #d0d0d0; /* 聚焦边框 */
|
||||
|
||||
/* Scrollbar colors - Slightly darker for day mode */
|
||||
--color-scrollbar: #999999; /* 滚动条 - 中灰色 */
|
||||
--color-scrollbar-hover: #777777; /* 滚动条悬停 - 深灰色 */
|
||||
|
||||
/* Accent color - consistent with dark theme */
|
||||
--color-accent: #7a8be6; /* 强调色 */
|
||||
--color-accent-hover: #6a7bd6; /* 悬停 */
|
||||
--color-accent-active: #5a6bc6; /* 激活 */
|
||||
--color-accent-light: rgba(122, 139, 230, 0.08); /* 浅色强调 */
|
||||
--color-accent-ultra-light: rgba(122, 139, 230, 0.04); /* 极浅强调 */
|
||||
|
||||
--gradient-primary: linear-gradient(135deg, #5b7fff 0%, #7c9cff 100%);
|
||||
--gradient-subtle: linear-gradient(180deg, rgba(91, 127, 255, 0.03) 0%, transparent 100%);
|
||||
|
||||
/* Danger colors - Light theme */
|
||||
--color-danger: #ef4444;
|
||||
--color-danger-light: rgba(239, 68, 68, 0.08);
|
||||
--color-danger-dark: #dc2626;
|
||||
|
||||
/* Accent variations - Light theme */
|
||||
--color-accent-dark: #6a7bd6;
|
||||
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02);
|
||||
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.05), 0 2px 4px rgba(0, 0, 0, 0.03);
|
||||
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.06), 0 4px 8px rgba(0, 0, 0, 0.04);
|
||||
--shadow-xl: 0 12px 24px rgba(0, 0, 0, 0.07), 0 6px 12px rgba(0, 0, 0, 0.05);
|
||||
--shadow-2xl: 0 20px 40px rgba(0, 0, 0, 0.08), 0 10px 20px rgba(0, 0, 0, 0.06);
|
||||
--shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
273
frontend/src/types/README.md
Normal file
273
frontend/src/types/README.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# 前端数据类型规范
|
||||
|
||||
## 概述
|
||||
|
||||
本项目前端采用双层数据类型设计,与后端保持一致:
|
||||
|
||||
1. **SillyTavern 兼容层** (`sillytavern.types.ts`) - 仅用于导入导出
|
||||
2. **内部业务层** (`internal.types.ts`) - 前端真正使用的数据类型
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
frontend/src/types/
|
||||
├── index.ts # 统一导出
|
||||
├── sillytavern.types.ts # SillyTavern 兼容格式(仅导入导出)
|
||||
├── internal.types.ts # 内部业务格式(真正使用)
|
||||
├── converters.ts # 格式转换函数
|
||||
└── README.md # 本文档
|
||||
```
|
||||
|
||||
## 使用规范
|
||||
|
||||
### 1. SillyTavern 兼容层 (ST 前缀)
|
||||
|
||||
**用途**: 仅用于与 SillyTavern 格式的文件进行导入导出交互
|
||||
|
||||
**包含的类型**:
|
||||
- `STChatHeader` - SillyTavern 聊天头
|
||||
- `STChatMessage` - SillyTavern 聊天消息
|
||||
- `STChatLog` - SillyTavern 完整聊天记录
|
||||
- `STCharacterCard` - SillyTavern 角色卡
|
||||
- `STGenerationPreset` - SillyTavern 预设
|
||||
|
||||
**使用场景**:
|
||||
```typescript
|
||||
// ✅ 正确:导入文件时解析
|
||||
const stData: STChatLog = JSON.parse(fileContent);
|
||||
|
||||
// ✅ 正确:导出文件时序列化
|
||||
const exportData = convertInternalChatLogToST(internalData);
|
||||
downloadFile(JSON.stringify(exportData));
|
||||
|
||||
// ❌ 错误:不要在业务逻辑中直接使用
|
||||
const messages = stData.messages; // 应该先转换为内部格式
|
||||
```
|
||||
|
||||
### 2. 内部业务层 (无前缀)
|
||||
|
||||
**用途**: 前端所有业务逻辑、状态管理、API 交互都使用这些类型
|
||||
|
||||
**包含的类型**:
|
||||
- `ChatHeader` - 内部聊天头
|
||||
- `ChatMessage` - 内部聊天消息
|
||||
- `ChatLog` - 内部完整聊天记录
|
||||
- `RoleInfo` - 角色信息
|
||||
- `ChatSummary` - 聊天摘要
|
||||
- `ApiConfig` - API 配置
|
||||
- `GenerationPreset` - 生成预设
|
||||
- `PromptComponent` - Prompt 组件
|
||||
- `WSRequestMessage` / `WSResponseMessage` - WebSocket 消息
|
||||
|
||||
**使用场景**:
|
||||
```typescript
|
||||
// ✅ 正确:Store 中使用内部类型
|
||||
import type { ChatMessage } from '@/types';
|
||||
|
||||
const useChatStore = create<{
|
||||
messages: ChatMessage[];
|
||||
}>((set) => ({
|
||||
messages: [],
|
||||
}));
|
||||
|
||||
// ✅ 正确:API 响应使用内部类型
|
||||
const response = await fetch('/api/chat/role/chat');
|
||||
const data: ChatLog = await response.json();
|
||||
|
||||
// ✅ 正确:组件 Props 使用内部类型
|
||||
interface ChatBoxProps {
|
||||
messages: ChatMessage[];
|
||||
onSendMessage: (content: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 数据转换
|
||||
|
||||
**何时转换**:
|
||||
- 从文件导入 SillyTavern 格式 → 立即转换为内部格式
|
||||
- 导出为 SillyTavern 格式 → 从内部格式转换后导出
|
||||
- API 交互 → 确保后端返回的是 internal 格式
|
||||
|
||||
**转换函数**:
|
||||
```typescript
|
||||
import {
|
||||
convertSTChatLogToInternal,
|
||||
convertInternalChatLogToST,
|
||||
generateId
|
||||
} from '@/types';
|
||||
|
||||
// SillyTavern → 内部
|
||||
const internalData = convertSTChatLogToInternal(stData, chatId, characterId);
|
||||
|
||||
// 内部 → SillyTavern
|
||||
const stData = convertInternalChatLogToST(internalData);
|
||||
```
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### 从旧代码迁移
|
||||
|
||||
如果你正在重构现有的 JSX 文件:
|
||||
|
||||
1. **添加类型导入**:
|
||||
```typescript
|
||||
import type { ChatMessage, ChatHeader } from '@/types';
|
||||
```
|
||||
|
||||
2. **为 State 添加类型注解**:
|
||||
```typescript
|
||||
// 之前
|
||||
const [messages, setMessages] = useState([]);
|
||||
|
||||
// 之后
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
```
|
||||
|
||||
3. **为 Props 添加类型**:
|
||||
```typescript
|
||||
interface ComponentProps {
|
||||
messages: ChatMessage[];
|
||||
userName: string;
|
||||
}
|
||||
|
||||
const Component: React.FC<ComponentProps> = ({ messages, userName }) => {
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
4. **为函数参数和返回值添加类型**:
|
||||
```typescript
|
||||
// 之前
|
||||
const sendMessage = async (content) => {
|
||||
// ...
|
||||
};
|
||||
|
||||
// 之后
|
||||
const sendMessage = async (content: string): Promise<void> => {
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 始终使用内部类型
|
||||
|
||||
```typescript
|
||||
// ✅ 好
|
||||
const handleMessageUpdate = (message: ChatMessage) => {
|
||||
// 业务逻辑
|
||||
};
|
||||
|
||||
// ❌ 坏 - 混用 SillyTavern 类型
|
||||
const handleMessageUpdate = (message: STChatMessage) => {
|
||||
// 这会导致与后端 API 不兼容
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 在边界处进行转换
|
||||
|
||||
```typescript
|
||||
// ✅ 好 - 在导入时立即转换
|
||||
const handleImport = async (file: File) => {
|
||||
const content = await file.text();
|
||||
const stData: STChatLog = JSON.parse(content);
|
||||
const internalData = convertSTChatLogToInternal(stData, generateId(), characterId);
|
||||
setMessages(internalData.messages);
|
||||
};
|
||||
|
||||
// ❌ 坏 - 在整个应用中使用 SillyTavern 格式
|
||||
const handleImport = async (file: File) => {
|
||||
const content = await file.text();
|
||||
const stData: STChatLog = JSON.parse(content);
|
||||
setMessages(stData.slice(1)); // 直接使用 ST 格式
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Store 中使用内部类型
|
||||
|
||||
```typescript
|
||||
// ✅ 好
|
||||
import type { ChatMessage, ApiConfig } from '@/types';
|
||||
|
||||
const useStore = create<{
|
||||
messages: ChatMessage[];
|
||||
apiConfig: ApiConfig | null;
|
||||
}>((set) => ({
|
||||
messages: [],
|
||||
apiConfig: null,
|
||||
}));
|
||||
|
||||
// ❌ 坏 - 使用 any 或未定义的类型
|
||||
const useStore = create<{
|
||||
messages: any[];
|
||||
apiConfig: any;
|
||||
}>((set) => ({
|
||||
messages: [],
|
||||
apiConfig: null,
|
||||
}));
|
||||
```
|
||||
|
||||
### 4. API 调用时使用内部类型
|
||||
|
||||
```typescript
|
||||
// ✅ 好
|
||||
const fetchChatHistory = async (role: string, chat: string): Promise<ChatLog> => {
|
||||
const response = await fetch(`/api/chat/${role}/${chat}`);
|
||||
return response.json(); // 后端返回 internal 格式
|
||||
};
|
||||
|
||||
// ❌ 坏 - 没有类型注解
|
||||
const fetchChatHistory = async (role, chat) => {
|
||||
const response = await fetch(`/api/chat/${role}/${chat}`);
|
||||
return response.json();
|
||||
};
|
||||
```
|
||||
|
||||
## 与后端的对应关系
|
||||
|
||||
| 前端类型 | 后端模型 | 说明 |
|
||||
|---------|---------|------|
|
||||
| `ChatHeader` | `backend/models/internal.py:ChatHeader` | 完全对应 |
|
||||
| `ChatMessage` | `backend/models/internal.py:ChatMessage` | 前端额外添加 `floor` 字段 |
|
||||
| `ChatLog` | `backend/models/internal.py:ChatLog` | 完全对应 |
|
||||
| `ApiConfig` | 前端特有 | 前端本地存储的 API 配置 |
|
||||
| `GenerationPreset` | `backend/models/internal.py:GenerationPreset` | 字段命名略有差异(camelCase vs snake_case) |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 为什么需要两层类型?
|
||||
|
||||
**A**:
|
||||
- **SillyTavern 层**: 保持与外部生态的兼容性,用户可以导入导出 SillyTavern 格式的文件
|
||||
- **内部层**: 简化业务逻辑,添加项目特色功能(如 floor、tableData 等),与后端保持一致
|
||||
|
||||
### Q: 什么时候使用转换器?
|
||||
|
||||
**A**:
|
||||
- 仅在 I/O 边界使用(文件导入导出)
|
||||
- 业务逻辑中始终使用内部类型
|
||||
- API 交互由后端负责转换,前端直接使用内部类型
|
||||
|
||||
### Q: 如何添加新类型?
|
||||
|
||||
**A**:
|
||||
1. 判断类型的用途:
|
||||
- 如果是 SillyTavern 兼容 → 添加到 `sillytavern.types.ts`
|
||||
- 如果是内部业务使用 → 添加到 `internal.types.ts`
|
||||
2. 如需转换 → 在 `converters.ts` 中添加转换函数
|
||||
3. 在 `index.ts` 中导出(如果使用了 `export *` 则自动导出)
|
||||
|
||||
## 示例代码
|
||||
|
||||
完整的类型使用示例请参考:
|
||||
- `src/Store/Slices/ChatBoxSlice.jsx` - ChatBox Store(待重构)
|
||||
- `src/Store/Slices/RoleSelectorSlice.jsx` - RoleSelector Store(待重构)
|
||||
- `src/components/ChatBox/ChatBox.jsx` - ChatBox 组件(待重构)
|
||||
|
||||
## 后续工作
|
||||
|
||||
当前类型系统已建立,下一步需要:
|
||||
1. ✅ 更新 Store 使用新的类型定义
|
||||
2. ✅ 更新组件 Props 使用类型注解
|
||||
3. ✅ 确保 API 调用与后端 internal 模型一致
|
||||
4. ⏳ 考虑将 `.jsx` 文件迁移到 `.tsx` 以获得完整的类型检查
|
||||
246
frontend/src/types/converters.ts
Normal file
246
frontend/src/types/converters.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 数据转换器
|
||||
* 实现 SillyTavern 格式与内部格式之间的转换
|
||||
*/
|
||||
|
||||
import type {
|
||||
STChatHeader,
|
||||
STChatMessage,
|
||||
STChatLog,
|
||||
STCharacterCard,
|
||||
STGenerationPreset
|
||||
} from './sillytavern.types';
|
||||
import type {
|
||||
ChatHeader,
|
||||
ChatMessage,
|
||||
ChatLog,
|
||||
WorldInfo,
|
||||
WorldInfoEntry,
|
||||
ActivationType,
|
||||
CharacterCard,
|
||||
GenerationPreset
|
||||
} from './internal.types';
|
||||
|
||||
/**
|
||||
* 将 SillyTavern 聊天头转换为内部格式
|
||||
*/
|
||||
export function convertSTChatHeaderToInternal(
|
||||
stHeader: STChatHeader,
|
||||
chatId: string,
|
||||
characterId: string
|
||||
): ChatHeader {
|
||||
return {
|
||||
id: chatId,
|
||||
displayName: `${stHeader.character_name} - ${stHeader.user_name}`,
|
||||
characterId,
|
||||
userName: stHeader.user_name,
|
||||
characterName: stHeader.character_name,
|
||||
createdAt: Date.parse(stHeader.create_date) || Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
messageCount: 0, // 需要在转换消息后更新
|
||||
tableData: stHeader.chat_metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 SillyTavern 聊天消息转换为内部格式
|
||||
*/
|
||||
export function convertSTMessageToInternal(
|
||||
stMessage: STChatMessage,
|
||||
chatId: string,
|
||||
floor: number
|
||||
): ChatMessage {
|
||||
return {
|
||||
id: `${chatId}-${floor}`,
|
||||
floor,
|
||||
name: stMessage.name,
|
||||
is_user: stMessage.is_user,
|
||||
is_system: stMessage.is_system,
|
||||
sendDate: typeof stMessage.send_date === 'number'
|
||||
? new Date(stMessage.send_date).toISOString()
|
||||
: stMessage.send_date,
|
||||
mes: stMessage.mes,
|
||||
chatId,
|
||||
swipes: stMessage.swipes,
|
||||
swipe_id: stMessage.swipe_id,
|
||||
tokenCount: undefined,
|
||||
isTemporary: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 SillyTavern 完整聊天记录转换为内部格式
|
||||
*/
|
||||
export function convertSTChatLogToInternal(
|
||||
stChatLog: STChatLog,
|
||||
chatId: string,
|
||||
characterId: string
|
||||
): ChatLog {
|
||||
const [stHeader, ...stMessages] = stChatLog;
|
||||
|
||||
const header = convertSTChatHeaderToInternal(stHeader, chatId, characterId);
|
||||
|
||||
const messages = stMessages.map((msg, index) =>
|
||||
convertSTMessageToInternal(msg, chatId, index + 1)
|
||||
);
|
||||
|
||||
// 更新消息数量
|
||||
header.messageCount = messages.length;
|
||||
|
||||
return {
|
||||
header,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将内部聊天头转换为 SillyTavern 格式
|
||||
*/
|
||||
export function convertInternalChatHeaderToST(chatHeader: ChatHeader): STChatHeader {
|
||||
return {
|
||||
user_name: chatHeader.userName,
|
||||
character_name: chatHeader.characterName,
|
||||
create_date: new Date(chatHeader.createdAt).toISOString(),
|
||||
chat_metadata: chatHeader.tableData,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将内部聊天消息转换为 SillyTavern 格式
|
||||
*/
|
||||
export function convertInternalMessageToST(message: ChatMessage): STChatMessage {
|
||||
return {
|
||||
name: message.name,
|
||||
is_user: message.is_user,
|
||||
is_system: message.is_system,
|
||||
send_date: message.sendDate,
|
||||
mes: message.mes,
|
||||
swipes: message.swipes,
|
||||
swipe_id: message.swipe_id,
|
||||
is_hidden: message.isTemporary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将内部完整聊天记录转换为 SillyTavern 格式
|
||||
*/
|
||||
export function convertInternalChatLogToST(chatLog: ChatLog): STChatLog {
|
||||
const stHeader = convertInternalChatHeaderToST(chatLog.header);
|
||||
|
||||
const stMessages = chatLog.messages.map(msg =>
|
||||
convertInternalMessageToST(msg)
|
||||
);
|
||||
|
||||
return [stHeader, ...stMessages];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一 ID(简单实现,实际项目中应使用 UUID)
|
||||
*/
|
||||
export function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// ==================== 角色卡转换 ====================
|
||||
|
||||
/**
|
||||
* 将 SillyTavern 角色卡转换为内部格式
|
||||
*/
|
||||
export function convertSTCharacterCardToInternal(
|
||||
stCard: STCharacterCard,
|
||||
characterId: string = generateId()
|
||||
): CharacterCard {
|
||||
const now = Date.now();
|
||||
|
||||
return {
|
||||
id: characterId,
|
||||
name: stCard.data.name,
|
||||
description: stCard.data.description,
|
||||
personality: stCard.data.personality,
|
||||
scenario: stCard.data.scenario,
|
||||
first_mes: stCard.data.first_mes,
|
||||
mes_example: stCard.data.mes_example,
|
||||
categories: [], // 需要手动设置
|
||||
worldInfoId: stCard.data.extensions?.world,
|
||||
outputSchema: undefined, // 需要手动设置
|
||||
avatarPath: undefined, // 需要手动设置
|
||||
alternate_greetings: stCard.data.alternate_greetings,
|
||||
tags: stCard.data.tags,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastChatAt: undefined,
|
||||
isFavorite: stCard.data.extensions?.fav || false,
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将内部角色卡转换为 SillyTavern 格式
|
||||
*/
|
||||
export function convertInternalCharacterCardToST(
|
||||
card: CharacterCard
|
||||
): STCharacterCard {
|
||||
return {
|
||||
spec: 'chara_card_v2',
|
||||
spec_version: '2.0',
|
||||
data: {
|
||||
name: card.name,
|
||||
description: card.description,
|
||||
personality: card.personality,
|
||||
scenario: card.scenario,
|
||||
first_mes: card.first_mes,
|
||||
mes_example: card.mes_example,
|
||||
alternate_greetings: card.alternate_greetings,
|
||||
tags: card.tags,
|
||||
extensions: {
|
||||
world: card.worldInfoId,
|
||||
fav: card.isFavorite,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 预设转换 ====================
|
||||
|
||||
/**
|
||||
* 将 SillyTavern 预设转换为内部格式
|
||||
*/
|
||||
export function convertSTPresetToInternal(
|
||||
stPreset: STGenerationPreset,
|
||||
presetId: string = generateId()
|
||||
): GenerationPreset {
|
||||
const now = Date.now();
|
||||
|
||||
return {
|
||||
id: presetId,
|
||||
name: stPreset.name,
|
||||
temperature: stPreset.temperature ?? 1.0,
|
||||
topP: stPreset.top_p ?? 1.0,
|
||||
topK: stPreset.top_k ?? 0,
|
||||
repetitionPenalty: stPreset.repetition_penalty ?? 1.0,
|
||||
frequencyPenalty: stPreset.frequency_penalty,
|
||||
presencePenalty: stPreset.presence_penalty,
|
||||
maxLength: stPreset.max_length,
|
||||
isDefault: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将内部预设转换为 SillyTavern 格式
|
||||
*/
|
||||
export function convertInternalPresetToST(
|
||||
preset: GenerationPreset
|
||||
): STGenerationPreset {
|
||||
return {
|
||||
name: preset.name,
|
||||
temperature: preset.temperature,
|
||||
top_p: preset.topP,
|
||||
top_k: preset.topK,
|
||||
repetition_penalty: preset.repetitionPenalty,
|
||||
frequency_penalty: preset.frequencyPenalty,
|
||||
presence_penalty: preset.presencePenalty,
|
||||
max_length: preset.maxLength,
|
||||
};
|
||||
}
|
||||
7
frontend/src/types/index.ts
Normal file
7
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Shared Types 导出
|
||||
*/
|
||||
|
||||
export * from './sillytavern.types';
|
||||
export * from './internal.types';
|
||||
export * from './converters';
|
||||
405
frontend/src/types/internal.types.ts
Normal file
405
frontend/src/types/internal.types.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* 项目内部使用的数据结构定义
|
||||
* 这是前端真正使用的数据类型,与后端 internal.py 保持一致
|
||||
*/
|
||||
|
||||
import type { STChatHeader, STChatMessage } from './sillytavern.types';
|
||||
|
||||
// ==================== 世界书 (World Info) ====================
|
||||
|
||||
/**
|
||||
* 激活方式类型(4种枚举)
|
||||
*/
|
||||
export enum ActivationType {
|
||||
/** 永久激活 - 始终包含在上下文中 */
|
||||
PERMANENT = 'permanent',
|
||||
/** 关键词触发 - 匹配关键词时激活 */
|
||||
KEYWORD = 'keyword',
|
||||
/** RAG 检索激活 - 基于向量相似度检索 */
|
||||
RAG = 'rag',
|
||||
/** 逻辑表达式激活 - 基于变量条件判断 */
|
||||
LOGIC = 'logic',
|
||||
}
|
||||
|
||||
/**
|
||||
* 逻辑运算符(用于 LOGIC 激活类型)
|
||||
*/
|
||||
export enum LogicOperator {
|
||||
EQUALS = 'equals',
|
||||
NOT_EQUALS = 'not_equals',
|
||||
CONTAINS = 'contains',
|
||||
NOT_CONTAINS = 'not_contains',
|
||||
GREATER = 'greater',
|
||||
LESS = 'less',
|
||||
}
|
||||
|
||||
/**
|
||||
* 逻辑表达式结构(用于 LOGIC 激活类型)
|
||||
*/
|
||||
export interface LogicExpression {
|
||||
/** 第一个变量名 */
|
||||
variable1: string;
|
||||
/** 比较运算符 */
|
||||
operator: LogicOperator;
|
||||
/** 第二个变量名或值 */
|
||||
variable2: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RAG 配置(用于 RAG 激活类型)
|
||||
*/
|
||||
export interface RAGConfig {
|
||||
/** 绑定的 RAG 库 ID */
|
||||
libraryId: string;
|
||||
/** 相似度阈值 (0-1) */
|
||||
threshold?: number;
|
||||
/** 最大返回条目数 */
|
||||
maxEntries?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目内部世界书条目结构
|
||||
*/
|
||||
export interface WorldInfoEntry {
|
||||
/** 条目唯一标识符 (UUID) */
|
||||
uid: string;
|
||||
/** 主关键词列表 (用于 KEYWORD 激活) */
|
||||
key?: string[];
|
||||
/** 次要关键词列表 (可选过滤) */
|
||||
keysecondary?: string[];
|
||||
/** 条目内容 - 激活时注入的文本 */
|
||||
content: string;
|
||||
/** 激活方式 */
|
||||
activationType: ActivationType;
|
||||
/** 逻辑表达式 (LOGIC 类型使用) */
|
||||
logicExpression?: LogicExpression;
|
||||
/** RAG 配置 (RAG 类型使用) */
|
||||
ragConfig?: RAGConfig;
|
||||
/** 插入顺序 - 数值越大越靠近末尾 */
|
||||
order: number;
|
||||
/** 插入位置 */
|
||||
position?: string;
|
||||
/** 插入深度 (当 position='at_depth' 时使用) */
|
||||
depth?: number;
|
||||
/** 激活概率 (0-100) */
|
||||
probability?: number;
|
||||
/** 所属组标签 */
|
||||
group?: string[];
|
||||
/** 是否禁用 */
|
||||
disable: boolean;
|
||||
/** 创建时间戳 */
|
||||
createdAt: number;
|
||||
/** 最后更新时间戳 */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目内部世界书结构
|
||||
*/
|
||||
export interface WorldInfo {
|
||||
/** 世界书唯一标识符 (UUID) */
|
||||
id: string;
|
||||
/** 世界书名称 */
|
||||
name: string;
|
||||
/** 世界书描述 */
|
||||
description?: string;
|
||||
/** 条目数组 */
|
||||
entries: WorldInfoEntry[];
|
||||
/** 创建时间戳 */
|
||||
createdAt: number;
|
||||
/** 最后更新时间戳 */
|
||||
updatedAt: number;
|
||||
/** 版本号 (用于数据迁移) */
|
||||
version: number;
|
||||
}
|
||||
|
||||
// ==================== 聊天记录 (Chat Log) ====================
|
||||
|
||||
/**
|
||||
* 项目内部聊天记录头
|
||||
*/
|
||||
export interface ChatHeader {
|
||||
/** 聊天唯一标识符 */
|
||||
id: string;
|
||||
|
||||
/** 显示名称(聊天标题) */
|
||||
displayName: string;
|
||||
|
||||
/** 关联的角色卡 ID */
|
||||
characterId: string;
|
||||
|
||||
/** 用户角色名 */
|
||||
userName: string;
|
||||
|
||||
/** AI 角色名称 */
|
||||
characterName: string;
|
||||
|
||||
/** 表格内容(对应角色卡的 outputSchema 字段的值) */
|
||||
tableData?: Record<string, unknown>;
|
||||
|
||||
/** 创建时间戳 */
|
||||
createdAt: number;
|
||||
|
||||
/** 最后更新时间戳 */
|
||||
updatedAt: number;
|
||||
|
||||
/** 消息数量 */
|
||||
messageCount: number;
|
||||
|
||||
/** 关联的RAG历史消息库ID(用于向量化存储) */
|
||||
ragLibraryId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目内部聊天消息
|
||||
*/
|
||||
export interface ChatMessage {
|
||||
/** 消息唯一标识符 */
|
||||
id: string;
|
||||
|
||||
/** 楼层号 */
|
||||
floor: number;
|
||||
|
||||
/** 发送者名称 */
|
||||
name: string;
|
||||
|
||||
/** 是否为用户消息 */
|
||||
is_user: boolean;
|
||||
|
||||
/** 是否为系统消息 */
|
||||
is_system?: boolean;
|
||||
|
||||
/** 发送日期 ISO 字符串 */
|
||||
sendDate: string;
|
||||
|
||||
/** 消息内容文本 */
|
||||
mes: string;
|
||||
|
||||
/** 关联的聊天 ID */
|
||||
chatId: string;
|
||||
|
||||
/** 替换回答数组(swipe 功能) */
|
||||
swipes?: string[];
|
||||
|
||||
/** 当前选择的 swipe ID */
|
||||
swipe_id?: number;
|
||||
|
||||
/** Token 数量(用于统计) */
|
||||
tokenCount?: number;
|
||||
|
||||
/** 是否为临时消息(未保存) */
|
||||
isTemporary?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目内部完整聊天记录
|
||||
*/
|
||||
export interface ChatLog {
|
||||
/** 聊天头 */
|
||||
header: ChatHeader;
|
||||
|
||||
/** 消息列表 */
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
// ==================== 角色数据 ====================
|
||||
|
||||
/**
|
||||
* 前端角色数据结构
|
||||
* 简化版,用于角色选择器
|
||||
*/
|
||||
export interface RoleInfo {
|
||||
/** 角色名称 */
|
||||
role_name: string;
|
||||
|
||||
/** 该角色下的聊天列表 */
|
||||
chats: ChatSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天摘要信息
|
||||
*/
|
||||
export interface ChatSummary {
|
||||
/** 聊天名称 */
|
||||
chat_name: string;
|
||||
|
||||
/** 用户名称 */
|
||||
user_name: string;
|
||||
|
||||
/** 角色名称 */
|
||||
character_name: string;
|
||||
|
||||
/** 最后修改时间 */
|
||||
last_modified: string;
|
||||
|
||||
/** 消息数量 */
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
// ==================== API 配置 ====================
|
||||
|
||||
/**
|
||||
* API 配置接口
|
||||
*/
|
||||
export interface ApiConfig {
|
||||
/** 配置唯一标识符 */
|
||||
id: string;
|
||||
|
||||
/** API 类别(text/image/audio等) */
|
||||
category: string;
|
||||
|
||||
/** API URL */
|
||||
apiUrl: string;
|
||||
|
||||
/** API Key */
|
||||
apiKey: string;
|
||||
|
||||
/** 模型名称 */
|
||||
model: string;
|
||||
|
||||
/** 温度参数 */
|
||||
temperature: number;
|
||||
|
||||
/** 最大 Token 数 */
|
||||
maxTokens: number;
|
||||
|
||||
/** 系统提示词 */
|
||||
systemPrompt?: string;
|
||||
|
||||
/** 是否激活 */
|
||||
isActive?: boolean;
|
||||
|
||||
/** 创建时间戳 */
|
||||
createdAt?: number;
|
||||
|
||||
/** 更新时间戳 */
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
// ==================== 预设配置 ====================
|
||||
|
||||
/**
|
||||
* 生成预设参数
|
||||
*/
|
||||
export interface GenerationPreset {
|
||||
/** 预设 ID */
|
||||
id: string;
|
||||
|
||||
/** 预设名称 */
|
||||
name: string;
|
||||
|
||||
/** 温度 */
|
||||
temperature: number;
|
||||
|
||||
/** Top P */
|
||||
topP: number;
|
||||
|
||||
/** Top K */
|
||||
topK: number;
|
||||
|
||||
/** 重复惩罚 */
|
||||
repetitionPenalty: number;
|
||||
|
||||
/** 频率惩罚 */
|
||||
frequencyPenalty?: number;
|
||||
|
||||
/** 存在惩罚 */
|
||||
presencePenalty?: number;
|
||||
|
||||
/** 最大长度 */
|
||||
maxLength?: number;
|
||||
|
||||
/** 是否默认预设 */
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt 组件(提示词预设的一部分)
|
||||
*/
|
||||
export interface PromptComponent {
|
||||
/** 唯一标识符 */
|
||||
identifier: string;
|
||||
|
||||
/** 名称 */
|
||||
name: string;
|
||||
|
||||
/** 是否系统提示 */
|
||||
system_prompt: boolean;
|
||||
|
||||
/** 是否为标记节点 */
|
||||
marker: boolean;
|
||||
|
||||
/** 是否启用 */
|
||||
enabled: boolean;
|
||||
|
||||
/** 角色类型 (0=system, 1=user, 2=assistant) */
|
||||
role: number;
|
||||
|
||||
/** 内容 */
|
||||
content?: string;
|
||||
}
|
||||
|
||||
// ==================== WebSocket 消息 ====================
|
||||
|
||||
/**
|
||||
* WebSocket 请求消息
|
||||
*/
|
||||
export interface WSRequestMessage {
|
||||
/** 楼层号 */
|
||||
floor: number;
|
||||
|
||||
/** 消息内容 */
|
||||
mes: string;
|
||||
|
||||
/** 是否为用户消息 */
|
||||
is_user: boolean;
|
||||
|
||||
/** 当前角色 */
|
||||
currentRole: string;
|
||||
|
||||
/** 当前聊天 */
|
||||
currentChat: string;
|
||||
|
||||
/** 选项配置 */
|
||||
options: {
|
||||
dynamicTable: boolean;
|
||||
streamOutput: boolean;
|
||||
imageWorkflow: boolean;
|
||||
htmlRender: boolean;
|
||||
};
|
||||
|
||||
/** API 配置 */
|
||||
apiConfig: {
|
||||
api_url: string;
|
||||
api_key: string;
|
||||
};
|
||||
|
||||
/** 预设配置 */
|
||||
presetConfig: {
|
||||
selectedPreset: string;
|
||||
parameters: any;
|
||||
promptComponents: PromptComponent[];
|
||||
};
|
||||
|
||||
/** 是否流式输出 */
|
||||
stream: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 响应消息类型
|
||||
*/
|
||||
export type WSResponseType = 'chunk' | 'complete' | 'error';
|
||||
|
||||
/**
|
||||
* WebSocket 响应消息
|
||||
*/
|
||||
export interface WSResponseMessage {
|
||||
/** 消息类型 */
|
||||
type: WSResponseType;
|
||||
|
||||
/** 内容(chunk 类型时) */
|
||||
content?: string;
|
||||
|
||||
/** 错误信息(error 类型时) */
|
||||
message?: string;
|
||||
}
|
||||
182
frontend/src/types/sillytavern.types.ts
Normal file
182
frontend/src/types/sillytavern.types.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* SillyTavern 兼容的数据结构定义
|
||||
* 这些类型严格遵循 SillyTavern 官方规范,仅用于导入导出兼容
|
||||
*/
|
||||
|
||||
// ==================== 聊天记录 (Chat Log) ====================
|
||||
|
||||
/**
|
||||
* SillyTavern 聊天记录头(JSONL 第一行)
|
||||
*/
|
||||
export interface STChatHeader {
|
||||
/** 用户名称 */
|
||||
user_name: string;
|
||||
|
||||
/** 角色名称 */
|
||||
character_name: string;
|
||||
|
||||
/** 创建日期 */
|
||||
create_date: string;
|
||||
|
||||
/** 聊天元数据 */
|
||||
chat_metadata?: {
|
||||
/** 完整性校验哈希 */
|
||||
integrity?: string;
|
||||
|
||||
/** 其他元数据 */
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/** 其他可能的头部字段 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* SillyTavern 聊天消息记录
|
||||
*/
|
||||
export interface STChatMessage {
|
||||
/** 发送者名称 */
|
||||
name: string;
|
||||
|
||||
/** 是否为用户消息 */
|
||||
is_user: boolean;
|
||||
|
||||
/** 是否为系统消息 */
|
||||
is_system?: boolean;
|
||||
|
||||
/** 发送日期时间戳或 ISO 字符串 */
|
||||
send_date: number | string;
|
||||
|
||||
/** 实际对话消息文本 */
|
||||
mes: string;
|
||||
|
||||
/** 替换回答数组(swipe 功能) */
|
||||
swipes?: string[];
|
||||
|
||||
/** 当前选择的 swipe ID */
|
||||
swipe_id?: number;
|
||||
|
||||
/** 是否为隐藏消息 */
|
||||
is_hidden?: boolean;
|
||||
|
||||
/** 扩展字段 */
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* SillyTavern 聊天记录文件(JSONL 格式)
|
||||
* 第一行是 STChatHeader,后续每行是 STChatMessage
|
||||
*/
|
||||
export type STChatLog = [STChatHeader, ...STChatMessage[]];
|
||||
|
||||
// ==================== 角色卡 (Character Card) ====================
|
||||
|
||||
/**
|
||||
* SillyTavern 角色卡规范版本
|
||||
*/
|
||||
export enum STCharacterCardSpec {
|
||||
V1 = 'chara_card_v1',
|
||||
V2 = 'chara_card_v2',
|
||||
V3 = 'chara_card_v3',
|
||||
}
|
||||
|
||||
/**
|
||||
* SillyTavern 角色卡 V2/V3 数据结构
|
||||
*/
|
||||
export interface STCharacterCardData {
|
||||
/** 角色名称 */
|
||||
name: string;
|
||||
|
||||
/** 角色描述 */
|
||||
description: string;
|
||||
|
||||
/** 角色性格特征 */
|
||||
personality: string;
|
||||
|
||||
/** 场景设定 */
|
||||
scenario: string;
|
||||
|
||||
/** 首条开场消息 */
|
||||
first_mes: string;
|
||||
|
||||
/** 对话示例 */
|
||||
mes_example: string;
|
||||
|
||||
/** 替代问候语数组 */
|
||||
alternate_greetings?: string[];
|
||||
|
||||
/** 角色创建者备注 */
|
||||
creator_notes?: string;
|
||||
|
||||
/** 系统级别指令 */
|
||||
system_prompt?: string;
|
||||
|
||||
/** 历史后指令 */
|
||||
post_history_instructions?: string;
|
||||
|
||||
/** 标签数组 */
|
||||
tags?: string[];
|
||||
|
||||
/** 扩展字段 */
|
||||
extensions?: {
|
||||
/** 绑定的世界书文件名 */
|
||||
world?: string;
|
||||
|
||||
/** 健谈程度 0-1 */
|
||||
talkativeness?: number;
|
||||
|
||||
/** 收藏状态 */
|
||||
fav?: boolean;
|
||||
|
||||
/** 其他扩展字段 */
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SillyTavern 角色卡完整结构(V2/V3)
|
||||
*/
|
||||
export interface STCharacterCard {
|
||||
/** 规范标识 */
|
||||
spec: STCharacterCardSpec;
|
||||
|
||||
/** 规范版本 */
|
||||
spec_version?: string;
|
||||
|
||||
/** 角色数据 */
|
||||
data: STCharacterCardData;
|
||||
}
|
||||
|
||||
// ==================== 预设 (Preset) ====================
|
||||
|
||||
/**
|
||||
* SillyTavern 采样参数预设
|
||||
*/
|
||||
export interface STGenerationPreset {
|
||||
/** 预设名称 */
|
||||
name: string;
|
||||
|
||||
/** 温度 */
|
||||
temperature?: number;
|
||||
|
||||
/** Top P */
|
||||
top_p?: number;
|
||||
|
||||
/** Top K */
|
||||
top_k?: number;
|
||||
|
||||
/** 重复惩罚 */
|
||||
repetition_penalty?: number;
|
||||
|
||||
/** 频率惩罚 */
|
||||
frequency_penalty?: number;
|
||||
|
||||
/** 存在惩罚 */
|
||||
presence_penalty?: number;
|
||||
|
||||
/** 最大生成长度 */
|
||||
max_length?: number;
|
||||
|
||||
/** 其他采样参数 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
Reference in New Issue
Block a user