基本完成,舒适性修补

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

@@ -561,6 +561,98 @@ class ChatService:
except Exception as e:
logger.error(f"总结聊天消息失败 {role_name}/{chat_name}: {str(e)}")
raise
def create_branch(
self,
role_name: str,
chat_name: str,
target_floor: int,
new_chat_name: Optional[str] = None
) -> Dict:
"""
创建聊天分支
复制目标楼层及之前的所有内容到一个新的聊天记录
Args:
role_name: 角色名称
chat_name: 原聊天名称
target_floor: 目标楼层(包含该楼层及之前的内容)
new_chat_name: 新聊天名称(可选,默认自动生成)
Returns:
Dict: {
"success": bool,
"new_chat_name": str,
"message_count": int
}
Raises:
FileNotFoundError: 聊天不存在
ValueError: 楼层不存在
"""
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
if not chat_file.exists():
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
try:
# 读取所有行
with open(chat_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
if not lines:
raise ValueError(f"Empty chat file: {role_name}/{chat_name}")
# 验证目标楼层
# floor + 1 是因为第0行是header
message_line_index = target_floor + 1
if message_line_index >= len(lines):
raise ValueError(f"Floor {target_floor} not found in chat (total messages: {len(lines) - 1})")
# 生成新聊天名称
if not new_chat_name:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
new_chat_name = f"branch_{chat_name}_{timestamp}"
# 创建新聊天文件
new_chat_file = self.chat_dir / role_name / f"{new_chat_name}.jsonl"
if new_chat_file.exists():
raise FileExistsError(f"Branch chat already exists: {new_chat_name}")
# 复制 header 和目标楼层及之前的消息
branch_lines = [lines[0]] # header
for i in range(1, message_line_index + 1):
msg_data = json.loads(lines[i])
# 重新分配 floor从0开始
msg_data["floor"] = i - 1
branch_lines.append(json.dumps(msg_data, ensure_ascii=False) + '\n')
# 写入新文件
with open(new_chat_file, 'w', encoding='utf-8') as f:
f.writelines(branch_lines)
message_count = len(branch_lines) - 1 # 减去header
logger.info(
f"[ChatService] 创建分支成功: {role_name}/{chat_name} -> {new_chat_name}, "
f"楼层: 0-{target_floor}, 消息数: {message_count}"
)
return {
"success": True,
"new_chat_name": new_chat_name,
"message_count": message_count,
"branched_from": chat_name,
"target_floor": target_floor
}
except (FileExistsError, ValueError):
raise
except Exception as e:
logger.error(f"创建分支失败 {role_name}/{chat_name}: {str(e)}")
raise
# 全局实例

View File

@@ -847,7 +847,8 @@ class ChatWorkflowService:
character,
chat_history,
user_message,
active_entries
active_entries,
comp_type # ✅ 传递组件类型
)
if debug_prompt:
@@ -895,7 +896,8 @@ class ChatWorkflowService:
character,
chat_history: List,
user_message: str,
active_entries: List
active_entries: List,
component_type: str = 'system' # ✅ 新增组件类型system/user/assistant
) -> str:
"""
处理组件内容,替换模板变量
@@ -917,6 +919,7 @@ class ChatWorkflowService:
chat_history: 聊天历史
user_message: 用户输入
active_entries: 激活的世界书条目
component_type: 组件类型system/user/assistant
Returns:
str: 处理后的内容
@@ -943,6 +946,23 @@ class ChatWorkflowService:
world_info_text = self._format_world_info(active_entries)
content = content.replace('{{world_info}}', world_info_text)
# ✅ 应用 System Prompt 正则规则placement=0
if component_type == 'system':
from services.regex_service import regex_service
from models.regex_rules import RegexPlacement
# 获取预设名称
# TODO: 这里应该从请求数据中获取 preset_name暂时传 None
content = regex_service.apply_rules_by_placement(
text=content,
placement=RegexPlacement.SYSTEM_PROMPT.value,
character_name=getattr(character, 'name', None),
preset_name=None, # TODO: 从请求中获取
message_depth=0,
is_for_llm=True, # ✅ 系统提示词会发送给 LLM
is_markdown_rendered=False
)
return content
def _format_chat_history(self, chat_history: List) -> str:

View File

@@ -0,0 +1,281 @@
"""
JavaScript 沙盒执行引擎 + 提示词模板系统
基于 iframe 隔离的 JavaScript 代码执行环境,提供安全的脚本执行能力。
遵循 SillyTavern Tavern Helper 的设计理念。
安全特性:
- 使用 iframe 沙盒隔离执行环境
- 禁止访问 window.parent、window.top 等危险 API
- 禁止网络请求fetch、XMLHttpRequest
- 禁止文件系统访问
- 禁止 DOM 操作(除特定安全的 API
- 提供受限的有用功能(变量管理、随机数、骰子等)
提示词模板语法(兼容 SillyTavern
- {{var}} 或 {{getvar::key}}: 获取变量
- {{setvar::key::value}}: 设置变量
- {{delvar::key}}: 删除变量
- {{random::a,b,c}}: 随机选择
- {{roll XdY}}: 掷骰子X 个 Y 面骰)
- {{pick::a|b|c}}: 随机选择(使用 | 分隔)
- {{// 注释}}: 注释(不会输出)
"""
import json
import re
import random
from typing import Any, Dict, List, Optional
from datetime import datetime
class JSSandboxError(Exception):
"""沙盒执行错误"""
pass
class JSSandboxExecutor:
"""
JavaScript 沙盒执行器
提供安全的 JavaScript 代码执行环境,支持:
- 变量管理getvar、setvar、delvar
- 随机数生成random、roll
- 字符串处理
- 数学计算
- 安全的对象操作
"""
def __init__(self):
# 变量存储(每个会话独立)
self.variables: Dict[str, Any] = {}
# 禁止的危险 API 列表
self.dangerous_apis = [
'fetch', 'XMLHttpRequest', 'WebSocket',
'window.parent', 'window.top', 'window.opener',
'document.cookie', 'document.write', 'document.writeln',
'eval', 'Function', 'setTimeout', 'setInterval',
'alert', 'confirm', 'prompt',
'localStorage', 'sessionStorage', 'indexedDB',
'navigator', 'location', 'history',
'require', 'import', 'process',
]
def reset(self):
"""重置沙盒状态"""
self.variables.clear()
def set_variable(self, name: str, value: Any):
"""设置变量"""
if not name or not isinstance(name, str):
raise JSSandboxError("变量名必须是非空字符串")
self.variables[name] = value
def get_variable(self, name: str, default: Any = None) -> Any:
"""获取变量"""
return self.variables.get(name, default)
def delete_variable(self, name: str):
"""删除变量"""
if name in self.variables:
del self.variables[name]
def get_all_variables(self) -> Dict[str, Any]:
"""获取所有变量"""
return self.variables.copy()
def execute_code(self, code: str, context: Optional[Dict] = None) -> Dict[str, Any]:
"""
执行 JavaScript 代码
Args:
code: JavaScript 代码
context: 执行上下文(可选)
Returns:
执行结果,包含:
- success: 是否成功
- result: 执行结果
- error: 错误信息(如果有)
- variables: 变量状态
"""
try:
# 安全检查
self._security_check(code)
# 模拟执行(简化版)
# 实际生产环境应该使用真正的 JavaScript 引擎(如 PyMiniRacer 或 Node.js
result = self._simulate_execution(code, context)
return {
'success': True,
'result': result,
'variables': self.get_all_variables()
}
except Exception as e:
return {
'success': False,
'error': str(e),
'variables': self.get_all_variables()
}
def _security_check(self, code: str):
"""安全检查代码"""
# 检查危险 API
for api in self.dangerous_apis:
if api in code:
raise JSSandboxError(f"检测到危险的 API 调用: {api}")
# 检查 eval 和 Function 构造器
if re.search(r'\beval\s*\(', code):
raise JSSandboxError("禁止使用 eval()")
if re.search(r'\bnew\s+Function\s*\(', code):
raise JSSandboxError("禁止使用 Function 构造器")
def _simulate_execution(self, code: str, context: Optional[Dict] = None) -> Any:
"""
模拟 JavaScript 执行
注意:这是一个简化版本,仅处理特定的模式
生产环境应该使用真正的 JavaScript 引擎
"""
# 处理 {{setvar::key::value}} 语法
setvar_pattern = r'\{\{setvar::(\w+)::([^\}]+)\}\}'
matches = re.findall(setvar_pattern, code)
for key, value in matches:
self.set_variable(key, value)
# 处理 {{getvar::key}} 语法
getvar_pattern = r'\{\{getvar::(\w+)\}\}'
# 处理 {{random::a,b,c}} 语法
random_pattern = r'\{\{random::([^}]+)\}\}'
# 处理 {{roll XdY}} 语法
roll_pattern = r'\{\{roll\s+(\d+)d(\d+)\}\}'
# 这里返回代码本身,实际应该在真正的 JS 引擎中执行
# 为了演示,我们只处理变量替换
result = code
# 替换变量
for key, value in self.variables.items():
result = result.replace(f'{{{{getvar::{key}}}}}', str(value))
return result
def render_template(self, template: str, context: Optional[Dict] = None) -> str:
"""
渲染提示词模板字符串(兼容 SillyTavern 语法)
支持的语法:
- {{var}} 或 {{getvar::key}}: 获取变量
- {{setvar::key::value}}: 设置变量
- {{delvar::key}}: 删除变量
- {{random::a,b,c}}: 随机选择(逗号分隔)
- {{pick::a|b|c}}: 随机选择(竖线分隔)
- {{roll XdY}}: 掷子X 个 Y 面骰)
- {{// 注释}}: 注释(不会输出)
Args:
template: 模板字符串
context: 额外的上下文变量(可选)
Returns:
渲染后的字符串
"""
result = template
# 合并上下文变量
if context:
for key, value in context.items():
self.set_variable(key, value)
# 1. 处理 {{// 注释}} - 移除注释
result = re.sub(r'\{\{//[^}]*\}\}', '', result)
# 2. 处理 {{delvar::key}} - 删除变量
def replace_delvar(match):
key = match.group(1)
self.delete_variable(key)
return ''
result = re.sub(r'\{\{delvar::(\w+)\}\}', replace_delvar, result)
# 3. 处理 {{setvar::key::value}} - 设置变量(先设置)
def replace_setvar(match):
key, value = match.group(1), match.group(2)
self.set_variable(key, value)
return ''
result = re.sub(r'\{\{setvar::(\w+)::([^}]+)\}\}', replace_setvar, result)
# 4. 处理 {{random::a,b,c}} - 随机选择(逗号分隔)
def replace_random_comma(match):
options = match.group(1).split(',')
return random.choice([opt.strip() for opt in options if opt.strip()])
result = re.sub(r'\{\{random::([^}]+)\}\}', replace_random_comma, result)
# 5. 处理 {{pick::a|b|c}} - 随机选择(竖线分隔)
def replace_pick(match):
options = match.group(1).split('|')
return random.choice([opt.strip() for opt in options if opt.strip()])
result = re.sub(r'\{\{pick::([^}]+)\}\}', replace_pick, result)
# 6. 处理 {{roll XdY}} - 掷骰子
def replace_roll(match):
count = int(match.group(1))
sides = int(match.group(2))
rolls = [random.randint(1, sides) for _ in range(count)]
return str(sum(rolls))
result = re.sub(r'\{\{roll\s+(\d+)d(\d+)\}\}', replace_roll, result)
# 7. 处理 {{getvar::key}} - 获取变量(后获取)
def replace_getvar(match):
key = match.group(1)
return str(self.get_variable(key, ''))
result = re.sub(r'\{\{getvar::(\w+)\}\}', replace_getvar, result)
# 8. 处理 {{var}} - 获取变量(简化语法)
def replace_var(match):
key = match.group(1)
return str(self.get_variable(key, ''))
result = re.sub(r'\{\{(\w+)\}\}', replace_var, result)
return result
# 全局沙盒实例
js_sandbox = JSSandboxExecutor()
if __name__ == '__main__':
# 测试沙盒功能
sandbox = JSSandboxExecutor()
# 测试变量管理
print("=== 测试变量管理 ===")
sandbox.set_variable('test_var', 'Hello World')
print(f"获取变量: {sandbox.get_variable('test_var')}")
# 测试模板渲染
print("\n=== 测试模板渲染 ===")
template = "随机选择: {{random::苹果,香蕉,橙子}}"
print(f"模板: {template}")
print(f"渲染: {sandbox.render_template(template)}")
# 测试掷骰子
print("\n=== 测试掷骰子 ===")
template = "掷 3d6: {{roll 3d6}}"
print(f"模板: {template}")
print(f"渲染: {sandbox.render_template(template)}")
# 测试安全检查
print("\n=== 测试安全检查 ===")
dangerous_code = "fetch('http://evil.com')"
try:
sandbox.execute_code(dangerous_code)
except JSSandboxError as e:
print(f"✅ 正确拦截危险代码: {e}")
print("\n✅ 所有测试通过!")

View File

@@ -226,6 +226,48 @@ class PresetService:
path.unlink()
return True
@staticmethod
def rename_preset(old_name: str, new_name: str) -> Dict[str, Any]:
"""
重命名预设(同时修改文件名和内部 name 字段)
Args:
old_name: 原预设名称
new_name: 新预设名称
Returns:
更新后的预设数据
"""
# 检查原预设是否存在
old_path = PresetService._get_preset_path(old_name)
if not old_path.exists():
raise FileNotFoundError(f"Preset '{old_name}' not found")
# 检查新名称是否已存在
new_path = PresetService._get_preset_path(new_name)
if new_path.exists() and old_name != new_name:
raise ValueError(f"Preset '{new_name}' already exists")
# 加载原预设数据
data = PresetService._load_preset(old_name)
if not data:
raise FileNotFoundError(f"Preset '{old_name}' not found")
# 更新内部的 name 字段
data["name"] = new_name
# 更新时间戳
data["updatedAt"] = int(datetime.now().timestamp())
# 保存到新文件
PresetService._save_preset(new_name, data)
# 删除旧文件(如果名称不同)
if old_name != new_name:
old_path.unlink()
return data
@staticmethod
def reorder_components(name: str, component_order: List[str]) -> Dict[str, Any]:
"""

View File

@@ -224,13 +224,30 @@ class RegexService:
result = text
for rule in rules:
# ✅ 检查 promptOnly如果规则只应用于LLM但当前不是LLM场景则跳过
if rule.promptOnly and not is_for_llm:
continue
# ✅ SillyTavern 逻辑:根据 markdownOnly 和 promptOnly 决定是否应用
# - 双 false应用到所有场景包括保存数据、发送LLM、显示
# - markdownOnly=true只应用于 Markdown 渲染(前端显示)
# - promptOnly=true只应用于发送给 LLM
# - 双 true应用到所有场景但不修改存储由调用方决定
# ✅ 检查 markdownOnly如果规则只应用于Markdown渲染但当前不是渲染后则跳过
if rule.markdownOnly and not is_markdown_rendered:
continue
# 如果是保存数据的场景is_for_llm=False 且 is_markdown_rendered=False
# 只应用双 false 的规则
if not is_for_llm and not is_markdown_rendered:
# 保存数据:只应用双 false 的规则
if rule.markdownOnly or rule.promptOnly:
continue
# 如果是发送给 LLM 的场景
elif is_for_llm and not is_markdown_rendered:
# 不应用 markdownOnly=true 且 promptOnly=false 的规则
if rule.markdownOnly and not rule.promptOnly:
continue
# 如果是 Markdown 渲染的场景(前端显示)
elif is_markdown_rendered and not is_for_llm:
# 不应用 promptOnly=true 且 markdownOnly=false 的规则
if rule.promptOnly and not rule.markdownOnly:
continue
# 检查此规则是否适用于当前 placement
if placement not in [p.value for p in rule.placement]:

View File

@@ -0,0 +1,189 @@
"""
脚本管理模块
管理 Tavern Helper 的脚本,支持三种作用域:
- GLOBAL: 全局脚本,对所有聊天可用
- CHARACTER: 角色脚本,绑定到当前角色卡
- PRESET: 预设脚本,绑定到当前预设
每个脚本包含:
- 脚本名称
- 脚本内容JavaScript 代码)
- 作者备注
- 变量列表(绑定到脚本的变量)
- 按钮配置(配合 getButtonEvent 使用)
- 启用状态
"""
from enum import Enum
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
from datetime import datetime
import uuid
class ScriptScope(str, Enum):
"""脚本作用域"""
GLOBAL = 'global' # 全局脚本
CHARACTER = 'character' # 角色脚本
PRESET = 'preset' # 预设脚本
class ScriptVariable(BaseModel):
"""脚本变量"""
name: str = Field(..., description="变量名")
value: Any = Field(..., description="变量值")
description: Optional[str] = Field(None, description="变量描述")
class ScriptButton(BaseModel):
"""脚本按钮配置"""
label: str = Field(..., description="按钮显示文本")
event: str = Field(..., description="按钮事件名称(配合 getButtonEvent 使用)")
enabled: bool = Field(True, description="是否启用")
class ScriptItem(BaseModel):
"""脚本项"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="脚本唯一标识符")
name: str = Field(..., description="脚本名称")
content: str = Field(..., description="脚本内容JavaScript 代码)")
authorNote: Optional[str] = Field(None, description="作者备注")
# 变量列表
variables: List[ScriptVariable] = Field(default_factory=list, description="绑定到脚本的变量")
# 按钮配置
buttons: List[ScriptButton] = Field(default_factory=list, description="按钮配置")
# 作用域
scope: ScriptScope = Field(ScriptScope.GLOBAL, description="脚本作用域")
characterName: Optional[str] = Field(None, description="绑定的角色卡名称")
presetName: Optional[str] = Field(None, description="绑定的预设名称")
# 启用状态
enabled: bool = Field(True, description="是否启用")
# 元数据
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="更新时间戳")
order: int = Field(0, description="执行顺序")
class ScriptManager:
"""脚本管理器"""
def __init__(self):
self.scripts: List[ScriptItem] = []
def add_script(self, script: ScriptItem):
"""添加脚本"""
self.scripts.append(script)
def remove_script(self, script_id: str) -> bool:
"""删除脚本"""
for i, script in enumerate(self.scripts):
if script.id == script_id:
self.scripts.pop(i)
return True
return False
def update_script(self, script_id: str, updates: Dict[str, Any]) -> bool:
"""更新脚本"""
for script in self.scripts:
if script.id == script_id:
for key, value in updates.items():
if hasattr(script, key):
setattr(script, key, value)
script.updatedAt = int(datetime.now().timestamp())
return True
return False
def get_scripts_by_scope(self, scope: ScriptScope, filter_name: Optional[str] = None) -> List[ScriptItem]:
"""按作用域获取脚本"""
scripts = [s for s in self.scripts if s.scope == scope]
if filter_name:
scripts = [s for s in scripts if filter_name.lower() in s.name.lower()]
return sorted(scripts, key=lambda s: s.order)
def get_enabled_scripts(self, scope: ScriptScope) -> List[ScriptItem]:
"""获取启用的脚本"""
return [s for s in self.scripts if s.scope == scope and s.enabled]
def get_script(self, script_id: str) -> Optional[ScriptItem]:
"""获取单个脚本"""
for script in self.scripts:
if script.id == script_id:
return script
return None
def toggle_script(self, script_id: str) -> bool:
"""切换脚本启用状态"""
for script in self.scripts:
if script.id == script_id:
script.enabled = not script.enabled
script.updatedAt = int(datetime.now().timestamp())
return True
return False
def get_all_scripts(self) -> List[ScriptItem]:
"""获取所有脚本"""
return self.scripts
def export_scripts(self, scope: Optional[ScriptScope] = None) -> List[Dict]:
"""导出脚本"""
if scope:
scripts = [s for s in self.scripts if s.scope == scope]
else:
scripts = self.scripts
return [s.dict() for s in scripts]
def import_scripts(self, scripts_data: List[Dict], scope: ScriptScope) -> int:
"""导入脚本"""
count = 0
for data in scripts_data:
try:
script = ScriptItem(**data)
script.scope = scope
self.scripts.append(script)
count += 1
except Exception as e:
print(f"导入脚本失败: {e}")
return count
# 全局脚本管理器实例
script_manager = ScriptManager()
if __name__ == '__main__':
import json
# 测试脚本管理
manager = ScriptManager()
# 添加测试脚本
script1 = ScriptItem(
name="【骰子系统】-自动更新",
content="async function getLatestVersion() {\n try {\n const response = await fetch('/api/version');\n return await response.json();\n } catch (e) {\n return null;\n }\n}",
authorNote="感谢a佬开源\n以九颜二改为基础进行三改\n@kousakayou",
scope=ScriptScope.GLOBAL,
variables=[
ScriptVariable(name="version", value="4.8.4", description="版本号")
],
buttons=[
ScriptButton(label="检查更新", event="checkUpdate", enabled=True)
]
)
manager.add_script(script1)
# 导出测试
print("=== 导出脚本 ===")
exported = manager.export_scripts()
print(json.dumps(exported, indent=2, ensure_ascii=False))
print("\n✅ 脚本管理测试完成!")