基本完成,舒适性修补
This commit is contained in:
@@ -1,6 +1,110 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware'; // ✅ 添加持久化支持
|
||||
|
||||
// ✅ 默认固有组件列表(marker=true)
|
||||
const DEFAULT_PROMPT_COMPONENTS = [
|
||||
{
|
||||
identifier: "worldInfoBefore",
|
||||
name: "World Info (before)",
|
||||
description: "世界书条目,插入在角色设定之前",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0,
|
||||
dataSource: "激活的世界书条目(前置)"
|
||||
},
|
||||
{
|
||||
identifier: "worldInfoAfter",
|
||||
name: "World Info (after)",
|
||||
description: "世界书条目,插入在角色设定之后",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0,
|
||||
dataSource: "激活的世界书条目(后置)"
|
||||
},
|
||||
{
|
||||
identifier: "charDescription",
|
||||
name: "Char Description",
|
||||
description: "角色描述,从角色卡提取",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0,
|
||||
dataSource: "当前角色卡的 description 字段"
|
||||
},
|
||||
{
|
||||
identifier: "charPersonality",
|
||||
name: "Char Personality",
|
||||
description: "角色性格,从角色卡提取",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0,
|
||||
dataSource: "当前角色卡的 personality 字段"
|
||||
},
|
||||
{
|
||||
identifier: "scenario",
|
||||
name: "Scenario",
|
||||
description: "场景设定,从角色卡提取",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0,
|
||||
dataSource: "当前角色卡的 scenario 字段"
|
||||
},
|
||||
{
|
||||
identifier: "personaDescription",
|
||||
name: "Persona Description",
|
||||
description: "用户角色描述,从用户设定提取",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0,
|
||||
dataSource: "当前用户设定的 persona 字段"
|
||||
},
|
||||
{
|
||||
identifier: "dialogueExamples",
|
||||
name: "Dialogue Examples",
|
||||
description: "对话示例,从角色卡提取",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0,
|
||||
dataSource: "当前角色卡的 mesExample 字段"
|
||||
},
|
||||
{
|
||||
identifier: "chatHistory",
|
||||
name: "Chat History",
|
||||
description: "聊天历史,从当前聊天记录提取",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0,
|
||||
dataSource: "当前聊天的消息历史"
|
||||
},
|
||||
{
|
||||
identifier: "authorNotes",
|
||||
name: "Author's Notes",
|
||||
description: "作者注释,从角色卡或聊天元数据提取",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: false,
|
||||
role: 0,
|
||||
dataSource: "角色卡的 authorNote 或聊天的 authorNotes 字段"
|
||||
},
|
||||
{
|
||||
identifier: "postHistoryInstructions",
|
||||
name: "Post-History Instructions",
|
||||
description: "历史记录后指令,从聊天元数据提取",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: false,
|
||||
role: 0,
|
||||
dataSource: "当前聊天的 postHistoryInstructions 字段"
|
||||
}
|
||||
];
|
||||
|
||||
const usePresetStore = create(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -209,6 +313,25 @@ const usePresetStore = create(
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 检查并补全缺失的固有组件(marker=true)
|
||||
const BUILTIN_MARKERS = [
|
||||
'worldInfoBefore', 'worldInfoAfter', 'charDescription', 'charPersonality',
|
||||
'scenario', 'personaDescription', 'dialogueExamples', 'chatHistory',
|
||||
'authorNotes', 'postHistoryInstructions'
|
||||
];
|
||||
|
||||
const existingIdentifiers = new Set(components.map(c => c.identifier));
|
||||
|
||||
for (const markerId of BUILTIN_MARKERS) {
|
||||
if (!existingIdentifiers.has(markerId)) {
|
||||
// 查找默认定义
|
||||
const defaultComponent = DEFAULT_PROMPT_COMPONENTS.find(c => c.identifier === markerId);
|
||||
if (defaultComponent) {
|
||||
components.push({ ...defaultComponent });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新状态,确保参数容器展开
|
||||
set({
|
||||
selectedPreset: presetId,
|
||||
@@ -235,6 +358,27 @@ const usePresetStore = create(
|
||||
saveCurrentAsPreset: async ({ name }) => {
|
||||
const state = get();
|
||||
try {
|
||||
// ✅ 检查并补全缺失的固有组件(marker=true)
|
||||
const BUILTIN_MARKERS = [
|
||||
'worldInfoBefore', 'worldInfoAfter', 'charDescription', 'charPersonality',
|
||||
'scenario', 'personaDescription', 'dialogueExamples', 'chatHistory',
|
||||
'authorNotes', 'postHistoryInstructions'
|
||||
];
|
||||
|
||||
let componentsToSave = [...state.promptComponents];
|
||||
const existingIdentifiers = new Set(componentsToSave.map(c => c.identifier));
|
||||
|
||||
for (const markerId of BUILTIN_MARKERS) {
|
||||
if (!existingIdentifiers.has(markerId)) {
|
||||
// 查找默认定义
|
||||
const defaultComponent = DEFAULT_PROMPT_COMPONENTS.find(c => c.identifier === markerId);
|
||||
if (defaultComponent) {
|
||||
componentsToSave.push({ ...defaultComponent });
|
||||
console.log('[保存预设] ✅ 补全缺失的固有组件:', markerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 SillyTavern 标准格式的预设数据
|
||||
const presetData = {
|
||||
// 基本参数 - 使用 SillyTavern 标准字段名
|
||||
@@ -248,7 +392,7 @@ const usePresetStore = create(
|
||||
request_timeout: state.parameters.request_timeout || 60,
|
||||
|
||||
// SillyTavern 标准的 prompts 数组
|
||||
prompts: state.promptComponents.map((component) => ({
|
||||
prompts: componentsToSave.map((component) => ({
|
||||
identifier: component.identifier,
|
||||
name: component.name,
|
||||
content: component.content || '',
|
||||
@@ -260,7 +404,7 @@ const usePresetStore = create(
|
||||
// prompt_order - SillyTavern 用于管理顺序和启用状态
|
||||
prompt_order: [{
|
||||
character_id: 'global',
|
||||
order: state.promptComponents.map(component => ({
|
||||
order: componentsToSave.map(component => ({
|
||||
identifier: component.identifier,
|
||||
enabled: component.enabled !== false
|
||||
}))
|
||||
@@ -282,7 +426,7 @@ const usePresetStore = create(
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 添加到本地预设列表
|
||||
// ✅ 添加到本地预设列表(新预设排在最前面)
|
||||
const newPreset = {
|
||||
id: name, // 使用名称作为 ID
|
||||
name,
|
||||
@@ -292,7 +436,7 @@ const usePresetStore = create(
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
presets: [...state.presets, newPreset],
|
||||
presets: [newPreset, ...state.presets], // ✅ 新预设插入到最前面
|
||||
selectedPreset: name
|
||||
}));
|
||||
|
||||
@@ -339,37 +483,97 @@ const usePresetStore = create(
|
||||
isParametersExpanded: !state.isParametersExpanded
|
||||
})),
|
||||
|
||||
// 设置预设组件列表
|
||||
setPromptComponents: (components) => set({ promptComponents: components }),
|
||||
// 设置预设组件列表 - ✅ 自动保存
|
||||
setPromptComponents: async (components) => {
|
||||
set({ promptComponents: components });
|
||||
|
||||
// ✅ 自动保存到后端
|
||||
const state = get();
|
||||
if (state.selectedPreset) {
|
||||
try {
|
||||
await state._autoSavePreset();
|
||||
} catch (error) {
|
||||
console.error('[PresetStore] 自动保存失败:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 更新组件
|
||||
updateComponent: (index, updatedComponent) => set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
newComponents[index] = { ...newComponents[index], ...updatedComponent };
|
||||
return { promptComponents: newComponents };
|
||||
}),
|
||||
// 更新组件 - ✅ 自动保存
|
||||
updateComponent: async (index, updatedComponent) => {
|
||||
set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
newComponents[index] = { ...newComponents[index], ...updatedComponent };
|
||||
return { promptComponents: newComponents };
|
||||
});
|
||||
|
||||
// ✅ 自动保存到后端
|
||||
const state = get();
|
||||
if (state.selectedPreset) {
|
||||
try {
|
||||
await state._autoSavePreset();
|
||||
} catch (error) {
|
||||
console.error('[PresetStore] 自动保存失败:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 切换组件启用状态
|
||||
toggleComponentEnabled: (index) => set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
newComponents[index] = {
|
||||
...newComponents[index],
|
||||
enabled: !newComponents[index].enabled
|
||||
};
|
||||
return { promptComponents: newComponents };
|
||||
}),
|
||||
// 切换组件启用状态 - ✅ 自动保存
|
||||
toggleComponentEnabled: async (index) => {
|
||||
set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
newComponents[index] = {
|
||||
...newComponents[index],
|
||||
enabled: !newComponents[index].enabled
|
||||
};
|
||||
return { promptComponents: newComponents };
|
||||
});
|
||||
|
||||
// ✅ 自动保存到后端
|
||||
const state = get();
|
||||
if (state.selectedPreset) {
|
||||
try {
|
||||
await state._autoSavePreset();
|
||||
} catch (error) {
|
||||
console.error('[PresetStore] 自动保存失败:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 添加新组件
|
||||
addComponent: (component) => set((state) => ({
|
||||
promptComponents: [...state.promptComponents, component]
|
||||
})),
|
||||
// 添加新组件 - ✅ 自动保存
|
||||
addComponent: async (component) => {
|
||||
set((state) => ({
|
||||
promptComponents: [...state.promptComponents, component]
|
||||
}));
|
||||
|
||||
// ✅ 自动保存到后端
|
||||
const state = get();
|
||||
if (state.selectedPreset) {
|
||||
try {
|
||||
await state._autoSavePreset();
|
||||
} catch (error) {
|
||||
console.error('[PresetStore] 自动保存失败:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 删除组件
|
||||
removeComponent: (index) => set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
newComponents.splice(index, 1);
|
||||
return { promptComponents: newComponents };
|
||||
}),
|
||||
// 删除组件 - ✅ 自动保存
|
||||
removeComponent: async (index) => {
|
||||
set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
newComponents.splice(index, 1);
|
||||
return { promptComponents: newComponents };
|
||||
});
|
||||
|
||||
// ✅ 自动保存到后端
|
||||
const state = get();
|
||||
if (state.selectedPreset) {
|
||||
try {
|
||||
await state._autoSavePreset();
|
||||
} catch (error) {
|
||||
console.error('[PresetStore] 自动保存失败:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 移动组件位置
|
||||
moveComponent: (fromIndex, toIndex) => set((state) => {
|
||||
@@ -478,7 +682,6 @@ const usePresetStore = create(
|
||||
};
|
||||
}
|
||||
}
|
||||
) // ✅ 闭合 persist(
|
||||
);
|
||||
));
|
||||
|
||||
export default usePresetStore;
|
||||
|
||||
@@ -11,6 +11,11 @@ const useUserStore = create(
|
||||
(set, get) => ({
|
||||
// 当前用户角色
|
||||
currentUserRole: { name: '', description: '' },
|
||||
|
||||
// ✅ 用户角色列表(支持多个角色)
|
||||
userRoles: [
|
||||
{ id: 'default', name: '默认用户', description: '普通用户角色' }
|
||||
],
|
||||
|
||||
// 设置用户角色
|
||||
setCurrentUserRole: (role) => {
|
||||
@@ -34,6 +39,40 @@ const useUserStore = create(
|
||||
set((state) => ({
|
||||
currentUserRole: { ...state.currentUserRole, description }
|
||||
}));
|
||||
},
|
||||
|
||||
// ✅ 添加新用户角色
|
||||
addUserRole: (role) => {
|
||||
set((state) => ({
|
||||
userRoles: [...state.userRoles, { ...role, id: role.id || `role_${Date.now()}` }]
|
||||
}));
|
||||
},
|
||||
|
||||
// ✅ 删除用户角色
|
||||
removeUserRole: (roleId) => {
|
||||
set((state) => ({
|
||||
userRoles: state.userRoles.filter(r => r.id !== roleId)
|
||||
}));
|
||||
},
|
||||
|
||||
// ✅ 更新用户角色
|
||||
updateUserRole: (roleId, updates) => {
|
||||
set((state) => ({
|
||||
userRoles: state.userRoles.map(r =>
|
||||
r.id === roleId ? { ...r, ...updates } : r
|
||||
)
|
||||
}));
|
||||
},
|
||||
|
||||
// ✅ 选择用户角色(从列表中选择一个作为当前角色)
|
||||
selectUserRole: (roleId) => {
|
||||
set((state) => {
|
||||
const selectedRole = state.userRoles.find(r => r.id === roleId);
|
||||
if (selectedRole) {
|
||||
return { currentUserRole: { ...selectedRole } };
|
||||
}
|
||||
return state;
|
||||
});
|
||||
}
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: 0; /* 去掉圆角 */
|
||||
line-height: 1.8;
|
||||
position: relative;
|
||||
position: relative; /* ✅ 为绝对定位的 swipe 按钮提供定位上下文 */
|
||||
transition: background-color 0.2s ease;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
@@ -234,18 +234,23 @@
|
||||
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.swipe-controls {
|
||||
/* ==================== Swipe Controls - 绝对定位到两侧 ==================== */
|
||||
|
||||
.swipe-controls-absolute {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-md);
|
||||
justify-content: space-between; /* ✅ 左右分布 */
|
||||
padding: 0 var(--spacing-xs); /* ✅ 减少内边距 */
|
||||
width: 100%; /* ✅ 占满宽度 */
|
||||
position: relative;
|
||||
padding: 0 var(--spacing-sm); /* ✅ 在 padding 区域内 */
|
||||
pointer-events: none; /* ✅ 让点击事件穿透到消息内容 */
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.swipe-button {
|
||||
.swipe-button-side {
|
||||
background: rgba(255, 255, 255, 0.15); /* ✅ 半透明背景 */
|
||||
backdrop-filter: blur(8px); /* ✅ 毛玻璃效果 */
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
@@ -259,43 +264,57 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.7; /* ✅ 默认半透明 */
|
||||
opacity: 0; /* ✅ 默认隐藏 */
|
||||
pointer-events: auto; /* ✅ 按钮本身可以接收点击 */
|
||||
}
|
||||
|
||||
/* 深色主题下的 swipe 按钮 */
|
||||
[data-color-theme='dark'] .swipe-button {
|
||||
[data-color-theme='dark'] .swipe-button-side {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.swipe-button:hover:not(:disabled) {
|
||||
/* ✅ hover 消息时显示按钮 */
|
||||
.message:hover .swipe-button-side {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.swipe-button-side:hover:not(:disabled) {
|
||||
background-color: rgba(102, 126, 234, 0.2); /* ✅ hover 时更明显 */
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
opacity: 1; /* ✅ hover 时完全不透明 */
|
||||
opacity: 1 !important; /* ✅ hover 时完全不透明 */
|
||||
}
|
||||
|
||||
.swipe-button:active:not(:disabled) {
|
||||
.swipe-button-side:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.swipe-button:disabled {
|
||||
opacity: 0.3;
|
||||
.swipe-button-side:disabled {
|
||||
opacity: 0.2 !important;
|
||||
cursor: not-allowed;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.swipe-counter {
|
||||
font-size: 0.85rem;
|
||||
.swipe-counter-side {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
min-width: 50px;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
padding: 2px 8px;
|
||||
padding: 2px 6px;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border-radius: var(--radius-sm);
|
||||
opacity: 0; /* ✅ 默认隐藏 */
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* ✅ hover 消息时显示页码 */
|
||||
.message:hover .swipe-counter-side {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ==================== Input Area - Fixed at Bottom ==================== */
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import useChatBoxStore from '../../../Store/Mid/ChatBoxSlice';
|
||||
import useChatBoxUIStore from '../../../Store/Mid/ChatBoxUISlice'; // ✅ 新增
|
||||
import { processTextForDisplay } from '../../../hooks/useRegexProcessor'; // ✅ 修改:导入普通函数
|
||||
import MarkdownRenderer from '../../shared/MarkdownRenderer';
|
||||
import './ChatBox.css';
|
||||
|
||||
@@ -61,6 +62,47 @@ const ChatBox = () => {
|
||||
cycleRenderMode // ✅ 新增:切换渲染模式
|
||||
} = useChatBoxStore();
|
||||
|
||||
// ✅ 新增:存储处理后的消息内容
|
||||
const [processedMessages, setProcessedMessages] = React.useState({});
|
||||
|
||||
// ✅ 当消息变化时,预处理正则规则
|
||||
React.useEffect(() => {
|
||||
const processMessages = async () => {
|
||||
const processed = {};
|
||||
|
||||
for (const message of messages) {
|
||||
if (!message.is_user && message.mes) {
|
||||
// 计算消息深度
|
||||
const messageDepth = messages.length > 0
|
||||
? messages[messages.length - 1].floor - message.floor
|
||||
: 0;
|
||||
|
||||
try {
|
||||
// 异步处理正则规则
|
||||
const displayText = await processTextForDisplay(
|
||||
message.mes,
|
||||
messageDepth,
|
||||
message.is_user
|
||||
);
|
||||
processed[message.floor] = displayText;
|
||||
} catch (error) {
|
||||
console.error('[ChatBox] 正则处理失败:', error);
|
||||
processed[message.floor] = message.mes;
|
||||
}
|
||||
} else {
|
||||
// 用户消息或空消息直接使用原文
|
||||
processed[message.floor] = message.mes;
|
||||
}
|
||||
}
|
||||
|
||||
setProcessedMessages(processed);
|
||||
};
|
||||
|
||||
if (messages.length > 0) {
|
||||
processMessages();
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
// 点击外部关闭选项面板
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
@@ -278,6 +320,10 @@ const ChatBox = () => {
|
||||
if (newIndex >= 0 && newIndex < message.swipes.length) {
|
||||
// ✅ 合并更新,保留其他楼层的 swipe 状态
|
||||
setCurrentSwipeId({ ...currentSwipeId, [messageId]: newIndex });
|
||||
} else if (direction === 1 && newIndex >= message.swipes.length) {
|
||||
// ✅ 当尝试切换到超出最后一个版本时,触发重roll(添加新swipe)
|
||||
console.log('[ChatBox] 右键点击最后一个版本,触发重roll');
|
||||
handleRerollMessage(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -385,6 +431,60 @@ const ChatBox = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 分支功能:创建新的聊天记录
|
||||
const handleBranchChat = async (message) => {
|
||||
try {
|
||||
const { currentRole, currentChat, setChatBoxRoleAndChat } = useChatBoxStore.getState();
|
||||
|
||||
if (!currentRole || !currentChat) {
|
||||
alert('请先选择角色和聊天');
|
||||
closeContextMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`确定要从楼层 #${message.floor} 创建分支吗?\n这将复制该楼层及之前的所有内容到一个新的聊天记录。`)) {
|
||||
closeContextMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ChatBox] 创建分支,目标楼层:', message.floor);
|
||||
|
||||
// 调用后端 API 创建分支
|
||||
const response = await fetch(
|
||||
`/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/branch`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
target_floor: message.floor
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || '创建分支失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[ChatBox] 分支创建成功:', result);
|
||||
|
||||
// 关闭菜单
|
||||
closeContextMenu();
|
||||
|
||||
// 切换到新的聊天记录
|
||||
setChatBoxRoleAndChat(currentRole, result.new_chat_name);
|
||||
|
||||
alert(`分支创建成功!\n新聊天: ${result.new_chat_name}\n消息数: ${result.message_count}`);
|
||||
|
||||
} catch (err) {
|
||||
console.error('[ChatBox] 创建分支失败:', err);
|
||||
alert('创建分支失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换选项显示
|
||||
const toggleOptionsPanel = () => {
|
||||
toggleOptions(); // ✅ 使用 store 方法
|
||||
@@ -538,12 +638,46 @@ const ChatBox = () => {
|
||||
// ✅ 使用 id 或组合 key 确保唯一性
|
||||
const uniqueKey = message.id || `${message.floor}-${index}`;
|
||||
|
||||
// ✅ 判断是否为最后一条消息(最后一楼)
|
||||
const isLastMessage = messages.length > 0 && message.floor === messages[messages.length - 1].floor;
|
||||
|
||||
// 计算消息深度(从最新消息开始计数)
|
||||
const messageDepth = messages.length > 0 ? messages[messages.length - 1].floor - message.floor : 0;
|
||||
|
||||
// ✅ 使用预处理后的消息内容(已应用 markdownOnly 正则)
|
||||
const displayMes = processedMessages[message.floor] || currentMes;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={uniqueKey}
|
||||
className={`message ${isUser ? 'user' : 'ai'}`}
|
||||
onContextMenu={(e) => handleContextMenu(e, message)} // ✅ 添加右键菜单
|
||||
>
|
||||
{/* ✅ Swipe 控制按钮 - 仅在最后一楼显示 */}
|
||||
{hasSwipes && !isUser && isLastMessage && (
|
||||
<div className="swipe-controls-absolute">
|
||||
<button
|
||||
className="swipe-button-side"
|
||||
onClick={() => handleSwipeChange(message.floor, -1)}
|
||||
disabled={currentSwipeIndex === 0}
|
||||
title="上一个版本"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<span className="swipe-counter-side" title={`共 ${message.swipes.length} 个版本`}>
|
||||
{currentSwipeIndex + 1}/{message.swipes.length}
|
||||
</span>
|
||||
<button
|
||||
className="swipe-button-side"
|
||||
onClick={() => handleSwipeChange(message.floor, 1)}
|
||||
disabled={false} // ✅ 始终启用,最后一个版本时点击会触发重roll
|
||||
title={currentSwipeIndex === message.swipes.length - 1 ? "生成新版本(重roll)" : "下一个版本"}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="message-container">
|
||||
<div className="message-header">
|
||||
<span className="message-name">{displayName}</span>
|
||||
@@ -556,7 +690,7 @@ const ChatBox = () => {
|
||||
<textarea
|
||||
className="edit-textarea"
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
onChange={(e) => updateEditContent(e.target.value)}
|
||||
/>
|
||||
<div className="edit-buttons">
|
||||
<button
|
||||
@@ -576,51 +710,24 @@ const ChatBox = () => {
|
||||
) : (
|
||||
<div className="bubble">
|
||||
{options.renderMode === 'html' && !isUser ? (
|
||||
// ✅ 智能 HTML 渲染:检测是否为纯文本,自动转换换行符
|
||||
<div dangerouslySetInnerHTML={{
|
||||
__html: (() => {
|
||||
// 检测是否包含 HTML 标签
|
||||
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(currentMes);
|
||||
if (hasHtmlTags) {
|
||||
// 如果已有 HTML 标签,直接返回
|
||||
return currentMes;
|
||||
}
|
||||
// 如果是纯文本,将换行符转换为 <br>
|
||||
return currentMes
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
})()
|
||||
}} />
|
||||
// ✅ HTML 渲染:只在检测到 HTML 标签时使用 dangerouslySetInnerHTML
|
||||
// ✅ 如果是纯文本,使用 plain-text 模式显示(保留原始格式)
|
||||
(() => {
|
||||
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(displayMes);
|
||||
if (hasHtmlTags) {
|
||||
// 如果包含 HTML 标签,直接渲染
|
||||
return <div dangerouslySetInnerHTML={{ __html: displayMes }} />;
|
||||
} else {
|
||||
// 如果是纯文本,使用 pre-wrap 保留换行和空格
|
||||
return <div className="plain-text">{displayMes}</div>;
|
||||
}
|
||||
})()
|
||||
) : options.renderMode === 'markdown' ? (
|
||||
<MarkdownRenderer content={currentMes} />
|
||||
// ✅ 使用 displayMes(已应用 markdownOnly 正则)
|
||||
<MarkdownRenderer content={displayMes} />
|
||||
) : (
|
||||
<div className="plain-text">{currentMes}</div>
|
||||
)}
|
||||
{/* ✅ Swipe 控制按钮 - 所有有 swipe 的 AI 消息都显示 */}
|
||||
{hasSwipes && !isUser && (
|
||||
<div className="swipe-controls">
|
||||
<button
|
||||
className="swipe-button"
|
||||
onClick={() => handleSwipeChange(message.floor, -1)}
|
||||
disabled={currentSwipeIndex === 0}
|
||||
title="上一个版本"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<span className="swipe-counter" title={`共 ${message.swipes.length} 个版本`}>
|
||||
{currentSwipeIndex + 1}/{message.swipes.length}
|
||||
</span>
|
||||
<button
|
||||
className="swipe-button"
|
||||
onClick={() => handleSwipeChange(message.floor, 1)}
|
||||
disabled={currentSwipeIndex === message.swipes.length - 1}
|
||||
title="下一个版本"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
// ✅ 纯文本模式
|
||||
<div className="plain-text">{displayMes}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -845,6 +952,11 @@ const ChatBox = () => {
|
||||
<span className="menu-label">重roll</span>
|
||||
</div>
|
||||
)}
|
||||
{/* ✅ 分支功能 - 所有消息都可以分支 */}
|
||||
<div className="context-menu-item" onClick={() => handleBranchChat(contextMenu.message)}>
|
||||
<span className="menu-icon">🌿</span>
|
||||
<span className="menu-label">分支</span>
|
||||
</div>
|
||||
<div className="context-menu-divider"></div>
|
||||
<div className="context-menu-item danger" onClick={() => handleDeleteMessage(contextMenu.message)}>
|
||||
<span className="menu-icon">🗑️</span>
|
||||
|
||||
@@ -718,7 +718,7 @@ const CharacterCard = () => {
|
||||
>
|
||||
<option key="none" value="">不绑定</option>
|
||||
{worldBooks.map(book => (
|
||||
<option key={book.id} value={book.id}>
|
||||
<option key={`worldbook-${book.id}`} value={book.id}>
|
||||
{book.name}
|
||||
</option>
|
||||
))}
|
||||
@@ -733,9 +733,9 @@ const CharacterCard = () => {
|
||||
value={editForm.historyMode || 'full'}
|
||||
onChange={(e) => handleFormChange('historyMode', e.target.value)}
|
||||
>
|
||||
<option value="full">全量模式(保留所有消息)</option>
|
||||
<option value="summary">总结模式(定期总结历史)</option>
|
||||
<option value="rag" disabled={editForm.historyMode === 'rag'}>
|
||||
<option key="full" value="full">全量模式(保留所有消息)</option>
|
||||
<option key="summary" value="summary">总结模式(定期总结历史)</option>
|
||||
<option key="rag" value="rag" disabled={editForm.historyMode === 'rag'}>
|
||||
RAG模式(需要API配置,选择后不可取消)
|
||||
</option>
|
||||
</select>
|
||||
|
||||
@@ -241,12 +241,12 @@
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 1000;
|
||||
min-width: 450px;
|
||||
max-width: 80vw;
|
||||
max-height: 80vh;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-lg), 0 0 0 1px rgba(0, 0, 0, 0.1);
|
||||
z-index: var(--z-modal-content); /* ✅ 弹窗层 - 确保在最高层 */
|
||||
min-width: 900px;
|
||||
max-width: calc(100vw - 60px); /* 左右各留30px边距 */
|
||||
max-height: calc(100vh - 100px); /* 上下留出空间,避免遮挡顶部栏 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -286,12 +286,12 @@
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 16px;
|
||||
padding: 24px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ✅ 角色选择器 */
|
||||
@@ -336,14 +336,15 @@
|
||||
|
||||
.component-textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
padding: 14px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
line-height: 1.7;
|
||||
resize: vertical;
|
||||
min-height: 500px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ const PresetPanel = () => {
|
||||
|
||||
// 组件编辑状态
|
||||
const [editingComponentIndex, setEditingComponentIndex] = useState(-1);
|
||||
const [editComponentName, setEditComponentName] = useState(''); // ✅ 新增:组件名称
|
||||
const [editComponentContent, setEditComponentContent] = useState('');
|
||||
const [editComponentRole, setEditComponentRole] = useState(0); // 0=system, 1=user, 2=ai
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -226,7 +227,7 @@ const PresetPanel = () => {
|
||||
throw new Error('Failed to update preset');
|
||||
}
|
||||
|
||||
alert('预设已保存!');
|
||||
// ✅ 静默保存,不显示成功提示
|
||||
} catch (error) {
|
||||
console.error('保存预设失败:', error);
|
||||
alert('保存预设失败: ' + error.message);
|
||||
@@ -283,15 +284,37 @@ const PresetPanel = () => {
|
||||
const handleEditPreset = async () => {
|
||||
if (editPresetId && editPresetName.trim()) {
|
||||
try {
|
||||
await updatePresetName(editPresetId, editPresetName);
|
||||
// ✅ 使用专门的重命名 API
|
||||
const response = await fetch(`/api/presets/${editPresetId}/rename`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ newName: editPresetName })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Failed to rename preset');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
setEditPresetId('');
|
||||
setEditPresetName('');
|
||||
setShowEditDialog(false);
|
||||
|
||||
// ✅ 如果重命名后当前选中的预设 ID 变了,需要更新选中状态
|
||||
if (selectedPreset === editPresetId && result.newName) {
|
||||
setSelectedPreset(result.newName);
|
||||
}
|
||||
|
||||
// 重新加载预设列表
|
||||
setCurrentPage(1);
|
||||
fetchPresets();
|
||||
} catch (error) {
|
||||
console.error('编辑预设名称失败:', error);
|
||||
alert('编辑预设名称失败: ' + error.message);
|
||||
console.error('重命名预设失败:', error);
|
||||
alert('重命名预设失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -609,7 +632,9 @@ const PresetPanel = () => {
|
||||
// 开始编辑组件
|
||||
const handleStartEditComponent = (index) => {
|
||||
setEditingComponentIndex(index);
|
||||
setEditComponentName(promptComponents[index].name); // ✅ 初始化组件名称
|
||||
setEditComponentContent(promptComponents[index].content);
|
||||
setEditComponentRole(promptComponents[index].role || 0);
|
||||
setIsEditing(true);
|
||||
setShowComponentEditDialog(true);
|
||||
};
|
||||
@@ -631,16 +656,74 @@ const PresetPanel = () => {
|
||||
}
|
||||
|
||||
setEditingComponentIndex(index);
|
||||
setEditComponentName(component.name); // ✅ 初始化组件名称
|
||||
setEditComponentRole(component.role || 0);
|
||||
setIsEditing(!component.marker); // 固定组件不可编辑
|
||||
setShowComponentEditDialog(true);
|
||||
};
|
||||
|
||||
// 保存组件编辑
|
||||
const handleSaveComponentEdit = () => {
|
||||
const handleSaveComponentEdit = async () => {
|
||||
if (editingComponentIndex >= 0 && isEditing) {
|
||||
updateComponent(editingComponentIndex, { content: editComponentContent });
|
||||
// ✅ 先更新前端状态(包括名称和内容)
|
||||
updateComponent(editingComponentIndex, {
|
||||
name: editComponentName,
|
||||
content: editComponentContent,
|
||||
role: editComponentRole
|
||||
});
|
||||
|
||||
// ✅ 然后保存到后端
|
||||
if (selectedPreset) {
|
||||
try {
|
||||
// 构建预设数据
|
||||
const presetData = {
|
||||
name: selectedPreset,
|
||||
temperature: parameters.temperature,
|
||||
frequency_penalty: parameters.frequency_penalty,
|
||||
presence_penalty: parameters.presence_penalty,
|
||||
top_p: parameters.top_p,
|
||||
top_k: parameters.top_k,
|
||||
max_tokens: parameters.max_tokens,
|
||||
request_timeout: parameters.request_timeout || 60,
|
||||
|
||||
prompts: promptComponents.map((component, idx) => ({
|
||||
identifier: component.identifier,
|
||||
name: idx === editingComponentIndex ? editComponentName : component.name,
|
||||
content: idx === editingComponentIndex ? editComponentContent : (component.content || ''),
|
||||
system_prompt: component.role === 0,
|
||||
role: idx === editingComponentIndex ? editComponentRole : component.role,
|
||||
enabled: component.enabled !== false
|
||||
})),
|
||||
|
||||
prompt_order: [{
|
||||
character_id: 'global',
|
||||
order: promptComponents.map(component => ({
|
||||
identifier: component.identifier,
|
||||
enabled: component.enabled !== false
|
||||
}))
|
||||
}]
|
||||
};
|
||||
|
||||
// 发送到后端更新
|
||||
const response = await fetch(`/api/presets/${selectedPreset}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(presetData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save preset');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存预设失败:', error);
|
||||
alert('保存预设失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
setEditingComponentIndex(-1);
|
||||
setEditComponentName('');
|
||||
setEditComponentContent('');
|
||||
setShowComponentEditDialog(false);
|
||||
};
|
||||
@@ -648,6 +731,7 @@ const PresetPanel = () => {
|
||||
// 取消组件编辑
|
||||
const handleCancelComponentEdit = () => {
|
||||
setEditingComponentIndex(-1);
|
||||
setEditComponentName('');
|
||||
setEditComponentContent('');
|
||||
setShowComponentEditDialog(false);
|
||||
};
|
||||
@@ -655,6 +739,7 @@ const PresetPanel = () => {
|
||||
// 关闭组件查看对话框
|
||||
const handleCloseComponentView = () => {
|
||||
setEditingComponentIndex(-1);
|
||||
setEditComponentName('');
|
||||
setEditComponentContent('');
|
||||
setShowComponentEditDialog(false);
|
||||
};
|
||||
@@ -785,6 +870,22 @@ const PresetPanel = () => {
|
||||
>
|
||||
💾 保存当前预设
|
||||
</button>
|
||||
<button
|
||||
className="dropdown-item"
|
||||
disabled={!selectedPreset}
|
||||
title="重命名当前选中的预设"
|
||||
onClick={() => {
|
||||
if (selectedPreset) {
|
||||
const preset = presets.find(p => p.id === selectedPreset);
|
||||
setEditPresetId(selectedPreset);
|
||||
setEditPresetName(preset?.name || '');
|
||||
setShowEditDialog(true);
|
||||
}
|
||||
setShowActionsMenu(false);
|
||||
}}
|
||||
>
|
||||
✏️ 重命名
|
||||
</button>
|
||||
<button
|
||||
className="dropdown-item"
|
||||
title="从 JSON 文件导入预设配置"
|
||||
@@ -888,8 +989,31 @@ const PresetPanel = () => {
|
||||
<button className="close-btn" onClick={handleCloseComponentView}>×</button>
|
||||
</div>
|
||||
<div className="dialog-content">
|
||||
{/* ✅ 角色选择器 */}
|
||||
{isEditing && (
|
||||
{/* ✅ 检查是否是固有组件(marker=true) */}
|
||||
{editingComponentIndex >= 0 && promptComponents[editingComponentIndex].marker && (
|
||||
<div className="builtin-component-notice">
|
||||
<p>⚠️ 这是 SillyTavern 的固有组件,不可编辑。</p>
|
||||
<p>📌 <strong>数据来源:</strong>{promptComponents[editingComponentIndex].dataSource || '系统自动获取'}</p>
|
||||
<p>💡 该组件会在发送消息时从对应的数据源自动提取最新内容。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ 组件名称输入框 - 固有组件禁用 */}
|
||||
{isEditing && !promptComponents[editingComponentIndex]?.marker && (
|
||||
<div className="component-name-input">
|
||||
<label className="name-label">组件名称:</label>
|
||||
<input
|
||||
type="text"
|
||||
className="name-input"
|
||||
value={editComponentName}
|
||||
onChange={(e) => setEditComponentName(e.target.value)}
|
||||
placeholder="输入组件名称"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ 角色选择器 - 固有组件禁用 */}
|
||||
{isEditing && !promptComponents[editingComponentIndex]?.marker && (
|
||||
<div className="component-role-selector">
|
||||
<label className="role-label">角色:</label>
|
||||
<select
|
||||
@@ -904,11 +1028,12 @@ const PresetPanel = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ 内容文本域 - 固有组件只读 */}
|
||||
<textarea
|
||||
value={editComponentContent}
|
||||
onChange={(e) => setEditComponentContent(e.target.value)}
|
||||
className="component-textarea"
|
||||
readOnly={!isEditing}
|
||||
readOnly={!isEditing || promptComponents[editingComponentIndex]?.marker}
|
||||
rows={20}
|
||||
/>
|
||||
</div>
|
||||
@@ -916,7 +1041,7 @@ const PresetPanel = () => {
|
||||
<span className="token-count">
|
||||
{editComponentContent ? editComponentContent.length : 0}
|
||||
</span>
|
||||
{isEditing && (
|
||||
{isEditing && !promptComponents[editingComponentIndex]?.marker && (
|
||||
<div className="dialog-buttons">
|
||||
<button onClick={handleSaveComponentEdit}>保存</button>
|
||||
<button onClick={handleCancelComponentEdit}>取消</button>
|
||||
|
||||
@@ -448,6 +448,12 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 紧凑选项布局 */
|
||||
.form-row.compact-options {
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-section h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
@@ -461,6 +467,13 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 紧凑的 checkbox 组 - 使用网格布局 */
|
||||
.checkbox-group.compact {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px 12px;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -468,6 +481,12 @@
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.checkbox-group.compact .checkbox-item {
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.checkbox-item input[type="checkbox"] {
|
||||
|
||||
@@ -26,7 +26,7 @@ const RegexPanel = () => {
|
||||
findRegex: '',
|
||||
replaceString: '',
|
||||
trimStrings: [],
|
||||
placement: [1], // 默认 AI输出 (注意:0=System, 1=AI Output, 2=User Input)
|
||||
placement: [2], // 默认 AI输出 (SillyTavern 标准:0=System, 1=User Input, 2=AI Output)
|
||||
disabled: false,
|
||||
markdownOnly: false,
|
||||
promptOnly: false,
|
||||
@@ -36,14 +36,14 @@ const RegexPanel = () => {
|
||||
maxDepth: null
|
||||
});
|
||||
|
||||
// 作用范围选项(修正顺序)
|
||||
// 作用范围选项(SillyTavern 官方标准)
|
||||
const placementOptions = [
|
||||
{ value: 0, label: '系统提示词' },
|
||||
{ value: 1, label: 'AI输出' },
|
||||
{ value: 2, label: '用户输入' },
|
||||
{ value: 3, label: '快捷命令' },
|
||||
{ value: 4, label: '世界信息' },
|
||||
{ value: 5, label: '推理' }
|
||||
{ value: 0, label: '系统提示词' }, // System Prompt
|
||||
{ value: 1, label: '用户输入' }, // User Input
|
||||
{ value: 2, label: 'AI输出' }, // AI Output
|
||||
{ value: 3, label: '快捷命令' }, // Quick Reply
|
||||
{ value: 4, label: '世界信息' }, // World Info
|
||||
{ value: 5, label: '推理' } // Reasoning/Thinking
|
||||
];
|
||||
|
||||
// 替换模式选项
|
||||
@@ -115,7 +115,7 @@ const RegexPanel = () => {
|
||||
findRegex: '',
|
||||
replaceString: '',
|
||||
trimStrings: [],
|
||||
placement: [1],
|
||||
placement: [2], // 默认 AI Output
|
||||
disabled: false,
|
||||
markdownOnly: false,
|
||||
promptOnly: false,
|
||||
@@ -160,7 +160,6 @@ const RegexPanel = () => {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('规则保存成功!');
|
||||
setShowEditor(false);
|
||||
if (scope === 'preset') {
|
||||
fetchPresetRulesets(selectedPreset);
|
||||
@@ -334,11 +333,11 @@ const RegexPanel = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 作用范围和其他选项 */}
|
||||
<div className="form-row">
|
||||
{/* 作用范围和其他选项 - 紧凑布局 */}
|
||||
<div className="form-row compact-options">
|
||||
<div className="form-section">
|
||||
<h4>作用范围</h4>
|
||||
<div className="checkbox-group">
|
||||
<div className="checkbox-group compact">
|
||||
{placementOptions.map(opt => (
|
||||
<label key={opt.value} className="checkbox-item">
|
||||
<input
|
||||
@@ -354,8 +353,8 @@ const RegexPanel = () => {
|
||||
|
||||
<div className="form-section">
|
||||
<h4>其他选项</h4>
|
||||
<div className="checkbox-group">
|
||||
<label className="checkbox-item">
|
||||
<div className="checkbox-group compact">
|
||||
<label className="checkbox-item" title="禁用此规则,使其不生效">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.disabled}
|
||||
@@ -363,29 +362,29 @@ const RegexPanel = () => {
|
||||
/>
|
||||
<span>已禁用</span>
|
||||
</label>
|
||||
<label className="checkbox-item">
|
||||
<label className="checkbox-item" title="用户编辑消息时也重新应用此规则">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.runOnEdit}
|
||||
onChange={(e) => updateFormField('runOnEdit', e.target.checked)}
|
||||
/>
|
||||
<span>在编辑时运行</span>
|
||||
<span>编辑时运行</span>
|
||||
</label>
|
||||
<label className="checkbox-item">
|
||||
<label className="checkbox-item" title="勾选后:只在前端显示时应用正则,不修改存储的原始数据">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.markdownOnly}
|
||||
onChange={(e) => updateFormField('markdownOnly', e.target.checked)}
|
||||
/>
|
||||
<span>仅格式显示</span>
|
||||
<span>仅影响显示</span>
|
||||
</label>
|
||||
<label className="checkbox-item">
|
||||
<label className="checkbox-item" title="勾选后:只在发送给LLM时应用正则,不修改存储的原始数据">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.promptOnly}
|
||||
onChange={(e) => updateFormField('promptOnly', e.target.checked)}
|
||||
/>
|
||||
<span>仅格式提示词</span>
|
||||
<span>仅影响提示词</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -480,9 +479,9 @@ const RegexPanel = () => {
|
||||
<div className="rule-info">
|
||||
<span className="rule-name">{rule.scriptName}</span>
|
||||
<div className="rule-badges">
|
||||
{rule.disabled && <span className="rule-badge disabled">已禁用</span>}
|
||||
{rule.promptOnly && <span className="rule-badge prompt">仅提示词</span>}
|
||||
{rule.markdownOnly && <span className="rule-badge markdown">仅格式</span>}
|
||||
{rule.disabled && <span className="rule-badge disabled" title="此规则已禁用">已禁用</span>}
|
||||
{rule.promptOnly && <span className="rule-badge prompt" title="不影响显示给用户的内容">不影响显示</span>}
|
||||
{rule.markdownOnly && <span className="rule-badge markdown" title="不影响发送给 AI 的内容">不影响发送</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rule-actions">
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
/* Tavern Helper 样式 */
|
||||
|
||||
.tavern-helper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.tavern-helper-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tavern-helper-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-new {
|
||||
background: linear-gradient(135deg, #ff6b6b, #ee5a6f);
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-extension-info {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-extension-info:hover {
|
||||
background-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 标签页导航 */
|
||||
.tavern-helper-tabs {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background-color: var(--color-accent);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.tavern-helper-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* 设置项通用样式 */
|
||||
.setting-group {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.setting-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.setting-header:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.setting-info h4 {
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.setting-desc {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 开关样式 */
|
||||
.toggle-label {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-label input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 24px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 2px;
|
||||
background-color: var(--color-text-muted);
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-label input:checked + .toggle-slider {
|
||||
background-color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.toggle-label input:checked + .toggle-slider:before {
|
||||
transform: translateX(20px);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
/* 设置区块 */
|
||||
.setting-section {
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.setting-section.experimental {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.setting-section.experimental .section-title::before {
|
||||
content: "⚠️ ";
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.setting-item label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.setting-control input[type="number"] {
|
||||
width: 100px;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background-color: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 单选按钮组 */
|
||||
.radio-group {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.radio-btn {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
background-color: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.radio-btn:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.radio-btn.active {
|
||||
background-color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 脚本标签页 */
|
||||
.scripts-toolbar {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.btn-tool {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-tool:hover {
|
||||
background-color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 搜索框 */
|
||||
.search-box {
|
||||
position: relative;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
padding-right: 40px;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.35-4.35'/%3E%3C/svg%3E");
|
||||
background-size: contain;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 脚本区块 */
|
||||
.script-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.script-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.section-header h4 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 脚本列表 */
|
||||
.script-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.script-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.script-item:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
opacity: 0.5;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.script-name {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.script-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-icon.danger:hover {
|
||||
background-color: rgba(255, 77, 77, 0.2);
|
||||
color: #ff4d4d;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-xl);
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 滚动条美化 */
|
||||
.tavern-helper-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.tavern-helper-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tavern-helper-content::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tavern-helper-content::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import React, { useState } from 'react';
|
||||
import './TavernHelper.css';
|
||||
|
||||
/**
|
||||
* 酒馆助手组件(Tavern Helper)
|
||||
*
|
||||
* 模仿 SillyTavern 的酒馆助手插件,提供:
|
||||
* - 渲染标签页:控制代码块渲染
|
||||
* - 脚本标签页:管理 JavaScript 脚本
|
||||
* - 工具标签页:实用工具
|
||||
* - 优化标签页:性能优化选项
|
||||
* - 开发标签页:开发者工具
|
||||
*/
|
||||
const TavernHelper = () => {
|
||||
const [activeTab, setActiveTab] = useState('render');
|
||||
|
||||
// 渲染设置
|
||||
const [renderSettings, setRenderSettings] = useState({
|
||||
enabled: true,
|
||||
renderDepth: 0,
|
||||
codeFoldEnabled: true,
|
||||
codeFoldMode: 'frontend-only', // all, frontend-only, disabled
|
||||
blobUrlRender: false,
|
||||
disableCodeHighlight: true,
|
||||
streamRender: true,
|
||||
});
|
||||
|
||||
// 脚本状态
|
||||
const [globalScriptsEnabled, setGlobalScriptsEnabled] = useState(false);
|
||||
const [characterScriptsEnabled, setCharacterScriptsEnabled] = useState(false);
|
||||
const [presetScriptsEnabled, setPresetScriptsEnabled] = useState(true);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'render', label: '渲染', icon: '✎' },
|
||||
{ id: 'scripts', label: '脚本', icon: '' },
|
||||
{ id: 'tools', label: '工具', icon: '' },
|
||||
{ id: 'optimization', label: '优化', icon: '⚡' },
|
||||
{ id: 'dev', label: '开发', icon: '' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="tavern-helper">
|
||||
<div className="tavern-helper-header">
|
||||
<h3>酒馆助手 <span className="badge-new">New!</span></h3>
|
||||
<span className="version">Ver 4.8.4</span>
|
||||
<button className="btn-extension-info">扩展信息</button>
|
||||
</div>
|
||||
|
||||
{/* 标签页导航 */}
|
||||
<div className="tavern-helper-tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`tab-button ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
<span className="tab-icon">{tab.icon}</span>
|
||||
<span className="tab-label">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 标签页内容 */}
|
||||
<div className="tavern-helper-content">
|
||||
{activeTab === 'render' && <RenderTab settings={renderSettings} onChange={setRenderSettings} />}
|
||||
{activeTab === 'scripts' && (
|
||||
<ScriptsTab
|
||||
globalEnabled={globalScriptsEnabled}
|
||||
characterEnabled={characterScriptsEnabled}
|
||||
presetEnabled={presetScriptsEnabled}
|
||||
onGlobalToggle={setGlobalScriptsEnabled}
|
||||
onCharacterToggle={setCharacterScriptsEnabled}
|
||||
onPresetToggle={setPresetScriptsEnabled}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'tools' && <ToolsTab />}
|
||||
{activeTab === 'optimization' && <OptimizationTab />}
|
||||
{activeTab === 'dev' && <DevTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染标签页
|
||||
*/
|
||||
const RenderTab = ({ settings, onChange }) => {
|
||||
return (
|
||||
<div className="tab-content render-tab">
|
||||
{/* 启用渲染器 */}
|
||||
<div className="setting-group">
|
||||
<div className="setting-header">
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enabled}
|
||||
onChange={(e) => onChange({ ...settings, enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
<div className="setting-info">
|
||||
<h4>启用渲染器</h4>
|
||||
<p className="setting-desc">启用后,符合条件的代码块将被渲染</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 渲染优化 */}
|
||||
<div className="setting-section">
|
||||
<h5 className="section-title">⚡ 渲染优化</h5>
|
||||
|
||||
<div className="setting-item">
|
||||
<label>渲染深度</label>
|
||||
<div className="setting-control">
|
||||
<input
|
||||
type="number"
|
||||
value={settings.renderDepth}
|
||||
onChange={(e) => onChange({ ...settings, renderDepth: parseInt(e.target.value) })}
|
||||
min="0"
|
||||
/>
|
||||
<p className="field-hint">设置需要渲染的楼层数,从最新楼层开始计数。为 0 时,将渲染所有楼层</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<label>启用代码折叠</label>
|
||||
<div className="setting-control">
|
||||
<div className="radio-group">
|
||||
<button
|
||||
className={`radio-btn ${settings.codeFoldMode === 'all' ? 'active' : ''}`}
|
||||
onClick={() => onChange({ ...settings, codeFoldMode: 'all' })}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
className={`radio-btn ${settings.codeFoldMode === 'frontend-only' ? 'active' : ''}`}
|
||||
onClick={() => onChange({ ...settings, codeFoldMode: 'frontend-only' })}
|
||||
>
|
||||
仅前端
|
||||
</button>
|
||||
<button
|
||||
className={`radio-btn ${settings.codeFoldMode === 'disabled' ? 'active' : ''}`}
|
||||
onClick={() => onChange({ ...settings, codeFoldMode: 'disabled' })}
|
||||
>
|
||||
禁用
|
||||
</button>
|
||||
</div>
|
||||
<p className="field-hint">折叠指定类型的代码块,当选择"仅前端"时,将只折叠可渲染成前端界面但没被渲染的代码块</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-header">
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.blobUrlRender}
|
||||
onChange={(e) => onChange({ ...settings, blobUrlRender: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
<div className="setting-info">
|
||||
<h4>启用 Blob URL 渲染</h4>
|
||||
<p className="setting-desc">使用 Blob URL 渲染前端界面,更方便 f12 开发者工具调试;但某些浏览器可能不支持</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-header">
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.disableCodeHighlight}
|
||||
onChange={(e) => onChange({ ...settings, disableCodeHighlight: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
<div className="setting-info">
|
||||
<h4>取消前端代码高亮</h4>
|
||||
<p className="setting-desc">避免酒馆对可渲染成前端界面的代码块进行语法高亮,从而提升渲染性能</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 实验功能 */}
|
||||
<div className="setting-section experimental">
|
||||
<h5 className="section-title"> 实验功能</h5>
|
||||
|
||||
<div className="setting-header">
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.streamRender}
|
||||
onChange={(e) => onChange({ ...settings, streamRender: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
<div className="setting-info">
|
||||
<h4>允许流式渲染</h4>
|
||||
<p className="setting-desc">在AI流式输出时就渲染,某些前端界面可能无法这样渲染。此外,这可能与某些脚本、插件、酒馆美化不兼容</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 脚本标签页
|
||||
*/
|
||||
const ScriptsTab = ({
|
||||
globalEnabled,
|
||||
characterEnabled,
|
||||
presetEnabled,
|
||||
onGlobalToggle,
|
||||
onCharacterToggle,
|
||||
onPresetToggle,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
return (
|
||||
<div className="tab-content scripts-tab">
|
||||
{/* 工具栏 */}
|
||||
<div className="scripts-toolbar">
|
||||
<button className="btn-tool">➕ 脚本</button>
|
||||
<button className="btn-tool">📁 文件夹</button>
|
||||
<button className="btn-tool">📥 导入</button>
|
||||
<button className="btn-tool">📦 内置库</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="search-box">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索(支持普通和/正则/)"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<span className="search-icon"></span>
|
||||
</div>
|
||||
|
||||
{/* 全局脚本 */}
|
||||
<div className="script-section">
|
||||
<div className="section-header">
|
||||
<h4>全局脚本</h4>
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={globalEnabled}
|
||||
onChange={(e) => onGlobalToggle(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="section-desc">酒馆全局可用</p>
|
||||
|
||||
<div className="script-list">
|
||||
<div className="script-item">
|
||||
<span className="drag-handle">☰</span>
|
||||
<span className="script-name">【骰子系统】-自动更新</span>
|
||||
<div className="script-actions">
|
||||
<button className="btn-icon" title="启用/禁用"></button>
|
||||
<button className="btn-icon" title="信息">ℹ️</button>
|
||||
<button className="btn-icon" title="编辑">✏️</button>
|
||||
<button className="btn-icon" title="更多">⋯</button>
|
||||
<button className="btn-icon danger" title="删除">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="script-item">
|
||||
<span className="drag-handle">☰</span>
|
||||
<span className="script-name">蚀心入魔·数据库</span>
|
||||
<div className="script-actions">
|
||||
<button className="btn-icon" title="启用/禁用">🔘</button>
|
||||
<button className="btn-icon" title="信息">ℹ️</button>
|
||||
<button className="btn-icon" title="编辑">✏️</button>
|
||||
<button className="btn-icon" title="更多">⋯</button>
|
||||
<button className="btn-icon danger" title="删除">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 角色脚本 */}
|
||||
<div className="script-section">
|
||||
<div className="section-header">
|
||||
<h4>角色脚本</h4>
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={characterEnabled}
|
||||
onChange={(e) => onCharacterToggle(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="section-desc">绑定到当前角色卡</p>
|
||||
|
||||
<div className="empty-state">
|
||||
<p>暂无脚本本</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预设脚本 */}
|
||||
<div className="script-section">
|
||||
<div className="section-header">
|
||||
<h4>预设脚本</h4>
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={presetEnabled}
|
||||
onChange={(e) => onPresetToggle(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="section-desc">绑定到当前预设</p>
|
||||
|
||||
<div className="script-list">
|
||||
<div className="script-item">
|
||||
<span className="drag-handle">☰</span>
|
||||
<span className="script-name">import-mvu</span>
|
||||
<div className="script-actions">
|
||||
<button className="btn-icon" title="启用/禁用">🔘</button>
|
||||
<button className="btn-icon" title="信息">ℹ️</button>
|
||||
<button className="btn-icon" title="编辑">✏️</button>
|
||||
<button className="btn-icon" title="更多"></button>
|
||||
<button className="btn-icon danger" title="删除">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="script-item">
|
||||
<span className="drag-handle">☰</span>
|
||||
<span className="script-name">月经周期计算器</span>
|
||||
<div className="script-actions">
|
||||
<button className="btn-icon" title="启用/禁用">🔘</button>
|
||||
<button className="btn-icon" title="信息">ℹ️</button>
|
||||
<button className="btn-icon" title="编辑">✏️</button>
|
||||
<button className="btn-icon" title="更多"></button>
|
||||
<button className="btn-icon danger" title="删除">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 工具标签页(占位)
|
||||
*/
|
||||
const ToolsTab = () => {
|
||||
return (
|
||||
<div className="tab-content tools-tab">
|
||||
<div className="empty-state">
|
||||
<h3>🔧 工具</h3>
|
||||
<p>实用工具即将推出...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 优化标签页(占位)
|
||||
*/
|
||||
const OptimizationTab = () => {
|
||||
return (
|
||||
<div className="tab-content optimization-tab">
|
||||
<div className="empty-state">
|
||||
<h3> 优化</h3>
|
||||
<p>性能优化选项即将推出...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 开发标签页(占位)
|
||||
*/
|
||||
const DevTab = () => {
|
||||
return (
|
||||
<div className="tab-content dev-tab">
|
||||
<div className="empty-state">
|
||||
<h3>🛠 开发</h3>
|
||||
<p>开发者工具即将推出...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TavernHelper;
|
||||
@@ -20,8 +20,8 @@ const Toolbar = () => {
|
||||
setColorTheme
|
||||
} = useAppLayoutStore();
|
||||
|
||||
// ✅ 从 UserStore 获取用户角色
|
||||
const { currentUserRole } = useUserStore();
|
||||
// ✅ 从 UserStore 获取用户角色和方法
|
||||
const { currentUserRole, userRoles, updateRoleName, updateRoleDescription, selectUserRole, addUserRole } = useUserStore();
|
||||
|
||||
// 从 Store 获取全局世界书
|
||||
const { globalWorldBooks } = useWorldBookStore();
|
||||
@@ -213,6 +213,57 @@ const Toolbar = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ✅ 用户角色设置面板 */}
|
||||
{activePanel === 'currentRole' && (
|
||||
<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">
|
||||
<div className="settings-section">
|
||||
<h4>选择角色</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{userRoles.map((role) => (
|
||||
<div
|
||||
key={role.id}
|
||||
onClick={() => selectUserRole(role.id)}
|
||||
style={{
|
||||
padding: '12px',
|
||||
borderRadius: '6px',
|
||||
border: currentUserRole.name === role.name ? '2px solid var(--color-primary)' : '1px solid var(--border-color)',
|
||||
background: currentUserRole.name === role.name ? 'rgba(var(--color-primary-rgb), 0.1)' : 'var(--color-bg-secondary)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: '600', marginBottom: '4px', color: 'var(--color-text-primary)' }}>
|
||||
{role.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--color-text-secondary)' }}>
|
||||
{role.description || '无描述'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="setting-section" style={{ marginTop: '20px', paddingTop: '20px', borderTop: '1px solid var(--border-color)' }}>
|
||||
<h4>💡 说明</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', color: 'var(--color-text-secondary)', fontSize: '13px', lineHeight: '1.6' }}>
|
||||
<li>点击角色即可切换当前使用的用户角色</li>
|
||||
<li>角色描述会自动用于预设的 <code>personaDescription</code> 组件</li>
|
||||
<li>设置会自动保存到浏览器本地存储</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全局世界书面板 - 使用条件渲染优化 */}
|
||||
{activePanel === 'worldBook' && (
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
|
||||
170
frontend/src/hooks/useRegexProcessor.js
Normal file
170
frontend/src/hooks/useRegexProcessor.js
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 正则处理工具
|
||||
*
|
||||
* 在前端渲染消息时应用 markdownOnly 正则规则
|
||||
* markdownOnly=true 的规则只影响显示,不影响存储的数据
|
||||
*/
|
||||
import usePresetStore from '../Store/SideBarLeft/PresetSlice';
|
||||
|
||||
// 全局缓存正则规则
|
||||
let cachedRules = [];
|
||||
let lastFetchTime = 0;
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存
|
||||
|
||||
/**
|
||||
* 获取正则规则(带缓存)
|
||||
*/
|
||||
async function fetchRules() {
|
||||
const now = Date.now();
|
||||
|
||||
// 如果缓存未过期,直接返回
|
||||
if (cachedRules.length > 0 && (now - lastFetchTime) < CACHE_DURATION) {
|
||||
return cachedRules;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/regex/rules');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.rules) {
|
||||
cachedRules = data.rules;
|
||||
lastFetchTime = now;
|
||||
return data.rules;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Regex Processor] 加载正则规则失败:', error);
|
||||
}
|
||||
|
||||
return cachedRules;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用正则规则到文本(用于前端显示)
|
||||
*
|
||||
* @param {string} text - 原始文本
|
||||
* @param {Array} rules - 所有正则规则
|
||||
* @param {number} messageDepth - 消息深度
|
||||
* @returns {string} - 处理后的文本(仅用于显示)
|
||||
*/
|
||||
function applyRulesForDisplay(text, rules, messageDepth = 0) {
|
||||
if (!text || !rules || rules.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let result = text;
|
||||
|
||||
// 筛选出适用于前端显示的规则
|
||||
const displayRules = rules.filter(rule => {
|
||||
// 检查是否禁用了
|
||||
if (rule.disabled) return false;
|
||||
|
||||
// 检查 placement 是否包含 AI_OUTPUT (2)
|
||||
const placements = rule.placement || [];
|
||||
if (!placements.includes(2)) return false;
|
||||
|
||||
// ✅ SillyTavern 逻辑:
|
||||
// - 双 false:应用到所有场景(包括显示)
|
||||
// - markdownOnly=true:只应用于显示
|
||||
// - promptOnly=true:不应用于显示
|
||||
// - 双 true:应用到所有场景(包括显示)
|
||||
if (rule.promptOnly && !rule.markdownOnly) {
|
||||
// 只影响提示词,不影响显示
|
||||
return false;
|
||||
}
|
||||
|
||||
// 其他情况都应用于显示
|
||||
// - 双 false:应用
|
||||
// - markdownOnly=true:应用
|
||||
// - 双 true:应用
|
||||
|
||||
// 检查消息深度
|
||||
if (messageDepth < (rule.minDepth || 0)) return false;
|
||||
if (rule.maxDepth !== null && rule.maxDepth !== undefined && messageDepth > rule.maxDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 按 order 排序
|
||||
displayRules.sort((a, b) => a.order - b.order);
|
||||
|
||||
// 应用规则
|
||||
for (const rule of displayRules) {
|
||||
result = applySingleRule(result, rule);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用单条正则规则
|
||||
*/
|
||||
function applySingleRule(text, rule) {
|
||||
try {
|
||||
const { findRegex, replaceString, substituteRegex, trimStrings } = rule;
|
||||
|
||||
if (!findRegex) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// 解析正则表达式和标志
|
||||
let pattern = findRegex;
|
||||
let flags = 'g'; // 默认全局匹配
|
||||
|
||||
// 提取标志(如果格式为 /pattern/flags)
|
||||
if (findRegex.startsWith('/') && findRegex.lastIndexOf('/') > 0) {
|
||||
const lastSlashIndex = findRegex.lastIndexOf('/');
|
||||
pattern = findRegex.slice(1, lastSlashIndex);
|
||||
const extractedFlags = findRegex.slice(lastSlashIndex + 1);
|
||||
if (extractedFlags) {
|
||||
flags = extractedFlags;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建正则表达式对象
|
||||
const regex = new RegExp(pattern, flags);
|
||||
|
||||
// 根据替换模式执行替换
|
||||
let replaced;
|
||||
if (substituteRegex === 1) {
|
||||
// REPLACE_FIRST: 仅替换首次匹配
|
||||
replaced = text.replace(regex, replaceString || '');
|
||||
} else {
|
||||
// REPLACE_ALL (0) 或 REPLACE_CAPTURED (2): 替换所有匹配
|
||||
replaced = text.replace(regex, replaceString || '');
|
||||
}
|
||||
|
||||
// 应用 trimStrings(删除指定的字符串)
|
||||
if (trimStrings && Array.isArray(trimStrings)) {
|
||||
for (const trimStr of trimStrings) {
|
||||
replaced = replaced.split(trimStr).join('');
|
||||
}
|
||||
}
|
||||
|
||||
return replaced;
|
||||
} catch (error) {
|
||||
console.error(`[Regex Processor] 正则规则执行错误 [${rule.scriptName}]:`, error);
|
||||
return text; // 出错时返回原文本
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文本的正则显示效果
|
||||
*
|
||||
* @param {string} originalText - 原始文本(从后端获取的,未处理的)
|
||||
* @param {number} messageDepth - 消息深度(0=最新消息)
|
||||
* @param {boolean} isUserMessage - 是否为用户消息
|
||||
* @returns {Promise<string>} - 处理后的文本(用于显示)
|
||||
*/
|
||||
export async function processTextForDisplay(originalText, messageDepth = 0, isUserMessage = false) {
|
||||
if (isUserMessage) {
|
||||
// 用户消息不应用 markdownOnly 规则
|
||||
return originalText;
|
||||
}
|
||||
|
||||
const rules = await fetchRules();
|
||||
return applyMarkdownOnlyRules(originalText, rules, messageDepth);
|
||||
}
|
||||
|
||||
export default processTextForDisplay;
|
||||
Reference in New Issue
Block a user