Files
Genarrative/src/hooks/story/characterChat.ts
2026-04-08 16:41:29 +08:00

377 lines
10 KiB
TypeScript

import {type Dispatch, type SetStateAction,useState} from 'react';
import {
generateCharacterPanelChatSuggestions,
generateCharacterPanelChatSummary,
streamCharacterPanelChatReply,
} from '../../services/aiService';
import type {StoryGenerationContext} from '../../services/aiTypes';
import type {
Character,
CharacterChatRecord,
CharacterChatTurn,
GameState,
} from '../../types';
const MAX_CHARACTER_CHAT_TURNS = 24;
export type CharacterChatTarget = {
character: Character;
npcId: string | null;
roleLabel: string;
hp: number;
maxHp: number;
mana: number;
maxMana: number;
affinity?: number | null;
};
export type CharacterChatModalState = {
target: CharacterChatTarget;
draft: string;
messages: CharacterChatTurn[];
suggestions: string[];
summary: string;
isSending: boolean;
isLoadingSuggestions: boolean;
error: string | null;
};
export interface CharacterChatUi {
modal: CharacterChatModalState | null;
openChat: (target: CharacterChatTarget) => void;
closeChat: () => void;
setDraft: (value: string) => void;
useSuggestion: (value: string) => void;
refreshSuggestions: () => void;
sendDraft: () => void;
}
export function getCharacterChatRecord(state: GameState, characterId: string): CharacterChatRecord {
return state.characterChats[characterId] ?? {
history: [],
summary: '',
updatedAt: null,
};
}
export function trimCharacterChatHistory(history: CharacterChatTurn[]) {
return history.slice(-MAX_CHARACTER_CHAT_TURNS);
}
export function buildLocalCharacterChatSummary(
character: Character,
history: CharacterChatTurn[],
previousSummary: string,
) {
const latestTurns = history
.slice(-4)
.map(turn => `${turn.speaker === 'player' ? '玩家' : character.name}: ${turn.text}`)
.join(' ');
const currentSummary = latestTurns
? `${character.name} 在私下对话中变得更加开放。最近的交流:${latestTurns}`
: `${character.name} 愿意继续私下对话,并逐渐更加信任玩家。`;
if (!previousSummary) {
return currentSummary.slice(0, 118);
}
return `${previousSummary} ${currentSummary}`.slice(0, 118);
}
export function buildLocalCharacterChatSuggestions(character: Character) {
return [
'请更清楚地告诉我你的意思。',
`${character.name},你真正担心的是什么?`,
'暂时放下大局。我想更好地了解你。',
];
}
function buildCharacterChatRecordUpdate(
state: GameState,
characterId: string,
record: CharacterChatRecord,
) {
return {
...state,
characterChats: {
...state.characterChats,
[characterId]: record,
},
};
}
type CharacterChatTargetStatus = {
roleLabel: string;
hp: number;
maxHp: number;
mana: number;
maxMana: number;
affinity?: number | null;
};
function buildTargetStatus(target: CharacterChatTarget): CharacterChatTargetStatus {
return {
roleLabel: target.roleLabel,
hp: target.hp,
maxHp: target.maxHp,
mana: target.mana,
maxMana: target.maxMana,
affinity: target.affinity ?? null,
};
}
export function useCharacterChatFlow({
gameState,
setGameState,
buildStoryContextFromState,
}: {
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
buildStoryContextFromState: (state: GameState) => StoryGenerationContext;
}) {
const [characterChatModal, setCharacterChatModal] = useState<CharacterChatModalState | null>(null);
const loadCharacterChatSuggestions = async (
target: CharacterChatTarget,
messages: CharacterChatTurn[],
summary: string,
) => {
if (!gameState.worldType || !gameState.playerCharacter) {
setCharacterChatModal(current =>
current && current.target.character.id === target.character.id
? {
...current,
suggestions: buildLocalCharacterChatSuggestions(target.character),
isLoadingSuggestions: false,
}
: current,
);
return;
}
setCharacterChatModal(current =>
current && current.target.character.id === target.character.id
? {
...current,
isLoadingSuggestions: true,
}
: current,
);
try {
const suggestions = await generateCharacterPanelChatSuggestions(
gameState.worldType,
gameState.playerCharacter,
target.character,
gameState.storyHistory,
buildStoryContextFromState(gameState),
messages,
summary,
buildTargetStatus(target),
);
setCharacterChatModal(current =>
current && current.target.character.id === target.character.id
? {
...current,
suggestions,
isLoadingSuggestions: false,
}
: current,
);
} catch (error) {
console.error('Failed to generate character chat suggestions:', error);
setCharacterChatModal(current =>
current && current.target.character.id === target.character.id
? {
...current,
suggestions: buildLocalCharacterChatSuggestions(target.character),
isLoadingSuggestions: false,
}
: current,
);
}
};
const openCharacterChat = (target: CharacterChatTarget) => {
const record = getCharacterChatRecord(gameState, target.character.id);
setCharacterChatModal({
target,
draft: '',
messages: record.history,
suggestions: buildLocalCharacterChatSuggestions(target.character),
summary: record.summary,
isSending: false,
isLoadingSuggestions: true,
error: null,
});
void loadCharacterChatSuggestions(target, record.history, record.summary);
};
const sendCharacterChatDraft = async () => {
if (!characterChatModal || !gameState.worldType || !gameState.playerCharacter) {
return;
}
const draft = characterChatModal.draft.trim();
if (!draft) {
return;
}
const target = characterChatModal.target;
const existingRecord = getCharacterChatRecord(gameState, target.character.id);
const baseMessages = trimCharacterChatHistory(characterChatModal.messages);
const nextMessages = trimCharacterChatHistory([
...baseMessages,
{
speaker: 'player' as const,
text: draft,
},
]);
setCharacterChatModal(current =>
current && current.target.character.id === target.character.id
? {
...current,
draft: '',
messages: [...nextMessages, {speaker: 'character', text: ''}],
suggestions: [],
isSending: true,
isLoadingSuggestions: true,
error: null,
}
: current,
);
let replyText = '';
try {
replyText = await streamCharacterPanelChatReply(
gameState.worldType,
gameState.playerCharacter,
target.character,
gameState.storyHistory,
buildStoryContextFromState(gameState),
nextMessages,
existingRecord.summary,
draft,
buildTargetStatus(target),
{
onUpdate: text => {
setCharacterChatModal(current =>
current && current.target.character.id === target.character.id
? {
...current,
messages: [...nextMessages, {speaker: 'character', text}],
}
: current,
);
},
},
);
} catch (error) {
console.error('Failed to stream character panel chat reply:', error);
setCharacterChatModal(current =>
current && current.target.character.id === target.character.id
? {
...current,
draft,
messages: baseMessages,
isSending: false,
isLoadingSuggestions: false,
error: error instanceof Error ? error.message : '未知智能生成错误',
suggestions: current.suggestions.length > 0
? current.suggestions
: buildLocalCharacterChatSuggestions(target.character),
}
: current,
);
return;
}
const finalMessages = trimCharacterChatHistory([
...nextMessages,
{
speaker: 'character' as const,
text: replyText,
},
]);
let nextSummary = existingRecord.summary;
try {
nextSummary = await generateCharacterPanelChatSummary(
gameState.worldType,
gameState.playerCharacter,
target.character,
gameState.storyHistory,
buildStoryContextFromState(gameState),
finalMessages,
existingRecord.summary,
buildTargetStatus(target),
);
} catch (error) {
console.error('Failed to summarize character chat:', error);
nextSummary = buildLocalCharacterChatSummary(target.character, finalMessages, existingRecord.summary);
}
const nextRecord: CharacterChatRecord = {
history: finalMessages,
summary: nextSummary,
updatedAt: new Date().toISOString(),
};
setGameState(current =>
buildCharacterChatRecordUpdate(current, target.character.id, nextRecord),
);
setCharacterChatModal(current =>
current && current.target.character.id === target.character.id
? {
...current,
messages: finalMessages,
summary: nextSummary,
isSending: false,
error: null,
}
: current,
);
await loadCharacterChatSuggestions(target, finalMessages, nextSummary);
};
const characterChatUi: CharacterChatUi = {
modal: characterChatModal,
openChat: openCharacterChat,
closeChat: () => setCharacterChatModal(null),
setDraft: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)),
useSuggestion: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)),
refreshSuggestions: () => {
if (!characterChatModal) {
return;
}
void loadCharacterChatSuggestions(
characterChatModal.target,
characterChatModal.messages,
characterChatModal.summary,
);
},
sendDraft: () => {
void sendCharacterChatDraft();
},
};
return {
characterChatModal,
setCharacterChatModal,
characterChatUi,
openCharacterChat,
sendCharacterChatDraft,
loadCharacterChatSuggestions,
clearCharacterChatModal: () => setCharacterChatModal(null),
};
}