diff --git a/backend/ai-service/AIService.js b/backend/ai-service/AIService.js index a25c9dd..478335e 100644 --- a/backend/ai-service/AIService.js +++ b/backend/ai-service/AIService.js @@ -6,6 +6,9 @@ import { LocalAIProvider } from './providers/LocalAIProvider.js'; import MemoryManager from './memory/MemoryManager.js'; import RequestQueue from './RequestQueue.js'; +// WeakMap for caching lowercase keywords without mutating original objects +const keywordCache = new WeakMap(); + /** * AI服务主类 * 统一管理所有AI提供商,提供统一的接口 @@ -511,31 +514,48 @@ ${relevantTricks.map(t => `- **${t.trick_name}** (${t.trick_type}): ${t.trick_de /** * 检查动态事件触发 + * Optimized: Pre-compute lowercase input once, cache lowercase keywords using WeakMap */ checkDynamicEventTrigger(playerInput, dynamicEvents, currentChapter) { + if (!dynamicEvents || dynamicEvents.length === 0) { + return null; + } + const lowerInput = playerInput.toLowerCase(); - for (const event of dynamicEvents) { - if (event.earliest_chapter > currentChapter || event.latest_chapter < currentChapter) { - continue; - } - + // Pre-defined search and accusation keywords for faster lookup + const searchKeywords = ['搜索', '检查', '调查']; + const accusationKeywords = ['指认', '凶手是', '怀疑']; + + // Filter events by chapter range first to reduce iterations + const eligibleEvents = dynamicEvents.filter( + event => event.earliest_chapter <= currentChapter && event.latest_chapter >= currentChapter + ); + + for (const event of eligibleEvents) { const trigger = event.trigger_condition; - // 根据触发类型检查 switch (event.trigger_type) { case 'keyword': - if (trigger.keywords?.some(kw => lowerInput.includes(kw.toLowerCase()))) { - return event; + // Cache lowercase keywords using WeakMap to avoid mutating trigger object + if (trigger.keywords) { + let lowerKeywords = keywordCache.get(trigger); + if (!lowerKeywords) { + lowerKeywords = trigger.keywords.map(kw => kw.toLowerCase()); + keywordCache.set(trigger, lowerKeywords); + } + if (lowerKeywords.some(kw => lowerInput.includes(kw))) { + return event; + } } break; case 'search_action': - if (lowerInput.includes('搜索') || lowerInput.includes('检查') || lowerInput.includes('调查')) { + if (searchKeywords.some(kw => lowerInput.includes(kw))) { return event; } break; case 'accusation': - if (lowerInput.includes('指认') || lowerInput.includes('凶手是') || lowerInput.includes('怀疑')) { + if (accusationKeywords.some(kw => lowerInput.includes(kw))) { return event; } break; diff --git a/backend/ai-service/RequestQueue.js b/backend/ai-service/RequestQueue.js index 124830f..3493bb7 100644 --- a/backend/ai-service/RequestQueue.js +++ b/backend/ai-service/RequestQueue.js @@ -52,19 +52,26 @@ class RequestQueue { /** * 按优先级插入队列 + * Optimized: Use binary search for O(log n) insertion instead of O(n) */ insertByPriority(request) { - let inserted = false; - for (let i = 0; i < this.queue.length; i++) { - if (this.queue[i].options.priority < request.options.priority) { - this.queue.splice(i, 0, request); - inserted = true; - break; + const priority = request.options.priority; + + // Binary search to find insertion point + let left = 0; + let right = this.queue.length; + + while (left < right) { + const mid = Math.floor((left + right) / 2); + if (this.queue[mid].options.priority >= priority) { + left = mid + 1; + } else { + right = mid; } } - if (!inserted) { - this.queue.push(request); - } + + // Insert at the found position + this.queue.splice(left, 0, request); } /** diff --git a/backend/ai-service/memory/MemoryManager.js b/backend/ai-service/memory/MemoryManager.js index 1fe931f..a4427fb 100644 --- a/backend/ai-service/memory/MemoryManager.js +++ b/backend/ai-service/memory/MemoryManager.js @@ -120,6 +120,7 @@ export class MemoryManager { /** * 从内容中提取记忆 + * Optimized: Single pass through sentences, pre-compile patterns * @param {string} content - 内容 * @param {Object} options - 选项 * @returns {Array} 提取的记忆数组 @@ -128,44 +129,53 @@ export class MemoryManager { const memories = []; const sentences = content.split(/[。!?]/).filter(s => s.trim().length > 5); - // 关键词模式 + // Pre-compiled keyword sets for faster lookup + // Note: Only character, event, and world patterns are used for memory extraction const patterns = { - character: ['名字', '角色', '人物', '他', '她', '他们', '她们'], - event: ['发现', '决定', '承诺', '秘密', '计划', '行动'], - world: ['地点', '世界', '规则', '魔法', '设定', '环境'], - emotion: ['感情', '情感', '爱', '恨', '恐惧', '希望'] + character: new Set(['名字', '角色', '人物', '他', '她', '他们', '她们']), + event: new Set(['发现', '决定', '承诺', '秘密', '计划', '行动']), + world: new Set(['地点', '世界', '规则', '魔法', '设定', '环境']) }; - sentences.forEach(sentence => { + // Process all sentences in a single pass + for (const sentence of sentences) { const trimmed = sentence.trim(); + let matched = false; - // 检查角色信息 - if (patterns.character.some(keyword => trimmed.includes(keyword))) { - memories.push({ - content: trimmed, - memoryType: this.MEMORY_TYPES.CHARACTER, - importance: 3 - }); - } - - // 检查关键事件 - if (patterns.event.some(keyword => trimmed.includes(keyword))) { - memories.push({ - content: trimmed, - memoryType: this.MEMORY_TYPES.EVENT, - importance: 4 - }); - } - - // 检查世界设定 - if (patterns.world.some(keyword => trimmed.includes(keyword))) { - memories.push({ - content: trimmed, - memoryType: this.MEMORY_TYPES.WORLD, - importance: 3 - }); + // Check all patterns for this sentence + for (const [patternType, keywords] of Object.entries(patterns)) { + for (const keyword of keywords) { + if (trimmed.includes(keyword)) { + let memoryType, importance; + switch (patternType) { + case 'character': + memoryType = this.MEMORY_TYPES.CHARACTER; + importance = 3; + break; + case 'event': + memoryType = this.MEMORY_TYPES.EVENT; + importance = 4; + break; + case 'world': + memoryType = this.MEMORY_TYPES.WORLD; + importance = 3; + break; + default: + continue; + } + + memories.push({ + content: trimmed, + memoryType, + importance + }); + matched = true; + break; // Stop checking this pattern type once matched + } + } + if (matched) break; // Stop checking other patterns once we have a match for this sentence } - }); + } // 去重(基于内容相似度) const uniqueMemories = this.deduplicateMemories(memories); diff --git a/backend/storage/database.js b/backend/storage/database.js index 9c6d9e1..d3a6482 100644 --- a/backend/storage/database.js +++ b/backend/storage/database.js @@ -640,33 +640,44 @@ class Database { async deleteStory(id) { if (!id) return; try { - await this.db.run( - `DELETE FROM player_puzzle_progress - WHERE puzzle_id IN (SELECT id FROM chapter_puzzles WHERE story_id = ?)`, - [id] - ); - await this.db.run( - `DELETE FROM player_feedback_progress - WHERE chapter_id IN (SELECT id FROM chapters WHERE story_id = ?)`, - [id] - ); - await this.db.run( - 'DELETE FROM player_clues WHERE chapter_id IN (SELECT id FROM chapters WHERE story_id = ?)', - [id] - ); - await this.db.run('DELETE FROM player_tasks WHERE story_id = ?', [id]); - await this.db.run('DELETE FROM player_interactions WHERE story_id = ?', [id]); - await this.db.run('DELETE FROM character_clue_cards WHERE story_id = ?', [id]); - await this.db.run('DELETE FROM player_roles WHERE story_id = ?', [id]); - await this.db.run('DELETE FROM story_characters WHERE story_id = ?', [id]); - await this.db.run('DELETE FROM chapter_puzzles WHERE story_id = ?', [id]); - await this.db.run('DELETE FROM chapter_todos WHERE story_id = ?', [id]); - await this.db.run('DELETE FROM story_outlines WHERE story_id = ?', [id]); - await this.db.run('DELETE FROM messages WHERE story_id = ?', [id]); - await this.db.run('DELETE FROM interactions WHERE story_id = ?', [id]); - await this.db.run('DELETE FROM memories WHERE story_id = ?', [id]); - await this.db.run('DELETE FROM chapters WHERE story_id = ?', [id]); - await this.db.run('DELETE FROM stories WHERE id = ?', [id]); + // Use a transaction for atomic deletion of all related data + await this.transaction(async () => { + // Delete in order respecting foreign key constraints + // First, delete records that reference other tables + await this.db.run( + `DELETE FROM player_puzzle_progress + WHERE puzzle_id IN (SELECT id FROM chapter_puzzles WHERE story_id = ?)`, + [id] + ); + await this.db.run( + `DELETE FROM player_feedback_progress + WHERE chapter_id IN (SELECT id FROM chapters WHERE story_id = ?)`, + [id] + ); + await this.db.run( + 'DELETE FROM player_clues WHERE chapter_id IN (SELECT id FROM chapters WHERE story_id = ?)', + [id] + ); + + // Then delete the main story-related tables + await Promise.all([ + this.db.run('DELETE FROM player_tasks WHERE story_id = ?', [id]), + this.db.run('DELETE FROM player_interactions WHERE story_id = ?', [id]), + this.db.run('DELETE FROM character_clue_cards WHERE story_id = ?', [id]), + this.db.run('DELETE FROM player_roles WHERE story_id = ?', [id]), + this.db.run('DELETE FROM story_characters WHERE story_id = ?', [id]), + this.db.run('DELETE FROM chapter_puzzles WHERE story_id = ?', [id]), + this.db.run('DELETE FROM chapter_todos WHERE story_id = ?', [id]), + this.db.run('DELETE FROM story_outlines WHERE story_id = ?', [id]), + this.db.run('DELETE FROM messages WHERE story_id = ?', [id]), + this.db.run('DELETE FROM interactions WHERE story_id = ?', [id]), + this.db.run('DELETE FROM memories WHERE story_id = ?', [id]) + ]); + + // Finally delete chapters and story + await this.db.run('DELETE FROM chapters WHERE story_id = ?', [id]); + await this.db.run('DELETE FROM stories WHERE id = ?', [id]); + }); } catch (error) { console.error(`删除故事 ${id} 失败:`, error); throw error; diff --git a/frontend/src/components/GameRoom/StoryPanel.jsx b/frontend/src/components/GameRoom/StoryPanel.jsx index f6e03b0..88b0ac8 100644 --- a/frontend/src/components/GameRoom/StoryPanel.jsx +++ b/frontend/src/components/GameRoom/StoryPanel.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useMemo, useCallback } from 'react'; import { useGame } from '../../context/GameContext'; import CharacterCard from './CharacterCard'; import ScriptSelector from './ScriptSelector'; @@ -66,33 +66,43 @@ export default function StoryPanel() { } }, [viewMode, clearUnreadDirectCount]); - // 根据viewMode过滤消息 - const displayMessages = viewMode === 'storyMachine' - ? (storyMachineMessages || []) - : viewMode === 'direct' - ? (directMessages || []).sort((a, b) => { - const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0; - const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0; - return timeA - timeB; - }) - : (messages || []).filter(m => { - // 全局视图:显示所有全局可见的消息,但不包括玩家间私聊 - return m.type === 'global' || - m.type === 'chapter' || - m.type === 'ai' || - m.type === 'system' || - m.type === 'player' || - (m.visibility === 'global' && - m.type !== 'private' && - m.type !== 'story_machine' && - m.type !== 'player_to_player' && - m.senderId !== 'ai'); - }).sort((a, b) => { - // 按时间戳排序,确保消息按时间顺序显示 + // 根据viewMode过滤消息 - Memoized for performance + const displayMessages = useMemo(() => { + if (viewMode === 'storyMachine') { + return storyMachineMessages || []; + } + + if (viewMode === 'direct') { + // Sort the array directly without unnecessary slice since useMemo handles memoization + return [...(directMessages || [])].sort((a, b) => { const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0; const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0; return timeA - timeB; }); + } + + // Global view: filter and sort + const filtered = (messages || []).filter(m => { + // 全局视图:显示所有全局可见的消息,但不包括玩家间私聊 + return m.type === 'global' || + m.type === 'chapter' || + m.type === 'ai' || + m.type === 'system' || + m.type === 'player' || + (m.visibility === 'global' && + m.type !== 'private' && + m.type !== 'story_machine' && + m.type !== 'player_to_player' && + m.senderId !== 'ai'); + }); + + // Sort the filtered array in place + return filtered.sort((a, b) => { + const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0; + const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0; + return timeA - timeB; + }); + }, [viewMode, messages, storyMachineMessages, directMessages]); // 修复自动滚动问题:延迟滚动确保DOM更新完成 // 只在用户没有手动滚动时才自动滚动到底部 @@ -591,8 +601,8 @@ export default function StoryPanel() { function MessageItem({ message, viewMode = 'global', storyCharacters = [], onCharacterClick }) { const { player, room } = useGame(); - // 格式化时间戳 - const formatTimestamp = (timestamp) => { + // 格式化时间戳 - memoized with useCallback to avoid recreation on every render + const formatTimestamp = useCallback((timestamp) => { if (!timestamp) return ''; const date = new Date(timestamp); const now = new Date(); @@ -612,7 +622,7 @@ function MessageItem({ message, viewMode = 'global', storyCharacters = [], onCha minute: '2-digit' }); } - }; + }, []); // 全局消息(玩家输入) // 判断条件:type为global,或者visibility为global且不是私密消息 @@ -770,6 +780,9 @@ function MessageItem({ message, viewMode = 'global', storyCharacters = [], onCha // AI生成的章节 if (message.type === 'chapter' || message.type === 'ai') { // 高亮显示人物名称(玩家@xxx格式,NPC用不同样式) + // Pre-compile NPC pattern (static, doesn't change) + const NPC_PATTERN = /\[NPC:([^\]]+)\]|@NPC:([^\s,。!?,\.!?]+)/g; + const highlightCharacters = (content, players) => { if (!content) return content; @@ -777,12 +790,13 @@ function MessageItem({ message, viewMode = 'global', storyCharacters = [], onCha const playerNames = players ? players.map(p => p.username || p.name).filter(Boolean) : []; // 先处理NPC标记格式:[NPC:名称] 或 @NPC:名称 - const npcPattern = /\[NPC:([^\]]+)\]|@NPC:([^\s,。!?,\.!?]+)/g; + // Reset regex lastIndex for proper iteration + NPC_PATTERN.lastIndex = 0; const npcMatches = []; let npcMatch; // 收集所有NPC标记 - while ((npcMatch = npcPattern.exec(content)) !== null) { + while ((npcMatch = NPC_PATTERN.exec(content)) !== null) { npcMatches.push({ start: npcMatch.index, end: npcMatch.index + npcMatch[0].length, @@ -824,7 +838,7 @@ function MessageItem({ message, viewMode = 'global', storyCharacters = [], onCha // 合并NPC标记和已知角色匹配 const allNpcs = [...npcMatches, ...characterMatches].sort((a, b) => a.start - b.start); - // 处理玩家名称 + // 处理玩家名称 - Create pattern only if there are player names const playerPattern = playerNames.length > 0 ? new RegExp(`(${playerNames.map(name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'gi') : null;