Skip to content
Closed
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
149 changes: 108 additions & 41 deletions src/plugins/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import { parseRawModelName } from 'koishi-plugin-chatluna/llm-core/utils/count_tokens'
import { Config } from '..'
import {
CharacterAfterChatEventPayload,
CharacterBeforeChatEventPayload,
ChatLunaChain,
GroupTemp,
GuildConfig,
Expand Down Expand Up @@ -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
}
Comment on lines +87 to +89
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep hook conversationId aligned with ChatLuna's existing conversation key.

createStreamConfig() still uses session.guildId ?? session.channelId for non-direct chats, but this helper drops the channelId fallback. On sessions without guildId, the before/after hooks will emit undefined, so external memory keyed by conversationId will 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function getCharacterConversationId(session: Session) {
return session.isDirect ? session.userId : session.guildId
}
function getCharacterConversationId(session: Session) {
return session.isDirect
? session.userId
: (session.guildId ?? session.channelId)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/plugins/chat.ts` around lines 87 - 89, getCharacterConversationId drops
the channelId fallback causing conversationId to diverge from
createStreamConfig; update getCharacterConversationId to return session.isDirect
? session.userId : (session.guildId ?? session.channelId) so non-direct sessions
use guildId when present or channelId as a fallback, keeping it aligned with
createStreamConfig and preventing undefined conversation keys.


class PendingMessageQueue extends MessageQueue {
private _messages: {
message: Message
Expand Down Expand Up @@ -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.'

Check notice on line 521 in src/plugins/chat.ts

View check run for this annotation

codefactor.io / CodeFactor

src/plugins/chat.ts#L521

This line has a length of 330. Maximum allowed is 160. (max-len)
}
required.push('status')
}
Expand Down Expand Up @@ -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.',

Check notice on line 804 in src/plugins/chat.ts

View check run for this annotation

codefactor.io / CodeFactor

src/plugins/chat.ts#L804

This line has a length of 229. Maximum allowed is 160. (max-len)
'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.'

Check notice on line 805 in src/plugins/chat.ts

View check run for this annotation

codefactor.io / CodeFactor

src/plugins/chat.ts#L805

This line has a length of 391. Maximum allowed is 160. (max-len)
)
}
}
Expand Down Expand Up @@ -1496,6 +1508,7 @@
): Promise<{
completionMessages: BaseMessage[]
persistedHumanMessage: BaseMessage
systemPrompt: string
}> {
const { recentMessages, lastMessage, contextMessages } =
await formatMessage(
Expand All @@ -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))
Expand All @@ -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 (
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There are two points for improvement in this block:

  1. The current time is formatted twice using new Date(). It is better to calculate it once and reuse it to ensure consistency between inputVariables and persistedInputVariables.
  2. persistedInputVariables is created as a shallow copy of inputVariables before the chatluna_character/before-chat event is emitted. If an external plugin injects new variables into inputVariables during the event, these changes will not be reflected in persistedInputVariables (and thus missing from the persisted history prompt) unless the listener explicitly modifies both. Consider deriving persistedInputVariables after the event emission or ensuring they stay in sync.
    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
Expand Down Expand Up @@ -1705,7 +1746,8 @@

return {
completionMessages,
persistedHumanMessage
persistedHumanMessage,
systemPrompt: formattedSystemPrompt
}
}

Expand Down Expand Up @@ -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
)
}
}
})

Check notice on line 2373 in src/plugins/chat.ts

View check run for this annotation

codefactor.io / CodeFactor

src/plugins/chat.ts#L2090-L2373

Complex Method
}

function getReplyToolInputError(
Expand Down
57 changes: 51 additions & 6 deletions src/service/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ObjectLock } from 'koishi-plugin-chatluna/utils/lock'
import { Config } from '..'
import { Preset } from '../preset'
import {
CharacterClearChatHistoryEventPayload,
CharacterReplyToolField,
GroupLock,
GroupTemp,
Expand All @@ -30,6 +31,23 @@ function getMessageKey(message: Message) {
return `${message.id}\n${message.messageId ?? ''}\n${message.timestamp ?? 0}\n${message.content}`
}

function toClearChatHistoryPayload(
sessionKey: string
): CharacterClearChatHistoryEventPayload {
const isDirect = sessionKey.startsWith('private:')
const conversationId = isDirect
? sessionKey.slice('private:'.length)
: sessionKey.startsWith('group:')
? sessionKey.slice('group:'.length)
: sessionKey

return {
sessionKey,
conversationId,
isDirect
}
}

export class MessageCollector extends Service {
private _messages: Record<string, Message[]> = {}

Expand Down Expand Up @@ -445,6 +463,17 @@ export class MessageCollector extends Service {
return this._getMutex(groupId).lock()
}

private _emitClearChatHistory(sessionKey: string) {
this.ctx
.parallel(
'chatluna_character/clear-chat-history',
toClearChatHistoryPayload(sessionKey)
)
.catch((error) => {
this.logger.error(error)
})
}

async clear(groupId?: string) {
if (groupId) {
const isDirect = groupId.startsWith('private:')
Expand Down Expand Up @@ -493,11 +522,18 @@ export class MessageCollector extends Service {
} finally {
unlock()
}
this._emitClearChatHistory(groupId)
return
}

// For clear-all, acquire locks in sorted order to prevent deadlocks
const groupIds = Object.keys(this._groupLocks).sort()
const groupIds = Array.from(
new Set([
...Object.keys(this._groupLocks),
...Object.keys(this._messages),
...Object.keys(this._groupTemp)
])
).sort()
const unlocks: (() => void)[] = []
for (const gid of groupIds) {
unlocks.push(await this._lockByGroupId(gid))
Expand Down Expand Up @@ -559,6 +595,10 @@ export class MessageCollector extends Service {
unlocks[i]()
}
}

for (const groupId of groupIds) {
this._emitClearChatHistory(groupId)
}
}

async broadcastOnBot(
Expand Down Expand Up @@ -667,7 +707,11 @@ export class MessageCollector extends Service {
quotedElements
)
})(),
name: session.quote?.user?.name,
name: getNotEmptyString(
session.quote?.user?.nick,
session.quote?.user?.name,
session.quote?.user?.id
),
id: session.quote?.user?.id,
messageId: session.quote.id,
timestamp: session.quote.timestamp
Expand All @@ -677,10 +721,11 @@ export class MessageCollector extends Service {
const message: Message = {
content,
name: getNotEmptyString(
session.author?.nick,
session.author?.name,
session.event.user?.nick,
session.event.user?.name,
session.username
session.author?.username,
session.username,
session.userId
),
id: session.author.id,
messageId: session.messageId,
Expand Down Expand Up @@ -874,7 +919,7 @@ export class MessageCollector extends Service {
const maxMessageSize =
Object.assign({}, this._config, globalConfig, guildConfig)
.maxMessages ?? 40
let groupArray = this._messages[groupId] ?? []
const groupArray = this._messages[groupId] ?? []

groupArray.push(message)

Expand Down
48 changes: 48 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,42 @@ export interface PresetTemplate {
path?: string
}

export interface CharacterBeforeChatEventPayload {
session: Session
sessionKey: string
conversationId: string | undefined
presetName: string
preset: PresetTemplate
messages: Message[]
focusMessage?: Message
triggerReason?: string
systemVariables: Record<string, unknown>
inputVariables: Record<string, unknown>
persistedInputVariables: Record<string, unknown>
}

export interface CharacterAfterChatEventPayload {
session: Session
sessionKey: string
conversationId: string | undefined
presetName: string
preset: PresetTemplate
systemPrompt: string
messages: Message[]
focusMessage?: Message
triggerReason?: string
persistedHumanMessage: BaseMessage
lastResponseMessage?: BaseMessage
completionMessages: BaseMessage[]
status?: string | null
}

export interface CharacterClearChatHistoryEventPayload {
sessionKey: string
conversationId: string
isDirect: boolean
}

export interface GroupInfo {
messageCount: number
messageWait?: boolean
Expand Down Expand Up @@ -296,4 +332,16 @@ declare module 'koishi' {
chathub_character_variable: CharacterVariableRecord
chathub_character_wake_up_reply: WakeUpReplyRecord
}

interface Events {
'chatluna_character/before-chat': (
payload: CharacterBeforeChatEventPayload
) => void | Promise<void>
'chatluna_character/after-chat': (
payload: CharacterAfterChatEventPayload
) => void | Promise<void>
'chatluna_character/clear-chat-history': (
payload: CharacterClearChatHistoryEventPayload
) => void | Promise<void>
}
}
Loading