基本完成,舒适性修补

This commit is contained in:
2026-05-10 00:09:29 +08:00
parent 9faccc2c03
commit d6745b45a5
78 changed files with 3133 additions and 8850 deletions

View File

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

View File

@@ -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;
});
}
}),
{

View File

@@ -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 ==================== */

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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>

View File

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

View File

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

View File

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

View File

@@ -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"] {

View File

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

View File

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

View File

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

View File

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

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