-
Notifications
You must be signed in to change notification settings - Fork 12
feat: 为 Character 增加外部记忆适配生命周期事件 #82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Procyon-Nan
wants to merge
2
commits into
ChatLunaLab:main
from
Procyon-Nan:ProcyonNAN/character-livingmemory-hooks
Closed
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,8 @@ | |
| import { parseRawModelName } from 'koishi-plugin-chatluna/llm-core/utils/count_tokens' | ||
| import { Config } from '..' | ||
| import { | ||
| CharacterAfterChatEventPayload, | ||
| CharacterBeforeChatEventPayload, | ||
| ChatLunaChain, | ||
| GroupTemp, | ||
| GuildConfig, | ||
|
|
@@ -76,6 +78,16 @@ | |
|
|
||
| const replyToolProgress = '__character_reply_progress__' | ||
|
|
||
| function getCharacterSessionKey(session: Session) { | ||
| return `${session.isDirect ? 'private' : 'group'}:${ | ||
| session.isDirect ? session.userId : session.guildId | ||
| }` | ||
| } | ||
|
|
||
| function getCharacterConversationId(session: Session) { | ||
| return session.isDirect ? session.userId : session.guildId | ||
| } | ||
|
|
||
| class PendingMessageQueue extends MessageQueue { | ||
| private _messages: { | ||
| message: Message | ||
|
|
@@ -506,7 +518,7 @@ | |
| props.status = { | ||
| type: 'string', | ||
| description: | ||
| 'Continuously maintained status text. You MUST carry over and incrementally update the previous status; do not rewrite from scratch each time. Preserve recent history and memory entries until they are no longer relevant. Follow the exact format defined in the system prompt. Do not include XML tags in this field.' | ||
| } | ||
| required.push('status') | ||
| } | ||
|
|
@@ -789,8 +801,8 @@ | |
|
|
||
| if (config.toolCallReplyStatusTag) { | ||
| tips.push( | ||
| 'The `status` field must strictly follow the <status> format specified in the system prompt. Do not change that format arbitrarily, and do not include the opening or closing <status> XML tags in the field value.', | ||
| 'Your conversation context does not include previous tool call records. Use the memory section in `status` to briefly note what each tool call did (e.g. "searched X", "set wake_up for Y"). Keep these notes until the context no longer contains any related messages or the topic is clearly no longer relevant, then drop them. This prevents duplicate or conflicting operations.' | ||
| ) | ||
| } | ||
| } | ||
|
|
@@ -1496,6 +1508,7 @@ | |
| ): Promise<{ | ||
| completionMessages: BaseMessage[] | ||
| persistedHumanMessage: BaseMessage | ||
| systemPrompt: string | ||
| }> { | ||
| const { recentMessages, lastMessage, contextMessages } = | ||
| await formatMessage( | ||
|
|
@@ -1507,17 +1520,6 @@ | |
| focusMessage | ||
| ) | ||
|
|
||
| const formattedSystemPrompt = await currentPreset.system.format( | ||
| { | ||
| time: '', | ||
| stickers: '', | ||
| status: '' | ||
| }, | ||
| session.app.chatluna.promptRenderer, | ||
| { | ||
| session | ||
| } | ||
| ) | ||
| if (!chain) { | ||
| logger.debug('messages_new: ' + JSON.stringify(recentMessages)) | ||
| logger.debug('messages_last: ' + JSON.stringify(lastMessage)) | ||
|
|
@@ -1533,8 +1535,10 @@ | |
| .replaceAll('}', '}}') | ||
| const built = { | ||
| preset: currentPreset.name, | ||
| conversationId: session.isDirect ? session.userId : session.guildId | ||
| conversationId: getCharacterConversationId(session) | ||
| } | ||
| const sessionKey = getCharacterSessionKey(session) | ||
| const conversationId = built.conversationId | ||
|
|
||
| let historyNewMessages = recentMessages | ||
| if ( | ||
|
|
@@ -1565,41 +1569,78 @@ | |
|
|
||
| temp.lastHistoryNew = recentMessages.slice() | ||
| const userPrompt = formatReplyUserPrompt(session, config) | ||
| const timestamp = formatTimestamp(new Date()) | ||
| const persistedHistoryNew = recentMessages | ||
| .join('\n\n') | ||
| .replaceAll('{', '{{') | ||
| .replaceAll('}', '}}') | ||
| const systemVariables: Record<string, unknown> = { | ||
| time: '', | ||
| stickers: '', | ||
| status: '' | ||
| } | ||
| const inputVariables: Record<string, unknown> = { | ||
| history_new: historyNewMessages | ||
| .join('\n\n') | ||
| .replaceAll('{', '{{') | ||
| .replaceAll('}', '}}'), | ||
| history_last: historyLast, | ||
| time: timestamp, | ||
| stickers: '', | ||
| status: temp.status ?? currentPreset.status ?? '', | ||
| trigger_reason: triggerReasonText, | ||
| prompt: session.content, | ||
| built | ||
| } | ||
| const persistedInputVariables: Record<string, unknown> = { | ||
| ...inputVariables, | ||
| history_new: persistedHistoryNew, | ||
| time: timestamp | ||
| } | ||
|
Comment on lines
+1582
to
+1599
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are two points for improvement in this block:
const now = new Date()
const timestamp = formatTimestamp(now)
const inputVariables: Record<string, unknown> = {
history_new: historyNewMessages
.join('\n\n')
.replaceAll('{', '{{')
.replaceAll('}', '}}'),
history_last: historyLast,
time: timestamp,
stickers: '',
status: temp.status ?? currentPreset.status ?? '',
trigger_reason: triggerReasonText,
prompt: session.content,
built
}
const persistedInputVariables: Record<string, unknown> = {
...inputVariables,
history_new: recentMessages
.join('\n\n')
.replaceAll('{', '{{')
.replaceAll('}', '}}'),
time: timestamp
} |
||
|
|
||
| const beforePayload: CharacterBeforeChatEventPayload = { | ||
| session, | ||
| sessionKey, | ||
| conversationId, | ||
| presetName: currentPreset.name, | ||
| preset: currentPreset, | ||
| messages: messages.slice(), | ||
| focusMessage, | ||
| triggerReason, | ||
| systemVariables, | ||
| inputVariables, | ||
| persistedInputVariables | ||
| } | ||
|
|
||
| try { | ||
| await ctx.parallel('chatluna_character/before-chat', beforePayload) | ||
| } catch (error) { | ||
| logger.error(error) | ||
| } | ||
|
|
||
| const formattedSystemPrompt = await currentPreset.system.format( | ||
| systemVariables, | ||
| session.app.chatluna.promptRenderer, | ||
| { | ||
| session | ||
| } | ||
| ) | ||
| const humanMessage = new HumanMessage( | ||
| (await currentPreset.input.format( | ||
| { | ||
| history_new: historyNewMessages | ||
| .join('\n\n') | ||
| .replaceAll('{', '{{') | ||
| .replaceAll('}', '}}'), | ||
| history_last: historyLast, | ||
| time: formatTimestamp(new Date()), | ||
| stickers: '', | ||
| status: temp.status ?? currentPreset.status ?? '', | ||
| trigger_reason: triggerReasonText, | ||
| prompt: session.content, | ||
| built | ||
| }, | ||
| inputVariables, | ||
| session.app.chatluna.promptRenderer, | ||
| { | ||
| session | ||
| } | ||
| )) + (userPrompt.length > 0 ? `\n\n${userPrompt}` : '') | ||
| ) | ||
| const finalPersistedInputVariables = { | ||
| ...inputVariables, | ||
| ...persistedInputVariables, | ||
| history_new: persistedHistoryNew | ||
| } | ||
| const prompt = await currentPreset.input.format( | ||
| { | ||
| history_new: recentMessages | ||
| .join('\n\n') | ||
| .replaceAll('{', '{{') | ||
| .replaceAll('}', '}}'), | ||
| history_last: historyLast, | ||
| time: formatTimestamp(new Date()), | ||
| stickers: '', | ||
| status: temp.status ?? currentPreset.status ?? '', | ||
| trigger_reason: triggerReasonText, | ||
| prompt: session.content, | ||
| built | ||
| }, | ||
| finalPersistedInputVariables, | ||
| session.app.chatluna.promptRenderer, | ||
| { | ||
| session | ||
|
|
@@ -1705,7 +1746,8 @@ | |
|
|
||
| return { | ||
| completionMessages, | ||
| persistedHumanMessage | ||
| persistedHumanMessage, | ||
| systemPrompt: formattedSystemPrompt | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -2045,265 +2087,290 @@ | |
| presetPool = {} | ||
| }) | ||
|
|
||
| service.collect(async (session, messages, triggerReason, signal) => { | ||
| const guildId = session.isDirect ? session.userId : session.guildId | ||
| const key = `${session.isDirect ? 'private' : 'group'}:${guildId}` | ||
| let queue: PendingMessageQueue | undefined | ||
|
|
||
| try { | ||
| const model = await (modelPool[key] ?? | ||
| Promise.resolve( | ||
| session.isDirect ? globalPrivateModel : globalGroupModel | ||
| )) | ||
|
|
||
| const { copyOfConfig, currentPreset } = | ||
| await getConfigAndPresetForGuild( | ||
| guildId, | ||
| session.isDirect, | ||
| config, | ||
| globalPrivatePreset, | ||
| globalGroupPreset, | ||
| presetPool, | ||
| key, | ||
| preset | ||
| ) | ||
|
|
||
| if (model.value == null) { | ||
| logger.warn( | ||
| `Model ${copyOfConfig.model} load not successful. ` + | ||
| 'Please check your logs output.' | ||
| ) | ||
| return | ||
| } | ||
|
|
||
| replyToolConfigs[key] = copyOfConfig | ||
| const chainKey = key | ||
|
|
||
| if (!copyOfConfig.toolCalling) { | ||
| delete chainPool[chainKey] | ||
| } else if ( | ||
| !chainPool[chainKey] || | ||
| chainPool[chainKey].reply !== | ||
| copyOfConfig.experimentalToolCallReply | ||
| ) { | ||
| chainPool[chainKey] = { | ||
| chain: await createChatLunaChain( | ||
| ctx, | ||
| model, | ||
| (currentSession) => | ||
| createReplyTools( | ||
| ctx, | ||
| currentSession, | ||
| replyToolConfigs[key] | ||
| ) | ||
| ), | ||
| reply: copyOfConfig.experimentalToolCallReply | ||
| } | ||
| } | ||
|
|
||
| const latestMessages = service.getMessages(key) ?? messages | ||
| const count = latestMessages.length | ||
| const temp = await service.getTemp(session, latestMessages) | ||
| const focusMessage = latestMessages[latestMessages.length - 1] | ||
|
|
||
| const { completionMessages, persistedHumanMessage } = | ||
| const { completionMessages, persistedHumanMessage, systemPrompt } = | ||
| await prepareMessages( | ||
| ctx, | ||
| latestMessages, | ||
| copyOfConfig, | ||
| session, | ||
| model.value, | ||
| currentPreset, | ||
| temp, | ||
| chainPool[chainKey]?.chain.value, | ||
| focusMessage, | ||
| triggerReason | ||
| ) | ||
|
|
||
| if (!chainPool[chainKey]) { | ||
| logger.debug( | ||
| 'completion message: ' + | ||
| JSON.stringify( | ||
| completionMessages.map((it) => it.content) | ||
| ) | ||
| ) | ||
| } | ||
|
|
||
| let lastResponseMessage: BaseMessage | undefined | ||
| const nextReplyReasons: string[] = [] | ||
| let latestStatus = temp.status | ||
| let sentAny = false | ||
| let hasEmptyReplies = false | ||
| let hasNonEmptyReplies = false | ||
|
|
||
| queue = new PendingMessageQueue( | ||
| copyOfConfig.enableMessageId, | ||
| (messages) => { | ||
| service.markConsumedPendingMessages(session, messages) | ||
| } | ||
| ) | ||
|
|
||
| service.startPendingMessages(session, (message, reason) => { | ||
| queue?.pushRaw(message, reason) | ||
| }) | ||
|
|
||
| try { | ||
| for await (const chunk of streamModelResponse( | ||
| ctx, | ||
| session, | ||
| model.value, | ||
| completionMessages, | ||
| copyOfConfig, | ||
| currentPreset.name, | ||
| chainPool[chainKey]?.chain.value, | ||
| signal, | ||
| queue, | ||
| (event) => { | ||
| if (event.type === 'round-decision') { | ||
| service.setPendingMessagesWillConsume( | ||
| session, | ||
| event.canContinue === true | ||
| ) | ||
| return | ||
| } | ||
|
|
||
| if (event.type !== 'tool-call') { | ||
| return | ||
| } | ||
|
|
||
| const action = event.actions[event.actions.length - 1] | ||
| if (!action) { | ||
| return | ||
| } | ||
|
|
||
| if (action.tool !== 'character_reply') { | ||
| service.setPendingMessagesWillConsume(session, true) | ||
| return | ||
| } | ||
|
|
||
| const args = | ||
| action.toolInput && | ||
| typeof action.toolInput === 'object' && | ||
| !Array.isArray(action.toolInput) | ||
| ? (action.toolInput as Record<string, unknown>) | ||
| : {} | ||
| service.setPendingMessagesWillConsume( | ||
| session, | ||
| args.is_final === false | ||
| ) | ||
| } | ||
| )) { | ||
| latestStatus = chunk.parsedResponse.status ?? latestStatus | ||
|
|
||
| const isEmptyReply = | ||
| chunk.parsedResponse.elements.length < 1 && | ||
| chunk.parsedResponse.rawMessage.trim().length < 1 | ||
| if (isEmptyReply) { | ||
| hasEmptyReplies = true | ||
| } else { | ||
| hasNonEmptyReplies = true | ||
| } | ||
|
|
||
| if ( | ||
| copyOfConfig.experimentalToolCallReply && | ||
| chunk.toolCalls | ||
| ) { | ||
| const toolState = parseReplyTools( | ||
| copyOfConfig, | ||
| chunk.toolCalls | ||
| ) | ||
| nextReplyReasons.push(...toolState.nextReplyReasons) | ||
| } else { | ||
| nextReplyReasons.push( | ||
| ...extractNextReplyReasons(chunk.responseContent) | ||
| ) | ||
| } | ||
|
|
||
| const sendResult = await handleParsedResponseChunk( | ||
| session, | ||
| copyOfConfig, | ||
| ctx, | ||
| chunk.parsedResponse | ||
| ) | ||
|
|
||
| if (!sendResult.sentAny) { | ||
| continue | ||
| } | ||
|
|
||
| sentAny = true | ||
| lastResponseMessage = | ||
| copyOfConfig.experimentalToolCallReply && | ||
| chunk.toolCalls?.length | ||
| ? new AIMessage(chunk.responseContent) | ||
| : chunk.responseMessage | ||
| await ctx.chatluna_character.broadcastOnBot( | ||
| session, | ||
| sendResult.sentMessages | ||
| ) | ||
|
|
||
| if (sendResult.breakSay) { | ||
| break | ||
| } | ||
| } | ||
| } finally { | ||
| service.stopPendingMessages(session) | ||
| } | ||
|
|
||
| if (!sentAny) { | ||
| if (hasEmptyReplies && !hasNonEmptyReplies) { | ||
| await registerResponseTriggers( | ||
| ctx, | ||
| key, | ||
| copyOfConfig, | ||
| nextReplyReasons | ||
| ) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| const persistedMessages = service.getMessages(key) ?? latestMessages | ||
| if (persistedMessages.length > count) { | ||
| temp.status = latestStatus | ||
| await service.persistStatus( | ||
| session, | ||
| latestStatus, | ||
| persistedMessages[persistedMessages.length - 1] | ||
| ) | ||
| } | ||
|
|
||
| temp.completionMessages.push(persistedHumanMessage) | ||
| if (lastResponseMessage) { | ||
| temp.completionMessages.push(lastResponseMessage) | ||
| } | ||
|
|
||
| trimCompletionMessages( | ||
| temp.completionMessages, | ||
| copyOfConfig.modelCompletionCount | ||
| ) | ||
|
|
||
| await registerResponseTriggers( | ||
| ctx, | ||
| key, | ||
| copyOfConfig, | ||
| nextReplyReasons | ||
| ) | ||
|
|
||
| service.muteAtLeast(session, copyOfConfig.coolDownTime * 1000) | ||
| const completedMessages = | ||
| service.getMessages(key) ?? persistedMessages | ||
| const afterPayload: CharacterAfterChatEventPayload = { | ||
| session, | ||
| sessionKey: key, | ||
| conversationId: getCharacterConversationId(session), | ||
| presetName: currentPreset.name, | ||
| preset: currentPreset, | ||
| systemPrompt, | ||
| messages: completedMessages.slice(), | ||
| focusMessage, | ||
| triggerReason, | ||
| persistedHumanMessage, | ||
| lastResponseMessage, | ||
| completionMessages: temp.completionMessages.slice(), | ||
| status: latestStatus | ||
| } | ||
|
|
||
| service | ||
| .muteAtLeast(session, copyOfConfig.coolDownTime * 1000) | ||
| .then(() => | ||
| ctx.parallel('chatluna_character/after-chat', afterPayload) | ||
| ) | ||
| .catch((error) => { | ||
| logger.error(error) | ||
| }) | ||
| } catch (e) { | ||
| logger.error(e) | ||
| } finally { | ||
| await service.releaseResponseLock(session) | ||
|
|
||
| const pending = queue?.takeLatestTrigger() | ||
| if (pending) { | ||
| await service.triggerCollect( | ||
| session, | ||
| pending.triggerReason!, | ||
| pending.message | ||
| ) | ||
| } | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| function getReplyToolInputError( | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keep hook
conversationIdaligned with ChatLuna's existing conversation key.createStreamConfig()still usessession.guildId ?? session.channelIdfor non-direct chats, but this helper drops thechannelIdfallback. On sessions withoutguildId, the before/after hooks will emitundefined, so external memory keyed byconversationIdwill drift from the model's actual conversation scope.🔧 Suggested fix
function getCharacterConversationId(session: Session) { - return session.isDirect ? session.userId : session.guildId + return session.isDirect + ? session.userId + : (session.guildId ?? session.channelId) }📝 Committable suggestion
🤖 Prompt for AI Agents