Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 30 additions & 10 deletions backend/ai-service/AIService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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提供商,提供统一的接口
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 16 additions & 9 deletions backend/ai-service/RequestQueue.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
74 changes: 42 additions & 32 deletions backend/ai-service/memory/MemoryManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export class MemoryManager {

/**
* 从内容中提取记忆
* Optimized: Single pass through sentences, pre-compile patterns
* @param {string} content - 内容
* @param {Object} options - 选项
* @returns {Array} 提取的记忆数组
Expand All @@ -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);
Expand Down
65 changes: 38 additions & 27 deletions backend/storage/database.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
74 changes: 44 additions & 30 deletions frontend/src/components/GameRoom/StoryPanel.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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更新完成
// 只在用户没有手动滚动时才自动滚动到底部
Expand Down Expand Up @@ -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();
Expand All @@ -612,7 +622,7 @@ function MessageItem({ message, viewMode = 'global', storyCharacters = [], onCha
minute: '2-digit'
});
}
};
}, []);

// 全局消息(玩家输入)
// 判断条件:type为global,或者visibility为global且不是私密消息
Expand Down Expand Up @@ -770,19 +780,23 @@ 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;

// 获取所有玩家名称
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,
Expand Down Expand Up @@ -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;
Expand Down