From cbb4034a9129b3c149c1b10a28998fedbd487831 Mon Sep 17 00:00:00 2001 From: Echo <1711788888@qq.com> Date: Tue, 3 Mar 2026 03:40:03 +0800 Subject: [PATCH] =?UTF-8?q?:art:=20=E4=BC=98=E5=8C=96=E6=AD=A3=E5=88=99?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=92=8C=E5=89=8D=E7=AB=AF=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Echo <1711788888@qq.com> --- server/model/app/conversation.go | 13 +- server/service/app/conversation.go | 112 +++--- server/service/app/regex_script.go | 72 ++++ web-app/src/api/character.ts | 2 +- web-app/src/components/ChatArea.tsx | 34 +- web-app/src/components/MessageContent.tsx | 367 ++++++++++++++++++-- web-app/src/pages/AdminPage.tsx | 16 +- web-app/src/pages/CharacterManagePage.tsx | 18 +- web-app/src/pages/ChatPage.tsx | 44 ++- web-app/src/pages/RegexScriptManagePage.tsx | 2 +- web-app/src/pages/WorldbookManagePage.tsx | 3 +- web-app/src/vite-env.d.ts | 9 + web-app/vite.config.ts | 16 + 13 files changed, 613 insertions(+), 95 deletions(-) create mode 100644 web-app/src/vite-env.d.ts diff --git a/server/model/app/conversation.go b/server/model/app/conversation.go index 98e06b8..90edc2e 100644 --- a/server/model/app/conversation.go +++ b/server/model/app/conversation.go @@ -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"` // 消息数量 diff --git a/server/service/app/conversation.go b/server/service/app/conversation.go index 1282466..3b30b46 100644 --- a/server/service/app/conversation.go +++ b/server/service/app/conversation.go @@ -101,14 +101,10 @@ func (s *ConversationService) CreateConversation(userID uint, req *request.Creat userName = user.NickName } - // 应用输出阶段正则脚本处理开场白 + // 【重要】不再应用正则脚本处理开场白,保留原始内容 + // 让前端来处理 的渲染 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, ¤tVars) + } + displayContent := cleanedContent // 使用清理后的内容(移除了 {{setvar::}}) + if currentVars != nil { + displayContent = regexService.SubstituteGetVars(displayContent, currentVars) + global.GVA_LOG.Info(fmt.Sprintf("替换了 {{getvar::}} 变量")) + } + + // 提取 ,保护它们不被正则脚本修改 + 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 + "\n\n" + finalContent + } + if statusBlock != "" { + finalContent = finalContent + "\n\n\n" + statusBlock + "\n" + } + + 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 函数来提取和保护 + // 前端会负责渲染这些标签 + global.GVA_LOG.Info("[AI回复] 保留原始内容,不应用输出阶段正则脚本") return aiResponse, nil } diff --git a/server/service/app/regex_script.go b/server/service/app/regex_script.go index e884302..c20d615 100644 --- a/server/service/app/regex_script.go +++ b/server/service/app/regex_script.go @@ -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 提取 中的 YAML 数据 +func (s *RegexScriptService) ExtractStatusBlock(text string) (string, string) { + // 匹配 ... + re := regexp.MustCompile(`(?s)\s*(.*?)\s*`) + matches := re.FindStringSubmatch(text) + + if len(matches) == 2 { + statusBlock := strings.TrimSpace(matches[1]) + cleanText := re.ReplaceAllString(text, "") + return statusBlock, cleanText + } + + return "", text +} + +// ExtractMaintext 提取 中的内容 +func (s *RegexScriptService) ExtractMaintext(text string) (string, string) { + // 匹配 ... + re := regexp.MustCompile(`(?s)\s*(.*?)\s*`) + 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 diff --git a/web-app/src/api/character.ts b/web-app/src/api/character.ts index 975e08c..8d9b1f3 100644 --- a/web-app/src/api/character.ts +++ b/web-app/src/api/character.ts @@ -63,7 +63,7 @@ export interface UpdateCharacterRequest { postHistoryInstructions?: string tags?: string[] alternateGreetings?: string[] - characterBook?: Record + characterBook?: Record | null extensions?: Record isPublic?: boolean } diff --git a/web-app/src/components/ChatArea.tsx b/web-app/src/components/ChatArea.tsx index 45f0236..85e2c0e 100644 --- a/web-app/src/components/ChatArea.tsx +++ b/web-app/src/components/ChatArea.tsx @@ -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) => { if (e.key === 'Enter' && !e.shiftKey && !sending && inputValue.trim()) { e.preventDefault() @@ -644,7 +668,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate > { setInputValue(choice) textareaRef.current?.focus() diff --git a/web-app/src/components/MessageContent.tsx b/web-app/src/components/MessageContent.tsx index 0ef6cac..dc40eee 100644 --- a/web-app/src/components/MessageContent.tsx +++ b/web-app/src/components/MessageContent.tsx @@ -127,21 +127,35 @@ function parseChoices(content: string): { choices: Choice[]; cleanContent: strin return { choices, cleanContent } } -// 清理脚本输出内容 -function cleanScriptOutput(content: string): string { - // 移除 ... 块 - let cleaned = content.replace(/[\s\S]*?<\/UpdateVariable>/gi, '') +// 提取 内容 +function extractMaintext(content: string): { maintext: string; cleanContent: string } { + const maintextRegex = /([\s\S]*?)<\/maintext>/i + const match = content.match(maintextRegex) - // 移除 ... 块 - cleaned = cleaned.replace(/[\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 } } -// 解析状态面板数据 +// 提取 YAML 数据 +function extractStatusBlock(content: string): { statusYaml: string; cleanContent: string } { + const statusRegex = /\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 = /([\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([]) const [displayContent, setDisplayContent] = useState(content) - const [remainingText, setRemainingText] = useState('') const [statusPanel, setStatusPanel] = useState(null) + const [statusYaml, setStatusYaml] = useState('') + const [maintext, setMaintext] = useState('') const iframeRef = useRef(null) + const statusIframeRef = useRef(null) useEffect(() => { console.log('[MessageContent] 原始内容:', content) let processedContent = content - // 解析状态面板 - const { status, cleanContent: contentAfterStatus } = parseStatusPanel(processedContent) - console.log('[MessageContent] 状态面板:', status) - setStatusPanel(status) + // 提取 内容 + const { maintext: extractedMaintext, cleanContent: contentAfterMaintext } = extractMaintext(processedContent) + if (extractedMaintext) { + console.log('[MessageContent] 提取到 maintext:', extractedMaintext) + setMaintext(extractedMaintext) + processedContent = contentAfterMaintext + } + + // 提取 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; } + ${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(` + + + + + +