完成大量美化,zustand迁移,动态表格修复

This commit is contained in:
2026-05-02 02:08:53 +08:00
parent f0e7e75ffb
commit 7fc9e10c99
53 changed files with 2954 additions and 1462 deletions

View File

@@ -1,43 +1,28 @@
// frontend-react/src/App.jsx
import React, { useState, useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef } from 'react'; // ✅ 移除 useState
import TopBar from './components/TopBar';
import { ChatBox } from './components/Mid';
import SideBarLeft from './components/SideBarLeft';
import SideBarRight from './components/SideBarRight';
import useAppLayoutStore from './Store/AppLayoutSlice'; // ✅ 新增
import './index.css';
function App() {
// 布局模式:'chat'(聊天模式) | 'edit'(编辑模式)
const [layoutMode, setLayoutMode] = useState('chat');
// 左侧栏模式:'fixed'(固定)| 'smart'(智能)| 'expanded'(扩展)
const [sidebarMode, setSidebarMode] = useState(() => {
return localStorage.getItem('sidebarMode') || 'smart';
});
// 智能模式的悬停状态
const [isSidebarHovered, setIsSidebarHovered] = useState(false);
// ✅ 从 AppLayoutStore 获取状态和方法
const {
layoutMode,
sidebarMode,
isSidebarHovered,
colorTheme,
setLayoutMode,
setSidebarMode,
setSidebarHovered,
setColorTheme
} = useAppLayoutStore();
// 防抖定时器引用
const hoverTimeoutRef = useRef(null);
const leaveTimeoutRef = useRef(null);
// 配色主题
const [colorTheme, setColorTheme] = useState(() => {
return localStorage.getItem('colorTheme') || 'default';
});
// 保存左侧栏模式到 LocalStorage
useEffect(() => {
localStorage.setItem('sidebarMode', sidebarMode);
}, [sidebarMode]);
// 保存配色主题到 LocalStorage
useEffect(() => {
localStorage.setItem('colorTheme', colorTheme);
// 应用主题到 document
document.documentElement.setAttribute('data-color-theme', colorTheme);
}, [colorTheme]);
// 处理鼠标进入左侧栏 - 使用 useCallback 优化
const handleMouseEnter = useCallback(() => {
@@ -49,11 +34,11 @@ function App() {
// 设置防抖延迟后展开
hoverTimeoutRef.current = setTimeout(() => {
setIsSidebarHovered(true);
setSidebarHovered(true); // ✅ 使用 store 方法
setLayoutMode('edit');
}, 400); // 400ms 防抖
}
}, [sidebarMode]);
}, [sidebarMode, setSidebarHovered, setLayoutMode]);
// 处理鼠标离开左侧栏 - 使用 useCallback 优化
const handleMouseLeave = useCallback(() => {
@@ -65,11 +50,11 @@ function App() {
// 设置延迟收起,给用户反应时间
leaveTimeoutRef.current = setTimeout(() => {
setIsSidebarHovered(false);
setSidebarHovered(false); // ✅ 使用 store 方法
setLayoutMode('chat');
}, 250); // 250ms 延迟
}
}, [sidebarMode]);
}, [sidebarMode, setSidebarHovered, setLayoutMode]);
// 清理定时器
useEffect(() => {
@@ -79,34 +64,10 @@ function App() {
};
}, []);
// 更新左侧栏模式(从设置面板调用)
const updateSidebarMode = useCallback((mode) => {
setSidebarMode(mode);
// 切换模式时重置悬停状态
setIsSidebarHovered(false);
if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current);
if (leaveTimeoutRef.current) clearTimeout(leaveTimeoutRef.current);
if (mode === 'expanded') {
setLayoutMode('edit');
} else {
setLayoutMode('chat');
}
}, []);
// 更新配色主题(从设置面板调用)
const updateColorTheme = useCallback((theme) => {
setColorTheme(theme);
}, []);
return (
<div className={`app ${layoutMode}-mode`}>
<TopBar
sidebarMode={sidebarMode}
colorTheme={colorTheme}
onSidebarModeChange={updateSidebarMode}
onColorThemeChange={updateColorTheme}
/>
{/* ✅ TopBar 不再需要 props直接从 Store 读取状态 */}
<TopBar />
{/* 主内容容器 */}
<div className="main-container">

View File

@@ -0,0 +1,96 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
/**
* App 布局状态 Store
* 管理应用整体布局和主题(持久化)
*/
const useAppLayoutStore = create(
persist(
(set) => ({
// ==================== 状态 ====================
// 布局模式:'chat' | 'workflow' | 'settings'
layoutMode: 'chat',
// 侧边栏模式:'left' | 'right' | 'both' | 'none'
sidebarMode: 'both',
// 侧边栏是否悬停
isSidebarHovered: false,
// 颜色主题:'light' | 'dark'
colorTheme: 'dark',
// ==================== Actions ====================
/**
* 设置布局模式
* @param {string} mode - 布局模式
*/
setLayoutMode: (mode) => {
set({ layoutMode: mode });
},
/**
* 设置侧边栏模式
* @param {string} mode - 侧边栏模式
*/
setSidebarMode: (mode) => {
set({ sidebarMode: mode });
},
/**
* 切换侧边栏悬停状态
* @param {boolean} hovered - 是否悬停
*/
setSidebarHovered: (hovered) => {
set({ isSidebarHovered: hovered });
},
/**
* 设置颜色主题
* @param {string} theme - 主题名
*/
setColorTheme: (theme) => {
set({ colorTheme: theme });
// 同步到 DOM
document.documentElement.setAttribute('data-color-theme', theme);
},
/**
* 切换颜色主题
*/
toggleColorTheme: () => {
set((state) => {
const newTheme = state.colorTheme === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-color-theme', newTheme);
return { colorTheme: newTheme };
});
},
/**
* 重置所有布局状态
*/
reset: () => {
set({
layoutMode: 'chat',
sidebarMode: 'both',
isSidebarHovered: false,
colorTheme: 'dark'
});
}
}),
{
name: 'app-layout-storage', // localStorage key
partialize: (state) => ({
layoutMode: state.layoutMode,
sidebarMode: state.sidebarMode,
colorTheme: state.colorTheme
})
}
)
);
export default useAppLayoutStore;

View File

@@ -108,6 +108,39 @@ const useChatBoxStore = create(
sendMessage: async (content) => {
const { messages, userName, characterName, currentRole, currentChat, options, wsConnection } = get();
// ✅ 如果没有 currentChat先创建聊天文件
let actualChat = currentChat;
if (!currentChat && currentRole) {
console.log(`[ChatBoxStore] 检测到未选择聊天,自动创建...`);
try {
const chatName = '默认聊天';
const response = await fetch(`/api/chat/${encodeURIComponent(currentRole)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_name: chatName,
metadata: {
user_name: userName || 'User',
character_name: characterName || currentRole
}
})
});
if (response.ok || response.status === 400) {
actualChat = chatName;
// 更新 currentChat
set({ currentChat: chatName });
console.log(`[ChatBoxStore] ✅ 已创建/使用聊天: ${chatName}`);
} else {
throw new Error(`创建聊天失败: ${response.status}`);
}
} catch (error) {
console.error('[ChatBoxStore] ❌ 创建聊天失败:', error);
set({ error: '创建聊天失败: ' + error.message });
return;
}
}
// 获取 API 配置
const apiConfigStore = useApiConfigStore.getState();
@@ -138,7 +171,7 @@ const useChatBoxStore = create(
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 wsUrl = `${backendUrl.replace(/^http/, 'ws')}/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(actualChat)}/ws`;
const ws = new WebSocket(wsUrl);
console.log('[WebSocket] 正在建立连接...', { url: wsUrl });
@@ -236,7 +269,6 @@ const useChatBoxStore = create(
}
};
// 发送请求到WebSocket确保连接已建立
// 发送请求到WebSocket确保连接已建立
const sendAfterConnect = () => {
if (ws.readyState === WebSocket.OPEN) {
@@ -246,7 +278,7 @@ const useChatBoxStore = create(
mes: content,
is_user: true,
currentRole: currentRole,
currentChat: currentChat,
currentChat: actualChat, // 使用 actualChat
options: options,
apiConfig: {
api_url: apiConfigStore.allApis.find(api => api.category === 'text' && api.id === apiConfigStore.activeMap.text)?.api_url || '',
@@ -296,6 +328,14 @@ const useChatBoxStore = create(
// 加载聊天历史
fetchChatHistory: async (roleName, chatName) => {
const currentState = get();
// 如果已经在加载中,跳过
if (currentState.isLoading) {
console.log(`[ChatBoxStore] 跳过重复加载: ${roleName}/${chatName}`);
return;
}
set({ isLoading: true, error: null });
try {
// 确俚chatName是字符串
@@ -331,17 +371,21 @@ const useChatBoxStore = create(
}
}
// 只更新消息相关状态,不更新 currentRole/currentChat避免触发监听器
set({
messages: messages,
userName: data.metadata?.user_name || 'User',
characterName: data.metadata?.character_name || roleName || 'Assistant',
isLoading: false
});
console.log(`[ChatBoxStore] 已加载聊天: ${roleName}/${actualChatName}, 消息数: ${messages.length}`);
} catch (error) {
set({
error: error.message,
isLoading: false
});
console.error('[ChatBoxStore] 加载聊天失败:', error);
}
},
@@ -483,16 +527,49 @@ const useChatBoxStore = create(
useChatBoxStore.subscribe(
(state) => ({ role: state.currentRole, chat: state.currentChat }),
({ role, chat }, prev) => {
console.log(`[ChatBoxStore 监听器] 状态变化检测:`, {
当前: { role, chat },
之前: prev,
角色变化: role !== prev.role,
聊天变化: chat !== prev.chat
});
// 只有当角色或聊天发生变化时才处理
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);
// 确保角色存在
if (role) {
// 如果聊天也存在,加载聊天历史
if (chat) {
// 确俚chat是字符串如果是对象则提取chat_name
const actualChat = typeof chat === 'object' && chat !== null ? chat.chat_name : chat;
// 检查是否已经在加载中,避免重复加载
const currentState = useChatBoxStore.getState();
console.log(`[ChatBoxStore 监听器] isLoading 状态:`, currentState.isLoading);
if (!currentState.isLoading) {
console.log(`[ChatBoxStore 监听器] ✅ 开始加载聊天: ${role}/${actualChat}`);
useChatBoxStore.getState().fetchChatHistory(role, actualChat);
} else {
console.log(`[ChatBoxStore 监听器] ⏭️ 跳过加载,正在加载中: ${role}/${actualChat}`);
}
} else {
// 聊天为 null只设置角色不加载聊天等待用户发送第一条消息
console.log(`[ChatBoxStore 监听器] 只设置角色,未选择聊天: ${role}`);
useChatBoxStore.setState({
currentRole: role,
currentChat: null,
messages: [], // 清空消息
characterName: role // 使用角色名作为显示名称
});
}
} else {
// 角色也为 null完全清空
console.log(`[ChatBoxStore 监听器] 🗑️ 清空所有状态`);
useChatBoxStore.getState().clearChatHistory();
}
} else {
console.log(`[ChatBoxStore 监听器] ⏸️ 无变化,不触发加载`);
}
},
{ equalityFn: (a, b) => a.role === b.role && a.chat === b.chat }

View File

@@ -0,0 +1,154 @@
import { create } from 'zustand';
/**
* ChatBox UI 状态 Store
* 管理聊天框的 UI 交互状态(非持久化)
*/
const useChatBoxUIStore = create((set) => ({
// ==================== 状态 ====================
// 当前编辑的消息 ID
editingId: null,
// 编辑中的内容
editContent: '',
// 输入框内容
inputValue: '',
// 是否显示选项面板
showOptions: false,
// 是否显示聊天选择器
showChatSelector: false,
// 角色的聊天列表
characterChats: [],
// 当前 swipe ID用于多版本切换
currentSwipeId: {},
// 输入框高度
inputHeight: 42,
// ==================== Actions ====================
/**
* 开始编辑消息
* @param {string|number} messageId - 消息 ID
* @param {string} content - 消息内容
*/
startEditing: (messageId, content) => {
set({
editingId: messageId,
editContent: content
});
},
/**
* 取消编辑
*/
cancelEditing: () => {
set({
editingId: null,
editContent: ''
});
},
/**
* 更新编辑内容
* @param {string} content - 新内容
*/
updateEditContent: (content) => {
set({ editContent: content });
},
/**
* 设置输入框内容
* @param {string} value - 输入值
*/
setInputValue: (value) => {
set({ inputValue: value });
},
/**
* 清空输入框
*/
clearInput: () => {
set({ inputValue: '' });
},
/**
* 切换选项面板
*/
toggleOptions: () => {
set((state) => ({ showOptions: !state.showOptions }));
},
/**
* 设置选项面板显示状态
* @param {boolean} show - 是否显示
*/
setShowOptions: (show) => {
set({ showOptions: show });
},
/**
* 切换聊天选择器
*/
toggleChatSelector: () => {
set((state) => ({ showChatSelector: !state.showChatSelector }));
},
/**
* 设置聊天选择器显示状态
* @param {boolean} show - 是否显示
*/
setShowChatSelector: (show) => {
set({ showChatSelector: show });
},
/**
* 设置角色聊天列表
* @param {Array} chats - 聊天列表
*/
setCharacterChats: (chats) => {
set({ characterChats: chats });
},
/**
* 设置当前 swipe ID
* @param {Object} swipeId - swipe ID 对象 { [messageId]: swipeIndex }
*/
setCurrentSwipeId: (swipeId) => {
set((state) => ({
currentSwipeId: { ...state.currentSwipeId, ...swipeId }
}));
},
/**
* 设置输入框高度
* @param {number} height - 高度px
*/
setInputHeight: (height) => {
set({ inputHeight: height });
},
/**
* 重置所有 UI 状态
*/
reset: () => {
set({
editingId: null,
editContent: '',
inputValue: '',
showOptions: false,
showChatSelector: false,
characterChats: [],
currentSwipeId: {},
inputHeight: 42
});
}
}));
export default useChatBoxUIStore;

View File

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

View File

@@ -0,0 +1,130 @@
import { create } from 'zustand';
/**
* CharacterCard UI 状态 Store
* 管理角色卡列表的 UI 交互状态(非持久化)
*/
const useCharacterCardUIStore = create((set) => ({
// ==================== 状态 ====================
// 筛选标签
filterTag: '',
// 是否处于编辑模式
isEditing: false,
// 编辑表单数据
editForm: null,
// 当前页码
currentPage: 1,
// 每页显示数量
pageSize: 12,
// ==================== Actions ====================
/**
* 设置筛选标签
* @param {string} tag - 标签名
*/
setFilterTag: (tag) => {
set({ filterTag: tag, currentPage: 1 }); // 重置页码
},
/**
* 清空筛选
*/
clearFilter: () => {
set({ filterTag: '', currentPage: 1 });
},
/**
* 进入编辑模式
* @param {Object} character - 角色数据
*/
startEditing: (character) => {
set({
isEditing: true,
editForm: {
name: character.name,
description: character.description || '',
personality: character.personality || '',
scenario: character.scenario || '',
first_mes: character.first_mes || '',
mes_example: character.mes_example || '',
categories: character.categories || [],
tags: character.tags || []
}
});
},
/**
* 退出编辑模式
*/
cancelEditing: () => {
set({
isEditing: false,
editForm: null
});
},
/**
* 更新编辑表单字段
* @param {string} field - 字段名
* @param {*} value - 字段值
*/
updateEditForm: (field, value) => {
set((state) => ({
editForm: {
...state.editForm,
[field]: value
}
}));
},
/**
* 设置页码
* @param {number} page - 页码
*/
setCurrentPage: (page) => {
set({ currentPage: page });
},
/**
* 设置每页显示数量
* @param {number} size - 每页数量
*/
setPageSize: (size) => {
set({ pageSize: size, currentPage: 1 }); // 重置页码
},
/**
* 下一页
*/
nextPage: () => {
set((state) => ({ currentPage: state.currentPage + 1 }));
},
/**
* 上一页
*/
prevPage: () => {
set((state) => ({ currentPage: Math.max(1, state.currentPage - 1) }));
},
/**
* 重置所有 UI 状态
*/
reset: () => {
set({
filterTag: '',
isEditing: false,
editForm: null,
currentPage: 1,
pageSize: 12
});
}
}));
export default useCharacterCardUIStore;

View File

@@ -28,6 +28,10 @@ const usePresetStore = create((set, get) => ({
// 参数设置折叠状态
isParametersExpanded: true,
// 分页状态
currentPage: 1,
pageSize: 8,
// 预设组件列表
promptComponents: [
{
@@ -129,17 +133,22 @@ const usePresetStore = create((set, get) => ({
// 记录原始数据用于调试
console.log('从后端获取的预设数据:', presetData);
// 提取参数并更新状态,确保所有参数都有默认值
// 提取参数并更新状态,支持内部结构和SillyTavern结构
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,
frequency_penalty: presetData.frequency_penalty !== undefined ? presetData.frequency_penalty :
(presetData.frequencyPenalty !== undefined ? presetData.frequencyPenalty : 0.0),
presence_penalty: presetData.presence_penalty !== undefined ? presetData.presence_penalty :
(presetData.presencePenalty !== undefined ? presetData.presencePenalty : 0.0),
top_p: presetData.top_p !== undefined ? presetData.top_p :
(presetData.topP !== undefined ? presetData.topP : 1.0),
top_k: presetData.top_k !== undefined ? presetData.top_k :
(presetData.topK !== undefined ? presetData.topK : 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),
(presetData.max_tokens !== undefined ? presetData.max_tokens :
(presetData.maxLength !== undefined ? presetData.maxLength : 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,
@@ -149,9 +158,30 @@ const usePresetStore = create((set, get) => ({
// 记录映射后的参数用于调试
console.log('映射后的参数:', parameters);
// 处理预设组件
// 处理预设组件 - 支持内部结构和SillyTavern结构
let components = [];
if (presetData.prompts && Array.isArray(presetData.prompts)) {
// 优先使用内部结构的 entries
if (presetData.entries && Array.isArray(presetData.entries)) {
components = presetData.entries.map(entry => ({
identifier: entry.identifier,
name: entry.name,
content: entry.content || '',
enabled: entry.enabled !== false,
role: entry.role === 'system' ? 0 : entry.role === 'user' ? 1 : 2,
system_prompt: entry.role === 'system',
marker: entry.isSystemNode || false
}));
// 按 order 排序
components.sort((a, b) => {
const orderA = presetData.entries.find(e => e.identifier === a.identifier)?.order || 0;
const orderB = presetData.entries.find(e => e.identifier === b.identifier)?.order || 0;
return orderA - orderB;
});
}
// 兼容SillyTavern结构的 prompts
else if (presetData.prompts && Array.isArray(presetData.prompts)) {
// 获取当前角色的prompt_order添加更严格的检查
const currentOrder = (presetData.prompt_order &&
Array.isArray(presetData.prompt_order) &&
@@ -182,7 +212,6 @@ const usePresetStore = create((set, get) => ({
}
}
// 更新状态,确保参数容器展开
set({
selectedPreset: presetId,
@@ -209,23 +238,31 @@ const usePresetStore = create((set, get) => ({
saveCurrentAsPreset: async ({ name }) => {
const state = get();
try {
// 构建预设数据
// 构建预设数据 - 使用内部专有结构
const presetData = {
...state.parameters,
prompts: state.promptComponents.map(component => ({
// GenerationPreset 部分 - 采样参数
id: `preset_${Date.now()}`,
name: name,
temperature: state.parameters.temperature,
topP: state.parameters.top_p,
topK: state.parameters.top_k,
frequencyPenalty: state.parameters.frequency_penalty,
presencePenalty: state.parameters.presence_penalty,
maxLength: state.parameters.max_tokens,
isDefault: false,
// PromptPresetView 部分 - prompt组件列表
characterId: 'global', // 全局预设,不绑定特定角色
entries: state.promptComponents.map((component, index) => ({
identifier: component.identifier,
name: component.name,
enabled: component.enabled !== false,
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
}))
}]
order: index,
role: component.role === 0 ? 'system' : component.role === 1 ? 'user' : 'ai',
tokenCount: component.content ? component.content.length : 0,
isSystemNode: component.marker || false
}))
};
// 发送到后端
@@ -234,10 +271,7 @@ const usePresetStore = create((set, get) => ({
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
preset_name: name,
...presetData
})
body: JSON.stringify(presetData)
});
if (!response.ok) {
@@ -248,7 +282,7 @@ const usePresetStore = create((set, get) => ({
// 添加到本地预设列表
const newPreset = {
id: name,
id: presetData.id,
name,
description: '',
component_count: state.promptComponents.length,
@@ -257,7 +291,7 @@ const usePresetStore = create((set, get) => ({
set((state) => ({
presets: [...state.presets, newPreset],
selectedPreset: name
selectedPreset: presetData.id
}));
return result;
@@ -350,6 +384,26 @@ const usePresetStore = create((set, get) => ({
identifier: component.identifier,
enabled: component.enabled !== false
}));
},
// 设置当前页
setCurrentPage: (page) => set({ currentPage: page }),
// 设置每页数量
setPageSize: (size) => set({ pageSize: size, currentPage: 1 }),
// 获取当前页的预设列表
getCurrentPagePresets: () => {
const { presets, currentPage, pageSize } = get();
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
return presets.slice(startIndex, endIndex);
},
// 获取总页数
getTotalPages: () => {
const { presets, pageSize } = get();
return Math.ceil(presets.length / pageSize);
}
}));

View File

@@ -4,3 +4,4 @@ export { default as useApiConfigStore } from './ApiConfigSlice';
export { default as usePresetStore } from './PresetSlice';
export { default as useWorldBookStore } from './WorldBookSlice';
export { default as useCharacterStore } from './CharacterSlice';
export { default as useCharacterCardUIStore } from './CharacterCardUISlice';

View File

@@ -0,0 +1,255 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
/**
* 动态表格 Store
* 管理 SillyTavern 风格的标签数据
*/
const useTableStore = create(
persist(
(set, get) => ({
// ==================== 状态 ====================
// 标签数组
tags: [],
// 当前角色和聊天
currentRole: null,
currentChat: null,
// 加载状态
isLoading: false,
// 最后更新时间
lastUpdated: null,
// ==================== Actions ====================
/**
* 加载标签数据
* @param {string} role - 角色名
* @param {string} chat - 聊天名
*/
loadTags: async (role, chat) => {
if (!role) {
set({ tags: [], currentRole: null, currentChat: null });
return;
}
set({ isLoading: true, currentRole: role, currentChat: chat });
try {
let fetchedTags = [];
// 优先级1从聊天文件读取
if (chat) {
console.log('[TableStore] 从聊天文件读取标签数据');
const response = await fetch(`/api/chat/${encodeURIComponent(role)}/${encodeURIComponent(chat)}`);
if (!response.ok) throw new Error('获取聊天数据失败');
const chatData = await response.json();
const header = chatData.header || {};
fetchedTags = header.tags || [];
if (fetchedTags.length > 0) {
set({
tags: fetchedTags,
lastUpdated: Date.now(),
isLoading: false
});
console.log(`[TableStore] ✅ 已加载 ${fetchedTags.length} 个标签`);
return;
}
}
// 优先级2从角色卡读取降级
console.log('[TableStore] 聊天中无标签,从角色卡读取');
const charResponse = await fetch(`/api/characters/${encodeURIComponent(role)}`);
if (!charResponse.ok) throw new Error('获取角色卡失败');
const charData = await charResponse.json();
fetchedTags = charData.tags || [];
set({
tags: fetchedTags,
lastUpdated: Date.now(),
isLoading: false
});
console.log(`[TableStore] ✅ 已从角色卡加载 ${fetchedTags.length} 个标签`);
} catch (error) {
console.error('[TableStore] 获取标签数据失败:', error);
set({ tags: [], isLoading: false });
}
},
/**
* 保存标签到后端(带防抖)
* @param {Array} newTags - 新的标签数组
*/
saveTags: (newTags) => {
const { currentRole, currentChat } = get();
if (!currentRole || !currentChat) {
console.warn('[TableStore] 无法保存:缺少角色或聊天信息');
return;
}
// 清除之前的定时器(如果存在)
if (get().saveTimeoutId) {
clearTimeout(get().saveTimeoutId);
}
// 延迟 1.5 秒后保存
const timeoutId = setTimeout(async () => {
try {
const response = await fetch(
`/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/table`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tags: newTags })
}
);
if (!response.ok) throw new Error('保存失败');
set({ lastUpdated: Date.now() });
console.log('[TableStore] ✅ 标签已保存到后端');
} catch (error) {
console.error('[TableStore] 保存标签失败:', error);
alert('保存失败: ' + error.message);
}
}, 1500);
// 保存定时器 ID
set({ saveTimeoutId: timeoutId });
},
/**
* 更新标签(乐观更新 + 延迟保存)
* @param {Array} newTags - 新的标签数组
*/
updateTags: (newTags) => {
set({ tags: newTags });
get().saveTags(newTags);
},
/**
* 添加标签
* @param {string|string[]} tagOrTags - 单个标签或标签数组
*/
addTag: (tagOrTags) => {
const { tags } = get();
// 支持批量添加
const newTagsList = Array.isArray(tagOrTags) ? tagOrTags : [tagOrTags];
const newTags = [...tags, ...newTagsList];
get().updateTags(newTags);
},
/**
* 删除标签
* @param {number} index - 标签索引
*/
deleteTag: (index) => {
const { tags } = get();
const newTags = tags.filter((_, i) => i !== index);
get().updateTags(newTags);
},
/**
* 移动标签
* @param {number} fromIndex - 起始索引
* @param {number} toIndex - 目标索引
*/
moveTag: (fromIndex, toIndex) => {
const { tags } = get();
if (fromIndex < 0 || toIndex < 0 || fromIndex >= tags.length || toIndex >= tags.length) {
return;
}
const newTags = [...tags];
[newTags[fromIndex], newTags[toIndex]] = [newTags[toIndex], newTags[fromIndex]];
get().updateTags(newTags);
},
/**
* 左移标签
* @param {number} index - 标签索引
*/
moveLeft: (index) => {
if (index === 0) return;
get().moveTag(index, index - 1);
},
/**
* 右移标签
* @param {number} index - 标签索引
*/
moveRight: (index) => {
const { tags } = get();
if (index === tags.length - 1) return;
get().moveTag(index, index + 1);
},
/**
* 编辑标签
* @param {number} index - 标签索引
* @param {string} newValue - 新值
*/
editTag: (index, newValue) => {
const { tags } = get();
const newTags = [...tags];
newTags[index] = newValue.trim();
get().updateTags(newTags);
},
/**
* 清空状态
*/
clear: () => {
// 清除定时器
if (get().saveTimeoutId) {
clearTimeout(get().saveTimeoutId);
}
set({
tags: [],
currentRole: null,
currentChat: null,
isLoading: false,
lastUpdated: null,
saveTimeoutId: null
});
},
/**
* 强制刷新(立即从后端重新加载)
*/
refresh: async () => {
const { currentRole, currentChat } = get();
if (!currentRole) return;
await get().loadTags(currentRole, currentChat);
}
}),
{
name: 'table-storage', // localStorage key
partialize: (state) => ({
tags: state.tags,
currentRole: state.currentRole,
currentChat: state.currentChat,
lastUpdated: state.lastUpdated
})
}
)
);
export default useTableStore;

View File

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

View File

@@ -0,0 +1,48 @@
// frontend-react/src/Store/UserSlice.jsx
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
/**
* 用户角色 Store
* 管理当前玩家角色信息
*/
const useUserStore = create(
persist(
(set, get) => ({
// 当前用户角色
currentUserRole: { name: '', description: '' },
// 设置用户角色
setCurrentUserRole: (role) => {
set({ currentUserRole: role });
},
// 清除用户角色
clearCurrentUserRole: () => {
set({ currentUserRole: { name: '', description: '' } });
},
// 更新用户角色名称
updateRoleName: (name) => {
set((state) => ({
currentUserRole: { ...state.currentUserRole, name }
}));
},
// 更新用户角色描述
updateRoleDescription: (description) => {
set((state) => ({
currentUserRole: { ...state.currentUserRole, description }
}));
}
}),
{
name: 'user-role-storage', // localStorage key
partialize: (state) => ({
currentUserRole: state.currentUserRole
})
}
)
);
export default useUserStore;

View File

@@ -1,6 +1,8 @@
// 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';
export { useSideBarLeftStore, useApiConfigStore, usePresetStore, useWorldBookStore, useCharacterCardUIStore } from './SideBarLeft';
export { useSideBarRightStore, useTableStore } from './SideBarRight';
export { useChatBoxStore, useChatBoxUIStore } from './Mid';
export { default as useAppLayoutStore } from './AppLayoutSlice';
export { default as useUserStore } from './UserSlice'; //

View File

@@ -53,19 +53,29 @@
/* 用户消息 - 右侧对齐,用色彩区分 */
.message.user {
align-self: stretch;
background: linear-gradient(to right, var(--color-accent-ultra-light), var(--color-accent-light));
background: linear-gradient(to right, rgba(102, 126, 234, 0.08), rgba(102, 126, 234, 0.15));
color: var(--color-text-primary);
border-left: 3px solid var(--color-accent);
}
/* AI 消息 - 左侧对齐,用色彩区分 */
/* 参考 Discord/Slack/Notion 的夜间模式配色 */
.message.ai {
align-self: stretch;
background-color: var(--color-bg-elevated); /* 使用主题的浅色背景形成对比 */
/* 日间模式:半透明白色背景 */
background-color: rgba(255, 255, 255, 0.6);
color: var(--color-text-primary);
border-left: 3px solid transparent;
}
/* 深色主题下的 AI 消息配色优化 */
[data-color-theme='dark'] .message.ai {
/* 使用深灰色背景,避免纯白色刺眼 */
/* 参考 VS Code / Discord 的深色模式 */
background-color: rgba(45, 45, 48, 0.5);
color: var(--color-text-primary);
}
.message-container {
display: flex;
flex-direction: column;

View File

@@ -1,24 +1,38 @@
// frontend-react/src/components/ChatBox/ChatBox.jsx
import React, { useState, useEffect, useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import useChatBoxStore from '../../../Store/Mid/ChatBoxSlice';
import useChatBoxUIStore from '../../../Store/Mid/ChatBoxUISlice'; // ✅ 新增
import MarkdownRenderer from '../../shared/MarkdownRenderer';
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);
// 聊天选择器相关状态
const [showChatSelector, setShowChatSelector] = useState(false);
const [characterChats, setCharacterChats] = useState([]);
const chatSelectorRef = useRef(null);
// 新增管理每条消息的当前显示的swipe版本
const [currentSwipeId, setCurrentSwipeId] = useState({});
// ✅ 从 ChatBoxUIStore 获取 UI 状态
const {
editingId,
editContent,
inputValue,
showOptions,
showChatSelector,
characterChats,
currentSwipeId,
inputHeight,
startEditing,
cancelEditing,
updateEditContent,
setInputValue,
clearInput,
toggleOptions,
setShowOptions,
toggleChatSelector,
setShowChatSelector,
setCharacterChats,
setCurrentSwipeId,
setInputHeight
} = useChatBoxUIStore();
// 从 ChatBoxStore 获取状态和方法
const {
@@ -35,8 +49,6 @@ const ChatBox = () => {
toggleOption
} = useChatBoxStore();
const [inputHeight, setInputHeight] = useState(42);
// 点击外部关闭选项面板
useEffect(() => {
const handleClickOutside = (event) => {
@@ -88,8 +100,8 @@ const ChatBox = () => {
stopGeneration();
} else {
sendMessage(inputValue);
setInputValue('');
setInputHeight(24); // 重置为一行高度
clearInput(); // ✅ 使用 store 方法
setInputHeight(42); // 重置为一行高度
}
};
@@ -112,22 +124,19 @@ const ChatBox = () => {
// 处理编辑消息
const handleEdit = (message) => {
setEditingId(message.floor);
setEditContent(message.mes);
startEditing(message.floor, message.mes); // ✅ 使用 store 方法
};
// 保存编辑
const handleSaveEdit = (messageId) => {
// 调用 store 中的 updateMessage 方法
updateMessage(messageId, editContent);
setEditingId(null);
setEditContent('');
cancelEditing(); // ✅ 使用 store 方法
};
// 取消编辑
const handleCancelEdit = () => {
setEditingId(null);
setEditContent('');
cancelEditing(); // ✅ 使用 store 方法
};
// 新增处理swipe切换
@@ -140,17 +149,14 @@ const ChatBox = () => {
const newIndex = currentIndex + direction;
if (newIndex >= 0 && newIndex < message.swipes.length) {
setCurrentSwipeId(prev => ({
...prev,
[messageId]: newIndex
}));
setCurrentSwipeId({ [messageId]: newIndex }); // ✅ 使用 store 方法
}
}
};
// 切换选项显示
const toggleOptionsPanel = () => {
setShowOptions(!showOptions);
toggleOptions(); // ✅ 使用 store 方法
};
// 打开聊天选择器
@@ -169,10 +175,10 @@ const ChatBox = () => {
if (!response.ok) throw new Error('获取聊天列表失败');
const chats = await response.json();
setCharacterChats(chats);
setCharacterChats(chats); // ✅ 使用 store 方法
// 显示聊天选择器弹窗,让用户手动选择
setShowChatSelector(true);
toggleChatSelector(); // ✅ 使用 store 方法
} catch (error) {
console.error('获取聊天列表失败:', error);
alert('获取聊天列表失败: ' + error.message);
@@ -182,16 +188,13 @@ const ChatBox = () => {
// 选择聊天并加载
const handleSelectChat = async (chatName) => {
try {
const { setChatBoxRoleAndChat, fetchChatHistory, currentRole } = useChatBoxStore.getState();
const { setChatBoxRoleAndChat, currentRole } = useChatBoxStore.getState();
// 设置当前角色和聊天
// 设置当前角色和聊天,这会触发 ChatBoxStore 的监听器自动加载聊天历史
setChatBoxRoleAndChat(currentRole, chatName);
// 加载聊天历史
await fetchChatHistory(currentRole, chatName);
// 关闭选择器
setShowChatSelector(false);
setShowChatSelector(false); // ✅ 使用 store 方法
console.log(`已切换到聊天: ${chatName}`);
} catch (error) {
@@ -336,7 +339,7 @@ const ChatBox = () => {
<button
className={`options-toggle ${showOptions ? 'active' : ''}`}
title="Toggle Options"
onClick={() => setShowOptions(!showOptions)}
onClick={toggleOptions} // 使用 store 方法
>
{showOptions ? '×' : '≡'}
</button>

View File

@@ -2,7 +2,7 @@
import React from 'react';
import './SideBarLeft.css';
import { useSideBarLeftStore } from '../../Store/indexStore';
import useSideBarRightStore from '../../Store/SideBarLeft/SideBarLeftSlice';
import usePresetStore from '../../Store/SideBarLeft/PresetSlice';
import Gallery from './tabs/Gallery';
import CharacterCard from './tabs/CharacterCard';
import ApiConfig from './tabs/ApiConfig';
@@ -11,6 +11,17 @@ import WorldBook from './tabs/WorldBook';
const SideBarLeft = () => {
const { activeTab, tabs, setActiveTab } = useSideBarLeftStore();
const { fetchPresets } = usePresetStore();
// 处理标签切换
const handleTabClick = (tabId) => {
setActiveTab(tabId);
// 如果切换到预设标签,刷新预设列表
if (tabId === 'presets') {
fetchPresets();
}
};
return (
<div className="sidebar-left">
@@ -19,7 +30,7 @@ const SideBarLeft = () => {
<button
key={tab.id}
className={`tab-button ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
onClick={() => handleTabClick(tab.id)}
title={tab.title}
>
{tab.label}

View File

@@ -1,6 +1,6 @@
/* ApiConfig - Compact Modern Design */
.api-config-container {
padding: var(--spacing-md);
padding: 0; /* sidebar-content已有padding这里不需要 */
color: var(--color-text-primary);
height: 100%;
overflow-y: auto;

View File

@@ -4,7 +4,7 @@
display: flex;
flex-direction: column;
height: 100%;
padding: 6px;
padding: 0; /* sidebar-content已有padding这里不需要 */
gap: 6px;
background: var(--color-bg-primary);
overflow-y: auto;
@@ -101,8 +101,8 @@
flex: 1;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 6px;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); /* 减小最小宽度让窄屏也能显示2列 */
gap: 8px; /* 增加间距,让卡片更有呼吸感 */
padding: 2px;
}
@@ -110,8 +110,8 @@
position: relative;
display: flex;
flex-direction: column;
width: 140px; /* 固定宽度 */
height: 200px; /* 固定高度 */
width: 100%; /* 改为自适应宽度 */
height: 170px; /* 减小高度从200px降到170px */
background: var(--color-bg-secondary);
border: 2px solid var(--color-border);
border-radius: 6px;
@@ -140,7 +140,7 @@
/* 角色头像 */
.character-avatar-wrapper {
width: 100%;
aspect-ratio: 3/4;
aspect-ratio: 3/4; /* 保持3:4比例 */
position: relative;
background: var(--color-bg-tertiary);
overflow: hidden;
@@ -170,24 +170,24 @@
/* 角色信息 */
.character-info {
padding: 6px;
padding: 5px; /* 减小padding从6px降到5px */
flex: 1;
min-height: 50px;
min-height: 40px; /* 减小最小高度从50px降到40px */
/* 移除 user-select因为已经在父元素设置了 */
}
.character-name {
font-size: 12px;
font-size: 11px; /* 稍微减小字体从12px降到11px */
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 3px;
margin-bottom: 2px; /* 减小间距 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.character-desc {
font-size: 10px;
font-size: 9px; /* 减小字体从10px降到9px */
color: var(--color-text-muted);
line-height: 1.3;
display: -webkit-box;
@@ -200,7 +200,7 @@
display: flex;
flex-wrap: wrap;
gap: 2px;
margin-top: 3px;
margin-top: 2px; /* 减小间距从3px降到2px */
}
.tag {
@@ -280,18 +280,26 @@
background: var(--color-bg-tertiary);
border-radius: 4px;
border: 1px solid var(--color-border-light);
flex-wrap: wrap; /* 允许换行 */
gap: 6px; /* 添加间距 */
}
.edit-title {
font-size: 12px;
font-weight: 600;
color: var(--color-text-primary);
flex: 1; /* 占据剩余空间 */
min-width: 120px; /* 最小宽度,防止过度压缩 */
white-space: nowrap; /* 不换行 */
overflow: hidden; /* 超出隐藏 */
text-overflow: ellipsis; /* 显示省略号 */
}
.edit-actions {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap; /* 按钮也可以换行 */
}
.export-format-selector {
@@ -409,6 +417,12 @@
min-height: 60px;
}
.form-hint {
font-size: 10px;
color: var(--color-text-muted);
margin-top: 2px;
}
/* 聊天选择器弹窗 */
.chat-selector-overlay {
position: fixed;

View File

@@ -1,6 +1,6 @@
1// frontend-react/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.jsx
import React, { useState, useEffect, useRef, useCallback, memo } from 'react';
import { useCharacterStore } from '../../../../Store/SideBarLeft';
import React, { useState, useEffect, useRef, useCallback, memo } from 'react'; // ✅ 保留 useState 用于 exportFormat
import { useCharacterStore, useCharacterCardUIStore } from '../../../../Store/SideBarLeft'; // ✅ 新增
import useChatBoxStore from '../../../../Store/Mid/ChatBoxSlice';
import './CharacterCard.css';
@@ -76,23 +76,32 @@ const CharacterCard = () => {
selectedCharacter,
isLoading,
error,
currentPage,
pageSize,
characterChats,
fetchCharacters,
selectCharacter,
deleteCharacter,
exportCharacterAsPng,
setCurrentPage,
setPageSize,
getCurrentPageCharacters,
getTotalPages,
fetchCharacterChats
} = useCharacterStore();
const [filterTag, setFilterTag] = useState('');
const [isEditing, setIsEditing] = useState(false); // 是否处于编辑模式
const [editForm, setEditForm] = useState(null); // 编辑表单数据
// ✅ 从 CharacterCardUIStore 获取 UI 状态
const {
filterTag,
isEditing,
editForm,
currentPage,
pageSize,
setFilterTag,
clearFilter,
startEditing,
cancelEditing,
updateEditForm,
setCurrentPage,
setPageSize,
nextPage,
prevPage
} = useCharacterCardUIStore();
const [exportFormat, setExportFormat] = useState('png'); // 导出格式: 'png' 或 'json'
const fileInputRef = useRef(null);
const clickTimeoutRef = useRef(null);
@@ -117,11 +126,11 @@ const CharacterCard = () => {
? characters.filter(char => (char.tags || []).includes(filterTag))
: characters;
// 获取当前页的角色数据
const currentPageCharacters = getCurrentPageCharacters();
// 获取总页数
const totalPages = getTotalPages();
// ✅ 计算当前页和总页数
const totalPages = Math.ceil(filteredCharacters.length / pageSize) || 1;
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const currentPageCharacters = filteredCharacters.slice(startIndex, endIndex);
// 处理导入文件
const handleImport = async (event) => {
@@ -161,7 +170,7 @@ const CharacterCard = () => {
first_mes: '',
mes_example: '',
categories: [],
tags: []
tags: [] // ✅ 使用标签数组
});
} catch (err) {
console.error('创建失败:', err);
@@ -247,17 +256,7 @@ const CharacterCard = () => {
selectCharacter(character);
// 进入编辑模式
setIsEditing(true);
setEditForm({
name: character.name,
description: character.description || '',
personality: character.personality || '',
scenario: character.scenario || '',
first_mes: character.first_mes || '',
mes_example: character.mes_example || '',
categories: character.categories || [],
tags: character.tags || []
});
startEditing(character); // ✅ 使用 store 方法
// 获取该角色的聊天列表,并自动切换到最后一个聊天
try {
@@ -267,38 +266,19 @@ const CharacterCard = () => {
if (chats.length > 0) {
// 有聊天记录,切换到最后一个
const lastChat = chats[chats.length - 1].chat_name;
// 先设置角色和聊天,这会触发 ChatBoxStore 的监听器自动加载聊天历史
useChatBoxStore.getState().setChatBoxRoleAndChat(character.name, lastChat);
console.log(`[CharacterCard] 已切换到角色 ${character.name} 的聊天: ${lastChat}`);
} else {
// 没有聊天记录,自动创建一个默认聊天
console.log(`[CharacterCard] 角色 ${character.name} 没有聊天,创建默认聊天...`);
// 没有聊天记录,只设置角色,不创建聊天文件
// 聊天文件将在用户发送第一条消息时创建
console.log(`[CharacterCard] 角色 ${character.name} 没有聊天,等待用户发送第一条消息...`);
const defaultChatName = '默认聊天';
try {
const createResponse = await fetch(`/api/chat/${encodeURIComponent(character.name)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_name: defaultChatName,
metadata: {
user_name: 'User',
character_name: character.name
}
})
});
if (createResponse.ok || createResponse.status === 400) {
// 400 表示聊天已存在,也可以直接使用
useChatBoxStore.getState().setChatBoxRoleAndChat(character.name, defaultChatName);
console.log(`[CharacterCard] 已为角色 ${character.name} 设置聊天: ${defaultChatName}`);
} else {
console.error('[CharacterCard] 创建默认聊天失败:', await createResponse.text());
}
} catch (error) {
console.error('[CharacterCard] 创建聊天异常:', error);
}
// 只设置角色不设置聊天currentChat 为 null
// ChatBox 会显示开场白(临时消息),但不会保存到文件
useChatBoxStore.getState().setChatBoxRoleAndChat(character.name, null);
}
}
} catch (error) {
@@ -312,8 +292,7 @@ const CharacterCard = () => {
// 退出编辑模式
const handleCancelEdit = () => {
setIsEditing(false);
setEditForm(null);
cancelEditing(); // ✅ 使用 store 方法
};
// 保存编辑
@@ -323,8 +302,7 @@ const CharacterCard = () => {
try {
const { updateCharacter } = useCharacterStore.getState();
await updateCharacter(selectedCharacter.name, editForm);
setIsEditing(false);
setEditForm(null);
cancelEditing(); // ✅ 使用 store 方法
} catch (err) {
console.error('保存失败:', err);
alert('保存失败: ' + err.message);
@@ -333,10 +311,7 @@ const CharacterCard = () => {
// 处理表单字段变化
const handleFormChange = (field, value) => {
setEditForm(prev => ({
...prev,
[field]: value
}));
updateEditForm(field, value); // ✅ 使用 store 方法
};
return (
@@ -485,6 +460,24 @@ const CharacterCard = () => {
placeholder="标签1, 标签2, 标签3"
/>
</div>
<div className="form-group">
<label>动态表格标签 (SillyTavern 关键字机制)</label>
<input
type="text"
value={editForm.tags?.join(', ') || ''}
onChange={(e) => {
// 支持多种分隔符:逗号、分号、空格
const tagsList = e.target.value
.split(/[,;\s]+/)
.map(tag => tag.trim())
.filter(tag => tag !== '');
handleFormChange('tags', tagsList);
}}
placeholder="力量:80, 敏捷:65, 智力:90, HP:100"
/>
<small className="form-hint">每个标签是一个键值对用逗号/分号/空格分隔双击右侧表格中的标签可编辑</small>
</div>
</div>
</div>
)}
@@ -514,7 +507,7 @@ const CharacterCard = () => {
<div className="pagination-controls">
<button
className="pagination-btn"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
onClick={prevPage} // 使用 store 方法
disabled={currentPage === 1}
>
上一页
@@ -526,7 +519,7 @@ const CharacterCard = () => {
<button
className="pagination-btn"
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
onClick={nextPage} // 使用 store 方法
disabled={currentPage === totalPages}
>
下一页
@@ -535,7 +528,7 @@ const CharacterCard = () => {
<select
className="page-size-selector"
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
onChange={(e) => setPageSize(Number(e.target.value))} // 使用 store 方法
>
<option value={8}>8/</option>
<option value={12}>12/</option>

View File

@@ -1,10 +1,10 @@
/* 主容器 */
/* 主容器 - sidebar-content已有padding这里不需要 */
.preset-panel {
display: flex;
flex-direction: column;
height: 100%;
padding: 12px;
gap: 12px;
padding: 0;
gap: 8px;
background: var(--color-bg-subtle);
overflow-y: auto;
}
@@ -25,34 +25,27 @@
margin-top: -6px;
}
/* 预设头部 */
/* 预设头部 - 紧凑布局 */
.preset-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
gap: 6px;
padding: 6px 8px;
background: var(--color-bg-elevated);
border-radius: 6px;
border-radius: 4px;
box-shadow: var(--shadow-sm);
}
.preset-select-container {
.preset-select-wrapper {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
}
.preset-label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-primary);
white-space: nowrap;
}
.preset-select {
flex: 1;
padding: 6px 10px;
padding: 5px 8px;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: 4px;
@@ -79,15 +72,14 @@
color: #adb5bd;
}
/* 操作按钮 */
.preset-actions {
display: flex;
gap: 6px;
/* 操作下拉菜单 */
.actions-dropdown {
position: relative;
}
.preset-action-btn {
width: 30px;
height: 30px;
.dropdown-toggle {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
@@ -97,17 +89,54 @@
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
padding: 0;
}
.preset-action-btn:hover {
.dropdown-toggle:hover {
background: var(--color-bg-tertiary);
border-color: var(--color-border-focus);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.preset-action-btn:active {
transform: translateY(0);
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: var(--shadow-lg);
z-index: 100;
min-width: 140px;
padding: 4px;
}
.dropdown-item {
width: 100%;
padding: 8px 12px;
background: transparent;
border: none;
border-radius: 4px;
font-size: 12px;
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
display: flex;
align-items: center;
gap: 8px;
}
.dropdown-item:hover:not(:disabled) {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.dropdown-item:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* 对话框 */
@@ -293,10 +322,10 @@
font-weight: 500;
}
/* 参数设置区域 */
/* 参数设置区域 - 紧凑布局 */
.preset-parameters-container {
background: var(--color-bg-elevated);
border-radius: 6px;
border-radius: 4px;
box-shadow: var(--shadow-xs);
overflow: hidden;
}
@@ -305,10 +334,11 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
padding: 8px 10px;
background: var(--color-bg-tertiary);
cursor: pointer;
transition: background 0.2s ease;
gap: 8px; /* 标题和参数之间的间距 */
}
.parameters-header:hover {
@@ -316,9 +346,44 @@
}
.parameters-header span:first-child {
font-size: 13px;
font-size: 12px;
font-weight: 600;
color: #2c3e50;
flex-shrink: 0; /* 不压缩标题 */
}
/* 标题栏中的参数 */
.header-parameter {
display: flex;
align-items: center;
gap: 4px;
flex: 1; /* 占据中间空间 */
justify-content: center; /* 居中显示 */
}
.header-param-label {
font-size: 11px;
font-weight: 500;
color: #495057;
cursor: help;
white-space: nowrap;
}
.header-param-input {
width: 45px;
padding: 3px 4px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 3px;
font-size: 11px;
text-align: center;
transition: all 0.2s ease;
}
.header-param-input:focus {
outline: none;
border-color: #4a6cf7;
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
}
.expand-icon {
@@ -332,162 +397,62 @@
}
.preset-parameters {
padding: 12px;
padding: 8px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); /* 减小最小宽度,让窄屏也能显示多列 */
gap: 6px;
}
.parameter-row {
.parameter-row-compact {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
flex-direction: row; /* 改为横向布局 */
align-items: center; /* 垂直居中 */
justify-content: space-between; /* 两端对齐 */
gap: 4px; /* 参数名和输入框之间的间距 */
min-width: 0; /* 允许收缩 */
padding: 2px 4px; /* 内边距 */
background: var(--color-bg-tertiary); /* 添加背景色,形成容器感 */
border: 1px solid var(--color-border); /* 添加边框 */
border-radius: 4px; /* 圆角 */
}
.parameter-row:last-child {
margin-bottom: 0;
}
.parameter-label {
width: 100px;
font-size: 12px;
.parameter-label-compact {
font-size: 11px;
font-weight: 500;
color: #495057;
cursor: help;
white-space: nowrap; /* 防止参数名换行 */
overflow: hidden; /* 超出隐藏 */
text-overflow: ellipsis; /* 省略号 */
flex: 1; /* 占据剩余空间 */
min-width: 0; /* 允许收缩 */
}
.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: var(--color-bg-tertiary);
.parameter-input-compact {
width: 45px; /* 固定小宽度 */
padding: 3px 4px; /* 减小padding */
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 12px;
border-radius: 3px;
font-size: 11px;
text-align: center;
transition: all 0.2s ease;
flex-shrink: 0; /* 不压缩输入框 */
}
.parameter-number:focus {
.parameter-input-compact: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: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
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 var(--color-border);
}
.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: var(--color-bg-elevated);
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;
}
/* 预设组件区域 */
/* 预设组件区域 - 减少padding */
.preset-components-section {
flex: 1;
display: flex;
flex-direction: column;
background: var(--color-bg-elevated);
border-radius: 6px;
border-radius: 4px;
box-shadow: var(--shadow-xs);
overflow: hidden;
}
@@ -496,25 +461,25 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
padding: 8px 10px;
background: var(--color-bg-tertiary);
border-bottom: 1px solid var(--color-border);
}
.components-header h3 {
margin: 0;
font-size: 13px;
font-size: 12px;
font-weight: 600;
color: #2c3e50;
}
.add-component-btn {
padding: 4px 10px;
padding: 3px 8px;
background: #4a6cf7;
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
@@ -529,7 +494,7 @@
.components-list {
flex: 1;
overflow-y: auto;
padding: 6px;
padding: 4px;
}
.drag-indicator {
@@ -548,11 +513,11 @@
.prompt-component-item {
display: flex;
align-items: center;
padding: 8px 10px;
margin-bottom: 6px;
padding: 6px 8px;
margin-bottom: 4px;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: 4px;
border-radius: 3px;
transition: all 0.2s ease;
cursor: grab;
}
@@ -587,14 +552,14 @@
.component-controls {
display: flex;
align-items: center;
gap: 6px;
gap: 4px;
flex: 1;
}
.drag-handle {
color: #adb5bd;
cursor: grab;
font-size: 12px;
font-size: 11px;
user-select: none;
transition: color 0.2s ease;
}
@@ -604,8 +569,8 @@
}
.toggle-btn {
width: 20px;
height: 20px;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
@@ -613,7 +578,7 @@
border: 1px solid var(--color-border);
border-radius: 3px;
cursor: pointer;
font-size: 10px;
font-size: 9px;
transition: all 0.2s ease;
}
@@ -633,7 +598,7 @@
}
.component-name {
font-size: 12px;
font-size: 11px;
font-weight: 500;
color: #2c3e50;
flex: 1;
@@ -688,6 +653,69 @@
border-color: #ff6b6b;
}
/* 分页控件 */
.pagination-controls {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
padding: 10px 6px;
margin-top: 8px;
border-top: 1px solid var(--color-border-light);
}
.pagination-btn {
padding: 5px 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;
}
.pagination-btn:hover:not(:disabled) {
background: var(--color-bg-elevated);
border-color: var(--color-border-focus);
color: var(--color-text-primary);
}
.pagination-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pagination-info {
font-size: 12px;
color: var(--color-text-secondary);
font-weight: 500;
min-width: 80px;
text-align: center;
}
.page-size-selector {
padding: 4px 8px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.page-size-selector:hover {
border-color: var(--color-border-focus);
}
.page-size-selector:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-light);
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;

View File

@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react';
import usePresetStore from '../../../../Store/SideBarLeft/PresetSlice';
import useSideBarLeftStore from '../../../../Store/SideBarLeft/SideBarLeftSlice';
import './Presets.css';
const PresetPanel = () => {
@@ -10,6 +9,8 @@ const PresetPanel = () => {
presets,
isLoadingPresets,
promptComponents,
currentPage,
pageSize,
setSelectedPreset,
updateParameter,
saveCurrentAsPreset,
@@ -22,13 +23,18 @@ const PresetPanel = () => {
updateComponent,
addComponent,
removeComponent,
moveComponent
moveComponent,
setCurrentPage,
setPageSize,
getCurrentPagePresets,
getTotalPages
} = usePresetStore();
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
const [showComponentEditDialog, setShowComponentEditDialog] = useState(false);
const [showActionsMenu, setShowActionsMenu] = useState(false);
const [newPresetName, setNewPresetName] = useState('');
const [editPresetId, setEditPresetId] = useState('');
const [editPresetName, setEditPresetName] = useState('');
@@ -44,27 +50,50 @@ const PresetPanel = () => {
const [draggedItem, setDraggedItem] = useState(null);
const [dragOverItem, setDragOverItem] = useState(null);
// 获取当前激活的分页
const { activeTab } = useSideBarLeftStore();
// 记录上一次的分页状态
const prevActiveTabRef = React.useRef(activeTab);
// 点击外部关闭下拉菜单
useEffect(() => {
const handleClickOutside = (event) => {
if (showActionsMenu && !event.target.closest('.actions-dropdown')) {
setShowActionsMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showActionsMenu]);
// 参数描述映射
// 计算分页数据
const currentPagePresets = getCurrentPagePresets();
const totalPages = getTotalPages();
// 参数描述映射(全名用于悬浮提示)
const parameterDescriptions = {
temperature: "生成温度,控制随机性(0-2)",
frequency_penalty: "频率惩罚降低重复token概率",
presence_penalty: "存在惩罚,鼓励谈论新话题",
top_p: "核采样,控制词汇选择范围",
freq_penalty: "频率惩罚降低重复token概率(-2到2)",
pres_penalty: "存在惩罚,鼓励谈论新话题(-2到2)",
top_p: "核采样,控制词汇选择范围(0-1)",
top_k: "随机采样范围从概率最高的K个词中选择",
max_context: "上下文窗口大小(Token上限)",
max_ctx: "上下文窗口大小(Token上限)",
max_tokens: "单次回复的最大长度",
max_context_unlocked: "是否允许超出限制的上下文",
stream_openai: "是否使用流式输出",
seed: "随机种子(-1为随机)",
n: "生成回复的数量"
};
// 参数简写映射
const parameterLabels = {
temperature: "温度",
freq_penalty: "频惩",
pres_penalty: "存惩",
top_p: "Top-P",
top_k: "Top-K",
max_ctx: "上下文",
max_tokens: "MaxTok",
seed: "种子",
n: "数量"
};
// 显示工具提示
const showTooltip = (event, content) => {
setTooltip({
@@ -83,26 +112,25 @@ const PresetPanel = () => {
// 处理参数更新
const handleParameterChange = (name, value) => {
let convertedValue = value;
if (name === 'temperature' || name === 'frequency_penalty' || name === 'presence_penalty' || name === 'top_p') {
if (name === 'temperature' || name === 'freq_penalty' || name === 'pres_penalty' || name === 'top_p') {
convertedValue = parseFloat(value);
} else if (name === 'top_k' || name === 'max_context' || name === 'max_tokens' || name === 'seed' || name === 'n') {
} else if (name === 'top_k' || name === 'max_ctx' || 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 });
// 将简写参数名转换回原始参数名
const paramNameMap = {
'freq_penalty': 'frequency_penalty',
'pres_penalty': 'presence_penalty',
'max_ctx': 'max_context'
};
const actualParamName = paramNameMap[name] || name;
updateParameter({ name: actualParamName, value: convertedValue });
};
// 加载预设列表 - 只在切换到预设分页时刷新
useEffect(() => {
// 检测是否从其他分页切换到预设分页
if (activeTab === 'presets' && prevActiveTabRef.current !== 'presets') {
fetchPresets();
}
// 更新上一次的分页状态
prevActiveTabRef.current = activeTab;
}, [activeTab, fetchPresets]);
// 加载预设列表 - 由 SideBarLeft 标签切换时触发
// 注意fetchPresets 已在 SideBarLeft.jsx 的 handleTabClick 中调用
// 保存当前设置为预设
const handleSavePreset = async () => {
@@ -111,7 +139,8 @@ const PresetPanel = () => {
await saveCurrentAsPreset({ name: newPresetName });
setNewPresetName('');
setShowSaveDialog(false);
// 重新加载预设列表
// 重新加载预设列表并重置到第一页
setCurrentPage(1);
fetchPresets();
} catch (error) {
console.error('保存预设失败:', error);
@@ -141,21 +170,73 @@ const PresetPanel = () => {
const handleImportPreset = async () => {
try {
const importedPreset = JSON.parse(importPresetData);
if (importedPreset.name && importedPreset.parameters) {
await saveCurrentAsPreset({ name: importedPreset.name });
// 支持内部结构和SillyTavern结构
let presetName = '';
let importedParameters = {};
let importedComponents = [];
// 检测是否为内部结构(有 entries 字段)
if (importedPreset.entries && Array.isArray(importedPreset.entries)) {
// 内部结构
presetName = importedPreset.name;
importedParameters = {
temperature: importedPreset.temperature,
top_p: importedPreset.topP,
top_k: importedPreset.topK,
frequency_penalty: importedPreset.frequencyPenalty,
presence_penalty: importedPreset.presencePenalty,
max_tokens: importedPreset.maxLength
};
// 转换 entries 为 promptComponents 格式
importedComponents = importedPreset.entries.map(entry => ({
identifier: entry.identifier,
name: entry.name,
content: entry.content || '',
enabled: entry.enabled !== false,
role: entry.role === 'system' ? 0 : entry.role === 'user' ? 1 : 2,
system_prompt: entry.role === 'system',
marker: entry.isSystemNode || false
})).sort((a, b) => {
const orderA = importedPreset.entries.find(e => e.identifier === a.identifier)?.order || 0;
const orderB = importedPreset.entries.find(e => e.identifier === b.identifier)?.order || 0;
return orderA - orderB;
});
}
// 兼容SillyTavern结构有 parameters 和 promptComponents 字段)
else if (importedPreset.name && (importedPreset.parameters || importedPreset.temperature)) {
presetName = importedPreset.name;
importedParameters = importedPreset.parameters || {
temperature: importedPreset.temperature,
frequency_penalty: importedPreset.frequency_penalty,
presence_penalty: importedPreset.presence_penalty,
top_p: importedPreset.top_p,
top_k: importedPreset.top_k,
max_tokens: importedPreset.max_tokens || importedPreset.openai_max_tokens
};
importedComponents = importedPreset.promptComponents || [];
}
if (presetName) {
await saveCurrentAsPreset({ name: presetName });
// 更新参数
Object.keys(importedPreset.parameters).forEach(key => {
updateParameter({ name: key, value: importedPreset.parameters[key] });
Object.keys(importedParameters).forEach(key => {
if (importedParameters[key] !== undefined) {
updateParameter({ name: key, value: importedParameters[key] });
}
});
// 更新组件列表
if (importedPreset.promptComponents) {
setPromptComponents(importedPreset.promptComponents);
if (importedComponents.length > 0) {
setPromptComponents(importedComponents);
}
setImportPresetData('');
setShowImportDialog(false);
// 重新加载预设列表
// 重新加载预设列表并重置到第一页
setCurrentPage(1);
fetchPresets();
}
} catch (error) {
@@ -169,10 +250,31 @@ const PresetPanel = () => {
if (selectedPreset) {
const preset = presets.find(p => p.id === selectedPreset);
if (preset) {
// 使用内部专有结构导出
const exportData = {
...preset,
parameters,
promptComponents
// GenerationPreset 部分
id: selectedPreset,
name: preset.name,
temperature: parameters.temperature,
topP: parameters.top_p,
topK: parameters.top_k,
frequencyPenalty: parameters.frequency_penalty,
presencePenalty: parameters.presence_penalty,
maxLength: parameters.max_tokens,
isDefault: false,
// PromptPresetView 部分
characterId: 'global',
entries: promptComponents.map((component, index) => ({
identifier: component.identifier,
name: component.name,
enabled: component.enabled !== false,
content: component.content || '',
order: index,
role: component.role === 0 ? 'system' : component.role === 1 ? 'user' : 'ai',
tokenCount: component.content ? component.content.length : 0,
isSystemNode: component.marker || false
}))
};
const dataStr = JSON.stringify(exportData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
@@ -208,11 +310,24 @@ const PresetPanel = () => {
setShowComponentEditDialog(true);
};
// 查看组件内容
// 查看/编辑组件内容 - 实时获取引用内容
const handleViewComponent = (index) => {
const component = promptComponents[index];
// 如果是固定组件,实时从其他前端部件获取内容
if (component.marker) {
// TODO: 根据component.identifier从对应的Store中实时获取内容
// 例如chatHistory -> 从ChatBoxSlice获取当前聊天记录
// charDescription -> 从CharacterSlice获取角色描述
// 这里先显示提示信息
setEditComponentContent(`[实时内容将从 ${component.name} 动态加载]\n\n当前暂不支持预览该组件会在发送消息时自动从系统获取最新内容。`);
} else {
// 可编辑组件,显示保存的内容
setEditComponentContent(component.content || '');
}
setEditingComponentIndex(index);
setEditComponentContent(promptComponents[index].content);
setIsEditing(false);
setIsEditing(!component.marker); // 固定组件不可编辑
setShowComponentEditDialog(true);
};
@@ -303,14 +418,7 @@ const PresetPanel = () => {
{/* 顶部:预设选择与操作 */}
<div className="preset-header">
{/* 预设选择下拉框 */}
<div className="preset-select-container">
<label
className="preset-label"
onMouseEnter={(e) => showTooltip(e, "选择预设配置")}
onMouseLeave={hideTooltip}
>
预设:
</label>
<div className="preset-select-wrapper">
<select
className="preset-select"
value={selectedPreset}
@@ -318,55 +426,76 @@ const PresetPanel = () => {
disabled={isLoadingPresets}
>
<option value="">{isLoadingPresets ? "加载中..." : "选择预设..."}</option>
{presets.map(preset => (
{currentPagePresets.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 className="actions-dropdown">
<button
className="dropdown-toggle"
onClick={() => setShowActionsMenu(!showActionsMenu)}
onMouseEnter={(e) => showTooltip(e, "更多操作")}
onMouseLeave={hideTooltip}
>
</button>
{showActionsMenu && (
<div className="dropdown-menu">
<button
className="dropdown-item"
title="将当前的参数设置和组件配置保存为新的预设"
onClick={() => {
setShowSaveDialog(true);
setShowActionsMenu(false);
}}
>
💾 保存为新预设
</button>
<button
className="dropdown-item"
disabled={!selectedPreset}
title="修改当前选中预设的名称"
onClick={() => {
if (selectedPreset) {
const preset = presets.find(p => p.id === selectedPreset);
if (preset) {
setEditPresetId(selectedPreset);
setEditPresetName(preset.name);
setShowEditDialog(true);
}
}
setShowActionsMenu(false);
}}
>
编辑名称
</button>
<button
className="dropdown-item"
title="从 JSON 文件导入预设配置"
onClick={() => {
setShowImportDialog(true);
setShowActionsMenu(false);
}}
>
📥 导入预设
</button>
<button
className="dropdown-item"
disabled={!selectedPreset}
title="将当前预设导出为 JSON 文件"
onClick={() => {
handleExportPreset();
setShowActionsMenu(false);
}}
>
📤 导出预设
</button>
</div>
)}
</div>
</div>
</div>
@@ -456,29 +585,38 @@ const PresetPanel = () => {
onClick={toggleParametersExpanded}
>
<span>参数设置</span>
{/* 生成数量 - 放在标题栏旁边 */}
<div className="header-parameter">
<label
className="header-param-label"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.n)}
onMouseLeave={hideTooltip}
>
{parameterLabels.n}
</label>
<input
type="number"
min="1"
value={parameters.n}
onChange={(e) => handleParameterChange('n', e.target.value)}
className="header-param-input"
onClick={(e) => e.stopPropagation()} // 防止点击输入框时折叠
/>
</div>
<span className={`expand-icon ${isParametersExpanded ? 'expanded' : ''}`}></span>
</div>
{isParametersExpanded && (
<div className="preset-parameters">
{/* 温度滑块 */}
<div className="parameter-row">
{/* 温度输入框 */}
<div className="parameter-row-compact">
<label
className="parameter-label"
className="parameter-label-compact"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.temperature)}
onMouseLeave={hideTooltip}
>
Temperature
{parameterLabels.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"
@@ -486,86 +624,59 @@ const PresetPanel = () => {
step="0.1"
value={parameters.temperature}
onChange={(e) => handleParameterChange('temperature', e.target.value)}
className="parameter-number"
className="parameter-input-compact"
/>
</div>
{/* 频率惩罚滑块 */}
<div className="parameter-row">
{/* 频率惩罚输入框 */}
<div className="parameter-row-compact">
<label
className="parameter-label"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.frequency_penalty)}
className="parameter-label-compact"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.freq_penalty)}
onMouseLeave={hideTooltip}
>
Frequency Penalty
{parameterLabels.freq_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"
onChange={(e) => handleParameterChange('freq_penalty', e.target.value)}
className="parameter-input-compact"
/>
</div>
{/* 存在惩罚滑块 */}
<div className="parameter-row">
{/* 存在惩罚输入框 */}
<div className="parameter-row-compact">
<label
className="parameter-label"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.presence_penalty)}
className="parameter-label-compact"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.pres_penalty)}
onMouseLeave={hideTooltip}
>
Presence Penalty
{parameterLabels.pres_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"
onChange={(e) => handleParameterChange('pres_penalty', e.target.value)}
className="parameter-input-compact"
/>
</div>
{/* Top P 滑块 */}
<div className="parameter-row">
{/* Top P 输入框 */}
<div className="parameter-row-compact">
<label
className="parameter-label"
className="parameter-label-compact"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.top_p)}
onMouseLeave={hideTooltip}
>
Top P
{parameterLabels.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"
@@ -573,55 +684,55 @@ const PresetPanel = () => {
step="0.05"
value={parameters.top_p}
onChange={(e) => handleParameterChange('top_p', e.target.value)}
className="parameter-number"
className="parameter-input-compact"
/>
</div>
{/* Top K 输入框 */}
<div className="parameter-row">
<div className="parameter-row-compact">
<label
className="parameter-label"
className="parameter-label-compact"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.top_k)}
onMouseLeave={hideTooltip}
>
Top K
{parameterLabels.top_k}
</label>
<input
type="number"
min="0"
value={parameters.top_k}
onChange={(e) => handleParameterChange('top_k', e.target.value)}
className="parameter-input"
className="parameter-input-compact"
/>
</div>
{/* 最大上下文输入框 */}
<div className="parameter-row">
<div className="parameter-row-compact">
<label
className="parameter-label"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.max_context)}
className="parameter-label-compact"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.max_ctx)}
onMouseLeave={hideTooltip}
>
Max Context
{parameterLabels.max_ctx}
</label>
<input
type="number"
min="1"
max="10000000"
value={parameters.max_context}
onChange={(e) => handleParameterChange('max_context', e.target.value)}
className="parameter-input"
onChange={(e) => handleParameterChange('max_ctx', e.target.value)}
className="parameter-input-compact"
/>
</div>
{/* 最大Token输入框 */}
<div className="parameter-row">
<div className="parameter-row-compact">
<label
className="parameter-label"
className="parameter-label-compact"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.max_tokens)}
onMouseLeave={hideTooltip}
>
Max Tokens
{parameterLabels.max_tokens}
</label>
<input
type="number"
@@ -629,79 +740,26 @@ const PresetPanel = () => {
max="100000"
value={parameters.max_tokens}
onChange={(e) => handleParameterChange('max_tokens', e.target.value)}
className="parameter-input"
className="parameter-input-compact"
/>
</div>
{/* 随机种子输入框 */}
<div className="parameter-row">
<div className="parameter-row-compact">
<label
className="parameter-label"
className="parameter-label-compact"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.seed)}
onMouseLeave={hideTooltip}
>
Seed
{parameterLabels.seed}
</label>
<input
type="number"
value={parameters.seed}
onChange={(e) => handleParameterChange('seed', e.target.value)}
className="parameter-input"
className="parameter-input-compact"
/>
</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>
@@ -793,6 +851,51 @@ const PresetPanel = () => {
/>
</div>
{/* 分页控件 */}
{totalPages > 1 && (
<div className="pagination-controls">
<button
className="pagination-btn"
onClick={() => {
setCurrentPage(Math.max(1, currentPage - 1));
fetchPresets();
}}
disabled={currentPage === 1}
>
上一页
</button>
<span className="pagination-info">
{currentPage} / {totalPages}
</span>
<button
className="pagination-btn"
onClick={() => {
setCurrentPage(Math.min(totalPages, currentPage + 1));
fetchPresets();
}}
disabled={currentPage === totalPages}
>
下一页
</button>
<select
className="page-size-selector"
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value));
fetchPresets();
}}
>
<option value={8}>8/</option>
<option value={12}>12/</option>
<option value={20}>20/</option>
<option value={50}>50/</option>
</select>
</div>
)}
</div>
</div>
);

View File

@@ -4,7 +4,7 @@
display: flex;
flex-direction: column;
height: 100%;
padding: 8px;
padding: 0; /* sidebar-content已有padding这里不需要 */
gap: 8px;
background: var(--color-bg-primary);
overflow-y: auto;

View File

@@ -3,6 +3,7 @@
display: flex;
flex-direction: column;
height: 100%;
padding: 0; /* tab-content已有padding这里不需要 */
background-color: var(--color-bg-secondary);
overflow: hidden;
}
@@ -34,7 +35,7 @@
flex: 1;
display: flex;
flex-direction: column;
padding: 12px;
padding: 0; /* 移除内部padding依赖外层tab-content */
gap: 12px;
overflow-y: auto;
}

View File

@@ -33,15 +33,27 @@ const Dice = () => {
};
}
// 解析多个骰子组合,如 "1d4+1d3"
// 解析多个骰子组合,如 "1d4+1d3" 或 "1d10-1d6"
const parts = expr.split(/(?=[+-])/);
const components = [];
let total = 0;
let detail = [];
for (const part of parts) {
// 提取符号(如果有)
let sign = 1;
let cleanPart = part;
if (part.startsWith('+')) {
sign = 1;
cleanPart = part.substring(1);
} else if (part.startsWith('-')) {
sign = -1;
cleanPart = part.substring(1);
}
// 匹配 NdM 格式
const diceMatch = part.match(/^(\d*)d(\d+)$/i);
const diceMatch = cleanPart.match(/^(\d*)d(\d+)$/i);
if (diceMatch) {
const count = diceMatch[1] ? parseInt(diceMatch[1]) : 1;
const sides = parseInt(diceMatch[2]);
@@ -53,25 +65,26 @@ const Dice = () => {
}
const sum = rolls.reduce((a, b) => a + b, 0);
total += sum;
total += sign * sum; // 应用符号
components.push({
type: 'dice',
count,
sides,
rolls,
sum
sum: sign * sum
});
detail.push(`${count}d${sides}=[${rolls.join(',')}]`);
const signStr = sign > 0 && detail.length > 0 ? '+' : '';
detail.push(`${signStr}${count}d${sides}=[${rolls.join(',')}]`);
} else {
// 匹配固定数值(带符号)
const fixedMatch = part.match(/^([+-])(\d+)$/);
// 匹配固定数值
const fixedMatch = cleanPart.match(/^(\d+)$/);
if (fixedMatch) {
const sign = fixedMatch[1] === '+' ? 1 : -1;
const value = parseInt(fixedMatch[2]);
const value = parseInt(fixedMatch[1]);
total += sign * value;
detail.push(`${sign > 0 ? '+' : '-'}${value}`);
const signStr = sign > 0 && detail.length > 0 ? '+' : '';
detail.push(`${signStr}${value}`);
}
}
}

View File

@@ -4,7 +4,7 @@
display: flex;
flex-direction: column;
height: 100%;
padding: 8px;
padding: 0; /* tab-content已有padding这里不需要 */
gap: 8px;
background: var(--color-bg-primary);
overflow-y: auto;

View File

@@ -0,0 +1,275 @@
/* 表格面板 */
.table-panel {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--color-bg-primary);
}
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border-light);
background-color: var(--color-bg-secondary);
}
.title-text {
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text-primary);
}
.table-info {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.refresh-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 4px;
background-color: transparent;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s ease;
padding: 0;
}
.refresh-btn:hover:not(:disabled) {
background-color: rgba(102, 126, 234, 0.15);
transform: rotate(180deg);
}
.refresh-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* 空状态 */
.empty-table {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
color: var(--color-text-muted);
padding: 16px;
}
.empty-table p:first-child {
font-size: 2rem;
margin: 0;
}
.empty-table p:nth-child(2) {
font-size: 0.9rem;
margin: 0;
font-weight: 500;
}
/* 表格容器 - 支持滚动 */
.table-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
/* 标签容器 - 横向排列,自动换行 */
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-content: flex-start;
}
.no-tags-hint {
width: 100%;
text-align: center;
padding: 12px;
color: var(--color-text-muted);
font-style: italic;
font-size: 0.85rem;
}
/* 单个标签项 */
.tag-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 16px;
font-size: 0.82rem;
color: var(--color-text-primary);
transition: all 0.15s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
max-width: 100%;
}
.tag-item:hover {
border-color: var(--color-accent);
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.15);
transform: translateY(-1px);
}
.tag-item.editing {
border-color: var(--color-accent);
background-color: rgba(102, 126, 234, 0.1);
padding: 3px 6px;
}
/* 标签文本 */
.tag-text {
cursor: pointer;
user-select: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.tag-text:hover {
color: var(--color-accent);
}
/* 标签编辑容器 */
.tag-edit-container {
display: flex;
align-items: center;
width: 100%;
}
.tag-edit-input {
flex: 1;
padding: 2px 6px;
border: 1px solid var(--color-accent);
border-radius: 12px;
font-size: 0.82rem;
outline: none;
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
min-width: 100px;
}
.tag-edit-input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
/* 标签操作按钮组 */
.tag-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s ease;
}
.tag-item:hover .tag-actions {
opacity: 1;
}
.tag-action-btn {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 0.7rem;
transition: all 0.15s ease;
background-color: transparent;
color: var(--color-text-muted);
}
.tag-action-btn:hover:not(:disabled) {
background-color: rgba(102, 126, 234, 0.15);
color: var(--color-accent);
transform: scale(1.1);
}
.tag-action-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.tag-action-btn.delete:hover:not(:disabled) {
background-color: rgba(244, 67, 54, 0.15);
color: #f44336;
}
/* 添加标签输入框 */
.add-tag-container {
display: flex;
gap: 6px;
padding: 8px;
background-color: var(--color-bg-secondary);
border-radius: 6px;
border: 1px solid var(--color-border-light);
}
.add-tag-input {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 0.82rem;
outline: none;
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
transition: border-color 0.2s ease;
}
.add-tag-input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
}
.add-tag-input::placeholder {
color: var(--color-text-muted);
opacity: 0.6;
}
.add-tag-btn {
padding: 6px 12px;
background-color: var(--color-accent);
color: white;
border: none;
border-radius: 4px;
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.add-tag-btn:hover:not(:disabled) {
background-color: var(--color-accent-dark);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3);
}
.add-tag-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}

View File

@@ -1,10 +1,198 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { useTableStore } from '../../../../Store/SideBarRight';
import './Table.css';
const Table = () => {
// ✅ 从 Zustand store 获取状态和方法
const {
tags,
isLoading,
currentRole,
currentChat,
loadTags,
updateTags,
addTag,
deleteTag,
moveLeft,
moveRight,
editTag,
refresh
} = useTableStore();
// 本地 UI 状态(不需要持久化)
const [editingIndex, setEditingIndex] = useState(null);
const [editValue, setEditValue] = useState('');
const [newTagInput, setNewTagInput] = useState('');
// 监听角色/聊天变化,自动加载数据
useEffect(() => {
if (currentRole) {
loadTags(currentRole, currentChat);
}
}, [currentRole, currentChat, loadTags]);
// 开始编辑标签
const handleStartEdit = (index, value) => {
setEditingIndex(index);
setEditValue(value);
};
// 保存编辑
const handleSaveEdit = () => {
if (editingIndex === null) return;
editTag(editingIndex, editValue);
setEditingIndex(null);
setEditValue('');
};
// 取消编辑
const handleCancelEdit = () => {
setEditingIndex(null);
setEditValue('');
};
// 添加新标签(支持批量输入)
const handleAddTag = () => {
const input = newTagInput.trim();
if (!input) return;
// 智能分割:支持空格、逗号、分号作为分隔符
const newTagsList = input
.split(/[\s,;]+/)
.filter(tag => tag.trim() !== '')
.map(tag => tag.trim());
addTag(newTagsList);
setNewTagInput('');
};
// 如果没有角色,显示提示
if (!currentRole) {
return (
<div className="table-panel">
<div className="tab-header">
<span className="title-text">动态表格</span>
</div>
<div className="empty-table">
<p>📊</p>
<p>请选择一个角色</p>
</div>
</div>
);
}
return (
<div className="table-panel">
<h2>动态表格</h2>
<p>这是动态表格面板的占位页面</p>
<div className="tab-header">
<span className="title-text">动态表格</span>
<div className="header-actions">
<span className="table-info">
{tags.length} 个标签
</span>
<button
className="refresh-btn"
onClick={refresh}
disabled={isLoading}
title="刷新数据"
>
{isLoading ? '⏳' : '🔄'}
</button>
</div>
</div>
<div className="table-container">
{/* 标签列表 */}
<div className="tags-container">
{tags.length === 0 ? (
<div className="no-tags-hint">
暂无标签请在下方添加
</div>
) : (
tags.map((tag, index) => (
<div
key={index}
className={`tag-item ${editingIndex === index ? 'editing' : ''}`}
>
{editingIndex === index ? (
// 编辑模式
<div className="tag-edit-container">
<input
type="text"
className="tag-edit-input"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveEdit();
if (e.key === 'Escape') handleCancelEdit();
}}
onBlur={handleSaveEdit}
autoFocus
/>
</div>
) : (
// 显示模式
<>
<span
className="tag-text"
onDoubleClick={() => handleStartEdit(index, tag)}
title="双击编辑"
>
{tag}
</span>
<div className="tag-actions">
<button
className="tag-action-btn move-left"
onClick={() => moveLeft(index)}
disabled={index === 0}
title="左移"
>
</button>
<button
className="tag-action-btn move-right"
onClick={() => moveRight(index)}
disabled={index === tags.length - 1}
title="右移"
>
</button>
<button
className="tag-action-btn delete"
onClick={() => deleteTag(index)}
title="删除"
>
</button>
</div>
</>
)}
</div>
))
)}
</div>
{/* 添加新标签输入框 */}
<div className="add-tag-container">
<input
type="text"
className="add-tag-input"
placeholder="输入标签(支持空格/逗号分隔多个)..."
value={newTagInput}
onChange={(e) => setNewTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleAddTag();
}}
/>
<button
className="add-tag-btn"
onClick={handleAddTag}
disabled={!newTagInput.trim()}
>
+ 添加
</button>
</div>
</div>
</div>
);
};

View File

@@ -1,14 +1,26 @@
// frontend-react/src/components/TopBar/TopBar.jsx
import React, {useState, useRef, useEffect} from 'react';
import './TopBar.css';
import ThemeToggle from './items/ThemeToggle';
import React, { useState, useRef, useEffect } from 'react';
import useAppLayoutStore from '../../Store/AppLayoutSlice'; // ✅ 新增
import useUserStore from '../../Store/UserSlice'; // ✅ 新增 - 用户角色
import useWorldBookStore from '../../Store/SideBarLeft/WorldBookSlice';
import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice';
import ThemeToggle from './items/ThemeToggle';
import './TopBar.css';
const Toolbar = ({ sidebarMode, colorTheme, onSidebarModeChange, onColorThemeChange }) => {
const Toolbar = () => {
const [activePanel, setActivePanel] = useState(null);
const panelRef = useRef(null);
const [currentUserRole, setCurrentUserRole] = useState({ name: '', description: '' });
// ✅ 直接从 AppLayoutStore 获取状态和方法
const {
sidebarMode,
colorTheme,
setSidebarMode,
setColorTheme
} = useAppLayoutStore();
// ✅ 从 UserStore 获取用户角色
const { currentUserRole } = useUserStore();
// 从 Store 获取全局世界书
const { globalWorldBooks } = useWorldBookStore();
@@ -17,41 +29,6 @@ const Toolbar = ({ sidebarMode, colorTheme, onSidebarModeChange, onColorThemeCha
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]);
// 点击外部关闭面板 - 使用 useCallback 优化
const handleClickOutside = React.useCallback((event) => {
@@ -67,17 +44,10 @@ const Toolbar = ({ sidebarMode, colorTheme, onSidebarModeChange, onColorThemeCha
};
}, [handleClickOutside]);
// 加载当前用户角色
useEffect(() => {
const savedRole = localStorage.getItem('currentUserRole');
if (savedRole) {
try {
setCurrentUserRole(JSON.parse(savedRole));
} catch (e) {
console.error('解析用户角色失败:', e);
}
}
}, []);
// ✅ 简化:直接调用 store 方法
const handleSidebarModeChange = (mode) => {
setSidebarMode(mode);
};
// 处理面板切换
const handlePanelToggle = (panelName) => {
@@ -218,7 +188,7 @@ const Toolbar = ({ sidebarMode, colorTheme, onSidebarModeChange, onColorThemeCha
name="sidebarMode"
value="fixed"
checked={sidebarMode === 'fixed'}
onChange={(e) => onSidebarModeChange(e.target.value)}
onChange={(e) => handleSidebarModeChange(e.target.value)} // 使用 store 方法
/>
<span>固定</span>
</label>
@@ -228,7 +198,7 @@ const Toolbar = ({ sidebarMode, colorTheme, onSidebarModeChange, onColorThemeCha
name="sidebarMode"
value="smart"
checked={sidebarMode === 'smart'}
onChange={(e) => onSidebarModeChange(e.target.value)}
onChange={(e) => handleSidebarModeChange(e.target.value)} // 使用 store 方法
/>
<span>智能</span>
</label>
@@ -238,7 +208,7 @@ const Toolbar = ({ sidebarMode, colorTheme, onSidebarModeChange, onColorThemeCha
name="sidebarMode"
value="expanded"
checked={sidebarMode === 'expanded'}
onChange={(e) => onSidebarModeChange(e.target.value)}
onChange={(e) => handleSidebarModeChange(e.target.value)} // 使用 store 方法
/>
<span>扩展</span>
</label>

View File

@@ -39,15 +39,15 @@
/* CSS Variables for layout ratios */
:root {
/* Fixed mode: 1 : 3.5 : 1.25 (default) */
--left-fixed: 1fr;
--middle-fixed: 3.5fr;
--right-fixed: 1.25fr;
/* Fixed mode (default): 2 : 6 : 2 */
--left-fixed: 2fr;
--middle-fixed: 6fr;
--right-fixed: 2fr;
/* Expanded mode: 1.8 : 2 : 1 */
--left-expanded: 1.8fr;
--middle-expanded: 2fr;
--right-expanded: 1fr;
/* Expanded mode: 4 : 4.5 : 1.5 */
--left-expanded: 4fr;
--middle-expanded: 4.5fr;
--right-expanded: 1.5fr;
}
.main-container {
@@ -74,13 +74,13 @@
transition: flex-basis 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Fixed mode (default): 1 : 3.5 : 1.25 */
/* Fixed mode (default): 2 : 6 : 2 */
.app.chat-mode .main-container,
.sidebar-mode-fixed ~ .chat-area-wrapper {
grid-template-columns: var(--left-fixed) var(--middle-fixed) var(--right-fixed);
}
/* Expanded mode: 1.8 : 2 : 1 */
/* Expanded mode: 4 : 4.5 : 1.5 */
.app.edit-mode .main-container,
.sidebar-mode-expanded,
.sidebar-mode-smart.sidebar-expanded {
@@ -181,13 +181,13 @@
@media (min-width: 1920px) {
.app.chat-mode .main-container,
.sidebar-mode-fixed ~ .chat-area-wrapper {
grid-template-columns: minmax(280px, var(--left-fixed)) var(--middle-fixed) minmax(340px, var(--right-fixed));
grid-template-columns: minmax(320px, var(--left-fixed)) var(--middle-fixed) minmax(320px, var(--right-fixed));
}
.app.edit-mode .main-container,
.sidebar-mode-expanded,
.sidebar-mode-smart.sidebar-expanded {
grid-template-columns: minmax(350px, var(--left-expanded)) var(--middle-expanded) minmax(250px, var(--right-expanded)) !important;
grid-template-columns: minmax(450px, var(--left-expanded)) var(--middle-expanded) minmax(200px, var(--right-expanded)) !important;
}
}

View File

@@ -134,8 +134,17 @@ export interface ChatHeader {
/** AI 角色名称 */
characterName: string;
/** 表格内容(对应角色卡的 outputSchema 字段的值) */
tableData?: Record<string, unknown>;
/** 动态表格表头 (从角色卡继承) */
tableHeaders?: string[];
/** 表格默认值 (键为表头名,值为默认值) */
tableDefaults?: Record<string, unknown>;
/** 表格数据行 (每行是一个字典) */
tableData?: Record<string, unknown>[];
/** 表格数据行时间戳 (键为行索引,值为更新时间戳) */
tableTimestamps?: Record<number, number>;
/** 创建时间戳 */
createdAt: number;