基本完成,舒适性修补
This commit is contained in:
@@ -389,6 +389,35 @@ async def _save_messages(
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
# ✅ 应用双 false 的正则规则(永久修改存储数据)
|
||||
from services.regex_service import regex_service
|
||||
from models.regex_rules import RegexPlacement
|
||||
|
||||
# 获取预设名称
|
||||
preset_config = request_data.get("presetConfig", {})
|
||||
preset_name = preset_config.get("selectedPreset")
|
||||
|
||||
# 计算消息深度
|
||||
floor = request_data.get("floor", 0)
|
||||
message_depth = 0 # AI 回复是最新消息,深度为 0
|
||||
|
||||
# ✅ 应用 AI Output 正则规则(placement=2)
|
||||
# 只应用双 false 的规则(markdownOnly=false 且 promptOnly=false)
|
||||
processed_ai_response = regex_service.apply_rules_by_placement(
|
||||
text=ai_response,
|
||||
placement=RegexPlacement.AI_OUTPUT.value,
|
||||
character_name=role_name,
|
||||
preset_name=preset_name,
|
||||
message_depth=message_depth,
|
||||
is_for_llm=False, # ✅ 不是发送给 LLM,是保存数据
|
||||
is_markdown_rendered=False # ✅ 不是 Markdown 渲染后
|
||||
)
|
||||
|
||||
# 如果处理后的内容与原始内容不同,说明有双 false 规则被应用
|
||||
if processed_ai_response != ai_response:
|
||||
print(f"[Regex] ✅ 已应用双 false 正则规则(永久修改存储数据)")
|
||||
ai_response = processed_ai_response
|
||||
|
||||
# ✅ 检查是否是重roll模式(targetFloor 存在且不为 null)
|
||||
target_floor = request_data.get("floor")
|
||||
is_reroll = target_floor is not None
|
||||
|
||||
@@ -134,3 +134,45 @@ async def update_table_data(role_name: str, chat_name: str, table_update: dict):
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{role_name}/{chat_name}/branch", status_code=status.HTTP_201_CREATED)
|
||||
async def branch_chat(role_name: str, chat_name: str, branch_data: dict):
|
||||
"""
|
||||
创建聊天分支
|
||||
|
||||
复制当前楼层及之前的所有内容到一个新的聊天记录
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 原聊天名称
|
||||
branch_data: {
|
||||
"target_floor": int, # 目标楼层(包含该楼层及之前的内容)
|
||||
"new_chat_name": str # 新聊天名称(可选,默认自动生成)
|
||||
}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"new_chat_name": str,
|
||||
"message_count": int
|
||||
}
|
||||
"""
|
||||
try:
|
||||
target_floor = branch_data.get("target_floor")
|
||||
new_chat_name = branch_data.get("new_chat_name")
|
||||
|
||||
if target_floor is None:
|
||||
raise HTTPException(status_code=400, detail="缺少 target_floor 参数")
|
||||
|
||||
# 调用服务层创建分支
|
||||
result = chat_service.create_branch(role_name, chat_name, target_floor, new_chat_name)
|
||||
|
||||
return result
|
||||
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"创建分支失败: {str(e)}")
|
||||
|
||||
@@ -66,6 +66,27 @@ async def update_preset(preset_name: str, update_data: dict):
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/{preset_name}/rename")
|
||||
async def rename_preset(preset_name: str, rename_data: dict):
|
||||
"""重命名预设(同时修改文件名和内部 name 字段)"""
|
||||
try:
|
||||
new_name = rename_data.get("newName")
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=400, detail="newName is required")
|
||||
|
||||
# 清理新名称(去掉可能的时间戳和后缀)
|
||||
import re
|
||||
clean_name = re.sub(r'_\d{10,13}$', '', new_name.replace('.json', ''))
|
||||
|
||||
updated_preset = PresetService.rename_preset(preset_name, clean_name)
|
||||
return {"success": True, "preset": updated_preset, "newName": clean_name}
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete("/{preset_name}")
|
||||
async def delete_preset(preset_name: str):
|
||||
"""删除指定预设"""
|
||||
|
||||
@@ -121,27 +121,61 @@ async def get_preset_ruleset(preset_name: str):
|
||||
|
||||
@router.post("/rules")
|
||||
async def add_rule(request: RuleUpdateRequest):
|
||||
"""添加新规则"""
|
||||
"""添加或更新规则"""
|
||||
try:
|
||||
regex_service.save_ruleset(
|
||||
RegexRuleset(
|
||||
# 获取现有的规则集
|
||||
existing_ruleset = None
|
||||
if request.scope == RegexScope.GLOBAL:
|
||||
# 对于全局作用域,查找是否已有同名规则集
|
||||
for ruleset_id, ruleset in regex_service.global_rulesets.items():
|
||||
if ruleset.name == request.rule.scriptName:
|
||||
existing_ruleset = ruleset
|
||||
break
|
||||
elif request.scope == RegexScope.CHARACTER and request.name:
|
||||
if request.name in regex_service.character_rulesets:
|
||||
existing_ruleset = regex_service.character_rulesets[request.name]
|
||||
elif request.scope == RegexScope.PRESET and request.name:
|
||||
if request.name in regex_service.preset_rulesets:
|
||||
existing_ruleset = regex_service.preset_rulesets[request.name]
|
||||
|
||||
if existing_ruleset:
|
||||
# 如果已存在同名规则集,则更新其中的规则
|
||||
updated_rules = []
|
||||
rule_found = False
|
||||
for rule in existing_ruleset.rules:
|
||||
if rule.id == request.rule.id:
|
||||
# 更新现有规则
|
||||
updated_rules.append(request.rule)
|
||||
rule_found = True
|
||||
else:
|
||||
# 保留其他规则
|
||||
updated_rules.append(rule)
|
||||
|
||||
if not rule_found:
|
||||
# 如果没有找到相同ID的规则,则添加新规则
|
||||
updated_rules.append(request.rule)
|
||||
|
||||
# 更新规则集
|
||||
existing_ruleset.rules = updated_rules
|
||||
regex_service.save_ruleset(existing_ruleset, request.scope, request.name)
|
||||
else:
|
||||
# 如果不存在同名规则集,则创建新的规则集
|
||||
new_ruleset = RegexRuleset(
|
||||
id=request.rule.id,
|
||||
name=request.rule.scriptName,
|
||||
rules=[request.rule]
|
||||
),
|
||||
request.scope,
|
||||
request.name
|
||||
)
|
||||
)
|
||||
regex_service.save_ruleset(new_ruleset, request.scope, request.name)
|
||||
|
||||
# 重新加载规则
|
||||
regex_service._load_all_rules()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "规则添加成功"
|
||||
"message": "规则保存成功"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"添加规则失败: {e}")
|
||||
logger.error(f"保存规则失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# 全局实例
|
||||
|
||||
@@ -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:
|
||||
|
||||
281
backend/services/js_sandbox.py
Normal file
281
backend/services/js_sandbox.py
Normal 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✅ 所有测试通过!")
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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]:
|
||||
|
||||
189
backend/services/script_manager.py
Normal file
189
backend/services/script_manager.py
Normal 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✅ 脚本管理测试完成!")
|
||||
Reference in New Issue
Block a user