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

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

31
frontend/src/App.jsx Normal file
View 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;

View 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;

View File

@@ -0,0 +1,2 @@
// Mid 区域相关的 Store
export { default as useChatBoxStore } from './ChatBoxSlice';

View 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;

View 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;

View 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;

View 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;

View 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';

View 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;

View File

@@ -0,0 +1,2 @@
// SideBarRight 相关的 Store
export { default as useSideBarRightStore } from './SideBarRightSlice';

View 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;

View File

@@ -0,0 +1,2 @@
// TopBar 相关的 Store
export { default as useRoleSelectorStore } from './RoleSelectorSlice';

View 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';

View 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);
}

View 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;

View File

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

View File

@@ -0,0 +1,3 @@
// Mid 区域主组件
// 目前主要包含 ChatBox
export { default as ChatBox } from './ChatBox';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View 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;
}

View 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;

View File

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

View File

@@ -0,0 +1,12 @@
import React from 'react';
const Debug = () => {
return (
<div className="debug-panel">
<h2>上下文调试</h2>
<p>这是上下文调试面板的占位页面</p>
</div>
);
};
export default Debug;

View File

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

View 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);
}

View 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;

View File

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

View File

@@ -0,0 +1,12 @@
import React from 'react';
const Macros = () => {
return (
<div className="macros-panel">
<h2>快捷宏</h2>
<p>这是快捷宏面板的占位页面</p>
</div>
);
};
export default Macros;

View File

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

View File

@@ -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;
}

View File

@@ -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;

View File

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

View File

@@ -0,0 +1,12 @@
import React from 'react';
const Table = () => {
return (
<div className="table-panel">
<h2>动态表格</h2>
<p>这是动态表格面板的占位页面</p>
</div>
);
};
export default Table;

View File

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

View 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;
}

View 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;

View File

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

View File

@@ -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;
}

View File

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

View File

@@ -0,0 +1,4 @@
/* Theme Toggle - Inherits from action-btn */
.theme-toggle {
/* All styles inherited from .action-btn */
}

View File

@@ -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;

View File

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

View 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;

View File

@@ -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;

View File

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

115
frontend/src/index.css Normal file
View 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
View 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>
);

View 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);
}

View 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);
}

View 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` 以获得完整的类型检查

View 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,
};
}

View File

@@ -0,0 +1,7 @@
/**
* Shared Types 导出
*/
export * from './sillytavern.types';
export * from './internal.types';
export * from './converters';

View 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;
}

View 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;
}