🎨 优化正则模块和前端渲染功能

Signed-off-by: Echo <1711788888@qq.com>
This commit is contained in:
2026-03-03 03:40:03 +08:00
parent 3f8220340e
commit cbb4034a91
13 changed files with 613 additions and 95 deletions

View File

@@ -19,12 +19,13 @@ type Conversation struct {
Title string `gorm:"type:varchar(200)" json:"title"` // 对话标题
// 对话配置
PresetID *uint `gorm:"index" json:"presetId"` // 使用的预设ID
WorldbookID *uint `gorm:"index" json:"worldbookId"` // 使用的世界书ID
AIProvider string `gorm:"type:varchar(50)" json:"aiProvider"` // AI提供商 (openai/anthropic)
Model string `gorm:"type:varchar(100)" json:"model"` // 使用的模型
Settings datatypes.JSON `gorm:"type:jsonb" json:"settings"` // 对话设置 (temperature等)
WorldbookEnabled bool `gorm:"default:false" json:"worldbookEnabled"` // 是否启用世界书
PresetID *uint `gorm:"index" json:"presetId"` // 使用的预设ID
WorldbookID *uint `gorm:"index" json:"worldbookId"` // 使用的世界书ID
AIProvider string `gorm:"type:varchar(50)" json:"aiProvider"` // AI提供商 (openai/anthropic)
Model string `gorm:"type:varchar(100)" json:"model"` // 使用的模型
Settings datatypes.JSON `gorm:"type:jsonb" json:"settings"` // 对话设置 (temperature等)
WorldbookEnabled bool `gorm:"default:false" json:"worldbookEnabled"` // 是否启用世界书
Variables datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"variables"` // 变量存储 ({{setvar::}}/{{getvar::}})
// 统计信息
MessageCount int `gorm:"default:0" json:"messageCount"` // 消息数量

View File

@@ -101,14 +101,10 @@ func (s *ConversationService) CreateConversation(userID uint, req *request.Creat
userName = user.NickName
}
// 应用输出阶段正则脚本处理开场白
// 【重要】不再应用正则脚本处理开场白,保留原始内容
// 让前端来处理 <Status_block> 和 <maintext> 的渲染
processedFirstMes := character.FirstMes
var regexService RegexScriptService
outputScripts, err := regexService.GetScriptsForPlacement(userID, 1, &character.ID, nil)
if err == nil && len(outputScripts) > 0 {
processedFirstMes = regexService.ExecuteScripts(outputScripts, processedFirstMes, userName, character.Name)
global.GVA_LOG.Info(fmt.Sprintf("开场白应用正则脚本: 原始长度=%d, 处理后长度=%d", len(character.FirstMes), len(processedFirstMes)))
}
global.GVA_LOG.Info(fmt.Sprintf("[开场白] 保留原始内容,长度=%d", len(processedFirstMes)))
firstMessage := app.Message{
ConversationID: conversation.ID,
@@ -418,16 +414,75 @@ func (s *ConversationService) SendMessage(userID, conversationID uint, req *requ
return nil, err
}
// 应用显示阶段的正则脚本 (Placement 3)
displayContent := assistantMessage.Content
displayScripts, err := regexService.GetScriptsForPlacement(userID, 3, &conversation.CharacterID, nil)
if err == nil && len(displayScripts) > 0 {
displayContent = regexService.ExecuteScripts(displayScripts, displayContent, userName, character.Name)
global.GVA_LOG.Info(fmt.Sprintf("应用了 %d 个显示阶段正则脚本", len(displayScripts)))
// 提取并保存变量 (从 AI 回复中提取 {{setvar::key::value}})
newVars, cleanedContent := regexService.ExtractSetVars(assistantMessage.Content)
if len(newVars) > 0 {
// 加载现有变量
var existingVars map[string]string
if len(conversation.Variables) > 0 {
json.Unmarshal(conversation.Variables, &existingVars)
}
if existingVars == nil {
existingVars = make(map[string]string)
}
// 合并新变量
for k, v := range newVars {
existingVars[k] = v
}
// 保存回数据库
varsJSON, _ := json.Marshal(existingVars)
global.GVA_DB.Model(&conversation).Update("variables", datatypes.JSON(varsJSON))
global.GVA_LOG.Info(fmt.Sprintf("提取并保存了 %d 个变量: %v", len(newVars), newVars))
}
// 先替换 {{getvar::}} 为实际变量值(在应用正则脚本之前)
var currentVars map[string]string
if len(conversation.Variables) > 0 {
json.Unmarshal(conversation.Variables, &currentVars)
}
displayContent := cleanedContent // 使用清理后的内容(移除了 {{setvar::}}
if currentVars != nil {
displayContent = regexService.SubstituteGetVars(displayContent, currentVars)
global.GVA_LOG.Info(fmt.Sprintf("替换了 {{getvar::}} 变量"))
}
// 提取 <Status_block> 和 <maintext>,保护它们不被正则脚本修改
statusBlock, contentWithoutStatus := regexService.ExtractStatusBlock(displayContent)
maintext, contentWithoutMaintext := regexService.ExtractMaintext(contentWithoutStatus)
global.GVA_LOG.Info(fmt.Sprintf("[状态栏] 提取到 Status_block 长度: %d, maintext 长度: %d", len(statusBlock), len(maintext)))
if len(statusBlock) > 0 {
previewLen := len(statusBlock)
if previewLen > 100 {
previewLen = 100
}
global.GVA_LOG.Info(fmt.Sprintf("[状态栏] Status_block 内容预览: %s", statusBlock[:previewLen]))
}
// 应用显示阶段的正则脚本 (Placement 3) - 只处理剩余内容
finalProcessedContent := contentWithoutMaintext
displayScripts, err2 := regexService.GetScriptsForPlacement(userID, 3, &conversation.CharacterID, nil)
if err2 == nil && len(displayScripts) > 0 {
global.GVA_LOG.Info(fmt.Sprintf("[状态栏] 应用正则脚本前的内容长度: %d", len(finalProcessedContent)))
finalProcessedContent = regexService.ExecuteScripts(displayScripts, finalProcessedContent, userName, character.Name)
global.GVA_LOG.Info(fmt.Sprintf("[状态栏] 应用了 %d 个显示阶段正则脚本,处理后内容长度: %d", len(displayScripts), len(finalProcessedContent)))
}
// 重新组装内容maintext + Status_block + 处理后的内容
finalContent := finalProcessedContent
if maintext != "" {
finalContent = "<maintext>" + maintext + "</maintext>\n\n" + finalContent
}
if statusBlock != "" {
finalContent = finalContent + "\n\n<Status_block>\n" + statusBlock + "\n</Status_block>"
}
global.GVA_LOG.Info(fmt.Sprintf("[状态栏] 最终返回内容长度: %d", len(finalContent)))
resp := response.ToMessageResponse(&assistantMessage)
resp.Content = displayContent // 使用处理后的显示内容
resp.Content = finalContent // 使用处理后的显示内容
return &resp, nil
}
@@ -577,31 +632,10 @@ func (s *ConversationService) callAIService(conversation app.Conversation, chara
}
global.GVA_LOG.Info(fmt.Sprintf("========== AI返回的完整内容 ==========\n%s\n==========================================", aiResponse))
// 应用输出阶段的正则脚本 (Placement 1)
var regexService RegexScriptService
global.GVA_LOG.Info(fmt.Sprintf("查询输出阶段正则脚本: userID=%d, placement=1, charID=%d", conversation.UserID, conversation.CharacterID))
outputScripts, err := regexService.GetScriptsForPlacement(conversation.UserID, 1, &conversation.CharacterID, nil)
if err != nil {
global.GVA_LOG.Error(fmt.Sprintf("查询输出阶段正则脚本失败: %v", err))
} else {
global.GVA_LOG.Info(fmt.Sprintf("找到 %d 个输出阶段正则脚本", len(outputScripts)))
if len(outputScripts) > 0 {
// 获取用户信息
var user app.AppUser
err = global.GVA_DB.Where("id = ?", conversation.UserID).First(&user).Error
userName := ""
if err == nil {
userName = user.Username
if userName == "" {
userName = user.NickName
}
}
originalResponse := aiResponse
aiResponse = regexService.ExecuteScripts(outputScripts, aiResponse, userName, character.Name)
global.GVA_LOG.Info(fmt.Sprintf("应用了 %d 个输出阶段正则脚本,原文: %s, 处理后: %s", len(outputScripts), originalResponse, aiResponse))
}
}
// 【重要】不再应用 Placement 1 正则脚本,保留 AI 原始回复
// 让 SendMessage 函数来提取和保护 <Status_block> 和 <maintext>
// 前端会负责渲染这些标签
global.GVA_LOG.Info("[AI回复] 保留原始内容,不应用输出阶段正则脚本")
return aiResponse, nil
}

View File

@@ -311,6 +311,78 @@ func (s *RegexScriptService) substituteMacros(text string, userName string, char
return result
}
// ExtractSetVars 从文本中提取 {{setvar::key::value}} 并返回变量映射和清理后的文本
func (s *RegexScriptService) ExtractSetVars(text string) (map[string]string, string) {
vars := make(map[string]string)
// 匹配 {{setvar::key::value}}
re := regexp.MustCompile(`\{\{setvar::([^:]+)::([^}]*)\}\}`)
matches := re.FindAllStringSubmatch(text, -1)
for _, match := range matches {
if len(match) == 3 {
key := strings.TrimSpace(match[1])
value := match[2]
vars[key] = value
}
}
// 从文本中移除所有 {{setvar::}} 标记
cleanText := re.ReplaceAllString(text, "")
return vars, cleanText
}
// SubstituteGetVars 替换文本中的 {{getvar::key}} 为实际变量值
func (s *RegexScriptService) SubstituteGetVars(text string, variables map[string]string) string {
result := text
// 匹配 {{getvar::key}}
re := regexp.MustCompile(`\{\{getvar::([^}]+)\}\}`)
result = re.ReplaceAllStringFunc(result, func(match string) string {
matches := re.FindStringSubmatch(match)
if len(matches) == 2 {
key := strings.TrimSpace(matches[1])
if value, ok := variables[key]; ok {
return value
}
}
return "" // 如果变量不存在,返回空字符串
})
return result
}
// ExtractStatusBlock 提取 <Status_block> 中的 YAML 数据
func (s *RegexScriptService) ExtractStatusBlock(text string) (string, string) {
// 匹配 <Status_block>...</Status_block>
re := regexp.MustCompile(`(?s)<Status_block>\s*(.*?)\s*</Status_block>`)
matches := re.FindStringSubmatch(text)
if len(matches) == 2 {
statusBlock := strings.TrimSpace(matches[1])
cleanText := re.ReplaceAllString(text, "")
return statusBlock, cleanText
}
return "", text
}
// ExtractMaintext 提取 <maintext> 中的内容
func (s *RegexScriptService) ExtractMaintext(text string) (string, string) {
// 匹配 <maintext>...</maintext>
re := regexp.MustCompile(`(?s)<maintext>\s*(.*?)\s*</maintext>`)
matches := re.FindStringSubmatch(text)
if len(matches) == 2 {
maintext := strings.TrimSpace(matches[1])
cleanText := re.ReplaceAllString(text, "")
return maintext, cleanText
}
return "", text
}
// GetScriptsForPlacement 获取指定阶段的脚本
func (s *RegexScriptService) GetScriptsForPlacement(userID uint, placement int, charID *uint, presetID *uint) ([]app.RegexScript, error) {
var scripts []app.RegexScript

View File

@@ -63,7 +63,7 @@ export interface UpdateCharacterRequest {
postHistoryInstructions?: string
tags?: string[]
alternateGreetings?: string[]
characterBook?: Record<string, any>
characterBook?: Record<string, any> | null
extensions?: Record<string, any>
isPublic?: boolean
}

View File

@@ -75,6 +75,24 @@ export default function ChatArea({ conversation, character, onConversationUpdate
scrollToBottom()
}, [messages])
// 监听状态栏按钮点击事件
useEffect(() => {
const handleStatusBarAction = (event: CustomEvent) => {
const action = event.detail
if (action && typeof action === 'string' && !sending) {
console.log('[ChatArea] 收到状态栏操作,自动发送消息:', action)
setInputValue(action)
// 延迟发送,确保 inputValue 已更新
setTimeout(() => {
handleSendMessage(action)
}, 100)
}
}
window.addEventListener('sendMessageFromStatusBar', handleStatusBarAction as EventListener)
return () => window.removeEventListener('sendMessageFromStatusBar', handleStatusBarAction as EventListener)
}, [sending])
const loadMessages = async () => {
try {
setLoading(true)
@@ -185,11 +203,10 @@ export default function ChatArea({ conversation, character, onConversationUpdate
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
const handleSend = async () => {
if (!inputValue.trim() || sending) return
const handleSendMessage = async (message: string) => {
if (!message.trim() || sending) return
const userMessage = inputValue.trim()
setInputValue('')
const userMessage = message.trim()
setSending(true)
const tempUserMessage: Message = {
@@ -277,6 +294,13 @@ export default function ChatArea({ conversation, character, onConversationUpdate
}
}
const handleSend = async () => {
if (!inputValue.trim() || sending) return
const userMessage = inputValue.trim()
setInputValue('')
await handleSendMessage(userMessage)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !sending && inputValue.trim()) {
e.preventDefault()
@@ -644,7 +668,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
>
<MessageContent
content={msg.content}
role={msg.role}
role={msg.role as 'user' | 'assistant'}
onChoiceSelect={(choice) => {
setInputValue(choice)
textareaRef.current?.focus()

View File

@@ -127,21 +127,35 @@ function parseChoices(content: string): { choices: Choice[]; cleanContent: strin
return { choices, cleanContent }
}
// 清理脚本输出内容
function cleanScriptOutput(content: string): string {
// 移除 <UpdateVariable>...</UpdateVariable> 块
let cleaned = content.replace(/<UpdateVariable>[\s\S]*?<\/UpdateVariable>/gi, '')
// 提取 <maintext> 内容
function extractMaintext(content: string): { maintext: string; cleanContent: string } {
const maintextRegex = /<maintext>([\s\S]*?)<\/maintext>/i
const match = content.match(maintextRegex)
// 移除 <Analysis>...</Analysis> 块
cleaned = cleaned.replace(/<Analysis>[\s\S]*?<\/Analysis>/gi, '')
if (!match) {
return { maintext: '', cleanContent: content }
}
// 移除 _.set() 调用
cleaned = cleaned.replace(/^\s*_.set\([^)]+\);\s*$/gm, '')
return cleaned.trim()
const maintext = match[1].trim()
const cleanContent = content.replace(maintextRegex, '').trim()
return { maintext, cleanContent }
}
// 解析状态面板数据
// 提取 <Status_block> YAML 数据
function extractStatusBlock(content: string): { statusYaml: string; cleanContent: string } {
const statusRegex = /<Status_block>\s*([\s\S]*?)\s*<\/Status_block>/i
const match = content.match(statusRegex)
if (!match) {
return { statusYaml: '', cleanContent: content }
}
const statusYaml = match[1].trim()
const cleanContent = content.replace(statusRegex, '').trim()
return { statusYaml, cleanContent }
}
// 解析状态面板数据JSON 格式 - 保留兼容性)
function parseStatusPanel(content: string): { status: any; cleanContent: string } {
const statusRegex = /<status_current_variable>([\s\S]*?)<\/status_current_variable>/i
const match = content.match(statusRegex)
@@ -167,28 +181,49 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
const [allowScript, setAllowScript] = useState(false)
const [choices, setChoices] = useState<Choice[]>([])
const [displayContent, setDisplayContent] = useState(content)
const [remainingText, setRemainingText] = useState('')
const [statusPanel, setStatusPanel] = useState<any>(null)
const [statusYaml, setStatusYaml] = useState<string>('')
const [maintext, setMaintext] = useState<string>('')
const iframeRef = useRef<HTMLIFrameElement>(null)
const statusIframeRef = useRef<HTMLIFrameElement>(null)
useEffect(() => {
console.log('[MessageContent] 原始内容:', content)
let processedContent = content
// 解析状态面板
const { status, cleanContent: contentAfterStatus } = parseStatusPanel(processedContent)
console.log('[MessageContent] 状态面板:', status)
setStatusPanel(status)
// 提取 <maintext> 内容
const { maintext: extractedMaintext, cleanContent: contentAfterMaintext } = extractMaintext(processedContent)
if (extractedMaintext) {
console.log('[MessageContent] 提取到 maintext:', extractedMaintext)
setMaintext(extractedMaintext)
processedContent = contentAfterMaintext
}
// 提取 <Status_block> YAML 数据
const { statusYaml: extractedYaml, cleanContent: contentAfterStatus } = extractStatusBlock(processedContent)
if (extractedYaml) {
console.log('[MessageContent] 提取到 Status_block YAML:', extractedYaml)
setStatusYaml(extractedYaml)
processedContent = contentAfterStatus
}
// 解析状态面板JSON 格式 - 保留兼容性)
const { status, cleanContent: contentAfterStatusPanel } = parseStatusPanel(processedContent)
if (status) {
console.log('[MessageContent] 状态面板:', status)
setStatusPanel(status)
processedContent = contentAfterStatusPanel
}
// 解析选择项
const { choices: parsedChoices, cleanContent } = parseChoices(contentAfterStatus)
setChoices(parsedChoices)
console.log('[MessageContent] 选择项:', parsedChoices)
// 清理脚本输出
const finalContent = cleanScriptOutput(cleanContent)
console.log('[MessageContent] 清理后内容:', finalContent)
// 直接使用清理后的内容,不再进行脚本输出清理
const finalContent = cleanContent
console.log('[MessageContent] 最终内容:', finalContent)
// 检测是否包含 HTML 标签或代码块
const htmlRegex = /<[^>]+>/g
@@ -277,7 +312,6 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
}
setDisplayContent(renderedContent)
setRemainingText('')
setHasHtml(hasHtmlTags || hasCodeBlocks)
setHasScript(hasScriptContent)
@@ -336,6 +370,30 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
max-width: 100% !important;
}
</style>
<script>
// 监听来自父页面的消息(用于更新状态栏)
window.addEventListener('message', function(event) {
if (event.data.type === 'updateStatus') {
// 更新状态栏中的变量值
Object.keys(event.data.variables || {}).forEach(function(key) {
var elements = document.querySelectorAll('[data-var="' + key + '"]');
elements.forEach(function(el) {
el.textContent = event.data.variables[key];
});
});
}
});
// 向父页面发送消息的辅助函数
function sendToParent(type, data) {
window.parent.postMessage({ type: type, data: data }, '*');
}
// 用户操作触发函数(供状态栏按钮调用)
function onPlayerAction(action) {
sendToParent('playerAction', { action: action });
}
</script>
</head>
<body>
${displayContent}
@@ -354,6 +412,239 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
}
}
// 渲染状态栏 iframe使用 doc.write 动态注入 YAML 数据)
const renderStatusBar = () => {
if (!statusIframeRef.current || !statusYaml) return
console.log('[MessageContent] 开始渲染状态栏YAML 数据长度:', statusYaml.length)
const iframe = statusIframeRef.current
const doc = iframe.contentDocument || iframe.contentWindow?.document
if (doc) {
// 使用 doc.write() 分步注入,避免模板字符串的转义问题
doc.open()
// 1. 写入 HTML 头部
doc.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"><\/script>
<script src="https://cdn.tailwindcss.com"><\/script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
body {
margin: 0;
padding: 16px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: transparent;
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
}
.status-block {
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.status-title {
font-size: 16px;
font-weight: 600;
color: rgba(157, 124, 245, 1);
}
.status-info {
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
margin: 4px 0;
}
.character-card {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 12px;
margin: 8px 0;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.character-name {
font-weight: 600;
font-size: 15px;
margin-bottom: 8px;
color: rgba(157, 124, 245, 1);
}
.attribute {
margin: 4px 0;
font-size: 13px;
line-height: 1.5;
}
.attribute-key {
color: rgba(157, 124, 245, 0.8);
font-weight: 500;
}
.option-item {
padding: 8px 12px;
margin: 4px 0;
background: rgba(157, 124, 245, 0.1);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
border-left: 2px solid rgba(157, 124, 245, 0.3);
}
.option-item:hover {
background: rgba(157, 124, 245, 0.2);
border-left-color: rgba(157, 124, 245, 0.7);
}
</style>
</head>
<body>
`)
// 2. 创建 YAML script 标签并直接设置 textContent避免 HTML 转义)
doc.write('<script id="yaml-data-source" type="text/yaml"><\/script>')
// 3. 写入渲染脚本
doc.write(`
<script>
// 向父页面发送消息
function sendToParent(type, data) {
window.parent.postMessage({ type: type, data: data }, '*');
}
// 用户操作触发函数
function onPlayerAction(action) {
console.log('[StatusBar] 发送操作:', action);
sendToParent('playerAction', { action: action });
}
// 立即执行渲染
(function() {
try {
const yamlScript = document.getElementById('yaml-data-source');
if (!yamlScript || !yamlScript.textContent.trim()) {
document.body.innerHTML = '<div style="padding:20px;text-align:center;color:rgba(255,255,255,0.5);">状态栏数据加载中...</div>';
return;
}
const yamlData = jsyaml.load(yamlScript.textContent);
if (!yamlData || Object.keys(yamlData).length === 0) {
document.body.innerHTML = '<div style="padding:20px;text-align:center;color:rgba(255,255,255,0.5);">状态栏数据为空</div>';
return;
}
// 渲染状态栏
const rootKey = Object.keys(yamlData)[0];
const data = yamlData[rootKey];
let html = '<div class="status-block">';
// 渲染标题
html += '<div class="status-header">';
html += '<div class="status-title">' + rootKey + '</div>';
html += '</div>';
// 渲染基本信息(日期、地点等)
Object.entries(data).forEach(([key, value]) => {
if (key.includes('日期') || key.includes('时间') || key.includes('地点') || key.includes('位置')) {
html += '<div class="status-info">' + value + '</div>';
}
});
// 渲染用户列表
if (data['用户列表'] && Array.isArray(data['用户列表'])) {
html += '<div style="margin-top:12px;">';
data['用户列表'].forEach(userItem => {
const userData = userItem['用户'] || userItem;
if (typeof userData === 'object') {
html += '<div class="character-card">';
const userName = userData['名字'] || '未知角色';
html += '<div class="character-name">' + userName + '</div>';
Object.entries(userData).forEach(([k, v]) => {
if (k !== '名字') {
if (Array.isArray(v)) {
html += '<div class="attribute"><span class="attribute-key">' + k + ':</span></div>';
v.forEach(item => {
if (typeof item === 'object') {
Object.values(item).forEach(val => {
html += '<div style="margin-left:12px;font-size:12px;">' + val + '</div>';
});
} else {
html += '<div style="margin-left:12px;font-size:12px;">' + item + '</div>';
}
});
} else {
html += '<div class="attribute"><span class="attribute-key">' + k + ':</span> ' + v + '</div>';
}
}
});
html += '</div>';
}
});
html += '</div>';
}
// 渲染行动选项
if (data['行动选项'] && typeof data['行动选项'] === 'object') {
const actionOptions = data['行动选项'];
html += '<div style="margin-top:12px;">';
html += '<div style="font-weight:600;margin-bottom:8px;color:rgba(157,124,245,1);">';
html += (actionOptions['名字'] || '角色') + ' 的行动选项';
html += '</div>';
if (actionOptions['选项'] && Array.isArray(actionOptions['选项'])) {
actionOptions['选项'].forEach(option => {
const escapedOption = option.replace(/'/g, "\\\\'").replace(/"/g, '&quot;');
html += '<div class="option-item" onclick="onPlayerAction(\\'' + escapedOption + '\\')">' + option + '</div>';
});
}
html += '</div>';
}
html += '</div>';
document.body.innerHTML = html;
console.log('[StatusBar] 渲染成功');
} catch (error) {
console.error('[StatusBar] 渲染失败:', error);
document.body.innerHTML = '<div style="padding:20px;text-align:center;color:rgba(255,100,100,0.8);">状态栏渲染失败: ' + error.message + '</div>';
}
})();
<\/script>
</body>
</html>
`)
doc.close()
// 4. 关键步骤:直接设置 YAML 数据到 script 标签的 textContent
// 这样可以避免 HTML 转义问题
const yamlScript = doc.getElementById('yaml-data-source')
if (yamlScript) {
yamlScript.textContent = statusYaml
console.log('[MessageContent] YAML 数据已注入,长度:', statusYaml.length)
} else {
console.error('[MessageContent] 找不到 yaml-data-source 元素')
}
// 5. 自动调整 iframe 高度
setTimeout(() => {
if (doc.body) {
const height = doc.body.scrollHeight
iframe.style.height = `${Math.max(height + 32, 200)}px`
console.log('[MessageContent] iframe 高度已调整:', iframe.style.height)
}
}, 300)
}
}
useEffect(() => {
if (allowScript && hasScript && iframeRef.current) {
// 延迟渲染确保 iframe 已挂载
@@ -363,6 +654,15 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
}
}, [allowScript, hasScript, displayContent])
useEffect(() => {
if (statusYaml && statusIframeRef.current) {
// 延迟渲染确保 iframe 已挂载
setTimeout(() => {
renderStatusBar()
}, 50)
}
}, [statusYaml])
// 如果是用户消息,直接显示纯文本
if (role === 'user') {
return <p className="text-sm leading-relaxed whitespace-pre-wrap break-words overflow-wrap-anywhere">{content}</p>
@@ -451,7 +751,7 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
</div>
)}
{/* 状态面板 */}
{/* 状态面板JSON 格式 - 保留兼容性) */}
{statusPanel && (
<div className="mt-4 p-4 glass rounded-lg border border-secondary/20">
<div className="text-sm font-medium text-secondary mb-3"></div>
@@ -478,6 +778,31 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
</div>
</div>
)}
{/* 状态栏YAML 格式 - SillyTavern 兼容) */}
{statusYaml && (
<div className="mt-4 w-full border border-white/10 rounded-lg bg-black/20 overflow-hidden">
<iframe
ref={statusIframeRef}
className="w-full"
sandbox="allow-scripts allow-same-origin"
style={{ minHeight: '200px', border: 'none' }}
/>
</div>
)}
{/* maintext 内容(如果提取到) */}
{maintext && !statusYaml && (
<div className="mt-4 p-4 glass rounded-lg border border-primary/20">
<div className="text-sm leading-relaxed break-words overflow-wrap-anywhere">
{maintext.split('\n').map((line, index) => (
<div key={index} className="min-h-[1.5em]">
{line ? <DialogueText text={line} /> : <br />}
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -124,7 +124,7 @@ export default function AdminPage() {
function AIConfigSection() {
const [configs, setConfigs] = useState<AIConfig[]>([])
const [loading, setLoading] = useState(true)
const [showAddModal, setShowAddModal] = useState(false)
const [_showAddModal, setShowAddModal] = useState(false)
const [editingConfig, setEditingConfig] = useState<AIConfig | null>(null)
const [testingConfig, setTestingConfig] = useState<number | null>(null)
const [testResult, setTestResult] = useState<{ success: boolean; message: string; latency: number } | null>(null)
@@ -286,7 +286,7 @@ function AIConfigSection() {
</div>
)}
{(showAddModal || editingConfig) && (
{(_showAddModal || editingConfig) && (
<AIConfigModal
config={editingConfig}
onClose={() => {
@@ -800,8 +800,8 @@ function CharactersManageSection() {
function PresetsManageSection() {
const [presets, setPresets] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [showAddModal, setShowAddModal] = useState(false)
const [editingPreset, setEditingPreset] = useState<any>(null)
const [_showAddModal, setShowAddModal] = useState(false)
const [_editingPreset, setEditingPreset] = useState<any>(null)
useEffect(() => {
loadPresets()
@@ -971,8 +971,8 @@ function UsersManageSection() {
}
}
const handleToggleStatus = async (id: number, enable: boolean) => {
if (!confirm(`确定要${enable ? '禁用' : '启用'}这个用户吗?`)) return
const handleToggleStatus = async (_id: number, _enable: boolean) => {
if (!confirm(`确定要${_enable ? '禁用' : '启用'}这个用户吗?`)) return
try {
// 需要后端提供更新用户状态的接口
@@ -983,8 +983,8 @@ function UsersManageSection() {
}
}
const handleToggleAdmin = async (id: number, isAdmin: boolean) => {
if (!confirm(`确定要${isAdmin ? '移除' : '授予'}管理员权限吗?`)) return
const handleToggleAdmin = async (_id: number, _isAdmin: boolean) => {
if (!confirm(`确定要${_isAdmin ? '移除' : '授予'}管理员权限吗?`)) return
try {
// 需要后端提供更新用户权限的接口

View File

@@ -4,7 +4,7 @@ import Navbar from '../components/Navbar'
import {Book, Code2, Download, Edit, FileJson, FileUp, Image as ImageIcon, Plus, Search, Trash2, X} from 'lucide-react'
import {type Character, characterApi} from '../api/character'
import {type RegexScript, regexScriptApi} from '../api/regex'
import {useAppStore} from '../store'
// import {useAppStore} from '../store'
interface WorldBookEntry {
keys: string[]
@@ -36,7 +36,7 @@ export default function CharacterManagePage() {
const [worldBookEntries, setWorldBookEntries] = useState<WorldBookEntry[]>([])
const [showRegexScriptEditor, setShowRegexScriptEditor] = useState(false)
const [regexScripts, setRegexScripts] = useState<RegexScript[]>([])
const [editingTab, setEditingTab] = useState<'basic' | 'worldbook' | 'regex'>('basic')
const [_editingTab, _setEditingTab] = useState<'basic' | 'worldbook' | 'regex'>('basic')
const [showAddRegexModal, setShowAddRegexModal] = useState(false)
const [newRegexForm, setNewRegexForm] = useState({
name: '',
@@ -82,7 +82,7 @@ export default function CharacterManagePage() {
const loadRegexScripts = async (characterId: number) => {
try {
const response = await regexScriptApi.getRegexScriptList({
const response = await regexScriptApi.getList({
scope: 1,
pageSize: 100,
})
@@ -766,7 +766,7 @@ export default function CharacterManagePage() {
<button
onClick={async () => {
try {
await regexScriptApi.createRegexScript({
await regexScriptApi.create({
name: '新脚本',
findRegex: '.*', // 默认匹配所有内容
replaceWith: '',
@@ -821,7 +821,7 @@ export default function CharacterManagePage() {
onClick={async () => {
if (!confirm('确定要删除这个脚本吗?')) return
try {
await regexScriptApi.deleteRegexScript(script.id)
await regexScriptApi.delete(script.id)
loadRegexScripts(selectedCharacter.id)
} catch (err: any) {
alert(err.response?.data?.msg || '删除失败')
@@ -927,7 +927,7 @@ export default function CharacterManagePage() {
if (!testInput) return
try {
const response = await regexScriptApi.testRegexScript(script.id, testInput)
const response = await regexScriptApi.test(script.id, testInput)
console.log('测试响应:', response)
// 处理响应数据结构
@@ -949,7 +949,7 @@ export default function CharacterManagePage() {
<button
onClick={async () => {
try {
await regexScriptApi.updateRegexScript(script.id, {
await regexScriptApi.update(script.id, {
name: script.name,
findRegex: script.findRegex,
replaceWith: script.replaceWith,
@@ -1039,7 +1039,7 @@ export default function CharacterManagePage() {
const scripts = Array.isArray(jsonData) ? jsonData : [jsonData]
for (const scriptData of scripts) {
await regexScriptApi.createRegexScript({
await regexScriptApi.create({
name: scriptData.scriptName || scriptData.name || '导入的脚本',
findRegex: scriptData.findRegex || '.*',
replaceWith: scriptData.replaceString || scriptData.replaceWith || '',
@@ -1170,7 +1170,7 @@ export default function CharacterManagePage() {
}
try {
await regexScriptApi.createRegexScript({
await regexScriptApi.create({
name: newRegexForm.name,
findRegex: newRegexForm.findRegex,
replaceWith: newRegexForm.replaceWith,

View File

@@ -18,8 +18,7 @@ export default function ChatPage() {
// 使用 MVU store
const {
currentConversation,
currentCharacter,
currentConversation: storeConversation,
loading,
sidebarOpen: showSidebar,
setCurrentConversation,
@@ -29,6 +28,18 @@ export default function ChatPage() {
setVariable,
} = useAppStore()
// 使用本地状态来存储完整的 Conversation 和 Character 对象
const [currentConversation, setLocalConversation] = useState<Conversation | null>(null)
const [currentCharacter, setLocalCharacter] = useState<Character | null>(null)
// 同步 store 和本地状态
useEffect(() => {
if (storeConversation && currentConversation?.id !== storeConversation.id) {
// 当 store 中的对话改变时,重新加载完整数据
initializeChat()
}
}, [storeConversation?.id])
// 从URL参数获取角色ID或对话ID
const characterId = searchParams.get('character')
const conversationId = searchParams.get('conversation')
@@ -37,6 +48,23 @@ export default function ChatPage() {
initializeChat()
}, [characterId, conversationId])
// 监听 iframe 消息(状态栏按钮点击事件)
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'playerAction') {
// 用户点击了状态栏中的按钮,自动发送消息
const action = event.data.data.action
console.log('[ChatPage] 收到状态栏操作:', action)
// 这里需要触发消息发送,但需要访问 ChatArea 的 sendMessage 方法
// 暂时通过自定义事件来实现
window.dispatchEvent(new CustomEvent('sendMessageFromStatusBar', { detail: action }))
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [])
useEffect(() => {
if (currentConversation?.settings) {
try {
@@ -72,10 +100,12 @@ export default function ChatPage() {
// 如果有对话ID加载现有对话
if (conversationId) {
const convResp = await conversationApi.getConversationById(Number(conversationId))
setLocalConversation(convResp.data)
setCurrentConversation(convResp.data)
// 加载对话关联的角色
const charResp = await characterApi.getCharacterById(convResp.data.characterId)
setLocalCharacter(charResp.data)
setCurrentCharacter(charResp.data)
// 更新变量系统
@@ -84,6 +114,7 @@ export default function ChatPage() {
// 如果有角色ID创建新对话
else if (characterId) {
const charResp = await characterApi.getCharacterById(Number(characterId))
setLocalCharacter(charResp.data)
setCurrentCharacter(charResp.data)
// 更新变量系统
@@ -94,6 +125,7 @@ export default function ChatPage() {
characterId: Number(characterId),
title: `${charResp.data.name} 的对话`,
})
setLocalConversation(convResp.data)
setCurrentConversation(convResp.data)
// 更新URL为对话ID
@@ -108,10 +140,16 @@ export default function ChatPage() {
}
const handleConversationChange = (conversation: Conversation) => {
setLocalConversation(conversation)
setCurrentConversation(conversation)
navigate(`/chat?conversation=${conversation.id}`)
}
const handleConversationUpdate = (conversation: Conversation) => {
setLocalConversation(conversation)
setCurrentConversation(conversation)
}
const handleSettingsUpdate = (settings: any) => {
if (settings.backgroundImage !== undefined) {
setBackgroundImage(settings.backgroundImage)
@@ -188,7 +226,7 @@ export default function ChatPage() {
<ChatArea
conversation={currentConversation}
character={currentCharacter}
onConversationUpdate={setCurrentConversation}
onConversationUpdate={handleConversationUpdate}
/>
{showCharacterPanel && (
<CharacterPanel

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'
import { Plus, Search, Edit2, Trash2, Upload, Download, Play, X } from 'lucide-react'
import { Plus, Search, Edit2, Trash2, Upload, Download, X } from 'lucide-react'
import { regexAPI } from '../api/regex'
interface RegexScript {

View File

@@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react';
import {
getWorldbookList,
createWorldbook,
updateWorldbook,
deleteWorldbook,
importWorldbook,
exportWorldbook,
@@ -21,7 +20,7 @@ const WorldbookManagePage: React.FC = () => {
const [selectedWorldbook, setSelectedWorldbook] = useState<Worldbook | null>(null);
const [entries, setEntries] = useState<WorldbookEntry[]>([]);
const [selectedEntry, setSelectedEntry] = useState<WorldbookEntry | null>(null);
const [loading, setLoading] = useState(false);
const [_loading, setLoading] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEntryModal, setShowEntryModal] = useState(false);
const [searchKeyword, setSearchKeyword] = useState('');

9
web-app/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -3,4 +3,20 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
// 禁用文件名哈希,强制浏览器重新加载
rollupOptions: {
output: {
entryFileNames: `assets/[name].js`,
chunkFileNames: `assets/[name].js`,
assetFileNames: `assets/[name].[ext]`
}
}
},
server: {
// 开发服务器禁用缓存
headers: {
'Cache-Control': 'no-store',
}
}
})