376
src/hooks/story/characterChat.ts
Normal file
376
src/hooks/story/characterChat.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import {type Dispatch, type SetStateAction,useState} from 'react';
|
||||
|
||||
import {
|
||||
generateCharacterPanelChatSuggestions,
|
||||
generateCharacterPanelChatSummary,
|
||||
streamCharacterPanelChatReply,
|
||||
} from '../../services/ai';
|
||||
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 : '未知 AI 错误',
|
||||
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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user